/// Semantic diagnostics bridge.
///
/// Delegates all analysis to the `mir-analyzer` crate and converts its `Issue`
/// type into the `tower-lsp` `Diagnostic` type expected by the LSP backend.
use std::sync::Arc;

use php_ast::{ClassMemberKind, EnumMemberKind, ExprKind, NamespaceBody, Stmt, StmtKind};
use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Position, Range, Url};

use crate::ast::{ParsedDoc, offset_to_position};
use crate::backend::DiagnosticsConfig;
use crate::docblock::{docblock_before, parse_docblock};

/// Run semantic checks on `doc` against `other_docs` and return LSP diagnostics.
/// Applies `cfg` to suppress individual diagnostic categories.
pub fn semantic_diagnostics(
    uri: &Url,
    doc: &ParsedDoc,
    other_docs: &[(Url, Arc<ParsedDoc>)],
    cfg: &DiagnosticsConfig,
) -> Vec<Diagnostic> {
    if !cfg.enabled {
        return vec![];
    }

    let codebase = mir_codebase::Codebase::new();
    let file: Arc<str> = Arc::from(uri.as_str());

    // Load PHP built-in stubs so calls to strlen(), array_map(), etc. are recognised.
    mir_analyzer::stubs::load_stubs(&codebase);

    // Pass 1: collect definitions from all documents so cross-file type info is available.
    for (other_uri, other_doc) in other_docs {
        let other_file: Arc<str> = Arc::from(other_uri.as_str());
        let collector = mir_analyzer::collector::DefinitionCollector::new(
            &codebase,
            other_file,
            other_doc.source(),
        );
        collector.collect(other_doc.program());
    }
    let collector =
        mir_analyzer::collector::DefinitionCollector::new(&codebase, file.clone(), doc.source());
    let collector_issues = collector.collect(doc.program());

    // Resolve inheritance, build dispatch tables, etc.
    codebase.finalize();

    // Pass 2: analyse function/method bodies in the current document.
    let mut issue_buffer = mir_issues::IssueBuffer::new();
    let mut analyzer = mir_analyzer::stmt::StatementsAnalyzer::new(
        &codebase,
        file.clone(),
        doc.source(),
        &mut issue_buffer,
    );
    let mut ctx = mir_analyzer::context::Context::new();
    analyzer.analyze_stmts(&doc.program().stmts, &mut ctx);

    collector_issues
        .into_iter()
        .chain(issue_buffer.into_issues())
        .filter(|i| !i.suppressed)
        .filter(|i| issue_passes_filter(i, cfg))
        .map(|i| to_lsp_diagnostic(i, uri))
        .collect()
}

/// Returns `true` if the mir-analyzer issue is allowed through by the config.
fn issue_passes_filter(issue: &mir_issues::Issue, cfg: &DiagnosticsConfig) -> bool {
    use mir_issues::IssueKind;
    match &issue.kind {
        IssueKind::UndefinedVariable { .. } | IssueKind::PossiblyUndefinedVariable { .. } => {
            cfg.undefined_variables
        }
        IssueKind::UndefinedFunction { .. } | IssueKind::UndefinedMethod { .. } => {
            cfg.undefined_functions
        }
        IssueKind::UndefinedClass { .. } => cfg.undefined_classes,
        IssueKind::InvalidReturnType { .. }
        | IssueKind::InvalidArgument { .. }
        | IssueKind::NullMethodCall { .. }
        | IssueKind::NullPropertyFetch { .. }
        | IssueKind::NullableReturnStatement { .. }
        | IssueKind::InvalidPropertyAssignment { .. }
        | IssueKind::InvalidOperand { .. } => cfg.type_errors,
        IssueKind::DeprecatedMethod { .. } | IssueKind::DeprecatedClass { .. } => {
            cfg.deprecated_calls
        }
        _ => true,
    }
}

/// Check for deprecated function/method calls and emit Warning diagnostics.
pub fn deprecated_call_diagnostics(
    source: &str,
    doc: &ParsedDoc,
    other_docs: &[Arc<ParsedDoc>],
    cfg: &DiagnosticsConfig,
) -> Vec<Diagnostic> {
    if !cfg.enabled || !cfg.deprecated_calls {
        return vec![];
    }
    let mut diags = Vec::new();
    collect_deprecated_calls(source, &doc.program().stmts, doc, other_docs, &mut diags);
    diags
}

fn collect_deprecated_calls(
    source: &str,
    stmts: &[Stmt<'_, '_>],
    doc: &ParsedDoc,
    other_docs: &[Arc<ParsedDoc>],
    diags: &mut Vec<Diagnostic>,
) {
    for stmt in stmts {
        match &stmt.kind {
            StmtKind::Expression(e) => {
                check_expr_for_deprecated(source, e, doc, other_docs, diags);
            }
            StmtKind::Namespace(ns) => {
                if let NamespaceBody::Braced(inner) = &ns.body {
                    collect_deprecated_calls(source, inner, doc, other_docs, diags);
                }
            }
            StmtKind::Function(f) => {
                collect_deprecated_calls(source, &f.body, doc, other_docs, diags);
            }
            StmtKind::Class(c) => {
                for member in c.members.iter() {
                    if let ClassMemberKind::Method(m) = &member.kind
                        && let Some(body) = &m.body
                    {
                        collect_deprecated_calls(source, body, doc, other_docs, diags);
                    }
                }
            }
            StmtKind::Trait(t) => {
                for member in t.members.iter() {
                    if let ClassMemberKind::Method(m) = &member.kind
                        && let Some(body) = &m.body
                    {
                        collect_deprecated_calls(source, body, doc, other_docs, diags);
                    }
                }
            }
            StmtKind::Enum(e) => {
                for member in e.members.iter() {
                    if let EnumMemberKind::Method(m) = &member.kind
                        && let Some(body) = &m.body
                    {
                        collect_deprecated_calls(source, body, doc, other_docs, diags);
                    }
                }
            }
            _ => {}
        }
    }
}

fn check_expr_for_deprecated(
    source: &str,
    expr: &php_ast::Expr<'_, '_>,
    doc: &ParsedDoc,
    other_docs: &[Arc<ParsedDoc>],
    diags: &mut Vec<Diagnostic>,
) {
    if let ExprKind::Assign(a) = &expr.kind {
        check_expr_for_deprecated(source, a.value, doc, other_docs, diags);
        return;
    }
    if let ExprKind::FunctionCall(call) = &expr.kind {
        if let ExprKind::Identifier(name) = &call.name.kind {
            let func_name = name.as_ref();
            // Search all docs for this function's declaration
            let all_sources: Vec<(&str, &ParsedDoc)> = std::iter::once((source, doc))
                .chain(other_docs.iter().map(|d| (d.source(), d.as_ref())))
                .collect();
            for (src, d) in &all_sources {
                if let Some(span_start) = find_function_span(d, func_name)
                    && let Some(raw) = docblock_before(src, span_start)
                {
                    let db = parse_docblock(&raw);
                    if db.is_deprecated() {
                        let start_pos = offset_to_position(source, call.name.span.start);
                        let end_pos = offset_to_position(source, call.name.span.end);
                        let msg = match &db.deprecated {
                            Some(m) if !m.is_empty() => {
                                format!("Deprecated: {} — {}", func_name, m)
                            }
                            _ => format!("Deprecated: {}", func_name),
                        };
                        diags.push(Diagnostic {
                            range: Range {
                                start: Position {
                                    line: start_pos.line,
                                    character: start_pos.character,
                                },
                                end: Position {
                                    line: end_pos.line,
                                    character: end_pos.character,
                                },
                            },
                            severity: Some(DiagnosticSeverity::WARNING),
                            source: Some("php-lsp".to_string()),
                            message: msg,
                            ..Default::default()
                        });
                        break;
                    }
                }
            }
        }
        // Recurse into arguments so nested calls are also checked.
        for arg in call.args.iter() {
            check_expr_for_deprecated(source, &arg.value, doc, other_docs, diags);
        }
    }
    if let ExprKind::MethodCall(call) = &expr.kind {
        if let ExprKind::Identifier(name) = &call.method.kind {
            let method_name = name.as_ref();
            let all_sources: Vec<(&str, &ParsedDoc)> = std::iter::once((source, doc))
                .chain(other_docs.iter().map(|d| (d.source(), d.as_ref())))
                .collect();
            for (src, d) in &all_sources {
                if let Some(span_start) = find_method_span(d, method_name)
                    && let Some(raw) = docblock_before(src, span_start)
                {
                    let db = parse_docblock(&raw);
                    if db.is_deprecated() {
                        let start_pos = offset_to_position(source, call.method.span.start);
                        let end_pos = offset_to_position(source, call.method.span.end);
                        let msg = match &db.deprecated {
                            Some(m) if !m.is_empty() => {
                                format!("Deprecated: {} — {}", method_name, m)
                            }
                            _ => format!("Deprecated: {}", method_name),
                        };
                        diags.push(Diagnostic {
                            range: Range {
                                start: Position {
                                    line: start_pos.line,
                                    character: start_pos.character,
                                },
                                end: Position {
                                    line: end_pos.line,
                                    character: end_pos.character,
                                },
                            },
                            severity: Some(DiagnosticSeverity::WARNING),
                            source: Some("php-lsp".to_string()),
                            message: msg,
                            ..Default::default()
                        });
                        break;
                    }
                }
            }
        }
        // Recurse into object and arguments so nested calls are also checked.
        check_expr_for_deprecated(source, call.object, doc, other_docs, diags);
        for arg in call.args.iter() {
            check_expr_for_deprecated(source, &arg.value, doc, other_docs, diags);
        }
    }
}

fn find_function_span(doc: &ParsedDoc, func_name: &str) -> Option<u32> {
    find_function_span_in_stmts(&doc.program().stmts, func_name)
}

fn find_function_span_in_stmts(stmts: &[Stmt<'_, '_>], func_name: &str) -> Option<u32> {
    for stmt in stmts {
        match &stmt.kind {
            StmtKind::Function(f) if f.name == func_name => {
                return Some(stmt.span.start);
            }
            StmtKind::Namespace(ns) => {
                if let NamespaceBody::Braced(inner) = &ns.body
                    && let Some(s) = find_function_span_in_stmts(inner, func_name)
                {
                    return Some(s);
                }
            }
            _ => {}
        }
    }
    None
}

fn find_method_span(doc: &ParsedDoc, method_name: &str) -> Option<u32> {
    find_method_span_in_stmts(&doc.program().stmts, method_name)
}

fn find_method_span_in_stmts(stmts: &[Stmt<'_, '_>], method_name: &str) -> Option<u32> {
    for stmt in stmts {
        match &stmt.kind {
            StmtKind::Class(c) => {
                for member in c.members.iter() {
                    if let ClassMemberKind::Method(m) = &member.kind
                        && m.name == method_name
                    {
                        return Some(member.span.start);
                    }
                }
            }
            StmtKind::Trait(t) => {
                for member in t.members.iter() {
                    if let ClassMemberKind::Method(m) = &member.kind
                        && m.name == method_name
                    {
                        return Some(member.span.start);
                    }
                }
            }
            StmtKind::Enum(e) => {
                for member in e.members.iter() {
                    if let EnumMemberKind::Method(m) = &member.kind
                        && m.name == method_name
                    {
                        return Some(member.span.start);
                    }
                }
            }
            StmtKind::Namespace(ns) => {
                if let NamespaceBody::Braced(inner) = &ns.body
                    && let Some(s) = find_method_span_in_stmts(inner, method_name)
                {
                    return Some(s);
                }
            }
            _ => {}
        }
    }
    None
}

/// Check for duplicate class/function/interface/trait/enum declarations.
pub fn duplicate_declaration_diagnostics(
    source: &str,
    doc: &ParsedDoc,
    cfg: &DiagnosticsConfig,
) -> Vec<Diagnostic> {
    if !cfg.enabled || !cfg.duplicate_declarations {
        return vec![];
    }
    let mut seen: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
    let mut diags = Vec::new();
    collect_duplicate_decls(source, &doc.program().stmts, "", &mut seen, &mut diags);
    diags
}

fn collect_duplicate_decls(
    source: &str,
    stmts: &[php_ast::Stmt<'_, '_>],
    current_ns: &str,
    seen: &mut std::collections::HashMap<String, ()>,
    diags: &mut Vec<Diagnostic>,
) {
    // Track the active namespace for unbraced `namespace Foo;` declarations.
    let mut active_ns = current_ns.to_string();

    for stmt in stmts {
        let name_and_span: Option<(&str, u32)> = match &stmt.kind {
            StmtKind::Class(c) => c.name.map(|n| (n, stmt.span.start)),
            StmtKind::Interface(i) => Some((i.name, stmt.span.start)),
            StmtKind::Trait(t) => Some((t.name, stmt.span.start)),
            StmtKind::Enum(e) => Some((e.name, stmt.span.start)),
            StmtKind::Function(f) => Some((f.name, stmt.span.start)),
            StmtKind::Namespace(ns) => {
                let ns_name = ns
                    .name
                    .as_ref()
                    .map(|n| n.to_string_repr().to_string())
                    .unwrap_or_default();
                match &ns.body {
                    php_ast::NamespaceBody::Braced(inner) => {
                        let child_ns = if current_ns.is_empty() {
                            ns_name
                        } else {
                            format!("{}\\{}", current_ns, ns_name)
                        };
                        collect_duplicate_decls(source, inner, &child_ns, seen, diags);
                    }
                    php_ast::NamespaceBody::Simple => {
                        // Unbraced namespace: subsequent siblings belong to this namespace.
                        active_ns = if current_ns.is_empty() {
                            ns_name
                        } else {
                            format!("{}\\{}", current_ns, ns_name)
                        };
                    }
                }
                None
            }
            _ => None,
        };
        if let Some((name, span_start)) = name_and_span {
            let key = if active_ns.is_empty() {
                name.to_string()
            } else {
                format!("{}\\{}", active_ns, name)
            };
            if seen.insert(key, ()).is_some() {
                let pos = crate::ast::offset_to_position(source, span_start);
                diags.push(Diagnostic {
                    range: Range {
                        start: pos,
                        end: pos,
                    },
                    severity: Some(DiagnosticSeverity::WARNING),
                    message: format!(
                        "Duplicate declaration: `{name}` is already defined in this file"
                    ),
                    source: Some("php-lsp".to_string()),
                    ..Default::default()
                });
            }
        }
    }
}

fn to_lsp_diagnostic(issue: mir_issues::Issue, _uri: &Url) -> Diagnostic {
    // mir-analyzer uses 1-based line numbers; LSP uses 0-based.
    let line = issue.location.line.saturating_sub(1);
    let col_start = issue.location.col_start as u32;
    let col_end = issue.location.col_end as u32;
    Diagnostic {
        range: Range {
            start: Position {
                line,
                character: col_start,
            },
            end: Position {
                line,
                character: col_end.max(col_start + 1),
            },
        },
        severity: Some(match issue.severity {
            mir_issues::Severity::Error => DiagnosticSeverity::ERROR,
            mir_issues::Severity::Warning => DiagnosticSeverity::WARNING,
            mir_issues::Severity::Info => DiagnosticSeverity::INFORMATION,
        }),
        code: Some(NumberOrString::String(issue.kind.name().to_string())),
        source: Some("php-lsp".to_string()),
        message: issue.kind.message(),
        ..Default::default()
    }
}

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

    #[test]
    fn deprecated_function_call_emits_warning() {
        let src =
            "<?php\n/** @deprecated Use newFunc() instead */\nfunction oldFunc() {}\n\noldFunc();";
        let doc = ParsedDoc::parse(src.to_string());
        let diags = deprecated_call_diagnostics(src, &doc, &[], &DiagnosticsConfig::default());
        assert_eq!(
            diags.len(),
            1,
            "expected exactly 1 deprecated warning diagnostic"
        );
        let d = &diags[0];
        assert_eq!(d.severity, Some(DiagnosticSeverity::WARNING));
        assert!(
            d.message.contains("oldFunc"),
            "message should mention the function name"
        );
    }

    #[test]
    fn duplicate_class_emits_warning() {
        let src = "<?php\nclass Foo {}\nclass Foo {}";
        let doc = ParsedDoc::parse(src.to_string());
        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::default());
        assert_eq!(
            diags.len(),
            1,
            "expected exactly 1 duplicate warning, got: {:?}",
            diags
        );
        assert_eq!(diags[0].severity, Some(DiagnosticSeverity::WARNING));
        assert!(
            diags[0].message.contains("Foo"),
            "message should mention 'Foo'"
        );
    }

    #[test]
    fn no_duplicate_for_unique_declarations() {
        let src = "<?php\nclass Foo {}\nclass Bar {}";
        let doc = ParsedDoc::parse(src.to_string());
        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::default());
        assert!(diags.is_empty());
    }

    #[test]
    fn namespace_scoped_duplicate_not_flagged() {
        // Two classes named `Foo` in different namespaces — should produce zero diagnostics.
        let src = "<?php\nnamespace App\\A {\nclass Foo {}\n}\nnamespace App\\B {\nclass Foo {}\n}";
        let doc = ParsedDoc::parse(src.to_string());
        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::default());
        assert!(
            diags.is_empty(),
            "classes with same name in different namespaces should not be flagged, got: {:?}",
            diags
        );
    }

    #[test]
    fn duplicate_interface_declaration() {
        // Same interface defined twice in same file — should produce exactly one error.
        let src = "<?php\ninterface Logger {}\ninterface Logger {}";
        let doc = ParsedDoc::parse(src.to_string());
        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::default());
        assert_eq!(
            diags.len(),
            1,
            "expected exactly 1 duplicate-declaration diagnostic, got: {:?}",
            diags
        );
        assert!(
            diags[0].message.contains("Logger"),
            "diagnostic message should mention 'Logger'"
        );
        assert_eq!(
            diags[0].severity,
            Some(DiagnosticSeverity::WARNING),
            "duplicate declaration should be a warning"
        );
    }

    #[test]
    fn duplicate_trait_declaration() {
        // Same trait defined twice in same file — should produce exactly one error.
        let src = "<?php\ntrait Serializable {}\ntrait Serializable {}";
        let doc = ParsedDoc::parse(src.to_string());
        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::default());
        assert_eq!(
            diags.len(),
            1,
            "expected exactly 1 duplicate-declaration diagnostic, got: {:?}",
            diags
        );
        assert!(
            diags[0].message.contains("Serializable"),
            "diagnostic message should mention 'Serializable'"
        );
        assert_eq!(
            diags[0].severity,
            Some(DiagnosticSeverity::WARNING),
            "duplicate trait declaration should be a warning"
        );
    }

    #[test]
    fn deprecated_method_call_emits_warning() {
        // Calling a method annotated @deprecated should emit a warning.
        let src = concat!(
            "<?php\n",
            "class Mailer {\n",
            "    /** @deprecated Use sendAsync() instead */\n",
            "    public function send() {}\n",
            "}\n",
            "$m = new Mailer();\n",
            "$m->send();\n",
        );
        let doc = ParsedDoc::parse(src.to_string());
        let diags = deprecated_call_diagnostics(src, &doc, &[], &DiagnosticsConfig::default());
        assert_eq!(
            diags.len(),
            1,
            "expected exactly 1 deprecated warning, got: {:?}",
            diags
        );
        let d = &diags[0];
        assert_eq!(d.severity, Some(DiagnosticSeverity::WARNING));
        assert!(
            d.message.contains("send"),
            "message should mention 'send', got: {}",
            d.message
        );
        assert!(
            d.message.to_lowercase().contains("deprecated"),
            "message should contain 'deprecated', got: {}",
            d.message
        );
    }

    #[test]
    fn deprecated_function_warning_has_correct_message() {
        // The deprecation warning message must contain the function name AND the
        // word "Deprecated" (case-sensitive per implementation: "Deprecated: …").
        let src = "<?php\n/** @deprecated old API */\nfunction legacyFn() {}\n\nlegacyFn();";
        let doc = ParsedDoc::parse(src.to_string());
        let diags = deprecated_call_diagnostics(src, &doc, &[], &DiagnosticsConfig::default());
        assert_eq!(diags.len(), 1, "expected exactly 1 diagnostic");
        let msg = &diags[0].message;
        assert!(
            msg.contains("legacyFn"),
            "message should contain function name 'legacyFn', got: {msg}"
        );
        assert!(
            msg.to_lowercase().contains("deprecated"),
            "message should contain 'deprecated', got: {msg}"
        );
    }

    #[test]
    fn duplicate_diagnostic_has_warning_severity() {
        // Duplicate declarations are reported as WARNING by our implementation.
        // (Note: `duplicate_declaration_diagnostics` emits DiagnosticSeverity::WARNING.)
        let src = "<?php\nfunction doWork() {}\nfunction doWork() {}";
        let doc = ParsedDoc::parse(src.to_string());
        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::default());
        assert_eq!(diags.len(), 1, "expected exactly 1 duplicate diagnostic");
        assert_eq!(
            diags[0].severity,
            Some(DiagnosticSeverity::WARNING),
            "duplicate declaration diagnostic should have WARNING severity"
        );
    }

    #[test]
    fn deprecated_call_nested_in_argument_is_detected() {
        // A deprecated function call nested inside another call's argument must still warn.
        let src = concat!(
            "<?php\n",
            "/** @deprecated */\n",
            "function oldFn(): string { return ''; }\n",
            "function wrapper(string $s): void {}\n",
            "wrapper(oldFn());\n",
        );
        let doc = ParsedDoc::parse(src.to_string());
        let diags = deprecated_call_diagnostics(src, &doc, &[], &DiagnosticsConfig::default());
        assert_eq!(
            diags.len(),
            1,
            "expected 1 deprecation warning for nested call, got: {:?}",
            diags
        );
        assert!(
            diags[0].message.contains("oldFn"),
            "message should mention 'oldFn'"
        );
    }

    #[test]
    fn deprecated_method_in_trait_is_detected() {
        // A method annotated @deprecated in a trait should trigger a warning when called.
        let src = concat!(
            "<?php\n",
            "trait Logger {\n",
            "    /** @deprecated Use logAsync() instead */\n",
            "    public function log() {}\n",
            "}\n",
            "class App { use Logger; }\n",
            "$a = new App();\n",
            "$a->log();\n",
        );
        let doc = ParsedDoc::parse(src.to_string());
        let diags = deprecated_call_diagnostics(src, &doc, &[], &DiagnosticsConfig::default());
        assert_eq!(
            diags.len(),
            1,
            "expected 1 deprecated warning for trait method, got: {:?}",
            diags
        );
        assert!(
            diags[0].message.contains("log"),
            "message should mention 'log'"
        );
    }

    #[test]
    fn deprecated_method_in_enum_is_detected() {
        let src = concat!(
            "<?php\n",
            "enum Status {\n",
            "    case Active;\n",
            "    /** @deprecated Use activeLabel() instead */\n",
            "    public function label(): string { return 'active'; }\n",
            "}\n",
            "$s = Status::Active;\n",
            "$s->label();\n",
        );
        let doc = ParsedDoc::parse(src.to_string());
        let diags = deprecated_call_diagnostics(src, &doc, &[], &DiagnosticsConfig::default());
        assert_eq!(
            diags.len(),
            1,
            "expected 1 deprecated warning for enum method, got: {:?}",
            diags
        );
        assert!(
            diags[0].message.contains("label"),
            "message should mention 'label'"
        );
    }

    #[test]
    fn unbraced_namespace_classes_with_same_name_not_flagged() {
        // Two classes named `Foo` in different unbraced namespaces — should not be a duplicate.
        let src = "<?php\nnamespace App\\A;\nclass Foo {}\nnamespace App\\B;\nclass Foo {}";
        let doc = ParsedDoc::parse(src.to_string());
        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::default());
        assert!(
            diags.is_empty(),
            "classes with same name in different unbraced namespaces should not be flagged, got: {:?}",
            diags
        );
    }

    #[test]
    fn unbraced_namespace_duplicate_in_same_namespace_is_flagged() {
        // Two classes named `Foo` in the same unbraced namespace — should produce one warning.
        let src = "<?php\nnamespace App;\nclass Foo {}\nclass Foo {}";
        let doc = ParsedDoc::parse(src.to_string());
        let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::default());
        assert_eq!(
            diags.len(),
            1,
            "expected 1 duplicate-declaration diagnostic, got: {:?}",
            diags
        );
        assert!(diags[0].message.contains("Foo"));
    }

    #[test]
    fn to_lsp_diagnostic_sets_code_to_issue_kind_name() {
        use mir_issues::{Issue, IssueKind, Location};
        use std::sync::Arc;
        use tower_lsp::lsp_types::{NumberOrString, Url};

        let uri = Url::parse("file:///test.php").unwrap();
        let location = Location {
            file: Arc::from("file:///test.php"),
            line: 1,
            col_start: 0,
            col_end: 3,
        };
        let issue = Issue::new(
            IssueKind::UndefinedClass {
                name: "Foo".to_string(),
            },
            location,
        );
        let diag = to_lsp_diagnostic(issue, &uri);
        assert_eq!(
            diag.code,
            Some(NumberOrString::String("UndefinedClass".to_string())),
            "diagnostic code must be the IssueKind name so code actions can match by type"
        );
        assert!(
            diag.message.contains("Foo"),
            "diagnostic message should mention the class name"
        );
    }
}
