"""Regression coverage for #4756 session-visit model catalog freshness."""

from __future__ import annotations

import io
import json
import os
import time
from pathlib import Path
from urllib.parse import urlparse

REPO = Path(__file__).resolve().parent.parent


def _catalog(label: str) -> dict:
    return {
        "active_provider": "openai",
        "default_model": label,
        "configured_model_badges": {},
        "groups": [
            {
                "provider": "OpenAI",
                "provider_id": "openai",
                "models": [{"id": label, "label": label, "supports_fast_tier": False}],
            }
        ],
        "aliases": {},
    }


def _reset_models_memory_cache(monkeypatch):
    import api.config as cfg

    monkeypatch.setattr(cfg, "_available_models_cache", None, raising=False)
    monkeypatch.setattr(cfg, "_available_models_cache_ts", 0.0, raising=False)
    monkeypatch.setattr(cfg, "_available_models_live_rebuild_ts", 0.0, raising=False)
    monkeypatch.setattr(cfg, "_available_models_cache_source_fingerprint", None, raising=False)
    monkeypatch.setattr(cfg, "_cache_build_in_progress", False, raising=False)


def test_session_visit_fresh_profile_cache_returns_without_live_rebuild(tmp_path, monkeypatch):
    import api.config as cfg

    _reset_models_memory_cache(monkeypatch)
    disk_catalog = _catalog("cached-model")
    cache_path = tmp_path / "models_cache.profile.json"
    cache_path.write_text("{}", encoding="utf-8")
    now = time.time()
    os.utime(cache_path, (now, now))

    monkeypatch.setattr(cfg, "_SESSION_VISIT_MODELS_FRESHNESS_SECONDS", 300.0, raising=False)
    monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
    monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: disk_catalog)
    monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: None)
    monkeypatch.setattr(cfg, "_models_cache_source_fingerprint", lambda: {"profile": "demo"})

    def _unexpected_live_rebuild(**_kwargs):
        raise AssertionError("fresh session-visit cache must not run a live rebuild")

    monkeypatch.setattr(cfg, "get_available_models", _unexpected_live_rebuild)

    assert cfg.get_available_models_for_session_visit() == disk_catalog


def test_session_visit_ignores_recently_warmed_memory_when_disk_cache_is_stale(tmp_path, monkeypatch):
    import api.config as cfg

    _reset_models_memory_cache(monkeypatch)
    stale_catalog = _catalog("stale-model")
    rebuilt_catalog = _catalog("rebuilt-model")
    cache_path = tmp_path / "models_cache.profile.json"
    cache_path.write_text("{}", encoding="utf-8")
    old = time.time() - 600.0
    os.utime(cache_path, (old, old))
    calls = []

    monkeypatch.setattr(cfg, "_SESSION_VISIT_MODELS_FRESHNESS_SECONDS", 300.0, raising=False)
    monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
    monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: stale_catalog)
    monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: stale_catalog)
    monkeypatch.setattr(cfg, "_models_cache_source_fingerprint", lambda: {"profile": "demo"})
    monkeypatch.setattr(cfg, "_available_models_cache", stale_catalog, raising=False)
    monkeypatch.setattr(cfg, "_available_models_cache_ts", time.monotonic(), raising=False)
    monkeypatch.setattr(cfg, "_available_models_cache_source_fingerprint", {"profile": "demo"}, raising=False)

    def _live_rebuild(**kwargs):
        calls.append(kwargs)
        return rebuilt_catalog

    monkeypatch.setattr(cfg, "get_available_models", _live_rebuild)

    assert cfg.get_available_models_for_session_visit() == rebuilt_catalog
    assert calls == [{"force_refresh": True}]


def test_session_visit_stale_profile_cache_revalidates_with_live_rebuild(tmp_path, monkeypatch):
    import api.config as cfg

    _reset_models_memory_cache(monkeypatch)
    stale_catalog = _catalog("stale-model")
    rebuilt_catalog = _catalog("rebuilt-model")
    cache_path = tmp_path / "models_cache.profile.json"
    cache_path.write_text("{}", encoding="utf-8")
    old = time.time() - 600.0
    os.utime(cache_path, (old, old))
    calls = []

    monkeypatch.setattr(cfg, "_SESSION_VISIT_MODELS_FRESHNESS_SECONDS", 300.0, raising=False)
    monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
    monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: stale_catalog)
    monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: stale_catalog)

    def _live_rebuild(**kwargs):
        calls.append(kwargs)
        return rebuilt_catalog

    monkeypatch.setattr(cfg, "get_available_models", _live_rebuild)

    assert cfg.get_available_models_for_session_visit() == rebuilt_catalog
    assert calls == [{"force_refresh": True}]


def test_session_visit_overlapping_stale_calls_coalesce_to_single_live_rebuild(tmp_path, monkeypatch):
    import api.config as cfg
    import threading
    from concurrent.futures import ThreadPoolExecutor

    _reset_models_memory_cache(monkeypatch)
    stale_catalog = _catalog("stale-model")
    rebuilt_catalog = _catalog("rebuilt-model")
    config_path = tmp_path / "config.yaml"
    config_path.write_text("{}", encoding="utf-8")
    cache_path = tmp_path / "models_cache.profile.json"
    cache_path.write_text("{}", encoding="utf-8")
    old = time.time() - 600.0
    os.utime(cache_path, (old, old))
    fingerprint = {"profile": "demo"}
    stale_load_counts = {}
    stale_load_lock = threading.Lock()
    second_load_gate = threading.Barrier(2)
    rebuild_count = 0
    rebuild_lock = threading.Lock()

    monkeypatch.setattr(cfg, "_SESSION_VISIT_MODELS_FRESHNESS_SECONDS", 300.0, raising=False)
    monkeypatch.setattr(cfg, "_LIVE_REBUILD_BUDGET_SECONDS", 0.0, raising=False)
    monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path)
    monkeypatch.setattr(cfg, "_cfg_path", config_path, raising=False)
    monkeypatch.setattr(cfg, "_cfg_mtime", config_path.stat().st_mtime, raising=False)
    monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
    monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: None)
    monkeypatch.setattr(cfg, "_models_cache_source_fingerprint", lambda: fingerprint)
    monkeypatch.setattr(cfg, "_save_models_cache_to_disk", lambda _cache: None)

    def _load_stale_models_cache_from_disk():
        ident = threading.get_ident()
        with stale_load_lock:
            count = stale_load_counts.get(ident, 0) + 1
            stale_load_counts[ident] = count
        if count == 2:
            second_load_gate.wait(timeout=5)
        return stale_catalog

    def _invoke_models_rebuild(_builder):
        nonlocal rebuild_count
        with rebuild_lock:
            rebuild_count += 1
        return rebuilt_catalog

    monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", _load_stale_models_cache_from_disk)
    monkeypatch.setattr(cfg, "_invoke_models_rebuild", _invoke_models_rebuild)

    with ThreadPoolExecutor(max_workers=2) as executor:
        futures = [executor.submit(cfg.get_available_models_for_session_visit) for _ in range(2)]
        results = [future.result(timeout=10) for future in futures]

    assert all(result == rebuilt_catalog for result in results)
    assert rebuild_count == 1


def test_force_refresh_sync_followers_wait_past_legacy_timeout(tmp_path, monkeypatch):
    import api.config as cfg
    import threading

    _reset_models_memory_cache(monkeypatch)
    stale_catalog = _catalog("stale-model")
    rebuilt_catalog = _catalog("rebuilt-model")
    config_path = tmp_path / "config.yaml"
    config_path.write_text("{}", encoding="utf-8")
    cache_path = tmp_path / "models_cache.profile.json"
    cache_path.write_text("{}", encoding="utf-8")
    old = time.time() - 600.0
    os.utime(cache_path, (old, old))
    fingerprint = {"profile": "demo"}
    rebuild_count = 0
    timeout_waits = []
    original_wait_for = threading.Condition.wait_for

    monkeypatch.setattr(cfg, "_LIVE_REBUILD_BUDGET_SECONDS", 0.0, raising=False)
    monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path)
    monkeypatch.setattr(cfg, "_cfg_path", config_path, raising=False)
    monkeypatch.setattr(cfg, "_cfg_mtime", config_path.stat().st_mtime, raising=False)
    monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
    monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: None)
    monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: stale_catalog)
    monkeypatch.setattr(cfg, "_models_cache_source_fingerprint", lambda: fingerprint)
    monkeypatch.setattr(cfg, "_save_models_cache_to_disk", lambda _cache: None)

    def _wait_for(self, predicate, timeout=None):
        if self is not cfg._cache_build_cv:
            return original_wait_for(self, predicate, timeout)
        timeout_waits.append(timeout)
        if timeout is None:
            published_at = time.monotonic()
            cfg._available_models_cache = rebuilt_catalog
            cfg._available_models_cache_ts = published_at
            cfg._available_models_live_rebuild_ts = published_at
            cfg._available_models_cache_source_fingerprint = fingerprint
            cfg._cache_build_in_progress = False
            return True
        if timeout == 60.0:
            return False
        return original_wait_for(self, predicate, timeout=timeout)

    def _invoke_models_rebuild(_builder):
        nonlocal rebuild_count
        rebuild_count += 1
        return rebuilt_catalog

    monkeypatch.setattr(cfg, "_cache_build_in_progress", True, raising=False)
    monkeypatch.setattr(threading.Condition, "wait_for", _wait_for)
    monkeypatch.setattr(cfg, "_invoke_models_rebuild", _invoke_models_rebuild)

    assert cfg.get_available_models(force_refresh=True) == rebuilt_catalog
    assert rebuild_count == 0
    assert None in timeout_waits
    assert 60.0 not in timeout_waits

def test_force_refresh_sync_followers_retry_after_failed_active_rebuild(tmp_path, monkeypatch):
    import api.config as cfg
    import threading

    _reset_models_memory_cache(monkeypatch)
    stale_catalog = _catalog("stale-model")
    rebuilt_catalog = _catalog("rebuilt-model")
    config_path = tmp_path / "config.yaml"
    config_path.write_text("{}", encoding="utf-8")
    cache_path = tmp_path / "models_cache.profile.json"
    cache_path.write_text("{}", encoding="utf-8")
    old = time.time() - 600.0
    os.utime(cache_path, (old, old))
    fingerprint = {"profile": "demo"}
    rebuild_count = 0
    timeout_waits = []
    original_wait_for = threading.Condition.wait_for

    monkeypatch.setattr(cfg, "_LIVE_REBUILD_BUDGET_SECONDS", 0.0, raising=False)
    monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path)
    monkeypatch.setattr(cfg, "_cfg_path", config_path, raising=False)
    monkeypatch.setattr(cfg, "_cfg_mtime", config_path.stat().st_mtime, raising=False)
    monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
    monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: None)
    monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: stale_catalog)
    monkeypatch.setattr(cfg, "_models_cache_source_fingerprint", lambda: fingerprint)
    monkeypatch.setattr(cfg, "_save_models_cache_to_disk", lambda _cache: None)

    def _wait_for(self, predicate, timeout=None):
        if self is not cfg._cache_build_cv:
            return original_wait_for(self, predicate, timeout)
        timeout_waits.append(timeout)
        if timeout is None:
            cfg._cache_build_in_progress = False
            return True
        return original_wait_for(self, predicate, timeout=timeout)

    def _invoke_models_rebuild(_builder):
        nonlocal rebuild_count
        rebuild_count += 1
        return rebuilt_catalog

    monkeypatch.setattr(cfg, "_cache_build_in_progress", True, raising=False)
    monkeypatch.setattr(threading.Condition, "wait_for", _wait_for)
    monkeypatch.setattr(cfg, "_invoke_models_rebuild", _invoke_models_rebuild)

    assert cfg.get_available_models(force_refresh=True) == rebuilt_catalog
    assert rebuild_count == 1
    assert None in timeout_waits
    assert cfg._available_models_cache == rebuilt_catalog
    assert cfg._cache_build_in_progress is False


def test_force_refresh_bounded_followers_wait_only_remaining_budget(tmp_path, monkeypatch):
    import api.config as cfg
    import threading

    _reset_models_memory_cache(monkeypatch)
    stale_catalog = _catalog("stale-model")
    config_path = tmp_path / "config.yaml"
    config_path.write_text("{}", encoding="utf-8")
    cache_path = tmp_path / "models_cache.profile.json"
    cache_path.write_text("{}", encoding="utf-8")
    old = time.time() - 600.0
    os.utime(cache_path, (old, old))
    fingerprint = {"profile": "demo"}
    timeout_waits = []
    original_wait_for = threading.Condition.wait_for

    monkeypatch.setattr(cfg, "_LIVE_REBUILD_BUDGET_SECONDS", 0.05, raising=False)
    monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path)
    monkeypatch.setattr(cfg, "_cfg_path", config_path, raising=False)
    monkeypatch.setattr(cfg, "_cfg_mtime", config_path.stat().st_mtime, raising=False)
    monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
    monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: None)
    monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: stale_catalog)
    monkeypatch.setattr(cfg, "_models_cache_source_fingerprint", lambda: fingerprint)
    monkeypatch.setattr(cfg, "_save_models_cache_to_disk", lambda _cache: None)

    def _wait_for(self, predicate, timeout=None):
        if self is not cfg._cache_build_cv:
            return original_wait_for(self, predicate, timeout)
        timeout_waits.append(timeout)
        time.sleep(min(timeout or 0.0, 0.01))
        return False

    monkeypatch.setattr(cfg, "_cache_build_in_progress", True, raising=False)
    monkeypatch.setattr(threading.Condition, "wait_for", _wait_for)

    started_at = time.monotonic()
    result = cfg.get_available_models(force_refresh=True)
    elapsed = time.monotonic() - started_at

    assert result == stale_catalog
    assert len(timeout_waits) == 1
    assert timeout_waits[0] is not None
    assert 0.0 <= timeout_waits[0] <= 0.05
    assert 60.0 not in timeout_waits
    assert elapsed < 0.1


def test_session_visit_overlapping_stale_calls_do_not_duplicate_over_budget_rebuild(tmp_path, monkeypatch):
    import api.config as cfg
    import threading
    from concurrent.futures import ThreadPoolExecutor

    _reset_models_memory_cache(monkeypatch)
    stale_catalog = _catalog("stale-model")
    rebuilt_catalog = _catalog("rebuilt-model")
    config_path = tmp_path / "config.yaml"
    config_path.write_text("{}", encoding="utf-8")
    cache_path = tmp_path / "models_cache.profile.json"
    cache_path.write_text("{}", encoding="utf-8")
    old = time.time() - 600.0
    os.utime(cache_path, (old, old))
    fingerprint = {"profile": "demo"}
    stale_load_counts = {}
    stale_load_lock = threading.Lock()
    second_load_gate = threading.Barrier(2)
    rebuild_count = 0
    rebuild_lock = threading.Lock()

    monkeypatch.setattr(cfg, "_SESSION_VISIT_MODELS_FRESHNESS_SECONDS", 300.0, raising=False)
    monkeypatch.setattr(cfg, "_LIVE_REBUILD_BUDGET_SECONDS", 0.01, raising=False)
    monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path)
    monkeypatch.setattr(cfg, "_cfg_path", config_path, raising=False)
    monkeypatch.setattr(cfg, "_cfg_mtime", config_path.stat().st_mtime, raising=False)
    monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
    monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: None)
    monkeypatch.setattr(cfg, "_models_cache_source_fingerprint", lambda: fingerprint)
    monkeypatch.setattr(cfg, "_save_models_cache_to_disk", lambda _cache: None)

    def _load_stale_models_cache_from_disk():
        ident = threading.get_ident()
        with stale_load_lock:
            count = stale_load_counts.get(ident, 0) + 1
            stale_load_counts[ident] = count
        if count == 2:
            second_load_gate.wait(timeout=5)
        return stale_catalog

    def _invoke_models_rebuild(_builder):
        nonlocal rebuild_count
        with rebuild_lock:
            rebuild_count += 1
        time.sleep(0.05)
        return rebuilt_catalog

    monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", _load_stale_models_cache_from_disk)
    monkeypatch.setattr(cfg, "_invoke_models_rebuild", _invoke_models_rebuild)

    with ThreadPoolExecutor(max_workers=2) as executor:
        futures = [executor.submit(cfg.get_available_models_for_session_visit) for _ in range(2)]
        results = [future.result(timeout=10) for future in futures]

    assert all(result == stale_catalog for result in results)
    assert rebuild_count == 1
    time.sleep(0.1)
    assert cfg._available_models_cache == rebuilt_catalog
    assert cfg._cache_build_in_progress is False


def test_session_visit_force_refresh_ignores_plain_disk_publish_started_after_refresh(tmp_path, monkeypatch):
    import api.config as cfg
    import threading
    from concurrent.futures import ThreadPoolExecutor

    _reset_models_memory_cache(monkeypatch)
    stale_catalog = _catalog("stale-model")
    rebuilt_catalog = _catalog("rebuilt-model")
    config_path = tmp_path / "config.yaml"
    config_path.write_text("{}", encoding="utf-8")
    cache_path = tmp_path / "models_cache.profile.json"
    cache_path.write_text("{}", encoding="utf-8")
    old = time.time() - 600.0
    os.utime(cache_path, (old, old))
    fingerprint = {"profile": "demo"}
    stale_load_counts = {}
    stale_load_lock = threading.Lock()
    force_refresh_waiting = threading.Event()
    plain_publish_done = threading.Event()
    rebuild_count = 0
    rebuild_lock = threading.Lock()

    monkeypatch.setattr(cfg, "_SESSION_VISIT_MODELS_FRESHNESS_SECONDS", 300.0, raising=False)
    monkeypatch.setattr(cfg, "_LIVE_REBUILD_BUDGET_SECONDS", 0.0, raising=False)
    monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path)
    monkeypatch.setattr(cfg, "_cfg_path", config_path, raising=False)
    monkeypatch.setattr(cfg, "_cfg_mtime", config_path.stat().st_mtime, raising=False)
    monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
    monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: stale_catalog)
    monkeypatch.setattr(cfg, "_models_cache_source_fingerprint", lambda: fingerprint)
    monkeypatch.setattr(cfg, "_save_models_cache_to_disk", lambda _cache: None)

    def _load_stale_models_cache_from_disk():
        ident = threading.get_ident()
        with stale_load_lock:
            count = stale_load_counts.get(ident, 0) + 1
            stale_load_counts[ident] = count
        if count == 2:
            force_refresh_waiting.set()
            assert plain_publish_done.wait(timeout=5)
        return stale_catalog

    def _invoke_models_rebuild(_builder):
        nonlocal rebuild_count
        with rebuild_lock:
            rebuild_count += 1
        return rebuilt_catalog

    def _plain_disk_hit():
        result = cfg.get_available_models()
        plain_publish_done.set()
        return result

    monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", _load_stale_models_cache_from_disk)
    monkeypatch.setattr(cfg, "_invoke_models_rebuild", _invoke_models_rebuild)

    with ThreadPoolExecutor(max_workers=2) as executor:
        refresh_future = executor.submit(cfg.get_available_models_for_session_visit)
        assert force_refresh_waiting.wait(timeout=5)
        plain_result = executor.submit(_plain_disk_hit).result(timeout=10)
        refresh_result = refresh_future.result(timeout=10)

    assert plain_result == stale_catalog
    assert refresh_result == rebuilt_catalog
    assert rebuild_count == 1
    assert cfg._available_models_cache == rebuilt_catalog


def test_session_visit_fresh_disk_hit_does_not_overwrite_newer_memory_cache(tmp_path, monkeypatch):
    import api.config as cfg

    _reset_models_memory_cache(monkeypatch)
    stale_catalog = _catalog("stale-model")
    rebuilt_catalog = _catalog("rebuilt-model")
    cache_path = tmp_path / "models_cache.profile.json"
    cache_path.write_text("{}", encoding="utf-8")
    now = time.time()
    os.utime(cache_path, (now, now))
    fingerprint = {"profile": "demo"}

    monkeypatch.setattr(cfg, "_SESSION_VISIT_MODELS_FRESHNESS_SECONDS", 300.0, raising=False)
    monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
    monkeypatch.setattr(cfg, "_models_cache_source_fingerprint", lambda: fingerprint)

    def _disk_hit_after_newer_memory_publish():
        cfg._available_models_cache = rebuilt_catalog
        cfg._available_models_cache_ts = time.monotonic()
        cfg._available_models_cache_source_fingerprint = fingerprint
        return stale_catalog

    monkeypatch.setattr(cfg, "_load_models_cache_from_disk", _disk_hit_after_newer_memory_publish)
    monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: None)
    monkeypatch.setattr(
        cfg,
        "get_available_models",
        lambda **_kwargs: (_ for _ in ()).throw(
            AssertionError("fresh disk hit must not rebuild live models")
        ),
    )

    assert cfg.get_available_models_for_session_visit() == rebuilt_catalog
    assert cfg._available_models_cache == rebuilt_catalog


def test_force_refresh_keeps_build_flag_set_until_disk_save_finishes(tmp_path, monkeypatch):
    import api.config as cfg

    _reset_models_memory_cache(monkeypatch)
    rebuilt_catalog = _catalog("rebuilt-model")
    config_path = tmp_path / "config.yaml"
    config_path.write_text("{}", encoding="utf-8")
    cache_path = tmp_path / "models_cache.profile.json"
    fingerprint = {"profile": "demo"}
    observed = []

    monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path)
    monkeypatch.setattr(cfg, "_cfg_path", config_path, raising=False)
    monkeypatch.setattr(cfg, "_cfg_mtime", config_path.stat().st_mtime, raising=False)
    monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
    monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: None)
    monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: None)
    monkeypatch.setattr(cfg, "_models_cache_source_fingerprint", lambda: fingerprint)
    monkeypatch.setattr(cfg, "_invoke_models_rebuild", lambda _builder: rebuilt_catalog)

    def _save_and_observe(_cache):
        observed.append(cfg._cache_build_in_progress)

    monkeypatch.setattr(cfg, "_save_models_cache_to_disk", _save_and_observe)

    assert cfg.get_available_models(force_refresh=True) == rebuilt_catalog
    assert observed == [True]
    assert cfg._cache_build_in_progress is False


def test_default_disk_hit_does_not_restamp_stale_cache_for_session_visit(tmp_path, monkeypatch):
    import api.config as cfg

    _reset_models_memory_cache(monkeypatch)
    stale_catalog = _catalog("stale-model")
    rebuilt_catalog = _catalog("rebuilt-model")
    cache_path = tmp_path / "models_cache.profile.json"
    cache_path.write_text("{}", encoding="utf-8")
    old = time.time() - 600.0
    os.utime(cache_path, (old, old))
    refresh_calls = []

    monkeypatch.setattr(cfg, "_SESSION_VISIT_MODELS_FRESHNESS_SECONDS", 300.0, raising=False)
    monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
    monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: stale_catalog)
    monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: stale_catalog)
    monkeypatch.setattr(cfg, "_models_cache_source_fingerprint", lambda: {"profile": "demo"})
    monkeypatch.setattr(cfg, "_cfg_mtime", 0.0, raising=False)
    monkeypatch.setattr(cfg, "_save_models_cache_to_disk", lambda _cache: (_ for _ in ()).throw(
        AssertionError("plain disk hits must not rewrite the models cache file")
    ))

    assert cfg.get_available_models() == stale_catalog

    def _live_rebuild(**kwargs):
        refresh_calls.append(kwargs)
        return rebuilt_catalog

    monkeypatch.setattr(cfg, "get_available_models", _live_rebuild)

    assert cfg.get_available_models_for_session_visit() == rebuilt_catalog
    assert refresh_calls == [{"force_refresh": True}]


def test_session_visit_live_rebuild_failure_falls_back_to_cached_catalog(tmp_path, monkeypatch):
    import api.config as cfg

    _reset_models_memory_cache(monkeypatch)
    stale_catalog = _catalog("fallback-model")
    cache_path = tmp_path / "models_cache.profile.json"
    cache_path.write_text("{}", encoding="utf-8")
    old = time.time() - 600.0
    os.utime(cache_path, (old, old))

    monkeypatch.setattr(cfg, "_SESSION_VISIT_MODELS_FRESHNESS_SECONDS", 300.0, raising=False)
    monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path)
    monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: None)
    monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: stale_catalog)

    def _failing_live_rebuild(**kwargs):
        assert kwargs == {"force_refresh": True}
        raise RuntimeError("provider unavailable")

    monkeypatch.setattr(cfg, "get_available_models", _failing_live_rebuild)

    assert cfg.get_available_models_for_session_visit() == stale_catalog


class _FakeHandler:
    def __init__(self):
        self.status = None
        self.sent_headers = []
        self.body = bytearray()
        self.wfile = self
        self.rfile = io.BytesIO(b"")
        self.headers = {"Content-Length": "0"}
        self.request = None

    def send_response(self, status):
        self.status = status

    def send_header(self, name, value):
        self.sent_headers.append((name, value))

    def end_headers(self):
        pass

    def write(self, data):
        self.body.extend(data)

    def json_body(self):
        return json.loads(bytes(self.body).decode("utf-8"))


def test_models_route_session_visit_freshness_uses_bounded_helper(monkeypatch):
    import api.routes as routes

    expected = _catalog("route-model")
    calls = []

    def _session_visit_catalog():
        calls.append("session_visit")
        return expected

    monkeypatch.setattr(routes, "get_available_models_for_session_visit", _session_visit_catalog)

    handler = _FakeHandler()
    parsed = urlparse("http://example.com/api/models?freshness=session_visit")
    routes.handle_get(handler, parsed)

    assert handler.status == 200
    assert handler.json_body()["default_model"] == "route-model"
    assert calls == ["session_visit"]


def _read_static(name: str) -> str:
    return (REPO / "static" / name).read_text(encoding="utf-8")


def _extract_function_body(src: str, signature: str) -> str:
    idx = src.find(signature)
    if idx == -1:
        raise AssertionError(f"signature {signature!r} not found")
    header_end = src.find("){", idx)
    if header_end == -1:
        raise AssertionError(f"function body start for {signature!r} not found")
    open_idx = header_end + 1
    depth = 0
    for i in range(open_idx, len(src)):
        if src[i] == "{":
            depth += 1
        elif src[i] == "}":
            depth -= 1
            if depth == 0:
                return src[idx : i + 1]
    raise AssertionError(f"unbalanced braces in {signature!r}")


def test_populate_model_dropdown_accepts_session_visit_freshness_and_guards_stale_responses():
    body = _extract_function_body(_read_static("ui.js"), "async function populateModelDropdown(")
    live_tail = _extract_function_body(_read_static("ui.js"), "async function _fetchLiveModels(")

    assert "modelsUrl.searchParams.set('freshness',opts.freshness)" in body
    assert "const requestSeq=++_modelDropdownRequestSeq" in body
    assert body.count("requestSeq!==_modelDropdownRequestSeq") >= 3
    assert "_fetchLiveModels(data.active_provider, sel, requestSeq)" in body
    assert live_tail.count("requestSeq!==null&&requestSeq!==_modelDropdownRequestSeq") >= 4


def test_load_session_schedules_session_visit_model_refresh_before_message_load():
    body = _extract_function_body(_read_static("sessions.js"), "async function loadSession(")

    assign_idx = body.index("S.session=data.session")
    message_load_idx = body.index("await _ensureMessagesLoaded(sid", assign_idx)
    failure_return_idx = body.index("return;", message_load_idx)
    model_block_idx = body.index("if(typeof populateModelDropdown==='function')", assign_idx)
    guard_helper_idx = body.index("const isActiveModelRefreshSession", model_block_idx)
    promise_idx = body.index("const modelRefreshPromise=_deferSessionSideEffect", model_block_idx)
    ready_idx = body.index("window._modelDropdownReady=modelRefreshPromise", promise_idx)
    refresh_idx = body.index("populateModelDropdown({freshness:'session_visit'})", promise_idx)

    assert assign_idx < model_block_idx < message_load_idx < failure_return_idx
    assert model_block_idx < promise_idx < refresh_idx < ready_idx
    assert guard_helper_idx < promise_idx
    assert "_loadingSessionId!==modelRefreshSid" not in body[model_block_idx:ready_idx], (
        "deferred model refresh must guard on the active session, not _loadingSessionId, "
        "because loadSession clears _loadingSessionId when the first paint is complete"
    )


def test_session_visit_model_refresh_is_deferred_until_after_first_paint():
    sessions = _read_static("sessions.js")
    defer_helper = _extract_function_body(sessions, "function _afterSessionFirstPaint(")
    side_effect_helper = _extract_function_body(sessions, "function _deferSessionSideEffect(")
    load_body = _extract_function_body(sessions, "async function loadSession(")

    assert "requestAnimationFrame(()=>requestAnimationFrame(run))" in defer_helper
    assert "requestIdleCallback(invoke,{timeout:1500})" in defer_helper
    assert "return _afterSessionFirstPaint(()=>" in side_effect_helper
    assert "const modelRefreshPromise=_deferSessionSideEffect" in load_body
    assert "isActiveModelRefreshSession()" in load_body
    assert "return populateModelDropdown({freshness:'session_visit'});" in load_body


def test_boot_model_dropdown_clears_cached_ready_on_401():
    body = _extract_function_body(_read_static("boot.js"), "const _redirectBootModelDropdownIfUnauth=(res)=>")

    status_idx = body.index("if(!res||res.status!==401) return false;")
    clear_idx = body.index("window._modelDropdownReady=null;")
    consumed_idx = body.index("if(_bootActiveProfileUnauthRedirectBudget.isConsumed()) return true;")

    assert status_idx < clear_idx < consumed_idx
