"""Tests for hermes_cli.doctor."""

import importlib.util
import os
import subprocess
import sys
import types
import io
import contextlib
from argparse import Namespace
from pathlib import Path
from types import SimpleNamespace

import pytest

import hermes_cli.doctor as doctor
import hermes_constants
from hermes_cli import config as config_mod
import hermes_cli.gateway as gateway_cli
from hermes_cli import doctor as doctor_mod
from hermes_cli.doctor_config import _has_provider_env_config
from hermes_cli.doctor_report import Finding
import shutil
from hermes_cli import doctor_tools
from hermes_cli import doctor_state
from hermes_cli import doctor_platform
from hermes_cli import doctor_config
from tools import browser_tool_install as bt_install


@pytest.fixture(autouse=True)
def _no_browser_downloads(monkeypatch):
    """Unrelated doctor --fix tests must not start an installer worker.

    Browser acquisition/readback is exercised with real temporary PM facts in
    test_browser_pm; tests here may replace this boundary deliberately.
    """
    def refuse(*args, **kwargs):
        raise RuntimeError("PM downloads disabled in doctor unit tests")

    monkeypatch.setattr("pm.client._request", refuse)


def _tls_out_normalized(out: str) -> str:
    """Doctor print matcher for TLS rows: key on words, not spacing."""
    return " ".join(out.lower().split())


def test_check_certificates_exercises_real_tls_policy(monkeypatch, capsys):
    """install_truststore True: the check exercises the actual policy
    (agent.ssl_verify platform trust store), reports TLS as configured, and
    never presents context construction as certificate verification."""
    monkeypatch.setattr("agent.ssl_verify.install_truststore", lambda: True)

    doctor_platform.check_certificates()

    out = _tls_out_normalized(capsys.readouterr().out)
    assert "✓" in out
    assert "tls" in out
    assert "skipped" not in out
    assert "verification active" not in out  # configuration, not proof of validation
    assert "platform trust" in out or "configured" in out


def test_check_certificates_fallback_names_openssl_without_platform_trust_claim(monkeypatch, capsys):
    """install_truststore False + a constructible context is an OpenSSL fallback:
    the output names the actual fallback, stays available, and no ✓ row claims
    the platform trust store is active."""
    monkeypatch.setattr("agent.ssl_verify.install_truststore", lambda: False)

    doctor_platform.check_certificates()

    raw = capsys.readouterr().out
    out = _tls_out_normalized(raw)
    assert "⚠" in raw
    assert "trust store" in out
    assert "openssl" in out  # names the actual fallback
    assert "verification active" not in out  # construction proves nothing verified
    assert "available" in out  # status is context available, not verification proven
    assert not any(
        line.startswith("✓") and "platform trust" in line
        for line in raw.splitlines()
    )


class TestDoctorPlatformHints:
    def test_sqlite_upgrade_hint_recreates_docker_containers(self, monkeypatch):
        monkeypatch.setattr(config_mod, "detect_install_method", lambda _root: "docker")

        hint = doctor_platform._sqlite_upgrade_hint()

        assert "docker pull nousresearch/hermes-agent:latest" in hint
        assert "hermes update" not in hint


    def test_sqlite_upgrade_hint_apt_stamp_names_termux_external_update(self):
        # The "apt" install method is Termux APT by contract (config.py
        # _UPDATE_COMMAND_BY_METHOD). Deployment kinds are first-class: doctor
        # reports the correct external update command instead of routing an
        # out-of-place method through the in-place `hermes update` path.
        hint = doctor_platform._sqlite_upgrade_hint("apt")

        assert "run `pkg upgrade hermes-agent`" in hint
        assert "hermes update" not in hint

    def test_sqlite_upgrade_hint_preserves_nix_guidance_as_prose(self):
        from hermes_cli.config import recommended_update_command_for_method

        guidance = recommended_update_command_for_method("nix")
        hint = doctor_platform._sqlite_upgrade_hint("nix")

        assert guidance in hint
        assert f"run `{guidance}`" not in hint
        assert "hermes update" not in hint


class TestProviderEnvDetection:
    def test_detects_openai_api_key(self):
        content = "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=***"
        assert _has_provider_env_config(content)


    def test_returns_false_when_no_provider_settings(self):
        content = "TERMINAL_ENV=local\n"
        assert not _has_provider_env_config(content)


class TestDoctorToolAvailabilitySummary:
    def test_missing_api_key_summary_ignores_disabled_toolsets(self, monkeypatch):
        unavailable = [
            {"name": "rl", "missing_vars": ["TINKER_API_KEY"]},
            {"name": "web", "missing_vars": ["EXA_API_KEY"]},
        ]
        monkeypatch.setattr(doctor_tools, "_enabled_cli_toolsets_for_doctor", lambda: {"web"})

        filtered = doctor_tools._missing_api_key_toolsets_for_summary(unavailable)

        assert [item["name"] for item in filtered] == ["web"]

    def test_image_gen_without_provider_reports_setup_hint_not_system_dependency(self, monkeypatch):
        """image_gen declares no single env var (FAL / managed Nous / plugin providers); an
        unconfigured backend is a setup problem and must say so, and it counts toward the
        'run hermes setup' summary like any missing key (#9516)."""
        unavailable = [{"name": "image_gen", "env_vars": [], "tools": ["image_generate"]},
                       {"name": "homeassistant", "env_vars": [], "tools": []}]
        monkeypatch.setattr(doctor_tools, "_enabled_cli_toolsets_for_doctor", lambda: {"image_gen"})
        monkeypatch.setattr(doctor_tools, "_apply_doctor_tool_availability_overrides", lambda a, u: (a, u))
        monkeypatch.setattr(doctor_tools, "_doctor_web_capability_rows", lambda: [])
        fake_model_tools = types.SimpleNamespace(
            check_tool_availability=lambda: ([], unavailable),
            TOOLSET_REQUIREMENTS={"image_gen": {"name": "image_gen"}, "homeassistant": {"name": "homeassistant"}},
        )
        monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            f = doctor_tools._check_tool_availability(False)
        out = buf.getvalue()

        image_line = next(line for line in out.splitlines() if "image_gen" in line)
        assert "hermes tools" in image_line and "system dependency" not in image_line and "unavailable" in image_line
        assert "system dependency not met" in next(line for line in out.splitlines() if "homeassistant" in line)
        assert any("hermes setup" in issue for issue in f.issues)

    def test_web_capability_rows_warn_when_selected_provider_not_ready(self, monkeypatch):
        """#78412: selected firecrawl with is_available=False must warn."""
        class _Unavailable:
            name = "firecrawl"

            def is_available(self):
                return False

        unavailable = _Unavailable()
        monkeypatch.setattr(
            "agent.web_search_registry.get_active_search_provider",
            lambda: unavailable,
        )
        monkeypatch.setattr(
            "agent.web_search_registry.get_active_extract_provider",
            lambda: unavailable,
        )

        rows = doctor_tools._doctor_web_capability_rows()
        assert rows
        assert all(status == "warn" for status, _, _ in rows)

    def test_web_capability_rows_ok_when_provider_ready(self, monkeypatch):
        class _Ready:
            name = "ddgs"

            def is_available(self):
                return True

        ready = _Ready()
        monkeypatch.setattr(
            "agent.web_search_registry.get_active_search_provider",
            lambda: ready,
        )
        monkeypatch.setattr(
            "agent.web_search_registry.get_active_extract_provider",
            lambda: ready,
        )

        rows = doctor_tools._doctor_web_capability_rows()
        assert rows and all(status == "ok" for status, _, _ in rows)


class TestDoctorEnvFileEncoding:
    """Regression for #18637 (bug 3): `hermes doctor` crashed on Windows
    Chinese locale (GBK) because `.env` was read with Path.read_text(encoding="utf-8") which
    defaults to the system locale encoding, not UTF-8."""

    def test_doctor_reads_env_as_utf8_even_when_locale_is_not_utf8(
        self, monkeypatch, tmp_path
    ):
        import pathlib

        hermes_home = tmp_path / ".hermes"
        hermes_home.mkdir()
        # Write a UTF-8 .env containing an em dash (U+2014 = e2 80 94). The
        # 0x94 byte is exactly the one the issue reporter hit: it's invalid
        # as a GBK trailing byte in this position, so locale-default reads
        # raise UnicodeDecodeError on Chinese Windows.
        env_path = hermes_home / ".env"
        env_path.write_text(
            "OPENAI_API_KEY=sk-test  # em-dash here — should not crash\n",
            encoding="utf-8",
        )

        monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home)

        orig_read_text = pathlib.Path.read_text

        def gbk_like_read_text(self, encoding=None, errors=None, **kwargs):
            # Simulate a GBK locale: refuse to decode this specific UTF-8
            # .env unless the caller pins a UTF-8 codec. utf-8-sig is the
            # sanctioned read encoding (Windows tools BOM-prefix files it
            # touches); it decodes BOM-less UTF-8 identically.
            if self == env_path and encoding not in ("utf-8", "utf-8-sig"):
                raise UnicodeDecodeError(
                    "gbk", b"\x94", 0, 1, "illegal multibyte sequence"
                )
            return orig_read_text(self, encoding=encoding, errors=errors, **kwargs)

        monkeypatch.setattr(pathlib.Path, "read_text", gbk_like_read_text)

        # Short-circuit the expensive tool-availability probe — we only
        # need doctor to reach the .env read without crashing.
        fake_model_tools = types.SimpleNamespace(
            check_tool_availability=lambda *a, **kw: (_ for _ in ()).throw(SystemExit(0)),
            TOOLSET_REQUIREMENTS={},
        )
        monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

        # Run doctor. If the .env read still uses locale encoding, this
        # raises UnicodeDecodeError and the test fails.
        with pytest.raises(SystemExit):
            doctor_mod.run_doctor(Namespace(fix=False))


    def test_doctor_reads_invalid_utf8_env_via_latin1_fallback(
        self, monkeypatch, tmp_path
    ):
        """cp1252/latin-1 .env with ASCII provider hints must not abort doctor."""
        hermes_home = tmp_path / ".hermes"
        hermes_home.mkdir()
        env_path = hermes_home / ".env"
        # 0xff is invalid UTF-8; latin-1 decodes it. Keep an ASCII provider key
        # so the scan still reports a configured endpoint/key.
        env_path.write_bytes(b"OPENAI_API_KEY=sk-test\xff\n")

        monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home)

        fake_model_tools = types.SimpleNamespace(
            check_tool_availability=lambda *a, **kw: (_ for _ in ()).throw(SystemExit(0)),
            TOOLSET_REQUIREMENTS={},
        )
        monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

        with pytest.raises(SystemExit):
            doctor_mod.run_doctor(Namespace(fix=False))


class TestDoctorToolAvailabilityOverrides:


    def test_marks_kanban_available_only_when_missing_worker_env_gate(self, monkeypatch):
        monkeypatch.setattr(doctor_state, "_honcho_is_configured_for_doctor", lambda: False)
        monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)

        available, unavailable = doctor_tools._apply_doctor_tool_availability_overrides(
            [],
            [{"name": "kanban", "env_vars": [], "tools": ["kanban_show"]}],
        )

        assert available == ["kanban"]
        assert unavailable == []

    def test_leaves_kanban_unavailable_when_worker_env_is_set(self, monkeypatch):
        monkeypatch.setenv("HERMES_KANBAN_TASK", "probe")
        kanban_entry = {"name": "kanban", "env_vars": [], "tools": ["kanban_show"]}

        available, unavailable = doctor_tools._apply_doctor_tool_availability_overrides(
            [],
            [kanban_entry],
        )

        assert available == []
        assert unavailable == [kanban_entry]




def test_doctor_reports_vercel_backend_diagnostics(monkeypatch, tmp_path):
    monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
    monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", "python3.13")
    monkeypatch.setenv("TERMINAL_CONTAINER_DISK", "2048")
    monkeypatch.setenv("VERCEL_TOKEN", "super-secret-value")
    monkeypatch.delenv("VERCEL_PROJECT_ID", raising=False)
    monkeypatch.setenv("VERCEL_TEAM_ID", "team")
    monkeypatch.setattr(importlib.util, "find_spec", lambda name: object() if name == "vercel" else None)

    fake_model_tools = types.SimpleNamespace(
        check_tool_availability=lambda *a, **kw: ([], []),
        TOOLSET_REQUIREMENTS={},
    )
    monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        doctor_mod.run_doctor(Namespace(fix=False))

    out = buf.getvalue()
    assert "VERCEL_PROJECT_ID" in out  # names the missing auth var
    assert "super-secret-value" not in out  # never echoes the token value


# ── Memory provider section (doctor should only check the *active* provider) ──


class TestDoctorMemoryProviderSection:
    """The ◆ Memory Provider section should respect memory.provider config."""

    def _make_hermes_home(self, tmp_path, provider="", memory_config=None):
        """Create a minimal HERMES_HOME with config.yaml."""
        home = tmp_path / ".hermes"
        home.mkdir(parents=True, exist_ok=True)
        import hermes_yaml as yaml
        config = dict(memory_config or {})
        if provider:
            config["provider"] = provider
        config = {"memory": config}
        (home / "config.yaml").write_text(yaml.safe_dump(config), encoding="utf-8")
        return home

    def _run_doctor_and_capture(
        self,
        monkeypatch,
        tmp_path,
        provider="",
        *,
        memory_config=None,
        stale_builtin_files=False,
    ):
        """Run doctor and capture stdout."""
        home = self._make_hermes_home(tmp_path, provider, memory_config)
        if stale_builtin_files:
            memories = home / "memories"
            memories.mkdir()
            (memories / "MEMORY.md").write_text("stale memory", encoding="utf-8")
            (memories / "USER.md").write_text("stale user", encoding="utf-8")
        monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
        monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
        monkeypatch.setattr(doctor_mod, "_DHH", str(home))
        (tmp_path / "project").mkdir(exist_ok=True)

        # Stub tool availability (returns empty) so doctor runs past it
        fake_model_tools = types.SimpleNamespace(
            check_tool_availability=lambda *a, **kw: ([], []),
            TOOLSET_REQUIREMENTS={},
        )
        monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

        # Stub auth checks to avoid real API calls
        try:
            from hermes_cli import auth as _auth_mod
            monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
            monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
            monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
        except Exception:
            pass

        # Keep doctor from probing the AMBIENT gh CLI. A PATH lookup that
        # resolves (any dev box has gh) makes doctor shell out to
        # `gh auth status`, which both leaks the runner's real auth state
        # into the assertion surface and -- on Windows -- dies with WinError 5
        # when gh resolves to a Store/MSIX reparse-point shim. The gh-
        # specific doctor behaviors have their own dedicated tests below,
        # which mock gh explicitly.
        real_which = shutil.which
        monkeypatch.setattr(
            shutil,
            "which",
            lambda cmd: None if cmd == "gh" else real_which(cmd),
        )

        import io, contextlib
        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            doctor_mod.run_doctor(Namespace(fix=False))
        return buf.getvalue()

    def test_no_provider_shows_builtin_ok(self, monkeypatch, tmp_path):
        out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="")
        assert "Memory Provider" in out
        assert "Built-in memory active" in out
        # Should NOT mention Honcho or Mem0 errors
        assert "Honcho API key" not in out
        assert "Mem0" not in out


    def test_mem0_provider_not_installed_shows_fail(self, monkeypatch, tmp_path):
        # Make mem0 import fail
        monkeypatch.setitem(sys.modules, "plugins.memory.mem0", None)
        out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="mem0")
        assert "Memory Provider" in out
        assert "Built-in memory active" not in out

    @pytest.mark.parametrize("memory_enabled", [False, True])
    def test_stale_builtin_files_reported_only_when_store_enabled(
        self, monkeypatch, tmp_path, memory_enabled
    ):
        # #100668: disabled built-in stores must not surface stale files as active.
        out = self._run_doctor_and_capture(
            monkeypatch,
            tmp_path,
            provider="mnemosyne",
            memory_config={
                "memory_enabled": memory_enabled,
                "user_profile_enabled": False,
            },
            stale_builtin_files=True,
        )

        assert ("MEMORY.md exists" in out) is memory_enabled
        assert "USER.md exists" not in out
        assert ("Built-in memory files disabled by config" in out) is not memory_enabled




def test_run_doctor_accepts_named_provider_from_providers_section(monkeypatch, tmp_path):
    home = tmp_path / ".hermes"
    home.mkdir(parents=True, exist_ok=True)

    import hermes_yaml as yaml

    (home / "config.yaml").write_text(
        yaml.safe_dump(
            {
                "model": {
                    "provider": "volcengine-plan",
                    "default": "doubao-seed-2.0-code",
                },
                "providers": {
                    "volcengine-plan": {
                        "name": "volcengine-plan",
                        "base_url": "https://ark.cn-beijing.volces.com/api/coding/v3",
                        "default_model": "doubao-seed-2.0-code",
                        "models": {"doubao-seed-2.0-code": {}},
                    }
                },
            }
        )
    , encoding="utf-8")

    monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
    monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
    monkeypatch.setattr(doctor_mod, "_DHH", str(home))
    (tmp_path / "project").mkdir(exist_ok=True)

    fake_model_tools = types.SimpleNamespace(
        check_tool_availability=lambda *a, **kw: ([], []),
        TOOLSET_REQUIREMENTS={},
    )
    monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

    try:
        from hermes_cli import auth as _auth_mod
        monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
    except Exception:
        pass

    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        doctor_mod.run_doctor(Namespace(fix=False))

    out = buf.getvalue()
    assert "model.provider 'volcengine-plan' is not a recognised provider" not in out


def test_run_doctor_accepts_stable_key_when_provider_name_differs(
    monkeypatch, tmp_path
):
    home = tmp_path / ".hermes"
    home.mkdir(parents=True, exist_ok=True)
    (home / "config.yaml").write_text(
        "model:\n"
        "  provider: custom:local-127.0.0.1:11434\n"
        "  default: qwen3.5:9b\n"
        "providers:\n"
        "  local-127.0.0.1:11434:\n"
        "    name: Local Ollama\n"
        "    base_url: http://127.0.0.1:11434/v1\n"
        "    default_model: qwen3.5:9b\n",
        encoding="utf-8",
    )

    monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
    monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
    monkeypatch.setattr(doctor_mod, "_DHH", str(home))
    (tmp_path / "project").mkdir(exist_ok=True)

    fake_model_tools = types.SimpleNamespace(
        check_tool_availability=lambda *a, **kw: ([], []),
        TOOLSET_REQUIREMENTS={},
    )
    monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

    try:
        from hermes_cli import auth as _auth_mod
        monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
    except Exception:
        pass

    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        doctor_mod.run_doctor(Namespace(fix=False))

    out = buf.getvalue()
    assert (
        "model.provider 'custom:local-127.0.0.1:11434' is not a recognised provider"
        not in out
    )
    assert "model.provider 'custom:local-127.0.0.1:11434' is unknown" not in out


def test_run_doctor_accepts_bare_custom_provider(monkeypatch, tmp_path):
    home = tmp_path / ".hermes"
    home.mkdir(parents=True, exist_ok=True)
    (home / "config.yaml").write_text(
        "model:\n"
        "  provider: custom\n"
        "  default: local-model\n"
        "  base_url: http://localhost:8000/v1\n",
        encoding="utf-8",
    )

    monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
    monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
    monkeypatch.setattr(doctor_mod, "_DHH", str(home))
    (tmp_path / "project").mkdir(exist_ok=True)

    fake_model_tools = types.SimpleNamespace(
        check_tool_availability=lambda *a, **kw: ([], []),
        TOOLSET_REQUIREMENTS={},
    )
    monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

    try:
        from hermes_cli import auth as _auth_mod
        monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
    except Exception:
        pass

    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        doctor_mod.run_doctor(Namespace(fix=False))

    out = buf.getvalue()
    assert "model.provider 'custom' is not a recognised provider" not in out


def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(monkeypatch, tmp_path):
    home = tmp_path / ".hermes"
    home.mkdir(parents=True, exist_ok=True)
    (home / "config.yaml").write_text(
        "model:\n"
        "  provider: openrouter\n"
        "  default: openai/gpt-4.1-mini\n",
        encoding="utf-8",
    )

    monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
    monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
    monkeypatch.setattr(doctor_mod, "_DHH", str(home))
    (tmp_path / "project").mkdir(exist_ok=True)

    fake_model_tools = types.SimpleNamespace(
        check_tool_availability=lambda *a, **kw: ([], []),
        TOOLSET_REQUIREMENTS={},
    )
    monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
    monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
    monkeypatch.delenv("OPENAI_API_KEY", raising=False)

    try:
        from hermes_cli import auth as _auth_mod

        monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {})
    except Exception:
        pass

    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        doctor_mod.run_doctor(Namespace(fix=False))

    out = buf.getvalue()
    assert "model.provider 'openrouter' is set but no API key is configured" in out


@pytest.mark.parametrize(
    ("provider", "default_model"),
    [
        ("ai-gateway", "anthropic/claude-sonnet-4.6"),
        ("opencode-zen", "anthropic/claude-sonnet-4.6"),
        ("kilocode", "anthropic/claude-sonnet-4.6"),
        ("kimi-coding", "kimi-k2"),
        ("nvidia", "qwen/qwen3.5-122b-a10b"),
        ("moa", "anthropic/claude-sonnet-4.6"),
    ],
)
def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
    monkeypatch, tmp_path, provider, default_model
):
    home = tmp_path / ".hermes"
    home.mkdir(parents=True, exist_ok=True)
    (home / "config.yaml").write_text(
        "model:\n"
        f"  provider: {provider}\n"
        f"  default: {default_model}\n",
        encoding="utf-8",
    )

    monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
    monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
    monkeypatch.setattr(doctor_mod, "_DHH", str(home))
    (tmp_path / "project").mkdir(exist_ok=True)

    fake_model_tools = types.SimpleNamespace(
        check_tool_availability=lambda *a, **kw: ([], []),
        TOOLSET_REQUIREMENTS={},
    )
    monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

    try:
        from hermes_cli import auth as _auth_mod
        monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
    except Exception:
        pass

    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        doctor_mod.run_doctor(Namespace(fix=False))

    out = buf.getvalue()
    assert f"model.provider '{provider}' is not a recognised provider" not in out
    assert f"model.provider '{provider}' is unknown" not in out
    if provider in {"ai-gateway", "opencode-zen", "kilocode", "nvidia"}:
        assert (
            f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider}'"
            not in out
        )


def test_run_doctor_accepts_vendor_slugs_for_named_custom_provider(monkeypatch, tmp_path):
    home = tmp_path / ".hermes"
    home.mkdir(parents=True, exist_ok=True)
    (home / "config.yaml").write_text(
        "model:\n"
        "  provider: custom:hpc-ai\n"
        "  default: deepseek/deepseek-v4-flash\n"
        "custom_providers:\n"
        "  - name: hpc-ai\n"
        "    base_url: https://hpc-ai.example/v1\n"
        "    api_key: test-key\n",
        encoding="utf-8",
    )

    monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
    monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
    monkeypatch.setattr(doctor_mod, "_DHH", str(home))
    (tmp_path / "project").mkdir(exist_ok=True)

    fake_model_tools = types.SimpleNamespace(
        check_tool_availability=lambda *a, **kw: ([], []),
        TOOLSET_REQUIREMENTS={},
    )
    monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

    try:
        from hermes_cli import auth as _auth_mod
        monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
    except Exception:
        pass

    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        doctor_mod.run_doctor(Namespace(fix=False))

    out = buf.getvalue()
    assert "model.provider 'custom:hpc-ai' is not a recognised provider" not in out
    assert "model.provider 'custom:hpc-ai' is unknown" not in out
    assert (
        "model.default 'deepseek/deepseek-v4-flash' uses a vendor/model slug but provider is "
        "'custom:hpc-ai'"
        not in out
    )
    assert "Either set model.provider to 'openrouter', or drop the vendor prefix." not in out


@pytest.mark.parametrize(
    ("base_url", "expects_warning"),
    [
        ("http://localhost:20128/v1", False),
        ("https://api.openai.com/v1", True),
    ],
)
def test_run_doctor_vendor_slug_policy_for_openai_api_endpoint(
    monkeypatch, tmp_path, base_url, expects_warning
):
    """openai-api behind a custom router owns a vendor/model namespace (#69912); the real
    OpenAI endpoint keeps the warning."""
    home = tmp_path / ".hermes"
    home.mkdir(parents=True, exist_ok=True)
    (home / "config.yaml").write_text(
        "model:\n"
        "  provider: openai-api\n"
        "  default: nvidia/z-ai/glm-5.2\n"
        f"  base_url: {base_url}\n",
        encoding="utf-8",
    )

    monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
    monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
    monkeypatch.setattr(doctor_mod, "_DHH", str(home))
    (tmp_path / "project").mkdir(exist_ok=True)

    fake_model_tools = types.SimpleNamespace(
        check_tool_availability=lambda *a, **kw: ([], []),
        TOOLSET_REQUIREMENTS={},
    )
    monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

    try:
        from hermes_cli import auth as _auth_mod
        monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
    except Exception:
        pass

    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        doctor_mod.run_doctor(Namespace(fix=False))

    warning = (
        "model.default 'nvidia/z-ai/glm-5.2' uses a vendor/model slug "
        "but provider is 'openai-api'"
    )
    assert (warning in buf.getvalue()) is expects_warning




def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path):
    home = tmp_path / ".hermes"
    home.mkdir(parents=True, exist_ok=True)
    (home / ".env").write_text("KIMI_CN_API_KEY=***\n", encoding="utf-8")
    (home / "config.yaml").write_text(
        "model:\n"
        "  provider: kimi-coding-cn\n"
        "  default: kimi-k2.6\n",
        encoding="utf-8",
    )

    monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
    monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
    monkeypatch.setattr(doctor_mod, "_DHH", str(home))
    (tmp_path / "project").mkdir(exist_ok=True)

    fake_model_tools = types.SimpleNamespace(
        check_tool_availability=lambda *a, **kw: ([], []),
        TOOLSET_REQUIREMENTS={},
    )
    monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

    try:
        from hermes_cli import auth as _auth_mod
        monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_auth_status", lambda provider: {"logged_in": True})
        monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
    except Exception:
        pass

    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        doctor_mod.run_doctor(Namespace(fix=False))

    out = buf.getvalue()
    assert "model.provider 'kimi-coding-cn' is not a recognised provider" not in out


def _doctor_env_for_agent_browser(monkeypatch, tmp_path):
    """Shared fixture setup for the agent-browser npx-resolution
    branch in run_doctor (hermes_cli/doctor.py ~1557-1605)."""
    home = tmp_path / ".hermes"
    home.mkdir(parents=True, exist_ok=True)
    (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
    project = tmp_path / "project"
    project.mkdir(exist_ok=True)

    monkeypatch.setenv("PREFIX", "/usr")
    monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
    monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
    monkeypatch.setattr(doctor_mod, "_DHH", str(home))
    monkeypatch.setattr(
        shutil,
        "which",
        lambda cmd: "/usr/bin/node" if cmd in {"node", "npm"} else None,
    )

    fake_model_tools = types.SimpleNamespace(
        check_tool_availability=lambda *a, **kw: ([], []),
        TOOLSET_REQUIREMENTS={},
    )
    monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

    try:
        from hermes_cli import auth as _auth_mod
        monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
    except Exception:
        pass


def test_run_doctor_reports_installed_agent_browser(monkeypatch, tmp_path):
    _doctor_env_for_agent_browser(monkeypatch, tmp_path)

    monkeypatch.setattr(bt_install, "_find_agent_browser", lambda **_kw: "/pm/agent-browser")

    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        doctor_mod.run_doctor(Namespace(fix=False))
    out = buf.getvalue()

    assert "agent-browser" in out
    assert "/pm/agent-browser" in out
    assert "agent-browser not installed" not in out


def test_doctor_fix_does_not_claim_success_without_published_binary(monkeypatch, tmp_path):
    from hermes_cli import doctor_tools
    _doctor_env_for_agent_browser(monkeypatch, tmp_path)

    def missing(**kwargs):
        raise FileNotFoundError("no published browser")

    monkeypatch.setattr(bt_install, "_find_agent_browser", missing)
    monkeypatch.setattr("pm.ensure", lambda *a, **kw: None)

    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        assert doctor_tools._check_agent_browser(True) is False
    out = buf.getvalue()

    assert "agent-browser install failed" in out
    assert "no published browser" in out


def test_doctor_fix_reports_pm_install_failure(monkeypatch, tmp_path):
    from hermes_cli import doctor_tools
    import pm
    _doctor_env_for_agent_browser(monkeypatch, tmp_path)

    def missing(**kwargs):
        raise FileNotFoundError("no published browser")

    def refuse(*args, **kwargs):
        raise pm.InstallError("agent-browser", "offline")

    monkeypatch.setattr(bt_install, "_find_agent_browser", missing)
    monkeypatch.setattr(pm, "ensure", refuse)

    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        assert doctor_tools._check_agent_browser(True) is False
    out = buf.getvalue()

    assert "agent-browser install failed" in out
    assert "offline" in out


def test_run_doctor_kimi_cn_env_is_detected_and_probe_is_null_safe(monkeypatch, tmp_path):
    home = tmp_path / ".hermes"
    home.mkdir(parents=True, exist_ok=True)
    (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
    (home / ".env").write_text("KIMI_CN_API_KEY=sk-test\n", encoding="utf-8")
    project = tmp_path / "project"
    project.mkdir(exist_ok=True)

    monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
    monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
    monkeypatch.setattr(doctor_mod, "_DHH", str(home))
    monkeypatch.setenv("KIMI_CN_API_KEY", "sk-test")

    fake_model_tools = types.SimpleNamespace(
        check_tool_availability=lambda *a, **kw: ([], []),
        TOOLSET_REQUIREMENTS={},
    )
    monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

    try:
        from hermes_cli import auth as _auth_mod
        monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
    except Exception:
        pass

    calls = []

    def fake_get(url, headers=None, timeout=None):
        calls.append((url, headers, timeout))
        return types.SimpleNamespace(status_code=200)

    import httpx
    monkeypatch.setattr(httpx, "get", fake_get)

    import io, contextlib
    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        doctor_mod.run_doctor(Namespace(fix=False))
    out = buf.getvalue()

    assert "API key or custom endpoint configured" in out
    assert "Kimi / Moonshot (China)" in out
    assert "str expected, not NoneType" not in out
    assert any(url == "https://api.moonshot.cn/v1/models" for url, _, _ in calls)


def test_run_doctor_dashscope_retries_china_endpoint_after_intl_unauthorized(monkeypatch, tmp_path):
    home = tmp_path / ".hermes"
    home.mkdir(parents=True, exist_ok=True)
    (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
    (home / ".env").write_text("DASHSCOPE_API_KEY=sk-test\n", encoding="utf-8")
    project = tmp_path / "project"
    project.mkdir(exist_ok=True)

    monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
    monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
    monkeypatch.setattr(doctor_mod, "_DHH", str(home))
    monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-test")
    monkeypatch.delenv("DASHSCOPE_BASE_URL", raising=False)

    fake_model_tools = types.SimpleNamespace(
        check_tool_availability=lambda *a, **kw: ([], []),
        TOOLSET_REQUIREMENTS={},
    )
    monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

    try:
        from hermes_cli import auth as _auth_mod
        monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
    except ImportError:
        pass

    calls = []

    def fake_get(url, headers=None, timeout=None):
        calls.append((url, headers, timeout))
        status = 200 if "dashscope.aliyuncs.com" in url else 401
        return types.SimpleNamespace(status_code=status)

    import httpx
    monkeypatch.setattr(httpx, "get", fake_get)

    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        doctor_mod.run_doctor(Namespace(fix=False))
    out = buf.getvalue()

    assert "Alibaba/DashScope" in out
    assert "invalid API key" not in out
    assert any(
        url == "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models"
        for url, _, _ in calls
    )
    assert any(
        url == "https://dashscope.aliyuncs.com/compatible-mode/v1/models"
        for url, _, _ in calls
    )


@pytest.mark.parametrize("base_url", [None, "https://opencode.ai/zen/go/v1"])
def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path, base_url):
    home = tmp_path / ".hermes"
    home.mkdir(parents=True, exist_ok=True)
    (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
    (home / ".env").write_text("OPENCODE_GO_API_KEY=***\n", encoding="utf-8")
    project = tmp_path / "project"
    project.mkdir(exist_ok=True)

    monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
    monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
    monkeypatch.setattr(doctor_mod, "_DHH", str(home))
    monkeypatch.setenv("OPENCODE_GO_API_KEY", "sk-test")
    if base_url:
        monkeypatch.setenv("OPENCODE_GO_BASE_URL", base_url)
    else:
        monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False)

    fake_model_tools = types.SimpleNamespace(
        check_tool_availability=lambda *a, **kw: ([], []),
        TOOLSET_REQUIREMENTS={},
    )
    monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

    try:
        from hermes_cli import auth as _auth_mod
        monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
        monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
    except ImportError:
        pass

    calls = []

    def fake_get(url, headers=None, timeout=None):
        calls.append((url, headers, timeout))
        return types.SimpleNamespace(status_code=200)

    import httpx
    monkeypatch.setattr(httpx, "get", fake_get)

    import io, contextlib
    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        doctor_mod.run_doctor(Namespace(fix=False))
    out = buf.getvalue()

    assert any(
        "OpenCode Go" in line and "(key configured)" in line
        for line in out.splitlines()
    )
    assert not any(url == "https://opencode.ai/zen/go/v1/models" for url, _, _ in calls)
    assert not any("opencode" in url.lower() and "models" in url.lower() for url, _, _ in calls)


class TestGitHubTokenCheck:
    """Tests for GitHub token / gh auth detection in doctor."""






    def test_gh_authenticated_on_gh_without_authenticated_json_field(self, monkeypatch):
        """gh 2.98+ dropped the `authenticated` field from `gh auth status --json`,
        so that invocation exits 1 even for a logged-in user. A logged-in user on
        such a gh must still be reported as authenticated."""
        from hermes_cli import doctor_state

        def gh_2_98(cmd, **kwargs):
            assert cmd[:3] == ["gh", "auth", "status"], cmd
            if "--json" in cmd and "authenticated" in cmd:
                return types.SimpleNamespace(returncode=1, stdout=b"", stderr=b"unknown JSON field")
            return types.SimpleNamespace(returncode=0, stdout=b"", stderr=b"Logged in to github.com")

        monkeypatch.setattr(subprocess, "run", gh_2_98)
        assert doctor_state._gh_authenticated() is True


def _run_doctor_with_healthy_oauth_fallback(
    monkeypatch,
    tmp_path,
    *,
    env_key: str,
    bad_key: str,
    failing_host: str,
    minimax_oauth_status: dict,
    xai_oauth_status: dict | None = None,
) -> str:
    home = tmp_path / ".hermes"
    home.mkdir(parents=True, exist_ok=True)
    (home / "config.yaml").write_text(
        "model:\n"
        "  provider: nous\n"
        "  default: moonshotai/kimi-k2.6\n",
        encoding="utf-8",
    )
    project = tmp_path / "project"
    project.mkdir(exist_ok=True)

    monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
    monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
    monkeypatch.setattr(doctor_mod, "_DHH", str(home))
    monkeypatch.setenv(env_key, bad_key)
    monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
    monkeypatch.delenv("OPENAI_API_KEY", raising=False)
    monkeypatch.delenv("GEMINI_API_KEY", raising=False)
    monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
    monkeypatch.delenv("MINIMAX_API_KEY", raising=False)
    monkeypatch.delenv("MINIMAX_CN_API_KEY", raising=False)
    monkeypatch.setenv(env_key, bad_key)

    fake_model_tools = types.SimpleNamespace(
        check_tool_availability=lambda *a, **kw: ([], []),
        TOOLSET_REQUIREMENTS={},
    )
    monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

    from hermes_cli import auth as _auth_mod

    monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {"logged_in": True})
    monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
    monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: minimax_oauth_status)
    _xai_status = xai_oauth_status if xai_oauth_status is not None else {}
    monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: _xai_status)

    def fake_get(url, headers=None, timeout=None):
        status = 401 if failing_host in url else 200
        return types.SimpleNamespace(status_code=status)

    import httpx

    monkeypatch.setattr(httpx, "get", fake_get)

    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        doctor_mod.run_doctor(Namespace(fix=False))
    return buf.getvalue()


@pytest.mark.parametrize(
    ("env_key", "bad_key", "failing_host", "minimax_oauth_status", "xai_oauth_status", "unexpected_issue"),
    [
        (
            "MINIMAX_API_KEY",
            "bad-minimax-key",
            "minimax.io",
            {"logged_in": True, "region": "global"},
            None,
            "Check MINIMAX_API_KEY in .env",
        ),
        (
            "XAI_API_KEY",
            "bad-xai-key",
            "api.x.ai",
            {},
            {"logged_in": True, "auth_mode": "oauth_pkce"},
            "Check XAI_API_KEY in .env",
        ),
    ],
)
def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy(
    monkeypatch,
    tmp_path,
    env_key,
    bad_key,
    failing_host,
    minimax_oauth_status,
    xai_oauth_status,
    unexpected_issue,
):
    out = _run_doctor_with_healthy_oauth_fallback(
        monkeypatch,
        tmp_path,
        env_key=env_key,
        bad_key=bad_key,
        failing_host=failing_host,
        minimax_oauth_status=minimax_oauth_status,
        xai_oauth_status=xai_oauth_status,
    )

    assert "invalid API key" in out
    assert unexpected_issue not in out






# ---------------------------------------------------------------------------
# ◆ Auth Providers — xAI OAuth display in run_doctor()
# ---------------------------------------------------------------------------


class TestDoctorXaiOAuthStatus:
    """The ◆ Auth Providers section must show xAI OAuth login state.

    xAI OAuth is checked in a *separate* try/except block so that an import
    failure (or runtime exception) cannot silence the Nous / Codex / Gemini /
    MiniMax rows that were already printed above it.
    """

    def _run(self, monkeypatch, tmp_path, *, xai_auth_fn) -> str:
        """Run doctor with a controlled xAI auth callable; return stdout."""
        home = tmp_path / ".hermes"
        home.mkdir(parents=True, exist_ok=True)
        (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
        project = tmp_path / "project"
        project.mkdir(exist_ok=True)

        monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
        monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
        monkeypatch.setattr(doctor_mod, "_DHH", str(home))

        fake_model_tools = types.SimpleNamespace(
            check_tool_availability=lambda *a, **kw: ([], []),
            TOOLSET_REQUIREMENTS={},
        )
        monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

        from hermes_cli import auth as _auth_mod
        monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {"logged_in": False})
        monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
        monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
        monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", xai_auth_fn)

        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            doctor_mod.run_doctor(Namespace(fix=False))
        return buf.getvalue()


    def test_logged_in_does_not_emit_not_logged_in_on_xai_line(self, monkeypatch, tmp_path):
        out = self._run(
            monkeypatch, tmp_path,
            xai_auth_fn=lambda: {"logged_in": True},
        )
        assert "xAI OAuth" in out
        # The xAI OAuth line itself must say "(logged in)", not "(not logged in)".
        xai_line = next(l for l in out.splitlines() if "xAI OAuth" in l)
        assert "(logged in)" in xai_line
        assert "(not logged in)" not in xai_line


    def test_import_failure_does_not_affect_other_providers(self, monkeypatch, tmp_path):
        """Nous / Codex / Gemini / MiniMax rows must survive an xAI import failure."""
        home = tmp_path / ".hermes"
        home.mkdir(parents=True, exist_ok=True)
        (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
        project = tmp_path / "project"
        project.mkdir(exist_ok=True)

        monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
        monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
        monkeypatch.setattr(doctor_mod, "_DHH", str(home))

        fake_model_tools = types.SimpleNamespace(
            check_tool_availability=lambda *a, **kw: ([], []),
            TOOLSET_REQUIREMENTS={},
        )
        monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

        from hermes_cli import auth as _auth_mod
        monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {"logged_in": True})
        monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
        monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
        monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False)

        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            doctor_mod.run_doctor(Namespace(fix=False))
        out = buf.getvalue()
        assert "Nous Portal auth" in out
        assert "logged in" in out

    def test_function_raises_does_not_crash_doctor(self, monkeypatch, tmp_path):
        """A runtime exception from get_xai_oauth_auth_status must be swallowed."""
        def _raise():
            raise RuntimeError("simulated xAI status failure")

        out = self._run(monkeypatch, tmp_path, xai_auth_fn=_raise)
        assert "Auth Providers" in out


# ---------------------------------------------------------------------------
# ◆ Auth Providers — codex CLI import hint placement (issue #27975)
# ---------------------------------------------------------------------------




class TestDoctorStaleMaxIterationsDrift:
    """Regression for #17534: a stale HERMES_MAX_ITERATIONS in .env shadows
    agent.max_turns in config.yaml. The repro symptom is config.yaml saying
    400 while the gateway activity line reads N/90. Doctor must detect the
    drift, and `--fix` must remove the .env ghost (config.yaml wins).

    The detector reads the .env FILE directly, NOT os.environ — the gateway
    startup bridge can already have overridden os.environ to the config value,
    so the ghost is only visible in the file.
    """

    def _run_config_section(self, monkeypatch, tmp_path, *, fix, ghost, cfg_turns,
                            os_environ_value=None):
        import contextlib
        import io
        from argparse import Namespace

        hermes_home = tmp_path / ".hermes"
        hermes_home.mkdir(parents=True)
        (hermes_home / "config.yaml").write_text(
            f"agent:\n  max_turns: {cfg_turns}\n", encoding="utf-8"
        )
        env_lines = ["OPENAI_API_KEY=sk-test\n"]
        if ghost is not None:
            env_lines.append(f"HERMES_MAX_ITERATIONS={ghost}\n")
        (hermes_home / ".env").write_text("".join(env_lines), encoding="utf-8")

        monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home)
        monkeypatch.setattr(doctor_mod, "get_hermes_home", lambda: hermes_home)
        # Point the config helpers at the temp home.
        monkeypatch.setenv("HERMES_HOME", str(hermes_home))
        if os_environ_value is not None:
            # Simulate the gateway bridge having already overridden os.environ.
            monkeypatch.setenv("HERMES_MAX_ITERATIONS", str(os_environ_value))
        else:
            monkeypatch.delenv("HERMES_MAX_ITERATIONS", raising=False)

        # Short-circuit at the Tool Availability stage — the drift check runs
        # well before it in the Configuration Files section.
        fake_model_tools = types.SimpleNamespace(
            check_tool_availability=lambda *a, **kw: (_ for _ in ()).throw(SystemExit(0)),
            TOOLSET_REQUIREMENTS={},
        )
        monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)

        buf = io.StringIO()
        with contextlib.redirect_stdout(buf), pytest.raises(SystemExit):
            doctor_mod.run_doctor(Namespace(fix=fix))
        return buf.getvalue(), hermes_home

    def test_detects_drift_warn_only(self, monkeypatch, tmp_path):
        out, hermes_home = self._run_config_section(
            monkeypatch, tmp_path, fix=False, ghost=90, cfg_turns=400,
            os_environ_value=400,  # bridge contaminated os.environ
        )
        assert "HERMES_MAX_ITERATIONS=90" in out
        assert "shadows" in out
        # Warn-only must NOT mutate .env.
        assert "HERMES_MAX_ITERATIONS=90" in (hermes_home / ".env").read_text(encoding="utf-8")

    def test_fix_removes_ghost(self, monkeypatch, tmp_path):
        out, hermes_home = self._run_config_section(
            monkeypatch, tmp_path, fix=True, ghost=90, cfg_turns=400,
            os_environ_value=400,
        )
        assert "Removed stale HERMES_MAX_ITERATIONS" in out
        env_after = (hermes_home / ".env").read_text(encoding="utf-8")
        assert "HERMES_MAX_ITERATIONS" not in env_after
        assert "OPENAI_API_KEY=sk-test" in env_after  # other keys preserved


    def test_no_drift_when_ghost_absent(self, monkeypatch, tmp_path):
        out, _ = self._run_config_section(
            monkeypatch, tmp_path, fix=False, ghost=None, cfg_turns=400,
        )
        assert "shadows" not in out


class TestDoctorLegacyCustomProvidersResidue:
    """A legacy ``custom_providers`` list entry without a ``providers:`` twin lives on in the retired list
    store; doctor must name it and point at the move. Twins (URL modulo trailing slash /
    case) and non-list values are not this step's business."""

    def _run(self, tmp_path, yaml_text):
        cfg = tmp_path / "config.yaml"
        cfg.write_text(yaml_text, encoding="utf-8")
        finding = doctor_config.Finding()
        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            doctor_config._drift_legacy_custom_providers(finding, False, cfg)
        return buf.getvalue(), finding

    def test_orphan_entry_is_flagged_with_repair_instruction(self, tmp_path):
        out, finding = self._run(tmp_path, (
            "custom_providers:\n  - name: Local (8283)\n    base_url: http://127.0.0.1:8283/v1\n"
            "providers:\n  other:\n    api: http://127.0.0.1:8290/v1\n"))
        assert "Local (8283)" in out
        assert finding.manual_issues and "providers.<key>.api: http://127.0.0.1:8283/v1" in finding.manual_issues[0]
        assert finding.fixed == 0 and finding.issues == []  # warn-only: no --fix rewrite of config.yaml

    def test_twin_and_scalar_are_silent(self, tmp_path):
        out, finding = self._run(tmp_path, (
            "custom_providers:\n  - name: Local\n    base_url: http://127.0.0.1:8283/V1/\n"
            "providers:\n  local:\n    api: http://127.0.0.1:8283/v1\n"))
        assert out == "" and finding.manual_issues == []
        out, finding = self._run(tmp_path, "custom_providers: oops\n")
        assert out == "" and finding.manual_issues == []



class TestDoctorDeprecatedConfigAndEnv:
    """Doctor must surface deprecated/legacy config keys and env vars with
    modern replacements as non-failing warnings — without auto-migrating.
    """



    def test_collect_deprecated_env_vars_ignores_empty(self):
        assert doctor_config.collect_deprecated_env_vars({"TERMINAL_CWD": "  "}) == []
        assert doctor_config.collect_deprecated_env_vars({}) == []
        assert doctor_config.collect_deprecated_env_vars(None) == []




class TestStagedRuntimeVenv:
    """doctor_platform._check_python_environment distinguishes staged
    dependencies (pm's Venv().venv_dir() resolved state) from the active
    interpreter: under no-boot-through-venv bundled installs
    sys.prefix == sys.base_prefix by design, so "not active" must not read
    as "missing dependencies" when pm has staged the runtime venv."""

    @staticmethod
    def _stub_venv(monkeypatch, venv_dir):
        class _StubVenv:
            def venv_dir(self):
                return venv_dir

        monkeypatch.setattr("pm.packages.Venv", _StubVenv)

    # --- _staged_venv_dir: pm authority + provisioned-venv marker ---


    def test_resolved_path_without_venv_marker_is_not_staged(self, tmp_path, monkeypatch):
        empty = tmp_path / "venv"
        empty.mkdir()
        self._stub_venv(monkeypatch, empty)

        assert doctor_platform._staged_venv_dir() is None

    def test_unreadable_pm_degrades_to_none(self, monkeypatch):
        class _BrokenVenv:
            def venv_dir(self):
                raise RuntimeError("pm unavailable")

        monkeypatch.setattr("pm.packages.Venv", _BrokenVenv)

        assert doctor_platform._staged_venv_dir() is None

    # --- the check's staged-vs-active rows ---


    def test_nothing_staged_keeps_legacy_interpreter_probe(self, monkeypatch, capsys):
        monkeypatch.setattr(doctor_platform, "_staged_venv_dir", lambda: None)
        monkeypatch.setattr(doctor_platform.sys, "prefix", "/usr")
        monkeypatch.setattr(doctor_platform.sys, "base_prefix", "/usr")

        doctor_platform._check_python_environment(False)

        out = capsys.readouterr().out
        assert "Runtime venv staged" not in out
        assert "Not in virtual environment" in out

def test_run_doctor_reports_shadowed_lightpanda_engine(monkeypatch, tmp_path):
    helper = TestDoctorMemoryProviderSection()

    monkeypatch.setattr("tools.browser_tool_lightpanda_fallback._using_lightpanda_engine", lambda: True)
    monkeypatch.setattr(
        "tools.browser_tool_lightpanda_fallback.lightpanda_engine_status",
        lambda: (False, "cloud provider Browserbase is selected"),
    )
    out = helper._run_doctor_and_capture(monkeypatch, tmp_path)
    assert "browser.engine=lightpanda is shadowed" in out
    assert "Browserbase" in out




def test_run_doctor_warns_when_lightpanda_binary_missing(monkeypatch, tmp_path):
    helper = TestDoctorMemoryProviderSection()

    monkeypatch.setattr("tools.browser_tool_lightpanda_fallback._using_lightpanda_engine", lambda: True)
    monkeypatch.setattr("tools.browser_tool_lightpanda_fallback.lightpanda_engine_status", lambda: (True, "Browser Use mode"))
    monkeypatch.setattr("tools.browser_lightpanda.find_lightpanda_binary", lambda: None)
    out = helper._run_doctor_and_capture(monkeypatch, tmp_path)
    assert "Lightpanda selected but binary not found" in out


def test_docker_daemon_probe_uses_version_not_info(monkeypatch):
    """`docker info` needs the /info endpoint, which socket proxies commonly block, so doctor reported
    "daemon not running" against a working DOCKER_HOST (#72927). `docker version` (/version) is what the
    backend itself probes with."""
    from hermes_cli import doctor_tools

    calls: list = []
    monkeypatch.setattr(doctor_tools, "find_docker", lambda: "/usr/bin/docker")
    monkeypatch.setattr(doctor_tools, "_run_ok", lambda cmd, timeout, **kw: calls.append(cmd) or True)
    monkeypatch.setattr(doctor_tools, "_require", lambda *a, **k: None)

    doctor_tools._check_docker_backend("docker", False, [])

    assert calls == [["/usr/bin/docker", "version"]]


def test_doctor_reports_auxiliary_blocks_that_do_not_resolve(tmp_path, monkeypatch):
    """A routed auxiliary.<task> block that the runtime resolver rejects is a doctor finding, not a
    silent fall-back to the main model (#116055); a resolvable one is not flagged."""
    import hermes_yaml as yaml
    from hermes_cli import doctor_config

    monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
    cfg_file = tmp_path / "config.yaml"
    cfg_file.write_text(yaml.safe_dump({"auxiliary": {
        "background_review": {"provider": "no-such-provider", "model": "m"},
        "compression": {"provider": "openai", "model": "gpt-x", "base_url": "https://gateway.example/v1", "api_key": "gw"},
    }}))
    issues = []
    doctor_config._validate_auxiliary_config(cfg_file, issues)
    assert len(issues) == 1 and "auxiliary.background_review" in issues[0] and "no-such-provider" in issues[0]


@pytest.mark.platforms("macos")
class TestMacOSTCCGrants:
    """macOS TCC grant persistence check (#86385): a cdhash-pinned DR (pre-#73681
    local builds) silently resets Screen Recording/Accessibility grants on every
    rebuild while the Settings toggle stays ON."""

    @staticmethod
    def _darwin_bundle(monkeypatch, tmp_path, dr):
        monkeypatch.setattr(doctor_platform, "_desktop_app_bundle", lambda: tmp_path / "Hermes.app")
        if dr is not ...:
            monkeypatch.setattr(doctor_platform, "_macos_desktop_dr", lambda app: dr)

    def test_silent_without_desktop_bundle(self, monkeypatch, capsys):
        monkeypatch.setattr(doctor_platform, "_desktop_app_bundle", lambda: None)
        doctor_platform.check_macos_tcc_grants()
        assert capsys.readouterr().out == ""

    def test_warns_on_cdhash_pinned_dr(self, monkeypatch, capsys, tmp_path):
        self._darwin_bundle(
            monkeypatch, tmp_path,
            'designated => identifier "com.nousresearch.hermes" and cdhash H"97e692f3890f781fa0ad5ad6cb9d769cfaf42628"',
        )
        doctor_platform.check_macos_tcc_grants()
        out = capsys.readouterr().out
        assert "TCC grants will reset after every update" in out
        assert "hermes update" in out
        assert "signing identity is stable" not in out

    def test_identifier_dr_is_stable_with_upgrade_hint_and_repair_info(self, monkeypatch, capsys, tmp_path):
        self._darwin_bundle(monkeypatch, tmp_path, 'designated => identifier "com.nousresearch.hermes"')
        doctor_platform.check_macos_tcc_grants()
        out = capsys.readouterr().out
        assert "TCC signing identity is stable" in out
        assert "--setup-tcc-identity" in out
        assert "tccutil reset ScreenCapture com.nousresearch.hermes" in out

    def test_certificate_anchored_dr_is_stable_without_upgrade_hint(self, monkeypatch, capsys, tmp_path):
        self._darwin_bundle(
            monkeypatch, tmp_path,
            'designated => identifier "com.nousresearch.hermes" and certificate root = H"aabbcc"',
        )
        doctor_platform.check_macos_tcc_grants()
        out = capsys.readouterr().out
        assert "TCC signing identity is stable" in out
        assert "--setup-tcc-identity" not in out
        assert "tccutil reset ScreenCapture com.nousresearch.hermes" in out

    @pytest.mark.parametrize("failure", ["none", "empty", "timeout", "no_codesign"])
    def test_unreadable_dr_warns_and_never_claims_stable(self, monkeypatch, capsys, tmp_path, failure):
        """codesign failing, hanging, missing or printing nothing degrades to a
        warning; an empty DR must not false-positive as a stable identity."""
        if failure in ("none", "empty"):
            self._darwin_bundle(monkeypatch, tmp_path, None if failure == "none" else "")
        else:
            self._darwin_bundle(monkeypatch, tmp_path, ...)
            if failure == "timeout":
                monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/codesign")

                def _timeout(*args, **kwargs):
                    raise subprocess.TimeoutExpired(cmd=["codesign"], timeout=15)

                monkeypatch.setattr(subprocess, "run", _timeout)
            else:
                monkeypatch.setattr(shutil, "which", lambda _name: None)
        doctor_platform.check_macos_tcc_grants()
        out = capsys.readouterr().out
        assert "could not read code-signing requirement" in out
        assert "stable" not in out
