use car_a2ui::{A2uiSurface, PatchComponents, RenderReport};
use std::hash::{Hash, Hasher};
use std::time::Instant;

use crate::state::DecisionState;
use crate::strategies::{BuiltInStrategies, RenderingStrategy};
use crate::types::{Decision, IntentSignature};

/// Default stability window: number of consecutive renders with an
/// unchanged signature before the agent considers the surface stable
/// and stops proposing. Small enough that fixtures converge in a
/// handful of iterations; large enough that one transient match
/// doesn't lock the agent out.
pub const DEFAULT_STABILITY_WINDOW: u64 = 3;

/// In-process UI-improvement agent. Held by the car-server-core as a
/// shared instance; invoked from the `handle_a2ui_render_report`
/// handler with each inbound `RenderReport`.
///
/// Pure decision logic — no I/O, no async, no persistence handles.
/// Persistence (memgine ingest of decision outcomes, durable budget
/// state) is the caller's responsibility because async + lock-type
/// (`std::sync` vs `tokio::sync`) choices belong to the runtime, not
/// the strategy core.
pub struct UIImprovementAgent {
    strategies: Vec<Box<dyn RenderingStrategy>>,
    stability_window: u64,
    state: DecisionState,
}

impl UIImprovementAgent {
    /// Construct with the built-in strategy set and the default
    /// stability window.
    pub fn with_default_strategies() -> Self {
        Self::new(BuiltInStrategies::default_set(), DEFAULT_STABILITY_WINDOW)
    }

    /// Construct with a caller-supplied strategy list. Order matters
    /// — the first strategy with a non-empty proposal wins. Pass
    /// [`DEFAULT_STABILITY_WINDOW`] unless tests need a tighter or
    /// looser termination condition.
    pub fn new(strategies: Vec<Box<dyn RenderingStrategy>>, stability_window: u64) -> Self {
        Self {
            strategies,
            stability_window,
            state: DecisionState::new(),
        }
    }

    /// Consume a single render report. Sync function called from
    /// car-server-core's async render-report handler. Returns a
    /// `Decision`; the caller routes any `Patch` envelope through
    /// the surface store's `apply_a2ui_envelope` path.
    pub fn on_render_report(&self, report: &RenderReport, surface: &A2uiSurface) -> Decision {
        let started = Instant::now();

        // Hard-stop: renderer painted unknown components. Loud
        // invariant violation — improvement loop MUST pause.
        if let Some(n) = report.effective_placeholder_count() {
            if n > 0 {
                return Decision::HardStop {
                    reason: format!(
                        "placeholder_count={} — renderer painted unknown components",
                        n
                    ),
                };
            }
        }

        // Stability window: signature unchanged for N renders means
        // there's nothing more for the agent to do here.
        if let (Some(seq), Some(stable_since)) =
            (report.effective_sequence(), report.signature_stable_since)
        {
            if seq.saturating_sub(stable_since) >= self.stability_window {
                return Decision::StableNoChange;
            }
        }

        // Defense against double-patching the same render. Token
        // shape: prefer the monotone sequence, fall back to the
        // report_id UUID, last resort a signature-content hash so
        // identical signatures don't cycle the same patch on every
        // render. Falling back to signature-hash is the
        // sequence-None branch linus flagged: without this, a
        // renderer that omits sequence + report_id silently
        // double-patches every emit.
        let token = dedup_token(report);
        if self.state.already_decided_with(&report.surface_id, &token) {
            return Decision::StableNoChange;
        }

        // Decode the structured signature; bail to no-change if the
        // renderer emitted something we can't parse.
        let signature = match IntentSignature::from_value(&report.signature) {
            Ok(s) => s,
            Err(e) => {
                tracing::warn!(
                    surface_id = %report.surface_id,
                    error = %e,
                    "render report signature failed to decode; skipping"
                );
                return Decision::StableNoChange;
            }
        };

        // First strategy with a non-empty proposal wins. Candidate
        // trace logged at debug so silent overlap surfaces in
        // production traces (neo's observability ask).
        let mut candidates_considered: usize = 0;
        for strategy in &self.strategies {
            candidates_considered += 1;
            let Some(patch) = strategy.proposes(report, &signature, surface) else {
                continue;
            };
            let Some(ops) = patch.as_array() else {
                continue;
            };
            if ops.is_empty() {
                continue;
            }

            // Strategy contract: the first op MUST be a `test`
            // precondition guarding the assumption the strategy is
            // making. Without it, a concurrent surface mutation
            // between agent-decision and patch-apply can corrupt
            // state. Rejected in both debug and release — silently
            // applying broken patches is strictly worse than
            // declining. Strategies that genuinely don't need a
            // precondition can emit a vacuous test (e.g.
            // `{op:"test", path:"", value:<surface-snapshot>}` —
            // ugly, intentional). The two builtins satisfy this
            // trivially.
            let first_op_kind = ops
                .first()
                .and_then(|o| o.get("op"))
                .and_then(|v| v.as_str());
            if first_op_kind != Some("test") {
                tracing::warn!(
                    surface_id = %report.surface_id,
                    strategy = %strategy.id(),
                    "strategy returned patch without leading test precondition; \
                     dropping — concurrent mutations make this unsafe"
                );
                continue;
            }

            self.state.record(&report.surface_id, token);
            let patch_hash = hash_patch(&patch);
            tracing::debug!(
                surface_id = %report.surface_id,
                strategy = %strategy.id(),
                candidates_considered,
                patch_hash,
                elapsed_us = started.elapsed().as_micros() as u64,
                "ui-agent decided patch"
            );

            return Decision::Patch {
                envelope: PatchComponents {
                    surface_id: report.surface_id.clone(),
                    patch,
                    in_response_to: report.effective_report_id().map(String::from),
                },
                strategy_id: strategy.id().to_string(),
                patch_hash,
                elapsed_ns: started.elapsed().as_nanos(),
            };
        }

        tracing::debug!(
            surface_id = %report.surface_id,
            candidates_considered,
            elapsed_us = started.elapsed().as_micros() as u64,
            "ui-agent no proposal"
        );
        Decision::StableNoChange
    }

    /// Clear per-surface decision state. Use when the surface was
    /// deleted and re-created (the renderer's sequence counter
    /// resets and we shouldn't apply stale double-patch guards).
    pub fn reset(&self, surface_id: &str) {
        self.state.reset(surface_id);
    }
}

/// Per-report dedup token. Order: monotone sequence, then UUID, then
/// signature-content hash. Last branch is the degraded-mode path for
/// renderers that omit both sequence and report_id from the
/// `RenderReport` envelope (the schema permits it).
fn dedup_token(report: &RenderReport) -> String {
    if let Some(seq) = report.effective_sequence() {
        return format!("seq:{}", seq);
    }
    if let Some(id) = report.effective_report_id() {
        return format!("id:{}", id);
    }
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    serde_json::to_string(&report.signature)
        .unwrap_or_default()
        .hash(&mut hasher);
    format!("sig:{:x}", hasher.finish())
}

/// Stable fingerprint of a JSON-Patch ops array. Used by the runtime
/// convergence monitor (when it lands) to detect oscillation across
/// renders without re-hashing.
fn hash_patch(patch: &serde_json::Value) -> u64 {
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    serde_json::to_string(patch)
        .unwrap_or_default()
        .hash(&mut hasher);
    hasher.finish()
}

#[cfg(test)]
mod tests {
    use super::*;
    use car_a2ui::{A2uiSurface, RenderReport};
    use chrono::Utc;
    use serde_json::{json, Value};
    use std::collections::BTreeMap;

    fn small_list_surface(domain: Option<&str>) -> A2uiSurface {
        let mut props = serde_json::Map::new();
        props.insert("items".to_string(), json!({"path": "/items"}));
        if let Some(d) = domain {
            props.insert("x-renderer".to_string(), json!({"domain": d}));
        }
        let mut components = BTreeMap::new();
        // Fixture stores the component discriminator under `component`
        // — what car-a2ui validates and what the strategy's test op
        // probes. Earlier draft used `type`; that built a surface the
        // unit test didn't exercise (the strategy ignored the field;
        // the production test op would have failed).
        components.insert(
            "root".to_string(),
            json!({"component": "List", "props": props}),
        );
        A2uiSurface {
            surface_id: "s".to_string(),
            catalog_id: "basic-v0.9".to_string(),
            theme: Value::Null,
            send_data_model: false,
            components,
            data_model: json!({"items": ["a", "b", "c"]}),
            owner: None,
            updated_at: Utc::now(),
        }
    }

    fn rootless_surface() -> A2uiSurface {
        // Surface with no "root" key — strategies should decline
        // rather than silently target the lex-first component.
        let mut components = BTreeMap::new();
        components.insert("alpha".to_string(), json!({"type": "Text"}));
        A2uiSurface {
            surface_id: "rootless".to_string(),
            catalog_id: "basic-v0.9".to_string(),
            theme: Value::Null,
            send_data_model: false,
            components,
            data_model: json!({}),
            owner: None,
            updated_at: Utc::now(),
        }
    }

    fn report_for(
        surface: &A2uiSurface,
        signature: Value,
        sequence: u64,
        stable_since: u64,
    ) -> RenderReport {
        RenderReport {
            surface_id: surface.surface_id.clone(),
            signature,
            density: Some("comfortable".to_string()),
            visible_field_count: Some(3),
            action_count: Some(0),
            viewport: json!({"width": 400, "height": 600, "scale": 1.0}),
            hidden_via_overflow: vec![],
            strategy_id: None,
            timestamp: Utc::now(),
            report_id: Some(format!("rpt-{}", sequence)),
            sequence: Some(sequence),
            placeholder_count: Some(0),
            signature_stable_since: Some(stable_since),
            metadata: Default::default(),
        }
    }

    fn small_list_signature(domain: Option<&str>) -> Value {
        let mut sig = json!({
            "structure": "list",
            "cardinality_band": "2-5",
            "action_count_band": "none",
        });
        if let Some(d) = domain {
            sig["domain"] = json!(d);
        }
        sig
    }

    #[test]
    fn proposes_patch_for_undomained_small_list() {
        let agent = UIImprovementAgent::with_default_strategies();
        let surface = small_list_surface(None);
        let report = report_for(&surface, small_list_signature(None), 1, 1);
        match agent.on_render_report(&report, &surface) {
            Decision::Patch {
                envelope,
                strategy_id,
                patch_hash,
                elapsed_ns,
            } => {
                assert_eq!(strategy_id, "tag_small_list_domain");
                assert_eq!(envelope.surface_id, "s");
                assert_eq!(envelope.in_response_to.as_deref(), Some("rpt-1"));
                let ops = envelope.patch.as_array().unwrap();
                assert_eq!(ops.len(), 2);
                assert_eq!(ops[0]["op"], "test");
                assert_eq!(ops[1]["op"], "add");
                assert!(patch_hash != 0, "patch_hash should be populated");
                assert!(elapsed_ns > 0, "elapsed_ns should be populated");
            }
            other => panic!("expected Patch, got {:?}", other),
        }
    }

    #[test]
    fn hard_stops_on_placeholders() {
        let agent = UIImprovementAgent::with_default_strategies();
        let surface = small_list_surface(None);
        let mut report = report_for(&surface, small_list_signature(None), 1, 1);
        report.placeholder_count = Some(2);
        match agent.on_render_report(&report, &surface) {
            Decision::HardStop { reason } => {
                assert!(reason.contains("placeholder_count=2"));
            }
            other => panic!("expected HardStop, got {:?}", other),
        }
    }

    #[test]
    fn stops_after_stability_window() {
        let agent = UIImprovementAgent::with_default_strategies();
        let surface = small_list_surface(None);
        let report = report_for(&surface, small_list_signature(None), 10, 7);
        assert_eq!(
            agent.on_render_report(&report, &surface),
            Decision::StableNoChange
        );
    }

    #[test]
    fn does_not_propose_when_domain_already_set() {
        let agent = UIImprovementAgent::with_default_strategies();
        let surface = small_list_surface(Some("small_list"));
        let report = report_for(&surface, small_list_signature(Some("small_list")), 2, 2);
        assert_eq!(
            agent.on_render_report(&report, &surface),
            Decision::StableNoChange
        );
    }

    #[test]
    fn no_double_patch_at_same_sequence() {
        let agent = UIImprovementAgent::with_default_strategies();
        let surface = small_list_surface(None);
        let report = report_for(&surface, small_list_signature(None), 1, 1);
        let first = agent.on_render_report(&report, &surface);
        assert!(matches!(first, Decision::Patch { .. }));
        let second = agent.on_render_report(&report, &surface);
        assert_eq!(second, Decision::StableNoChange);
    }

    #[test]
    fn no_double_patch_when_sequence_absent() {
        // linus flagged: dedup must work even when the renderer
        // omits both sequence and report_id from the report. Falls
        // back to signature-content hash.
        let agent = UIImprovementAgent::with_default_strategies();
        let surface = small_list_surface(None);
        let mut report = report_for(&surface, small_list_signature(None), 1, 1);
        report.sequence = None;
        report.report_id = None;
        report.signature_stable_since = None;
        let first = agent.on_render_report(&report, &surface);
        assert!(
            matches!(first, Decision::Patch { .. }),
            "first emit should propose"
        );
        let second = agent.on_render_report(&report, &surface);
        assert_eq!(
            second,
            Decision::StableNoChange,
            "second emit with same signature must dedup via signature hash"
        );
    }

    #[test]
    fn malformed_signature_returns_no_change() {
        let agent = UIImprovementAgent::with_default_strategies();
        let surface = small_list_surface(None);
        let report = report_for(&surface, json!({"junk": true}), 1, 1);
        assert_eq!(
            agent.on_render_report(&report, &surface),
            Decision::StableNoChange
        );
    }

    #[test]
    fn empty_strategy_proposal_does_not_count() {
        struct EmptyStrategy;
        impl RenderingStrategy for EmptyStrategy {
            fn id(&self) -> &'static str {
                "empty"
            }
            fn proposes(
                &self,
                _r: &RenderReport,
                _s: &IntentSignature,
                _surf: &A2uiSurface,
            ) -> Option<Value> {
                Some(json!([]))
            }
        }
        let agent =
            UIImprovementAgent::new(vec![Box::new(EmptyStrategy)], DEFAULT_STABILITY_WINDOW);
        let surface = small_list_surface(None);
        let report = report_for(&surface, small_list_signature(None), 1, 1);
        assert_eq!(
            agent.on_render_report(&report, &surface),
            Decision::StableNoChange
        );
    }

    #[test]
    fn proposes_density_for_large_list() {
        // Build a 50-item List surface — should hit
        // DenseListForLargeCardinality.
        let mut props = serde_json::Map::new();
        props.insert("items".to_string(), json!({"path": "/items"}));
        let mut components = BTreeMap::new();
        components.insert(
            "root".to_string(),
            json!({"component": "List", "props": props}),
        );
        let surface = A2uiSurface {
            surface_id: "big".to_string(),
            catalog_id: "basic-v0.9".to_string(),
            theme: Value::Null,
            send_data_model: false,
            components,
            data_model: json!({"items": (0..50).collect::<Vec<_>>()}),
            owner: None,
            updated_at: Utc::now(),
        };
        let signature = json!({
            "structure": "list",
            "cardinality_band": "21-100",
            "action_count_band": "none"
        });
        let report = report_for(&surface, signature, 1, 1);
        let agent = UIImprovementAgent::with_default_strategies();
        match agent.on_render_report(&report, &surface) {
            Decision::Patch {
                envelope,
                strategy_id,
                ..
            } => {
                assert_eq!(strategy_id, "dense_list_for_large_cardinality");
                let ops = envelope.patch.as_array().unwrap();
                assert_eq!(ops[0]["op"], "test");
                assert_eq!(ops[1]["op"], "add");
                assert_eq!(
                    ops[1]["value"]["density"], "compact",
                    "large list should get compact density"
                );
            }
            other => panic!("expected Patch, got {:?}", other),
        }
    }

    #[test]
    fn declines_when_surface_has_no_root() {
        // Removed the lex-first fallback in strategies.rs; surfaces
        // without an explicit `root` key now produce no proposal
        // instead of silently patching `/alpha/props/x-renderer`.
        let agent = UIImprovementAgent::with_default_strategies();
        let surface = rootless_surface();
        let report = report_for(&surface, small_list_signature(None), 1, 1);
        assert_eq!(
            agent.on_render_report(&report, &surface),
            Decision::StableNoChange
        );
    }
}
