"""ttyd lifecycle integration test.

Exercises the per-project ttyd lifecycle through FastAPI's TestClient
end-to-end against a real loaded project (driver-beware):

  start  →  status (running)  →  health  →  context-window  →  stop  →  status (stopped)

Plus an idempotency check on /start (second call returns the same port +
session_id, per the in-process registry guard).

Skips cleanly when ``ttyd`` is not on PATH; production hosts have it via
``brew install ttyd``. Teardown invokes ``_kill_all_ttyds()`` so the test
never leaks ttyd procs even on assertion failure.

"""

from __future__ import annotations

import shutil
import time
from datetime import datetime

import pytest
from fastapi.testclient import TestClient

from recoil.api.main import app
from recoil.api.ttyd_routes import _kill_all_ttyds

# ── Constants ────────────────────────────────────────────────────────────────

TTYD_BIN_PRESENT = shutil.which("ttyd") is not None
PROJECT_ID = "driver-beware"

P_START = "/api/ttyd/start"
P_STOP = "/api/ttyd/stop"
P_STATUS = "/api/ttyd/status"
P_CONTEXT = "/api/ttyd/context-window"
P_HEALTH = "/api/ttyd/health"

from recoil.api.ttyd_routes import _PORT_MIN as PORT_MIN, _PORT_MAX as PORT_MAX  # noqa: E402


pytestmark = pytest.mark.skipif(
    not TTYD_BIN_PRESENT, reason="ttyd binary not on PATH"
)


# ── Fixtures ─────────────────────────────────────────────────────────────────


@pytest.fixture
def client():
    """FastAPI TestClient with guaranteed ttyd-proc cleanup on teardown."""
    with TestClient(app) as c:
        try:
            yield c
        finally:
            # Kill every spawned ttyd even if the test exploded mid-flight.
            _kill_all_ttyds()


@pytest.fixture(autouse=True)
def _shrink_session_capture_timeout(monkeypatch):
    """Cap the JSONL capture poll at 2s so the test doesn't hang 15s.

    A fresh ``claude --resume`` session on a never-touched project may
    not write its JSONL fast enough; we don't care — the contract only
    requires session_id to be a string (``"unknown"`` is legal per
    ``_capture_session_id``).
    """
    import recoil.api.ttyd_routes as ttyd_mod

    monkeypatch.setattr(ttyd_mod, "_SESSION_CAPTURE_TIMEOUT_S", 2.0)
    monkeypatch.setattr(ttyd_mod, "_SESSION_CAPTURE_POLL_S", 0.2)
    yield


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


def _is_iso_timestamp(s: object) -> bool:
    if not isinstance(s, str):
        return False
    try:
        # _now_iso emits ``YYYY-MM-DDTHH:MM:SSZ`` — fromisoformat handles
        # the ``+00:00`` form; swap a trailing Z for +00:00 to parse.
        normalized = s.replace("Z", "+00:00") if s.endswith("Z") else s
        datetime.fromisoformat(normalized)
        return True
    except (TypeError, ValueError):
        return False


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


def test_ttyd_full_lifecycle(client: TestClient) -> None:
    """start → status → health → context-window → stop → status(stopped)."""

    # ── /start ────────────────────────────────────────────────────────────
    r = client.post(P_START, json={"project_id": PROJECT_ID})
    assert r.status_code == 200, r.text
    started = r.json()
    assert isinstance(started.get("port"), int), started
    assert PORT_MIN <= started["port"] <= PORT_MAX, started
    assert isinstance(started.get("session_id"), str), started
    port = started["port"]
    session_id = started["session_id"]

    # ── /status (running) ─────────────────────────────────────────────────
    r = client.get(P_STATUS, params={"project_id": PROJECT_ID})
    assert r.status_code == 200, r.text
    status = r.json()
    assert status["running"] is True, status
    assert status["port"] == port, status
    # session_id may be returned as None if it was empty; otherwise must
    # equal what /start returned. (Empty string -> None per status route.)
    if session_id:
        assert status["session_id"] == session_id, status
    assert _is_iso_timestamp(status.get("started_at")), status

    # ── /health ───────────────────────────────────────────────────────────
    r = client.get(P_HEALTH)
    assert r.status_code == 200, r.text
    assert r.json() == {"status": "ok"}

    # ── /context-window ───────────────────────────────────────────────────
    r = client.get(P_CONTEXT, params={"project_id": PROJECT_ID})
    assert r.status_code == 200, r.text
    cw = r.json()
    assert "used" in cw and (cw["used"] is None or isinstance(cw["used"], int)), cw
    assert isinstance(cw.get("limit"), int), cw
    assert cw["pct"] is None or isinstance(cw["pct"], float), cw

    # ── /stop ─────────────────────────────────────────────────────────────
    r = client.post(P_STOP, json={"project_id": PROJECT_ID})
    assert r.status_code == 200, r.text
    assert r.json() == {"ok": True}

    # ── /status (stopped) — poll up to 3s ────────────────────────────────
    deadline = time.monotonic() + 3.0
    last_status: dict = {}
    while time.monotonic() < deadline:
        r = client.get(P_STATUS, params={"project_id": PROJECT_ID})
        assert r.status_code == 200, r.text
        last_status = r.json()
        if last_status.get("running") is False:
            break
        time.sleep(0.1)
    assert last_status.get("running") is False, last_status


def test_ttyd_start_is_idempotent(client: TestClient) -> None:
    """Two consecutive /start calls return the same port + session_id."""
    r1 = client.post(P_START, json={"project_id": PROJECT_ID})
    assert r1.status_code == 200, r1.text
    first = r1.json()

    r2 = client.post(P_START, json={"project_id": PROJECT_ID})
    assert r2.status_code == 200, r2.text
    second = r2.json()

    assert first["port"] == second["port"], (first, second)
    assert first["session_id"] == second["session_id"], (first, second)

    # Cleanup explicitly (the autouse teardown also handles this, but
    # leaving the proc behind would slow the next test under -x reruns).
    client.post(P_STOP, json={"project_id": PROJECT_ID})
