"""Tests for the /codex-runtime slash-command shared logic.

These cover the pure-Python state machine; CLI and gateway handlers are
tested separately because they involve config persistence and prompt
formatting that's surface-specific."""

from __future__ import annotations

from unittest.mock import patch

import pytest

from hermes_cli import codex_runtime_switch as crs


class TestParseArgs:
    @pytest.mark.parametrize("arg,expected", [
        ("", None),
        ("   ", None),
        ("auto", "auto"),
        ("codex_app_server", "codex_app_server"),
        ("on", "codex_app_server"),
        ("off", "auto"),
        ("codex", "codex_app_server"),
        ("default", "auto"),
        ("hermes", "auto"),
        ("ENABLE", "codex_app_server"),  # case-insensitive
        ("DiSaBlE", "auto"),
    ])
    def test_valid_args(self, arg, expected):
        value, errors = crs.parse_args(arg)
        assert errors == []
        assert value == expected

    def test_invalid_arg_returns_error(self):
        value, errors = crs.parse_args("turbo")
        assert value is None
        assert errors and "Unknown runtime" in errors[0]


class TestGetCurrentRuntime:
    def test_default_when_unset(self):
        assert crs.get_current_runtime({}) == "auto"
        assert crs.get_current_runtime({"model": {}}) == "auto"
        assert crs.get_current_runtime({"model": {"openai_runtime": ""}}) == "auto"

    def test_unrecognized_falls_back_to_auto(self):
        assert crs.get_current_runtime(
            {"model": {"openai_runtime": "garbage"}}
        ) == "auto"

    def test_explicit_codex(self):
        assert crs.get_current_runtime(
            {"model": {"openai_runtime": "codex_app_server"}}
        ) == "codex_app_server"

    def test_handles_non_dict_config(self):
        assert crs.get_current_runtime(None) == "auto"  # type: ignore[arg-type]
        assert crs.get_current_runtime("notadict") == "auto"  # type: ignore[arg-type]
        assert crs.get_current_runtime({"model": "notadict"}) == "auto"


class TestSetRuntime:
    def test_creates_model_section_if_missing(self):
        cfg = {}
        old = crs.set_runtime(cfg, "codex_app_server")
        assert old == "auto"
        assert cfg["model"]["openai_runtime"] == "codex_app_server"

    def test_returns_previous_value(self):
        cfg = {"model": {"openai_runtime": "codex_app_server"}}
        old = crs.set_runtime(cfg, "auto")
        assert old == "codex_app_server"
        assert cfg["model"]["openai_runtime"] == "auto"

    def test_invalid_value_raises(self):
        with pytest.raises(ValueError):
            crs.set_runtime({}, "garbage")


class TestApply:
    def test_read_only_call_reports_state(self):
        cfg = {"model": {"openai_runtime": "codex_app_server"}}
        with patch.object(crs, "check_codex_binary_ok",
                          return_value=(True, "0.130.0")):
            r = crs.apply(cfg, None)
        assert r.success
        assert r.new_value == "codex_app_server"
        assert r.old_value == "codex_app_server"
        assert "codex_app_server" in r.message
        assert "0.130.0" in r.message

    def test_no_change_when_already_set(self):
        cfg = {"model": {"openai_runtime": "auto"}}
        r = crs.apply(cfg, "auto")
        assert r.success
        assert r.message == "openai_runtime already set to auto"

    def test_enable_blocked_when_codex_missing(self):
        cfg = {}
        with patch.object(crs, "check_codex_binary_ok",
                          return_value=(False, "codex not found")):
            r = crs.apply(cfg, "codex_app_server")
        assert r.success is False
        assert "Cannot enable" in r.message
        assert "npm i -g @openai/codex" in r.message
        # Config NOT mutated on failure
        assert cfg.get("model", {}).get("openai_runtime") in {None, ""}

    def test_enable_succeeds_when_codex_present(self):
        cfg = {}
        persisted = {}

        def persist(c):
            persisted.update(c)

        # Patch migrate so this test doesn't reach into the user's real
        # ~/.codex/config.toml. See issue #26250 Bug C — without this patch,
        # crs.apply() invokes the real migrate() which writes to
        # Path.home() / ".codex" using whatever HERMES_HOME the running pytest
        # session has set, leaking pytest tempdir paths into the user's
        # codex config.
        with patch.object(crs, "check_codex_binary_ok",
                          return_value=(True, "0.130.0")), \
             patch("hermes_cli.codex_runtime_plugin_migration.migrate"):
            r = crs.apply(cfg, "codex_app_server", persist_callback=persist)
        assert r.success
        assert r.new_value == "codex_app_server"
        assert r.old_value == "auto"
        assert r.requires_new_session is True
        assert "via MCP" in r.message  # hermes-tools callback message
        assert cfg["model"]["openai_runtime"] == "codex_app_server"
        assert persisted["model"]["openai_runtime"] == "codex_app_server"

    def test_disable_does_not_check_binary(self):
        cfg = {"model": {"openai_runtime": "codex_app_server"}}
        with patch.object(crs, "check_codex_binary_ok") as bin_check:
            r = crs.apply(cfg, "auto")
        assert r.success
        # Binary check is irrelevant when disabling — should not be called
        # with the codex_app_server enable-gate signature.
        assert r.new_value == "auto"
        assert r.old_value == "codex_app_server"

    def test_persist_callback_failure_reported(self):
        cfg = {}

        def persist_boom(c):
            raise IOError("disk full")

        with patch.object(crs, "check_codex_binary_ok",
                          return_value=(True, "0.130.0")):
            r = crs.apply(cfg, "codex_app_server", persist_callback=persist_boom)
        assert r.success is False
        assert "persist failed" in r.message
        assert "disk full" in r.message

    def test_enable_triggers_mcp_migration(self):
        """Enabling codex_app_server should auto-migrate Hermes mcp_servers
        to ~/.codex/config.toml so the spawned subprocess sees them."""
        cfg = {
            "mcp_servers": {
                "filesystem": {"command": "npx", "args": ["-y", "fs-server"]},
            }
        }

        with patch.object(crs, "check_codex_binary_ok",
                          return_value=(True, "0.130.0")), \
             patch("hermes_cli.codex_runtime_plugin_migration.migrate") as mig:
            mig.return_value.migrated = ["filesystem", "hermes-tools"]
            mig.return_value.migrated_plugins = []
            mig.return_value.plugin_query_error = None
            mig.return_value.wrote_permissions_default = ":workspace"
            mig.return_value.errors = []
            mig.return_value.target_path = "/fake/.codex/config.toml"
            r = crs.apply(cfg, "codex_app_server")
        assert r.success
        assert mig.called  # migration was triggered
        # User MCP servers are reported (excluding internal hermes-tools)
        assert "Migrated 1 MCP server" in r.message
        assert "filesystem" in r.message
        # Permissions default surfaces
        assert "Default sandbox: :workspace" in r.message
        # Hermes tool callback announcement
        assert "via MCP" in r.message

    def test_disable_does_not_trigger_migration(self):
        """Switching back to auto must not write to ~/.codex/."""
        cfg = {
            "model": {"openai_runtime": "codex_app_server"},
            "mcp_servers": {"x": {"command": "y"}},
        }
        with patch("hermes_cli.codex_runtime_plugin_migration.migrate") as mig:
            r = crs.apply(cfg, "auto")
        assert r.success
        assert not mig.called  # disabling does not migrate

    def test_migration_failure_does_not_block_enable(self):
        """If MCP migration raises, the runtime change still proceeds —
        users can manually re-run migration later."""
        cfg = {"mcp_servers": {"x": {"command": "y"}}}
        with patch.object(crs, "check_codex_binary_ok",
                          return_value=(True, "0.130.0")), \
             patch("hermes_cli.codex_runtime_plugin_migration.migrate",
                   side_effect=RuntimeError("disk full")):
            r = crs.apply(cfg, "codex_app_server")
        assert r.success  # change still applied
        assert r.new_value == "codex_app_server"
        assert "MCP migration skipped" in r.message
        assert "disk full" in r.message

    def test_binary_check_cached_within_apply(self):
        """check_codex_binary_ok is invoked at most once per apply() call.

        The enable path has three sites that need the version (state report,
        enable gate, success message). Without caching, a single
        /codex-runtime invocation spawns `codex --version` three times.
        Regression guard against a refactor that drops the cache.
        """
        cfg = {}
        with patch.object(crs, "check_codex_binary_ok",
                          return_value=(True, "0.130.0")) as bin_check, \
             patch("hermes_cli.codex_runtime_plugin_migration.migrate"):
            r = crs.apply(cfg, "codex_app_server")
        assert r.success
        assert bin_check.call_count == 1, (
            f"check_codex_binary_ok was called {bin_check.call_count} time(s); "
            "should be cached and called exactly once per apply()"
        )

    def test_binary_check_cached_on_read_only_call(self):
        """Read-only call (new_value=None) calls the binary check exactly
        once and reuses the result for the message."""
        cfg = {"model": {"openai_runtime": "codex_app_server"}}
        with patch.object(crs, "check_codex_binary_ok",
                          return_value=(True, "0.130.0")) as bin_check:
            crs.apply(cfg, None)
        assert bin_check.call_count == 1
