//! `cph-model` — load the ADR-0008 declarative layout into an in-memory lesson.
//!
//! This crate is the **loader**, not the full checker. It reads
//! `<root>/manifest.toml` (project / info / ordered `[[parts]]` / declared
//! `[targets.*]`) and each part's `<root>/<path>/element.toml`, and produces an
//! ordered [`Lesson`], where the order of `parts` carries teaching semantics.
//!
//! Scope boundaries (deliberately staying in lane):
//! - It validates **structure** only: manifest shape, element.toml shape, and
//!   the cross-check `part.kind == element.toml kind`.
//! - It does **not** validate instance data against a kind's JSON Schema (that
//!   is WU-3 / `cph-schema`), does **not** check that `content` `.typ` files
//!   exist (also schema-driven, WU-3), and does **not** compile typst (WU-4).
//!
//! Entry point: [`load`].

use std::path::{Component, Path, PathBuf};

use cph_diag::{DiagCode, Diagnostic};
use serde::{Deserialize, Serialize};

/// An ordered, in-memory lesson loaded from an engineering file.
///
/// `parts` is an ordered
/// `Vec`, and that order is the lesson's order (ADR-0008 §"the lesson manifest
/// is declarative" — the `[[parts]]` array order is the single source of truth).
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Lesson {
    /// `[project]` from the manifest (id, name).
    pub project: Project,
    /// `[info]` from the manifest (title, optional author).
    pub info: Info,
    /// The ordered parts — the lesson's element sequence.
    pub parts: Vec<Part>,
    /// Declared export targets, collected from the `[targets.<name>]` tables.
    ///
    /// Per ADR-0009/0011 an export target is a *build* producing a typed
    /// [`Artifact`] via an ordered list of typed [`Step`]s. This carries those
    /// in declared (TOML document) order. See [`TargetConfig`].
    pub targets: Vec<TargetConfig>,
    /// Engineering-file root (absolute), for resolving part paths.
    pub root: PathBuf,
}

impl Lesson {
    /// The declared export-target names, in declared order.
    ///
    /// Convenience for callers that only need the names (the shape this crate
    /// exposed before ADR-0009 turned `targets` into structured
    /// [`TargetConfig`]s).
    pub fn target_names(&self) -> Vec<&str> {
        self.targets.iter().map(|t| t.name.as_str()).collect()
    }
}

/// One declared export target's build config (ADR-0009/0011).
///
/// An export target is a **build** producing a typed [`Artifact`] via an
/// **ordered** list of typed [`Step`]s. ADR-0011 fixed the *shape* of that
/// build: the artifact carries fields (where the product lands / which files it
/// is), and the build is a step list, each step a typed operation. Presentation
/// (heading numbering, styling) lives in the `typstCompile` template file, not
/// in the manifest — so there is no `numbering` field here anymore.
///
/// ## Defaults for a minimal manifest
///
/// - A target with **no `artifact`** key defaults to
///   [`Artifact::SingleFile`] with `filepath = "build/<name>.pdf"`, so an
///   almost-empty `[targets.student]` still produces a usable build.
/// - A target with **no `steps`** defaults to a single
///   [`Step::TypstCompile`] with `template = "exports/<name>.typ"` (the
///   framework's stock per-target template).
/// - A target with **no `covers`** key leaves [`TargetConfig::covers`] `None`,
///   meaning "coverage not declared". The consumer (`cph-check`) resolves that
///   to the full known-kind universe — see the field doc.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TargetConfig {
    /// Target name, e.g. `"student"` (the `[targets.<name>]` table key).
    pub name: String,
    /// The artifact this target produces. Defaults to a [`Artifact::SingleFile`]
    /// at `build/<name>.pdf` when the `artifact` field is absent.
    pub artifact: Artifact,
    /// The ordered build steps. Defaults to a single [`Step::TypstCompile`]
    /// with template `exports/<name>.typ` when no `[[steps]]` are given.
    pub steps: Vec<Step>,
    /// The **render-coverage declaration**: which element kinds this target
    /// renders. Realizes ADR-0011's "render
    /// coverage is a declaration, not a payload": the declaration keeps *which
    /// kinds a target renders* (used by the `renderIgnored` seed diagnostic),
    /// while the rendering "how" lives in the template/steps.
    ///
    /// `Some(kinds)` is the explicit manifest list (`covers = ["segment", …]`).
    /// `None` means the `covers` key was **absent**; the loader does not own the
    /// kind universe (it must not depend on `cph-schema`), so it records the
    /// absence and lets `cph-check` default it to all known kinds. An empty
    /// `Some(vec![])` is distinct: a target that explicitly covers nothing.
    pub covers: Option<Vec<String>>,
}

/// The artifact an export target produces (ADR-0009/0011).
///
/// **Pinned by ADR-0011** as an ADT with fields:
///
/// ```text
/// Artifact = singleFile (filepath) | fileTree (root, outputs)
/// ```
///
/// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product
/// is" — one file at some path vs. a tree of files under some root matching a
/// glob — is the non-obvious domain semantics that must be in the type, not
/// erased behind a bare tag. `SingleFile` is one bundled document (a 讲义 / 教案
/// PDF); `FileTree` is a tree of files (a third-party-platform archive). The
/// on-disk `artifact` inline table's `type` discriminator selects the variant:
/// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` →
/// [`Artifact::FileTree`].
///
/// As with `cph-diag`'s `Severity`, there is no CI gate enforcing ADR↔
/// implementation alignment (see the repo constitution) — it is maintained by
/// review, which is why the decision is documented here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Artifact {
    /// One bundled document landing at `filepath` (relative to the engineering
    /// root). ADR-0011 `singleFile`. The default artifact shape.
    SingleFile {
        /// Where the single product is written (relative to the engineering
        /// root), e.g. `build/student.pdf`.
        filepath: PathBuf,
    },
    /// A set of files under `root` matching the `outputs` glob. ADR-0011
    /// `fileTree`.
    FileTree {
        /// The output directory (relative to the engineering root).
        root: PathBuf,
        /// A **glob** describing which files this build produces, e.g.
        /// `**/*.{html,js,json}` — kept as a string (the contract pins the
        /// field, not a path/glob algebra).
        outputs: String,
    },
}

impl Artifact {
    /// The default artifact when `artifact` is omitted: a single file at
    /// `build/<name>.pdf`.
    fn default_for(name: &str) -> Artifact {
        Artifact::SingleFile {
            filepath: PathBuf::from(format!("build/{name}.pdf")),
        }
    }
}

/// One typed build step (ADR-0011).
///
/// **Pinned by ADR-0011** as an ADT:
///
/// ```text
/// Step = typstCompile (template) | shell (run) | assembleMarkdown (field)
/// ```
///
/// A step is a *typed* operation (extensible): `TypstCompile` compiles a
/// template file — typed, not a raw shell line, because the framework must wire
/// the manifest into it (`--input manifest=…`), which a bare `typst compile`
/// string cannot express. `Shell` is the escape hatch for steps that resist
/// declaration (ADR-0005's medium-only category (b): HTML interactive / npm
/// builds). `AssembleMarkdown` concatenates a per-element markdown content
/// field into a single-file markdown deliverable (ADR-0015: the slides outline
/// and 逐字稿 transcript surfaces, authored directly in markdown+KaTeX to
/// sidestep the typst→md conversion). The on-disk `[[steps]]` entry's `type`
/// discriminator selects the variant: `"typst-compile"` →
/// [`Step::TypstCompile`], `"shell"` → [`Step::Shell`], `"assemble-markdown"`
/// → [`Step::AssembleMarkdown`].
///
/// As with [`Artifact`], this alignment is maintained by review, not CI.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Step {
    /// Compile a template file (relative to the engineering root) into the
    /// artifact; the framework injects the manifest. ADR-0011
    /// `typstCompile`.
    TypstCompile {
        /// The template file to compile as main, e.g. `exports/student.typ`.
        template: PathBuf,
    },
    /// Run a shell command — the escape hatch. ADR-0011 `shell`.
    Shell {
        /// The command line to run.
        run: String,
    },
    /// Assemble a single-file markdown deliverable by concatenating each
    /// element's `field` markdown content file in `[[parts]]` order. ADR-0011
    /// `assembleMarkdown` (ADR-0015). Not a typst build — the
    /// framework owns the read/concatenate/write itself.
    AssembleMarkdown {
        /// The per-element markdown content field to assemble (e.g. `slides`,
        /// `transcript`).
        field: String,
    },
}

impl Step {
    /// The default step when no `[[steps]]` are given: compile the stock
    /// per-target template `exports/<name>.typ`.
    fn default_for(name: &str) -> Step {
        Step::TypstCompile {
            template: PathBuf::from(format!("exports/{name}.typ")),
        }
    }
}

/// `[project]` table.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Project {
    /// Stable project id.
    pub id: String,
    /// Folder / display name.
    pub name: String,
}

/// `[info]` table (passed through to render targets verbatim).
///
/// The *canonical* model whose
/// `authors` is always a list. The authoring-surface form (string-or-array
/// `author`) is the separate [`RawInfo`] / [`RawAuthor`], normalized into this
/// at the load boundary. No
/// CI gate enforces ADR↔implementation alignment (repo constitution); it is kept greppable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Info {
    /// Lesson title.
    pub title: String,
    /// Authors, in declared order. A lesson can have several (a teaching group),
    /// so this is a list, not a single name. Empty when `[info]` declares no
    /// `author`. The on-disk `author` accepts either a bare string (one author)
    /// or an array of strings (see [`RawAuthor`]); both load into this `Vec`.
    pub authors: Vec<String>,
}

/// One `[[parts]]` entry plus its loaded element descriptor.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Part {
    /// Declared kind from the manifest (one of the known kinds; ADR-0006).
    pub kind: String,
    /// Element folder path **as written in the manifest** (relative to root),
    /// kept verbatim for diagnostics / display.
    pub path: PathBuf,
    /// The element's self-description loaded from its `element.toml`.
    pub descriptor: ElementDescriptor,
}

/// An element's `element.toml`, parsed into kind + remaining scalar fields.
///
/// ADR-0008: `kind` is explicit in the descriptor (not inferred from the parent
/// directory), so a folder is self-describing.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ElementDescriptor {
    /// `kind` from `element.toml`. Must equal the part's `kind`; a mismatch is
    /// reported as a diagnostic (see [`load`]).
    pub kind: String,
    /// Element folder, absolute path.
    pub dir: PathBuf,
    /// The remaining `element.toml` keys (the scalar fields). Schema validation
    /// of these is WU-3's job, not this loader's.
    pub scalars: toml::Table,
}

// --- raw deserialization shapes (mirror the on-disk TOML) ---------------------

#[derive(Debug, Deserialize)]
struct RawManifest {
    project: Option<RawProject>,
    info: Option<RawInfo>,
    #[serde(default)]
    parts: Vec<RawPart>,
    #[serde(default)]
    targets: toml::Table,
}

#[derive(Debug, Deserialize)]
struct RawProject {
    id: String,
    name: String,
}

/// The authoring-surface `[info]`: the raw form that exists for
/// fill-in convenience, normalized into the canonical [`Info`] at the load
/// boundary. Not the form the rest of the model traffics in.
#[derive(Debug, Deserialize)]
struct RawInfo {
    title: String,
    author: Option<RawAuthor>,
}

/// On-disk `author`: either a single name (`author = "…"`) or a list
/// (`author = ["…", "…"]`). A fill-in convenience whose
/// string-or-array union lives **only** at the load boundary — [`RawAuthor::into_vec`]
/// folds it into the canonical [`Info::authors`] `Vec`, after which it never appears.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawAuthor {
    One(String),
    Many(Vec<String>),
}

impl RawAuthor {
    /// Flatten to the ordered author list: a single
    /// name becomes a one-element list; a list passes through verbatim.
    fn into_vec(self) -> Vec<String> {
        match self {
            RawAuthor::One(s) => vec![s],
            RawAuthor::Many(v) => v,
        }
    }
}

#[derive(Debug, Deserialize)]
struct RawPart {
    kind: String,
    path: String,
}

/// Load the engineering file at `root` into a [`Lesson`].
///
/// Returns `(Option<Lesson>, Vec<Diagnostic>)`:
/// - `Some(lesson)` whenever the manifest parses into a `Lesson` at all. The
///   structure is produced even when individual parts have problems, so the
///   WU-5 orchestrator gets **both** the partial lesson and the collected
///   diagnostics.
/// - `None` only on a hard failure where no `Lesson` can be built: the
///   `manifest.toml` is missing/unreadable, is not valid TOML, or lacks the
///   required `[project]` / `[info]` tables. In that case the diagnostics
///   describe the hard failure.
///
/// Collected (non-fatal) diagnostics include: a part path that does not exist
/// or escapes the root via `..`, a missing/malformed `element.toml`, and a
/// `part.kind != element.toml kind` mismatch.
///
/// ## Diagnostic-code mapping (judgment call, WU-1)
///
/// `cph-diag`'s code set is closed and has **no** dedicated "manifest
/// malformed" code. We deliberately do not invent one here. Until such a code
/// is added, manifest-level structural errors (bad TOML, missing `[project]` /
/// `[info]`) are mapped to the closest existing code, [`DiagCode::SchemaViolation`],
/// with a message making the real cause clear. A genuinely missing **part
/// path** uses [`DiagCode::PartPathMissing`] (its actual meaning); a missing /
/// unreadable / malformed `element.toml` also maps to `SchemaViolation`.
// TODO(cph-diag): consider adding a dedicated `ManifestMalformed` code so
// manifest-structure errors don't overload `SchemaViolation`.
pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
    let mut diags = Vec::new();

    let manifest_path = root.join("manifest.toml");

    let manifest_src = match std::fs::read_to_string(&manifest_path) {
        Ok(s) => s,
        Err(e) => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!("cannot read manifest.toml: {e}"),
                )
                .with_hint(format!(
                    "expected an engineering-file manifest at {}",
                    manifest_path.display()
                )),
            );
            return (None, diags);
        }
    };

    let raw: RawManifest = match toml::from_str(&manifest_src) {
        Ok(r) => r,
        Err(e) => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!("manifest.toml is not valid TOML: {e}"),
                )
                .with_hint("fix the TOML syntax in manifest.toml"),
            );
            return (None, diags);
        }
    };

    // `.cph-version` compatibility (ADR-0016). Decided at load time, before
    // any structural/schema/compile work. An incompatible version is an
    // error diagnostic (`CphVersionMismatch`); it does not halt loading, so
    // any other defects surface alongside it — but an error diagnostic alone
    // already makes the lesson illegal (ADR-0010), so `check`/`build` refuse.
    // A missing `.cph-version` is skipped for now (ADR-0016 OPEN).
    check_cph_version(root, &mut diags);

    // [project] and [info] are required to build a Lesson at all.
    let project = match raw.project {
        Some(p) => Project {
            id: p.id,
            name: p.name,
        },
        None => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    "manifest.toml is missing the required [project] table",
                )
                .with_hint("add a [project] table with `id` and `name`"),
            );
            return (None, diags);
        }
    };

    let info = match raw.info {
        Some(i) => Info {
            title: i.title,
            authors: i.author.map(RawAuthor::into_vec).unwrap_or_default(),
        },
        None => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    "manifest.toml is missing the required [info] table",
                )
                .with_hint("add an [info] table with at least `title`"),
            );
            return (None, diags);
        }
    };

    // Parse each [targets.<name>] table into a structured build config
    // (ADR-0009/0011). Order is the TOML document order, as before. Malformed
    // target config is non-fatal: it is reported and the target is kept with
    // defaults so loading continues.
    let targets: Vec<TargetConfig> = raw
        .targets
        .into_iter()
        .map(|(name, value)| parse_target(name, value, &mut diags))
        .collect();

    // Load each part. Problems are collected, not fatal: we still build the
    // Part (with a best-effort descriptor) so order/membership is observable.
    let mut parts = Vec::with_capacity(raw.parts.len());
    for raw_part in raw.parts {
        let rel_path = PathBuf::from(&raw_part.path);

        // Reject `..` traversal: a part path must stay within the root.
        if has_parent_traversal(&rel_path) {
            diags.push(
                Diagnostic::error(
                    DiagCode::PartPathMissing,
                    format!(
                        "part path '{}' escapes the engineering-file root via '..'",
                        raw_part.path
                    ),
                )
                .with_hint("part paths must be relative folders inside the engineering file"),
            );
            // Still record the part with an empty descriptor so order is kept.
            parts.push(Part {
                kind: raw_part.kind.clone(),
                path: rel_path.clone(),
                descriptor: ElementDescriptor {
                    kind: raw_part.kind,
                    dir: root.join(&rel_path),
                    scalars: toml::Table::new(),
                },
            });
            continue;
        }

        let dir = root.join(&rel_path);

        let descriptor = if !dir.is_dir() {
            diags.push(
                Diagnostic::error(
                    DiagCode::PartPathMissing,
                    format!("part folder '{}' does not exist", raw_part.path),
                )
                .with_hint(format!(
                    "create the folder '{}' or fix the `path` in manifest.toml",
                    raw_part.path
                )),
            );
            ElementDescriptor {
                kind: raw_part.kind.clone(),
                dir,
                scalars: toml::Table::new(),
            }
        } else {
            load_descriptor(&dir, &rel_path, &raw_part.kind, &mut diags)
        };

        parts.push(Part {
            kind: raw_part.kind,
            path: rel_path,
            descriptor,
        });
    }

    let lesson = Lesson {
        project,
        info,
        parts,
        targets,
        root: root.to_path_buf(),
    };
    (Some(lesson), diags)
}

/// The cph version the running CLI was built with (ADR-0016). Pulled from the
/// crate's `CARGO_PKG_VERSION` at compile time.
pub const CPH_VERSION: &str = env!("CARGO_PKG_VERSION");

/// Read the engineering file's `.cph-version` and, if present, push a
/// `CphVersionMismatch` error when it is not compatible with [`CPH_VERSION`]
/// (ADR-0016). A missing file is skipped for now (migration period; OPEN in
/// ADR-0016).
fn check_cph_version(root: &Path, diags: &mut Vec<Diagnostic>) {
    let path = root.join(".cph-version");
    let Ok(src) = std::fs::read_to_string(&path) else {
        return; // missing `.cph-version` — skipped (ADR-0016 OPEN).
    };
    let file_version = src.trim();
    if file_version.is_empty() {
        diags.push(
            Diagnostic::error(
                DiagCode::CphVersionMismatch,
                format!(".cph-version is empty; expected cph version {CPH_VERSION}"),
            )
            .with_hint(format!(
                "write the cph version this file targets into {} (currently {CPH_VERSION})",
                path.display()
            )),
        );
        return;
    }
    if !versions_compatible(file_version, CPH_VERSION) {
        diags.push(
            Diagnostic::error(
                DiagCode::CphVersionMismatch,
                format!(
                    ".cph-version declares {file_version}, but this cph is {CPH_VERSION} (incompatible)"
                ),
            )
            .with_hint(format!(
                "align them: set .cph-version to {CPH_VERSION}, or install cph {file_version}"
            )),
        );
    }
}

/// Whether an engineering file's declared cph version is compatible with the
/// running CLI's version (ADR-0016). **The rule is exact equality** — the
/// strictest choice, deliberately, while the format is young (0.0.x). This is
/// the single seam for future relaxation to a semver range: relaxing the
/// format does not change the diagnostic class, the spec, or the CLI — only
/// this predicate.
fn versions_compatible(file_version: &str, cli_version: &str) -> bool {
    file_version == cli_version
}

/// Load one element's `element.toml` into an [`ElementDescriptor`], collecting
/// diagnostics. On a missing/malformed `element.toml` the descriptor falls back
/// to the part's declared kind with empty scalars so the lesson stays buildable.
fn load_descriptor(
    dir: &Path,
    rel_path: &Path,
    part_kind: &str,
    diags: &mut Vec<Diagnostic>,
) -> ElementDescriptor {
    let element_toml = dir.join("element.toml");
    let rel_element_toml = rel_path.join("element.toml");

    let src = match std::fs::read_to_string(&element_toml) {
        Ok(s) => s,
        Err(e) => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!(
                        "cannot read element.toml for part '{}': {e}",
                        rel_path.display()
                    ),
                )
                .with_hint(format!(
                    "add an element.toml at {} with at least `kind = \"{part_kind}\"`",
                    rel_element_toml.display()
                )),
            );
            return ElementDescriptor {
                kind: part_kind.to_string(),
                dir: dir.to_path_buf(),
                scalars: toml::Table::new(),
            };
        }
    };

    let mut table: toml::Table = match toml::from_str(&src) {
        Ok(t) => t,
        Err(e) => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!(
                        "element.toml for part '{}' is not valid TOML: {e}",
                        rel_path.display()
                    ),
                )
                .with_hint("fix the TOML syntax in element.toml"),
            );
            return ElementDescriptor {
                kind: part_kind.to_string(),
                dir: dir.to_path_buf(),
                scalars: toml::Table::new(),
            };
        }
    };

    // `kind` is required and must be a string. Pull it out of the scalar map so
    // `scalars` ends up being "the remaining keys".
    let kind = match table.remove("kind") {
        Some(toml::Value::String(k)) => k,
        Some(_) => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!(
                        "element.toml for part '{}' has a non-string `kind`",
                        rel_path.display()
                    ),
                )
                .with_hint("`kind` must be a string, e.g. `kind = \"lemma\"`"),
            );
            part_kind.to_string()
        }
        None => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!(
                        "element.toml for part '{}' is missing the required `kind` key",
                        rel_path.display()
                    ),
                )
                .with_hint(format!("add `kind = \"{part_kind}\"` to element.toml")),
            );
            part_kind.to_string()
        }
    };

    // Cross-check: the descriptor's kind must equal the part's declared kind.
    if kind != part_kind {
        diags.push(
            Diagnostic::error(
                DiagCode::UnknownKind,
                format!(
                    "kind mismatch for part '{}': manifest says '{part_kind}', element.toml says '{kind}'",
                    rel_path.display()
                ),
            )
            .with_hint("make the manifest `kind` and the element.toml `kind` agree"),
        );
    }

    ElementDescriptor {
        kind,
        dir: dir.to_path_buf(),
        scalars: table,
    }
}

/// Parse one `[targets.<name>]` table into a [`TargetConfig`] (ADR-0009/0011),
/// collecting [`DiagCode::SchemaViolation`] diagnostics for malformed fields.
///
/// Malformed config is **non-fatal**: a bad/absent `artifact` falls back to the
/// default ([`Artifact::default_for`]); a bad/absent `steps` falls back to the
/// default single `typst-compile` step ([`Step::default_for`]) — all reported,
/// never dropping the target. An empty `[targets.<name>]` body is valid (all
/// defaults, no diagnostics).
fn parse_target(name: String, value: toml::Value, diags: &mut Vec<Diagnostic>) -> TargetConfig {
    // A target body must be a table; anything else (`student = 1`) is malformed.
    let mut table = match value {
        toml::Value::Table(t) => t,
        _ => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!("target '{name}' must be a table, e.g. `[targets.{name}]`"),
                )
                .with_hint("declare a target as a `[targets.<name>]` table"),
            );
            return TargetConfig {
                artifact: Artifact::default_for(&name),
                steps: vec![Step::default_for(&name)],
                covers: None,
                name,
            };
        }
    };

    let artifact = match table.remove("artifact") {
        None => Artifact::default_for(&name),
        Some(value) => parse_artifact(&name, value, diags),
    };

    let steps = match table.remove("steps") {
        None => vec![Step::default_for(&name)],
        Some(value) => parse_steps(&name, value, diags),
    };

    let covers = match table.remove("covers") {
        None => None,
        Some(value) => parse_covers(&name, value, diags),
    };

    TargetConfig {
        name,
        artifact,
        steps,
        covers,
    }
}

/// Parse a target's optional `covers` array into the render-coverage declaration
/// (ADR-0011). Each entry is a kind name (a string). A non-array `covers`, or any
/// non-string entry, is reported as a [`DiagCode::SchemaViolation`]; in that case
/// the declaration falls back to `None` ("not declared"), so `cph-check` defaults
/// it to the full known-kind universe rather than silently covering nothing.
///
/// A well-formed but empty `covers = []` yields `Some(vec![])`: a target that
/// explicitly renders no kind (every used kind then draws a `renderIgnored`
/// warning) — distinct from an absent key.
fn parse_covers(
    name: &str,
    value: toml::Value,
    diags: &mut Vec<Diagnostic>,
) -> Option<Vec<String>> {
    let items = match value {
        toml::Value::Array(items) => items,
        _ => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!(
                        "target '{name}' has a non-array `covers`; declare it as a list of \
                         kind names, e.g. `covers = [\"segment\", \"example\"]`"
                    ),
                )
                .with_hint("set `covers` to an array of kind-name strings, or omit it"),
            );
            return None;
        }
    };

    let mut kinds = Vec::with_capacity(items.len());
    for item in items {
        match item {
            toml::Value::String(s) => kinds.push(s),
            other => {
                diags.push(
                    Diagnostic::error(
                        DiagCode::SchemaViolation,
                        format!(
                            "target '{name}' has a non-string entry in `covers`: {}",
                            json_like_type(&other)
                        ),
                    )
                    .with_hint("each `covers` entry is a kind name (a string)"),
                );
                return None;
            }
        }
    }
    Some(kinds)
}

/// A short type label for a TOML value, for the `covers` non-string diagnostic.
fn json_like_type(v: &toml::Value) -> &'static str {
    match v {
        toml::Value::String(_) => "string",
        toml::Value::Integer(_) => "integer",
        toml::Value::Float(_) => "float",
        toml::Value::Boolean(_) => "boolean",
        toml::Value::Datetime(_) => "datetime",
        toml::Value::Array(_) => "array",
        toml::Value::Table(_) => "table",
    }
}

/// Parse the `artifact` inline table into an [`Artifact`]. The `type`
/// discriminator selects the variant and which fields are read. An unknown or
/// malformed `type`, a non-table value, or a missing required field is reported
/// as a [`DiagCode::SchemaViolation`]; in every error case a usable default
/// ([`Artifact::default_for`]) is returned so loading continues.
fn parse_artifact(name: &str, value: toml::Value, diags: &mut Vec<Diagnostic>) -> Artifact {
    let mut table = match value {
        toml::Value::Table(t) => t,
        _ => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!(
                        "target '{name}' has a non-table `artifact`; it must be an \
                         inline table like `{{ type = \"single-file\", filepath = \"…\" }}`"
                    ),
                )
                .with_hint("set `artifact` to an inline table with a `type` discriminator"),
            );
            return Artifact::default_for(name);
        }
    };

    let kind = match table.remove("type") {
        Some(toml::Value::String(s)) => s,
        Some(_) => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!("target '{name}' has a non-string `artifact.type`"),
                )
                .with_hint("set `artifact.type` to \"single-file\" or \"file-tree\""),
            );
            return Artifact::default_for(name);
        }
        None => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!("target '{name}' has an `artifact` with no `type` discriminator"),
                )
                .with_hint("add `type = \"single-file\"` (or `\"file-tree\"`) to `artifact`"),
            );
            return Artifact::default_for(name);
        }
    };

    match kind.as_str() {
        "single-file" => {
            let filepath = match take_string_field(&mut table, "filepath") {
                Some(s) => PathBuf::from(s),
                None => {
                    diags.push(
                        Diagnostic::error(
                            DiagCode::SchemaViolation,
                            format!(
                                "target '{name}' single-file `artifact` is missing a string \
                                 `filepath`"
                            ),
                        )
                        .with_hint("add `filepath = \"build/<name>.pdf\"` to the artifact"),
                    );
                    return Artifact::default_for(name);
                }
            };
            Artifact::SingleFile { filepath }
        }
        "file-tree" => {
            let root = take_string_field(&mut table, "root");
            let outputs = take_string_field(&mut table, "outputs");
            match (root, outputs) {
                (Some(root), Some(outputs)) => Artifact::FileTree {
                    root: PathBuf::from(root),
                    outputs,
                },
                (root, outputs) => {
                    let mut missing = Vec::new();
                    if root.is_none() {
                        missing.push("root");
                    }
                    if outputs.is_none() {
                        missing.push("outputs");
                    }
                    diags.push(
                        Diagnostic::error(
                            DiagCode::SchemaViolation,
                            format!(
                                "target '{name}' file-tree `artifact` is missing required \
                                 string field(s): {}",
                                missing.join(", ")
                            ),
                        )
                        .with_hint(
                            "a file-tree artifact needs `root = \"<dir>\"` and \
                             `outputs = \"<glob>\"`",
                        ),
                    );
                    Artifact::default_for(name)
                }
            }
        }
        other => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!(
                        "target '{name}' has an invalid `artifact.type` '{other}'; \
                         valid values are \"single-file\", \"file-tree\""
                    ),
                )
                .with_hint("set `artifact.type` to \"single-file\" or \"file-tree\""),
            );
            Artifact::default_for(name)
        }
    }
}

/// Parse the `steps` array-of-tables into an ordered `Vec<Step>`, preserving
/// declared order. A non-array `steps`, or any individual step with an unknown
/// or malformed `type` / missing required field, is reported as a
/// [`DiagCode::SchemaViolation`]; malformed individual steps are skipped while
/// well-formed ones are kept. If `steps` is present but yields no usable steps,
/// the default step ([`Step::default_for`]) is substituted so the build stays
/// runnable.
fn parse_steps(name: &str, value: toml::Value, diags: &mut Vec<Diagnostic>) -> Vec<Step> {
    let items = match value {
        toml::Value::Array(items) => items,
        _ => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!(
                        "target '{name}' has a non-array `steps`; declare steps as \
                         `[[targets.{name}.steps]]` entries"
                    ),
                )
                .with_hint("each step is a `[[targets.<name>.steps]]` table with a `type`"),
            );
            return vec![Step::default_for(name)];
        }
    };

    let mut steps = Vec::with_capacity(items.len());
    for item in items {
        if let Some(step) = parse_step(name, item, diags) {
            steps.push(step);
        }
    }

    if steps.is_empty() {
        // `steps` was present but produced nothing usable: keep the build
        // runnable with the default step (the malformed entries were reported).
        vec![Step::default_for(name)]
    } else {
        steps
    }
}

/// Parse one `[[steps]]` entry into a [`Step`]. The `type` discriminator selects
/// the variant. Returns `None` (after reporting a [`DiagCode::SchemaViolation`])
/// for a non-table entry, an unknown/malformed `type`, or a missing required
/// field.
fn parse_step(name: &str, value: toml::Value, diags: &mut Vec<Diagnostic>) -> Option<Step> {
    let mut table = match value {
        toml::Value::Table(t) => t,
        _ => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!("target '{name}' has a step that is not a table"),
                )
                .with_hint("each step is a `[[targets.<name>.steps]]` table with a `type`"),
            );
            return None;
        }
    };

    let kind = match table.remove("type") {
        Some(toml::Value::String(s)) => s,
        Some(_) => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!("target '{name}' has a step with a non-string `type`"),
                )
                .with_hint(
                    "set the step `type` to \"typst-compile\", \"shell\", or \"assemble-markdown\"",
                ),
            );
            return None;
        }
        None => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!("target '{name}' has a step with no `type` discriminator"),
                )
                .with_hint(
                    "add `type = \"typst-compile\"` (or `\"shell\"`, `\"assemble-markdown\"`) to the step",
                ),
            );
            return None;
        }
    };

    match kind.as_str() {
        "typst-compile" => match take_string_field(&mut table, "template") {
            Some(template) => Some(Step::TypstCompile {
                template: PathBuf::from(template),
            }),
            None => {
                diags.push(
                    Diagnostic::error(
                        DiagCode::SchemaViolation,
                        format!(
                            "target '{name}' typst-compile step is missing a string `template`"
                        ),
                    )
                    .with_hint("add `template = \"exports/<name>.typ\"` to the step"),
                );
                None
            }
        },
        "shell" => match take_string_field(&mut table, "run") {
            Some(run) => Some(Step::Shell { run }),
            None => {
                diags.push(
                    Diagnostic::error(
                        DiagCode::SchemaViolation,
                        format!("target '{name}' shell step is missing a string `run`"),
                    )
                    .with_hint("add `run = \"<command>\"` to the step"),
                );
                None
            }
        },
        "assemble-markdown" => match take_string_field(&mut table, "field") {
            Some(field) => Some(Step::AssembleMarkdown { field }),
            None => {
                diags.push(
                    Diagnostic::error(
                        DiagCode::SchemaViolation,
                        format!(
                            "target '{name}' assemble-markdown step is missing a string `field`"
                        ),
                    )
                    .with_hint("add `field = \"slides\"` (or `\"transcript\"`) to the step"),
                );
                None
            }
        },
        other => {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!(
                        "target '{name}' has a step with an invalid `type` '{other}'; \
                         valid values are \"typst-compile\", \"shell\", \"assemble-markdown\""
                    ),
                )
                .with_hint(
                    "set the step `type` to \"typst-compile\", \"shell\", or \"assemble-markdown\"",
                ),
            );
            None
        }
    }
}

/// Remove `key` from `table` and return it if it is a string; otherwise `None`
/// (a missing key and a non-string value are both treated as absent — the
/// caller reports the missing-field diagnostic with the right context).
fn take_string_field(table: &mut toml::Table, key: &str) -> Option<String> {
    match table.remove(key) {
        Some(toml::Value::String(s)) => Some(s),
        _ => None,
    }
}

/// True if `path` contains any `..` component (parent-dir traversal).
fn has_parent_traversal(path: &Path) -> bool {
    path.components().any(|c| matches!(c, Component::ParentDir))
}

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

    #[test]
    fn parent_traversal_detection() {
        assert!(has_parent_traversal(Path::new("../x")));
        assert!(has_parent_traversal(Path::new("a/../b")));
        assert!(!has_parent_traversal(Path::new("a/b/c")));
        assert!(!has_parent_traversal(Path::new("segments/intro")));
    }
}
