//! U3 — Capture and record outbound send outcomes (stop swallowing failures).
//!
//! Pre-U3, `RealMessageSender::send` did `.map(|_| ())`, discarding the JXA
//! `sent` boolean — so a SOFT failure (`Ok(SendResult { sent:false })`,
//! recipient-not-found / no Messages service) was silently treated as a
//! SUCCESS. This unit makes the send path record a structured outcome into the
//! shared per-channel liveness, and these tests pin all three shapes:
//!
//! - `Ok(sent:false, reason)` → recorded as a FAILURE with the reason (the
//!   swallowed-success regression guard — the mandatory genuine assertion).
//! - `Err("…")` (hard error, e.g. an Automation/TCC denial) → recorded as a
//!   failure; the existing rollback + retry path is preserved.
//! - `Ok(sent:true)` → `last_send_ok:true`, timestamp set, no rollback.

use car_proto::CreateHostApprovalRequest;
use car_messaging::channel_supervisor::SharedLiveness;
use car_messaging::messaging_config::{ChannelId, MessagingConfigStore};
use car_messaging::messaging_orchestrator::{MessageSender, MessagingOrchestrator, SendOutcome};
use car_server_types::host::HostState;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tempfile::TempDir;

const PAIRED: &str = "+15551234567";

/// A sender whose outcome is fixed by the test: a hard `Err`, a soft
/// `Ok(sent:false)`, or a success `Ok(sent:true)`. Also counts attempts so the
/// retry path can be asserted.
enum Outcome {
    Hard(String),
    Soft(String),
    Ok,
}

struct StubSender {
    outcome: Outcome,
    attempts: Mutex<usize>,
}

impl StubSender {
    fn new(outcome: Outcome) -> Arc<Self> {
        Arc::new(Self {
            outcome,
            attempts: Mutex::new(0),
        })
    }
    fn attempts(&self) -> usize {
        *self.attempts.lock().unwrap()
    }
}

impl MessageSender for StubSender {
    fn send(&self, _handle: &str, _body: &str) -> Result<SendOutcome, String> {
        *self.attempts.lock().unwrap() += 1;
        match &self.outcome {
            Outcome::Hard(reason) => Err(reason.clone()),
            Outcome::Soft(reason) => Ok(SendOutcome::soft_fail(reason.clone())),
            Outcome::Ok => Ok(SendOutcome::ok()),
        }
    }
}

/// Build an ENABLED, paired orchestrator whose sends record into `liveness`.
fn orch_with(sender: Arc<dyn MessageSender>, liveness: SharedLiveness) -> (MessagingOrchestrator, Arc<HostState>, TempDir) {
    let dir = TempDir::new().expect("tempdir");
    let store = MessagingConfigStore::with_base_dir(dir.path());
    store.set_enabled(true).unwrap();
    store.add_handle(PAIRED).unwrap();
    let host = Arc::new(HostState::new());
    let orch = MessagingOrchestrator::with_liveness(
        host.clone(),
        MessagingConfigStore::with_base_dir(dir.path()),
        sender,
        dir.path().to_path_buf(),
        liveness,
    );
    (orch, host, dir)
}

async fn seed_fire_and_return(host: &HostState, action: &str) -> String {
    let req = CreateHostApprovalRequest {
        agent_id: None,
        action: action.to_string(),
        details: serde_json::Value::Null,
        options: vec![],
        system_level: true,
    };
    host.create_approval(None, req).await.unwrap().id
}

fn fresh_liveness() -> SharedLiveness {
    Arc::new(Mutex::new(HashMap::new()))
}

fn snapshot(liveness: &SharedLiveness) -> car_messaging::channel_supervisor::ChannelLiveness {
    liveness
        .lock()
        .unwrap()
        .get(&ChannelId::IMessage)
        .cloned()
        .unwrap_or_default()
}

#[tokio::test]
async fn soft_sent_false_is_recorded_as_failure_not_success() {
    // THE regression guard: a sender returning Ok(sent:false) must be recorded
    // as a FAILURE with its reason — NOT counted as a success.
    let liveness = fresh_liveness();
    let sender = StubSender::new(Outcome::Soft("recipient not found".to_string()));
    let (orch, host, _dir) = orch_with(sender.clone() as Arc<dyn MessageSender>, liveness.clone());

    seed_fire_and_return(&host, "wire $5000").await;
    orch.observe_and_notify().await;

    let snap = snapshot(&liveness);
    assert_eq!(
        snap.last_send_ok,
        Some(false),
        "a soft sent:false is a FAILURE, not a success"
    );
    assert_eq!(
        snap.last_error.as_deref(),
        Some("recipient not found"),
        "the soft-failure reason is surfaced, not swallowed"
    );
    assert!(
        snap.last_send_at_ms.is_some(),
        "the failed attempt is timestamped"
    );
}

#[tokio::test]
async fn hard_error_is_recorded_and_rollback_preserved() {
    let liveness = fresh_liveness();
    let sender = StubSender::new(Outcome::Hard("osascript failed: not authorized".to_string()));
    let (orch, host, _dir) = orch_with(sender.clone() as Arc<dyn MessageSender>, liveness.clone());

    seed_fire_and_return(&host, "delete prod database").await;
    orch.observe_and_notify().await;

    let snap = snapshot(&liveness);
    assert_eq!(snap.last_send_ok, Some(false), "hard error is a failure");
    assert_eq!(
        snap.last_error.as_deref(),
        Some("osascript failed: not authorized"),
        "the hard-error reason is recorded"
    );

    // Rollback + retry preserved: the code mapping was rolled back on the hard
    // error, so a SECOND observe re-attempts the send (re-mints a code).
    orch.observe_and_notify().await;
    assert_eq!(
        sender.attempts(),
        2,
        "a hard error rolls back the code so the next tick retries"
    );
}

#[tokio::test]
async fn success_sets_ok_and_timestamp_no_rollback() {
    let liveness = fresh_liveness();
    let sender = StubSender::new(Outcome::Ok);
    let (orch, host, _dir) = orch_with(sender.clone() as Arc<dyn MessageSender>, liveness.clone());

    seed_fire_and_return(&host, "send the board update").await;
    orch.observe_and_notify().await;

    let snap = snapshot(&liveness);
    assert_eq!(snap.last_send_ok, Some(true), "a delivered send is a success");
    assert!(snap.last_error.is_none(), "no error on success");
    assert!(snap.last_send_at_ms.is_some(), "the send is timestamped");

    // No rollback on success: the idempotency guard means a second observe does
    // NOT re-send for the same approval (exactly one attempt).
    orch.observe_and_notify().await;
    assert_eq!(
        sender.attempts(),
        1,
        "success keeps the code mapping; the same approval is not re-sent"
    );
}
