//! `project.yaml` writer (hand-rolled, ADR-0015 §3) and
//! reader (`serde_norway`, ADR-0015 §7).
//!
//! The schema YAML uses a small, fixed set of structures —
//! tables, columns, relationships — and the values it carries
//! are all known-safe (identifiers from the DSL, types from
//! the fixed `Type` enum, action names from `ReferentialAction`).
//! Hand-rolling the writer avoids pulling a YAML serializer
//! dep just for the write path; the read path uses
//! `serde_norway` because we need to handle whatever the user
//! (or a future migrator, or a hand-edit) puts in there.
//
// `pub(crate)` items in this private submodule are
// re-exported from `persistence::mod.rs`; that path is what
// the db worker uses. Clippy's `redundant_pub_crate` lint
// flags this pattern, but it's load-bearing here.
#![allow(clippy::redundant_pub_crate)]

use std::fmt::Write as _;

use serde::Deserialize;

use crate::dsl::action::ReferentialAction;
use crate::dsl::types::Type;
use crate::mode::Mode;

use super::{
    ColumnSchema, IndexSchema, RelationshipSchema, SchemaSnapshot, TableCheck, TableSchema,
};

/// Serialize a `SchemaSnapshot` to a `project.yaml` body.
#[must_use]
pub(super) fn serialize_schema(schema: &SchemaSnapshot) -> String {
    let mut out = String::new();
    let _ = writeln!(
        out,
        "version: {}",
        crate::persistence::migrations::CURRENT_SCHEMA_VERSION
    );
    let _ = writeln!(out, "project:");
    let _ = writeln!(out, "  created_at: {}", quote_if_needed(&schema.created_at));
    // ADR-0015 mode-restore amendment (issue #14): the input mode
    // lives alongside `created_at` as project-level metadata, not
    // schema. `rebuild` ignores it; restore-on-open reads it.
    let _ = writeln!(out, "  mode: {}", schema.mode.keyword());

    if schema.tables.is_empty() {
        let _ = writeln!(out, "tables: []");
    } else {
        let _ = writeln!(out, "tables:");
        for table in &schema.tables {
            write_table(&mut out, table);
        }
    }

    if schema.relationships.is_empty() {
        let _ = writeln!(out, "relationships: []");
    } else {
        let _ = writeln!(out, "relationships:");
        for rel in &schema.relationships {
            write_relationship(&mut out, rel);
        }
    }

    if schema.indexes.is_empty() {
        let _ = writeln!(out, "indexes: []");
    } else {
        let _ = writeln!(out, "indexes:");
        for index in &schema.indexes {
            write_index(&mut out, index);
        }
    }

    out
}

fn write_index(out: &mut String, index: &IndexSchema) {
    let _ = writeln!(out, "  - name: {}", quote_if_needed(&index.name));
    let _ = writeln!(out, "    table: {}", quote_if_needed(&index.table));
    write!(out, "    columns: [").unwrap();
    for (i, col) in index.columns.iter().enumerate() {
        if i > 0 {
            out.push_str(", ");
        }
        out.push_str(&quote_if_needed(col));
    }
    let _ = writeln!(out, "]");
    // Emit `unique` only when true (ADR-0035 §4d), matching the
    // column-`unique` convention — keeps pre-unique-index project files
    // byte-stable on a no-op round-trip.
    if index.unique {
        let _ = writeln!(out, "    unique: true");
    }
}

fn write_table(out: &mut String, table: &TableSchema) {
    let _ = writeln!(out, "  - name: {}", quote_if_needed(&table.name));
    write!(out, "    primary_key: [").unwrap();
    for (i, key) in table.primary_key.iter().enumerate() {
        if i > 0 {
            out.push_str(", ");
        }
        out.push_str(&quote_if_needed(key));
    }
    let _ = writeln!(out, "]");
    let _ = writeln!(out, "    columns:");
    for col in &table.columns {
        write_column(out, col);
    }
    // Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2) —
    // emitted only when present so unconstrained tables stay compact.
    if !table.unique_constraints.is_empty() {
        let _ = writeln!(out, "    unique_constraints:");
        for cols in &table.unique_constraints {
            write!(out, "      - [").unwrap();
            for (i, c) in cols.iter().enumerate() {
                if i > 0 {
                    out.push_str(", ");
                }
                out.push_str(&quote_if_needed(c));
            }
            let _ = writeln!(out, "]");
        }
    }
    // Table-level CHECK constraints as raw SQL text (ADR-0035 §4a.3) —
    // double-quoted (an expression like `a < b` is not a bare scalar)
    // and emitted only when present. An unnamed CHECK is a bare string
    // (back-compatible); a named CHECK (ADR-0035 §4g) is an `{expr,
    // name}` mapping so the name round-trips through a rebuild.
    if !table.check_constraints.is_empty() {
        let _ = writeln!(out, "    check_constraints:");
        for check in &table.check_constraints {
            match &check.name {
                None => {
                    let _ = writeln!(out, "      - {}", yaml_string(&check.expr));
                }
                Some(name) => {
                    let _ = writeln!(out, "      - expr: {}", yaml_string(&check.expr));
                    let _ = writeln!(out, "        name: {}", yaml_string(name));
                }
            }
        }
    }
}

/// Always render `s` as a double-quoted YAML string — used
/// for a column's `default` SQL literal, which must round-trip
/// as a string even when it looks numeric (ADR-0029).
fn yaml_string(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            _ => out.push(c),
        }
    }
    out.push('"');
    out
}

fn write_column(out: &mut String, col: &ColumnSchema) {
    let mut line = format!(
        "      - {{ name: {}, type: {}",
        quote_if_needed(&col.name),
        col.user_type.keyword(),
    );
    // ADR-0018 / ADR-0029 constraint flags — emitted only when
    // set, so an unconstrained column stays a compact two-field
    // entry and older readers stay forward-compatible.
    if col.unique {
        line.push_str(", unique: true");
    }
    if col.not_null {
        line.push_str(", not_null: true");
    }
    if let Some(default) = &col.default {
        line.push_str(", default: ");
        line.push_str(&yaml_string(default));
    }
    if let Some(check) = &col.check {
        line.push_str(", check: ");
        line.push_str(&yaml_string(check));
    }
    line.push_str(" }");
    let _ = writeln!(out, "{line}");
}

fn write_relationship(out: &mut String, rel: &RelationshipSchema) {
    let _ = writeln!(out, "  - name: {}", quote_if_needed(&rel.name));
    let _ = writeln!(
        out,
        "    parent: {{ table: {}, columns: [{}] }}",
        quote_if_needed(&rel.parent_table),
        write_col_list(&rel.parent_columns),
    );
    let _ = writeln!(
        out,
        "    child:  {{ table: {}, columns: [{}] }}",
        quote_if_needed(&rel.child_table),
        write_col_list(&rel.child_columns),
    );
    let _ = writeln!(out, "    on_delete: {}", action_keyword(rel.on_delete));
    let _ = writeln!(out, "    on_update: {}", action_keyword(rel.on_update));
}

/// Format a column list for an inline yaml flow sequence — `a, b`
/// (the caller wraps in `[…]`), each element quoted if needed.
/// Matches the `primary_key: [...]` / index `columns: [...]` house
/// style (ADR-0043 D5). One element for a single-column endpoint.
fn write_col_list(cols: &[String]) -> String {
    cols.iter()
        .map(|c| quote_if_needed(c))
        .collect::<Vec<_>>()
        .join(", ")
}

const fn action_keyword(action: ReferentialAction) -> &'static str {
    match action {
        ReferentialAction::NoAction => "no_action",
        ReferentialAction::Restrict => "restrict",
        ReferentialAction::SetNull => "set_null",
        ReferentialAction::Cascade => "cascade",
    }
}

/// Quote a string for safe inclusion as a YAML scalar.
///
/// We're conservative: anything not made of safe characters
/// (alphanumerics, `_`, `-`, `:` for ISO timestamps, `.`)
/// gets double-quoted with `"` and `\` escaped. Common
/// identifiers from the DSL (which restricts to alnum + `_`)
/// pass through unquoted, which keeps the YAML pleasantly
/// readable.
fn quote_if_needed(s: &str) -> String {
    if needs_quoting(s) {
        let mut out = String::with_capacity(s.len() + 2);
        out.push('"');
        for c in s.chars() {
            match c {
                '"' => out.push_str("\\\""),
                '\\' => out.push_str("\\\\"),
                '\n' => out.push_str("\\n"),
                _ => out.push(c),
            }
        }
        out.push('"');
        out
    } else {
        s.to_string()
    }
}

fn needs_quoting(s: &str) -> bool {
    if s.is_empty() {
        return true;
    }
    // YAML reserves several leading characters and the empty
    // string. Be defensive on anything outside the safe set.
    let first = s.chars().next().unwrap();
    if !is_safe_yaml_char(first) || first == '-' {
        return true;
    }
    // Scalar text that looks like a YAML keyword needs quoting
    // even if every character is safe.
    if matches!(
        s,
        "true" | "false" | "null" | "~" | "yes" | "no" | "on" | "off"
    ) {
        return true;
    }
    s.chars().any(|c| !is_safe_yaml_char(c))
}

const fn is_safe_yaml_char(c: char) -> bool {
    c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':')
}

/// Parse a `project.yaml` body into a `SchemaSnapshot`.
///
/// The wire types below mirror the format `serialize_schema`
/// emits. Anything outside that shape produces a structured
/// error — callers (the rebuild path) translate those into a
/// fatal banner per ADR-0015 §8.
pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
    let raw: RawProject =
        serde_norway::from_str(body).map_err(|e| YamlError::Syntax(e.to_string()))?;
    // The migration framework (ADR-0015) upgrades older project files to
    // `CURRENT_SCHEMA_VERSION` before `parse_schema` ever runs, so the
    // parser only accepts the current version (a stale version reaching
    // here means migration was skipped — a bug worth surfacing).
    if raw.version != crate::persistence::migrations::CURRENT_SCHEMA_VERSION {
        return Err(YamlError::UnsupportedVersion(raw.version));
    }
    let mut tables: Vec<TableSchema> = Vec::with_capacity(raw.tables.len());
    for t in raw.tables {
        let mut columns: Vec<ColumnSchema> = Vec::with_capacity(t.columns.len());
        for c in t.columns {
            let user_type = c
                .user_type
                .parse::<Type>()
                .map_err(|_| YamlError::UnknownType {
                    table: t.name.clone(),
                    column: c.name.clone(),
                    raw: c.user_type.clone(),
                })?;
            columns.push(ColumnSchema {
                name: c.name,
                user_type,
                unique: c.unique,
                not_null: c.not_null,
                default: c.default,
                check: c.check,
            });
        }
        tables.push(TableSchema {
            name: t.name,
            primary_key: t.primary_key,
            columns,
            unique_constraints: t.unique_constraints,
            check_constraints: t
                .check_constraints
                .into_iter()
                .map(TableCheck::from)
                .collect(),
        });
    }
    let mut relationships: Vec<RelationshipSchema> = Vec::with_capacity(raw.relationships.len());
    for r in raw.relationships {
        let on_delete = parse_action(&r.on_delete)
            .ok_or_else(|| YamlError::UnknownAction(r.on_delete.clone()))?;
        let on_update = parse_action(&r.on_update)
            .ok_or_else(|| YamlError::UnknownAction(r.on_update.clone()))?;
        relationships.push(RelationshipSchema {
            name: r.name,
            parent_table: r.parent.table,
            parent_columns: r.parent.columns,
            child_table: r.child.table,
            child_columns: r.child.columns,
            on_delete,
            on_update,
        });
    }
    let indexes: Vec<IndexSchema> = raw
        .indexes
        .into_iter()
        .map(|i| IndexSchema {
            name: i.name,
            table: i.table,
            columns: i.columns,
            unique: i.unique,
        })
        .collect();
    Ok(SchemaSnapshot {
        created_at: raw.project.created_at,
        mode: raw
            .project
            .mode
            .as_deref()
            .and_then(Mode::from_keyword)
            .unwrap_or_default(),
        tables,
        relationships,
        indexes,
    })
}

/// Read just the stored input mode from a `project.yaml` body,
/// for restore-on-open (ADR-0015 mode-restore amendment, issue
/// #14). Returns `None` when the file has no `mode:` field (a
/// pre-#14 project, or a hand-written one) — distinct from an
/// explicit `mode: simple` — so the caller can tell "no stored
/// preference" from a deliberate choice. An unrecognised value
/// is also `None` (fall back to the default rather than reject
/// the whole file over a UI hint). Tolerant of an otherwise
/// unparseable body for the same reason.
#[must_use]
pub(super) fn parse_stored_mode(body: &str) -> Option<Mode> {
    let raw: RawProject = serde_norway::from_str(body).ok()?;
    raw.project.mode.as_deref().and_then(Mode::from_keyword)
}

#[derive(Debug)]
pub(crate) enum YamlError {
    Syntax(String),
    UnsupportedVersion(u32),
    UnknownType {
        table: String,
        column: String,
        raw: String,
    },
    UnknownAction(String),
}

impl std::fmt::Display for YamlError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Syntax(msg) => f.write_str(&crate::t!("persistence.yaml.syntax", detail = msg,)),
            Self::UnsupportedVersion(v) => f.write_str(&crate::t!(
                "persistence.yaml.unsupported_version",
                version = v,
            )),
            Self::UnknownType { table, column, raw } => f.write_str(&crate::t!(
                "persistence.yaml.unknown_type",
                table = table,
                column = column,
                raw = raw,
            )),
            Self::UnknownAction(raw) => {
                f.write_str(&crate::t!("persistence.yaml.unknown_action", raw = raw,))
            }
        }
    }
}

impl std::error::Error for YamlError {}

fn parse_action(s: &str) -> Option<ReferentialAction> {
    match s {
        "no_action" => Some(ReferentialAction::NoAction),
        "restrict" => Some(ReferentialAction::Restrict),
        "set_null" => Some(ReferentialAction::SetNull),
        "cascade" => Some(ReferentialAction::Cascade),
        _ => None,
    }
}

#[derive(Deserialize)]
struct RawProject {
    version: u32,
    project: RawProjectMeta,
    #[serde(default)]
    tables: Vec<RawTable>,
    #[serde(default)]
    relationships: Vec<RawRelationship>,
    /// Optional: project files written before ADR-0025 carry no
    /// `indexes:` field and default to an empty list.
    #[serde(default)]
    indexes: Vec<RawIndex>,
}

#[derive(Deserialize)]
struct RawProjectMeta {
    created_at: String,
    /// Optional: pre-#14 project files carry no `mode:` field and
    /// default to the app's startup mode. Stored as a raw string
    /// so an unrecognised value degrades to the default rather
    /// than failing the parse (ADR-0015 mode-restore amendment).
    #[serde(default)]
    mode: Option<String>,
}

#[derive(Deserialize)]
struct RawTable {
    name: String,
    primary_key: Vec<String>,
    columns: Vec<RawColumn>,
    /// Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2).
    /// Optional on read — older project files omit it.
    #[serde(default)]
    unique_constraints: Vec<Vec<String>>,
    /// Table-level CHECK constraints (ADR-0035 §4a.3, named in §4g).
    /// Optional on read — older project files omit it. Each entry is a
    /// bare string (unnamed) or an `{expr, name}` mapping (named).
    #[serde(default)]
    check_constraints: Vec<RawTableCheck>,
}

/// A table-CHECK as read from `project.yaml`: a bare string (unnamed —
/// the pre-4g form, back-compatible) or an `{expr, name}` mapping (a
/// named CHECK, ADR-0035 §4g). `#[serde(untagged)]` tries the string
/// form first, then the mapping.
#[derive(Deserialize)]
#[serde(untagged)]
enum RawTableCheck {
    Bare(String),
    Named {
        expr: String,
        #[serde(default)]
        name: Option<String>,
    },
}

impl From<RawTableCheck> for TableCheck {
    fn from(raw: RawTableCheck) -> Self {
        match raw {
            RawTableCheck::Bare(expr) => Self { name: None, expr },
            RawTableCheck::Named { expr, name } => Self { name, expr },
        }
    }
}

#[derive(Deserialize)]
struct RawColumn {
    name: String,
    #[serde(rename = "type")]
    user_type: String,
    /// Optional flag introduced in ADR-0018 for single-column
    /// UNIQUE constraints. Older project files without this
    /// field default to `false`.
    #[serde(default)]
    unique: bool,
    /// `NOT NULL` flag (ADR-0029); absent in older files.
    #[serde(default)]
    not_null: bool,
    /// `DEFAULT` SQL literal (ADR-0029); absent in older files.
    #[serde(default)]
    default: Option<String>,
    /// `CHECK` SQL (ADR-0029); absent in older files.
    #[serde(default)]
    check: Option<String>,
}

#[derive(Deserialize)]
struct RawRelationship {
    name: String,
    parent: RawEndpoint,
    child: RawEndpoint,
    on_delete: String,
    on_update: String,
}

#[derive(Deserialize)]
struct RawEndpoint {
    table: String,
    /// FK endpoint column list (ADR-0043): `columns: [a, b]`, one
    /// element for a single-column endpoint — matching the
    /// `primary_key` / index `columns` house style.
    columns: Vec<String>,
}

#[derive(Deserialize)]
struct RawIndex {
    name: String,
    table: String,
    columns: Vec<String>,
    /// `UNIQUE` index flag (ADR-0035 §4d). Optional on read — project
    /// files written before unique indexes existed omit it and default
    /// to `false`.
    #[serde(default)]
    unique: bool,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::dsl::types::Type;

    fn snapshot() -> SchemaSnapshot {
        SchemaSnapshot {
            created_at: "2026-05-07T14:30:12Z".to_string(),
            mode: Mode::Simple,
            tables: vec![
                TableSchema {
                    name: "Customers".to_string(),
                    primary_key: vec!["id".to_string()],
                    columns: vec![
                        ColumnSchema {
                            name: "id".to_string(),
                            user_type: Type::Serial,
                            unique: false,
                            not_null: false,
                            default: None,
                            check: None,
                        },
                        ColumnSchema {
                            name: "Name".to_string(),
                            user_type: Type::Text,
                            unique: false,
                            not_null: false,
                            default: None,
                            check: None,
                        },
                    ],
                    unique_constraints: Vec::new(),
                    check_constraints: Vec::new(),
                },
                TableSchema {
                    name: "Orders".to_string(),
                    primary_key: vec!["id".to_string()],
                    columns: vec![
                        ColumnSchema {
                            name: "id".to_string(),
                            user_type: Type::Serial,
                            unique: false,
                            not_null: false,
                            default: None,
                            check: None,
                        },
                        ColumnSchema {
                            name: "CustId".to_string(),
                            user_type: Type::Int,
                            unique: false,
                            not_null: false,
                            default: None,
                            check: None,
                        },
                    ],
                    unique_constraints: Vec::new(),
                    check_constraints: Vec::new(),
                },
            ],
            relationships: vec![RelationshipSchema {
                name: "Customers_id_to_Orders_CustId".to_string(),
                parent_table: "Customers".to_string(),
                parent_columns: vec!["id".to_string()],
                child_table: "Orders".to_string(),
                child_columns: vec!["CustId".to_string()],
                on_delete: ReferentialAction::Cascade,
                on_update: ReferentialAction::NoAction,
            }],
            indexes: vec![IndexSchema {
                name: "Orders_CustId_idx".to_string(),
                table: "Orders".to_string(),
                columns: vec!["CustId".to_string()],
                unique: false,
            }],
        }
    }

    #[test]
    fn writes_expected_yaml_for_full_schema() {
        let body = serialize_schema(&snapshot());
        // Spot-check structural lines rather than asserting on
        // the whole blob — easier to read in failure output.
        assert!(body.contains("version: 2"));
        assert!(body.contains("created_at: 2026-05-07T14:30:12Z"));
        assert!(body.contains("- name: Customers"));
        assert!(body.contains("primary_key: [id]"));
        assert!(body.contains("{ name: id, type: serial }"));
        assert!(body.contains("{ name: Name, type: text }"));
        assert!(body.contains("- name: Customers_id_to_Orders_CustId"));
        assert!(body.contains("parent: { table: Customers, columns: [id] }"));
        assert!(body.contains("child:  { table: Orders, columns: [CustId] }"));
        assert!(body.contains("on_delete: cascade"));
        assert!(body.contains("on_update: no_action"));
        assert!(body.contains("- name: Orders_CustId_idx"));
        assert!(body.contains("table: Orders"));
        assert!(body.contains("columns: [CustId]"));
    }

    #[test]
    fn empty_lists_use_inline_brackets() {
        let body = serialize_schema(&SchemaSnapshot {
            created_at: "2026-05-07T14:30:12Z".to_string(),
            mode: Mode::Simple,
            tables: vec![],
            relationships: vec![],
            indexes: vec![],
        });
        assert!(body.contains("tables: []"));
        assert!(body.contains("relationships: []"));
        assert!(body.contains("indexes: []"));
    }

    #[test]
    fn quotes_yaml_keywords_used_as_identifiers() {
        let body = serialize_schema(&SchemaSnapshot {
            created_at: "2026-05-07T14:30:12Z".to_string(),
            mode: Mode::Simple,
            tables: vec![TableSchema {
                name: "true".to_string(), // reserved keyword
                primary_key: vec!["id".to_string()],
                columns: vec![ColumnSchema {
                    name: "yes".to_string(),
                    user_type: Type::Bool,
                    unique: false,
                    not_null: false,
                    default: None,
                    check: None,
                }],
                unique_constraints: Vec::new(),
                check_constraints: Vec::new(),
            }],
            relationships: vec![],
            indexes: vec![],
        });
        assert!(body.contains("- name: \"true\""));
        assert!(body.contains("{ name: \"yes\", type: bool }"));
    }

    #[test]
    fn quotes_strings_with_unsafe_characters() {
        assert_eq!(quote_if_needed("My Project"), "\"My Project\"");
        assert_eq!(quote_if_needed("with\"quote"), "\"with\\\"quote\"");
    }

    #[test]
    fn write_then_read_round_trips() {
        let original = snapshot();
        let body = serialize_schema(&original);
        let parsed = parse_schema(&body).expect("parse schema");
        assert_eq!(parsed, original);
    }

    #[test]
    fn unique_index_round_trips_through_yaml() {
        // ADR-0035 §4d: a UNIQUE index's uniqueness survives a serialize
        // → parse cycle. A plain index emits no `unique` line; a unique
        // index emits `unique: true`.
        let snap = SchemaSnapshot {
            created_at: "2026-05-25T00:00:00Z".to_string(),
            mode: Mode::Simple,
            tables: vec![TableSchema {
                name: "Customers".to_string(),
                primary_key: vec!["id".to_string()],
                columns: vec![
                    ColumnSchema {
                        name: "id".to_string(),
                        user_type: Type::Serial,
                        unique: false,
                        not_null: false,
                        default: None,
                        check: None,
                    },
                    ColumnSchema {
                        name: "Email".to_string(),
                        user_type: Type::Text,
                        unique: false,
                        not_null: false,
                        default: None,
                        check: None,
                    },
                ],
                unique_constraints: Vec::new(),
                check_constraints: Vec::new(),
            }],
            relationships: Vec::new(),
            indexes: vec![
                IndexSchema {
                    name: "Customers_Email_uidx".to_string(),
                    table: "Customers".to_string(),
                    columns: vec!["Email".to_string()],
                    unique: true,
                },
                IndexSchema {
                    name: "Customers_id_idx".to_string(),
                    table: "Customers".to_string(),
                    columns: vec!["id".to_string()],
                    unique: false,
                },
            ],
        };
        let body = serialize_schema(&snap);
        // The unique index emits the flag; the plain one does not.
        assert!(body.contains("unique: true"), "yaml:\n{body}");
        assert_eq!(
            body.matches("unique: true").count(),
            1,
            "only the unique index carries the flag:\n{body}"
        );
        let parsed = parse_schema(&body).expect("parse schema");
        assert_eq!(parsed, snap);
    }

    #[test]
    fn index_without_unique_field_defaults_to_false() {
        // Older project files (written before unique indexes) omit the
        // `unique` field; the `#[serde(default)]` makes it `false`.
        let body = "\
version: 2
project:
  created_at: 2026-05-25T00:00:00Z
tables:
  - name: Customers
    primary_key: [id]
    columns:
      - { name: id, type: serial }
relationships: []
indexes:
  - name: Customers_id_idx
    table: Customers
    columns: [id]
";
        let parsed = parse_schema(body).expect("parse schema");
        assert_eq!(parsed.indexes.len(), 1);
        assert!(!parsed.indexes[0].unique);
    }

    #[test]
    fn column_constraints_round_trip_through_yaml() {
        // NOT NULL / UNIQUE / DEFAULT survive a serialize →
        // parse cycle (ADR-0029 §7).
        let snap = SchemaSnapshot {
            created_at: "2026-05-19T00:00:00Z".to_string(),
            mode: Mode::Simple,
            tables: vec![TableSchema {
                name: "Books".to_string(),
                primary_key: vec!["isbn".to_string()],
                columns: vec![
                    ColumnSchema {
                        name: "isbn".to_string(),
                        user_type: Type::Text,
                        unique: false,
                        not_null: false,
                        default: None,
                        check: None,
                    },
                    ColumnSchema {
                        name: "title".to_string(),
                        user_type: Type::Text,
                        unique: true,
                        not_null: true,
                        default: Some("'untitled'".to_string()),
                        check: None,
                    },
                    ColumnSchema {
                        name: "stock".to_string(),
                        user_type: Type::Int,
                        unique: false,
                        not_null: false,
                        default: Some("0".to_string()),
                        check: Some("\"stock\" >= 0".to_string()),
                    },
                ],
                unique_constraints: Vec::new(),
                check_constraints: Vec::new(),
            }],
            relationships: vec![],
            indexes: vec![],
        };
        let body = serialize_schema(&snap);
        let parsed = parse_schema(&body).expect("parse schema");
        assert_eq!(parsed, snap, "constraints survive the yaml round-trip");
    }

    #[test]
    fn table_level_constraints_round_trip_through_yaml() {
        // Composite UNIQUE and table-level CHECK (raw SQL text) survive
        // a serialize → parse cycle in declaration order (ADR-0035
        // §4a.2 / §4a.3).
        let snap = SchemaSnapshot {
            created_at: "2026-05-25T00:00:00Z".to_string(),
            mode: Mode::Simple,
            tables: vec![TableSchema {
                name: "T".to_string(),
                primary_key: vec![],
                columns: vec![
                    ColumnSchema {
                        name: "a".to_string(),
                        user_type: Type::Int,
                        unique: false,
                        not_null: false,
                        default: None,
                        check: None,
                    },
                    ColumnSchema {
                        name: "b".to_string(),
                        user_type: Type::Int,
                        unique: false,
                        not_null: false,
                        default: None,
                        check: None,
                    },
                    ColumnSchema {
                        name: "c".to_string(),
                        user_type: Type::Int,
                        unique: false,
                        not_null: false,
                        default: None,
                        check: None,
                    },
                ],
                unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
                check_constraints: vec![TableCheck::unnamed("a < b"), TableCheck::unnamed("b < c")],
            }],
            relationships: vec![],
            indexes: vec![],
        };
        let body = serialize_schema(&snap);
        let parsed = parse_schema(&body).expect("parse schema");
        assert_eq!(
            parsed, snap,
            "table-level UNIQUE + CHECK survive the yaml round-trip in order"
        );
    }

    #[test]
    fn named_check_constraints_round_trip_through_yaml() {
        // ADR-0035 §4g: a *named* table-CHECK serializes to the `{expr,
        // name}` mapping form and round-trips, mixed with an unnamed one.
        let snap = SchemaSnapshot {
            created_at: "2026-05-25T00:00:00Z".to_string(),
            mode: Mode::Simple,
            tables: vec![TableSchema {
                name: "T".to_string(),
                primary_key: vec!["id".to_string()],
                columns: vec![
                    ColumnSchema {
                        name: "id".to_string(),
                        user_type: Type::Int,
                        unique: false,
                        not_null: false,
                        default: None,
                        check: None,
                    },
                    ColumnSchema {
                        name: "qty".to_string(),
                        user_type: Type::Int,
                        unique: false,
                        not_null: false,
                        default: None,
                        check: None,
                    },
                ],
                unique_constraints: vec![],
                check_constraints: vec![
                    TableCheck {
                        name: Some("qty_positive".to_string()),
                        expr: "qty >= 0".to_string(),
                    },
                    TableCheck::unnamed("qty < 1000"),
                ],
            }],
            relationships: vec![],
            indexes: vec![],
        };
        let body = serialize_schema(&snap);
        let parsed = parse_schema(&body).expect("parse schema");
        assert_eq!(
            parsed, snap,
            "named + unnamed table-CHECKs survive the yaml round-trip"
        );
    }

    #[test]
    fn old_format_bare_string_check_constraints_still_parse() {
        // Back-compat: a project file written before §4g (bare-string
        // check_constraints) parses with name = None.
        let body = "\
version: 2
project:
  created_at: \"2026-05-25T00:00:00Z\"
tables:
  - name: T
    primary_key: [id]
    columns:
      - { name: id, type: int }
      - { name: qty, type: int }
    check_constraints:
      - \"qty >= 0\"
relationships: []
indexes: []
";
        let parsed = parse_schema(body).expect("parse old-format schema");
        assert_eq!(
            parsed.tables[0].check_constraints,
            vec![TableCheck::unnamed("qty >= 0")],
            "a bare-string CHECK parses as an unnamed TableCheck"
        );
    }

    #[test]
    fn check_constraints_optional_on_read() {
        // A project file written before table-level CHECK existed (no
        // `check_constraints:` key) parses with an empty list.
        let body = "\
version: 2
project:
  created_at: 2026-05-25T00:00:00Z
tables:
  - name: T
    primary_key: [id]
    columns:
      - { name: id, type: int }
relationships: []
";
        let parsed = parse_schema(body).expect("parse");
        assert!(parsed.tables[0].check_constraints.is_empty());
    }

    #[test]
    fn parses_minimal_yaml_with_no_tables() {
        let body = "\
version: 2
project:
  created_at: 2026-05-07T14:30:12Z
tables: []
relationships: []
";
        let parsed = parse_schema(body).expect("parse minimal");
        assert_eq!(parsed.tables.len(), 0);
        assert_eq!(parsed.relationships.len(), 0);
        // A project file with no `indexes:` field (written
        // before ADR-0025) parses with an empty index list.
        assert_eq!(parsed.indexes.len(), 0);
        assert_eq!(parsed.created_at, "2026-05-07T14:30:12Z");
    }

    #[test]
    fn rejects_unknown_version() {
        let body = "version: 9\nproject:\n  created_at: x\ntables: []\nrelationships: []\n";
        match parse_schema(body) {
            Err(YamlError::UnsupportedVersion(9)) => {}
            other => panic!("expected UnsupportedVersion(9), got {other:?}"),
        }
    }

    #[test]
    fn rejects_unknown_column_type() {
        let body = "\
version: 2
project:
  created_at: x
tables:
  - name: T
    primary_key: [id]
    columns:
      - { name: id, type: bogus }
relationships: []
";
        match parse_schema(body) {
            Err(YamlError::UnknownType { raw, .. }) => assert_eq!(raw, "bogus"),
            other => panic!("expected UnknownType, got {other:?}"),
        }
    }

    #[test]
    fn rejects_unknown_action() {
        let body = "\
version: 2
project:
  created_at: x
tables: []
relationships:
  - name: R
    parent: { table: A, columns: [id] }
    child:  { table: B, columns: [aid] }
    on_delete: blow_up
    on_update: no_action
";
        match parse_schema(body) {
            Err(YamlError::UnknownAction(s)) => assert_eq!(s, "blow_up"),
            other => panic!("expected UnknownAction, got {other:?}"),
        }
    }

    #[test]
    fn preserves_compound_primary_key_order() {
        let body = serialize_schema(&SchemaSnapshot {
            created_at: "2026-05-07T14:30:12Z".to_string(),
            mode: Mode::Simple,
            tables: vec![TableSchema {
                name: "Items".to_string(),
                primary_key: vec!["a".to_string(), "b".to_string()],
                columns: vec![
                    ColumnSchema {
                        name: "a".to_string(),
                        user_type: Type::Int,
                        unique: false,
                        not_null: false,
                        default: None,
                        check: None,
                    },
                    ColumnSchema {
                        name: "b".to_string(),
                        user_type: Type::Int,
                        unique: false,
                        not_null: false,
                        default: None,
                        check: None,
                    },
                ],
                unique_constraints: Vec::new(),
                check_constraints: Vec::new(),
            }],
            relationships: vec![],
            indexes: vec![],
        });
        assert!(body.contains("primary_key: [a, b]"));
    }

    // ---- ADR-0015 mode-restore amendment (issue #14) ----

    #[test]
    fn mode_round_trips_through_serialize_and_parse() {
        for mode in [Mode::Simple, Mode::Advanced] {
            let snap = SchemaSnapshot {
                created_at: "2026-05-07T14:30:12Z".to_string(),
                mode,
                tables: vec![],
                relationships: vec![],
                indexes: vec![],
            };
            let body = serialize_schema(&snap);
            assert!(
                body.contains(&format!("mode: {}", mode.keyword())),
                "serialized body carries the mode keyword: {body}"
            );
            let parsed = parse_schema(&body).expect("round-trips");
            assert_eq!(parsed.mode, mode);
        }
    }

    #[test]
    fn parse_schema_defaults_mode_to_simple_when_field_absent() {
        // A pre-#14 project file carries no `mode:` field; it must
        // parse with the default mode, not fail.
        let body = "version: 2\nproject:\n  created_at: x\ntables: []\nrelationships: []\n";
        let parsed = parse_schema(body).expect("legacy file parses");
        assert_eq!(parsed.mode, Mode::Simple);
    }

    #[test]
    fn parse_stored_mode_distinguishes_absent_from_explicit() {
        // `None` (no stored preference) must be distinct from an
        // explicit `simple`, so restore-on-open precedence can tell
        // "fall back to default" from "the user chose simple".
        let absent = "version: 2\nproject:\n  created_at: x\ntables: []\n";
        assert_eq!(parse_stored_mode(absent), None);

        let explicit_simple = "version: 2\nproject:\n  created_at: x\n  mode: simple\ntables: []\n";
        assert_eq!(parse_stored_mode(explicit_simple), Some(Mode::Simple));

        let advanced = "version: 2\nproject:\n  created_at: x\n  mode: advanced\ntables: []\n";
        assert_eq!(parse_stored_mode(advanced), Some(Mode::Advanced));
    }

    #[test]
    fn parse_stored_mode_falls_back_to_none_on_unknown_value() {
        // An unrecognised mode keyword degrades to "no preference"
        // rather than rejecting the whole file over a UI hint.
        let body = "version: 2\nproject:\n  created_at: x\n  mode: expert\ntables: []\n";
        assert_eq!(parse_stored_mode(body), None);
    }
}
