//! Per-command persistence to `project.yaml`, `data/*.csv`,
//! and `history.log` (ADR-0015 §3–§6).
//!
//! Iteration 2 wiring: every successful user command, after
//! its SQLite mutations are staged but before the transaction
//! commits, asks `Persistence` to write the affected text
//! targets atomically (write-temp + fsync + rename). The
//! commit-db-last ordering (ADR-0015 §6) is enforced in
//! `db.rs`; this module owns the file-format details and the
//! atomic-write primitive.
//!
//! Failure semantics: any write or rename failure produces a
//! `PersistenceError`. The caller (the db worker) is
//! responsible for translating that into a fatal error and
//! letting the SQLite tx roll back.

use std::cell::Cell;
use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};

use tracing::debug;

use crate::dsl::action::ReferentialAction;
use crate::dsl::types::Type;
use crate::mode::Mode;
use crate::project::{DATA_DIR, HISTORY_LOG, PROJECT_YAML};

// Submodules are private; the few items the db worker needs
// during rebuild (ADR-0015 §7) are re-exported below.
mod csv_io;
mod history;
pub mod migrations;
mod yaml;

pub(crate) use csv_io::{decode_cell, parse_csv};
pub(crate) use yaml::parse_schema;

/// Current UTC time as an ISO-8601 `Z` string — the same shape
/// `history.log` records (ADR-0015 §5). Exposed crate-wide so the
/// undo snapshot ring (ADR-0006) timestamps entries identically.
#[must_use]
pub(crate) fn utc_iso8601_now() -> String {
    history::utc_iso8601_now()
}

/// Owns persistence to a single project on disk. Cheap to
/// move; the db worker holds one instance for its lifetime.
///
/// Carries the **current input mode** (ADR-0015 mode-restore
/// amendment, issue #14). Mode is live UI state, not schema, so
/// it is not stored in the database — instead the worker holds
/// the current value here and stamps it into `project.yaml` on
/// every write, so the file always reflects the mode the user is
/// actually in. Interior mutability (`Cell`) lets the worker
/// update it through the `&self` write path; the worker thread
/// owns the single instance, so no synchronisation is needed.
#[derive(Debug, Clone)]
pub struct Persistence {
    project_path: PathBuf,
    current_mode: Cell<Mode>,
}

#[derive(Debug)]
pub enum PersistenceError {
    Io {
        operation: &'static str,
        path: PathBuf,
        source: std::io::Error,
    },
    Encode {
        kind: &'static str,
        path: PathBuf,
        message: String,
    },
}

impl std::fmt::Display for PersistenceError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Io {
                operation,
                path,
                source,
            } => f.write_str(&crate::t!(
                "persistence.io",
                operation = operation,
                path = path.display(),
                source = source,
            )),
            Self::Encode {
                kind,
                path,
                message,
            } => f.write_str(&crate::t!(
                "persistence.encode",
                kind = kind,
                path = path.display(),
                message = message,
            )),
        }
    }
}

impl std::error::Error for PersistenceError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Io { source, .. } => Some(source),
            Self::Encode { .. } => None,
        }
    }
}

impl PersistenceError {
    /// Path the failure was associated with.
    #[must_use]
    pub fn path(&self) -> &Path {
        match self {
            Self::Io { path, .. } | Self::Encode { path, .. } => path,
        }
    }

    /// Short label for the kind of operation that failed,
    /// suitable for the fatal banner.
    #[must_use]
    pub const fn operation(&self) -> &'static str {
        match self {
            Self::Io { operation, .. } => operation,
            Self::Encode { .. } => "encode",
        }
    }
}

/// Snapshot of the full schema as it is to be written to
/// `project.yaml`.
///
/// Read from the database after the in-flight mutation has
/// staged its changes (within the same SQLite tx) so the YAML
/// reflects the post-mutation state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchemaSnapshot {
    pub created_at: String,
    /// The input mode recorded in `project.yaml` (ADR-0015
    /// mode-restore amendment, issue #14). On **read** this is
    /// the project's stored mode (defaulting to `Simple` for
    /// pre-#14 files with no `mode:` field). On **write** the
    /// persister stamps the live `Persistence::current_mode`
    /// here before serialising, so the file always reflects the
    /// mode the user is actually in.
    pub mode: Mode,
    pub tables: Vec<TableSchema>,
    pub relationships: Vec<RelationshipSchema>,
    /// Indexes across all tables (ADR-0025). Carried as a flat
    /// list mirroring `relationships`; each entry names its
    /// table. Empty for project files written before indexes
    /// existed — the YAML field is optional on read.
    pub indexes: Vec<IndexSchema>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableSchema {
    pub name: String,
    pub primary_key: Vec<String>,
    pub columns: Vec<ColumnSchema>,
    /// Composite (multi-column) `UNIQUE` constraints (ADR-0035
    /// §4a.2). Single-column UNIQUE is carried on the column's
    /// `ColumnSchema::unique` flag instead. Empty for project files
    /// written before composite UNIQUE existed — the YAML field is
    /// optional on read.
    pub unique_constraints: Vec<Vec<String>>,
    /// Table-level `CHECK (<expr>)` constraints, in declaration
    /// order, as raw SQL text with an optional name (ADR-0035 §4a.3,
    /// named in §4g). The engine reports no CHECK constraints, so these
    /// are the source of truth (held in
    /// `__rdbms_playground_table_checks`) and echoed verbatim into the
    /// rebuilt DDL. Empty for project files written before table-level
    /// CHECK existed — the YAML field is optional on read.
    pub check_constraints: Vec<TableCheck>,
}

/// A table-level `CHECK` constraint with an optional name (ADR-0035 §4g).
///
/// The name is `Some` only for a `CONSTRAINT <name> CHECK (…)` added via
/// `ALTER TABLE` (the source of `DROP CONSTRAINT <name>`); a `CREATE
/// TABLE` table-CHECK and any pre-4g project file are unnamed (`None`).
/// The YAML carries a bare string for the unnamed form (back-compatible)
/// and an `{expr, name}` mapping for the named form.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableCheck {
    pub name: Option<String>,
    pub expr: String,
}

impl TableCheck {
    /// An unnamed table-CHECK (the `CREATE TABLE` / pre-4g form).
    #[must_use]
    pub fn unnamed(expr: impl Into<String>) -> Self {
        Self {
            name: None,
            expr: expr.into(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColumnSchema {
    pub name: String,
    pub user_type: Type,
    /// Whether this column carries a single-column UNIQUE
    /// constraint (ADR-0018 §4). Stored explicitly in the
    /// project YAML so that a `serial → int` round-trip
    /// (which leaves UNIQUE in place) is preserved across a
    /// save/load cycle. Defaults to `false` when missing in
    /// older project files.
    pub unique: bool,
    /// `NOT NULL` constraint (ADR-0029). Defaults to `false`
    /// when missing in older project files.
    pub not_null: bool,
    /// `DEFAULT` expression as a SQL literal (ADR-0029) — the
    /// form SQLite reports and `schema_to_ddl` echoes verbatim.
    /// `None` when the column has no default.
    pub default: Option<String>,
    /// `CHECK` constraint in compiled-SQL form (ADR-0029 §7),
    /// echoed verbatim into the rebuilt DDL. `None` when the
    /// column has no check.
    pub check: Option<String>,
}

/// One index as recorded in `project.yaml` (ADR-0025).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexSchema {
    pub name: String,
    pub table: String,
    /// The indexed columns, in index order.
    pub columns: Vec<String>,
    /// Whether this is a `UNIQUE` index (ADR-0035 §4d — advanced-mode
    /// `CREATE UNIQUE INDEX`). The engine reports it via
    /// `pragma_index_list`'s `unique` column, so it is read back rather
    /// than stored in any `__rdbms_*` table; it is carried here so it
    /// round-trips through `project.yaml` and survives `rebuild`.
    /// Defaults to `false` when missing in older project files (the YAML
    /// field is optional on read); `version` stays `1`.
    pub unique: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelationshipSchema {
    pub name: String,
    pub parent_table: String,
    /// Parent PK column(s); one element for single-column, ordered
    /// list for a compound-PK FK (ADR-0043). Paired positionally
    /// with `child_columns`.
    pub parent_columns: Vec<String>,
    pub child_table: String,
    /// Child column(s), positionally paired with `parent_columns`.
    pub child_columns: Vec<String>,
    pub on_delete: ReferentialAction,
    pub on_update: ReferentialAction,
}

/// Snapshot of one table's full row data, for writing
/// `data/<table>.csv`. The column order matches the table's
/// declaration order; the row tuples are aligned to it.
#[derive(Debug, Clone, PartialEq)]
pub struct TableSnapshot {
    pub name: String,
    pub columns: Vec<ColumnSchema>,
    pub rows: Vec<Vec<CellValue>>,
}

/// A scalar cell value, in the small ADT understood by the
/// CSV encoder.
///
/// `Null` and `Text("")` are distinct. `Eq` is intentionally
/// NOT derived because `Real(f64)` does not satisfy it (NaN);
/// use `PartialEq` for comparison.
#[derive(Debug, Clone, PartialEq)]
pub enum CellValue {
    Null,
    Integer(i64),
    Real(f64),
    Text(String),
}

impl Persistence {
    #[must_use]
    pub const fn new(project_path: PathBuf) -> Self {
        Self {
            project_path,
            current_mode: Cell::new(Mode::Simple),
        }
    }

    /// Builder: set the initial input mode this handle stamps into
    /// `project.yaml`. Used at boot / project-switch once the
    /// mode to restore has been resolved (ADR-0015 mode-restore
    /// amendment, issue #14).
    #[must_use]
    pub fn with_mode(self, mode: Mode) -> Self {
        self.current_mode.set(mode);
        self
    }

    /// The input mode this handle currently stamps into
    /// `project.yaml` writes.
    #[must_use]
    pub const fn current_mode(&self) -> Mode {
        self.current_mode.get()
    }

    /// Update the current input mode. The next `project.yaml`
    /// write records it. Called by the worker when the user
    /// changes mode mid-session (the `mode` command).
    pub fn set_mode(&self, mode: Mode) {
        self.current_mode.set(mode);
    }

    /// Read the mode recorded in an existing `project.yaml`, for
    /// restore-on-open (issue #14). Best-effort: a missing file,
    /// a parse failure, or an absent `mode:` field all yield
    /// `None` so the caller falls back to the default. A pre-#14
    /// project (no `mode:` field) parses with the default mode,
    /// which we report as `None` to keep "no stored preference"
    /// distinct from an explicit `simple`.
    #[must_use]
    pub fn read_stored_mode(project_path: &Path) -> Option<Mode> {
        let body = fs::read_to_string(project_path.join(PROJECT_YAML)).ok()?;
        yaml::parse_stored_mode(&body)
    }

    /// Project root directory. Used in tests and diagnostics.
    #[must_use]
    pub fn project_path(&self) -> &Path {
        &self.project_path
    }

    /// Write `project.yaml` from a full schema snapshot.
    /// Atomic: writes to `project.yaml.tmp`, fsyncs, then
    /// renames over the destination.
    pub fn write_schema(&self, schema: &SchemaSnapshot) -> Result<(), PersistenceError> {
        let body = yaml::serialize_schema(schema);
        debug!(bytes = body.len(), "persist: write project.yaml (atomic)");
        atomic_write(&self.project_path.join(PROJECT_YAML), body.as_bytes())
    }

    /// Write `data/<table>.csv` from a table snapshot. Atomic
    /// per file. Creates the `data/` directory if missing
    /// (tolerant of fresh projects).
    ///
    /// **Empty tables produce no CSV.** A header-only file
    /// would carry no information beyond what `project.yaml`
    /// already records, so an empty snapshot is treated
    /// identically to "drop this table's data file": the CSV
    /// is removed if it exists, no file is created if it
    /// doesn't. This keeps the rule "data lives in CSV; no
    /// data, no CSV" consistent and avoids surprising users
    /// with files they didn't ask for.
    pub fn write_table_data(&self, table: &TableSnapshot) -> Result<(), PersistenceError> {
        if table.rows.is_empty() {
            debug!(table = %table.name, "persist: table empty -> removing CSV (no data, no CSV)");
            return self.delete_table_data(&table.name);
        }
        debug!(table = %table.name, rows = table.rows.len(), "persist: write data/<table>.csv (atomic)");
        let data_dir = self.project_path.join(DATA_DIR);
        fs::create_dir_all(&data_dir).map_err(|source| PersistenceError::Io {
            operation: "create",
            path: data_dir.clone(),
            source,
        })?;
        let body = csv_io::serialize_table(table).map_err(|message| PersistenceError::Encode {
            kind: "CSV",
            path: data_dir.join(format!("{}.csv", table.name)),
            message,
        })?;
        atomic_write(&data_dir.join(format!("{}.csv", table.name)), &body)
    }

    /// Remove `data/<table>.csv` if present. Used when a
    /// table is dropped so stale CSVs don't linger.
    pub fn delete_table_data(&self, table_name: &str) -> Result<(), PersistenceError> {
        let path = self
            .project_path
            .join(DATA_DIR)
            .join(format!("{table_name}.csv"));
        match fs::remove_file(&path) {
            Ok(()) => Ok(()),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(source) => Err(PersistenceError::Io {
                operation: "delete",
                path,
                source,
            }),
        }
    }

    /// Append one successful-command record to `history.log`. `advanced`
    /// (ADR-0052) tags the record `ok:adv` when the command was submitted
    /// in an advanced effective mode, so hydration can reconstruct its
    /// `:`-prefixed form for reuse in simple mode.
    pub fn append_history(
        &self,
        command_text: &str,
        advanced: bool,
    ) -> Result<(), PersistenceError> {
        let path = self.project_path.join(HISTORY_LOG);
        let status = history::status_token(history::STATUS_OK, advanced);
        let line =
            history::format_record_with_status(command_text, history::utc_iso8601_now(), &status);
        debug!(
            len = command_text.len(),
            advanced, "persist: append ok record to history.log"
        );
        history::append(&path, &line)
    }

    /// Append a failed-command record to `history.log`, tagged
    /// `err` (ADR-0034 §1). Used by the runtime's error path so a
    /// command that failed to parse or to execute is still
    /// recallable across sessions (it never reaches the worker's
    /// transactional `ok` journal). Best-effort at the call site:
    /// a failure to record a failure must never escalate a user
    /// error into a fatal (ADR-0034 §4).
    pub fn append_history_failure(
        &self,
        command_text: &str,
        advanced: bool,
    ) -> Result<(), PersistenceError> {
        let path = self.project_path.join(HISTORY_LOG);
        let status = history::status_token(history::STATUS_ERR, advanced);
        let line =
            history::format_record_with_status(command_text, history::utc_iso8601_now(), &status);
        debug!(
            len = command_text.len(),
            advanced, "persist: append err record to history.log"
        );
        history::append(&path, &line)
    }

    /// Read the most-recent `max_n` sources out of
    /// `history.log` for input-history hydration on project
    /// open (ADR-0015 §12). Returned in chronological order
    /// (oldest first). A missing file is `Ok(Vec::new())`.
    pub fn read_recent_history(&self, max_n: usize) -> Result<Vec<String>, PersistenceError> {
        let path = self.project_path.join(HISTORY_LOG);
        history::read_recent_sources(&path, max_n)
    }
}

/// How `run_replay` should treat one already-trimmed,
/// non-blank, non-`#` line (ADR-0034 §3).
pub(crate) enum ReplayLine {
    /// Run this command text — either a journal `ok` record's
    /// extracted source, or a bare command verbatim.
    Run(String),
    /// A journal record whose status is not `ok` — skip it
    /// silently (a skipped failure is not a replay failure).
    Skip,
}

/// Classify one replay input line (ADR-0034 §3). A journal
/// record (`<ts>|<status>|<source>`) runs its source only when
/// `ok` and is skipped otherwise; any other line is a bare
/// command run verbatim. Detection is by the leading
/// timestamp+status prefix, so a bare command that merely
/// contains `|` (e.g. `select 'a|b' from t`) is run as-is.
pub(crate) fn classify_replay_line(line: &str) -> ReplayLine {
    match history::parse_journal_record(line) {
        Some(rec) if rec.status_is_ok => ReplayLine::Run(rec.source),
        Some(_) => ReplayLine::Skip,
        None => ReplayLine::Run(line.to_string()),
    }
}

/// Write `body` to `path` atomically via temp file + fsync +
/// rename. The temp file is named `<final>.tmp` in the same
/// directory so the rename stays on the same filesystem.
fn atomic_write(path: &Path, body: &[u8]) -> Result<(), PersistenceError> {
    let tmp_path = path.with_extension(extension_with_tmp(path));
    {
        let mut tmp = fs::File::create(&tmp_path).map_err(|source| PersistenceError::Io {
            operation: "create",
            path: tmp_path.clone(),
            source,
        })?;
        tmp.write_all(body).map_err(|source| PersistenceError::Io {
            operation: "write",
            path: tmp_path.clone(),
            source,
        })?;
        tmp.sync_all().map_err(|source| PersistenceError::Io {
            operation: "fsync",
            path: tmp_path.clone(),
            source,
        })?;
    }
    fs::rename(&tmp_path, path).map_err(|source| PersistenceError::Io {
        operation: "rename",
        path: path.to_path_buf(),
        source,
    })?;
    Ok(())
}

/// Build the `.tmp` extension for a path.
///
/// If the path already has an extension (`project.yaml`), the
/// tmp variant is `project.yaml.tmp`. If the path has no
/// extension, the extension becomes plain `tmp`.
fn extension_with_tmp(path: &Path) -> String {
    path.extension().map_or_else(
        || "tmp".to_string(),
        |ext| format!("{}.tmp", ext.to_string_lossy()),
    )
}

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

    fn tempdir() -> tempfile::TempDir {
        tempfile::tempdir().expect("create tempdir")
    }

    #[test]
    fn extension_with_tmp_appends_to_existing_extension() {
        assert_eq!(
            extension_with_tmp(Path::new("a/b/project.yaml")),
            "yaml.tmp"
        );
        assert_eq!(
            extension_with_tmp(Path::new("a/b/Customers.csv")),
            "csv.tmp"
        );
        assert_eq!(extension_with_tmp(Path::new("a/b/lockfile")), "tmp");
    }

    #[test]
    fn atomic_write_roundtrips() {
        let dir = tempdir();
        let target = dir.path().join("file.txt");
        atomic_write(&target, b"hello\n").unwrap();
        assert_eq!(fs::read_to_string(&target).unwrap(), "hello\n");

        // Calling again replaces atomically — no .tmp left behind.
        atomic_write(&target, b"world\n").unwrap();
        assert_eq!(fs::read_to_string(&target).unwrap(), "world\n");
        assert!(!target.with_extension("txt.tmp").exists());
    }

    #[test]
    fn write_schema_writes_yaml() {
        let dir = tempdir();
        let p = Persistence::new(dir.path().to_path_buf());
        let schema = SchemaSnapshot {
            created_at: "2026-05-07T14:30:12Z".to_string(),
            mode: Mode::Simple,
            tables: vec![],
            relationships: vec![],
            indexes: vec![],
        };
        p.write_schema(&schema).unwrap();
        let body = fs::read_to_string(dir.path().join(PROJECT_YAML)).unwrap();
        assert!(body.contains("version: 2"));
        assert!(body.contains("created_at:"));
    }

    #[test]
    fn write_and_delete_table_data() {
        let dir = tempdir();
        let p = Persistence::new(dir.path().to_path_buf());
        let table = TableSnapshot {
            name: "Customers".to_string(),
            columns: vec![ColumnSchema {
                name: "Name".to_string(),
                user_type: Type::Text,
                unique: false,
                not_null: false,
                default: None,
                check: None,
            }],
            rows: vec![vec![CellValue::Text("Alice".to_string())]],
        };
        p.write_table_data(&table).unwrap();
        let csv_path = dir.path().join(DATA_DIR).join("Customers.csv");
        assert!(csv_path.exists());
        let body = fs::read_to_string(&csv_path).unwrap();
        assert!(body.contains("Name"));
        assert!(body.contains("Alice"));

        p.delete_table_data("Customers").unwrap();
        assert!(!csv_path.exists());

        // Idempotent on a missing file.
        p.delete_table_data("Customers").unwrap();
    }

    #[test]
    fn append_history_creates_and_appends() {
        let dir = tempdir();
        let p = Persistence::new(dir.path().to_path_buf());
        p.append_history("create table Foo with pk id(serial)", false)
            .unwrap();
        p.append_history("insert into Foo (1)", false).unwrap();
        let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap();
        let lines: Vec<&str> = body.trim_end().lines().collect();
        assert_eq!(lines.len(), 2);
        assert!(lines[0].ends_with("|ok|create table Foo with pk id(serial)"));
        assert!(lines[1].ends_with("|ok|insert into Foo (1)"));
    }

    #[test]
    fn read_stored_mode_round_trips_a_written_project_yaml() {
        // ADR-0015 mode-restore amendment (issue #14): a mode
        // written into project.yaml reads back via read_stored_mode.
        let dir = tempdir();
        let p = Persistence::new(dir.path().to_path_buf());
        let schema = SchemaSnapshot {
            created_at: "2026-05-31T00:00:00Z".to_string(),
            mode: Mode::Advanced,
            tables: vec![],
            relationships: vec![],
            indexes: vec![],
        };
        p.write_schema(&schema).unwrap();
        assert_eq!(
            Persistence::read_stored_mode(dir.path()),
            Some(Mode::Advanced),
        );
    }

    #[test]
    fn read_stored_mode_is_none_for_a_missing_project_yaml() {
        let dir = tempdir();
        assert_eq!(Persistence::read_stored_mode(dir.path()), None);
    }
}
