"""
Tests for coordinator/ttyd.py — ttyd process lifecycle management.
All 11 acceptance-criteria tests are defined here, plus 5 tests for the
_pids_on_port() lsof→fuser→ss fallback chain (the root-cause guard for systems
where lsof is not installed).
"""

import signal
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

import muxplex.ttyd as ttyd_mod
from muxplex.ttyd import (
    _kill_pids_on_port,
    _pids_on_port,
    kill_orphan_ttyd,
    kill_ttyd,
    spawn_ttyd,
)


# ---------------------------------------------------------------------------
# autouse fixture — redirect PID paths to tmp_path for every test
# ---------------------------------------------------------------------------


@pytest.fixture(autouse=True)
def use_tmp_pid_dir(tmp_path, monkeypatch):
    """Redirect PID file I/O to a fresh temp directory for every test."""
    tmp_pid_dir = tmp_path / "ttyd"
    tmp_pid_path = tmp_pid_dir / "ttyd.pid"
    monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
    monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
    # Clear the pool between tests
    ttyd_mod._pool.clear()


# ---------------------------------------------------------------------------
# Helper for mocking asyncio.create_subprocess_exec
# ---------------------------------------------------------------------------


def _make_mock_ttyd_process(pid: int = 12345):
    """Return a mock ttyd process with the given PID."""
    proc = MagicMock()
    proc.pid = pid
    return proc


# ---------------------------------------------------------------------------
# spawn_ttyd tests
# ---------------------------------------------------------------------------


async def test_spawn_ttyd_writes_pid_file():
    """spawn_ttyd() must write the process PID to TTYD_PID_PATH."""
    mock_proc = _make_mock_ttyd_process(pid=99999)

    with patch(
        "asyncio.create_subprocess_exec",
        new=AsyncMock(return_value=mock_proc),
    ):
        await spawn_ttyd("my-session")

    pid_path = ttyd_mod.TTYD_PID_PATH
    assert pid_path.exists(), "PID file was not created"
    assert pid_path.read_text().strip() == "99999"


async def test_spawn_ttyd_uses_correct_command():
    """spawn_ttyd() must call ttyd with args: -W -m 3 -p 7682 tmux attach -t <name>."""
    mock_proc = _make_mock_ttyd_process(pid=54321)

    with patch(
        "asyncio.create_subprocess_exec",
        new=AsyncMock(return_value=mock_proc),
    ) as mock_create:
        await spawn_ttyd("test-session")

    call_args = mock_create.call_args[0]
    assert list(call_args) == [
        "ttyd",
        "-W",
        "-m",
        "3",
        "-p",
        "7682",
        "tmux",
        "attach",
        "-t",
        "test-session",
    ]


async def test_spawn_ttyd_returns_process_object():
    """spawn_ttyd() must return the process object from create_subprocess_exec."""
    mock_proc = _make_mock_ttyd_process(pid=11111)

    with patch(
        "asyncio.create_subprocess_exec",
        new=AsyncMock(return_value=mock_proc),
    ):
        result = await spawn_ttyd("another-session")

    assert result is mock_proc


# ---------------------------------------------------------------------------
# kill_ttyd tests
# ---------------------------------------------------------------------------


async def test_kill_ttyd_returns_false_when_no_pid_file():
    """kill_ttyd() returns False when no PID file exists."""
    # autouse fixture ensures no PID file is present
    result = await kill_ttyd()
    assert result is False


async def test_kill_ttyd_reads_pid_file_and_sends_sigterm():
    """kill_ttyd() reads the PID file and sends SIGTERM to the running process."""
    pid_path = ttyd_mod.TTYD_PID_PATH
    pid_path.parent.mkdir(parents=True, exist_ok=True)
    pid_path.write_text("12345")

    kill_calls = []

    def mock_os_kill(pid, sig):
        kill_calls.append((pid, sig))
        # First existence-check (sig=0) succeeds; subsequent sig=0 calls raise
        if sig == 0 and sum(1 for _, s in kill_calls if s == 0) > 1:
            raise ProcessLookupError

    with patch("os.kill", side_effect=mock_os_kill):
        result = await kill_ttyd()

    assert result is True
    sigterm_calls = [(pid, sig) for pid, sig in kill_calls if sig == signal.SIGTERM]
    assert len(sigterm_calls) == 1
    assert sigterm_calls[0][0] == 12345


async def test_kill_ttyd_removes_pid_file():
    """kill_ttyd() removes the PID file regardless of whether process was alive."""
    pid_path = ttyd_mod.TTYD_PID_PATH
    pid_path.parent.mkdir(parents=True, exist_ok=True)
    pid_path.write_text("12345")

    def mock_os_kill(pid, sig):
        # All os.kill calls raise ProcessLookupError — simulates already-dead process
        raise ProcessLookupError

    with patch("os.kill", side_effect=mock_os_kill):
        result = await kill_ttyd()

    assert result is True
    assert not pid_path.exists(), "PID file should be removed after kill_ttyd()"


async def test_kill_ttyd_handles_process_already_dead():
    """kill_ttyd() returns True and clears state when process is already gone."""
    pid_path = ttyd_mod.TTYD_PID_PATH
    pid_path.parent.mkdir(parents=True, exist_ok=True)
    pid_path.write_text("99999")

    # Simulate process already dead: os.kill(pid, 0) raises ProcessLookupError
    with patch("os.kill", side_effect=ProcessLookupError):
        result = await kill_ttyd()

    assert result is True
    assert not pid_path.exists(), (
        "PID file should be removed when process was already dead"
    )
    assert ttyd_mod._active_process is None


# ---------------------------------------------------------------------------
# kill_orphan_ttyd tests
#
# kill_orphan_ttyd() is a thin delegation to kill_ttyd(). These tests verify
# both the delegation wiring and that behaviour is consistent with kill_ttyd().
# ---------------------------------------------------------------------------


async def test_kill_orphan_ttyd_returns_false_when_no_pid_file():
    """kill_orphan_ttyd() returns False when no PID file exists (no orphan)."""
    # autouse fixture ensures no PID file is present
    result = await kill_orphan_ttyd()
    assert result is False


async def test_kill_orphan_ttyd_kills_running_process():
    """kill_orphan_ttyd() kills a running orphan process and returns True."""
    pid_path = ttyd_mod.TTYD_PID_PATH
    pid_path.parent.mkdir(parents=True, exist_ok=True)
    pid_path.write_text("55555")

    kill_calls = []

    def mock_os_kill(pid, sig):
        kill_calls.append((pid, sig))
        # First existence-check (sig=0) succeeds; subsequent sig=0 calls raise
        if sig == 0 and sum(1 for _, s in kill_calls if s == 0) > 1:
            raise ProcessLookupError

    with patch("os.kill", side_effect=mock_os_kill):
        result = await kill_orphan_ttyd()

    assert result is True
    assert not pid_path.exists(), "PID file should be removed after kill_orphan_ttyd()"
    sigterm_calls = [(pid, sig) for pid, sig in kill_calls if sig == signal.SIGTERM]
    assert len(sigterm_calls) == 1
    assert sigterm_calls[0][0] == 55555


async def test_kill_orphan_ttyd_handles_pid_file_with_dead_process():
    """kill_orphan_ttyd() handles a stale PID file whose process is already gone."""
    pid_path = ttyd_mod.TTYD_PID_PATH
    pid_path.parent.mkdir(parents=True, exist_ok=True)
    pid_path.write_text("77777")

    with patch("os.kill", side_effect=ProcessLookupError):
        result = await kill_orphan_ttyd()

    assert result is True
    assert not pid_path.exists(), "PID file should be removed after orphan cleanup"


async def test_kill_ttyd_kills_orphan_on_port_when_pid_file_desynced():
    """kill_ttyd() must also kill orphaned ttyd processes on TTYD_PORT.

    If the PID file points to a dead process (desynced), but the REAL ttyd is
    still running on TTYD_PORT (orphaned from a previous spawn), kill_ttyd()
    must find and kill it via lsof -ti :<port>.  This is the belt-and-suspenders
    fallback that prevents the session-switching bug where a new ttyd cannot bind
    the port because the old one is still running.
    """
    pid_path = ttyd_mod.TTYD_PID_PATH
    pid_path.parent.mkdir(parents=True, exist_ok=True)
    # PID file with a dead process (desynced)
    pid_path.write_text("99999")

    killed_pids: list[tuple[int, int]] = []

    def mock_os_kill(pid: int, sig: int) -> None:
        if pid == 99999 and sig == 0:
            raise ProcessLookupError  # PID file process is already dead
        killed_pids.append((pid, sig))

    mock_lsof_result = MagicMock()
    mock_lsof_result.returncode = 0
    mock_lsof_result.stdout = "12345\n"  # orphan PID occupying TTYD_PORT

    def mock_subprocess_run(cmd, **kwargs):  # noqa: ANN001
        result = MagicMock()
        if "lsof" in cmd and "-ti" in cmd:
            return mock_lsof_result
        result.returncode = 1
        result.stdout = ""
        return result

    with (
        patch("os.kill", side_effect=mock_os_kill),
        patch("muxplex.ttyd._subprocess.run", side_effect=mock_subprocess_run),
    ):
        result = await kill_ttyd()

    assert result is True, "kill_ttyd must return True when orphan found on port"
    orphan_killed = any(
        pid == 12345 and sig == signal.SIGTERM for pid, sig in killed_pids
    )
    assert orphan_killed, (
        "kill_ttyd must send SIGTERM to orphan process (12345) found via "
        "lsof on TTYD_PORT when PID file is desynced"
    )


async def test_spawn_ttyd_force_kills_process_on_port_before_binding():
    """spawn_ttyd() must force-kill any process occupying TTYD_PORT before spawning.

    If kill_ttyd() completed but the port is still occupied (race condition),
    spawn_ttyd() must do a final SIGKILL cleanup so the new ttyd can bind.
    """
    mock_proc = _make_mock_ttyd_process(pid=22222)

    # First lsof call returns an occupant; second call (after kill) returns empty
    call_count = 0

    def mock_subprocess_run(cmd, **kwargs):  # noqa: ANN001
        nonlocal call_count
        result = MagicMock()
        if "lsof" in cmd and "-ti" in cmd:
            call_count += 1
            if call_count == 1:
                result.returncode = 0
                result.stdout = "54321\n"  # port still occupied
                return result
        result.returncode = 1
        result.stdout = ""
        return result

    killed_pids: list[tuple[int, int]] = []

    def mock_os_kill(pid: int, sig: int) -> None:
        killed_pids.append((pid, sig))

    with (
        patch("asyncio.create_subprocess_exec", new=AsyncMock(return_value=mock_proc)),
        patch("muxplex.ttyd._subprocess.run", side_effect=mock_subprocess_run),
        patch("os.kill", side_effect=mock_os_kill),
    ):
        await spawn_ttyd("test-session")

    force_killed = any(
        pid == 54321 and sig == signal.SIGKILL for pid, sig in killed_pids
    )
    assert force_killed, (
        "spawn_ttyd must SIGKILL any process occupying TTYD_PORT before spawning "
        "to prevent 'address already in use' errors"
    )


# ---------------------------------------------------------------------------
# _pids_on_port fallback chain tests
#
# These cover the exact production failure: lsof not installed → all kill
# strategies silently returned False → stale ttyd survived restarts.
# ---------------------------------------------------------------------------


def _make_subprocess_result(returncode: int, stdout: str) -> MagicMock:
    r = MagicMock()
    r.returncode = returncode
    r.stdout = stdout
    return r


def test_pids_on_port_uses_lsof_when_available():
    """_pids_on_port() returns PID list from lsof output when lsof is available."""

    def mock_run(cmd, **kwargs):  # noqa: ANN001
        if cmd[0] == "lsof":
            return _make_subprocess_result(0, "3555095\n")
        return _make_subprocess_result(1, "")

    with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
        result = _pids_on_port(7682)

    assert result == [3555095], "Should return PID from lsof"


def test_pids_on_port_falls_back_to_fuser_when_lsof_missing():
    """_pids_on_port() uses fuser when lsof is not installed (FileNotFoundError).

    This is the exact failure mode that caused the yazi-stuck-ttyd bug:
    lsof was absent, _kill_pids_on_port silently returned False, and the
    stale ttyd was never killed.
    """

    def mock_run(cmd, **kwargs):  # noqa: ANN001
        if cmd[0] == "lsof":
            raise FileNotFoundError("lsof not found")
        if cmd[0] == "fuser":
            return _make_subprocess_result(0, " 3555095")
        return _make_subprocess_result(1, "")

    with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
        result = _pids_on_port(7682)

    assert result == [3555095], "Should fall back to fuser when lsof is missing"


def test_pids_on_port_falls_back_to_ss_when_lsof_and_fuser_missing():
    """_pids_on_port() uses ss when both lsof and fuser are unavailable."""
    ss_output = (
        'LISTEN 0 128 0.0.0.0:7682 0.0.0.0:* users:(("ttyd",pid=3555095,fd=12))\n'
    )

    def mock_run(cmd, **kwargs):  # noqa: ANN001
        if cmd[0] in ("lsof", "fuser"):
            raise FileNotFoundError(f"{cmd[0]} not found")
        if cmd[0] == "ss":
            return _make_subprocess_result(0, ss_output)
        return _make_subprocess_result(1, "")

    with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
        result = _pids_on_port(7682)

    assert result == [3555095], "Should fall back to ss when lsof and fuser missing"


def test_pids_on_port_returns_empty_when_all_tools_fail():
    """_pids_on_port() returns [] when no discovery tool is available."""

    def mock_run(cmd, **kwargs):  # noqa: ANN001
        raise FileNotFoundError(f"{cmd[0]} not found")

    with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
        result = _pids_on_port(7682)

    assert result == [], "Should return empty list when all tools fail"


def test_kill_pids_on_port_sends_signal_via_fuser_fallback():
    """_kill_pids_on_port() sends the correct signal using the fuser fallback.

    End-to-end regression guard: verifies that the full chain from
    _kill_pids_on_port → _pids_on_port → fuser → os.kill works correctly
    so that stale ttyd processes are killed even when lsof is absent.
    """
    killed: list[tuple[int, int]] = []

    def mock_run(cmd, **kwargs):  # noqa: ANN001
        if cmd[0] == "lsof":
            raise FileNotFoundError("lsof not found")
        if cmd[0] == "fuser":
            return _make_subprocess_result(0, " 3555095")
        return _make_subprocess_result(1, "")

    def mock_kill(pid: int, sig: int) -> None:
        killed.append((pid, sig))

    with (
        patch("muxplex.ttyd._subprocess.run", side_effect=mock_run),
        patch("os.kill", side_effect=mock_kill),
    ):
        result = _kill_pids_on_port(7682, signal.SIGKILL)

    assert result is True, "_kill_pids_on_port must return True when fuser finds a PID"
    assert (3555095, signal.SIGKILL) in killed, (
        "Must send SIGKILL to PID 3555095 discovered via fuser fallback"
    )


async def test_kill_orphan_ttyd_handles_invalid_pid_file_content():
    """kill_orphan_ttyd() gracefully handles a PID file with non-integer content."""
    pid_path = ttyd_mod.TTYD_PID_PATH
    pid_path.parent.mkdir(parents=True, exist_ok=True)
    pid_path.write_text("not-a-pid")

    # Should not raise and should clean up the file.
    # kill_ttyd() calls pid_path.unlink(missing_ok=True) before returning False
    # on invalid content, so the file is removed even though no kill occurred.
    result = await kill_orphan_ttyd()

    assert result is False
    assert not pid_path.exists(), "Invalid PID file should be removed"
