//! `cph-diag` — the shared diagnostic vocabulary for the checker.
//!
//! Every other crate in the workspace depends on these types to report
//! problems. The vocabulary is intentionally small and stable: a [`Severity`]
//! (two-valued, ADR-0010), a closed set of machine-stable [`DiagCode`]s, an
//! optional [`SourceSpan`] pointing back at the offending source, and a
//! [`Diagnostic`] tying them together with a human message and a fix hint.
//!
//! All public types derive `Serialize` (downstream prints diagnostics as JSON),
//! plus `Debug`, `Clone`, `PartialEq`, `Eq`.

use std::fmt;
use std::ops::Range;
use std::path::PathBuf;

use serde::Serialize;

/// Severity of a diagnostic.
///
/// **Pinned by ADR-0005 / ADR-0010: exactly two values.**
///
/// ```text
/// warning | error
/// ```
///
/// This two-valued shape is a **contract decision**, not an accident: the
/// finer levels (`info` / `hint` / `note`) are deliberately undecided, so we
/// do **not** add an info/note level here. `error` blocks (the artifact is
/// invalid); `warning` does not block (the artifact still exports, but with
/// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning").
///
/// There is no CI gate enforcing ADR↔implementation alignment (see the repo
/// constitution); it is maintained by review, which is why the decision is
/// documented here rather than only in the ADR.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Severity {
    /// Non-blocking: the artifact still exports, but is lossy / has an ignored
    /// element. ADR-0010 `warning`.
    Warning,
    /// Blocking: the artifact is invalid. ADR-0010 `error`.
    Error,
}

/// A location in a source file within the engineering-file tree.
///
/// `file` is relative to the engineering-file root so spans are portable and
/// diffable. `line` / `col` are 1-based (as editors display them); `byte_range`
/// is a 0-based byte offset range into the file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SourceSpan {
    /// Path to the file, relative to the engineering-file root.
    pub file: PathBuf,
    /// Byte offset range into the file (0-based).
    pub byte_range: Range<usize>,
    /// 1-based line number.
    pub line: u32,
    /// 1-based column number.
    pub col: u32,
}

/// Stable, machine-readable diagnostic codes.
///
/// This set is **closed**: codes are part of the checker's public contract, so
/// downstream tooling can match on the string form (see [`DiagCode::as_str`]).
/// Do not invent codes outside this enum without a deliberate decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum DiagCode {
    /// A `[[parts]]` entry references a folder/path that does not exist.
    PartPathMissing,
    /// An element declares a `kind` that is not a known kind.
    UnknownKind,
    /// A required `content` `.typ` file (per the kind schema) is missing.
    MissingContentFile,
    /// Instance data does not conform to its kind's JSON Schema.
    SchemaViolation,
    /// The assembled typst source failed to compile.
    ///
    /// Includes **reference-resolution** failures — an unresolved cross-reference
    /// (`@ref`) or a relative `import`/`include` that escapes the import boundary
    /// or names a missing file. typst detects all of these at compile time, so
    /// they surface here rather than as a separate diagnostic (ADR-0012 folded
    /// the former `DanglingReference` class into this one).
    TypstCompile,
    /// An element is ignored under a render target (ADR-0005: warning).
    RenderIgnored,
    /// The engineering file's `.cph-version` is not compatible with the running
    /// CLI's version (ADR-0016). Decided at load time; `error` severity.
    CphVersionMismatch,
}

impl DiagCode {
    /// The stable string form of this code, e.g. `"E-PART-PATH"`.
    ///
    /// The `E-` / `W-` prefix reflects the *typical* severity of the code but
    /// is not authoritative — the actual severity lives on the [`Diagnostic`].
    pub fn as_str(&self) -> &'static str {
        match self {
            DiagCode::PartPathMissing => "E-PART-PATH",
            DiagCode::UnknownKind => "E-UNKNOWN-KIND",
            DiagCode::MissingContentFile => "E-MISSING-CONTENT",
            DiagCode::SchemaViolation => "E-SCHEMA",
            DiagCode::TypstCompile => "E-TYPST-COMPILE",
            DiagCode::RenderIgnored => "W-RENDER-IGNORED",
            DiagCode::CphVersionMismatch => "E-CPH-VERSION",
        }
    }
}

impl fmt::Display for DiagCode {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// A single diagnostic emitted by the checker.
///
/// Construct via [`Diagnostic::error`] / [`Diagnostic::warning`] and attach an
/// optional [`SourceSpan`] / hint with the builder methods
/// [`Diagnostic::with_span`] / [`Diagnostic::with_hint`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Diagnostic {
    /// How serious this diagnostic is.
    pub severity: Severity,
    /// The stable machine code.
    pub code: DiagCode,
    /// Human-readable message.
    pub message: String,
    /// Optional location this diagnostic points at.
    pub span: Option<SourceSpan>,
    /// Optional fix hint — the product's core value.
    pub hint: Option<String>,
}

impl Diagnostic {
    /// Create an `error`-severity diagnostic.
    pub fn error(code: DiagCode, message: impl Into<String>) -> Self {
        Diagnostic {
            severity: Severity::Error,
            code,
            message: message.into(),
            span: None,
            hint: None,
        }
    }

    /// Create a `warning`-severity diagnostic.
    pub fn warning(code: DiagCode, message: impl Into<String>) -> Self {
        Diagnostic {
            severity: Severity::Warning,
            code,
            message: message.into(),
            span: None,
            hint: None,
        }
    }

    /// Attach a source span (builder-style).
    pub fn with_span(mut self, span: SourceSpan) -> Self {
        self.span = Some(span);
        self
    }

    /// Attach a fix hint (builder-style).
    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
        self.hint = Some(hint.into());
        self
    }
}

impl fmt::Display for Severity {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(match self {
            Severity::Warning => "warning",
            Severity::Error => "error",
        })
    }
}

impl fmt::Display for Diagnostic {
    /// Renders one line `error[E-PART-PATH]: <msg>`, plus an indented
    /// `--> file:line:col` line when a span is present, and a `hint: ...` line
    /// when a hint is present.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}[{}]: {}", self.severity, self.code, self.message)?;
        if let Some(span) = &self.span {
            write!(
                f,
                "\n  --> {}:{}:{}",
                span.file.display(),
                span.line,
                span.col
            )?;
        }
        if let Some(hint) = &self.hint {
            write!(f, "\n  hint: {hint}")?;
        }
        Ok(())
    }
}

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

    #[test]
    fn code_as_str_is_stable() {
        assert_eq!(DiagCode::PartPathMissing.as_str(), "E-PART-PATH");
        assert_eq!(DiagCode::RenderIgnored.as_str(), "W-RENDER-IGNORED");
        assert_eq!(DiagCode::SchemaViolation.as_str(), "E-SCHEMA");
        // Display matches as_str.
        assert_eq!(DiagCode::UnknownKind.to_string(), "E-UNKNOWN-KIND");
    }

    #[test]
    fn error_constructor() {
        let d = Diagnostic::error(DiagCode::UnknownKind, "no such kind 'frob'");
        assert_eq!(d.severity, Severity::Error);
        assert_eq!(d.code, DiagCode::UnknownKind);
        assert_eq!(d.message, "no such kind 'frob'");
        assert!(d.span.is_none());
        assert!(d.hint.is_none());
    }

    #[test]
    fn warning_constructor() {
        let d = Diagnostic::warning(DiagCode::RenderIgnored, "ignored under 'teacher'");
        assert_eq!(d.severity, Severity::Warning);
        assert_eq!(d.code, DiagCode::RenderIgnored);
    }

    #[test]
    fn builders_attach_span_and_hint() {
        let span = SourceSpan {
            file: PathBuf::from("segments/intro/textbook.typ"),
            byte_range: 10..20,
            line: 3,
            col: 5,
        };
        let d = Diagnostic::error(DiagCode::TypstCompile, "unclosed bracket")
            .with_span(span.clone())
            .with_hint("close the '[' opened on line 2");
        assert_eq!(d.span, Some(span));
        assert_eq!(d.hint.as_deref(), Some("close the '[' opened on line 2"));
    }

    #[test]
    fn display_bare() {
        let d = Diagnostic::error(DiagCode::PartPathMissing, "folder not found");
        assert_eq!(d.to_string(), "error[E-PART-PATH]: folder not found");
    }

    #[test]
    fn display_with_span_and_hint() {
        let d = Diagnostic::error(DiagCode::PartPathMissing, "folder not found")
            .with_span(SourceSpan {
                file: PathBuf::from("manifest.toml"),
                byte_range: 0..0,
                line: 12,
                col: 3,
            })
            .with_hint("create the folder 'segments/intro' or fix the path");
        assert_eq!(
            d.to_string(),
            "error[E-PART-PATH]: folder not found\n  --> manifest.toml:12:3\n  hint: create the folder 'segments/intro' or fix the path"
        );
    }

    #[test]
    fn display_warning() {
        let d = Diagnostic::warning(DiagCode::RenderIgnored, "ignored under 'teacher'");
        assert_eq!(
            d.to_string(),
            "warning[W-RENDER-IGNORED]: ignored under 'teacher'"
        );
    }
}
