//! A2UI v0.9 protocol model and in-memory surface store.
//!
//! This crate intentionally models the protocol envelope and surface
//! lifecycle while keeping component payloads as `serde_json::Value`.
//! A2UI catalogs define component-specific properties, so the safe
//! boundary here is: validate and route envelopes, store components by
//! id, and let renderers map catalog components to trusted UI widgets.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use thiserror::Error;
use tokio::sync::RwLock;

pub const A2UI_VERSION_V0_9: &str = "v0.9";
pub const A2UI_MIME_TYPE: &str = "application/vnd.a2ui+json";
pub const BASIC_CATALOG_V0_9: &str = "https://a2ui.org/specification/v0_9/basic_catalog.json";

#[derive(Debug, Error)]
pub enum A2uiError {
    #[error("unsupported A2UI version `{0}`")]
    UnsupportedVersion(String),
    #[error("A2UI envelope must contain exactly one message")]
    InvalidEnvelope,
    #[error("surface `{0}` does not exist")]
    UnknownSurface(String),
    #[error("component is missing required string id")]
    MissingComponentId,
    #[error("unsupported A2UI catalog `{0}`")]
    UnsupportedCatalog(String),
    #[error("unsupported A2UI component `{0}`")]
    UnsupportedComponent(String),
    #[error("A2UI limit exceeded: {0}")]
    LimitExceeded(String),
    #[error("A2UI validation failed: {0}")]
    Validation(String),
    #[error("JSON pointer `{0}` is invalid for update")]
    InvalidPath(String),
    #[error("failed to parse A2UI payload: {0}")]
    Parse(String),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct A2uiCapabilities {
    pub version: String,
    pub mime_type: String,
    pub catalogs: Vec<String>,
    pub components: Vec<String>,
    pub limits: A2uiLimits,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct A2uiLimits {
    pub max_surfaces: usize,
    pub max_components_per_surface: usize,
    pub max_component_bytes: usize,
    pub max_data_model_bytes: usize,
    pub max_payload_bytes: usize,
    pub max_surface_age_secs: i64,
}

impl Default for A2uiLimits {
    fn default() -> Self {
        Self {
            max_surfaces: 32,
            max_components_per_surface: 256,
            max_component_bytes: 16 * 1024,
            max_data_model_bytes: 256 * 1024,
            max_payload_bytes: 512 * 1024,
            max_surface_age_secs: 60 * 60,
        }
    }
}

pub fn capabilities() -> A2uiCapabilities {
    A2uiCapabilities {
        version: A2UI_VERSION_V0_9.into(),
        mime_type: A2UI_MIME_TYPE.into(),
        catalogs: vec![BASIC_CATALOG_V0_9.into()],
        components: supported_components()
            .into_iter()
            .map(str::to_string)
            .collect(),
        limits: A2uiLimits::default(),
    }
}

fn supported_components() -> BTreeSet<&'static str> {
    [
        "Text",
        "Row",
        "Column",
        "List",
        "Card",
        "Divider",
        "Button",
        "TextField",
        "CheckBox",
        "ChoicePicker",
        "Select",
        "Slider",
        "Image",
        "Icon",
        "Video",
        "AudioPlayer",
        "DateTimeInput",
        "Chart",
        "File",
        "FilePicker",
        "Spacer",
        "Badge",
        "Tabs",
        "Modal",
    ]
    .into_iter()
    .collect()
}

/// Desugar CAR's A2UI convenience forms into strict A2UI v0.9 shapes, so
/// every consumer (the SwiftUI + web renderers, `a2ui.get` / `a2ui.surfaces`
/// / `a2ui.event`) sees spec-compliant surfaces while agents keep emitting
/// the ergonomic forms CAR has always documented. Applied on ingest in
/// [`A2uiSurfaceStore::update_components`]. Idempotent and a no-op on
/// already-spec input, so re-applying a surface is safe.
///
/// Transforms (Parslee-ai/car-releases#57/#58 spec alignment):
/// - **Button `action: "name"`** (bare string) →
///   `action: { event: { name } }` — A2UI v0.9 `ActionSchema` is an object.
/// - **Button `label: "text"`** (string, when no `child` is present) → a
///   synthetic `Text` child `{ id: "<btn>__label", component: "Text", text }`
///   plus `child: "<btn>__label"` — the v0.9 Button renders a `child`, not a
///   `label`. The synthetic id is deterministic so updates stay idempotent.
/// - **Image `src`/`alt`** → `url`/`description` — the v0.9 Image field names.
///
/// Form components (`TextField`, `CheckBox`, `Select`, `Slider`,
/// `ChoicePicker`) are intentionally left untouched here — their `action`
/// fields are a separate, semantic migration (Phase 2; the v0.9 forms have
/// no action and surface changes via data binding).
pub fn normalize_components(components: Vec<Value>) -> Vec<Value> {
    let mut out = Vec::with_capacity(components.len());
    for component in components {
        normalize_one(component, &mut out);
    }
    out
}

fn normalize_one(component: Value, out: &mut Vec<Value>) {
    let Value::Object(mut obj) = component else {
        out.push(component);
        return;
    };
    match obj.get("component").and_then(Value::as_str) {
        Some("Button") => {
            // action: "name" (string) -> { event: { name } }
            if let Some(Value::String(name)) = obj.get("action") {
                let name = name.clone();
                obj.insert(
                    "action".into(),
                    serde_json::json!({ "event": { "name": name } }),
                );
            }
            // label: "text" -> synthetic Text child + child ref (only when
            // the agent hasn't already provided a `child`).
            if obj.get("child").is_none() {
                if let (Some(Value::String(label)), Some(id)) = (
                    obj.get("label").cloned(),
                    obj.get("id").and_then(Value::as_str).map(str::to_string),
                ) {
                    let label_id = format!("{id}__label");
                    out.push(serde_json::json!({
                        "id": label_id, "component": "Text", "text": label,
                    }));
                    obj.insert("child".into(), Value::String(label_id));
                    obj.remove("label");
                }
            }
            out.push(Value::Object(obj));
        }
        Some("Image") => {
            // CAR uses src/alt; A2UI v0.9 uses url/description. Rename only
            // when the spec field isn't already set.
            if let Some(src) = obj.remove("src") {
                obj.entry("url").or_insert(src);
            }
            if let Some(alt) = obj.remove("alt") {
                obj.entry("description").or_insert(alt);
            }
            out.push(Value::Object(obj));
        }
        // Continuous form inputs: A2UI v0.9 has NO `action` on these — the
        // value two-way-binds to the data model and user changes reach the
        // agent when a submit `Button` fires, carrying the bound values in
        // its action `context`. Drop CAR's legacy per-field `action` (which
        // fired an `a2ui.action` on every edit) so the surface matches the
        // spec and renderers don't round-trip each keystroke (car#259-adjacent).
        // `value`/`checked` bindings (`{path}`) are left intact.
        Some("TextField" | "CheckBox" | "Select" | "Slider" | "ChoicePicker" | "DateTimeInput") => {
            obj.remove("action");
            out.push(Value::Object(obj));
        }
        _ => out.push(Value::Object(obj)),
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateSurface {
    pub surface_id: String,
    #[serde(default = "default_catalog_id")]
    pub catalog_id: String,
    #[serde(default, skip_serializing_if = "Value::is_null")]
    pub theme: Value,
    #[serde(default)]
    pub send_data_model: bool,
}

fn default_catalog_id() -> String {
    BASIC_CATALOG_V0_9.to_string()
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateComponents {
    pub surface_id: String,
    #[serde(default)]
    pub components: Vec<Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateDataModel {
    pub surface_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub path: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub value: Option<Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteSurface {
    pub surface_id: String,
}

/// Incremental RFC 6902 JSON-Patch over the component tree
/// (Parslee-ai/car#180). The `patch` field is a JSON-Patch document
/// (`[{ "op": "add" | "remove" | "replace" | "move" | "copy" | "test",
/// "path": "/<componentId>[/...]" , "value": ... }]`). The renderer
/// MUST apply ops surgically — preserving focus, scroll, and
/// in-flight text input on any *un-patched* sub-tree. Whole-component
/// replace remains available via `update_components`.
///
/// Pointers target the *component tree* (the surface's component map,
/// keyed by id). Examples:
///
/// - `{ "op": "replace", "path": "/title/text", "value": "Goodbye" }`
/// - `{ "op": "add", "path": "/footer", "value": { "id": "footer", "component": "Text", "text": "..." } }`
/// - `{ "op": "remove", "path": "/old_button" }`
///
/// On any op failure the patch is rejected wholesale — the renderer
/// MUST NOT partially apply, so an improvement-pass that misfires
/// leaves the visible surface unchanged (the agent then sees the
/// failure status and decides whether to retry or back off).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PatchComponents {
    pub surface_id: String,
    /// JSON-Patch document (RFC 6902). Validated against the
    /// `json-patch` crate's `Patch` parser at apply time.
    pub patch: Value,
    /// Optional echo of [`RenderReport::report_id`] from the
    /// report this patch was computed against
    /// (Parslee-ai/car#184). When set, the renderer SHOULD
    /// reject the patch if the named report has already been
    /// superseded — protects against agent-side races where a
    /// patch lands after the surface has moved on. Absent on
    /// renderer-initiated patches that aren't responding to a
    /// specific report.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub in_response_to: Option<String>,
}

/// Renderer-emitted render telemetry envelope (Parslee-ai/car#180).
/// Renderers SHOULD send these after each paint pass so an
/// improvement agent can decide whether to issue a `patch_components`
/// refinement. Fire-and-forget; no response expected. The carrier
/// (WS JSON-RPC notification or UniFFI `A2uiObserver` event) is
/// transport-specific.
///
/// `signature` is a stable structural fingerprint of the surface
/// (domain / structure / cardinality / feature set / action density)
/// — improvement agents key their strategy library on it. The fields
/// are advisory; the spec doesn't enumerate valid values.
///
/// **Correlation + termination fields** (Parslee-ai/car#184). The
/// four typed fields below were previously stuffed into `metadata`
/// under snake_case keys; the renderer side (a2ui-shader) has
/// already standardized on them. Promotion shortens the
/// `metadata.get("report_id").and_then(...)` dance to a direct
/// typed read. For back-compat with renderers that haven't
/// migrated yet, the `metadata` map remains and the [`effective_*`]
/// accessors fall back to it — see the helpers below the struct.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenderReport {
    pub surface_id: String,
    #[serde(default)]
    pub signature: Value,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub density: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub visible_field_count: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub action_count: Option<u32>,
    #[serde(default)]
    pub viewport: Value,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub hidden_via_overflow: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub strategy_id: Option<String>,
    pub timestamp: DateTime<Utc>,
    /// Stable per-report correlation handle (UUID). Patches issued
    /// by an improvement agent echo this in
    /// [`PatchComponents::in_response_to`] so the renderer can
    /// reject patches computed against a view it has already
    /// superseded. Top-level wire name follows the struct's
    /// `rename_all = "camelCase"` → `reportId`; legacy emitters
    /// that wrote it as `metadata["report_id"]` (snake_case) are
    /// supported via [`Self::effective_report_id`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub report_id: Option<String>,
    /// Per-surface monotone counter. Agents key oscillation
    /// detection on it: the same patch hash arriving at sequence
    /// N and N+2 is a loop.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sequence: Option<u64>,
    /// Components painted as placeholders (renderer didn't
    /// recognize the agent-supplied component type). > 0 is a
    /// hard-stop invariant violation — improvement loops MUST
    /// abort.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub placeholder_count: Option<u32>,
    /// Sequence at which the current `signature` first appeared.
    /// Equals `sequence` on first emit; stays pinned while the
    /// signature is unchanged. Drives the "stability window"
    /// termination signal — N consecutive same-signature renders
    /// = nothing more for the improvement agent to do.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signature_stable_since: Option<u64>,
    /// Open metadata bag. The well-known
    /// `x-renderer.improvement_pass` boolean flag (when present and
    /// `true`) marks the report as coming from a renderer that
    /// already applied an improvement-pass — improvement agents
    /// SHOULD avoid issuing a second pass on the same surface to
    /// keep the loop bounded.
    ///
    /// **Dual-shape resolution** (Parslee-ai/car#184). Pre-#184
    /// renderers wrote the four correlation fields above into
    /// this bag under snake_case keys (`report_id`, `sequence`,
    /// `placeholder_count`, `signature_stable_since`). Post-#184
    /// renderers SHOULD emit them at the top level. When both
    /// shapes are present, the **typed field is canonical** and
    /// the metadata duplicate is ignored — the metadata bag is
    /// not a backchannel for overriding typed state, just a
    /// fallback path for emitters that haven't migrated.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub metadata: BTreeMap<String, Value>,
}

impl RenderReport {
    /// Resolve the stable per-report correlation handle, preferring
    /// the typed field and falling back to
    /// `metadata["report_id"]` for pre-#184 renderers.
    pub fn effective_report_id(&self) -> Option<&str> {
        if let Some(s) = self.report_id.as_deref() {
            return Some(s);
        }
        self.metadata.get("report_id").and_then(Value::as_str)
    }

    /// Resolve the per-surface monotone counter with metadata
    /// fallback (see [`Self::effective_report_id`]).
    pub fn effective_sequence(&self) -> Option<u64> {
        self.sequence
            .or_else(|| self.metadata.get("sequence").and_then(Value::as_u64))
    }

    /// Resolve placeholder count with metadata fallback. > 0 is
    /// the hard-stop invariant — see field docs.
    pub fn effective_placeholder_count(&self) -> Option<u32> {
        self.placeholder_count.or_else(|| {
            self.metadata
                .get("placeholder_count")
                .and_then(Value::as_u64)
                .and_then(|n| u32::try_from(n).ok())
        })
    }

    /// Resolve signature-stable-since with metadata fallback.
    pub fn effective_signature_stable_since(&self) -> Option<u64> {
        self.signature_stable_since.or_else(|| {
            self.metadata
                .get("signature_stable_since")
                .and_then(Value::as_u64)
        })
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientAction {
    // The action name comes from the component's `action` prop. The web
    // renderer forwards `@a2ui/web_core`'s action object to `a2ui.action`
    // verbatim, so accept `action` as an alias for `name` — otherwise a
    // shape mismatch fails deserialization before the action can be
    // delivered (Parslee-ai/car-releases#58).
    #[serde(alias = "action")]
    pub name: String,
    pub surface_id: String,
    pub source_component_id: String,
    pub timestamp: DateTime<Utc>,
    #[serde(default)]
    pub context: Value,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct A2uiSurfaceOwner {
    pub kind: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub task_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub context_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub endpoint: Option<String>,
}

impl A2uiSurfaceOwner {
    pub fn a2a(task_id: Option<String>, context_id: Option<String>) -> Option<Self> {
        if task_id.is_none() && context_id.is_none() {
            return None;
        }
        Some(Self {
            kind: "a2a".into(),
            task_id,
            context_id,
            endpoint: None,
        })
    }

    pub fn with_endpoint(mut self, endpoint: Option<String>) -> Self {
        self.endpoint = endpoint;
        self
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct A2uiEnvelope {
    #[serde(default = "default_version")]
    pub version: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub create_surface: Option<CreateSurface>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub update_components: Option<UpdateComponents>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub patch_components: Option<PatchComponents>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub update_data_model: Option<UpdateDataModel>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub delete_surface: Option<DeleteSurface>,
}

impl Default for A2uiEnvelope {
    fn default() -> Self {
        Self {
            version: default_version(),
            create_surface: None,
            update_components: None,
            patch_components: None,
            update_data_model: None,
            delete_surface: None,
        }
    }
}

fn default_version() -> String {
    A2UI_VERSION_V0_9.to_string()
}

impl A2uiEnvelope {
    pub fn validate(&self) -> Result<(), A2uiError> {
        if self.version != A2UI_VERSION_V0_9 {
            return Err(A2uiError::UnsupportedVersion(self.version.clone()));
        }
        let count = self.create_surface.is_some() as u8
            + self.update_components.is_some() as u8
            + self.patch_components.is_some() as u8
            + self.update_data_model.is_some() as u8
            + self.delete_surface.is_some() as u8;
        if count != 1 {
            return Err(A2uiError::InvalidEnvelope);
        }
        Ok(())
    }
}

#[derive(Debug, Clone)]
pub struct A2uiValidator {
    limits: A2uiLimits,
    supported_components: BTreeSet<&'static str>,
}

impl Default for A2uiValidator {
    fn default() -> Self {
        Self {
            limits: A2uiLimits::default(),
            supported_components: supported_components(),
        }
    }
}

impl A2uiValidator {
    pub fn new(limits: A2uiLimits) -> Self {
        Self {
            limits,
            supported_components: supported_components(),
        }
    }

    pub fn limits(&self) -> A2uiLimits {
        self.limits
    }

    pub fn validate_payload(&self, value: &Value) -> Result<(), A2uiError> {
        let bytes = serde_json::to_vec(value).map_err(|e| A2uiError::Parse(e.to_string()))?;
        if bytes.len() > self.limits.max_payload_bytes {
            return Err(A2uiError::LimitExceeded(format!(
                "payload is {} bytes; max is {}",
                bytes.len(),
                self.limits.max_payload_bytes
            )));
        }
        Ok(())
    }

    pub fn validate_envelope(&self, envelope: &A2uiEnvelope) -> Result<(), A2uiError> {
        envelope.validate()?;
        if let Some(create) = &envelope.create_surface {
            self.validate_surface_id(&create.surface_id)?;
            if create.catalog_id != BASIC_CATALOG_V0_9 {
                return Err(A2uiError::UnsupportedCatalog(create.catalog_id.clone()));
            }
        }
        if let Some(update) = &envelope.update_components {
            self.validate_surface_id(&update.surface_id)?;
            if update.components.len() > self.limits.max_components_per_surface {
                return Err(A2uiError::LimitExceeded(format!(
                    "update has {} components; max is {}",
                    update.components.len(),
                    self.limits.max_components_per_surface
                )));
            }
            let mut ids = BTreeSet::new();
            for component in &update.components {
                self.validate_component(component)?;
                let id = component
                    .get("id")
                    .and_then(Value::as_str)
                    .ok_or(A2uiError::MissingComponentId)?;
                if !ids.insert(id) {
                    return Err(A2uiError::Validation(format!(
                        "duplicate component id `{id}` in update"
                    )));
                }
            }
        }
        if let Some(patch) = &envelope.patch_components {
            self.validate_surface_id(&patch.surface_id)?;
            // The patch body must be a non-empty array of ops. We
            // round-trip through the json-patch crate's parser so
            // the apply path can rely on the Patch type being
            // well-formed.
            let ops = patch.patch.as_array().ok_or_else(|| {
                A2uiError::Validation("patchComponents.patch must be an array".into())
            })?;
            if ops.is_empty() {
                return Err(A2uiError::Validation(
                    "patchComponents.patch must contain at least one op".into(),
                ));
            }
            // Defensive shape check: a one-segment `add` / `replace`
            // path (`/<componentId>`) must carry an object value —
            // otherwise the post-apply BTreeMap deserialize errors
            // with a confusing message after the patch already ran.
            // Fail loudly here instead. Multi-segment paths
            // (`/<id>/<prop>`) can carry any JSON value.
            for op in ops {
                let op_kind = op.get("op").and_then(Value::as_str);
                let path = op.get("path").and_then(Value::as_str).unwrap_or("");
                let one_segment =
                    path.starts_with('/') && !path[1..].is_empty() && !path[1..].contains('/');
                if matches!(op_kind, Some("add") | Some("replace")) && one_segment {
                    let value_is_object = op.get("value").and_then(Value::as_object).is_some();
                    if !value_is_object {
                        return Err(A2uiError::Validation(format!(
                            "patch op `{}` at path `{path}` must have an object value \
                             (a component definition)",
                            op_kind.unwrap_or("?")
                        )));
                    }
                }
            }
            let _parsed: json_patch::Patch = serde_json::from_value(patch.patch.clone())
                .map_err(|e| A2uiError::Validation(format!("invalid JSON-Patch: {e}")))?;
            // Per-payload size cap already governs the whole
            // envelope (validate_payload runs first), so we don't
            // need a second arbitrary cap here. The post-apply
            // per-component size check (validate_component, 16 KB
            // default) catches any single op that writes an
            // oversized leaf value.
        }
        if let Some(update) = &envelope.update_data_model {
            self.validate_surface_id(&update.surface_id)?;
            if let Some(path) = &update.path {
                pointer_parts(path)?;
            }
            if let Some(value) = &update.value {
                let bytes =
                    serde_json::to_vec(value).map_err(|e| A2uiError::Parse(e.to_string()))?;
                if bytes.len() > self.limits.max_data_model_bytes {
                    return Err(A2uiError::LimitExceeded(format!(
                        "data update is {} bytes; max is {}",
                        bytes.len(),
                        self.limits.max_data_model_bytes
                    )));
                }
            }
        }
        if let Some(delete) = &envelope.delete_surface {
            self.validate_surface_id(&delete.surface_id)?;
        }
        Ok(())
    }

    fn validate_surface_id(&self, id: &str) -> Result<(), A2uiError> {
        if id.is_empty() || id.len() > 128 {
            return Err(A2uiError::Validation(
                "surface id must be 1-128 characters".into(),
            ));
        }
        Ok(())
    }

    pub(crate) fn validate_component(&self, component: &Value) -> Result<(), A2uiError> {
        let bytes = serde_json::to_vec(component).map_err(|e| A2uiError::Parse(e.to_string()))?;
        if bytes.len() > self.limits.max_component_bytes {
            return Err(A2uiError::LimitExceeded(format!(
                "component is {} bytes; max is {}",
                bytes.len(),
                self.limits.max_component_bytes
            )));
        }
        let obj = component
            .as_object()
            .ok_or_else(|| A2uiError::Validation("component must be an object".into()))?;
        let id = obj
            .get("id")
            .and_then(Value::as_str)
            .ok_or(A2uiError::MissingComponentId)?;
        if id.is_empty() || id.len() > 128 {
            return Err(A2uiError::Validation(
                "component id must be 1-128 characters".into(),
            ));
        }
        let component_type = obj
            .get("component")
            .and_then(Value::as_str)
            .ok_or_else(|| A2uiError::Validation("component type is required".into()))?;
        if !self.supported_components.contains(component_type) {
            return Err(A2uiError::UnsupportedComponent(component_type.into()));
        }
        Ok(())
    }
}

/// Extract A2UI envelopes from a raw JSON payload.
///
/// Accepts the direct envelope form and common A2A carrier shapes:
/// `{ "a2ui": { ... } }`, A2A `DataPart` values, and artifact/task
/// objects containing `parts`.
pub fn envelopes_from_value(value: &Value) -> Result<Vec<A2uiEnvelope>, A2uiError> {
    let mut out = Vec::new();
    collect_envelopes(value, &mut out)?;
    Ok(out)
}

pub fn owner_from_value(value: &Value) -> Option<A2uiSurfaceOwner> {
    match value {
        Value::Object(map) => {
            let task_id = map
                .get("taskId")
                .or_else(|| map.get("task_id"))
                .or_else(|| {
                    if map.contains_key("status")
                        && (map.contains_key("contextId") || map.contains_key("context_id"))
                    {
                        map.get("id")
                    } else {
                        None
                    }
                })
                .and_then(Value::as_str)
                .map(str::to_string);
            let context_id = map
                .get("contextId")
                .or_else(|| map.get("context_id"))
                .and_then(Value::as_str)
                .map(str::to_string);
            let direct = A2uiSurfaceOwner::a2a(task_id, context_id);
            if direct.is_some() {
                return direct;
            }
            for key in ["task", "artifact", "payload"] {
                if let Some(owner) = map.get(key).and_then(owner_from_value) {
                    return Some(owner);
                }
            }
            if let Some(items) = map.get("artifacts").and_then(Value::as_array) {
                for item in items {
                    if let Some(owner) = owner_from_value(item) {
                        return Some(owner);
                    }
                }
            }
            None
        }
        Value::Array(items) => items.iter().find_map(owner_from_value),
        _ => None,
    }
}

fn collect_envelopes(value: &Value, out: &mut Vec<A2uiEnvelope>) -> Result<(), A2uiError> {
    if looks_like_envelope(value) {
        let envelope: A2uiEnvelope =
            serde_json::from_value(value.clone()).map_err(|e| A2uiError::Parse(e.to_string()))?;
        envelope.validate()?;
        out.push(envelope);
        return Ok(());
    }

    match value {
        Value::Object(map) => {
            if let Some(a2ui) = map.get("a2ui") {
                collect_envelopes(a2ui, out)?;
            }
            if let Some(data) = map.get("data") {
                collect_envelopes(data, out)?;
            }
            if let Some(parts) = map.get("parts") {
                collect_envelopes(parts, out)?;
            }
            if let Some(artifact) = map.get("artifact") {
                collect_envelopes(artifact, out)?;
            }
            if let Some(artifacts) = map.get("artifacts") {
                collect_envelopes(artifacts, out)?;
            }
        }
        Value::Array(items) => {
            for item in items {
                collect_envelopes(item, out)?;
            }
        }
        _ => {}
    }
    Ok(())
}

fn looks_like_envelope(value: &Value) -> bool {
    value.as_object().is_some_and(|map| {
        map.contains_key("createSurface")
            || map.contains_key("updateComponents")
            || map.contains_key("patchComponents")
            || map.contains_key("updateDataModel")
            || map.contains_key("deleteSurface")
    })
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct A2uiSurface {
    pub surface_id: String,
    pub catalog_id: String,
    #[serde(default)]
    pub theme: Value,
    #[serde(default)]
    pub send_data_model: bool,
    #[serde(default)]
    pub components: BTreeMap<String, Value>,
    #[serde(default)]
    pub data_model: Value,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub owner: Option<A2uiSurfaceOwner>,
    pub updated_at: DateTime<Utc>,
}

impl A2uiSurface {
    pub fn has_root(&self) -> bool {
        self.components.contains_key("root")
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct A2uiApplyResult {
    pub surface_id: String,
    pub deleted: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub surface: Option<A2uiSurface>,
}

#[derive(Clone, Default)]
pub struct A2uiSurfaceStore {
    surfaces: Arc<RwLock<BTreeMap<String, A2uiSurface>>>,
    validator: A2uiValidator,
}

impl A2uiSurfaceStore {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn with_limits(limits: A2uiLimits) -> Self {
        Self {
            surfaces: Arc::new(RwLock::new(BTreeMap::new())),
            validator: A2uiValidator::new(limits),
        }
    }

    pub fn capabilities(&self) -> A2uiCapabilities {
        let mut c = capabilities();
        c.limits = self.validator.limits();
        c
    }

    pub fn validate_payload(&self, value: &Value) -> Result<(), A2uiError> {
        self.validator.validate_payload(value)
    }

    pub async fn apply(&self, envelope: A2uiEnvelope) -> Result<A2uiApplyResult, A2uiError> {
        self.apply_with_owner(envelope, None).await
    }

    pub async fn apply_with_owner(
        &self,
        envelope: A2uiEnvelope,
        owner: Option<A2uiSurfaceOwner>,
    ) -> Result<A2uiApplyResult, A2uiError> {
        self.validator.validate_envelope(&envelope)?;
        if let Some(msg) = envelope.create_surface {
            return self.create_surface(msg, owner).await;
        }
        if let Some(msg) = envelope.update_components {
            return self.update_components(msg, owner).await;
        }
        if let Some(msg) = envelope.patch_components {
            return self.patch_components(msg, owner).await;
        }
        if let Some(msg) = envelope.update_data_model {
            return self.update_data_model(msg, owner).await;
        }
        if let Some(msg) = envelope.delete_surface {
            return self.delete_surface(msg).await;
        }
        Err(A2uiError::InvalidEnvelope)
    }

    pub async fn list(&self) -> Vec<A2uiSurface> {
        self.surfaces.read().await.values().cloned().collect()
    }

    pub async fn get(&self, surface_id: &str) -> Option<A2uiSurface> {
        self.surfaces.read().await.get(surface_id).cloned()
    }

    pub async fn owner(&self, surface_id: &str) -> Option<A2uiSurfaceOwner> {
        self.surfaces
            .read()
            .await
            .get(surface_id)
            .and_then(|surface| surface.owner.clone())
    }

    pub async fn reap_expired(&self, now: DateTime<Utc>) -> Vec<String> {
        let max_age_secs = self.validator.limits().max_surface_age_secs;
        let mut surfaces = self.surfaces.write().await;
        let expired: Vec<String> = surfaces
            .iter()
            .filter_map(|(id, surface)| {
                let age = now.signed_duration_since(surface.updated_at).num_seconds();
                (age > max_age_secs).then(|| id.clone())
            })
            .collect();
        for id in &expired {
            surfaces.remove(id);
        }
        expired
    }

    async fn create_surface(
        &self,
        msg: CreateSurface,
        owner: Option<A2uiSurfaceOwner>,
    ) -> Result<A2uiApplyResult, A2uiError> {
        let mut surfaces = self.surfaces.write().await;
        if !surfaces.contains_key(&msg.surface_id)
            && surfaces.len() >= self.validator.limits().max_surfaces
        {
            return Err(A2uiError::LimitExceeded(format!(
                "surface count is {}; max is {}",
                surfaces.len(),
                self.validator.limits().max_surfaces
            )));
        }
        let surface = A2uiSurface {
            surface_id: msg.surface_id.clone(),
            catalog_id: msg.catalog_id,
            theme: msg.theme,
            send_data_model: msg.send_data_model,
            components: BTreeMap::new(),
            data_model: Value::Object(Map::new()),
            owner,
            updated_at: Utc::now(),
        };
        surfaces.insert(msg.surface_id.clone(), surface.clone());
        Ok(A2uiApplyResult {
            surface_id: msg.surface_id,
            deleted: false,
            surface: Some(surface),
        })
    }

    async fn update_components(
        &self,
        msg: UpdateComponents,
        owner: Option<A2uiSurfaceOwner>,
    ) -> Result<A2uiApplyResult, A2uiError> {
        let mut surfaces = self.surfaces.write().await;
        let surface = surfaces
            .get_mut(&msg.surface_id)
            .ok_or_else(|| A2uiError::UnknownSurface(msg.surface_id.clone()))?;
        if owner.is_some() {
            surface.owner = owner;
        }
        // Desugar CAR convenience forms to spec shapes on ingest (may add a
        // synthetic Text child for Button labels), so the stored surface —
        // and everything served from it — is A2UI v0.9-compliant.
        let components = normalize_components(msg.components);
        let mut final_ids: BTreeSet<String> = surface.components.keys().cloned().collect();
        for component in &components {
            let id = component
                .get("id")
                .and_then(Value::as_str)
                .ok_or(A2uiError::MissingComponentId)?;
            final_ids.insert(id.to_string());
        }
        if final_ids.len() > self.validator.limits().max_components_per_surface {
            return Err(A2uiError::LimitExceeded(format!(
                "surface would exceed {} components",
                self.validator.limits().max_components_per_surface
            )));
        }
        for component in components {
            let id = component
                .get("id")
                .and_then(Value::as_str)
                .ok_or(A2uiError::MissingComponentId)?;
            surface.components.insert(id.to_string(), component);
        }
        surface.updated_at = Utc::now();
        Ok(A2uiApplyResult {
            surface_id: msg.surface_id,
            deleted: false,
            surface: Some(surface.clone()),
        })
    }

    /// Apply an RFC 6902 JSON-Patch over the surface's component
    /// tree. The patch targets the `components` map directly: paths
    /// like `/<componentId>` add/remove whole components, and paths
    /// like `/<componentId>/<prop>` mutate fields inside one.
    ///
    /// All-or-nothing semantics: if the json-patch crate's apply
    /// step errors on any op, the surface stays unchanged so a
    /// botched improvement-pass can never leave the surface in a
    /// half-applied state. The renderer's focus/scroll-preservation
    /// contract (docs/a2ui-renderer-contract.md) does the rest.
    async fn patch_components(
        &self,
        msg: PatchComponents,
        owner: Option<A2uiSurfaceOwner>,
    ) -> Result<A2uiApplyResult, A2uiError> {
        let mut surfaces = self.surfaces.write().await;
        let surface = surfaces
            .get_mut(&msg.surface_id)
            .ok_or_else(|| A2uiError::UnknownSurface(msg.surface_id.clone()))?;
        if owner.is_some() {
            surface.owner = owner;
        }
        // Operate on a JSON projection of the components map so we
        // can hand the whole thing to json-patch and get back the
        // either-fully-applied-or-original state.
        let mut as_value: Value = serde_json::to_value(&surface.components)
            .map_err(|e| A2uiError::Parse(e.to_string()))?;
        let parsed: json_patch::Patch = serde_json::from_value(msg.patch.clone())
            .map_err(|e| A2uiError::Validation(format!("invalid JSON-Patch: {e}")))?;
        json_patch::patch(&mut as_value, &parsed)
            .map_err(|e| A2uiError::Validation(format!("patch apply failed: {e}")))?;
        // Convert back; the patch document may have introduced new
        // components or removed existing ones — re-parse the map.
        let next_components: BTreeMap<String, Value> = serde_json::from_value(as_value)
            .map_err(|e| A2uiError::Validation(format!("patch produced non-map result: {e}")))?;
        // Enforce the same per-surface component cap that
        // update_components does — a patch that adds 1000 components
        // through `add` ops shouldn't bypass the limit.
        if next_components.len() > self.validator.limits().max_components_per_surface {
            return Err(A2uiError::LimitExceeded(format!(
                "surface would exceed {} components after patch",
                self.validator.limits().max_components_per_surface
            )));
        }
        // Each surviving component must still pass the per-component
        // validator (size, supported type, id shape). A patch that
        // injected an unsupported component type is rejected.
        for component in next_components.values() {
            self.validator.validate_component(component)?;
        }
        surface.components = next_components;
        surface.updated_at = Utc::now();
        Ok(A2uiApplyResult {
            surface_id: msg.surface_id,
            deleted: false,
            surface: Some(surface.clone()),
        })
    }

    async fn update_data_model(
        &self,
        msg: UpdateDataModel,
        owner: Option<A2uiSurfaceOwner>,
    ) -> Result<A2uiApplyResult, A2uiError> {
        let mut surfaces = self.surfaces.write().await;
        let surface = surfaces
            .get_mut(&msg.surface_id)
            .ok_or_else(|| A2uiError::UnknownSurface(msg.surface_id.clone()))?;
        if owner.is_some() {
            surface.owner = owner;
        }
        let path = msg.path.as_deref().unwrap_or("/");
        let mut next_data_model = surface.data_model.clone();
        match msg.value {
            Some(value) if path == "/" || path.is_empty() => next_data_model = value,
            Some(value) => set_json_pointer(&mut next_data_model, path, value)?,
            None if path == "/" || path.is_empty() => next_data_model = Value::Object(Map::new()),
            None => remove_json_pointer(&mut next_data_model, path)?,
        }
        let bytes =
            serde_json::to_vec(&next_data_model).map_err(|e| A2uiError::Parse(e.to_string()))?;
        if bytes.len() > self.validator.limits().max_data_model_bytes {
            return Err(A2uiError::LimitExceeded(format!(
                "data model is {} bytes; max is {}",
                bytes.len(),
                self.validator.limits().max_data_model_bytes
            )));
        }
        surface.data_model = next_data_model;
        surface.updated_at = Utc::now();
        Ok(A2uiApplyResult {
            surface_id: msg.surface_id,
            deleted: false,
            surface: Some(surface.clone()),
        })
    }

    async fn delete_surface(&self, msg: DeleteSurface) -> Result<A2uiApplyResult, A2uiError> {
        self.surfaces.write().await.remove(&msg.surface_id);
        Ok(A2uiApplyResult {
            surface_id: msg.surface_id,
            deleted: true,
            surface: None,
        })
    }
}

fn set_json_pointer(root: &mut Value, path: &str, value: Value) -> Result<(), A2uiError> {
    let parts = pointer_parts(path)?;
    if parts.is_empty() {
        *root = value;
        return Ok(());
    }
    if !root.is_object() {
        *root = Value::Object(Map::new());
    }
    let mut current = root;
    for part in &parts[..parts.len() - 1] {
        match current {
            Value::Object(map) => {
                current = map
                    .entry(part.clone())
                    .or_insert_with(|| Value::Object(Map::new()));
                if !current.is_object() {
                    *current = Value::Object(Map::new());
                }
            }
            _ => return Err(A2uiError::InvalidPath(path.to_string())),
        }
    }
    match current {
        Value::Object(map) => {
            map.insert(parts.last().cloned().unwrap_or_default(), value);
            Ok(())
        }
        _ => Err(A2uiError::InvalidPath(path.to_string())),
    }
}

fn remove_json_pointer(root: &mut Value, path: &str) -> Result<(), A2uiError> {
    let parts = pointer_parts(path)?;
    if parts.is_empty() {
        *root = Value::Null;
        return Ok(());
    }
    let mut current = root;
    for part in &parts[..parts.len() - 1] {
        match current {
            Value::Object(map) => {
                let Some(next) = map.get_mut(part) else {
                    return Ok(());
                };
                current = next;
            }
            _ => return Ok(()),
        }
    }
    if let Value::Object(map) = current {
        map.remove(parts.last().unwrap());
    }
    Ok(())
}

fn pointer_parts(path: &str) -> Result<Vec<String>, A2uiError> {
    if path.is_empty() || path == "/" {
        return Ok(Vec::new());
    }
    if !path.starts_with('/') {
        return Err(A2uiError::InvalidPath(path.to_string()));
    }
    Ok(path
        .split('/')
        .skip(1)
        .map(|part| part.replace("~1", "/").replace("~0", "~"))
        .collect())
}

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

    #[test]
    fn normalize_button_action_string_to_event_object() {
        let out = normalize_components(vec![
            json!({ "id": "b", "component": "Button", "action": "go", "child": "t" }),
        ]);
        assert_eq!(out.len(), 1);
        assert_eq!(out[0]["action"], json!({ "event": { "name": "go" } }));
    }

    #[test]
    fn normalize_button_label_to_synthetic_text_child() {
        let out = normalize_components(vec![
            json!({ "id": "b", "component": "Button", "label": "Save", "action": "save" }),
        ]);
        // Synthetic Text child is emitted before the Button.
        assert_eq!(out.len(), 2);
        assert_eq!(
            out[0],
            json!({ "id": "b__label", "component": "Text", "text": "Save" })
        );
        assert_eq!(out[1]["child"], json!("b__label"));
        assert!(out[1].get("label").is_none(), "label is removed");
        assert_eq!(out[1]["action"], json!({ "event": { "name": "save" } }));
    }

    #[test]
    fn normalize_is_idempotent_on_spec_forms() {
        // Already-spec Button (object action + child) passes through unchanged
        // and produces no extra synthetic Text.
        let spec = json!({ "id": "b", "component": "Button",
            "action": { "event": { "name": "go" } }, "child": "t" });
        let out = normalize_components(vec![spec.clone()]);
        assert_eq!(out, vec![spec]);
    }

    #[test]
    fn normalize_strips_action_from_form_components() {
        for kind in [
            "TextField",
            "CheckBox",
            "Select",
            "Slider",
            "ChoicePicker",
            "DateTimeInput",
        ] {
            let out = normalize_components(vec![json!({
                "id": "f", "component": kind,
                "value": { "path": "/v" }, "action": "f.change"
            })]);
            assert_eq!(out.len(), 1);
            assert!(
                out[0].get("action").is_none(),
                "{kind}: action must be stripped"
            );
            // the data binding is preserved
            assert_eq!(
                out[0]["value"],
                json!({ "path": "/v" }),
                "{kind}: value binding kept"
            );
        }
    }

    #[test]
    fn normalize_image_src_alt_to_url_description() {
        let out = normalize_components(vec![
            json!({ "id": "i", "component": "Image", "src": "data:...", "alt": "pic" }),
        ]);
        assert_eq!(out[0]["url"], json!("data:..."));
        assert_eq!(out[0]["description"], json!("pic"));
        assert!(out[0].get("src").is_none() && out[0].get("alt").is_none());
    }

    #[tokio::test]
    async fn store_normalizes_convenience_button_to_spec() {
        let store = A2uiSurfaceStore::new();
        store
            .apply(A2uiEnvelope {
                create_surface: Some(CreateSurface {
                    surface_id: "s".into(),
                    catalog_id: BASIC_CATALOG_V0_9.into(),
                    theme: Value::Null,
                    send_data_model: false,
                }),
                ..Default::default()
            })
            .await
            .unwrap();
        let res = store
            .apply(A2uiEnvelope {
                update_components: Some(UpdateComponents {
                    surface_id: "s".into(),
                    components: vec![
                        json!({ "id": "b", "component": "Button", "label": "Go", "action": "go" }),
                    ],
                }),
                ..Default::default()
            })
            .await
            .unwrap();
        let surface = res.surface.unwrap();
        // The Button is desugared and a synthetic Text child added.
        let button = &surface.components["b"];
        assert_eq!(button["action"], json!({ "event": { "name": "go" } }));
        assert_eq!(button["child"], json!("b__label"));
        assert!(button.get("label").is_none());
        assert_eq!(
            surface.components["b__label"],
            json!({ "id": "b__label", "component": "Text", "text": "Go" })
        );
    }

    #[tokio::test]
    async fn applies_surface_component_and_data_updates() {
        let store = A2uiSurfaceStore::new();
        store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: Some(CreateSurface {
                    surface_id: "main".into(),
                    catalog_id: BASIC_CATALOG_V0_9.into(),
                    theme: Value::Null,
                    send_data_model: true,
                }),
                update_components: None,
                patch_components: None,
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap();
        store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: None,
                update_components: Some(UpdateComponents {
                    surface_id: "main".into(),
                    components: vec![
                        json!({"id":"root","component":"Text","text":{"path":"/title"}}),
                    ],
                }),
                patch_components: None,
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap();
        let result = store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: None,
                update_components: None,
                patch_components: None,
                update_data_model: Some(UpdateDataModel {
                    surface_id: "main".into(),
                    path: Some("/title".into()),
                    value: Some(json!("Hello")),
                }),
                delete_surface: None,
            })
            .await
            .unwrap();
        let surface = result.surface.unwrap();
        assert!(surface.has_root());
        assert_eq!(surface.data_model.pointer("/title"), Some(&json!("Hello")));
    }

    #[test]
    fn extracts_envelopes_from_a2a_artifact_payloads() {
        let payload = json!({
            "taskId": "task-1",
            "contextId": "ctx-1",
            "artifactId": "art-1",
            "parts": [{
                "kind": "data",
                "data": {
                    "a2ui": {
                        "version": "v0.9",
                        "createSurface": { "surfaceId": "main" }
                    }
                }
            }]
        });
        let envelopes = envelopes_from_value(&payload).unwrap();
        assert_eq!(envelopes.len(), 1);
        assert_eq!(
            envelopes[0].create_surface.as_ref().unwrap().surface_id,
            "main"
        );
        assert_eq!(
            owner_from_value(&payload),
            Some(A2uiSurfaceOwner {
                kind: "a2a".into(),
                task_id: Some("task-1".into()),
                context_id: Some("ctx-1".into()),
                endpoint: None,
            })
        );
    }

    #[test]
    fn extracts_owner_from_a2a_task_shape() {
        let payload = json!({
            "id": "task-1",
            "contextId": "ctx-1",
            "status": { "state": "completed" },
            "artifacts": []
        });
        assert_eq!(
            owner_from_value(&payload),
            Some(A2uiSurfaceOwner {
                kind: "a2a".into(),
                task_id: Some("task-1".into()),
                context_id: Some("ctx-1".into()),
                endpoint: None,
            })
        );
    }

    #[tokio::test]
    async fn apply_with_owner_persists_routing_target() {
        let store = A2uiSurfaceStore::new();
        let owner = A2uiSurfaceOwner {
            kind: "a2a".into(),
            task_id: Some("task-1".into()),
            context_id: Some("ctx-1".into()),
            endpoint: Some("http://127.0.0.1:9101".into()),
        };
        store
            .apply_with_owner(
                A2uiEnvelope {
                    version: A2UI_VERSION_V0_9.into(),
                    create_surface: Some(CreateSurface {
                        surface_id: "main".into(),
                        catalog_id: BASIC_CATALOG_V0_9.into(),
                        theme: Value::Null,
                        send_data_model: false,
                    }),
                    update_components: None,
                    patch_components: None,
                    update_data_model: None,
                    delete_surface: None,
                },
                Some(owner.clone()),
            )
            .await
            .unwrap();
        assert_eq!(store.owner("main").await, Some(owner));
    }

    #[tokio::test]
    async fn rejects_unsupported_components() {
        let store = A2uiSurfaceStore::new();
        store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: Some(CreateSurface {
                    surface_id: "main".into(),
                    catalog_id: BASIC_CATALOG_V0_9.into(),
                    theme: Value::Null,
                    send_data_model: false,
                }),
                update_components: None,
                patch_components: None,
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap();

        let err = store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: None,
                update_components: Some(UpdateComponents {
                    surface_id: "main".into(),
                    components: vec![json!({"id":"root","component":"RawHtml","html":"<b>x</b>"})],
                }),
                patch_components: None,
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap_err();
        assert!(matches!(err, A2uiError::UnsupportedComponent(_)));
    }

    #[tokio::test]
    async fn enforces_surface_and_payload_limits() {
        let limits = A2uiLimits {
            max_surfaces: 1,
            max_payload_bytes: 8,
            ..A2uiLimits::default()
        };
        let store = A2uiSurfaceStore::with_limits(limits);
        assert!(matches!(
            store.validate_payload(&json!({"too":"large"})),
            Err(A2uiError::LimitExceeded(_))
        ));
        for id in ["one", "two"] {
            let result = store
                .apply(A2uiEnvelope {
                    version: A2UI_VERSION_V0_9.into(),
                    create_surface: Some(CreateSurface {
                        surface_id: id.into(),
                        catalog_id: BASIC_CATALOG_V0_9.into(),
                        theme: Value::Null,
                        send_data_model: false,
                    }),
                    update_components: None,
                    patch_components: None,
                    update_data_model: None,
                    delete_surface: None,
                })
                .await;
            if id == "one" {
                result.unwrap();
            } else {
                assert!(matches!(result.unwrap_err(), A2uiError::LimitExceeded(_)));
            }
        }
    }

    #[tokio::test]
    async fn replacing_component_at_limit_does_not_count_as_new_component() {
        let store = A2uiSurfaceStore::with_limits(A2uiLimits {
            max_components_per_surface: 1,
            ..A2uiLimits::default()
        });
        store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: Some(CreateSurface {
                    surface_id: "main".into(),
                    catalog_id: BASIC_CATALOG_V0_9.into(),
                    theme: Value::Null,
                    send_data_model: false,
                }),
                update_components: None,
                patch_components: None,
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap();
        store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: None,
                update_components: Some(UpdateComponents {
                    surface_id: "main".into(),
                    components: vec![json!({"id":"root","component":"Text","text":"first"})],
                }),
                patch_components: None,
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap();

        let result = store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: None,
                update_components: Some(UpdateComponents {
                    surface_id: "main".into(),
                    components: vec![json!({"id":"root","component":"Text","text":"second"})],
                }),
                patch_components: None,
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap();
        assert_eq!(
            result.surface.unwrap().components["root"]["text"],
            json!("second")
        );

        let err = store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: None,
                update_components: Some(UpdateComponents {
                    surface_id: "main".into(),
                    components: vec![json!({"id":"other","component":"Text","text":"third"})],
                }),
                patch_components: None,
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap_err();
        assert!(matches!(err, A2uiError::LimitExceeded(_)));
    }

    #[tokio::test]
    async fn oversized_data_update_does_not_mutate_existing_model() {
        let store = A2uiSurfaceStore::with_limits(A2uiLimits {
            max_data_model_bytes: 16,
            ..A2uiLimits::default()
        });
        store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: Some(CreateSurface {
                    surface_id: "main".into(),
                    catalog_id: BASIC_CATALOG_V0_9.into(),
                    theme: Value::Null,
                    send_data_model: false,
                }),
                update_components: None,
                patch_components: None,
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap();
        store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: None,
                update_components: None,
                patch_components: None,
                update_data_model: Some(UpdateDataModel {
                    surface_id: "main".into(),
                    path: Some("/ok".into()),
                    value: Some(json!("x")),
                }),
                delete_surface: None,
            })
            .await
            .unwrap();

        let err = store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: None,
                update_components: None,
                patch_components: None,
                update_data_model: Some(UpdateDataModel {
                    surface_id: "main".into(),
                    path: Some("/tooLarge".into()),
                    value: Some(json!("this value is too large")),
                }),
                delete_surface: None,
            })
            .await
            .unwrap_err();
        assert!(matches!(err, A2uiError::LimitExceeded(_)));
        let surface = store.get("main").await.unwrap();
        assert_eq!(surface.data_model, json!({"ok":"x"}));
    }

    #[tokio::test]
    async fn patch_rejects_empty_op_array_and_non_object_add_value() {
        let store = A2uiSurfaceStore::new();
        store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: Some(CreateSurface {
                    surface_id: "main".into(),
                    catalog_id: BASIC_CATALOG_V0_9.into(),
                    theme: Value::Null,
                    send_data_model: false,
                }),
                update_components: None,
                patch_components: None,
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap();
        // Empty array — no-op patches bump updated_at and broadcast
        // events for nothing. Reject.
        let err = store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: None,
                update_components: None,
                patch_components: Some(PatchComponents {
                    surface_id: "main".into(),
                    patch: json!([]),
                    in_response_to: None,
                }),
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap_err();
        assert!(matches!(err, A2uiError::Validation(_)));
        // One-segment `add` with a non-object value would crash the
        // post-apply BTreeMap deserialize with a confusing message.
        // Catch it at envelope validation.
        let err = store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: None,
                update_components: None,
                patch_components: Some(PatchComponents {
                    surface_id: "main".into(),
                    patch: json!([
                        { "op": "add", "path": "/x", "value": "not-an-object" }
                    ]),
                    in_response_to: None,
                }),
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap_err();
        assert!(matches!(err, A2uiError::Validation(_)));
    }

    #[tokio::test]
    async fn patch_rejects_unsupported_component_type_after_add() {
        let store = A2uiSurfaceStore::new();
        store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: Some(CreateSurface {
                    surface_id: "main".into(),
                    catalog_id: BASIC_CATALOG_V0_9.into(),
                    theme: Value::Null,
                    send_data_model: false,
                }),
                update_components: None,
                patch_components: None,
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap();
        // Seed with a valid Text component.
        store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: None,
                update_components: Some(UpdateComponents {
                    surface_id: "main".into(),
                    components: vec![json!({"id":"hello","component":"Text","text":"hi"})],
                }),
                patch_components: None,
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap();
        // Patch injects a component of a type that's NOT in the
        // basic catalog. patch_components must reject; surface stays
        // unchanged.
        let err = store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: None,
                update_components: None,
                patch_components: Some(PatchComponents {
                    surface_id: "main".into(),
                    patch: json!([
                        { "op": "add", "path": "/bad", "value": { "id": "bad", "component": "Unobtainium", "label": "x" } }
                    ]),
                    in_response_to: None,
                }),
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap_err();
        assert!(matches!(err, A2uiError::UnsupportedComponent(_)));
        let surface = store.get("main").await.unwrap();
        assert!(surface.components.contains_key("hello"));
        assert!(!surface.components.contains_key("bad"));
    }

    #[tokio::test]
    async fn patch_remove_then_replace_round_trips() {
        let store = A2uiSurfaceStore::new();
        store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: Some(CreateSurface {
                    surface_id: "main".into(),
                    catalog_id: BASIC_CATALOG_V0_9.into(),
                    theme: Value::Null,
                    send_data_model: false,
                }),
                update_components: None,
                patch_components: None,
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap();
        store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: None,
                update_components: Some(UpdateComponents {
                    surface_id: "main".into(),
                    components: vec![
                        json!({"id":"root","component":"Column","children":["a","b"]}),
                        json!({"id":"a","component":"Text","text":"alpha"}),
                        json!({"id":"b","component":"Text","text":"beta"}),
                    ],
                }),
                patch_components: None,
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap();
        let result = store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: None,
                update_components: None,
                patch_components: Some(PatchComponents {
                    surface_id: "main".into(),
                    patch: json!([
                        { "op": "replace", "path": "/a/text", "value": "ALPHA" },
                        { "op": "remove", "path": "/b" }
                    ]),
                    in_response_to: None,
                }),
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap();
        let surface = result.surface.unwrap();
        assert_eq!(
            surface.components.get("a").unwrap().get("text").unwrap(),
            &json!("ALPHA")
        );
        assert!(!surface.components.contains_key("b"));
    }

    #[tokio::test]
    async fn reap_expired_removes_old_surfaces() {
        let store = A2uiSurfaceStore::with_limits(A2uiLimits {
            max_surface_age_secs: 0,
            ..A2uiLimits::default()
        });
        store
            .apply(A2uiEnvelope {
                version: A2UI_VERSION_V0_9.into(),
                create_surface: Some(CreateSurface {
                    surface_id: "old".into(),
                    catalog_id: BASIC_CATALOG_V0_9.into(),
                    theme: Value::Null,
                    send_data_model: false,
                }),
                update_components: None,
                patch_components: None,
                update_data_model: None,
                delete_surface: None,
            })
            .await
            .unwrap();
        let removed = store
            .reap_expired(Utc::now() + chrono::Duration::seconds(1))
            .await;
        assert_eq!(removed, vec!["old".to_string()]);
        assert!(store.get("old").await.is_none());
    }

    // ---------------------------------------------------------------------
    // Parslee-ai/car#184 — render_report correlation field promotion tests
    // ---------------------------------------------------------------------

    fn report_with_metadata(meta: BTreeMap<String, Value>) -> RenderReport {
        RenderReport {
            surface_id: "main".into(),
            signature: Value::Null,
            density: None,
            visible_field_count: None,
            action_count: None,
            viewport: Value::Null,
            hidden_via_overflow: Vec::new(),
            strategy_id: None,
            timestamp: Utc::now(),
            report_id: None,
            sequence: None,
            placeholder_count: None,
            signature_stable_since: None,
            metadata: meta,
        }
    }

    #[test]
    fn render_report_typed_fields_round_trip_with_camel_case_wire_names() {
        let mut r = report_with_metadata(BTreeMap::new());
        r.report_id = Some("uuid-abc".into());
        r.sequence = Some(42);
        r.placeholder_count = Some(0);
        r.signature_stable_since = Some(38);
        let wire = serde_json::to_value(&r).unwrap();
        // Top-level wire names follow the struct's camelCase
        // convention — consistent with `surfaceId`, `visibleFieldCount`,
        // etc. Legacy emitters that wrote snake_case keys into the
        // `metadata` bag are handled by the `effective_*` accessors.
        // An earlier draft used `#[serde(rename = "report_id")]` to
        // force snake_case at the top level, but that bought
        // mixed-case JSON for no observable benefit (legacy
        // emitters write to metadata, not the new fields, and
        // never read the new fields). Linus called it out;
        // Parslee-ai/car#184 review.
        assert_eq!(wire["reportId"], json!("uuid-abc"));
        assert_eq!(wire["sequence"], json!(42));
        assert_eq!(wire["placeholderCount"], json!(0));
        assert_eq!(wire["signatureStableSince"], json!(38));
        let parsed: RenderReport = serde_json::from_value(wire).unwrap();
        assert_eq!(parsed.report_id.as_deref(), Some("uuid-abc"));
        assert_eq!(parsed.sequence, Some(42));
        assert_eq!(parsed.placeholder_count, Some(0));
        assert_eq!(parsed.signature_stable_since, Some(38));
    }

    #[test]
    fn patch_components_carries_in_response_to_for_correlation() {
        let p = PatchComponents {
            surface_id: "main".into(),
            patch: json!([]),
            in_response_to: Some("uuid-abc".into()),
        };
        let wire = serde_json::to_value(&p).unwrap();
        assert_eq!(wire["inResponseTo"], json!("uuid-abc"));
        let parsed: PatchComponents = serde_json::from_value(wire).unwrap();
        assert_eq!(parsed.in_response_to.as_deref(), Some("uuid-abc"));
    }

    #[test]
    fn patch_components_omits_in_response_to_when_absent() {
        let p = PatchComponents {
            surface_id: "main".into(),
            patch: json!([]),
            in_response_to: None,
        };
        let wire = serde_json::to_value(&p).unwrap();
        assert!(wire.get("inResponseTo").is_none());
    }

    #[test]
    fn render_report_effective_accessors_fall_back_to_metadata() {
        // Pre-#184 renderer: typed fields absent, values in metadata.
        let r = report_with_metadata(BTreeMap::from([
            ("report_id".to_string(), json!("legacy-uuid")),
            ("sequence".to_string(), json!(7)),
            ("placeholder_count".to_string(), json!(0)),
            ("signature_stable_since".to_string(), json!(5)),
        ]));
        assert_eq!(r.effective_report_id(), Some("legacy-uuid"));
        assert_eq!(r.effective_sequence(), Some(7));
        assert_eq!(r.effective_placeholder_count(), Some(0));
        assert_eq!(r.effective_signature_stable_since(), Some(5));
    }

    #[test]
    fn render_report_typed_fields_win_over_metadata_when_both_present() {
        // A renderer mid-migration emits both. Typed field is the
        // canonical source — metadata is the legacy fallback only.
        let mut r = report_with_metadata(BTreeMap::from([
            ("report_id".to_string(), json!("metadata-uuid")),
            ("sequence".to_string(), json!(1)),
        ]));
        r.report_id = Some("typed-uuid".into());
        r.sequence = Some(99);
        assert_eq!(r.effective_report_id(), Some("typed-uuid"));
        assert_eq!(r.effective_sequence(), Some(99));
    }

    #[test]
    fn render_report_effective_accessors_handle_metadata_type_mismatches() {
        // A renderer with a buggy emitter writes the count as a
        // string. effective_placeholder_count returns None rather
        // than panicking — agents see "no signal" and pick whatever
        // default makes sense.
        let r = report_with_metadata(BTreeMap::from([(
            "placeholder_count".to_string(),
            json!("not a number"),
        )]));
        assert_eq!(r.effective_placeholder_count(), None);
    }
}
