//! AnnotationsCapability — freehand / shape annotations on the canvas.
//
// POL-4b: production code must not panic. Tests still use unwrap/expect freely.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
//
//!
//! First concrete consumer of the UC-03 Capability formalism. See
//! `docs/superpowers/specs/2026-05-01-uc-03-capabilities-formalism.md` § 9 for
//! the design rationale; the data model lives in [`data`] and the wire frame
//! in [`wire`].
//!
//! Phase G3 lands the [`Capability`] impl: persistence, frame handling, and
//! settings schema. Phase G4 routes [`AnnotationsFrame`] through the typed
//! `CapabilityFrame::Annotations` sub-variant on the wire bus.

pub mod data;
pub mod wire;

pub use data::{
    Annotation, AnnotationId, AnnotationKind, AnnotationScope, AnnotationStyle, AnnotationsState,
    TimestampMs,
};
#[cfg(feature = "wire")]
pub use wire::AnnotationsFrame;

#[cfg(feature = "wire")]
pub use capability::{AnnotationsCapability, AnnotationsSettings};

#[cfg(feature = "wire")]
mod capability {
    use std::collections::HashMap;

    use maat_sdk::{
        TypedKey, Capability, CapabilityId, CapabilityResult, CapabilityScope,
        FrameContext, PersistContext, SemverVersion, SettingDescriptor, SettingKind,
    };
    use maat_sdk::artifact::Artifact as _;
    use tracing::{debug, error, warn};

    use crate::data::AnnotationsState;
    #[cfg(test)]
    use crate::data::Annotation;
    use crate::wire::AnnotationsFrame;

    /// Settings overrides applied via the manifest / settings panel.
    /// Stashed on the capability and merged with hard-coded defaults at read
    /// time. Fields are `Option<_>` so a partial settings blob (e.g. only
    /// `default_color` set) leaves the rest at their schema-default values.
    #[derive(Clone, Debug, Default, PartialEq)]
    #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    pub struct AnnotationsSettings {
        #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
        pub default_color: Option<String>,
        #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
        pub default_stroke_width: Option<i64>,
        #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
        pub max_annotations_per_scene: Option<i64>,
    }

    /// Typed key for the capability's primary state blob. Stored under
    /// the capability's pre-scoped `Store` handle.
    const STATE: TypedKey<AnnotationsState> = TypedKey::new("state");

    /// Hard-coded schema defaults — the source of truth that the
    /// `settings_schema()` descriptors mirror. Kept private to the module so
    /// drift is caught by `apply_settings` validation rather than spread
    /// across two declarations.
    const DEFAULT_COLOR: &str = "#3a7ed8";
    const DEFAULT_STROKE_WIDTH: i64 = 2;
    const DEFAULT_MAX_ANNOTATIONS_PER_SCENE: i64 = 1000;
    const STROKE_WIDTH_MIN: i64 = 1;
    const STROKE_WIDTH_MAX: i64 = 20;
    const MAX_ANNOTATIONS_MIN: i64 = 10;
    const MAX_ANNOTATIONS_MAX: i64 = 100_000;

    /// First UC-03 capability — persists annotation strokes per scene and
    /// broadcasts mutations through the `CapabilityFrame::Annotations`
    /// sub-channel.
    ///
    /// S25 Phase 5e — `#[capability_plugin]` annotation generates the
    /// `engine::CapabilityPlugin` impl and `maat_manifest()`
    /// associated function so this capability registers via the
    /// `install_static_capability_plugin_factory` path.
    #[::maat_sdk_derive::capability_plugin(
        canonical_id  = "github.com/williamweatherholtz/maat-capability-annotations",
        display_name  = "Annotations",
        authors       = ["William Weatherholtz"],
        description   = "Per-scene drawing/annotation overlay capability.",
        license       = "MIT",
    )]
    #[derive(Debug, Default)]
    pub struct AnnotationsCapability {
        state:    AnnotationsState,
        settings: AnnotationsSettings,
    }

    impl AnnotationsCapability {
        /// Capability semver. Manifest export records this; the engine
        /// re-checks the schema hash on import for binary-level
        /// compatibility.
        pub const VERSION: SemverVersion = SemverVersion::new(0, 1, 0);

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

        /// Test/borrow accessor — the engine never reads state directly,
        /// but tests want to assert on the result of `handle_incoming`.
        pub fn state(&self) -> &AnnotationsState {
            &self.state
        }

        pub fn settings(&self) -> &AnnotationsSettings {
            &self.settings
        }

        /// Resolved `max_annotations_per_scene` (override or default).
        fn cap_per_scene(&self) -> usize {
            self.settings
                .max_annotations_per_scene
                .unwrap_or(DEFAULT_MAX_ANNOTATIONS_PER_SCENE)
                .max(0) as usize
        }
    }

    #[async_trait::async_trait]
    impl Capability for AnnotationsCapability {
        const LOCAL_NAME: &'static str = "annotations";
        type Frame = AnnotationsFrame;

        fn scope(&self) -> CapabilityScope {
            CapabilityScope::Canvas
        }

        fn id(&self) -> CapabilityId {
            CapabilityId::new("core:annotations")
        }

        async fn load(&mut self, ctx: &PersistContext) -> CapabilityResult<()> {
            // Defensive: the engine builds the PersistContext, but a
            // misconfigured registry could hand us a non-Canvas scope.
            // Better to fail loudly than silently load from the wrong key.
            if ctx.scope != CapabilityScope::Canvas {
                return Err(format!(
                    "AnnotationsCapability is Canvas-scoped; refusing to load from scope {:?}",
                    ctx.scope
                )
                .into());
            }
            // 0850: postcard-stamped envelope. Decision tree (mirrors journal):
            //   1. Try `Stamped<AnnotationsState>` first.
            //   2. If hash matches → take data.
            //   3. If hash mismatches → take data with debug log
            //      (serde-defaults forward-compat fallback).
            //   4. If stamped decode fails entirely → try bare
            //      `AnnotationsState` (legacy pre-0850 files).
            let raw = ctx.store.get_typed(STATE.path()).await?;
            if let Some(bytes) = raw {
                if let Ok(stamped) = postcard::from_bytes::<
                    maat_sdk::artifact::Stamped<AnnotationsState>,
                >(&bytes) {
                    if stamped.schema_hash == AnnotationsState::SCHEMA_HASH {
                        self.state = stamped.data;
                    } else {
                        // 0861: stamped schema_hash mismatch is a HARD ERROR
                        // (no transition machinery wired for postcard yet).
                        // Operator sees both hashes via tracing::error!.
                        let current = AnnotationsState::SCHEMA_HASH;
                        error!(
                            stored = %stamped.schema_hash,
                            current = current,
                            "annotations: stamped schema_hash mismatch; refusing to load (no transition registered)"
                        );
                        return Err(format!(
                            "annotations: stamped schema_hash mismatch: stored={:?}, current={:?}",
                            stamped.schema_hash,
                            current,
                        )
                        .into());
                    }
                } else if let Ok(legacy) =
                    postcard::from_bytes::<AnnotationsState>(&bytes)
                {
                    debug!("annotations: legacy unstamped state loaded; will re-save stamped on next save");
                    self.state = legacy;
                } else {
                    return Err("annotations: persisted state failed to decode as either Stamped<AnnotationsState> or bare AnnotationsState".into());
                }
            }
            Ok(())
        }

        async fn save(&self, ctx: &PersistContext) -> CapabilityResult<()> {
            if ctx.scope != CapabilityScope::Canvas {
                return Err(format!(
                    "AnnotationsCapability is Canvas-scoped; refusing to save to scope {:?}",
                    ctx.scope
                )
                .into());
            }
            // 0850: wrap in Stamped envelope before writing.
            let stamped = maat_sdk::artifact::Stamped::new(self.state.clone());
            let bytes = postcard::to_allocvec(&stamped).map_err(|e| {
                error!(error = %e, "AnnotationsCapability postcard encode failed");
                format!("encode stamped annotations state: {e}")
            })?;
            if let Err(e) = ctx.store.put_typed(STATE.path(), bytes).await {
                error!(error = %e, "AnnotationsCapability store write failed");
                return Err(e.into());
            }
            Ok(())
        }

        async fn handle_incoming(
            &mut self,
            frame: Self::Frame,
            _ctx: &mut FrameContext,
        ) -> CapabilityResult<()> {
            // TODO(phase G4/G5): authority check via FrameContext (sender's
            // seat must match annotation.author for player frames; GMs may
            // override). FrameContext lands its identity fields in Phase E
            // / G5; today we trust the dispatch boundary.
            match frame {
                AnnotationsFrame::Created { scene, annotation } => {
                    let cap = self.cap_per_scene();
                    let bucket = self.state.by_scene.entry(scene.clone()).or_default();
                    if bucket.len() >= cap {
                        warn!(
                            annotation_id = annotation.id.0,
                            scene_id = %scene,
                            limit = cap,
                            "annotation add rejected: per-scene limit reached"
                        );
                        return Err(format!(
                            "scene annotation limit reached ({} / {})",
                            bucket.len(),
                            cap
                        )
                        .into());
                    }
                    debug!(
                        annotation_id = annotation.id.0,
                        scene_id = %scene,
                        "annotation added"
                    );
                    bucket.push(annotation);
                }
                AnnotationsFrame::Updated { scene, annotation } => {
                    if let Some(bucket) = self.state.by_scene.get_mut(&scene) {
                        match bucket.iter_mut().find(|a| a.id == annotation.id) {
                            Some(existing) => {
                                debug!(
                                    annotation_id = annotation.id.0,
                                    scene_id = %scene,
                                    "annotation updated"
                                );
                                *existing = annotation;
                            }
                            None => {
                                warn!(
                                    annotation_id = annotation.id.0,
                                    scene_id = %scene,
                                    "annotation update targeting non-existent annotation"
                                );
                                return Err(format!(
                                    "annotation {} not found on scene {}",
                                    annotation.id, scene
                                )
                                .into());
                            }
                        }
                    } else {
                        warn!(
                            annotation_id = annotation.id.0,
                            scene_id = %scene,
                            "annotation update targeting non-existent scene"
                        );
                        return Err(format!("scene {} has no annotations", scene).into());
                    }
                }
                AnnotationsFrame::Removed { scene, id } => {
                    debug!(annotation_id = id.0, scene_id = %scene, "annotation removed");
                    if let Some(bucket) = self.state.by_scene.get_mut(&scene) {
                        bucket.retain(|a| a.id != id);
                        if bucket.is_empty() {
                            self.state.by_scene.remove(&scene);
                        }
                    }
                }
                AnnotationsFrame::SceneCleared { scene } => {
                    debug!(scene_id = %scene, "annotations cleared for scene");
                    self.state.by_scene.remove(&scene);
                }
            }
            Ok(())
        }

        fn settings_schema(&self) -> Vec<SettingDescriptor> {
            vec![
                SettingDescriptor {
                    key:         "default_color".into(),
                    label:       "Default annotation color".into(),
                    description: Some(
                        "Color used when starting a new annotation.".into(),
                    ),
                    hover_help:  Some("Hex format, e.g. #3a7ed8".into()),
                    kind:        SettingKind::Color {
                        default: DEFAULT_COLOR.into(),
                    },
                },
                SettingDescriptor {
                    key:         "default_stroke_width".into(),
                    label:       "Default stroke width".into(),
                    description: None,
                    hover_help:  None,
                    kind:        SettingKind::Int {
                        default: DEFAULT_STROKE_WIDTH,
                        min:     Some(STROKE_WIDTH_MIN),
                        max:     Some(STROKE_WIDTH_MAX),
                    },
                },
                SettingDescriptor {
                    key:         "max_annotations_per_scene".into(),
                    label:       "Max annotations per scene".into(),
                    description: Some("Limit to prevent runaway state.".into()),
                    hover_help:  None,
                    kind:        SettingKind::Int {
                        default: DEFAULT_MAX_ANNOTATIONS_PER_SCENE,
                        min:     Some(MAX_ANNOTATIONS_MIN),
                        max:     Some(MAX_ANNOTATIONS_MAX),
                    },
                },
            ]
        }

        fn current_settings(&self) -> serde_json::Value {
            serde_json::to_value(&self.settings).unwrap_or(serde_json::Value::Null)
        }

        fn apply_settings(
            &mut self,
            settings: serde_json::Value,
        ) -> CapabilityResult<()> {
            // Empty / null clears overrides back to schema defaults.
            if matches!(settings, serde_json::Value::Null) {
                self.settings = AnnotationsSettings::default();
                return Ok(());
            }
            let parsed: AnnotationsSettings = serde_json::from_value(settings)
                .map_err(|e| format!("invalid annotations settings: {e}"))?;
            // Range validation — reject out-of-range integers loudly so the
            // settings panel can surface a helpful message rather than
            // silently snapping to defaults.
            if let Some(w) = parsed.default_stroke_width {
                if !(STROKE_WIDTH_MIN..=STROKE_WIDTH_MAX).contains(&w) {
                    return Err(format!(
                        "default_stroke_width {w} out of range [{STROKE_WIDTH_MIN},{STROKE_WIDTH_MAX}]",
                    )
                    .into());
                }
            }
            if let Some(m) = parsed.max_annotations_per_scene {
                if !(MAX_ANNOTATIONS_MIN..=MAX_ANNOTATIONS_MAX).contains(&m) {
                    return Err(format!(
                        "max_annotations_per_scene {m} out of range [{MAX_ANNOTATIONS_MIN},{MAX_ANNOTATIONS_MAX}]",
                    )
                    .into());
                }
            }
            if let Some(c) = parsed.default_color.as_deref() {
                if !is_valid_hex_color(c) {
                    return Err(format!(
                        "default_color `{c}` is not a valid hex color (#rrggbb / #rrggbbaa)",
                    )
                    .into());
                }
            }
            self.settings = parsed;
            Ok(())
        }
    }

    /// Validate `#rrggbb` / `#rrggbbaa`. Used by `apply_settings` and exposed
    /// for unit tests + the client settings panel.
    pub fn is_valid_hex_color(s: &str) -> bool {
        let bytes = s.as_bytes();
        if !matches!(bytes.first(), Some(b'#')) {
            return false;
        }
        let hex = &s[1..];
        if hex.len() != 6 && hex.len() != 8 {
            return false;
        }
        hex.chars().all(|c| c.is_ascii_hexdigit())
    }

    // Quick sanity: settings type unused warning otherwise.
    #[allow(dead_code)]
    fn _hashmap_check() -> HashMap<u8, u8> {
        HashMap::new()
    }

    #[cfg(test)]
    mod tests {
        use super::*;
        use crate::data::{
            AnnotationKind, AnnotationScope, AnnotationStyle, AnnotationsState, TimestampMs,
        };
        use crate::AnnotationId;
        use maat_sdk::{StoreScope, StoreScopeKind, PersistContext, SceneId, SeatId};
        use maat_engine::store::{store_for, MemStoreBackend};
        use std::sync::Arc;

        fn canvas_ctx(scene: &str) -> PersistContext {
            let backend = Arc::new(MemStoreBackend::new());
            let scope = StoreScope::new(
                "github.com/williamweatherholtz/maat-capability-annotations",
                StoreScopeKind::Session(format!("scene:{scene}")),
            );
            PersistContext::new(store_for(backend, scope), CapabilityScope::Canvas)
        }

        /// Build a `PersistContext` that shares the same `MemStoreBackend`
        /// across two capability instances — used to verify save→load
        /// round-trips work across instances.
        fn canvas_ctx_shared(scene: &str) -> (PersistContext, PersistContext) {
            let backend: Arc<dyn maat_engine::store::StoreBackend> =
                Arc::new(MemStoreBackend::new());
            let make = || {
                let scope = StoreScope::new(
                    "github.com/williamweatherholtz/maat-capability-annotations",
                    StoreScopeKind::Session(format!("scene:{scene}")),
                );
                PersistContext::new(store_for(backend.clone(), scope), CapabilityScope::Canvas)
            };
            (make(), make())
        }

        fn sample_annotation(id: u64) -> Annotation {
            Annotation {
                id:         AnnotationId::new(id),
                author:     SeatId::from_raw(1),
                created_at: TimestampMs::new(0),
                kind:       AnnotationKind::Line {
                    from: (0.0, 0.0),
                    to:   (1.0, 1.0),
                },
                style:      AnnotationStyle::default(),
                scope:      AnnotationScope::Public,
            }
        }

        #[test]
        fn id_and_scope_are_canonical() {
            let cap = AnnotationsCapability::new();
            assert_eq!(cap.id().as_str(), "core:annotations");
            assert_eq!(Capability::scope(&cap), CapabilityScope::Canvas);
        }

        #[tokio::test]
        async fn handle_incoming_created_appends() {
            let mut cap = AnnotationsCapability::new();
            let mut fctx = FrameContext::new();
            cap.handle_incoming(
                AnnotationsFrame::Created {
                    scene:      SceneId::new("a"),
                    annotation: sample_annotation(1),
                },
                &mut fctx,
            )
            .await
            .unwrap();
            assert_eq!(cap.state().total_count(), 1);
        }

        #[tokio::test]
        async fn handle_incoming_updated_replaces() {
            let mut cap = AnnotationsCapability::new();
            let mut fctx = FrameContext::new();
            cap.handle_incoming(
                AnnotationsFrame::Created {
                    scene:      SceneId::new("a"),
                    annotation: sample_annotation(1),
                },
                &mut fctx,
            )
            .await
            .unwrap();
            let mut updated = sample_annotation(1);
            updated.style.color = "#ff0000".into();
            cap.handle_incoming(
                AnnotationsFrame::Updated {
                    scene:      SceneId::new("a"),
                    annotation: updated.clone(),
                },
                &mut fctx,
            )
            .await
            .unwrap();
            assert_eq!(
                cap.state().for_scene(&SceneId::new("a"))[0].style.color,
                "#ff0000",
            );
        }

        #[tokio::test]
        async fn handle_incoming_updated_unknown_id_errors() {
            let mut cap = AnnotationsCapability::new();
            let mut fctx = FrameContext::new();
            let res = cap.handle_incoming(
                AnnotationsFrame::Updated {
                    scene:      SceneId::new("a"),
                    annotation: sample_annotation(99),
                },
                &mut fctx,
            )
            .await;
            assert!(res.is_err());
        }

        #[tokio::test]
        async fn handle_incoming_removed_drops() {
            let mut cap = AnnotationsCapability::new();
            let mut fctx = FrameContext::new();
            cap.handle_incoming(
                AnnotationsFrame::Created {
                    scene:      SceneId::new("a"),
                    annotation: sample_annotation(1),
                },
                &mut fctx,
            )
            .await
            .unwrap();
            cap.handle_incoming(
                AnnotationsFrame::Removed {
                    scene: SceneId::new("a"),
                    id:    AnnotationId::new(1),
                },
                &mut fctx,
            )
            .await
            .unwrap();
            assert_eq!(cap.state().total_count(), 0);
        }

        #[tokio::test]
        async fn handle_incoming_scene_cleared_removes_bucket() {
            let mut cap = AnnotationsCapability::new();
            let mut fctx = FrameContext::new();
            cap.handle_incoming(
                AnnotationsFrame::Created {
                    scene:      SceneId::new("a"),
                    annotation: sample_annotation(1),
                },
                &mut fctx,
            )
            .await
            .unwrap();
            cap.handle_incoming(
                AnnotationsFrame::Created {
                    scene:      SceneId::new("a"),
                    annotation: sample_annotation(2),
                },
                &mut fctx,
            )
            .await
            .unwrap();
            cap.handle_incoming(
                AnnotationsFrame::SceneCleared {
                    scene: SceneId::new("a"),
                },
                &mut fctx,
            )
            .await
            .unwrap();
            assert_eq!(cap.state().total_count(), 0);
        }

        #[tokio::test]
        async fn handle_incoming_respects_per_scene_cap() {
            let mut cap = AnnotationsCapability::new();
            cap.apply_settings(serde_json::json!({
                "max_annotations_per_scene": 10
            }))
            .unwrap();
            let mut fctx = FrameContext::new();
            // Push exactly 10 — fine.
            for i in 0..10 {
                cap.handle_incoming(
                    AnnotationsFrame::Created {
                        scene:      SceneId::new("a"),
                        annotation: sample_annotation(i + 1),
                    },
                    &mut fctx,
                )
                .await
                .unwrap();
            }
            // 11th — error.
            let res = cap.handle_incoming(
                AnnotationsFrame::Created {
                    scene:      SceneId::new("a"),
                    annotation: sample_annotation(11),
                },
                &mut fctx,
            )
            .await;
            assert!(res.is_err(), "expected per-scene cap to reject 11th");
        }

        #[tokio::test]
        async fn save_then_load_round_trips_state() {
            let (save_ctx, load_ctx) = canvas_ctx_shared("alpha");

            let mut cap = AnnotationsCapability::new();
            let mut fctx = FrameContext::new();
            cap.handle_incoming(
                AnnotationsFrame::Created {
                    scene:      SceneId::new("alpha"),
                    annotation: sample_annotation(1),
                },
                &mut fctx,
            )
            .await
            .unwrap();
            cap.save(&save_ctx).await.unwrap();

            // Fresh capability, same backend → state restored.
            let mut reloaded = AnnotationsCapability::new();
            reloaded.load(&load_ctx).await.unwrap();
            assert_eq!(reloaded.state(), cap.state());
        }

        #[tokio::test]
        async fn load_with_no_persisted_state_is_clean() {
            let ctx = canvas_ctx("fresh");
            let mut cap = AnnotationsCapability::new();
            cap.load(&ctx).await.unwrap();
            assert_eq!(cap.state(), &AnnotationsState::new());
        }

        /// Spec: 0850 — after save(), the on-disk bytes parse as
        /// `Stamped<AnnotationsState>` with the current SCHEMA_HASH.
        #[tokio::test]
        async fn save_uses_stamped_envelope() {
            use maat_sdk::artifact::Stamped;
            let (save_ctx, load_ctx) = canvas_ctx_shared("env");
            let mut cap = AnnotationsCapability::new();
            let mut fctx = FrameContext::new();
            cap.handle_incoming(
                AnnotationsFrame::Created {
                    scene:      SceneId::new("env"),
                    annotation: sample_annotation(1),
                },
                &mut fctx,
            )
            .await
            .unwrap();
            cap.save(&save_ctx).await.unwrap();

            let raw = load_ctx
                .store
                .get_typed(STATE.path())
                .await
                .unwrap()
                .expect("save should have written bytes");
            let stamped: Stamped<AnnotationsState> = postcard::from_bytes(&raw)
                .expect("save output should parse as Stamped<AnnotationsState>");
            assert_eq!(
                stamped.schema_hash,
                AnnotationsState::SCHEMA_HASH,
                "schema_hash should match the Artifact impl's SCHEMA_HASH"
            );
            assert_eq!(stamped.data, *cap.state(),
                "stamped data should equal capability state");
        }

        /// Spec: 0850 — legacy bare-`AnnotationsState` payloads
        /// (pre-0850) still load cleanly via the load decision tree's
        /// fallback branch. The next save() re-writes them stamped.
        #[tokio::test]
        async fn loads_legacy_bare_state() {
            use maat_sdk::artifact::Stamped;
            let (write_ctx, load_ctx) = canvas_ctx_shared("legacy");

            // Hand-write a bare `AnnotationsState` payload (pre-0850 shape).
            let mut legacy_state = AnnotationsState::new();
            legacy_state
                .by_scene
                .entry(SceneId::new("legacy"))
                .or_default()
                .push(sample_annotation(42));
            let legacy_bytes = postcard::to_allocvec(&legacy_state).unwrap();
            write_ctx
                .store
                .put_typed(STATE.path(), legacy_bytes)
                .await
                .unwrap();

            // Fresh capability loads the legacy bytes.
            let mut cap = AnnotationsCapability::new();
            cap.load(&load_ctx).await.unwrap();
            assert_eq!(cap.state(), &legacy_state,
                "legacy bare state should load cleanly");

            // Next save re-writes stamped.
            cap.save(&write_ctx).await.unwrap();
            let raw = load_ctx
                .store
                .get_typed(STATE.path())
                .await
                .unwrap()
                .expect("re-save should have written bytes");
            let stamped: Result<Stamped<AnnotationsState>, _> =
                postcard::from_bytes(&raw);
            assert!(
                stamped.is_ok(),
                "next save after legacy load should write stamped envelope"
            );
        }

        /// Spec: 0861 — a postcard-encoded `Stamped<AnnotationsState>`
        /// whose `schema_hash` does NOT equal the current
        /// `<AnnotationsState as Artifact>::SCHEMA_HASH` must cause
        /// `Capability::load` to return `Err`. The pre-0861 behavior
        /// silently accepted the data; 0861 makes mismatch fatal.
        #[tokio::test]
        async fn load_rejects_mismatched_schema_hash() {
            use maat_sdk::artifact::Stamped;
            let (write_ctx, load_ctx) = canvas_ctx_shared("mismatch");

            // Hand-write a Stamped envelope with a deliberately wrong hash.
            let mut state = AnnotationsState::new();
            state
                .by_scene
                .entry(SceneId::new("mismatch"))
                .or_default()
                .push(sample_annotation(7));
            let stamped = Stamped {
                schema_hash: "capability/annotations/v999".to_string(),
                data: state,
            };
            let bytes = postcard::to_allocvec(&stamped).unwrap();
            write_ctx
                .store
                .put_typed(STATE.path(), bytes)
                .await
                .unwrap();

            // Fresh capability — load must reject the mismatched hash.
            let mut cap = AnnotationsCapability::new();
            let err = cap.load(&load_ctx).await.unwrap_err();
            let msg = err.to_string();
            assert!(
                msg.contains("v999"),
                "error should cite stored hash; got: {msg}"
            );
            assert!(
                msg.contains("capability/annotations/v1"),
                "error should cite current hash; got: {msg}"
            );
            assert!(
                msg.contains("mismatch"),
                "error should describe the failure mode; got: {msg}"
            );
        }

        /// Spec: 0862 — branch (4) of the load decision tree. Bytes
        /// that decode as neither `Stamped<AnnotationsState>` nor
        /// bare `AnnotationsState` must surface as an Err whose
        /// message indicates decode failure (not a silent default).
        #[tokio::test]
        async fn load_rejects_garbage_bytes() {
            let (write_ctx, load_ctx) = canvas_ctx_shared("garbage");
            let garbage: Vec<u8> = vec![0xff; 32];
            write_ctx
                .store
                .put_typed(STATE.path(), garbage)
                .await
                .unwrap();
            let mut cap = AnnotationsCapability::new();
            let err = cap.load(&load_ctx).await.unwrap_err();
            let msg = err.to_string();
            assert!(
                msg.contains("failed to decode"),
                "error should describe decode failure; got: {msg}"
            );
        }

        #[tokio::test]
        async fn load_rejects_non_canvas_scope() {
            let backend = Arc::new(MemStoreBackend::new());
            let scope = StoreScope::new(
                "github.com/williamweatherholtz/maat-capability-annotations",
                StoreScopeKind::Session("x".into()),
            );
            let ctx = PersistContext::new(
                store_for(backend, scope),
                CapabilityScope::Session,
            );
            let mut cap = AnnotationsCapability::new();
            let err = cap.load(&ctx).await.unwrap_err();
            assert!(err.to_string().contains("Canvas-scoped"));
        }

        #[tokio::test]
        async fn save_rejects_non_canvas_scope() {
            let backend = Arc::new(MemStoreBackend::new());
            let scope = StoreScope::new(
                "github.com/williamweatherholtz/maat-capability-annotations",
                StoreScopeKind::Campaign("c".into()),
            );
            let ctx = PersistContext::new(
                store_for(backend, scope),
                CapabilityScope::Campaign,
            );
            let cap = AnnotationsCapability::new();
            let err = cap.save(&ctx).await.unwrap_err();
            assert!(err.to_string().contains("Canvas-scoped"));
        }

        #[test]
        fn settings_schema_has_three_keys_with_correct_kinds() {
            let cap = AnnotationsCapability::new();
            let schema = cap.settings_schema();
            assert_eq!(schema.len(), 3);
            assert_eq!(schema[0].key, "default_color");
            assert!(matches!(schema[0].kind, SettingKind::Color { .. }));
            assert_eq!(schema[1].key, "default_stroke_width");
            assert!(matches!(schema[1].kind, SettingKind::Int { .. }));
            assert_eq!(schema[2].key, "max_annotations_per_scene");
            assert!(matches!(schema[2].kind, SettingKind::Int { .. }));
        }

        #[test]
        fn settings_round_trip_via_apply_and_current() {
            let mut cap = AnnotationsCapability::new();
            cap.apply_settings(serde_json::json!({
                "default_color": "#abcdef",
                "default_stroke_width": 5,
            }))
            .unwrap();
            let current = cap.current_settings();
            // Round-trip through current_settings → apply_settings.
            let mut other = AnnotationsCapability::new();
            other.apply_settings(current).unwrap();
            assert_eq!(other.settings(), cap.settings());
        }

        #[test]
        fn apply_settings_rejects_out_of_range_stroke_width() {
            let mut cap = AnnotationsCapability::new();
            let err = cap
                .apply_settings(serde_json::json!({"default_stroke_width": 999}))
                .unwrap_err();
            assert!(err.to_string().contains("out of range"));
        }

        #[test]
        fn apply_settings_rejects_invalid_hex_color() {
            let mut cap = AnnotationsCapability::new();
            let err = cap
                .apply_settings(serde_json::json!({"default_color": "blue"}))
                .unwrap_err();
            assert!(err.to_string().contains("hex color"));
        }

        #[test]
        fn apply_settings_null_resets_to_defaults() {
            let mut cap = AnnotationsCapability::new();
            cap.apply_settings(serde_json::json!({"default_color": "#abcdef"}))
                .unwrap();
            cap.apply_settings(serde_json::Value::Null).unwrap();
            assert_eq!(cap.settings(), &AnnotationsSettings::default());
        }

        #[test]
        fn is_valid_hex_color_basic_cases() {
            assert!(is_valid_hex_color("#000000"));
            assert!(is_valid_hex_color("#ffffff"));
            assert!(is_valid_hex_color("#3a7ed8"));
            assert!(is_valid_hex_color("#3a7ed8ff"));
            assert!(!is_valid_hex_color("3a7ed8"));
            assert!(!is_valid_hex_color("#xyzxyz"));
            assert!(!is_valid_hex_color("#abc"));
        }

        // -----------------------------------------------------------------
        // Tracing verification (O1b)
        // -----------------------------------------------------------------

        #[tracing_test::traced_test]
        #[tokio::test]
        async fn annotation_add_emits_debug_tracing_event() {
            let mut cap = AnnotationsCapability::new();
            let mut fctx = FrameContext::new();
            cap.handle_incoming(
                AnnotationsFrame::Created {
                    scene:      SceneId::new("test-scene"),
                    annotation: sample_annotation(42),
                },
                &mut fctx,
            )
            .await
            .unwrap();
            assert!(
                logs_contain("annotation added"),
                "expected 'annotation added' debug event in trace"
            );
        }

        #[tracing_test::traced_test]
        #[tokio::test]
        async fn annotation_missing_entity_target_emits_warn_tracing_event() {
            let mut cap = AnnotationsCapability::new();
            let mut fctx = FrameContext::new();
            // Update targeting an annotation that does not exist in the scene
            // → warn about a non-existent annotation/scene.
            let _ = cap.handle_incoming(
                AnnotationsFrame::Updated {
                    scene:      SceneId::new("empty-scene"),
                    annotation: sample_annotation(999),
                },
                &mut fctx,
            )
            .await;
            assert!(
                logs_contain("annotation update targeting non-existent"),
                "expected warn about non-existent annotation/scene in trace"
            );
        }
    }
}
