//! The run registry (C18): an append-only store of run records — one
//! `(manifest, metrics)` `RunReport` per line in a JSONL file — with a typed
//! read-path (serde) for listing and ranking runs across invocations ("compare
//! experiments over time", which has no home in git or Gitea). Storage is
//! serde_json; display is the caller's concern (the CLI prints via
//! `RunReport::to_json`).
//!
//! Orchestration **families** (sweep / Monte-Carlo / walk-forward, C12/C21) are
//! stored as related records in a sibling family store (`families.jsonl`): each
//! member is a `RunReport` stamped with its `family` name + `run` index (the
//! `family_id` handle is derived from the pair), re-derived as a unit by
//! [`group_families`]. The flat runs store and its API are untouched.

use std::cmp::Ordering;
use std::fmt;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

use aura_engine::{RunReport, SweepFamily, SweepPoint};

mod lineage;
pub use lineage::{
    group_families, mc_member_reports, sweep_member_reports, walkforward_member_reports, Family,
    FamilyKind, FamilyRunRecord,
};

/// An append-only run registry over a JSONL file: one serde_json line per
/// `RunReport`.
pub struct Registry {
    path: PathBuf,
}

impl Registry {
    /// Bind to a JSONL path. No I/O — the file is created lazily on first append.
    pub fn open(path: impl AsRef<Path>) -> Registry {
        Registry { path: path.as_ref().to_path_buf() }
    }

    /// Append one record as a single JSON line, creating the file (and its parent
    /// directory) if absent.
    pub fn append(&self, report: &RunReport) -> Result<(), RegistryError> {
        if let Some(parent) = self.path.parent().filter(|p| !p.as_os_str().is_empty()) {
            fs::create_dir_all(parent)?;
        }
        // a RunReport is finite by construction (its f64 fields are finite), so
        // serialization is infallible here.
        let line = serde_json::to_string(report).expect("a finite RunReport serializes");
        let mut file = fs::OpenOptions::new().create(true).append(true).open(&self.path)?;
        writeln!(file, "{line}")?;
        Ok(())
    }

    /// Parse every non-empty line back into a typed `RunReport`, in file order. A
    /// missing file is an empty registry (`Ok(vec![])`), not an error.
    pub fn load(&self) -> Result<Vec<RunReport>, RegistryError> {
        let text = match fs::read_to_string(&self.path) {
            Ok(t) => t,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
            Err(e) => return Err(RegistryError::Io(e)),
        };
        let mut reports = Vec::new();
        for (i, raw) in text.lines().enumerate() {
            if raw.trim().is_empty() {
                continue;
            }
            let report =
                serde_json::from_str(raw).map_err(|source| RegistryError::Parse { line: i + 1, source })?;
            reports.push(report);
        }
        Ok(reports)
    }
}

/// Which metric a best-first comparison keys on. Resolves a metric *name* (a
/// string at the API boundary) to a closed kind once, so the per-comparison hot
/// path branches on this rather than re-parsing the name.
#[derive(Clone, Copy)]
enum Metric {
    TotalPips,
    MaxDrawdown,
    ExposureSignFlips,
}

/// The per-metric **best-first ordering** of two reports: `Less` means `a` is
/// better than `b`. "Best" is fixed by each metric's meaning: `total_pips`
/// higher-is-better; `max_drawdown` and `exposure_sign_flips` lower-is-better.
/// `f64` keys use `partial_cmp` (total, since metrics are finite by
/// construction). An unknown metric name is a `RegistryError::UnknownMetric`.
///
/// The single source of truth for "best" — both [`rank_by`] (sort by it) and
/// [`optimize`] (argmax by it) call this, so the per-metric direction lives in
/// exactly one place. The metric name is matched once here; the returned closure
/// carries the resolved `Metric`, so per-comparison work is just the key compare.
fn metric_cmp(
    metric: &str,
) -> Result<impl Fn(&RunReport, &RunReport) -> Ordering, RegistryError> {
    let metric = match metric {
        "total_pips" => Metric::TotalPips,
        "max_drawdown" => Metric::MaxDrawdown,
        "exposure_sign_flips" => Metric::ExposureSignFlips,
        other => return Err(RegistryError::UnknownMetric(other.to_string())),
    };
    Ok(move |a: &RunReport, b: &RunReport| match metric {
        // higher-is-better -> better when greater
        Metric::TotalPips => b.metrics.total_pips.partial_cmp(&a.metrics.total_pips).unwrap(),
        // lower-is-better -> better when smaller
        Metric::MaxDrawdown => a.metrics.max_drawdown.partial_cmp(&b.metrics.max_drawdown).unwrap(),
        Metric::ExposureSignFlips => {
            a.metrics.exposure_sign_flips.cmp(&b.metrics.exposure_sign_flips)
        }
    })
}

/// Sort `reports` **best-first** by a named metric. "Best" is per-metric, fixed
/// by each metric's meaning (see `metric_cmp`). A stable sort keeps file
/// (insertion) order among ties. An unknown metric name is a
/// `RegistryError::UnknownMetric`.
pub fn rank_by(mut reports: Vec<RunReport>, metric: &str) -> Result<Vec<RunReport>, RegistryError> {
    let cmp = metric_cmp(metric)?;
    reports.sort_by(|a, b| cmp(a, b));
    Ok(reports)
}

/// `rank_by`'s argmax: return the single best `SweepPoint` of a `SweepFamily`
/// under a named metric, by the same fixed per-metric sense of best (see
/// `metric_cmp`). The whole point is carried — its `params` coordinate AND its
/// `RunReport` — because the caller needs the params that won. Ties on the metric
/// resolve to the **earliest** enumeration-order point (the family is already in
/// odometer order; only a strictly-better later point displaces the incumbent).
/// An unknown metric is a `RegistryError::UnknownMetric`. A `SweepFamily` is
/// non-empty by construction (a sweep over a zero-point grid is rejected upstream
/// by `EmptyAxis`), so `reduce` always yields a winner.
pub fn optimize(family: &SweepFamily, metric: &str) -> Result<SweepPoint, RegistryError> {
    let cmp = metric_cmp(metric)?;
    let winner = family
        .points
        .iter()
        .reduce(|best, p| if cmp(&p.report, &best.report) == Ordering::Less { p } else { best })
        .expect("a SweepFamily is non-empty by construction (EmptyAxis is rejected upstream)");
    Ok(winner.clone())
}

/// What can go wrong reading or ranking the registry.
#[derive(Debug)]
pub enum RegistryError {
    /// An I/O error reading or writing the JSONL file.
    Io(std::io::Error),
    /// A stored line did not parse as a `RunReport` (1-based line number).
    Parse { line: usize, source: serde_json::Error },
    /// `rank_by` was given a metric name it does not know.
    UnknownMetric(String),
}

impl fmt::Display for RegistryError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            RegistryError::Io(e) => write!(f, "registry i/o: {e}"),
            RegistryError::Parse { line, source } => {
                write!(f, "registry parse error at line {line}: {source}")
            }
            RegistryError::UnknownMetric(m) => write!(
                f,
                "unknown metric '{m}' (known: total_pips, max_drawdown, exposure_sign_flips)"
            ),
        }
    }
}

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

impl From<std::io::Error> for RegistryError {
    fn from(e: std::io::Error) -> Self {
        RegistryError::Io(e)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use aura_core::{Cell, Scalar, Timestamp};
    use aura_engine::{RunManifest, RunMetrics, RunReport, SweepFamily, SweepPoint};

    fn report_with(total_pips: f64, max_drawdown: f64, flips: u64) -> RunReport {
        RunReport {
            manifest: RunManifest {
                commit: "c".to_string(),
                params: vec![("p".to_string(), Scalar::f64(1.0))],
                window: (Timestamp(1), Timestamp(2)),
                seed: 0,
                broker: "b".to_string(),
            },
            metrics: RunMetrics { total_pips, max_drawdown, exposure_sign_flips: flips },
        }
    }

    fn temp_path(name: &str) -> PathBuf {
        // unique per test + per process; no external tempfile dependency
        std::env::temp_dir().join(format!("aura-registry-{}-{}.jsonl", std::process::id(), name))
    }

    #[test]
    fn append_then_load_round_trips_in_order() {
        let path = temp_path("roundtrip");
        let _ = fs::remove_file(&path);
        let reg = Registry::open(&path);
        let a = report_with(1.0, 0.5, 1);
        let b = report_with(2.0, 0.3, 2);
        reg.append(&a).expect("append a");
        reg.append(&b).expect("append b");
        assert_eq!(reg.load().expect("load"), vec![a, b]);
        let _ = fs::remove_file(&path);
    }

    #[test]
    fn load_missing_file_is_empty() {
        let path = temp_path("missing");
        let _ = fs::remove_file(&path);
        let reg = Registry::open(&path);
        assert_eq!(reg.load().expect("load missing"), Vec::<RunReport>::new());
    }

    #[test]
    fn corrupt_line_is_a_parse_error_with_line_number() {
        let path = temp_path("corrupt");
        let _ = fs::remove_file(&path);
        let reg = Registry::open(&path);
        reg.append(&report_with(1.0, 0.5, 1)).expect("append");
        let mut f = fs::OpenOptions::new().append(true).open(&path).expect("open");
        writeln!(f, "not json").expect("write corrupt line");
        drop(f);
        match reg.load() {
            Err(RegistryError::Parse { line, .. }) => assert_eq!(line, 2),
            other => panic!("expected Parse at line 2, got {other:?}"),
        }
        let _ = fs::remove_file(&path);
    }

    #[test]
    fn rank_by_orders_best_first_per_metric() {
        let reports = vec![
            report_with(1.0, 0.9, 5),
            report_with(3.0, 0.1, 1),
            report_with(2.0, 0.5, 3),
        ];
        let by_pips = rank_by(reports.clone(), "total_pips").expect("rank pips");
        assert_eq!(by_pips[0].metrics.total_pips, 3.0); // higher-is-better -> desc
        let by_dd = rank_by(reports.clone(), "max_drawdown").expect("rank dd");
        assert_eq!(by_dd[0].metrics.max_drawdown, 0.1); // lower-is-better -> asc
        let by_flips = rank_by(reports.clone(), "exposure_sign_flips").expect("rank flips");
        assert_eq!(by_flips[0].metrics.exposure_sign_flips, 1); // lower-is-better -> asc
    }

    #[test]
    fn rank_by_unknown_metric_is_an_error() {
        let reports = vec![report_with(1.0, 0.5, 1)];
        match rank_by(reports, "sharpe") {
            Err(RegistryError::UnknownMetric(m)) => assert_eq!(m, "sharpe"),
            other => panic!("expected UnknownMetric, got {other:?}"),
        }
    }

    /// `optimize` is `rank_by`'s argmax: over a `SweepFamily` it returns the
    /// single winning `SweepPoint` — its `params` coordinate AND its `RunReport`
    /// — chosen by the metric's *fixed* sense of best (`total_pips`
    /// higher-is-better), and a tie on that metric resolves to the **earliest
    /// enumeration-order** point, never a later one. The point carried, not just
    /// its metric, is the property: the caller needs the params that won.
    #[test]
    fn optimize_picks_the_max_metric_point_ties_to_earliest() {
        // A hand-built family in odometer order. Two points tie at the maximal
        // total_pips (3.0); the EARLIER of the two (index 1, params p=10) must
        // win, not the later (index 3, params p=30) — that pins the tie rule.
        let point = |p: f64, pips: f64| SweepPoint {
            params: vec![Cell::from_f64(p)],
            report: report_with(pips, 0.5, 0),
        };
        let family = SweepFamily {
            space: vec![],
            points: vec![
                point(0.0, 1.0),  // 0: also-ran
                point(10.0, 3.0), // 1: tied max, earliest -> the winner
                point(20.0, 2.0), // 2: also-ran
                point(30.0, 3.0), // 3: tied max, but later -> must lose the tie
            ],
        };

        let winner = super::optimize(&family, "total_pips").expect("optimize total_pips");

        // the maximal-metric point wins...
        assert_eq!(winner.report.metrics.total_pips, 3.0);
        // ...and ties resolve to the earliest enumeration-order point: its params
        // identify point 1, not point 3.
        assert_eq!(winner.params, vec![Cell::from_f64(10.0)]);
        // the whole winning point is returned, params AND report together.
        assert_eq!(winner, family.points[1]);
    }

    fn temp_family_dir(name: &str) -> PathBuf {
        // a unique per-test directory so the families.jsonl sibling never collides
        // across tests sharing the temp dir; the registry binds to runs.jsonl inside.
        let dir =
            std::env::temp_dir().join(format!("aura-registry-fam-{}-{}", std::process::id(), name));
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).expect("create temp family dir");
        dir.join("runs.jsonl")
    }

    #[test]
    fn lineage_round_trips_one_family() {
        let path = temp_family_dir("roundtrip");
        let reg = Registry::open(&path);
        let reports =
            vec![report_with(1.0, 0.5, 1), report_with(2.0, 0.3, 2), report_with(3.0, 0.1, 0)];
        let id = reg.append_family("f", FamilyKind::MonteCarlo, &reports).expect("append family");
        assert_eq!(id, "f-0");
        let families = group_families(reg.load_family_members().expect("load members"));
        assert_eq!(families.len(), 1);
        let fam = &families[0];
        assert_eq!(fam.id, "f-0");
        assert_eq!(fam.kind, FamilyKind::MonteCarlo);
        // the split (family, run) fields are stamped on each member, and the
        // user-facing handle is derived from them (id is "{family}-{run}").
        assert_eq!((fam.members[0].family.as_str(), fam.members[0].run), ("f", 0));
        assert_eq!(fam.members[0].family_id(), "f-0");
        // members re-derived as a unit, ordinal-ordered, equal to the stamped reports
        let member_reports: Vec<_> = fam.members.iter().map(|m| m.report.clone()).collect();
        assert_eq!(member_reports, reports);
        assert_eq!(fam.members.iter().map(|m| m.ordinal).collect::<Vec<_>>(), vec![0, 1, 2]);
    }

    #[test]
    fn per_name_counter_increments_independently() {
        let path = temp_family_dir("counter");
        let reg = Registry::open(&path);
        let reports = vec![report_with(1.0, 0.5, 1)];
        let x0 = reg.append_family("x", FamilyKind::Sweep, &reports).expect("x-0");
        let x1 = reg.append_family("x", FamilyKind::Sweep, &reports).expect("x-1");
        let y0 = reg.append_family("y", FamilyKind::Sweep, &reports).expect("y-0");
        assert_eq!((x0.as_str(), x1.as_str(), y0.as_str()), ("x-0", "x-1", "y-0"));
        let families = group_families(reg.load_family_members().expect("load"));
        assert_eq!(families.len(), 3);
    }

    #[test]
    fn distinct_families_regroup_in_first_seen_order() {
        let path = temp_family_dir("regroup");
        let reg = Registry::open(&path);
        let a = vec![report_with(1.0, 0.5, 1), report_with(2.0, 0.5, 1)];
        let b = vec![report_with(3.0, 0.5, 1)];
        let ida = reg.append_family("a", FamilyKind::Sweep, &a).expect("a");
        let idb = reg.append_family("b", FamilyKind::WalkForward, &b).expect("b");
        let families = group_families(reg.load_family_members().expect("load"));
        // first-seen file order: a before b
        assert_eq!(families.iter().map(|f| f.id.clone()).collect::<Vec<_>>(), vec![ida, idb]);
        assert_eq!(families[0].members.len(), 2);
        assert_eq!(families[1].kind, FamilyKind::WalkForward);
        // members ordinal-sorted within each family
        assert_eq!(families[0].members.iter().map(|m| m.ordinal).collect::<Vec<_>>(), vec![0, 1]);
    }

    #[test]
    fn family_store_and_flat_store_are_disjoint() {
        let path = temp_family_dir("disjoint");
        let reg = Registry::open(&path);
        // a family write leaves the flat runs store empty...
        reg.append_family("f", FamilyKind::Sweep, &[report_with(1.0, 0.5, 1)]).expect("family");
        assert_eq!(reg.load().expect("flat load"), Vec::<RunReport>::new());
        // ...and a flat write leaves the family store untouched at one family.
        reg.append(&report_with(9.0, 0.5, 1)).expect("flat append");
        assert_eq!(reg.load().expect("flat load 2").len(), 1);
        assert_eq!(group_families(reg.load_family_members().expect("members")).len(), 1);
    }

    #[test]
    fn rank_a_family_as_a_unit() {
        let path = temp_family_dir("rankfam");
        let reg = Registry::open(&path);
        let reports =
            vec![report_with(1.0, 0.5, 1), report_with(3.0, 0.5, 1), report_with(2.0, 0.5, 1)];
        reg.append_family("f", FamilyKind::Sweep, &reports).expect("family");
        let fam = group_families(reg.load_family_members().expect("members"))
            .pop()
            .expect("one family");
        let member_reports: Vec<RunReport> = fam.members.iter().map(|m| m.report.clone()).collect();
        let ranked = rank_by(member_reports, "total_pips").expect("rank");
        assert_eq!(ranked[0].metrics.total_pips, 3.0); // best-first within the family
    }
}
