//! Tests for the reveal auto-conceal feature: per-reveal TTL, the
//! `App::maybe_expire_reveals` tick hook, and the `secret.reveal_started` /
//! `secret.reveal_expired` audit pair.
//!
//! Time-based assertions rewind `Reveal::revealed_at` rather than sleeping
//! for the full TTL — same trick as the clipboard tests.

use std::time::{Duration, Instant};

use tsafe_core::audit::{AuditContext, AuditEntry, AuditRevealContext, AuditStatus};
use tsafe_tui::app::{sensitive_string_for_test, App, Reveal, Screen, REVEAL_TTL};

fn dashboard_app(keys: Vec<&str>) -> App {
    let mut app = App::new_for_test(vec!["demo".into()], Screen::Dashboard);
    app.active_profile = Some("demo".into());
    app.secret_keys = keys.into_iter().map(String::from).collect();
    app
}

fn make_reveal(value: &str) -> Reveal {
    Reveal {
        value: sensitive_string_for_test(value),
        revealed_at: Instant::now(),
    }
}

// ── maybe_expire_reveals: the three lifecycle states ────────────────────────

#[test]
fn maybe_expire_reveals_is_noop_when_revealed_is_empty() {
    let mut app = dashboard_app(vec!["K"]);
    assert!(app.revealed.is_empty());

    app.maybe_expire_reveals();

    assert!(app.revealed.is_empty());
    assert!(
        app.status_message.is_none(),
        "no expiry happened, status must not be touched"
    );
}

#[test]
fn maybe_expire_reveals_is_noop_when_entries_are_within_ttl() {
    let mut app = dashboard_app(vec!["K"]);
    app.revealed.insert("K".into(), make_reveal("supersecret"));

    app.maybe_expire_reveals();

    assert_eq!(
        app.revealed.len(),
        1,
        "fresh reveal must not be evicted by the tick hook"
    );
    assert!(app.status_message.is_none());
}

#[test]
fn maybe_expire_reveals_evicts_entries_past_ttl() {
    let mut app = dashboard_app(vec!["K1", "K2"]);
    let stale = Reveal {
        value: sensitive_string_for_test("supersecret"),
        revealed_at: Instant::now() - REVEAL_TTL - Duration::from_millis(1),
    };
    let fresh = make_reveal("still-fresh");
    app.revealed.insert("K1".into(), stale);
    app.revealed.insert("K2".into(), fresh);

    app.maybe_expire_reveals();

    assert!(
        !app.revealed.contains_key("K1"),
        "stale entry must be evicted"
    );
    assert!(app.revealed.contains_key("K2"), "fresh entry must survive");
    let status = app.status_message.as_deref().unwrap_or("");
    assert!(
        status.contains("auto-concealed"),
        "status must announce auto-conceal, got: {status:?}"
    );
}

#[test]
fn maybe_expire_reveals_evicts_multiple_entries_in_one_tick() {
    let mut app = dashboard_app(vec!["A", "B", "C"]);
    let stale = || Reveal {
        value: sensitive_string_for_test("v"),
        revealed_at: Instant::now() - REVEAL_TTL - Duration::from_millis(1),
    };
    app.revealed.insert("A".into(), stale());
    app.revealed.insert("B".into(), stale());
    app.revealed.insert("C".into(), stale());

    app.maybe_expire_reveals();

    assert!(
        app.revealed.is_empty(),
        "all three stale entries must be evicted in a single tick"
    );
    let status = app.status_message.as_deref().unwrap_or("");
    assert!(
        status.contains("3 reveal"),
        "status must report the count, got: {status:?}"
    );
}

// ── Audit shape ─────────────────────────────────────────────────────────────

#[test]
fn audit_entry_secret_reveal_started_has_expected_shape() {
    let entry = AuditEntry::success("demo", "secret.reveal_started", Some("API_KEY")).with_context(
        AuditContext::from_reveal(AuditRevealContext {
            ttl_secs: REVEAL_TTL.as_secs(),
        }),
    );

    assert_eq!(entry.operation, "secret.reveal_started");
    assert_eq!(entry.key.as_deref(), Some("API_KEY"));
    assert!(matches!(entry.status, AuditStatus::Success));
    let ctx = entry.context.clone().unwrap().reveal.unwrap();
    assert_eq!(ctx.ttl_secs, 60);

    // Anti-criterion ISC-A1: serialised form must NOT contain the secret value.
    let json = serde_json::to_string(&entry).unwrap();
    assert!(
        !json.contains("supersecret"),
        "audit JSON leaked something it should not: {json}"
    );
}

#[test]
fn audit_entry_secret_reveal_expired_carries_ttl() {
    let entry = AuditEntry::success("demo", "secret.reveal_expired", Some("API_KEY")).with_context(
        AuditContext::from_reveal(AuditRevealContext {
            ttl_secs: REVEAL_TTL.as_secs(),
        }),
    );

    assert_eq!(entry.operation, "secret.reveal_expired");
    let ctx = entry.context.unwrap().reveal.unwrap();
    assert_eq!(
        ctx.ttl_secs, 60,
        "expired entry must record the TTL the user implicitly accepted"
    );
}

// ── Anti-criteria: Debug never leaks reveal values ──────────────────────────

#[test]
fn debug_render_of_app_redacts_revealed_entries() {
    let mut app = dashboard_app(vec!["K"]);
    app.revealed
        .insert("K".into(), make_reveal("supersecret-reveal"));

    let rendered = format!("{app:?}");

    assert!(
        !rendered.contains("supersecret-reveal"),
        "Debug impl must redact reveal values, got: {rendered}"
    );
    assert!(
        rendered.contains("redacted"),
        "Debug impl must show a redaction marker for reveals, got: {rendered}"
    );
}

// ── Existing reveal toggle UX is preserved ──────────────────────────────────

#[test]
fn reveal_map_remove_zeroes_via_sensitive_string_drop() {
    // We can't directly observe heap-zeroing, but we CAN observe that the
    // `Reveal` value is a `SensitiveString` (= `Zeroizing<String>`). If a
    // future refactor switches the field type back to a raw `String`, the
    // type-check here fails — caught by the test, not by post-mortem.
    let r: Reveal = make_reveal("v");
    let _: &tsafe_tui::app::SensitiveString = &r.value;
    // Drop happens at end-of-scope; SensitiveString::drop zeroes the buffer.
}
