//! Structured health data model for the `doctor` substrate.
//!
//! This module provides a pure data-collection layer: no I/O side effects beyond
//! reading vault/snapshot/audit files that already exist on disk.  The structs are
//! `Serialize`-able so higher layers (e.g. `tsafe doctor --json`) can emit them
//! directly without re-shaping the data.
//!
//! # Design principles
//!
//! - No password / crypto: health collection intentionally works on the **on-disk
//!   metadata** (schema field, cipher label, file presence, snapshot count, audit
//!   timestamps) rather than decrypting any secret values.  This keeps the health
//!   check fast and usable in monitoring scripts that do not hold the master password.
//! - No panics, only `Option`/`Result` edges: every field that might be absent due to
//!   a missing file or a parse error is typed as `Option<T>` so callers can act on the
//!   specific gap instead of silently dropping information.
//! - Additive: later lanes or callers may embed `HealthReport` in larger structures or
//!   add new fields without breaking existing JSON consumers.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::audit::AuditLog;
use crate::profile;
use crate::snapshot;
use crate::vault::VaultFile;

// ── Signal severity ───────────────────────────────────────────────────────────

/// Severity level for a single health signal.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SignalSeverity {
    /// No issue detected; the component is operating as expected.
    Ok,
    /// Informational note — not an error but worth surfacing.
    Info,
    /// Minor concern that should be investigated but is not blocking.
    Warning,
    /// Critical problem that requires immediate attention.
    Critical,
}

impl SignalSeverity {
    /// Returns `true` when the severity is `Warning` or worse.
    pub fn is_degraded(self) -> bool {
        self >= Self::Warning
    }
}

// ── Individual signal ─────────────────────────────────────────────────────────

/// A single machine-readable health signal.
///
/// Signals are the atomic unit of the health model.  Each carries a stable
/// `code` (dot-namespaced, e.g. `vault.reachable`) so monitoring scripts can
/// key on it without parsing the human-readable `message`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HealthSignal {
    /// Stable dot-namespaced code for this signal (e.g. `vault.reachable`).
    pub code: String,
    /// Severity of this signal.
    pub severity: SignalSeverity,
    /// Human-readable summary of the signal.
    pub message: String,
    /// Optional additional detail (e.g. a parse-error string).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
}

impl HealthSignal {
    fn ok(code: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            code: code.into(),
            severity: SignalSeverity::Ok,
            message: message.into(),
            detail: None,
        }
    }

    fn info(code: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            code: code.into(),
            severity: SignalSeverity::Info,
            message: message.into(),
            detail: None,
        }
    }

    fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            code: code.into(),
            severity: SignalSeverity::Warning,
            message: message.into(),
            detail: None,
        }
    }

    fn critical(
        code: impl Into<String>,
        message: impl Into<String>,
        detail: Option<String>,
    ) -> Self {
        Self {
            code: code.into(),
            severity: SignalSeverity::Critical,
            message: message.into(),
            detail,
        }
    }
}

// ── Vault metadata snapshot ───────────────────────────────────────────────────

/// Parsed metadata collected from the vault file without decrypting any secrets.
///
/// `None` fields indicate the vault file was absent or could not be parsed.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct VaultHealthMeta {
    /// The vault schema string (e.g. `"tsafe/vault/v1"`).
    pub schema: String,
    /// The cipher label (e.g. `"xchacha20poly1305"`).
    pub cipher: String,
    /// When the vault was first created.
    pub created_at: DateTime<Utc>,
    /// When the vault was last written.
    pub updated_at: DateTime<Utc>,
    /// Number of secrets currently stored (encrypted count — no decryption needed).
    pub secret_count: usize,
    /// Whether this is a team vault (has `age_recipients`).
    pub is_team_vault: bool,
}

// ── Overall health status ─────────────────────────────────────────────────────

/// Coarse overall health verdict derived from the worst active signal.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OverallHealth {
    /// All signals are `Ok` or `Info`.
    Healthy,
    /// At least one `Warning` signal is present but no `Critical` ones.
    Degraded,
    /// At least one `Critical` signal is present.
    Critical,
}

// ── Health report ─────────────────────────────────────────────────────────────

/// The full structured health report for a single profile.
///
/// Produced by [`collect_health`].  No decryption is performed — the report is
/// safe to pass to untrusted consumers (monitoring agents, log pipelines) that
/// should not hold the vault master password.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HealthReport {
    /// The profile this report describes.
    pub profile: String,
    /// UTC timestamp when the report was generated.
    pub generated_at: DateTime<Utc>,
    /// Coarse overall verdict derived from the worst signal severity.
    pub overall: OverallHealth,
    /// Whether the vault file exists and is reachable on disk.
    pub vault_reachable: bool,
    /// Parsed vault metadata; `None` if the vault is absent or unparseable.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub vault_meta: Option<VaultHealthMeta>,
    /// Number of profiles that have an existing vault file on this installation.
    pub profile_count: usize,
    /// Number of snapshots available for this profile.
    pub snapshot_count: usize,
    /// Timestamp of the most recent audit log entry for this profile, if any.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_audit_entry_at: Option<DateTime<Utc>>,
    /// Timestamp of the most recent successful `rotate` operation in the audit log.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_rotate_at: Option<DateTime<Utc>>,
    /// Total number of audit log entries visible within the scan window.
    pub audit_entry_count: usize,
    /// Structured signals, sorted by severity descending then by code ascending.
    pub signals: Vec<HealthSignal>,
    /// Number of `Ok` signals.
    pub ok_count: usize,
    /// Number of `Info` signals.
    pub info_count: usize,
    /// Number of `Warning` signals.
    pub warning_count: usize,
    /// Number of `Critical` signals.
    pub critical_count: usize,
}

impl HealthReport {
    /// Convenience: `true` when `overall == Healthy`.
    pub fn is_healthy(&self) -> bool {
        self.overall == OverallHealth::Healthy
    }
}

// ── Collection ────────────────────────────────────────────────────────────────

/// Audit-log scan depth used by the health collector.
///
/// A finite window keeps collection fast even on large audit logs.  8 000 entries
/// is generous for typical installations while bounding the scan to a few MiB.
const AUDIT_SCAN_LIMIT: usize = 8_000;

/// Collect a [`HealthReport`] for `profile`.
///
/// # Behaviour
///
/// - Reads the vault file path from `profile::vault_path`, checks existence, and
///   attempts a metadata-only parse (no password needed).
/// - Lists snapshots via `snapshot::list`.
/// - Queries the audit log for the most recent entry and the most recent
///   successful `rotate` entry.
/// - Lists all known profiles via `profile::list_profiles`.
/// - No crypto, no decryption, no I/O side effects beyond the reads above.
///
/// # Errors
///
/// This function is infallible at the top level — individual failures are surfaced
/// as `Critical` or `Warning` signals rather than returned as `Err`.
pub fn collect_health(profile: &str) -> HealthReport {
    let generated_at = Utc::now();
    let mut signals: Vec<HealthSignal> = Vec::new();

    // ── vault reachability ────────────────────────────────────────────────────
    let vault_path = profile::vault_path(profile);
    let vault_reachable = vault_path.exists();

    if vault_reachable {
        signals.push(HealthSignal::ok(
            "vault.reachable",
            format!("vault file found: {}", vault_path.display()),
        ));
    } else {
        signals.push(HealthSignal::critical(
            "vault.reachable",
            format!(
                "vault file not found for profile '{profile}' — run: tsafe --profile {profile} init"
            ),
            None,
        ));
    }

    // ── vault metadata parse ──────────────────────────────────────────────────
    let vault_meta: Option<VaultHealthMeta> = if vault_reachable {
        match std::fs::read_to_string(&vault_path) {
            Ok(json) => match serde_json::from_str::<VaultFile>(&json) {
                Ok(vf) => {
                    // Schema check
                    if matches!(vf.schema.as_str(), "tsafe/vault/v1" | "tsafe/vault/v2") {
                        signals.push(HealthSignal::ok(
                            "vault.schema",
                            format!("vault schema: {}", vf.schema),
                        ));
                    } else {
                        signals.push(HealthSignal::critical(
                            "vault.schema",
                            format!("unknown vault schema: {}", vf.schema),
                            None,
                        ));
                    }

                    // Cipher check
                    if crate::crypto::parse_cipher_kind(&vf.cipher).is_ok() {
                        signals.push(HealthSignal::ok(
                            "vault.cipher",
                            format!("cipher: {}", vf.cipher),
                        ));
                    } else {
                        signals.push(HealthSignal::critical(
                            "vault.cipher",
                            format!("unrecognised cipher: {}", vf.cipher),
                            None,
                        ));
                    }

                    let is_team = !vf.age_recipients.is_empty() && vf.wrapped_dek.is_some();
                    let secret_count = vf.secrets.len();

                    signals.push(HealthSignal::info(
                        "vault.secret_count",
                        format!("{secret_count} secret(s) stored in vault"),
                    ));

                    Some(VaultHealthMeta {
                        schema: vf.schema,
                        cipher: vf.cipher,
                        created_at: vf.created_at,
                        updated_at: vf.updated_at,
                        secret_count,
                        is_team_vault: is_team,
                    })
                }
                Err(e) => {
                    signals.push(HealthSignal::critical(
                        "vault.parse",
                        "vault file could not be parsed as valid vault JSON",
                        Some(e.to_string()),
                    ));
                    None
                }
            },
            Err(e) => {
                signals.push(HealthSignal::critical(
                    "vault.read",
                    "vault file exists but could not be read",
                    Some(e.to_string()),
                ));
                None
            }
        }
    } else {
        None
    };

    // ── snapshot count ────────────────────────────────────────────────────────
    let snapshot_count = match snapshot::list(profile) {
        Ok(snaps) if snaps.is_empty() => {
            signals.push(HealthSignal::info(
                "vault.snapshots",
                "no snapshots yet — a snapshot is created on the next write operation",
            ));
            0
        }
        Ok(snaps) => {
            let n = snaps.len();
            signals.push(HealthSignal::ok(
                "vault.snapshots",
                format!("{n} snapshot(s) available for this profile"),
            ));
            n
        }
        Err(e) => {
            signals.push(HealthSignal::critical(
                "vault.snapshots",
                "could not list snapshots",
                Some(e.to_string()),
            ));
            0
        }
    };

    // ── profile count ─────────────────────────────────────────────────────────
    let profile_count = match profile::list_profiles() {
        Ok(profiles) => {
            let n = profiles.len();
            signals.push(HealthSignal::info(
                "profiles.count",
                format!("{n} profile(s) found on this installation"),
            ));
            n
        }
        Err(e) => {
            signals.push(HealthSignal::warning(
                "profiles.count",
                format!("could not enumerate profiles: {e}"),
            ));
            0
        }
    };

    // ── audit log ─────────────────────────────────────────────────────────────
    let audit_log = AuditLog::new(&profile::audit_log_path(profile));

    let (last_audit_entry_at, last_rotate_at, audit_entry_count) =
        match audit_log.read(Some(AUDIT_SCAN_LIMIT)) {
            Ok(entries) => {
                let count = entries.len();
                let last_ts = entries.first().map(|e| e.timestamp);
                let last_rotate = entries
                    .iter()
                    .find(|e| {
                        e.profile == profile
                            && e.operation == "rotate"
                            && matches!(e.status, crate::audit::AuditStatus::Success)
                    })
                    .map(|e| e.timestamp);

                if count == 0 {
                    signals.push(HealthSignal::info(
                        "audit.entries",
                        "audit log is empty or does not exist yet",
                    ));
                } else {
                    if let Some(ts) = last_ts {
                        signals.push(HealthSignal::info(
                            "audit.last_entry",
                            format!("most recent audit entry: {ts}"),
                        ));
                    }
                    if count == AUDIT_SCAN_LIMIT {
                        signals.push(HealthSignal::info(
                            "audit.scan_limit",
                            format!(
                            "audit scan capped at {AUDIT_SCAN_LIMIT} entries — log may be larger"
                        ),
                        ));
                    }
                }

                (last_ts, last_rotate, count)
            }
            Err(e) => {
                signals.push(HealthSignal::warning(
                    "audit.read",
                    format!("could not read audit log: {e}"),
                ));
                (None, None, 0)
            }
        };

    // ── derive overall status ─────────────────────────────────────────────────
    let mut ok_count = 0usize;
    let mut info_count = 0usize;
    let mut warning_count = 0usize;
    let mut critical_count = 0usize;

    for sig in &signals {
        match sig.severity {
            SignalSeverity::Ok => ok_count += 1,
            SignalSeverity::Info => info_count += 1,
            SignalSeverity::Warning => warning_count += 1,
            SignalSeverity::Critical => critical_count += 1,
        }
    }

    let overall = if critical_count > 0 {
        OverallHealth::Critical
    } else if warning_count > 0 {
        OverallHealth::Degraded
    } else {
        OverallHealth::Healthy
    };

    // Sort signals: Critical > Warning > Info > Ok, then by code within each tier.
    signals.sort_by(|a, b| b.severity.cmp(&a.severity).then(a.code.cmp(&b.code)));

    HealthReport {
        profile: profile.to_string(),
        generated_at,
        overall,
        vault_reachable,
        vault_meta,
        profile_count,
        snapshot_count,
        last_audit_entry_at,
        last_rotate_at,
        audit_entry_count,
        signals,
        ok_count,
        info_count,
        warning_count,
        critical_count,
    }
}

// ── unit tests ────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::vault::Vault;
    use std::collections::HashMap;
    use tempfile::tempdir;

    fn pw() -> &'static [u8] {
        b"health-test-password"
    }

    /// Set TSAFE_VAULT_DIR to a `vaults/` subdirectory of `root` so that the
    /// state directory (`root/state/audit/`) is isolated within the tempdir
    /// rather than leaking into the shared system-temp parent.  Without this,
    /// parallel tests all write to the same `<system-tmp>/state/audit/` path
    /// because `app_state_dir()` uses `parent(TSAFE_VAULT_DIR)/state`.
    fn with_vault_dir<F: FnOnce()>(root: &std::path::Path, f: F) {
        let vault_subdir = root.join("vaults");
        std::fs::create_dir_all(&vault_subdir).unwrap();
        temp_env::with_var("TSAFE_VAULT_DIR", Some(vault_subdir.to_str().unwrap()), f);
    }

    #[test]
    fn missing_vault_yields_critical_signal_and_no_meta() {
        let dir = tempdir().unwrap();
        with_vault_dir(dir.path(), || {
            let report = collect_health("nonexistent");
            assert!(!report.vault_reachable);
            assert_eq!(report.overall, OverallHealth::Critical);
            assert!(report.vault_meta.is_none());
            assert_eq!(report.snapshot_count, 0);

            let critical = report
                .signals
                .iter()
                .find(|s| s.code == "vault.reachable")
                .unwrap();
            assert_eq!(critical.severity, SignalSeverity::Critical);
        });
    }

    #[test]
    fn healthy_vault_yields_healthy_overall() {
        let dir = tempdir().unwrap();
        with_vault_dir(dir.path(), || {
            // Create a vault so health collection can find it.
            let vault_path = crate::profile::vault_path("test");
            std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
            Vault::create(&vault_path, pw()).unwrap();

            let report = collect_health("test");
            assert!(report.vault_reachable);
            assert_eq!(report.overall, OverallHealth::Healthy);
            assert!(report.vault_meta.is_some());

            let meta = report.vault_meta.unwrap();
            assert_eq!(meta.secret_count, 0);
            assert!(!meta.is_team_vault);
            assert!(meta.schema.starts_with("tsafe/vault/"));
        });
    }

    #[test]
    fn secret_count_is_reflected_in_meta() {
        let dir = tempdir().unwrap();
        with_vault_dir(dir.path(), || {
            let vault_path = crate::profile::vault_path("counting");
            std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
            let mut vault = Vault::create(&vault_path, pw()).unwrap();
            vault.set("A", "alpha", HashMap::new()).unwrap();
            vault.set("B", "beta", HashMap::new()).unwrap();
            drop(vault);

            let report = collect_health("counting");
            assert_eq!(report.vault_meta.unwrap().secret_count, 2);

            // The info signal for secret count should be present.
            let sig = report
                .signals
                .iter()
                .find(|s| s.code == "vault.secret_count")
                .unwrap();
            assert_eq!(sig.severity, SignalSeverity::Info);
            assert!(sig.message.contains("2 secret(s)"));
        });
    }

    #[test]
    fn snapshot_count_is_reflected() {
        let dir = tempdir().unwrap();
        with_vault_dir(dir.path(), || {
            let vault_path = crate::profile::vault_path("snap-profile");
            std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
            let mut vault = Vault::create(&vault_path, pw()).unwrap();
            // Each write produces a snapshot (after the first).
            vault.set("K", "v1", HashMap::new()).unwrap();
            vault.set("K", "v2", HashMap::new()).unwrap();
            drop(vault);

            let report = collect_health("snap-profile");
            // At least 1 snapshot should exist (second write triggers one).
            assert!(report.snapshot_count >= 1);
        });
    }

    #[test]
    fn profile_count_includes_created_profiles() {
        let dir = tempdir().unwrap();
        with_vault_dir(dir.path(), || {
            for name in ["p1", "p2", "p3"] {
                let vault_path = crate::profile::vault_path(name);
                std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
                Vault::create(&vault_path, pw()).unwrap();
            }

            let report = collect_health("p1");
            assert_eq!(report.profile_count, 3);
        });
    }

    #[test]
    fn audit_timestamps_are_collected() {
        let dir = tempdir().unwrap();
        with_vault_dir(dir.path(), || {
            // Seed the audit log manually.
            let log_path = crate::profile::audit_log_path("audited");
            std::fs::create_dir_all(log_path.parent().unwrap()).unwrap();
            let log = crate::audit::AuditLog::new(&log_path);
            log.append(&crate::audit::AuditEntry::success(
                "audited",
                "set",
                Some("K"),
            ))
            .unwrap();
            log.append(&crate::audit::AuditEntry::success(
                "audited", "rotate", None,
            ))
            .unwrap();

            let report = collect_health("audited");
            assert!(report.last_audit_entry_at.is_some());
            assert!(report.last_rotate_at.is_some());
            assert_eq!(report.audit_entry_count, 2);
        });
    }

    #[test]
    fn empty_audit_log_yields_zero_counts() {
        let dir = tempdir().unwrap();
        with_vault_dir(dir.path(), || {
            let report = collect_health("no-audit");
            assert!(report.last_audit_entry_at.is_none());
            assert!(report.last_rotate_at.is_none());
            assert_eq!(report.audit_entry_count, 0);
        });
    }

    #[test]
    fn report_is_serializable_to_json() {
        let dir = tempdir().unwrap();
        with_vault_dir(dir.path(), || {
            let report = collect_health("json-test");
            let json = serde_json::to_string_pretty(&report).unwrap();
            assert!(json.contains("\"profile\""));
            assert!(json.contains("\"overall\""));
            assert!(json.contains("\"signals\""));
            // Round-trip: the JSON must deserialize back to a HealthReport.
            let _: HealthReport = serde_json::from_str(&json).unwrap();
        });
    }

    #[test]
    fn signals_are_sorted_critical_first() {
        let dir = tempdir().unwrap();
        with_vault_dir(dir.path(), || {
            // No vault → critical signals will be present.
            let report = collect_health("sort-test");
            // First signal must be the worst severity (Critical in this case).
            if let (Some(first), Some(last)) = (report.signals.first(), report.signals.last()) {
                assert!(first.severity >= last.severity);
            }
        });
    }

    #[test]
    fn corrupt_vault_file_yields_critical_parse_signal() {
        let dir = tempdir().unwrap();
        with_vault_dir(dir.path(), || {
            let vault_path = crate::profile::vault_path("corrupt");
            std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
            std::fs::write(&vault_path, b"not valid json at all").unwrap();

            let report = collect_health("corrupt");
            assert!(report.vault_reachable); // file exists
            assert_eq!(report.overall, OverallHealth::Critical);
            assert!(report.vault_meta.is_none()); // couldn't parse
            let sig = report
                .signals
                .iter()
                .find(|s| s.code == "vault.parse")
                .unwrap();
            assert_eq!(sig.severity, SignalSeverity::Critical);
        });
    }

    #[test]
    fn overall_health_is_healthy_when_no_warnings() {
        let dir = tempdir().unwrap();
        with_vault_dir(dir.path(), || {
            let vault_path = crate::profile::vault_path("all-ok");
            std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
            Vault::create(&vault_path, pw()).unwrap();

            let report = collect_health("all-ok");
            assert_eq!(report.overall, OverallHealth::Healthy);
            assert_eq!(report.critical_count, 0);
            assert_eq!(report.warning_count, 0);
        });
    }

    #[test]
    fn signal_severity_ordering_is_correct() {
        // OverallHealth::Critical > Warning > Info > Ok (Ord is derived).
        assert!(SignalSeverity::Critical > SignalSeverity::Warning);
        assert!(SignalSeverity::Warning > SignalSeverity::Info);
        assert!(SignalSeverity::Info > SignalSeverity::Ok);
        assert!(SignalSeverity::Critical.is_degraded());
        assert!(SignalSeverity::Warning.is_degraded());
        assert!(!SignalSeverity::Info.is_degraded());
        assert!(!SignalSeverity::Ok.is_degraded());
    }
}
