//! # Tap wire protocol (spec §3 — versioned JSON event framing)
//!
//! One JSON object per WS text frame. PCM payloads are base64-encoded
//! **little-endian i16** bytes (spec §3, §9 — explicit LE, not host-endian).
//! Every envelope carries `v: 1` + `type` + per-direction `seq` + advisory `ts`.
//!
//! ## Why JSON + base64 over binary length-prefixed framing
//!
//! ARCHITECTURE.md names WSS as presumptive transport because the consumer
//! is a Python script / an OpenAI-Realtime-style API for which event-
//! framed WSS is the de-facto protocol. A JSON event envelope maps onto
//! that ecosystem directly — a hand-rolled Python brain uses `json.loads`;
//! the step-3 OpenAI adapter translates our events to OpenAI's event
//! schema. A binary length-prefixed framing would force every brain to
//! implement a byte-parser. ~33% wire overhead (65 KB/s at 24 kHz mono
//! i16) is negligible at slice-2's scale; brain-authoring ergonomics
//! dominate. A future-rung `v: 2` may negotiate a binary mode (spec §9).
//!
//! ## v2 reservations (documented 2026-07-04; implemented at the version bump)
//!
//! The 2026-07-04 scalability review (finding M4) identified the missing
//! reconnect semantics as the most calcifying gap in the system — wire
//! protocols are the hardest surface to retrofit. v1 stays frozen; the
//! v2 negotiation MUST carry:
//!
//! - **`hello.resume_token`** — an opaque token the brain returned on a
//!   prior accept for this session, plus a **connection epoch** counter.
//!   Lets a brain fleet distinguish "resume this conversation" (state
//!   held / recoverable) from "unknown session" (context lost).
//! - **`hello_ack.resume: accepted | rejected`** — the brain's explicit
//!   answer. A rejected resume tells the core the brain is amnesiac, so
//!   the core (not the caller's ears) decides: re-prime, escalate, or end.
//! - **`bye.reason: terminal | transient`** — today every close funnels
//!   into infinite re-dial (5s cap, forever); a brain that deliberately
//!   ends a session is re-dialed for the rest of the call. `terminal`
//!   exits the retry loop.
//!
//! Anything in this list changes BOTH sides of the wire — which is why it
//! is reserved here, in the protocol's own doc, rather than in a review
//! doc nobody re-reads at implementation time.

use rutster_media::PcmFrame;
use serde::ser::{SerializeStruct, Serializer};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Wire protocol version. Slice-2 ships v1 (spec §3.1, §3.4).
pub const PROTOCOL_VERSION: u8 = 1;

/// Samples per 20 ms frame @ 24 kHz mono. Re-declared locally for wire-
/// format validation; MUST match `rutster_media::pcm::SAMPLES_PER_FRAME`.
/// (Re-declaring rather than `use`-ing the constant keeps the wire-format
/// spec pinned in this crate — a future bump in `rutster-media` doesn't
/// silently change the wire format without an explicit bump here.)
pub const SAMPLES_PER_FRAME: usize = 480;

/// Frame kind — wire `type` field. Serde renames to the snake_case wire
/// names (spec §3.2, §3.3).
///
/// # Why an enum (not `String`)
/// Exhaustiveness: when a new variant is added, every `match` site is
/// flagged by the compiler. A `String` would let new frame kinds slip
/// in silently. Unknown wire values deserialize to `FrameKind::Unknown`
/// (see `serde(other)`) — the receiver drops them per spec §3.4.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FrameKind {
    Hello,
    AudioIn,
    AudioOut,
    SessionEnd,
    Bye,
    Error,
    /// brain → core: user speech started (advisory; translated from OpenAI
    /// `input_audio_buffer.speech_started`; spec §3.2).
    SpeechStarted,
    /// brain → core: user speech stopped (advisory; OpenAI
    /// `input_audio_buffer.speech_stopped`; spec §3.2).
    SpeechStopped,
    /// brain → core: brain proposes a tool call (translated from OpenAI
    /// `response.function_call_arguments.done`; spec §3.2).
    FunctionCall,
    /// core → brain: tool registry reply (spec §3.3).
    FunctionCallOutput,
    /// brain → core: brain declares its tool catalog on hello + on changes
    /// (spec §3.2).
    #[serde(rename = "tools.update")]
    ToolsUpdate,
    /// Unknown wire `type` values land here (spec §3.4: log + count + drop).
    /// `#[serde(other)]` catches any string not in the variants above.
    #[serde(other)]
    Unknown,
}

/// On-wire envelope (spec §3.1).
///
/// # Serde strategy — manual `Serialize`, no derived `Deserialize`
///
/// The wire contract is ONE flat JSON object: payload fields (`session_id`,
/// `pcm`, `samples`, `reason`, `code`, `message`, ...) are *siblings* of
/// `v`, `type`, `seq`, `ts` — not nested under a `payload` key (spec §3.1).
///
/// The obvious serde approach — `#[derive(Serialize, Deserialize)]` on
/// `Envelope` with `#[serde(flatten)] payload: Payload` where `Payload` is
/// `#[serde(tag = "type")]` — **does not work**: both the envelope's
/// `#[serde(rename = "type")] kind` field and the flattened payload's tag
/// claim the same JSON `type` key, producing `duplicate field "type"` on
/// serialize and `missing field "type"` for unknown wire values on
/// deserialize (Payload has no `Unknown` variant to absorb them).
///
/// The fix applied here (recommended in the task brief): keep `Envelope` as
/// the encoding-side type with a manual `Serialize` that writes payload
/// fields flat as siblings of `v`/`type`/`seq`/`ts`. Deserialization goes
/// through a separate `WireEnvelope` intermediate (private) that flattens
/// leftover fields into a `serde_json::Map` and dispatches on `kind` to
/// build the specific payload struct. This matches the wire spec exactly
/// and lets `FrameKind::Unknown` (via `#[serde(other)]`) flow through to
/// `DecodedPayload::Unknown` — which the brief's tagged-enum approach
/// cannot do.
#[derive(Debug, Clone)]
pub struct Envelope {
    pub v: u8,
    pub kind: FrameKind,
    pub seq: u64,
    pub ts: u64,
    pub payload: Payload,
}

/// Flatten envelope + payload into ONE JSON object (spec §3.1's flat
/// shape). This is the wire byte contract: payload fields as siblings of
/// `v`/`type`/`seq`/`ts`, never nested under a `payload` key.
///
/// The derived `#[derive(Serialize)]` + `#[serde(flatten)]` approach
/// failed (see the type-level doc above for the duplicate-`type` diagnosis).
/// Writing `Serialize` manually is the minimal-invasive fix: it controls
/// the wire shape directly and avoids fighting serde's flatten-over-
/// tagged-enum machinery.
///
/// Field order is pinned (`v`, `type`, `seq`, `ts`, then payload fields)
/// to make the wire bytes deterministic — JSON object key order isn't
/// semantically meaningful but pinning it helps snapshot tests and
/// interop with brains that read key order for debugging.
impl Serialize for Envelope {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let payload_field_count = match &self.payload {
            Payload::Hello(_) => 4, // session_id, sample_rate, channels, frame_ms
            Payload::AudioIn(_) | Payload::AudioOut(_) => 2, // pcm, samples
            Payload::SessionEnd(_) | Payload::Bye(_) => 1, // reason
            Payload::Error(_) => 2, // code, message
            // Slice-3 adds: empty payload (0 fields), 3-field payloads, 1-field payload.
            Payload::SpeechStarted | Payload::SpeechStopped => 0,
            Payload::FunctionCall(_) => 3,       // id, name, args
            Payload::FunctionCallOutput(_) => 3, // id, status, result
            Payload::ToolsUpdate(_) => 1,        // tools
        };
        let mut st = serializer.serialize_struct("Envelope", 4 + payload_field_count)?;
        st.serialize_field("v", &self.v)?;
        // `FrameKind`'s own serde derive turns `AudioIn` → "audio_in" etc.
        // via its `#[serde(rename_all = "snake_case")]`. The wire key is
        // "type" (spec §3.1's name); Rust's field name `kind` doesn't
        // appear on the wire at all.
        st.serialize_field("type", &self.kind)?;
        st.serialize_field("seq", &self.seq)?;
        st.serialize_field("ts", &self.ts)?;
        match &self.payload {
            Payload::Hello(p) => {
                st.serialize_field("session_id", &p.session_id)?;
                st.serialize_field("sample_rate", &p.sample_rate)?;
                st.serialize_field("channels", &p.channels)?;
                st.serialize_field("frame_ms", &p.frame_ms)?;
            }
            Payload::AudioIn(p) | Payload::AudioOut(p) => {
                st.serialize_field("pcm", &p.pcm)?;
                st.serialize_field("samples", &p.samples)?;
            }
            Payload::SessionEnd(p) => {
                st.serialize_field("reason", &p.reason)?;
            }
            Payload::Bye(p) => {
                st.serialize_field("reason", &p.reason)?;
            }
            Payload::Error(p) => {
                st.serialize_field("code", &p.code)?;
                st.serialize_field("message", &p.message)?;
            }
            Payload::SpeechStarted | Payload::SpeechStopped => {
                // No payload fields — the envelope's `v`/`type`/`seq`/`ts`
                // is the whole message. The event name IS the message.
            }
            Payload::FunctionCall(p) => {
                st.serialize_field("id", &p.id)?;
                st.serialize_field("name", &p.name)?;
                st.serialize_field("args", &p.args)?;
            }
            Payload::FunctionCallOutput(p) => {
                st.serialize_field("id", &p.id)?;
                st.serialize_field("status", &p.status)?;
                st.serialize_field("result", &p.result)?;
            }
            Payload::ToolsUpdate(p) => {
                st.serialize_field("tools", &p.tools)?;
            }
        }
        st.end()
    }
}

/// Payload — internal-only data carrier; never directly serialized.
/// `Envelope`'s manual `Serialize` flattens payload fields into the
/// envelope. `decode_envelope` builds payloads from a flat
/// `serde_json::Map` (see `WireEnvelope` in `decode_envelope`).
///
/// `AudioIn` and `AudioOut` share the same `AudioPayload` shape because
/// the wire format does (spec §3.2, §3.3); direction is carried by the
/// envelope's `type` field, not by a payload tag. The decode side
/// surfaces direction via [`DecodedPayload`] (separate `AudioIn`/
/// `AudioOut` variants) so callers `match` exhaustively on direction.
#[derive(Debug, Clone)]
pub enum Payload {
    Hello(HelloPayload),
    AudioIn(AudioPayload),
    AudioOut(AudioPayload),
    SessionEnd(SessionEndPayload),
    Bye(ReasonPayload),
    Error(ErrorPayload),
    /// Slice-3 additive (spec §3.2): brain → core, advisory; empty payload
    /// (the event name IS the message).
    SpeechStarted,
    SpeechStopped,
    /// Slice-3 additive (spec §3.2).
    FunctionCall(FunctionCallPayload),
    /// Slice-3 additive (spec §3.3): core → brain reply.
    FunctionCallOutput(FunctionCallOutputPayload),
    /// Slice-3 additive (spec §3.2): brain → core catalog declaration.
    ToolsUpdate(ToolsUpdatePayload),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelloPayload {
    pub session_id: String,
    #[serde(default = "default_sample_rate")]
    pub sample_rate: u32,
    #[serde(default = "default_channels")]
    pub channels: String,
    #[serde(default = "default_frame_ms")]
    pub frame_ms: u32,
}

fn default_sample_rate() -> u32 {
    24000
}
fn default_channels() -> String {
    "mono".to_string()
}
fn default_frame_ms() -> u32 {
    20
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioPayload {
    /// base64-encoded LE i16 bytes (spec §3, §9).
    pub pcm: String,
    pub samples: usize,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionEndPayload {
    pub reason: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReasonPayload {
    pub reason: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorPayload {
    pub code: String,
    pub message: String,
}

/// `function_call` payload (brain → core; spec §3.2). Carries the
/// brain-minted id + tool name + args. Args is a raw JSON Value (not a
/// typed struct) so any tool schema is allowed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCallPayload {
    pub id: String,
    pub name: String,
    /// Raw JSON arguments — the tool registry dispatches by name and lets
    /// each Tool impl parse the args itself. (OpenAI sends `arguments` as a
    /// JSON string; the translator parses it back to a Value before
    /// emitting the function_call tap frame.)
    pub args: serde_json::Value,
}

/// `function_call_output` payload (core → brain; spec §3.3). The reply for
/// a `function_call`. `status` is one of `"ok"`, `"error"`, `"not_implemented"`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCallOutputPayload {
    pub id: String,
    pub status: String,
    pub result: serde_json::Value,
}

/// `tools.update` payload (brain → core; spec §3.2). The brain declares its
/// tool catalog so the core's tool registry can validate function_call
/// events by name.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolsUpdatePayload {
    /// An array of tool descriptors (each has `name`, `description`,
    /// `parameters`). The shape is intentionally permissive (a JSON array,
    /// not a typed Vec<ToolSchema>) so the brain can declare schemas the
    /// core doesn't know about — the core only checks the `name` field for
    /// dispatch, ignores the rest.
    pub tools: serde_json::Value,
}

/// Decoded frame — what `decode_envelope` returns. The kind is split into
/// `audio_in` / `audio_out` variants (not just `Audio(AudioPayload)`) so
/// callers `match` exhaustively on direction (spec §3.2 vs §3.3 — the two
/// carry the same payload shape but mean opposite directions).
#[derive(Debug, Clone)]
pub struct DecodedFrame {
    pub v: u8,
    pub seq: u64,
    pub ts: u64,
    pub payload: DecodedPayload,
}

#[derive(Debug, Clone)]
pub enum DecodedPayload {
    Hello(HelloPayload),
    AudioIn(AudioPayload),
    AudioOut(AudioPayload),
    SessionEnd(SessionEndPayload),
    Bye(ReasonPayload),
    Error(ErrorPayload),
    /// Slice-3 (spec §3.2): the brain detected user speech started/stopped.
    SpeechStarted,
    SpeechStopped,
    /// Slice-3 (spec §3.2): brain wants the core to execute a tool.
    FunctionCall(FunctionCallPayload),
    /// Slice-3 (spec §3.3): the core's tool-registry reply.
    FunctionCallOutput(FunctionCallOutputPayload),
    /// Slice-3 (spec §3.2): brain declares its catalog so the core can
    /// validate function_call events.
    ToolsUpdate(ToolsUpdatePayload),
    /// Unknown `type` — log + count + drop (spec §3.4 of slice-2).
    Unknown,
}

#[derive(Debug, Error)]
pub enum TapProtoError {
    #[error("JSON (de)serialization failed: {0}")]
    Json(#[from] serde_json::Error),
    #[error("base64 decode failed: {0}")]
    Base64(#[from] base64::DecodeError),
    #[error("protocol version mismatch: got {got}, expected {expected}")]
    Version { got: u8, expected: u8 },
    #[error("sample count mismatch: got {got}, expected {expected}")]
    SampleCount { got: usize, expected: usize },
    #[error("decoded PCM length {got} is not a whole number of i16 samples (expected even)")]
    OddPcmLength { got: usize },
}

/// Encode a `PcmFrame` → base64 LE i16 bytes (spec §3, §9 — explicit LE).
pub fn encode_pcm(frame: &PcmFrame) -> String {
    use base64::Engine as _;
    // `i16::to_le_bytes()` — explicit little-endian, NOT host-endian.
    // The wire contract is LE-only in v1; a big-endian brain would need
    // v2 endianness negotiation (spec §9). On x86_64/aarch64 this is a
    // no-op at the hardware level but the explicit call site documents
    // the wire contract for the reader.
    let bytes: Vec<u8> = frame.samples.iter().flat_map(|s| s.to_le_bytes()).collect();
    base64::engine::general_purpose::STANDARD.encode(&bytes)
}

/// Decode base64 LE i16 bytes → `PcmFrame`. Validates `samples` count.
pub fn decode_pcm(pcm_b64: &str, expected_samples: usize) -> Result<PcmFrame, TapProtoError> {
    use base64::Engine as _;
    let bytes = base64::engine::general_purpose::STANDARD.decode(pcm_b64)?;
    if bytes.len() % 2 != 0 {
        return Err(TapProtoError::OddPcmLength { got: bytes.len() });
    }
    let got_samples = bytes.len() / 2;
    if got_samples != expected_samples {
        return Err(TapProtoError::SampleCount {
            got: got_samples,
            expected: expected_samples,
        });
    }
    let mut samples = [0i16; SAMPLES_PER_FRAME];
    for (i, chunk) in bytes.chunks_exact(2).enumerate() {
        // `i16::from_le_bytes` — explicit LE decode, matches `encode_pcm`.
        samples[i] = i16::from_le_bytes([chunk[0], chunk[1]]);
    }
    Ok(PcmFrame { samples })
}

/// Build an outgoing `audio_in` frame string (core → brain, spec §3.2).
pub fn encode_audio_in(frame: &PcmFrame, seq: u64, ts: u64) -> Result<String, TapProtoError> {
    let env = Envelope {
        v: PROTOCOL_VERSION,
        kind: FrameKind::AudioIn,
        seq,
        ts,
        payload: Payload::AudioIn(AudioPayload {
            pcm: encode_pcm(frame),
            samples: SAMPLES_PER_FRAME,
        }),
    };
    Ok(serde_json::to_string(&env)?)
}

/// Build an outgoing `audio_out` frame string (brain → core, spec §3.3).
pub fn encode_audio_out(frame: &PcmFrame, seq: u64, ts: u64) -> Result<String, TapProtoError> {
    let env = Envelope {
        v: PROTOCOL_VERSION,
        kind: FrameKind::AudioOut,
        seq,
        ts,
        payload: Payload::AudioOut(AudioPayload {
            pcm: encode_pcm(frame),
            samples: SAMPLES_PER_FRAME,
        }),
    };
    Ok(serde_json::to_string(&env)?)
}

pub fn encode_hello(session_id: &str, seq: u64, ts: u64) -> Result<String, TapProtoError> {
    let env = Envelope {
        v: PROTOCOL_VERSION,
        kind: FrameKind::Hello,
        seq,
        ts,
        payload: Payload::Hello(HelloPayload {
            session_id: session_id.to_string(),
            sample_rate: 24000,
            channels: "mono".to_string(),
            frame_ms: 20,
        }),
    };
    Ok(serde_json::to_string(&env)?)
}

pub fn encode_session_end(reason: &str, seq: u64, ts: u64) -> Result<String, TapProtoError> {
    let env = Envelope {
        v: PROTOCOL_VERSION,
        kind: FrameKind::SessionEnd,
        seq,
        ts,
        payload: Payload::SessionEnd(SessionEndPayload {
            reason: reason.to_string(),
        }),
    };
    Ok(serde_json::to_string(&env)?)
}

pub fn encode_bye(reason: &str, seq: u64, ts: u64) -> Result<String, TapProtoError> {
    let env = Envelope {
        v: PROTOCOL_VERSION,
        kind: FrameKind::Bye,
        seq,
        ts,
        payload: Payload::Bye(ReasonPayload {
            reason: reason.to_string(),
        }),
    };
    Ok(serde_json::to_string(&env)?)
}

pub fn encode_error(code: &str, msg: &str, seq: u64, ts: u64) -> Result<String, TapProtoError> {
    let env = Envelope {
        v: PROTOCOL_VERSION,
        kind: FrameKind::Error,
        seq,
        ts,
        payload: Payload::Error(ErrorPayload {
            code: code.to_string(),
            message: msg.to_string(),
        }),
    };
    Ok(serde_json::to_string(&env)?)
}

/// Build `speech_started` (brain → core, advisory; spec §3.2).
pub fn encode_speech_started(seq: u64, ts: u64) -> Result<String, TapProtoError> {
    let env = Envelope {
        v: PROTOCOL_VERSION,
        kind: FrameKind::SpeechStarted,
        seq,
        ts,
        payload: Payload::SpeechStarted,
    };
    Ok(serde_json::to_string(&env)?)
}

/// Build `speech_stopped` (brain → core, advisory; spec §3.2).
pub fn encode_speech_stopped(seq: u64, ts: u64) -> Result<String, TapProtoError> {
    let env = Envelope {
        v: PROTOCOL_VERSION,
        kind: FrameKind::SpeechStopped,
        seq,
        ts,
        payload: Payload::SpeechStopped,
    };
    Ok(serde_json::to_string(&env)?)
}

/// Build `function_call` (brain → core; spec §3.2). `args_json_str` is the
/// raw JSON string the brain's translator parses from OpenAI's
/// `response.function_call_arguments.done.arguments` (which is itself a
/// JSON string in OpenAI's wire format).
pub fn encode_function_call(
    id: &str,
    name: &str,
    args_json_str: &str,
    seq: u64,
    ts: u64,
) -> Result<String, TapProtoError> {
    let args: serde_json::Value = if args_json_str.is_empty() {
        serde_json::Value::Null
    } else {
        serde_json::from_str(args_json_str)?
    };
    let env = Envelope {
        v: PROTOCOL_VERSION,
        kind: FrameKind::FunctionCall,
        seq,
        ts,
        payload: Payload::FunctionCall(FunctionCallPayload {
            id: id.to_string(),
            name: name.to_string(),
            args,
        }),
    };
    Ok(serde_json::to_string(&env)?)
}

/// Build `function_call_output` (core → brain; spec §3.3). `result_json_str`
/// is the raw JSON string the tool-registry dispatch returns (serialized
/// into the result field of the payload).
pub fn encode_function_call_output(
    id: &str,
    status: &str, // "ok" | "error" | "not_implemented"
    result_json_str: &str,
    seq: u64,
    ts: u64,
) -> Result<String, TapProtoError> {
    let result: serde_json::Value = if result_json_str.is_empty() {
        serde_json::Value::Null
    } else {
        serde_json::from_str(result_json_str)?
    };
    let env = Envelope {
        v: PROTOCOL_VERSION,
        kind: FrameKind::FunctionCallOutput,
        seq,
        ts,
        payload: Payload::FunctionCallOutput(FunctionCallOutputPayload {
            id: id.to_string(),
            status: status.to_string(),
            result,
        }),
    };
    Ok(serde_json::to_string(&env)?)
}

/// Build `tools.update` (brain → core; spec §3.2). `tools_json_str` is the
/// raw JSON array of tool descriptors.
pub fn encode_tools_update(
    tools_json_str: &str,
    seq: u64,
    ts: u64,
) -> Result<String, TapProtoError> {
    let tools: serde_json::Value = if tools_json_str.is_empty() {
        serde_json::Value::Array(vec![])
    } else {
        serde_json::from_str(tools_json_str)?
    };
    let env = Envelope {
        v: PROTOCOL_VERSION,
        kind: FrameKind::ToolsUpdate,
        seq,
        ts,
        payload: Payload::ToolsUpdate(ToolsUpdatePayload { tools }),
    };
    Ok(serde_json::to_string(&env)?)
}

/// Decode an incoming wire string into a `DecodedFrame`. Validates `v` and
/// (for audio frames) `samples` and PCM round-trip. Unknown `type` values
/// decode to `DecodedPayload::Unknown` (caller logs + counts + drops per
/// spec §3.4).
///
/// # Deserialize strategy — `WireEnvelope` + dispatch on `kind`
///
/// We can't deserialize straight into `Envelope` because `Envelope` has no
/// derived `Deserialize` (see its type-level doc). Instead we deserialize
/// into a private `WireEnvelope` whose `#[serde(flatten)] extra:
/// Map<String, Value>` hoovers up every field not named `v`/`type`/`seq`/
/// `ts` — that's the payload's fields, exactly the flat wire shape spec §3.1
/// mandates. Then we dispatch on `kind` (recovered via `FrameKind`'s
/// `#[serde(other)] Unknown` fallback) and deserialize the `extra` map
/// into the specific payload struct.
///
/// This is the cleanest way to (a) keep the flat wire shape, (b) distinguish
/// `audio_in` from `audio_out` (they share `AudioPayload` shape — only
/// `kind` tells them apart), and (c) route unknown `type` strings to
/// `DecodedPayload::Unknown` rather than erroring (the brief's tagged-enum
/// approach can't do (c)).
pub fn decode_envelope(s: &str) -> Result<DecodedFrame, TapProtoError> {
    let w: WireEnvelope = serde_json::from_str(s)?;
    if w.v != PROTOCOL_VERSION {
        return Err(TapProtoError::Version {
            got: w.v,
            expected: PROTOCOL_VERSION,
        });
    }
    let extra_value = serde_json::Value::Object(w.extra);
    let payload = match w.kind {
        FrameKind::Hello => {
            let p: HelloPayload = serde_json::from_value(extra_value)?;
            DecodedPayload::Hello(p)
        }
        FrameKind::AudioIn => {
            let p: AudioPayload = serde_json::from_value(extra_value)?;
            if p.samples != SAMPLES_PER_FRAME {
                return Err(TapProtoError::SampleCount {
                    got: p.samples,
                    expected: SAMPLES_PER_FRAME,
                });
            }
            // Validate the PCM decodes (drops malformations early; hot-path
            // policy is "drop + observe" — caller logs + counts, doesn't crash).
            let _ = decode_pcm(&p.pcm, p.samples)?;
            DecodedPayload::AudioIn(p)
        }
        FrameKind::AudioOut => {
            let p: AudioPayload = serde_json::from_value(extra_value)?;
            if p.samples != SAMPLES_PER_FRAME {
                return Err(TapProtoError::SampleCount {
                    got: p.samples,
                    expected: SAMPLES_PER_FRAME,
                });
            }
            let _ = decode_pcm(&p.pcm, p.samples)?;
            DecodedPayload::AudioOut(p)
        }
        FrameKind::SessionEnd => {
            let p: SessionEndPayload = serde_json::from_value(extra_value)?;
            DecodedPayload::SessionEnd(p)
        }
        FrameKind::Bye => {
            let p: ReasonPayload = serde_json::from_value(extra_value)?;
            DecodedPayload::Bye(p)
        }
        FrameKind::Error => {
            let p: ErrorPayload = serde_json::from_value(extra_value)?;
            DecodedPayload::Error(p)
        }
        FrameKind::SpeechStarted => DecodedPayload::SpeechStarted,
        FrameKind::SpeechStopped => DecodedPayload::SpeechStopped,
        FrameKind::FunctionCall => {
            let p: FunctionCallPayload = serde_json::from_value(extra_value)?;
            DecodedPayload::FunctionCall(p)
        }
        FrameKind::FunctionCallOutput => {
            let p: FunctionCallOutputPayload = serde_json::from_value(extra_value)?;
            DecodedPayload::FunctionCallOutput(p)
        }
        FrameKind::ToolsUpdate => {
            let p: ToolsUpdatePayload = serde_json::from_value(extra_value)?;
            DecodedPayload::ToolsUpdate(p)
        }
        FrameKind::Unknown => DecodedPayload::Unknown,
    };
    Ok(DecodedFrame {
        v: w.v,
        seq: w.seq,
        ts: w.ts,
        payload,
    })
}

/// Private intermediate for `decode_envelope`: parses the four named
/// envelope fields and flattens every other JSON key (the payload fields)
/// into `extra`. The `extra` map is then dispatched on `kind` to build
/// the specific payload struct (see `decode_envelope`).
///
/// `seq` and `ts` carry `#[serde(default)]` because spec §3.1 marks `ts`
/// as advisory and a malformed peer might omit `seq`; we'd rather decode-
/// and-observe than reject. (No slice-2 test exercises this, but it
/// matches the "drop + observe" posture of spec §3.8.)
#[derive(Deserialize)]
struct WireEnvelope {
    v: u8,
    #[serde(rename = "type")]
    kind: FrameKind,
    #[serde(default)]
    seq: u64,
    #[serde(default)]
    ts: u64,
    #[serde(flatten)]
    extra: serde_json::Map<String, serde_json::Value>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn audio_in_round_trips() {
        let mut frame = PcmFrame::zeroed();
        frame.samples[0] = 1234;
        frame.samples[479] = -5678;
        let wire = encode_audio_in(&frame, 7, 42).unwrap();
        let decoded = decode_envelope(&wire).unwrap();
        assert_eq!(decoded.v, 1);
        assert_eq!(decoded.seq, 7);
        assert_eq!(decoded.ts, 42);
        match decoded.payload {
            DecodedPayload::AudioIn(p) => {
                assert_eq!(p.samples, 480);
                let decoded_frame = decode_pcm(&p.pcm, p.samples).unwrap();
                assert_eq!(decoded_frame.samples[0], 1234);
                assert_eq!(decoded_frame.samples[479], -5678);
            }
            other => panic!("expected AudioIn, got {:?}", other),
        }
    }

    #[test]
    fn audio_out_round_trips() {
        let frame = PcmFrame::zeroed();
        let wire = encode_audio_out(&frame, 0, 0).unwrap();
        let decoded = decode_envelope(&wire).unwrap();
        assert!(matches!(decoded.payload, DecodedPayload::AudioOut(_)));
    }

    #[test]
    fn hello_round_trips() {
        let wire = encode_hello("550e8400-e29b-41d4-a716-446655440000", 0, 0).unwrap();
        let decoded = decode_envelope(&wire).unwrap();
        match decoded.payload {
            DecodedPayload::Hello(p) => {
                assert_eq!(p.session_id, "550e8400-e29b-41d4-a716-446655440000");
                assert_eq!(p.sample_rate, 24000);
                assert_eq!(p.channels, "mono");
                assert_eq!(p.frame_ms, 20);
            }
            other => panic!("expected Hello, got {:?}", other),
        }
    }

    #[test]
    fn session_end_round_trips() {
        let wire = encode_session_end("hangup", 3, 100).unwrap();
        let decoded = decode_envelope(&wire).unwrap();
        match decoded.payload {
            DecodedPayload::SessionEnd(p) => assert_eq!(p.reason, "hangup"),
            other => panic!("expected SessionEnd, got {:?}", other),
        }
    }

    #[test]
    fn bye_round_trips() {
        let wire = encode_bye("core_shutdown", 5, 200).unwrap();
        let decoded = decode_envelope(&wire).unwrap();
        assert!(matches!(decoded.payload, DecodedPayload::Bye(_)));
    }

    #[test]
    fn error_round_trips() {
        let wire = encode_error("bad_samples", "expected 480", 1, 50).unwrap();
        let decoded = decode_envelope(&wire).unwrap();
        match decoded.payload {
            DecodedPayload::Error(p) => {
                assert_eq!(p.code, "bad_samples");
                assert_eq!(p.message, "expected 480");
            }
            other => panic!("expected Error, got {:?}", other),
        }
    }

    #[test]
    fn pcm_is_explicit_little_endian() {
        let mut frame = PcmFrame::zeroed();
        frame.samples[0] = 0x0102; // 258 — LE bytes are [0x02, 0x01]
        let s = encode_pcm(&frame);
        use base64::Engine as _;
        let bytes = base64::engine::general_purpose::STANDARD
            .decode(&s)
            .unwrap();
        // First two bytes are samples[0] in LE: [0x02, 0x01].
        assert_eq!(bytes[0], 0x02);
        assert_eq!(bytes[1], 0x01);
    }

    #[test]
    fn sample_count_mismatch_returns_error() {
        let wire = r#"{"v":1,"type":"audio_in","seq":0,"ts":0,"pcm":"AAAA","samples":100}"#;
        let err = decode_envelope(wire).unwrap_err();
        assert!(matches!(
            err,
            TapProtoError::SampleCount {
                got: 100,
                expected: 480
            }
        ));
    }

    #[test]
    fn unknown_type_decodes_to_unknown_variant() {
        let wire = r#"{"v":1,"type":"future_event","seq":0,"ts":0}"#;
        let decoded = decode_envelope(wire).unwrap();
        assert!(matches!(decoded.payload, DecodedPayload::Unknown));
    }

    #[test]
    fn version_mismatch_returns_error() {
        let wire = r#"{"v":99,"type":"hello","seq":0,"ts":0,"session_id":"x"}"#;
        let err = decode_envelope(wire).unwrap_err();
        assert!(matches!(
            err,
            TapProtoError::Version {
                got: 99,
                expected: 1
            }
        ));
    }

    /// Slice-3 additive event types must round-trip through (de)serialization
    /// without breaking slice-2's v1 contract. Every new kind + payload.
    #[test]
    fn speech_started_round_trips() {
        let s = encode_speech_started(7, 100).unwrap();
        assert!(s.contains("\"type\":\"speech_started\""));
        assert!(s.contains("\"v\":1"));
        let d = decode_envelope(&s).unwrap();
        assert_eq!(d.seq, 7);
        assert_eq!(d.ts, 100);
        assert!(matches!(d.payload, DecodedPayload::SpeechStarted));
    }

    #[test]
    fn speech_stopped_round_trips() {
        let s = encode_speech_stopped(9, 200).unwrap();
        assert!(s.contains("\"type\":\"speech_stopped\""));
        let d = decode_envelope(&s).unwrap();
        assert_eq!(d.seq, 9);
        assert!(matches!(d.payload, DecodedPayload::SpeechStopped));
    }

    #[test]
    fn function_call_round_trips() {
        let s = encode_function_call("abc-123", "hangup", "{}", 0, 0).unwrap();
        assert!(s.contains("\"type\":\"function_call\""));
        assert!(s.contains("\"id\":\"abc-123\""));
        assert!(s.contains("\"name\":\"hangup\""));
        let d = decode_envelope(&s).unwrap();
        match d.payload {
            DecodedPayload::FunctionCall(p) => {
                assert_eq!(p.id, "abc-123");
                assert_eq!(p.name, "hangup");
            }
            other => panic!("expected FunctionCall, got {other:?}"),
        }
    }

    #[test]
    fn function_call_output_round_trips() {
        let s =
            encode_function_call_output("abc-123", "ok", r#"{"channel_state":"Closing"}"#, 0, 0)
                .unwrap();
        assert!(s.contains("\"type\":\"function_call_output\""));
        assert!(s.contains("\"status\":\"ok\""));
        let d = decode_envelope(&s).unwrap();
        match d.payload {
            DecodedPayload::FunctionCallOutput(p) => {
                assert_eq!(p.id, "abc-123");
                assert_eq!(p.status, "ok");
            }
            other => panic!("expected FunctionCallOutput, got {other:?}"),
        }
    }

    #[test]
    fn tools_update_round_trips() {
        let tools_json = r#"[{"name":"hangup","description":"hang up the call"}]"#;
        let s = encode_tools_update(tools_json, 0, 0).unwrap();
        assert!(s.contains("\"type\":\"tools.update\""));
        assert!(s.contains("\"tools\":["));
        let d = decode_envelope(&s).unwrap();
        match d.payload {
            DecodedPayload::ToolsUpdate(p) => {
                assert!(p.tools.is_array());
                assert_eq!(p.tools.as_array().unwrap().len(), 1);
            }
            other => panic!("expected ToolsUpdate, got {other:?}"),
        }
    }

    /// Forwards-compat: slice-2's echo brain sees the new types as unknown
    /// (the `#[serde(other)]` on the old enum absorbed them). With the new
    /// enum in place, the kinds decode to their new variants (rather than
    /// Unknown). This test asserts the decode no longer drops them.
    #[test]
    fn new_kinds_decode_to_their_variants_not_unknown() {
        for s in [
            encode_speech_started(0, 0).unwrap(),
            encode_speech_stopped(0, 0).unwrap(),
            encode_function_call("x", "x", "null", 0, 0).unwrap(),
            encode_function_call_output("x", "ok", "null", 0, 0).unwrap(),
            encode_tools_update("[]", 0, 0).unwrap(),
        ] {
            let d = decode_envelope(&s).unwrap();
            assert!(
                !matches!(d.payload, DecodedPayload::Unknown),
                "new event type decoded as Unknown: {s}"
            );
        }
    }
}
