//! Integration tests for `check_workspace` (Iter 5b).
//!
//! These tests drive the canonical `examples/ws_*.ail.json` files;
//! the loader and the checker together form the pipeline that `ail check`
//! runs in JSON mode against a workspace.

use ailang_check::{check_workspace, Severity};
use ailang_core::load_workspace;
use std::path::Path;

/// Iter 18c.2: assert that the linearity check's `suggested_rewrites`
/// payload is non-empty AND each `replacement` parses as a form-A AILang
/// term. This is the contract the check promises to consumers of
/// `ail check --json`: a machine can take the replacement string and
/// substitute it back into the source without re-tokenising the term.
fn assert_suggested_rewrites_well_formed(d: &ailang_check::Diagnostic) {
    assert!(
        !d.suggested_rewrites.is_empty(),
        "diagnostic {} (def={:?}) has no suggested_rewrites",
        d.code,
        d.def
    );
    for r in &d.suggested_rewrites {
        ailang_surface::parse_term(&r.replacement).unwrap_or_else(|e| {
            panic!(
                "suggested rewrite for `{}` does not parse as form-A AILang: {:?}\nreplacement: {}",
                d.code, e, r.replacement
            )
        });
    }
}

fn examples_dir() -> std::path::PathBuf {
    let manifest = env!("CARGO_MANIFEST_DIR");
    Path::new(manifest).parent().unwrap().parent().unwrap().join("examples")
}

#[test]
fn happy_path_resolves_qualified_import() {
    // ws_main imports ws_lib and calls `ws_lib.add` — fully typed.
    // Expected: no diagnostics.
    let entry = examples_dir().join("ws_main.ail.json");
    let ws = load_workspace(&entry).expect("load ws_main");
    let diags = check_workspace(&ws);
    assert!(
        diags.is_empty(),
        "expected no diagnostics; got: {}",
        serde_json::to_string_pretty(&diags).unwrap()
    );
}

#[test]
fn unknown_import_is_reported() {
    // ws_broken references `ws_lib.bogus` — module is there, def isn't.
    let entry = examples_dir().join("ws_broken.ail.json");
    let ws = load_workspace(&entry).expect("load ws_broken");
    let diags = check_workspace(&ws);
    assert_eq!(diags.len(), 1, "got: {:?}", diags);
    assert!(matches!(diags[0].severity, Severity::Error));
    assert_eq!(diags[0].code, "unknown-import");
    // Context must point structurally to module + def name.
    assert_eq!(
        diags[0].ctx.get("module").and_then(|v| v.as_str()),
        Some("ws_lib")
    );
    assert_eq!(
        diags[0].ctx.get("name").and_then(|v| v.as_str()),
        Some("bogus")
    );
}

#[test]
fn unknown_module_prefix_is_reported() {
    // ws_unknown_module has no imports but references `nope.x`.
    let entry = examples_dir().join("ws_unknown_module.ail.json");
    let ws = load_workspace(&entry).expect("load ws_unknown_module");
    let diags = check_workspace(&ws);
    assert_eq!(diags.len(), 1, "got: {:?}", diags);
    assert!(matches!(diags[0].severity, Severity::Error));
    assert_eq!(diags[0].code, "unknown-module");
    assert_eq!(
        diags[0].ctx.get("module").and_then(|v| v.as_str()),
        Some("nope")
    );
}

#[test]
fn invalid_def_name_with_dot_is_reported() {
    // Synthetic: a module with a def whose name contains a dot.
    // We construct this as a Module directly and feed it into a
    // trivial workspace, because the canonical convention should not
    // let this through to disk in the first place.
    use ailang_core::ast::*;
    use std::collections::BTreeMap;

    let m = Module {
        schema: ailang_core::SCHEMA.into(),
        name: "t".into(),
        imports: vec![],
        defs: vec![Def::Const(ConstDef {
            name: "weird.name".into(),
            ty: Type::int(),
            value: Term::Lit {
                lit: Literal::Int { value: 0 },
            },
            doc: None,
        })],
    };
    let mut modules = BTreeMap::new();
    modules.insert(m.name.clone(), m.clone());
    let ws = ailang_core::Workspace {
        entry: m.name.clone(),
        modules,
        root_dir: std::path::PathBuf::from("."),
        registry: ailang_core::workspace::Registry::default(),
    };
    let diags = check_workspace(&ws);
    assert_eq!(diags.len(), 1, "got: {:?}", diags);
    assert_eq!(diags[0].code, "invalid-def-name");
    assert_eq!(
        diags[0].ctx.get("reason").and_then(|v| v.as_str()),
        Some("contains-dot")
    );
}

/// Iter 6: body-check is multi-diagnose. A module with two independent
/// errors in different defs must produce two diagnostics — the first
/// error must not short-circuit the second.
#[test]
fn body_errors_accumulate_across_defs() {
    use ailang_core::ast::*;
    use std::collections::BTreeMap;

    // Two fns, each with a different body error:
    //   bad_a: arity mismatch — calls `+` with three arguments.
    //   bad_b: references a name that does not exist anywhere.
    let bad_a = Def::Fn(FnDef {
        name: "bad_a".into(),
        ty: Type::Fn {
            params: vec![],
            ret: Box::new(Type::int()),
            effects: vec![],
            param_modes: vec![],
            ret_mode: ParamMode::Implicit,
        },
        params: vec![],
        body: Term::App {
            callee: Box::new(Term::Var { name: "+".into() }),
            args: vec![
                Term::Lit { lit: Literal::Int { value: 1 } },
                Term::Lit { lit: Literal::Int { value: 2 } },
                Term::Lit { lit: Literal::Int { value: 3 } },
            ],
            tail: false,
        },
        suppress: vec![],
        doc: None,
    });
    let bad_b = Def::Fn(FnDef {
        name: "bad_b".into(),
        ty: Type::Fn {
            params: vec![],
            ret: Box::new(Type::int()),
            effects: vec![],
            param_modes: vec![],
            ret_mode: ParamMode::Implicit,
        },
        params: vec![],
        body: Term::Var {
            name: "this_does_not_exist".into(),
        },
        suppress: vec![],
        doc: None,
    });

    let m = Module {
        schema: ailang_core::SCHEMA.into(),
        name: "two_errors".into(),
        imports: vec![],
        defs: vec![bad_a, bad_b],
    };
    let mut modules = BTreeMap::new();
    modules.insert(m.name.clone(), m.clone());
    let ws = ailang_core::Workspace {
        entry: m.name.clone(),
        modules,
        root_dir: std::path::PathBuf::from("."),
        registry: ailang_core::workspace::Registry::default(),
    };
    let diags = check_workspace(&ws);

    assert_eq!(diags.len(), 2, "want both errors, got: {:#?}", diags);

    // Each diagnostic carries the offending def name as `def`.
    let defs: Vec<&str> = diags
        .iter()
        .filter_map(|d| d.def.as_deref())
        .collect();
    assert!(defs.contains(&"bad_a"), "missing bad_a; got {defs:?}");
    assert!(defs.contains(&"bad_b"), "missing bad_b; got {defs:?}");
}

/// Iter 18c.2: a fn with all-explicit modes that consumes its
/// `(own (con List))` parameter twice should trigger
/// `use-after-consume` on the second occurrence and ship a
/// non-empty, well-formed `suggested_rewrites`.
///
/// Body:
///   `(seq (app sum_list xs) (app sum_list xs))`
///
/// Both `xs` are in Own arg position; the second use is after consume.
#[test]
fn use_after_consume_on_own_param_is_reported() {
    use ailang_core::ast::*;
    use std::collections::BTreeMap;

    // Helper: a fn that consumes a (con List) and returns Int.
    let sum_list = Def::Fn(FnDef {
        name: "sum_list".into(),
        ty: Type::Fn {
            params: vec![Type::Con { name: "List".into(), args: vec![] }],
            param_modes: vec![ParamMode::Own],
            ret: Box::new(Type::int()),
            ret_mode: ParamMode::Implicit,
            effects: vec![],
        },
        params: vec!["ys".into()],
        body: Term::Lit { lit: Literal::Int { value: 0 } },
        suppress: vec![],
        doc: None,
    });

    // The offending fn. Body is `(+ (sum_list xs) (sum_list xs))` — both
    // arg slots of `+` are Implicit/Consume, so `xs` is consumed twice
    // without intervening clone. The second occurrence triggers
    // `use-after-consume`. (Using `+` rather than `Seq` keeps the
    // function's return-type Int, which matches its declared signature.)
    let bad = Def::Fn(FnDef {
        name: "bad".into(),
        ty: Type::Fn {
            params: vec![Type::Con { name: "List".into(), args: vec![] }],
            param_modes: vec![ParamMode::Own],
            ret: Box::new(Type::int()),
            ret_mode: ParamMode::Implicit,
            effects: vec![],
        },
        params: vec!["xs".into()],
        body: Term::App {
            callee: Box::new(Term::Var { name: "+".into() }),
            args: vec![
                Term::App {
                    callee: Box::new(Term::Var { name: "sum_list".into() }),
                    args: vec![Term::Var { name: "xs".into() }],
                    tail: false,
                },
                Term::App {
                    callee: Box::new(Term::Var { name: "sum_list".into() }),
                    args: vec![Term::Var { name: "xs".into() }],
                    tail: false,
                },
            ],
            tail: false,
        },
        suppress: vec![],
        doc: None,
    });

    // List ADT (referenced by both fn types via `Type::Con`); a real
    // module needs the type to be in-scope for `check_type_well_formed`.
    let list_adt = Def::Type(TypeDef {
        name: "List".into(),
        vars: vec![],
        ctors: vec![Ctor {
            name: "Nil".into(),
            fields: vec![],
        }],
        doc: None,
        drop_iterative: false,
    });

    let m = Module {
        schema: ailang_core::SCHEMA.into(),
        name: "lin_uac".into(),
        imports: vec![],
        defs: vec![list_adt, sum_list, bad],
    };
    let mut modules = BTreeMap::new();
    modules.insert(m.name.clone(), m.clone());
    let ws = ailang_core::Workspace {
        entry: m.name.clone(),
        modules,
        root_dir: std::path::PathBuf::from("."),
        registry: ailang_core::workspace::Registry::default(),
    };
    let diags = check_workspace(&ws);

    let lin: Vec<&ailang_check::Diagnostic> = diags
        .iter()
        .filter(|d| d.code == "use-after-consume")
        .collect();
    assert_eq!(
        lin.len(),
        1,
        "want exactly one use-after-consume; got: {:#?}",
        diags
    );
    let d = lin[0];
    assert!(matches!(d.severity, Severity::Error));
    assert_eq!(d.def.as_deref(), Some("bad"));
    assert_eq!(
        d.ctx.get("binder").and_then(|v| v.as_str()),
        Some("xs"),
        "ctx should name the offending binder; got {:?}",
        d.ctx
    );
    assert_suggested_rewrites_well_formed(d);
}

/// Iter 18c.2: a fn whose body passes `xs` to a `Borrow` arg and then,
/// in a SIBLING arg slot of the same call, consumes it via an `Own`
/// arg, should trigger `consume-while-borrowed`.
///
/// Body:
///   `(app dual_fn xs xs)`
///   where `dual_fn` has param_modes = [Borrow, Own].
#[test]
fn consume_while_borrowed_in_sibling_arg_is_reported() {
    use ailang_core::ast::*;
    use std::collections::BTreeMap;

    let list_adt = Def::Type(TypeDef {
        name: "List".into(),
        vars: vec![],
        ctors: vec![Ctor {
            name: "Nil".into(),
            fields: vec![],
        }],
        doc: None,
        drop_iterative: false,
    });

    // dual_fn: (borrow List) → (own List) → Int.
    let dual_fn = Def::Fn(FnDef {
        name: "dual_fn".into(),
        ty: Type::Fn {
            params: vec![
                Type::Con { name: "List".into(), args: vec![] },
                Type::Con { name: "List".into(), args: vec![] },
            ],
            param_modes: vec![ParamMode::Borrow, ParamMode::Own],
            ret: Box::new(Type::int()),
            ret_mode: ParamMode::Implicit,
            effects: vec![],
        },
        params: vec!["a".into(), "b".into()],
        body: Term::Lit { lit: Literal::Int { value: 0 } },
        suppress: vec![],
        doc: None,
    });

    let bad = Def::Fn(FnDef {
        name: "bad".into(),
        ty: Type::Fn {
            params: vec![Type::Con { name: "List".into(), args: vec![] }],
            param_modes: vec![ParamMode::Own],
            ret: Box::new(Type::int()),
            ret_mode: ParamMode::Implicit,
            effects: vec![],
        },
        params: vec!["xs".into()],
        body: Term::App {
            callee: Box::new(Term::Var { name: "dual_fn".into() }),
            args: vec![
                Term::Var { name: "xs".into() },
                Term::Var { name: "xs".into() },
            ],
            tail: false,
        },
        suppress: vec![],
        doc: None,
    });

    let m = Module {
        schema: ailang_core::SCHEMA.into(),
        name: "lin_cwb".into(),
        imports: vec![],
        defs: vec![list_adt, dual_fn, bad],
    };
    let mut modules = BTreeMap::new();
    modules.insert(m.name.clone(), m.clone());
    let ws = ailang_core::Workspace {
        entry: m.name.clone(),
        modules,
        root_dir: std::path::PathBuf::from("."),
        registry: ailang_core::workspace::Registry::default(),
    };
    let diags = check_workspace(&ws);

    let lin: Vec<&ailang_check::Diagnostic> = diags
        .iter()
        .filter(|d| d.code == "consume-while-borrowed")
        .collect();
    assert_eq!(
        lin.len(),
        1,
        "want exactly one consume-while-borrowed; got: {:#?}",
        diags
    );
    let d = lin[0];
    assert!(matches!(d.severity, Severity::Error));
    assert_eq!(d.def.as_deref(), Some("bad"));
    assert_eq!(
        d.ctx.get("binder").and_then(|v| v.as_str()),
        Some("xs"),
        "ctx should name the offending binder; got {:?}",
        d.ctx
    );
    assert_suggested_rewrites_well_formed(d);
}

/// Iter 18d.1: happy-path `(reuse-as xs (term-ctor List Cons ...))`
/// inside an all-explicit-mode `map_inc`-style fn must produce NO
/// diagnostics — neither `reuse-as-non-allocating-body` (body is a
/// Ctor) nor `reuse-as-source-not-bare-var` (source is `xs`) nor
/// `use-after-consume` (xs is matched then consumed once via
/// reuse-as in the Cons arm; the merge across arms is consistent).
///
/// Body:
///   `(match xs
///      (case Nil (term-ctor List Nil))
///      (case (Cons h t)
///        (reuse-as xs (term-ctor List Cons (+ h 1) (app map_inc t)))))`
#[test]
fn reuse_as_happy_path_in_map_inc_is_linearity_clean() {
    use ailang_core::ast::*;
    use std::collections::BTreeMap;

    let list_adt = Def::Type(TypeDef {
        name: "List".into(),
        vars: vec![],
        ctors: vec![
            Ctor { name: "Nil".into(), fields: vec![] },
            Ctor {
                name: "Cons".into(),
                fields: vec![
                    Type::Con { name: "Int".into(), args: vec![] },
                    Type::Con { name: "List".into(), args: vec![] },
                ],
            },
        ],
        doc: None,
        drop_iterative: false,
    });

    let map_inc_ty = Type::Fn {
        params: vec![Type::Con { name: "List".into(), args: vec![] }],
        param_modes: vec![ParamMode::Own],
        ret: Box::new(Type::Con { name: "List".into(), args: vec![] }),
        ret_mode: ParamMode::Own,
        effects: vec![],
    };

    // Cons arm body:
    //   (reuse-as xs
    //     (term-ctor List Cons (app + h 1) (app map_inc t)))
    let cons_arm_body = Term::ReuseAs {
        source: Box::new(Term::Var { name: "xs".into() }),
        body: Box::new(Term::Ctor {
            type_name: "List".into(),
            ctor: "Cons".into(),
            args: vec![
                Term::App {
                    callee: Box::new(Term::Var { name: "+".into() }),
                    args: vec![
                        Term::Var { name: "h".into() },
                        Term::Lit { lit: Literal::Int { value: 1 } },
                    ],
                    tail: false,
                },
                Term::App {
                    callee: Box::new(Term::Var { name: "map_inc".into() }),
                    args: vec![Term::Var { name: "t".into() }],
                    tail: false,
                },
            ],
        }),
    };

    let map_inc_body = Term::Match {
        scrutinee: Box::new(Term::Var { name: "xs".into() }),
        arms: vec![
            Arm {
                pat: Pattern::Ctor { ctor: "Nil".into(), fields: vec![] },
                body: Term::Ctor {
                    type_name: "List".into(),
                    ctor: "Nil".into(),
                    args: vec![],
                },
            },
            Arm {
                pat: Pattern::Ctor {
                    ctor: "Cons".into(),
                    fields: vec![
                        Pattern::Var { name: "h".into() },
                        Pattern::Var { name: "t".into() },
                    ],
                },
                body: cons_arm_body,
            },
        ],
    };

    let map_inc = Def::Fn(FnDef {
        name: "map_inc".into(),
        ty: map_inc_ty,
        params: vec!["xs".into()],
        body: map_inc_body,
        suppress: vec![],
        doc: None,
    });

    let m = Module {
        schema: ailang_core::SCHEMA.into(),
        name: "reuse_as_happy".into(),
        imports: vec![],
        defs: vec![list_adt, map_inc],
    };
    let mut modules = BTreeMap::new();
    modules.insert(m.name.clone(), m.clone());
    let ws = ailang_core::Workspace {
        entry: m.name.clone(),
        modules,
        root_dir: std::path::PathBuf::from("."),
        registry: ailang_core::workspace::Registry::default(),
    };
    let diags = check_workspace(&ws);
    assert!(
        diags.is_empty(),
        "happy-path reuse-as must check clean; got: {:#?}",
        diags
    );
}

/// Iter 18d.2: a deliberately mismatched reuse-as — `(reuse-as xs
/// (Nil))` inside the Cons arm of a match on `xs` — must surface as
/// a `reuse-as-shape-mismatch` diagnostic. The source's path-ctor
/// (Cons, 2 fields) and the body ctor (Nil, 0 fields) have different
/// shapes; the in-place rewrite is unsafe; the build must fail.
///
/// The diagnostic must:
/// - carry code `reuse-as-shape-mismatch`
/// - identify the offending def (`f`)
/// - record `ctx.reason` as a stable kebab-case sub-code (here:
///   `field-count-mismatch`) so JSON consumers can branch on the
///   specific failure mode without prose-parsing
/// - ship a non-empty `suggested_rewrites` whose replacement parses
///   back as form-A AILang (the rewrite drops the wrapper and keeps
///   the body alone)
#[test]
fn reuse_as_shape_mismatch_is_reported_on_cons_to_nil() {
    use ailang_core::ast::*;
    use std::collections::BTreeMap;

    let list_adt = Def::Type(TypeDef {
        name: "List".into(),
        vars: vec![],
        ctors: vec![
            Ctor { name: "Nil".into(), fields: vec![] },
            Ctor {
                name: "Cons".into(),
                fields: vec![
                    Type::Con { name: "Int".into(), args: vec![] },
                    Type::Con { name: "List".into(), args: vec![] },
                ],
            },
        ],
        doc: None,
        drop_iterative: false,
    });

    let f_ty = Type::Fn {
        params: vec![Type::Con { name: "List".into(), args: vec![] }],
        param_modes: vec![ParamMode::Own],
        ret: Box::new(Type::Con { name: "List".into(), args: vec![] }),
        ret_mode: ParamMode::Own,
        effects: vec![],
    };

    // Body:
    //   (match xs
    //     (Nil → Nil)
    //     (Cons h t → (reuse-as xs Nil)))   ; BAD: 2-field Cons → 0-field Nil.
    let cons_arm_body = Term::ReuseAs {
        source: Box::new(Term::Var { name: "xs".into() }),
        body: Box::new(Term::Ctor {
            type_name: "List".into(),
            ctor: "Nil".into(),
            args: vec![],
        }),
    };
    let body = Term::Match {
        scrutinee: Box::new(Term::Var { name: "xs".into() }),
        arms: vec![
            Arm {
                pat: Pattern::Ctor { ctor: "Nil".into(), fields: vec![] },
                body: Term::Ctor {
                    type_name: "List".into(),
                    ctor: "Nil".into(),
                    args: vec![],
                },
            },
            Arm {
                pat: Pattern::Ctor {
                    ctor: "Cons".into(),
                    fields: vec![
                        Pattern::Var { name: "h".into() },
                        Pattern::Var { name: "t".into() },
                    ],
                },
                body: cons_arm_body,
            },
        ],
    };

    let f = Def::Fn(FnDef {
        name: "f".into(),
        ty: f_ty,
        params: vec!["xs".into()],
        body,
        suppress: vec![],
        doc: None,
    });

    let m = Module {
        schema: ailang_core::SCHEMA.into(),
        name: "reuse_as_mismatch".into(),
        imports: vec![],
        defs: vec![list_adt, f],
    };
    let mut modules = BTreeMap::new();
    modules.insert(m.name.clone(), m.clone());
    let ws = ailang_core::Workspace {
        entry: m.name.clone(),
        modules,
        root_dir: std::path::PathBuf::from("."),
        registry: ailang_core::workspace::Registry::default(),
    };
    let diags = check_workspace(&ws);
    let shape_diags: Vec<&ailang_check::Diagnostic> = diags
        .iter()
        .filter(|d| d.code == "reuse-as-shape-mismatch")
        .collect();
    assert_eq!(
        shape_diags.len(),
        1,
        "expected exactly one reuse-as-shape-mismatch diagnostic; got: {diags:#?}"
    );
    let d = shape_diags[0];
    assert!(matches!(d.severity, Severity::Error));
    assert_eq!(d.def.as_deref(), Some("f"));
    assert_eq!(
        d.ctx.get("reason").and_then(|v| v.as_str()),
        Some("field-count-mismatch"),
        "ctx.reason must be the stable sub-code; ctx was: {}",
        d.ctx
    );
    assert_suggested_rewrites_well_formed(d);
}

/// Iter 18c.2: positive control. The ON-DISK `borrow_own_demo` fixture
/// (the only currently-shipping all-explicit-mode program) must remain
/// linearity-clean. If this regresses, the check has become incorrect:
/// `borrow_own_demo`'s `list_length` (borrow) and `sum_list` (own)
/// are exactly the canonical accept shape.
#[test]
fn borrow_own_demo_is_linearity_clean() {
    let entry = examples_dir().join("borrow_own_demo.ail.json");
    let ws = load_workspace(&entry).expect("load borrow_own_demo");
    let diags = check_workspace(&ws);
    let lin: Vec<&ailang_check::Diagnostic> = diags
        .iter()
        .filter(|d| {
            d.code == "use-after-consume" || d.code == "consume-while-borrowed"
        })
        .collect();
    assert!(
        lin.is_empty(),
        "borrow_own_demo must stay linearity-clean; got: {:#?}",
        lin
    );
    // Belt-and-braces: the *whole* check should be clean too — modes
    // are still metadata-only at the typechecker level (Iter 18a).
    assert!(
        diags.is_empty(),
        "borrow_own_demo must check clean; got: {:#?}",
        diags
    );
}
