//! Adversarial agent-session tests — task 4.4 of the whole-product maturity program.
//!
//! Coverage:
//!   1. PID reuse attack
//!   2. Session TTL staleness (idle and absolute)
//!   3. Stale state after a simulated agent restart
//!   4. Concurrent session exhaustion / integrity under load
//!   5. Invalid / malformed session tokens (no panic, clear error)

use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use tsafe_core::agent::{AgentRequest, AgentResponse, AgentSession, AgentSessionState};

// ── Helper ────────────────────────────────────────────────────────────────────

/// Run a single request through a fresh session whose absolute deadline is
/// `now + absolute_secs` (or `now - 1s` when `absolute_secs == 0` to simulate
/// a pre-expired session).
fn run_once(
    req: AgentRequest,
    peer_pid: Option<u32>,
    token: &str,
    idle_secs: u64,
    absolute_secs: i64, // negative → deadline in the past
) -> (AgentResponse, AgentSessionState) {
    let now = Instant::now();
    let absolute_deadline = if absolute_secs >= 0 {
        now + Duration::from_secs(absolute_secs as u64)
    } else {
        now - Duration::from_secs((-absolute_secs) as u64)
    };
    let mut session = AgentSession::new(token, idle_secs, absolute_deadline);
    let outcome = session.handle_request(&req, peer_pid, "vault-password", now);
    (outcome.response, outcome.state)
}

// ═══════════════════════════════════════════════════════════════════════════════
// 1. PID Reuse Attack
// ═══════════════════════════════════════════════════════════════════════════════

/// Process A (`pid = 1000`) holds a valid session.  Process A dies.  Process B
/// gets the same OS PID (`1000`) and connects to the agent.  The wire format of
/// its `OpenVault` request is identical to A's — but the *peer_pid* seen by the
/// daemon (from SO_PEERCRED / GetNamedPipeClientProcessId) is still 1000.
///
/// This test proves that the session model does not rely solely on the claimed
/// `requesting_pid` field inside the JSON; the daemon must also compare the
/// *transport-level* peer PID.  By faking the peer PID we confirm the rejection
/// path works.
///
/// In the realistic attack the transport-level PID **also** equals 1000 (B reused
/// the number), so this scenario exercises the second, weaker part of the
/// defence: the random session token.  A fresh process cannot know the token, so
/// even if peer_pid matches it must present the correct token.
#[test]
fn agent_pid_reuse_attack_rejected_without_valid_session_token() {
    // Session was created for process A with token "original-token-abc".
    let now = Instant::now();
    let absolute = now + Duration::from_secs(300);
    let mut session = AgentSession::new("original-token-abc", 300, absolute);

    // Process A held the session and knew the token; it exited.
    // Process B gets the same PID (1000) but does NOT know the token.
    let pid_reuse_req = AgentRequest::OpenVault {
        profile: "default".into(),
        session_token: "guessed-wrong-token".into(),
        requesting_pid: 1000,
    };

    let outcome = session.handle_request(&pid_reuse_req, Some(1000), "vault-password", now);

    assert!(
        !outcome.stop,
        "a rejected PID-reuse attempt must not stop the agent (still valid for other callers)"
    );
    assert_eq!(
        outcome.state,
        AgentSessionState::Active,
        "session remains active after a failed PID-reuse attempt"
    );
    match outcome.response {
        AgentResponse::Err { reason } => {
            assert!(
                reason.contains("invalid session token"),
                "rejection reason must mention the token: {reason}"
            );
        }
        other => panic!("expected Err, got {other:?}"),
    }
}

/// Variant: attacker guesses the correct token but connects from the wrong PID.
/// This simulates a different process on the same machine trying to impersonate.
#[test]
fn agent_pid_reuse_attack_rejected_when_transport_pid_mismatches_claim() {
    let now = Instant::now();
    let absolute = now + Duration::from_secs(300);
    let mut session = AgentSession::new("real-token-xyz", 300, absolute);

    // Attacker knows the token (e.g. leaked via /proc) but connects from PID 9999,
    // while the session was bound to PID 1000 in the OpenVault request.
    let req = AgentRequest::OpenVault {
        profile: "default".into(),
        session_token: "real-token-xyz".into(),
        requesting_pid: 1000, // claimed PID
    };
    // peer_pid (from transport) is 9999 — does not match the claim.
    let outcome = session.handle_request(&req, Some(9999), "vault-password", now);

    assert!(!outcome.stop);
    assert_eq!(outcome.state, AgentSessionState::Active);
    match outcome.response {
        AgentResponse::Err { reason } => {
            assert!(
                reason.contains("does not match the connecting process"),
                "rejection must mention PID mismatch: {reason}"
            );
        }
        other => panic!("expected PID-mismatch Err, got {other:?}"),
    }
}

/// Guard: a legitimately-behaving process A with the correct token and the
/// matching transport PID MUST still succeed (sanity check).
#[test]
fn agent_legitimate_process_with_correct_token_and_pid_is_accepted() {
    let now = Instant::now();
    let absolute = now + Duration::from_secs(300);
    let mut session = AgentSession::new("legit-token", 300, absolute);

    let req = AgentRequest::OpenVault {
        profile: "default".into(),
        session_token: "legit-token".into(),
        requesting_pid: 42,
    };
    let outcome = session.handle_request(&req, Some(42), "vault-password", now);

    assert!(!outcome.stop);
    assert_eq!(outcome.state, AgentSessionState::Active);
    match outcome.response {
        AgentResponse::Password { password } => assert_eq!(password, "vault-password"),
        other => panic!("expected Password, got {other:?}"),
    }
}

// ═══════════════════════════════════════════════════════════════════════════════
// 2. Session TTL Staleness
// ═══════════════════════════════════════════════════════════════════════════════

/// A session whose absolute TTL has elapsed must reject every request type and
/// must NOT return any password material.
#[test]
fn agent_expired_absolute_ttl_rejects_open_vault_and_returns_error_not_password() {
    let now = Instant::now();
    // Create an already-expired session (absolute deadline 1 second in the past).
    let absolute = now - Duration::from_secs(1);
    let mut session = AgentSession::new("ttl-token", 300, absolute);

    let req = AgentRequest::OpenVault {
        profile: "default".into(),
        session_token: "ttl-token".into(),
        requesting_pid: 7,
    };
    let outcome = session.handle_request(&req, Some(7), "vault-password", now);

    assert!(outcome.stop, "expired session must set stop=true");
    assert_eq!(outcome.state, AgentSessionState::Expired);

    match &outcome.response {
        AgentResponse::Err { reason } => {
            assert!(
                reason.contains("expired"),
                "error must say 'expired', got: {reason}"
            );
            assert!(!reason.is_empty(), "error reason must not be empty");
        }
        AgentResponse::Password { .. } => {
            panic!("expired session must NEVER return a password")
        }
        other => panic!("expected Err, got {other:?}"),
    }
}

/// An idle-timeout expiry produces a distinct, informative reason.
///
/// Strategy: create a 1-second idle window, then evaluate the session with a
/// `now` value that is 2 seconds later (past the idle deadline) while the
/// absolute deadline remains far in the future.
#[test]
fn agent_idle_timeout_staleness_produces_distinct_idle_reason() {
    let base = Instant::now();
    let absolute = base + Duration::from_secs(3600); // far future
                                                     // idle_secs = 1 → idle_deadline = base + 1s
    let mut session = AgentSession::new("idle-token", 1, absolute);

    // Advance `now` past the idle deadline.
    let later = base + Duration::from_secs(2);

    let req = AgentRequest::OpenVault {
        profile: "default".into(),
        session_token: "idle-token".into(),
        requesting_pid: 8,
    };
    let outcome = session.handle_request(&req, Some(8), "vault-password", later);

    assert!(outcome.stop);
    assert_eq!(outcome.state, AgentSessionState::Expired);
    match &outcome.response {
        AgentResponse::Err { reason } => {
            assert!(
                reason.contains("idle"),
                "idle-TTL expiry must mention 'idle': {reason}"
            );
        }
        other => panic!("expected Err, got {other:?}"),
    }
}

/// A session that is still within its TTL window must be accepted.
/// (Guards that the TTL code path doesn't aggressively expire live sessions.)
#[test]
fn agent_live_session_within_ttl_window_is_never_falsely_expired() {
    let now = Instant::now();
    let absolute = now + Duration::from_secs(60);
    let mut session = AgentSession::new("live-token", 60, absolute);

    let req = AgentRequest::OpenVault {
        profile: "default".into(),
        session_token: "live-token".into(),
        requesting_pid: 5,
    };
    let outcome = session.handle_request(&req, Some(5), "vault-password", now);

    assert!(!outcome.stop);
    assert_eq!(outcome.state, AgentSessionState::Active);
    match outcome.response {
        AgentResponse::Password { password } => assert_eq!(password, "vault-password"),
        other => panic!("expected Password, got {other:?}"),
    }
}

/// A request made exactly at the absolute deadline boundary must be treated as
/// expired (>= semantics).
#[test]
fn agent_request_at_exact_absolute_deadline_boundary_is_expired() {
    let now = Instant::now();
    // absolute_deadline == now — so `now >= absolute_deadline` is true.
    let absolute = now;
    let mut session = AgentSession::new("boundary-token", 300, absolute);

    let req = AgentRequest::OpenVault {
        profile: "default".into(),
        session_token: "boundary-token".into(),
        requesting_pid: 9,
    };
    let outcome = session.handle_request(&req, Some(9), "vault-password", now);

    assert!(
        outcome.stop,
        "request at boundary must be treated as expired"
    );
    assert_eq!(outcome.state, AgentSessionState::Expired);
    match outcome.response {
        AgentResponse::Err { reason } => assert!(reason.contains("expired"), "{reason}"),
        other => panic!("expected Err at boundary, got {other:?}"),
    }
}

/// Idle deadline must never be refreshed past the absolute deadline, even when
/// `idle_secs` is very large.
///
/// Proof-by-behaviour: the session is created with a 5-second absolute window
/// and a 10 000-second idle window.  After a successful OpenVault the idle
/// deadline should be capped at the absolute deadline.  We verify this
/// indirectly by checking that a request at `now + 6s` (past absolute but
/// within what an uncapped idle would allow) is still rejected as expired.
#[test]
fn agent_idle_refresh_cannot_extend_past_absolute_deadline() {
    let base = Instant::now();
    let absolute = base + Duration::from_secs(5);
    // idle_secs much larger than the absolute window — must be capped.
    let mut session = AgentSession::new("cap-token", 10_000, absolute);

    // First request succeeds and triggers an idle refresh.
    let open = session.handle_request(
        &AgentRequest::OpenVault {
            profile: "default".into(),
            session_token: "cap-token".into(),
            requesting_pid: 3,
        },
        Some(3),
        "vault-password",
        base,
    );
    assert_eq!(open.state, AgentSessionState::Active);

    // Now evaluate at `base + 6s` — past the 5-second absolute cap.
    // If idle had NOT been capped the session would still appear active.
    let later = base + Duration::from_secs(6);
    let outcome = session.handle_request(&AgentRequest::Ping, Some(3), "vault-password", later);

    assert!(
        outcome.stop,
        "session past absolute deadline must be expired even after an idle refresh"
    );
    assert_eq!(
        outcome.state,
        AgentSessionState::Expired,
        "uncapped idle refresh would incorrectly keep the session alive past absolute_deadline"
    );
}

// ═══════════════════════════════════════════════════════════════════════════════
// 3. Stale State After Agent Restart
// ═══════════════════════════════════════════════════════════════════════════════

/// When the agent restarts, in-memory sessions are gone.  A client holding an
/// old session token must be rejected because the new agent has no record of it.
///
/// Modelled in-process: create a session, "restart" by constructing a fresh
/// `AgentSession` with a *new* token, then try the old token.
#[test]
fn agent_stale_session_token_after_restart_is_rejected() {
    let now = Instant::now();
    let absolute = now + Duration::from_secs(300);

    // Pre-restart session token known to the client.
    let old_token = "pre-restart-token";
    // Post-restart: agent creates a brand-new session with a different token.
    let mut new_session = AgentSession::new("post-restart-token", 300, absolute);

    // Client tries to replay the old token against the new session.
    let req = AgentRequest::OpenVault {
        profile: "default".into(),
        session_token: old_token.into(),
        requesting_pid: 100,
    };
    let outcome = new_session.handle_request(&req, Some(100), "vault-password", now);

    assert!(!outcome.stop);
    assert_eq!(outcome.state, AgentSessionState::Active);
    match outcome.response {
        AgentResponse::Err { reason } => {
            assert!(
                reason.contains("invalid session token"),
                "stale token replay must mention 'invalid session token': {reason}"
            );
        }
        other => panic!("expected Err for stale token replay, got {other:?}"),
    }
}

/// After an agent restart the new session token is accepted normally.
#[test]
fn agent_new_session_token_after_restart_is_accepted() {
    let now = Instant::now();
    let absolute = now + Duration::from_secs(300);
    let mut new_session = AgentSession::new("post-restart-token", 300, absolute);

    let req = AgentRequest::OpenVault {
        profile: "default".into(),
        session_token: "post-restart-token".into(),
        requesting_pid: 200,
    };
    let outcome = new_session.handle_request(&req, Some(200), "vault-password", now);

    assert!(!outcome.stop);
    assert_eq!(outcome.state, AgentSessionState::Active);
    match outcome.response {
        AgentResponse::Password { password } => assert_eq!(password, "vault-password"),
        other => panic!("expected Password for fresh post-restart token, got {other:?}"),
    }
}

/// A locked session cannot be re-activated by any subsequent request, simulating
/// the "lock before restart" path that must not leak material post-lock.
#[test]
fn agent_locked_session_is_irrevocable_and_does_not_leak_password() {
    let now = Instant::now();
    let absolute = now + Duration::from_secs(300);
    let mut session = AgentSession::new("lock-token", 300, absolute);

    // Lock the session.
    let lock_outcome = session.handle_request(
        &AgentRequest::Lock {
            session_token: "lock-token".into(),
        },
        Some(1),
        "vault-password",
        now,
    );
    assert!(lock_outcome.stop);
    assert_eq!(lock_outcome.state, AgentSessionState::Locked);

    // Attempt every request type after lock.
    for req in [
        AgentRequest::Ping,
        AgentRequest::OpenVault {
            profile: "default".into(),
            session_token: "lock-token".into(),
            requesting_pid: 1,
        },
    ] {
        let outcome = session.handle_request(&req, Some(1), "vault-password", now);
        assert!(outcome.stop, "locked session must always stop");
        assert_eq!(outcome.state, AgentSessionState::Locked);
        match &outcome.response {
            AgentResponse::Err { reason } => {
                assert!(
                    reason.contains("locked"),
                    "post-lock error must mention 'locked': {reason}"
                );
            }
            AgentResponse::Password { .. } => panic!("locked session must never return password"),
            other => panic!("expected Err after lock, got {other:?}"),
        }
    }
}

// ═══════════════════════════════════════════════════════════════════════════════
// 4. Concurrent Session Exhaustion / Integrity Under Load
// ═══════════════════════════════════════════════════════════════════════════════

/// Many threads concurrently issuing `OpenVault` requests — the session must
/// maintain consistent state: no phantom password leaks, no data races.
///
/// Uses `Arc<Mutex<AgentSession>>` to serialise access (mirroring the daemon's
/// single-threaded pipe loop) while maximising concurrent *request generation*.
#[test]
fn agent_concurrent_requests_maintain_session_integrity() {
    use std::thread;

    let now = Instant::now();
    let absolute = now + Duration::from_secs(3600);
    let session = Arc::new(Mutex::new(AgentSession::new(
        "shared-token",
        3600,
        absolute,
    )));
    let password = "shared-secret";

    let thread_count = 32;
    let requests_per_thread = 50;

    let handles: Vec<_> = (0..thread_count)
        .map(|i| {
            let session = Arc::clone(&session);
            thread::spawn(move || {
                let pid = 1000 + i;
                let mut successes = 0u32;
                let mut rejections = 0u32;

                for _ in 0..requests_per_thread {
                    let req = AgentRequest::OpenVault {
                        profile: "default".into(),
                        session_token: "shared-token".into(),
                        requesting_pid: pid,
                    };
                    let mut s = session.lock().expect("session mutex must not be poisoned");
                    let outcome = s.handle_request(&req, Some(pid), password, Instant::now());

                    // Invariant: only Password or Err responses — never Ok or unexpected.
                    match &outcome.response {
                        AgentResponse::Password { password: pw } => {
                            assert_eq!(pw, password, "password must be exactly the vault password");
                            successes += 1;
                        }
                        AgentResponse::Err { .. } => {
                            rejections += 1;
                        }
                        other => {
                            panic!("unexpected response variant from OpenVault: {other:?}");
                        }
                    }
                }

                (successes, rejections)
            })
        })
        .collect();

    let mut total_success = 0u32;
    let mut total_rejection = 0u32;
    for h in handles {
        let (s, r) = h.join().expect("thread must not panic");
        total_success += s;
        total_rejection += r;
    }

    let total = total_success + total_rejection;
    assert_eq!(
        total,
        thread_count * requests_per_thread,
        "all requests must be accounted for"
    );

    // The session should still be active (no spurious expiry/lock from load).
    let final_state = session
        .lock()
        .expect("mutex poisoned")
        .state(Instant::now());
    assert_eq!(
        final_state,
        AgentSessionState::Active,
        "session must remain Active after concurrent load"
    );
}

/// A session must not serve requests after it naturally expires from concurrent load.
/// This verifies that once expired, it stays expired regardless of who requests.
#[test]
fn agent_expired_session_stays_expired_under_concurrent_pressure() {
    use std::thread;

    let now = Instant::now();
    // Session already expired.
    let absolute = now - Duration::from_secs(1);
    let session = Arc::new(Mutex::new(AgentSession::new(
        "expired-shared",
        300,
        absolute,
    )));

    let handles: Vec<_> = (0..8)
        .map(|i| {
            let session = Arc::clone(&session);
            thread::spawn(move || {
                let pid = 2000 + i as u32;
                let req = AgentRequest::OpenVault {
                    profile: "default".into(),
                    session_token: "expired-shared".into(),
                    requesting_pid: pid,
                };
                let mut s = session.lock().expect("mutex poisoned");
                let outcome = s.handle_request(&req, Some(pid), "secret", Instant::now());

                // Must never yield a password.
                assert!(
                    !matches!(outcome.response, AgentResponse::Password { .. }),
                    "expired session must not yield password"
                );
                outcome.state
            })
        })
        .collect();

    for h in handles {
        let state = h.join().expect("thread must not panic");
        assert_eq!(
            state,
            AgentSessionState::Expired,
            "all concurrent callers must see Expired state"
        );
    }
}

// ═══════════════════════════════════════════════════════════════════════════════
// 5. Invalid / Malformed Session Tokens
// ═══════════════════════════════════════════════════════════════════════════════

/// Empty string session token must be rejected with a clear error, not a panic.
#[test]
fn agent_empty_session_token_is_rejected_without_panic() {
    let (resp, state) = run_once(
        AgentRequest::OpenVault {
            profile: "default".into(),
            session_token: String::new(),
            requesting_pid: 1,
        },
        Some(1),
        "real-token",
        60,
        60,
    );
    assert_eq!(state, AgentSessionState::Active);
    match resp {
        AgentResponse::Err { reason } => {
            assert!(
                reason.contains("invalid session token"),
                "empty token must yield 'invalid session token': {reason}"
            );
        }
        other => panic!("expected Err for empty token, got {other:?}"),
    }
}

/// Token containing only whitespace is rejected (not silently accepted).
#[test]
fn agent_whitespace_only_session_token_is_rejected() {
    let (resp, state) = run_once(
        AgentRequest::OpenVault {
            profile: "default".into(),
            session_token: "   \t\n  ".into(),
            requesting_pid: 2,
        },
        Some(2),
        "real-token",
        60,
        60,
    );
    assert_eq!(state, AgentSessionState::Active);
    match resp {
        AgentResponse::Err { reason } => {
            assert!(reason.contains("invalid session token"), "{reason}")
        }
        other => panic!("expected Err for whitespace token, got {other:?}"),
    }
}

/// A very long token (potential buffer probe) is rejected without panic.
#[test]
fn agent_very_long_session_token_is_rejected_without_panic() {
    let long_token: String = "a".repeat(65536);
    let (resp, state) = run_once(
        AgentRequest::OpenVault {
            profile: "default".into(),
            session_token: long_token,
            requesting_pid: 3,
        },
        Some(3),
        "real-token",
        60,
        60,
    );
    assert_eq!(state, AgentSessionState::Active);
    match resp {
        AgentResponse::Err { reason } => {
            assert!(reason.contains("invalid session token"), "{reason}")
        }
        other => panic!("expected Err for overlong token, got {other:?}"),
    }
}

/// Token with embedded null bytes is rejected without panic.
#[test]
fn agent_null_byte_in_session_token_is_rejected_without_panic() {
    let (resp, state) = run_once(
        AgentRequest::OpenVault {
            profile: "default".into(),
            session_token: "tok\x00en".into(),
            requesting_pid: 4,
        },
        Some(4),
        "real-token",
        60,
        60,
    );
    assert_eq!(state, AgentSessionState::Active);
    match resp {
        AgentResponse::Err { reason } => {
            assert!(reason.contains("invalid session token"), "{reason}")
        }
        other => panic!("expected Err for null-byte token, got {other:?}"),
    }
}

/// Token that nearly matches (off by one character) is rejected.
#[test]
fn agent_nearly_matching_session_token_is_rejected() {
    let real = "abcdef0123456789";
    let almost = "abcdef0123456788"; // last char differs

    let (resp, state) = run_once(
        AgentRequest::OpenVault {
            profile: "default".into(),
            session_token: almost.into(),
            requesting_pid: 5,
        },
        Some(5),
        real,
        60,
        60,
    );
    assert_eq!(state, AgentSessionState::Active);
    match resp {
        AgentResponse::Err { reason } => {
            assert!(reason.contains("invalid session token"), "{reason}")
        }
        other => panic!("expected Err for near-match token, got {other:?}"),
    }
}

/// Unicode / multi-byte token that does not match is rejected without panic.
#[test]
fn agent_unicode_session_token_mismatch_is_rejected_without_panic() {
    let (resp, state) = run_once(
        AgentRequest::OpenVault {
            profile: "default".into(),
            session_token: "héllo-wörld-🔐".into(),
            requesting_pid: 6,
        },
        Some(6),
        "real-token",
        60,
        60,
    );
    assert_eq!(state, AgentSessionState::Active);
    match resp {
        AgentResponse::Err { reason } => {
            assert!(reason.contains("invalid session token"), "{reason}")
        }
        other => panic!("expected Err for unicode token mismatch, got {other:?}"),
    }
}

/// Malformed `Lock` request (wrong token) must not lock the session.
#[test]
fn agent_malformed_lock_token_does_not_lock_session() {
    let now = Instant::now();
    let absolute = now + Duration::from_secs(300);
    let mut session = AgentSession::new("real-lock-token", 300, absolute);

    let bad_lock = session.handle_request(
        &AgentRequest::Lock {
            session_token: "wrong-lock-token".into(),
        },
        Some(1),
        "vault-password",
        now,
    );

    assert!(!bad_lock.stop, "bad lock token must not stop the agent");
    assert_eq!(bad_lock.state, AgentSessionState::Active);
    match bad_lock.response {
        AgentResponse::Err { reason } => {
            assert!(reason.contains("invalid session token"), "{reason}");
        }
        other => panic!("expected Err, got {other:?}"),
    }

    // Session must still serve valid requests after a failed lock attempt.
    let follow = session.handle_request(
        &AgentRequest::OpenVault {
            profile: "default".into(),
            session_token: "real-lock-token".into(),
            requesting_pid: 1,
        },
        Some(1),
        "vault-password",
        now,
    );
    match follow.response {
        AgentResponse::Password { password } => assert_eq!(password, "vault-password"),
        other => panic!("expected Password after failed lock, got {other:?}"),
    }
}
