"""Regression tests for #1567 — Nous Portal picker capacity + endpoint symmetry.

Two issues addressed in one PR:

1. **Endpoint disagreement (the bug):** The Settings → Providers card and the
   model picker dropdown returned different Nous catalogs because their
   detection paths differ. ``api/providers.py:get_providers`` iterates ALL
   OAuth providers regardless of `list_available_providers().authenticated`.
   ``api/config.py:_build_available_models_uncached`` only includes providers
   in ``detected_providers``, which is gated on
   ``list_available_providers().authenticated``. On some hermes_cli versions
   that flag disagrees with ``get_auth_status(<id>).logged_in``. Result: the
   providers card shows the live catalog (e.g. 396 models) and the picker
   shows nothing or the stale 4-entry static fallback.

2. **UX cap (the design concern):** Even with the disagreement fixed, dumping
   a 397-model dropdown into the picker would be unusable. We cap the
   dropdown at ~15 featured entries (deterministic vendor-priority sample,
   sticky for the user's currently-selected model) and return the full
   catalog under ``extra_models`` so /model autocomplete and the dynamic
   label map still cover everything.

Tests in this file pin both invariants.
"""

from __future__ import annotations

import sys
import types

import api.config as config
import api.profiles as profiles


# Big catalog matches the shape of an enterprise Nous Portal account.
# Volume distribution mirrors what we saw on Nathan's machine (~30 models)
# extrapolated up to ~400 with the same vendor mix Deor reported.
_BIG_CATALOG_VENDORS = {
    "anthropic": 8, "openai": 30, "google": 12, "moonshotai": 5, "z-ai": 15,
    "minimax": 10, "qwen": 80, "x-ai": 8, "deepseek": 20, "stepfun": 10,
    "xiaomi": 6, "tencent": 12, "nvidia": 25, "arcee-ai": 8,
    "meta-llama": 50, "mistralai": 40, "cohere": 25, "databricks": 15, "lambda-ai": 18,
}


def _build_big_catalog() -> list[str]:
    out = []
    for v, n in _BIG_CATALOG_VENDORS.items():
        for i in range(n):
            out.append(f"{v}/model-{v}-{i:02d}")
    return out


def _install_fake_hermes_cli(
    monkeypatch,
    *,
    nous_ids: list[str] | None = None,
    raise_on_lookup: bool = False,
    list_authenticated: bool = True,
    auth_status_logged_in: bool = True,
):
    """Install fake ``hermes_cli`` modules with controllable Nous behavior.

    The two flags ``list_authenticated`` and ``auth_status_logged_in`` model
    the divergence between ``hermes_cli.models.list_available_providers()``
    and ``hermes_cli.auth.get_auth_status()`` that #1567 calls out as a
    real-world pattern on some hermes_cli versions.
    """
    fake_pkg = types.ModuleType("hermes_cli")
    fake_pkg.__path__ = []

    fake_models = types.ModuleType("hermes_cli.models")
    fake_models.list_available_providers = lambda: [
        {"id": "nous", "label": "Nous Portal", "aliases": [], "authenticated": list_authenticated},
    ]
    if raise_on_lookup:
        def _raise(_pid):
            raise RuntimeError("simulated hermes_cli failure")
        fake_models.provider_model_ids = _raise
    else:
        ids = list(nous_ids) if nous_ids is not None else []
        fake_models.provider_model_ids = lambda pid: ids if pid == "nous" else []

    fake_auth = types.ModuleType("hermes_cli.auth")

    def _get_auth_status(pid):
        if pid == "nous":
            return {"logged_in": auth_status_logged_in, "key_source": "oauth"}
        return {}

    fake_auth.get_auth_status = _get_auth_status

    monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg)
    monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models)
    monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth)
    monkeypatch.delitem(sys.modules, "agent.credential_pool", raising=False)
    monkeypatch.delitem(sys.modules, "agent", raising=False)

    config.invalidate_models_cache()


def _swap_in_test_config(extra_cfg):
    old_cfg = dict(config.cfg)
    old_mtime = config._cfg_mtime
    config.cfg.clear()
    config.cfg["model"] = {}
    config.cfg.update(extra_cfg)
    try:
        config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
    except Exception:
        config._cfg_mtime = 0.0

    def _restore():
        config.cfg.clear()
        config.cfg.update(old_cfg)
        config._cfg_mtime = old_mtime

    return _restore


def _scrub_provider_env(monkeypatch):
    """Drop every provider env var so detection doesn't leak unrelated keys."""
    for var in (
        "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY",
        "DEEPSEEK_API_KEY", "XAI_API_KEY", "GROQ_API_KEY",
        "MISTRAL_API_KEY", "OPENROUTER_API_KEY",
        "OLLAMA_CLOUD_API_KEY", "OLLAMA_API_KEY",
        "GLM_API_KEY", "KIMI_API_KEY", "MOONSHOT_API_KEY",
        "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY",
        "XIAOMI_API_KEY",
        "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY",
        "NOUS_API_KEY", "NVIDIA_API_KEY", "LM_API_KEY", "LMSTUDIO_API_KEY",
    ):
        monkeypatch.delenv(var, raising=False)


# ────────────────────────────────────────────────────────────────────────
# Section 1 — _build_nous_featured_set helper invariants
# ────────────────────────────────────────────────────────────────────────


class TestBuildNousFeaturedSet:
    """Unit tests for the deterministic featured-vs-extras split helper."""

    def test_small_catalog_is_no_op(self):
        from api.config import _build_nous_featured_set, _NOUS_FEATURED_THRESHOLD
        # 20 entries — below the threshold, helper should return the input
        # untouched and an empty extras list.
        catalog = [f"vendor/model-{i:02d}" for i in range(20)]
        assert len(catalog) <= _NOUS_FEATURED_THRESHOLD
        featured, extras = _build_nous_featured_set(catalog)
        assert featured == catalog
        assert extras == []

    def test_large_catalog_is_capped_to_target(self):
        from api.config import _build_nous_featured_set, _NOUS_FEATURED_TARGET
        catalog = _build_big_catalog()
        assert len(catalog) > 100, "test fixture should produce a large catalog"
        featured, extras = _build_nous_featured_set(catalog)
        assert len(featured) == _NOUS_FEATURED_TARGET, (
            f"Large catalog should produce exactly _NOUS_FEATURED_TARGET "
            f"featured entries, got {len(featured)}."
        )
        assert len(extras) == len(catalog) - _NOUS_FEATURED_TARGET

    def test_featured_and_extras_are_disjoint_and_complete(self):
        from api.config import _build_nous_featured_set
        catalog = _build_big_catalog()
        featured, extras = _build_nous_featured_set(catalog)
        assert set(featured) & set(extras) == set(), (
            "featured and extras must be disjoint — every model belongs to "
            "exactly one bucket."
        )
        assert set(featured) | set(extras) == set(catalog), (
            "featured ∪ extras must equal the input catalog — no model "
            "should be silently dropped."
        )

    def test_priority_vendors_get_picked_first(self):
        from api.config import _build_nous_featured_set, _NOUS_VENDOR_PRIORITY
        catalog = _build_big_catalog()
        featured, _ = _build_nous_featured_set(catalog)
        # Every priority vendor with ≥1 entry in the catalog must appear in
        # featured (round-robin guarantee until we hit the slot budget).
        featured_vendors = {m.split("/", 1)[0] for m in featured}
        for v in _NOUS_VENDOR_PRIORITY:
            if v in _BIG_CATALOG_VENDORS:
                assert v in featured_vendors, (
                    f"Priority vendor {v!r} missing from featured set — "
                    f"round-robin guarantee violated."
                )

    def test_sticky_selection_is_preserved(self):
        from api.config import _build_nous_featured_set
        catalog = _build_big_catalog()
        # Pick a model from a leftover (non-priority) vendor that wouldn't
        # normally make the featured cut.
        sticky = "lambda-ai/model-lambda-ai-15"
        assert sticky in catalog
        featured, extras = _build_nous_featured_set(catalog, selected_model_id=sticky)
        assert sticky in featured, (
            f"Sticky-selected model {sticky!r} must appear in featured — "
            f"otherwise the user's choice gets orphaned out of the dropdown "
            f"after a refresh."
        )
        assert sticky not in extras

    def test_sticky_selection_handles_at_nous_prefix(self):
        from api.config import _build_nous_featured_set
        catalog = _build_big_catalog()
        # The frontend stores selections as @nous:vendor/model — helper must
        # strip the prefix to match against the bare-id catalog.
        sticky_with_prefix = "@nous:lambda-ai/model-lambda-ai-15"
        bare = "lambda-ai/model-lambda-ai-15"
        featured, _ = _build_nous_featured_set(catalog, selected_model_id=sticky_with_prefix)
        assert bare in featured

    def test_curated_static_flagships_are_preserved(self):
        from api.config import _build_nous_featured_set, _PROVIDER_MODELS
        # Build a catalog that contains all the curated static IDs so the
        # rule-2 path fires.
        static_ids = []
        for entry in _PROVIDER_MODELS.get("nous", []):
            sid = entry["id"]
            if sid.startswith("@nous:"):
                sid = sid[len("@nous:"):]
            static_ids.append(sid)
        catalog = static_ids + [f"filler-vendor/filler-{i:03d}" for i in range(100)]
        featured, _ = _build_nous_featured_set(catalog)
        for sid in static_ids:
            assert sid in featured, (
                f"Curated static flagship {sid!r} dropped from featured set."
            )

    def test_empty_catalog_returns_empty(self):
        from api.config import _build_nous_featured_set
        f, e = _build_nous_featured_set([])
        assert f == [] and e == []

    def test_deterministic_across_calls(self):
        from api.config import _build_nous_featured_set
        catalog = _build_big_catalog()
        f1, e1 = _build_nous_featured_set(catalog)
        f2, e2 = _build_nous_featured_set(catalog)
        assert f1 == f2 and e1 == e2, (
            "Featured set must be deterministic — random/seeded selection "
            "would cause cache thrash and dropdown flicker on every reload."
        )


# ────────────────────────────────────────────────────────────────────────
# Section 2 — End-to-end /api/models behaviour with the cap applied
# ────────────────────────────────────────────────────────────────────────


class TestApiModelsLargeCatalog:
    """Wired-up test exercising the dispatch branch at config.py:2243."""

    def test_picker_caps_large_catalog_and_exposes_extras(self, monkeypatch, tmp_path):
        _scrub_provider_env(monkeypatch)
        catalog = _build_big_catalog()
        _install_fake_hermes_cli(monkeypatch, nous_ids=catalog)
        monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)

        restore = _swap_in_test_config({"model": {"provider": "nous"}})
        try:
            data = config.get_available_models()
            nous_groups = [g for g in data["groups"] if g["provider_id"] == "nous"]
            assert len(nous_groups) == 1
            grp = nous_groups[0]
            from api.config import _NOUS_FEATURED_TARGET
            assert len(grp["models"]) == _NOUS_FEATURED_TARGET, (
                f"Picker should render {_NOUS_FEATURED_TARGET} featured entries "
                f"on a {len(catalog)}-model catalog, got {len(grp['models'])}."
            )
            assert "extra_models" in grp, (
                "Capped Nous group must include 'extra_models' so /model "
                "autocomplete and the label map cover the full catalog."
            )
            assert len(grp["extra_models"]) == len(catalog) - _NOUS_FEATURED_TARGET
            # Optgroup label is decorated with the truncation count so the user
            # knows the dropdown is intentionally trimmed.
            assert f"{_NOUS_FEATURED_TARGET} of {len(catalog)}" in grp["provider"], (
                f"Provider label should include '({_NOUS_FEATURED_TARGET} of "
                f"{len(catalog)})' for capped catalogs, got {grp['provider']!r}."
            )
        finally:
            restore()

    def test_picker_does_not_cap_small_catalog(self, monkeypatch, tmp_path):
        _scrub_provider_env(monkeypatch)
        # 20 models — below threshold, should pass through with no extras.
        small_catalog = [f"vendor-{i % 4}/model-{i:02d}" for i in range(20)]
        _install_fake_hermes_cli(monkeypatch, nous_ids=small_catalog)
        monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)

        restore = _swap_in_test_config({"model": {"provider": "nous"}})
        try:
            data = config.get_available_models()
            grp = next(g for g in data["groups"] if g["provider_id"] == "nous")
            assert len(grp["models"]) == 20
            assert "extra_models" not in grp or grp["extra_models"] == []
            assert "of " not in grp["provider"], (
                "Optgroup label should NOT include a truncation count when no "
                "trimming happened, got " + repr(grp["provider"])
            )
        finally:
            restore()


# ────────────────────────────────────────────────────────────────────────
# Section 3 — Auth-detection symmetry (#1567 part 1)
# ────────────────────────────────────────────────────────────────────────


class TestNousDetectionSymmetry:
    """The picker must include Nous whenever the providers card would —
    fixes the asymmetric-detection bug at the heart of #1567."""

    def test_picker_includes_nous_when_get_auth_status_logged_in(self, monkeypatch, tmp_path):
        """list_available_providers() reports authenticated=False but
        get_auth_status('nous').logged_in=True. Picker must still show Nous."""
        _scrub_provider_env(monkeypatch)
        catalog = ["anthropic/claude-opus-4.7", "openai/gpt-5.5"]
        _install_fake_hermes_cli(
            monkeypatch,
            nous_ids=catalog,
            list_authenticated=False,  # primary detection path says NO
            auth_status_logged_in=True,  # secondary detection path says YES
        )
        monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)

        restore = _swap_in_test_config({"model": {"provider": "nous"}})
        try:
            data = config.get_available_models()
            nous_groups = [g for g in data["groups"] if g["provider_id"] == "nous"]
            assert nous_groups, (
                "Picker must include Nous group when get_auth_status reports "
                "logged_in=True, even if list_available_providers disagrees. "
                "This is the asymmetric-detection bug from #1567."
            )
            assert len(nous_groups[0]["models"]) == 2
        finally:
            restore()

    def test_picker_omits_nous_when_both_auth_signals_false(self, monkeypatch, tmp_path):
        """When neither signal reports authenticated, Nous should NOT appear.
        Previously the static 4-entry list could leak in via the fallback path
        even for unauthenticated users — that fallback is now scoped to the
        hermes_cli-unavailable case only."""
        _scrub_provider_env(monkeypatch)
        _install_fake_hermes_cli(
            monkeypatch,
            nous_ids=[],  # no live catalog (also no auth)
            list_authenticated=False,
            auth_status_logged_in=False,
        )
        monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)

        restore = _swap_in_test_config({"model": {"provider": "anthropic"}})
        try:
            # Active provider is anthropic, not nous — so detected_providers
            # only includes nous if the new auth-symmetry check fires.
            data = config.get_available_models()
            nous_groups = [g for g in data["groups"] if g["provider_id"] == "nous"]
            assert not nous_groups, (
                "Nous must NOT appear in picker when neither auth signal "
                "reports authenticated. Got: " + str(nous_groups)
            )
        finally:
            restore()


# ────────────────────────────────────────────────────────────────────────
# Section 4 — Live-fetch-empty handling (#1567 part 2)
# ────────────────────────────────────────────────────────────────────────


class TestNousLiveFetchEmpty:
    """When authenticated but live-fetch returns [] (transient hermes_cli
    state, OAuth refresh in flight), DON'T fall back to the stale 4-entry
    static list — that creates the providers-card-vs-picker disagreement
    that #1567 reports. Omit the group entirely instead."""

    def test_authenticated_empty_catalog_omits_nous_group(self, monkeypatch, tmp_path):
        _scrub_provider_env(monkeypatch)
        _install_fake_hermes_cli(
            monkeypatch,
            nous_ids=[],  # live-fetch returns empty list (no exception)
            auth_status_logged_in=True,  # but user IS authenticated
        )
        monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)

        restore = _swap_in_test_config({"model": {"provider": "nous"}})
        try:
            data = config.get_available_models()
            nous_groups = [g for g in data["groups"] if g["provider_id"] == "nous"]
            assert not nous_groups, (
                "Authenticated user with empty live-fetch should NOT see "
                "the stale 4-entry static list — that's exactly the "
                "providers-card-vs-picker disagreement #1567 reports. "
                "Omit the Nous group entirely; it'll re-populate on the "
                "next cache rebuild when the live-fetch returns something."
            )
        finally:
            restore()

    def test_hermes_cli_unavailable_falls_back_to_static_4(self, monkeypatch, tmp_path):
        """When hermes_cli is unavailable (raises) — distinct from returning [] —
        we DO fall back to the static 4-entry list so the picker isn't empty
        in that degraded environment. This preserves pre-#1538 behavior for
        test envs without hermes_cli."""
        _scrub_provider_env(monkeypatch)
        _install_fake_hermes_cli(
            monkeypatch,
            raise_on_lookup=True,
            auth_status_logged_in=True,
        )
        monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)

        restore = _swap_in_test_config({"model": {"provider": "nous"}})
        try:
            data = config.get_available_models()
            nous_groups = [g for g in data["groups"] if g["provider_id"] == "nous"]
            assert nous_groups, (
                "When hermes_cli raises, Nous group MUST still appear with "
                "the curated static fallback so the picker isn't empty in "
                "test envs that lack the agent package."
            )
            assert len(nous_groups[0]["models"]) == 4, (
                "Static fallback should expose the curated 4-entry list "
                "from _PROVIDER_MODELS['nous']."
            )
        finally:
            restore()


# ────────────────────────────────────────────────────────────────────────
# Section 5 — Providers card ↔ picker symmetry
# ────────────────────────────────────────────────────────────────────────


class TestProvidersCardPickerSymmetry:
    """Both endpoints must report the same featured set + total count for
    Nous Portal. This is the load-bearing invariant that ends the visual
    disagreement #1567 reports."""

    def test_providers_card_and_picker_agree_on_featured_set(self, monkeypatch, tmp_path):
        _scrub_provider_env(monkeypatch)
        catalog = _build_big_catalog()
        _install_fake_hermes_cli(monkeypatch, nous_ids=catalog)
        monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)

        restore = _swap_in_test_config({"model": {"provider": "nous"}})
        try:
            from api.providers import get_providers
            from api.config import _NOUS_FEATURED_TARGET

            providers = {p["id"]: p for p in get_providers()["providers"]}
            picker = config.get_available_models()
            picker_nous = next(g for g in picker["groups"] if g["provider_id"] == "nous")

            card = providers["nous"]
            # Both render exactly _NOUS_FEATURED_TARGET visible models.
            assert len(card["models"]) == _NOUS_FEATURED_TARGET
            assert len(picker_nous["models"]) == _NOUS_FEATURED_TARGET

            # Both report the full catalog size somewhere.
            assert card["models_total"] == len(catalog), (
                f"Providers card models_total should match live catalog size, "
                f"got {card['models_total']} vs catalog {len(catalog)}."
            )
            picker_total = len(picker_nous.get("models", [])) + len(
                picker_nous.get("extra_models", [])
            )
            assert picker_total == len(catalog), (
                f"Picker featured + extras must equal live catalog size, "
                f"got {picker_total} vs {len(catalog)}."
            )

            # And they pick THE SAME featured set (not e.g. one's first-15
            # and another's last-15).
            card_ids = [m["id"] for m in card["models"]]
            picker_ids = [m["id"] for m in picker_nous["models"]]
            assert card_ids == picker_ids, (
                f"Providers card and picker must show the SAME featured "
                f"set so users see consistent labels in both places. "
                f"Card: {card_ids}\nPicker: {picker_ids}"
            )
        finally:
            restore()


# ────────────────────────────────────────────────────────────────────────
# Section 6 — Frontend contract (static-source assertions)
# ────────────────────────────────────────────────────────────────────────


class TestFrontendExtrasContract:
    """Pin the JS-side contract: dropdown reads `models`, slash command and
    label map ALSO read `extra_models`. Without this, a model from the
    catalog tail gets a bare-ID label or is invisible to /model autocomplete."""

    def test_ui_js_hydrates_dynamic_labels_from_extra_models(self):
        from pathlib import Path
        src = (Path(__file__).resolve().parent.parent / "static" / "ui.js").read_text(encoding="utf-8")
        # Find the populateModelDropdown function and check it consumes
        # extra_models. Use a windowed substring search so the test stays
        # robust against minor refactors of surrounding code.
        idx = src.find("async function populateModelDropdown")
        assert idx != -1
        body = src[idx : idx + 3000]
        assert "extra_models" in body, (
            "populateModelDropdown must hydrate _dynamicModelLabels from "
            "g.extra_models so a model selected outside the featured set "
            "still gets a proper label. Without this, /model audio-lines "
            "→ 'audio-lines' bare-ID display. (#1567)"
        )

    def test_commands_js_loads_slash_args_from_extra_models(self):
        from pathlib import Path
        src = (Path(__file__).resolve().parent.parent / "static" / "commands.js").read_text(encoding="utf-8")
        idx = src.find("async function _loadSlashModelSubArgs")
        assert idx != -1
        body = src[idx : idx + 1500]
        assert "extra_models" in body, (
            "_loadSlashModelSubArgs must iterate group.extra_models so /model "
            "autocomplete covers the full catalog, not just the dropdown's "
            "featured subset. The slash command exists precisely so power "
            "users can reach any model by typing its name. (#1567)"
        )

    def test_panels_js_uses_models_total_for_count(self):
        from pathlib import Path
        src = (Path(__file__).resolve().parent.parent / "static" / "panels.js").read_text(encoding="utf-8")
        idx = src.find("function _buildProviderCard")
        assert idx != -1
        body = src[idx : idx + 1500]
        assert "models_total" in body, (
            "Provider card header should use p.models_total (full catalog "
            "size) for the count, not p.models.length (which is now the "
            "trimmed featured-set size). Without this, the header text says "
            "'15 models' instead of '396 models' for capped catalogs. (#1567)"
        )

    def test_panels_js_renders_more_disclosure_pill(self):
        from pathlib import Path
        src = (Path(__file__).resolve().parent.parent / "static" / "panels.js").read_text(encoding="utf-8")
        # The "+N more" disclosure must reference the difference between
        # rendered count and total count somewhere in the providers-card
        # rendering path.
        assert "provider-card-model-tag-more" in src, (
            "Provider card must render a '+N more' disclosure pill when "
            "len(models) < models_total, so users know the dropdown is "
            "intentionally capped and the rest is reachable via /model."
        )
