//! Append-only `history.log` writer (ADR-0015 §5).
//!
//! Format: one record per line, three pipe-separated fields:
//!
//! ```text
//! 2026-05-07T14:30:12Z|ok|create table Customers with pk id(serial)
//! ```
//!
//! Status is always `ok` in v1; failed commands are not
//! recorded. The status field is kept in the line shape so
//! future use cases can carry additional values without a
//! format break.
//!
//! Newlines inside the command (which do not yet appear, but
//! will when multi-line input I1 lands) are escaped to a
//! literal `\n`.

use std::fs::OpenOptions;
use std::io::Write as _;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use super::PersistenceError;

/// Journal-record status tokens (ADR-0034 §1). Kept as named
/// constants so the writer and the readers (hydration + replay)
/// cannot drift on the spelling.
pub(super) const STATUS_OK: &str = "ok";
pub(super) const STATUS_ERR: &str = "err";

/// The optional status suffix marking an advanced-mode submission
/// (ADR-0052, issue #30): `ok:adv` / `err:adv`. Recorded so that
/// hydration can reconstruct the `:`-prefixed runnable form of an
/// advanced command, making advanced history reusable in simple mode.
pub(super) const ADV_SUFFIX: &str = "adv";

/// Build the status token for a `base` (`ok`/`err`) and submission mode.
pub(super) fn status_token(base: &str, advanced: bool) -> String {
    if advanced {
        format!("{base}:{ADV_SUFFIX}")
    } else {
        base.to_string()
    }
}

/// Parse a status token into `(is_ok, advanced)` (ADR-0052). The base
/// (`ok` ⇒ replayable, anything else ⇒ skip) precedes an optional
/// `:adv` mode suffix. An unknown base degrades to `(false, _)`, so
/// replay skips it rather than mis-running it.
pub(super) fn parse_status(status: &str) -> (bool, bool) {
    let (base, suffix) = status.split_once(':').unwrap_or((status, ""));
    (base == STATUS_OK, suffix == ADV_SUFFIX)
}

/// Format a successful-command record. Pure; no I/O. (Simple-mode
/// convenience used by tests; production threads the mode through
/// [`format_record_with_status`] + [`status_token`], so this is
/// test-only since ADR-0052.)
#[cfg(test)]
pub(super) fn format_record(command_text: &str, timestamp_iso: String) -> String {
    format_record_with_status(command_text, timestamp_iso, STATUS_OK)
}

/// Format a record with an explicit status token (ADR-0034 §1).
/// Pure; no I/O.
pub(super) fn format_record_with_status(
    command_text: &str,
    timestamp_iso: String,
    status: &str,
) -> String {
    let escaped = escape_command(command_text);
    format!("{timestamp_iso}|{status}|{escaped}\n")
}

/// Read the most-recent `max_n` user-issued command sources
/// from `history_log_path`, in chronological order
/// (oldest-first within the returned slice).
///
/// This is the I2-persist hydration helper (ADR-0015 §12):
/// on project open, the runtime seeds the in-memory navigable
/// history from this list so Up/Down recall picks up where
/// the user left off in the previous session.
///
/// Lines that do not match the `<ts>|<status>|<source>` shape
/// are silently skipped — they are likely corruption or a
/// future format extension; either way, refusing to seed at
/// all because of a single bad line would be a worse UX than
/// quietly rejoining the user's history.
///
/// A missing file returns an empty `Vec`; other IO errors
/// are surfaced via `PersistenceError` so the caller can
/// decide how to handle them. In practice the runtime treats
/// hydration failures as non-fatal — the user just gets an
/// empty history and a tracing warning.
pub(super) fn read_recent_sources(
    history_log_path: &std::path::Path,
    max_n: usize,
) -> Result<Vec<String>, super::PersistenceError> {
    use std::io::ErrorKind;
    let body = match std::fs::read_to_string(history_log_path) {
        Ok(b) => b,
        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
        Err(source) => {
            return Err(super::PersistenceError::Io {
                operation: "read",
                path: history_log_path.to_path_buf(),
                source,
            });
        }
    };
    let mut sources: Vec<String> = body.lines().filter_map(parse_record_source).collect();
    if sources.len() > max_n {
        let skip = sources.len() - max_n;
        sources.drain(0..skip);
    }
    Ok(sources)
}

/// Parse one `<ts>|<status>|<source>` line and return the
/// unescaped source. Returns `None` for malformed lines.
fn parse_record_source(line: &str) -> Option<String> {
    // Format: timestamp|status|source-with-pipes-allowed
    // We split into at most 3 parts so a `|` inside source
    // (which append() does NOT escape — pipes are valid SQL
    // characters) is preserved.
    let mut parts = line.splitn(3, '|');
    let _ts = parts.next()?;
    let status = parts.next()?;
    let source = parts.next()?;
    let (_is_ok, advanced) = parse_status(status);
    let command = unescape_command(source);
    // ADR-0052: an advanced record is hydrated in its `:`-prefixed
    // simple-mode runnable form, so cross-session recall matches the
    // in-session ring (and recall strips the `:` again in advanced
    // mode). A simple record hydrates bare. Old `ok`/`err` logs have no
    // `:adv` suffix → read as simple, unchanged.
    Some(if advanced {
        format!(": {command}")
    } else {
        command
    })
}

/// A parsed journal record (ADR-0034 §3). `source` is already
/// unescaped.
pub(super) struct JournalRecord {
    pub status_is_ok: bool,
    pub source: String,
}

/// Classify `line` as a journal record or a bare command
/// (ADR-0034 §3). Returns `Some(JournalRecord)` only when the
/// line begins with a valid `<iso8601-timestamp>|<status>|`
/// prefix — so a bare command containing `|` (e.g.
/// `select 'a|b' from t`) is `None` (treated as bare by the
/// caller) because it does not start with a timestamp. A valid
/// timestamp prefix with a non-`ok` (or unrecognised) status is
/// still a journal record, reported with `status_is_ok = false`
/// so replay skips it rather than mis-running it as a command.
pub(super) fn parse_journal_record(line: &str) -> Option<JournalRecord> {
    let mut parts = line.splitn(3, '|');
    let ts = parts.next()?;
    let status = parts.next()?;
    let source = parts.next()?;
    if !looks_like_iso8601(ts) {
        return None;
    }
    // ADR-0052: the status may carry a `:adv` mode suffix; replayability
    // keys off the base token only (`ok` / `ok:adv` are both ok).
    let (status_is_ok, _advanced) = parse_status(status);
    Some(JournalRecord {
        status_is_ok,
        source: unescape_command(source),
    })
}

/// True when `s` is exactly an `YYYY-MM-DDTHH:MM:SSZ` timestamp
/// — the shape `utc_iso8601_now` emits. Used to distinguish a
/// journal record's leading field from a bare command that
/// merely contains `|`.
fn looks_like_iso8601(s: &str) -> bool {
    let b = s.as_bytes();
    if b.len() != 20 {
        return false;
    }
    let digit = |i: usize| b[i].is_ascii_digit();
    digit(0)
        && digit(1)
        && digit(2)
        && digit(3)
        && b[4] == b'-'
        && digit(5)
        && digit(6)
        && b[7] == b'-'
        && digit(8)
        && digit(9)
        && b[10] == b'T'
        && digit(11)
        && digit(12)
        && b[13] == b':'
        && digit(14)
        && digit(15)
        && b[16] == b':'
        && digit(17)
        && digit(18)
        && b[19] == b'Z'
}

fn unescape_command(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut chars = s.chars();
    while let Some(c) = chars.next() {
        if c != '\\' {
            out.push(c);
            continue;
        }
        match chars.next() {
            Some('n') => out.push('\n'),
            Some('r') => out.push('\r'),
            Some('\\') => out.push('\\'),
            // Preserve unknown escapes literally so a future
            // extension to `escape_command` doesn't corrupt
            // entries written before that extension.
            Some(other) => {
                out.push('\\');
                out.push(other);
            }
            None => out.push('\\'),
        }
    }
    out
}

/// Append `line` (which already ends in `\n`) to the file at
/// `path`. Creates the file if it doesn't exist. fsyncs after
/// the write so a power-cut doesn't lose the latest entry.
pub(super) fn append(path: &Path, line: &str) -> Result<(), PersistenceError> {
    let mut f = OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .map_err(|source| PersistenceError::Io {
            operation: "open",
            path: path.to_path_buf(),
            source,
        })?;
    f.write_all(line.as_bytes())
        .map_err(|source| PersistenceError::Io {
            operation: "write",
            path: path.to_path_buf(),
            source,
        })?;
    f.sync_all().map_err(|source| PersistenceError::Io {
        operation: "fsync",
        path: path.to_path_buf(),
        source,
    })?;
    Ok(())
}

fn escape_command(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\\' => out.push_str("\\\\"),
            _ => out.push(c),
        }
    }
    out
}

/// Current UTC time as ISO-8601 with second precision and a
/// `Z` suffix.
pub(super) fn utc_iso8601_now() -> String {
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0);
    iso8601_from_unix_secs(secs)
}

fn iso8601_from_unix_secs(secs: i64) -> String {
    let day_secs = secs.rem_euclid(86_400);
    let h = day_secs / 3600;
    let m = (day_secs % 3600) / 60;
    let s = day_secs % 60;
    let (y, mo, d) = ymd_from_unix_secs(secs);
    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}

const fn ymd_from_unix_secs(secs: i64) -> (u32, u32, u32) {
    let days = secs.div_euclid(86_400);
    let z = days + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = (z - era * 146_097) as u64;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    (y as u32, m as u32, d as u32)
}

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

    #[test]
    fn record_format() {
        let line = format_record(
            "create table Customers with pk id(serial)",
            "2026-05-07T14:30:12Z".to_string(),
        );
        assert_eq!(
            line,
            "2026-05-07T14:30:12Z|ok|create table Customers with pk id(serial)\n",
        );
    }

    #[test]
    fn newlines_in_command_are_escaped() {
        let line = format_record("foo\nbar", "T".to_string());
        assert_eq!(line, "T|ok|foo\\nbar\n");
    }

    // ---- ADR-0034 §3 — journal-record detection for replay ----

    #[test]
    fn parse_journal_record_ok_extracts_unescaped_source() {
        let rec = parse_journal_record("2026-05-24T10:00:00Z|ok|create table T with pk id(int)")
            .expect("valid ok journal record");
        assert!(rec.status_is_ok);
        assert_eq!(rec.source, "create table T with pk id(int)");
    }

    #[test]
    fn parse_journal_record_err_is_record_but_not_ok() {
        // A valid timestamp + `err` status is a journal record (so
        // replay treats it as skippable), reported `status_is_ok =
        // false`.
        let rec = parse_journal_record("2026-05-24T10:00:02Z|err|insert into T values (1)")
            .expect("valid err journal record");
        assert!(!rec.status_is_ok);
    }

    #[test]
    fn parse_journal_record_unknown_status_is_record_but_not_ok() {
        // A valid timestamp + an unrecognised status is still a
        // journal record (ADR-0034 §1 "readers ignore values they
        // do not recognise"); replay skips it rather than running it.
        let rec = parse_journal_record("2026-05-24T10:00:02Z|frobnicate|whatever")
            .expect("valid-ts record with unknown status");
        assert!(!rec.status_is_ok);
    }

    #[test]
    fn parse_journal_record_rejects_bare_command_with_pipe() {
        // A bare command that merely contains `|` must NOT be read
        // as a journal record — its first field is not a timestamp.
        assert!(parse_journal_record("select 'a|b' from t").is_none());
        assert!(parse_journal_record("show data Orders").is_none());
    }

    #[test]
    fn parse_journal_record_rejects_timestamp_ish_but_invalid_prefix() {
        // Boundary: looks vaguely date-y but isn't the exact
        // `YYYY-MM-DDTHH:MM:SSZ` shape → bare command.
        assert!(parse_journal_record("2026-5-24|ok|x").is_none());
        assert!(parse_journal_record("2026-05-24 10:00:00|ok|x").is_none());
        assert!(parse_journal_record("notatimestamp|ok|x").is_none());
    }

    #[test]
    fn parse_journal_record_preserves_pipe_in_source() {
        // `|` is not escaped by the writer (it's a valid SQL char);
        // `splitn(3, '|')` keeps everything after the second `|`.
        let rec =
            parse_journal_record("2026-05-24T10:00:00Z|ok|select 'a|b' from t").expect("ok record");
        assert_eq!(rec.source, "select 'a|b' from t");
    }

    #[test]
    fn parse_journal_record_round_trips_a_written_record() {
        // What `format_record` writes, `parse_journal_record` reads
        // back to the original command (escape→unescape lossless for
        // the awkward cases).
        let cmd = "update T set v = 'x\\y' where id = 1";
        let line = format_record(cmd, "2026-05-24T10:00:00Z".to_string());
        let rec = parse_journal_record(line.trim_end()).expect("round-trip record");
        assert!(rec.status_is_ok);
        assert_eq!(rec.source, cmd);
    }

    #[test]
    fn backslash_is_escaped() {
        let line = format_record("a\\b", "T".to_string());
        assert_eq!(line, "T|ok|a\\\\b\n");
    }

    #[test]
    fn iso8601_format_is_well_formed() {
        let s = utc_iso8601_now();
        // YYYY-MM-DDTHH:MM:SSZ has length 20.
        assert_eq!(s.len(), 20);
        assert!(s.ends_with('Z'));
        assert_eq!(&s[4..5], "-");
        assert_eq!(&s[10..11], "T");
    }

    #[test]
    fn iso8601_known_seconds() {
        assert_eq!(iso8601_from_unix_secs(0), "1970-01-01T00:00:00Z");
        assert_eq!(
            iso8601_from_unix_secs(1_778_112_000),
            "2026-05-07T00:00:00Z"
        );
    }

    #[test]
    fn read_recent_sources_returns_empty_when_file_missing() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("nope.log");
        let got = read_recent_sources(&path, 10).unwrap();
        assert!(got.is_empty());
    }

    #[test]
    fn read_recent_sources_unescapes_newlines_and_backslashes() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("history.log");
        let line1 = format_record("a\nb", "T1".to_string());
        let line2 = format_record("c\\d", "T2".to_string());
        std::fs::write(&path, format!("{line1}{line2}")).unwrap();
        let got = read_recent_sources(&path, 10).unwrap();
        assert_eq!(got, vec!["a\nb".to_string(), "c\\d".to_string()]);
    }

    #[test]
    fn read_recent_sources_caps_at_max_n_keeping_most_recent() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("history.log");
        let body: String = (0..10)
            .map(|i| format_record(&format!("cmd{i}"), format!("T{i}")))
            .collect();
        std::fs::write(&path, body).unwrap();
        let got = read_recent_sources(&path, 3).unwrap();
        assert_eq!(
            got,
            vec!["cmd7".to_string(), "cmd8".to_string(), "cmd9".to_string()]
        );
    }

    #[test]
    fn read_recent_sources_skips_malformed_lines() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("history.log");
        // Two valid lines and one garbage line in the middle.
        let body = format!(
            "{}{}{}",
            format_record("good1", "T1".to_string()),
            "this is not a record\n",
            format_record("good2", "T2".to_string()),
        );
        std::fs::write(&path, body).unwrap();
        let got = read_recent_sources(&path, 10).unwrap();
        assert_eq!(got, vec!["good1".to_string(), "good2".to_string()]);
    }

    #[test]
    fn read_recent_sources_preserves_pipes_inside_source() {
        // The append-side does NOT escape `|`, so pipes inside
        // the source must round-trip through the parser. This
        // is what splitn(3) on `|` is supposed to handle.
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("history.log");
        std::fs::write(&path, "T1|ok|select 'a|b' from t\n").unwrap();
        let got = read_recent_sources(&path, 10).unwrap();
        assert_eq!(got, vec!["select 'a|b' from t".to_string()]);
    }

    #[test]
    fn append_creates_and_grows_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("history.log");

        append(&path, "first|ok|a\n").unwrap();
        append(&path, "second|ok|b\n").unwrap();
        let body = fs::read_to_string(&path).unwrap();
        assert_eq!(body, "first|ok|a\nsecond|ok|b\n");
    }

    // ---- ADR-0052 (issue #30): mode tag in the status field ----

    #[test]
    fn status_token_builds_and_parses_the_adv_suffix() {
        assert_eq!(status_token(STATUS_OK, false), "ok");
        assert_eq!(status_token(STATUS_OK, true), "ok:adv");
        assert_eq!(status_token(STATUS_ERR, true), "err:adv");
        assert_eq!(parse_status("ok"), (true, false));
        assert_eq!(parse_status("ok:adv"), (true, true));
        assert_eq!(parse_status("err"), (false, false));
        assert_eq!(parse_status("err:adv"), (false, true));
        // Unknown base → not ok (replay skips it), simple.
        assert_eq!(parse_status("frobnicate"), (false, false));
    }

    #[test]
    fn read_recent_sources_reconstructs_colon_prefix_for_advanced() {
        // An advanced record (`ok:adv`) hydrates in its `:`-prefixed
        // simple-mode runnable form; a simple record stays bare. This is
        // the cross-session half of the issue #30 fix.
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("history.log");
        let adv = format_record_with_status(
            "select * from T",
            "2026-06-13T10:00:00Z".to_string(),
            &status_token(STATUS_OK, true),
        );
        let simple = format_record_with_status(
            "create table T with pk",
            "2026-06-13T10:00:01Z".to_string(),
            &status_token(STATUS_OK, false),
        );
        std::fs::write(&path, format!("{adv}{simple}")).unwrap();
        let got = read_recent_sources(&path, 10).unwrap();
        assert_eq!(
            got,
            vec![
                ": select * from T".to_string(),
                "create table T with pk".to_string(),
            ],
        );
    }

    #[test]
    fn parse_journal_record_treats_ok_adv_as_ok() {
        // Replay keys off the base token, so `ok:adv` replays like `ok`
        // (source stays canonical).
        let rec = parse_journal_record("2026-06-13T10:00:00Z|ok:adv|select * from T")
            .expect("ok:adv journal record");
        assert!(rec.status_is_ok);
        assert_eq!(rec.source, "select * from T");
        let err = parse_journal_record("2026-06-13T10:00:00Z|err:adv|select bad")
            .expect("err:adv journal record");
        assert!(!err.status_is_ok);
    }

    #[test]
    fn old_three_field_log_reads_as_simple() {
        // Back-compat: a pre-ADR-0052 log (no `:adv`) hydrates bare.
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("history.log");
        std::fs::write(&path, "2026-01-01T00:00:00Z|ok|select 1\n").unwrap();
        let got = read_recent_sources(&path, 10).unwrap();
        assert_eq!(got, vec!["select 1".to_string()]);
    }
}
