"""
Tests for the Brynq Runtime CLI (runtime/cli.py).

Tests are structured in five groups:
    1. Parser — argparse structure, subcommands, help text
    2. Status — status display with mocked backends
    3. Models — model listing with mocked Ollama and keys
    4. Start/Stop — server lifecycle with mocked subprocess
    5. Chat — interactive chat with mocked I/O
    6. Chain — chain execution with mocked runtime API
    7. Login — OAuth flow structure (no real network)

All external calls (Ollama, runtime API, browser, subprocess) are mocked.
No network access, no disk writes outside tmp_path.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch

import pytest

from runtime.cli import (
    _fix_windows_encoding,
    _is_runtime_running,
    _ollama_is_running,
    _ollama_list_models,
    _read_keys,
    _read_tokens,
    _write_tokens,
    build_parser,
    cmd_chain,
    cmd_chat,
    cmd_login,
    cmd_models,
    cmd_start,
    cmd_status,
    cmd_stop,
    main,
)
from runtime.config import RuntimeConfig


# ── Fixtures ──────────────────────────────────────────────────────────


@pytest.fixture()
def config(tmp_path: Path) -> RuntimeConfig:
    """Create a test config with tmp_path as data directory."""
    return RuntimeConfig(
        data_dir=tmp_path,
        local_port=18003,
        ollama_url="http://localhost:99999",  # Unreachable
    )


@pytest.fixture()
def config_with_keys(tmp_path: Path) -> RuntimeConfig:
    """Config with pre-stored API keys."""
    cfg = RuntimeConfig(data_dir=tmp_path, local_port=18003)
    keys_file = tmp_path / "keys.json"
    keys_file.write_text(json.dumps({
        "anthropic": "sk-ant-test1234",
        "openai": "sk-oai-test5678",
    }), "utf-8")
    return cfg


# ═══════════════════════════════════════════════════════════════════════
# 1. Parser
# ═══════════════════════════════════════════════════════════════════════


class TestParser:
    """Tests for the CLI argument parser."""

    @patch("runtime.cli._fix_windows_encoding")
    def test_no_command_returns_zero(self, _m: MagicMock) -> None:
        """No subcommand prints help and returns 0."""
        result = main([])
        assert result == 0

    def test_parser_has_all_subcommands(self) -> None:
        parser = build_parser()
        # Extract subcommand names from the parser
        subparsers_actions = [
            action for action in parser._subparsers._actions
            if hasattr(action, "_parser_class")
        ]
        assert len(subparsers_actions) == 1
        choices = subparsers_actions[0].choices
        assert "start" in choices
        assert "stop" in choices
        assert "status" in choices
        assert "models" in choices
        assert "login" in choices
        assert "chat" in choices
        assert "chain" in choices

    def test_start_no_browser_flag(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["start", "--no-browser"])
        assert args.command == "start"
        assert args.no_browser is True

    def test_login_requires_provider(self) -> None:
        parser = build_parser()
        with pytest.raises(SystemExit):
            parser.parse_args(["login"])

    def test_login_accepts_provider(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["login", "claude"])
        assert args.command == "login"
        assert args.provider == "claude"

    def test_chain_requires_prompt(self) -> None:
        parser = build_parser()
        with pytest.raises(SystemExit):
            parser.parse_args(["chain"])

    def test_chain_accepts_prompt(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["chain", "analyze this with Claude"])
        assert args.command == "chain"
        assert args.prompt == "analyze this with Claude"

    def test_chat_model_flag(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["chat", "--model", "llama3"])
        assert args.command == "chat"
        assert args.model == "llama3"

    def test_chat_model_default(self) -> None:
        parser = build_parser()
        args = parser.parse_args(["chat"])
        assert args.model is None


# ═══════════════════════════════════════════════════════════════════════
# 2. Status
# ═══════════════════════════════════════════════════════════════════════


class TestStatusCommand:
    """Tests for `brynq-runtime status`."""

    @patch("runtime.cli._is_runtime_running", return_value=False)
    @patch("runtime.cli._ollama_is_running", return_value=False)
    def test_all_stopped(
        self, _m1: MagicMock, _m2: MagicMock, config: RuntimeConfig, capsys: pytest.CaptureFixture[str],
    ) -> None:
        parser = build_parser()
        args = parser.parse_args(["status"])
        result = cmd_status(args, config)

        assert result == 0
        output = capsys.readouterr().out
        assert "stopped" in output.lower() or "not running" in output.lower()

    @patch("runtime.cli._is_runtime_running", return_value=True)
    @patch("runtime.cli._ollama_is_running", return_value=True)
    @patch("runtime.cli._ollama_list_models", return_value=["llama3", "mistral"])
    @patch("runtime.cli._runtime_get")
    def test_all_running(
        self,
        mock_get: MagicMock,
        _m1: MagicMock,
        _m2: MagicMock,
        _m3: MagicMock,
        config_with_keys: RuntimeConfig,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        mock_get.return_value = {"cloud": {"connected": True}}

        parser = build_parser()
        args = parser.parse_args(["status"])
        result = cmd_status(args, config_with_keys)

        assert result == 0
        output = capsys.readouterr().out
        assert "running" in output.lower()
        assert "llama3" in output
        assert "anthropic" in output.lower() or "claude" in output.lower()

    @patch("runtime.cli._is_runtime_running", return_value=False)
    @patch("runtime.cli._ollama_is_running", return_value=False)
    def test_shows_version(
        self, _m1: MagicMock, _m2: MagicMock, config: RuntimeConfig, capsys: pytest.CaptureFixture[str],
    ) -> None:
        parser = build_parser()
        args = parser.parse_args(["status"])
        cmd_status(args, config)
        output = capsys.readouterr().out
        assert "0.1.0" in output


# ═══════════════════════════════════════════════════════════════════════
# 3. Models
# ═══════════════════════════════════════════════════════════════════════


class TestModelsCommand:
    """Tests for `brynq-runtime models`."""

    @patch("runtime.cli._ollama_is_running", return_value=False)
    def test_no_ollama_no_keys(
        self, _m: MagicMock, config: RuntimeConfig, capsys: pytest.CaptureFixture[str],
    ) -> None:
        parser = build_parser()
        args = parser.parse_args(["models"])
        result = cmd_models(args, config)

        assert result == 0
        output = capsys.readouterr().out
        assert "not running" in output.lower() or "stopped" in output.lower()

    @patch("runtime.cli._ollama_is_running", return_value=True)
    @patch("runtime.cli._ollama_list_models", return_value=["llama3", "codellama"])
    def test_ollama_models_listed(
        self, _m1: MagicMock, _m2: MagicMock, config: RuntimeConfig, capsys: pytest.CaptureFixture[str],
    ) -> None:
        parser = build_parser()
        args = parser.parse_args(["models"])
        cmd_models(args, config)
        output = capsys.readouterr().out
        assert "llama3" in output
        assert "codellama" in output

    @patch("runtime.cli._ollama_is_running", return_value=False)
    def test_cloud_models_with_keys(
        self, _m: MagicMock, config_with_keys: RuntimeConfig, capsys: pytest.CaptureFixture[str],
    ) -> None:
        parser = build_parser()
        args = parser.parse_args(["models"])
        cmd_models(args, config_with_keys)
        output = capsys.readouterr().out
        # Should show Anthropic and OpenAI models as ready
        assert "Claude" in output or "claude" in output
        assert "GPT" in output or "gpt" in output
        # Gemini should show as no key
        assert "no key" in output.lower()


# ═══════════════════════════════════════════════════════════════════════
# 4. Start / Stop
# ═══════════════════════════════════════════════════════════════════════


class TestStartCommand:
    """Tests for `brynq-runtime start`."""

    @patch("runtime.cli._is_runtime_running", return_value=True)
    @patch("runtime.cli.webbrowser.open")
    def test_already_running(
        self, mock_browser: MagicMock, _m: MagicMock, config: RuntimeConfig,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        parser = build_parser()
        args = parser.parse_args(["start"])
        result = cmd_start(args, config)

        assert result == 0
        output = capsys.readouterr().out
        assert "already running" in output.lower()
        mock_browser.assert_called_once()

    @patch("runtime.cli._is_runtime_running", return_value=True)
    @patch("runtime.cli.webbrowser.open")
    def test_already_running_no_browser(
        self, mock_browser: MagicMock, _m: MagicMock, config: RuntimeConfig,
    ) -> None:
        parser = build_parser()
        args = parser.parse_args(["start", "--no-browser"])
        cmd_start(args, config)
        mock_browser.assert_not_called()

    @patch("runtime.cli._is_runtime_running")
    @patch("runtime.cli._ollama_is_running", return_value=False)
    @patch("runtime.cli.subprocess.Popen")
    @patch("runtime.cli.webbrowser.open")
    def test_start_success(
        self,
        mock_browser: MagicMock,
        mock_popen: MagicMock,
        _m_ollama: MagicMock,
        mock_running: MagicMock,
        config: RuntimeConfig,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        # First call: not running. After start: running.
        mock_running.side_effect = [False, True]
        mock_proc = MagicMock()
        mock_proc.pid = 12345
        mock_popen.return_value = mock_proc

        parser = build_parser()
        args = parser.parse_args(["start"])

        with patch("runtime.cli.time.sleep"):
            result = cmd_start(args, config)

        assert result == 0
        output = capsys.readouterr().out
        assert "running" in output.lower()
        mock_browser.assert_called_once()

        # PID file created
        pid_file = config.data_dir / "runtime.pid"
        assert pid_file.exists()
        assert pid_file.read_text("utf-8") == "12345"


class TestStopCommand:
    """Tests for `brynq-runtime stop`."""

    @patch("runtime.cli._is_runtime_running", return_value=False)
    def test_not_running(
        self, _m: MagicMock, config: RuntimeConfig, capsys: pytest.CaptureFixture[str],
    ) -> None:
        parser = build_parser()
        args = parser.parse_args(["stop"])
        result = cmd_stop(args, config)

        assert result == 0
        output = capsys.readouterr().out
        assert "not running" in output.lower()

    @patch("runtime.cli._is_runtime_running", return_value=False)
    @patch("runtime.cli.os.kill")
    def test_stop_with_pid(
        self, mock_kill: MagicMock, _m: MagicMock, config: RuntimeConfig,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        # Create PID file
        config.data_dir.mkdir(parents=True, exist_ok=True)
        pid_file = config.data_dir / "runtime.pid"
        pid_file.write_text("99999", "utf-8")

        # os.kill(99999, SIGTERM) succeeds, then os.kill(99999, 0) raises (dead)
        mock_kill.side_effect = [None, OSError("No such process")]

        parser = build_parser()
        args = parser.parse_args(["stop"])

        with patch("runtime.cli.time.sleep"):
            result = cmd_stop(args, config)

        assert result == 0
        output = capsys.readouterr().out
        assert "stopping" in output.lower() or "stopped" in output.lower()
        assert not pid_file.exists()


# ═══════════════════════════════════════════════════════════════════════
# 5. Chat
# ═══════════════════════════════════════════════════════════════════════


class TestChatCommand:
    """Tests for `brynq-runtime chat`."""

    @pytest.mark.slow  # reads from stdin, hangs in CI/autopilot
    @patch("shutil.which", return_value=None)
    @patch("runtime.cli._is_runtime_running", return_value=False)
    @patch("runtime.cli._ollama_is_running", return_value=False)
    def test_no_runtime_no_ollama(
        self, _m1: MagicMock, _m2: MagicMock, _m3: MagicMock, config: RuntimeConfig,
    ) -> None:
        parser = build_parser()
        args = parser.parse_args(["chat"])
        result = cmd_chat(args, config)
        assert result == 1

    @patch("runtime.cli._is_runtime_running", return_value=True)
    @patch("runtime.cli._ollama_is_running", return_value=True)
    @patch("runtime.cli._ollama_list_models", return_value=["llama3"])
    @patch("builtins.input", side_effect=["/quit"])
    def test_quit_immediately(
        self, _m_input: MagicMock, _m1: MagicMock, _m2: MagicMock, _m3: MagicMock,
        config: RuntimeConfig,
    ) -> None:
        parser = build_parser()
        args = parser.parse_args(["chat"])
        result = cmd_chat(args, config)
        assert result == 0

    @patch("runtime.cli._is_runtime_running", return_value=True)
    @patch("runtime.cli._ollama_is_running", return_value=True)
    @patch("runtime.cli._ollama_list_models", return_value=["llama3"])
    @pytest.mark.slow  # reads from stdin, hangs in CI/autopilot
    @patch("builtins.input", side_effect=["/models", "/quit"])
    def test_slash_models_command(
        self, _m_input: MagicMock,
        _m1: MagicMock, _m2: MagicMock, _m3: MagicMock,
        config: RuntimeConfig, capsys: pytest.CaptureFixture[str],
    ) -> None:
        parser = build_parser()
        args = parser.parse_args(["chat"])
        cmd_chat(args, config)
        output = capsys.readouterr().out
        assert "llama3" in output.lower() or "Ollama" in output

    @patch("runtime.cli._is_runtime_running", return_value=True)
    @patch("runtime.cli._ollama_is_running", return_value=True)
    @patch("runtime.cli._ollama_list_models", return_value=["llama3", "mistral"])
    @patch("builtins.input", side_effect=["/add mistral", "/quit"])
    def test_switch_model(
        self, _m_input: MagicMock, _m1: MagicMock, _m2: MagicMock, _m3: MagicMock,
        config: RuntimeConfig, capsys: pytest.CaptureFixture[str],
    ) -> None:
        parser = build_parser()
        args = parser.parse_args(["chat"])
        cmd_chat(args, config)
        output = capsys.readouterr().out
        assert "mistral" in output

    @patch("shutil.which", return_value=None)
    @patch("runtime.cli._is_runtime_running", return_value=True)
    @patch("runtime.cli._ollama_is_running", return_value=True)
    @patch("runtime.cli._ollama_list_models", return_value=["llama3"])
    @patch("urllib.request.urlopen")
    @patch("builtins.input", side_effect=["hello world", "/quit"])
    def test_send_message(
        self, _m_input: MagicMock, mock_urlopen: MagicMock,
        _m1: MagicMock, _m2: MagicMock, _m3: MagicMock,
        _m_which: MagicMock,
        config: RuntimeConfig, capsys: pytest.CaptureFixture[str],
    ) -> None:
        # Mock urllib.request.urlopen to return Ollama-format response
        mock_resp = MagicMock()
        mock_resp.read.return_value = json.dumps({
            "message": {"content": "Hello from AI!"},
        }).encode("utf-8")
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_resp

        parser = build_parser()
        args = parser.parse_args(["chat"])
        cmd_chat(args, config)

        output = capsys.readouterr().out
        assert "Hello from AI!" in output

    @patch("runtime.cli._is_runtime_running", return_value=True)
    @patch("runtime.cli._ollama_is_running", return_value=True)
    @patch("runtime.cli._ollama_list_models", return_value=["llama3"])
    @patch("builtins.input", side_effect=EOFError)
    def test_eof_exits_gracefully(
        self, _m_input: MagicMock, _m1: MagicMock, _m2: MagicMock, _m3: MagicMock,
        config: RuntimeConfig,
    ) -> None:
        parser = build_parser()
        args = parser.parse_args(["chat"])
        result = cmd_chat(args, config)
        assert result == 0

    @patch("runtime.cli._is_runtime_running", return_value=True)
    @patch("runtime.cli._ollama_is_running", return_value=True)
    @patch("runtime.cli._ollama_list_models", return_value=["llama3"])
    @patch("builtins.input", side_effect=KeyboardInterrupt)
    def test_ctrl_c_exits_gracefully(
        self, _m_input: MagicMock, _m1: MagicMock, _m2: MagicMock, _m3: MagicMock,
        config: RuntimeConfig,
    ) -> None:
        parser = build_parser()
        args = parser.parse_args(["chat"])
        result = cmd_chat(args, config)
        assert result == 0


# ═══════════════════════════════════════════════════════════════════════
# 6. Chain
# ═══════════════════════════════════════════════════════════════════════


class TestChainCommand:
    """Tests for `brynq-runtime chain`."""

    @patch("runtime.cli._is_runtime_running", return_value=False)
    def test_runtime_not_running(
        self, _m: MagicMock, config: RuntimeConfig, capsys: pytest.CaptureFixture[str],
    ) -> None:
        parser = build_parser()
        args = parser.parse_args(["chain", "test prompt"])
        result = cmd_chain(args, config)

        assert result == 1
        output = capsys.readouterr().out
        assert "not running" in output.lower()

    @patch("runtime.cli._is_runtime_running", return_value=True)
    @patch("runtime.cli._runtime_post")
    def test_chain_success(
        self, mock_post: MagicMock, _m: MagicMock,
        config: RuntimeConfig, capsys: pytest.CaptureFixture[str],
    ) -> None:
        mock_post.return_value = {
            "plan_id": "chain-1",
            "steps": [
                {"step_id": "s1", "content": "Analysis result", "model": "claude", "success": True, "error": None},
                {"step_id": "s2", "content": "Summary", "model": "llama3", "success": True, "error": None},
            ],
            "total_duration_ms": 450,
            "success": True,
        }

        parser = build_parser()
        args = parser.parse_args(["chain", "analyze then summarize"])
        result = cmd_chain(args, config)

        assert result == 0
        output = capsys.readouterr().out
        assert "Analysis result" in output
        assert "Summary" in output
        assert "success" in output.lower()

    @patch("runtime.cli._is_runtime_running", return_value=True)
    @patch("runtime.cli._runtime_post")
    def test_chain_step_failure(
        self, mock_post: MagicMock, _m: MagicMock,
        config: RuntimeConfig, capsys: pytest.CaptureFixture[str],
    ) -> None:
        mock_post.return_value = {
            "plan_id": "chain-2",
            "steps": [
                {"step_id": "s1", "content": "", "model": "claude", "success": False, "error": "API key invalid"},
            ],
            "total_duration_ms": 100,
            "success": False,
        }

        parser = build_parser()
        args = parser.parse_args(["chain", "test"])
        result = cmd_chain(args, config)

        assert result == 1
        output = capsys.readouterr().out
        assert "failed" in output.lower()
        assert "API key invalid" in output


# ═══════════════════════════════════════════════════════════════════════
# 7. Login
# ═══════════════════════════════════════════════════════════════════════


class TestLoginCommand:
    """Tests for `brynq-runtime login`."""

    def test_unknown_provider(self, config: RuntimeConfig, capsys: pytest.CaptureFixture[str]) -> None:
        from runtime.cli import cmd_login

        parser = build_parser()
        args = parser.parse_args(["login", "unknown_provider"])
        result = cmd_login(args, config)

        assert result == 1
        output = capsys.readouterr().out
        assert "error" in output.lower() or "unknown" in output.lower()

    def test_claude_alias_resolves(self, config: RuntimeConfig, capsys: pytest.CaptureFixture[str]) -> None:
        """'claude' should resolve to 'anthropic' provider."""
        from runtime.cli import cmd_login

        parser = build_parser()
        args = parser.parse_args(["login", "claude"])
        # Without BRYNQ_ANTHROPIC_CLIENT_ID set, it should fail with "no client ID"
        result = cmd_login(args, config)

        assert result == 1
        output = capsys.readouterr().out
        assert "client id" in output.lower() or "BRYNQ_ANTHROPIC_CLIENT_ID" in output

    def test_gemini_alias_resolves(self, config: RuntimeConfig, capsys: pytest.CaptureFixture[str]) -> None:
        from runtime.cli import cmd_login

        parser = build_parser()
        args = parser.parse_args(["login", "gemini"])
        result = cmd_login(args, config)

        assert result == 1
        output = capsys.readouterr().out
        assert "client id" in output.lower() or "BRYNQ_GOOGLE_CLIENT_ID" in output


# ═══════════════════════════════════════════════════════════════════════
# 8. Helpers
# ═══════════════════════════════════════════════════════════════════════


class TestHelpers:
    """Tests for internal helper functions."""

    def test_is_runtime_running_unreachable(self, config: RuntimeConfig) -> None:
        assert _is_runtime_running(config) is False

    def test_ollama_is_running_unreachable(self) -> None:
        assert _ollama_is_running("http://localhost:99999") is False

    def test_ollama_list_models_unreachable(self) -> None:
        assert _ollama_list_models("http://localhost:99999") == []

    def test_read_keys_empty(self, tmp_path: Path) -> None:
        assert _read_keys(tmp_path) == {}

    def test_read_keys_with_data(self, tmp_path: Path) -> None:
        (tmp_path / "keys.json").write_text('{"anthropic": "sk-test"}', "utf-8")
        assert _read_keys(tmp_path) == {"anthropic": "sk-test"}

    def test_read_keys_invalid_json(self, tmp_path: Path) -> None:
        (tmp_path / "keys.json").write_text("not json", "utf-8")
        assert _read_keys(tmp_path) == {}

    def test_read_tokens_empty(self, tmp_path: Path) -> None:
        assert _read_tokens(tmp_path) == {}

    def test_write_and_read_tokens(self, tmp_path: Path) -> None:
        tokens = {"anthropic": {"access_token": "at-test", "email": "user@test.com"}}
        _write_tokens(tmp_path, tokens)
        assert _read_tokens(tmp_path) == tokens

    def test_write_tokens_creates_directory(self, tmp_path: Path) -> None:
        nested = tmp_path / "deep" / "nested"
        _write_tokens(nested, {"test": {"token": "abc"}})
        assert (nested / "oauth_tokens.json").exists()


# ═══════════════════════════════════════════════════════════════════════
# 9. Main entry point
# ═══════════════════════════════════════════════════════════════════════


class TestMain:
    """Tests for the main() entry point."""

    @patch("runtime.cli._fix_windows_encoding")
    def test_unknown_command(self, _enc: MagicMock) -> None:
        """Unknown commands fall through to help (exit 0 from argparse or 1 from handler)."""
        # argparse will error on unknown commands
        with pytest.raises(SystemExit):
            main(["nonexistent-command"])

    @patch("runtime.cli._fix_windows_encoding")
    @patch("runtime.cli._is_runtime_running", return_value=False)
    @patch("runtime.cli._ollama_is_running", return_value=False)
    def test_main_status(self, _m1: MagicMock, _m2: MagicMock, _enc: MagicMock) -> None:
        result = main(["status"])
        assert result == 0

    @patch("runtime.cli._fix_windows_encoding")
    @patch("runtime.cli._is_runtime_running", return_value=False)
    @patch("runtime.cli._ollama_is_running", return_value=False)
    def test_main_models(self, _m1: MagicMock, _m2: MagicMock, _enc: MagicMock) -> None:
        result = main(["models"])
        assert result == 0
