// crates/rutster-trunk/tests/sim_integ.rs
//
// T8 — PSTN sim end-to-end integration test (slice-5 spec §7).
//
// Drives a synthetic PSTN caller through the FOB reflex loop end-to-end:
//   loud PCM -> local VAD trips -> barge kills playout -> brain replies
//   -> un-mute -> caller hangup -> session closes.
//
// The test is in `rutster-trunk` so it can construct `TrunkSession` directly.
// It cannot use the binary crate's `spawn_tap_engine` / `MediaThread` (circular
// dev-dependency), so it builds a minimal test-only tap engine task that calls
// `rutster_tap::tap_client::run_tap_client` against the same BrainShim surface
// slice-4's `barge_in_integration.rs` uses.

use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};

use futures_util::{SinkExt, StreamExt};
use rutster_brain_realtime::mock::MockRealtimeBrain;
use rutster_brain_realtime::openai_client::run_openai_pump;
use rutster_call_model::Channel;
use rutster_media::{LocalVadReflex, PcmFrame, Reflex, ReflexMetrics};

use rutster_tap::{
    DecodedPayload, FunctionCallEvent, FunctionCallOutputEvent, TapAudioPipe, TapMetrics,
    decode_envelope, encode_hello, tap_client::run_tap_client,
};
use rutster_trunk::loop_driver::drive;
use rutster_trunk::session::TrunkSession;
use tokio::net::TcpListener;
use tokio::sync::{mpsc, oneshot};
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tracing::info;
use url::Url;

// === Brain-shim helpers (mirrored from slice-4 barge_in_integration.rs) ===
// The brain process's accept loop is inlined so the test exercises the real
// OpenAI-client pump (`run_openai_pump`) against the mock brain without
// spawning a subprocess or depending on private helpers in another test.

/// Handle returned by `start_brain_shim`. Drop to tear down.
struct BrainShim {
    addr: std::net::SocketAddr,
    shutdown: Option<oneshot::Sender<()>>,
    join: tokio::task::JoinHandle<()>,
}

impl Drop for BrainShim {
    fn drop(&mut self) {
        if let Some(tx) = self.shutdown.take() {
            let _ = tx.send(());
        }
        self.join.abort();
    }
}

/// Start an in-process brain-process-equivalent WS server on an ephemeral port.
async fn start_brain_shim(mock_url: String) -> BrainShim {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
    let join = tokio::spawn(async move {
        brain_accept_loop(listener, shutdown_rx, mock_url).await;
    });
    BrainShim {
        addr,
        shutdown: Some(shutdown_tx),
        join,
    }
}

/// Accept loop: spawns a per-connection task for each tap WS dial.
async fn brain_accept_loop(
    listener: TcpListener,
    mut shutdown: oneshot::Receiver<()>,
    mock_url: String,
) {
    loop {
        tokio::select! {
            biased;
            _ = &mut shutdown => {
                info!("brain_shim accept loop shutting down");
                return;
            }
            res = listener.accept() => {
                let Ok((stream, peer)) = res else { continue };
                let url = mock_url.clone();
                tokio::spawn(async move {
                    if let Err(e) = handle_tap_connection(stream, peer, &url).await {
                        info!(%peer, error = ?e, "brain_shim connection ended");
                    }
                });
            }
        }
    }
}

/// Handle one tap WS connection: handshake, split sink+stream, dial the mock
/// OpenAI side, and run `run_openai_pump`.
async fn handle_tap_connection(
    stream: tokio::net::TcpStream,
    peer: std::net::SocketAddr,
    openai_url: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut tap_ws = tokio_tungstenite::accept_async(stream).await?;
    info!(%peer, "brain_shim tap WS connection accepted");

    let hello_in = tap_ws
        .next()
        .await
        .ok_or("tap connection closed before hello")??;
    let hello_text = hello_in.into_text().map_err(|_| "hello not text")?;
    let decoded = decode_envelope(&hello_text)?;
    let session_id = match decoded.payload {
        DecodedPayload::Hello(p) => p.session_id,
        _ => return Err("first tap frame not hello".into()),
    };
    info!(%peer, %session_id, "brain_shim tap hello received");
    let ack = encode_hello(&session_id, 0, 0)?;
    tap_ws.send(Message::Text(ack)).await?;

    let (tap_via, pump_tap_rx) = mpsc::channel::<String>(64);
    let (pump_tap_tx, mut tap_out_rx) = mpsc::channel::<String>(64);
    let (mut tap_sink, mut tap_stream) = tap_ws.split();

    let in_fwd = tokio::spawn(async move {
        while let Some(msg_res) = tap_stream.next().await {
            if let Ok(m) = msg_res {
                if let Ok(text) = m.into_text() {
                    if tap_via.send(text).await.is_err() {
                        break;
                    }
                }
            }
        }
    });
    let out_fwd = tokio::spawn(async move {
        while let Some(text) = tap_out_rx.recv().await {
            if tap_sink.send(Message::Text(text)).await.is_err() {
                break;
            }
        }
    });

    let request = openai_url.into_client_request()?;
    let (openai_ws, _resp) = tokio_tungstenite::connect_async(request).await?;
    info!(%peer, %openai_url, "brain_shim OpenAI side connected");

    let pump_result =
        run_openai_pump(openai_ws, pump_tap_rx, pump_tap_tx, "alloy".to_string()).await;
    info!(%peer, ?pump_result, "brain_shim pump exited");
    in_fwd.abort();
    out_fwd.abort();
    Ok(())
}

// === Test helpers ===

/// Build a loud 24 kHz PCM frame whose RMS energy is well above the local-VAD
/// threshold. A constant amplitude of 1000 matches slice-4's test fixture.
fn loud_frame() -> PcmFrame {
    let mut frame = PcmFrame::zeroed();
    for s in frame.samples.iter_mut() {
        *s = 1000;
    }
    frame
}

#[tokio::test]
async fn pstn_sim_synthetic_caller_drives_trunk_reflex_loop() {
    let _ = tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "rutster_trunk=info".into()),
        )
        .try_init();

    // 1. Start the mock OpenAI Realtime brain.
    let mock = MockRealtimeBrain::start().await.expect("mock brain binds");

    // 2. Start the brain shim that speaks the tap protocol on the core side
    //    and the OpenAI protocol on the brain side.
    let shim = start_brain_shim(mock.url()).await;
    let tap_url = Url::parse(&format!("ws://{}/", shim.addr)).unwrap();

    // 3. Build the trunk-leg pipe stack: TapAudioPipe -> Reflex -> LocalVadReflex.
    //    This is the same composition `MediaThread::RegisterTrunk` will build.
    let (tx_pcm_in, mut rx_pcm_in) = mpsc::channel::<PcmFrame>(32);
    let (tx_audio_out, rx_audio_out) = mpsc::channel::<PcmFrame>(32);
    let tap_metrics = TapMetrics::new();
    let tap_pipe = TapAudioPipe::new(tx_pcm_in, rx_audio_out, tap_metrics.clone());

    let (advisory_tx, advisory_rx) = mpsc::channel::<rutster_media::AdvisoryEvent>(16);
    let reflex_metrics = ReflexMetrics::new();
    let reflex = Reflex::new(tap_pipe, advisory_rx, reflex_metrics.clone());
    let wrapped_pipe = LocalVadReflex::new(reflex, advisory_tx.clone());

    // 4. Construct the TrunkSession that the FOB will tick via `drive`.
    let channel = Channel::new_inbound();
    let session_id = channel.id;
    let (inbound_tx, inbound_rx) = mpsc::channel::<PcmFrame>(16);
    let (outbound_tx, mut outbound_rx) = mpsc::channel::<PcmFrame>(16);
    let now = Instant::now();
    let mut session = TrunkSession::new(channel, wrapped_pipe, inbound_rx, outbound_tx, now);

    // 5. Spawn a minimal test-only tap engine task. We cannot use the binary
    //    crate's `spawn_tap_engine` from inside `rutster-trunk` (circular
    //    dev-dependency), so we call `run_tap_client` directly after dialing
    //    the brain shim.
    let (close_tx, mut close_rx) = oneshot::channel::<()>();
    let (tx_function_call, _rx_function_call) = mpsc::channel::<FunctionCallEvent>(8);
    let (_tx_function_call_output, mut rx_function_call_output) =
        mpsc::channel::<FunctionCallOutputEvent>(8);
    let engine_metrics = tap_metrics.clone();
    let engine_handle = tokio::spawn(async move {
        let request = tap_url
            .as_str()
            .into_client_request()
            .expect("valid ws url");
        let (ws, _resp) = tokio_tungstenite::connect_async(request)
            .await
            .expect("connect to brain_shim");
        let _ = run_tap_client(
            ws,
            session_id,
            &mut rx_pcm_in,
            tx_audio_out,
            tx_function_call,
            &mut rx_function_call_output,
            advisory_tx,
            engine_metrics,
            &mut close_rx,
        )
        .await;
    });

    // Wait for tap handshake + OpenAI dial to complete.
    tokio::time::sleep(Duration::from_millis(150)).await;

    // 6. Spawn the synthetic Twilio caller task: push loud inbound frames into
    //    the trunk leg and count outbound (brain-reply) frames coming back.
    let outbound_count = Arc::new(AtomicUsize::new(0));
    let outbound_count_caller = outbound_count.clone();
    let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
    let caller_handle = tokio::spawn(async move {
        let loud = loud_frame();
        let mut local_count = 0usize;
        loop {
            // Push caller audio to the trunk leg. try_send matches the hot-path
            // "drop + observe" policy: if the FOB backs up, keep going.
            let _ = inbound_tx.try_send(loud.clone());

            // Drain any outbound (brain reply) frames the FOB produced.
            while outbound_rx.try_recv().is_ok() {
                local_count += 1;
            }
            outbound_count_caller.store(local_count, Ordering::Relaxed);

            tokio::time::sleep(Duration::from_millis(20)).await;
            if stop_rx.try_recv().is_ok() {
                break;
            }
        }
        local_count
    });

    // 7. Drive the trunk-leg poll loop at 20 ms intervals.
    let mut barge_seen = false;
    let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
    while tokio::time::Instant::now() < deadline {
        let now = Instant::now();
        let _ = drive(&mut session, now);

        if !barge_seen && reflex_metrics.barge_in_count.load(Ordering::Relaxed) >= 1 {
            barge_seen = true;
            info!("PSTN sim: local VAD barge-in fired");
        }

        // Stop early once we have both barge-in and at least one observed
        // outbound frame (the mock brain replies with audio_out deltas).
        if barge_seen && outbound_count.load(Ordering::Relaxed) > 0 {
            break;
        }

        tokio::time::sleep(Duration::from_millis(20)).await;
    }

    // 8. Stop the caller and collect its final count.
    let _ = stop_tx.send(());
    let final_outbound_count = tokio::time::timeout(Duration::from_secs(1), caller_handle)
        .await
        .expect("caller task finishes")
        .expect("caller task panics");

    // Then: the local VAD must have fired during the call.
    assert!(
        barge_seen,
        "PSTN caller speech must trigger a local-VAD barge-in on the trunk leg"
    );
    assert_eq!(
        reflex_metrics.barge_in_count.load(Ordering::Relaxed),
        1,
        "barge-in must fire exactly once for the first utterance"
    );

    // And: the mock brain must have received audio_in and replied with at
    // least one outbound frame after the barge (resume condition).
    assert!(
        final_outbound_count > 0,
        "mock brain must reply with at least one audio_out frame observed on the trunk outbound mpsc"
    );

    // 9. Caller hangup: stop sending inbound frames and force the 60 s idle
    //    timeout path by moving `last_idle_rx` into the past. The FOB should
    //    close the session.
    session.last_idle_rx = Instant::now() - Duration::from_secs(90);
    let next = drive(&mut session, Instant::now());
    assert!(
        session.is_closed(),
        "trunk session must close after idle timeout (simulating caller hangup)"
    );
    assert_eq!(
        next, None,
        "drive must return None once the session is closed"
    );

    // 10. Clean up the tap engine.
    let _ = close_tx.send(());
    let _ = tokio::time::timeout(Duration::from_secs(1), engine_handle).await;
}
