//! `diagnostics` command -- print a health summary (human-readable or JSON).

use serde::Serialize;

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

pub(crate) fn run(base: &std::path::Path, pretty: bool) -> Result<(String, i32), CliError> {
    // Exit code 1 if data dir doesn't exist.
    if !base.exists() {
        return Err(CliError::new("data directory does not exist"));
    }

    let version = env!("CARGO_PKG_VERSION");
    let build_hash = tidaldb::BUILD_HASH;

    let mut exit_code = 0;

    // Single read-only WAL diagnosis: the segment scan AND checkpoint read both
    // happen exactly once here and feed every WAL-derived field below (the
    // `WalState`, the real signal-entry count, and the uncheckpointed replay
    // lag). A failed diagnosis means the directory is degraded -> exit 2.
    let wal_report = tidaldb::wal::diagnostics::diagnose_wal(base).ok();
    if wal_report.is_none() {
        exit_code = EXIT_DEGRADED;
    }

    let wal = wal_report
        .as_ref()
        .map_or_else(WalState::empty, WalState::from_report);

    // Compute checkpoint age in seconds. `None` when no checkpoint has ever been
    // taken (checkpoint_ts == 0); rendered as JSON `null` and "none" in pretty,
    // never a fabricated `0` that reads as "checkpointed just now".
    let checkpoint_age_secs: Option<u64> = if wal.checkpoint_ts > 0 {
        // Saturate rather than `as u64`-truncate: a post-2554 wall clock whose
        // nanos exceed u64::MAX would otherwise wrap to a tiny value and report
        // a freshly-checkpointed store. Clamping to u64::MAX keeps the age
        // monotonic (and large), which is the honest "checkpoint is ancient"
        // reading. checkpoint_ts is itself a u64 nanos value, so the subtrahend
        // is in-range and the saturating_sub below cannot overflow.
        let now_ns = u64::try_from(
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap_or_default()
                .as_nanos(),
        )
        .unwrap_or(u64::MAX);
        Some(now_ns.saturating_sub(wal.checkpoint_ts) / 1_000_000_000)
    } else {
        None
    };

    // Actual count of valid signal events presently in the WAL segments. This
    // is a true entry count, not the old `last_segment_seq` proxy (a sequence
    // number, which over-reports by the checkpoint offset and never shrinks
    // after compaction). It under-counts only events already folded into a
    // checkpoint and compacted away, which the field name's "estimated" admits.
    let signal_estimated_entries: u64 = wal_report.as_ref().map_or(0, |r| r.total_events);

    // Real replay lag: bytes of WAL not yet covered by the last checkpoint, i.e.
    // the recovery backlog a restart would have to replay. Computed as the size
    // of every segment that still holds at least one event at or after the
    // checkpoint sequence.
    let wal_lag_bytes: u64 = wal_report.as_ref().map_or(0, uncheckpointed_wal_bytes);

    // Tantivy text index stats. `read_text_index_stats` distinguishes an absent
    // index (healthy empty -> Some((0, 0))) from one that exists but cannot be
    // opened (corruption / partial write / lock -> None). A None here is a
    // degraded read: exit 2 and emit null, never a fabricated 0 byte-identical
    // to a healthy empty index.
    let text_index_dir = base.join("text_index");
    let item_text = read_text_index_stats(&text_index_dir);
    if item_text.is_none() {
        exit_code = EXIT_DEGRADED;
    }

    let creator_text_index_dir = base.join("creator_text_index");
    let creator_text = read_text_index_stats(&creator_text_index_dir);
    if creator_text.is_none() {
        exit_code = EXIT_DEGRADED;
    }

    if pretty {
        let output = format_pretty(
            version,
            build_hash,
            base,
            &wal,
            checkpoint_age_secs,
            signal_estimated_entries,
            wal_lag_bytes,
            item_text,
            creator_text,
        );
        Ok((output, exit_code))
    } else {
        // Fields that genuinely cannot be derived offline are emitted as JSON
        // `null` (never a fabricated `0`, which an operator would read as a
        // real "healthy / idle" value). The parallel `offline_unavailable`
        // map documents each omitted field and why, so the output is
        // self-describing. See OFFLINE_UNAVAILABLE for the reasons.
        let output = DiagnosticsOutput {
            version,
            build_hash,
            wal_segments: wal.segments,
            wal_total_bytes: wal.wal_dir_bytes,
            wal_lag_bytes,
            checkpoint_age_seconds: checkpoint_age_secs,
            checkpoint_wal_sequence: wal.checkpoint_seq,
            signal_estimated_entries,
            tantivy_segments: item_text.map(|(segments, _)| segments),
            tantivy_indexed_docs: item_text.map(|(_, docs)| docs),
            creator_tantivy_segments: creator_text.map(|(segments, _)| segments),
            creator_tantivy_docs: creator_text.map(|(_, docs)| docs),
            usearch_directory_bytes: None,
            sessions_active: None,
            sessions_closed_total: None,
            sessions_auto_closed_total: None,
            degradation_level: None,
            collection_count: None,
            cohort_count: None,
            offline_unavailable: offline_unavailable_map(),
        };

        let rendered = render_json(&output, false)?;
        Ok((rendered, exit_code))
    }
}

/// Read Tantivy index stats, distinguishing **absent** from **unreadable**.
///
/// - directory absent -> `Some((0, 0))`: a store that never indexed any text is
///   healthy and empty, not degraded.
/// - directory present but [`read_stats_from_dir`](tidaldb::text::read_stats_from_dir)
///   returns `None` (corruption / partial write / lock) -> `None`: the index
///   cannot be inspected. The caller maps this to exit code 2 and a `null`
///   field, never a fabricated `0` that is byte-identical to healthy-empty.
fn read_text_index_stats(dir: &std::path::Path) -> Option<(usize, u64)> {
    if !dir.exists() {
        return Some((0, 0));
    }
    tidaldb::text::read_stats_from_dir(dir)
}

/// `diagnostics` JSON output.
///
/// Fields that cannot be honestly derived offline are typed `Option<_>` and
/// always serialized as JSON `null` (never a fabricated `0`, which an operator
/// would misread as a real value); the `offline_unavailable` map explains each
/// one. serde renders a `None` as `null` by default, so the null-vs-number
/// distinction is encoded directly in the type. The `checkpoint_age_seconds`
/// and Tantivy `*_segments`/`*_docs` fields are `Option` for the same reason:
/// no checkpoint / an unreadable index must read as `null`, not `0`.
#[derive(Serialize)]
struct DiagnosticsOutput<'a> {
    version: &'a str,
    build_hash: &'a str,
    wal_segments: usize,
    wal_total_bytes: u64,
    wal_lag_bytes: u64,
    checkpoint_age_seconds: Option<u64>,
    checkpoint_wal_sequence: u64,
    signal_estimated_entries: u64,
    tantivy_segments: Option<usize>,
    tantivy_indexed_docs: Option<u64>,
    creator_tantivy_segments: Option<usize>,
    creator_tantivy_docs: Option<u64>,
    usearch_directory_bytes: Option<u64>,
    sessions_active: Option<u64>,
    sessions_closed_total: Option<u64>,
    sessions_auto_closed_total: Option<u64>,
    degradation_level: Option<u64>,
    collection_count: Option<u64>,
    cohort_count: Option<u64>,
    offline_unavailable: std::collections::BTreeMap<&'static str, &'static str>,
}

/// Bytes of WAL not yet covered by the last checkpoint — the recovery backlog a
/// restart would replay.
///
/// A segment contributes its full on-disk size if it still holds at least one
/// event at or after `checkpoint_seq` (the boundary recovery replays from:
/// `event_seq >= checkpoint_seq`). A segment's last event sequence is
/// `first_seq + event_count - 1`, so it is fully checkpointed iff
/// `first_seq + event_count <= checkpoint_seq`. With no checkpoint
/// (`checkpoint_seq == 0`) every event is unreplayed, so all segment bytes count
/// as lag, which is the correct "nothing is durable yet" reading.
fn uncheckpointed_wal_bytes(report: &tidaldb::wal::diagnostics::WalDiagnosticReport) -> u64 {
    report
        .segments
        .iter()
        .filter(|s| s.first_seq.saturating_add(s.event_count) > report.checkpoint_seq)
        .map(|s| s.file_size)
        .sum()
}

/// Fields that `tidalctl diagnostics` cannot honestly derive offline, each
/// paired with the reason it is unavailable.
///
/// Two distinct causes:
///
/// 1. **Runtime-only state** — `sessions_active` and `degradation_level` exist
///    only in a *running* `TidalDb`'s memory. A closed database has no live
///    sessions and no load-degradation level; reporting `0` would fabricate a
///    "healthy / idle" judgment that the inspector cannot actually observe.
///
/// 2. **Locked keyspace** — `collection_count`, `cohort_count`,
///    `sessions_closed_total`, and `sessions_auto_closed_total` are persisted
///    inside the `items` fjall keyspace (under `Tag::Collection`, `Tag::Cohort`,
///    and `Tag::Session` keys). Counting them requires *opening* that keyspace,
///    which fjall guards with an exclusive `lock` file. tidalctl is, by design,
///    a lock-free inspector that may run against a live instance, so it refuses
///    to acquire that lock. `usearch_directory_bytes` has no on-disk artifact at
///    all (the vector index is in-memory only).
const OFFLINE_UNAVAILABLE: &[(&str, &str)] = &[
    ("sessions_active", REASON_SESSIONS_ACTIVE),
    ("degradation_level", REASON_DEGRADATION),
    ("sessions_closed_total", REASON_LOCKED_KEYSPACE),
    ("sessions_auto_closed_total", REASON_LOCKED_KEYSPACE),
    ("collection_count", REASON_LOCKED_KEYSPACE),
    ("cohort_count", REASON_LOCKED_KEYSPACE),
    ("usearch_directory_bytes", REASON_USEARCH_IN_MEMORY),
];

// Single-sourced unavailability reasons. Both the JSON `offline_unavailable`
// map and the human-readable pretty output reference these exact strings, so
// the two surfaces can never drift apart.
const REASON_SESSIONS_ACTIVE: &str = "runtime-only: a closed database has no live sessions";
const REASON_DEGRADATION: &str = "runtime-only: load degradation exists only in a running instance";
const REASON_LOCKED_KEYSPACE: &str =
    "persisted in the locked items keyspace; lock-free inspector will not open it";
const REASON_USEARCH_IN_MEMORY: &str =
    "no on-disk artifact: the USearch vector index is in-memory only";

/// Build the `offline_unavailable` map (field name -> reason) for serialization.
///
/// `BTreeMap` keeps the output order stable across runs (handy for diffing /
/// snapshot tests) and serde owns all string escaping.
fn offline_unavailable_map() -> std::collections::BTreeMap<&'static str, &'static str> {
    OFFLINE_UNAVAILABLE.iter().copied().collect()
}

#[allow(clippy::too_many_arguments)]
fn format_pretty(
    version: &str,
    build_hash: &str,
    base: &std::path::Path,
    wal: &WalState,
    checkpoint_age_secs: Option<u64>,
    signal_estimated_entries: u64,
    wal_lag_bytes: u64,
    item_text: Option<(usize, u64)>,
    creator_text: Option<(usize, u64)>,
) -> String {
    use std::fmt::Write;

    let mut out = String::new();

    out.push_str("tidalDB Diagnostics\n");
    out.push_str("===================\n");
    let _ = writeln!(out, "Version:        {version} (build: {build_hash})");
    let _ = writeln!(out, "Data dir:       {}", base.display());
    out.push_str("Storage mode:   durable\n");
    out.push('\n');

    out.push_str("WAL\n");
    out.push_str("---\n");
    let _ = writeln!(out, "Segments:       {}", wal.segments);
    #[allow(clippy::cast_precision_loss)]
    let wal_mb = wal.wal_dir_bytes as f64 / 1_048_576.0;
    let _ = writeln!(out, "Total size:     {wal_mb:.1} MB");
    #[allow(clippy::cast_precision_loss)]
    let lag_mb = wal_lag_bytes as f64 / 1_048_576.0;
    let _ = writeln!(
        out,
        "Replay lag:     {lag_mb:.1} MB ({wal_lag_bytes} bytes uncheckpointed)"
    );
    let _ = writeln!(out, "First seq:      {}", wal.first_seq);
    let _ = writeln!(out, "Last seq:       {}", wal.last_segment_seq);
    out.push('\n');

    out.push_str("Checkpoint\n");
    out.push_str("----------\n");
    if let Some(age) = checkpoint_age_secs {
        // Format checkpoint timestamp as seconds since Unix epoch.
        let ts_secs = wal.checkpoint_ts / 1_000_000_000;
        let _ = writeln!(out, "Last checkpoint: {ts_secs}s Unix ({age}s ago)");
    } else {
        out.push_str("Last checkpoint: none\n");
    }
    let _ = writeln!(out, "WAL sequence:    {}", wal.checkpoint_seq);
    out.push('\n');

    out.push_str("Signal Ledger\n");
    out.push_str("-------------\n");
    let _ = writeln!(out, "Estimated entries: ~{signal_estimated_entries}");
    out.push('\n');

    out.push_str("Text Index (Tantivy)\n");
    out.push_str("--------------------\n");
    write_text_index_section(&mut out, item_text);
    out.push('\n');

    out.push_str("Creator Text Index (Tantivy)\n");
    out.push_str("----------------------------\n");
    write_text_index_section(&mut out, creator_text);
    out.push('\n');

    // Every "not available offline" line pulls its reason from the same
    // REASON_* constant the JSON `offline_unavailable` map uses, so the two
    // surfaces can never disagree about *why* a field is omitted.
    out.push_str("Vector Index (USearch)\n");
    out.push_str("---------------------\n");
    let _ = writeln!(
        out,
        "Directory size: not available offline ({REASON_USEARCH_IN_MEMORY})"
    );
    out.push('\n');

    out.push_str("Sessions\n");
    out.push_str("--------\n");
    let _ = writeln!(
        out,
        "Active:         not available offline ({REASON_SESSIONS_ACTIVE})"
    );
    let _ = writeln!(
        out,
        "Closed (total): not available offline ({REASON_LOCKED_KEYSPACE})"
    );
    let _ = writeln!(
        out,
        "Auto-closed:    not available offline ({REASON_LOCKED_KEYSPACE})"
    );
    out.push('\n');

    out.push_str("Degradation\n");
    out.push_str("-----------\n");
    let _ = writeln!(
        out,
        "Level:          not available offline ({REASON_DEGRADATION})"
    );
    out.push('\n');

    let _ = writeln!(
        out,
        "Collections:    not available offline ({REASON_LOCKED_KEYSPACE})"
    );
    let _ = writeln!(
        out,
        "Cohorts:        not available offline ({REASON_LOCKED_KEYSPACE})"
    );
    out.push('\n');

    out.push_str(
        "Note: a lock-free inspector cannot open the items keyspace (fjall holds an\n\
         exclusive lock) and cannot observe a running instance's in-memory state.\n\
         Run diagnostics against a live database for session, cohort, collection,\n\
         degradation, and vector-index figures.\n",
    );

    out
}

/// Render a Tantivy index's segment/doc counts, or "not readable" when the index
/// directory exists but could not be opened (the `None` case).
fn write_text_index_section(out: &mut String, stats: Option<(usize, u64)>) {
    use std::fmt::Write;
    match stats {
        Some((segments, docs)) => {
            let _ = writeln!(out, "Segments:       {segments}");
            let _ = writeln!(out, "Indexed docs:   {docs}");
        }
        None => {
            out.push_str(
                "Segments:       not readable (index present but could not be opened)\n\
                 Indexed docs:   not readable (index present but could not be opened)\n",
            );
        }
    }
}

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

    /// The offline-unavailable field set is maintained in two structurally
    /// separate places: the [`OFFLINE_UNAVAILABLE`] reason table and the
    /// always-`None` fields of [`DiagnosticsOutput`]. They must describe the
    /// exact same field set or the JSON's `offline_unavailable` map will claim a
    /// field is omitted that is actually populated (or omit one that reads as a
    /// silent `null`). This test serializes the real output shape `run` emits
    /// and asserts the set of `null`-valued top-level fields equals exactly the
    /// keys in `OFFLINE_UNAVAILABLE`, mechanically preventing the two from
    /// drifting. The legitimately-derivable Option fields
    /// (`checkpoint_age_seconds`, the Tantivy `*_segments`/`*_docs`) are
    /// populated with `Some` here so only the genuinely-offline nulls remain.
    #[test]
    fn offline_null_fields_match_unavailable_table() {
        let output = DiagnosticsOutput {
            version: "test",
            build_hash: "deadbeef",
            wal_segments: 1,
            wal_total_bytes: 1,
            wal_lag_bytes: 0,
            checkpoint_age_seconds: Some(0),
            checkpoint_wal_sequence: 0,
            signal_estimated_entries: 0,
            tantivy_segments: Some(0),
            tantivy_indexed_docs: Some(0),
            creator_tantivy_segments: Some(0),
            creator_tantivy_docs: Some(0),
            usearch_directory_bytes: None,
            sessions_active: None,
            sessions_closed_total: None,
            sessions_auto_closed_total: None,
            degradation_level: None,
            collection_count: None,
            cohort_count: None,
            offline_unavailable: offline_unavailable_map(),
        };

        let value: serde_json::Value =
            serde_json::to_value(&output).expect("DiagnosticsOutput serializes");
        let obj = value.as_object().expect("output is a JSON object");

        let null_fields: std::collections::BTreeSet<&str> = obj
            .iter()
            .filter(|(_, v)| v.is_null())
            .map(|(k, _)| k.as_str())
            .collect();

        let table_keys: std::collections::BTreeSet<&str> = OFFLINE_UNAVAILABLE
            .iter()
            .map(|(field, _)| *field)
            .collect();

        assert_eq!(
            null_fields, table_keys,
            "the always-None DiagnosticsOutput fields and the OFFLINE_UNAVAILABLE \
             table have drifted; keep both in sync"
        );

        // Every advertised key must also describe itself in the embedded map.
        for key in &table_keys {
            assert!(
                output.offline_unavailable.contains_key(*key),
                "offline_unavailable map is missing advertised field {key}"
            );
        }
    }
}
