//! L1 tests for [`ChatBridge`] — the structured-reply ↔ Channel registry, the
//! twin of [`PtyBridge`] (ARCHITECTURE §17.7) — and for [`chunk_from_event`], the
//! single `ReplyEvent → ReplyChunk` translation point.
//!
//! These exercise the load-bearing transport logic the D4 commands are thin
//! shells over: the `agent_send` pump's delivery + scrollback recording, the
//! `reattach_agent_chat` repaint-without-respawn, the `close_agent_session`
//! teardown, and — the most critical invariant — generation supersede (no double
//! emission after a re-attach). A real [`tauri::ipc::Channel`] built from a
//! capturing closure is used (no Tauri runtime, no real CLI/PTY/session).

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

use tauri::ipc::{Channel, InvokeResponseBody};

use app_tauri_lib::chat::{
    chunk_from_event, ChatBridge, MAX_CHAT_SCROLLBACK_BYTES, MAX_CHAT_SCROLLBACK_CHUNKS,
};
use app_tauri_lib::dto::ReplyChunk;
use domain::ids::SessionId;
use domain::ports::ReplyEvent;
use uuid::Uuid;

/// Builds a `Channel<ReplyChunk>` whose sent chunks are recorded into `sink`.
///
/// `ReplyChunk` is `Serialize`/`Deserialize`, so chunks arrive as a JSON string
/// in an `InvokeResponseBody::Json`; we parse them back to `ReplyChunk` for
/// assertions (round-tripping the exact camelCase tagged shape on the way).
fn capturing_channel(sink: Arc<Mutex<Vec<ReplyChunk>>>) -> Channel<ReplyChunk> {
    Channel::new(move |body: InvokeResponseBody| {
        let chunk: ReplyChunk = match body {
            InvokeResponseBody::Json(s) => serde_json::from_str(&s).unwrap(),
            InvokeResponseBody::Raw(b) => serde_json::from_slice(&b).unwrap(),
        };
        sink.lock().unwrap().push(chunk);
        Ok(())
    })
}

fn sid() -> SessionId {
    SessionId::from_uuid(Uuid::new_v4())
}

fn delta(s: &str) -> ReplyChunk {
    ReplyChunk::TextDelta { text: s.to_owned() }
}

fn final_chunk(s: &str) -> ReplyChunk {
    ReplyChunk::Final {
        content: s.to_owned(),
    }
}

fn error_chunk(s: &str) -> ReplyChunk {
    ReplyChunk::Error {
        message: s.to_owned(),
    }
}

fn chunk_bytes(chunk: &ReplyChunk) -> usize {
    match chunk {
        ReplyChunk::TextDelta { text } => text.len(),
        ReplyChunk::ToolActivity { label } => label.len(),
        ReplyChunk::Final { content } => content.len(),
        ReplyChunk::Error { message } => message.len(),
    }
}

// ---------------------------------------------------------------------------
// chunk_from_event — exhaustive mapping (zone 6)
// ---------------------------------------------------------------------------

#[test]
fn chunk_from_event_maps_text_delta() {
    assert_eq!(
        chunk_from_event(ReplyEvent::TextDelta { text: "hi".into() }),
        Some(ReplyChunk::TextDelta { text: "hi".into() })
    );
}

#[test]
fn chunk_from_event_maps_tool_activity() {
    assert_eq!(
        chunk_from_event(ReplyEvent::ToolActivity {
            label: "reads file".into()
        }),
        Some(ReplyChunk::ToolActivity {
            label: "reads file".into()
        })
    );
}

#[test]
fn chunk_from_event_maps_error() {
    assert_eq!(
        chunk_from_event(ReplyEvent::Error {
            message: "auth failed".into()
        }),
        Some(error_chunk("auth failed"))
    );
}

#[test]
fn chunk_from_event_maps_final() {
    assert_eq!(
        chunk_from_event(ReplyEvent::Final {
            content: "done".into()
        }),
        Some(ReplyChunk::Final {
            content: "done".into()
        })
    );
}

#[test]
fn chunk_from_event_maps_blank_final_to_visible_error() {
    assert_eq!(
        chunk_from_event(ReplyEvent::Final {
            content: " \n\t ".into()
        }),
        Some(error_chunk("Réponse vide du modèle."))
    );
}

#[test]
fn chunk_from_event_drops_heartbeat() {
    // A heartbeat is a non-terminal liveness proof with no chat content (lot 1) ⇒ no
    // wire chunk; the pump skips it while still draining to the Final.
    assert_eq!(chunk_from_event(ReplyEvent::Heartbeat), None);
}

// ---------------------------------------------------------------------------
// agent_send pump behaviour: deltas* then exactly one Final (zone 2)
// ---------------------------------------------------------------------------

/// Replays a turn's events through the bridge exactly as the `agent_send` pump
/// does (`chunk_from_event` + `send_output`), then `detach_if(gen)` at stream
/// end. This is the body of the spawned pump thread, run inline.
fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec<ReplyEvent>) {
    let mut terminal_visible = false;
    for event in events {
        // Mirror the real pump: heartbeats map to no chunk and are skipped.
        if let Some(chunk) = chunk_from_event(event) {
            if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) {
                terminal_visible = true;
            }
            let _ = bridge.send_output(session, chunk);
        }
    }
    if !terminal_visible {
        let _ = bridge.send_output(
            session,
            error_chunk("Le modèle n'a renvoyé aucune réponse."),
        );
    }
    bridge.detach_if(session, gen);
}

#[test]
fn pump_delivers_deltas_then_exactly_one_final_in_order() {
    let bridge = ChatBridge::new();
    let session = sid();
    let sink = Arc::new(Mutex::new(Vec::new()));
    let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));

    pump_turn(
        &bridge,
        &session,
        gen,
        vec![
            ReplyEvent::TextDelta { text: "Hel".into() },
            ReplyEvent::TextDelta { text: "lo".into() },
            ReplyEvent::Final {
                content: "Hello".into(),
            },
        ],
    );

    let got = sink.lock().unwrap();
    assert_eq!(
        got.as_slice(),
        &[delta("Hel"), delta("lo"), final_chunk("Hello")],
        "deltas in order, then the single Final"
    );
    // Exactly one Final, and it is last (deterministic terminal chunk).
    let finals = got
        .iter()
        .filter(|c| matches!(c, ReplyChunk::Final { .. }))
        .count();
    assert_eq!(finals, 1, "exactly one Final per turn");
    assert!(matches!(got.last(), Some(ReplyChunk::Final { .. })));
}

#[test]
fn pump_with_only_a_final_delivers_just_the_final() {
    // Zero deltas is valid (deltas*); the turn still ends on exactly one Final.
    let bridge = ChatBridge::new();
    let session = sid();
    let sink = Arc::new(Mutex::new(Vec::new()));
    let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));

    pump_turn(
        &bridge,
        &session,
        gen,
        vec![ReplyEvent::Final {
            content: "ok".into(),
        }],
    );

    assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("ok")]);
}

#[test]
fn pump_with_heartbeat_then_eof_emits_visible_error() {
    let bridge = ChatBridge::new();
    let session = sid();
    let sink = Arc::new(Mutex::new(Vec::new()));
    let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));

    pump_turn(&bridge, &session, gen, vec![ReplyEvent::Heartbeat]);

    assert_eq!(
        sink.lock().unwrap().as_slice(),
        &[error_chunk("Le modèle n'a renvoyé aucune réponse.")]
    );
}

#[test]
fn pump_with_blank_final_emits_visible_error_without_silent_detach() {
    let bridge = ChatBridge::new();
    let session = sid();
    let sink = Arc::new(Mutex::new(Vec::new()));
    let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));

    pump_turn(
        &bridge,
        &session,
        gen,
        vec![ReplyEvent::Final {
            content: String::new(),
        }],
    );

    assert_eq!(
        sink.lock().unwrap().as_slice(),
        &[error_chunk("Réponse vide du modèle.")]
    );
}

#[test]
fn pump_with_existing_error_does_not_emit_generic_error() {
    let bridge = ChatBridge::new();
    let session = sid();
    let sink = Arc::new(Mutex::new(Vec::new()));
    let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));

    pump_turn(
        &bridge,
        &session,
        gen,
        vec![ReplyEvent::Error {
            message: "auth failed".into(),
        }],
    );

    assert_eq!(
        sink.lock().unwrap().as_slice(),
        &[error_chunk("auth failed")]
    );
}

// ---------------------------------------------------------------------------
// Generation supersede — the critical invariant (zone 1)
// ---------------------------------------------------------------------------

/// A `reattach_agent_chat` mid-turn: a first pump is in flight (old generation)
/// when the view re-attaches (new channel, bumped generation). The old pump's
/// remaining chunks must NOT reach the *new* channel, and when the old pump ends
/// its `detach_if(old_gen)` must be a no-op (never tearing down the live one).
#[test]
fn reattach_supersedes_old_pump_no_double_emission() {
    let bridge = ChatBridge::new();
    let session = sid();
    let old = Arc::new(Mutex::new(Vec::new()));
    let new = Arc::new(Mutex::new(Vec::new()));

    // Turn starts: old channel registered, pump emits a first delta.
    let old_gen = bridge.register(session, capturing_channel(Arc::clone(&old)));
    bridge.send_output(&session, delta("a")); // delivered to old

    // View navigates away & back → reattach registers a NEW channel (bumps gen).
    let _new_gen = bridge.register(session, capturing_channel(Arc::clone(&new)));

    // Old pump keeps draining its (now stale) stream, then ends with its own gen.
    bridge.send_output(&session, delta("b")); // recorded to scrollback, routed to NEW channel
    bridge.detach_if(&session, old_gen); // MUST be a no-op (newer gen present)

    // The new (live) attach must NOT have been detached: it is still deliverable.
    assert!(
        bridge.send_output(&session, final_chunk("done")),
        "live re-attach channel must survive a superseded pump's detach_if"
    );

    // The OLD channel only ever saw the single pre-reattach delta — no double emit.
    assert_eq!(
        old.lock().unwrap().as_slice(),
        &[delta("a")],
        "superseded channel receives nothing after the reattach"
    );
}

#[test]
fn detach_if_current_generation_stops_delivery() {
    // When the pump that owns the current generation ends (e.g. Final reached and
    // no reattach happened), detach_if drops the channel: further output is not
    // delivered (turn over) but the session stays known until close.
    let bridge = ChatBridge::new();
    let session = sid();
    let sink = Arc::new(Mutex::new(Vec::new()));
    let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));

    bridge.send_output(&session, final_chunk("end"));
    bridge.detach_if(&session, gen);

    // Channel detached: a stray chunk is recorded to scrollback but not delivered.
    assert!(!bridge.send_output(&session, delta("late")));
    assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("end")]);
    // Session is still tracked (scrollback survives until close).
    assert_eq!(bridge.active_sessions(), 1);
}

// ---------------------------------------------------------------------------
// reattach_agent_chat: scrollback repaint without re-spawn (zone 3)
// ---------------------------------------------------------------------------

#[test]
fn scrollback_accumulates_every_routed_chunk_in_order() {
    let bridge = ChatBridge::new();
    let session = sid();
    let gen = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));

    pump_turn(
        &bridge,
        &session,
        gen,
        vec![
            ReplyEvent::TextDelta { text: "x".into() },
            ReplyEvent::ToolActivity {
                label: "runs".into(),
            },
            ReplyEvent::Final {
                content: "x".into(),
            },
        ],
    );

    assert_eq!(
        bridge.scrollback(&session),
        vec![
            delta("x"),
            ReplyChunk::ToolActivity {
                label: "runs".into()
            },
            final_chunk("x"),
        ],
        "scrollback retains the full conversation, in order"
    );
}

#[test]
fn reattach_repaints_scrollback_and_preserves_it_without_resend() {
    // Models reattach_agent_chat: a first turn streamed, the view detached, then a
    // new channel registers. The scrollback survives the re-register (no session
    // method is called — no re-spawn), and is what the command returns.
    let bridge = ChatBridge::new();
    let session = sid();

    let g0 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
    pump_turn(
        &bridge,
        &session,
        g0,
        vec![
            ReplyEvent::TextDelta { text: "Hi".into() },
            ReplyEvent::Final {
                content: "Hi".into(),
            },
        ],
    );

    // Snapshot exactly what reattach_agent_chat returns BEFORE swapping channel.
    let repainted = bridge.scrollback(&session);
    assert_eq!(repainted, vec![delta("Hi"), final_chunk("Hi")]);

    // Re-attach: register a fresh channel. Scrollback is preserved (not cleared,
    // not duplicated), generation bumped.
    let new_sink = Arc::new(Mutex::new(Vec::new()));
    let g1 = bridge.register(session, capturing_channel(Arc::clone(&new_sink)));
    assert_eq!(g1, g0 + 1, "re-attach bumps the generation");
    assert_eq!(
        bridge.scrollback(&session),
        repainted,
        "scrollback unchanged by re-attach (no re-send, no re-spawn)"
    );
    // The fresh channel got nothing yet: reattach does NOT replay over the channel
    // (the command returns the scrollback for the front to replay itself).
    assert!(
        new_sink.lock().unwrap().is_empty(),
        "re-attach must not push the old turns onto the new channel"
    );
}

#[test]
fn scrollback_of_unknown_session_is_empty() {
    let bridge = ChatBridge::new();
    assert!(bridge.scrollback(&sid()).is_empty());
}

#[test]
fn scrollback_drops_oldest_chunks_when_chunk_cap_is_exceeded() {
    let bridge = ChatBridge::new();
    let session = sid();
    let gen = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
    bridge.detach_if(&session, gen);

    for i in 0..(MAX_CHAT_SCROLLBACK_CHUNKS + 10) {
        bridge.send_output(&session, delta(&format!("chunk-{i}")));
    }

    let scrollback = bridge.scrollback(&session);
    assert_eq!(scrollback.len(), MAX_CHAT_SCROLLBACK_CHUNKS);
    assert_eq!(scrollback.first(), Some(&delta("chunk-10")));
    assert_eq!(
        scrollback.last(),
        Some(&delta(&format!("chunk-{}", MAX_CHAT_SCROLLBACK_CHUNKS + 9)))
    );
    assert_eq!(
        scrollback.iter().take(3).cloned().collect::<Vec<_>>(),
        vec![delta("chunk-10"), delta("chunk-11"), delta("chunk-12")],
        "remaining chunks keep their relative order and exact content"
    );
}

#[test]
fn scrollback_drops_oldest_whole_chunks_when_byte_cap_is_exceeded() {
    let bridge = ChatBridge::new();
    let session = sid();
    let gen = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
    bridge.detach_if(&session, gen);
    let chunk_size = MAX_CHAT_SCROLLBACK_BYTES / 4;

    for i in 0..6 {
        let text = format!("{i:02}:{}", "x".repeat(chunk_size - 3));
        bridge.send_output(&session, delta(&text));
    }

    let scrollback = bridge.scrollback(&session);
    let retained_bytes: usize = scrollback.iter().map(chunk_bytes).sum();
    assert!(
        retained_bytes <= MAX_CHAT_SCROLLBACK_BYTES,
        "retained bytes {retained_bytes} must stay under cap {MAX_CHAT_SCROLLBACK_BYTES}"
    );
    assert_eq!(scrollback.len(), 4);
    assert!(matches!(
        scrollback.first(),
        Some(ReplyChunk::TextDelta { text }) if text.starts_with("02:")
    ));
    assert!(matches!(
        scrollback.last(),
        Some(ReplyChunk::TextDelta { text }) if text.starts_with("05:")
    ));
    assert!(scrollback.iter().all(|chunk| matches!(
        chunk,
        ReplyChunk::TextDelta { text } if text.len() == chunk_size
    )));
}

#[test]
fn truncated_scrollback_replays_valid_unmodified_chunks_in_order() {
    let bridge = ChatBridge::new();
    let session = sid();
    let gen = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
    bridge.detach_if(&session, gen);

    for i in 0..(MAX_CHAT_SCROLLBACK_CHUNKS + 3) {
        bridge.send_output(&session, delta(&format!("delta-{i}")));
    }
    bridge.send_output(
        &session,
        ReplyChunk::ToolActivity {
            label: "tool".into(),
        },
    );
    bridge.send_output(&session, final_chunk("done"));

    let scrollback = bridge.scrollback(&session);
    assert_eq!(scrollback.len(), MAX_CHAT_SCROLLBACK_CHUNKS);
    assert_eq!(scrollback[0], delta("delta-5"));
    assert_eq!(
        &scrollback[(MAX_CHAT_SCROLLBACK_CHUNKS - 2)..],
        &[
            ReplyChunk::ToolActivity {
                label: "tool".into()
            },
            final_chunk("done")
        ],
        "chunks are retained whole and unmodified"
    );

    let replay_sink = Arc::new(Mutex::new(Vec::new()));
    let replay = ChatBridge::new();
    let replay_session = sid();
    replay.register(replay_session, capturing_channel(Arc::clone(&replay_sink)));
    for chunk in scrollback.clone() {
        assert!(replay.send_output(&replay_session, chunk));
    }
    assert_eq!(*replay_sink.lock().unwrap(), scrollback);
}

#[test]
fn detach_keeps_scrollback_bounded_and_unregister_purges_it() {
    let bridge = ChatBridge::new();
    let session = sid();
    let gen = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
    bridge.detach_if(&session, gen);

    for i in 0..(MAX_CHAT_SCROLLBACK_CHUNKS + 25) {
        bridge.send_output(&session, delta(&format!("detached-{i}")));
    }

    assert_eq!(bridge.active_sessions(), 1);
    assert_eq!(
        bridge.scrollback(&session).len(),
        MAX_CHAT_SCROLLBACK_CHUNKS,
        "detached sessions keep bounded replay buffers"
    );

    bridge.unregister(&session);

    assert_eq!(bridge.active_sessions(), 0);
    assert!(bridge.scrollback(&session).is_empty());
}

// ---------------------------------------------------------------------------
// close_agent_session: unregister purges channel AND scrollback (zone 4)
// ---------------------------------------------------------------------------

#[test]
fn unregister_purges_channel_and_scrollback() {
    let bridge = ChatBridge::new();
    let session = sid();
    let sink = Arc::new(Mutex::new(Vec::new()));
    let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
    pump_turn(
        &bridge,
        &session,
        gen,
        vec![ReplyEvent::Final {
            content: "bye".into(),
        }],
    );
    assert_eq!(bridge.active_sessions(), 1);
    assert!(!bridge.scrollback(&session).is_empty());

    bridge.unregister(&session);

    assert_eq!(bridge.active_sessions(), 0, "session removed");
    assert!(
        bridge.scrollback(&session).is_empty(),
        "scrollback purged on close"
    );
    assert!(
        !bridge.send_output(&session, delta("z")),
        "no delivery after close"
    );
    // The closed channel must not receive the post-close chunk.
    assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("bye")]);
}

// ---------------------------------------------------------------------------
// Registry basics, mirrored from pty_bridge.rs for parity
// ---------------------------------------------------------------------------

#[test]
fn register_returns_monotonic_generation_per_session() {
    let bridge = ChatBridge::new();
    let session = sid();
    let g0 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
    let g1 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
    assert_eq!(g0, 0);
    assert_eq!(g1, 1);
    assert_eq!(bridge.active_sessions(), 1, "same id replaced, not added");
}

#[test]
fn send_output_to_unknown_session_returns_false() {
    let bridge = ChatBridge::new();
    assert!(!bridge.send_output(&sid(), delta("x")));
}

#[test]
fn register_same_session_replaces_channel() {
    let bridge = ChatBridge::new();
    let session = sid();
    let first = Arc::new(Mutex::new(Vec::new()));
    let second = Arc::new(Mutex::new(Vec::new()));
    bridge.register(session, capturing_channel(Arc::clone(&first)));
    bridge.register(session, capturing_channel(Arc::clone(&second)));

    bridge.send_output(&session, delta("9"));
    assert!(first.lock().unwrap().is_empty(), "old channel unused");
    assert_eq!(second.lock().unwrap().as_slice(), &[delta("9")]);
}
