//! Iter 19b: per-module post-process that consumes
//! [`ailang_core::ast::FnDef::suppress`] entries.
//!
//! For each `Def::Fn(f)` and each `Suppress { code, because }` in
//! `f.suppress`:
//!
//! 1. If `because.trim()` is empty, push an `empty-suppress-reason`
//!    `Error` diagnostic (the typechecker rejects the suppression
//!    itself; the original diagnostic still fires unmasked, because
//!    a malformed suppression cannot suppress anything).
//! 2. Otherwise, drop every diagnostic from the accumulated list whose
//!    `def == Some(f.name)` AND `code == s.code`.
//!
//! The check runs once per module after every other diagnostic source
//! (typecheck → linearity → reuse-shape) has appended its output.
//! That way every diagnostic the suppression might mask is already in
//! the list when we filter.
//!
//! ## Why a wrong code does not error
//!
//! The diagnostic registry is open-set — codes are added per iter
//! and consumers (LLM tooling, future review tools) treat the set
//! as growing. A `(suppress (code "<unknown>") ...)` entry simply
//! matches no diagnostic and therefore suppresses nothing; the
//! typechecker stays silent about it. This is intentionally
//! permissive: the cost of a typo is "the warning still fires", not
//! a hard build break, so authors who mis-type a code get the
//! original signal back instead of a confusing meta-error.
//!
//! ## Why empty `because` is hard-Error
//!
//! `because` is the only thing keeping a suppress from being a
//! drive-by silence: without a reason, a future maintainer (likely
//! a future LLM) reading the def has no way to judge whether to
//! keep, tighten, or remove the suppression. Refusing to typecheck
//! the def at all forces the explicit-intent contract.

use crate::diagnostic::{Diagnostic, Severity};
use ailang_core::ast::{Def, Module};

/// Apply the suppress filter to `diags` (mutates in place) for every
/// `Def::Fn` in `m`. New diagnostics (`empty-suppress-reason`) are
/// pushed onto `diags`.
pub(crate) fn apply(m: &Module, diags: &mut Vec<Diagnostic>) {
    // Walk fns; collect (def_name, code) pairs to drop, plus any
    // empty-reason errors to add. We can do this in one pass per fn
    // because a wrong code cannot drop anything (no match) and an
    // empty reason explicitly does not drop the diagnostic it points
    // to (per the module-level rationale).
    let mut to_drop: Vec<(String, String)> = Vec::new();
    let mut to_add: Vec<Diagnostic> = Vec::new();
    for def in &m.defs {
        let f = match def {
            Def::Fn(f) => f,
            _ => continue,
        };
        for s in &f.suppress {
            if s.because.trim().is_empty() {
                to_add.push(make_empty_suppress_reason(&f.name, &s.code));
                // Do NOT add to `to_drop`: an invalid suppression does
                // not suppress anything — see module rationale.
            } else {
                to_drop.push((f.name.clone(), s.code.clone()));
            }
        }
    }

    if !to_drop.is_empty() {
        diags.retain(|d| {
            let key = match &d.def {
                Some(name) => (name.clone(), d.code.clone()),
                None => return true,
            };
            !to_drop.contains(&key)
        });
    }

    diags.extend(to_add);
}

/// Build the `empty-suppress-reason` diagnostic. Severity is
/// hard-Error: a suppression with no reason is unreviewable, so the
/// build refuses it. The `ctx` carries the `code` string so a tool
/// downstream can see exactly which suppression was malformed
/// (independent of the human-readable `message`).
fn make_empty_suppress_reason(def: &str, code: &str) -> Diagnostic {
    let mut d = Diagnostic {
        severity: Severity::Error,
        code: "empty-suppress-reason".into(),
        message: format!(
            "suppress entry for diagnostic `{code}` requires a non-empty `because` reason"
        ),
        def: Some(def.to_string()),
        ctx: serde_json::json!({"code": code}),
        suggested_rewrites: Vec::new(),
    };
    // Defensive: keep the field always-emitted shape (the Diagnostic
    // builder also leaves it `[]`, but constructing the struct
    // directly above means we set it explicitly here for clarity).
    d.suggested_rewrites.clear();
    d
}

#[cfg(test)]
mod tests {
    use super::*;
    use ailang_core::ast::{
        Def, FnDef, Literal, Module, ParamMode, Suppress, Term, Type,
    };

    fn module_with_one_fn(name: &str, suppress: Vec<Suppress>) -> Module {
        Module {
            schema: ailang_core::SCHEMA.into(),
            name: "t".into(),
            imports: vec![],
            defs: vec![Def::Fn(FnDef {
                name: name.into(),
                ty: Type::Fn {
                    params: vec![],
                    param_modes: vec![],
                    ret: Box::new(Type::int()),
                    ret_mode: ParamMode::Implicit,
                    effects: vec![],
                },
                params: vec![],
                body: Term::Lit { lit: Literal::Int { value: 0 } },
                doc: None,
                suppress,
            })],
        }
    }

    /// Iter 19b: a suppress entry with a matching `(def, code)` pair
    /// drops the diagnostic from the accumulated list.
    #[test]
    fn matching_suppress_drops_the_diagnostic() {
        let m = module_with_one_fn(
            "f",
            vec![Suppress {
                code: "over-strict-mode".into(),
                because: "RC test fixture".into(),
            }],
        );
        let mut diags = vec![Diagnostic::warning("over-strict-mode", "msg")
            .with_def("f")];
        apply(&m, &mut diags);
        assert!(diags.is_empty(), "diagnostic should have been dropped: {diags:?}");
    }

    /// Iter 19b: an empty `because` produces an `empty-suppress-reason`
    /// Error AND does NOT drop the original diagnostic.
    #[test]
    fn empty_because_errors_and_does_not_suppress() {
        let m = module_with_one_fn(
            "f",
            vec![Suppress {
                code: "over-strict-mode".into(),
                because: "".into(),
            }],
        );
        let mut diags = vec![Diagnostic::warning("over-strict-mode", "msg")
            .with_def("f")];
        apply(&m, &mut diags);
        // Original diag still present; new error appended.
        assert_eq!(diags.len(), 2, "expected original + new error: {diags:?}");
        let has_orig = diags.iter().any(|d| d.code == "over-strict-mode");
        let empty_err: Vec<_> = diags
            .iter()
            .filter(|d| d.code == "empty-suppress-reason")
            .collect();
        assert!(has_orig, "original over-strict-mode must still fire");
        assert_eq!(empty_err.len(), 1, "exactly one empty-suppress-reason");
        assert_eq!(empty_err[0].severity, Severity::Error);
        assert_eq!(empty_err[0].def.as_deref(), Some("f"));
        assert_eq!(
            empty_err[0].ctx,
            serde_json::json!({"code": "over-strict-mode"})
        );
    }

    /// Iter 19b: whitespace-only `because` is treated the same as
    /// empty — the trimmed text is what matters.
    #[test]
    fn whitespace_only_because_errors() {
        let m = module_with_one_fn(
            "f",
            vec![Suppress {
                code: "over-strict-mode".into(),
                because: "   \t \n ".into(),
            }],
        );
        let mut diags = vec![Diagnostic::warning("over-strict-mode", "msg")
            .with_def("f")];
        apply(&m, &mut diags);
        assert!(
            diags
                .iter()
                .any(|d| d.code == "empty-suppress-reason"),
            "whitespace-only because should fire empty-suppress-reason: {diags:?}"
        );
        assert!(
            diags.iter().any(|d| d.code == "over-strict-mode"),
            "original diagnostic must still fire when suppress is invalid"
        );
    }

    /// Iter 19b: a suppress entry whose `code` does not match any
    /// diagnostic in the list is silently a no-op (the diagnostic
    /// registry is open-set; a typo costs "warning still fires", not
    /// a meta-error).
    #[test]
    fn unmatched_code_is_silent_no_op() {
        let m = module_with_one_fn(
            "f",
            vec![Suppress {
                code: "type-mismatch".into(),
                because: "test".into(),
            }],
        );
        let mut diags = vec![Diagnostic::warning("over-strict-mode", "msg")
            .with_def("f")];
        apply(&m, &mut diags);
        // The original is untouched; no empty-suppress-reason is added.
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert_eq!(diags[0].code, "over-strict-mode");
    }

    /// Iter 19b: a diagnostic on a *different* def is not affected by
    /// a suppression on this fn.
    #[test]
    fn suppression_only_drops_diagnostics_on_same_def() {
        let m = module_with_one_fn(
            "f",
            vec![Suppress {
                code: "over-strict-mode".into(),
                because: "test".into(),
            }],
        );
        let mut diags = vec![
            Diagnostic::warning("over-strict-mode", "f msg").with_def("f"),
            Diagnostic::warning("over-strict-mode", "g msg").with_def("g"),
        ];
        apply(&m, &mut diags);
        assert_eq!(diags.len(), 1, "only `f`'s diagnostic should drop: {diags:?}");
        assert_eq!(diags[0].def.as_deref(), Some("g"));
    }

    /// Iter 19b: a diagnostic with `def == None` is never dropped by
    /// any suppression (no def-name to match against).
    #[test]
    fn def_none_diagnostic_is_never_dropped() {
        let m = module_with_one_fn(
            "f",
            vec![Suppress {
                code: "over-strict-mode".into(),
                because: "test".into(),
            }],
        );
        let mut diags = vec![Diagnostic::warning("over-strict-mode", "msg")];
        apply(&m, &mut diags);
        assert_eq!(diags.len(), 1, "{diags:?}");
    }
}
