"""Tests for provider-aware `/model` validation in hermes_cli.models."""

from unittest.mock import MagicMock, patch

from hermes_cli.models import (
    azure_foundry_model_api_mode,
    copilot_model_api_mode,
    fetch_github_model_catalog,
    curated_models_for_provider,
    fetch_api_models,
    fetch_lmstudio_models,
    github_model_reasoning_efforts,
    normalize_copilot_model_id,
    normalize_opencode_model_id,
    normalize_provider,
    opencode_model_api_mode,
    parse_model_input,
    probe_api_models,
    provider_label,
    provider_model_ids,
    validate_requested_model,
)


# -- helpers -----------------------------------------------------------------

FAKE_API_MODELS = [
    "anthropic/claude-opus-4.6",
    "anthropic/claude-sonnet-4.5",
    "openai/gpt-5.4-pro",
    "openai/gpt-5.4",
    "google/gemini-3-pro-preview",
]


def _validate(model, provider="openrouter", api_models=FAKE_API_MODELS, **kw):
    """Shortcut: call validate_requested_model with mocked API."""
    probe_payload = {
        "models": api_models,
        "probed_url": "http://localhost:11434/v1/models",
        "resolved_base_url": kw.get("base_url", "") or "http://localhost:11434/v1",
        "suggested_base_url": None,
        "used_fallback": False,
    }
    with patch("hermes_cli.models.fetch_api_models", return_value=api_models), \
         patch("hermes_cli.models.probe_api_models", return_value=probe_payload):
        return validate_requested_model(model, provider, **kw)


# -- parse_model_input -------------------------------------------------------

class TestParseModelInput:
    def test_plain_model_keeps_current_provider(self):
        provider, model = parse_model_input("anthropic/claude-sonnet-4.5", "openrouter")
        assert provider == "openrouter"
        assert model == "anthropic/claude-sonnet-4.5"

    def test_provider_colon_model_switches_provider(self):
        provider, model = parse_model_input("openrouter:anthropic/claude-sonnet-4.5", "nous")
        assert provider == "openrouter"
        assert model == "anthropic/claude-sonnet-4.5"

    def test_provider_alias_resolved(self):
        provider, model = parse_model_input("glm:glm-5", "openrouter")
        assert provider == "zai"
        assert model == "glm-5"

    def test_stepfun_alias_resolved(self):
        provider, model = parse_model_input("step:step-3.5-flash", "openrouter")
        assert provider == "stepfun"
        assert model == "step-3.5-flash"

    def test_no_slash_no_colon_keeps_provider(self):
        provider, model = parse_model_input("gpt-5.4", "openrouter")
        assert provider == "openrouter"
        assert model == "gpt-5.4"

    def test_nous_provider_switch(self):
        provider, model = parse_model_input("nous:hermes-3", "openrouter")
        assert provider == "nous"
        assert model == "hermes-3"

    def test_empty_model_after_colon_keeps_current(self):
        provider, model = parse_model_input("openrouter:", "nous")
        assert provider == "nous"
        assert model == "openrouter:"

    def test_colon_at_start_keeps_current(self):
        provider, model = parse_model_input(":something", "openrouter")
        assert provider == "openrouter"
        assert model == ":something"

    def test_unknown_prefix_colon_not_treated_as_provider(self):
        """Colons are only provider delimiters if the left side is a known provider."""
        provider, model = parse_model_input("anthropic/claude-3.5-sonnet:beta", "openrouter")
        assert provider == "openrouter"
        assert model == "anthropic/claude-3.5-sonnet:beta"

    def test_http_url_not_treated_as_provider(self):
        provider, model = parse_model_input("http://localhost:8080/model", "openrouter")
        assert provider == "openrouter"
        assert model == "http://localhost:8080/model"

    def test_custom_colon_model_single(self):
        """custom:model-name → anonymous custom provider."""
        provider, model = parse_model_input("custom:qwen-2.5", "openrouter")
        assert provider == "custom"
        assert model == "qwen-2.5"

    def test_custom_triple_syntax(self):
        """custom:name:model → named custom provider."""
        provider, model = parse_model_input("custom:local-server:qwen-2.5", "openrouter")
        assert provider == "custom:local-server"
        assert model == "qwen-2.5"

    def test_custom_triple_spaces(self):
        """Triple syntax should handle whitespace."""
        provider, model = parse_model_input("custom: my-server : my-model ", "openrouter")
        assert provider == "custom:my-server"
        assert model == "my-model"

    def test_custom_triple_empty_model_falls_back(self):
        """custom:name: with no model → treated as custom:name (bare)."""
        provider, model = parse_model_input("custom:name:", "openrouter")
        # Empty model after second colon → no triple match, falls through
        assert provider == "custom"
        assert model == "name:"


# -- curated_models_for_provider ---------------------------------------------

class TestCuratedModelsForProvider:
    def test_openrouter_returns_curated_list(self):
        with patch(
            "hermes_cli.models.fetch_openrouter_models",
            return_value=[
                ("anthropic/claude-opus-4.6", "recommended"),
                ("qwen/qwen3.6-plus", ""),
            ],
        ):
            models = curated_models_for_provider("openrouter")
        assert len(models) > 0
        assert any("claude" in m[0] for m in models)

    def test_zai_returns_glm_models(self):
        models = curated_models_for_provider("zai")
        assert any("glm" in m[0] for m in models)

    def test_unknown_provider_returns_empty(self):
        assert curated_models_for_provider("totally-unknown") == []


# -- normalize_provider ------------------------------------------------------

class TestNormalizeProvider:
    def test_defaults_to_openrouter(self):
        assert normalize_provider(None) == "openrouter"
        assert normalize_provider("") == "openrouter"

    def test_known_aliases(self):
        assert normalize_provider("glm") == "zai"
        assert normalize_provider("kimi") == "kimi-coding"
        assert normalize_provider("moonshot") == "kimi-coding"
        assert normalize_provider("step") == "stepfun"
        assert normalize_provider("github-copilot") == "copilot"

    def test_case_insensitive(self):
        assert normalize_provider("OpenRouter") == "openrouter"


class TestProviderLabel:
    def test_known_labels_and_auto(self):
        assert provider_label("anthropic") == "Anthropic"
        assert provider_label("kimi") == "Kimi / Kimi Coding Plan"
        assert provider_label("stepfun") == "StepFun Step Plan"
        assert provider_label("copilot") == "GitHub Copilot"
        assert provider_label("copilot-acp") == "GitHub Copilot ACP"
        assert provider_label("auto") == "Auto"

    def test_unknown_provider_preserves_original_name(self):
        assert provider_label("my-custom-provider") == "my-custom-provider"


# -- provider_model_ids ------------------------------------------------------

class TestProviderModelIds:
    def test_openrouter_returns_curated_list(self):
        with patch(
            "hermes_cli.models.fetch_openrouter_models",
            return_value=[
                ("anthropic/claude-opus-4.6", "recommended"),
                ("qwen/qwen3.6-plus", ""),
            ],
        ):
            ids = provider_model_ids("openrouter")
        assert len(ids) > 0
        assert all("/" in mid for mid in ids)

    def test_unknown_provider_returns_empty(self):
        assert provider_model_ids("some-unknown-provider") == []

    def test_zai_returns_glm_models(self):
        assert "glm-5" in provider_model_ids("zai")

    def test_stepfun_prefers_live_catalog(self):
        with patch(
            "hermes_cli.auth.resolve_api_key_provider_credentials",
            return_value={"api_key": "***", "base_url": "https://api.stepfun.com/step_plan/v1"},
        ), patch(
            "hermes_cli.models.fetch_api_models",
            return_value=["step-3.5-flash", "step-3-agent-lite"],
        ):
            assert provider_model_ids("stepfun") == ["step-3.5-flash", "step-3-agent-lite"]

    def test_copilot_prefers_live_catalog(self):
        with patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={"api_key": "gh-token"}), \
             patch("hermes_cli.models._fetch_github_models", return_value=["gpt-5.4", "claude-sonnet-4.6"]):
            assert provider_model_ids("copilot") == ["gpt-5.4", "claude-sonnet-4.6"]

    def test_copilot_acp_reuses_copilot_catalog(self):
        with patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={"api_key": "gh-token"}), \
             patch("hermes_cli.models._fetch_github_models", return_value=["gpt-5.4", "claude-sonnet-4.6"]):
            assert provider_model_ids("copilot-acp") == ["gpt-5.4", "claude-sonnet-4.6"]

    def test_copilot_falls_back_to_curated_defaults_without_stale_opus(self):
        with patch("hermes_cli.models._resolve_copilot_catalog_api_key", return_value="gh-token"), \
             patch("hermes_cli.models._fetch_github_models", return_value=None):
            ids = provider_model_ids("copilot")

        assert "gpt-5.4" in ids
        assert "claude-sonnet-4.6" in ids
        assert "claude-sonnet-4" in ids
        assert "claude-sonnet-4.5" in ids
        assert "claude-haiku-4.5" in ids
        assert "gemini-3.1-pro-preview" in ids
        assert "claude-opus-4.6" not in ids

    def test_copilot_acp_falls_back_to_copilot_defaults(self):
        with patch("hermes_cli.models._resolve_copilot_catalog_api_key", return_value="gh-token"), \
             patch("hermes_cli.models._fetch_github_models", return_value=None):
            ids = provider_model_ids("copilot-acp")

        assert "gpt-5.4" in ids
        assert "claude-sonnet-4.6" in ids
        assert "claude-sonnet-4" in ids
        assert "gemini-3.1-pro-preview" in ids
        assert "copilot-acp" not in ids
        assert "claude-opus-4.6" not in ids


# -- fetch_api_models --------------------------------------------------------

class TestFetchApiModels:
    def test_returns_none_when_no_base_url(self):
        assert fetch_api_models("key", None) is None

    def test_returns_none_on_network_error(self):
        with patch("hermes_cli.models.urllib.request.urlopen", side_effect=Exception("timeout")):
            assert fetch_api_models("key", "https://example.com/v1") is None

    def test_probe_api_models_tries_v1_fallback(self):
        class _Resp:
            def __enter__(self):
                return self

            def __exit__(self, exc_type, exc, tb):
                return False

            def read(self):
                return b'{"data": [{"id": "local-model"}]}'

        calls = []

        def _fake_urlopen(req, timeout=5.0):
            calls.append(req.full_url)
            if req.full_url.endswith("/v1/models"):
                return _Resp()
            raise Exception("404")

        with patch("hermes_cli.models.urllib.request.urlopen", side_effect=_fake_urlopen):
            probe = probe_api_models("key", "http://localhost:8000")

        assert calls == ["http://localhost:8000/models", "http://localhost:8000/v1/models"]
        assert probe["models"] == ["local-model"]
        assert probe["resolved_base_url"] == "http://localhost:8000/v1"
        assert probe["used_fallback"] is True

    def test_probe_api_models_uses_copilot_catalog(self):
        class _Resp:
            def __enter__(self):
                return self

            def __exit__(self, exc_type, exc, tb):
                return False

            def read(self):
                return b'{"data": [{"id": "gpt-5.4", "model_picker_enabled": true, "supported_endpoints": ["/responses"], "capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}}}, {"id": "claude-sonnet-4.6", "model_picker_enabled": true, "supported_endpoints": ["/chat/completions"], "capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}}}, {"id": "text-embedding-3-small", "model_picker_enabled": true, "capabilities": {"type": "embedding"}}]}'

        with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()) as mock_urlopen:
            probe = probe_api_models("gh-token", "https://api.githubcopilot.com")

        assert mock_urlopen.call_args[0][0].full_url == "https://api.githubcopilot.com/models"
        assert probe["models"] == ["gpt-5.4", "claude-sonnet-4.6"]
        assert probe["resolved_base_url"] == "https://api.githubcopilot.com"
        assert probe["used_fallback"] is False

    def test_fetch_github_model_catalog_filters_non_chat_models(self):
        class _Resp:
            def __enter__(self):
                return self

            def __exit__(self, exc_type, exc, tb):
                return False

            def read(self):
                return b'{"data": [{"id": "gpt-5.4", "model_picker_enabled": true, "supported_endpoints": ["/responses"], "capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}}}, {"id": "text-embedding-3-small", "model_picker_enabled": true, "capabilities": {"type": "embedding"}}]}'

        with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()):
            catalog = fetch_github_model_catalog("gh-token")

        assert catalog is not None
        assert [item["id"] for item in catalog] == ["gpt-5.4"]


class TestGithubReasoningEfforts:
    def test_gpt5_supports_minimal_to_high(self):
        catalog = [{
            "id": "gpt-5.4",
            "capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}},
            "supported_endpoints": ["/responses"],
        }]
        assert github_model_reasoning_efforts("gpt-5.4", catalog=catalog) == [
            "low",
            "medium",
            "high",
        ]

    def test_legacy_catalog_reasoning_still_supported(self):
        catalog = [{"id": "openai/o3", "capabilities": ["reasoning"]}]
        assert github_model_reasoning_efforts("openai/o3", catalog=catalog) == [
            "low",
            "medium",
            "high",
        ]

    def test_non_reasoning_model_returns_empty(self):
        catalog = [{"id": "gpt-4.1", "capabilities": {"type": "chat", "supports": {}}}]
        assert github_model_reasoning_efforts("gpt-4.1", catalog=catalog) == []


class TestCopilotNormalization:
    def test_normalize_old_github_models_slug(self):
        catalog = [{"id": "gpt-4.1"}, {"id": "gpt-5.4"}]
        assert normalize_copilot_model_id("openai/gpt-4.1-mini", catalog=catalog) == "gpt-4.1"

    def test_copilot_api_mode_gpt5_uses_responses(self):
        """GPT-5+ models should use Responses API (matching opencode)."""
        assert copilot_model_api_mode("gpt-5.4") == "codex_responses"
        assert copilot_model_api_mode("gpt-5.4-mini") == "codex_responses"
        assert copilot_model_api_mode("gpt-5.3-codex") == "codex_responses"
        assert copilot_model_api_mode("gpt-5.2-codex") == "codex_responses"
        assert copilot_model_api_mode("gpt-5.2") == "codex_responses"

    def test_copilot_api_mode_gpt5_mini_uses_chat(self):
        """gpt-5-mini is the exception — uses Chat Completions."""
        assert copilot_model_api_mode("gpt-5-mini") == "chat_completions"

    def test_copilot_api_mode_non_gpt5_uses_chat(self):
        """Non-GPT-5 models use Chat Completions."""
        assert copilot_model_api_mode("gpt-4.1") == "chat_completions"
        assert copilot_model_api_mode("gpt-4o") == "chat_completions"
        assert copilot_model_api_mode("gpt-4o-mini") == "chat_completions"
        assert copilot_model_api_mode("claude-sonnet-4.6") == "chat_completions"
        assert copilot_model_api_mode("claude-opus-4.6") == "chat_completions"
        assert copilot_model_api_mode("gemini-2.5-pro") == "chat_completions"

    def test_copilot_api_mode_with_catalog_both_endpoints(self):
        """When catalog shows both endpoints, model ID pattern wins."""
        catalog = [{
            "id": "gpt-5.4",
            "supported_endpoints": ["/chat/completions", "/responses"],
        }]
        # GPT-5.4 should use responses even though chat/completions is listed
        assert copilot_model_api_mode("gpt-5.4", catalog=catalog) == "codex_responses"

    def test_copilot_api_mode_with_catalog_only_responses(self):
        catalog = [{
            "id": "gpt-5.4",
            "supported_endpoints": ["/responses"],
            "capabilities": {"type": "chat"},
        }]
        assert copilot_model_api_mode("gpt-5.4", catalog=catalog) == "codex_responses"

    def test_normalize_opencode_model_id_strips_provider_prefix(self):
        assert normalize_opencode_model_id("opencode-go", "opencode-go/kimi-k2.5") == "kimi-k2.5"
        assert normalize_opencode_model_id("opencode-zen", "opencode-zen/claude-sonnet-4-6") == "claude-sonnet-4-6"
        assert normalize_opencode_model_id("opencode-go", "glm-5") == "glm-5"

    def test_opencode_zen_api_modes_match_docs(self):
        assert opencode_model_api_mode("opencode-zen", "gpt-5.4") == "codex_responses"
        assert opencode_model_api_mode("opencode-zen", "gpt-5.3-codex") == "codex_responses"
        assert opencode_model_api_mode("opencode-zen", "opencode-zen/gpt-5.4") == "codex_responses"
        assert opencode_model_api_mode("opencode-zen", "claude-sonnet-4-6") == "anthropic_messages"
        assert opencode_model_api_mode("opencode-zen", "opencode-zen/claude-sonnet-4-6") == "anthropic_messages"
        assert opencode_model_api_mode("opencode-zen", "gemini-3-flash") == "chat_completions"
        assert opencode_model_api_mode("opencode-zen", "minimax-m2.5") == "chat_completions"

    def test_opencode_go_api_modes_match_docs(self):
        assert opencode_model_api_mode("opencode-go", "glm-5.1") == "chat_completions"
        assert opencode_model_api_mode("opencode-go", "opencode-go/glm-5.1") == "chat_completions"
        assert opencode_model_api_mode("opencode-go", "glm-5") == "chat_completions"
        assert opencode_model_api_mode("opencode-go", "opencode-go/glm-5") == "chat_completions"
        assert opencode_model_api_mode("opencode-go", "kimi-k2.5") == "chat_completions"
        assert opencode_model_api_mode("opencode-go", "opencode-go/kimi-k2.5") == "chat_completions"
        assert opencode_model_api_mode("opencode-go", "minimax-m2.5") == "anthropic_messages"
        assert opencode_model_api_mode("opencode-go", "opencode-go/minimax-m2.5") == "anthropic_messages"


class TestAzureFoundryModelApiMode:
    """Azure Foundry deploys GPT-5.x / codex / o-series as Responses-API-only.

    Azure returns ``400 "The requested operation is unsupported."`` when
    /chat/completions is called against these deployments.  Verified in the
    wild by a user debug bundle on 2026-04-26: gpt-5.3-codex failed with
    that exact payload while gpt-4o-pure worked on the same endpoint.
    """

    def test_gpt5_family_uses_responses(self):
        assert azure_foundry_model_api_mode("gpt-5") == "codex_responses"
        assert azure_foundry_model_api_mode("gpt-5.3") == "codex_responses"
        assert azure_foundry_model_api_mode("gpt-5.4") == "codex_responses"
        assert azure_foundry_model_api_mode("gpt-5-codex") == "codex_responses"
        assert azure_foundry_model_api_mode("gpt-5.3-codex") == "codex_responses"
        # gpt-5-mini exceptions are Copilot-specific; Azure deploys the whole
        # gpt-5 family on Responses API uniformly.
        assert azure_foundry_model_api_mode("gpt-5-mini") == "codex_responses"

    def test_codex_family_uses_responses(self):
        assert azure_foundry_model_api_mode("codex") == "codex_responses"
        assert azure_foundry_model_api_mode("codex-mini") == "codex_responses"

    def test_o_series_reasoning_uses_responses(self):
        assert azure_foundry_model_api_mode("o1") == "codex_responses"
        assert azure_foundry_model_api_mode("o1-preview") == "codex_responses"
        assert azure_foundry_model_api_mode("o1-mini") == "codex_responses"
        assert azure_foundry_model_api_mode("o3") == "codex_responses"
        assert azure_foundry_model_api_mode("o3-mini") == "codex_responses"
        assert azure_foundry_model_api_mode("o4-mini") == "codex_responses"

    def test_gpt4_family_returns_none(self):
        """GPT-4, GPT-4o, etc. speak chat completions on Azure."""
        assert azure_foundry_model_api_mode("gpt-4") is None
        assert azure_foundry_model_api_mode("gpt-4o") is None
        assert azure_foundry_model_api_mode("gpt-4o-pure") is None
        assert azure_foundry_model_api_mode("gpt-4o-mini") is None
        assert azure_foundry_model_api_mode("gpt-4-turbo") is None
        assert azure_foundry_model_api_mode("gpt-4.1") is None
        assert azure_foundry_model_api_mode("gpt-3.5-turbo") is None

    def test_non_openai_deployments_return_none(self):
        """Llama, Mistral, Grok, etc. keep the default chat completions."""
        assert azure_foundry_model_api_mode("llama-3.1-70b") is None
        assert azure_foundry_model_api_mode("mistral-large") is None
        assert azure_foundry_model_api_mode("grok-4") is None
        assert azure_foundry_model_api_mode("phi-3-medium") is None

    def test_vendor_prefix_stripped(self):
        """Users who copy-paste ``openai/gpt-5.3-codex`` should still match."""
        assert azure_foundry_model_api_mode("openai/gpt-5.3-codex") == "codex_responses"
        assert azure_foundry_model_api_mode("openai/gpt-4o") is None

    def test_empty_and_none_return_none(self):
        assert azure_foundry_model_api_mode(None) is None
        assert azure_foundry_model_api_mode("") is None
        assert azure_foundry_model_api_mode("   ") is None

    def test_case_insensitive(self):
        assert azure_foundry_model_api_mode("GPT-5.3-Codex") == "codex_responses"
        assert azure_foundry_model_api_mode("Codex-Mini") == "codex_responses"


# -- validate — format checks -----------------------------------------------

class TestValidateFormatChecks:
    def test_empty_model_rejected(self):
        result = _validate("")
        assert result["accepted"] is False
        assert "empty" in result["message"]

    def test_whitespace_only_rejected(self):
        result = _validate("   ")
        assert result["accepted"] is False

    def test_model_with_spaces_rejected(self):
        result = _validate("anthropic/ claude-opus")
        assert result["accepted"] is False

    def test_no_slash_model_still_probes_api(self):
        result = _validate("gpt-5.4", api_models=["gpt-5.4", "gpt-5.4-pro"])
        assert result["accepted"] is True
        assert result["persist"] is True

    def test_no_slash_model_rejected_if_not_in_api(self):
        result = _validate("gpt-5.4", api_models=["openai/gpt-5.4"])
        assert result["accepted"] is False
        assert result["persist"] is False
        assert "not found" in result["message"]


# -- validate — API found ----------------------------------------------------

class TestValidateApiFound:
    def test_model_found_in_api(self):
        result = _validate("anthropic/claude-opus-4.6")
        assert result["accepted"] is True
        assert result["persist"] is True
        assert result["recognized"] is True

    def test_model_found_for_custom_endpoint(self):
        result = _validate(
            "my-model", provider="openrouter",
            api_models=["my-model"], base_url="http://localhost:11434/v1",
        )
        assert result["accepted"] is True
        assert result["persist"] is True
        assert result["recognized"] is True


# -- validate — API not found ------------------------------------------------

class TestValidateApiNotFound:
    def test_model_not_in_api_rejected_with_guidance(self):
        result = _validate("anthropic/claude-nonexistent")
        assert result["accepted"] is False
        assert result["persist"] is False
        assert "not found" in result["message"]

    def test_warning_includes_suggestions(self):
        result = _validate("anthropic/claude-opus-4.5")
        assert result["accepted"] is True
        # Close match auto-corrects; less similar inputs show suggestions
        assert "Auto-corrected" in result["message"] or "Similar models" in result["message"]

    def test_auto_correction_returns_corrected_model(self):
        """When a very close match exists, validate returns corrected_model."""
        result = _validate("anthropic/claude-opus-4.5")
        assert result["accepted"] is True
        assert result.get("corrected_model") == "anthropic/claude-opus-4.6"
        assert result["recognized"] is True

    def test_dissimilar_model_shows_suggestions_not_autocorrect(self):
        """Models too different for auto-correction are rejected with suggestions."""
        result = _validate("anthropic/claude-nonexistent")
        assert result["accepted"] is False
        assert result.get("corrected_model") is None
        assert "not found" in result["message"]


# -- validate — API unreachable — soft-accept via catalog or warning --------

class TestValidateApiFallback:
    """When /models is unreachable, the validator must accept the model (with
    a warning) rather than reject it outright — otherwise provider switches
    fail in the gateway for any provider whose /models endpoint is down or
    doesn't exist (e.g. opencode-go returns 404 HTML).

    Two paths:
      1. Provider has a curated catalog (``_PROVIDER_MODELS`` / live fetch):
         validate against it (recognized=True for known models,
         recognized=False with 'Note:' for unknown).
      2. Provider has no catalog: accept with a generic 'Note:' warning.

    In both cases ``accepted`` and ``persist`` must be True so the gateway can
    write the ``_session_model_overrides`` entry.
    """

    def test_known_model_accepted_via_catalog_when_api_down(self):
        # Force the openrouter catalog lookup to return a deterministic list.
        with patch(
            "hermes_cli.models.provider_model_ids",
            return_value=["anthropic/claude-opus-4.6", "openai/gpt-5.4"],
        ):
            result = _validate("anthropic/claude-opus-4.6", api_models=None)
        assert result["accepted"] is True
        assert result["persist"] is True
        assert result["recognized"] is True

    def test_unknown_model_accepted_with_note_when_api_down(self):
        with patch(
            "hermes_cli.models.provider_model_ids",
            return_value=["anthropic/claude-opus-4.6", "openai/gpt-5.4"],
        ):
            result = _validate("anthropic/claude-next-gen", api_models=None)
        assert result["accepted"] is True
        assert result["persist"] is True
        assert result["recognized"] is False
        # Message flags it as unverified against the catalog.
        assert "not found" in result["message"].lower() or "note" in result["message"].lower()

    def test_zai_known_model_accepted_via_catalog_when_api_down(self):
        # glm-5 is in the zai curated catalog (_PROVIDER_MODELS["zai"]).
        result = _validate("glm-5", provider="zai", api_models=None)
        assert result["accepted"] is True
        assert result["persist"] is True
        assert result["recognized"] is True

    def test_unknown_provider_soft_accepted_when_api_down(self):
        # No catalog for unknown providers — soft-accept with a Note.
        with patch("hermes_cli.models.provider_model_ids", return_value=[]):
            result = _validate("some-model", provider="totally-unknown", api_models=None)
        assert result["accepted"] is True
        assert result["persist"] is True
        assert result["recognized"] is False
        assert "note" in result["message"].lower()

    def test_custom_endpoint_warns_with_probed_url_and_v1_hint(self):
        with patch(
            "hermes_cli.models.probe_api_models",
            return_value={
                "models": None,
                "probed_url": "http://localhost:8000/v1/models",
                "resolved_base_url": "http://localhost:8000",
                "suggested_base_url": "http://localhost:8000/v1",
                "used_fallback": False,
            },
        ):
            result = validate_requested_model(
                "qwen3",
                "custom",
                api_key="local-key",
                base_url="http://localhost:8000",
            )

        # Unreachable /models on a custom endpoint no longer hard-rejects —
        # the model is persisted with a warning so Cloudflare-protected /
        # proxy endpoints that don't expose /models still work. See #12950.
        assert result["accepted"] is False
        assert result["persist"] is True
        assert "http://localhost:8000/v1/models" in result["message"]
        assert "http://localhost:8000/v1" in result["message"]

    def test_fetch_lmstudio_models_filters_embedding_type(self):
        mock_resp = MagicMock()
        mock_resp.__enter__.return_value = mock_resp
        mock_resp.__exit__.return_value = False
        mock_resp.read.return_value = (
            b'{"models":['
            b'{"key":"publisher/chat-model","id":"publisher/chat-model","type":"llm"},'
            b'{"key":"publisher/embed-model","id":"publisher/embed-model","type":"embedding"}'
            b']}'
        )

        with patch("hermes_cli.models.urllib.request.urlopen", return_value=mock_resp):
            models = fetch_lmstudio_models(base_url="http://localhost:1234/v1")

        assert models == ["publisher/chat-model"]

    def test_validate_lmstudio_rejects_embedding_models(self):
        mock_resp = MagicMock()
        mock_resp.__enter__.return_value = mock_resp
        mock_resp.__exit__.return_value = False
        mock_resp.read.return_value = (
            b'{"models":['
            b'{"key":"publisher/chat-model","id":"publisher/chat-model","type":"llm"},'
            b'{"key":"publisher/embed-model","id":"publisher/embed-model","type":"embedding"}'
            b']}'
        )

        with patch("hermes_cli.models.urllib.request.urlopen", return_value=mock_resp):
            result = validate_requested_model(
                "publisher/embed-model",
                "lmstudio",
                base_url="http://localhost:1234/v1",
            )

        assert result["accepted"] is False
        assert result["recognized"] is False
        assert "not found in LM Studio's model listing" in result["message"]

    def test_fetch_lmstudio_models_raises_auth_error_on_401(self):
        import urllib.error
        from hermes_cli.auth import AuthError
        import pytest

        http_error = urllib.error.HTTPError(
            url="http://localhost:1234/api/v1/models",
            code=401,
            msg="Unauthorized",
            hdrs=None,
            fp=None,
        )

        with patch("hermes_cli.models.urllib.request.urlopen", side_effect=http_error):
            with pytest.raises(AuthError) as excinfo:
                fetch_lmstudio_models(base_url="http://localhost:1234/v1")

        assert excinfo.value.provider == "lmstudio"
        assert excinfo.value.code == "auth_rejected"
        assert "401" in str(excinfo.value)

    def test_fetch_lmstudio_models_returns_empty_on_network_error(self):
        with patch(
            "hermes_cli.models.urllib.request.urlopen",
            side_effect=ConnectionRefusedError(),
        ):
            models = fetch_lmstudio_models(base_url="http://localhost:1234/v1")

        assert models == []

    def test_validate_lmstudio_distinguishes_auth_failure(self):
        import urllib.error

        http_error = urllib.error.HTTPError(
            url="http://localhost:1234/api/v1/models",
            code=401,
            msg="Unauthorized",
            hdrs=None,
            fp=None,
        )

        with patch("hermes_cli.models.urllib.request.urlopen", side_effect=http_error):
            result = validate_requested_model(
                "publisher/chat-model",
                "lmstudio",
                base_url="http://localhost:1234/v1",
            )

        assert result["accepted"] is False
        assert "401" in result["message"]
        assert "LM_API_KEY" in result["message"]

    def test_validate_lmstudio_distinguishes_unreachable(self):
        with patch(
            "hermes_cli.models.urllib.request.urlopen",
            side_effect=ConnectionRefusedError(),
        ):
            result = validate_requested_model(
                "publisher/chat-model",
                "lmstudio",
                base_url="http://localhost:1234/v1",
            )

        assert result["accepted"] is False
        assert "Could not reach LM Studio" in result["message"]


# -- validate — Codex auto-correction ------------------------------------------

class TestValidateCodexAutoCorrection:
    """Auto-correction for typos on openai-codex provider."""

    def test_missing_dash_auto_corrects(self):
        """gpt5.3-codex (missing dash) auto-corrects to gpt-5.3-codex."""
        codex_models = ["gpt-5.4-mini", "gpt-5.4", "gpt-5.3-codex",
                        "gpt-5.2-codex", "gpt-5.1-codex-max"]
        with patch("hermes_cli.models.provider_model_ids", return_value=codex_models):
            result = validate_requested_model("gpt5.3-codex", "openai-codex")
        assert result["accepted"] is True
        assert result["recognized"] is True
        assert result["corrected_model"] == "gpt-5.3-codex"
        assert "Auto-corrected" in result["message"]

    def test_exact_match_no_correction(self):
        """Exact model name does not trigger auto-correction."""
        codex_models = ["gpt-5.4-mini", "gpt-5.4", "gpt-5.3-codex"]
        with patch("hermes_cli.models.provider_model_ids", return_value=codex_models):
            result = validate_requested_model("gpt-5.3-codex", "openai-codex")
        assert result["accepted"] is True
        assert result["recognized"] is True
        assert result.get("corrected_model") is None
        assert result["message"] is None



# -- probe_api_models — Cloudflare UA mitigation --------------------------------

class TestProbeApiModelsUserAgent:
    """Probing custom /v1/models must send a Hermes User-Agent.

    Some custom Claude proxies (e.g. ``packyapi.com``) sit behind Cloudflare with
    Browser Integrity Check enabled. The default ``Python-urllib/3.x`` signature
    is rejected with HTTP 403 ``error code: 1010``, which ``probe_api_models``
    swallowed into ``{"models": None}``, surfacing to users as a misleading
    "Could not reach the ... API to validate ..." error — even though the
    endpoint is reachable and the listing exists.
    """

    def _make_mock_response(self, body: bytes):
        from unittest.mock import MagicMock
        mock_resp = MagicMock()
        mock_resp.__enter__ = MagicMock(return_value=mock_resp)
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_resp.read = MagicMock(return_value=body)
        return mock_resp

    def test_probe_sends_hermes_user_agent(self):
        from unittest.mock import patch

        body = b'{"data":[{"id":"claude-opus-4.7"}]}'
        with patch(
            "hermes_cli.models.urllib.request.urlopen",
            return_value=self._make_mock_response(body),
        ) as mock_urlopen:
            result = probe_api_models("sk-test", "https://example.com/v1")

        assert result["models"] == ["claude-opus-4.7"]
        # The urlopen call receives a Request object as its first positional arg
        req = mock_urlopen.call_args[0][0]
        ua = req.get_header("User-agent")  # urllib title-cases header names
        assert ua, "probe_api_models must send a User-Agent header"
        assert ua.startswith("hermes-cli/"), (
            f"User-Agent must advertise hermes-cli, got {ua!r}"
        )
        # Must not fall back to urllib's default — that's what Cloudflare 1010 blocks.
        assert not ua.startswith("Python-urllib")

    def test_probe_user_agent_sent_without_api_key(self):
        """UA must be present even for endpoints that don't need auth."""
        from unittest.mock import patch

        body = b'{"data":[]}'
        with patch(
            "hermes_cli.models.urllib.request.urlopen",
            return_value=self._make_mock_response(body),
        ) as mock_urlopen:
            probe_api_models(None, "https://example.com/v1")

        req = mock_urlopen.call_args[0][0]
        ua = req.get_header("User-agent")
        assert ua and ua.startswith("hermes-cli/")
        # No Authorization was set, but UA must still be present.
        assert req.get_header("Authorization") is None
