//! `scope-stats` command (M9) -- tally WAL signal events by governance scope.

use serde::Serialize;

use crate::{CliError, EXIT_DEGRADED, json::render_json};

/// Per-scope signal-event tally over a WAL directory.
#[derive(Default)]
struct ScopeStats {
    local: u64,
    community: u64,
    session: u64,
    agent: u64,
    /// Events whose scope discriminant is not a known [`SignalScope`] value.
    unknown: u64,
}

impl ScopeStats {
    const fn total(&self) -> u64 {
        self.local + self.community + self.session + self.agent + self.unknown
    }

    /// Events that are eligible to ship (everything that is not local-scoped).
    /// Unknown discriminants are conservatively counted as non-shippable.
    const fn share_eligible(&self) -> u64 {
        self.community + self.session + self.agent
    }
}

/// Tri-state integrity of the WAL scan, so the operator can distinguish a
/// clean scan from a torn tail from one whose integrity could NOT be verified
/// at all (e.g. a corrupt `checkpoint.meta` that makes the cross-check fail).
///
/// Folding `unverified` into `clean` is exactly the false positive W29 fixes:
/// the tally read may succeed off the segments while the integrity diagnosis
/// itself errors out, so claiming `complete` then is silently-wrong reporting
/// on a durability-adjacent surface.
#[derive(Serialize, Clone, Copy, PartialEq, Eq, Debug)]
#[serde(rename_all = "snake_case")]
enum Integrity {
    /// Diagnosis succeeded with zero inconsistencies — the tally is whole.
    Clean,
    /// Diagnosis succeeded but found a torn/corrupt tail — the tally undercounts.
    Torn,
    /// Diagnosis itself failed (e.g. corrupt `checkpoint.meta`) — integrity
    /// could not be proven, so the tally must NOT be claimed complete.
    Unverified,
}

impl Integrity {
    /// Only a proven-clean scan is `complete`. A torn tail OR an unverifiable
    /// store both yield `false` — we never assert completeness we cannot prove.
    const fn is_complete(self) -> bool {
        matches!(self, Self::Clean)
    }

    const fn describe(self) -> &'static str {
        match self {
            Self::Clean => "tally covers every WAL batch",
            Self::Torn => "torn/corrupt tail — tally undercounts",
            Self::Unverified => "integrity could not be verified — do not trust the tally",
        }
    }
}

/// `scope-stats` command output: the per-scope tally plus a tail-integrity
/// indicator so the operator can tell whether the tally is complete.
#[derive(Serialize)]
struct ScopeStatsOutput {
    /// Number of WAL segment files, or `None` (JSON `null`, pretty `"unknown"`)
    /// when the segment listing could not be read — never a fabricated `0` that
    /// reads as "no segments" while a non-zero `total_events` was just tallied.
    wal_segments: Option<usize>,
    scope_stats: ScopeStatsBody,
}

impl ScopeStatsOutput {
    /// Human-readable rendering for `--pretty`, matching the section style of
    /// `recover`/`diagnostics` rather than merely indenting the JSON. Operators
    /// scan this at the terminal; machines should use the (default) JSON form.
    fn format_pretty(&self) -> String {
        use std::fmt::Write;

        let body = &self.scope_stats;
        let by = &body.by_scope;
        let mut out = String::new();

        out.push_str("WAL Scope Stats\n");
        out.push_str("===============\n\n");

        match self.wal_segments {
            Some(n) => {
                let _ = writeln!(out, "WAL Segments:   {n}");
            }
            // Honest "unknown" rather than a fabricated 0: the listing failed
            // after the events were already tallied (e.g. a concurrent rollover).
            None => {
                let _ = writeln!(out, "WAL Segments:   unknown");
            }
        }
        let _ = writeln!(out, "Total events:   {}", body.total_events);
        let _ = writeln!(out, "Share-eligible: {}", body.share_eligible);
        out.push('\n');

        out.push_str("By Scope:\n");
        let _ = writeln!(out, "  Local:     {}", by.local);
        let _ = writeln!(out, "  Community: {}", by.community);
        let _ = writeln!(out, "  Session:   {}", by.session);
        let _ = writeln!(out, "  Agent:     {}", by.agent);
        let _ = writeln!(out, "  Unknown:   {}", by.unknown);
        out.push('\n');

        out.push_str("Integrity:\n");
        let _ = writeln!(out, "  Inconsistencies: {}", body.inconsistency_count);
        // `complete` is the operator's go/no-go on trusting the tally: a torn or
        // corrupt tail truncates the scan and the per-scope numbers undercount,
        // and an unverifiable store (failed diagnosis) is NOT claimed complete.
        let _ = writeln!(
            out,
            "  Complete:        {} ({})",
            body.complete,
            body.integrity.describe(),
        );

        out
    }
}

#[derive(Serialize)]
struct ScopeStatsBody {
    total_events: u64,
    share_eligible: u64,
    by_scope: ByScope,
    /// Number of corrupt/truncated WAL batches encountered while scanning. A
    /// non-zero value means the read stopped at a torn or corrupt tail, so the
    /// per-scope tally undercounts the true on-disk event set. `0` means a
    /// clean, complete scan.
    inconsistency_count: u64,
    /// `true` ONLY when integrity was proven clean (diagnosis succeeded with
    /// zero inconsistencies). `false` on a torn/corrupt tail (see
    /// `inconsistency_count`) AND when integrity could not be verified at all
    /// (failed diagnosis) — we never assert completeness we cannot prove. Lets a
    /// consumer branch on integrity without re-deriving it from the count.
    complete: bool,
    /// Tri-state integrity: `clean` / `torn` / `unverified`. Distinguishes a
    /// torn tail (some data present, scan truncated) from an unverifiable store
    /// (diagnosis itself failed), which `complete` alone collapses into one
    /// `false`. A consumer that gates shipping on integrity should treat
    /// anything other than `clean` as do-not-ship.
    integrity: Integrity,
}

#[derive(Serialize)]
struct ByScope {
    local: u64,
    community: u64,
    session: u64,
    agent: u64,
    unknown: u64,
}

/// Lock-free scan of every WAL segment, classifying each signal event by its
/// governance scope. Reports the per-scope tally, a share-eligible count
/// (non-local events), and a tail-integrity indicator. Touches only WAL segment
/// files — never the locked keyspace — so it is safe to run against a live
/// instance.
///
/// `read_all_events` stops silently at the first torn/corrupt batch, so the raw
/// tally alone cannot tell a clean scan from one truncated by a torn tail. We
/// therefore cross-check against [`diagnose_wal`], whose `inconsistency_count`
/// counts every corrupt/truncated batch (both readers use the same two-phase
/// validation and stop at the same boundary). The diagnosis is a tri-state:
///
/// * `Ok(0 inconsistencies)` → [`Integrity::Clean`], `complete: true`, exit 0;
/// * `Ok(n > 0)` → [`Integrity::Torn`], `complete: false`, exit 0 (the tally
///   undercounts but the command still succeeds — the prefix is useful);
/// * `Err(_)` (e.g. a corrupt `checkpoint.meta` the segment scan never touched)
///   → [`Integrity::Unverified`], `complete: false`, and `EXIT_DEGRADED` (2) to
///   match `status`/`diagnostics` — integrity could NOT be proven, so a script's
///   `scope-stats && ship` must not treat the store as clean. Folding this case
///   into `complete: true` (the prior `map_or(0, …)`) was a silent false
///   positive precisely in the degraded case the indicator exists to flag.
pub(crate) fn run(base: &std::path::Path, pretty: bool) -> Result<(String, i32), CliError> {
    use tidaldb::governance::SignalScope;

    let paths = tidaldb::Paths::new(base);
    let wal_dir = paths.wal_dir();

    let mut stats = ScopeStats::default();
    let mut inconsistency_count = 0u64;
    let mut integrity = Integrity::Clean;
    let mut exit_code = 0;

    if wal_dir.exists() {
        let events = tidaldb::wal::reader::read_all_events(&wal_dir)
            .map_err(|e| CliError::new(format!("WAL read error: {e}")))?;
        for ev in events {
            match SignalScope::from_discriminant(ev.scope) {
                Ok(SignalScope::Local) => stats.local += 1,
                Ok(SignalScope::Community(_)) => stats.community += 1,
                Ok(SignalScope::Session) => stats.session += 1,
                Ok(SignalScope::Agent) => stats.agent += 1,
                Err(_) => stats.unknown += 1,
            }
        }

        // Cross-check tail integrity. diagnose_wal walks the same segments with
        // identical validation and reports corrupt/truncated batches; a torn or
        // corrupt tail that silently truncated `read_all_events` shows up here as
        // a non-zero inconsistency count. Distinguish the THREE outcomes instead
        // of folding a failed diagnosis into the clean path (W29).
        let diag = tidaldb::wal::diagnostics::diagnose_wal(base);
        inconsistency_count = diag.as_ref().map_or(0, |r| r.inconsistency_count);
        integrity = match &diag {
            Ok(r) if r.inconsistency_count == 0 => Integrity::Clean,
            Ok(_) => Integrity::Torn,
            // Integrity could not be verified (e.g. corrupt checkpoint.meta the
            // segment scan never reads). Do NOT claim completeness; degrade the
            // exit code so a gating script cannot mistake this for success.
            Err(_) => {
                exit_code = EXIT_DEGRADED;
                Integrity::Unverified
            }
        };
    }

    // Segment count. `None` = the listing could not be read (reported as
    // "unknown", never a fabricated 0); a missing/empty WAL dir is genuinely 0
    // segments, a known count, so `Some(0)`.
    //
    // SUG#9: report an unreadable segment listing as `None`, not `0`. The listing
    // can change between this scan and `read_all_events` on a live, lock-free
    // inspector (a segment rolled/removed concurrently), and a `0` next to a
    // non-zero `total_events` is internally inconsistent.
    let segment_count: Option<usize> = if wal_dir.exists() {
        tidaldb::wal::segment::list_segments(&wal_dir)
            .ok()
            .map(|s| s.len())
    } else {
        Some(0)
    };

    let output = ScopeStatsOutput {
        wal_segments: segment_count,
        scope_stats: ScopeStatsBody {
            total_events: stats.total(),
            share_eligible: stats.share_eligible(),
            by_scope: ByScope {
                local: stats.local,
                community: stats.community,
                session: stats.session,
                agent: stats.agent,
                unknown: stats.unknown,
            },
            inconsistency_count,
            complete: integrity.is_complete(),
            integrity,
        },
    };

    // `--pretty` selects the human-readable section layout (consistent with
    // `recover`/`diagnostics`); the default emits compact machine JSON. The exit
    // code carries the integrity verdict (0 clean/torn-but-readable, 2 when
    // integrity could not be verified) — same contract as status/diagnostics.
    if pretty {
        Ok((output.format_pretty(), exit_code))
    } else {
        Ok((render_json(&output, false)?, exit_code))
    }
}

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

    fn sample() -> ScopeStatsOutput {
        ScopeStatsOutput {
            wal_segments: Some(3),
            scope_stats: ScopeStatsBody {
                total_events: 10,
                share_eligible: 6,
                by_scope: ByScope {
                    local: 4,
                    community: 3,
                    session: 2,
                    agent: 1,
                    unknown: 0,
                },
                inconsistency_count: 0,
                complete: true,
                integrity: Integrity::Clean,
            },
        }
    }

    #[test]
    fn pretty_is_human_readable_not_json() {
        let out = sample().format_pretty();
        // Human-readable form: labelled sections, no JSON braces/quotes.
        assert!(out.contains("WAL Scope Stats"));
        assert!(out.contains("By Scope:"));
        assert!(out.contains("Local:     4"));
        assert!(out.contains("Community: 3"));
        assert!(out.contains("Complete:        true"));
        assert!(!out.contains('{'), "pretty output must not be JSON");
    }

    #[test]
    fn pretty_flags_torn_tail_as_incomplete() {
        let mut output = sample();
        output.scope_stats.inconsistency_count = 2;
        output.scope_stats.complete = false;
        output.scope_stats.integrity = Integrity::Torn;
        let out = output.format_pretty();
        assert!(out.contains("Inconsistencies: 2"));
        assert!(out.contains("Complete:        false"));
        assert!(out.contains("undercounts"));
    }

    /// W29: an unverifiable store (failed diagnosis) must NOT be claimed
    /// complete. `Integrity::Unverified` yields `is_complete() == false` and a
    /// distinct, honest description — not the clean-path "covers every batch".
    #[test]
    fn unverified_integrity_is_never_complete() {
        assert!(
            !Integrity::Unverified.is_complete(),
            "unverified integrity must never be reported as complete"
        );
        assert!(
            !Integrity::Torn.is_complete(),
            "a torn tail must never be reported as complete"
        );
        assert!(
            Integrity::Clean.is_complete(),
            "only a proven-clean scan is complete"
        );
        let desc = Integrity::Unverified.describe();
        assert!(
            desc.contains("could not be verified"),
            "unverified must describe the integrity gap honestly, got {desc:?}"
        );
        assert_ne!(
            Integrity::Unverified.describe(),
            Integrity::Clean.describe(),
            "unverified must not borrow the clean-path message"
        );
    }

    /// W29: `Integrity` serializes to the documented `snake_case` discriminants so
    /// a consumer can branch on `integrity == "unverified"` to refuse to ship.
    #[test]
    fn integrity_serializes_snake_case() {
        let json = |i: Integrity| {
            serde_json::to_value(i)
                .expect("Integrity serializes")
                .as_str()
                .expect("Integrity is a JSON string")
                .to_string()
        };
        assert_eq!(json(Integrity::Clean), "clean");
        assert_eq!(json(Integrity::Torn), "torn");
        assert_eq!(json(Integrity::Unverified), "unverified");
    }

    /// SUG#9: an unreadable segment listing renders as "unknown", never a
    /// fabricated `0` that reads as "no segments".
    #[test]
    fn pretty_reports_unknown_segment_count() {
        let mut output = sample();
        output.wal_segments = None;
        let out = output.format_pretty();
        assert!(
            out.contains("WAL Segments:   unknown"),
            "unreadable segment listing must render as unknown, got:\n{out}"
        );
    }
}
