//! Map typst [`SourceDiagnostic`]s to [`cph_diag::Diagnostic`]s.
//!
//! ## Span mapping (typst 0.15 specifics)
//!
//! In 0.15 a diagnostic's `span` is a [`typst::syntax::DiagSpan`] (not the bare
//! `Span` of 0.14). To locate it we:
//! 1. `span.id() -> Option<FileId>` for the owning file (`None` ⇒ detached, so
//!    no span is emitted);
//! 2. `WorldExt::range(span) -> Option<Range<usize>>` for the byte range — this
//!    handles every `DiagSpanKind` variant internally (numbered source spans
//!    *and* raw external byte ranges), so we never match the enum by hand;
//! 3. compute 1-based line/col from the file's [`typst::syntax::Source`] via
//!    `lines().byte_to_line_column` (0-based ⇒ `+1`).
//!
//! The `main` file is now the real **template** (`exports/<target>.typ`), so a
//! span there resolves naturally to an authored file — no re-pointing needed. A
//! span landing in the **virtual augmented manifest** ([`crate::MANIFEST_VPATH`],
//! served in-memory, not on disk) is reported with its vpath plus a hint marking
//! it as the framework-synthesized manifest (a manifest-generation bug
//! indicator) rather than mis-attributed to a user file. Spans in the
//! `cph-render` package are reported with an `@local/cph-render:…` pseudo-path.

use std::path::PathBuf;

use cph_diag::{DiagCode, Diagnostic, Severity, SourceSpan};
use typst::diag::{Severity as TypstSeverity, SourceDiagnostic};
use typst::syntax::{FileId, VirtualRoot};
use typst::{World, WorldExt};

use crate::world::LessonWorld;

/// Convert one typst diagnostic into a `cph-diag` [`Diagnostic`], resolving its
/// span against `world`.
pub fn map_diagnostic(world: &LessonWorld, d: &SourceDiagnostic) -> Diagnostic {
    let severity = match d.severity {
        TypstSeverity::Error => Severity::Error,
        TypstSeverity::Warning => Severity::Warning,
    };

    // Flatten the typst call trace into the message tail so call-site context
    // isn't lost (the primary span points at the innermost node).
    let mut message = d.message.to_string();
    for tp in &d.trace {
        message.push_str(&format!("\n  in {}", tp.v));
    }

    let mut diag = Diagnostic {
        severity,
        code: DiagCode::TypstCompile,
        message,
        span: None,
        hint: None,
    };

    // typst's own hints are the product's core value — carry them through.
    let mut hints: Vec<String> = d.hints.iter().map(|h| h.v.to_string()).collect();

    if let Some(id) = d.span.id() {
        if let Some(file) = file_for(world, id) {
            if is_virtual_manifest(id) {
                hints.push(
                    "this span is inside the framework-synthesized manifest \
                     (/.cph/manifest.toml), not a lesson source file — likely a \
                     manifest-generation issue"
                        .to_string(),
                );
            }
            // Byte range via WorldExt::range (DiagSpan is Copy).
            let byte_range = world.range(d.span).unwrap_or(0..0);
            let (line, col) = line_col(world, id, byte_range.start);
            diag.span = Some(SourceSpan {
                file,
                byte_range,
                line,
                col,
            });
        }
    }

    if !hints.is_empty() {
        diag.hint = Some(hints.join("; "));
    }
    diag
}

/// Whether `id` is the in-memory augmented-manifest virtual file
/// ([`crate::MANIFEST_VPATH`]) the engine injects (not a real lesson file).
fn is_virtual_manifest(id: FileId) -> bool {
    matches!(id.root(), VirtualRoot::Project)
        && id.vpath().get_with_slash() == crate::MANIFEST_VPATH
}

/// 1-based (line, col) for a byte offset in file `id`, defaulting to `(1, 1)`
/// when the source can't be read or the offset is out of range.
fn line_col(world: &LessonWorld, id: FileId, byte: usize) -> (u32, u32) {
    let Ok(source) = world.source(id) else {
        return (1, 1);
    };
    match source.lines().byte_to_line_column(byte) {
        // byte_to_line_column is 0-based; editors are 1-based.
        Some((line, col)) => (line as u32 + 1, col as u32 + 1),
        None => (1, 1),
    }
}

/// File path for a `FileId`. Project files are relative to the lesson root (no
/// leading slash); package files use an `@namespace/name:version/path` form.
fn file_for(world: &LessonWorld, id: FileId) -> Option<PathBuf> {
    let _ = world;
    match id.root() {
        VirtualRoot::Project => Some(PathBuf::from(id.vpath().get_without_slash())),
        VirtualRoot::Package(spec) => Some(PathBuf::from(format!(
            "@{}/{}:{}{}",
            spec.namespace,
            spec.name,
            spec.version,
            id.vpath().get_with_slash()
        ))),
    }
}
