"""Regression tests — wakeup-turn model-resolve hang (t_46fadfbc).

Proven root cause (from a live thread-stack capture of a hung wakeup
turn, "Slow WebUI request still running"):

    _handle_chat_start → _resolve_compatible_session_model_state
      → get_available_models → _build_available_models_uncached
        → _read_live_provider_model_ids → get_copilot_api_token
          → exchange_copilot_token → urllib HTTPS → BLOCKED

A server-initiated Option-Z wakeup turn (drain thread, idle session, no
browser) reached chat/start with a COLD provider catalog and triggered a
LIVE per-provider rebuild whose Copilot token-exchange HTTPS call hung the
wakeup turn forever on this WSL/corp network — NOT a race.

Two fixes, two tests:

1. test_wakeup_turn_uses_persisted_model_no_live_probe
   start_session_turn(source="process_wakeup") resolves the model from the
   persisted session record via the cache-only path; the live provider
   rebuild (and the Copilot exchange) is never invoked even when the catalog
   is cold. The turn still starts with the persisted model.

2. test_chat_start_survives_slow_provider_probe
   Defense-in-depth: an unbounded/hanging provider probe cannot stall a
   foreground get_available_models() past the wall-clock budget — it falls
   back to a usable model list and lets the rebuild finish out-of-band.
"""

from __future__ import annotations

import threading
import time
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[1]


# ---------------------------------------------------------------------------
# Fix 1 — wakeup resolves persisted model with NO live provider probe
# ---------------------------------------------------------------------------

def test_wakeup_turn_uses_persisted_model_no_live_probe(monkeypatch):
    """Cold catalog + a session with a persisted model/provider: a
    server-side wakeup must start the turn using the PERSISTED model and must
    NOT call the live per-provider rebuild (which is what does the blocking
    Copilot token-exchange HTTPS call in the proven thread-stack).
    """
    from api import config as cfg
    import api.routes as routes

    sid = "sess-wakeup-persisted"
    persisted_model = "anthropic/claude-sonnet-4"
    fake_stream_id = "stream-wakeup-persisted-1"

    # HARD ASSERT: the live per-provider probe must never run on the wakeup
    # path. _read_live_provider_model_ids is the function the thread-stack
    # shows calling get_copilot_api_token → exchange_copilot_token.
    def _boom(*_a, **_k):
        raise AssertionError(
            "live provider rebuild ran on a wakeup turn — "
            "_read_live_provider_model_ids must NOT be called (it does the "
            "blocking Copilot token-exchange HTTPS call)"
        )

    monkeypatch.setattr(cfg, "_read_live_provider_model_ids", _boom, raising=True)
    # Cold cache: force the cache-miss branch that used to trigger the live
    # rebuild. The autouse conftest fixture already invalidates, but be
    # explicit so this test documents the precondition.
    cfg.invalidate_models_cache()

    captured: dict = {}

    def _fake_start_chat_stream_for_session(s, **kwargs):
        captured["model"] = kwargs.get("model")
        captured["model_provider"] = kwargs.get("model_provider")
        return {"stream_id": fake_stream_id, "session_id": s.session_id, "_status": 200}

    class _FakeSession:
        session_id = sid
        model = persisted_model
        model_provider = "anthropic"

    monkeypatch.setattr(
        routes, "_start_chat_stream_for_session",
        _fake_start_chat_stream_for_session, raising=True,
    )
    monkeypatch.setattr(routes, "get_session", lambda _sid: _FakeSession(), raising=True)
    monkeypatch.setattr(
        routes, "_resolve_chat_workspace_with_recovery",
        lambda s, w: "/tmp/ws", raising=True,
    )

    t0 = time.monotonic()
    resp = routes.start_session_turn(
        sid, "[IMPORTANT: Background process done]", source="process_wakeup"
    )
    elapsed = time.monotonic() - t0

    assert resp.get("stream_id") == fake_stream_id, "wakeup turn did not start"
    # The persisted model survives to the turn — never dropped, never blocked.
    assert captured["model"] == persisted_model, (
        f"wakeup turn used {captured.get('model')!r}, expected the persisted "
        f"session model {persisted_model!r}"
    )
    # Fast: no network. Cache-only resolution is sub-second; allow generous
    # slack for slow CI but well under the 10s Copilot timeout that was the
    # original symptom.
    assert elapsed < 5.0, (
        f"wakeup turn took {elapsed:.2f}s — a live provider probe likely ran"
    )


def test_wakeup_resolve_passes_prefer_cached_catalog(monkeypatch):
    """White-box: start_session_turn must route model resolution through the
    cache-only path (prefer_cached_catalog=True). Pins the wiring so a future
    refactor can't silently reintroduce the live-probe hang.
    """
    import api.routes as routes

    sid = "sess-wakeup-wiring"
    seen: dict = {}

    real = routes._resolve_compatible_session_model_state

    def _spy(model_id, model_provider=None, *, profile_provider=None,
             profile_default_model=None, profile_config=None,
             prefer_cached_catalog=False, **kwargs):
        seen["prefer_cached_catalog"] = prefer_cached_catalog
        # The wakeup path now also threads the session's profile model defaults
        # through (greptile fix) so a brand-new session with an empty model
        # falls back to the profile default, not the global DEFAULT_MODEL.
        seen["profile_provider"] = profile_provider
        seen["profile_default_model"] = profile_default_model
        seen["profile_config"] = profile_config
        return ("m", None, False)

    monkeypatch.setattr(
        routes, "_resolve_compatible_session_model_state", _spy, raising=True
    )
    monkeypatch.setattr(
        routes, "_start_chat_stream_for_session",
        lambda s, **k: {"stream_id": "x", "session_id": s.session_id, "_status": 200},
        raising=True,
    )

    class _FakeSession:
        session_id = sid
        model = "m"
        model_provider = None

    monkeypatch.setattr(routes, "get_session", lambda _s: _FakeSession(), raising=True)
    monkeypatch.setattr(
        routes, "_resolve_chat_workspace_with_recovery",
        lambda s, w: "/tmp/ws", raising=True,
    )

    routes.start_session_turn(sid, "[IMPORTANT: x]", source="process_wakeup")
    assert seen.get("prefer_cached_catalog") is True, (
        "start_session_turn must resolve the model with "
        "prefer_cached_catalog=True so a wakeup never triggers a live probe"
    )
    assert real is not None  # the real function still exists (not deleted)


# ---------------------------------------------------------------------------
# Fix 2 — bounded rebuild: a slow probe cannot stall chat/start forever
# ---------------------------------------------------------------------------

def test_chat_start_survives_slow_provider_probe(monkeypatch):
    """Simulate a hanging provider probe on the NORMAL (prefer_cache=False)
    cold path: get_available_models() must return within the wall-clock
    budget with a usable fallback instead of blocking on the network.
    """
    from api import config as cfg

    cfg.invalidate_models_cache()
    monkeypatch.setattr(cfg, "_LIVE_REBUILD_BUDGET_SECONDS", 0.4, raising=True)

    probe_started = threading.Event()
    release_probe = threading.Event()
    real_thread = threading.Thread
    rebuild_workers = []
    rebuild_worker = None

    def _record_thread(*args, **kwargs):
        worker = real_thread(*args, **kwargs)
        if kwargs.get("name") == "models-catalog-rebuild":
            rebuild_workers.append(worker)
        return worker

    def _slow_rebuild(_builder):
        probe_started.set()
        # Hold the probe past the foreground budget without assuming how
        # quickly a loaded CI worker schedules or sleeps.
        assert release_probe.wait(timeout=5.0), "test did not release slow probe"
        return {
            "active_provider": "anthropic",
            "default_model": "anthropic/claude-sonnet-4",
            "configured_model_badges": {},
            "groups": [],
        }

    # Replace the rebuild seam so no real per-provider network call happens
    # but the foreground still has to wait for (a stand-in for) it.
    monkeypatch.setattr(cfg, "_invoke_models_rebuild", _slow_rebuild, raising=True)
    # Ensure no disk cache short-circuits the cold path.
    monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: None, raising=True)
    monkeypatch.setattr(cfg.threading, "Thread", _record_thread, raising=True)

    try:
        result = cfg.get_available_models()

        assert probe_started.wait(timeout=5.0), (
            "the rebuild worker should have started"
        )
        assert len(rebuild_workers) == 1
        rebuild_worker = rebuild_workers[0]
        # The foreground returned while the probe was deliberately blocked, so
        # the result can only be the bounded-path fallback. Assert that contract
        # directly instead of comparing wall-clock time on a contended runner.
        assert rebuild_worker.is_alive()
        assert isinstance(result, dict)
        for k in (
            "active_provider",
            "default_model",
            "configured_model_badges",
            "groups",
        ):
            assert k in result, f"fallback catalog missing {k!r}"
        assert isinstance(result["groups"], list)
    finally:
        release_probe.set()
        for worker in rebuild_workers:
            worker.join(timeout=5.0)
        for worker in rebuild_workers:
            assert not worker.is_alive(), "slow rebuild worker did not finish"
        if rebuild_workers:
            assert cfg._cache_build_in_progress is False
        cfg.invalidate_models_cache()

    assert rebuild_worker is not None


def test_minimal_static_catalog_is_network_free(monkeypatch):
    """The fallback catalog builder must never reach the live provider probe.
    """
    from api import config as cfg

    monkeypatch.setattr(
        cfg, "_read_live_provider_model_ids",
        lambda *_a, **_k: (_ for _ in ()).throw(
            AssertionError("minimal catalog must not probe providers")
        ),
        raising=True,
    )
    out = cfg._minimal_static_models_catalog()
    assert set(out) >= {
        "active_provider", "default_model", "configured_model_badges", "groups"
    }
    assert isinstance(out["groups"], list)


def test_prefer_cache_kw_exists_and_skips_live_rebuild(monkeypatch):
    """get_available_models(prefer_cache=True) must resolve without invoking
    the rebuild seam at all when the cache is cold.
    """
    from api import config as cfg

    cfg.invalidate_models_cache()
    monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: None, raising=True)

    def _must_not_run(_b):
        raise AssertionError(
            "prefer_cache=True triggered the live rebuild seam — it must "
            "serve cache/minimal-static only"
        )

    monkeypatch.setattr(cfg, "_invoke_models_rebuild", _must_not_run, raising=True)

    result = cfg.get_available_models(prefer_cache=True)
    assert isinstance(result, dict)
    assert "default_model" in result and "groups" in result


# ---------------------------------------------------------------------------
# Source-grep wiring guards
# ---------------------------------------------------------------------------

def test_get_available_models_has_prefer_cache_param():
    import inspect

    from api import config as cfg

    sig = inspect.signature(cfg.get_available_models)
    assert "prefer_cache" in sig.parameters
    param = sig.parameters["prefer_cache"]
    assert param.kind is inspect.Parameter.KEYWORD_ONLY
    assert param.default is False
    assert "_LIVE_REBUILD_BUDGET_SECONDS" in cfg.__dict__
    assert "_minimal_static_models_catalog" in cfg.__dict__


def test_start_session_turn_uses_cached_catalog():
    src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
    # The wakeup entrypoint must resolve with the cache-only flag.
    i = src.find("def start_session_turn(")
    assert i != -1
    j = src.find("def _handle_process_complete_ack", i)
    body = src[i:j]
    assert "prefer_cached_catalog=True" in body, (
        "start_session_turn must pass prefer_cached_catalog=True"
    )
