from __future__ import annotations

import importlib
import json
import sys
import types
from pathlib import Path
from types import SimpleNamespace

import click
import pytest
from click.testing import CliRunner


def _load_init_module(monkeypatch):
    monkeypatch.delitem(sys.modules, "headroom.cli.init", raising=False)
    monkeypatch.delitem(sys.modules, "headroom.cli.main", raising=False)
    fake_main_module = types.ModuleType("headroom.cli.main")

    @click.group()
    def fake_main() -> None:
        pass

    fake_main_module.main = fake_main
    monkeypatch.setitem(sys.modules, "headroom.cli.main", fake_main_module)
    importlib.invalidate_caches()
    init_cli = importlib.import_module("headroom.cli.init")
    monkeypatch.delitem(sys.modules, "headroom.cli.init", raising=False)
    return init_cli, fake_main


def test_init_auto_detects_targets(monkeypatch) -> None:
    init_cli, fake_main = _load_init_module(monkeypatch)
    runner = CliRunner()
    captured: dict[str, object] = {}

    monkeypatch.setattr(init_cli, "detect_init_targets", lambda global_scope: ["claude", "codex"])
    monkeypatch.setattr(init_cli, "_run_init_targets", lambda **kwargs: captured.update(kwargs))

    result = runner.invoke(fake_main, ["init", "-g"])

    assert result.exit_code == 0, result.output
    assert captured["targets"] == ["claude", "codex"]
    assert captured["global_scope"] is True


def test_init_fails_when_auto_detection_empty(monkeypatch) -> None:
    """Bare ``headroom init`` with no agents on PATH prints a guided error.

    Regression guard for issue #245: the error must list every target that
    was probed, confirm that -g / --global is a valid flag, and show the
    explicit per-target invocation so the user knows how to proceed.
    """

    init_cli, fake_main = _load_init_module(monkeypatch)
    runner = CliRunner()
    monkeypatch.setattr(init_cli.shutil, "which", lambda name: None)

    result = runner.invoke(fake_main, ["init", "-g"])

    assert result.exit_code != 0
    assert "No supported user-scope agents were found on PATH" in result.output
    assert "probed the following agents" in result.output
    # Every in-scope target is listed with its lookup status.
    for target in ("claude", "codex", "copilot", "openclaw"):
        assert target in result.output
    # The user is told that -g is still valid and given a concrete next step.
    assert "-g" in result.output
    assert "headroom init -g claude" in result.output


def test_format_empty_detection_error_local_scope(monkeypatch) -> None:
    """Local-scope variant of the guided error only lists local-scope agents."""

    init_cli, _ = _load_init_module(monkeypatch)
    monkeypatch.setattr(init_cli.shutil, "which", lambda name: None)

    message = init_cli._format_empty_detection_error(global_scope=False)

    assert "local-scope agents" in message
    assert "claude" in message and "codex" in message
    # Copilot / openclaw are global-only; must not be suggested for local.
    assert "headroom init copilot" not in message
    assert "headroom init openclaw" not in message
    assert "headroom init claude" in message
    assert "headroom init codex" in message


def test_format_empty_detection_error_reports_found_paths(monkeypatch, tmp_path) -> None:
    """When a binary IS present, the error still surfaces its path for debugging."""

    init_cli, _ = _load_init_module(monkeypatch)
    fake_claude = tmp_path / "claude"
    fake_claude.write_text("")
    monkeypatch.setattr(
        init_cli.shutil,
        "which",
        lambda name: str(fake_claude) if name == "claude" else None,
    )

    message = init_cli._format_empty_detection_error(global_scope=True)

    assert f"claude: found at {fake_claude}" in message
    assert "codex: not found" in message


def test_init_verbose_enables_debug_logging_on_stderr(monkeypatch) -> None:
    """``headroom init -v`` should emit diagnostic lines to stderr.

    Different Click 8.x versions expose stderr on ``CliRunner`` results
    differently (``mix_stderr`` was removed in 8.2, and ``result.stderr``
    appeared around the same time). To stay compatible with any Click 8.x
    the repo targets, the test reads ``result.stderr`` when the attribute
    exists AND contains data, otherwise falls back to ``result.output``
    (which is the combined stream when stderr isn't captured separately).
    """

    init_cli, fake_main = _load_init_module(monkeypatch)
    monkeypatch.setattr(init_cli.shutil, "which", lambda name: None)
    runner = CliRunner()

    result = runner.invoke(fake_main, ["init", "-v", "-g"])

    # Newer Click: stderr captured separately.
    stderr = getattr(result, "stderr", None) or ""
    if not stderr:
        # Older Click: everything in result.output.
        stderr = result.output

    assert result.exit_code != 0, f"output: {result.output!r}"
    assert "[headroom init]" in stderr
    assert "detect_init_targets" in stderr
    assert "global_scope=True" in stderr
    for target in ("claude", "codex", "copilot", "openclaw"):
        assert target in stderr


def test_init_verbose_is_idempotent(monkeypatch) -> None:
    """Calling _enable_verbose_logging repeatedly keeps one handler attached."""

    init_cli, _ = _load_init_module(monkeypatch)
    # Clear any prior handler state on the dedicated init logger.
    init_cli.logger.handlers.clear()
    if hasattr(init_cli.logger, init_cli._VERBOSE_HANDLER_ATTR):
        delattr(init_cli.logger, init_cli._VERBOSE_HANDLER_ATTR)

    init_cli._enable_verbose_logging()
    init_cli._enable_verbose_logging()
    init_cli._enable_verbose_logging()

    assert len(init_cli.logger.handlers) == 1


def test_init_copilot_requires_global(monkeypatch) -> None:
    init_cli, fake_main = _load_init_module(monkeypatch)
    runner = CliRunner()
    monkeypatch.setattr(init_cli, "_ensure_runtime_manifest", lambda **kwargs: "init-local-test")

    result = runner.invoke(fake_main, ["init", "copilot"])

    assert result.exit_code != 0
    assert "requires -g" in result.output


def test_init_claude_local_writes_settings_and_installs_marketplace(
    monkeypatch, tmp_path: Path
) -> None:
    init_cli, fake_main = _load_init_module(monkeypatch)
    runner = CliRunner()
    monkeypatch.chdir(tmp_path)
    marketplace_calls: list[str] = []
    monkeypatch.setattr(init_cli, "_ensure_runtime_manifest", lambda **kwargs: "init-local-demo")
    monkeypatch.setattr(
        init_cli,
        "_install_claude_marketplace",
        lambda scope: marketplace_calls.append(scope),
    )

    result = runner.invoke(fake_main, ["init", "claude"])

    assert result.exit_code == 0, result.output
    settings_path = tmp_path / ".claude" / "settings.local.json"
    payload = json.loads(settings_path.read_text(encoding="utf-8"))
    assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
    assert marketplace_calls == ["local"]
    assert any(
        "--profile init-local-demo" in hook["command"] and "init hook ensure" in hook["command"]
        for entry in payload["hooks"]["SessionStart"]
        for hook in entry["hooks"]
    )


def test_init_codex_merges_feature_flag_into_existing_table(monkeypatch, tmp_path: Path) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    monkeypatch.chdir(tmp_path)
    config_path = tmp_path / ".codex" / "config.toml"
    config_path.parent.mkdir(parents=True)
    config_path.write_text("[features]\nshell_tool = true\n", encoding="utf-8")

    init_cli._init_codex(global_scope=False, profile="init-local-demo", port=9000)

    content = config_path.read_text(encoding="utf-8")
    assert 'base_url = "http://127.0.0.1:9000/v1"' in content
    assert content.count("[features]") == 1
    assert "codex_hooks = true" in content
    assert 'env_key = "OPENAI_API_KEY"' not in content
    hooks = json.loads((tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8"))
    assert "--profile init-local-demo" in hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"]
    assert "init hook ensure" in hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"]


def test_init_claude_uses_custom_port(monkeypatch, tmp_path: Path) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    monkeypatch.chdir(tmp_path)
    monkeypatch.setattr(init_cli, "_install_claude_marketplace", lambda scope: None)

    init_cli._init_claude(global_scope=False, profile="init-local-demo", port=9011)

    payload = json.loads((tmp_path / ".claude" / "settings.local.json").read_text(encoding="utf-8"))
    assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9011"


def test_init_copilot_global_writes_hooks_and_env(monkeypatch, tmp_path: Path) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    captured_env: dict[str, str] = {}
    monkeypatch.setattr(init_cli, "_copilot_config_path", lambda: tmp_path / "copilot-config.json")
    monkeypatch.setattr(init_cli, "_apply_user_env", lambda values: captured_env.update(values))
    monkeypatch.setattr(init_cli, "_install_copilot_marketplace", lambda: None)

    init_cli._init_copilot(global_scope=True, profile="init-user", port=9005, backend="openai")

    payload = json.loads((tmp_path / "copilot-config.json").read_text(encoding="utf-8"))
    assert "SessionStart" in payload["hooks"]
    assert "PreToolUse" in payload["hooks"]
    assert "--profile init-user" in payload["hooks"]["SessionStart"][0]["command"]
    assert captured_env == {
        "COPILOT_PROVIDER_TYPE": "openai",
        "COPILOT_PROVIDER_BASE_URL": "http://127.0.0.1:9005/v1",
        "COPILOT_PROVIDER_WIRE_API": "completions",
    }


def test_init_hook_ensure_prefers_local_profile(monkeypatch) -> None:
    init_cli, fake_main = _load_init_module(monkeypatch)
    ensured: list[str] = []

    def fake_load(profile: str):
        return object() if profile == "init-repo-12345678" else None

    monkeypatch.setattr(init_cli, "_local_profile", lambda cwd=None: "init-repo-12345678")
    monkeypatch.setattr(init_cli, "load_manifest", fake_load)
    monkeypatch.setattr(
        init_cli, "_ensure_profile_running", lambda profile: ensured.append(profile)
    )

    runner = CliRunner()
    result = runner.invoke(fake_main, ["init", "hook", "ensure"])

    assert result.exit_code == 0, result.output
    assert ensured == ["init-repo-12345678"]


def test_init_openclaw_requires_global(monkeypatch) -> None:
    _, fake_main = _load_init_module(monkeypatch)
    runner = CliRunner()

    result = runner.invoke(fake_main, ["init", "openclaw"])

    assert result.exit_code != 0
    assert "requires -g" in result.output


def test_init_openclaw_delegates_to_wrap(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    calls: list[list[str]] = []

    class _Result:
        returncode = 0

    monkeypatch.setattr(init_cli, "resolve_headroom_command", lambda: ["headroom"])
    monkeypatch.setattr(
        init_cli.subprocess,
        "run",
        lambda cmd: calls.append(cmd) or _Result(),
    )

    init_cli._init_openclaw(global_scope=True, port=9999)

    assert calls == [["headroom", "wrap", "openclaw", "--proxy-port", "9999"]]


def test_detect_init_targets_respects_scope(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    monkeypatch.setattr(
        init_cli.shutil,
        "which",
        lambda name: name if name in {"claude", "copilot", "codex", "openclaw"} else None,
    )

    assert init_cli.detect_init_targets(False) == ["claude", "codex"]
    assert init_cli.detect_init_targets(True) == ["claude", "copilot", "codex", "openclaw"]


def test_marketplace_source_prefers_env_override(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    monkeypatch.setenv("HEADROOM_MARKETPLACE_SOURCE", "custom/source")

    assert init_cli._marketplace_source() == "custom/source"


def test_run_checked_treats_existing_install_as_success(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)

    class _Result:
        returncode = 1
        stderr = "plugin already exists"
        stdout = ""

    monkeypatch.setattr(init_cli.subprocess, "run", lambda *args, **kwargs: _Result())

    init_cli._run_checked(["claude", "plugin", "install"], action="claude plugin install")


def test_command_string_and_matcher_on_windows(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="nt"))
    monkeypatch.setattr(init_cli.subprocess, "list2cmdline", lambda parts: "joined-command")

    assert init_cli._command_string(["headroom", "init"]) == "joined-command"
    assert init_cli._powershell_matcher() == "Bash|PowerShell"


def test_json_file_handles_missing_empty_and_non_mapping(monkeypatch, tmp_path: Path) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    missing = tmp_path / "missing.json"
    empty = tmp_path / "empty.json"
    array_payload = tmp_path / "payload.json"
    empty.write_text("   \n", encoding="utf-8")
    array_payload.write_text('["value"]\n', encoding="utf-8")

    assert init_cli._json_file(missing) == {}
    assert init_cli._json_file(empty) == {}
    assert init_cli._json_file(array_payload) == {}


def test_ensure_claude_hooks_rewrites_existing_entries(monkeypatch, tmp_path: Path) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    settings_path = tmp_path / "settings.json"
    settings_path.write_text(
        json.dumps(
            {
                "env": {"KEEP": "1"},
                "hooks": {
                    "SessionStart": [
                        "not-a-dict",
                        {"hooks": "not-a-list"},
                        {
                            "matcher": "startup|resume",
                            "hooks": [{"type": "command", "command": "echo keep-me"}],
                        },
                        {
                            "matcher": "startup|resume",
                            "hooks": [
                                {
                                    "type": "command",
                                    "command": "headroom init hook ensure --marker headroom-init-claude",
                                }
                            ],
                        },
                    ]
                },
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr(init_cli, "_hook_command", lambda *parts: "headroom init hook ensure")

    init_cli._ensure_claude_hooks(settings_path, "init-local-demo", 9001)

    payload = json.loads(settings_path.read_text(encoding="utf-8"))
    assert payload["env"] == {"KEEP": "1", "ANTHROPIC_BASE_URL": "http://127.0.0.1:9001"}
    session_entries = payload["hooks"]["SessionStart"]
    assert session_entries[0] == "not-a-dict"
    assert session_entries[1] == {"hooks": "not-a-list"}
    assert session_entries[2]["hooks"][0]["command"] == "echo keep-me"
    assert session_entries[-1]["hooks"][0]["command"].endswith("--marker headroom-init-claude")


def test_ensure_copilot_hooks_replaces_existing_marker(monkeypatch, tmp_path: Path) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    config_path = tmp_path / "copilot.json"
    config_path.write_text(
        json.dumps(
            {
                "hooks": {
                    "SessionStart": [
                        {"type": "command", "command": "echo keep"},
                        {
                            "type": "command",
                            "command": "headroom init hook ensure --marker headroom-init-copilot",
                        },
                    ]
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr(init_cli, "_hook_command", lambda *parts: "headroom init hook ensure")

    init_cli._ensure_copilot_hooks(config_path, "init-user")

    payload = json.loads(config_path.read_text(encoding="utf-8"))
    commands = [entry["command"] for entry in payload["hooks"]["SessionStart"]]
    assert commands == ["echo keep", "headroom init hook ensure --marker headroom-init-copilot"]


def test_replace_marker_block_replaces_existing_block(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    content = "before\n# start\nold\n# end\nafter\n"

    replaced = init_cli._replace_marker_block(content, "# start", "# end", "# start\nnew\n# end")

    assert replaced == "before\n\nafter\n\n# start\nnew\n# end\n"


def test_ensure_codex_provider_replaces_existing_marker(monkeypatch, tmp_path: Path) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    path = tmp_path / "config.toml"
    path.write_text(
        f"prefix\n{init_cli._CODEX_PROVIDER_MARKER_START}\nold = true\n{init_cli._CODEX_PROVIDER_MARKER_END}\n",
        encoding="utf-8",
    )

    init_cli._ensure_codex_provider(path, 9100)

    content = path.read_text(encoding="utf-8")
    assert content.count(init_cli._CODEX_PROVIDER_MARKER_START) == 1
    assert 'base_url = "http://127.0.0.1:9100/v1"' in content
    assert "old = true" not in content
    assert 'env_key = "OPENAI_API_KEY"' not in content


def test_ensure_codex_feature_flag_replaces_existing_marker(monkeypatch, tmp_path: Path) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    path = tmp_path / "config.toml"
    path.write_text(
        f"[features]\n{init_cli._CODEX_FEATURE_MARKER_START}\ncodex_hooks = false\n{init_cli._CODEX_FEATURE_MARKER_END}\n",
        encoding="utf-8",
    )

    init_cli._ensure_codex_feature_flag(path)

    content = path.read_text(encoding="utf-8")
    assert content.count(init_cli._CODEX_FEATURE_MARKER_START) == 1
    assert "codex_hooks = true" in content


def test_ensure_codex_feature_flag_skips_duplicate_existing_setting(
    monkeypatch, tmp_path: Path
) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    path = tmp_path / "config.toml"
    path.write_text("[features]\ncodex_hooks = true\nshell_tool = true\n", encoding="utf-8")

    init_cli._ensure_codex_feature_flag(path)

    content = path.read_text(encoding="utf-8")
    assert content.count("codex_hooks = true") == 1
    assert init_cli._CODEX_FEATURE_MARKER_START not in content


def test_ensure_codex_feature_flag_creates_features_section_when_missing(
    monkeypatch, tmp_path: Path
) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    path = tmp_path / "config.toml"
    path.write_text('model = "gpt-5"\n', encoding="utf-8")

    init_cli._ensure_codex_feature_flag(path)

    content = path.read_text(encoding="utf-8")
    assert "[features]" in content
    assert "codex_hooks = true" in content


def test_manifest_changed_detects_differences(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    existing = SimpleNamespace(
        port=8787,
        backend="anthropic",
        anyllm_provider=None,
        region=None,
        memory_enabled=False,
    )

    assert not init_cli._manifest_changed(
        existing,
        port=8787,
        backend="anthropic",
        anyllm_provider=None,
        region=None,
        memory=False,
    )
    assert init_cli._manifest_changed(
        existing,
        port=9000,
        backend="anthropic",
        anyllm_provider=None,
        region=None,
        memory=False,
    )


def test_ensure_runtime_manifest_merges_targets_and_stops_changed_runtime(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    existing = SimpleNamespace(
        targets=["claude"],
        mutations=["mutation"],
        port=8787,
        backend="anthropic",
        anyllm_provider=None,
        region=None,
        memory_enabled=False,
    )
    saved: list[object] = []
    stopped: list[object] = []
    built = SimpleNamespace(supervisor_kind="", artifacts=[], mutations=[], targets=[])

    monkeypatch.setattr(init_cli, "_runtime_profile", lambda global_scope, cwd=None: "init-user")
    monkeypatch.setattr(init_cli, "load_manifest", lambda profile: existing)
    monkeypatch.setattr(
        init_cli,
        "build_manifest",
        lambda **kwargs: built.__dict__.update(kwargs) or built,
    )
    monkeypatch.setattr(init_cli, "save_manifest", lambda manifest: saved.append(manifest))
    monkeypatch.setattr(init_cli, "stop_runtime", lambda manifest: stopped.append(manifest))

    profile = init_cli._ensure_runtime_manifest(
        global_scope=True,
        targets=["codex"],
        port=9001,
        backend="anthropic",
        anyllm_provider=None,
        region=None,
        memory=False,
    )

    assert profile == "init-user"
    assert stopped == [existing]
    assert saved == [built]
    assert built.targets == ["claude", "codex"]
    assert built.mutations == ["mutation"]
    assert built.supervisor_kind == init_cli.SupervisorKind.NONE.value
    assert built.artifacts == []


def test_ensure_runtime_manifest_ignores_stop_runtime_errors(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    existing = SimpleNamespace(
        targets=[],
        mutations=[],
        port=8787,
        backend="anthropic",
        anyllm_provider=None,
        region=None,
        memory_enabled=False,
    )
    saved: list[object] = []
    built = SimpleNamespace(supervisor_kind="", artifacts=[], mutations=[], targets=[])

    monkeypatch.setattr(init_cli, "_runtime_profile", lambda global_scope, cwd=None: "init-user")
    monkeypatch.setattr(init_cli, "load_manifest", lambda profile: existing)
    monkeypatch.setattr(
        init_cli,
        "build_manifest",
        lambda **kwargs: built.__dict__.update(kwargs) or built,
    )
    monkeypatch.setattr(init_cli, "save_manifest", lambda manifest: saved.append(manifest))
    monkeypatch.setattr(
        init_cli, "stop_runtime", lambda manifest: (_ for _ in ()).throw(RuntimeError("boom"))
    )

    init_cli._ensure_runtime_manifest(
        global_scope=True,
        targets=["claude"],
        port=9001,
        backend="anthropic",
        anyllm_provider=None,
        region=None,
        memory=False,
    )

    assert saved == [built]


def test_apply_user_env_routes_by_platform(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    manifest = SimpleNamespace(base_env={"OLD": "1"}, tool_envs={})
    windows_calls: list[object] = []
    unix_calls: list[object] = []
    monkeypatch.setattr(init_cli, "_env_manifest", lambda values: manifest)
    monkeypatch.setattr(
        init_cli, "_apply_windows_env_scope", lambda value: windows_calls.append(value)
    )
    monkeypatch.setattr(init_cli, "_apply_unix_env_scope", lambda value: unix_calls.append(value))

    monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="nt"))
    init_cli._apply_user_env({"COPILOT_PROVIDER_TYPE": "openai"})
    monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="posix"))
    init_cli._apply_user_env({"COPILOT_PROVIDER_TYPE": "anthropic"})

    assert manifest.base_env == {}
    assert manifest.tool_envs == {"copilot": {"COPILOT_PROVIDER_TYPE": "anthropic"}}
    assert windows_calls == [manifest]
    assert unix_calls == [manifest]


def test_resolve_copilot_env_supports_anthropic(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)

    assert init_cli._resolve_copilot_env(9010, "anthropic") == {
        "COPILOT_PROVIDER_TYPE": "anthropic",
        "COPILOT_PROVIDER_BASE_URL": "http://127.0.0.1:9010",
    }


def test_marketplace_source_prefers_repo_checkout(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    monkeypatch.delenv("HEADROOM_MARKETPLACE_SOURCE", raising=False)

    assert init_cli._marketplace_source() == str(Path(init_cli.__file__).resolve().parents[2])


def test_run_checked_raises_on_failure(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)

    class _Result:
        returncode = 2
        stderr = "bad stderr"
        stdout = "bad stdout"

    monkeypatch.setattr(init_cli.subprocess, "run", lambda *args, **kwargs: _Result())

    with pytest.raises(
        click.ClickException, match="claude plugin install failed: bad stderr\nbad stdout"
    ):
        init_cli._run_checked(["claude", "plugin", "install"], action="claude plugin install")


def test_install_claude_marketplace_errors_without_binary(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    monkeypatch.setattr(init_cli.shutil, "which", lambda name: None)

    with pytest.raises(click.ClickException, match="'claude' not found"):
        init_cli._install_claude_marketplace("local")


def test_install_claude_marketplace_runs_expected_commands(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    calls: list[tuple[list[str], str]] = []
    monkeypatch.setattr(init_cli.shutil, "which", lambda name: "claude")
    monkeypatch.setattr(init_cli, "_marketplace_source", lambda: "repo/source")
    monkeypatch.setattr(
        init_cli, "_run_checked", lambda command, action: calls.append((command, action))
    )

    init_cli._install_claude_marketplace("user")

    assert calls == [
        (["claude", "plugin", "marketplace", "add", "repo/source"], "claude marketplace add"),
        (
            ["claude", "plugin", "install", "headroom@headroom-marketplace", "--scope", "user"],
            "claude plugin install",
        ),
    ]


def test_install_copilot_marketplace_handles_missing_binary(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    monkeypatch.setattr(init_cli.shutil, "which", lambda name: None)

    with pytest.raises(click.ClickException, match="'copilot' not found"):
        init_cli._install_copilot_marketplace()


def test_install_copilot_marketplace_runs_expected_commands(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    calls: list[tuple[list[str], str]] = []
    monkeypatch.setattr(init_cli.shutil, "which", lambda name: "copilot")
    monkeypatch.setattr(init_cli, "_marketplace_source", lambda: "repo/source")
    monkeypatch.setattr(
        init_cli, "_run_checked", lambda command, action: calls.append((command, action))
    )

    init_cli._install_copilot_marketplace()

    assert calls == [
        (["copilot", "plugin", "marketplace", "add", "repo/source"], "copilot marketplace add"),
        (
            ["copilot", "plugin", "install", "headroom@headroom-marketplace"],
            "copilot plugin install",
        ),
    ]


def test_ensure_profile_running_covers_runtime_modes(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    docker_manifest = SimpleNamespace(
        preset=init_cli.InstallPreset.PERSISTENT_DOCKER.value,
        supervisor_kind=init_cli.SupervisorKind.NONE.value,
        profile="docker-profile",
    )
    service_manifest = SimpleNamespace(
        preset=init_cli.InstallPreset.PERSISTENT_TASK.value,
        supervisor_kind=init_cli.SupervisorKind.SERVICE.value,
        profile="service-profile",
    )
    task_manifest = SimpleNamespace(
        preset=init_cli.InstallPreset.PERSISTENT_TASK.value,
        supervisor_kind=init_cli.SupervisorKind.NONE.value,
        profile="task-profile",
    )
    manifests = {
        "docker-profile": docker_manifest,
        "service-profile": service_manifest,
        "task-profile": task_manifest,
    }
    docker_calls: list[object] = []
    service_calls: list[object] = []
    detached_calls: list[str] = []
    wait_calls: list[tuple[str, int]] = []

    monkeypatch.setattr(init_cli, "load_manifest", lambda profile: manifests.get(profile))

    def fake_wait_ready(manifest, timeout_seconds: int) -> bool:
        wait_calls.append((manifest.profile, timeout_seconds))
        return False

    monkeypatch.setattr(init_cli, "wait_ready", fake_wait_ready)
    monkeypatch.setattr(
        init_cli, "start_persistent_docker", lambda manifest: docker_calls.append(manifest)
    )
    monkeypatch.setattr(
        init_cli, "start_supervisor", lambda manifest: service_calls.append(manifest)
    )
    monkeypatch.setattr(
        init_cli,
        "start_detached_agent",
        lambda profile: detached_calls.append(profile),
    )

    init_cli._ensure_profile_running("missing")
    init_cli._ensure_profile_running("docker-profile")
    init_cli._ensure_profile_running("service-profile")
    init_cli._ensure_profile_running("task-profile")

    assert docker_calls == [docker_manifest]
    assert service_calls == [service_manifest]
    assert detached_calls == ["task-profile"]
    assert ("docker-profile", 1) in wait_calls
    assert ("docker-profile", 45) in wait_calls


def test_ensure_profile_running_returns_when_ready_or_on_exception(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    manifest = SimpleNamespace(
        preset=init_cli.InstallPreset.PERSISTENT_TASK.value,
        supervisor_kind=init_cli.SupervisorKind.NONE.value,
        profile="task-profile",
    )
    detached_calls: list[str] = []
    monkeypatch.setattr(init_cli, "load_manifest", lambda profile: manifest)
    monkeypatch.setattr(init_cli, "wait_ready", lambda manifest, timeout_seconds: True)
    monkeypatch.setattr(
        init_cli,
        "start_detached_agent",
        lambda profile: detached_calls.append(profile),
    )

    init_cli._ensure_profile_running("task-profile")
    assert detached_calls == []

    monkeypatch.setattr(init_cli, "wait_ready", lambda manifest, timeout_seconds: False)
    monkeypatch.setattr(
        init_cli,
        "start_detached_agent",
        lambda profile: (_ for _ in ()).throw(RuntimeError("boom")),
    )
    init_cli._ensure_profile_running("task-profile")


def test_init_codex_windows_warns_about_upstream_hook_limitation(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    messages: list[str] = []
    monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="nt"))
    monkeypatch.setattr(init_cli, "_codex_scope_path", lambda global_scope: Path("config.toml"))
    monkeypatch.setattr(init_cli, "_codex_hooks_path", lambda global_scope: Path("hooks.json"))
    monkeypatch.setattr(init_cli, "_ensure_codex_provider", lambda path, port: None)
    monkeypatch.setattr(init_cli, "_ensure_codex_feature_flag", lambda path: None)
    monkeypatch.setattr(init_cli, "_ensure_codex_hooks", lambda path, profile: None)
    monkeypatch.setattr(init_cli.click, "echo", lambda message: messages.append(message))

    init_cli._init_codex(global_scope=True, profile="init-user", port=9000)

    assert any("disabled upstream on Windows" in message for message in messages)


def test_init_openclaw_propagates_nonzero_exit(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)

    class _Result:
        returncode = 9

    monkeypatch.setattr(init_cli, "resolve_headroom_command", lambda: ["headroom"])
    monkeypatch.setattr(init_cli.subprocess, "run", lambda command: _Result())

    with pytest.raises(SystemExit) as exc:
        init_cli._init_openclaw(global_scope=True, port=9999)

    assert exc.value.code == 9


def test_run_init_targets_dispatches_supported_targets(monkeypatch) -> None:
    init_cli, _ = _load_init_module(monkeypatch)
    calls: list[tuple[str, tuple[object, ...]]] = []
    monkeypatch.setattr(init_cli, "_ensure_runtime_manifest", lambda **kwargs: "init-profile")
    monkeypatch.setattr(
        init_cli,
        "_init_claude",
        lambda **kwargs: calls.append(
            ("claude", (kwargs["global_scope"], kwargs["profile"], kwargs["port"]))
        ),
    )
    monkeypatch.setattr(
        init_cli,
        "_init_copilot",
        lambda **kwargs: calls.append(
            ("copilot", (kwargs["global_scope"], kwargs["profile"], kwargs["port"]))
        ),
    )
    monkeypatch.setattr(
        init_cli,
        "_init_codex",
        lambda **kwargs: calls.append(
            ("codex", (kwargs["global_scope"], kwargs["profile"], kwargs["port"]))
        ),
    )
    monkeypatch.setattr(
        init_cli,
        "_init_openclaw",
        lambda **kwargs: calls.append(("openclaw", (kwargs["global_scope"], kwargs["port"]))),
    )

    init_cli._run_init_targets(
        targets=["claude", "copilot", "codex", "openclaw"],
        global_scope=True,
        port=9000,
        backend="openai",
        anyllm_provider="provider",
        region="us-east-1",
        memory=True,
    )

    assert calls == [
        ("claude", (True, "init-profile", 9000)),
        ("copilot", (True, "init-profile", 9000)),
        ("codex", (True, "init-profile", 9000)),
        ("openclaw", (True, 9000)),
    ]


def test_init_subcommand_uses_group_options(monkeypatch) -> None:
    init_cli, fake_main = _load_init_module(monkeypatch)
    runner = CliRunner()
    captured: dict[str, object] = {}
    monkeypatch.setattr(init_cli, "_run_init_targets", lambda **kwargs: captured.update(kwargs))

    result = runner.invoke(
        fake_main,
        ["init", "-g", "--port", "9007", "--backend", "openai", "--memory", "claude"],
    )

    assert result.exit_code == 0, result.output
    assert captured == {
        "targets": ["claude"],
        "global_scope": True,
        "port": 9007,
        "backend": "openai",
        "anyllm_provider": None,
        "region": None,
        "memory": True,
    }


def test_init_hook_ensure_prefers_global_when_local_missing(monkeypatch) -> None:
    init_cli, fake_main = _load_init_module(monkeypatch)
    ensured: list[str] = []
    monkeypatch.setattr(init_cli, "_local_profile", lambda cwd=None: "init-repo-12345678")
    monkeypatch.setattr(
        init_cli,
        "load_manifest",
        lambda profile: object() if profile == init_cli._GLOBAL_PROFILE else None,
    )
    monkeypatch.setattr(
        init_cli, "_ensure_profile_running", lambda profile: ensured.append(profile)
    )

    runner = CliRunner()
    result = runner.invoke(fake_main, ["init", "hook", "ensure"])

    assert result.exit_code == 0, result.output
    assert ensured == [init_cli._GLOBAL_PROFILE]


def test_init_hook_ensure_uses_explicit_profile(monkeypatch) -> None:
    init_cli, fake_main = _load_init_module(monkeypatch)
    ensured: list[str] = []
    monkeypatch.setattr(
        init_cli, "_ensure_profile_running", lambda profile: ensured.append(profile)
    )

    runner = CliRunner()
    result = runner.invoke(fake_main, ["init", "hook", "ensure", "--profile", "init-explicit"])

    assert result.exit_code == 0, result.output
    assert ensured == ["init-explicit"]


# ---------------------------------------------------------------------------
# Bug 3 (#406): _ensure_codex_provider must inject openai_base_url
# ---------------------------------------------------------------------------


def test_init_codex_writes_openai_base_url(monkeypatch, tmp_path: Path) -> None:
    """_ensure_codex_provider must write openai_base_url at the top level so that
    subscription (ChatGPT plan) users are routed through headroom even when the
    init entry point is used instead of wrap."""
    init_cli, _ = _load_init_module(monkeypatch)
    path = tmp_path / ".codex" / "config.toml"
    path.parent.mkdir(parents=True, exist_ok=True)

    init_cli._ensure_codex_provider(path, 8787)

    content = path.read_text(encoding="utf-8")
    assert 'openai_base_url = "http://127.0.0.1:8787/v1"' in content, (
        f"openai_base_url missing from init codex config:\n{content}"
    )
    # Must NOT appear inside a [section] block.
    lines = content.splitlines()
    in_section = False
    for line in lines:
        stripped = line.strip()
        if stripped.startswith("["):
            in_section = True
        if in_section and stripped.startswith("openai_base_url"):
            raise AssertionError(
                f"openai_base_url appeared inside a section block in init output:\n{content}"
            )
    # Bug 3 regression guard.
    assert "requires_openai_auth" not in content, (
        f"requires_openai_auth must not appear in init codex config:\n{content}"
    )


def test_init_codex_strip_removes_openai_base_url(monkeypatch, tmp_path: Path) -> None:
    """_strip_codex_init_block must remove both the managed block and any orphaned
    openai_base_url lines left by a crashed or partial init."""
    init_cli, _ = _load_init_module(monkeypatch)

    # Normal install-then-strip cycle.
    path = tmp_path / "config.toml"
    path.write_text('model = "gpt-4o"\n', encoding="utf-8")
    init_cli._ensure_codex_provider(path, 8787)
    assert 'openai_base_url = "http://127.0.0.1:8787/v1"' in path.read_text(encoding="utf-8")

    stripped = init_cli._strip_codex_init_block(path.read_text(encoding="utf-8"))
    assert "openai_base_url" not in stripped, (
        f"_strip_codex_init_block must remove openai_base_url after install:\n{stripped}"
    )
    assert "requires_openai_auth" not in stripped
    assert 'model = "gpt-4o"' in stripped

    # Orphan-cleanup path: openai_base_url left outside marker block.
    orphan_content = (
        'model = "gpt-4o"\n'
        'openai_base_url = "http://127.0.0.1:8787/v1"\n'
        'model_provider = "headroom"\n'
    )
    orphan_stripped = init_cli._strip_codex_init_block(orphan_content)
    assert "openai_base_url" not in orphan_stripped, (
        f"_strip_codex_init_block must remove orphaned openai_base_url:\n{orphan_stripped}"
    )
    assert 'model = "gpt-4o"' in orphan_stripped
