//! UC-16 — Artifact hash stamping and transition registry.
//!
//! Persisted top-level JSON files (the things `JsonFile` stores write
//! to disk) carry a stable `schema_hash` identifying their on-disk
//! shape. On read, the engine checks the stamped hash against the
//! current `Artifact::SCHEMA_HASH`:
//!
//! - **Match** — direct deserialize, fast path.
//! - **Mismatch** — try the type's registered transitions; if none
//!   match, fall back to direct deserialize (serde defaults often
//!   handle additive changes); if THAT fails, return
//!   `NoTransition` with a typed error.
//! - **Unstamped (legacy)** — file written before UC-16 shipped;
//!   attempt direct deserialize against the current shape.
//!
//! See `docs/superpowers/specs/2026-05-13-uc-16-artifact-hash-stamping.md`.

use serde::{de::DeserializeOwned, Deserialize, Serialize};

/// On-disk envelope. Every persisted artifact serializes as
/// `{ "schema_hash": "...", "data": { ... } }`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Stamped<T> {
    pub schema_hash: String,
    pub data: T,
}

impl<T: Artifact> Stamped<T> {
    /// Wrap `data` with the current `SCHEMA_HASH` for `T`. The
    /// canonical way to build a `Stamped<T>` envelope; replaces the
    /// `Stamped { schema_hash: T::SCHEMA_HASH.to_string(), data }`
    /// hand-construction pattern at every save site (0863).
    pub fn new(data: T) -> Self {
        Self { schema_hash: T::SCHEMA_HASH.to_string(), data }
    }
}

/// A persistable on-disk type. The `SCHEMA_HASH` is a stable string
/// identifier — bump it when the shape changes destructively (and
/// register a transition).
pub trait Artifact: Serialize + DeserializeOwned + Sized {
    /// Stable identifier for the on-disk shape. Convention:
    /// `"<owner>/<type>/v<n>"`.
    const SCHEMA_HASH: &'static str;

    /// Transitions FROM other hashes INTO this type.
    /// Empty by default; the first PR that introduces a destructive
    /// change overrides this to register the migration edge.
    fn transitions() -> &'static [TransitionEntry<Self>] {
        &[]
    }
}

pub type TransitionFn<T> = fn(serde_json::Value) -> Result<T, ArtifactError>;

#[derive(Clone, Copy)]
pub struct TransitionEntry<T> {
    pub from: &'static str,
    pub apply: TransitionFn<T>,
}

#[derive(Debug)]
pub enum ArtifactError {
    /// Couldn't parse the input as JSON at all.
    Parse(String),
    /// Hash didn't match current `SCHEMA_HASH`, no transition matched,
    /// and direct deserialize against the current shape failed too.
    NoTransition {
        from: String,
        to: &'static str,
        last_error: String,
    },
    /// A transition fn was located but failed when applied.
    TransitionFailed {
        from: String,
        to: &'static str,
        reason: String,
    },
    /// `save` failed to serialize.
    SerializeFailed(String),
}

impl std::fmt::Display for ArtifactError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Parse(e) => write!(f, "artifact: parse error: {e}"),
            Self::NoTransition { from, to, last_error } => write!(
                f,
                "artifact: no transition registered from {from:?} \
                 to {to:?} and direct deserialize failed: {last_error}"
            ),
            Self::TransitionFailed { from, to, reason } => write!(
                f,
                "artifact: transition {from:?} -> {to:?} failed: {reason}"
            ),
            Self::SerializeFailed(e) => write!(f, "artifact: serialize failed: {e}"),
        }
    }
}

impl std::error::Error for ArtifactError {}

/// Serialize `value` as a `Stamped<T>` envelope. Always stamps the
/// current `SCHEMA_HASH`.
pub fn save<T: Artifact>(value: &T) -> Result<String, ArtifactError> {
    let stamped = Stamped {
        schema_hash: T::SCHEMA_HASH.to_string(),
        data: value,
    };
    serde_json::to_string_pretty(&stamped).map_err(|e| ArtifactError::SerializeFailed(e.to_string()))
}

/// How `load_with_outcome` resolved a stamped (or unstamped) artifact.
/// UC-16c: callers consult this to decide whether to write a `.bak` of
/// the original file (anything other than `DirectMatch` indicates the
/// on-disk format may have been transformed).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoadOutcome {
    /// Stamped hash matched exactly; no migration occurred.
    DirectMatch,
    /// Stamped hash didn't match; a registered transition was applied.
    TransitionApplied { from: String, to: &'static str },
    /// Stamped hash didn't match but no transition was registered;
    /// the data still parsed via serde defaults / additive-compat.
    SerdeDefaultsFallback { stored_hash: String, current_hash: &'static str },
    /// File had no `schema_hash` field; loaded directly against the
    /// current shape.
    LegacyUnstamped,
}

impl LoadOutcome {
    /// True iff the loaded data was transformed (or might benefit from
    /// being re-saved under the current shape). UC-16c uses this to
    /// gate `.bak` writes.
    pub fn requires_backup(&self) -> bool {
        !matches!(self, LoadOutcome::DirectMatch)
    }
}

/// Deserialize from JSON text. See the module docs for the decision
/// tree.
pub fn load<T: Artifact + 'static>(text: &str) -> Result<T, ArtifactError> {
    load_with_outcome::<T>(text).map(|(v, _)| v)
}

/// As `load` but also returns the `LoadOutcome` describing how the
/// load was resolved. UC-16c callers use this to decide whether to
/// preserve the original file as `.bak` before subsequent writes
/// overwrite it.
pub fn load_with_outcome<T: Artifact + 'static>(
    text: &str,
) -> Result<(T, LoadOutcome), ArtifactError> {
    let value: serde_json::Value = serde_json::from_str(text)
        .map_err(|e| ArtifactError::Parse(e.to_string()))?;

    // Stamped envelope path.
    if let Some(stored_hash) = value.get("schema_hash").and_then(|v| v.as_str()) {
        let Some(data) = value.get("data") else {
            return Err(ArtifactError::Parse(
                "stamped envelope missing `data` field".into(),
            ));
        };
        let stored = stored_hash.to_string();

        // Fast path: hash matches.
        if stored == T::SCHEMA_HASH {
            return serde_json::from_value(data.clone())
                .map(|v| (v, LoadOutcome::DirectMatch))
                .map_err(|e| ArtifactError::NoTransition {
                    from: stored,
                    to: T::SCHEMA_HASH,
                    last_error: format!("data field failed to deserialize: {e}"),
                });
        }

        // Transition lookup.
        for entry in T::transitions() {
            if entry.from == stored {
                let from = stored.clone();
                return (entry.apply)(data.clone())
                    .map(|v| {
                        (
                            v,
                            LoadOutcome::TransitionApplied {
                                from: from.clone(),
                                to: T::SCHEMA_HASH,
                            },
                        )
                    })
                    .map_err(|e| match e {
                        ArtifactError::Parse(reason)
                        | ArtifactError::SerializeFailed(reason) => {
                            ArtifactError::TransitionFailed {
                                from: stored.clone(),
                                to: T::SCHEMA_HASH,
                                reason,
                            }
                        }
                        other => other,
                    });
            }
        }

        // Serde-defaults fallback for additive-compatible changes.
        match serde_json::from_value::<T>(data.clone()) {
            Ok(v) => Ok((
                v,
                LoadOutcome::SerdeDefaultsFallback {
                    stored_hash: stored,
                    current_hash: T::SCHEMA_HASH,
                },
            )),
            Err(e) => Err(ArtifactError::NoTransition {
                from: stored,
                to: T::SCHEMA_HASH,
                last_error: e.to_string(),
            }),
        }
    } else {
        // Legacy unstamped file (pre-UC-16). Try the current shape directly.
        serde_json::from_value::<T>(value)
            .map(|v| (v, LoadOutcome::LegacyUnstamped))
            .map_err(|e| ArtifactError::NoTransition {
                from: "<unstamped>".to_string(),
                to: T::SCHEMA_HASH,
                last_error: e.to_string(),
            })
    }
}

/// UC-16c — preserve a file as `<path>.bak` before subsequent writes
/// overwrite it. Idempotent: if `<path>.bak` already exists it is NOT
/// re-overwritten (the FIRST observed snapshot is the rollback target;
/// successive opens that re-trigger backups would clobber the original
/// rollback point). Returns the backup path on success; `Ok(None)` if
/// the source path doesn't exist (nothing to back up).
pub fn write_backup(
    path: &std::path::Path,
) -> std::io::Result<Option<std::path::PathBuf>> {
    if !path.exists() {
        return Ok(None);
    }
    let bak = bak_path(path);
    if bak.exists() {
        return Ok(Some(bak));
    }
    std::fs::copy(path, &bak)?;
    Ok(Some(bak))
}

/// Compute the `.bak` sibling path for `path`. Public for callers that
/// want to assert on the path without performing the copy.
pub fn bak_path(path: &std::path::Path) -> std::path::PathBuf {
    let mut out = path.as_os_str().to_owned();
    out.push(".bak");
    out.into()
}

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

    // -----------------------------------------------------------------
    // Synthetic artifact types for the BDD scenarios.
    // -----------------------------------------------------------------

    /// Current shape.
    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
    struct FooV2 {
        a: u32,
        #[serde(default)]
        b: u32,
    }

    impl Artifact for FooV2 {
        const SCHEMA_HASH: &'static str = "test/foo/v2";
        fn transitions() -> &'static [TransitionEntry<Self>] {
            &[TransitionEntry {
                from: "test/foo/v1",
                apply: foo_v1_to_v2,
            }]
        }
    }

    /// Transition: v1 had only `a`; v2 adds `b` derived as `a * 10`.
    fn foo_v1_to_v2(value: serde_json::Value) -> Result<FooV2, ArtifactError> {
        let a = value
            .get("a")
            .and_then(|v| v.as_u64())
            .ok_or_else(|| ArtifactError::Parse("v1 missing `a`".into()))?;
        Ok(FooV2 {
            a: a as u32,
            b: (a as u32) * 10,
        })
    }

    /// A type with no transitions at all and a strict shape, used to
    /// exercise NoTransition.
    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
    struct Strict {
        required: String,
    }

    impl Artifact for Strict {
        const SCHEMA_HASH: &'static str = "test/strict/v1";
    }

    // -----------------------------------------------------------------
    // 0863 — Stamped::new(data) ctor uses Artifact's SCHEMA_HASH.
    // -----------------------------------------------------------------
    /// Spec: 0863 — `Stamped::new(data)` stamps with the current
    /// `<T as Artifact>::SCHEMA_HASH` and preserves the wrapped data.
    #[test]
    fn stamped_new_uses_artifact_schema_hash() {
        let v = FooV2 { a: 11, b: 22 };
        let stamped = Stamped::new(v.clone());
        assert_eq!(stamped.schema_hash, FooV2::SCHEMA_HASH);
        assert_eq!(stamped.data, v);
    }

    // -----------------------------------------------------------------
    // UC-16-1 — round-trip at the current hash.
    // -----------------------------------------------------------------
    #[test]
    fn uc_16_round_trip_at_current_hash() {
        let v = FooV2 { a: 7, b: 42 };
        let text = save(&v).unwrap();
        // Stamp visible in the serialized output.
        assert!(text.contains("test/foo/v2"));
        let back: FooV2 = load(&text).unwrap();
        assert_eq!(back, v);
    }

    // -----------------------------------------------------------------
    // UC-16-2 — hash mismatch, serde defaults make it compatible.
    // -----------------------------------------------------------------
    #[test]
    fn uc_16_hash_mismatch_loads_via_serde_defaults_when_compatible() {
        // Stamped under a future hash we don't know; data shape only
        // has `a`. Because `b` is `#[serde(default)]`, the direct
        // deserialize fallback succeeds.
        let stamped = serde_json::json!({
            "schema_hash": "test/foo/v999_future",
            "data": { "a": 5 }
        })
        .to_string();
        let back: FooV2 = load(&stamped).unwrap();
        assert_eq!(back, FooV2 { a: 5, b: 0 });
    }

    // -----------------------------------------------------------------
    // UC-16-3 — registered transition fires on hash mismatch.
    // -----------------------------------------------------------------
    #[test]
    fn uc_16_hash_mismatch_runs_transition_when_registered() {
        let stamped = serde_json::json!({
            "schema_hash": "test/foo/v1",
            "data": { "a": 3 }
        })
        .to_string();
        let back: FooV2 = load(&stamped).unwrap();
        // Transition derived b = a * 10.
        assert_eq!(back, FooV2 { a: 3, b: 30 });
    }

    // -----------------------------------------------------------------
    // UC-16-4 — no transition + incompatible shape returns NoTransition.
    // -----------------------------------------------------------------
    #[test]
    fn uc_16_no_transition_returns_typed_error() {
        let stamped = serde_json::json!({
            "schema_hash": "test/strict/v999",
            "data": { "irrelevant": "wrong shape" }
        })
        .to_string();
        match load::<Strict>(&stamped) {
            Err(ArtifactError::NoTransition { from, to, .. }) => {
                assert_eq!(from, "test/strict/v999");
                assert_eq!(to, "test/strict/v1");
            }
            other => panic!("expected NoTransition, got {other:?}"),
        }
    }

    // -----------------------------------------------------------------
    // UC-16-5 — legacy unstamped file loads when shape still matches.
    // -----------------------------------------------------------------
    #[test]
    fn uc_16_legacy_unstamped_file_loads() {
        // File written before UC-16; no envelope, just the raw type.
        let legacy = serde_json::json!({ "a": 9 }).to_string();
        let back: FooV2 = load(&legacy).unwrap();
        assert_eq!(back, FooV2 { a: 9, b: 0 });
    }

    // -----------------------------------------------------------------
    // UC-16c — LoadOutcome variants.
    // -----------------------------------------------------------------
    #[test]
    fn uc_16c_direct_match_outcome_is_direct_match() {
        let v = FooV2 { a: 1, b: 2 };
        let text = save(&v).unwrap();
        let (_, outcome) = load_with_outcome::<FooV2>(&text).unwrap();
        assert_eq!(outcome, LoadOutcome::DirectMatch);
        assert!(!outcome.requires_backup());
    }

    #[test]
    fn uc_16c_transition_applied_outcome_carries_from_to() {
        let stamped = serde_json::json!({
            "schema_hash": "test/foo/v1",
            "data": { "a": 3 }
        })
        .to_string();
        let (_, outcome) = load_with_outcome::<FooV2>(&stamped).unwrap();
        match outcome {
            LoadOutcome::TransitionApplied { from, to } => {
                assert_eq!(from, "test/foo/v1");
                assert_eq!(to, "test/foo/v2");
            }
            other => panic!("expected TransitionApplied, got {other:?}"),
        }
        assert!(load_with_outcome::<FooV2>(&stamped).unwrap().1.requires_backup());
    }

    #[test]
    fn uc_16c_serde_defaults_fallback_outcome_carries_stored_hash() {
        let stamped = serde_json::json!({
            "schema_hash": "test/foo/v999_future",
            "data": { "a": 5 }
        })
        .to_string();
        let (_, outcome) = load_with_outcome::<FooV2>(&stamped).unwrap();
        match outcome {
            LoadOutcome::SerdeDefaultsFallback { stored_hash, current_hash } => {
                assert_eq!(stored_hash, "test/foo/v999_future");
                assert_eq!(current_hash, "test/foo/v2");
            }
            other => panic!("expected SerdeDefaultsFallback, got {other:?}"),
        }
    }

    #[test]
    fn uc_16c_legacy_unstamped_outcome_marker() {
        let legacy = serde_json::json!({ "a": 9 }).to_string();
        let (_, outcome) = load_with_outcome::<FooV2>(&legacy).unwrap();
        assert_eq!(outcome, LoadOutcome::LegacyUnstamped);
        assert!(outcome.requires_backup());
    }

    // -----------------------------------------------------------------
    // UC-16c — `write_backup` / `bak_path`.
    // -----------------------------------------------------------------
    #[test]
    fn uc_16c_bak_path_appends_dot_bak() {
        use std::path::Path;
        let p = Path::new("/tmp/foo.json");
        assert_eq!(bak_path(p), Path::new("/tmp/foo.json.bak"));
    }

    #[test]
    fn uc_16c_write_backup_copies_existing_file() {
        let path = std::env::temp_dir().join("uc_16c_backup_test.json");
        let bak = bak_path(&path);
        std::fs::remove_file(&path).ok();
        std::fs::remove_file(&bak).ok();

        std::fs::write(&path, "original contents").unwrap();
        let written = write_backup(&path).unwrap();
        assert_eq!(written, Some(bak.clone()));
        assert_eq!(std::fs::read_to_string(&bak).unwrap(), "original contents");

        std::fs::remove_file(&path).ok();
        std::fs::remove_file(&bak).ok();
    }

    #[test]
    fn uc_16c_write_backup_is_idempotent() {
        let path = std::env::temp_dir().join("uc_16c_backup_idempotent.json");
        let bak = bak_path(&path);
        std::fs::remove_file(&path).ok();
        std::fs::remove_file(&bak).ok();

        std::fs::write(&path, "first").unwrap();
        let _ = write_backup(&path).unwrap();
        // Overwrite the source; second backup must NOT clobber the original snapshot.
        std::fs::write(&path, "second").unwrap();
        let _ = write_backup(&path).unwrap();
        assert_eq!(
            std::fs::read_to_string(&bak).unwrap(),
            "first",
            "second backup must preserve the first observed snapshot"
        );

        std::fs::remove_file(&path).ok();
        std::fs::remove_file(&bak).ok();
    }

    #[test]
    fn uc_16c_write_backup_missing_source_returns_none() {
        let path = std::env::temp_dir().join("uc_16c_backup_absent.json");
        let bak = bak_path(&path);
        std::fs::remove_file(&path).ok();
        std::fs::remove_file(&bak).ok();
        assert_eq!(write_backup(&path).unwrap(), None);
    }

    // -----------------------------------------------------------------
    // Sanity: stamped output has both fields and ordered envelope.
    // -----------------------------------------------------------------
    #[test]
    fn save_emits_stamped_envelope_with_both_fields() {
        let v = Strict { required: "ok".into() };
        let text = save(&v).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
        assert_eq!(
            parsed.get("schema_hash").and_then(|v| v.as_str()),
            Some("test/strict/v1")
        );
        assert!(parsed.get("data").is_some());
    }
}
