//! L3 tests for the terminal use cases (`OpenTerminal`, `WriteToTerminal`,
//! `ResizeTerminal`, `CloseTerminal`) and the [`TerminalSessions`] registry.
//!
//! Every port is faked in-memory so the use cases run without any real PTY:
//! - [`FakePty`] — a recording [`PtyPort`] that mints a deterministic
//!   [`SessionId`] on `spawn` and records every `write`/`resize`/`kill`,
//! - [`SpyBus`] — records published [`DomainEvent`]s.

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

use async_trait::async_trait;
use domain::events::DomainEvent;
use domain::ports::{
    EventBus, EventStream, ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec,
};
use domain::{PtySize, SessionId};

use application::{
    CloseTerminal, CloseTerminalInput, OpenTerminal, OpenTerminalInput, ResizeTerminal,
    ResizeTerminalInput, TerminalSessions, WriteToTerminal, WriteToTerminalInput,
};

// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------

/// One recorded PTY call.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Call {
    Spawn { spec: SpawnSpec, size: PtySize },
    Write { id: SessionId, data: Vec<u8> },
    Resize { id: SessionId, size: PtySize },
    Kill { id: SessionId },
}

#[derive(Default)]
struct FakePtyInner {
    calls: Vec<Call>,
    /// SessionId the next `spawn` will mint (defaults to random).
    next_id: Option<SessionId>,
    /// Exit code the next `kill` will report.
    kill_code: Option<i32>,
    /// When set, `write`/`resize` fail to exercise error propagation.
    fail_io: bool,
}

/// A recording [`PtyPort`]: no real OS PTY, just bookkeeping.
#[derive(Default, Clone)]
struct FakePty(Arc<Mutex<FakePtyInner>>);

impl FakePty {
    fn with_next_id(id: SessionId) -> Self {
        let pty = Self::default();
        pty.0.lock().unwrap().next_id = Some(id);
        pty
    }
    fn calls(&self) -> Vec<Call> {
        self.0.lock().unwrap().calls.clone()
    }
    fn set_kill_code(&self, code: Option<i32>) {
        self.0.lock().unwrap().kill_code = code;
    }
    fn set_fail_io(&self, fail: bool) {
        self.0.lock().unwrap().fail_io = fail;
    }
}

#[async_trait]
impl PtyPort for FakePty {
    async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result<PtyHandle, PtyError> {
        let mut inner = self.0.lock().unwrap();
        inner.calls.push(Call::Spawn { spec, size });
        let session_id = inner.next_id.unwrap_or_else(SessionId::new_random);
        Ok(PtyHandle { session_id })
    }

    fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> {
        let mut inner = self.0.lock().unwrap();
        if inner.fail_io {
            return Err(PtyError::Io("boom".to_owned()));
        }
        inner.calls.push(Call::Write {
            id: handle.session_id,
            data: data.to_vec(),
        });
        Ok(())
    }

    fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError> {
        let mut inner = self.0.lock().unwrap();
        if inner.fail_io {
            return Err(PtyError::Io("boom".to_owned()));
        }
        inner.calls.push(Call::Resize {
            id: handle.session_id,
            size,
        });
        Ok(())
    }

    fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
        Ok(Box::new(std::iter::empty()))
    }

    fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
        Ok(Vec::new())
    }

    async fn wait(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
        Ok(ExitStatus { code: Some(0) })
    }

    fn try_wait(&self, _handle: &PtyHandle) -> Result<Option<ExitStatus>, PtyError> {
        Ok(Some(ExitStatus { code: Some(0) }))
    }

    async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
        let mut inner = self.0.lock().unwrap();
        inner.calls.push(Call::Kill {
            id: handle.session_id,
        });
        Ok(ExitStatus {
            code: inner.kill_code,
        })
    }
}

/// Records published events.
#[derive(Default, Clone)]
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);

impl SpyBus {
    fn events(&self) -> Vec<DomainEvent> {
        self.0.lock().unwrap().clone()
    }
}

impl EventBus for SpyBus {
    fn publish(&self, event: DomainEvent) {
        self.0.lock().unwrap().push(event);
    }
    fn subscribe(&self) -> EventStream {
        Box::new(std::iter::empty())
    }
}

fn sid(n: u128) -> SessionId {
    SessionId::from_uuid(uuid::Uuid::from_u128(n))
}

fn open_input(cwd: &str) -> OpenTerminalInput {
    OpenTerminalInput {
        cwd: cwd.to_owned(),
        rows: 24,
        cols: 80,
        command: None,
        args: Vec::new(),
        node_id: None,
    }
}

// ---------------------------------------------------------------------------
// OpenTerminal
// ---------------------------------------------------------------------------

#[tokio::test]
async fn open_spawns_with_resolved_spec_and_size() {
    let pty = FakePty::with_next_id(sid(42));
    let sessions = Arc::new(TerminalSessions::new());
    let bus = SpyBus::default();
    let open = OpenTerminal::new(
        Arc::new(pty.clone()),
        Arc::clone(&sessions),
        Arc::new(bus.clone()),
    );

    let input = OpenTerminalInput {
        command: Some("/bin/zsh".to_owned()),
        args: vec!["-l".to_owned()],
        ..open_input("/home/me/proj")
    };
    let out = open.execute(input).await.expect("open succeeds");

    // The session adopts the PTY-minted id.
    assert_eq!(out.session.id, sid(42));

    let calls = pty.calls();
    assert_eq!(calls.len(), 1, "exactly one spawn");
    match &calls[0] {
        Call::Spawn { spec, size } => {
            assert_eq!(spec.command, "/bin/zsh");
            assert_eq!(spec.args, vec!["-l".to_owned()]);
            assert_eq!(spec.cwd.as_str(), "/home/me/proj");
            assert_eq!(*size, PtySize::new(24, 80).unwrap());
        }
        other => panic!("expected spawn, got {other:?}"),
    }
}

#[tokio::test]
async fn open_defaults_command_when_none() {
    let pty = FakePty::default();
    let open = OpenTerminal::new(
        Arc::new(pty.clone()),
        Arc::new(TerminalSessions::new()),
        Arc::new(SpyBus::default()),
    );
    open.execute(open_input("/p")).await.unwrap();

    match &pty.calls()[0] {
        Call::Spawn { spec, .. } => assert!(
            !spec.command.is_empty(),
            "a default shell command is filled in"
        ),
        other => panic!("expected spawn, got {other:?}"),
    }
}

#[tokio::test]
async fn open_registers_session_in_registry() {
    let pty = FakePty::with_next_id(sid(7));
    let sessions = Arc::new(TerminalSessions::new());
    let open = OpenTerminal::new(
        Arc::new(pty),
        Arc::clone(&sessions),
        Arc::new(SpyBus::default()),
    );

    assert!(sessions.is_empty());
    open.execute(open_input("/p")).await.unwrap();

    assert_eq!(sessions.len(), 1);
    assert!(sessions.handle(&sid(7)).is_some(), "handle registered");
    assert!(sessions.session(&sid(7)).is_some(), "snapshot registered");
}

#[tokio::test]
async fn open_publishes_pty_output_open_event() {
    let pty = FakePty::with_next_id(sid(9));
    let bus = SpyBus::default();
    let open = OpenTerminal::new(
        Arc::new(pty),
        Arc::new(TerminalSessions::new()),
        Arc::new(bus.clone()),
    );
    open.execute(open_input("/p")).await.unwrap();

    assert_eq!(
        bus.events(),
        vec![DomainEvent::PtyOutput {
            session_id: sid(9),
            bytes: Vec::new(),
        }]
    );
}

#[tokio::test]
async fn open_rejects_non_absolute_cwd() {
    let open = OpenTerminal::new(
        Arc::new(FakePty::default()),
        Arc::new(TerminalSessions::new()),
        Arc::new(SpyBus::default()),
    );
    let err = open
        .execute(open_input("relative/path"))
        .await
        .expect_err("non-absolute cwd rejected");
    assert_eq!(err.code(), "INVALID", "got {err:?}");
}

#[tokio::test]
async fn open_rejects_zero_sized_terminal() {
    let pty = FakePty::default();
    let open = OpenTerminal::new(
        Arc::new(pty.clone()),
        Arc::new(TerminalSessions::new()),
        Arc::new(SpyBus::default()),
    );
    let err = open
        .execute(OpenTerminalInput {
            rows: 0,
            ..open_input("/p")
        })
        .await
        .expect_err("zero-sized terminal rejected");
    assert_eq!(err.code(), "INVALID", "got {err:?}");
    assert!(pty.calls().is_empty(), "must not spawn on invalid size");
}

// ---------------------------------------------------------------------------
// WriteToTerminal
// ---------------------------------------------------------------------------

#[tokio::test]
async fn write_routes_bytes_to_the_right_session() {
    let pty = FakePty::with_next_id(sid(1));
    let sessions = Arc::new(TerminalSessions::new());
    let open = OpenTerminal::new(
        Arc::new(pty.clone()),
        Arc::clone(&sessions),
        Arc::new(SpyBus::default()),
    );
    open.execute(open_input("/p")).await.unwrap();

    let write = WriteToTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions));
    write
        .execute(WriteToTerminalInput {
            session_id: sid(1),
            data: b"ls\n".to_vec(),
        })
        .expect("write succeeds");

    let writes: Vec<_> = pty
        .calls()
        .into_iter()
        .filter(|c| matches!(c, Call::Write { .. }))
        .collect();
    assert_eq!(
        writes,
        vec![Call::Write {
            id: sid(1),
            data: b"ls\n".to_vec(),
        }]
    );
}

#[tokio::test]
async fn write_to_unknown_session_is_not_found() {
    let pty = FakePty::default();
    let write = WriteToTerminal::new(Arc::new(pty.clone()), Arc::new(TerminalSessions::new()));
    let err = write
        .execute(WriteToTerminalInput {
            session_id: sid(404),
            data: b"x".to_vec(),
        })
        .expect_err("unknown session rejected");
    assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
    assert!(pty.calls().is_empty(), "no PTY call for unknown session");
}

#[tokio::test]
async fn write_propagates_pty_io_error() {
    let pty = FakePty::with_next_id(sid(2));
    let sessions = Arc::new(TerminalSessions::new());
    OpenTerminal::new(
        Arc::new(pty.clone()),
        Arc::clone(&sessions),
        Arc::new(SpyBus::default()),
    )
    .execute(open_input("/p"))
    .await
    .unwrap();

    pty.set_fail_io(true);
    let write = WriteToTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions));
    let err = write
        .execute(WriteToTerminalInput {
            session_id: sid(2),
            data: b"x".to_vec(),
        })
        .expect_err("io failure surfaces");
    assert_eq!(err.code(), "PROCESS", "got {err:?}");
}

// ---------------------------------------------------------------------------
// ResizeTerminal
// ---------------------------------------------------------------------------

#[tokio::test]
async fn resize_calls_pty_with_new_size() {
    let pty = FakePty::with_next_id(sid(3));
    let sessions = Arc::new(TerminalSessions::new());
    OpenTerminal::new(
        Arc::new(pty.clone()),
        Arc::clone(&sessions),
        Arc::new(SpyBus::default()),
    )
    .execute(open_input("/p"))
    .await
    .unwrap();

    let resize = ResizeTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions));
    resize
        .execute(ResizeTerminalInput {
            session_id: sid(3),
            rows: 40,
            cols: 120,
        })
        .expect("resize succeeds");

    let resizes: Vec<_> = pty
        .calls()
        .into_iter()
        .filter(|c| matches!(c, Call::Resize { .. }))
        .collect();
    assert_eq!(
        resizes,
        vec![Call::Resize {
            id: sid(3),
            size: PtySize::new(40, 120).unwrap(),
        }]
    );
}

#[tokio::test]
async fn resize_unknown_session_is_not_found() {
    let resize = ResizeTerminal::new(
        Arc::new(FakePty::default()),
        Arc::new(TerminalSessions::new()),
    );
    let err = resize
        .execute(ResizeTerminalInput {
            session_id: sid(404),
            rows: 40,
            cols: 120,
        })
        .expect_err("unknown session rejected");
    assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}

#[tokio::test]
async fn resize_rejects_zero_size() {
    let resize = ResizeTerminal::new(
        Arc::new(FakePty::default()),
        Arc::new(TerminalSessions::new()),
    );
    let err = resize
        .execute(ResizeTerminalInput {
            session_id: sid(1),
            rows: 0,
            cols: 80,
        })
        .expect_err("zero size rejected");
    assert_eq!(err.code(), "INVALID", "got {err:?}");
}

// ---------------------------------------------------------------------------
// CloseTerminal
// ---------------------------------------------------------------------------

#[tokio::test]
async fn close_kills_pty_removes_session_and_returns_code() {
    let pty = FakePty::with_next_id(sid(5));
    pty.set_kill_code(Some(0));
    let sessions = Arc::new(TerminalSessions::new());
    OpenTerminal::new(
        Arc::new(pty.clone()),
        Arc::clone(&sessions),
        Arc::new(SpyBus::default()),
    )
    .execute(open_input("/p"))
    .await
    .unwrap();
    assert_eq!(sessions.len(), 1);

    let close = CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions));
    let out = close
        .execute(CloseTerminalInput { session_id: sid(5) })
        .await
        .expect("close succeeds");

    assert_eq!(out.code, Some(0));
    assert!(sessions.is_empty(), "session removed from registry");
    assert!(
        pty.calls()
            .iter()
            .any(|c| matches!(c, Call::Kill { id } if *id == sid(5))),
        "kill called for the session"
    );
}

#[tokio::test]
async fn close_surfaces_signal_exit_as_none_code() {
    let pty = FakePty::with_next_id(sid(6));
    pty.set_kill_code(None);
    let sessions = Arc::new(TerminalSessions::new());
    OpenTerminal::new(
        Arc::new(pty.clone()),
        Arc::clone(&sessions),
        Arc::new(SpyBus::default()),
    )
    .execute(open_input("/p"))
    .await
    .unwrap();

    let close = CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions));
    let out = close
        .execute(CloseTerminalInput { session_id: sid(6) })
        .await
        .unwrap();
    assert_eq!(out.code, None);
}

#[tokio::test]
async fn close_unknown_session_is_not_found() {
    let pty = FakePty::default();
    let close = CloseTerminal::new(Arc::new(pty.clone()), Arc::new(TerminalSessions::new()));
    let err = close
        .execute(CloseTerminalInput {
            session_id: sid(404),
        })
        .await
        .expect_err("unknown session rejected");
    assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
    assert!(pty.calls().is_empty(), "no kill for unknown session");
}

// ---------------------------------------------------------------------------
// TerminalSessions registry (unit-level)
// ---------------------------------------------------------------------------

#[test]
fn registry_insert_handle_session_remove_len() {
    use domain::{NodeId, ProjectPath, SessionKind, TerminalSession};

    let registry = TerminalSessions::new();
    assert!(registry.is_empty());
    assert_eq!(registry.len(), 0);

    let id = sid(100);
    let handle = PtyHandle { session_id: id };
    let session = TerminalSession::starting(
        id,
        NodeId::new_random(),
        ProjectPath::new("/p").unwrap(),
        SessionKind::Plain,
        PtySize::new(24, 80).unwrap(),
    );
    registry.insert(handle.clone(), session);

    assert_eq!(registry.len(), 1);
    assert!(!registry.is_empty());
    assert_eq!(registry.handle(&id), Some(handle.clone()));
    assert!(registry.session(&id).is_some());
    assert_eq!(registry.session(&id).unwrap().id, id);

    // Unknown id resolves to None.
    assert!(registry.handle(&sid(999)).is_none());
    assert!(registry.session(&sid(999)).is_none());

    // Remove returns the handle and empties the registry.
    assert_eq!(registry.remove(&id), Some(handle));
    assert!(registry.is_empty());
    assert!(registry.remove(&id).is_none(), "second remove is a no-op");
}
