"""Tests for the A360 MCP CLI.

Covers:
* 3-tier precedence (CLI > env > default), ``${ENV}`` full-only expansion,
  fail-fast on placeholders / partial refs / missing required values.
* Opt-in PID-tagged file logging + 7-day sweep.
* Parent watchdog semantics (skip when already detached; thread starts
  otherwise; ``on_parent_gone`` callback fires on parent death).
* BrokenPipe detection (leaf, ExceptionGroup all-pipe, mixed group, non-pipe).
* ``main()`` orchestration (config error → exit 1; clean exit → 0;
  BrokenPipe → 0; ``AuthManager.logout()`` invoked on exit).

Network is mocked everywhere: ``A360MCPServer`` is patched to a factory that
returns a ``MagicMock(spec=A360MCPServer)`` whose ``run_stdio`` is an
``AsyncMock`` so ``anyio.run`` does not block on real I/O.
"""

from __future__ import annotations

import logging
import os
import time
from argparse import Namespace
from unittest.mock import AsyncMock, MagicMock

import pytest

from a360_mcp import cli as cli_mod
from a360_mcp.cli import (
    ENV_API_KEY,
    ENV_CONTROL_ROOM_URL,
    ENV_LOG_FILE,
    ENV_TOOL_PACKAGE,
    ENV_USERNAME,
    _is_broken_pipe_group,
    _resolve_log_file,
    _start_parent_watchdog,
    _sweep_stale_logs,
    configure_logging,
    create_config,
    has_env_reference,
    looks_like_unfilled_placeholder,
    main,
    parse_args,
    resolve_env_reference,
)
from a360_mcp.server import A360MCPServer
from a360_mcp.utils.config import ServerConfig

# --------------------------------------------------------------------------- #
# Autouse isolation
# --------------------------------------------------------------------------- #


@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
    """Strip every A360_* env var before each test so argparse defaults are deterministic."""
    for key in list(os.environ):
        if key.startswith("A360_"):
            monkeypatch.delenv(key, raising=False)
    # Reset root logging so configure_logging tests see a clean slate.
    root = logging.getLogger()
    for handler in list(root.handlers):
        root.removeHandler(handler)
    root.setLevel(logging.WARNING)
    yield
    for handler in list(root.handlers):
        root.removeHandler(handler)
    root.setLevel(logging.WARNING)


def _ns(**kwargs) -> Namespace:
    """Build a Namespace with the CLI's full field set, defaults applied."""
    base = dict(
        control_room_url=None,
        username=None,
        api_key=None,
        password=None,
        multiple_login=False,
        allow_writes=False,
        request_timeout=60,
        ca_bundle=None,
        tls_verify=True,
        tool_package=None,
        transport="stdio",
        http_host="127.0.0.1",
        http_port=8080,
        http_path="/mcp",
        debug=False,
    )
    base.update(kwargs)
    return Namespace(**base)


# --------------------------------------------------------------------------- #
# ${ENV} expansion + placeholder detection
# --------------------------------------------------------------------------- #


class TestEnvReference:
    def test_has_env_reference_detects_marker(self):
        assert has_env_reference("${FOO}") is True
        assert has_env_reference("prefix-${FOO}") is True

    def test_has_env_reference_rejects_non_string(self):
        assert has_env_reference(None) is False
        assert has_env_reference(123) is False  # type: ignore[arg-type]

    def test_resolve_full_reference(self, monkeypatch):
        monkeypatch.setenv("MY_VAR", "secret-value")
        assert resolve_env_reference("${MY_VAR}") == "secret-value"

    def test_resolve_unset_returns_none(self):
        assert resolve_env_reference("${DEFINITELY_UNSET_VAR}") is None

    def test_resolve_self_reference_returns_none(self, monkeypatch):
        monkeypatch.setenv("LOOP_VAR", "${LOOP_VAR}")
        assert resolve_env_reference("${LOOP_VAR}") is None

    def test_resolve_empty_passthrough(self):
        assert resolve_env_reference(None) is None
        assert resolve_env_reference("") == ""

    def test_resolve_non_ref_passthrough(self):
        assert resolve_env_reference("literal") == "literal"
        # Partial ref is NOT resolved by this function — callers detect via
        # has_env_reference and reject loudly.
        assert resolve_env_reference("${VAR}_x") == "${VAR}_x"


class TestPlaceholderDetection:
    @pytest.mark.parametrize(
        "value,expected",
        [
            ("REPLACE_WITH_USERNAME", True),
            ("replace_with_key", True),
            ("your_username_here", True),
            ("your_password_please", True),
            ("changeme", True),
            ("real-api-key-12345", False),
            ("", False),
            (None, False),
        ],
    )
    def test_placeholder_detection(self, value, expected):
        assert looks_like_unfilled_placeholder(value) is expected


# --------------------------------------------------------------------------- #
# parse_args
# --------------------------------------------------------------------------- #


class TestParseArgs:
    def test_defaults_from_env(self, monkeypatch):
        monkeypatch.setenv(ENV_CONTROL_ROOM_URL, "https://env.example.com")
        monkeypatch.setenv(ENV_USERNAME, "env_user")
        monkeypatch.setenv(ENV_API_KEY, "env-key")
        args = parse_args([])
        assert args.control_room_url == "https://env.example.com"
        assert args.username == "env_user"
        assert args.api_key == "env-key"
        assert args.transport == "stdio"

    def test_cli_overrides_env(self, monkeypatch):
        monkeypatch.setenv(ENV_CONTROL_ROOM_URL, "https://env.example.com")
        args = parse_args(["--control-room-url", "https://cli.example.com"])
        assert args.control_room_url == "https://cli.example.com"

    def test_unknown_arg_rejected(self):
        with pytest.raises(SystemExit):
            parse_args(["--bogus"])

    def test_version_flag(self, capsys):
        with pytest.raises(SystemExit) as exc:
            parse_args(["--version"])
        assert exc.value.code == 0
        captured = capsys.readouterr()
        assert "a360-mcp" in captured.out

    def test_transport_choice_validated(self):
        with pytest.raises(SystemExit):
            parse_args(["--transport", "bogus"])

    def test_transport_http_accepted(self):
        args = parse_args(["--transport", "streamable-http"])
        assert args.transport == "streamable-http"


# --------------------------------------------------------------------------- #
# create_config
# --------------------------------------------------------------------------- #


class TestCreateConfig:
    def test_happy_path_api_key(self, monkeypatch):
        monkeypatch.setenv("MY_KEY", "secret-key")
        args = _ns(
            control_room_url="https://cr.example.com",
            username="svc",
            api_key="${MY_KEY}",
        )
        config = create_config(args)
        assert isinstance(config, ServerConfig)
        assert config.control_room_url == "https://cr.example.com"
        assert config.auth.username == "svc"
        assert config.auth.api_key_value == "secret-key"
        assert config.auth.password_value is None

    def test_happy_path_password(self):
        args = _ns(
            control_room_url="https://cr.example.com",
            username="svc",
            password="hunter2",
        )
        config = create_config(args)
        assert config.auth.password_value == "hunter2"
        assert config.auth.api_key_value is None

    def test_cli_overrides_env(self, monkeypatch):
        monkeypatch.setenv(ENV_API_KEY, "env-key")
        args = _ns(
            control_room_url="https://cr.example.com",
            username="svc",
            api_key="cli-key",
        )
        config = create_config(args)
        assert config.auth.api_key_value == "cli-key"

    def test_env_fallback_when_cli_absent(self, monkeypatch):
        monkeypatch.setenv(ENV_API_KEY, "env-key")
        args = _ns(
            control_room_url="https://cr.example.com",
            username="svc",
            api_key=None,
        )
        config = create_config(args)
        assert config.auth.api_key_value == "env-key"

    def test_placeholder_rejected(self):
        args = _ns(
            control_room_url="https://cr.example.com",
            username="svc",
            api_key="REPLACE_WITH_KEY",
        )
        with pytest.raises(ValueError, match="placeholder"):
            create_config(args)

    def test_changeme_rejected(self):
        args = _ns(
            control_room_url="https://cr.example.com",
            username="svc",
            password="changeme",
        )
        with pytest.raises(ValueError, match="placeholder"):
            create_config(args)

    def test_partial_env_ref_rejected(self, monkeypatch):
        monkeypatch.setenv("VAULT", "secret")
        args = _ns(
            control_room_url="https://cr.example.com",
            username="svc",
            api_key="${VAULT}_prod",  # partial — should fail loudly
        )
        with pytest.raises(ValueError, match="Partial"):
            create_config(args)

    def test_resolved_placeholder_rejected(self, monkeypatch):
        monkeypatch.setenv("MY_KEY", "REPLACE_WITH_REAL_KEY")
        args = _ns(
            control_room_url="https://cr.example.com",
            username="svc",
            api_key="${MY_KEY}",
        )
        with pytest.raises(ValueError, match="placeholder"):
            create_config(args)

    def test_unset_env_ref_returns_none(self):
        args = _ns(
            control_room_url="https://cr.example.com",
            username="svc",
            api_key="${DEFINITELY_UNSET}",
        )
        with pytest.raises(ValueError, match="one of api_key or password is required"):
            create_config(args)

    def test_missing_url_rejected(self):
        args = _ns(username="svc", api_key="key")
        with pytest.raises(ValueError, match="Control Room URL is required"):
            create_config(args)

    def test_missing_username_rejected(self):
        args = _ns(control_room_url="https://cr.example.com", api_key="key")
        with pytest.raises(ValueError, match="Username is required"):
            create_config(args)

    def test_missing_both_secrets_rejected(self):
        args = _ns(
            control_room_url="https://cr.example.com",
            username="svc",
        )
        with pytest.raises(ValueError, match="one of api_key or password is required"):
            create_config(args)

    def test_allow_writes_propagates(self):
        args = _ns(
            control_room_url="https://cr.example.com",
            username="svc",
            api_key="key",
            allow_writes=True,
        )
        config = create_config(args)
        assert config.allow_writes is True

    def test_request_timeout_propagates(self):
        args = _ns(
            control_room_url="https://cr.example.com",
            username="svc",
            api_key="key",
            request_timeout=99,
        )
        config = create_config(args)
        assert config.request_timeout == 99

    def test_negative_timeout_rejected(self):
        args = _ns(
            control_room_url="https://cr.example.com",
            username="svc",
            api_key="key",
            request_timeout=-5,
        )
        with pytest.raises(ValueError, match="positive"):
            create_config(args)

    def test_tls_verify_off_propagates(self):
        args = _ns(
            control_room_url="https://cr.example.com",
            username="svc",
            api_key="key",
            tls_verify=False,
        )
        config = create_config(args)
        assert config.tls_verify is False

    def test_control_room_url_normalized(self):
        args = _ns(
            control_room_url="https://cr.example.com/",  # trailing slash
            username="svc",
            api_key="key",
        )
        config = create_config(args)
        assert config.control_room_url == "https://cr.example.com"


# --------------------------------------------------------------------------- #
# Logging
# --------------------------------------------------------------------------- #


class TestConfigureLogging:
    def test_no_log_file_stderr_only(self):
        configure_logging(force=True)
        root = logging.getLogger()
        # Exactly one handler — the stderr StreamHandler.
        assert len(root.handlers) == 1
        assert isinstance(root.handlers[0], logging.StreamHandler)

    def test_log_file_attaches_rotating_handler(self, monkeypatch, tmp_path):
        log_path = tmp_path / "a360.log"
        monkeypatch.setenv(ENV_LOG_FILE, str(log_path))
        configure_logging(force=True)
        root = logging.getLogger()
        handler_types = {type(h) for h in root.handlers}
        from logging.handlers import RotatingFileHandler

        assert RotatingFileHandler in handler_types
        # File appears only after a record is emitted.
        root.info("hello")
        for h in root.handlers:
            if isinstance(h, RotatingFileHandler):
                h.flush()
        assert log_path.exists()

    def test_log_file_dir_uses_pid_filename(self, monkeypatch, tmp_path):
        monkeypatch.setenv(ENV_CONTROL_ROOM_URL, "https://bot-farm.example.com")
        monkeypatch.setenv(ENV_LOG_FILE, str(tmp_path))
        path = _resolve_log_file(os.environ.get(ENV_LOG_FILE))
        assert path is not None
        assert path.parent == tmp_path
        assert f"pid={os.getpid()}" in str(path) or f".{os.getpid()}." in path.name
        assert "bot-farm.example.com" in path.name

    def test_empty_log_file_env_returns_none(self, monkeypatch):
        monkeypatch.setenv(ENV_LOG_FILE, "")
        assert _resolve_log_file(os.environ.get(ENV_LOG_FILE)) is None

    def test_log_file_with_trailing_sep_treated_as_dir(self, monkeypatch, tmp_path):
        monkeypatch.setenv(ENV_LOG_FILE, str(tmp_path) + os.sep)
        path = _resolve_log_file(os.environ.get(ENV_LOG_FILE))
        assert path is not None
        assert path.parent == tmp_path

    def test_force_resets_handlers(self, tmp_path):
        configure_logging(force=True)
        before = len(logging.getLogger().handlers)
        configure_logging(force=True)
        after = len(logging.getLogger().handlers)
        assert before == after  # idempotent under force


class TestSweepStaleLogs:
    def test_removes_old_files(self, tmp_path):
        slug = "default"
        # Create one stale, one fresh.
        stale = tmp_path / f"a360-mcp_{slug}.999.log"
        stale.write_text("old")
        fresh = tmp_path / f"a360-mcp_{slug}.888.log"
        fresh.write_text("new")

        old_mtime = time.time() - 30 * 86400  # 30 days ago
        os.utime(stale, (old_mtime, old_mtime))

        removed = _sweep_stale_logs(tmp_path, slug=slug)
        assert removed == 1
        assert not stale.exists()
        assert fresh.exists()

    def test_no_match_kept(self, tmp_path):
        unrelated = tmp_path / "other.log"
        unrelated.write_text("keep me")
        removed = _sweep_stale_logs(tmp_path, slug="default")
        assert removed == 0
        assert unrelated.exists()

    def test_missing_dir_returns_zero(self, tmp_path):
        removed = _sweep_stale_logs(tmp_path / "does-not-exist", slug="default")
        assert removed == 0


# --------------------------------------------------------------------------- #
# BrokenPipe detection
# --------------------------------------------------------------------------- #


class TestBrokenPipeGroup:
    def test_leaf_broken_pipe(self):
        assert _is_broken_pipe_group(BrokenPipeError()) is True

    def test_leaf_connection_reset(self):
        assert _is_broken_pipe_group(ConnectionResetError()) is True

    def test_all_pipe_exception_group(self):
        group = ExceptionGroup("shutdown", [BrokenPipeError(), ConnectionResetError()])
        assert _is_broken_pipe_group(group) is True

    def test_mixed_exception_group(self):
        group = ExceptionGroup("mixed", [BrokenPipeError(), ValueError()])
        assert _is_broken_pipe_group(group) is False

    def test_unrelated_exception(self):
        assert _is_broken_pipe_group(ValueError("nope")) is False


# --------------------------------------------------------------------------- #
# Parent watchdog
# --------------------------------------------------------------------------- #


class TestParentWatchdog:
    def test_skipped_when_already_detached(self, monkeypatch):
        # Pretend we're already reparented to init.
        monkeypatch.setattr(os, "getppid", lambda: 1)
        thread = _start_parent_watchdog(on_parent_gone=lambda: None, poll_seconds=1)
        assert thread is None

    def test_thread_starts_and_fires_callback(self, monkeypatch):
        # Initial capture sees a live parent; subsequent polls see PID 1
        # (reparented to init). The thread must start, detect the change,
        # fire the callback, and return cleanly (os._exit is stubbed).
        ppid_values = [99999, 1]

        def fake_getppid():
            if ppid_values:
                return ppid_values.pop(0)
            return 1

        monkeypatch.setattr(os, "getppid", fake_getppid)
        monkeypatch.setattr(time, "sleep", lambda _s: None)
        exit_calls = []
        monkeypatch.setattr(os, "_exit", lambda code: exit_calls.append(code))

        callback_calls = []
        thread = _start_parent_watchdog(
            on_parent_gone=lambda: callback_calls.append("fired"),
            poll_seconds=1,
        )
        assert thread is not None
        assert thread.daemon is True
        assert thread.name == "parent-watchdog"
        thread.join(timeout=1.0)
        assert callback_calls == ["fired"]
        assert exit_calls == [0]


# --------------------------------------------------------------------------- #
# main() orchestration
# --------------------------------------------------------------------------- #


@pytest.fixture
def _mock_server(monkeypatch):
    """Patch ``A360MCPServer`` and ``anyio.run`` so main() does no real I/O.

    Returns the mock server instance so individual tests can configure
    ``run_stdio`` behaviour (e.g. raise BrokenPipe).
    """
    mock_instance = MagicMock(spec=A360MCPServer)
    mock_instance.run_stdio = AsyncMock()
    mock_instance.auth_manager = MagicMock()
    mock_instance.auth_manager.logout = MagicMock()

    def _fake_init(config, *, package=None, auth_manager=None):
        return mock_instance

    monkeypatch.setattr(cli_mod, "A360MCPServer", _fake_init)

    # anyio.run should invoke the coroutine once and return.
    def _fake_anyio_run(coro_fn, *args, **kwargs):
        import asyncio

        loop = asyncio.new_event_loop()
        try:
            return loop.run_until_complete(coro_fn(*args, **kwargs))
        finally:
            loop.close()

    monkeypatch.setattr(cli_mod.anyio, "run", _fake_anyio_run)

    # Parent watchdog: stubbed so no real thread is left running.
    monkeypatch.setattr(cli_mod, "_start_parent_watchdog", lambda **_kw: None)
    return mock_instance


class TestMain:
    def test_config_error_returns_1(self, monkeypatch, _mock_server):
        # No URL or username anywhere.
        code = main([])
        assert code == 1
        # Server was never constructed.
        _mock_server.auth_manager.logout.assert_not_called()

    def test_clean_exit_returns_0(self, monkeypatch, _mock_server):
        monkeypatch.setenv(ENV_CONTROL_ROOM_URL, "https://cr.example.com")
        monkeypatch.setenv(ENV_USERNAME, "svc")
        monkeypatch.setenv(ENV_API_KEY, "key")
        code = main([])
        assert code == 0
        _mock_server.run_stdio.assert_awaited_once()

    def test_broken_pipe_returns_0(self, monkeypatch, _mock_server):
        _mock_server.run_stdio = AsyncMock(side_effect=BrokenPipeError())
        monkeypatch.setenv(ENV_CONTROL_ROOM_URL, "https://cr.example.com")
        monkeypatch.setenv(ENV_USERNAME, "svc")
        monkeypatch.setenv(ENV_API_KEY, "key")
        code = main([])
        assert code == 0

    def test_connection_reset_returns_0(self, monkeypatch, _mock_server):
        _mock_server.run_stdio = AsyncMock(side_effect=ConnectionResetError())
        monkeypatch.setenv(ENV_CONTROL_ROOM_URL, "https://cr.example.com")
        monkeypatch.setenv(ENV_USERNAME, "svc")
        monkeypatch.setenv(ENV_API_KEY, "key")
        code = main([])
        assert code == 0

    def test_broken_pipe_exception_group_returns_0(self, monkeypatch, _mock_server):
        group = ExceptionGroup("shutdown", [BrokenPipeError()])
        _mock_server.run_stdio = AsyncMock(side_effect=group)
        monkeypatch.setenv(ENV_CONTROL_ROOM_URL, "https://cr.example.com")
        monkeypatch.setenv(ENV_USERNAME, "svc")
        monkeypatch.setenv(ENV_API_KEY, "key")
        code = main([])
        assert code == 0

    def test_other_exception_returns_1(self, monkeypatch, _mock_server):
        _mock_server.run_stdio = AsyncMock(side_effect=RuntimeError("boom"))
        monkeypatch.setenv(ENV_CONTROL_ROOM_URL, "https://cr.example.com")
        monkeypatch.setenv(ENV_USERNAME, "svc")
        monkeypatch.setenv(ENV_API_KEY, "key")
        code = main([])
        assert code == 1

    def test_logout_registered_atexit(self, monkeypatch, _mock_server):
        atexit_calls = []

        def _fake_register(callback, *args, **kwargs):
            atexit_calls.append((callback, args, kwargs))

        monkeypatch.setattr(cli_mod.atexit, "register", _fake_register)
        monkeypatch.setenv(ENV_CONTROL_ROOM_URL, "https://cr.example.com")
        monkeypatch.setenv(ENV_USERNAME, "svc")
        monkeypatch.setenv(ENV_API_KEY, "key")
        main([])
        # Exactly one atexit registration: _shutdown with auth_manager.
        assert len(atexit_calls) == 1
        callback, args, _kwargs = atexit_calls[0]
        assert callback is cli_mod._shutdown
        assert args == (_mock_server.auth_manager,)

    def test_tool_package_propagated_to_env(self, monkeypatch, _mock_server):
        monkeypatch.setenv(ENV_CONTROL_ROOM_URL, "https://cr.example.com")
        monkeypatch.setenv(ENV_USERNAME, "svc")
        monkeypatch.setenv(ENV_API_KEY, "key")
        main(["--tool-package", "diagnose"])
        assert os.environ.get(ENV_TOOL_PACKAGE) == "diagnose"

    def test_http_transport_raises_not_implemented(self, monkeypatch, _mock_server):
        monkeypatch.setenv(ENV_CONTROL_ROOM_URL, "https://cr.example.com")
        monkeypatch.setenv(ENV_USERNAME, "svc")
        monkeypatch.setenv(ENV_API_KEY, "key")
        code = main(["--transport", "streamable-http"])
        # NotImplementedError is caught by the generic Exception branch → exit 1.
        assert code == 1


# --------------------------------------------------------------------------- #
# __main__.py wiring (smoke)
# --------------------------------------------------------------------------- #


def test_main_module_importable():
    import a360_mcp.__main__ as m

    assert hasattr(m, "main")
