//! `cph-check` — check-pipeline orchestration.
//!
//! Owned by **WU-5**. Ties together `cph-model` (load), `cph-schema`
//! (validate), and `cph-typst` (compile), runs the render-coverage rule
//! (ADR-0005's "missing render ⇒ warning"), and collects all diagnostics.
//!
//! The orchestrator owns *sequencing and gating*, not the individual checks:
//! each phase is implemented by an upstream crate, and `cph-check` decides the
//! order, what gates what, and how the results are merged / deduped. See
//! [`check`] for the full pipeline and [`build`] for the export path.

use std::path::Path;

use cph_diag::{DiagCode, Diagnostic, Severity};
use cph_typst::Engine;

/// The default render target when a lesson declares no `[targets.*]` at all.
const DEFAULT_TARGET: &str = "student";

/// Severity of the render-coverage ("element ignored under a target") diagnostic.
///
/// **PINNED to `warning` by ADR-0005:** when a
/// `(kind, target)` pair has no render rule the checker reports that the element
/// is ignored under that target and **does not block the export**. Naming the
/// severity as a const makes "it is a warning, not an error" a greppable
/// fact rather than an inline literal.
const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning;

/// The result of running [`check`] (or the check phases of [`build`]).
#[derive(Debug, Clone, PartialEq)]
pub struct CheckReport {
    /// Every diagnostic collected across all phases, deduplicated.
    pub diagnostics: Vec<Diagnostic>,
    /// `false` if `cph_model::load` returned `None` (a hard manifest failure
    /// that prevents any further phase from running).
    pub lesson_loaded: bool,
}

impl CheckReport {
    /// How many collected diagnostics are `Error`-severity.
    pub fn error_count(&self) -> usize {
        self.diagnostics
            .iter()
            .filter(|d| d.severity == Severity::Error)
            .count()
    }

    /// How many collected diagnostics are `Warning`-severity.
    pub fn warning_count(&self) -> usize {
        self.diagnostics
            .iter()
            .filter(|d| d.severity == Severity::Warning)
            .count()
    }

    /// Whether any collected diagnostic is `Error`-severity.
    ///
    /// **Legality decision (ADR-0010).** `!has_errors()` decides lesson
    /// legality: a lesson is *legal* iff its diagnostics contain no error-level
    /// diagnostic (warnings are non-blocking — see `Severity`). There is no CI
    /// gate enforcing ADR↔implementation alignment (repo constitution); it is
    /// kept greppable here so a reviewer can tie the orchestrator's gate to
    /// the ADR.
    pub fn has_errors(&self) -> bool {
        self.diagnostics
            .iter()
            .any(|d| d.severity == Severity::Error)
    }
}

/// Run the full check pipeline against the engineering file at `root`.
///
/// Phases, in order, with gating:
/// - **(a) load** — `cph_model::load`. Its diagnostics are collected. On a hard
///   failure (`None`) the report is returned immediately with
///   `lesson_loaded == false`; nothing downstream can run.
/// - **(b) structural** — for each part, check `part.kind` is in
///   `cph_schema::known_kinds()`; an unknown kind is an `UnknownKind` error.
///   (cph-model already checked path existence, `..` traversal, and
///   part/element kind *mismatch*; this complements that with the *known-set*
///   check and does not duplicate it.) Parts whose folder cph-model already
///   flagged as missing are skipped for the rest of the pipeline.
/// - **(c) schema** — for each part whose folder exists and whose kind is
///   known, `cph_schema::validate(&part.descriptor)`.
/// - **(d) typst compile** — only if phases (a)–(c) produced **zero** errors
///   (otherwise the compile is noise on known-broken input). Compiles each
///   declared `lesson.targets` (defaulting to `"student"` if none), deduping
///   identical diagnostics across targets.
/// - **(e) render-coverage** — a `warning` (never an error) for each
///   `(kind, target)` the target's `covers` declaration does not include. Runs
///   regardless of (d) gating.
///
/// The engine is taken as a parameter so the caller owns it (and tests can
/// inject `Engine::with_render_dir`).
pub fn check(root: &Path, engine: &Engine) -> CheckReport {
    let mut diags = Vec::new();

    // (a) load.
    let (lesson, load_diags) = cph_model::load(root);
    diags.extend(load_diags);

    let Some(lesson) = lesson else {
        // Hard manifest failure: nothing else can run.
        return CheckReport {
            diagnostics: dedup(diags),
            lesson_loaded: false,
        };
    };

    let known = cph_schema::known_kinds();

    // (b) structural + (c) schema.
    run_structural_and_schema(&lesson, known, &mut diags);

    // (d) typst compile — gated on zero errors from (a)–(c).
    let pre_compile_has_error = diags.iter().any(|d| d.severity == Severity::Error);
    if !pre_compile_has_error {
        for target in compile_targets(&lesson) {
            diags.extend(engine.compile_check(&lesson, target));
        }
    }

    // (e) render-coverage — always runs; only a warning. Skips parts already
    // broken in (b) (missing folder / unknown kind).
    diags.extend(render_coverage(&lesson, known));

    CheckReport {
        diagnostics: dedup(diags),
        lesson_loaded: true,
    }
}

/// Build a PDF for `target`.
///
/// Runs the check phases **(a)–(c)** (load → structural → schema). If those
/// produce any `Error`, the build is refused: returns `(None, report)` with the
/// blocking diagnostics. Otherwise it compiles + exports the PDF for `target`
/// via `engine.build_pdf`; on a clean compile returns `(Some(bytes), report)`,
/// and on a typst error returns `(None, report)` with the compile diagnostics
/// folded into the report.
///
/// Note `build` does **not** run render-coverage (phase (e)): coverage warnings
/// describe export *loss*, which is informational for a `check`, not a gate on
/// producing a single target's PDF.
pub fn build(root: &Path, engine: &Engine, target: &str) -> (Option<Vec<u8>>, CheckReport) {
    let mut diags = Vec::new();

    // (a) load.
    let (lesson, load_diags) = cph_model::load(root);
    diags.extend(load_diags);

    let Some(lesson) = lesson else {
        return (
            None,
            CheckReport {
                diagnostics: dedup(diags),
                lesson_loaded: false,
            },
        );
    };

    let known = cph_schema::known_kinds();

    // (b) structural + (c) schema.
    run_structural_and_schema(&lesson, known, &mut diags);

    if diags.iter().any(|d| d.severity == Severity::Error) {
        return (
            None,
            CheckReport {
                diagnostics: dedup(diags),
                lesson_loaded: true,
            },
        );
    }

    // Phases passed: compile + export the requested target.
    match engine.build_pdf(&lesson, target) {
        Ok(bytes) => (
            Some(bytes),
            CheckReport {
                diagnostics: dedup(diags),
                lesson_loaded: true,
            },
        ),
        Err(compile_diags) => {
            diags.extend(compile_diags);
            (
                None,
                CheckReport {
                    diagnostics: dedup(diags),
                    lesson_loaded: true,
                },
            )
        }
    }
}

/// One shell step's execution outcome (for [`run_shell_target`]).
#[derive(Debug, Clone, PartialEq)]
pub struct ShellStepOutcome {
    /// The command line that was run (verbatim from the manifest `run` field).
    pub run: String,
    /// Process exit code (`None` if the process was killed by a signal).
    pub status: Option<i32>,
    /// Captured stdout.
    pub stdout: String,
    /// Captured stderr.
    pub stderr: String,
}

impl ShellStepOutcome {
    /// Whether the step exited successfully (status 0).
    pub fn ok(&self) -> bool {
        self.status == Some(0)
    }
}

/// The result of [`run_shell_target`].
#[derive(Debug, Clone, PartialEq)]
pub struct ShellRunReport {
    /// The check phases' report (load → structural → schema). Shell steps run
    /// only when this has no errors.
    pub check: CheckReport,
    /// Each shell step's outcome, in declared order. Empty if the build was
    /// refused (check errors, unknown target, or the target has no shell steps).
    pub outcomes: Vec<ShellStepOutcome>,
    /// `true` if the target was found, check passed, and every shell step exited 0.
    pub ok: bool,
}

/// Execute the `Shell` steps of a `file-tree`/tool-generated target (ADR-0009
/// category (b); e.g. the KenKen interactives produced by the `kendoku` CLI).
///
/// This is the **escape-hatch executor**: unlike [`build`] (which compiles a
/// typst template to a PDF), a shell target hands off to an external tool that
/// writes files itself. cph only *runs the declared command* in the engineering
/// root and reports what happened — it assembles nothing.
///
/// Contract / safety:
/// - Runs the check phases first (load → structural → schema). Any error refuses
///   the run (`ok == false`, no command executed) — a shell build of a broken
///   lesson is not attempted.
/// - Each `Step::Shell { run }` is executed via the platform shell
///   (`sh -c <run>` / `cmd /C <run>`) with the **engineering root as the working
///   directory**, so a manifest can use root-relative paths.
/// - **Arbitrary command execution is opt-in by construction**: this function is
///   only reached from `cph build --target <name>`, never from `check`. Callers
///   (the CLI) surface the command before running it.
/// - `TypstCompile` steps inside the same target are skipped here (a shell target
///   is not a typst build); a target with no shell steps yields `ok == false`.
pub fn run_shell_target(root: &Path, engine: &Engine, target: &str) -> ShellRunReport {
    let mut diags = Vec::new();

    let (lesson, load_diags) = cph_model::load(root);
    diags.extend(load_diags);

    let Some(lesson) = lesson else {
        return ShellRunReport {
            check: CheckReport {
                diagnostics: dedup(diags),
                lesson_loaded: false,
            },
            outcomes: Vec::new(),
            ok: false,
        };
    };

    let known = cph_schema::known_kinds();
    run_structural_and_schema(&lesson, known, &mut diags);

    // Suppress the unused-parameter warning while keeping the signature uniform
    // with `build`/`check` (the engine is not needed to run shell steps, but
    // callers pass it so the API is consistent and future steps may use it).
    let _ = engine;

    let has_error = diags.iter().any(|d| d.severity == Severity::Error);

    let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else {
        diags.push(
            Diagnostic::error(
                DiagCode::SchemaViolation,
                format!("target '{target}' not declared in manifest"),
            )
            .with_hint(format!(
                "add a `[targets.{target}]` table, or run a declared target"
            )),
        );
        return ShellRunReport {
            check: CheckReport {
                diagnostics: dedup(diags),
                lesson_loaded: true,
            },
            outcomes: Vec::new(),
            ok: false,
        };
    };

    let shell_steps: Vec<&str> = tc
        .steps
        .iter()
        .filter_map(|s| match s {
            cph_model::Step::Shell { run } => Some(run.as_str()),
            cph_model::Step::TypstCompile { .. } => None,
            cph_model::Step::AssembleMarkdown { .. } => None,
        })
        .collect();

    if has_error || shell_steps.is_empty() {
        return ShellRunReport {
            check: CheckReport {
                diagnostics: dedup(diags),
                lesson_loaded: true,
            },
            outcomes: Vec::new(),
            ok: false,
        };
    }

    // Run each shell step in the engineering root. First non-zero exit stops the
    // sequence (a failed step's successors likely depend on it).
    let mut outcomes = Vec::new();
    let mut all_ok = true;
    for run in shell_steps {
        let outcome = run_one_shell(&lesson.root, run);
        let ok = outcome.ok();
        outcomes.push(outcome);
        if !ok {
            all_ok = false;
            break;
        }
    }

    ShellRunReport {
        check: CheckReport {
            diagnostics: dedup(diags),
            lesson_loaded: true,
        },
        outcomes,
        ok: all_ok,
    }
}

/// Whether the named target is a **shell-step** target (its steps contain at
/// least one `Step::Shell` and no `Step::TypstCompile`) — i.e. a tool-generated
/// asset bundle that [`run_shell_target`] executes rather than [`build`]
/// compiling to PDF.
///
/// Loads the lesson read-only (ignoring diagnostics); an unloadable lesson or an
/// unknown target returns `false`, so the caller falls through to the normal
/// (typst) build path, which then reports the real error.
pub fn target_is_shell(root: &Path, target: &str) -> bool {
    let (Some(lesson), _) = cph_model::load(root) else {
        return false;
    };
    lesson
        .targets
        .iter()
        .find(|t| t.name == target)
        .is_some_and(|t| {
            t.steps
                .iter()
                .any(|s| matches!(s, cph_model::Step::Shell { .. }))
                && !t
                    .steps
                    .iter()
                    .any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))
        })
}

/// Run one shell command line in `cwd` via the platform shell, capturing output.
fn run_one_shell(cwd: &Path, run: &str) -> ShellStepOutcome {
    use std::process::Command;

    let output = if cfg!(target_os = "windows") {
        Command::new("cmd")
            .arg("/C")
            .arg(run)
            .current_dir(cwd)
            .output()
    } else {
        Command::new("sh")
            .arg("-c")
            .arg(run)
            .current_dir(cwd)
            .output()
    };

    match output {
        Ok(out) => ShellStepOutcome {
            run: run.to_string(),
            status: out.status.code(),
            stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
            stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
        },
        Err(e) => ShellStepOutcome {
            run: run.to_string(),
            status: None,
            stdout: String::new(),
            stderr: format!("failed to spawn command: {e}"),
        },
    }
}

/// One assemble-markdown step's outcome (for [`run_markdown_assemble_target`]).
#[derive(Debug, Clone, PartialEq)]
pub struct MarkdownAssembleOutcome {
    /// The field name that was assembled (verbatim from the step's `field`).
    pub field: String,
    /// How many elements' `<field>.md` files were read and concatenated.
    pub parts_read: usize,
    /// The assembled markdown body (empty if nothing was read).
    pub body: String,
    /// Where the assembled file was written (relative to the engineering root);
    /// `None` if the write failed or was skipped.
    pub written: Option<String>,
    /// `None` on success; an error message if reading a required file or writing
    /// the artifact failed.
    pub error: Option<String>,
}

impl MarkdownAssembleOutcome {
    /// Whether the step succeeded (wrote the artifact with no error).
    pub fn ok(&self) -> bool {
        self.error.is_none() && self.written.is_some()
    }
}

/// The result of [`run_markdown_assemble_target`].
#[derive(Debug, Clone, PartialEq)]
pub struct MarkdownAssembleReport {
    /// The check phases' report (load → structural → schema). Assembly runs only
    /// when this has no errors.
    pub check: CheckReport,
    /// Each assemble-markdown step's outcome, in declared order. Empty if the
    /// build was refused (check errors, unknown target, no assemble steps).
    pub outcomes: Vec<MarkdownAssembleOutcome>,
    /// `true` if the target was found, check passed, and every assemble step wrote
    /// its artifact without error.
    pub ok: bool,
}

/// Execute the `AssembleMarkdown` steps of a target (ADR-0015): concatenate each
/// element's `<field>.md` markdown content file in `[[parts]]` order into the
/// target's single-file artifact. This is the **third typed step**: unlike
/// [`build`] (typst template → PDF) the framework owns the read/concatenate/write
/// itself (not a typst compile, not an external tool like [`run_shell_target`]).
///
/// Contract / safety (same three boundaries as the shell step, ADR-0013/0015):
/// - Runs the check phases first (load → structural → schema). Any error refuses
///   the run (`ok == false`, nothing written) — assembling a broken lesson is
///   not attempted.
/// - Walks `lesson.parts` **in declared order**, reading `<part.path>/<field>.md`
///   when it exists (skipping absent ones — these fields are optional), joining
///   them with a blank line, and writing to the artifact's single-file
///   `filepath` (relative to the engineering root). Each `slides.md`/etc.
///   authors its own heading; the assembler injects nothing (presentation in the
///   content, ADR-0011).
/// - **Opt-in by construction**: only reached from `cph build --target <name>`,
///   never from `check`. A non-typst target, so `check`'s compile phase skips it.
/// - A write error is a **build-process error**, not a lesson diagnostic — it is
///   reported in `MarkdownAssembleOutcome::error`, not the 6-class taxonomy.
pub fn run_markdown_assemble_target(
    root: &Path,
    engine: &Engine,
    target: &str,
) -> MarkdownAssembleReport {
    let mut diags = Vec::new();

    let (lesson, load_diags) = cph_model::load(root);
    diags.extend(load_diags);

    let Some(lesson) = lesson else {
        return MarkdownAssembleReport {
            check: CheckReport {
                diagnostics: dedup(diags),
                lesson_loaded: false,
            },
            outcomes: Vec::new(),
            ok: false,
        };
    };

    let known = cph_schema::known_kinds();
    run_structural_and_schema(&lesson, known, &mut diags);

    // Signature uniform with `build`/`run_shell_target`; the engine is not used
    // to assemble markdown (no typst compile), but callers pass it for API
    // consistency.
    let _ = engine;

    let has_error = diags.iter().any(|d| d.severity == Severity::Error);

    let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else {
        diags.push(
            Diagnostic::error(
                DiagCode::SchemaViolation,
                format!("target '{target}' not declared in manifest"),
            )
            .with_hint(format!(
                "add a `[targets.{target}]` table, or run a declared target"
            )),
        );
        return MarkdownAssembleReport {
            check: CheckReport {
                diagnostics: dedup(diags),
                lesson_loaded: true,
            },
            outcomes: Vec::new(),
            ok: false,
        };
    };

    let assemble_steps: Vec<&str> = tc
        .steps
        .iter()
        .filter_map(|s| match s {
            cph_model::Step::AssembleMarkdown { field } => Some(field.as_str()),
            _ => None,
        })
        .collect();

    if has_error || assemble_steps.is_empty() {
        return MarkdownAssembleReport {
            check: CheckReport {
                diagnostics: dedup(diags),
                lesson_loaded: true,
            },
            outcomes: Vec::new(),
            ok: false,
        };
    }

    // Run each assemble-markdown step. First failure stops the sequence.
    let mut outcomes = Vec::new();
    let mut all_ok = true;
    for field in assemble_steps {
        let outcome = assemble_one(&lesson, field);
        let ok = outcome.ok();
        outcomes.push(outcome);
        if !ok {
            all_ok = false;
            break;
        }
    }

    MarkdownAssembleReport {
        check: CheckReport {
            diagnostics: dedup(diags),
            lesson_loaded: true,
        },
        outcomes,
        ok: all_ok,
    }
}

/// Assemble one markdown `field` across all parts in order, writing the
/// single-file artifact.
fn assemble_one(lesson: &cph_model::Lesson, field: &str) -> MarkdownAssembleOutcome {
    let mut chunks: Vec<String> = Vec::new();
    let mut parts_read = 0usize;

    // Inject the course-level title as the document's h1. The title is course
    // metadata (from `[info]` in the manifest), not per-element content, so the
    // assembler prepends it; each element's `<field>.md` then contributes its
    // `##` part heading and `###` 小节. (ADR-0011's "presentation lives in the
    // content" is about per-element presentation; the single course title is a
    // document-level fact the assembler owns.)
    if !lesson.info.title.trim().is_empty() {
        chunks.push(format!("# {}", lesson.info.title.trim()));
    }

    for part in &lesson.parts {
        if !part.descriptor.dir.is_dir() {
            continue;
        }
        let md = part.descriptor.dir.join(format!("{field}.md"));
        if md.is_file() {
            match std::fs::read_to_string(&md) {
                Ok(body) => {
                    chunks.push(body);
                    parts_read += 1;
                }
                Err(e) => {
                    return MarkdownAssembleOutcome {
                        field: field.to_string(),
                        parts_read,
                        body: String::new(),
                        written: None,
                        error: Some(format!("failed to read '{}': {e}", md.display())),
                    };
                }
            }
        }
        // Absent `<field>.md` is fine — the field is optional (ADR-0015).
    }

    let body = chunks.join("\n\n");

    // Resolve the target's single-file artifact path. (The assemble target is a
    // single-file artifact by construction; a file-tree target has no single
    // filepath to write to, which is a manifest error surfaced here.)
    let tc = lesson.targets.iter().find(|t| {
        t.steps
            .iter()
            .any(|s| matches!(s, cph_model::Step::AssembleMarkdown { field: f } if f == field))
    });
    let Some(tc) = tc else {
        return MarkdownAssembleOutcome {
            field: field.to_string(),
            parts_read,
            body,
            written: None,
            error: Some("no target carries this assemble-markdown step".into()),
        };
    };
    let cph_model::Artifact::SingleFile { filepath } = &tc.artifact else {
        return MarkdownAssembleOutcome {
            field: field.to_string(),
            parts_read,
            body,
            written: None,
            error: Some(format!(
                "target '{}' has a non-single-file artifact; assemble-markdown writes a single file",
                tc.name
            )),
        };
    };

    let out_path = lesson.root.join(filepath);
    if let Some(parent) = out_path.parent() {
        if let Err(e) = std::fs::create_dir_all(parent) {
            return MarkdownAssembleOutcome {
                field: field.to_string(),
                parts_read,
                body,
                written: None,
                error: Some(format!("failed to create '{}': {e}", parent.display())),
            };
        }
    }
    match std::fs::write(&out_path, &body) {
        Ok(()) => {
            // Collect referenced image assets so the build dir is self-contained.
            // The assembled `body` is written under `build_root` (the artifact
            // filepath's parent); its `![](rel)` image references resolve against
            // that build root. So each referenced local image is copied from the
            // engineering root (`lesson.root/<rel>`) into the build root
            // (`build_root/<rel>`), preserving its relative path. A referenced
            // image that is missing at the engineering root is a build-process
            // error (a self-contained build with a dangling ref is broken) — it
            // does not enter the 6-class diagnostic taxonomy (ADR-0015).
            if let Some(build_root) = out_path.parent() {
                if let Err(e) = collect_image_assets(&lesson.root, build_root, &body) {
                    return MarkdownAssembleOutcome {
                        field: field.to_string(),
                        parts_read,
                        body,
                        written: Some(filepath.display().to_string()),
                        error: Some(e),
                    };
                }
            }
            MarkdownAssembleOutcome {
                field: field.to_string(),
                parts_read,
                body,
                written: Some(filepath.display().to_string()),
                error: None,
            }
        }
        Err(e) => MarkdownAssembleOutcome {
            field: field.to_string(),
            parts_read,
            body,
            written: None,
            error: Some(format!("failed to write '{}': {e}", out_path.display())),
        },
    }
}

/// Copy each local markdown image reference in `body` from the engineering root
/// `eng_root` into the build root `build_root`, preserving the reference's
/// relative path. Returns `Err(message)` on the first referenced image that is
/// missing at `eng_root` (a build-process error, ADR-0015).
///
/// References are `![alt](target)` markdown images; `target` is taken up to the
/// first whitespace (a possible ` "title"` suffix is stripped). Absolute URLs
/// (`http://`, `https://`, `data:`) are left untouched — they are not local
/// assets to collect. Duplicate references copy once.
fn collect_image_assets(eng_root: &Path, build_root: &Path, body: &str) -> Result<(), String> {
    let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
    for target in image_refs(body) {
        if target.is_empty()
            || target.starts_with("http://")
            || target.starts_with("https://")
            || target.starts_with("data:")
        {
            continue;
        }
        if !seen.insert(target.to_string()) {
            continue;
        }
        let src = eng_root.join(target);
        if !src.is_file() {
            return Err(format!(
                "slides references image '{}' which is missing from the engineering root",
                src.display()
            ));
        }
        let dst = build_root.join(target);
        if let Some(parent) = dst.parent() {
            if let Err(e) = std::fs::create_dir_all(parent) {
                return Err(format!("failed to create '{}': {e}", parent.display()));
            }
        }
        if let Err(e) = std::fs::copy(&src, &dst) {
            return Err(format!(
                "failed to copy '{}' → '{}': {e}",
                src.display(),
                dst.display()
            ));
        }
    }
    Ok(())
}

/// Scan `body` for markdown image references `![alt](target)` and yield each
/// `target` (whitespace-trimmed of a trailing ` "title"`). Hand-rolled to avoid
/// pulling a regex dependency for a single, simple pattern.
fn image_refs(body: &str) -> Vec<&str> {
    let bytes = body.as_bytes();
    let mut out = Vec::new();
    let mut i = 0;
    while i + 1 < bytes.len() {
        // Look for the `![` opener.
        if bytes[i] == b'!' && bytes[i + 1] == b'[' {
            // Find the matching `]`.
            if let Some(bracket_close) = body[i + 2..].find(']') {
                let after_bracket = i + 2 + bracket_close + 1;
                if after_bracket < bytes.len() && bytes[after_bracket] == b'(' {
                    // Find the closing `)` of the link target.
                    if let Some(paren_close) = body[after_bracket + 1..].find(')') {
                        let target_start = after_bracket + 1;
                        let target_end = target_start + paren_close;
                        let target = &body[target_start..target_end];
                        // Trim a possible ` "title"` suffix: take up to first space.
                        let target = target.split_whitespace().next().unwrap_or("");
                        out.push(target);
                        i = target_end + 1;
                        continue;
                    }
                }
            }
        }
        i += 1;
    }
    out
}

/// Whether the named target is an **assemble-markdown** target (its steps
/// contain at least one `Step::AssembleMarkdown` and no `Step::TypstCompile`) —
/// i.e. a markdown deliverable that [`run_markdown_assemble_target`] builds
/// rather than [`build`] compiling to PDF.
///
/// Loads the lesson read-only (ignoring diagnostics); an unloadable lesson or an
/// unknown target returns `false`, so the caller falls through to the normal
/// (typst) build path, which then reports the real error.
pub fn target_is_markdown_assemble(root: &Path, target: &str) -> bool {
    let (Some(lesson), _) = cph_model::load(root) else {
        return false;
    };
    lesson
        .targets
        .iter()
        .find(|t| t.name == target)
        .is_some_and(|t| {
            t.steps
                .iter()
                .any(|s| matches!(s, cph_model::Step::AssembleMarkdown { .. }))
                && !t
                    .steps
                    .iter()
                    .any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))
        })
}

/// Whether a part's folder exists on disk. cph-model emits `PartPathMissing`
/// for parts whose folder is absent, so the orchestrator skips those parts in
/// the structural/schema/coverage phases to avoid piling diagnostics on the
/// same root cause.
fn part_exists(p: &cph_model::Part) -> bool {
    p.descriptor.dir.is_dir()
}

/// Phases (b) + (c): the known-kind structural check and per-part schema
/// validation. Shared by [`check`] and [`build`] so their gating stays
/// identical.
fn run_structural_and_schema(
    lesson: &cph_model::Lesson,
    known: &[&str],
    diags: &mut Vec<Diagnostic>,
) {
    // (b) structural: known-kind check.
    for part in &lesson.parts {
        if !part_exists(part) {
            continue;
        }
        if !known.contains(&part.kind.as_str()) {
            diags.push(
                Diagnostic::error(
                    DiagCode::UnknownKind,
                    format!(
                        "part '{}' has unknown kind '{}'",
                        part.path.display(),
                        part.kind
                    ),
                )
                .with_hint(format!("valid kinds are: {}", known.join(", "))),
            );
        }
    }

    // (c) schema: validate each existing part with a known kind. Skipping
    // unknown kinds avoids a second `UnknownKind` from `cph_schema::validate`
    // (already reported in (b)).
    for part in &lesson.parts {
        if !part_exists(part) || !known.contains(&part.kind.as_str()) {
            continue;
        }
        diags.extend(cph_schema::validate(&part.descriptor));
    }
}

/// The targets to compile-check for [`check`]: every declared **typst-compile**
/// target, or `["student"]` if the lesson declares none.
///
/// A target whose steps are all `Shell` (e.g. a tool-generated asset bundle —
/// ADR-0005 category (b), like the KenKen interactives produced by `kendoku`) is
/// **not** typst-compiled, so it is excluded here: compile-checking it would
/// wrongly route a non-typst build through the typst engine. Such targets are
/// executed only on an explicit `cph build --target <name>` (see
/// [`run_shell_target`]); `check` validates the lesson structure, not the
/// outcome of running an external tool.
fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> {
    if lesson.targets.is_empty() {
        return vec![DEFAULT_TARGET];
    }
    lesson
        .targets
        .iter()
        .filter(|t| {
            t.steps
                .iter()
                .any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))
        })
        .map(|t| t.name.as_str())
        .collect()
}

/// Phase (e): for each `(part.kind, target)` over the lesson's declared targets,
/// emit a `RenderIgnored` **warning** when the target does not cover that kind.
///
/// Coverage is the per-target **declaration** ADR-0011 pinned (and the spec's
/// `RenderConfig.covers k t` in `Export/Render.lean`): a target's
/// `cph_model::TargetConfig::covers` lists the kinds it renders. A used kind not
/// in that list draws the warning. An **absent** `covers` (the loader's `None`)
/// is resolved here — the orchestrator owns the kind universe via `cph-schema` —
/// to "covers all known kinds", so an undeclared target is fully covering rather
/// than covering nothing. (An explicit `covers = []` stays empty: it covers
/// nothing on purpose.) Parts already broken in (b) (missing folder / unknown
/// kind) are skipped — they are reported there.
fn render_coverage(lesson: &cph_model::Lesson, known: &[&str]) -> Vec<Diagnostic> {
    let mut out = Vec::new();
    // De-dupe (kind, target) pairs so we emit one warning per pair, not per part.
    let mut seen: Vec<(String, String)> = Vec::new();

    for part in &lesson.parts {
        // Skip parts already flagged structurally.
        if !part_exists(part) || !known.contains(&part.kind.as_str()) {
            continue;
        }
        for target in &lesson.targets {
            // Resolve the coverage set: an absent declaration defaults to the
            // full known-kind universe; an explicit list (possibly empty) is
            // taken verbatim.
            let covered = match &target.covers {
                None => true,
                Some(kinds) => kinds.iter().any(|k| k == &part.kind),
            };
            if covered {
                continue;
            }
            let key = (part.kind.clone(), target.name.clone());
            if seen.contains(&key) {
                continue;
            }
            seen.push(key);
            let mut d = Diagnostic::warning(
                DiagCode::RenderIgnored,
                format!(
                    "kind '{}' is not covered by target '{}'; it will be omitted from that export",
                    part.kind, target.name
                ),
            )
            .with_hint(format!(
                "add '{}' to `covers` under target '{}', or remove the target",
                part.kind, target.name
            ));
            // Pin the severity via the named contract const. `Diagnostic::warning`
            // already produces a warning, but assigning the const keeps the
            // alignment to `renderIgnoredSeverity` load-bearing and greppable.
            d.severity = RENDER_IGNORED_SEVERITY;
            out.push(d);
        }
    }
    out
}

/// Deduplicate diagnostics that are identical (same code+severity+message+span+
/// hint). The same typst error can surface once per compiled target, and the
/// same root cause can be reached by more than one phase; callers see each
/// distinct problem once.
fn dedup(diags: Vec<Diagnostic>) -> Vec<Diagnostic> {
    let mut out: Vec<Diagnostic> = Vec::with_capacity(diags.len());
    for d in diags {
        if !out.contains(&d) {
            out.push(d);
        }
    }
    out
}
