use bytes::{Buf, BufMut, Bytes, BytesMut};
use std::io;
use tokio_util::codec::{Decoder, Encoder};

// Handshake
const TYPE_HELLO: u8 = 0x01;
const TYPE_HELLO_ACK: u8 = 0x02;

// Session relay
const TYPE_DATA: u8 = 0x10;
const TYPE_RESIZE: u8 = 0x11;
const TYPE_EXIT: u8 = 0x12;
const TYPE_DETACHED: u8 = 0x13;
const TYPE_PING: u8 = 0x14;
const TYPE_PONG: u8 = 0x15;
const TYPE_ENV: u8 = 0x16;
const TYPE_DIAG_REQUEST: u8 = 0x17;
const TYPE_DIAG_RESPONSE: u8 = 0x18;
const TYPE_SERVER_SHUTDOWN: u8 = 0x19;
const TYPE_RESUME: u8 = 0x1A;
const TYPE_NOTICE: u8 = 0x1B;

// Agent forwarding
const TYPE_AGENT_FORWARD: u8 = 0x20;
const TYPE_AGENT_OPEN: u8 = 0x21;
const TYPE_AGENT_DATA: u8 = 0x22;
const TYPE_AGENT_CLOSE: u8 = 0x23;

// URL/browser
const TYPE_OPEN_FORWARD: u8 = 0x28;
const TYPE_OPEN_URL: u8 = 0x29;

// Clipboard
const TYPE_CLIPBOARD_SET: u8 = 0x2A;
const TYPE_CLIPBOARD_GET: u8 = 0x2B;
const TYPE_CLIPBOARD_DATA: u8 = 0x2C;

// Tunnel
const TYPE_TUNNEL_LISTEN: u8 = 0x30;
const TYPE_TUNNEL_OPEN: u8 = 0x31;
const TYPE_TUNNEL_DATA: u8 = 0x32;
const TYPE_TUNNEL_CLOSE: u8 = 0x33;

// File transfer
const TYPE_SEND_OFFER: u8 = 0x38;
const TYPE_SEND_DONE: u8 = 0x39;
const TYPE_SEND_CANCEL: u8 = 0x3A;
const TYPE_SEND_FILE: u8 = 0x3B;

// Port forwarding
const TYPE_PORT_FORWARD_LISTEN: u8 = 0x40;
const TYPE_PORT_FORWARD_READY: u8 = 0x41;
const TYPE_PORT_FORWARD_OPEN: u8 = 0x42;
const TYPE_PORT_FORWARD_DATA: u8 = 0x43;
const TYPE_PORT_FORWARD_CLOSE: u8 = 0x44;
const TYPE_PORT_FORWARD_STOP: u8 = 0x45;
const TYPE_PORT_FORWARD_REQUEST: u8 = 0x46;

// Control requests
const TYPE_NEW_SESSION: u8 = 0x50;
const TYPE_ATTACH: u8 = 0x51;
const TYPE_LIST_SESSIONS: u8 = 0x52;
const TYPE_KILL_SESSION: u8 = 0x53;
const TYPE_KILL_SERVER: u8 = 0x54;
const TYPE_TAIL: u8 = 0x55;
const TYPE_RENAME_SESSION: u8 = 0x56;
const TYPE_SET_LINGER: u8 = 0x57;

// Control responses
const TYPE_SESSION_CREATED: u8 = 0x60;
const TYPE_SESSION_INFO: u8 = 0x61;
const TYPE_OK: u8 = 0x62;
const TYPE_ERROR: u8 = 0x63;
const TYPE_ATTACH_ACK: u8 = 0x64;

const HEADER_LEN: usize = 5; // type(1) + length(4)
const MAX_FRAME_SIZE: usize = 1 << 20; // 1 MB

/// Protocol version for handshake negotiation.
pub const PROTOCOL_VERSION: u16 = 23;

/// Capability bit: client/server supports clipboard forwarding.
pub const CAP_CLIPBOARD: u32 = 0x01;

/// How long the server waits with no inbound client frames before declaring the
/// attached client dead and dropping to the unattached ring-buffer state. This
/// is a cross-module contract: `config.rs` clamps client heartbeat settings so
/// `interval + timeout` stays under it, guaranteeing a healthy client always
/// proves liveness before the server evicts it.
pub const IDLE_EVICT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90);

/// Slack between the client's heartbeat ceiling and the server's idle-evict.
/// Absorbs scheduling jitter and one RTT so a client right at the ceiling is
/// never evicted by a hair.
pub const IDLE_EVICT_SAFETY_MARGIN: std::time::Duration = std::time::Duration::from_secs(10);

/// Structured error codes for forward-compatible error handling.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCode {
    NoSuchSession,
    NameAlreadyExists,
    InvalidName,
    EmptyName,
    VersionMismatch,
    UnexpectedFrame,
    AlreadyAttached,
    /// The attach token presented by the client does not match the current
    /// owner of the session -- another client took over while this one was
    /// disconnected. Terminal: the presenting client must exit (do not retry).
    OwnerChanged,
    Unknown(u16),
}

impl ErrorCode {
    pub fn to_u16(self) -> u16 {
        match self {
            Self::NoSuchSession => 1,
            Self::NameAlreadyExists => 2,
            Self::InvalidName => 3,
            Self::EmptyName => 4,
            Self::VersionMismatch => 5,
            Self::UnexpectedFrame => 6,
            Self::AlreadyAttached => 7,
            Self::OwnerChanged => 8,
            Self::Unknown(v) => v,
        }
    }

    pub fn from_u16(v: u16) -> Self {
        match v {
            1 => Self::NoSuchSession,
            2 => Self::NameAlreadyExists,
            3 => Self::InvalidName,
            4 => Self::EmptyName,
            5 => Self::VersionMismatch,
            6 => Self::UnexpectedFrame,
            7 => Self::AlreadyAttached,
            8 => Self::OwnerChanged,
            _ => Self::Unknown(v),
        }
    }
}

/// Discriminator byte for the unified per-session service socket (`svc-{id}.sock`).
/// Sent as the first byte on every connection to route to the correct handler.
#[repr(u8)]
pub enum SvcRequest {
    OpenUrl = 1,
    Send = 2,
    Receive = 3,
    Clipboard = 5,
}

impl SvcRequest {
    pub fn from_byte(b: u8) -> Option<Self> {
        match b {
            1 => Some(Self::OpenUrl),
            2 => Some(Self::Send),
            3 => Some(Self::Receive),
            5 => Some(Self::Clipboard),
            _ => None,
        }
    }

    pub fn to_byte(self) -> u8 {
        self as u8
    }
}

/// Metadata for one session, returned in SessionInfo.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionEntry {
    pub id: u32,
    pub name: String,
    pub pty_path: String,
    pub shell_pid: u32,
    pub created_at: u64,
    pub attached: bool,
    pub last_heartbeat: u64,
    pub foreground_cmd: String,
    pub cwd: String,
    pub client_name: String,
    pub agent_forwarding_active: bool,
    /// True iff this session is the daemon's current `last_attached`. Used by
    /// the client to resolve `"-"` the same way the daemon would, instead of
    /// heuristically picking `max_by_key(last_heartbeat)` which diverges.
    pub is_last_attached: bool,
    /// Epoch seconds of the last terminal activity (PTY output or client
    /// keystrokes). 0 = unknown (older server). Drives the `Idle` column in
    /// `gritty ls` so stale sessions are easy to spot and reap.
    pub last_activity: u64,
    /// How long the session survives with zero attached clients before the
    /// daemon reaps it (seconds; 0 = never). Drives the `Linger` column in
    /// `gritty ls`.
    pub linger_secs: u64,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Frame {
    Data(Bytes),
    Resize {
        cols: u16,
        rows: u16,
    },
    Exit {
        code: i32,
    },
    /// Sent to a client when another client takes over the session.
    Detached,
    /// Heartbeat request (client → server).
    Ping,
    /// Heartbeat reply (server → client).
    Pong,
    /// Environment variables (client → server, sent before first Resize on new session).
    Env {
        vars: Vec<(String, String)>,
    },
    /// Client requests server-side diagnostics (client → server).
    DiagRequest,
    /// Server-side diagnostics (server → client). Opaque text blob displayed by the client.
    DiagResponse {
        text: String,
    },
    /// Sent to attached/tail clients when the daemon is shutting down
    /// (kill-server / SIGTERM). Terminal -- the session is gone, the client
    /// should exit instead of reconnecting. Distinguishes intentional
    /// shutdown from a transient disconnect, which for a remote host would
    /// otherwise spin the reconnect loop until the tunnel supervisor happens
    /// to restart the server minutes later.
    ServerShutdown,
    /// Server tells the client its authoritative position in the PTY output
    /// stream (server → client). Sent as the first frame after a reconnect /
    /// takeover handoff: the client sets its `rendered_offset` to this value,
    /// then counts subsequent `Data` payload bytes up from it. Lets a clean
    /// resume replay exactly `Data[offset..]` with no redundant repaint, and
    /// lets a truncated / full-repaint resume jump the client's counter
    /// forward past bytes it will never render byte-for-byte.
    Resume {
        offset: u64,
    },
    /// Server chrome written to the client's terminal but NOT counted toward
    /// `rendered_offset` (server → client). Used for reconnect dividers,
    /// truncation markers, alt-screen priming, and partial-line repaints --
    /// bytes to display that are not part of the client's authoritative
    /// position in the live PTY stream.
    Notice(Bytes),
    /// Client signals it can handle agent forwarding (client → server).
    AgentForward,
    /// New agent connection on the remote side (server → client).
    AgentOpen {
        channel_id: u32,
    },
    /// Agent protocol data (bidirectional).
    AgentData {
        channel_id: u32,
        data: Bytes,
    },
    /// Close an agent channel (bidirectional).
    AgentClose {
        channel_id: u32,
    },
    /// Server tells client to put data in the clipboard (server → client).
    ClipboardSet {
        data: Bytes,
    },
    /// Server asks client for clipboard contents (server → client).
    ClipboardGet,
    /// Client sends clipboard contents to server (client → server).
    ClipboardData {
        data: Bytes,
    },
    /// Client signals it can handle URL open forwarding (client → server).
    OpenForward,
    /// URL to open on the client machine (server → client).
    OpenUrl {
        url: String,
    },
    /// Server asks client to bind a local TCP port for reverse tunneling (server → client).
    TunnelListen {
        port: u16,
    },
    /// Client signals a tunnel connection has been accepted (client → server).
    TunnelOpen {
        channel_id: u32,
    },
    /// Tunnel data relay (bidirectional).
    TunnelData {
        channel_id: u32,
        data: Bytes,
    },
    /// Tunnel connection closed (bidirectional).
    TunnelClose {
        channel_id: u32,
    },
    /// Server notifies attached client that a file transfer started (server → client).
    SendOffer {
        file_count: u32,
        total_bytes: u64,
    },
    /// Server notifies attached client that a file transfer completed (server → client).
    SendDone,
    /// File transfer cancelled (server → client).
    SendCancel {
        reason: String,
    },
    /// Server asks client to set up a port forward listener (server → client for remote-fwd).
    PortForwardListen {
        forward_id: u32,
        listen_port: u16,
        target_port: u16,
    },
    /// Client confirms port forward listener is ready (client → server).
    PortForwardReady {
        forward_id: u32,
    },
    /// New TCP connection on a port forward (bidirectional).
    PortForwardOpen {
        forward_id: u32,
        channel_id: u32,
        target_port: u16,
    },
    /// Port forward channel data (bidirectional).
    PortForwardData {
        channel_id: u32,
        data: Bytes,
    },
    /// Close a port forward channel (bidirectional).
    PortForwardClose {
        channel_id: u32,
    },
    /// Tear down an entire port forward (server → client).
    PortForwardStop {
        forward_id: u32,
    },
    /// Client requests a port forward (client → server).
    /// Direction 0 = local-forward (server listens), 1 = remote-forward (client listens).
    PortForwardRequest {
        forward_id: u32,
        direction: u8,
        listen_port: u16,
        target_port: u16,
    },
    /// Protocol version handshake (client → server, first frame on connection).
    Hello {
        version: u16,
        capabilities: u32,
        /// Persistent per-machine identifier. Generated once and stored in
        /// `$XDG_STATE_HOME/gritty/device_id`. The server records this as the
        /// session owner so auto-reconnects from a different device are
        /// rejected with `OwnerChanged`.
        device_id: u64,
    },
    /// Protocol version acknowledgement (server → client).
    ///
    /// `server_id` is an ephemeral identifier picked by the daemon at startup
    /// so reconnecting clients can detect a crashed-and-restarted server:
    /// if the ID differs from the one seen on the initial handshake, the
    /// session is gone forever.
    HelloAck {
        version: u16,
        capabilities: u32,
        server_id: u64,
    },
    // Control requests
    /// Local-side file transfer routing (client → daemon).
    SendFile {
        session: String,
    },
    NewSession {
        name: String,
        command: String,
        cwd: String,
        cols: u16,
        rows: u16,
        client_name: String,
        /// How long the session survives with zero attached clients before
        /// the daemon reaps it (seconds; 0 = never). Resolved client-side
        /// from `--linger` / config so the server just stores and enforces.
        linger_secs: u64,
    },
    Attach {
        session: String,
        client_name: String,
        force: bool,
        /// Existence probe only: daemon replies `Ok` without handing the
        /// connection to the session task (no ring-buffer flush, no takeover).
        no_replay: bool,
        /// Client's current terminal size. The server applies these before
        /// replaying scrollback/history so replayed bytes are produced at
        /// the right winsize. Zero = unknown (probe-only clients).
        cols: u16,
        rows: u16,
        /// Ownership claim for auto-reconnect. `0` = explicit connect (no
        /// ownership check; server adopts the Hello's `device_id` as new
        /// owner). Non-zero = auto-reconnect: the server compares the
        /// Hello's `device_id` against the stored owner and rejects with
        /// `ErrorCode::OwnerChanged` if they differ.
        attach_token: u64,
        /// How far the client has rendered into the session's PTY output
        /// stream. The server replays `Data[rendered_offset..]` on a clean
        /// resume so only content produced while disconnected is sent. `0`
        /// for a fresh explicit connect (which instead gets scrollback
        /// context). Older clients that omit this default to `0`.
        rendered_offset: u64,
        /// The client painted a reconnect status line (it was gone long
        /// enough to cross the chrome-delay threshold), so its cursor is no
        /// longer where `rendered_offset` left it. The server repaints the
        /// current line before resuming the stream. Older clients default to
        /// `false`.
        line_dirty: bool,
    },
    /// Read-only tail of a session's PTY output (client → server).
    Tail {
        session: String,
    },
    ListSessions,
    KillSession {
        session: String,
    },
    KillServer,
    RenameSession {
        session: String,
        new_name: String,
    },
    /// Change a session's linger duration (`~K` pin, or a future
    /// `set-linger` command). Same teardown semantics as the value sent in
    /// `NewSession`: 0 = never reap.
    SetLinger {
        session: String,
        linger_secs: u64,
    },
    // Control responses
    SessionCreated {
        id: u32,
    },
    SessionInfo {
        sessions: Vec<SessionEntry>,
    },
    Ok,
    /// Response to a successful `Attach` or auto-attach following `NewSession`.
    /// Carries the owner token the client must present on any subsequent
    /// reconnect, plus the resolved numeric session id so the client can
    /// use it for reconnect/forward-socket paths without re-resolving a
    /// name like `-` (which races against other attached sessions).
    AttachAck {
        token: u64,
        session_id: u32,
    },
    Error {
        code: ErrorCode,
        message: String,
    },
}

impl Frame {
    /// Extract a Frame from a `framed.next().await` result, converting
    /// the common None / Some(Err) cases into descriptive errors.
    pub fn expect_from(result: Option<Result<Frame, io::Error>>) -> anyhow::Result<Frame> {
        match result {
            Some(Ok(frame)) => Ok(frame),
            Some(Err(e)) => Err(anyhow::anyhow!("daemon protocol error: {e}")),
            None => Err(anyhow::anyhow!("daemon closed connection")),
        }
    }
}

pub struct FrameCodec;

fn encode_empty(dst: &mut BytesMut, ty: u8) {
    dst.put_u8(ty);
    dst.put_u32(0);
}

fn encode_str(dst: &mut BytesMut, ty: u8, s: &str) {
    dst.put_u8(ty);
    dst.put_u32(s.len() as u32);
    dst.extend_from_slice(s.as_bytes());
}

fn encode_blob(dst: &mut BytesMut, ty: u8, data: &[u8]) {
    dst.put_u8(ty);
    dst.put_u32(data.len() as u32);
    dst.extend_from_slice(data);
}

fn encode_prefix_blob(dst: &mut BytesMut, ty: u8, prefix: u32, data: &[u8]) {
    dst.put_u8(ty);
    dst.put_u32(4 + data.len() as u32);
    dst.put_u32(prefix);
    dst.extend_from_slice(data);
}

fn encode_env(dst: &mut BytesMut, vars: &[(String, String)]) {
    let body_len: usize = 4 + vars.iter().map(|(k, v)| 2 + k.len() + 2 + v.len()).sum::<usize>();
    dst.put_u8(TYPE_ENV);
    dst.put_u32(body_len as u32);
    dst.put_u32(vars.len() as u32);
    for (k, v) in vars {
        dst.put_u16(k.len() as u16);
        dst.extend_from_slice(k.as_bytes());
        dst.put_u16(v.len() as u16);
        dst.extend_from_slice(v.as_bytes());
    }
}

fn encode_session_info(dst: &mut BytesMut, sessions: &[SessionEntry]) {
    // Compute total body length: count(4) + sum of (entry_len(4) + entry_bytes)
    let body_len: usize = 4 + sessions
        .iter()
        .map(|e| {
            4 + entry_encoded_len(e) // entry_len prefix + entry content
        })
        .sum::<usize>();
    dst.put_u8(TYPE_SESSION_INFO);
    dst.put_u32(body_len as u32);
    dst.put_u32(sessions.len() as u32);
    for e in sessions {
        let elen = entry_encoded_len(e) as u32;
        dst.put_u32(elen);
        // id: u32
        dst.put_u32(e.id);
        // name: len-prefixed string
        dst.put_u16(e.name.len() as u16);
        dst.extend_from_slice(e.name.as_bytes());
        dst.put_u16(e.pty_path.len() as u16);
        dst.extend_from_slice(e.pty_path.as_bytes());
        dst.put_u32(e.shell_pid);
        dst.put_u64(e.created_at);
        dst.put_u8(if e.attached { 1 } else { 0 });
        dst.put_u64(e.last_heartbeat);
        dst.put_u16(e.foreground_cmd.len() as u16);
        dst.extend_from_slice(e.foreground_cmd.as_bytes());
        dst.put_u16(e.cwd.len() as u16);
        dst.extend_from_slice(e.cwd.as_bytes());
        dst.put_u16(e.client_name.len() as u16);
        dst.extend_from_slice(e.client_name.as_bytes());
        dst.put_u8(if e.agent_forwarding_active { 1 } else { 0 });
        dst.put_u8(if e.is_last_attached { 1 } else { 0 });
        dst.put_u64(e.last_activity);
        dst.put_u64(e.linger_secs);
    }
}

/// Compute the encoded byte length of a single SessionEntry (without the entry_len prefix).
fn entry_encoded_len(e: &SessionEntry) -> usize {
    4 // id: u32
    + 2 + e.name.len()
    + 2 + e.pty_path.len()
    + 4 // shell_pid
    + 8 // created_at
    + 1 // attached
    + 8 // last_heartbeat
    + 2 + e.foreground_cmd.len()
    + 2 + e.cwd.len()
    + 2 + e.client_name.len()
    + 1 // agent_forwarding_active
    + 1 // is_last_attached
    + 8 // last_activity
    + 8 // linger_secs
}

fn decode_string(payload: BytesMut) -> Result<String, io::Error> {
    String::from_utf8(payload.to_vec()).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}

fn expect_min_len(payload: &BytesMut, min: usize, name: &str) -> Result<(), io::Error> {
    if payload.len() < min {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            format!("{name} frame must be at least {min} bytes"),
        ));
    }
    Ok(())
}

fn read_u16(payload: &[u8], offset: usize) -> u16 {
    u16::from_be_bytes([payload[offset], payload[offset + 1]])
}

fn read_u32(payload: &[u8], offset: usize) -> u32 {
    u32::from_be_bytes([
        payload[offset],
        payload[offset + 1],
        payload[offset + 2],
        payload[offset + 3],
    ])
}

fn read_i32(payload: &[u8], offset: usize) -> i32 {
    i32::from_be_bytes([
        payload[offset],
        payload[offset + 1],
        payload[offset + 2],
        payload[offset + 3],
    ])
}

fn read_u64(payload: &[u8], offset: usize) -> u64 {
    u64::from_be_bytes([
        payload[offset],
        payload[offset + 1],
        payload[offset + 2],
        payload[offset + 3],
        payload[offset + 4],
        payload[offset + 5],
        payload[offset + 6],
        payload[offset + 7],
    ])
}

/// Auto-offset-tracking reader for decoding fixed-field payloads.
struct PayloadReader<'a> {
    data: &'a [u8],
    off: usize,
}

impl<'a> PayloadReader<'a> {
    fn new(data: &'a [u8]) -> Self {
        Self { data, off: 0 }
    }

    fn u16(&mut self) -> u16 {
        let v = read_u16(self.data, self.off);
        self.off += 2;
        v
    }

    fn u32(&mut self) -> u32 {
        let v = read_u32(self.data, self.off);
        self.off += 4;
        v
    }

    fn i32(&mut self) -> i32 {
        let v = read_i32(self.data, self.off);
        self.off += 4;
        v
    }

    fn u64(&mut self) -> u64 {
        let v = read_u64(self.data, self.off);
        self.off += 8;
        v
    }

    fn offset(&self) -> usize {
        self.off
    }
}

/// Encode a fixed-field frame: writes type byte, auto-computes payload length, writes fields.
macro_rules! encode_fields {
    ($dst:expr, $ty:expr $(, $val:expr => $method:ident)*) => {{
        let payload_len: u32 = 0 $(+ encode_fields!(@size $method))*;
        $dst.put_u8($ty);
        $dst.put_u32(payload_len);
        $($dst.$method($val);)*
    }};
    (@size put_u8) => { 1 };
    (@size put_u16) => { 2 };
    (@size put_u32) => { 4 };
    (@size put_i32) => { 4 };
    (@size put_u64) => { 8 };
}

fn decode_env(payload: BytesMut) -> Result<Option<Frame>, io::Error> {
    let p = &payload[..];
    if p.len() < 4 {
        return Err(io::Error::new(io::ErrorKind::InvalidData, "env frame too short"));
    }
    let count = read_u32(p, 0) as usize;
    let mut off = 4;
    let mut vars = Vec::with_capacity(count.min(1024));
    for _ in 0..count {
        if off + 2 > p.len() {
            return Err(io::Error::new(io::ErrorKind::InvalidData, "env frame truncated"));
        }
        let klen = read_u16(p, off) as usize;
        off += 2;
        if off + klen + 2 > p.len() {
            return Err(io::Error::new(io::ErrorKind::InvalidData, "env frame truncated"));
        }
        let key = String::from_utf8(p[off..off + klen].to_vec())
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
        off += klen;
        let vlen = read_u16(p, off) as usize;
        off += 2;
        if off + vlen > p.len() {
            return Err(io::Error::new(io::ErrorKind::InvalidData, "env frame truncated"));
        }
        let val = String::from_utf8(p[off..off + vlen].to_vec())
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
        off += vlen;
        vars.push((key, val));
    }
    Ok(Some(Frame::Env { vars }))
}

fn decode_session_info(payload: BytesMut) -> Result<Option<Frame>, io::Error> {
    let p = &payload[..];
    if p.len() < 4 {
        return Err(io::Error::new(io::ErrorKind::InvalidData, "session info frame too short"));
    }
    let count = read_u32(p, 0) as usize;
    let mut off = 4;
    let mut sessions = Vec::with_capacity(count.min(1024));
    let read_str = |p: &[u8], off: &mut usize| -> Result<String, io::Error> {
        if *off + 2 > p.len() {
            return Err(io::Error::new(io::ErrorKind::InvalidData, "session info truncated"));
        }
        let len = read_u16(p, *off) as usize;
        *off += 2;
        if *off + len > p.len() {
            return Err(io::Error::new(io::ErrorKind::InvalidData, "session info truncated"));
        }
        let s = String::from_utf8(p[*off..*off + len].to_vec())
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
        *off += len;
        Ok(s)
    };
    for _ in 0..count {
        // Read entry_len prefix
        if off + 4 > p.len() {
            return Err(io::Error::new(io::ErrorKind::InvalidData, "session info truncated"));
        }
        let entry_len = read_u32(p, off) as usize;
        off += 4;
        if off + entry_len > p.len() {
            return Err(io::Error::new(io::ErrorKind::InvalidData, "session info truncated"));
        }
        let entry_end = off + entry_len;
        let entry_slice = &p[off..entry_end];

        // Decode within entry_slice using a local offset
        let mut eoff = 0usize;
        if entry_slice.len() < 4 {
            return Err(io::Error::new(io::ErrorKind::InvalidData, "session entry too short"));
        }
        let id = read_u32(entry_slice, eoff);
        eoff += 4;

        let read_entry_str = |s: &[u8], eoff: &mut usize| -> Result<String, io::Error> {
            if *eoff + 2 > s.len() {
                return Err(io::Error::new(io::ErrorKind::InvalidData, "session entry truncated"));
            }
            let len = read_u16(s, *eoff) as usize;
            *eoff += 2;
            if *eoff + len > s.len() {
                return Err(io::Error::new(io::ErrorKind::InvalidData, "session entry truncated"));
            }
            let val = String::from_utf8(s[*eoff..*eoff + len].to_vec())
                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
            *eoff += len;
            Ok(val)
        };

        let name = read_entry_str(entry_slice, &mut eoff)?;
        let pty_path = read_entry_str(entry_slice, &mut eoff)?;
        // Fixed fields: shell_pid(4) + created_at(8) + attached(1) + last_heartbeat(8) = 21
        if eoff + 21 > entry_slice.len() {
            return Err(io::Error::new(io::ErrorKind::InvalidData, "session entry truncated"));
        }
        let shell_pid = read_u32(entry_slice, eoff);
        eoff += 4;
        let created_at = read_u64(entry_slice, eoff);
        eoff += 8;
        let attached = entry_slice[eoff] != 0;
        eoff += 1;
        let last_heartbeat = read_u64(entry_slice, eoff);
        eoff += 8;
        let foreground_cmd = read_entry_str(entry_slice, &mut eoff)?;
        let cwd = read_entry_str(entry_slice, &mut eoff)?;
        let client_name = read_entry_str(entry_slice, &mut eoff)?;
        // New field (added after client_name): agent_forwarding_active (1 byte).
        // Older servers don't send it, so default to false if absent.
        let agent_forwarding_active = if eoff < entry_slice.len() {
            let v = entry_slice[eoff] != 0;
            eoff += 1;
            v
        } else {
            false
        };
        // New field (added after agent_forwarding_active): is_last_attached
        // (1 byte). Older servers don't send it; default to false.
        let is_last_attached = if eoff < entry_slice.len() {
            let v = entry_slice[eoff] != 0;
            eoff += 1;
            v
        } else {
            false
        };
        // New field (added after is_last_attached): last_activity (8 bytes).
        // Older servers don't send it; default to 0 (unknown).
        let last_activity = if eoff + 8 <= entry_slice.len() {
            let v = read_u64(entry_slice, eoff);
            eoff += 8;
            v
        } else {
            0
        };
        // New field (added after last_activity): linger_secs (8 bytes).
        // Older servers don't send it; default to 0 (never).
        let linger_secs = if eoff + 8 <= entry_slice.len() {
            let v = read_u64(entry_slice, eoff);
            eoff += 8;
            v
        } else {
            0
        };
        let _ = eoff; // remaining bytes skipped via entry_end
        sessions.push(SessionEntry {
            id,
            name,
            pty_path,
            shell_pid,
            created_at,
            attached,
            last_heartbeat,
            foreground_cmd,
            cwd,
            client_name,
            agent_forwarding_active,
            is_last_attached,
            last_activity,
            linger_secs,
        });
        off = entry_end;
    }
    // Suppress unused-closure warning: read_str is kept for reference but entry decoding
    // uses read_entry_str within the entry_slice bounds.
    let _ = &read_str;
    Ok(Some(Frame::SessionInfo { sessions }))
}

impl Decoder for FrameCodec {
    type Item = Frame;
    type Error = io::Error;

    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Frame>, io::Error> {
        if src.len() < HEADER_LEN {
            return Ok(None);
        }

        let frame_type = src[0];
        let payload_len = u32::from_be_bytes([src[1], src[2], src[3], src[4]]) as usize;

        if payload_len > MAX_FRAME_SIZE {
            return Err(io::Error::new(
                io::ErrorKind::InvalidData,
                format!("frame payload too large: {payload_len} bytes (max {MAX_FRAME_SIZE})"),
            ));
        }

        if src.len() < HEADER_LEN + payload_len {
            src.reserve(HEADER_LEN + payload_len - src.len());
            return Ok(None);
        }

        src.advance(HEADER_LEN);
        let payload = src.split_to(payload_len);
        tracing::trace!(frame_type, payload_len, "decode frame");

        match frame_type {
            // Blob frames
            TYPE_DATA => Ok(Some(Frame::Data(payload.freeze()))),
            TYPE_NOTICE => Ok(Some(Frame::Notice(payload.freeze()))),
            TYPE_RESUME => {
                expect_min_len(&payload, 8, "resume")?;
                Ok(Some(Frame::Resume { offset: PayloadReader::new(&payload).u64() }))
            }
            TYPE_CLIPBOARD_SET => Ok(Some(Frame::ClipboardSet { data: payload.freeze() })),
            TYPE_CLIPBOARD_DATA => Ok(Some(Frame::ClipboardData { data: payload.freeze() })),

            // Fixed-field frames (PayloadReader auto-tracks offsets)
            TYPE_RESIZE => {
                expect_min_len(&payload, 4, "resize")?;
                let mut r = PayloadReader::new(&payload);
                Ok(Some(Frame::Resize { cols: r.u16(), rows: r.u16() }))
            }
            TYPE_EXIT => {
                expect_min_len(&payload, 4, "exit")?;
                Ok(Some(Frame::Exit { code: PayloadReader::new(&payload).i32() }))
            }
            TYPE_HELLO => {
                expect_min_len(&payload, 6, "hello")?;
                let mut r = PayloadReader::new(&payload);
                let version = r.u16();
                let capabilities = r.u32();
                let device_id = if payload.len() - r.offset() >= 8 { r.u64() } else { 0 };
                Ok(Some(Frame::Hello { version, capabilities, device_id }))
            }
            TYPE_HELLO_ACK => {
                expect_min_len(&payload, 14, "hello ack")?;
                let mut r = PayloadReader::new(&payload);
                Ok(Some(Frame::HelloAck {
                    version: r.u16(),
                    capabilities: r.u32(),
                    server_id: r.u64(),
                }))
            }
            TYPE_AGENT_OPEN => {
                expect_min_len(&payload, 4, "agent open")?;
                Ok(Some(Frame::AgentOpen { channel_id: PayloadReader::new(&payload).u32() }))
            }
            TYPE_AGENT_CLOSE => {
                expect_min_len(&payload, 4, "agent close")?;
                Ok(Some(Frame::AgentClose { channel_id: PayloadReader::new(&payload).u32() }))
            }
            TYPE_TUNNEL_LISTEN => {
                expect_min_len(&payload, 2, "tunnel listen")?;
                Ok(Some(Frame::TunnelListen { port: PayloadReader::new(&payload).u16() }))
            }
            TYPE_TUNNEL_OPEN => {
                expect_min_len(&payload, 4, "tunnel open")?;
                Ok(Some(Frame::TunnelOpen { channel_id: PayloadReader::new(&payload).u32() }))
            }
            TYPE_TUNNEL_CLOSE => {
                expect_min_len(&payload, 4, "tunnel close")?;
                Ok(Some(Frame::TunnelClose { channel_id: PayloadReader::new(&payload).u32() }))
            }
            TYPE_SEND_OFFER => {
                expect_min_len(&payload, 12, "send offer")?;
                let mut r = PayloadReader::new(&payload);
                Ok(Some(Frame::SendOffer { file_count: r.u32(), total_bytes: r.u64() }))
            }
            TYPE_PORT_FORWARD_LISTEN => {
                expect_min_len(&payload, 8, "port forward listen")?;
                let mut r = PayloadReader::new(&payload);
                Ok(Some(Frame::PortForwardListen {
                    forward_id: r.u32(),
                    listen_port: r.u16(),
                    target_port: r.u16(),
                }))
            }
            TYPE_PORT_FORWARD_READY => {
                expect_min_len(&payload, 4, "port forward ready")?;
                Ok(Some(Frame::PortForwardReady { forward_id: PayloadReader::new(&payload).u32() }))
            }
            TYPE_PORT_FORWARD_OPEN => {
                expect_min_len(&payload, 10, "port forward open")?;
                let mut r = PayloadReader::new(&payload);
                Ok(Some(Frame::PortForwardOpen {
                    forward_id: r.u32(),
                    channel_id: r.u32(),
                    target_port: r.u16(),
                }))
            }
            TYPE_PORT_FORWARD_CLOSE => {
                expect_min_len(&payload, 4, "port forward close")?;
                Ok(Some(Frame::PortForwardClose { channel_id: PayloadReader::new(&payload).u32() }))
            }
            TYPE_PORT_FORWARD_STOP => {
                expect_min_len(&payload, 4, "port forward stop")?;
                Ok(Some(Frame::PortForwardStop { forward_id: PayloadReader::new(&payload).u32() }))
            }
            TYPE_PORT_FORWARD_REQUEST => {
                expect_min_len(&payload, 9, "port forward request")?;
                let p = &payload[..];
                Ok(Some(Frame::PortForwardRequest {
                    forward_id: read_u32(p, 0),
                    direction: p[4],
                    listen_port: read_u16(p, 5),
                    target_port: read_u16(p, 7),
                }))
            }
            TYPE_SESSION_CREATED => {
                expect_min_len(&payload, 4, "session created")?;
                Ok(Some(Frame::SessionCreated { id: PayloadReader::new(&payload).u32() }))
            }

            // Prefix + blob frames (fixed header, trailing bytes)
            TYPE_AGENT_DATA => {
                expect_min_len(&payload, 4, "agent data")?;
                let mut r = PayloadReader::new(&payload);
                let channel_id = r.u32();
                let off = r.offset();
                Ok(Some(Frame::AgentData { channel_id, data: payload.freeze().slice(off..) }))
            }
            TYPE_TUNNEL_DATA => {
                expect_min_len(&payload, 4, "tunnel data")?;
                let mut r = PayloadReader::new(&payload);
                let channel_id = r.u32();
                let off = r.offset();
                Ok(Some(Frame::TunnelData { channel_id, data: payload.freeze().slice(off..) }))
            }
            TYPE_PORT_FORWARD_DATA => {
                expect_min_len(&payload, 4, "port forward data")?;
                let mut r = PayloadReader::new(&payload);
                let channel_id = r.u32();
                let off = r.offset();
                Ok(Some(Frame::PortForwardData { channel_id, data: payload.freeze().slice(off..) }))
            }

            // Empty frames
            TYPE_DETACHED => Ok(Some(Frame::Detached)),
            TYPE_SERVER_SHUTDOWN => Ok(Some(Frame::ServerShutdown)),
            TYPE_PING => Ok(Some(Frame::Ping)),
            TYPE_PONG => Ok(Some(Frame::Pong)),
            TYPE_AGENT_FORWARD => Ok(Some(Frame::AgentForward)),
            TYPE_OPEN_FORWARD => Ok(Some(Frame::OpenForward)),
            TYPE_CLIPBOARD_GET => Ok(Some(Frame::ClipboardGet)),
            TYPE_SEND_DONE => Ok(Some(Frame::SendDone)),
            TYPE_LIST_SESSIONS => Ok(Some(Frame::ListSessions)),
            TYPE_KILL_SERVER => Ok(Some(Frame::KillServer)),
            TYPE_OK => Ok(Some(Frame::Ok)),

            // String frames
            TYPE_OPEN_URL => Ok(Some(Frame::OpenUrl { url: decode_string(payload)? })),
            TYPE_SEND_CANCEL => Ok(Some(Frame::SendCancel { reason: decode_string(payload)? })),
            TYPE_TAIL => Ok(Some(Frame::Tail { session: decode_string(payload)? })),
            TYPE_KILL_SESSION => Ok(Some(Frame::KillSession { session: decode_string(payload)? })),
            TYPE_SEND_FILE => {
                let session = String::from_utf8(payload.to_vec())
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                Ok(Some(Frame::SendFile { session }))
            }

            // Structured frames
            TYPE_NEW_SESSION => {
                expect_min_len(&payload, 12, "new session")?;
                let p = &payload[..];
                let mut off = 0usize;
                let name_len = read_u16(p, off) as usize;
                off += 2;
                if off + name_len + 2 > p.len() {
                    return Err(io::Error::new(
                        io::ErrorKind::InvalidData,
                        "new session frame truncated",
                    ));
                }
                let name = String::from_utf8(p[off..off + name_len].to_vec())
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                off += name_len;
                let cmd_len = read_u16(p, off) as usize;
                off += 2;
                if off + cmd_len + 2 > p.len() {
                    return Err(io::Error::new(
                        io::ErrorKind::InvalidData,
                        "new session frame truncated",
                    ));
                }
                let command = String::from_utf8(p[off..off + cmd_len].to_vec())
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                off += cmd_len;
                let cwd_len = read_u16(p, off) as usize;
                off += 2;
                if off + cwd_len + 4 > p.len() {
                    return Err(io::Error::new(
                        io::ErrorKind::InvalidData,
                        "new session frame truncated",
                    ));
                }
                let cwd = String::from_utf8(p[off..off + cwd_len].to_vec())
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                off += cwd_len;
                let cols = read_u16(p, off);
                off += 2;
                let rows = read_u16(p, off);
                off += 2;
                let client_name = if off + 2 <= p.len() {
                    let cn_len = read_u16(p, off) as usize;
                    off += 2;
                    if off + cn_len > p.len() {
                        return Err(io::Error::new(
                            io::ErrorKind::InvalidData,
                            "new session frame truncated",
                        ));
                    }
                    let s = String::from_utf8(p[off..off + cn_len].to_vec())
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                    off += cn_len;
                    s
                } else {
                    String::new()
                };
                // New field (added after client_name): linger_secs (8 bytes).
                // Older clients don't send it; default to 0 (never reap).
                let linger_secs = if off + 8 <= p.len() { read_u64(p, off) } else { 0 };
                Ok(Some(Frame::NewSession {
                    name,
                    command,
                    cwd,
                    cols,
                    rows,
                    client_name,
                    linger_secs,
                }))
            }
            TYPE_ATTACH => {
                expect_min_len(&payload, 5, "attach")?;
                let p = &payload[..];
                let mut off = 0usize;
                let session_len = read_u16(p, off) as usize;
                off += 2;
                if off + session_len + 2 > p.len() {
                    return Err(io::Error::new(
                        io::ErrorKind::InvalidData,
                        "attach frame truncated",
                    ));
                }
                let session = String::from_utf8(p[off..off + session_len].to_vec())
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                off += session_len;
                let cn_len = read_u16(p, off) as usize;
                off += 2;
                if off + cn_len + 1 > p.len() {
                    return Err(io::Error::new(
                        io::ErrorKind::InvalidData,
                        "attach frame truncated",
                    ));
                }
                let client_name = String::from_utf8(p[off..off + cn_len].to_vec())
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                off += cn_len;
                let force = p[off] != 0;
                off += 1;
                let no_replay = p.get(off).copied().unwrap_or(0) != 0;
                off += 1;
                // cols/rows appended in protocol v12, attach_token in v14,
                // rendered_offset + line_dirty in v21. Missing trailing fields
                // default to 0 for forward compat.
                let cols = if off + 2 <= p.len() { read_u16(p, off) } else { 0 };
                off += 2;
                let rows = if off + 2 <= p.len() { read_u16(p, off) } else { 0 };
                off += 2;
                let read_u64 = |p: &[u8], off: usize| {
                    let mut b = [0u8; 8];
                    b.copy_from_slice(&p[off..off + 8]);
                    u64::from_be_bytes(b)
                };
                let attach_token = if off + 8 <= p.len() { read_u64(p, off) } else { 0 };
                off += 8;
                let rendered_offset = if off + 8 <= p.len() { read_u64(p, off) } else { 0 };
                off += 8;
                let line_dirty = p.get(off).copied().unwrap_or(0) != 0;
                Ok(Some(Frame::Attach {
                    session,
                    client_name,
                    force,
                    no_replay,
                    cols,
                    rows,
                    attach_token,
                    rendered_offset,
                    line_dirty,
                }))
            }
            TYPE_ATTACH_ACK => {
                expect_min_len(&payload, 12, "attach_ack")?;
                let mut b = [0u8; 8];
                b.copy_from_slice(&payload[0..8]);
                let token = u64::from_be_bytes(b);
                let mut sid = [0u8; 4];
                sid.copy_from_slice(&payload[8..12]);
                let session_id = u32::from_be_bytes(sid);
                Ok(Some(Frame::AttachAck { token, session_id }))
            }
            TYPE_RENAME_SESSION => {
                if payload.len() < 2 {
                    return Err(io::Error::new(
                        io::ErrorKind::InvalidData,
                        "rename session frame too short",
                    ));
                }
                let session_len = read_u16(&payload, 0) as usize;
                if 2 + session_len > payload.len() {
                    return Err(io::Error::new(
                        io::ErrorKind::InvalidData,
                        "rename session frame truncated",
                    ));
                }
                let session = String::from_utf8(payload[2..2 + session_len].to_vec())
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                let new_name = String::from_utf8(payload[2 + session_len..].to_vec())
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                Ok(Some(Frame::RenameSession { session, new_name }))
            }
            TYPE_SET_LINGER => {
                expect_min_len(&payload, 10, "set linger")?;
                let session_len = read_u16(&payload, 0) as usize;
                if 2 + session_len + 8 > payload.len() {
                    return Err(io::Error::new(
                        io::ErrorKind::InvalidData,
                        "set linger frame truncated",
                    ));
                }
                let session = String::from_utf8(payload[2..2 + session_len].to_vec())
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                let linger_secs = read_u64(&payload, 2 + session_len);
                Ok(Some(Frame::SetLinger { session, linger_secs }))
            }
            TYPE_ERROR => {
                expect_min_len(&payload, 2, "error")?;
                let code = ErrorCode::from_u16(read_u16(&payload, 0));
                let message = String::from_utf8(payload[2..].to_vec())
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                Ok(Some(Frame::Error { code, message }))
            }

            // Custom frames
            TYPE_ENV => decode_env(payload),
            TYPE_SESSION_INFO => decode_session_info(payload),

            // Diagnostics
            TYPE_DIAG_REQUEST => Ok(Some(Frame::DiagRequest)),
            TYPE_DIAG_RESPONSE => {
                let text = String::from_utf8(payload.to_vec())
                    .unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned());
                Ok(Some(Frame::DiagResponse { text }))
            }

            _ => Err(io::Error::new(
                io::ErrorKind::InvalidData,
                format!("unknown frame type: 0x{frame_type:02x}"),
            )),
        }
    }
}

impl Encoder<Frame> for FrameCodec {
    type Error = io::Error;

    fn encode(&mut self, frame: Frame, dst: &mut BytesMut) -> Result<(), io::Error> {
        match frame {
            // Blob frames
            Frame::Data(data) => encode_blob(dst, TYPE_DATA, &data),
            Frame::Notice(data) => encode_blob(dst, TYPE_NOTICE, &data),
            Frame::Resume { offset } => {
                dst.put_u8(TYPE_RESUME);
                dst.put_u32(8);
                dst.put_u64(offset);
            }
            Frame::ClipboardSet { data } => encode_blob(dst, TYPE_CLIPBOARD_SET, &data),
            Frame::ClipboardData { data } => encode_blob(dst, TYPE_CLIPBOARD_DATA, &data),

            // Fixed-field frames (encode_fields! auto-computes payload length)
            Frame::Resize { cols, rows } => {
                encode_fields!(dst, TYPE_RESIZE, cols => put_u16, rows => put_u16);
            }
            Frame::Exit { code } => {
                encode_fields!(dst, TYPE_EXIT, code => put_i32);
            }
            Frame::Hello { version, capabilities, device_id } => {
                encode_fields!(dst, TYPE_HELLO, version => put_u16, capabilities => put_u32, device_id => put_u64);
            }
            Frame::HelloAck { version, capabilities, server_id } => {
                encode_fields!(
                    dst,
                    TYPE_HELLO_ACK,
                    version => put_u16,
                    capabilities => put_u32,
                    server_id => put_u64
                );
            }
            Frame::AgentOpen { channel_id } => {
                encode_fields!(dst, TYPE_AGENT_OPEN, channel_id => put_u32);
            }
            Frame::AgentClose { channel_id } => {
                encode_fields!(dst, TYPE_AGENT_CLOSE, channel_id => put_u32);
            }
            Frame::TunnelListen { port } => {
                encode_fields!(dst, TYPE_TUNNEL_LISTEN, port => put_u16);
            }
            Frame::TunnelOpen { channel_id } => {
                encode_fields!(dst, TYPE_TUNNEL_OPEN, channel_id => put_u32);
            }
            Frame::TunnelClose { channel_id } => {
                encode_fields!(dst, TYPE_TUNNEL_CLOSE, channel_id => put_u32);
            }
            Frame::SendOffer { file_count, total_bytes } => {
                encode_fields!(dst, TYPE_SEND_OFFER, file_count => put_u32, total_bytes => put_u64);
            }
            Frame::PortForwardListen { forward_id, listen_port, target_port } => {
                encode_fields!(dst, TYPE_PORT_FORWARD_LISTEN,
                    forward_id => put_u32, listen_port => put_u16, target_port => put_u16);
            }
            Frame::PortForwardReady { forward_id } => {
                encode_fields!(dst, TYPE_PORT_FORWARD_READY, forward_id => put_u32);
            }
            Frame::PortForwardOpen { forward_id, channel_id, target_port } => {
                encode_fields!(dst, TYPE_PORT_FORWARD_OPEN,
                    forward_id => put_u32, channel_id => put_u32, target_port => put_u16);
            }
            Frame::PortForwardClose { channel_id } => {
                encode_fields!(dst, TYPE_PORT_FORWARD_CLOSE, channel_id => put_u32);
            }
            Frame::PortForwardStop { forward_id } => {
                encode_fields!(dst, TYPE_PORT_FORWARD_STOP, forward_id => put_u32);
            }
            Frame::PortForwardRequest { forward_id, direction, listen_port, target_port } => {
                encode_fields!(dst, TYPE_PORT_FORWARD_REQUEST,
                    forward_id => put_u32, direction => put_u8, listen_port => put_u16, target_port => put_u16);
            }
            Frame::SessionCreated { id } => {
                encode_fields!(dst, TYPE_SESSION_CREATED, id => put_u32);
            }

            // Prefix + blob frames
            Frame::AgentData { channel_id, data } => {
                encode_prefix_blob(dst, TYPE_AGENT_DATA, channel_id, &data);
            }
            Frame::TunnelData { channel_id, data } => {
                encode_prefix_blob(dst, TYPE_TUNNEL_DATA, channel_id, &data);
            }
            Frame::PortForwardData { channel_id, data } => {
                encode_prefix_blob(dst, TYPE_PORT_FORWARD_DATA, channel_id, &data);
            }

            // Empty frames
            Frame::Detached => encode_empty(dst, TYPE_DETACHED),
            Frame::ServerShutdown => encode_empty(dst, TYPE_SERVER_SHUTDOWN),
            Frame::Ping => encode_empty(dst, TYPE_PING),
            Frame::Pong => encode_empty(dst, TYPE_PONG),
            Frame::AgentForward => encode_empty(dst, TYPE_AGENT_FORWARD),
            Frame::OpenForward => encode_empty(dst, TYPE_OPEN_FORWARD),
            Frame::ClipboardGet => encode_empty(dst, TYPE_CLIPBOARD_GET),
            Frame::SendDone => encode_empty(dst, TYPE_SEND_DONE),
            Frame::ListSessions => encode_empty(dst, TYPE_LIST_SESSIONS),
            Frame::KillServer => encode_empty(dst, TYPE_KILL_SERVER),
            Frame::Ok => encode_empty(dst, TYPE_OK),

            // String frames
            Frame::OpenUrl { url } => encode_str(dst, TYPE_OPEN_URL, &url),
            Frame::SendCancel { reason } => encode_str(dst, TYPE_SEND_CANCEL, &reason),
            Frame::Tail { session } => encode_str(dst, TYPE_TAIL, &session),
            Frame::KillSession { session } => encode_str(dst, TYPE_KILL_SESSION, &session),
            Frame::SendFile { session } => encode_str(dst, TYPE_SEND_FILE, &session),
            Frame::RenameSession { session, new_name } => {
                let session_bytes = session.as_bytes();
                let name_bytes = new_name.as_bytes();
                let payload_len = 2 + session_bytes.len() + name_bytes.len();
                dst.put_u8(TYPE_RENAME_SESSION);
                dst.put_u32(payload_len as u32);
                dst.put_u16(session_bytes.len() as u16);
                dst.extend_from_slice(session_bytes);
                dst.extend_from_slice(name_bytes);
            }
            Frame::SetLinger { session, linger_secs } => {
                let session_bytes = session.as_bytes();
                let payload_len = 2 + session_bytes.len() + 8;
                dst.put_u8(TYPE_SET_LINGER);
                dst.put_u32(payload_len as u32);
                dst.put_u16(session_bytes.len() as u16);
                dst.extend_from_slice(session_bytes);
                dst.put_u64(linger_secs);
            }

            // Structured frames
            Frame::NewSession { name, command, cwd, cols, rows, client_name, linger_secs } => {
                let nb = name.as_bytes();
                let cb = command.as_bytes();
                let cwdb = cwd.as_bytes();
                let cnb = client_name.as_bytes();
                let payload_len =
                    2 + nb.len() + 2 + cb.len() + 2 + cwdb.len() + 2 + 2 + 2 + cnb.len() + 8;
                dst.put_u8(TYPE_NEW_SESSION);
                dst.put_u32(payload_len as u32);
                dst.put_u16(nb.len() as u16);
                dst.extend_from_slice(nb);
                dst.put_u16(cb.len() as u16);
                dst.extend_from_slice(cb);
                dst.put_u16(cwdb.len() as u16);
                dst.extend_from_slice(cwdb);
                dst.put_u16(cols);
                dst.put_u16(rows);
                dst.put_u16(cnb.len() as u16);
                dst.extend_from_slice(cnb);
                dst.put_u64(linger_secs);
            }
            Frame::Attach {
                session,
                client_name,
                force,
                no_replay,
                cols,
                rows,
                attach_token,
                rendered_offset,
                line_dirty,
            } => {
                let sb = session.as_bytes();
                let cnb = client_name.as_bytes();
                let payload_len = 2 + sb.len() + 2 + cnb.len() + 2 + 4 + 8 + 8 + 1;
                dst.put_u8(TYPE_ATTACH);
                dst.put_u32(payload_len as u32);
                dst.put_u16(sb.len() as u16);
                dst.extend_from_slice(sb);
                dst.put_u16(cnb.len() as u16);
                dst.extend_from_slice(cnb);
                dst.put_u8(if force { 1 } else { 0 });
                dst.put_u8(if no_replay { 1 } else { 0 });
                dst.put_u16(cols);
                dst.put_u16(rows);
                dst.put_u64(attach_token);
                dst.put_u64(rendered_offset);
                dst.put_u8(if line_dirty { 1 } else { 0 });
            }
            Frame::AttachAck { token, session_id } => {
                dst.put_u8(TYPE_ATTACH_ACK);
                dst.put_u32(12);
                dst.put_u64(token);
                dst.put_u32(session_id);
            }
            Frame::Error { code, message } => {
                let mb = message.as_bytes();
                let payload_len = 2 + mb.len();
                dst.put_u8(TYPE_ERROR);
                dst.put_u32(payload_len as u32);
                dst.put_u16(code.to_u16());
                dst.extend_from_slice(mb);
            }

            // Custom frames
            Frame::Env { vars } => encode_env(dst, &vars),
            Frame::SessionInfo { sessions } => encode_session_info(dst, &sessions),

            // Diagnostics
            Frame::DiagRequest => encode_empty(dst, TYPE_DIAG_REQUEST),
            Frame::DiagResponse { text } => encode_str(dst, TYPE_DIAG_RESPONSE, &text),
        }
        Ok(())
    }
}
