//! Structured diagnostics for `ail check --json`.
//!
//! A [`Diagnostic`] is the machine-readable representation of a problem
//! reported by the typechecker (or an upstream load step).
//! [`Severity`] serializes as a lowercase string
//! (`"error"` / `"warning"`); the `code` field is a stable kebab-case identifier
//! that tooling can consume without parsing the `message` text.
//!
//! Convention: each call to [`crate::check_module`] reports at most one
//! diagnostic (single-shot). Multiple diagnostics per run are a future
//! feature; the current format already allows them.
//!
//! Stable codes (as of iteration 5b):
//! - `schema-mismatch`
//! - `unknown-type`
//! - `unbound-var`
//! - `type-mismatch` — `ctx`: `{"expected": "...", "actual": "..."}`
//! - `arity-mismatch` — `ctx`: `{"expected": N, "actual": M}`
//! - `unknown-ctor`
//! - `unknown-effect-op`
//! - `non-exhaustive-match` — `ctx`: `{"missing": ["..."]}`
//! - `unknown-ctor-in-pattern`
//! - `nested-ctor-pattern-not-allowed`
//! - `duplicate-def`
//! - `not-a-function`
//! - `undeclared-effect`
//! - `fn-type-required`
//! - `param-count-mismatch`
//! - `polymorphic-not-supported`
//! - `const-has-effects`
//! - `pattern-type-mismatch`
//! - `primitive-needs-wildcard`
//! - `duplicate-type`
//! - `duplicate-ctor`
//! - `unknown-module` — `ctx`: `{"module": "<prefix>"}` (Iter 5b)
//! - `unknown-import` — `ctx`: `{"module": "<m>", "name": "<def>"}` (Iter 5b)
//! - `invalid-def-name` — `ctx`: `{"name": "<n>", "reason": "contains-dot"}` (Iter 5b)
//! - `module-not-found` — workspace loader (Iter 5b, in the CLI path)
//! - `module-cycle` — workspace loader (Iter 5b, in the CLI path)
//! - `module-name-mismatch` — workspace loader (Iter 5b, in the CLI path)
//! - `module-hash-mismatch` — workspace loader (Iter 5b, in the CLI path)
//! - `tail-call-not-in-tail-position` (Iter 14e, see Decision 8)
//! - `use-after-consume` — `ctx`: `{"binder": "<n>"}` (Iter 18c.2);
//!   carries non-empty [`Diagnostic::suggested_rewrites`] showing how to
//!   spell the fix in form-A AILang.
//! - `consume-while-borrowed` — `ctx`: `{"binder": "<n>"}` (Iter 18c.2);
//!   ditto on `suggested_rewrites`.
//! - `over-strict-mode` (Iter 19a) — `severity: warning`. Emitted by
//!   the linearity pass when a fn parameter is annotated `(own T)` but
//!   the body never consumes it (and never destructures it via
//!   `match`). `ctx`: `{"binder": "<n>", "current_mode": "own",
//!   "suggested_mode": "borrow"}`. Carries one
//!   [`SuggestedRewrite`] whose `replacement` is the relaxed
//!   `(fn-type ...)` in form-A. Pure advisory; the typechecker still
//!   accepts the original signature.
//! - `empty-suppress-reason` (Iter 19b) — `severity: error`. Emitted
//!   by the per-module suppress filter when a `(suppress (code ...)
//!   (because ""))` entry on an `FnDef` has an empty or
//!   whitespace-only `because`. `ctx`: `{"code": "<suppressed-code>"}`.
//!   Hard-Error: a suppression without a reason is unreviewable, so
//!   the build refuses it. Note that an invalid suppression does NOT
//!   suppress its target diagnostic — the original still fires
//!   alongside this error.
//! - `missing-constraint` (Iter 22b.2) — `severity: error`. Emitted
//!   by the per-fn typecheck arm when a polymorphic `FnDef` calls a
//!   class method (e.g. `show x` where `x: a`) but its declared
//!   `Forall.constraints` (after one-step superclass expansion) does
//!   not include the residual class constraint. `ctx`:
//!   `{"class": "<C>", "method": "<m>", "at_type": "<a>"}`. Concrete-
//!   type residuals are deferred to the `no-instance` diagnostic.
//! - `no-instance` (Iter 22b.2) — `severity: error`. Emitted by the
//!   per-fn typecheck arm when a class-method call resolves the class
//!   param to a fully-concrete type that has no matching entry in the
//!   workspace instance registry (`(class, canonical-type-hash)`).
//!   Dual of `missing-constraint`: when the residual is concrete, the
//!   fn cannot push the obligation to a caller, so an existing
//!   instance is the only way to discharge it. `ctx`:
//!   `{"class": "<C>", "method": "<m>", "at_type": "<T>"}`.

use serde::Serialize;

/// Severity of a [`Diagnostic`].
///
/// Serializes as a lowercase string (`"error"` / `"warning"`) so that
/// `ail check --json` consumers can branch on it without parsing prose.
/// Iter 19a is the first iter to emit [`Severity::Warning`] (via the
/// linearity-pass `over-strict-mode` lint); pre-19a the variant was
/// reserved.
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
    /// Hard failure. The module did not typecheck.
    Error,
    /// Non-fatal observation. Module typechecked; the lint is
    /// advisory.
    Warning,
}

/// Machine-readable problem report from the typechecker.
///
/// Stable JSON shape: every field is always emitted (no `skip_serializing_if`),
/// so a tool can decode into a fixed schema without conditional handling.
/// See the module-level doc for the list of `code` values.
#[derive(Serialize, Debug, Clone)]
pub struct Diagnostic {
    /// Severity of the diagnostic; see [`Severity`].
    pub severity: Severity,
    /// Stable kebab-case identifier (e.g. `"unbound-var"`,
    /// `"type-mismatch"`). Tool consumers should branch on this rather
    /// than on `message`. The full enumeration is listed in the module
    /// doc.
    pub code: String,
    /// Human-readable description. Free-form; do not parse. The
    /// `code` + `ctx` pair carries the structured equivalent.
    pub message: String,
    /// Which top-level definition is affected (if known). Always emitted in
    /// the JSON — `null` when unknown — so consumers don't have to handle
    /// the field conditionally.
    pub def: Option<String>,
    /// Free structured context. Empty = `{}`.
    pub ctx: serde_json::Value,
    /// Iter 18c.2: machine-applicable fix suggestions, each a snippet of
    /// form-A AILang the author can paste back at the offending site.
    /// Empty = no rewrite suggested. The list is always emitted (so JSON
    /// consumers see a stable shape; they can branch on `.is_empty()`).
    /// Currently populated by the linearity check (`use-after-consume` /
    /// `consume-while-borrowed` codes); other diagnostics emit `[]`.
    pub suggested_rewrites: Vec<SuggestedRewrite>,
}

/// One machine-applicable rewrite suggestion attached to a [`Diagnostic`].
///
/// Iter 18c.2: emitted by the linearity check to point the author (or an
/// LLM consumer of `ail check --json`) at the spelled fix. `replacement`
/// is form-A AILang text — parseable by `ailang_surface::parse_term`.
#[derive(Serialize, Debug, Clone)]
pub struct SuggestedRewrite {
    /// One-line free-form description of what the rewrite does (e.g.
    /// `"wrap the offending use in (clone X)"`). Not parsed by tooling.
    pub description: String,
    /// Form-A AILang snippet to substitute at the offending site. For
    /// the linearity-check diagnostics this is typically `(clone <n>)`
    /// or a small enclosing rewrite. The string MUST parse via the
    /// surface `parse_term` entrypoint; [`crate::linearity`] tests
    /// guard the round-trip.
    pub replacement: String,
}

impl Diagnostic {
    /// Builds an [`Severity::Error`] diagnostic with the given `code` and
    /// `message`. `def` is left unset and `ctx` defaults to `{}` — chain
    /// [`Diagnostic::with_def`] / [`Diagnostic::with_ctx`] to fill them
    /// in.
    pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            severity: Severity::Error,
            code: code.into(),
            message: message.into(),
            def: None,
            ctx: serde_json::Value::Object(serde_json::Map::new()),
            suggested_rewrites: Vec::new(),
        }
    }

    /// Iter 19a: builds a [`Severity::Warning`] diagnostic. Same shape
    /// as [`Diagnostic::error`] otherwise. Used by lint-style passes
    /// that report observations rather than hard typecheck failures
    /// (currently the only emitter is the linearity pass's
    /// `over-strict-mode` lint).
    pub fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            severity: Severity::Warning,
            code: code.into(),
            message: message.into(),
            def: None,
            ctx: serde_json::Value::Object(serde_json::Map::new()),
            suggested_rewrites: Vec::new(),
        }
    }

    /// Iter 18c.2: append a [`SuggestedRewrite`]. Builder-style. Used by
    /// the linearity check to attach the form-A fix it computed. Other
    /// diagnostics leave the field empty.
    pub fn with_suggested_rewrite(
        mut self,
        description: impl Into<String>,
        replacement: impl Into<String>,
    ) -> Self {
        self.suggested_rewrites.push(SuggestedRewrite {
            description: description.into(),
            replacement: replacement.into(),
        });
        self
    }

    /// Sets the affected top-level def name. Builder-style: returns
    /// `self`. Used by [`crate::CheckError::to_diagnostic`] when the
    /// error was wrapped in [`crate::CheckError::Def`].
    pub fn with_def(mut self, def: impl Into<String>) -> Self {
        self.def = Some(def.into());
        self
    }

    /// Replaces the `ctx` payload. Builder-style: returns `self`. Pass a
    /// `serde_json::Value` whose shape matches the `code`-specific
    /// schema documented at the module level (e.g.
    /// `{"expected": "...", "actual": "..."}` for `type-mismatch`).
    pub fn with_ctx(mut self, ctx: serde_json::Value) -> Self {
        self.ctx = ctx;
        self
    }
}

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

    #[test]
    fn serializes_with_stable_shape() {
        let d = Diagnostic::error("unbound-var", "unknown identifier: `x`")
            .with_def("main");
        let s = serde_json::to_string(&d).unwrap();
        // All fields must be present, severity lowercase, ctx is an
        // empty object (not null, not omitted), suggested_rewrites is
        // always-emitted as `[]` for non-linearity diagnostics.
        assert!(s.contains("\"severity\":\"error\""), "{s}");
        assert!(s.contains("\"code\":\"unbound-var\""), "{s}");
        assert!(s.contains("\"def\":\"main\""), "{s}");
        assert!(s.contains("\"ctx\":{}"), "{s}");
        assert!(s.contains("\"suggested_rewrites\":[]"), "{s}");
    }

    #[test]
    fn ctx_can_carry_structured_data() {
        let d = Diagnostic::error("type-mismatch", "type mismatch")
            .with_ctx(serde_json::json!({"expected": "Int", "actual": "Bool"}));
        let s = serde_json::to_string(&d).unwrap();
        assert!(s.contains("\"expected\":\"Int\""), "{s}");
        assert!(s.contains("\"actual\":\"Bool\""), "{s}");
    }

    /// Iter 18c.2: a diagnostic carrying a `SuggestedRewrite` serialises
    /// the rewrite under `suggested_rewrites` with the description and
    /// the form-A replacement.
    #[test]
    fn suggested_rewrite_serializes() {
        let d = Diagnostic::error("use-after-consume", "use of `xs` after consume")
            .with_suggested_rewrite("wrap earlier use in (clone)", "(clone xs)");
        let s = serde_json::to_string(&d).unwrap();
        assert!(s.contains("\"suggested_rewrites\":["), "{s}");
        assert!(s.contains("\"description\":\"wrap earlier use in (clone)\""), "{s}");
        assert!(s.contains("\"replacement\":\"(clone xs)\""), "{s}");
    }
}
