/// DreamStack Diagnostic — unified error/warning type shared across compiler crates.
///
/// Provides Elm-style error rendering with carets, multi-span labels, and suggestions.

use ds_parser::Span;

// ── Core Types ──────────────────────────────────────────

/// A compiler diagnostic — error, warning, or hint.
#[derive(Debug, Clone)]
pub struct Diagnostic {
    pub severity: Severity,
    pub code: Option<String>,
    pub message: String,
    pub span: Span,
    pub labels: Vec<Label>,
    pub suggestion: Option<Suggestion>,
}

/// Severity level of a diagnostic.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
    Hint,
    Warning,
    Error,
}

/// A secondary label pointing at a span with a message.
#[derive(Debug, Clone)]
pub struct Label {
    pub span: Span,
    pub message: String,
}

/// A suggested fix attached to a diagnostic.
#[derive(Debug, Clone)]
pub struct Suggestion {
    pub message: String,
    pub replacement: String,
    pub span: Span,
}

// ── Constructors ────────────────────────────────────────

impl Diagnostic {
    /// Create an error diagnostic.
    pub fn error(message: impl Into<String>, span: Span) -> Self {
        Diagnostic {
            severity: Severity::Error,
            code: None,
            message: message.into(),
            span,
            labels: Vec::new(),
            suggestion: None,
        }
    }

    /// Create a warning diagnostic.
    pub fn warning(message: impl Into<String>, span: Span) -> Self {
        Diagnostic {
            severity: Severity::Warning,
            code: None,
            message: message.into(),
            span,
            labels: Vec::new(),
            suggestion: None,
        }
    }

    /// Create a hint diagnostic.
    pub fn hint(message: impl Into<String>, span: Span) -> Self {
        Diagnostic {
            severity: Severity::Hint,
            code: None,
            message: message.into(),
            span,
            labels: Vec::new(),
            suggestion: None,
        }
    }

    /// Attach a diagnostic code (e.g. "E0001").
    pub fn with_code(mut self, code: impl Into<String>) -> Self {
        self.code = Some(code.into());
        self
    }

    /// Add a secondary label.
    pub fn with_label(mut self, span: Span, message: impl Into<String>) -> Self {
        self.labels.push(Label { span, message: message.into() });
        self
    }

    /// Add a suggestion.
    pub fn with_suggestion(mut self, span: Span, message: impl Into<String>, replacement: impl Into<String>) -> Self {
        self.suggestion = Some(Suggestion {
            message: message.into(),
            replacement: replacement.into(),
            span,
        });
        self
    }
}

// ── Rendering ───────────────────────────────────────────

impl Severity {
    pub fn label(&self) -> &'static str {
        match self {
            Severity::Error => "ERROR",
            Severity::Warning => "WARNING",
            Severity::Hint => "HINT",
        }
    }

    pub fn prefix(&self) -> &'static str {
        match self {
            Severity::Error => "error",
            Severity::Warning => "warning",
            Severity::Hint => "hint",
        }
    }
}

impl std::fmt::Display for Severity {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.label())
    }
}

/// Render a diagnostic with source context, carets, and labels.
///
/// Produces Elm/Rust-style error output:
/// ```text
/// ── ERROR ──────────────────────────────────────────────
/// 5:12
///
///   5 │ let count: Int = "hello"
///     │                  ^^^^^^^ expected Int, found String
///
///   Hint: ...
/// ```
pub fn render(diag: &Diagnostic, source: &str) -> String {
    let mut out = String::new();
    let lines: Vec<&str> = source.lines().collect();

    // Header
    let title = match &diag.code {
        Some(code) => format!("{} [{}]", diag.severity.label(), code),
        None => diag.severity.label().to_string(),
    };
    let rule_width = 60usize.saturating_sub(title.len() + 4);
    out.push_str(&format!("── {} {}\n", title, "─".repeat(rule_width)));

    // Primary span
    let line = diag.span.line as usize;
    let col = diag.span.col as usize;

    out.push_str(&format!("{}:{}\n", line, col));

    // Source context
    if line > 0 && line <= lines.len() {
        let src_line = lines[line - 1];
        let line_num = format!("{}", line);
        let pad = " ".repeat(line_num.len());

        out.push('\n');
        // Line before (context)
        if line >= 2 && line - 1 <= lines.len() {
            let prev = lines[line - 2];
            if !prev.trim().is_empty() {
                out.push_str(&format!("  {} │ {}\n", format!("{:>width$}", line - 1, width = line_num.len()), prev));
            }
        }

        out.push_str(&format!("  {} │ {}\n", line_num, src_line));

        // Caret line
        let caret_start = if col > 0 { col - 1 } else { 0 };
        let caret_len = if diag.span.end > diag.span.start {
            (diag.span.end - diag.span.start).max(1)
        } else {
            1
        };
        out.push_str(&format!("  {} │ {}{}",
            pad,
            " ".repeat(caret_start),
            "^".repeat(caret_len),
        ));

        // Primary message on caret line
        out.push_str(&format!(" {}\n", diag.message));
    }

    // Secondary labels
    for label in &diag.labels {
        let l = label.span.line as usize;
        if l > 0 && l <= lines.len() {
            let src = lines[l - 1];
            let lnum = format!("{}", l);
            let lpad = " ".repeat(lnum.len());
            out.push('\n');
            out.push_str(&format!("  {} │ {}\n", lnum, src));
            let lc = if label.span.col > 0 { label.span.col as usize - 1 } else { 0 };
            let ll = if label.span.end > label.span.start {
                (label.span.end - label.span.start).max(1)
            } else {
                1
            };
            out.push_str(&format!("  {} │ {}{} {}\n", lpad, " ".repeat(lc), "-".repeat(ll), label.message));
        }
    }

    // Suggestion
    if let Some(ref sugg) = diag.suggestion {
        out.push_str(&format!("\n  Hint: {}\n", sugg.message));
        if !sugg.replacement.is_empty() {
            out.push_str(&format!("  Try:  {}\n", sugg.replacement));
        }
    }

    out
}

// ── Sorting ─────────────────────────────────────────────

/// Sort diagnostics by severity (errors first) then by span position.
pub fn sort_diagnostics(diags: &mut Vec<Diagnostic>) {
    diags.sort_by(|a, b| {
        b.severity.cmp(&a.severity)
            .then_with(|| a.span.line.cmp(&b.span.line))
            .then_with(|| a.span.col.cmp(&b.span.col))
    });
}

// ── Conversions ─────────────────────────────────────────

use ds_parser::parser::ParseError;

/// Convert a `ParseError` into a `Diagnostic`.
impl From<ParseError> for Diagnostic {
    fn from(err: ParseError) -> Self {
        Diagnostic::error(
            err.message.clone(),
            Span {
                start: 0,
                end: 0,
                line: err.line,
                col: err.col,
            },
        )
        .with_code("E0001")
    }
}

/// Convert a slice of `ParseError`s into a `Vec<Diagnostic>`.
pub fn parse_errors_to_diagnostics(errors: &[ParseError]) -> Vec<Diagnostic> {
    errors.iter().map(|e| Diagnostic::from(e.clone())).collect()
}


// ── Tests ───────────────────────────────────────────────

// ─── v0.7: Diagnostic Extensions ───

#[derive(Debug, Clone, PartialEq)]
pub struct DiagnosticExt {
    pub message: String,
    pub severity: SeverityV2,
    pub code: Option<String>,
    pub fix: Option<FixSuggestionV2>,
    pub related: Vec<RelatedInfoV2>,
    pub snippet: Option<String>,
}

#[derive(Debug, Clone, PartialEq)]
pub enum SeverityV2 { Error, Warning, Info, Hint }

#[derive(Debug, Clone, PartialEq)]
pub struct FixSuggestionV2 { pub message: String, pub replacement: String }

#[derive(Debug, Clone, PartialEq)]
pub struct RelatedInfoV2 { pub message: String, pub file: String, pub line: u32 }

impl DiagnosticExt {
    pub fn error(msg: &str) -> Self { DiagnosticExt { message: msg.to_string(), severity: SeverityV2::Error, code: None, fix: None, related: Vec::new(), snippet: None } }
    pub fn warning(msg: &str) -> Self { DiagnosticExt { message: msg.to_string(), severity: SeverityV2::Warning, code: None, fix: None, related: Vec::new(), snippet: None } }
    pub fn with_code(mut self, code: &str) -> Self { self.code = Some(code.to_string()); self }
    pub fn with_fix(mut self, msg: &str, replacement: &str) -> Self { self.fix = Some(FixSuggestionV2 { message: msg.to_string(), replacement: replacement.to_string() }); self }
    pub fn with_related(mut self, msg: &str, file: &str, line: u32) -> Self { self.related.push(RelatedInfoV2 { message: msg.to_string(), file: file.to_string(), line }); self }
    pub fn with_snippet(mut self, s: &str) -> Self { self.snippet = Some(s.to_string()); self }
    pub fn is_error(&self) -> bool { matches!(self.severity, SeverityV2::Error) }
    pub fn to_json(&self) -> String { format!("{{\"severity\":\"{:?}\",\"message\":\"{}\"}}", self.severity, self.message) }
}

pub struct DiagnosticGroup { diagnostics: Vec<DiagnosticExt> }

impl DiagnosticGroup {
    pub fn new() -> Self { DiagnosticGroup { diagnostics: Vec::new() } }
    pub fn push(&mut self, d: DiagnosticExt) { self.diagnostics.push(d); }
    pub fn error_count(&self) -> usize { self.diagnostics.iter().filter(|d| d.is_error()).count() }
    pub fn warning_count(&self) -> usize { self.diagnostics.iter().filter(|d| matches!(d.severity, SeverityV2::Warning)).count() }
    pub fn summary(&self) -> String { format!("{} errors, {} warnings", self.error_count(), self.warning_count()) }
    pub fn len(&self) -> usize { self.diagnostics.len() }
    pub fn is_empty(&self) -> bool { self.diagnostics.is_empty() }
}

impl Default for DiagnosticGroup { fn default() -> Self { Self::new() } }

// ─── v0.8: LSP & Advanced Diagnostics ───

#[derive(Debug, Clone, PartialEq)]
pub struct LspDiagnostic { pub file: String, pub start_line: u32, pub start_col: u32, pub end_line: u32, pub end_col: u32, pub message: String, pub severity: u8, pub code: Option<String> }

impl LspDiagnostic {
    pub fn new(file: &str, start: (u32, u32), end: (u32, u32), msg: &str, sev: u8) -> Self {
        LspDiagnostic { file: file.to_string(), start_line: start.0, start_col: start.1, end_line: end.0, end_col: end.1, message: msg.to_string(), severity: sev, code: None }
    }
    pub fn with_code(mut self, c: &str) -> Self { self.code = Some(c.to_string()); self }
    pub fn to_json(&self) -> String { format!("{{\"range\":{{\"start\":{{\"line\":{},\"character\":{}}},\"end\":{{\"line\":{},\"character\":{}}}}},\"message\":\"{}\",\"severity\":{}}}", self.start_line, self.start_col, self.end_line, self.end_col, self.message, self.severity) }
}

pub struct DiagnosticBatch { items: Vec<LspDiagnostic>, suppressed: Vec<String> }

impl DiagnosticBatch {
    pub fn new() -> Self { DiagnosticBatch { items: Vec::new(), suppressed: Vec::new() } }
    pub fn push(&mut self, d: LspDiagnostic) { if !self.suppressed.contains(&d.message) { self.items.push(d); } }
    pub fn suppress(&mut self, msg: &str) { self.suppressed.push(msg.to_string()); }
    pub fn dedup(&mut self) { self.items.dedup_by(|a, b| a.message == b.message && a.file == b.file && a.start_line == b.start_line); }
    pub fn sort_by_severity(&mut self) { self.items.sort_by_key(|d| d.severity); }
    pub fn len(&self) -> usize { self.items.len() }
    pub fn is_empty(&self) -> bool { self.items.is_empty() }
    pub fn errors(&self) -> usize { self.items.iter().filter(|d| d.severity == 1).count() }
    pub fn warnings(&self) -> usize { self.items.iter().filter(|d| d.severity == 2).count() }
}

impl Default for DiagnosticBatch { fn default() -> Self { Self::new() } }

// ─── v0.9: Production Diagnostics ───

#[derive(Debug, Clone, PartialEq)]
pub enum DiagTag { Unnecessary, Deprecated, Custom(String) }

pub struct DiagnosticPipeline {
    file_index: Vec<(u32, String)>,
    lint_rules: Vec<(String, bool)>, // (rule_id, enabled)
    history: Vec<(String, u32)>, // (message, count)
    escalate_warnings: bool,
}

impl DiagnosticPipeline {
    pub fn new() -> Self { DiagnosticPipeline { file_index: Vec::new(), lint_rules: Vec::new(), history: Vec::new(), escalate_warnings: false } }
    pub fn register_file(&mut self, id: u32, path: &str) { self.file_index.push((id, path.to_string())); }
    pub fn resolve_file(&self, id: u32) -> Option<String> { self.file_index.iter().find(|(i, _)| *i == id).map(|(_, p)| p.clone()) }
    pub fn set_lint_rule(&mut self, rule: &str, enabled: bool) { self.lint_rules.push((rule.to_string(), enabled)); }
    pub fn is_lint_enabled(&self, rule: &str) -> bool { self.lint_rules.iter().rev().find(|(r, _)| r == rule).map(|(_, e)| *e).unwrap_or(true) }
    pub fn set_escalate(&mut self, v: bool) { self.escalate_warnings = v; }
    pub fn effective_severity(&self, is_warning: bool) -> u8 { if is_warning && self.escalate_warnings { 1 } else if is_warning { 2 } else { 1 } }
    pub fn record(&mut self, msg: &str) { if let Some(e) = self.history.iter_mut().find(|(m, _)| m == msg) { e.1 += 1; } else { self.history.push((msg.to_string(), 1)); } }
    pub fn history_count(&self, msg: &str) -> u32 { self.history.iter().find(|(m, _)| m == msg).map(|(_, c)| *c).unwrap_or(0) }
    pub fn file_count(&self) -> usize { self.file_index.len() }
    pub fn merge_spans(start1: u32, end1: u32, start2: u32, end2: u32) -> (u32, u32) { (start1.min(start2), end1.max(end2)) }
}

impl Default for DiagnosticPipeline { fn default() -> Self { Self::new() } }

// ─── v1.0: Full Diagnostic Suite ───

pub struct DiagnosticSuite {
    categories: Vec<(String, Vec<String>)>,
    budget: Option<u32>,
    emitted: u32,
    rate_limits: Vec<(String, u32, u32)>, // (msg, max, current)
    fingerprints: Vec<(String, String)>,
    baseline: Vec<String>,
    trend: Vec<(String, u32)>, // (timestamp, error_count)
    fixes_applied: u32,
    fixes_total: u32,
}

impl DiagnosticSuite {
    pub fn new() -> Self { DiagnosticSuite { categories: Vec::new(), budget: None, emitted: 0, rate_limits: Vec::new(), fingerprints: Vec::new(), baseline: Vec::new(), trend: Vec::new(), fixes_applied: 0, fixes_total: 0 } }
    pub fn add_category(&mut self, cat: &str, codes: Vec<&str>) { self.categories.push((cat.to_string(), codes.into_iter().map(str::to_string).collect())); }
    pub fn set_budget(&mut self, max: u32) { self.budget = Some(max); }
    pub fn can_emit(&self) -> bool { self.budget.map(|b| self.emitted < b).unwrap_or(true) }
    pub fn emit(&mut self) -> bool { if self.can_emit() { self.emitted += 1; true } else { false } }
    pub fn add_baseline(&mut self, fp: &str) { self.baseline.push(fp.to_string()); }
    pub fn is_baseline(&self, fp: &str) -> bool { self.baseline.contains(&fp.to_string()) }
    pub fn fingerprint(file: &str, line: u32, code: &str) -> String { format!("{}:{}:{}", file, line, code) }
    pub fn record_trend(&mut self, ts: &str, count: u32) { self.trend.push((ts.to_string(), count)); }
    pub fn latest_trend(&self) -> Option<u32> { self.trend.last().map(|(_, c)| *c) }
    pub fn record_fix(&mut self, applied: bool) { self.fixes_total += 1; if applied { self.fixes_applied += 1; } }
    pub fn fix_rate(&self) -> f64 { if self.fixes_total == 0 { 0.0 } else { self.fixes_applied as f64 / self.fixes_total as f64 * 100.0 } }

    pub fn to_sarif(file: &str, line: u32, msg: &str) -> String { format!("{{\"runs\":[{{\"results\":[{{\"message\":{{\"text\":\"{}\"}},\"locations\":[{{\"physicalLocation\":{{\"artifactLocation\":{{\"uri\":\"{}\"}},\"region\":{{\"startLine\":{}}}}}}}]}}]}}]}}", msg, file, line) }
    pub fn to_codeframe(line: u32, col: u32, source: &str, msg: &str) -> String { format!("  {} | {}\n  {} | {}^ {}", line, source, "", " ".repeat(col as usize), msg) }
    pub fn to_html(msg: &str, severity: &str) -> String { format!("<div class=\"diag {}\"><span>{}</span></div>", severity, msg) }
    pub fn to_markdown(msg: &str, file: &str, line: u32) -> String { format!("- **{}:{}** — {}", file, line, msg) }
    pub fn category_count(&self) -> usize { self.categories.len() }
    pub fn report(&self) -> String { format!("emitted:{} budget:{:?} fixes:{}/{}", self.emitted, self.budget, self.fixes_applied, self.fixes_total) }
}

impl Default for DiagnosticSuite { fn default() -> Self { Self::new() } }

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

    fn span(line: usize, col: usize, start: usize, end: usize) -> Span {
        Span { start, end, line, col }
    }

    #[test]
    fn test_error_rendering() {
        let source = "let count = 0\nlet name: Int = \"hello\"\nview main = text \"hi\"";
        let diag = Diagnostic::error("expected Int, found String", span(2, 17, 31, 38));

        let output = render(&diag, source);
        assert!(output.contains("ERROR"));
        assert!(output.contains("2:17"));
        assert!(output.contains("^^^^^^^"));
        assert!(output.contains("expected Int, found String"));
    }

    #[test]
    fn test_warning_rendering() {
        let source = "let unused = 42\nview main = text \"hi\"";
        let diag = Diagnostic::warning("signal `unused` is never read", span(1, 5, 4, 10));

        let output = render(&diag, source);
        assert!(output.contains("WARNING"));
        assert!(output.contains("unused"));
    }

    #[test]
    fn test_with_suggestion() {
        let source = "let count = 0\nmatch status\n  Loading -> text \"...\"\n";
        let diag = Diagnostic::error("non-exhaustive match", span(2, 1, 14, 26))
            .with_code("E0004")
            .with_suggestion(span(2, 1, 14, 26), "Add missing variants", "  _ -> text \"fallback\"");

        let output = render(&diag, source);
        assert!(output.contains("[E0004]"));
        assert!(output.contains("Hint:"));
        assert!(output.contains("Add missing variants"));
    }

    #[test]
    fn test_sort_diagnostics() {
        let mut diags = vec![
            Diagnostic::warning("w1", span(3, 1, 30, 35)),
            Diagnostic::error("e1", span(1, 1, 0, 5)),
            Diagnostic::error("e2", span(5, 1, 50, 55)),
        ];
        sort_diagnostics(&mut diags);
        // Errors first, then by line
        assert_eq!(diags[0].message, "e1");
        assert_eq!(diags[1].message, "e2");
        assert_eq!(diags[2].message, "w1");
    }

    #[test]
    fn test_parse_error_to_diagnostic() {
        let err = ParseError {
            message: "unexpected token: Colon".to_string(),
            line: 5,
            col: 12,
            source_line: Some("let x = foo(bar:".to_string()),
        };
        let diag = Diagnostic::from(err);
        assert_eq!(diag.severity, Severity::Error);
        assert!(diag.message.contains("unexpected token: Colon"));
        assert_eq!(diag.span.line, 5);
        assert_eq!(diag.span.col, 12);
        assert_eq!(diag.code, Some("E0001".to_string()));
    }

    #[test]
    fn test_parse_errors_to_diagnostics_batch() {
        let errors = vec![
            ParseError {
                message: "error 1".to_string(),
                line: 1,
                col: 1,
                source_line: None,
            },
            ParseError {
                message: "error 2".to_string(),
                line: 3,
                col: 5,
                source_line: None,
            },
        ];
        let diags = parse_errors_to_diagnostics(&errors);
        assert_eq!(diags.len(), 2);
        assert_eq!(diags[0].message, "error 1");
        assert_eq!(diags[1].message, "error 2");
        assert_eq!(diags[1].span.line, 3);
    }

    // ── v0.10 Diagnostic Pipeline Tests ─────────────────────

    #[test]
    fn test_render_with_code() {
        let source = "let x = true + 1";
        let diag = Diagnostic::error("type mismatch", span(1, 9, 8, 16))
            .with_code("E0003");
        let output = render(&diag, source);
        assert!(output.contains("[E0003]"), "should include error code");
        assert!(output.contains("type mismatch"), "should include message");
    }

    #[test]
    fn test_render_with_label() {
        let source = "let x: Int = \"hello\"";
        let diag = Diagnostic::error("type mismatch", span(1, 14, 13, 20))
            .with_label(span(1, 8, 7, 10), "expected type declared here");
        let output = render(&diag, source);
        assert!(output.contains("ERROR"), "should be error severity");
        assert!(output.contains("type mismatch"), "should have message");
    }

    #[test]
    fn test_render_hint() {
        let source = "let x = 0";
        let diag = Diagnostic::hint("consider using a more descriptive name", span(1, 5, 4, 5));
        let output = render(&diag, source);
        assert!(output.contains("HINT") || output.contains("hint"), "should render as hint");
    }

    #[test]
    fn test_render_multiline_source() {
        let source = "let a = 0\nlet b = a + 1\nlet c = b * 2";
        let diag = Diagnostic::warning("unused signal", span(3, 5, 28, 29));
        let output = render(&diag, source);
        assert!(output.contains("3:"), "should reference line 3");
    }

    #[test]
    fn test_render_col_zero_edge() {
        let source = "let x = 0";
        let diag = Diagnostic::error("test", span(1, 0, 0, 3));
        // Should not panic with col=0
        let output = render(&diag, source);
        assert!(output.contains("ERROR"));
    }

    #[test]
    fn test_render_minimal_source() {
        // Minimal single-character source
        let source = " ";
        let diag = Diagnostic::error("unexpected end of input", span(1, 1, 0, 1));
        let output = render(&diag, source);
        assert!(output.contains("unexpected end of input"));
    }

    // ─── v0.7 Tests ───

    #[test]
    fn test_diag_error() { let d = DiagnosticExt::error("bad"); assert!(d.is_error()); }

    #[test]
    fn test_diag_warning() { let d = DiagnosticExt::warning("warn"); assert!(!d.is_error()); }

    #[test]
    fn test_diag_code() { let d = DiagnosticExt::error("e").with_code("E001"); assert_eq!(d.code, Some("E001".into())); }

    #[test]
    fn test_diag_fix() { let d = DiagnosticExt::error("e").with_fix("add semicolon", ";"); assert!(d.fix.is_some()); }

    #[test]
    fn test_diag_related() { let d = DiagnosticExt::error("e").with_related("see also", "main.ds", 10); assert_eq!(d.related.len(), 1); }

    #[test]
    fn test_diag_json() { let d = DiagnosticExt::error("bad"); let j = d.to_json(); assert!(j.contains("Error")); assert!(j.contains("bad")); }

    #[test]
    fn test_diag_group() { let mut g = DiagnosticGroup::new(); g.push(DiagnosticExt::error("e1")); g.push(DiagnosticExt::warning("w1")); assert_eq!(g.error_count(), 1); assert_eq!(g.warning_count(), 1); }

    #[test]
    fn test_diag_summary() { let mut g = DiagnosticGroup::new(); g.push(DiagnosticExt::error("e")); assert_eq!(g.summary(), "1 errors, 0 warnings"); }

    #[test]
    fn test_diag_snippet() { let d = DiagnosticExt::error("e").with_snippet("let x = 1;"); assert_eq!(d.snippet, Some("let x = 1;".into())); }

    // ─── v0.8 Tests ───

    #[test]
    fn test_lsp_diagnostic() { let d = LspDiagnostic::new("main.ds", (1, 5), (1, 10), "error", 1); assert_eq!(d.start_line, 1); assert_eq!(d.severity, 1); }

    #[test]
    fn test_lsp_json() { let d = LspDiagnostic::new("a.ds", (0, 0), (0, 5), "bad", 1); let j = d.to_json(); assert!(j.contains("\"message\":\"bad\"")); }

    #[test]
    fn test_lsp_code() { let d = LspDiagnostic::new("a.ds", (0,0), (0,1), "e", 1).with_code("E001"); assert_eq!(d.code, Some("E001".into())); }

    #[test]
    fn test_batch_push() { let mut b = DiagnosticBatch::new(); b.push(LspDiagnostic::new("a.ds", (0,0), (0,1), "e", 1)); assert_eq!(b.len(), 1); }

    #[test]
    fn test_batch_suppress() { let mut b = DiagnosticBatch::new(); b.suppress("ignore"); b.push(LspDiagnostic::new("a.ds", (0,0), (0,1), "ignore", 1)); assert_eq!(b.len(), 0); }

    #[test]
    fn test_batch_dedup() { let mut b = DiagnosticBatch::new(); b.push(LspDiagnostic::new("a.ds", (1,0), (1,5), "dup", 1)); b.push(LspDiagnostic::new("a.ds", (1,0), (1,5), "dup", 1)); b.dedup(); assert_eq!(b.len(), 1); }

    #[test]
    fn test_batch_sort() { let mut b = DiagnosticBatch::new(); b.push(LspDiagnostic::new("a.ds", (0,0), (0,1), "w", 2)); b.push(LspDiagnostic::new("a.ds", (0,0), (0,1), "e", 1)); b.sort_by_severity(); assert_eq!(b.errors(), 1); }

    #[test]
    fn test_batch_counts() { let mut b = DiagnosticBatch::new(); b.push(LspDiagnostic::new("a.ds", (0,0), (0,1), "e", 1)); b.push(LspDiagnostic::new("a.ds", (0,0), (0,1), "w", 2)); assert_eq!(b.errors(), 1); assert_eq!(b.warnings(), 1); }

    #[test]
    fn test_batch_empty() { let b = DiagnosticBatch::new(); assert!(b.is_empty()); }

    // ─── v0.9 Tests ───

    #[test]
    fn test_file_index() { let mut p = DiagnosticPipeline::new(); p.register_file(1, "main.ds"); assert_eq!(p.resolve_file(1), Some("main.ds".into())); assert_eq!(p.resolve_file(2), None); }

    #[test]
    fn test_lint_rules() { let mut p = DiagnosticPipeline::new(); p.set_lint_rule("no-unused", false); assert!(!p.is_lint_enabled("no-unused")); assert!(p.is_lint_enabled("other")); }

    #[test]
    fn test_escalation() { let mut p = DiagnosticPipeline::new(); p.set_escalate(true); assert_eq!(p.effective_severity(true), 1); assert_eq!(p.effective_severity(false), 1); }

    #[test]
    fn test_no_escalation() { let p = DiagnosticPipeline::new(); assert_eq!(p.effective_severity(true), 2); }

    #[test]
    fn test_history() { let mut p = DiagnosticPipeline::new(); p.record("err"); p.record("err"); p.record("warn"); assert_eq!(p.history_count("err"), 2); assert_eq!(p.history_count("warn"), 1); }

    #[test]
    fn test_span_merge() { assert_eq!(DiagnosticPipeline::merge_spans(5, 10, 3, 8), (3, 10)); }

    #[test]
    fn test_file_count() { let mut p = DiagnosticPipeline::new(); p.register_file(1, "a.ds"); p.register_file(2, "b.ds"); assert_eq!(p.file_count(), 2); }

    #[test]
    fn test_diag_tag() { let t = DiagTag::Deprecated; assert_eq!(t, DiagTag::Deprecated); }

    #[test]
    fn test_custom_tag() { let t = DiagTag::Custom("experimental".into()); if let DiagTag::Custom(s) = t { assert_eq!(s, "experimental"); } else { panic!(); } }

    // ─── v1.0 Tests ───

    #[test]
    fn test_budget() { let mut s = DiagnosticSuite::new(); s.set_budget(2); assert!(s.emit()); assert!(s.emit()); assert!(!s.emit()); }
    #[test]
    fn test_no_budget() { let mut s = DiagnosticSuite::new(); assert!(s.emit()); }
    #[test]
    fn test_baseline() { let mut s = DiagnosticSuite::new(); s.add_baseline("a:1:E001"); assert!(s.is_baseline("a:1:E001")); assert!(!s.is_baseline("b:2:E002")); }
    #[test]
    fn test_fingerprint() { let fp = DiagnosticSuite::fingerprint("main.ds", 10, "E001"); assert_eq!(fp, "main.ds:10:E001"); }
    #[test]
    fn test_trend() { let mut s = DiagnosticSuite::new(); s.record_trend("t1", 5); s.record_trend("t2", 3); assert_eq!(s.latest_trend(), Some(3)); }
    #[test]
    fn test_fix_rate() { let mut s = DiagnosticSuite::new(); s.record_fix(true); s.record_fix(false); assert!((s.fix_rate() - 50.0).abs() < 0.01); }
    #[test]
    fn test_fix_rate_zero() { let s = DiagnosticSuite::new(); assert_eq!(s.fix_rate(), 0.0); }
    #[test]
    fn test_sarif() { let s = DiagnosticSuite::to_sarif("a.ds", 1, "bad"); assert!(s.contains("\"text\":\"bad\"")); }
    #[test]
    fn test_codeframe() { let cf = DiagnosticSuite::to_codeframe(5, 3, "let x = 1;", "unexpected"); assert!(cf.contains("let x = 1;")); }
    #[test]
    fn test_html_output() { let h = DiagnosticSuite::to_html("error msg", "error"); assert!(h.contains("class=\"diag error\"")); }
    #[test]
    fn test_markdown_output() { let m = DiagnosticSuite::to_markdown("bad syntax", "main.ds", 10); assert!(m.contains("**main.ds:10**")); }
    #[test]
    fn test_category() { let mut s = DiagnosticSuite::new(); s.add_category("syntax", vec!["E001", "E002"]); assert_eq!(s.category_count(), 1); }
    #[test]
    fn test_report_v1() { let s = DiagnosticSuite::new(); let r = s.report(); assert!(r.contains("emitted:0")); }
    #[test]
    fn test_empty_trend() { let s = DiagnosticSuite::new(); assert_eq!(s.latest_trend(), None); }
    #[test]
    fn test_budget_exact() { let mut s = DiagnosticSuite::new(); s.set_budget(1); assert!(s.emit()); assert!(!s.can_emit()); }
    #[test]
    fn test_no_baseline() { let s = DiagnosticSuite::new(); assert!(!s.is_baseline("x")); }
    #[test]
    fn test_all_fixes() { let mut s = DiagnosticSuite::new(); s.record_fix(true); s.record_fix(true); assert_eq!(s.fix_rate(), 100.0); }
    #[test]
    fn test_multi_categories() { let mut s = DiagnosticSuite::new(); s.add_category("a", vec![]); s.add_category("b", vec![]); assert_eq!(s.category_count(), 2); }
}


