//! Uniform JSON output envelope for all CLI and batch commands.
//!
//! Every JSON-emitting command wraps its payload in:
//! ```json
//! {"data": <payload>, "error": null, "version": 1, "_meta": {}}
//! ```
//! On failure:
//! ```json
//! {"data": null, "error": {"code": "...", "message": "..."}, "version": 1, "_meta": {}}
//! ```
//!
//! Agents parse one shape across all commands instead of per-command logic.
//! Bump [`JSON_OUTPUT_VERSION`] on any breaking schema change to the inner
//! `data` payloads (the envelope itself stays stable).
//!
//! The default lean wire shape drops `error: null` / `version` on the
//! success path and skips `_meta` when it carries no non-default fields.
//! Security-relevant signals are never suppressed: leaf serializers emit
//! `trust_level` whenever it is non-default and `injection_flags` whenever
//! non-empty (see `store::helpers::types`). `CQS_OUTPUT_FORMAT=v1` restores
//! the full `{data, error, version, _meta}` envelope for consumers that
//! want the wrapped shape.
//!
//! ## Surfaces
//!
//! - **CLI**: handlers call [`emit_json`] (pretty-printed) instead of
//!   `println!("{}", serde_json::to_string_pretty(&out)?)`.
//! - **Batch / daemon socket**: [`crate::cli::batch::write_json_line`] wraps
//!   each handler's `serde_json::Value` via [`wrap_value`] / [`wrap_error`]
//!   before serializing one JSONL record.
//! - **Daemon socket transport**: the outer `{status, output}` framing in
//!   `watch.rs` is orthogonal — `output` carries this envelope as a string.

use anyhow::Result;
use serde::Serialize;

/// Wire-format version. Bump on any breaking change to the `data` payload
/// shapes for any command. The envelope structure itself (data/error/version
/// keys) is stable across versions.
pub const JSON_OUTPUT_VERSION: u32 = 1;

/// Re-export of the lib-level [`cqs::output_format::EnvelopeShape`] type so
/// bin-level callers can write `cli::json_envelope::EnvelopeShape` for
/// ergonomic locality with the envelope helpers below.
pub use cqs::output_format::EnvelopeShape;

/// Meta block surfaced as `_meta` on every envelope.
///
/// **`worktree_stale` / `worktree_name`:** when the calling process
/// is reading from main's `.cqs/` because its `find_project_root()`
/// resolved to a git worktree without its own index,
/// [`EnvelopeMeta::current`] sets `worktree_stale = true` and copies the
/// worktree's directory name into `worktree_name`. The serde-skip
/// attributes keep both fields absent on the non-worktree happy path so
/// the wire shape only grows for affected responses — and [`Self::is_empty`]
/// then reports the block as empty so the `_meta` key is dropped entirely.
#[derive(Debug, Serialize)]
pub struct EnvelopeMeta {
    /// True when this process is serving from the main project's
    /// index because its CWD resolved to a git worktree without its
    /// own `.cqs/`. Consuming agents should fall back to
    /// reading absolute worktree paths for any chunk they intend to
    /// edit — the served snapshot reflects main's branch state.
    #[serde(skip_serializing_if = "cqs::serde_helpers::is_false")]
    pub worktree_stale: bool,
    /// Worktree directory name when `worktree_stale = true`, else
    /// omitted. Lets agents distinguish two worktrees of the same
    /// repo without re-deriving from CWD.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub worktree_name: Option<String>,
    /// Per-query worktree-search-overlay outcome (result-trust §3). Skip-when-
    /// default: present only when the current search requested an overlay.
    /// `{"files": N, "chunks": M}` when the overlay merged into results, or a
    /// `"skipped-*"` reason string when it was eligible but skipped. Read once
    /// from the thread-local set by the search path, so it is per-query rather
    /// than per-process (the daemon serves many searches per process).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub worktree_overlay: Option<serde_json::Value>,
}

impl EnvelopeMeta {
    /// Build the canonical meta block reflecting current process
    /// worktree state. CLI commands set the worktree state once
    /// during `find_project_root` → `resolve_index_dir`, so all
    /// envelope emission within the same process sees the same
    /// `worktree_stale` value.
    pub fn current() -> Self {
        Self {
            worktree_stale: cqs::worktree::is_worktree_stale(),
            worktree_name: cqs::worktree::current_worktree_name(),
            // Per-query, take-on-read: the search path sets the thread-local
            // before emission, and a single `current()` per envelope consumes
            // it. Absent ⇒ no overlay requested ⇒ key omitted.
            worktree_overlay: cqs::worktree_overlay::take_overlay_meta().map(|m| m.to_json()),
        }
    }

    /// `true` when every field is at its default (not a stale worktree,
    /// no worktree name, no overlay outcome). Drives "skip `_meta` when empty"
    /// emission in the slim envelope shape.
    pub fn is_empty(&self) -> bool {
        !self.worktree_stale && self.worktree_name.is_none() && self.worktree_overlay.is_none()
    }
}

impl Default for EnvelopeMeta {
    fn default() -> Self {
        Self::current()
    }
}

/// Canonical error-code taxonomy. Single source of truth for the wire-format
/// strings emitted in [`JsonError::code`]. `as_str()` is `const fn` so the
/// `error_codes::FOO` `&'static str` constants below can re-export these
/// without duplicating the string literals — adding a new code requires
/// adding a variant here first.
///
/// `#[non_exhaustive]` lets us add new codes without requiring downstream
/// matchers to handle every variant; consumers should always treat unknown
/// codes as an internal error.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCode {
    /// Requested entity does not exist (function name, file path, etc.).
    NotFound,
    /// User-supplied input was malformed or out of range.
    InvalidInput,
    /// Failed to parse a command, query, or input file.
    ParseError,
    /// Filesystem, network, or socket I/O failure.
    IoError,
    /// Catch-all for unexpected internal errors. Carries the anyhow chain
    /// in `message` so the root cause stays visible.
    Internal,
    /// Operation exceeded its time budget. Used by `cqs status --wait`
    /// timeout and any other time-bounded operation that times out before
    /// producing a result.
    Timeout,
}

impl ErrorCode {
    /// Render the variant as the wire-format string. `const fn` so the
    /// `error_codes::FOO` constants can delegate without runtime cost.
    pub const fn as_str(&self) -> &'static str {
        match self {
            ErrorCode::NotFound => "not_found",
            ErrorCode::InvalidInput => "invalid_input",
            ErrorCode::ParseError => "parse_error",
            ErrorCode::IoError => "io_error",
            ErrorCode::Internal => "internal",
            ErrorCode::Timeout => "timeout",
        }
    }
}

impl From<ErrorCode> for &'static str {
    fn from(code: ErrorCode) -> Self {
        code.as_str()
    }
}

/// A handler validation error whose message is SAFE to surface to a client
/// verbatim. [`redact_error`] downcasts to this and emits its `message`
/// unchanged with the carried [`ErrorCode`], instead of collapsing it to an
/// opaque chain-id like every other unknown error class.
///
/// Use this only for messages that carry no path, query text, or DB internals —
/// the redaction default exists precisely because most error text is unsafe.
/// The motivating case is a slot-mismatch rejection (`cqs_index` asked to
/// reindex a slot the daemon does not serve): the served-slot NAME must reach
/// the caller for the rejection to be actionable, and a slot name is safe to
/// disclose.
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
pub struct ClientFacingError {
    /// The wire-format error code surfaced alongside the message.
    pub code: ErrorCode,
    /// The verbatim, redaction-exempt client-facing message.
    pub message: String,
}

impl ClientFacingError {
    /// Construct a client-facing validation error with the given code.
    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
        Self {
            code,
            message: message.into(),
        }
    }
}

/// Standard error code taxonomy as `&'static str` constants. Each constant
/// delegates to [`ErrorCode::as_str`] so the wire-format strings live in
/// exactly one place.
///
/// All six codes reach clients. `not_found` is emitted by production
/// handlers (e.g. `cli::commands::infra::reference`), `io_error` by
/// [`redact_error`]'s `std::io::Error` downcast on the daemon batch path,
/// and `internal` / `invalid_input` / `parse_error` / `timeout` by their
/// respective call sites. Keeping them exposed as constants lets the
/// `tracing::warn!(code = ...)` calls and downstream matchers stay grounded
/// against the same taxonomy.
#[allow(dead_code)]
pub mod error_codes {
    use super::ErrorCode;

    /// Requested entity does not exist (function name, file path, etc.).
    pub const NOT_FOUND: &str = ErrorCode::NotFound.as_str();
    /// User-supplied input was malformed or out of range.
    pub const INVALID_INPUT: &str = ErrorCode::InvalidInput.as_str();
    /// Failed to parse a command, query, or input file.
    pub const PARSE_ERROR: &str = ErrorCode::ParseError.as_str();
    /// Filesystem, network, or socket I/O failure.
    pub const IO_ERROR: &str = ErrorCode::IoError.as_str();
    /// Catch-all for unexpected internal errors. Carries the anyhow chain
    /// in `message` so the root cause stays visible.
    pub const INTERNAL: &str = ErrorCode::Internal.as_str();
    /// Operation exceeded its time budget. Used by `cqs status --wait`
    /// timeout and any other time-bounded operation that times out before
    /// producing a result.
    pub const TIMEOUT: &str = ErrorCode::Timeout.as_str();
}

/// Standard envelope for all JSON-emitting commands.
#[derive(Debug, Serialize)]
pub struct Envelope<T: Serialize> {
    pub data: Option<T>,
    pub error: Option<JsonError>,
    pub version: u32,
    /// Constant advisory block surfaced on every envelope. Named `_meta` on
    /// the wire to signal "envelope metadata, not part of data" to consumers.
    #[serde(rename = "_meta")]
    pub meta: EnvelopeMeta,
}

/// Structured error reported in the `error` field of an [`Envelope`].
#[derive(Debug, Serialize)]
pub struct JsonError {
    pub code: String,
    pub message: String,
}

impl<T: Serialize> Envelope<T> {
    /// Build a success envelope wrapping `data`.
    pub fn ok(data: T) -> Self {
        Self {
            data: Some(data),
            error: None,
            version: JSON_OUTPUT_VERSION,
            meta: EnvelopeMeta::current(),
        }
    }
}

impl Envelope<serde_json::Value> {
    /// Build an error envelope. `Envelope<serde_json::Value>` is the canonical
    /// type for errors so the caller doesn't need to name a phantom data type.
    /// Used by [`wrap_error`] and [`emit_json_error`] for the JSON failure-
    /// path contract.
    pub fn err(code: &str, message: impl Into<String>) -> Self {
        Self {
            data: None,
            error: Some(JsonError {
                code: code.to_string(),
                message: message.into(),
            }),
            version: JSON_OUTPUT_VERSION,
            meta: EnvelopeMeta::current(),
        }
    }
}

/// Wrap a raw [`serde_json::Value`] payload in the standard envelope.
/// Used by batch and pipeline paths that already work with untyped values.
///
/// Takes `&serde_json::Value` so the daemon hot path (one wrap per
/// dispatched query) does not deep-clone the inner Map/Vec. The envelope
/// construction itself still allocates the outer `{data, error, version}`
/// object plus a shallow clone of the payload (necessary because
/// `serde_json::json!` macro takes ownership).
///
/// **Wire shape** (the slim success shape): `{"data": <payload>}`, plus
/// `"_meta": {...}` only when non-empty (worktree-stale or other
/// non-default fields). Drops `error: null` and `version` — both redundant
/// on the success path. `CQS_OUTPUT_FORMAT=v1` is handled one layer up by
/// [`emit_json`] for the CLI direct path; the batch / daemon JSONL path
/// always uses this slim shape.
pub fn wrap_value(payload: &serde_json::Value) -> serde_json::Value {
    // Build the envelope as a Map directly to avoid the deep clone that
    // `to_value(Envelope::ok(...))` would do.
    let meta = EnvelopeMeta::current();
    let mut env = serde_json::Map::with_capacity(2);
    env.insert("data".to_string(), payload.clone());
    if !meta.is_empty() {
        env.insert("_meta".to_string(), meta_value_for_envelope(&meta));
    }
    serde_json::Value::Object(env)
}

/// Build the canonical `_meta` JSON object from a pre-built
/// [`EnvelopeMeta`]. Avoids re-reading the worktree state when the
/// caller has already constructed the meta block (e.g. for an
/// `is_empty` check).
fn meta_value_for_envelope(meta: &EnvelopeMeta) -> serde_json::Value {
    serde_json::to_value(meta).unwrap_or_else(|_| {
        // Fallback: hand-construct a meta envelope mirroring the
        // EnvelopeMeta serde-skip rules. EnvelopeMeta can't actually
        // fail to serialize (it's a struct of bool + Option<String>),
        // but serde_json's API forces us to handle the Result.
        let mut m = serde_json::Map::with_capacity(2);
        if meta.worktree_stale {
            m.insert("worktree_stale".to_string(), serde_json::Value::Bool(true));
        }
        if let Some(name) = &meta.worktree_name {
            m.insert(
                "worktree_name".to_string(),
                serde_json::Value::String(name.clone()),
            );
        }
        if let Some(overlay) = &meta.worktree_overlay {
            m.insert("worktree_overlay".to_string(), overlay.clone());
        }
        serde_json::Value::Object(m)
    })
}

/// Merge per-response meta entries (e.g. `stale_origins` from a search
/// handler) with the process-level [`EnvelopeMeta`] fields, returning the
/// combined `_meta` object — or `None` when both are empty, so callers keep
/// the skip-when-empty emission convention `worktree_stale` established.
///
/// Per-response keys never collide with the process-level ones
/// (`worktree_stale` / `worktree_name`); insertion order is process-level
/// first, then per-response.
pub fn merged_meta_value(
    per_response: serde_json::Map<String, serde_json::Value>,
) -> Option<serde_json::Value> {
    let meta = EnvelopeMeta::current();
    if meta.is_empty() && per_response.is_empty() {
        return None;
    }
    let mut combined = match meta_value_for_envelope(&meta) {
        serde_json::Value::Object(m) => m,
        // meta_value_for_envelope always returns an object; defensive arm.
        _ => serde_json::Map::new(),
    };
    combined.extend(per_response);
    Some(serde_json::Value::Object(combined))
}

/// Pre-serialized `,"_meta":{...}` JSON fragment for the hot-path
/// streamed envelope writer (`write_json_line` in `crate::cli::batch`).
/// Builds fresh on each call so the worktree-stale fields reflect
/// current process state. The `,_meta:` prefix is appended verbatim by
/// callers that already wrote `{"data": ..., "error": ..., "version": N`.
///
/// Returns an empty string when the meta block has no non-default fields
/// (skip-empty rule). The hot-path writer can splice the result verbatim
/// before the closing `}`: empty fragment ⇒ no `_meta` key emitted;
/// `,"_meta":{...}` otherwise (e.g. when the worktree is stale).
pub fn meta_json_fragment() -> String {
    let meta = EnvelopeMeta::current();
    if meta.is_empty() {
        // No non-default meta fields → skip the key entirely.
        return String::new();
    }
    // Serialize `&meta` straight to a string. The struct's serde-skip rules
    // (skip false bool, skip None option) make this identical to routing
    // through `meta_value_for_envelope`'s Value, while dropping the extra
    // to_value allocation/tree-walk on this hot path.
    //
    // Graceful on serialize failure: the meta block is optional (skip-empty
    // is already a valid shape), so on the impossible-today error path we
    // drop the fragment rather than panic the daemon. A future field whose
    // Serialize returns Err must not crash the streamed envelope writer.
    let payload = serde_json::to_string(&meta).unwrap_or_else(|e| {
        tracing::warn!(error = %e, "Envelope meta failed to serialize; dropping _meta fragment");
        String::new()
    });
    if payload.is_empty() {
        return String::new();
    }
    format!(",\"_meta\":{payload}")
}

/// Build an error envelope as a raw [`serde_json::Value`]. Thin wrapper over
/// the typed [`Envelope::err`] path — same rationale as [`wrap_value`].
///
/// Accepts `&str` for the code rather than [`ErrorCode`] because some call
/// sites (pipeline error structs in `cli::batch::pipeline`) carry the code as
/// `&'static str` from `error_codes::FOO`. New code should prefer
/// [`ErrorCode::as_str`] for compile-checked emission.
///
/// **Wire shape** (slim error shape): `{"error": {...}}`, plus
/// `"_meta": {...}` only when non-empty. Drops `data: null` and `version`.
pub fn wrap_error(code: &str, message: &str) -> serde_json::Value {
    let meta = EnvelopeMeta::current();
    let mut env = serde_json::Map::with_capacity(2);
    env.insert(
        "error".to_string(),
        serde_json::json!({"code": code, "message": message}),
    );
    if !meta.is_empty() {
        env.insert("_meta".to_string(), meta_value_for_envelope(&meta));
    }
    serde_json::Value::Object(env)
}

/// Recursively replace NaN / +Inf / -Inf f64 values inside a [`serde_json::Value`]
/// tree with `null`.
///
/// Defense-in-depth: `serde_json::Number::from_f64` already rejects non-finite
/// floats by returning `None`, and the standard `Serialize` derive maps
/// `f64::NAN` to `Value::Null` rather than panicking. But typed structs
/// constructed by hand or third-party `Serialize` impls can route through
/// `Serializer::serialize_f64`, which returns `Err` for non-finite values.
/// Sanitizing the [`Value`] tree before re-serialization keeps the envelope
/// emit path total — every JSON-emitting surface (CLI `emit_json`, batch
/// `write_json_line`, chat REPL) shares the same retry-on-NaN behavior so
/// agents see one shape across all surfaces.
pub(crate) fn sanitize_json_floats(value: &mut serde_json::Value) {
    match value {
        serde_json::Value::Number(n) => {
            if let Some(f) = n.as_f64() {
                if f.is_nan() || f.is_infinite() {
                    *value = serde_json::Value::Null;
                }
            }
        }
        serde_json::Value::Array(arr) => {
            for item in arr {
                sanitize_json_floats(item);
            }
        }
        serde_json::Value::Object(map) => {
            for (_k, v) in map.iter_mut() {
                sanitize_json_floats(v);
            }
        }
        _ => {}
    }
}

/// Serialize any envelope value (typed or a pre-built
/// [`serde_json::Value`]) to a pretty-printed string, alphabetizing keys.
///
/// Accepts `&impl Serialize` so callers hand the typed `Envelope` (or a
/// hand-built map) directly rather than each pre-building a
/// `serde_json::Value` via `to_value` first. The single `to_value` here
/// is load-bearing, not redundant: the wire contract is alphabetical key
/// order (consumers diff envelope shapes), and serde_json has no
/// single-pass key-sorting serializer without the crate-wide
/// `preserve_order` feature. Routing through a `Value` (BTreeMap-backed,
/// so sorted) then `to_string_pretty` is the minimal pass count that
/// yields sorted output — the canonicalization, not waste.
///
/// On the steady-state path this serializes once to a `Value` and once to
/// a `String`. The sanitize-and-retry arm (NaN / Infinity in a leaf field)
/// reuses the already-built `Value`, so the failure path adds no extra
/// serialization over the happy path. Used by [`emit_json`] and the
/// chat / daemon-to-CLI surfaces so all inherit the same retry behavior —
/// no envelope leaks bare stderr text on a non-finite leaf.
///
/// Returns the final stringified envelope, or an `Err` only if even the
/// sanitized tree fails to serialize (in practice impossible: after
/// [`sanitize_json_floats`] no `Value::Number` can hold a non-finite f64).
pub fn format_envelope_to_string<T: Serialize>(value: &T) -> Result<String> {
    // One serialization into a `Value` to canonicalize key order; the
    // owned tree is then reused by the sanitize-retry arm without a
    // re-serialize.
    let mut buf = serde_json::to_value(value)?;
    match serde_json::to_string_pretty(&buf) {
        Ok(s) => Ok(s),
        Err(first) => {
            // Preserve the original error so the sanitize-retry path can
            // surface it on a retry failure. The typical case is NaN /
            // Infinity in a leaf field (which the sanitize fixes), but
            // `to_string_pretty` can also fail on serde custom Serialize
            // errors or recursion limits — a retry doesn't fix those, and
            // the operator deserves the first error in the log instead of
            // the redundant second.
            tracing::debug!(
                error = %first,
                "to_string_pretty failed; retrying after float-sanitize"
            );
            sanitize_json_floats(&mut buf);
            serde_json::to_string_pretty(&buf).map_err(|second| {
                anyhow::anyhow!(
                    "JSON serialization failed; \
                     first error: {first}; sanitize-retry error: {second}"
                )
            })
        }
    }
}

/// Print a typed value as pretty-printed JSON. Drop-in replacement for
/// `println!("{}", serde_json::to_string_pretty(&v)?)`.
///
/// **Wire shape** depends on the active [`EnvelopeShape`]:
/// - [`EnvelopeShape::V2Bare`] (default, `CQS_OUTPUT_FORMAT` unset) ⇒ bare
///   payload to stdout, no envelope.
/// - [`EnvelopeShape::V1Envelope`] (`CQS_OUTPUT_FORMAT=v1`) ⇒ full envelope
///   `{data, error: null, version: 1, _meta: {...}}` wrapped around
///   the value, pretty-printed.
///
/// Sanitizes NaN / Infinity floats via the same try → on-Err sanitize-and-retry
/// pattern as the batch / daemon socket path (see [`format_envelope_to_string`]
/// and [`crate::cli::batch::write_json_line`]). Keeps observable output uniform
/// across CLI, batch, and chat surfaces.
pub fn emit_json<T: Serialize>(value: &T) -> Result<()> {
    let format = EnvelopeShape::current();
    if format.emits_bare_payload() {
        // Bare payload to stdout, no envelope wrap. Pretty-print for human
        // readability of default CLI use; JSONL batch/daemon path is separate
        // and unaffected.
        emit_bare_payload_stdout(value)
    } else {
        let env = Envelope::ok(value);
        let s = format_envelope_to_string(&env)?;
        println!("{s}");
        Ok(())
    }
}

/// Print a value as a bare pretty-printed JSON payload to stdout —
/// no envelope wrap. Used by [`emit_json`] under `EnvelopeShape::V2Bare`.
/// Sanitizes NaN / Infinity via the same retry pattern as the envelope
/// path so non-finite floats become `null` instead of failing to
/// serialize.
///
/// When `EnvelopeMeta::current()` carries non-default state
/// (e.g. `worktree_stale=true`), splice a `_meta` field onto the bare
/// payload so the operational warning isn't silently dropped under the
/// V2Bare shape. JSON-object payloads accept the splice in-place;
/// array / scalar payloads can't carry `_meta`, so we fall back to a
/// `tracing::warn!` on stderr — the operator still sees the signal even
/// though the bare wire shape can't carry it.
fn emit_bare_payload_stdout<T: Serialize>(value: &T) -> Result<()> {
    let mut buf = serde_json::to_value(value)?;

    let meta = EnvelopeMeta::current();
    if !meta.is_empty() {
        if let Some(obj) = buf.as_object_mut() {
            // Object payload (e.g. `{"count": N, "dead": [...]}`) — splice
            // _meta in-place. Skip if the payload already has a `_meta` key
            // (defensive — should not happen under V2Bare callers).
            obj.entry("_meta")
                .or_insert_with(|| meta_value_for_envelope(&meta));
        } else if meta.worktree_stale {
            // Array or scalar payload — `_meta` doesn't fit the shape.
            // Emit the operational warning to stderr so the operator
            // sees the worktree-stale signal even though the bare JSON
            // can't carry it. CQS_OUTPUT_FORMAT=v1 restores the envelope
            // shape with `_meta.worktree_stale: true` if the consumer
            // needs to read the flag programmatically.
            tracing::warn!(
                worktree_name = ?meta.worktree_name,
                "Worktree is stale: query results reflect main's branch state, \
                 not the worktree's. The bare-payload (V2Bare) wire shape can't \
                 carry _meta on array-shaped responses; set CQS_OUTPUT_FORMAT=v1 \
                 to receive the operational metadata in the JSON payload."
            );
        }
    }

    let s = match serde_json::to_string_pretty(&buf) {
        Ok(s) => s,
        Err(first) => {
            tracing::debug!(
                error = %first,
                "to_string_pretty (bare) failed; retrying after float-sanitize"
            );
            sanitize_json_floats(&mut buf);
            serde_json::to_string_pretty(&buf).map_err(|second| {
                anyhow::anyhow!(
                    "JSON serialization failed; \
                     first error: {first}; sanitize-retry error: {second}"
                )
            })?
        }
    };
    println!("{s}");
    Ok(())
}

/// Print an error envelope as pretty-printed JSON. Used by `cqs ping --json`
/// (daemon-not-running path) and `cqs eval --baseline ... --json` (regression-
/// past-tolerance path) so JSON consumers always get the published failure
/// shape `{data:null, error:{code,message}, version:1}` instead of bare
/// stderr text.
///
/// Same retry-on-NaN guarantee as [`emit_json`]. Accepts `&str` for the
/// code so legacy callers using `error_codes::FOO` keep working; new code
/// should prefer [`ErrorCode::as_str`] for compile-checked emission.
/// Render a daemon slim-envelope payload exactly as the in-process CLI
/// would have presented it: bare pretty-printed payload under the V2Bare
/// default (with the daemon's `_meta` spliced into object payloads, mirroring
/// [`emit_json`]'s bare path), or the full v1 envelope under
/// `CQS_OUTPUT_FORMAT=v1`. This keeps the CLI's wire shape independent of
/// whether a daemon happened to serve the query.
///
/// Non-object payloads can't carry `_meta`; the operational signal degrades
/// to a `tracing::warn!`, same as the in-process bare path.
pub fn daemon_payload_to_cli_text(
    payload: &serde_json::Value,
    meta: Option<&serde_json::Value>,
) -> Result<String> {
    daemon_payload_to_cli_text_with(EnvelopeShape::current(), payload, meta)
}

/// Format-explicit core of [`daemon_payload_to_cli_text`], split out so tests
/// can pin both branches without racing the process-cached env read.
fn daemon_payload_to_cli_text_with(
    format: EnvelopeShape,
    payload: &serde_json::Value,
    meta: Option<&serde_json::Value>,
) -> Result<String> {
    if format.emits_bare_payload() {
        let mut v = payload.clone();
        if let Some(m) = meta {
            match v {
                serde_json::Value::Object(ref mut map) => {
                    map.insert("_meta".to_string(), m.clone());
                }
                _ => tracing::warn!(
                    "daemon _meta dropped: bare payload is not a JSON object \
                     (signal preserved on the daemon side; see daemon logs)"
                ),
            }
        }
        // Trailing newline for parity with the in-process `println!` paths.
        Ok(format!("{}\n", serde_json::to_string_pretty(&v)?))
    } else {
        // v1 full envelope. `Envelope::ok` fills `_meta` from the CLIENT's
        // process state (`EnvelopeMeta::current` → worktree_stale/name) but
        // CANNOT see the DAEMON's per-query signals (`stale_origins`,
        // `worktree_overlay`) — those were computed in the server process and
        // ride the wire in `meta`. Without merging them here the v1 surface
        // silently drops the overlay outcome and the staleness set that the
        // bare-payload branch above preserves. Serialize the envelope to a
        // Value, then fold the daemon meta into its `_meta` object (client
        // process-level keys first, daemon per-query keys layered on top —
        // they never collide with worktree_stale/name).
        let env = Envelope::ok(payload);
        let mut env_value = serde_json::to_value(&env)?;
        if let (Some(daemon_meta), Some(obj)) = (meta, env_value.as_object_mut()) {
            if let Some(daemon_obj) = daemon_meta.as_object() {
                let merged = match obj.remove("_meta") {
                    Some(serde_json::Value::Object(mut m)) => {
                        for (k, v) in daemon_obj {
                            m.insert(k.clone(), v.clone());
                        }
                        m
                    }
                    // `_meta` absent (client meta empty) → the daemon meta is
                    // the whole block.
                    _ => daemon_obj.clone(),
                };
                if !merged.is_empty() {
                    obj.insert("_meta".to_string(), serde_json::Value::Object(merged));
                }
            }
        }
        // Route through `format_envelope_to_string` so the NaN/Infinity
        // sanitize-retry path still covers a payload with non-finite floats.
        Ok(format!("{}\n", format_envelope_to_string(&env_value)?))
    }
}

pub fn emit_json_error(code: &str, message: &str) -> Result<()> {
    let env = Envelope::<serde_json::Value>::err(code, message);
    let s = format_envelope_to_string(&env)?;
    println!("{s}");
    Ok(())
}

/// Like [`emit_json_error`] but carries an optional `data` payload alongside
/// the error so consumers can still surface counters (snapshot, wait_secs,
/// etc.) in the failure shape. Used by `cqs status --wait` timeout to embed
/// the stale snapshot in the error envelope — JSON consumers see
/// `error.code="timeout"` AND keep the `data.snapshot` for diagnostic
/// display, all in one envelope.
///
/// **Wire shape** honors the active [`EnvelopeShape`], matching
/// [`wrap_error`] and [`emit_json`] rather than diverging into an
/// always-full envelope:
/// - [`EnvelopeShape::V2Bare`] (default) ⇒ slim `{"data": ..., "error":
///   {...}}` plus `"_meta": {...}` only when non-empty. Drops `version`
///   (the slim shape's convention), and drops the `data` key entirely when
///   no payload was supplied — bare-payload consumers never see `data:
///   null` noise.
/// - [`EnvelopeShape::V1Envelope`] (`CQS_OUTPUT_FORMAT=v1`) ⇒ full
///   `{data, error, version, _meta}` envelope (always carries `data`, as
///   `null` when absent) for consumers pinned to the wrapped shape.
///
/// Same retry-on-NaN guarantee as [`emit_json`].
pub fn emit_json_error_with_data(
    code: &str,
    message: &str,
    data: Option<serde_json::Value>,
) -> Result<()> {
    let buf = error_with_data_envelope(EnvelopeShape::current(), code, message, data)?;
    let s = format_envelope_to_string(&buf)?;
    println!("{s}");
    Ok(())
}

/// Build the error-with-data envelope value for [`emit_json_error_with_data`],
/// shaped by the supplied [`EnvelopeShape`]. Split out from the `println!`
/// emitter so the shape contract is unit-testable without redirecting stdout.
///
/// - [`EnvelopeShape::V2Bare`] ⇒ slim `{"error": {...}}` (from [`wrap_error`],
///   `_meta` only when non-empty), with `data` spliced in only when supplied.
/// - [`EnvelopeShape::V1Envelope`] ⇒ full `{data, error, version, _meta}`.
fn error_with_data_envelope(
    shape: EnvelopeShape,
    code: &str,
    message: &str,
    data: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
    if shape.emits_bare_payload() {
        // Slim shape: start from `wrap_error` (error + optional _meta), then
        // splice `data` only when the caller supplied a payload.
        let mut env = wrap_error(code, message);
        if let (Some(payload), Some(obj)) = (data, env.as_object_mut()) {
            obj.insert("data".to_string(), payload);
        }
        Ok(env)
    } else {
        // Full v1 envelope: data (null when absent) + error + version + _meta.
        let mut env = serde_json::Map::with_capacity(4);
        env.insert("data".to_string(), data.unwrap_or(serde_json::Value::Null));
        env.insert(
            "error".to_string(),
            serde_json::json!({"code": code, "message": message}),
        );
        env.insert(
            "version".to_string(),
            serde_json::Value::Number(JSON_OUTPUT_VERSION.into()),
        );
        env.insert(
            "_meta".to_string(),
            serde_json::to_value(EnvelopeMeta::current())?,
        );
        Ok(serde_json::Value::Object(env))
    }
}

/// Redact an error chain to a stable `(code, message)` pair safe to surface
/// over the daemon socket or to JSON consumers.
///
/// `format!("{e:#}")` on an `anyhow::Error` walks the entire context chain
/// and may emit raw HTTP bodies, filesystem paths, sqlite query fragments,
/// or other operator-side detail that has no business reaching a daemon
/// client. This helper:
///
/// - Inspects the root cause's type for a small allowlist of known-safe
///   downcasts (sqlx errors → `internal`, IO errors → `io_error`, anyhow
///   with no specific source → `internal`).
/// - Returns a stable `(ErrorCode, message)` pair where `message` is
///   either a redacted summary (for known classes) or a correlation
///   chain-id (`"err-<u64-hex>"`) that an operator can grep for in
///   `journalctl -u cqs-watch`.
/// - Logs the full chain via `tracing::warn!(chain_id, error = %format!("{:#}"))`
///   so the unredacted form stays available to the operator without
///   reaching the client.
///
/// The four daemon batch dispatch sites in `BatchContext::dispatch_line`
/// and `cmd_batch`'s stdin loop call this so a panicked sqlite query or
/// HTTP fetch failure can't leak request URLs / row contents to the
/// daemon socket client.
pub fn redact_error(err: &anyhow::Error) -> (ErrorCode, String) {
    // Generate a stable chain id once per error so the warn log and the
    // client-facing message both reference the same correlation handle.
    // Process-local; not a security boundary, just a grep target.
    let chain_id = {
        use std::collections::hash_map::DefaultHasher;
        use std::hash::{Hash, Hasher};
        let mut h = DefaultHasher::new();
        // Hash the rendered chain so retrying the same error gets the
        // same id (operator can correlate journal lines).
        format!("{err:#}").hash(&mut h);
        format!("err-{:016x}", h.finish())
    };

    // Walk the source chain to inspect the root cause type. Only specific
    // downcasts emit the underlying message; everything else gets the
    // opaque chain-id form so we never hand out arbitrary error text.
    let root: &(dyn std::error::Error + 'static) = err.chain().last().unwrap_or(err.as_ref());

    // sqlx errors leak query text and binding values via their Display
    // impl — never surface those to clients.
    if root.downcast_ref::<sqlx::Error>().is_some() {
        tracing::warn!(
            chain_id = %chain_id,
            error = %format!("{err:#}"),
            "Daemon batch: sqlite error redacted"
        );
        return (
            ErrorCode::Internal,
            format!("internal database error ({chain_id})"),
        );
    }

    // Client-facing validation errors carry a message the handler already
    // vetted as safe to disclose (e.g. a served-slot name). Surface it verbatim
    // with the carried code rather than redacting it to a chain-id — the whole
    // point of the type is that the message must reach the caller to be
    // actionable.
    if let Some(cf) = root.downcast_ref::<ClientFacingError>() {
        return (cf.code, cf.message.clone());
    }

    // IO errors: the Display form is safe (kind + os error message), but
    // the surrounding anyhow context may carry the path. Return the root
    // io::Error display only.
    if let Some(io_err) = root.downcast_ref::<std::io::Error>() {
        tracing::warn!(
            chain_id = %chain_id,
            error = %format!("{err:#}"),
            "Daemon batch: IO error redacted"
        );
        return (ErrorCode::IoError, format!("{io_err} ({chain_id})"));
    }

    // Default: unknown root. Surface only the chain-id; operators can
    // correlate via the warn log below.
    tracing::warn!(
        chain_id = %chain_id,
        error = %format!("{err:#}"),
        "Daemon batch: unknown error class redacted"
    );
    (
        ErrorCode::Internal,
        format!("internal error ({chain_id}); see daemon log"),
    )
}

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

    // Direct unit tests of `emit_json` would need stdout capture; instead
    // we exercise the behavior at the level of the dispatch decision (which
    // `EnvelopeShape` triggers bare vs envelope emission).

    #[test]
    fn dispatch_decision_v1_emits_envelope() {
        // V1Envelope (CQS_OUTPUT_FORMAT=v1) → envelope.
        assert!(!EnvelopeShape::V1Envelope.emits_bare_payload());
    }

    #[test]
    fn dispatch_decision_v2_emits_bare() {
        // V2Bare (default) → bare payload (the headline change).
        assert!(EnvelopeShape::V2Bare.emits_bare_payload());
    }

    #[test]
    fn ok_envelope_shape() {
        let env = Envelope::ok(serde_json::json!({"foo": 1}));
        let v = serde_json::to_value(&env).unwrap();
        assert_eq!(v["data"]["foo"], 1);
        assert!(v["error"].is_null());
        assert_eq!(v["version"], JSON_OUTPUT_VERSION);
    }

    #[test]
    fn err_envelope_shape() {
        let env = Envelope::<serde_json::Value>::err(error_codes::NOT_FOUND, "missing");
        let v = serde_json::to_value(&env).unwrap();
        assert!(v["data"].is_null());
        assert_eq!(v["error"]["code"], "not_found");
        assert_eq!(v["error"]["message"], "missing");
        assert_eq!(v["version"], JSON_OUTPUT_VERSION);
    }

    #[test]
    fn wrap_value_shape_is_slim() {
        // The success path drops error: null and version (redundant). _meta
        // skipped when meta is empty. Hot-path contract: `{"data": <payload>}`
        // minimum line.
        let v = wrap_value(&serde_json::json!([1, 2, 3]));
        assert_eq!(v["data"], serde_json::json!([1, 2, 3]));
        assert!(
            v.get("error").is_none(),
            "slim shape drops error key entirely; got: {v}"
        );
        assert!(
            v.get("version").is_none(),
            "slim shape drops version key entirely; got: {v}"
        );
    }

    #[test]
    fn wrap_error_shape_is_slim() {
        // The error path drops data: null and version. Error stays.
        let v = wrap_error(error_codes::PARSE_ERROR, "bad token");
        assert!(
            v.get("data").is_none(),
            "slim shape drops data key entirely; got: {v}"
        );
        assert_eq!(v["error"]["code"], "parse_error");
        assert_eq!(v["error"]["message"], "bad token");
        assert!(
            v.get("version").is_none(),
            "slim shape drops version key entirely; got: {v}"
        );
    }

    // The slim envelope never carries a `handling_advice` field — that
    // advisory was the only payload of the deleted ultrasecurity posture
    // knob. Security-relevant signals (trust_level, injection_flags)
    // live on the per-result payload from the leaf serializer, not in `_meta`.

    #[test]
    fn wrap_value_omits_handling_advice() {
        let v = wrap_value(&serde_json::json!({"x": 1}));
        assert!(
            v.get("_meta")
                .and_then(|m| m.get("handling_advice"))
                .is_none(),
            "handling_advice must never appear in the slim envelope; got: {v}"
        );
    }

    #[test]
    fn wrap_error_omits_handling_advice() {
        let v = wrap_error(error_codes::INVALID_INPUT, "bad query");
        assert!(
            v.get("_meta")
                .and_then(|m| m.get("handling_advice"))
                .is_none(),
            "handling_advice must never appear in the slim error envelope; got: {v}"
        );
    }

    #[test]
    fn typed_envelope_ok_omits_handling_advice() {
        let env = Envelope::ok(serde_json::json!({"x": 1}));
        let v = serde_json::to_value(&env).unwrap();
        assert!(
            v["_meta"].get("handling_advice").is_none(),
            "handling_advice must be absent. got: {}",
            v["_meta"]
        );
    }

    #[test]
    fn envelope_meta_omits_handling_advice() {
        let meta = EnvelopeMeta::current();
        let v = serde_json::to_value(&meta).unwrap();
        assert!(
            v.get("handling_advice").is_none(),
            "EnvelopeMeta must not serialize a handling_advice field; got: {v}"
        );
    }

    // merged_meta_value: the per-response merge keeps the skip-when-empty
    // convention — `None` when both the process meta and the per-response
    // entries are empty (tests run with non-stale worktree state, so the
    // process side is empty here).
    #[test]
    fn merged_meta_value_none_when_both_empty() {
        assert!(
            merged_meta_value(serde_json::Map::new()).is_none(),
            "empty per-response + empty process meta must skip _meta entirely"
        );
    }

    #[test]
    fn merged_meta_value_carries_per_response_entries() {
        let mut per = serde_json::Map::new();
        per.insert("stale_origins".to_string(), serde_json::json!(["src/a.rs"]));
        let v = merged_meta_value(per).expect("non-empty per-response meta must emit _meta");
        assert_eq!(v["stale_origins"], serde_json::json!(["src/a.rs"]));
    }

    /// The v1-envelope daemon presentation path must FOLD the daemon's
    /// per-query `_meta` (computed server-side: `worktree_overlay`,
    /// `stale_origins`) into the envelope — the bare-payload branch already
    /// does, and the v1 branch used to silently drop it (it rebuilt `_meta`
    /// from the CLIENT process state, which can't see the daemon's overlay
    /// outcome). Regression fence for the worktree-overlay daemon path.
    #[test]
    fn daemon_v1_envelope_folds_daemon_overlay_meta() {
        let payload = serde_json::json!({"query": "q", "results": [], "total": 0});
        let daemon_meta = serde_json::json!({
            "worktree_overlay": {"files": 2, "chunks": 5},
            "stale_origins": ["src/a.rs"],
        });
        let s = daemon_payload_to_cli_text_with(
            EnvelopeShape::V1Envelope,
            &payload,
            Some(&daemon_meta),
        )
        .expect("v1 daemon presentation");
        let v: serde_json::Value = serde_json::from_str(&s).expect("valid JSON");
        assert_eq!(
            v["_meta"]["worktree_overlay"],
            serde_json::json!({"files": 2, "chunks": 5}),
            "v1 envelope must carry the daemon's worktree_overlay; got {v}"
        );
        assert_eq!(
            v["_meta"]["stale_origins"],
            serde_json::json!(["src/a.rs"]),
            "v1 envelope must carry the daemon's stale_origins; got {v}"
        );
        // The payload still lives under `data`.
        assert_eq!(v["data"]["query"], "q");
    }

    /// No daemon `_meta` ⇒ the v1 envelope is unchanged from the plain
    /// `Envelope::ok` shape (the merge is a no-op when there's nothing to fold).
    #[test]
    fn daemon_v1_envelope_without_meta_is_plain() {
        let payload = serde_json::json!({"query": "q", "results": []});
        let s = daemon_payload_to_cli_text_with(EnvelopeShape::V1Envelope, &payload, None)
            .expect("v1 daemon presentation");
        let v: serde_json::Value = serde_json::from_str(&s).expect("valid JSON");
        assert_eq!(v["data"]["query"], "q");
        assert_eq!(v["version"], 1);
        // The v1 envelope always carries a `_meta` object (empty in clean
        // process state — `Envelope::ok`'s serde-skip drops the inner fields,
        // not the block). The merge must not introduce any per-query keys when
        // the daemon sent none.
        assert_eq!(
            v["_meta"],
            serde_json::json!({}),
            "no daemon meta + clean process state ⇒ empty _meta block; got {v}"
        );
    }

    #[test]
    fn meta_json_fragment_returns_empty_when_meta_is_empty() {
        // No non-default meta fields → empty fragment, so the hot-path writer
        // skips the `_meta` key entirely. Worktree state defaults to
        // non-stale in tests, so meta is empty here.
        let s = meta_json_fragment();
        assert_eq!(s, "", "empty meta should return empty fragment; got: {s:?}");
    }

    // ErrorCode enum drives the const proxies. Adding / renaming a variant
    // requires a compile-time match update — the as_str arm and the const
    // proxy stay in lockstep with the enum.
    #[test]
    fn error_code_str_round_trip() {
        assert_eq!(ErrorCode::NotFound.as_str(), error_codes::NOT_FOUND);
        assert_eq!(ErrorCode::InvalidInput.as_str(), error_codes::INVALID_INPUT);
        assert_eq!(ErrorCode::ParseError.as_str(), error_codes::PARSE_ERROR);
        assert_eq!(ErrorCode::IoError.as_str(), error_codes::IO_ERROR);
        assert_eq!(ErrorCode::Internal.as_str(), error_codes::INTERNAL);
        assert_eq!(ErrorCode::Timeout.as_str(), error_codes::TIMEOUT);
        assert_eq!(error_codes::TIMEOUT, "timeout");
    }

    #[test]
    fn error_code_into_static_str() {
        let code: &'static str = ErrorCode::ParseError.into();
        assert_eq!(code, "parse_error");
    }

    // `emit_json_error_with_data` under the **V1 envelope** shape produces
    // `{data: <payload>, error: {code, message}, version, _meta}` — the
    // payload in the `data` slot (success-style) AND an error in the `error`
    // slot. This dual-fill is intentional: timeout-class failures want to
    // carry diagnostic counters (snapshot, wait_secs) in `data` while
    // signalling failure via `error.code = "timeout"`. We assert the pure
    // builder directly (the `println!` emitter just stringifies its output).
    #[test]
    fn error_with_data_envelope_v1_full_shape() {
        let payload = serde_json::json!({
            "snapshot": {"state": "stale", "modified_files": 3},
            "wait_secs": 5,
        });
        let v = error_with_data_envelope(
            EnvelopeShape::V1Envelope,
            error_codes::TIMEOUT,
            "timed out",
            Some(payload),
        )
        .unwrap();
        // Diagnostic data carried alongside the error.
        assert_eq!(v["data"]["wait_secs"], 5);
        assert_eq!(v["data"]["snapshot"]["state"], "stale");
        assert_eq!(v["data"]["snapshot"]["modified_files"], 3);
        // Error envelope semantics: code == "timeout".
        assert_eq!(v["error"]["code"], "timeout");
        assert_eq!(v["error"]["message"], "timed out");
        assert_eq!(v["version"], JSON_OUTPUT_VERSION);
        // _meta never carries handling_advice anymore.
        assert!(v["_meta"].get("handling_advice").is_none());
    }

    // V1 envelope with `None` data emits explicit `data: null` — degrades to
    // the same shape as `emit_json_error` for callers without a payload.
    #[test]
    fn error_with_data_envelope_v1_none_data_is_null() {
        let v =
            error_with_data_envelope(EnvelopeShape::V1Envelope, error_codes::TIMEOUT, "x", None)
                .unwrap();
        assert!(v["data"].is_null());
        assert_eq!(v["error"]["code"], "timeout");
        assert_eq!(v["version"], JSON_OUTPUT_VERSION);
    }

    // V2Bare (default) honors the slim shape — matching `wrap_error` — instead
    // of always emitting the full envelope. The `data`
    // payload is spliced in, but `version` is dropped and `_meta` is absent
    // when empty.
    #[test]
    fn error_with_data_envelope_v2bare_is_slim_with_data() {
        let payload = serde_json::json!({"wait_secs": 5});
        let v = error_with_data_envelope(
            EnvelopeShape::V2Bare,
            error_codes::TIMEOUT,
            "timed out",
            Some(payload),
        )
        .unwrap();
        let obj = v.as_object().unwrap();
        assert_eq!(v["error"]["code"], "timeout");
        assert_eq!(v["data"]["wait_secs"], 5);
        // Slim shape: no `version` key, no `data: null` noise.
        assert!(
            !obj.contains_key("version"),
            "slim shape drops version: {v}"
        );
    }

    // V2Bare with `None` data omits the `data` key entirely (no `data: null`),
    // matching `wrap_error`'s bare-payload convention.
    #[test]
    fn error_with_data_envelope_v2bare_none_data_omits_key() {
        let v = error_with_data_envelope(EnvelopeShape::V2Bare, error_codes::TIMEOUT, "x", None)
            .unwrap();
        let obj = v.as_object().unwrap();
        assert_eq!(v["error"]["code"], "timeout");
        assert!(
            !obj.contains_key("data"),
            "slim shape with no payload omits data: {v}"
        );
        assert!(!obj.contains_key("version"));
    }

    // wrap_value is the slim shape; the typed Envelope::ok path is the full
    // `{data, error, version, _meta}` envelope (used by CQS_OUTPUT_FORMAT=v1
    // on the CLI direct path). The two intentionally differ: the slim shape
    // carries only `data` (+ `_meta` when non-empty), while the typed path
    // always carries every key. Pin that the inner data agrees.
    #[test]
    fn wrap_value_slim_data_agrees_with_envelope_ok() {
        let payload = serde_json::json!({"x": 1, "y": [2, 3]});
        let via_wrap = wrap_value(&payload);
        let via_typed = serde_json::to_value(Envelope::ok(&payload)).unwrap();
        assert_eq!(via_wrap["data"], via_typed["data"]);
        // Slim shape drops error/version; the full envelope keeps them.
        assert!(via_wrap.get("error").is_none());
        assert!(via_wrap.get("version").is_none());
        assert!(via_typed["error"].is_null());
        assert_eq!(via_typed["version"], JSON_OUTPUT_VERSION);
    }

    #[test]
    fn wrap_error_slim_error_agrees_with_envelope_err() {
        let via_wrap = wrap_error(error_codes::INVALID_INPUT, "bad query");
        let via_typed = serde_json::to_value(Envelope::<serde_json::Value>::err(
            "invalid_input",
            "bad query",
        ))
        .unwrap();
        assert_eq!(via_wrap["error"], via_typed["error"]);
        // Slim shape drops data/version; the full envelope keeps them.
        assert!(via_wrap.get("data").is_none());
        assert!(via_wrap.get("version").is_none());
        assert!(via_typed["data"].is_null());
        assert_eq!(via_typed["version"], JSON_OUTPUT_VERSION);
    }

    // Adversarial error-envelope inputs must round-trip through serde_json
    // unchanged. `wrap_error` builds the failure Value without writing
    // stdout, so we can assert the build → to_string → from_str cycle
    // preserves `error.code` / `error.message` byte-for-byte regardless of
    // what control characters, sizes, or multibyte sequences the inputs
    // carry. serde_json is responsible for escaping; these tests pin that
    // the envelope layer doesn't corrupt or truncate the payload first.
    #[test]
    fn wrap_error_round_trips_nul_byte_message() {
        let code = "internal";
        let message = "before\0after";
        let v = wrap_error(code, message);
        let s = serde_json::to_string(&v).expect("serialize");
        let parsed: serde_json::Value = serde_json::from_str(&s).expect("re-parse");
        assert_eq!(parsed["error"]["code"], code);
        assert_eq!(
            parsed["error"]["message"], message,
            "NUL byte in message must survive the round-trip intact"
        );
    }

    #[test]
    fn wrap_error_round_trips_one_megabyte_message() {
        let code = "internal";
        let message = "x".repeat(1024 * 1024);
        let v = wrap_error(code, &message);
        let s = serde_json::to_string(&v).expect("serialize 1MB message");
        let parsed: serde_json::Value = serde_json::from_str(&s).expect("re-parse 1MB message");
        assert_eq!(parsed["error"]["code"], code);
        assert_eq!(
            parsed["error"]["message"]
                .as_str()
                .expect("message is a string")
                .len(),
            1024 * 1024,
            "1MB message must round-trip without truncation"
        );
    }

    #[test]
    fn wrap_error_round_trips_json_significant_chars_in_code() {
        // A code carrying quotes, newlines, and backslashes must be escaped
        // by serde_json and recovered exactly — no premature stringification
        // in the envelope layer.
        let code = "weird\"code\nwith\\chars";
        let message = "msg";
        let v = wrap_error(code, message);
        let s = serde_json::to_string(&v).expect("serialize");
        let parsed: serde_json::Value = serde_json::from_str(&s).expect("re-parse");
        assert_eq!(
            parsed["error"]["code"], code,
            "JSON-significant chars in code must round-trip intact"
        );
        assert_eq!(parsed["error"]["message"], message);
    }

    #[test]
    fn wrap_error_round_trips_multibyte_utf8_message() {
        // Multibyte UTF-8 and emoji pass through unchanged — serde_json emits
        // them as-is (no \u escaping required) and recovers them byte-exact.
        let code = "invalid_input";
        let message = "café ☃ 日本語 🚀 résumé";
        let v = wrap_error(code, message);
        let s = serde_json::to_string(&v).expect("serialize");
        let parsed: serde_json::Value = serde_json::from_str(&s).expect("re-parse");
        assert_eq!(parsed["error"]["code"], code);
        assert_eq!(
            parsed["error"]["message"], message,
            "multibyte UTF-8 / emoji must pass through unchanged"
        );
    }

    // wrap_value takes &Value — confirm the caller doesn't need to clone the
    // payload at the call site. (The function still allocates the outer
    // envelope, but avoids a redundant deep clone.)
    #[test]
    fn wrap_value_takes_reference_no_caller_clone() {
        let payload = serde_json::json!({"big": (0..100).collect::<Vec<_>>()});
        // Pass by reference; payload is still owned at the call site.
        let _wrapped = wrap_value(&payload);
        assert_eq!(payload["big"][0], 0);
        assert_eq!(payload["big"][99], 99);
    }

    // A ClientFacingError is surfaced VERBATIM (with its carried code), not
    // redacted to a chain-id — the slot-mismatch rejection relies on the served
    // slot name reaching the caller. Distinct from the unknown-root default,
    // which redacts.
    #[test]
    fn redact_error_client_facing_surfaces_message_verbatim() {
        let err: anyhow::Error = ClientFacingError::new(
            ErrorCode::InvalidInput,
            "slot 'other' is not the slot this daemon serves ('served')",
        )
        .into();
        let (code, msg) = redact_error(&err);
        assert_eq!(
            code,
            ErrorCode::InvalidInput,
            "the carried code is preserved"
        );
        assert!(
            msg.contains("served") && msg.contains("other"),
            "the client-facing message must surface verbatim, got: {msg}"
        );
        assert!(
            !msg.contains("err-"),
            "a client-facing error must NOT be redacted to a chain-id, got: {msg}"
        );
    }

    // A ClientFacingError surfaces verbatim even when wrapped in extra anyhow
    // context — `redact_error` walks to the chain root to find it.
    #[test]
    fn redact_error_client_facing_surfaces_through_context() {
        let err: anyhow::Error = anyhow::Error::from(ClientFacingError::new(
            ErrorCode::NotFound,
            "no such slot 'x'",
        ))
        .context("while queueing index");
        let (code, msg) = redact_error(&err);
        assert_eq!(code, ErrorCode::NotFound);
        assert!(
            msg.contains("no such slot 'x'"),
            "the root client-facing message must surface, got: {msg}"
        );
    }

    // redact_error returns a stable code+chain-id pair for unknown error
    // roots so the daemon socket never echoes raw anyhow chains.
    #[test]
    fn redact_error_unknown_root_returns_internal_with_chain_id() {
        let err = anyhow::anyhow!("some internal failure with /etc/passwd in path");
        let (code, msg) = redact_error(&err);
        assert_eq!(code, ErrorCode::Internal);
        // The message must include the chain-id correlation handle and
        // must NOT include the original (potentially sensitive) text.
        assert!(msg.contains("err-"), "expected chain-id in message: {msg}");
        assert!(
            !msg.contains("/etc/passwd"),
            "raw error text leaked to client: {msg}"
        );
    }

    // Same input → same chain-id (deterministic correlation). An operator
    // grepping the journal for the chain-id finds the matching warn.
    #[test]
    fn redact_error_chain_id_is_deterministic_for_same_root() {
        let err1 = anyhow::anyhow!("repeatable error text");
        let err2 = anyhow::anyhow!("repeatable error text");
        let (_, msg1) = redact_error(&err1);
        let (_, msg2) = redact_error(&err2);
        // Both messages should embed the same chain-id since the rendered
        // chain text is identical.
        let extract = |msg: &str| -> Option<String> {
            msg.split_whitespace()
                .find(|w| w.contains("err-"))
                .map(|w| w.trim_start_matches('(').trim_end_matches(')').to_string())
        };
        assert_eq!(
            extract(&msg1),
            extract(&msg2),
            "chain-id should be deterministic for same root"
        );
    }

    // sqlx errors are downcast and redacted to "internal database error" —
    // never echoing the SQL query string or row binding values.
    #[test]
    fn redact_error_sqlite_root_returns_redacted_database_message() {
        // RowNotFound is a simple sqlx::Error variant that doesn't need a
        // real sqlite handle to construct. Surrounding context() carries
        // the SQL the redaction must drop.
        let sqlx_err = sqlx::Error::RowNotFound;
        let err = anyhow::Error::from(sqlx_err)
            .context("SELECT secret FROM users WHERE id = 'sensitive'");
        let (code, msg) = redact_error(&err);
        assert_eq!(code, ErrorCode::Internal);
        assert!(msg.starts_with("internal database error"));
        assert!(!msg.contains("SELECT"), "SQL leaked to client: {msg}");
        assert!(
            !msg.contains("sensitive"),
            "binding leaked to client: {msg}"
        );
    }

    // D.1: emit_json must sanitize NaN to null via the shared retry pattern,
    // matching write_json_line in batch/mod.rs. This test exercises the full
    // envelope pipeline (Envelope::ok → to_value → format_envelope_to_string)
    // so a regression that drops sanitization fails immediately.
    #[test]
    fn emit_json_sanitizes_nan_to_null() {
        let payload = serde_json::json!({"score": f64::NAN, "name": "x"});
        let env = Envelope::ok(payload);
        let mut buf = serde_json::to_value(&env).unwrap();
        sanitize_json_floats(&mut buf);
        let s = serde_json::to_string_pretty(&buf).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
        assert!(parsed["data"]["score"].is_null());
        assert_eq!(parsed["data"]["name"], "x");
        assert_eq!(parsed["version"], JSON_OUTPUT_VERSION);
    }

    // D.1: ±Infinity sanitization parity. serde_json's typed-struct path
    // already maps non-finite f64 to null, but a manually-constructed
    // Value tree (e.g. via custom Serialize impls that skip the safety
    // net) can still leak. Sanitizer must catch both signs.
    #[test]
    fn emit_json_sanitizes_pos_and_neg_infinity() {
        let payload = serde_json::json!({"a": f64::INFINITY, "b": f64::NEG_INFINITY, "name": "x"});
        let env = Envelope::ok(payload);
        let mut buf = serde_json::to_value(&env).unwrap();
        sanitize_json_floats(&mut buf);
        let s = serde_json::to_string_pretty(&buf).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
        assert!(parsed["data"]["a"].is_null());
        assert!(parsed["data"]["b"].is_null());
        assert_eq!(parsed["data"]["name"], "x");
    }

    // D.1: format_envelope_to_string is the chat-shared helper. It must
    // produce the same sanitized output as emit_json for any envelope value
    // — that's what gives chat parity with batch / CLI.
    #[test]
    fn format_envelope_to_string_handles_nan_payload() {
        let payload = serde_json::json!({"score": f64::NAN, "extra": [1.0, f64::NAN, 3.0]});
        let env = Envelope::ok(payload);
        let buf = serde_json::to_value(&env).unwrap();
        let s = format_envelope_to_string(&buf).expect("must not Err");
        let parsed: serde_json::Value =
            serde_json::from_str(&s).expect("output must be valid JSON");
        // serde_json's typed-struct path already maps NaN to null in `score`,
        // so the post-format value is null whether or not the retry triggered.
        assert!(parsed["data"]["score"].is_null());
        assert_eq!(parsed["data"]["extra"][0], 1.0);
        assert!(parsed["data"]["extra"][1].is_null());
        assert_eq!(parsed["data"]["extra"][2], 3.0);
    }

    // D.1: format_envelope_to_string is a no-op on clean values — any
    // sanitized retry would mutate output, so this pins the success path.
    #[test]
    fn format_envelope_to_string_passthrough_on_clean_value() {
        let env = Envelope::ok(serde_json::json!({"name": "foo", "score": 0.95}));
        let buf = serde_json::to_value(&env).unwrap();
        let s = format_envelope_to_string(&buf).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
        assert_eq!(parsed["data"]["name"], "foo");
        assert_eq!(parsed["data"]["score"], 0.95);
        assert!(parsed["error"].is_null());
    }

    // write_json_line (in `crate::cli::batch`) and format_envelope_to_string
    // share the same `wrap_value` + `sanitize_json_floats` retry path on
    // serializer failure — the retry must catch BOTH `+Inf` and `-Inf`, not
    // just NaN. This test exercises the same chain
    // (`wrap_value` → `sanitize_json_floats` → `serde_json::to_string`) the
    // batch path uses, with a mixed-Infinity payload.
    #[test]
    fn test_write_json_line_infinity_retry() {
        let payload = serde_json::json!({
            "score": f64::INFINITY,
            "neg_score": f64::NEG_INFINITY,
            "name": "x",
        });
        // `wrap_value` is exactly what write_json_line's retry arm calls
        // (`crate::cli::json_envelope::wrap_value(value)`) — the slim shape.
        let wrapped = wrap_value(&payload);
        let mut sanitized = wrapped;
        sanitize_json_floats(&mut sanitized);
        // The sanitized envelope must serialize to valid JSON (no NaN /
        // Infinity literals, since those are not allowed in the JSON spec).
        let s = serde_json::to_string(&sanitized).expect("must serialize after sanitize");
        let parsed: serde_json::Value =
            serde_json::from_str(&s).expect("output must be valid JSON");

        // Both Infinity fields must be replaced with null inside the data
        // payload; the non-numeric `name` field stays untouched.
        assert!(
            parsed["data"]["score"].is_null(),
            "+Infinity score must be sanitized to null. got: {}",
            parsed["data"]["score"]
        );
        assert!(
            parsed["data"]["neg_score"].is_null(),
            "-Infinity neg_score must be sanitized to null. got: {}",
            parsed["data"]["neg_score"]
        );
        assert_eq!(
            parsed["data"]["name"], "x",
            "non-numeric fields must pass through the retry untouched"
        );
        // Slim shape: error/version keys are absent.
        assert!(parsed.get("error").is_none());
        assert!(parsed.get("version").is_none());
        // The serialized form must not contain the literal "Infinity"
        // (would be invalid JSON; serde_json would have rejected it).
        assert!(
            !s.contains("Infinity"),
            "sanitized output must not contain the 'Infinity' literal: {s}"
        );
    }

    // `wrap_value` has no double-wrap detection. Calling it on an
    // already-wrapped (slim) envelope produces a NESTED envelope: the outer
    // `data` field holds the inner envelope object verbatim, including its
    // `data` key. This test pins that behaviour so a future "auto-detect
    // envelope shape and pass-through" change is a deliberate, test-failing
    // decision rather than accidental drift.
    //
    // The contract is: callers MUST NOT pass an already-wrapped value to
    // `wrap_value`. There's no compile-time check; this test documents what
    // happens when someone does.
    #[test]
    fn wrap_value_does_not_double_wrap_existing_envelope() {
        let inner_payload = serde_json::json!({"name": "foo", "count": 3});
        let first_wrap = wrap_value(&inner_payload);

        // Sanity: first wrap is the slim envelope.
        assert_eq!(first_wrap["data"], inner_payload);
        assert!(first_wrap.get("error").is_none());
        assert!(first_wrap.get("version").is_none());

        // Now wrap it AGAIN. wrap_value has no envelope detection, so
        // this produces `{data: {data: {...}}}`.
        let second_wrap = wrap_value(&first_wrap);

        // Outer envelope shape is intact.
        assert!(
            second_wrap["data"].is_object(),
            "second wrap's data must be an object (the entire first envelope)"
        );
        assert!(second_wrap.get("error").is_none());
        assert!(second_wrap.get("version").is_none());

        // Inner envelope is nested under outer `data` — the contract pin.
        let inner = &second_wrap["data"];
        assert_eq!(
            inner["data"], inner_payload,
            "double-wrap puts the original payload at data.data — \
             pins the no-detection contract"
        );

        // Cross-check: the deeply-nested payload survives intact under
        // `data.data` so a consumer that grepped `.data.data` would still
        // find their fields.
        assert_eq!(second_wrap["data"]["data"]["name"], "foo");
        assert_eq!(second_wrap["data"]["data"]["count"], 3);
    }

    #[test]
    fn daemon_payload_bare_splices_meta_into_object() {
        let payload = serde_json::json!({"query": "q", "results": []});
        let meta = serde_json::json!({"worktree_stale": true});
        let s = daemon_payload_to_cli_text_with(EnvelopeShape::V2Bare, &payload, Some(&meta))
            .expect("render");
        assert!(s.ends_with('\n'), "trailing newline for println parity");
        let v: serde_json::Value = serde_json::from_str(&s).expect("valid JSON");
        assert_eq!(v["query"], "q");
        assert_eq!(v["_meta"]["worktree_stale"], true);
        assert!(
            v.get("data").is_none(),
            "no envelope key on the bare surface"
        );
    }

    #[test]
    fn daemon_payload_bare_array_drops_meta_with_shape_intact() {
        let payload = serde_json::json!([1, 2, 3]);
        let meta = serde_json::json!({"worktree_stale": true});
        let s = daemon_payload_to_cli_text_with(EnvelopeShape::V2Bare, &payload, Some(&meta))
            .expect("render");
        let v: serde_json::Value = serde_json::from_str(&s).expect("valid JSON");
        assert_eq!(v, serde_json::json!([1, 2, 3]));
    }

    #[test]
    fn daemon_payload_v1_rebuilds_full_envelope() {
        let payload = serde_json::json!({"k": 1});
        let s = daemon_payload_to_cli_text_with(EnvelopeShape::V1Envelope, &payload, None)
            .expect("render");
        let v: serde_json::Value = serde_json::from_str(&s).expect("valid JSON");
        assert_eq!(v["data"]["k"], 1);
        assert!(v.get("version").is_some(), "v1 envelope carries version");
    }
}
