//! # MockCallControlClient — in-process test double for `CallControlClient`
//!
//! CI tests + the PSTN-sim e2e integration tests (T7, T8) use this mock; the
//! live `TwilioCallControlClient` (T6) is feature-gated behind `twilio-live`
//! and runs only on the maintainer's machine against real Twilio credentials.
//!
//! ## Why `std::sync::Mutex` (not `tokio::sync::Mutex`)
//!
//! The mock's async fn bodies are **synchronous** — they lock, push a record,
//! unlock, and return. There is no `.await` while holding the lock, so the
//! std mutex (cheaper, no async-overhead, no runtime coupling) is correct.
//! `tokio::sync::Mutex` is for guards held across `.await` points; using it
//! here would be cargo-cult. The async fns exist only to satisfy the trait's
//! `async` signature (desugared by `async_trait` to `Pin<Box<...>>`); their
//! bodies do not actually suspend.
//!
//! ## Poison recovery
//!
//! `std::sync::Mutex` "poisons" on panic — a previous lock holder that panicked
//! leaves the mutex poisoned, and `.lock()` returns `Err(PoisonError)`. For a
//! *test double*, a poisoned mutex means a previous test assertion failed; the
//! right move is to recover the inner value (`.into_inner()`) rather than
//! propagate the poison (which would cascade one test failure into every
//! subsequent test). `lock_or_recover` does this idiomatically — and avoids
//! the `unwrap()`/`expect()` that AGENTS.md's hot-path rule forbids in library
//! code (the mock is library code, not `#[cfg(test)]`, because T7/T8 integration
//! tests in `tests/` import it).

use std::sync::{Mutex, MutexGuard};

use async_trait::async_trait;

use super::{CallControlClient, CallControlError, SpendToken};

/// Recover a `MutexGuard` even if the mutex is poisoned.
///
/// Poison indicates a prior lock-holder panicked (a test assertion failed
/// while holding the lock). For a test double, propagating the poison would
/// cascade the failure into every subsequent test that touches the mock;
/// recovering the inner guard (`PoisonError::into_inner`) localizes the
/// failure. This is the standard documented recovery pattern for non-invariant
/// mutexes (see `std::sync::PoisonError::into_inner` docs).
fn lock_or_recover<T>(lock: &Mutex<T>) -> MutexGuard<'_, T> {
    lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}

/// A captured `originate` call — for test assertions.
///
/// `spend_token_present` (not the token itself) is recorded because `SpendToken`
/// is opaque in step 6; the mock only needs to verify whether a token was
/// supplied, not inspect it. This keeps the mock forward-compatible with step
/// 6's real (opaque, possibly non-`Clone`) token type.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OriginateRecord {
    /// The `to_phone` arg passed to `originate`.
    pub to: String,
    /// The `from_phone` arg passed to `originate`.
    pub from: String,
    /// Whether the caller passed `Some(SpendToken)` (step 6 will; step 5 passes `None`).
    pub spend_token_present: bool,
}

/// In-process test double for [`CallControlClient`]. No network IO.
///
/// `originate` returns a synthetic `CA...` correlation ID (matching Twilio's
/// CallSid format — `CA` + 32 hex chars — so downstream code that parses the
/// prefix is exercised honestly) and records the call. `hangup` records the
/// terminate. Both are infallible in the mock (no real provider to fail); the
/// `Result` wrappers return `Ok` so test wiring that propagates the `Result`
/// is exercised identically to the live path.
pub struct MockCallControlClient {
    /// Monotonic counter for synthetic CallSid generation. `AtomicU64` rather
    /// than a `Mutex<u64>` because an atomic increment needs no guard (no
    /// critical section) — cheaper and lock-poison-free.
    counter: std::sync::atomic::AtomicU64,
    originated: Mutex<Vec<OriginateRecord>>,
    hung_up: Mutex<Vec<String>>,
}

impl MockCallControlClient {
    pub fn new() -> Self {
        Self {
            counter: std::sync::atomic::AtomicU64::new(0),
            originated: Mutex::new(Vec::new()),
            hung_up: Mutex::new(Vec::new()),
        }
    }

    /// Snapshot of every `originate` call the mock received, in arrival order.
    pub fn originated_calls(&self) -> Vec<OriginateRecord> {
        lock_or_recover(&self.originated).clone()
    }

    /// Snapshot of every `correlation_id` passed to `hangup`, in arrival order.
    pub fn hung_up_calls(&self) -> Vec<String> {
        lock_or_recover(&self.hung_up).clone()
    }
}

impl Default for MockCallControlClient {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl CallControlClient for MockCallControlClient {
    async fn originate(
        &self,
        to_phone: &str,
        from_phone: &str,
        spend_token: Option<SpendToken>,
    ) -> Result<String, CallControlError> {
        let id = self
            .counter
            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        // Twilio CallSids are "CA" + 32 hex chars. The mock mirrors that shape
        // so any code that parses the prefix/length exercises the real path.
        let call_sid = format!("CA{id:032x}");

        let mut originated = lock_or_recover(&self.originated);
        originated.push(OriginateRecord {
            to: to_phone.to_owned(),
            from: from_phone.to_owned(),
            spend_token_present: spend_token.is_some(),
        });

        Ok(call_sid)
    }

    async fn hangup(&self, correlation_id: &str) -> Result<(), CallControlError> {
        let mut hung_up = lock_or_recover(&self.hung_up);
        // Idempotent in the mock (matching the live provider): recording the
        // same id twice is not an error, matching Twilio's `Status=completed`
        // semantics. A dedup check would be test-specific logic the real
        // provider doesn't expose.
        hung_up.push(correlation_id.to_owned());
        Ok(())
    }
}

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

    #[tokio::test]
    async fn originate_returns_call_sid_and_records_call() {
        let mock = MockCallControlClient::new();
        let sid = mock
            .originate("+15551234567", "+15550000000", None)
            .await
            .expect("mock originate never errors");

        // Twilio CallSid shape: "CA" + 32 hex chars = 34 chars total.
        assert!(sid.starts_with("CA"), "expected CA prefix, got {sid}");
        assert_eq!(sid.len(), 34, "expected 34-char CallSid, got {sid}");

        let calls = mock.originated_calls();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].to, "+15551234567");
        assert_eq!(calls[0].from, "+15550000000");
        assert!(
            !calls[0].spend_token_present,
            "step 5 passes None for spend_token"
        );
    }

    #[tokio::test]
    async fn originate_produces_distinct_call_sids_per_call() {
        let mock = MockCallControlClient::new();
        let sid_a = mock.originate("+1", "+2", None).await.unwrap();
        let sid_b = mock.originate("+3", "+4", None).await.unwrap();
        assert_ne!(sid_a, sid_b, "each originate must get a unique CallSid");
        assert_eq!(mock.originated_calls().len(), 2);
    }

    #[tokio::test]
    async fn hangup_records_correlation_id_and_is_idempotent() {
        let mock = MockCallControlClient::new();
        mock.hangup("CAdeadbeef").await.unwrap();
        // Calling again with the same id is a no-op success (live provider is idempotent).
        mock.hangup("CAdeadbeef").await.unwrap();
        mock.hangup("CAcafef00d").await.unwrap();

        let hung = mock.hung_up_calls();
        // The mock records every call (no dedup) — 2 for the repeat + 1 for the other.
        assert_eq!(hung.len(), 3);
        assert_eq!(
            hung.iter().filter(|s| s.as_str() == "CAdeadbeef").count(),
            2
        );
    }

    #[tokio::test]
    async fn spend_token_some_is_recorded_as_present() {
        // Step 5 callers pass None, but the trait accepts Some(SpendToken) for
        // forward-compat with step 6. The mock records presence only.
        let mock = MockCallControlClient::new();
        mock.originate("+1", "+2", Some(SpendToken)).await.unwrap();
        let calls = mock.originated_calls();
        assert_eq!(calls.len(), 1);
        assert!(calls[0].spend_token_present);
    }
}
