"""Unit tests for ttyd single-active-project policy (audit H4 / H2).

Tests _evict_other_projects_locked directly using mock _ProcRecord objects
to avoid real filesystem I/O, real subprocess spawning, and real port binding.
No ttyd binary required; these run unconditionally.
"""

from __future__ import annotations

import pytest

from recoil.api.ttyd_routes import (
    _PROCS,
    _PROCS_LOCK,
    _PORTS_RESERVED,
    _ProcRecord,
    _evict_other_projects_locked,
)
from recoil.api.chat_sessions import _now_iso
from unittest.mock import MagicMock


# ── Helpers ───────────────────────────────────────────────────────────────────


def _make_mock_proc(alive: bool = True) -> MagicMock:
    """Return a mock subprocess.Popen that looks alive (or dead)."""
    mock = MagicMock()
    mock.poll.return_value = None if alive else 1
    return mock


def _make_record(project_id: str, port: int, alive: bool = True) -> _ProcRecord:
    return _ProcRecord(
        proc=_make_mock_proc(alive=alive),
        port=port,
        session_id="",
        started_at=_now_iso(),
        project_id=project_id,
    )


@pytest.fixture(autouse=True)
def _clean_procs():
    """Ensure global _PROCS / _PORTS_RESERVED are empty before and after each test."""
    with _PROCS_LOCK:
        _PROCS.clear()
        _PORTS_RESERVED.clear()
    yield
    with _PROCS_LOCK:
        _PROCS.clear()
        _PORTS_RESERVED.clear()


# ── Tests ─────────────────────────────────────────────────────────────────────


def test_evict_other_evicts_non_active_projects():
    """Active project stays in _PROCS; other projects are evicted."""
    rec_tartarus = _make_record("tartarus", port=7681)
    rec_afterimage = _make_record("afterimage", port=7682)

    with _PROCS_LOCK:
        _PROCS["tartarus"] = rec_tartarus
        _PROCS["afterimage"] = rec_afterimage
        _PORTS_RESERVED.update({7681, 7682})

        evicted = _evict_other_projects_locked("afterimage")

    # tartarus should have been evicted; afterimage kept
    assert len(evicted) == 1
    assert evicted[0].project_id == "tartarus"

    # afterimage proc untouched in _PROCS
    assert "tartarus" not in _PROCS
    assert "afterimage" in _PROCS

    # tartarus port released; afterimage port still reserved
    assert 7681 not in _PORTS_RESERVED
    assert 7682 in _PORTS_RESERVED


def test_evict_other_noop_when_only_active_project():
    """No evictions when only the active project is registered."""
    rec = _make_record("tartarus", port=7681)

    with _PROCS_LOCK:
        _PROCS["tartarus"] = rec
        _PORTS_RESERVED.add(7681)

        evicted = _evict_other_projects_locked("tartarus")

    assert evicted == []
    assert "tartarus" in _PROCS
    assert 7681 in _PORTS_RESERVED


def test_evict_other_noop_when_procs_empty():
    """No error when _PROCS is empty."""
    with _PROCS_LOCK:
        evicted = _evict_other_projects_locked("tartarus")

    assert evicted == []


def test_evict_other_evicts_multiple_stale_projects():
    """All projects other than the active one are evicted."""
    recs = {
        "tartarus": _make_record("tartarus", port=7681),
        "afterimage": _make_record("afterimage", port=7682),
        "driver-beware": _make_record("driver-beware", port=7683),
    }

    with _PROCS_LOCK:
        _PROCS.update(recs)
        _PORTS_RESERVED.update({7681, 7682, 7683})

        evicted = _evict_other_projects_locked("driver-beware")

    evicted_ids = {r.project_id for r in evicted}
    assert evicted_ids == {"tartarus", "afterimage"}

    assert "tartarus" not in _PROCS
    assert "afterimage" not in _PROCS
    assert "driver-beware" in _PROCS

    assert 7681 not in _PORTS_RESERVED
    assert 7682 not in _PORTS_RESERVED
    assert 7683 in _PORTS_RESERVED


def test_evict_other_returns_record_for_dead_process():
    """A process that is already dead is still evicted and returned for cleanup."""
    rec_dead = _make_record("tartarus", port=7681, alive=False)
    rec_active = _make_record("afterimage", port=7682, alive=True)

    with _PROCS_LOCK:
        _PROCS["tartarus"] = rec_dead
        _PROCS["afterimage"] = rec_active
        _PORTS_RESERVED.update({7681, 7682})

        evicted = _evict_other_projects_locked("afterimage")

    assert len(evicted) == 1
    assert evicted[0].project_id == "tartarus"
    assert "tartarus" not in _PROCS
    assert 7681 not in _PORTS_RESERVED


def test_evict_other_does_not_send_signals():
    """_evict_other_projects_locked must NOT send signals — killing is caller's job."""
    rec_stale = _make_record("tartarus", port=7681, alive=True)
    rec_active = _make_record("afterimage", port=7682, alive=True)

    with _PROCS_LOCK:
        _PROCS["tartarus"] = rec_stale
        _PROCS["afterimage"] = rec_active
        _PORTS_RESERVED.update({7681, 7682})

        _evict_other_projects_locked("afterimage")

    # Eviction must NOT send any signals — killing happens outside the lock
    rec_stale.proc.terminate.assert_not_called()
    rec_stale.proc.kill.assert_not_called()
