// Copyright (c) 2025-2026 Defense Unicorns, Inc.
// SPDX-License-Identifier: Apache-2.0

//! Universal Document codec — adapter between [`crate::sync::types::Document`]
//! and the peat-lite [`MessageType::Document`] envelope.
//!
//! This module is the **transport-agnostic** codec. Each radio
//! (peat-lite UDP, peat-btle BLE, future LoRa) frames the bytes
//! produced here however its medium requires (chunking, scheduling,
//! ack/retry); no transport hard-codes a per-collection schema.
//! Adding a new collection on the network requires zero changes here
//! or downstream — only the publisher and consumer need to agree on
//! the body schema.
//!
//! ## Layering
//!
//! Body schema for `Document.fields` is **JSON-encoded
//! `HashMap<String, serde_json::Value>`** on the host side. peat-lite
//! itself stays `no_std`-clean by treating the body as opaque bytes
//! (per `peat_lite::protocol::document`); the JSON step lives here
//! in peat-mesh where `std` is already required.
//!
//! ### Why JSON, not postcard
//!
//! postcard is `no_std`-friendly and compact, which would have been
//! ideal for LoRa-class consumers. But postcard is **not
//! self-describing**, and `serde_json::Value` is a dynamic enum
//! whose `Deserialize` impl drives the deserializer via
//! `deserialize_any` — a method postcard does not implement
//! (returns `WontImplement`). Round-tripping a `Value` through
//! postcard fails at decode time.
//!
//! The fix paths considered:
//!
//! 1. Use a self-describing binary codec (CBOR / MessagePack). Adds
//!    a transitive dep, marginally smaller than JSON.
//! 2. Replace `Value` with a peat-specific tagged enum. Loses the
//!    `Document.fields: HashMap<String, Value>` ergonomics every
//!    consumer already depends on.
//! 3. Serialize `Value` to JSON bytes for the body. Slightly larger
//!    on the wire, but `serde_json` is already a top-level workspace
//!    dep, the doc-store roots in JSON-ish semantics, and it
//!    round-trips Value cleanly.
//!
//! Path 3 is the pragmatic choice. If LoRa pressure later demands a
//! tighter encoding, the body codec can move to a typed-per-collection
//! shape without touching the envelope, the transports, or the
//! consumers — that's the architectural payoff of the universal
//! envelope design.
//!
//! ### Wire format (above peat-lite's Document envelope)
//!
//! The peat-lite envelope carries `body: opaque bytes`. This module
//! defines the body as:
//!
//! ```text
//! body = serde_json::to_vec(&Document.fields)
//! ```
//!
//! No additional version/marker bytes — versioning lives at the
//! peat-lite header (the protocol version field). When the body
//! schema needs to change incompatibly, bump the peat-lite protocol
//! version; old receivers reject via the existing
//! `MessageError::UnsupportedVersion` path.

use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};

use peat_lite::protocol::document as pl_doc;
use peat_lite::protocol::error::MessageError;
use serde_json::Value;

use crate::sync::types::Document;

/// Errors produced by the universal Document codec.
///
/// Distinct from `peat_lite::MessageError` (which covers wire-frame
/// errors only) — this enum also surfaces postcard / serde failures
/// from the body layer.
#[derive(Debug, thiserror::Error)]
pub enum DocumentCodecError {
    /// peat-lite envelope encode/decode rejected the input. See
    /// [`MessageError`] for the shape.
    #[error("peat-lite envelope: {0:?}")]
    Envelope(MessageError),

    /// JSON failed to (de)serialize the body. Indicates a schema
    /// mismatch between sender and receiver — at minimum a
    /// peat-mesh major-version skew, or a non-conforming sender.
    #[error("body json: {0}")]
    Body(#[from] serde_json::Error),
}

impl From<MessageError> for DocumentCodecError {
    fn from(e: MessageError) -> Self {
        Self::Envelope(e)
    }
}

/// Encode a [`Document`] into the bytes that go after a peat-lite
/// header in a [`MessageType::Document`] frame.
///
/// `flags` is forwarded verbatim onto the wire — set
/// [`pl_doc::DOC_FLAG_TOMBSTONE`] for deletion sentinels (body bytes
/// will still be emitted; receivers MUST ignore the body when the
/// tombstone bit is set per the envelope spec).
///
/// The returned `Vec<u8>` is just the **payload** — callers append
/// it to a 16-byte peat-lite header constructed via
/// [`peat_lite::protocol::header::encode_header`] with
/// `MessageType::Document`.
pub fn encode_document(
    flags: u8,
    collection: &str,
    document: &Document,
) -> Result<Vec<u8>, DocumentCodecError> {
    // Tombstone short-circuit: per peat-lite envelope spec
    // (`DOC_FLAG_TOMBSTONE` rejects bodies at the encoder), the body
    // MUST be empty when the tombstone bit is set. Callers may still
    // pass a Document with `fields` populated (the doc-store snapshot
    // they're deleting); honor the spec by dropping the body here so
    // the wire contract holds without forcing every caller to clear
    // fields before publishing a deletion.
    let body_bytes: Vec<u8> = if flags & pl_doc::DOC_FLAG_TOMBSTONE != 0 {
        Vec::new()
    } else {
        // Body: JSON-serialize the fields HashMap. The Document.id
        // and Document.updated_at fields are carried by the envelope, not
        // the body — this avoids the "two sources of truth" bug class
        // (peat#835 round-trip) where envelope and body could diverge on
        // the same logical key.
        serde_json::to_vec(&document.fields)?
    };

    let doc_id = document.id.as_deref().unwrap_or("");
    let timestamp_ms = system_time_to_millis(document.updated_at);

    // Preflight buffer-size — `encoded_len` is exact, so we allocate
    // precisely and avoid the resize cost. `Option<usize>` return
    // signals overflow on 32-bit `usize` targets; surface as
    // `FieldTooLarge` so the caller sees a single error path
    // (not an `unwrap` panic) on pathological field sizes.
    let needed = pl_doc::encoded_len(collection.len(), doc_id.len(), body_bytes.len())
        .ok_or(MessageError::FieldTooLarge)?;
    let mut buf = vec![0u8; needed];
    let written = pl_doc::encode(
        flags,
        collection,
        doc_id,
        timestamp_ms,
        &body_bytes,
        &mut buf,
    )?;
    debug_assert_eq!(written, needed);
    Ok(buf)
}

/// Decode a [`Document`] from the payload that followed a peat-lite
/// `MessageType::Document` header. Returns the fully-realised
/// `Document` plus the original collection name (the envelope owns
/// it, the `Document` type does not).
///
/// The caller is responsible for passing only the payload region —
/// not the 16-byte peat-lite header itself.
pub fn decode_document(payload: &[u8]) -> Result<(String, Document), DocumentCodecError> {
    let view = pl_doc::decode(payload)?;
    let collection = view.collection.to_string();

    // Tombstone-flagged frames carry no body content (per envelope
    // spec); surface as a Document carrying the peat-mesh soft-delete
    // sentinel `_deleted: true` so the existing ADR-034 / Issue #369
    // convention is honored end-to-end. Without this synthesis, a
    // network-received deletion would arrive as "Document with empty
    // fields" — indistinguishable on the consumer side from a
    // legitimately empty document, lossy heuristics required.
    // Round-2 QA review flagged this as a [WARNING].
    let fields: HashMap<String, Value> = if view.is_tombstone() {
        let mut deletion = HashMap::new();
        deletion.insert("_deleted".to_string(), Value::Bool(true));
        deletion
    } else if view.body.is_empty() {
        HashMap::new()
    } else {
        serde_json::from_slice(view.body)?
    };

    let id = if view.doc_id.is_empty() {
        None
    } else {
        Some(view.doc_id.to_string())
    };
    let updated_at = millis_to_system_time(view.timestamp_ms);

    Ok((
        collection,
        Document {
            id,
            fields,
            updated_at,
        },
    ))
}

fn system_time_to_millis(t: SystemTime) -> i64 {
    // Pre-epoch SystemTime values produce Err — clamp to 0 ("1970
    // stale-marker") so the codec stays infallible. Saturating-cast
    // to i64 keeps far-future values from wrapping into negatives.
    // Clock-skewed devices that send pre-1970 timestamps will
    // silently disagree on which doc is newer under LWW; the warn
    // surfaces the misbehaving clock instead of letting the symptom
    // appear as an "epoch document" further downstream.
    match t.duration_since(UNIX_EPOCH) {
        Ok(d) => i64::try_from(d.as_millis()).unwrap_or(i64::MAX),
        Err(_) => {
            log::warn!(
                "document_codec: SystemTime predates UNIX_EPOCH; clamping to 0. \
                 Likely a clock-skewed peer — CRDT LWW comparisons will treat \
                 this document as the oldest possible."
            );
            0
        }
    }
}

fn millis_to_system_time(ms: i64) -> SystemTime {
    if ms <= 0 {
        UNIX_EPOCH
    } else {
        UNIX_EPOCH + std::time::Duration::from_millis(ms as u64)
    }
}

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

    fn doc_with_fields(id: Option<&str>, fields: HashMap<String, Value>) -> Document {
        Document {
            id: id.map(str::to_string),
            fields,
            updated_at: SystemTime::UNIX_EPOCH
                + std::time::Duration::from_millis(1_700_000_000_000),
        }
    }

    /// Round-trip a Document with mixed field types through
    /// envelope+body encode → decode and verify nothing drifts.
    /// Mirrors the peat#835 bug-class lock pattern at the codec
    /// layer: any future schema change that breaks this test
    /// fails CI before on-device UAT does.
    #[test]
    fn roundtrip_document_preserves_fields() {
        let mut fields = HashMap::new();
        fields.insert("lat".to_string(), json!(33.71576));
        fields.insert("lon".to_string(), json!(-84.41152));
        fields.insert("callsign".to_string(), json!("THREE GEESE"));
        fields.insert("battery_percent".to_string(), json!(85));
        fields.insert("alerts".to_string(), json!(["man_down"]));

        let original = doc_with_fields(Some("ANDROID-abc"), fields.clone());
        let bytes = encode_document(0, "platforms", &original).expect("encode");
        let (collection, decoded) = decode_document(&bytes).expect("decode");

        assert_eq!(collection, "platforms");
        assert_eq!(decoded.id.as_deref(), Some("ANDROID-abc"));
        assert_eq!(decoded.fields, fields);
        assert_eq!(decoded.updated_at, original.updated_at);
    }

    /// `id: None` (publisher delegates id assignment) round-trips
    /// without being conflated with malformed input.
    #[test]
    fn roundtrip_document_with_no_id() {
        let original = doc_with_fields(None, HashMap::new());
        let bytes = encode_document(0, "markers", &original).expect("encode");
        let (_, decoded) = decode_document(&bytes).expect("decode");
        assert_eq!(decoded.id, None);
    }

    /// Tombstone flag is preserved on the wire and the decoded
    /// Document carries `_deleted: true` so the peat-mesh soft-delete
    /// convention (ADR-034 / Issue #369) is honored end-to-end.
    /// Round-2 QA review on the codec flagged that an empty-fields
    /// signal is indistinguishable from a legitimately empty
    /// document — synthesizing the sentinel field closes that gap.
    #[test]
    fn roundtrip_tombstone() {
        let mut fields = HashMap::new();
        fields.insert("ignored".to_string(), json!("body"));
        let original = doc_with_fields(Some("ble-21444484"), fields);

        let bytes =
            encode_document(pl_doc::DOC_FLAG_TOMBSTONE, "tracks", &original).expect("encode");
        let view = pl_doc::decode(&bytes).expect("envelope decode");
        assert!(view.is_tombstone(), "tombstone flag should ride the wire");

        let (collection, decoded) = decode_document(&bytes).expect("decode");
        assert_eq!(collection, "tracks");
        assert_eq!(
            decoded.fields.get("_deleted"),
            Some(&Value::Bool(true)),
            "tombstone-decoded Document MUST carry `_deleted: true` for ADR-034 routing"
        );
        // No other fields — the deletion sentinel is the entire body.
        assert_eq!(
            decoded.fields.len(),
            1,
            "deletion should carry only the sentinel"
        );
    }

    /// Body schema mismatch surfaces as `DocumentCodecError::Body`
    /// rather than as a misparse — caller can log the JSON error for
    /// diagnostics without confusing it with envelope-level
    /// truncation.
    #[test]
    fn body_schema_mismatch_surfaces_distinct_error() {
        // Hand-build an envelope with a body that's not a valid
        // JSON HashMap encoding.
        let mut buf = vec![0u8; 256];
        let body = b"this is not json {{{";
        let n = pl_doc::encode(0, "platforms", "id-1", 7, body, &mut buf).expect("encode");

        match decode_document(&buf[..n]) {
            Err(DocumentCodecError::Body(_)) => {} // expected
            other => panic!("expected Body error, got {:?}", other),
        }
    }

    /// Nested JSON values (objects, arrays, mixed) preserve through
    /// the serde_json body encoder. Locks against a future refactor
    /// that swaps to a flatter codec dropping nested-object support.
    #[test]
    fn nested_field_values_preserve() {
        let mut fields = HashMap::new();
        fields.insert(
            "attributes".to_string(),
            json!({
                "speed_mps": 1.2,
                "tags": ["alpha", "bravo"],
                "nested": { "deep": [1, 2, 3] }
            }),
        );

        let original = doc_with_fields(Some("complex-1"), fields.clone());
        let bytes = encode_document(0, "platforms", &original).expect("encode");
        let (_, decoded) = decode_document(&bytes).expect("decode");
        assert_eq!(decoded.fields, fields);
    }

    /// Envelope length limit enforced at encode time. A body that
    /// drives the total over `MAX_BODY_LEN` (65535) returns the
    /// envelope's `FieldTooLarge`, not a serde_json truncation.
    #[test]
    fn oversized_body_surfaces_envelope_error() {
        let mut fields = HashMap::new();
        // 70 KB byte-blob. JSON-encoded as `[0,0,0,…]` it expands to
        // ~140 KB of digits + commas, well past the 2-byte-LE
        // body_len cap (`MAX_BODY_LEN = 65535`).
        fields.insert("blob".to_string(), json!(vec![0u8; 70 * 1024]));
        let original = doc_with_fields(Some("huge"), fields);

        match encode_document(0, "platforms", &original) {
            Err(DocumentCodecError::Envelope(MessageError::FieldTooLarge)) => {} // expected
            other => panic!("expected FieldTooLarge, got {:?}", other),
        }
    }
}
