"""Tests for the mld CLI (mld build command)."""

import json
import zipfile
from pathlib import Path
from unittest.mock import patch, MagicMock

import pytest

from mld_sdk.cli import (
    main,
    _build_manifest,
    _read_pyproject,
    _detect_package_manager,
    _build_frontend,
    _check_force_include,
)


@pytest.fixture
def plugin_project(tmp_path: Path) -> Path:
    """Create a minimal plugin project directory."""
    project = tmp_path / "my-plugin"
    project.mkdir()
    pyproject = project / "pyproject.toml"
    pyproject.write_text(
        '[project]\n'
        'name = "my-plugin"\n'
        'version = "0.1.0"\n'
        'description = "A test plugin"\n'
        'dependencies = ["fastapi"]\n'
        "\n"
        "[build-system]\n"
        'requires = ["hatchling"]\n'
        'build-backend = "hatchling.build"\n'
    )
    # Create a minimal source package so uv build could work
    src = project / "src" / "my_plugin"
    src.mkdir(parents=True)
    (src / "__init__.py").write_text("")
    return project


class TestBuildManifest:

    def test_basic_manifest(self):
        """Should produce a valid manifest dict."""
        project = {"name": "my-plugin", "version": "0.1.0", "description": "Test"}
        manifest = _build_manifest(project, "my_plugin-0.1.0-py3-none-any.whl", [])

        assert manifest["format_version"] == 1
        assert manifest["plugin"]["name"] == "my-plugin"
        assert manifest["plugin"]["version"] == "0.1.0"
        assert manifest["wheels"]["main"] == "my_plugin-0.1.0-py3-none-any.whl"
        assert manifest["wheels"]["dependencies"] == []

    def test_manifest_with_deps(self):
        """Should include dependency wheels in manifest."""
        project = {"name": "p", "version": "1.0"}
        deps = ["dep1-1.0-py3-none-any.whl", "dep2-2.0-py3-none-any.whl"]
        manifest = _build_manifest(project, "p-1.0-py3-none-any.whl", deps)

        assert manifest["wheels"]["dependencies"] == deps

    def test_manifest_default_requires_mld(self):
        """Should default requires_mld to >=0.1.0."""
        project = {"name": "p", "version": "1.0"}
        manifest = _build_manifest(project, "p-1.0.whl", [])
        assert manifest["plugin"]["requires_mld"] == ">=0.1.0"

    def test_manifest_has_frontend_default_false(self):
        """Should default has_frontend to False in manifest."""
        project = {"name": "p", "version": "1.0"}
        manifest = _build_manifest(project, "p-1.0.whl", [])
        assert manifest["plugin"]["has_frontend"] is False

    def test_manifest_has_frontend_true(self):
        """Should include has_frontend=True when specified."""
        project = {"name": "p", "version": "1.0"}
        manifest = _build_manifest(project, "p-1.0.whl", [], has_frontend=True)
        assert manifest["plugin"]["has_frontend"] is True


class TestReadPyproject:

    def test_reads_project_table(self, plugin_project):
        """Should parse name and version from pyproject.toml."""
        project = _read_pyproject(plugin_project)
        assert project["name"] == "my-plugin"
        assert project["version"] == "0.1.0"

    def test_missing_pyproject(self, tmp_path):
        """Should exit when pyproject.toml is missing."""
        with pytest.raises(SystemExit):
            _read_pyproject(tmp_path)

    def test_missing_project_table(self, tmp_path):
        """Should exit when [project] table is missing."""
        (tmp_path / "pyproject.toml").write_text("[build-system]\n")
        with pytest.raises(SystemExit):
            _read_pyproject(tmp_path)


class TestMldBuildCommand:

    def test_build_produces_mld_file(self, plugin_project, tmp_path):
        """Should create a valid .mld archive with manifest + wheel."""
        output_dir = tmp_path / "output"
        fake_whl = tmp_path / "build" / "my_plugin-0.1.0-py3-none-any.whl"
        fake_whl.parent.mkdir(parents=True)
        with zipfile.ZipFile(fake_whl, "w") as zf:
            zf.writestr("my_plugin/__init__.py", "")

        with patch("mld_sdk.cli._build_wheel", return_value=fake_whl):
            main(["build", str(plugin_project), "--output-dir", str(output_dir)])

        mld_file = output_dir / "my-plugin-0.1.0.mld"
        assert mld_file.exists()

        with zipfile.ZipFile(mld_file, "r") as zf:
            names = zf.namelist()
            assert "manifest.json" in names
            assert "my_plugin-0.1.0-py3-none-any.whl" in names

            manifest = json.loads(zf.read("manifest.json"))
            assert manifest["format_version"] == 1
            assert manifest["plugin"]["name"] == "my-plugin"
            assert manifest["wheels"]["main"] == "my_plugin-0.1.0-py3-none-any.whl"

    def test_build_with_vendor_deps(self, plugin_project, tmp_path):
        """Should include dependency wheels when --vendor-deps is passed."""
        output_dir = tmp_path / "output"
        fake_whl = tmp_path / "build" / "my_plugin-0.1.0-py3-none-any.whl"
        fake_whl.parent.mkdir(parents=True)
        with zipfile.ZipFile(fake_whl, "w") as zf:
            zf.writestr("my_plugin/__init__.py", "")

        dep_whl = tmp_path / "dep" / "fastapi-0.109.0-py3-none-any.whl"
        dep_whl.parent.mkdir(parents=True)
        with zipfile.ZipFile(dep_whl, "w") as zf:
            zf.writestr("fastapi/__init__.py", "")

        def fake_download(project_dir, dest):
            # Copy dep wheel into dest
            import shutil
            shutil.copy(dep_whl, dest / dep_whl.name)
            return [dest / dep_whl.name]

        with (
            patch("mld_sdk.cli._build_wheel", return_value=fake_whl),
            patch("mld_sdk.cli._download_deps", side_effect=fake_download),
        ):
            main([
                "build", str(plugin_project),
                "--vendor-deps",
                "--output-dir", str(output_dir),
            ])

        mld_file = output_dir / "my-plugin-0.1.0.mld"
        with zipfile.ZipFile(mld_file, "r") as zf:
            names = zf.namelist()
            assert "fastapi-0.109.0-py3-none-any.whl" in names
            manifest = json.loads(zf.read("manifest.json"))
            assert "fastapi-0.109.0-py3-none-any.whl" in manifest["wheels"]["dependencies"]

    def test_build_with_frontend(self, plugin_project, tmp_path):
        """Should build frontend before wheel and set has_frontend in manifest."""
        output_dir = tmp_path / "output"
        fake_whl = tmp_path / "build" / "my_plugin-0.1.0-py3-none-any.whl"
        fake_whl.parent.mkdir(parents=True)
        with zipfile.ZipFile(fake_whl, "w") as zf:
            zf.writestr("my_plugin/__init__.py", "")

        # Create a frontend directory so _build_frontend is triggered
        frontend = plugin_project / "frontend"
        frontend.mkdir()
        (frontend / "package.json").write_text('{"name": "test"}')

        with (
            patch("mld_sdk.cli._build_wheel", return_value=fake_whl),
            patch("mld_sdk.cli._build_frontend", return_value=True) as mock_fe,
            patch("mld_sdk.cli._check_force_include"),
        ):
            main(["build", str(plugin_project), "--output-dir", str(output_dir)])

        mock_fe.assert_called_once_with(plugin_project)

        mld_file = output_dir / "my-plugin-0.1.0.mld"
        with zipfile.ZipFile(mld_file, "r") as zf:
            manifest = json.loads(zf.read("manifest.json"))
            assert manifest["plugin"]["has_frontend"] is True

    def test_build_no_frontend_flag(self, plugin_project, tmp_path):
        """Should skip frontend build when --no-frontend is passed."""
        output_dir = tmp_path / "output"
        fake_whl = tmp_path / "build" / "my_plugin-0.1.0-py3-none-any.whl"
        fake_whl.parent.mkdir(parents=True)
        with zipfile.ZipFile(fake_whl, "w") as zf:
            zf.writestr("my_plugin/__init__.py", "")

        # Create a frontend directory
        frontend = plugin_project / "frontend"
        frontend.mkdir()
        (frontend / "package.json").write_text('{"name": "test"}')

        with (
            patch("mld_sdk.cli._build_wheel", return_value=fake_whl),
            patch("mld_sdk.cli._build_frontend") as mock_fe,
        ):
            main([
                "build", str(plugin_project),
                "--no-frontend",
                "--output-dir", str(output_dir),
            ])

        mock_fe.assert_not_called()

        mld_file = output_dir / "my-plugin-0.1.0.mld"
        with zipfile.ZipFile(mld_file, "r") as zf:
            manifest = json.loads(zf.read("manifest.json"))
            assert manifest["plugin"]["has_frontend"] is False

    def test_no_command_shows_help(self, capsys):
        """Should exit with help when no subcommand is given."""
        with pytest.raises(SystemExit):
            main([])


class TestDetectPackageManager:

    def test_detect_bun_lockb(self, tmp_path):
        """Should detect bun from bun.lockb file."""
        (tmp_path / "bun.lockb").write_bytes(b"")
        with patch("shutil.which", return_value="/usr/local/bin/bun"):
            pm, cmd = _detect_package_manager(tmp_path)
        assert pm == "bun"
        assert cmd == "bun"

    def test_detect_bun_lock(self, tmp_path):
        """Should detect bun from bun.lock file."""
        (tmp_path / "bun.lock").write_text("")
        with patch("shutil.which", return_value="/usr/local/bin/bun"):
            pm, cmd = _detect_package_manager(tmp_path)
        assert pm == "bun"
        assert cmd == "bun"

    def test_detect_npm(self, tmp_path):
        """Should detect npm from package-lock.json file."""
        (tmp_path / "package-lock.json").write_text("{}")
        with patch("shutil.which", return_value="/usr/local/bin/npm"):
            pm, cmd = _detect_package_manager(tmp_path)
        assert pm == "npm"
        assert cmd == "npm"

    def test_no_lockfile_prefers_bun(self, tmp_path):
        """Should prefer bun when no lockfile exists and bun is available."""
        def which_side_effect(name):
            return "/usr/local/bin/bun" if name == "bun" else None

        with patch("shutil.which", side_effect=which_side_effect):
            pm, cmd = _detect_package_manager(tmp_path)
        assert pm == "bun"

    def test_no_lockfile_falls_back_to_npm(self, tmp_path):
        """Should fall back to npm when no lockfile and bun not available."""
        def which_side_effect(name):
            return "/usr/local/bin/npm" if name == "npm" else None

        with patch("shutil.which", side_effect=which_side_effect):
            pm, cmd = _detect_package_manager(tmp_path)
        assert pm == "npm"

    def test_no_lockfile_no_tools_exits(self, tmp_path):
        """Should exit when no lockfile and no tools available."""
        with patch("shutil.which", return_value=None):
            with pytest.raises(SystemExit):
                _detect_package_manager(tmp_path)

    def test_bun_lockfile_but_bun_not_on_path(self, tmp_path):
        """Should exit when bun.lockb exists but bun is not on PATH."""
        (tmp_path / "bun.lockb").write_bytes(b"")
        with patch("shutil.which", return_value=None):
            with pytest.raises(SystemExit):
                _detect_package_manager(tmp_path)

    def test_npm_lockfile_but_npm_not_on_path(self, tmp_path):
        """Should exit when package-lock.json exists but npm is not on PATH."""
        (tmp_path / "package-lock.json").write_text("{}")
        with patch("shutil.which", return_value=None):
            with pytest.raises(SystemExit):
                _detect_package_manager(tmp_path)


class TestBuildFrontend:

    def test_no_frontend_dir_returns_false(self, tmp_path):
        """Should return False when no frontend/package.json exists."""
        result = _build_frontend(tmp_path)
        assert result is False

    def test_successful_build(self, tmp_path):
        """Should run install + build and return True."""
        frontend = tmp_path / "frontend"
        frontend.mkdir()
        (frontend / "package.json").write_text('{"name": "test"}')

        def fake_run(cmd, **kwargs):
            # Create dist on build step
            if "build" in cmd:
                dist = frontend / "dist"
                dist.mkdir(exist_ok=True)
                (dist / "index.html").write_text("<html></html>")
            return MagicMock(returncode=0, stdout="", stderr="")

        with (
            patch("mld_sdk.cli._detect_package_manager", return_value=("bun", "bun")),
            patch("subprocess.run", side_effect=fake_run),
        ):
            result = _build_frontend(tmp_path)

        assert result is True

    def test_install_failure_exits(self, tmp_path):
        """Should exit when install step fails."""
        frontend = tmp_path / "frontend"
        frontend.mkdir()
        (frontend / "package.json").write_text('{"name": "test"}')

        with (
            patch("mld_sdk.cli._detect_package_manager", return_value=("bun", "bun")),
            patch(
                "subprocess.run",
                return_value=MagicMock(returncode=1, stderr="install failed"),
            ),
        ):
            with pytest.raises(SystemExit):
                _build_frontend(tmp_path)

    def test_build_failure_exits(self, tmp_path):
        """Should exit when build step fails."""
        frontend = tmp_path / "frontend"
        frontend.mkdir()
        (frontend / "package.json").write_text('{"name": "test"}')

        call_count = 0

        def fake_run(cmd, **kwargs):
            nonlocal call_count
            call_count += 1
            if call_count == 1:  # install succeeds
                return MagicMock(returncode=0, stdout="", stderr="")
            return MagicMock(returncode=1, stderr="build failed")  # build fails

        with (
            patch("mld_sdk.cli._detect_package_manager", return_value=("bun", "bun")),
            patch("subprocess.run", side_effect=fake_run),
        ):
            with pytest.raises(SystemExit):
                _build_frontend(tmp_path)

    def test_empty_dist_exits(self, tmp_path):
        """Should exit when frontend/dist is empty after build."""
        frontend = tmp_path / "frontend"
        frontend.mkdir()
        (frontend / "package.json").write_text('{"name": "test"}')

        def fake_run(cmd, **kwargs):
            # Create empty dist on build step
            if "build" in cmd:
                (frontend / "dist").mkdir(exist_ok=True)
            return MagicMock(returncode=0, stdout="", stderr="")

        with (
            patch("mld_sdk.cli._detect_package_manager", return_value=("bun", "bun")),
            patch("subprocess.run", side_effect=fake_run),
        ):
            with pytest.raises(SystemExit):
                _build_frontend(tmp_path)


class TestCheckForceInclude:

    def test_warns_when_missing(self, tmp_path, capsys):
        """Should print warning when force-include is missing."""
        (tmp_path / "pyproject.toml").write_text(
            "[project]\nname = 'p'\nversion = '1.0'\n"
        )
        _check_force_include(tmp_path)
        captured = capsys.readouterr()
        assert "Warning" in captured.err
        assert "force-include" in captured.err

    def test_silent_when_present(self, tmp_path, capsys):
        """Should not warn when force-include has frontend/dist entry."""
        (tmp_path / "pyproject.toml").write_text(
            "[project]\nname = 'p'\nversion = '1.0'\n"
            "\n[tool.hatch.build.targets.wheel.force-include]\n"
            '"frontend/dist" = "my_plugin/frontend/dist"\n'
        )
        _check_force_include(tmp_path)
        captured = capsys.readouterr()
        assert captured.err == ""

    def test_warns_when_force_include_has_no_frontend(self, tmp_path, capsys):
        """Should warn when force-include exists but lacks frontend/dist."""
        (tmp_path / "pyproject.toml").write_text(
            "[project]\nname = 'p'\nversion = '1.0'\n"
            "\n[tool.hatch.build.targets.wheel.force-include]\n"
            '"some/other" = "pkg/other"\n'
        )
        _check_force_include(tmp_path)
        captured = capsys.readouterr()
        assert "Warning" in captured.err
