//! Reference tracker — lightweight side-channel that records when each
//! lifecycle memory record was last retrieved (referenced). Lives
//! outside the append-only ledger as a mutable JSON file at
//! `<lifecycle_root>/reference-tracker.json`.
//!
//! Used by the staleness detection subsystem (Phase 4 Round 17) to
//! apply a scoring penalty to memories that haven't been retrieved in
//! a long time.
//!
//! ## Concurrency
//! Uses `fs2::FileExt::lock_exclusive` (POSIX flock) to serialize
//! mutations. Same model as `distill_queue.rs`.

use fs2::FileExt;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions};
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

pub const TRACKER_FILE_NAME: &str = "reference-tracker.json";
const SCHEMA_VERSION: &str = "reference-tracker.v1";

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReferenceMap {
    #[serde(default)]
    pub schema_version: String,
    #[serde(default)]
    pub records: BTreeMap<String, ReferenceEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReferenceEntry {
    /// ISO 8601 UTC timestamp, e.g. "2026-05-08T14:30:00Z"
    pub last_referenced_at: String,
    pub count: u64,
}

/// Resolve the tracker file path under `<root>/reference-tracker.json`.
pub fn tracker_path(root: &Path) -> PathBuf {
    root.join(TRACKER_FILE_NAME)
}

/// Load tracker, update `last_referenced_at` to now (UTC ISO 8601) and
/// increment `count` for each record_id. Creates file if absent. Uses
/// file locking for concurrent safety. Errors are swallowed (eprintln
/// + return) — retrieval must never fail because of tracker I/O.
pub fn touch(root: &Path, record_ids: &[&str]) {
    if record_ids.is_empty() {
        return;
    }
    if let Err(err) = touch_inner(root, record_ids) {
        eprintln!("[spool] reference tracker touch failed: {err}");
    }
}

/// Load and parse the tracker file. Returns empty map if file missing
/// or corrupt.
pub fn read(root: &Path) -> ReferenceMap {
    let path = tracker_path(root);
    if !path.exists() {
        return ReferenceMap::default();
    }
    match fs::read_to_string(&path) {
        Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
        Err(_) => ReferenceMap::default(),
    }
}

/// Parse `last_referenced_at` and return days elapsed since then.
/// Returns None if parse fails.
pub fn age_days(entry: &ReferenceEntry) -> Option<u64> {
    let referenced_secs = parse_iso8601_to_unix_secs(&entry.last_referenced_at)?;
    let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
    if now_secs < referenced_secs {
        return Some(0);
    }
    Some((now_secs - referenced_secs) / 86400)
}

/// Apply the staleness decay curve:
/// - 0-14 days: 0
/// - 15-30 days: -2
/// - 31-60 days: -4
/// - 61-90 days: -6
/// - 91+ days: -8
/// - None (never referenced): 0
pub fn staleness_penalty(age: Option<u64>) -> i32 {
    match age {
        None => 0,
        Some(days) => match days {
            0..=3 => 4,
            4..=7 => 2,
            8..=14 => 0,
            15..=30 => -2,
            31..=60 => -4,
            61..=90 => -6,
            _ => -8,
        },
    }
}

// --- Internal helpers ---

fn touch_inner(root: &Path, record_ids: &[&str]) -> anyhow::Result<()> {
    fs::create_dir_all(root)
        .map_err(|e| anyhow::anyhow!("creating tracker dir {}: {e}", root.display()))?;

    let path = tracker_path(root);
    let file = OpenOptions::new()
        .create(true)
        .truncate(false)
        .read(true)
        .write(true)
        .open(&path)
        .map_err(|e| anyhow::anyhow!("opening tracker {}: {e}", path.display()))?;
    file.lock_exclusive()
        .map_err(|e| anyhow::anyhow!("locking tracker {}: {e}", path.display()))?;

    let result = (|| -> anyhow::Result<()> {
        let mut content = String::new();
        // Re-read under lock to avoid TOCTOU.
        let mut reader =
            File::open(&path).map_err(|e| anyhow::anyhow!("re-reading tracker: {e}"))?;
        reader.read_to_string(&mut content).ok();

        let mut map: ReferenceMap = if content.trim().is_empty() {
            ReferenceMap::default()
        } else {
            serde_json::from_str(&content).unwrap_or_default()
        };
        map.schema_version = SCHEMA_VERSION.to_string();

        let now = now_iso8601();
        for &id in record_ids {
            let entry = map.records.entry(id.to_string()).or_insert(ReferenceEntry {
                last_referenced_at: now.clone(),
                count: 0,
            });
            entry.last_referenced_at = now.clone();
            entry.count += 1;
        }

        let serialized = serde_json::to_string_pretty(&map)
            .map_err(|e| anyhow::anyhow!("serializing tracker: {e}"))?;
        fs::write(&path, serialized)
            .map_err(|e| anyhow::anyhow!("writing tracker {}: {e}", path.display()))?;
        Ok(())
    })();

    let _ = FileExt::unlock(&file);
    result
}

/// Generate current UTC time as ISO 8601 string (e.g. "2026-05-08T14:30:00Z").
/// Uses std::time only — no chrono dependency.
fn now_iso8601() -> String {
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    unix_secs_to_iso8601(secs)
}

/// Convert unix seconds to ISO 8601 UTC string.
fn unix_secs_to_iso8601(secs: u64) -> String {
    // Manual conversion from unix timestamp to calendar date/time.
    let days = secs / 86400;
    let time_of_day = secs % 86400;
    let hours = time_of_day / 3600;
    let minutes = (time_of_day % 3600) / 60;
    let seconds = time_of_day % 60;

    let (year, month, day) = days_to_ymd(days);
    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
}

/// Convert days since Unix epoch (1970-01-01) to (year, month, day).
fn days_to_ymd(days: u64) -> (u64, u64, u64) {
    // Algorithm adapted from Howard Hinnant's civil_from_days.
    let z = days + 719468;
    let era = z / 146097;
    let doe = z - era * 146097;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
    let y = yoe + 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, m, d)
}

/// Parse a subset of ISO 8601 timestamps to unix seconds.
/// Supports: "YYYY-MM-DDTHH:MM:SSZ" and "YYYY-MM-DDTHH:MM:SS+00:00".
fn parse_iso8601_to_unix_secs(s: &str) -> Option<u64> {
    let s = s.trim();
    // Minimum length: "2026-05-08T14:30:00Z" = 20 chars
    if s.len() < 20 {
        return None;
    }
    let year: u64 = s.get(0..4)?.parse().ok()?;
    if s.as_bytes().get(4)? != &b'-' {
        return None;
    }
    let month: u64 = s.get(5..7)?.parse().ok()?;
    if s.as_bytes().get(7)? != &b'-' {
        return None;
    }
    let day: u64 = s.get(8..10)?.parse().ok()?;
    if s.as_bytes().get(10)? != &b'T' {
        return None;
    }
    let hour: u64 = s.get(11..13)?.parse().ok()?;
    if s.as_bytes().get(13)? != &b':' {
        return None;
    }
    let min: u64 = s.get(14..16)?.parse().ok()?;
    if s.as_bytes().get(16)? != &b':' {
        return None;
    }
    let sec: u64 = s.get(17..19)?.parse().ok()?;

    // Validate ranges
    if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || min > 59 || sec > 59 {
        return None;
    }

    // Check timezone suffix: must be 'Z' or '+00:00' or '-00:00'
    let tz_part = s.get(19..)?;
    if tz_part != "Z" && tz_part != "+00:00" && tz_part != "-00:00" {
        return None;
    }

    let days = ymd_to_days(year, month, day)?;
    Some(days * 86400 + hour * 3600 + min * 60 + sec)
}

/// Convert (year, month, day) to days since Unix epoch.
/// Returns None for invalid dates.
fn ymd_to_days(year: u64, month: u64, day: u64) -> Option<u64> {
    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
        return None;
    }
    // Howard Hinnant's days_from_civil (adjusted for unsigned).
    let y = if month <= 2 { year - 1 } else { year };
    let m = if month <= 2 { month + 9 } else { month - 3 };
    let era = y / 400;
    let yoe = y - era * 400;
    let doy = (153 * m + 2) / 5 + day - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    let days = era * 146097 + doe;
    // Subtract the epoch offset (days from 0000-03-01 to 1970-01-01).
    days.checked_sub(719468)
}

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

    /// Test helper: convert unix seconds to ISO 8601 string.
    /// Exposed so other test modules (e.g. scorer tests) can build
    /// synthetic `ReferenceEntry` values with known ages.
    pub fn unix_secs_to_iso8601_for_test(secs: u64) -> String {
        super::unix_secs_to_iso8601(secs)
    }

    #[test]
    fn touch_creates_file_when_absent() {
        let temp = tempdir().unwrap();
        let root = temp.path();
        assert!(!tracker_path(root).exists());

        touch(root, &["rec-001", "rec-002"]);

        assert!(tracker_path(root).exists());
        let map = read(root);
        assert_eq!(map.schema_version, SCHEMA_VERSION);
        assert_eq!(map.records.len(), 2);
        assert!(map.records.contains_key("rec-001"));
        assert!(map.records.contains_key("rec-002"));
    }

    #[test]
    fn touch_updates_existing_entry() {
        let temp = tempdir().unwrap();
        let root = temp.path();

        // Seed with an old timestamp.
        let mut map = ReferenceMap {
            schema_version: SCHEMA_VERSION.to_string(),
            records: BTreeMap::new(),
        };
        map.records.insert(
            "rec-001".to_string(),
            ReferenceEntry {
                last_referenced_at: "2020-01-01T00:00:00Z".to_string(),
                count: 5,
            },
        );
        fs::create_dir_all(root).unwrap();
        fs::write(
            tracker_path(root),
            serde_json::to_string_pretty(&map).unwrap(),
        )
        .unwrap();

        touch(root, &["rec-001"]);

        let updated = read(root);
        let entry = updated.records.get("rec-001").unwrap();
        // Timestamp should be updated (not the old one).
        assert_ne!(entry.last_referenced_at, "2020-01-01T00:00:00Z");
        assert!(entry.last_referenced_at.ends_with('Z'));
    }

    #[test]
    fn touch_increments_count() {
        let temp = tempdir().unwrap();
        let root = temp.path();

        touch(root, &["rec-001"]);
        let map = read(root);
        assert_eq!(map.records["rec-001"].count, 1);

        touch(root, &["rec-001"]);
        let map = read(root);
        assert_eq!(map.records["rec-001"].count, 2);

        touch(root, &["rec-001", "rec-001"]);
        let map = read(root);
        // Two touches in one call = +2.
        assert_eq!(map.records["rec-001"].count, 4);
    }

    #[test]
    fn read_returns_empty_for_missing_file() {
        let temp = tempdir().unwrap();
        let map = read(temp.path());
        assert!(map.records.is_empty());
        assert_eq!(map.schema_version, "");
    }

    #[test]
    fn read_returns_empty_for_corrupt_file() {
        let temp = tempdir().unwrap();
        let root = temp.path();
        fs::write(tracker_path(root), "not valid json {{{").unwrap();
        let map = read(root);
        assert!(map.records.is_empty());
    }

    #[test]
    fn age_days_computes_correctly() {
        // Use a timestamp that is exactly 30 days ago.
        let now_secs = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let thirty_days_ago = now_secs - 30 * 86400;
        let ts = unix_secs_to_iso8601(thirty_days_ago);
        let entry = ReferenceEntry {
            last_referenced_at: ts,
            count: 1,
        };
        let days = age_days(&entry).unwrap();
        assert_eq!(days, 30);
    }

    #[test]
    fn age_days_returns_none_for_invalid_timestamp() {
        let entry = ReferenceEntry {
            last_referenced_at: "not-a-timestamp".to_string(),
            count: 1,
        };
        assert_eq!(age_days(&entry), None);

        let entry2 = ReferenceEntry {
            last_referenced_at: "2026-13-01T00:00:00Z".to_string(), // invalid month
            count: 1,
        };
        assert_eq!(age_days(&entry2), None);
    }

    #[test]
    fn staleness_penalty_curve() {
        assert_eq!(staleness_penalty(None), 0);
        assert_eq!(staleness_penalty(Some(0)), 4);
        assert_eq!(staleness_penalty(Some(3)), 4);
        assert_eq!(staleness_penalty(Some(4)), 2);
        assert_eq!(staleness_penalty(Some(7)), 2);
        assert_eq!(staleness_penalty(Some(8)), 0);
        assert_eq!(staleness_penalty(Some(14)), 0);
        assert_eq!(staleness_penalty(Some(15)), -2);
        assert_eq!(staleness_penalty(Some(30)), -2);
        assert_eq!(staleness_penalty(Some(31)), -4);
        assert_eq!(staleness_penalty(Some(60)), -4);
        assert_eq!(staleness_penalty(Some(61)), -6);
        assert_eq!(staleness_penalty(Some(90)), -6);
        assert_eq!(staleness_penalty(Some(91)), -8);
        assert_eq!(staleness_penalty(Some(365)), -8);
    }

    #[test]
    fn iso8601_roundtrip() {
        // Verify our formatting and parsing are consistent.
        let secs: u64 = 1_715_000_000; // ~2024-05-06
        let formatted = unix_secs_to_iso8601(secs);
        let parsed = parse_iso8601_to_unix_secs(&formatted).unwrap();
        assert_eq!(parsed, secs);
    }
}
