"""Tests for the ``mld dev`` command."""

from __future__ import annotations

import argparse
import signal
from pathlib import Path
from unittest.mock import MagicMock, call, patch

import pytest

try:
    import tomllib
except ModuleNotFoundError:  # pragma: no cover - Python < 3.11 fallback
    import tomli as tomllib  # type: ignore[no-redef]

from mld_sdk.dev_command import (
    _build_backend_cmd,
    _build_frontend_cmd,
    _cleanup_dev_proxy_config,
    _find_platform_dir,
    _resolve_platform_dir,
    _run_processes,
    _write_dev_proxy_config,
    cmd_dev,
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

def _make_plugin_project(tmp_path: Path, *, has_frontend: bool = True) -> Path:
    """Create a minimal MLD plugin project directory."""
    project = tmp_path / "mld-plugin-drp"
    project.mkdir()

    pyproject = project / "pyproject.toml"
    pyproject.write_text(
        '[project]\n'
        'name = "mld-plugin-drp"\n'
        'version = "0.2.0"\n'
        'description = "Drug Response Prediction"\n'
        'dependencies = ["mld-sdk>=0.9.0"]\n'
        "\n"
        '[project.entry-points."mld.plugins"]\n'
        'drp-analysis = "mld_plugin_drp.plugin:DRPAnalysisPlugin"\n'
        "\n"
        "[build-system]\n"
        'requires = ["hatchling"]\n'
        'build-backend = "hatchling.build"\n'
    )

    # Source file with routes prefix
    src = project / "src" / "mld_plugin_drp"
    src.mkdir(parents=True)
    (src / "__init__.py").write_text("")
    (src / "plugin.py").write_text(
        'PLUGIN_ROUTES_PREFIX = "/drp"\n'
        "\n"
        "def create_standalone_app():\n"
        "    pass\n"
    )

    if has_frontend:
        fe = project / "frontend"
        fe.mkdir()
        (fe / "package.json").write_text('{"name": "drp-frontend"}')
        (fe / "bun.lockb").write_bytes(b"")

    return project


@pytest.fixture
def plugin_project(tmp_path: Path) -> Path:
    return _make_plugin_project(tmp_path, has_frontend=True)


@pytest.fixture
def plugin_project_no_frontend(tmp_path: Path) -> Path:
    return _make_plugin_project(tmp_path, has_frontend=False)


# ---------------------------------------------------------------------------
# TestBuildBackendCmd
# ---------------------------------------------------------------------------


class TestBuildBackendCmd:

    def test_default_command(self):
        """Should build a uvicorn --factory command with the given target."""
        cmd = _build_backend_cmd("mld_plugin_drp.plugin:create_standalone_app", "127.0.0.1", 8003)
        assert cmd == [
            "uv", "run", "uvicorn", "mld_plugin_drp.plugin:create_standalone_app",
            "--factory", "--reload", "--host", "127.0.0.1", "--port", "8003",
        ]

    def test_custom_host_and_port(self):
        """Should respect custom host and port."""
        cmd = _build_backend_cmd("mod:factory", "0.0.0.0", 9000)
        assert "--host" in cmd
        assert cmd[cmd.index("--host") + 1] == "0.0.0.0"
        assert cmd[cmd.index("--port") + 1] == "9000"


# ---------------------------------------------------------------------------
# TestBuildFrontendCmd
# ---------------------------------------------------------------------------


class TestBuildFrontendCmd:

    def test_returns_none_when_no_package_json(self, tmp_path: Path):
        """Should return None when the frontend dir has no package.json."""
        result = _build_frontend_cmd(tmp_path)
        assert result is None

    def test_detects_bun(self, tmp_path: Path):
        """Should return bun run dev when bun is detected."""
        (tmp_path / "package.json").write_text('{"name": "test"}')
        (tmp_path / "bun.lockb").write_bytes(b"")
        with patch("shutil.which", return_value="/usr/local/bin/bun"):
            result = _build_frontend_cmd(tmp_path)
        assert result == ["bun", "run", "dev"]

    def test_detects_npm(self, tmp_path: Path):
        """Should return npm run dev when npm is detected."""
        (tmp_path / "package.json").write_text('{"name": "test"}')
        (tmp_path / "package-lock.json").write_text("{}")
        with patch("shutil.which", return_value="/usr/local/bin/npm"):
            result = _build_frontend_cmd(tmp_path)
        assert result == ["npm", "run", "dev"]


# ---------------------------------------------------------------------------
# TestFindPlatformDir
# ---------------------------------------------------------------------------


class TestFindPlatformDir:

    def test_finds_mld_sibling(self, tmp_path: Path):
        """Should find the conventional 'mld' sibling with api/main.py."""
        plugin_dir = tmp_path / "mld-plugin-drp"
        plugin_dir.mkdir()
        platform = tmp_path / "mld"
        (platform / "api").mkdir(parents=True)
        (platform / "api" / "main.py").write_text("")

        result = _find_platform_dir(plugin_dir)
        assert result == platform

    def test_finds_non_mld_sibling(self, tmp_path: Path):
        """Should find any sibling directory containing api/main.py."""
        plugin_dir = tmp_path / "mld-plugin-drp"
        plugin_dir.mkdir()
        platform = tmp_path / "my-platform"
        (platform / "api").mkdir(parents=True)
        (platform / "api" / "main.py").write_text("")

        result = _find_platform_dir(plugin_dir)
        assert result == platform

    def test_returns_none_when_missing(self, tmp_path: Path):
        """Should return None when no sibling has api/main.py."""
        plugin_dir = tmp_path / "mld-plugin-drp"
        plugin_dir.mkdir()

        result = _find_platform_dir(plugin_dir)
        assert result is None

    def test_prefers_mld_over_other(self, tmp_path: Path):
        """Should prefer the 'mld' directory over other matches."""
        plugin_dir = tmp_path / "mld-plugin-drp"
        plugin_dir.mkdir()

        mld = tmp_path / "mld"
        (mld / "api").mkdir(parents=True)
        (mld / "api" / "main.py").write_text("")

        other = tmp_path / "other-platform"
        (other / "api").mkdir(parents=True)
        (other / "api" / "main.py").write_text("")

        result = _find_platform_dir(plugin_dir)
        assert result == mld


class TestResolvePlatformDir:

    def test_uses_explicit_platform_dir_when_provided(self, tmp_path: Path):
        plugin_dir = tmp_path / "plugin"
        plugin_dir.mkdir()
        explicit = tmp_path / "custom-platform"
        (explicit / "api").mkdir(parents=True)
        (explicit / "api" / "main.py").write_text("")

        result = _resolve_platform_dir(plugin_dir, str(explicit))
        assert result == explicit.resolve()

    def test_returns_none_for_invalid_explicit_platform_dir(self, tmp_path: Path):
        plugin_dir = tmp_path / "plugin"
        plugin_dir.mkdir()
        invalid = tmp_path / "missing-platform"

        result = _resolve_platform_dir(plugin_dir, str(invalid))
        assert result is None


# ---------------------------------------------------------------------------
# TestDevProxyConfig
# ---------------------------------------------------------------------------


class TestDevProxyConfig:

    def test_write_creates_new_file(self, tmp_path: Path):
        """Should create config.dev.toml with a proxy entry."""
        _write_dev_proxy_config(tmp_path, "/drp", 8003)
        content = (tmp_path / "config.dev.toml").read_text()
        assert "[proxy]" in content
        assert '"/drp" = "http://localhost:8003"' in content

    def test_write_preserves_existing_entries(self, tmp_path: Path):
        """Should preserve existing proxy entries when adding a new one."""
        config = tmp_path / "config.dev.toml"
        config.write_text('[proxy]\n"/ms" = "http://localhost:8004"\n')

        _write_dev_proxy_config(tmp_path, "/drp", 8003)
        content = config.read_text()
        assert '"/ms" = "http://localhost:8004"' in content
        assert '"/drp" = "http://localhost:8003"' in content

    def test_write_updates_existing_entry(self, tmp_path: Path):
        """Should update the port for an existing prefix."""
        config = tmp_path / "config.dev.toml"
        config.write_text('[proxy]\n"/drp" = "http://localhost:8003"\n')

        _write_dev_proxy_config(tmp_path, "/drp", 9000)
        content = config.read_text()
        assert '"/drp" = "http://localhost:9000"' in content
        assert "8003" not in content

    def test_write_preserves_non_proxy_sections_and_proxy_metadata(self, tmp_path: Path):
        """Should keep unrelated config and proxy metadata tables intact."""
        config = tmp_path / "config.dev.toml"
        config.write_text(
            '[database]\n'
            'url = "postgres://localhost/db"\n'
            "\n"
            "[proxy]\n"
            '"/ms" = "http://localhost:8004"\n'
            "\n"
            '[proxy."/drp"]\n'
            'target = "http://localhost:7001"\n'
            'name = "DRP Plugin"\n'
        )

        _write_dev_proxy_config(tmp_path, "/drp", 8003)
        parsed = tomllib.loads(config.read_text())

        assert parsed["database"]["url"] == "postgres://localhost/db"
        assert parsed["proxy"]["/ms"] == "http://localhost:8004"
        assert parsed["proxy"]["/drp"]["target"] == "http://localhost:8003"
        assert parsed["proxy"]["/drp"]["name"] == "DRP Plugin"

    def test_cleanup_removes_entry(self, tmp_path: Path):
        """Should remove the specified prefix and keep others."""
        config = tmp_path / "config.dev.toml"
        config.write_text(
            '[proxy]\n"/drp" = "http://localhost:8003"\n"/ms" = "http://localhost:8004"\n'
        )

        _cleanup_dev_proxy_config(tmp_path, "/drp")
        content = config.read_text()
        assert "/drp" not in content
        assert '"/ms" = "http://localhost:8004"' in content

    def test_cleanup_deletes_file_when_empty(self, tmp_path: Path):
        """Should delete config.dev.toml when no entries remain."""
        config = tmp_path / "config.dev.toml"
        config.write_text('[proxy]\n"/drp" = "http://localhost:8003"\n')

        _cleanup_dev_proxy_config(tmp_path, "/drp")
        assert not config.exists()

    def test_cleanup_preserves_other_sections(self, tmp_path: Path):
        """Should preserve non-proxy sections when removing the last proxy entry."""
        config = tmp_path / "config.dev.toml"
        config.write_text(
            "[platform]\n"
            'name = "local"\n'
            "\n"
            "[proxy]\n"
            '"/drp" = "http://localhost:8003"\n'
        )

        _cleanup_dev_proxy_config(tmp_path, "/drp")

        assert config.exists()
        parsed = tomllib.loads(config.read_text())
        assert "proxy" not in parsed
        assert parsed["platform"]["name"] == "local"

    def test_cleanup_noop_when_missing(self, tmp_path: Path):
        """Should not raise when config.dev.toml does not exist."""
        _cleanup_dev_proxy_config(tmp_path, "/drp")  # should not raise


# ---------------------------------------------------------------------------
# TestRunProcesses
# ---------------------------------------------------------------------------


class TestRunProcesses:

    def test_starts_all_processes(self, tmp_path: Path):
        """Should start a Popen for each command."""
        mock_proc = MagicMock()
        mock_proc.poll.return_value = None
        mock_proc.pid = 12345
        mock_proc.wait.return_value = 0

        call_count = 0

        def popen_side_effect(*_args, **_kwargs):
            nonlocal call_count
            call_count += 1
            if call_count >= 2:
                # After starting all processes, trigger shutdown on next poll
                mock_proc.poll.return_value = 0
            return mock_proc

        with (
            patch("subprocess.Popen", side_effect=popen_side_effect) as mock_popen,
            patch("os.killpg"),
            patch("time.sleep"),
        ):
            commands = [
                ("Plugin backend", ["uv", "run", "uvicorn"], tmp_path),
                ("Plugin frontend", ["bun", "run", "dev"], tmp_path),
            ]
            _run_processes(commands)

        assert mock_popen.call_count == 2

    def test_backend_exit_triggers_shutdown(self, tmp_path: Path):
        """Should shut down when a backend process exits."""
        mock_proc = MagicMock()
        mock_proc.pid = 12345
        mock_proc.wait.return_value = 0

        poll_count = 0

        def poll_side_effect():
            nonlocal poll_count
            poll_count += 1
            # Simulate backend exiting on second poll
            if poll_count >= 2:
                return 1
            return None

        mock_proc.poll.side_effect = poll_side_effect

        with (
            patch("subprocess.Popen", return_value=mock_proc),
            patch("os.killpg"),
            patch("time.sleep"),
        ):
            _run_processes([("Plugin backend", ["uv", "run"], tmp_path)])

        # Should have attempted to kill the process group
        # (proc already exited, but killpg is still called for cleanup)

    def test_signal_handler_restores_originals(self, tmp_path: Path):
        """Should restore original signal handlers after running."""
        original_sigint = signal.getsignal(signal.SIGINT)
        original_sigterm = signal.getsignal(signal.SIGTERM)

        mock_proc = MagicMock()
        mock_proc.poll.return_value = 0
        mock_proc.pid = 12345
        mock_proc.wait.return_value = 0

        with (
            patch("subprocess.Popen", return_value=mock_proc),
            patch("os.killpg"),
            patch("time.sleep"),
        ):
            _run_processes([("Plugin backend", ["cmd"], tmp_path)])

        assert signal.getsignal(signal.SIGINT) == original_sigint
        assert signal.getsignal(signal.SIGTERM) == original_sigterm


# ---------------------------------------------------------------------------
# TestCmdDev
# ---------------------------------------------------------------------------


class TestCmdDev:

    def test_starts_backend_and_frontend(self, plugin_project: Path):
        """Should start both backend and frontend processes for a plugin with frontend."""
        args = argparse.Namespace(
            prefix=None,
            host="127.0.0.1",
            port=8003,
            no_frontend=False,
            platform=False,
        )

        mock_proc = MagicMock()
        mock_proc.poll.return_value = 0
        mock_proc.pid = 12345
        mock_proc.wait.return_value = 0

        with (
            patch("mld_sdk.dev_command.Path") as mock_path_cls,
            patch("subprocess.Popen", return_value=mock_proc) as mock_popen,
            patch("os.killpg"),
            patch("time.sleep"),
            patch("shutil.which", return_value="/usr/local/bin/bun"),
        ):
            mock_path_cls.cwd.return_value = plugin_project
            cmd_dev(args)

        # Should have started 2 processes (backend + frontend)
        assert mock_popen.call_count == 2
        started_cmds = [c.args[0] for c in mock_popen.call_args_list]
        # First is backend (uvicorn), second is frontend (bun)
        assert "uvicorn" in started_cmds[0]
        assert started_cmds[1] == ["bun", "run", "dev"]

    def test_no_frontend_flag_skips_frontend(self, plugin_project: Path):
        """Should only start backend when --no-frontend is set."""
        args = argparse.Namespace(
            prefix=None,
            host="127.0.0.1",
            port=8003,
            no_frontend=True,
            platform=False,
        )

        mock_proc = MagicMock()
        mock_proc.poll.return_value = 0
        mock_proc.pid = 12345
        mock_proc.wait.return_value = 0

        with (
            patch("mld_sdk.dev_command.Path") as mock_path_cls,
            patch("subprocess.Popen", return_value=mock_proc) as mock_popen,
            patch("os.killpg"),
            patch("time.sleep"),
        ):
            mock_path_cls.cwd.return_value = plugin_project
            cmd_dev(args)

        assert mock_popen.call_count == 1
        assert "uvicorn" in mock_popen.call_args_list[0].args[0]

    def test_custom_prefix(self, plugin_project: Path):
        """Should use custom prefix when provided via args."""
        args = argparse.Namespace(
            prefix="/custom",
            host="127.0.0.1",
            port=8003,
            no_frontend=True,
            platform=False,
        )

        mock_proc = MagicMock()
        mock_proc.poll.return_value = 0
        mock_proc.pid = 12345
        mock_proc.wait.return_value = 0

        with (
            patch("mld_sdk.dev_command.Path") as mock_path_cls,
            patch("subprocess.Popen", return_value=mock_proc) as mock_popen,
            patch("os.killpg"),
            patch("time.sleep"),
            patch("builtins.print") as mock_print,
        ):
            mock_path_cls.cwd.return_value = plugin_project
            cmd_dev(args)

        # Verify the custom prefix appears in the output
        printed = " ".join(str(c) for c in mock_print.call_args_list)
        assert "/custom" in printed

    def test_platform_mode(self, tmp_path: Path):
        """Should start platform processes and write proxy config in platform mode."""
        plugin_dir = _make_plugin_project(tmp_path, has_frontend=False)

        # Create a platform sibling
        platform_dir = tmp_path / "mld"
        (platform_dir / "api").mkdir(parents=True)
        (platform_dir / "api" / "main.py").write_text("")

        args = argparse.Namespace(
            prefix=None,
            host="127.0.0.1",
            port=8003,
            no_frontend=False,
            platform=True,
        )

        mock_proc = MagicMock()
        mock_proc.poll.return_value = 0
        mock_proc.pid = 12345
        mock_proc.wait.return_value = 0

        with (
            patch("mld_sdk.dev_command.Path") as mock_path_cls,
            patch("subprocess.Popen", return_value=mock_proc) as mock_popen,
            patch("os.killpg"),
            patch("time.sleep"),
        ):
            mock_path_cls.cwd.return_value = plugin_dir
            cmd_dev(args)

        # Should have started plugin backend + platform backend (no frontends)
        assert mock_popen.call_count == 2

        # config.dev.toml should be cleaned up after exit
        assert not (platform_dir / "config.dev.toml").exists()

    def test_platform_mode_uses_platform_dir_arg(self, tmp_path: Path):
        """Should use --platform-dir instead of sibling auto-discovery."""
        plugin_dir = _make_plugin_project(tmp_path, has_frontend=False)

        # Sibling candidate (should be ignored)
        sibling_platform = tmp_path / "mld"
        (sibling_platform / "api").mkdir(parents=True)
        (sibling_platform / "api" / "main.py").write_text("")

        explicit_platform = tmp_path / "explicit-platform"
        (explicit_platform / "api").mkdir(parents=True)
        (explicit_platform / "api" / "main.py").write_text("")

        args = argparse.Namespace(
            prefix=None,
            host="127.0.0.1",
            port=8003,
            no_frontend=False,
            platform=True,
            platform_dir=str(explicit_platform),
        )

        mock_proc = MagicMock()
        mock_proc.poll.return_value = 0
        mock_proc.pid = 12345
        mock_proc.wait.return_value = 0

        with (
            patch("mld_sdk.dev_command.Path.cwd", return_value=plugin_dir),
            patch("subprocess.Popen", return_value=mock_proc) as mock_popen,
            patch("os.killpg"),
            patch("time.sleep"),
        ):
            cmd_dev(args)

        started_cwds = [c.kwargs["cwd"] for c in mock_popen.call_args_list]
        assert explicit_platform in started_cwds
        assert sibling_platform not in started_cwds

    def test_platform_mode_missing_dir_exits(self, plugin_project: Path):
        """Should exit with error when platform directory cannot be found."""
        args = argparse.Namespace(
            prefix=None,
            host="127.0.0.1",
            port=8003,
            no_frontend=False,
            platform=True,
            platform_dir=None,
        )

        with (
            patch("mld_sdk.dev_command.Path") as mock_path_cls,
            patch("mld_sdk.dev_command._find_platform_dir", return_value=None),
        ):
            mock_path_cls.cwd.return_value = plugin_project
            with pytest.raises(SystemExit):
                cmd_dev(args)

    def test_no_frontend_project(self, plugin_project_no_frontend: Path):
        """Should only start backend for a plugin without frontend."""
        args = argparse.Namespace(
            prefix=None,
            host="127.0.0.1",
            port=8003,
            no_frontend=False,
            platform=False,
        )

        mock_proc = MagicMock()
        mock_proc.poll.return_value = 0
        mock_proc.pid = 12345
        mock_proc.wait.return_value = 0

        with (
            patch("mld_sdk.dev_command.Path") as mock_path_cls,
            patch("subprocess.Popen", return_value=mock_proc) as mock_popen,
            patch("os.killpg"),
            patch("time.sleep"),
        ):
            mock_path_cls.cwd.return_value = plugin_project_no_frontend
            cmd_dev(args)

        assert mock_popen.call_count == 1
