//! Tests for the `y` (yank) hotkey, the `App::copy_to_clipboard` helper, the
//! 30-second compare-then-clear timer, and the audit-emission shape.
//!
//! These tests inject a [`FakeClipboard`] via [`App::set_clipboard_backend`]
//! so they run in any CI environment without a real X11/Wayland/macOS/Windows
//! clipboard service.

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

use tsafe_core::audit::{AuditClipboardContext, AuditContext, AuditEntry, AuditStatus};
use tsafe_tui::app::{sensitive_string_for_test, App, Screen, CLIPBOARD_TTL};
use tsafe_tui::clipboard::{FakeClipboard, UnavailableClipboard};
use tsafe_tui::state::{dispatch_input, AppInput};
use zeroize::Zeroizing;

type PwStore = Option<Zeroizing<String>>;

fn no_pw() -> PwStore {
    None
}

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
}

// ── ISC-31 / ISC-32: dispatch + helper invariants ────────────────────────────

#[test]
fn copy_to_clipboard_sets_clipboard_status_and_arms_timer() {
    let mut app = dashboard_app(vec!["API_KEY"]);
    let fake = FakeClipboard::default();
    app.set_clipboard_backend(Box::new(fake.clone()));

    let result = app.copy_to_clipboard("API_KEY", sensitive_string_for_test("supersecret"));

    assert!(result.is_ok(), "copy_to_clipboard returned Err: {result:?}");
    assert_eq!(
        fake.state.lock().unwrap().as_deref(),
        Some("supersecret"),
        "clipboard backend did not receive the secret value"
    );
    let status = app.status_message.as_deref().unwrap_or("");
    assert!(
        status.contains("Copied 'API_KEY'"),
        "expected status to mention copied key, got: {status:?}"
    );
    assert!(
        status.contains("30s"),
        "expected status to mention 30s TTL, got: {status:?}"
    );
    assert!(
        app.copied_at.is_some(),
        "copied_at must be armed after a successful copy"
    );
    assert!(
        app.copied_value.is_some(),
        "copied_value must hold the secret for compare-then-clear"
    );
}

#[test]
fn dispatch_y_with_no_vault_surfaces_visible_error() {
    // Dashboard with secret_keys but no session_vault — ensures `'y'` routes to
    // the new handler and surfaces the missing-session error rather than being
    // silently swallowed.
    let mut app = dashboard_app(vec!["API_KEY"]);
    let mut store = no_pw();

    let quit = dispatch_input(&mut app, AppInput::Char('y'), &mut store);

    assert!(!quit, "y must not quit");
    let status = app.status_message.as_deref().unwrap_or("");
    assert!(
        status.contains("vault session missing"),
        "expected missing-session error, got: {status:?}"
    );
}

// ── ISC-33: namespace-header guard ───────────────────────────────────────────

#[test]
fn dispatch_y_on_namespace_header_is_a_no_op() {
    // Two namespaced keys → one collapsed namespace header. Cursor on header,
    // `selected_key()` returns None, the handler must not touch the clipboard
    // or status bar.
    let mut app = dashboard_app(vec!["ns/A", "ns/B"]);
    app.collapse_all_namespaces();
    app.secret_cursor = 0; // first visible row is the `ns` header
    let fake = FakeClipboard::default();
    app.set_clipboard_backend(Box::new(fake.clone()));

    let mut store = no_pw();
    dispatch_input(&mut app, AppInput::Char('y'), &mut store);

    assert!(
        fake.state.lock().unwrap().is_none(),
        "clipboard must not be touched when cursor is on a namespace header"
    );
    assert!(
        app.status_message.is_none(),
        "status bar must not be set on a no-op yank, got: {:?}",
        app.status_message
    );
    assert!(app.copied_at.is_none());
    assert!(app.copied_value.is_none());
}

// ── ISC-34: audit entry shape ────────────────────────────────────────────────

#[test]
fn audit_entry_for_clipboard_copy_has_expected_shape() {
    let entry = AuditEntry::success("demo", "clipboard.copy", Some("API_KEY")).with_context(
        AuditContext::from_clipboard(AuditClipboardContext {
            ttl_secs: CLIPBOARD_TTL.as_secs(),
            reason: None,
            excluded_from_history: Some(true),
            cleared_verified: None,
        }),
    );

    assert_eq!(entry.operation, "clipboard.copy");
    assert_eq!(entry.key.as_deref(), Some("API_KEY"));
    assert_eq!(entry.profile, "demo");
    assert!(matches!(entry.status, AuditStatus::Success));
    assert!(
        entry.message.is_none(),
        "success entry must not carry a message"
    );

    let ctx = entry.context.clone().expect("context must be present");
    let clipboard = ctx
        .clipboard
        .expect("clipboard sub-context must be present");
    assert_eq!(clipboard.ttl_secs, 30);
    assert!(
        clipboard.reason.is_none(),
        "success entry must not carry a reason"
    );

    // Anti-criterion ISC-A1: serialized 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_for_clipboard_copy_failed_carries_reason() {
    let entry = AuditEntry::failure(
        "demo",
        "clipboard.copy_failed",
        Some("API_KEY"),
        "Clipboard unavailable: no display server",
    )
    .with_context(AuditContext::from_clipboard(AuditClipboardContext {
        ttl_secs: CLIPBOARD_TTL.as_secs(),
        reason: Some("Clipboard unavailable: no display server".into()),
        excluded_from_history: Some(false),
        cleared_verified: None,
    }));

    assert_eq!(entry.operation, "clipboard.copy_failed");
    assert!(matches!(entry.status, AuditStatus::Failure));
    assert!(entry.message.as_deref().unwrap().contains("display"));
    let ctx = entry.context.unwrap().clipboard.unwrap();
    assert_eq!(ctx.ttl_secs, 30);
    assert_eq!(
        ctx.reason.as_deref(),
        Some("Clipboard unavailable: no display server")
    );
}

// ── Auto-clear: ISC-20–23 ─────────────────────────────────────────────────────

#[test]
fn maybe_expire_before_ttl_is_a_no_op() {
    let mut app = dashboard_app(vec!["K"]);
    let fake = FakeClipboard::default();
    app.set_clipboard_backend(Box::new(fake.clone()));

    app.copy_to_clipboard("K", sensitive_string_for_test("v"))
        .unwrap();
    assert!(app.copied_at.is_some());

    app.maybe_expire_clipboard();

    assert_eq!(fake.state.lock().unwrap().as_deref(), Some("v"));
    assert!(
        app.copied_at.is_some(),
        "timer must not be reset before TTL"
    );
    assert!(app.copied_value.is_some());
}

#[test]
fn maybe_expire_after_ttl_clears_when_contents_match() {
    let mut app = dashboard_app(vec!["K"]);
    let fake = FakeClipboard::default();
    app.set_clipboard_backend(Box::new(fake.clone()));

    app.copy_to_clipboard("K", sensitive_string_for_test("v"))
        .unwrap();
    // Pretend TTL elapsed by rewinding the recorded instant.
    app.copied_at = Some(Instant::now() - CLIPBOARD_TTL - Duration::from_millis(1));

    app.maybe_expire_clipboard();

    assert_eq!(
        fake.state.lock().unwrap().as_deref(),
        Some(""),
        "clipboard must be cleared (set to empty string) when contents match"
    );
    assert!(app.copied_at.is_none(), "timer must reset after expiry");
    assert!(
        app.copied_value.is_none(),
        "secret must drop after expiry so it zeroizes"
    );
}

#[test]
fn maybe_expire_after_ttl_leaves_clipboard_alone_when_contents_differ() {
    let mut app = dashboard_app(vec!["K"]);
    let fake = FakeClipboard::default();
    app.set_clipboard_backend(Box::new(fake.clone()));

    app.copy_to_clipboard("K", sensitive_string_for_test("secret"))
        .unwrap();
    // Simulate the user copying something else mid-window.
    *fake.state.lock().unwrap() = Some("user's other copy".into());
    app.copied_at = Some(Instant::now() - CLIPBOARD_TTL - Duration::from_millis(1));

    app.maybe_expire_clipboard();

    assert_eq!(
        fake.state.lock().unwrap().as_deref(),
        Some("user's other copy"),
        "auto-clear must not clobber a value the user copied after the secret"
    );
    assert!(app.copied_value.is_none(), "secret must still drop");
}

// ── Headless detection: ISC-27–30 ────────────────────────────────────────────

#[test]
fn copy_to_clipboard_with_unavailable_backend_returns_init_error() {
    let mut app = dashboard_app(vec!["K"]);
    app.set_clipboard_backend(Box::new(UnavailableClipboard));

    let result = app.copy_to_clipboard("K", sensitive_string_for_test("v"));

    let err = result.expect_err("must surface error when backend cannot init");
    let msg = err.to_string();
    assert!(
        msg.starts_with("Clipboard unavailable:"),
        "headless error must read clearly to the user, got: {msg:?}"
    );
    assert!(
        app.copied_at.is_none(),
        "no timer should arm when the copy never happened"
    );
    assert!(app.copied_value.is_none());
    assert!(
        app.status_message.is_none(),
        "copy_to_clipboard does not set status itself on failure — caller does, after audit"
    );
}

// ── L1: concealment hint + audit truthiness ──────────────────────────────────

#[test]
fn copy_to_clipboard_uses_set_secret_text_not_set_text() {
    // Regression guard: if a future refactor switches App::copy_to_clipboard
    // back to plain set_text, this test fails — the secret would land on the
    // pasteboard without org.nspasteboard.ConcealedType and Maccy would
    // archive it.
    let mut app = dashboard_app(vec!["K"]);
    let fake = FakeClipboard::default();
    app.set_clipboard_backend(Box::new(fake.clone()));

    let result = app
        .copy_to_clipboard("K", sensitive_string_for_test("v"))
        .expect("copy must succeed");

    assert_eq!(
        result.excluded_from_history,
        Some(true),
        "FakeClipboard mirrors the macOS production path; outcome must signal concealment"
    );
    assert!(
        *fake.last_was_secret.lock().unwrap(),
        "the backend's secret-write code path must be the one exercised, not plain set_text"
    );
}

#[test]
fn audit_entry_clipboard_cleared_carries_verification_flag() {
    let entry = AuditEntry::success("demo", "clipboard.cleared", None).with_context(
        AuditContext::from_clipboard(AuditClipboardContext {
            ttl_secs: CLIPBOARD_TTL.as_secs(),
            reason: None,
            excluded_from_history: None,
            cleared_verified: Some(true),
        }),
    );

    let ctx = entry.context.unwrap().clipboard.unwrap();
    assert_eq!(
        ctx.cleared_verified,
        Some(true),
        "clipboard.cleared entries must record that the verification step ran and matched"
    );
    assert!(
        ctx.excluded_from_history.is_none(),
        "the cleared entry shouldn't claim concealment — that lives on the original copy entry"
    );
}

#[test]
fn audit_entry_clipboard_preserved_records_user_overwrote() {
    let entry = AuditEntry::success("demo", "clipboard.preserved", None).with_context(
        AuditContext::from_clipboard(AuditClipboardContext {
            ttl_secs: CLIPBOARD_TTL.as_secs(),
            reason: None,
            excluded_from_history: None,
            cleared_verified: Some(false),
        }),
    );

    let ctx = entry.context.unwrap().clipboard.unwrap();
    assert_eq!(ctx.cleared_verified, Some(false));
    assert_eq!(entry.operation, "clipboard.preserved");
}

// ── Anti-criteria: defensive guards ──────────────────────────────────────────

#[test]
fn debug_render_of_app_redacts_copied_value() {
    let mut app = dashboard_app(vec!["K"]);
    app.set_clipboard_backend(Box::new(FakeClipboard::default()));
    app.copy_to_clipboard("K", sensitive_string_for_test("supersecret"))
        .unwrap();

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

    assert!(
        !rendered.contains("supersecret"),
        "Debug impl must redact copied_value, got: {rendered}"
    );
    assert!(
        rendered.contains("<redacted>"),
        "Debug impl must show a redaction marker, got: {rendered}"
    );
}
