//! `cph-schema` — kind JSON Schemas (ADR-0006) + structural validation.
//!
//! Owned by **WU-3**. The schema layer reads each kind's declarative JSON
//! Schema (built-in scalars plus the `content` extension type) and validates
//! element instance data against it, including which `content` `.typ` files
//! must exist.
//!
//! # The `content` extension convention
//!
//! ADR-0006 fixes that a kind's data schema is a declarative **JSON Schema**
//! (draft 2020-12 here) with one extension type — `content` — whose value is a
//! Typst source rather than inline data. JSON Schema has no native `content`
//! type, so this crate marks a content field with a **custom keyword**:
//!
//! ```json
//! { "type": "string", "x-cph-content": true }
//! ```
//!
//! The `x-cph-content: true` marker is the single convention this crate honors.
//! A field carrying it is treated as a **content field**: it does NOT appear in
//! `element.toml`; instead the validator checks that the sibling file
//! `<field>.typ` exists in the element's directory (ADR-0008). A required
//! content field whose `.typ` file is missing yields a
//! [`cph_diag::DiagCode::MissingContentFile`]; an *optional* content field
//! (one not in the schema's `required` list, e.g. a lemma's `proof`) may be
//! absent with no diagnostic. All other (non-`x-cph-content`) properties are
//! **scalar** fields that live in `element.toml` and are validated structurally.
//!
//! The four stdlib kind schemas live as real JSON artifacts under
//! `crates/cph-schema/schemas/{segment,example,lemma,sop}.json` and are
//! compiled in via `include_str!`, parsed once on first use.
//!
//! # Validation approach
//!
//! The MVP scalar surface is intentionally tiny (a single optional string on
//! `example`), so rather than pull in a full JSON Schema engine (and its
//! version churn), this crate uses `serde_json` to parse the schemas and a
//! small hand-rolled interpreter that understands exactly the keywords the
//! stdlib schemas use: `properties`, `required`, `type`, `enum`,
//! `additionalProperties`, plus the `x-cph-content` extension marker. This
//! keeps the schemas as declarative artifacts (ADR-0006 intent) while keeping
//! the validator dependency-light and fully under our control.

use std::collections::BTreeMap;
use std::sync::OnceLock;

use cph_diag::{DiagCode, Diagnostic};
use cph_model::ElementDescriptor;
use serde_json::Value as Json;

/// The custom JSON Schema keyword marking a field as a `content` (Typst-source)
/// field rather than a scalar. See the crate docs for the convention.
const CONTENT_MARKER: &str = "x-cph-content";

/// The known stdlib kinds, in a stable order.
const KNOWN_KINDS: &[&str] = &["segment", "example", "lemma", "sop"];

/// Raw schema JSON for each kind, embedded at compile time.
const SCHEMA_SOURCES: &[(&str, &str)] = &[
    ("segment", include_str!("../schemas/segment.json")),
    ("example", include_str!("../schemas/example.json")),
    ("lemma", include_str!("../schemas/lemma.json")),
    ("sop", include_str!("../schemas/sop.json")),
];

/// The scalar `type` a property declares (the subset the stdlib schemas use).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ScalarType {
    String,
    Number,
    Integer,
    Boolean,
}

impl ScalarType {
    fn from_json(s: &str) -> Option<Self> {
        match s {
            "string" => Some(ScalarType::String),
            "number" => Some(ScalarType::Number),
            "integer" => Some(ScalarType::Integer),
            "boolean" => Some(ScalarType::Boolean),
            _ => None,
        }
    }

    fn label(self) -> &'static str {
        match self {
            ScalarType::String => "string",
            ScalarType::Number => "number",
            ScalarType::Integer => "integer",
            ScalarType::Boolean => "boolean",
        }
    }

    /// Whether `value` (a scalar coming from `element.toml`, already converted
    /// to JSON) satisfies this declared type.
    fn matches(self, value: &Json) -> bool {
        match self {
            ScalarType::String => value.is_string(),
            ScalarType::Number => value.is_number(),
            ScalarType::Integer => value.is_i64() || value.is_u64(),
            ScalarType::Boolean => value.is_boolean(),
        }
    }
}

/// One scalar property of a kind (a field that lives in `element.toml`).
#[derive(Debug, Clone)]
struct ScalarField {
    name: String,
    /// Declared `type`, if any. `None` means "unconstrained" (we still accept
    /// the field but apply no type check).
    ty: Option<ScalarType>,
    /// Allowed values from a JSON Schema `enum`, as JSON values.
    allowed: Option<Vec<Json>>,
    required: bool,
}

/// One content property of a kind (a field whose value is a sibling content
/// file, marked with `x-cph-content`). The file extension is determined by the
/// marker's value: `true` (or `"typ"`) → `.typ`; any other string → that
/// extension (e.g. `"md"` → `.md`, ADR-0015 markdown content surfaces).
#[derive(Debug, Clone)]
struct ContentField {
    name: String,
    required: bool,
    /// The content file's extension without the dot (e.g. `"typ"`, `"md"`).
    ext: String,
}

/// A parsed kind schema: the content fields (which drive `.typ` existence
/// checks) and the scalar fields (validated against `element.toml`).
#[derive(Debug, Clone)]
pub struct KindSchema {
    kind: String,
    content_fields: Vec<ContentField>,
    scalar_fields: Vec<ScalarField>,
    /// Whether the schema forbids properties beyond those declared
    /// (`additionalProperties: false`).
    deny_additional: bool,
}

impl KindSchema {
    /// The kind name this schema describes (e.g. `"example"`).
    pub fn kind(&self) -> &str {
        &self.kind
    }

    /// The names of the `content` fields (those requiring a `<field>.typ`
    /// file), in schema order.
    pub fn content_field_names(&self) -> Vec<&str> {
        self.content_fields
            .iter()
            .map(|f| f.name.as_str())
            .collect()
    }

    /// The names of the scalar fields (those living in `element.toml`), in
    /// schema order.
    pub fn scalar_field_names(&self) -> Vec<&str> {
        self.scalar_fields.iter().map(|f| f.name.as_str()).collect()
    }

    /// Parse a kind schema from its raw JSON source. Panics on a malformed
    /// schema — schemas are compile-time artifacts owned by this crate, so a
    /// bad one is a build-time bug, not a runtime condition.
    fn parse(kind: &str, src: &str) -> Self {
        let root: Json = serde_json::from_str(src)
            .unwrap_or_else(|e| panic!("kind schema for '{kind}' is not valid JSON: {e}"));

        let deny_additional = root
            .get("additionalProperties")
            .and_then(Json::as_bool)
            .map(|b| !b)
            .unwrap_or(false);

        let required: Vec<String> = root
            .get("required")
            .and_then(Json::as_array)
            .map(|a| {
                a.iter()
                    .filter_map(|v| v.as_str().map(str::to_string))
                    .collect()
            })
            .unwrap_or_default();
        let is_required = |name: &str| required.iter().any(|r| r == name);

        let mut content_fields = Vec::new();
        let mut scalar_fields = Vec::new();

        let props = root
            .get("properties")
            .and_then(Json::as_object)
            .unwrap_or_else(|| panic!("kind schema for '{kind}' has no `properties` object"));

        for (name, prop) in props {
            // The `x-cph-content` marker selects a content field and fixes its
            // file extension. `true` (the legacy form) and the string `"typ"`
            // both mean a `.typ` file; any other string is taken as the
            // extension (e.g. `"md"` → `.md` for markdown content surfaces,
            // ADR-0015). Absent ⇒ a scalar field.
            let ext = match prop.get(CONTENT_MARKER) {
                Some(Json::Bool(true)) => Some("typ".to_string()),
                Some(Json::String(s)) => Some(s.clone()),
                _ => None,
            };

            if let Some(ext) = ext {
                content_fields.push(ContentField {
                    name: name.clone(),
                    required: is_required(name),
                    ext,
                });
            } else {
                let ty = prop
                    .get("type")
                    .and_then(Json::as_str)
                    .and_then(ScalarType::from_json);
                let allowed = prop
                    .get("enum")
                    .and_then(Json::as_array)
                    .map(|a| a.to_vec());
                scalar_fields.push(ScalarField {
                    name: name.clone(),
                    ty,
                    allowed,
                    required: is_required(name),
                });
            }
        }

        KindSchema {
            kind: kind.to_string(),
            content_fields,
            scalar_fields,
            deny_additional,
        }
    }
}

/// The lazily-built registry mapping kind name → parsed schema.
fn registry() -> &'static BTreeMap<&'static str, KindSchema> {
    static REGISTRY: OnceLock<BTreeMap<&'static str, KindSchema>> = OnceLock::new();
    REGISTRY.get_or_init(|| {
        SCHEMA_SOURCES
            .iter()
            .map(|(kind, src)| (*kind, KindSchema::parse(kind, src)))
            .collect()
    })
}

/// Look up the parsed [`KindSchema`] for `kind`, or `None` if it is unknown.
pub fn schema_for(kind: &str) -> Option<&'static KindSchema> {
    registry().get(kind)
}

/// The known stdlib kind names (`["segment", "example", "lemma", "sop"]`),
/// for use by the orchestrator (`cph-check`).
pub fn known_kinds() -> &'static [&'static str] {
    KNOWN_KINDS
}

/// Validate an element instance against its kind's schema.
///
/// Self-contained: looks up the schema by `desc.kind` and, if the kind is
/// unknown, returns a single [`cph_diag::DiagCode::UnknownKind`] diagnostic.
/// Otherwise it checks, in order:
///
/// 1. **Scalar fields** (`desc.scalars`, a `toml::Table`) against the
///    non-content part of the schema — required-ness, declared `type`, and
///    `enum`. Violations are [`cph_diag::DiagCode::SchemaViolation`]. Under an
///    `additionalProperties: false` schema, an unexpected scalar key is also a
///    `SchemaViolation`.
/// 2. **Content fields**: for each required content field, that the sibling
///    file `desc.dir.join("<field>.typ")` exists; if not, a
///    [`cph_diag::DiagCode::MissingContentFile`]. Optional content fields may be
///    absent with no diagnostic.
///
/// Every diagnostic carries a fix `hint`. `MissingContentFile` names the
/// expected path in its message; `span` is left `None` (there is no line/col
/// for a file that does not exist).
pub fn validate(desc: &ElementDescriptor) -> Vec<Diagnostic> {
    let Some(schema) = schema_for(&desc.kind) else {
        return vec![Diagnostic::error(
            DiagCode::UnknownKind,
            format!("unknown element kind '{}'", desc.kind),
        )
        .with_hint(format!("valid kinds are: {}", KNOWN_KINDS.join(", ")))];
    };

    let mut diags = Vec::new();
    validate_scalars(schema, desc, &mut diags);
    validate_content_files(schema, desc, &mut diags);
    diags
}

/// Validate the scalar fields in `desc.scalars` against the schema's scalar
/// part.
fn validate_scalars(schema: &KindSchema, desc: &ElementDescriptor, diags: &mut Vec<Diagnostic>) {
    let scalars = &desc.scalars;

    // Required scalar fields must be present.
    for field in &schema.scalar_fields {
        if field.required && !scalars.contains_key(&field.name) {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!(
                        "element of kind '{}' is missing required field '{}'",
                        schema.kind, field.name
                    ),
                )
                .with_hint(format!("add `{} = ...` to element.toml", field.name)),
            );
        }
    }

    // Each present scalar: type / enum checks, and (if known) reject unexpected
    // keys when additionalProperties is false. Content fields must never appear
    // in element.toml.
    for (key, value) in scalars {
        if let Some(field) = schema.content_fields.iter().find(|f| f.name == *key) {
            diags.push(
                Diagnostic::error(
                    DiagCode::SchemaViolation,
                    format!(
                        "field '{}' of kind '{}' is a content field and must not appear in element.toml",
                        key, schema.kind
                    ),
                )
                .with_hint(format!(
                    "remove '{key}' from element.toml; put its content in the sibling file '{key}.{}'",
                    field.ext
                )),
            );
            continue;
        }

        let Some(field) = schema.scalar_fields.iter().find(|f| f.name == *key) else {
            if schema.deny_additional {
                diags.push(
                    Diagnostic::error(
                        DiagCode::SchemaViolation,
                        format!("unexpected field '{}' for kind '{}'", key, schema.kind),
                    )
                    .with_hint(format!(
                        "remove '{key}' from element.toml (it is not part of the '{}' schema)",
                        schema.kind
                    )),
                );
            }
            continue;
        };

        let json = toml_to_json(value);

        if let Some(ty) = field.ty {
            if !ty.matches(&json) {
                diags.push(
                    Diagnostic::error(
                        DiagCode::SchemaViolation,
                        format!(
                            "field '{}' of kind '{}' must be a {}, got {}",
                            key,
                            schema.kind,
                            ty.label(),
                            json_type_label(&json)
                        ),
                    )
                    .with_hint(format!(
                        "set '{key}' to a {} value in element.toml",
                        ty.label()
                    )),
                );
                continue;
            }
        }

        if let Some(allowed) = &field.allowed {
            if !allowed.contains(&json) {
                let choices: Vec<String> = allowed.iter().map(render_json_choice).collect();
                diags.push(
                    Diagnostic::error(
                        DiagCode::SchemaViolation,
                        format!(
                            "field '{}' of kind '{}' has value {} which is not one of the allowed values",
                            key,
                            schema.kind,
                            render_json_choice(&json)
                        ),
                    )
                    .with_hint(format!("'{key}' must be one of: {}", choices.join(", "))),
                );
            }
        }
    }
}

/// For each required content field, check that its `<field>.<ext>` file exists
/// in the element directory (`.typ` for typst content, `.md` for markdown
/// content surfaces — ADR-0015).
fn validate_content_files(
    schema: &KindSchema,
    desc: &ElementDescriptor,
    diags: &mut Vec<Diagnostic>,
) {
    for field in &schema.content_fields {
        if !field.required {
            // Optional content (e.g. lemma's `proof`): absence is fine.
            continue;
        }
        let file_name = format!("{}.{}", field.name, field.ext);
        let path = desc.dir.join(&file_name);
        if !path.is_file() {
            diags.push(
                Diagnostic::error(
                    DiagCode::MissingContentFile,
                    format!(
                        "kind '{}' requires the content file '{}', which is missing",
                        schema.kind,
                        path.display()
                    ),
                )
                .with_hint(format!(
                    "create '{file_name}' in the element folder with the {} source for '{}'",
                    ext_label(&field.ext),
                    field.name
                )),
            );
        }
    }
}

/// A human label for a content-file extension, used in diagnostic hints.
fn ext_label(ext: &str) -> &'static str {
    match ext {
        "md" => "markdown",
        "typ" => "typst",
        _ => "content",
    }
}

/// Convert a `toml::Value` scalar into the equivalent `serde_json::Value` for
/// type checking. Composite TOML values (arrays / tables) are converted
/// structurally too, though the MVP stdlib schemas only use scalars.
fn toml_to_json(value: &toml::Value) -> Json {
    match value {
        toml::Value::String(s) => Json::String(s.clone()),
        toml::Value::Integer(i) => Json::from(*i),
        toml::Value::Float(f) => Json::from(*f),
        toml::Value::Boolean(b) => Json::Bool(*b),
        toml::Value::Datetime(dt) => Json::String(dt.to_string()),
        toml::Value::Array(arr) => Json::Array(arr.iter().map(toml_to_json).collect()),
        toml::Value::Table(tbl) => Json::Object(
            tbl.iter()
                .map(|(k, v)| (k.clone(), toml_to_json(v)))
                .collect(),
        ),
    }
}

/// A short label for a JSON value's runtime type, for diagnostic messages.
fn json_type_label(value: &Json) -> &'static str {
    match value {
        Json::Null => "null",
        Json::Bool(_) => "boolean",
        Json::Number(n) => {
            if n.is_i64() || n.is_u64() {
                "integer"
            } else {
                "number"
            }
        }
        Json::String(_) => "string",
        Json::Array(_) => "array",
        Json::Object(_) => "object",
    }
}

/// Render a JSON value for an enum-choice list (strings unquoted-ish, others as
/// JSON).
fn render_json_choice(value: &Json) -> String {
    match value {
        Json::String(s) => format!("\"{s}\""),
        other => other.to_string(),
    }
}

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

    #[test]
    fn registry_has_all_known_kinds() {
        for kind in known_kinds() {
            assert!(schema_for(kind).is_some(), "missing schema for {kind}");
        }
        assert!(schema_for("frob").is_none());
    }

    #[test]
    fn example_field_split() {
        let s = schema_for("example").unwrap();
        // `problem`/`solution` are typst content; `slides`/`transcript` are the
        // optional markdown content surfaces (ADR-0015); `source` is a scalar.
        // (serde_json's Map is a BTreeMap without the `preserve_order` feature,
        // so content field names come back alphabetically sorted.)
        assert_eq!(
            s.content_field_names(),
            vec!["problem", "slides", "solution", "transcript"]
        );
        assert_eq!(s.scalar_field_names(), vec!["source"]);
    }

    #[test]
    fn lemma_proof_is_optional_content() {
        let s = schema_for("lemma").unwrap();
        let proof = s.content_fields.iter().find(|f| f.name == "proof").unwrap();
        assert!(!proof.required);
        let stmt = s.content_fields.iter().find(|f| f.name == "stmt").unwrap();
        assert!(stmt.required);
    }

    #[test]
    fn markdown_content_fields_carry_md_ext() {
        // ADR-0015: `slides`/`transcript` are markdown content fields (`x-cph-content: "md"`),
        // distinct from the `.typ` content fields.
        let s = schema_for("segment").unwrap();
        let slides = s
            .content_fields
            .iter()
            .find(|f| f.name == "slides")
            .unwrap();
        assert_eq!(slides.ext, "md");
        assert!(!slides.required);
        let transcript = s
            .content_fields
            .iter()
            .find(|f| f.name == "transcript")
            .unwrap();
        assert_eq!(transcript.ext, "md");
        assert!(!transcript.required);
        // Legacy typst content still resolves to `.typ`.
        let textbook = s
            .content_fields
            .iter()
            .find(|f| f.name == "textbook")
            .unwrap();
        assert_eq!(textbook.ext, "typ");
        assert!(textbook.required);
    }
}
