from __future__ import annotations

import json
import inspect
import tomllib
from pathlib import Path
from types import SimpleNamespace

import pytest
from packaging.version import Version

from fivemghost_shared_client.registration import cli_registry
from fivemghost_shared_client.registration import skills as registration_skills
from fivemghost_shared_client.registration.skills import get_skill_install_targets
from fivemghost_shared_client.registration.claude_internal import (
    get_claude_internal_config_path,
    remove_claude_internal_config,
    upsert_claude_internal_config_data,
    write_claude_internal_config,
)
from fivemghost_shared_client.registration.codex_internal import (
    remove_codex_internal_config,
    upsert_codex_internal_config_text,
    write_codex_internal_config,
)
from fivemghost_shared_client.registration.gemini_internal import (
    get_gemini_internal_config_path,
    remove_gemini_internal_config,
    upsert_gemini_internal_config_data,
    write_gemini_internal_config,
)


def test_claude_internal_config_path_uses_internal_directory(tmp_path: Path) -> None:
    assert get_claude_internal_config_path(tmp_path) == tmp_path / ".claude-internal" / ".claude.json"


def test_gemini_internal_config_path_uses_shared_gemini_directory(tmp_path: Path) -> None:
    assert get_gemini_internal_config_path(tmp_path) == tmp_path / ".gemini" / "settings.internal.json"


def test_write_claude_internal_config_preserves_existing_data(tmp_path: Path) -> None:
    config_path = tmp_path / ".claude-internal" / ".claude.json"
    config_path.parent.mkdir(parents=True)
    config_path.write_text(json.dumps({"firstStartTime": "2026-04-14T00:00:00Z", "mcpServers": {"yt-mcp": {"type": "stdio"}}}))

    status = write_claude_internal_config(
        server_name="reddit-mcp",
        command="reddit-mcp",
        args=["serve"],
        config_path=config_path,
    )

    data = json.loads(config_path.read_text())
    assert status == "updated"
    assert data["firstStartTime"] == "2026-04-14T00:00:00Z"
    assert data["mcpServers"]["reddit-mcp"]["command"] == "reddit-mcp"
    assert data["mcpServers"]["reddit-mcp"]["args"] == ["serve"]
    assert data["mcpServers"]["reddit-mcp"]["type"] == "stdio"
    assert "yt-mcp" in data["mcpServers"]


def test_write_gemini_internal_config_preserves_existing_data(tmp_path: Path) -> None:
    config_path = tmp_path / ".gemini" / "settings.internal.json"
    config_path.parent.mkdir(parents=True)
    config_path.write_text(json.dumps({"security": {"auth": {"selectedType": "gongfeng"}}, "mcpServers": {"yt-mcp": {"command": "node"}}}))

    status = write_gemini_internal_config(
        server_name="reddit-mcp",
        command="reddit-mcp",
        args=["serve"],
        config_path=config_path,
    )

    data = json.loads(config_path.read_text())
    assert status == "updated"
    assert data["security"]["auth"]["selectedType"] == "gongfeng"
    assert data["mcpServers"]["reddit-mcp"]["command"] == "reddit-mcp"
    assert data["mcpServers"]["reddit-mcp"]["args"] == ["serve"]
    assert "yt-mcp" in data["mcpServers"]


def test_write_codex_internal_config_replaces_legacy_unquoted_table(tmp_path: Path) -> None:
    config_path = tmp_path / ".codex-internal" / "config.toml"
    config_path.parent.mkdir(parents=True)
    config_path.write_text(
        '[mcp_servers.reddit-mcp]\n'
        'command = "python3"\n'
        'args = ["/tmp/legacy-home/.reddit-mcp/launcher.py", "serve"]\n'
    )

    status = write_codex_internal_config(
        server_name="reddit-mcp",
        command="reddit-mcp",
        args=["serve"],
        config_path=config_path,
    )

    text = config_path.read_text()
    data = tomllib.loads(text)
    assert status == "updated"
    assert text.count('[mcp_servers."reddit-mcp"]') == 1
    assert '[mcp_servers.reddit-mcp]' not in text
    assert data["mcp_servers"]["reddit-mcp"]["command"] == "reddit-mcp"
    assert data["mcp_servers"]["reddit-mcp"]["args"] == ["serve"]


def test_remove_codex_internal_config_removes_legacy_unquoted_table(tmp_path: Path) -> None:
    config_path = tmp_path / ".codex-internal" / "config.toml"
    config_path.parent.mkdir(parents=True)
    config_path.write_text(
        '[mcp_servers.reddit-mcp]\n'
        'command = "python3"\n'
        'args = ["/tmp/reddit", "serve"]\n\n'
        '[mcp_servers."yt-mcp"]\n'
        'command = "node"\n'
        'args = ["/tmp/yt", "serve"]\n'
    )

    status = remove_codex_internal_config(server_name="reddit-mcp", config_path=config_path)

    text = config_path.read_text()
    data = tomllib.loads(text)
    assert status == "removed"
    assert "reddit-mcp" not in data["mcp_servers"]
    assert data["mcp_servers"]["yt-mcp"]["command"] == "node"


def test_upsert_codex_internal_config_text_replaces_existing_quoted_entry() -> None:
    text = (
        '[mcp_servers."reddit-mcp"]\n'
        'command = "python3"\n'
        'args = ["/tmp/old", "serve"]\n'
    )

    next_text = upsert_codex_internal_config_text(text, "reddit-mcp", "reddit-mcp", ["serve"])

    data = tomllib.loads(next_text)
    assert data["mcp_servers"]["reddit-mcp"]["args"] == ["serve"]


def test_remove_claude_internal_config_only_removes_target_entry(tmp_path: Path) -> None:
    config_path = tmp_path / ".claude-internal" / ".claude.json"
    config_path.parent.mkdir(parents=True)
    config_path.write_text(
        json.dumps(
            upsert_claude_internal_config_data(
                {"mcpServers": {"yt-mcp": {"type": "stdio", "command": "node", "args": ["/tmp/yt", "serve"], "env": {}}}},
                "reddit-mcp",
                "reddit-mcp",
                ["serve"],
            )
        )
    )

    status = remove_claude_internal_config(server_name="reddit-mcp", config_path=config_path)

    data = json.loads(config_path.read_text())
    assert status == "removed"
    assert "reddit-mcp" not in data["mcpServers"]
    assert "yt-mcp" in data["mcpServers"]


def test_remove_gemini_internal_config_only_removes_target_entry(tmp_path: Path) -> None:
    config_path = tmp_path / ".gemini" / "settings.internal.json"
    config_path.parent.mkdir(parents=True)
    config_path.write_text(
        json.dumps(
            upsert_gemini_internal_config_data(
                {"mcpServers": {"yt-mcp": {"command": "node", "args": ["/tmp/yt", "serve"]}}},
                "reddit-mcp",
                "reddit-mcp",
                ["serve"],
            )
        )
    )

    status = remove_gemini_internal_config(server_name="reddit-mcp", config_path=config_path)

    data = json.loads(config_path.read_text())
    assert status == "removed"
    assert "reddit-mcp" not in data["mcpServers"]
    assert "yt-mcp" in data["mcpServers"]


def test_register_all_writes_internal_client_configs(monkeypatch) -> None:
    seen: list[list[str]] = []

    monkeypatch.setattr(
        cli_registry.shutil,
        "which",
        lambda binary: f"/mock/{binary}" if binary in {"claude", "codex", "gemini"} else None,
    )
    monkeypatch.setattr(
        cli_registry.subprocess,
        "run",
        lambda command, **_kwargs: seen.append(command) or SimpleNamespace(returncode=0, stdout="", stderr=""),
    )
    monkeypatch.setattr(
        cli_registry,
        "write_openclaw_config",
        lambda **_kwargs: (_ for _ in ()).throw(AssertionError("openclaw fallback should not run here")),
    )
    monkeypatch.setattr(cli_registry, "_is_openclaw_likely_installed", lambda _path: False)

    cli_registry.register_all(server_name="reddit-mcp", command="reddit-mcp", args=["serve"])

    assert seen == [
        ["/mock/claude", "mcp", "remove", "-s", "user", "reddit-mcp"],
        ["/mock/claude", "mcp", "add", "-s", "user", "reddit-mcp", "--", "reddit-mcp", "serve"],
        ["/mock/codex", "mcp", "remove", "reddit-mcp"],
        ["/mock/codex", "mcp", "add", "reddit-mcp", "--", "reddit-mcp", "serve"],
        ["/mock/gemini", "mcp", "remove", "-s", "user", "reddit-mcp"],
        ["/mock/gemini", "mcp", "add", "-s", "user", "reddit-mcp", "reddit-mcp", "serve"],
    ]


def test_unregister_all_removes_internal_client_configs(monkeypatch) -> None:
    seen: list[tuple[str, str]] = []

    monkeypatch.setattr(cli_registry.shutil, "which", lambda _bin: None)
    monkeypatch.setattr(cli_registry, "remove_claude_internal_config", lambda **_kwargs: seen.append(("remove", "claude")) or "removed")
    monkeypatch.setattr(cli_registry, "remove_codex_internal_config", lambda **_kwargs: seen.append(("remove", "codex")) or "removed")
    monkeypatch.setattr(cli_registry, "remove_gemini_internal_config", lambda **_kwargs: seen.append(("remove", "gemini")) or "removed")
    monkeypatch.setattr(cli_registry, "remove_openclaw_config", lambda **_kwargs: seen.append(("remove", "openclaw")) or "removed")

    cli_registry.unregister_all(server_name="reddit-mcp")

    assert seen == [
        ("remove", "claude"),
        ("remove", "codex"),
        ("remove", "gemini"),
        ("remove", "openclaw"),
    ]


def test_register_all_fails_when_no_supported_client_detected(monkeypatch) -> None:
    monkeypatch.setattr(cli_registry.shutil, "which", lambda _bin: None)
    monkeypatch.setattr(cli_registry, "_is_openclaw_likely_installed", lambda _path: False)

    with pytest.raises(cli_registry.RegistrationFailure) as excinfo:
        cli_registry.register_all(server_name="reddit-mcp", command="reddit-mcp", args=["serve"])

    assert "No supported AI client detected" in str(excinfo.value)
    assert "reddit-mcp serve" in str(excinfo.value)


def test_register_all_fails_when_detected_client_registration_fails(monkeypatch) -> None:
    monkeypatch.setattr(cli_registry.shutil, "which", lambda binary: f"/mock/{binary}" if binary == "codex" else None)
    monkeypatch.setattr(cli_registry, "_is_openclaw_likely_installed", lambda _path: False)
    monkeypatch.setattr(
        cli_registry.subprocess,
        "run",
        lambda command, **_kwargs: (
            SimpleNamespace(returncode=0, stdout="", stderr="")
            if "remove" in command
            else SimpleNamespace(returncode=1, stdout="", stderr="config write failed")
        ),
    )

    with pytest.raises(cli_registry.RegistrationFailure) as excinfo:
        cli_registry.register_all(server_name="reddit-mcp", command="reddit-mcp", args=["serve"])

    assert "codex" in str(excinfo.value).lower()
    assert "manual_command='reddit-mcp serve'" in str(excinfo.value)
    assert "reddit-mcp serve" in str(excinfo.value)


def test_detected_clients_ignore_self_created_internal_directories(monkeypatch, tmp_path: Path) -> None:
    monkeypatch.setattr(cli_registry.shutil, "which", lambda _bin: None)
    monkeypatch.setattr(cli_registry, "_is_openclaw_likely_installed", lambda _path: False)

    (tmp_path / ".claude-internal" / "skills").mkdir(parents=True)
    (tmp_path / ".codex-internal" / "skills").mkdir(parents=True)
    (tmp_path / ".gemini-internal" / "skills").mkdir(parents=True)

    assert cli_registry._detected_clients() == []


def test_get_skill_install_targets_only_include_available_clients(tmp_path: Path) -> None:
    targets = get_skill_install_targets(
        skill_name="use-reddit-mcp",
        source_dir=tmp_path / "skill-source",
        home_dir=tmp_path,
        available_cli_names=["claude", "codex"],
        include_openclaw=False,
    )

    assert [target["client"] for target in targets] == ["claude", "codex"]


def test_install_skills_logs_are_ascii_safe(monkeypatch, capsys, tmp_path: Path) -> None:
    monkeypatch.setattr(
        registration_skills,
        "get_skill_install_targets",
        lambda **_kwargs: [
            {
                "client": "claude",
                "label": "Claude Code",
                "source_dir": str(tmp_path / "skill-source"),
                "target_dir": str(tmp_path / ".claude" / "skills" / "use-reddit-mcp"),
            }
        ],
    )
    monkeypatch.setattr(
        registration_skills,
        "install_skill_target",
        lambda _target: None,
    )

    registration_skills.install_skills(
        skill_name="use-reddit-mcp",
        source_dir=tmp_path / "skill-source",
        available_cli_names=["claude"],
    )

    out = capsys.readouterr().out
    assert "Installed skill to Claude Code" in out
    assert all(ord(ch) < 128 or ch in "\r\n\t" for ch in out)


def test_registration_contract_change_requires_version_bump() -> None:
    pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
    version_line = next(
        line for line in pyproject.read_text(encoding="utf-8").splitlines() if line.startswith("version = ")
    )
    package_version = Version(version_line.split('"')[1])
    params = inspect.signature(cli_registry.register_all).parameters

    # Why: released wheels must bump when runtime/launcher registration is replaced by command/args registration.
    if "command" in params and "args" in params and "runtime" not in params and "launcher_path" not in params:
        assert package_version > Version("0.0.2")
