"""Regression tests: ``/api/profiles`` handlers must not block the event loop.

``hermes_cli/web_routers/profiles.py`` holds handler bodies that were extracted
verbatim from ``web_server.py``, so the blocking library calls they inherited
run inline on the ASGI event loop. The worst of them are unbounded from the
dashboard's point of view: deleting a profile whose gateway is up sleeps up to
10 s in ``profiles._stop_gateway_process``, and ``describe-auto`` makes a
provider round-trip with a 60 s ceiling. While the loop is parked, the process
serves nothing else — including the ``/api/ws`` probes the desktop app and the
dashboard's own Chat tab depend on.

Each slow site gets a **concurrency proof**: the stubbed callee blocks on a
``threading.Event`` while an unrelated request is timed, which fails if the
loop is parked.

The block is bounded by a timeout so a regression costs the suite a few
seconds rather than hanging it.
"""

from __future__ import annotations

import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

import pytest

pytest.importorskip("fastapi")
from fastapi.testclient import TestClient  # noqa: E402

# How long a stubbed blocking call holds its thread when nobody releases it.
BLOCK_SECONDS = 5.0
# A concurrently-served request has to land well inside that window. The gap is
# large (a served request takes milliseconds) so the bound is not timing-fragile.
CONCURRENT_BUDGET = BLOCK_SECONDS / 2

@pytest.fixture()
def profile_dir(tmp_path, monkeypatch) -> Path:
    """A real profile directory under a throwaway HERMES_HOME."""
    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    from hermes_cli import profiles as profiles_mod

    d = profiles_mod.get_profile_dir("demo")
    d.mkdir(parents=True, exist_ok=True)
    (d / "config.yaml").write_text("{}\n")  # identity marker: a bare dir is not a profile
    return d

@pytest.fixture()
def client(profile_dir):
    """One ``TestClient`` context == one portal == one event loop.

    This matters: entering the context manager pins a single blocking portal
    for every request, so a handler that parks the loop really does starve the
    concurrent request below. A bare ``TestClient(app)`` spins up a fresh loop
    per request and would pass these tests even unfixed.
    """
    from hermes_cli import web_server

    with TestClient(web_server.app, raise_server_exceptions=False) as c:
        # web_server resolves _SESSION_TOKEN once, at import, so read it back
        # from the module rather than pinning a value from this file.
        c.headers["Authorization"] = f"Bearer {web_server._SESSION_TOKEN}"
        yield c

class _Blocker:
    """A stand-in for a slow library call, released by the test."""

    def __init__(self, result=None):
        self.entered = threading.Event()
        self.release = threading.Event()
        self._result = result

    def __call__(self, *args, **kwargs):
        self.entered.set()
        # Bounded on purpose: a regression must not hang the suite.
        self.release.wait(timeout=BLOCK_SECONDS)
        return self._result

def assert_serves_concurrently(client, blocker: _Blocker, fire) -> None:
    """Fire a request that blocks inside its handler, then time another one.

    ``fire`` issues the blocking request from a worker thread. Once the stub
    has been entered, an unrelated cheap route is timed on this thread: it can
    only answer quickly if the blocking work left the event loop.
    """
    with ThreadPoolExecutor(max_workers=1) as pool:
        blocked = pool.submit(fire)
        try:
            assert blocker.entered.wait(timeout=BLOCK_SECONDS), (
                "the blocking stub was never reached"
            )
            start = time.monotonic()
            probe = client.get("/api/profiles/demo/setup-command")
            elapsed = time.monotonic() - start
        finally:
            blocker.release.set()
        blocked.result(timeout=BLOCK_SECONDS * 2)

    assert probe.status_code == 200, probe.text
    assert elapsed < CONCURRENT_BUDGET, (
        f"a concurrent request waited {elapsed:.2f}s while the handler was "
        f"busy — the event loop was parked by the blocking call"
    )

# ── DELETE /api/profiles/{name} — the 10 s gateway-stop sleep ────────────────

def test_delete_profile_does_not_block_the_dashboard(client, monkeypatch, tmp_path):
    """``delete_profile`` stops a running gateway by polling for up to 10 s.

    That is longer than the desktop's WebSocket ready-probe tolerates, so it
    must not hold the loop.
    """
    from hermes_cli import profiles as profiles_mod

    blocker = _Blocker(result=tmp_path / "profiles" / "demo")
    monkeypatch.setattr(profiles_mod, "delete_profile", blocker)

    assert_serves_concurrently(
        client, blocker, lambda: client.delete("/api/profiles/demo")
    )

# ── POST /api/profiles/{name}/describe-auto — the 60 s LLM round-trip ────────

def _outcome(ok=True, reason="described", description="a demo profile"):
    from hermes_cli.profile_describer import DescribeOutcome

    return DescribeOutcome("demo", ok, reason, description=description)

def test_describe_auto_does_not_block_the_dashboard(client, monkeypatch):
    """The auxiliary provider call has a 60 s ceiling — six times the
    desktop's disconnect threshold."""
    from hermes_cli import profile_describer

    blocker = _Blocker(result=_outcome())
    monkeypatch.setattr(profile_describer, "describe_profile", blocker)

    assert_serves_concurrently(
        client,
        blocker,
        lambda: client.post(
            "/api/profiles/demo/describe-auto", json={"overwrite": True}
        ),
    )

# ── PATCH /api/profiles/{name} — rename walks and rewrites the profile tree ──

def test_rename_profile_does_not_block_the_dashboard(client, monkeypatch, tmp_path):
    from hermes_cli import profiles as profiles_mod

    blocker = _Blocker(result=tmp_path / "profiles" / "renamed")
    monkeypatch.setattr(profiles_mod, "rename_profile", blocker)

    assert_serves_concurrently(
        client,
        blocker,
        lambda: client.patch("/api/profiles/demo", json={"new_name": "renamed"}),
    )

# ── /api/profiles/active — sticky active-profile state file ──────────────────

# ── PUT /api/profiles/{name}/description — profile.yaml read/modify/write ────

# ── GET /api/profiles/{name}/desktop-overlay — reads desktop.json ────────────

def test_desktop_overlay_absent_still_reports_missing(client):
    """The offloaded read must keep distinguishing "no file" from "no data"."""
    resp = client.get("/api/profiles/demo/desktop-overlay")

    assert resp.status_code == 200, resp.text
    assert resp.json() == {"exists": False, "desktop": None}

def test_desktop_overlay_null_document_still_reports_present(client, profile_dir):
    """A ``desktop.json`` holding literal ``null`` exists, it is just empty —
    it must not be reported the same as a missing file."""
    (profile_dir / "desktop.json").write_text("null", encoding="utf-8")

    resp = client.get("/api/profiles/demo/desktop-overlay")

    assert resp.status_code == 200, resp.text
    assert resp.json() == {"exists": True, "desktop": None}

def test_desktop_overlay_unreadable_document_is_a_500(client, profile_dir):
    """A malformed overlay still surfaces as a 500, not a silent success."""
    (profile_dir / "desktop.json").write_text("{not json", encoding="utf-8")

    resp = client.get("/api/profiles/demo/desktop-overlay")

    assert resp.status_code == 500, resp.text

# ── Status-code mapping must survive the move into a worker thread ───────────

def test_delete_missing_profile_is_still_404(client, monkeypatch):
    from hermes_cli import profiles as profiles_mod

    def fake_delete(name, yes=False):
        raise FileNotFoundError(f"Profile '{name}' does not exist.")

    monkeypatch.setattr(profiles_mod, "delete_profile", fake_delete)

    assert client.delete("/api/profiles/demo").status_code == 404

def test_rename_to_existing_profile_is_still_400(client, monkeypatch):
    from hermes_cli import profiles as profiles_mod

    def fake_rename(old, new):
        raise FileExistsError(f"Profile '{new}' already exists.")

    monkeypatch.setattr(profiles_mod, "rename_profile", fake_rename)

    resp = client.patch("/api/profiles/demo", json={"new_name": "taken"})
    assert resp.status_code == 400, resp.text

def test_set_active_missing_profile_is_still_404(client, monkeypatch):
    from hermes_cli import profiles as profiles_mod

    def fake_set_active(name):
        raise FileNotFoundError(f"Profile '{name}' does not exist.")

    monkeypatch.setattr(profiles_mod, "set_active_profile", fake_set_active)

    assert client.post("/api/profiles/active", json={"name": "demo"}).status_code == 404

def test_describe_auto_unknown_profile_is_still_404(client):
    """``_resolve_profile_dir`` still runs before the worker hop, so an
    unknown profile is a 404 rather than a 500 from the wrapped call."""
    assert (
        client.post("/api/profiles/nope/describe-auto", json={}).status_code == 404
    )

# ── PUT /api/profiles/{name}/model — config.yaml read-modify-write ───────────
