//! `AIMX/1` wire protocol codec.
//!
//! Length-prefixed, binary-safe framing used by `aimx send` and the MCP
//! server to submit mail + state-mutation requests to `aimx serve` over
//! `/run/aimx/aimx.sock`. The codec is pure: it speaks only
//! `AsyncRead`/`AsyncWrite`, so it can be exercised with in-memory async
//! streams (e.g. `tokio_test::io::Builder`) without touching the filesystem
//! or a real socket.
//!
//! Framing:
//!
//! ```text
//! Client → Server (SEND):
//!   AIMX/1 SEND\n
//!   [Text-Only: true\n]
//!   Content-Length: <n>\n
//!   [Html-Body-Length: <m>\n]
//!   \n
//!   <n bytes of RFC 5322 message, unsigned>
//!   [<m bytes of custom HTML body, verbatim>]
//!
//! `Text-Only: true` ships the body verbatim as text/plain (no Markdown
//! render). `Html-Body-Length: <m>` ships the main body as text/plain
//! and the next <m> bytes as text/html verbatim. Setting both is a
//! parse error: the two flags are mutually exclusive.
//!
//! Client → Server (MARK-READ / MARK-UNREAD):
//!   AIMX/1 MARK-READ\n
//!   Mailbox: <name>\n
//!   Id: <id>\n
//!   Content-Length: 0\n
//!   \n
//!
//! Client → Server (MAILBOX-CREATE / MAILBOX-DELETE):
//!   AIMX/1 MAILBOX-CREATE\n
//!   Name: <mailbox-name>\n
//!   Content-Length: 0\n
//!   \n
//!
//! Client → Server (MAILBOX-LIST):
//!   AIMX/1 MAILBOX-LIST\n
//!   \n
//!
//! Client → Server (HOOK-CREATE):
//!   AIMX/1 HOOK-CREATE\n
//!   Mailbox: <mailbox-name>\n
//!   Event: on_receive|after_send\n
//!   Name: <optional-hook-name>\n
//!   Content-Length: <n>\n
//!   \n
//!   <n bytes of body — handler shape is reworked in a later sprint>
//!
//! Client → Server (HOOK-DELETE):
//!   AIMX/1 HOOK-DELETE\n
//!   Hook-Name: <name>\n
//!   Content-Length: 0\n
//!   \n
//!
//! Client → Server (HOOK-LIST):
//!   AIMX/1 HOOK-LIST\n
//!   \n
//!
//! Server → Client:
//!   AIMX/1 OK [<message-id>]\n
//! or
//!   AIMX/1 ERR <code> <reason>\n
//! ```
//!
//! The blank separator line is a literal `\n` (or `\r\n`). Header names are
//! case-insensitive. Unknown headers are silently ignored so the protocol
//! can be extended without breaking older clients.
//!
//! Notes on `SEND`:
//! - There is no `From-Mailbox:` header. The daemon parses the `From:`
//!   header from the submitted message body itself and resolves the
//!   sender mailbox against its in-memory Config. This lets `aimx send`
//!   run as a non-root user without needing read access to `config.toml`.
//!
//! Notes on state-mutation verbs:
//! - `MARK-READ` / `MARK-UNREAD` let the MCP server invoke
//!   `email_mark_read` / `email_mark_unread` without the MCP process
//!   needing write access to the root-owned mailbox files.
//! - `MAILBOX-CREATE` / `MAILBOX-DELETE` join the same codec, carrying a
//!   single `Name:` header. The daemon handles `config.toml`
//!   rewrite-and-rename plus the in-memory `RwLock<Arc<Config>>` swap,
//!   which is why `aimx mailboxes create/delete` does not require a daemon
//!   restart for inbound routing to pick up the change. Error codes
//!   reuse the existing set plus `VALIDATION` (name validation failures)
//!   and `NONEMPTY` (delete refused because the mailbox still holds files).
//! - `HOOK-CREATE` / `HOOK-DELETE` are placeholder verbs at the codec
//!   layer; the schema for hooks-over-UDS is reworked in a later sprint.
//!   Raw-cmd hooks remain operator-only via `sudo aimx hooks create
//!   --cmd`, which writes `config.toml` directly.

use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};

/// Maximum body size accepted by [`parse_request`]. Mirrors the SMTP DATA
/// cap (`smtp::DEFAULT_MAX_MESSAGE_SIZE`, 25 MB) so local clients cannot
/// submit mail the MTA would refuse anyway.
pub const DEFAULT_MAX_BODY_SIZE: usize = 25 * 1024 * 1024;

/// Maximum length of the request-line or a single header line, in bytes.
/// Keeps the parser's memory footprint bounded even on pathological input.
const MAX_HEADER_LINE: usize = 8 * 1024;

/// Decoded `AIMX/1 SEND` request. There is no `From-Mailbox:` header;
/// the daemon parses `From:` out of `body` and resolves the sender
/// mailbox itself against its in-memory Config.
///
/// `text_only` and `html_body` are the two escape hatches layered on top
/// of the default Markdown render path. Default frame (neither header
/// set) is the Markdown render path. `Text-Only: true` ships `body`
/// verbatim as `text/plain`. `Html-Body-Length: <n>` ships `body` as the
/// `text/plain` part and `html_body` (n bytes that follow on the wire)
/// verbatim as the `text/html` part. The codec rejects frames that set
/// both at once.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SendRequest {
    pub body: Vec<u8>,
    pub text_only: bool,
    pub html_body: Option<Vec<u8>>,
}

/// Decoded `AIMX/1 MARK-READ` or `AIMX/1 MARK-UNREAD` request. Targets
/// the inbox folder unconditionally — sent-mail mark has no agent use
/// case and was removed from the wire.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarkRequest {
    pub mailbox: String,
    pub id: String,
    pub read: bool,
}

/// Decoded `AIMX/1 MAILBOX-CREATE` / `AIMX/1 MAILBOX-DELETE` request.
///
/// Both verbs share the same shape; the enum selection is encoded in
/// `create` so the codec stays a single flat struct rather than two
/// near-identical types. `Owner:` is OPTIONAL on the wire — the
/// daemon-side handler decides what to do per PRD §6.5 R20 (root
/// callers must supply `Owner:` so the daemon knows which Linux user
/// to chown the newly-created mailbox directories to; non-root callers
/// have any wire-supplied `Owner:` ignored and the owner synthesized
/// from `peer_username(SO_PEERCRED)`). `MAILBOX-DELETE` ignores
/// `owner` regardless of caller — the daemon only needs the name to
/// remove the stanza. `MAILBOX-DELETE` also accepts an optional
/// `force` flag (encoded on the wire as `Force: true`) which tells the
/// daemon to wipe `inbox/<name>/` and `sent/<name>/` contents under
/// the per-mailbox lock before unlinking the stanza, so the wipe and
/// the config rewrite are atomic together (no data-destruction race
/// window between client-side wipe and daemon-side delete).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MailboxLifecycleRequest {
    pub name: String,
    /// `true` for `MAILBOX-CREATE`, `false` for `MAILBOX-DELETE`.
    pub create: bool,
    /// Linux user that owns the mailbox storage. Optional on the wire.
    /// On CREATE: required for root callers (no SO_PEERCRED-based
    /// identity to fall back on); ignored for non-root callers (the
    /// daemon synthesizes from `peer_username(SO_PEERCRED)` so privilege
    /// escalation is structurally impossible). On DELETE: ignored
    /// regardless of caller. When supplied and accepted, the daemon
    /// validates the owner resolves via `getpwnam` and chowns
    /// `inbox/<name>/` + `sent/<name>/` to `<owner>:<owner>` mode `0700`.
    pub owner: Option<String>,
    /// `MAILBOX-DELETE`-only: when `true`, the daemon wipes the inbox
    /// and sent directories under the same per-mailbox lock that
    /// guards the stanza removal, eliminating the data-destruction
    /// race window a client-side wipe would otherwise leave open.
    /// Ignored on CREATE.
    pub force: bool,
}

/// Decoded `AIMX/1 HOOK-CREATE` request. The verb wiring is reworked
/// in a later sprint; for now the daemon-side handler returns
/// `ERR PROTOCOL hook-create over UDS is not implemented` so callers
/// fall back to the root-only CLI path.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookCreateRequest {
    pub mailbox: String,
    pub event: String,
    pub name: Option<String>,
    pub body: Vec<u8>,
}

/// Decoded `AIMX/1 HOOK-DELETE` request. Locates the hook by effective
/// name (explicit or derived) across every configured mailbox; there is
/// no `Mailbox:` header on delete.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookDeleteRequest {
    pub name: String,
}

// `AIMX/1 HOOK-LIST` carries no payload; the daemon resolves the
// caller via `SO_PEERCRED` and returns the hooks visible to the
// caller's uid (root sees every hook on every mailbox; non-root sees
// only hooks on mailboxes it owns). The verb mirrors `MAILBOX-LIST`
// line-for-line so the codec stays uniform — see `Request::HookList`
// below and `parse_hook_list_headers` for the codec entry points.

/// JSON payload of an `AIMX/1 VERSION` response. Mirrors the four
/// `crate::version` build-metadata helpers so a client can render the
/// same `aimx <tag> (<sha>) <target> built <date>` banner without
/// invoking the daemon's binary directly. Used by `aimx doctor` to show
/// the running daemon's version next to the invoking binary's.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct VersionResponse {
    pub tag: String,
    pub git_hash: String,
    pub target: String,
    pub build_date: String,
}

/// One decoded `AIMX/1` request, tagged by verb.
#[derive(Debug, Clone, PartialEq)]
pub enum Request {
    Send(SendRequest),
    Mark(MarkRequest),
    MailboxLifecycle(MailboxLifecycleRequest),
    /// `AIMX/1 MAILBOX-LIST` carries no payload. The daemon resolves
    /// the caller via `SO_PEERCRED` and returns the mailboxes the uid
    /// owns; root sees every mailbox.
    MailboxList,
    HookCreate(HookCreateRequest),
    HookDelete(HookDeleteRequest),
    /// `AIMX/1 HOOK-LIST` carries no payload. The daemon resolves the
    /// caller via `SO_PEERCRED` and returns a JSON array of the hooks
    /// visible to the caller; non-root callers see only hooks on
    /// mailboxes they own. Mirrors `MAILBOX-LIST` line-for-line.
    HookList,
    /// `AIMX/1 VERSION` carries no payload. Returns the daemon's build
    /// metadata so operators can detect drift between an upgraded
    /// binary on disk and a still-running pre-upgrade daemon. Open to
    /// every UDS caller — version data is not sensitive.
    Version,
}

/// Error codes reported on the wire in `AIMX/1 ERR <code> <reason>`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrCode {
    Mailbox,
    Domain,
    Sign,
    Delivery,
    Temp,
    Malformed,
    /// Codec-level failure: unknown verb, missing required header, or other
    /// framing violation that is neither malformed-message nor a
    /// business-logic error. Kept distinct from `Malformed` so callers can
    /// tell "bad RFC 5322 body" apart from "bad AIMX/1 envelope".
    Protocol,
    /// Generic not-found / invalid-argument at the handler level (e.g. the
    /// referenced email id is absent, the id contains `..`, etc.).
    NotFound,
    /// I/O failure while performing the requested mutation (read error,
    /// write error, parse error on the persisted frontmatter).
    Io,
    /// Input validation failure on a `MAILBOX-CREATE` / `MAILBOX-DELETE`
    /// request: the submitted mailbox name violated one of
    /// `validate_mailbox_name`'s rules (empty, `..`, path separator, NUL,
    /// etc.).
    Validation,
    /// `MAILBOX-DELETE` refused because the target directory still
    /// contains files. Operator must archive / remove files first; the
    /// daemon never silently removes mail on delete.
    NonEmpty,
    /// `MAILBOX-CREATE` found `inbox/<name>/` / `sent/<name>/` already
    /// present but owned by a different uid/gid than the requested
    /// owner. Ambiguous state — operator must fix it with `chown`
    /// before the daemon will claim the directories.
    Conflict,
    /// The caller's uid (from `SO_PEERCRED`) is not authorized for the
    /// requested verb on the target mailbox, and is not root. Mirrors
    /// POSIX `EACCES` for operator ergonomics.
    Eaccess,
    /// The target resource referenced by the verb (mailbox, hook,
    /// template) does not exist. Distinct from [`ErrCode::NotFound`]
    /// so authz helpers can signal "unknown-target" vs
    /// "unknown-email-id" without overloading the legacy code. Emitted
    /// instead of `EACCES` whenever the caller's authz outcome is
    /// leaked by the existence-check itself (PRD
    /// §6.5 leak-free shape).
    Enoent,
    /// The Markdown body submitted on `SEND` exceeded
    /// `markdown_render::MAX_MARKDOWN_BODY_BYTES` (5 MiB). Distinct from
    /// `Malformed` so scripts can branch on the size-cap failure
    /// without parsing the reason string. The reason field still
    /// carries the canonical actionable message ("use --html-body for
    /// pre-rendered large documents or --attachment for sending the
    /// document as a file").
    BodyTooLarge,
}

impl ErrCode {
    pub fn as_str(self) -> &'static str {
        match self {
            ErrCode::Mailbox => "MAILBOX",
            ErrCode::Domain => "DOMAIN",
            ErrCode::Sign => "SIGN",
            ErrCode::Delivery => "DELIVERY",
            ErrCode::Temp => "TEMP",
            ErrCode::Malformed => "MALFORMED",
            ErrCode::Protocol => "PROTOCOL",
            ErrCode::NotFound => "NOTFOUND",
            ErrCode::Io => "IO",
            ErrCode::Validation => "VALIDATION",
            ErrCode::NonEmpty => "NONEMPTY",
            ErrCode::Conflict => "ECONFLICT",
            ErrCode::Eaccess => "EACCES",
            ErrCode::Enoent => "ENOENT",
            ErrCode::BodyTooLarge => "BODY_TOO_LARGE",
        }
    }

    /// Parse a wire-level code string back into the tagged enum.
    /// Returns `None` for unknown strings so clients distinguish
    /// "daemon drifted past our vocabulary" from "daemon reported X".
    pub fn from_str(s: &str) -> Option<Self> {
        let v = match s {
            "MAILBOX" => ErrCode::Mailbox,
            "DOMAIN" => ErrCode::Domain,
            "SIGN" => ErrCode::Sign,
            "DELIVERY" => ErrCode::Delivery,
            "TEMP" => ErrCode::Temp,
            "MALFORMED" => ErrCode::Malformed,
            "PROTOCOL" => ErrCode::Protocol,
            "NOTFOUND" => ErrCode::NotFound,
            "IO" => ErrCode::Io,
            "VALIDATION" => ErrCode::Validation,
            "NONEMPTY" => ErrCode::NonEmpty,
            "ECONFLICT" => ErrCode::Conflict,
            "EACCES" => ErrCode::Eaccess,
            "ENOENT" => ErrCode::Enoent,
            "BODY_TOO_LARGE" => ErrCode::BodyTooLarge,
            _ => return None,
        };
        Some(v)
    }
}

/// Response emitted by the daemon after processing a `SEND` request. The
/// `Ok` variant carries the message-id the daemon echoes back.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SendResponse {
    Ok { message_id: String },
    Err { code: ErrCode, reason: String },
}

/// Response emitted by the daemon after processing a bodyless verb
/// (MARK-READ, MARK-UNREAD). `AIMX/1 OK\n` on success, no payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AckResponse {
    Ok,
    Err { code: ErrCode, reason: String },
}

/// Response emitted by the daemon after processing a `MAILBOX-LIST`
/// request. The `Ok` variant carries the JSON body the daemon
/// renders from its `Arc<Config>` snapshot plus the on-disk inbox /
/// sent counts.
///
/// Wire format on success:
/// ```text
/// AIMX/1 OK\n
/// Content-Length: <n>\n
/// \n
/// <n bytes of JSON>
/// ```
///
/// The error form mirrors the other ack responses
/// (`AIMX/1 ERR <code> <reason>\nCode: <code>\n\n`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JsonAckResponse {
    Ok { body: Vec<u8> },
    Err { code: ErrCode, reason: String },
}

/// Codec error. Kept separate from `std::error::Error` so the daemon's
/// handler can map `ParseError` values into wire-level `ErrCode::Malformed`
/// responses without rebuilding the information.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
    /// Peer closed the connection before any request-line bytes arrived.
    ClosedBeforeRequest,
    /// Malformed framing or missing required headers. Reason is operator-
    /// facing and safe to render into `ERR MALFORMED <reason>`.
    Malformed(String),
    /// Verb token after `AIMX/1 ` was not one the codec recognises. The
    /// daemon maps this to `ERR PROTOCOL unknown verb '<x>'` to distinguish
    /// it from a body-level malformation.
    UnknownVerb(String),
    /// An underlying I/O error. Surfaced so the daemon can distinguish
    /// "client sent garbage" from "socket EBADF".
    Io(String),
}

impl std::fmt::Display for ParseError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ParseError::ClosedBeforeRequest => write!(f, "connection closed before request"),
            ParseError::Malformed(s) => write!(f, "malformed request: {s}"),
            ParseError::UnknownVerb(v) => write!(f, "unknown verb '{v}'"),
            ParseError::Io(s) => write!(f, "i/o error: {s}"),
        }
    }
}

impl std::error::Error for ParseError {}

impl From<std::io::Error> for ParseError {
    fn from(e: std::io::Error) -> Self {
        ParseError::Io(e.to_string())
    }
}

/// Read and decode one `AIMX/1` request from `reader`. Dispatches on the
/// verb token of the request-line so `SEND`, `MARK-READ`, and `MARK-UNREAD`
/// share the same framing.
///
/// Uses the codec-level [`DEFAULT_MAX_BODY_SIZE`] cap. Callers that need a
/// different cap should use [`parse_request_with_limit`].
pub async fn parse_request<R>(reader: &mut R) -> Result<Request, ParseError>
where
    R: AsyncRead + Unpin,
{
    parse_request_with_limit(reader, DEFAULT_MAX_BODY_SIZE).await
}

/// Like [`parse_request`] but with a caller-supplied maximum body size.
pub async fn parse_request_with_limit<R>(
    reader: &mut R,
    max_body: usize,
) -> Result<Request, ParseError>
where
    R: AsyncRead + Unpin,
{
    let request_line = read_line(reader).await?;
    let request_line = match request_line {
        Some(l) => l,
        None => return Err(ParseError::ClosedBeforeRequest),
    };

    let line = request_line.trim_end_matches('\r');
    let verb = line
        .strip_prefix("AIMX/1 ")
        .ok_or_else(|| ParseError::Malformed(format!("expected 'AIMX/1 <verb>', got {line:?}")))?;

    match verb {
        "SEND" => parse_send_headers_and_body(reader, max_body)
            .await
            .map(Request::Send),
        "MARK-READ" => parse_mark_headers(reader, true).await.map(Request::Mark),
        "MARK-UNREAD" => parse_mark_headers(reader, false).await.map(Request::Mark),
        "MAILBOX-CREATE" => parse_mailbox_crud_headers(reader, true)
            .await
            .map(Request::MailboxLifecycle),
        "MAILBOX-DELETE" => parse_mailbox_crud_headers(reader, false)
            .await
            .map(Request::MailboxLifecycle),
        "MAILBOX-LIST" => parse_mailbox_list_headers(reader)
            .await
            .map(|()| Request::MailboxList),
        "HOOK-CREATE" => parse_hook_create_headers_and_body(reader, max_body)
            .await
            .map(Request::HookCreate),
        "HOOK-DELETE" => parse_hook_delete_headers(reader)
            .await
            .map(Request::HookDelete),
        "HOOK-LIST" => parse_hook_list_headers(reader)
            .await
            .map(|()| Request::HookList),
        "VERSION" => parse_version_headers(reader)
            .await
            .map(|()| Request::Version),
        other => Err(ParseError::UnknownVerb(other.to_string())),
    }
}

/// Convenience wrapper for callers that only want the `SEND` verb.
/// Retained for test harnesses that exercise the SEND path directly; the
/// production dispatcher uses [`parse_request`] and matches on the
/// resulting [`Request`] enum so it can route MARK verbs to the state
/// handler. Returns `ParseError::Malformed` if a non-SEND verb is
/// received.
#[cfg(test)]
pub async fn parse_send_request<R>(reader: &mut R) -> Result<SendRequest, ParseError>
where
    R: AsyncRead + Unpin,
{
    match parse_request(reader).await? {
        Request::Send(r) => Ok(r),
        Request::Mark(_) => Err(ParseError::Malformed(
            "expected SEND verb, got MARK-*".to_string(),
        )),
        Request::MailboxLifecycle(_) | Request::MailboxList => Err(ParseError::Malformed(
            "expected SEND verb, got MAILBOX-*".to_string(),
        )),
        Request::HookCreate(_) | Request::HookDelete(_) | Request::HookList => Err(
            ParseError::Malformed("expected SEND verb, got HOOK-*".to_string()),
        ),
        Request::Version => Err(ParseError::Malformed(
            "expected SEND verb, got VERSION".to_string(),
        )),
    }
}

async fn parse_send_headers_and_body<R>(
    reader: &mut R,
    max_body: usize,
) -> Result<SendRequest, ParseError>
where
    R: AsyncRead + Unpin,
{
    // AIMX/1 header order is unspecified — the parser accumulates all
    // header lines into local state in arrival order and only enforces
    // cross-header invariants (mutual exclusion, required-headers,
    // duplicates) after the blank-line terminator. The encoder below
    // emits a canonical order for readability only; conforming clients
    // must not rely on it. Keep new headers wired into the same
    // accumulate-then-validate shape rather than positional checks.
    let mut content_length: Option<usize> = None;
    let mut html_body_length: Option<usize> = None;
    let mut text_only: bool = false;
    let mut text_only_seen: bool = false;

    loop {
        let line = read_line(reader)
            .await?
            .ok_or_else(|| ParseError::Malformed("unexpected EOF in headers".into()))?;
        let line = line.trim_end_matches('\r');
        if line.is_empty() {
            break;
        }

        let (name, value) = line
            .split_once(':')
            .ok_or_else(|| ParseError::Malformed(format!("invalid header line: {line:?}")))?;

        if !name.is_ascii() {
            return Err(ParseError::Malformed(format!(
                "non-ascii header name: {name:?}"
            )));
        }
        let name_norm = name.trim().to_ascii_lowercase();
        let value = value.trim().to_string();

        match name_norm.as_str() {
            "content-length" => {
                if content_length.is_some() {
                    return Err(ParseError::Malformed(
                        "duplicate Content-Length header".into(),
                    ));
                }
                let n: usize = value.parse().map_err(|_| {
                    ParseError::Malformed(format!("non-integer Content-Length: {value:?}"))
                })?;
                if n > max_body {
                    return Err(ParseError::Malformed(format!(
                        "Content-Length {n} exceeds cap {max_body}"
                    )));
                }
                content_length = Some(n);
            }
            "text-only" => {
                if text_only_seen {
                    return Err(ParseError::Malformed("duplicate Text-Only header".into()));
                }
                let v_lower = value.to_ascii_lowercase();
                text_only = match v_lower.as_str() {
                    "true" => true,
                    "false" => false,
                    _ => {
                        return Err(ParseError::Malformed(format!(
                            "invalid Text-Only value: {value:?} (expected 'true' or 'false')"
                        )));
                    }
                };
                text_only_seen = true;
            }
            "html-body-length" => {
                if html_body_length.is_some() {
                    return Err(ParseError::Malformed(
                        "duplicate Html-Body-Length header".into(),
                    ));
                }
                let n: usize = value.parse().map_err(|_| {
                    ParseError::Malformed(format!("non-integer Html-Body-Length: {value:?}"))
                })?;
                if n > max_body {
                    return Err(ParseError::Malformed(format!(
                        "Html-Body-Length {n} exceeds cap {max_body}"
                    )));
                }
                html_body_length = Some(n);
            }
            _ => {
                // Unknown headers are ignored. The daemon re-derives the
                // mailbox from the message body's `From:` header; no
                // header-provided sender is ever trusted.
            }
        }
    }

    let content_length = content_length
        .ok_or_else(|| ParseError::Malformed("missing required header: Content-Length".into()))?;

    // Mutual exclusion: a frame setting both --text-only and --html-body
    // is the operator asking for two opposite wire shapes at once. Reject
    // at the codec layer so the canonical error reaches the CLI without
    // dispatching into the handler.
    if text_only && html_body_length.is_some() {
        return Err(ParseError::Malformed(
            "AIMX/1 SEND: --text-only and --html-body are mutually exclusive".to_string(),
        ));
    }

    let mut body = vec![0u8; content_length];
    reader.read_exact(&mut body).await.map_err(|e| {
        if e.kind() == std::io::ErrorKind::UnexpectedEof {
            ParseError::Malformed(format!("body truncated: expected {content_length} bytes"))
        } else {
            ParseError::Io(e.to_string())
        }
    })?;

    let html_body = if let Some(html_len) = html_body_length {
        let mut buf = vec![0u8; html_len];
        reader.read_exact(&mut buf).await.map_err(|e| {
            if e.kind() == std::io::ErrorKind::UnexpectedEof {
                ParseError::Malformed(format!(
                    "html body truncated: expected {html_len} bytes after main body"
                ))
            } else {
                ParseError::Io(e.to_string())
            }
        })?;
        Some(buf)
    } else {
        None
    };

    Ok(SendRequest {
        body,
        text_only,
        html_body,
    })
}

async fn parse_mark_headers<R>(reader: &mut R, read: bool) -> Result<MarkRequest, ParseError>
where
    R: AsyncRead + Unpin,
{
    let mut mailbox: Option<String> = None;
    let mut id: Option<String> = None;
    let mut content_length: Option<usize> = None;

    loop {
        let line = read_line(reader)
            .await?
            .ok_or_else(|| ParseError::Malformed("unexpected EOF in headers".into()))?;
        let line = line.trim_end_matches('\r');
        if line.is_empty() {
            break;
        }

        let (name, value) = line
            .split_once(':')
            .ok_or_else(|| ParseError::Malformed(format!("invalid header line: {line:?}")))?;

        if !name.is_ascii() {
            return Err(ParseError::Malformed(format!(
                "non-ascii header name: {name:?}"
            )));
        }
        let name_norm = name.trim().to_ascii_lowercase();
        let value = value.trim().to_string();

        match name_norm.as_str() {
            "mailbox" => {
                if mailbox.is_some() {
                    return Err(ParseError::Malformed("duplicate Mailbox header".into()));
                }
                if value.is_empty() {
                    return Err(ParseError::Malformed("empty Mailbox value".into()));
                }
                mailbox = Some(value);
            }
            "id" => {
                if id.is_some() {
                    return Err(ParseError::Malformed("duplicate Id header".into()));
                }
                if value.is_empty() {
                    return Err(ParseError::Malformed("empty Id value".into()));
                }
                id = Some(value);
            }
            "folder" => {
                // The `Folder:` header was removed; MARK targets the
                // inbox unconditionally. Reject rather than silently
                // dropping so a stale client cannot believe it
                // selected the sent folder.
                return Err(ParseError::Malformed(
                    "Folder header is not allowed on MARK verbs (sent-mail \
                     mark removed; inbox is the only target)"
                        .into(),
                ));
            }
            "content-length" => {
                if content_length.is_some() {
                    return Err(ParseError::Malformed(
                        "duplicate Content-Length header".into(),
                    ));
                }
                let n: usize = value.parse().map_err(|_| {
                    ParseError::Malformed(format!("non-integer Content-Length: {value:?}"))
                })?;
                if n != 0 {
                    return Err(ParseError::Malformed(format!(
                        "MARK verb must have Content-Length: 0, got {n}"
                    )));
                }
                content_length = Some(n);
            }
            _ => {
                // Unknown headers are ignored.
            }
        }
    }

    let mailbox =
        mailbox.ok_or_else(|| ParseError::Malformed("missing required header: Mailbox".into()))?;
    let id = id.ok_or_else(|| ParseError::Malformed("missing required header: Id".into()))?;
    // Content-Length is optional for MARK verbs but if present must be 0.
    // The `!= 0` branch above already rejects otherwise. Accept the missing
    // case as "implicitly 0" to keep clients terse.
    let _ = content_length;

    Ok(MarkRequest { mailbox, id, read })
}

async fn parse_mailbox_crud_headers<R>(
    reader: &mut R,
    create: bool,
) -> Result<MailboxLifecycleRequest, ParseError>
where
    R: AsyncRead + Unpin,
{
    let mut name: Option<String> = None;
    let mut owner: Option<String> = None;
    let mut force: bool = false;
    let mut force_seen: bool = false;
    let mut content_length: Option<usize> = None;

    loop {
        let line = read_line(reader)
            .await?
            .ok_or_else(|| ParseError::Malformed("unexpected EOF in headers".into()))?;
        let line = line.trim_end_matches('\r');
        if line.is_empty() {
            break;
        }

        let (n, v) = line
            .split_once(':')
            .ok_or_else(|| ParseError::Malformed(format!("invalid header line: {line:?}")))?;

        if !n.is_ascii() {
            return Err(ParseError::Malformed(format!(
                "non-ascii header name: {n:?}"
            )));
        }
        let name_norm = n.trim().to_ascii_lowercase();
        let value = v.trim().to_string();

        match name_norm.as_str() {
            "name" => {
                if name.is_some() {
                    return Err(ParseError::Malformed("duplicate Name header".into()));
                }
                if value.is_empty() {
                    return Err(ParseError::Malformed("empty Name value".into()));
                }
                name = Some(value);
            }
            "owner" => {
                if owner.is_some() {
                    return Err(ParseError::Malformed("duplicate Owner header".into()));
                }
                if value.is_empty() {
                    return Err(ParseError::Malformed("empty Owner value".into()));
                }
                // Shape-check the owner at parse time so malformed
                // header values (embedded whitespace, colons, tabs,
                // etc.) reject with a precise `Malformed` here rather
                // than at the `getpwnam`/resolver layer further in.
                // Mirrors the `validate_run_as` regex gate.
                if !crate::config::is_valid_system_username(&value) {
                    return Err(ParseError::Malformed(format!(
                        "invalid Owner value: {value:?} (must match [a-z_][a-z0-9_-]*[$]?)"
                    )));
                }
                owner = Some(value);
            }
            "force" => {
                // `Force:` is only meaningful on MAILBOX-DELETE. Reject
                // on CREATE so the wire shape stays self-describing
                // rather than silently dropping a header an operator
                // explicitly set.
                if create {
                    return Err(ParseError::Malformed(
                        "Force header is only valid on MAILBOX-DELETE".into(),
                    ));
                }
                if force_seen {
                    return Err(ParseError::Malformed("duplicate Force header".into()));
                }
                let v_lower = value.to_ascii_lowercase();
                force = match v_lower.as_str() {
                    "true" => true,
                    "false" => false,
                    _ => {
                        return Err(ParseError::Malformed(format!(
                            "invalid Force value: {value:?} (expected 'true' or 'false')"
                        )));
                    }
                };
                force_seen = true;
            }
            "content-length" => {
                if content_length.is_some() {
                    return Err(ParseError::Malformed(
                        "duplicate Content-Length header".into(),
                    ));
                }
                let n: usize = value.parse().map_err(|_| {
                    ParseError::Malformed(format!("non-integer Content-Length: {value:?}"))
                })?;
                if n != 0 {
                    return Err(ParseError::Malformed(format!(
                        "MAILBOX verb must have Content-Length: 0, got {n}"
                    )));
                }
                content_length = Some(n);
            }
            _ => {
                // Unknown headers are ignored.
            }
        }
    }

    let name = name.ok_or_else(|| ParseError::Malformed("missing required header: Name".into()))?;
    // Content-Length is optional for MAILBOX-CRUD verbs; 0 is implicit.
    let _ = content_length;

    // `Owner:` is OPTIONAL on the wire. The daemon-side handler
    // (`mailbox_handler::handle_mailbox_crud`) decides what to do with
    // the field per PRD §6.5 R20:
    //
    //   - non-root callers: the daemon ignores any wire-supplied owner
    //     and synthesizes it from `peer_username(SO_PEERCRED)`. This is
    //     the path the MCP `mailbox_create` tool uses (it deliberately
    //     ships `owner: None` so an agent cannot plant a foreign owner).
    //   - root callers: the daemon requires `owner: Some(_)` (it has no
    //     SO_PEERCRED-based identity to fall back on) and rejects with
    //     `ErrCode::Validation` if the field is missing.
    //
    // The parser therefore stays permissive — making it strict would
    // re-introduce the protocol-level rejection that broke the MCP
    // `mailbox_create` tool for non-root callers in production.

    Ok(MailboxLifecycleRequest {
        name,
        create,
        owner,
        force,
    })
}

/// Parse the empty body of an `AIMX/1 MAILBOX-LIST` request. The verb
/// carries no headers — the parser reads exactly one blank line and
/// rejects any header line (including `Content-Length:`) so a future
/// rev cannot silently smuggle a forged owner header past the
/// `SO_PEERCRED` filter.
async fn parse_mailbox_list_headers<R>(reader: &mut R) -> Result<(), ParseError>
where
    R: AsyncRead + Unpin,
{
    let line = read_line(reader)
        .await?
        .ok_or_else(|| ParseError::Malformed("unexpected EOF in headers".into()))?;
    let line = line.trim_end_matches('\r');
    if !line.is_empty() {
        return Err(ParseError::Malformed(format!(
            "MAILBOX-LIST takes no headers, got {line:?}"
        )));
    }
    Ok(())
}

/// Parse the empty body of an `AIMX/1 HOOK-LIST` request. Mirrors
/// `parse_mailbox_list_headers` line-for-line: the verb carries no
/// headers, and silently ignoring one would let a future caller smuggle
/// a forged owner header past the `SO_PEERCRED` filter.
async fn parse_hook_list_headers<R>(reader: &mut R) -> Result<(), ParseError>
where
    R: AsyncRead + Unpin,
{
    let line = read_line(reader)
        .await?
        .ok_or_else(|| ParseError::Malformed("unexpected EOF in headers".into()))?;
    let line = line.trim_end_matches('\r');
    if !line.is_empty() {
        return Err(ParseError::Malformed(format!(
            "HOOK-LIST takes no headers, got {line:?}"
        )));
    }
    Ok(())
}

/// Parse the empty header block of an `AIMX/1 VERSION` request. Like
/// `MAILBOX-LIST`, the verb carries no payload and no client headers
/// influence the response, so any header line — including
/// `Content-Length:` — is rejected as malformed.
async fn parse_version_headers<R>(reader: &mut R) -> Result<(), ParseError>
where
    R: AsyncRead + Unpin,
{
    let line = read_line(reader)
        .await?
        .ok_or_else(|| ParseError::Malformed("unexpected EOF in headers".into()))?;
    let line = line.trim_end_matches('\r');
    if !line.is_empty() {
        return Err(ParseError::Malformed(format!(
            "VERSION takes no headers, got {line:?}"
        )));
    }
    Ok(())
}

async fn parse_hook_create_headers_and_body<R>(
    reader: &mut R,
    max_body: usize,
) -> Result<HookCreateRequest, ParseError>
where
    R: AsyncRead + Unpin,
{
    let mut mailbox: Option<String> = None;
    let mut event: Option<String> = None;
    let mut name: Option<String> = None;
    let mut content_length: Option<usize> = None;

    loop {
        let line = read_line(reader)
            .await?
            .ok_or_else(|| ParseError::Malformed("unexpected EOF in headers".into()))?;
        let line = line.trim_end_matches('\r');
        if line.is_empty() {
            break;
        }

        let (n, v) = line
            .split_once(':')
            .ok_or_else(|| ParseError::Malformed(format!("invalid header line: {line:?}")))?;

        if !n.is_ascii() {
            return Err(ParseError::Malformed(format!(
                "non-ascii header name: {n:?}"
            )));
        }
        let name_norm = n.trim().to_ascii_lowercase();
        let value = v.trim().to_string();

        match name_norm.as_str() {
            "mailbox" => {
                if mailbox.is_some() {
                    return Err(ParseError::Malformed("duplicate Mailbox header".into()));
                }
                if value.is_empty() {
                    return Err(ParseError::Malformed("empty Mailbox value".into()));
                }
                mailbox = Some(value);
            }
            "event" => {
                if event.is_some() {
                    return Err(ParseError::Malformed("duplicate Event header".into()));
                }
                if value.is_empty() {
                    return Err(ParseError::Malformed("empty Event value".into()));
                }
                event = Some(value);
            }
            "name" => {
                if name.is_some() {
                    return Err(ParseError::Malformed("duplicate Name header".into()));
                }
                if value.is_empty() {
                    return Err(ParseError::Malformed("empty Name value".into()));
                }
                name = Some(value);
            }
            "content-length" => {
                if content_length.is_some() {
                    return Err(ParseError::Malformed(
                        "duplicate Content-Length header".into(),
                    ));
                }
                let n: usize = value.parse().map_err(|_| {
                    ParseError::Malformed(format!("non-integer Content-Length: {value:?}"))
                })?;
                if n > max_body {
                    return Err(ParseError::Malformed(format!(
                        "Content-Length {n} exceeds cap {max_body}"
                    )));
                }
                content_length = Some(n);
            }
            _ => {}
        }
    }

    let mailbox =
        mailbox.ok_or_else(|| ParseError::Malformed("missing required header: Mailbox".into()))?;
    let event =
        event.ok_or_else(|| ParseError::Malformed("missing required header: Event".into()))?;
    let content_length = content_length
        .ok_or_else(|| ParseError::Malformed("missing required header: Content-Length".into()))?;

    let mut body = vec![0u8; content_length];
    reader.read_exact(&mut body).await.map_err(|e| {
        if e.kind() == std::io::ErrorKind::UnexpectedEof {
            ParseError::Malformed(format!("body truncated: expected {content_length} bytes"))
        } else {
            ParseError::Io(e.to_string())
        }
    })?;

    Ok(HookCreateRequest {
        mailbox,
        event,
        name,
        body,
    })
}

async fn parse_hook_delete_headers<R>(reader: &mut R) -> Result<HookDeleteRequest, ParseError>
where
    R: AsyncRead + Unpin,
{
    let mut name: Option<String> = None;
    let mut content_length: Option<usize> = None;

    loop {
        let line = read_line(reader)
            .await?
            .ok_or_else(|| ParseError::Malformed("unexpected EOF in headers".into()))?;
        let line = line.trim_end_matches('\r');
        if line.is_empty() {
            break;
        }

        let (n, v) = line
            .split_once(':')
            .ok_or_else(|| ParseError::Malformed(format!("invalid header line: {line:?}")))?;

        if !n.is_ascii() {
            return Err(ParseError::Malformed(format!(
                "non-ascii header name: {n:?}"
            )));
        }
        let header_name = n.trim().to_ascii_lowercase();
        let value = v.trim().to_string();

        match header_name.as_str() {
            "hook-name" => {
                if name.is_some() {
                    return Err(ParseError::Malformed("duplicate Hook-Name header".into()));
                }
                if value.is_empty() {
                    return Err(ParseError::Malformed("empty Hook-Name value".into()));
                }
                name = Some(value);
            }
            "content-length" => {
                if content_length.is_some() {
                    return Err(ParseError::Malformed(
                        "duplicate Content-Length header".into(),
                    ));
                }
                let n: usize = value.parse().map_err(|_| {
                    ParseError::Malformed(format!("non-integer Content-Length: {value:?}"))
                })?;
                if n != 0 {
                    return Err(ParseError::Malformed(format!(
                        "HOOK-DELETE must have Content-Length: 0, got {n}"
                    )));
                }
                content_length = Some(n);
            }
            _ => {
                // Unknown headers are ignored.
            }
        }
    }

    let name =
        name.ok_or_else(|| ParseError::Malformed("missing required header: Hook-Name".into()))?;
    let _ = content_length;

    Ok(HookDeleteRequest { name })
}

/// Read a single `\n`-terminated line from `reader`, returning it without the
/// trailing `\n`. Returns `Ok(None)` when the stream ends cleanly before any
/// byte arrives. Enforces [`MAX_HEADER_LINE`] to bound memory on garbage
/// input.
async fn read_line<R>(reader: &mut R) -> Result<Option<String>, ParseError>
where
    R: AsyncRead + Unpin,
{
    let mut buf = Vec::with_capacity(64);
    let mut byte = [0u8; 1];

    loop {
        match reader.read(&mut byte).await {
            Ok(0) => {
                if buf.is_empty() {
                    return Ok(None);
                }
                return Err(ParseError::Malformed(
                    "unexpected EOF mid-line (no terminator)".into(),
                ));
            }
            Ok(_) => {
                if byte[0] == b'\n' {
                    break;
                }
                buf.push(byte[0]);
                if buf.len() > MAX_HEADER_LINE {
                    return Err(ParseError::Malformed(format!(
                        "header line exceeds {MAX_HEADER_LINE} bytes"
                    )));
                }
            }
            Err(e) => return Err(ParseError::Io(e.to_string())),
        }
    }

    let s = String::from_utf8(buf)
        .map_err(|_| ParseError::Malformed("header line contains invalid UTF-8".into()))?;
    Ok(Some(s))
}

/// Write a `SendResponse` frame to `writer` and flush.
///
/// Wire format:
/// ```text
/// AIMX/1 OK <message-id>\n           (success)
/// AIMX/1 ERR <CODE> <reason>\n       (error, status line)
/// Code: <CODE>\n                     (structured header)
/// \n                                 (terminator)
/// ```
///
/// The legacy inline `<CODE>` on the status line is preserved so older
/// clients that only parse the first line keep working. New clients
/// read the `Code:` header off the second line for stable branching.
pub async fn write_response<W>(
    writer: &mut W,
    response: &SendResponse,
) -> Result<(), std::io::Error>
where
    W: AsyncWrite + Unpin,
{
    let payload = match response {
        SendResponse::Ok { message_id } => {
            let id = sanitize_inline(message_id);
            format!("AIMX/1 OK {id}\n")
        }
        SendResponse::Err { code, reason } => {
            let r = sanitize_inline(reason);
            format!(
                "AIMX/1 ERR {code} {r}\nCode: {code}\n\n",
                code = code.as_str()
            )
        }
    };
    writer.write_all(payload.as_bytes()).await?;
    writer.flush().await?;
    Ok(())
}

/// Write an `AckResponse` frame (bodyless OK / ERR) to `writer` and flush.
/// Used for MARK-READ / MARK-UNREAD and future bodyless verbs.
///
/// Wire format matches [`write_response`]: the error form carries both
/// the legacy inline `<CODE>` token on the status line AND an explicit
/// `Code:` header on the next line.
pub async fn write_ack_response<W>(
    writer: &mut W,
    response: &AckResponse,
) -> Result<(), std::io::Error>
where
    W: AsyncWrite + Unpin,
{
    let payload = match response {
        AckResponse::Ok => "AIMX/1 OK\n".to_string(),
        AckResponse::Err { code, reason } => {
            let r = sanitize_inline(reason);
            format!(
                "AIMX/1 ERR {code} {r}\nCode: {code}\n\n",
                code = code.as_str()
            )
        }
    };
    writer.write_all(payload.as_bytes()).await?;
    writer.flush().await?;
    Ok(())
}

/// Write a `JsonAckResponse` frame. Used by the daemon-side
/// `MAILBOX-LIST` handler so non-root callers receive a JSON body
/// listing the mailboxes they own. On error the wire shape matches
/// [`write_ack_response`].
pub async fn write_json_ack_response<W>(
    writer: &mut W,
    response: &JsonAckResponse,
) -> Result<(), std::io::Error>
where
    W: AsyncWrite + Unpin,
{
    match response {
        JsonAckResponse::Ok { body } => {
            let header = format!("AIMX/1 OK\nContent-Length: {}\n\n", body.len());
            writer.write_all(header.as_bytes()).await?;
            writer.write_all(body).await?;
        }
        JsonAckResponse::Err { code, reason } => {
            let r = sanitize_inline(reason);
            let payload = format!(
                "AIMX/1 ERR {code} {r}\nCode: {code}\n\n",
                code = code.as_str()
            );
            writer.write_all(payload.as_bytes()).await?;
        }
    }
    writer.flush().await?;
    Ok(())
}

/// Write an `AIMX/1 SEND` request frame to `writer`. Used by the client
/// (`aimx send`) and by tests that exercise `parse_request` over a paired
/// AsyncRead/AsyncWrite harness.
///
/// Emits the optional `Text-Only:` / `Html-Body-Length:` headers when
/// the corresponding fields are set, and appends the second body section
/// after the main body when `html_body` is `Some(..)`. Setting both
/// `text_only` and `html_body` at the call site produces a frame the
/// peer will reject with the canonical mutual-exclusion error; callers
/// should validate before reaching the codec.
pub async fn write_request<W>(writer: &mut W, request: &SendRequest) -> Result<(), std::io::Error>
where
    W: AsyncWrite + Unpin,
{
    let mut header = String::from("AIMX/1 SEND\n");
    if request.text_only {
        header.push_str("Text-Only: true\n");
    }
    header.push_str(&format!("Content-Length: {}\n", request.body.len()));
    if let Some(html) = &request.html_body {
        header.push_str(&format!("Html-Body-Length: {}\n", html.len()));
    }
    header.push('\n');
    writer.write_all(header.as_bytes()).await?;
    writer.write_all(&request.body).await?;
    if let Some(html) = &request.html_body {
        writer.write_all(html).await?;
    }
    writer.flush().await?;
    Ok(())
}

/// Write an `AIMX/1 MARK-READ` or `AIMX/1 MARK-UNREAD` request frame. Verb
/// chosen by `request.read` (`true` → MARK-READ, `false` → MARK-UNREAD).
pub async fn write_mark_request<W>(
    writer: &mut W,
    request: &MarkRequest,
) -> Result<(), std::io::Error>
where
    W: AsyncWrite + Unpin,
{
    let verb = if request.read {
        "MARK-READ"
    } else {
        "MARK-UNREAD"
    };
    let header = format!(
        "AIMX/1 {verb}\nMailbox: {}\nId: {}\nContent-Length: 0\n\n",
        sanitize_inline(&request.mailbox),
        sanitize_inline(&request.id),
    );
    writer.write_all(header.as_bytes()).await?;
    writer.flush().await?;
    Ok(())
}

/// Write an `AIMX/1 MAILBOX-CREATE` or `AIMX/1 MAILBOX-DELETE` request
/// frame. Verb chosen by `request.create` (`true` → MAILBOX-CREATE,
/// `false` → MAILBOX-DELETE). `Owner:` is emitted when `request.owner`
/// is `Some`; the daemon decides per-caller whether the field is
/// honored or ignored. `Force: true` is emitted only on MAILBOX-DELETE
/// when `request.force` is true so the daemon performs a server-side
/// wipe of `inbox/<name>/` and `sent/<name>/` under the per-mailbox
/// lock that already guards the stanza removal.
pub async fn write_mailbox_crud_request<W>(
    writer: &mut W,
    request: &MailboxLifecycleRequest,
) -> Result<(), std::io::Error>
where
    W: AsyncWrite + Unpin,
{
    let verb = if request.create {
        "MAILBOX-CREATE"
    } else {
        "MAILBOX-DELETE"
    };
    let mut header = format!("AIMX/1 {verb}\nName: {}\n", sanitize_inline(&request.name),);
    if let Some(owner) = &request.owner {
        header.push_str(&format!("Owner: {}\n", sanitize_inline(owner)));
    }
    if !request.create && request.force {
        header.push_str("Force: true\n");
    }
    header.push_str("Content-Length: 0\n\n");
    writer.write_all(header.as_bytes()).await?;
    writer.flush().await?;
    Ok(())
}

/// Write an `AIMX/1 MAILBOX-LIST` request frame. Bare verb line plus
/// a single blank separator — no headers, no body.
pub async fn write_mailbox_list_request<W>(writer: &mut W) -> Result<(), std::io::Error>
where
    W: AsyncWrite + Unpin,
{
    writer.write_all(b"AIMX/1 MAILBOX-LIST\n\n").await?;
    writer.flush().await?;
    Ok(())
}

/// Write an `AIMX/1 HOOK-LIST` request frame. Bare verb line plus a
/// single blank separator — no headers, no body. Mirrors
/// [`write_mailbox_list_request`] line-for-line.
pub async fn write_hook_list_request<W>(writer: &mut W) -> Result<(), std::io::Error>
where
    W: AsyncWrite + Unpin,
{
    writer.write_all(b"AIMX/1 HOOK-LIST\n\n").await?;
    writer.flush().await?;
    Ok(())
}

/// Write an `AIMX/1 VERSION` request frame. Bare verb line plus a single
/// blank separator — no headers, no body. Used by the doctor's daemon
/// version probe.
pub async fn write_version_request<W>(writer: &mut W) -> Result<(), std::io::Error>
where
    W: AsyncWrite + Unpin,
{
    writer.write_all(b"AIMX/1 VERSION\n\n").await?;
    writer.flush().await?;
    Ok(())
}

/// Write the `VERSION` response frame.
///
/// Wire format:
/// ```text
/// AIMX/1 OK\n
/// Content-Length: <n>\n
/// \n
/// <n bytes of JSON {"tag":"...","git_hash":"...","target":"...","build_date":"..."}>
/// ```
///
/// Mirrors [`write_json_ack_response`] but takes the typed
/// [`VersionResponse`] directly rather than a pre-serialised body so
/// callers cannot accidentally ship a malformed JSON shape.
pub async fn write_version_response<W>(
    writer: &mut W,
    response: &VersionResponse,
) -> Result<(), std::io::Error>
where
    W: AsyncWrite + Unpin,
{
    let body = serde_json::to_vec(response).map_err(std::io::Error::other)?;
    let header = format!("AIMX/1 OK\nContent-Length: {}\n\n", body.len());
    writer.write_all(header.as_bytes()).await?;
    writer.write_all(&body).await?;
    writer.flush().await?;
    Ok(())
}

/// Read a `VERSION` response frame off `reader`. Used by the doctor
/// probe; callers that want richer error semantics should layer their
/// own timeout on top.
pub async fn read_version_response<R>(reader: &mut R) -> Result<VersionResponse, ParseError>
where
    R: AsyncRead + Unpin,
{
    let status = read_line(reader)
        .await?
        .ok_or_else(|| ParseError::Malformed("unexpected EOF on response status".into()))?;
    let status = status.trim_end_matches('\r');
    if !status.starts_with("AIMX/1 OK") {
        return Err(ParseError::Malformed(format!(
            "expected 'AIMX/1 OK', got {status:?}"
        )));
    }

    let mut content_length: Option<usize> = None;
    loop {
        let line = read_line(reader)
            .await?
            .ok_or_else(|| ParseError::Malformed("unexpected EOF in headers".into()))?;
        let line = line.trim_end_matches('\r');
        if line.is_empty() {
            break;
        }
        let (n, v) = line
            .split_once(':')
            .ok_or_else(|| ParseError::Malformed(format!("invalid header line: {line:?}")))?;
        if n.trim().eq_ignore_ascii_case("content-length") {
            let val: usize = v.trim().parse().map_err(|_| {
                ParseError::Malformed(format!("non-integer Content-Length: {:?}", v.trim()))
            })?;
            content_length = Some(val);
        }
    }
    let n = content_length.ok_or_else(|| {
        ParseError::Malformed("missing Content-Length on VERSION response".into())
    })?;
    let mut body = vec![0u8; n];
    reader.read_exact(&mut body).await.map_err(|e| {
        if e.kind() == std::io::ErrorKind::UnexpectedEof {
            ParseError::Malformed(format!("body truncated: expected {n} bytes"))
        } else {
            ParseError::Io(e.to_string())
        }
    })?;
    let parsed: VersionResponse = serde_json::from_slice(&body)
        .map_err(|e| ParseError::Malformed(format!("invalid VERSION JSON: {e}")))?;
    Ok(parsed)
}

/// Write an `AIMX/1 HOOK-CREATE` request frame. The codec does not
/// parse the body, it simply ships the bytes the caller supplies.
#[allow(dead_code)]
pub async fn write_hook_create_request<W>(
    writer: &mut W,
    request: &HookCreateRequest,
) -> Result<(), std::io::Error>
where
    W: AsyncWrite + Unpin,
{
    let mut header = format!(
        "AIMX/1 HOOK-CREATE\nMailbox: {}\nEvent: {}\n",
        sanitize_inline(&request.mailbox),
        sanitize_inline(&request.event),
    );
    if let Some(name) = &request.name {
        header.push_str(&format!("Name: {}\n", sanitize_inline(name)));
    }
    header.push_str(&format!("Content-Length: {}\n\n", request.body.len()));
    writer.write_all(header.as_bytes()).await?;
    writer.write_all(&request.body).await?;
    writer.flush().await?;
    Ok(())
}

/// Write an `AIMX/1 HOOK-DELETE` request frame. Empty body.
#[allow(dead_code)]
pub async fn write_hook_delete_request<W>(
    writer: &mut W,
    request: &HookDeleteRequest,
) -> Result<(), std::io::Error>
where
    W: AsyncWrite + Unpin,
{
    let header = format!(
        "AIMX/1 HOOK-DELETE\nHook-Name: {}\nContent-Length: 0\n\n",
        sanitize_inline(&request.name),
    );
    writer.write_all(header.as_bytes()).await?;
    writer.flush().await?;
    Ok(())
}

/// Strip CR/LF from a single-line wire field. Callers must never emit bare
/// LF inside a reason or message-ID or the framer ambiguates the next line.
fn sanitize_inline(s: &str) -> String {
    s.chars().filter(|c| *c != '\n' && *c != '\r').collect()
}

#[cfg(test)]
mod mailbox_list_codec_tests {
    use super::*;
    use tokio::io::AsyncWriteExt;

    /// Round-trip a `MAILBOX-LIST` frame: writer emits the bare verb +
    /// blank separator, parser decodes it as `Request::MailboxList`.
    #[tokio::test]
    async fn round_trip_mailbox_list_request() {
        let mut buf: Vec<u8> = Vec::new();
        write_mailbox_list_request(&mut buf).await.unwrap();
        assert_eq!(buf, b"AIMX/1 MAILBOX-LIST\n\n");

        let mut reader = std::io::Cursor::new(buf);
        let req = parse_request(&mut reader).await.unwrap();
        assert_eq!(req, Request::MailboxList);
    }

    /// Any header on a `MAILBOX-LIST` frame is rejected. The verb
    /// resolves the caller via `SO_PEERCRED`; client-supplied owner
    /// hints would defeat that and must be a hard parse error.
    #[tokio::test]
    async fn mailbox_list_rejects_owner_header() {
        let frame = b"AIMX/1 MAILBOX-LIST\nOwner: alice\n\n";
        let mut reader = std::io::Cursor::new(frame.to_vec());
        match parse_request(&mut reader).await {
            Err(ParseError::Malformed(reason)) => {
                assert!(reason.contains("MAILBOX-LIST"), "{reason}");
            }
            other => panic!("expected Malformed, got {other:?}"),
        }
    }

    /// `Content-Length:` headers are also rejected — `MAILBOX-LIST`
    /// has no body, and silently ignoring the header would invite a
    /// future caller to send one and have it skipped.
    #[tokio::test]
    async fn mailbox_list_rejects_content_length_header() {
        let frame = b"AIMX/1 MAILBOX-LIST\nContent-Length: 0\n\n";
        let mut reader = std::io::Cursor::new(frame.to_vec());
        match parse_request(&mut reader).await {
            Err(ParseError::Malformed(_)) => {}
            other => panic!("expected Malformed, got {other:?}"),
        }
    }

    /// The JSON ack response writer round-trips: the frame carries
    /// `AIMX/1 OK`, a `Content-Length:` header, and the JSON body.
    #[tokio::test]
    async fn json_ack_response_writer_emits_content_length_and_body() {
        let body = br#"[{"name":"alice","total":1,"unread":0}]"#;
        let mut buf: Vec<u8> = Vec::new();
        write_json_ack_response(
            &mut buf,
            &JsonAckResponse::Ok {
                body: body.to_vec(),
            },
        )
        .await
        .unwrap();
        let header_end = buf.windows(2).position(|w| w == b"\n\n").unwrap();
        let header = std::str::from_utf8(&buf[..header_end]).unwrap();
        assert!(header.starts_with("AIMX/1 OK\n"));
        assert!(
            header.contains(&format!("Content-Length: {}", body.len())),
            "{header}"
        );
        assert_eq!(&buf[header_end + 2..], &body[..]);
    }

    /// Round-trip a `MARK-READ` frame: writer emits the verb plus the
    /// mailbox / id headers, parser decodes back to a `MarkRequest`.
    /// `Folder:` is no longer emitted or accepted.
    #[tokio::test]
    async fn round_trip_mark_request_omits_folder() {
        let req = MarkRequest {
            mailbox: "alice".into(),
            id: "2025-06-01-001".into(),
            read: true,
        };
        let mut buf: Vec<u8> = Vec::new();
        write_mark_request(&mut buf, &req).await.unwrap();
        let text = std::str::from_utf8(&buf).unwrap();
        assert!(text.starts_with("AIMX/1 MARK-READ\n"), "{text}");
        assert!(!text.to_ascii_lowercase().contains("folder:"), "{text}");

        let mut reader = std::io::Cursor::new(buf);
        let parsed = parse_request(&mut reader).await.unwrap();
        assert_eq!(parsed, Request::Mark(req));
    }

    /// A `Folder:` header on MARK is a hard parse error — silently
    /// dropping it would let a stale client believe it had selected the
    /// sent folder.
    #[tokio::test]
    async fn mark_rejects_folder_header() {
        let frame = b"AIMX/1 MARK-READ\nMailbox: alice\nId: 2025-06-01-001\nFolder: sent\nContent-Length: 0\n\n";
        let mut reader = std::io::Cursor::new(frame.to_vec());
        match parse_request(&mut reader).await {
            Err(ParseError::Malformed(reason)) => {
                assert!(reason.contains("Folder"), "{reason}");
            }
            other => panic!("expected Malformed, got {other:?}"),
        }
    }

    /// Round-trip a `HOOK-LIST` frame: writer emits the bare verb +
    /// blank separator, parser decodes it as `Request::HookList`.
    #[tokio::test]
    async fn round_trip_hook_list_request() {
        let mut buf: Vec<u8> = Vec::new();
        write_hook_list_request(&mut buf).await.unwrap();
        assert_eq!(buf, b"AIMX/1 HOOK-LIST\n\n");

        let mut reader = std::io::Cursor::new(buf);
        let req = parse_request(&mut reader).await.unwrap();
        assert_eq!(req, Request::HookList);
    }

    /// Any header on a `HOOK-LIST` frame is rejected. The verb resolves
    /// the caller via `SO_PEERCRED`; client-supplied owner / mailbox
    /// hints would defeat that and must be a hard parse error.
    #[tokio::test]
    async fn hook_list_rejects_owner_header() {
        let frame = b"AIMX/1 HOOK-LIST\nOwner: alice\n\n";
        let mut reader = std::io::Cursor::new(frame.to_vec());
        match parse_request(&mut reader).await {
            Err(ParseError::Malformed(reason)) => {
                assert!(reason.contains("HOOK-LIST"), "{reason}");
            }
            other => panic!("expected Malformed, got {other:?}"),
        }
    }

    /// `Content-Length:` headers are also rejected — `HOOK-LIST` has
    /// no body, and silently ignoring the header would invite a future
    /// caller to send one and have it skipped.
    #[tokio::test]
    async fn hook_list_rejects_content_length_header() {
        let frame = b"AIMX/1 HOOK-LIST\nContent-Length: 0\n\n";
        let mut reader = std::io::Cursor::new(frame.to_vec());
        match parse_request(&mut reader).await {
            Err(ParseError::Malformed(_)) => {}
            other => panic!("expected Malformed, got {other:?}"),
        }
    }

    /// Round-trip a `VERSION` request: writer emits a bare verb line
    /// plus a blank separator, parser decodes it as `Request::Version`.
    #[tokio::test]
    async fn round_trip_version_request() {
        let mut buf: Vec<u8> = Vec::new();
        write_version_request(&mut buf).await.unwrap();
        assert_eq!(buf, b"AIMX/1 VERSION\n\n");

        let mut reader = std::io::Cursor::new(buf);
        let req = parse_request(&mut reader).await.unwrap();
        assert_eq!(req, Request::Version);
    }

    /// Any header on a `VERSION` request — including `Content-Length:`
    /// — is rejected. The verb is bodyless and silently ignoring a
    /// header would invite a future client to ship one and have it
    /// dropped on the floor.
    #[tokio::test]
    async fn version_rejects_content_length_header() {
        let frame = b"AIMX/1 VERSION\nContent-Length: 0\n\n";
        let mut reader = std::io::Cursor::new(frame.to_vec());
        match parse_request(&mut reader).await {
            Err(ParseError::Malformed(reason)) => {
                assert!(reason.contains("VERSION"), "{reason}");
            }
            other => panic!("expected Malformed, got {other:?}"),
        }
    }

    /// Round-trip a `VERSION` response through the writer + reader so
    /// every field reaches the client unmodified.
    #[tokio::test]
    async fn round_trip_version_response() {
        let resp = VersionResponse {
            tag: "v1.2.3".to_string(),
            git_hash: "abcdef12".to_string(),
            target: "x86_64-unknown-linux-gnu".to_string(),
            build_date: "2026-04-28".to_string(),
        };
        let mut buf: Vec<u8> = Vec::new();
        write_version_response(&mut buf, &resp).await.unwrap();
        let header_end = buf.windows(2).position(|w| w == b"\n\n").unwrap();
        let header = std::str::from_utf8(&buf[..header_end]).unwrap();
        assert!(header.starts_with("AIMX/1 OK\n"), "{header}");
        assert!(header.contains("Content-Length: "), "{header}");

        let mut reader = std::io::Cursor::new(buf);
        let parsed = read_version_response(&mut reader).await.unwrap();
        assert_eq!(parsed, resp);
    }

    /// Error variant of the JSON ack writer mirrors the bodyless ack
    /// shape exactly so clients can share one parser.
    #[tokio::test]
    async fn json_ack_response_writer_error_form_matches_bodyless_ack() {
        let mut json_buf: Vec<u8> = Vec::new();
        write_json_ack_response(
            &mut json_buf,
            &JsonAckResponse::Err {
                code: ErrCode::Eaccess,
                reason: "denied".into(),
            },
        )
        .await
        .unwrap();
        let mut bare_buf: Vec<u8> = Vec::new();
        write_ack_response(
            &mut bare_buf,
            &AckResponse::Err {
                code: ErrCode::Eaccess,
                reason: "denied".into(),
            },
        )
        .await
        .unwrap();
        bare_buf.flush().await.unwrap();
        assert_eq!(json_buf, bare_buf);
    }
}

#[cfg(test)]
mod send_frame_codec_tests {
    //! Round-trip tests for the `AIMX/1 SEND` codec, with a focus on the
    //! `Text-Only` and `Html-Body-Length` headers.

    use super::*;

    /// Default frame: no new headers — the encoder emits today's wire
    /// shape and the decoder reproduces a `SendRequest` with both new
    /// fields off.
    #[tokio::test]
    async fn round_trip_default_send_frame() {
        let req = SendRequest {
            body: b"From: alice@example.com\r\n\r\nHello.\r\n".to_vec(),
            text_only: false,
            html_body: None,
        };
        let mut buf: Vec<u8> = Vec::new();
        write_request(&mut buf, &req).await.unwrap();
        let text = std::str::from_utf8(&buf).unwrap();
        assert!(text.starts_with("AIMX/1 SEND\n"), "{text}");
        assert!(!text.contains("Text-Only"));
        assert!(!text.contains("Html-Body-Length"));

        let mut reader = std::io::Cursor::new(buf);
        let parsed = parse_request(&mut reader).await.unwrap();
        assert_eq!(parsed, Request::Send(req));
    }

    /// `Text-Only: true` frames carry no HTML section. Round-trip must
    /// preserve both fields and the body bytes byte-for-byte.
    #[tokio::test]
    async fn round_trip_text_only_send_frame() {
        let req = SendRequest {
            body: b"From: alice@example.com\r\n\r\nYour code: 9999\r\n".to_vec(),
            text_only: true,
            html_body: None,
        };
        let mut buf: Vec<u8> = Vec::new();
        write_request(&mut buf, &req).await.unwrap();
        let text = std::str::from_utf8(&buf).unwrap();
        assert!(text.contains("Text-Only: true"), "{text}");
        assert!(!text.contains("Html-Body-Length"), "{text}");

        let mut reader = std::io::Cursor::new(buf);
        let parsed = parse_request(&mut reader).await.unwrap();
        assert_eq!(parsed, Request::Send(req));
    }

    /// `Html-Body-Length: N` frames carry the second body section
    /// verbatim. The encoder writes the header and the bytes; the
    /// decoder reads them back into `html_body`.
    #[tokio::test]
    async fn round_trip_html_body_send_frame() {
        let html = b"<p>custom <b>html</b></p>";
        let req = SendRequest {
            body: b"From: alice@example.com\r\n\r\nfallback text.\r\n".to_vec(),
            text_only: false,
            html_body: Some(html.to_vec()),
        };
        let mut buf: Vec<u8> = Vec::new();
        write_request(&mut buf, &req).await.unwrap();
        let text = std::str::from_utf8(&buf).unwrap();
        assert!(
            text.contains(&format!("Html-Body-Length: {}", html.len())),
            "{text}"
        );

        let mut reader = std::io::Cursor::new(buf);
        let parsed = parse_request(&mut reader).await.unwrap();
        assert_eq!(parsed, Request::Send(req));
    }

    /// A frame setting both `Text-Only: true` and `Html-Body-Length:` is
    /// rejected at the codec layer with the canonical mutual-exclusion
    /// error so the same wording reaches the operator regardless of
    /// which transport surfaced the conflict.
    #[tokio::test]
    async fn mutually_exclusive_headers_rejected_with_canonical_message() {
        let frame =
            b"AIMX/1 SEND\nText-Only: true\nContent-Length: 5\nHtml-Body-Length: 3\n\nhellohi!";
        let mut reader = std::io::Cursor::new(frame.to_vec());
        match parse_request(&mut reader).await {
            Err(ParseError::Malformed(reason)) => {
                assert_eq!(
                    reason, "AIMX/1 SEND: --text-only and --html-body are mutually exclusive",
                    "canonical mutual-exclusion message must reach the wire reason verbatim"
                );
            }
            other => panic!("expected Malformed mutual-exclusion error, got {other:?}"),
        }
    }

    /// A frame whose `Html-Body-Length: N` exceeds the bytes that
    /// follow surfaces a length-mismatch error so partial sends never
    /// silently truncate.
    #[tokio::test]
    async fn html_body_length_mismatch_is_malformed() {
        // Main body 5 bytes ("hello") then we promise 100 HTML bytes
        // but only ship 3 ("hi!"). The decoder must surface a body
        // truncation error.
        let frame = b"AIMX/1 SEND\nContent-Length: 5\nHtml-Body-Length: 100\n\nhellohi!";
        let mut reader = std::io::Cursor::new(frame.to_vec());
        match parse_request(&mut reader).await {
            Err(ParseError::Malformed(reason)) => {
                assert!(
                    reason.contains("html body truncated") || reason.contains("expected 100"),
                    "expected html-body length-mismatch error, got {reason:?}"
                );
            }
            other => panic!("expected Malformed length-mismatch, got {other:?}"),
        }
    }

    /// `Text-Only:` accepts only `true` / `false`; arbitrary tokens are
    /// rejected so a future client that emits `yes` / `1` doesn't sneak
    /// through with the wrong shape.
    #[tokio::test]
    async fn text_only_invalid_value_rejected() {
        let frame = b"AIMX/1 SEND\nText-Only: yes\nContent-Length: 5\n\nhello";
        let mut reader = std::io::Cursor::new(frame.to_vec());
        match parse_request(&mut reader).await {
            Err(ParseError::Malformed(reason)) => {
                assert!(reason.contains("Text-Only"), "{reason}");
            }
            other => panic!("expected Malformed, got {other:?}"),
        }
    }

    /// Duplicate `Text-Only:` headers are rejected — silently dropping
    /// one would let a future caller smuggle conflicting state past the
    /// mutual-exclusion check.
    #[tokio::test]
    async fn duplicate_text_only_header_rejected() {
        let frame = b"AIMX/1 SEND\nText-Only: true\nText-Only: false\nContent-Length: 5\n\nhello";
        let mut reader = std::io::Cursor::new(frame.to_vec());
        match parse_request(&mut reader).await {
            Err(ParseError::Malformed(reason)) => {
                assert!(reason.contains("duplicate Text-Only"), "{reason}");
            }
            other => panic!("expected Malformed, got {other:?}"),
        }
    }

    /// Duplicate `Html-Body-Length:` headers are rejected on the same
    /// principle as the Text-Only duplicate-header guard: silently
    /// dropping one would let a future caller smuggle conflicting
    /// state (a different second-body length) past the codec. Mirrors
    /// `duplicate_text_only_header_rejected` so a regression on
    /// either duplicate check surfaces independently.
    #[tokio::test]
    async fn duplicate_html_body_length_header_rejected() {
        let frame =
            b"AIMX/1 SEND\nHtml-Body-Length: 5\nHtml-Body-Length: 6\nContent-Length: 5\n\nhello";
        let mut reader = std::io::Cursor::new(frame.to_vec());
        match parse_request(&mut reader).await {
            Err(ParseError::Malformed(reason)) => {
                assert!(reason.contains("duplicate Html-Body-Length"), "{reason}");
            }
            other => panic!("expected Malformed, got {other:?}"),
        }
    }
}
