"""Binary frame protocol: encoding/decoding messages between daemon and client."""

import struct
from dataclasses import dataclass
from typing import Tuple

# Frame type constants
TYPE_RUN_REQ = 10  # Client → Daemon: {"host", "cmd", "cwd", "env", "timeout_s", ...}
TYPE_STDIN = 11    # Client → Daemon: raw bytes to send to remote process stdin
TYPE_CANCEL = 7    # Client → Daemon: cancel the run

TYPE_META = 4      # Daemon → Client: {"run_id", "host", "cmd", ...}
TYPE_STDOUT = 1    # Daemon → Client: raw bytes from remote stdout
TYPE_STDERR = 2    # Daemon → Client: raw bytes from remote stderr
TYPE_EXIT = 3      # Daemon → Client: i32 exit code
TYPE_ERROR = 5     # Daemon → Client: {"error": "message"}

# Frame format:
# [4-byte length (u32, big-endian)][body]
# [body] = [1-byte type (u8)][4-byte run_id (u32, big-endian)][payload]
#
# Total frame size = 4 + len(body) = 4 + 1 + 4 + len(payload) = 9 + len(payload)
#
# Guarantees:
# - Frame format is stable and unchanging
# - New frame types can be added (never reuse old type constants)
# - Length field includes only body, not the length field itself

_BODY_HEADER = struct.Struct("!BI")  # type (u8) + run_id (u32, big-endian)


class FramingError(Exception):
    """Error in frame encoding/decoding."""
    pass


def pack_frame(ftype: int, run_id: int, payload: bytes = b"") -> bytes:
    """
    Encode a frame into binary format.

    Args:
        ftype: Frame type constant (TYPE_RUN_REQ, TYPE_STDOUT, etc.)
        run_id: Run identifier (multiplexes concurrent runs)
        payload: Raw payload bytes (can be empty)

    Returns:
        Complete frame: [4-byte length][1-byte type][4-byte run_id][payload]

    Raises:
        FramingError: If inputs are invalid

    Guarantees:
    - Always succeeds if inputs are valid (type/run_id are ints, payload is bytes)
    - Returned bytes can be written directly to socket
    - Returned frame can be decoded with read_frame() to recover original inputs
    """
    if not isinstance(ftype, int) or not (0 <= ftype <= 255):
        raise FramingError(f"ftype must be int 0-255, got {ftype}")
    if not isinstance(run_id, int) or not (0 <= run_id <= 0xFFFFFFFF):
        raise FramingError(f"run_id must be int 0-2^32-1, got {run_id}")
    if not isinstance(payload, bytes):
        raise FramingError(f"payload must be bytes, got {type(payload).__name__}")

    # Build body: [type][run_id][payload]
    body = _BODY_HEADER.pack(ftype, run_id) + payload

    # Build frame: [length][body]
    length = len(body)
    if length > 0xFFFFFFFF:
        raise FramingError(f"payload too large: {length} bytes (max 2^32-1)")

    frame = struct.pack("!I", length) + body
    return frame


async def read_frame(reader) -> Tuple[int, int, bytes]:
    """
    Decode a frame from async reader.

    Args:
        reader: asyncio.StreamReader

    Returns:
        (ftype, run_id, payload) tuple

    Raises:
        FramingError: If frame is malformed
        asyncio.IncompleteReadError: If stream closes before frame is complete

    Guarantees:
    - If this function returns, frame is valid and complete
    - (ftype, run_id, payload) can be used to reconstruct original pack_frame() call
    - Can handle concurrent frames with different run_ids
    """
    # Read length field (4 bytes)
    try:
        length_bytes = await reader.readexactly(4)
    except Exception as e:
        raise FramingError(f"Failed to read frame length: {e}") from e

    try:
        (length,) = struct.unpack("!I", length_bytes)
    except struct.error as e:
        raise FramingError(f"Invalid frame length: {e}") from e

    if length < _BODY_HEADER.size:
        raise FramingError(f"Frame body too small: {length} bytes (min {_BODY_HEADER.size})")

    # Read body (type + run_id + payload)
    try:
        body = await reader.readexactly(length)
    except Exception as e:
        raise FramingError(f"Failed to read frame body ({length} bytes): {e}") from e

    # Parse body header
    try:
        ftype, run_id = _BODY_HEADER.unpack(body[: _BODY_HEADER.size])
    except struct.error as e:
        raise FramingError(f"Invalid frame header: {e}") from e

    # Extract payload
    payload = body[_BODY_HEADER.size :]

    return ftype, run_id, payload


@dataclass
class RunRequest:
    """Parsed RUN_REQ frame."""

    host: str
    cmd: str
    cwd: str | None = None
    env: dict[str, str] | None = None
    username: str | None = None
    pty: bool = False
    stdin_mode: str = "none"  # "none" or "pipe"
    timeout_s: int = 60
    read_only: bool = True
    policy: str | None = None  # Policy preset name: "read_only", "write_capable", "restrictive", etc.

    def to_dict(self) -> dict:
        """Convert to dict for JSON serialization."""
        return {
            "host": self.host,
            "cmd": self.cmd,
            "cwd": self.cwd,
            "env": self.env or {},
            "username": self.username,
            "pty": self.pty,
            "stdin_mode": self.stdin_mode,
            "timeout_s": self.timeout_s,
            "read_only": self.read_only,
        }

    @classmethod
    def from_dict(cls, d: dict) -> "RunRequest":
        """Create from dict (parsed JSON)."""
        return cls(
            host=d["host"],
            cmd=d["cmd"],
            cwd=d.get("cwd"),
            env=d.get("env"),
            username=d.get("username"),
            pty=bool(d.get("pty", False)),
            stdin_mode=d.get("stdin_mode", "none"),
            timeout_s=int(d.get("timeout_s", 60)),
            read_only=bool(d.get("read_only", True)),
            policy=d.get("policy"),
        )
