//! Content-addressed hashing for definitions.
//!
//! Hash = BLAKE3 over the canonical JSON bytes of a [`Def`] (see
//! [`crate::canonical`]). The hash function takes a [`Def`] by
//! reference, so there is no `hash` field to strip — the in-memory
//! struct does not carry one.
//!
//! The single entry point at this level is [`def_hash`]. The parallel
//! entry point at module granularity is
//! [`crate::workspace::module_hash`].

use crate::ast::Def;
use crate::canonical;

/// Content hash of a single [`Def`] — the 16-hex-char (64-bit) prefix
/// of its BLAKE3 hash over canonical JSON bytes.
///
/// 64 bits is wide enough to be unique across realistic AILang
/// codebases and short enough to read at a glance in pretty-printed
/// manifests. The hash is computed over the **canonical JSON byte
/// pre-image**, not over the in-memory struct, so any change to the
/// canonical form (new fields, different `skip_serializing_if`
/// behaviour, key reordering bug) changes every hash. The Iter 13a
/// regression test below pins concrete hashes for two example
/// definitions to catch that.
///
/// # Examples
///
/// ```ignore
/// use ailang_core::{ast::*, def_hash};
///
/// let def = Def::Const(ConstDef {
///     name: "answer".into(),
///     ty: Type::int(),
///     value: Term::Lit { lit: Literal::Int { value: 42 } },
///     doc: None,
/// });
///
/// // Stable across runs: same canonical bytes -> same hash.
/// assert_eq!(def_hash(&def), def_hash(&def));
/// assert_eq!(def_hash(&def).len(), 16);
/// ```
pub fn def_hash(def: &Def) -> String {
    let bytes = canonical::to_bytes(def);
    let h = blake3::hash(&bytes);
    let hex = h.to_hex();
    hex.as_str()[..16].to_string()
}

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

    fn sample_fn() -> Def {
        Def::Fn(FnDef {
            name: "add".into(),
            ty: Type::Fn {
                params: vec![Type::int(), Type::int()],
                ret: Box::new(Type::int()),
                effects: vec![],
                param_modes: vec![],
                ret_mode: ParamMode::Implicit,
            },
            params: vec!["a".into(), "b".into()],
            body: Term::App {
                callee: Box::new(Term::Var { name: "+".into() }),
                args: vec![
                    Term::Var { name: "a".into() },
                    Term::Var { name: "b".into() },
                ],
                tail: false,
            },
            suppress: vec![],
            doc: None,
        })
    }

    #[test]
    fn hash_is_stable() {
        let h1 = def_hash(&sample_fn());
        let h2 = def_hash(&sample_fn());
        assert_eq!(h1, h2);
        assert_eq!(h1.len(), 16);
    }

    #[test]
    fn hash_changes_with_content() {
        let mut def = sample_fn();
        let h1 = def_hash(&def);
        if let Def::Fn(ref mut f) = def {
            f.name = "mul".into();
        }
        let h2 = def_hash(&def);
        assert_ne!(h1, h2);
    }

    /// Iter 13a regression: adding `vars` to TypeDef and `args` to
    /// `Type::Con` must NOT change canonical-JSON hashes of any pre-13a
    /// definition. Recorded hashes were captured from on-disk modules
    /// before the schema extension; if this fires, a
    /// `skip_serializing_if` is missing or wrong. We deserialise the
    /// real example modules from disk to avoid drift between the test
    /// and the source-of-truth JSON.
    #[test]
    fn iter13a_schema_extension_preserves_pre_13a_hashes() {
        let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
        let examples = manifest_dir.join("../../examples");

        let sum_src = std::fs::read(examples.join("sum.ail.json"))
            .expect("examples/sum.ail.json present");
        let sum_mod: crate::ast::Module = serde_json::from_slice(&sum_src).unwrap();
        let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
        assert_eq!(def_hash(sum_def), "db33f57cb329935e");

        let list_src = std::fs::read(examples.join("list.ail.json"))
            .expect("examples/list.ail.json present");
        let list_mod: crate::ast::Module = serde_json::from_slice(&list_src).unwrap();
        let int_list_def = list_mod.defs.iter().find(|d| d.name() == "IntList").unwrap();
        assert_eq!(def_hash(int_list_def), "b082192bd0c99202");
    }

    /// Iter 19b regression: adding `suppress` to [`crate::ast::FnDef`]
    /// must NOT change canonical-JSON hashes of any pre-19b fn whose
    /// `suppress` is empty. The `skip_serializing_if = "Vec::is_empty"`
    /// predicate on the field is what enforces this; if it is wrong,
    /// every existing fixture's hash drifts and `ail diff` /
    /// `ail manifest` output breaks.
    ///
    /// We construct two FnDefs that differ only in `suppress` (one
    /// empty, one missing the field). They must hash bit-identically:
    /// the canonical-JSON of both is the same byte sequence because
    /// the empty Vec is elided.
    #[test]
    fn iter19b_empty_suppress_preserves_pre_19b_hashes() {
        let with_empty_suppress = sample_fn();
        // Mutate the bare sample to set suppress explicitly to a
        // non-empty Vec, then mutate it back to empty: two distinct
        // construction paths that should still hash identically.
        let mut with_explicit_empty = sample_fn();
        if let Def::Fn(ref mut f) = with_explicit_empty {
            f.suppress = vec![];
        }
        assert_eq!(
            def_hash(&with_empty_suppress),
            def_hash(&with_explicit_empty),
            "two FnDefs with empty suppress must hash identically"
        );

        // And: a FnDef with a *non-empty* suppress hashes
        // *differently* (sanity check that the field is in fact in
        // the canonical bytes when present).
        let mut with_suppress = sample_fn();
        if let Def::Fn(ref mut f) = with_suppress {
            f.suppress = vec![crate::ast::Suppress {
                code: "over-strict-mode".into(),
                because: "test reason".into(),
            }];
        }
        assert_ne!(
            def_hash(&with_empty_suppress),
            def_hash(&with_suppress),
            "non-empty suppress must produce a distinct hash"
        );
    }

    /// Iter 19b: an on-disk pre-19b fixture must still load cleanly
    /// (no `suppress` field present in the JSON) and produce its
    /// canonical-byte hash unchanged. The hash for `sum.sum` was
    /// recorded by the 13a regression and must stay 16 hex chars
    /// equal to `db33f57cb329935e` — this test re-asserts it after
    /// the 19b schema bump.
    #[test]
    fn iter19b_schema_extension_preserves_pre_19b_hashes() {
        let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
        let examples = manifest_dir.join("../../examples");

        let sum_src = std::fs::read(examples.join("sum.ail.json"))
            .expect("examples/sum.ail.json present");
        let sum_mod: crate::ast::Module = serde_json::from_slice(&sum_src).unwrap();
        let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
        assert_eq!(def_hash(sum_def), "db33f57cb329935e");
    }

    /// Iter 22b.1 regression: adding `Def::Class` and `Def::Instance`
    /// must NOT change canonical-JSON hashes of any pre-22b def. The
    /// new variants are alternatives, not field additions — pre-22b
    /// fixtures simply do not produce a `Class` / `Instance` `Def`,
    /// and the existing `Fn` / `Const` / `Type` arms are unchanged.
    /// This re-asserts the same on-disk hashes the 13a / 19b
    /// regressions pinned, after the 22b.1 schema bump.
    #[test]
    fn iter22b1_schema_extension_preserves_pre_22b_hashes() {
        let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
        let examples = manifest_dir.join("../../examples");

        let sum_src = std::fs::read(examples.join("sum.ail.json"))
            .expect("examples/sum.ail.json present");
        let sum_mod: crate::ast::Module = serde_json::from_slice(&sum_src).unwrap();
        let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
        assert_eq!(def_hash(sum_def), "db33f57cb329935e");

        let list_src = std::fs::read(examples.join("list.ail.json"))
            .expect("examples/list.ail.json present");
        let list_mod: crate::ast::Module = serde_json::from_slice(&list_src).unwrap();
        let int_list_def = list_mod
            .defs
            .iter()
            .find(|d| d.name() == "IntList")
            .unwrap();
        assert_eq!(def_hash(int_list_def), "b082192bd0c99202");
    }

    /// Iter 22b.1: a [`crate::ast::ClassDef`] with no doc, no
    /// superclass, and an empty methods list serialises to a stable
    /// canonical form. Two construction paths (default-elided
    /// optionals vs. JSON without those keys at all) hash
    /// bit-identically. If FAIL: a `skip_serializing_if` is missing
    /// on `superclass` or `doc`, or the parser produces different
    /// canonical bytes from the same logical value.
    #[test]
    fn iter22b1_classdef_empty_optionals_hash_stable() {
        let bare = Def::Class(crate::ast::ClassDef {
            name: "Empty".into(),
            param: "a".into(),
            superclass: None,
            methods: vec![],
            doc: None,
        });

        let json = r#"{"kind":"class","name":"Empty","param":"a","methods":[]}"#;
        let parsed: Def = serde_json::from_str(json).unwrap();

        assert_eq!(
            def_hash(&bare),
            def_hash(&parsed),
            "ClassDef with elided optionals must hash identically to construction with explicit None"
        );
    }

    /// Iter 22b.2: adding `constraints` to [`crate::ast::Type::Forall`]
    /// must NOT alter canonical-JSON bytes of any pre-22b.2 polymorphic
    /// type. The `#[serde(default, skip_serializing_if = "Vec::is_empty")]`
    /// attribute on the field is what enforces this; if it is wrong,
    /// every existing fixture's hash drifts and workspace dedup keys
    /// based on the canonical bytes break.
    ///
    /// Property: a `Type::Forall` constructed without specifying
    /// `constraints` (i.e. empty vec) must serialise with no
    /// `constraints` key in the canonical JSON.
    #[test]
    fn forall_without_constraints_hashes_bit_identical_to_pre_22b2() {
        use crate::ast::Type;
        let t = Type::Forall {
            vars: vec!["a".into()],
            constraints: vec![],
            body: Box::new(Type::fn_implicit(
                vec![Type::Var { name: "a".into() }],
                Type::Var { name: "a".into() },
                vec![],
            )),
        };
        let bytes = crate::canonical::to_bytes(&t);
        let s = std::str::from_utf8(&bytes).unwrap();
        assert!(
            !s.contains("constraints"),
            "Type::Forall serialised must omit `constraints` when empty; got: {s}"
        );
    }

    /// Iter ct.4 (canonical-type-names milestone close): pin the
    /// canonical-form hashes of the two cross-module fixtures
    /// migrated by `ail migrate-canonical-types` in ct.1. These
    /// hashes are the post-migration state; pre-migration values
    /// (when both fixtures carried bare cross-module Type::Con)
    /// are no longer reproducible because the ct.1 validator
    /// rejects that shape upstream.
    #[test]
    fn ct4_migrated_fixtures_have_canonical_form_hashes() {
        let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
        let examples = manifest_dir.join("../../examples");

        let ord_src = std::fs::read(examples.join("ordering_match.ail.json"))
            .expect("examples/ordering_match.ail.json present");
        let ord_mod: crate::ast::Module = serde_json::from_slice(&ord_src).unwrap();
        let main_def = ord_mod.defs.iter().find(|d| d.name() == "main").unwrap();
        assert_eq!(
            def_hash(main_def),
            "8d17235aa3d2e127",
            "ordering_match::main canonical hash must match captured post-migration value"
        );

        let dup_a_src = std::fs::read(examples.join("test_22b1_dup_a.ail.json"))
            .expect("examples/test_22b1_dup_a.ail.json present");
        let dup_a_mod: crate::ast::Module = serde_json::from_slice(&dup_a_src).unwrap();
        // test_22b1_dup_a has two defs: the Show class and an instance
        // of Show for test_22b1_dup_b.MyInt. Def::name() returns the
        // class name for both, so pin each by index. The instance
        // carries the migrated cross-module qualifier in its `type`.
        assert_eq!(dup_a_mod.defs.len(), 2, "test_22b1_dup_a expected to have exactly 2 defs (class + instance)");
        assert_eq!(
            def_hash(&dup_a_mod.defs[0]),
            "1c2573661ffd3da3",
            "test_22b1_dup_a class Show canonical hash must match captured post-migration value"
        );
        assert_eq!(
            def_hash(&dup_a_mod.defs[1]),
            "cc8685f92f246e40",
            "test_22b1_dup_a instance Show for test_22b1_dup_b.MyInt canonical hash must match captured post-migration value"
        );
    }

    /// Iter ct.4: re-assert that the canonical-form tightening
    /// did NOT change hashes of intra-module-only fixtures. The
    /// existing iter-13a / iter-19b / iter-22b.1 pin tests already
    /// assert these; this one names the canonical-type-names
    /// milestone explicitly so future archaeology finds the
    /// connection.
    #[test]
    fn ct4_unmigrated_fixtures_remain_bit_identical() {
        let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
        let examples = manifest_dir.join("../../examples");

        let sum_src = std::fs::read(examples.join("sum.ail.json")).unwrap();
        let sum_mod: crate::ast::Module = serde_json::from_slice(&sum_src).unwrap();
        let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
        assert_eq!(def_hash(sum_def), "db33f57cb329935e",
            "sum.sum hash drifted across canonical-form tightening — unexpected");

        let list_src = std::fs::read(examples.join("list.ail.json")).unwrap();
        let list_mod: crate::ast::Module = serde_json::from_slice(&list_src).unwrap();
        let int_list_def = list_mod.defs.iter().find(|d| d.name() == "IntList").unwrap();
        assert_eq!(def_hash(int_list_def), "b082192bd0c99202",
            "list.IntList hash drifted across canonical-form tightening — unexpected");
    }
}
