"""Tests for ilum cleanup command and core cleanup logic."""

from __future__ import annotations

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

from ilum.core.cleanup import (
    DetectedDependency,
    count_files_in_dir,
    detect_dependencies,
    docker_has_running_containers,
    remove_cli_directories,
    remove_dependency,
    remove_helm_repo,
    uninstall_cli,
)

# ── Core logic: detect_dependencies ─────────────────────────────────


class TestDetectDependencies:
    def test_finds_installed_helm(self) -> None:
        """shutil.which returns path for helm → included in results."""
        with (
            patch("ilum.core.cleanup.shutil.which") as mock_which,
            patch("ilum.core.cleanup._get_version", return_value="v3.14.0"),
            patch("ilum.core.cleanup._detect_removal_method", return_value="binary"),
        ):
            mock_which.side_effect = lambda n: "/usr/local/bin/helm" if n == "helm" else None
            result = detect_dependencies()
        assert len(result) == 1
        assert result[0].name == "helm"
        assert result[0].path == "/usr/local/bin/helm"

    def test_skips_missing_tools(self) -> None:
        """shutil.which returns None for all → empty list."""
        with patch("ilum.core.cleanup.shutil.which", return_value=None):
            result = detect_dependencies()
        assert result == []

    def test_detects_all_six_tools(self) -> None:
        """All 6 tools present → 6 DetectedDependency objects."""
        paths = {
            "helm": "/usr/local/bin/helm",
            "kubectl": "/usr/local/bin/kubectl",
            "minikube": "/usr/local/bin/minikube",
            "k3d": "/usr/local/bin/k3d",
            "kind": "/usr/local/bin/kind",
            "docker": "/usr/bin/docker",
        }
        with (
            patch(
                "ilum.core.cleanup.shutil.which",
                side_effect=lambda n: paths.get(n),
            ),
            patch("ilum.core.cleanup._get_version", return_value=""),
            patch("ilum.core.cleanup._detect_removal_method", return_value="binary"),
        ):
            result = detect_dependencies()
        assert len(result) == 6
        names = {d.name for d in result}
        assert names == {"helm", "kubectl", "minikube", "k3d", "kind", "docker"}


# ── Core logic: remove_cli_directories ──────────────────────────────


class TestRemoveCliDirectories:
    def test_dry_run_no_deletion(self, tmp_path: Path) -> None:
        """dry_run=True → directories not deleted, still returned."""
        d = tmp_path / "config"
        d.mkdir()
        (d / "file.yaml").write_text("data")

        result = remove_cli_directories([d], dry_run=True)
        assert result == [d]
        assert d.exists()  # NOT deleted

    def test_removes_existing_dirs(self, tmp_path: Path) -> None:
        """Existing dirs are removed via shutil.rmtree."""
        d1 = tmp_path / "config"
        d2 = tmp_path / "state"
        d1.mkdir()
        d2.mkdir()
        (d1 / "file.yaml").write_text("data")

        result = remove_cli_directories([d1, d2])
        assert result == [d1, d2]
        assert not d1.exists()
        assert not d2.exists()

    def test_skips_nonexistent_dirs(self, tmp_path: Path) -> None:
        """Non-existent dirs are skipped, not in return list."""
        existing = tmp_path / "exists"
        existing.mkdir()
        missing = tmp_path / "does_not_exist"

        result = remove_cli_directories([existing, missing])
        assert result == [existing]


# ── Core logic: remove_dependency ───────────────────────────────────


class TestRemoveDependency:
    def test_helm_binary_removal(self, tmp_path: Path) -> None:
        """Helm removed by deleting binary file."""
        binary = tmp_path / "helm"
        binary.write_text("#!/bin/bash")
        dep = DetectedDependency(
            name="helm", path=str(binary), version="v3.14.0", removal_method="binary"
        )
        assert remove_dependency(dep) is True
        assert not binary.exists()

    def test_minikube_purges_first(self) -> None:
        """Minikube runs 'delete --all --purge' before binary removal."""
        dep = DetectedDependency(
            name="minikube",
            path="/usr/local/bin/minikube",
            version="",
            removal_method="binary",
        )
        with (
            patch("ilum.core.cleanup.subprocess.run") as mock_run,
            patch("ilum.core.cleanup.os.remove") as mock_remove,
        ):
            remove_dependency(dep)
        mock_run.assert_called_once_with(
            ["minikube", "delete", "--all", "--purge"],
            capture_output=True,
            text=True,
            timeout=120,
        )
        mock_remove.assert_called_once_with("/usr/local/bin/minikube")

    def test_k3d_deletes_clusters_first(self) -> None:
        """k3d runs 'cluster delete --all' before binary removal."""
        dep = DetectedDependency(
            name="k3d",
            path="/usr/local/bin/k3d",
            version="",
            removal_method="binary",
        )
        with (
            patch("ilum.core.cleanup.subprocess.run") as mock_run,
            patch("ilum.core.cleanup.os.remove") as mock_remove,
        ):
            remove_dependency(dep)
        mock_run.assert_called_once_with(
            ["k3d", "cluster", "delete", "--all"],
            capture_output=True,
            text=True,
            timeout=120,
        )
        mock_remove.assert_called_once_with("/usr/local/bin/k3d")

    def test_docker_uses_apt(self) -> None:
        """Docker removed via apt on Debian/Ubuntu."""
        dep = DetectedDependency(
            name="docker",
            path="/usr/bin/docker",
            version="",
            removal_method="apt",
        )
        with patch("ilum.core.cleanup.subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(returncode=0)
            result = remove_dependency(dep)
        assert result is True
        mock_run.assert_called_once_with(
            [
                "sudo",
                "apt",
                "remove",
                "-y",
                "docker-ce",
                "docker-ce-cli",
                "containerd.io",
            ],
            capture_output=True,
            text=True,
            timeout=120,
            check=True,
        )

    def test_docker_dry_run(self) -> None:
        """Docker dry_run=True → no subprocess calls."""
        dep = DetectedDependency(
            name="docker",
            path="/usr/bin/docker",
            version="",
            removal_method="apt",
        )
        with patch("ilum.core.cleanup.subprocess.run") as mock_run:
            result = remove_dependency(dep, dry_run=True)
        assert result is True
        mock_run.assert_not_called()


# ── Core logic: remove_helm_repo ────────────────────────────────────


class TestRemoveHelmRepo:
    def test_removes_existing_repo(self) -> None:
        """'helm repo remove ilum' succeeds → True."""
        with (
            patch("ilum.core.cleanup.shutil.which", return_value="/usr/local/bin/helm"),
            patch("ilum.core.cleanup.subprocess.run") as mock_run,
        ):
            mock_run.return_value = MagicMock(returncode=0)
            result = remove_helm_repo()
        assert result is True

    def test_missing_repo_returns_false(self) -> None:
        """Repo doesn't exist → False, no error."""
        with (
            patch("ilum.core.cleanup.shutil.which", return_value="/usr/local/bin/helm"),
            patch("ilum.core.cleanup.subprocess.run") as mock_run,
        ):
            mock_run.return_value = MagicMock(returncode=1)
            result = remove_helm_repo()
        assert result is False

    def test_helm_not_installed(self) -> None:
        """Helm binary not found → False."""
        with patch("ilum.core.cleanup.shutil.which", return_value=None):
            result = remove_helm_repo()
        assert result is False

    def test_dry_run_no_execution(self) -> None:
        """Dry run → True without running subprocess."""
        with (
            patch("ilum.core.cleanup.shutil.which", return_value="/usr/local/bin/helm"),
            patch("ilum.core.cleanup.subprocess.run") as mock_run,
        ):
            result = remove_helm_repo(dry_run=True)
        assert result is True
        mock_run.assert_not_called()


# ── Core logic: uninstall_cli ───────────────────────────────────────


class TestUninstallCli:
    def test_pipx_uninstall(self) -> None:
        """pipx available → runs 'pipx uninstall ilum'."""
        with (
            patch("ilum.core.cleanup.shutil.which") as mock_which,
            patch("ilum.core.cleanup.subprocess.run") as mock_run,
            patch("ilum.core.cleanup.sys") as mock_sys,
        ):
            mock_sys.frozen = False
            mock_sys.executable = "/usr/bin/python3"
            mock_which.side_effect = lambda n: "/usr/bin/pipx" if n == "pipx" else None
            mock_run.return_value = MagicMock(returncode=0)
            success, hint = uninstall_cli()
        assert success is True
        assert "pipx" in hint
        mock_run.assert_called_once()
        assert mock_run.call_args[0][0] == ["pipx", "uninstall", "ilum"]

    def test_fallback_to_pip(self) -> None:
        """pipx/uv not available → falls back to sys.executable -m pip."""
        with (
            patch("ilum.core.cleanup.shutil.which", return_value=None),
            patch("ilum.core.cleanup.subprocess.run") as mock_run,
            patch("ilum.core.cleanup.sys") as mock_sys,
        ):
            mock_sys.frozen = False
            mock_sys.executable = "/usr/bin/python3"
            mock_run.return_value = MagicMock(returncode=0)
            success, hint = uninstall_cli()
        assert success is True
        assert "pip" in hint
        mock_run.assert_called_once()
        assert mock_run.call_args[0][0] == [
            "/usr/bin/python3",
            "-m",
            "pip",
            "uninstall",
            "-y",
            "ilum",
        ]

    def test_dry_run(self) -> None:
        """Dry run → (True, 'dry-run') without running subprocess."""
        with patch("ilum.core.cleanup.subprocess.run") as mock_run:
            success, hint = uninstall_cli(dry_run=True)
        assert success is True
        assert hint == "dry-run"
        mock_run.assert_not_called()

    def test_frozen_binary_self_delete(self, tmp_path: Path) -> None:
        """Standalone binary (sys.frozen) → deletes sys.executable."""
        binary = tmp_path / "ilum"
        binary.write_text("#!/bin/bash")

        with patch("ilum.core.cleanup.sys") as mock_sys:
            mock_sys.frozen = True
            mock_sys.executable = str(binary)
            success, hint = uninstall_cli()
        assert success is True
        assert "Removed binary" in hint
        assert not binary.exists()

    def test_frozen_binary_permission_error_sudo(self) -> None:
        """Standalone binary with PermissionError → falls back to sudo rm."""
        with (
            patch("ilum.core.cleanup.sys") as mock_sys,
            patch("ilum.core.cleanup.os.remove", side_effect=PermissionError),
            patch("ilum.core.cleanup.subprocess.run") as mock_run,
        ):
            mock_sys.frozen = True
            mock_sys.executable = "/usr/local/bin/ilum"
            mock_run.return_value = MagicMock(returncode=0)
            success, hint = uninstall_cli()
        assert success is True
        assert "sudo" in hint
        mock_run.assert_called_once_with(
            ["sudo", "rm", "/usr/local/bin/ilum"],
            capture_output=True,
            text=True,
            timeout=30,
            check=True,
        )

    def test_frozen_binary_removal_fails(self) -> None:
        """Standalone binary removal fails → returns hint with manual command."""
        with (
            patch("ilum.core.cleanup.sys") as mock_sys,
            patch("ilum.core.cleanup.os.remove", side_effect=OSError("fail")),
        ):
            mock_sys.frozen = True
            mock_sys.executable = "/usr/local/bin/ilum"
            success, hint = uninstall_cli()
        assert success is False
        assert "rm" in hint
        assert "/usr/local/bin/ilum" in hint

    def test_all_methods_fail_returns_fallback_hint(self) -> None:
        """No installers work → returns fallback hint with sys.executable."""
        with (
            patch("ilum.core.cleanup.shutil.which", return_value=None),
            patch("ilum.core.cleanup.subprocess.run") as mock_run,
            patch("ilum.core.cleanup.sys") as mock_sys,
        ):
            mock_sys.frozen = False
            mock_sys.executable = "/usr/bin/python3"
            mock_run.return_value = MagicMock(returncode=1)
            success, hint = uninstall_cli()
        assert success is False
        assert "/usr/bin/python3 -m pip uninstall ilum" in hint


# ── Core logic: docker_has_running_containers ───────────────────────


class TestDockerRunningContainers:
    def test_has_containers(self) -> None:
        """Docker ps returns container IDs → True."""
        with (
            patch("ilum.core.cleanup.shutil.which", return_value="/usr/bin/docker"),
            patch("ilum.core.cleanup.subprocess.run") as mock_run,
        ):
            mock_run.return_value = MagicMock(stdout="abc123\ndef456\n")
            assert docker_has_running_containers() is True

    def test_no_containers(self) -> None:
        """Docker ps returns empty → False."""
        with (
            patch("ilum.core.cleanup.shutil.which", return_value="/usr/bin/docker"),
            patch("ilum.core.cleanup.subprocess.run") as mock_run,
        ):
            mock_run.return_value = MagicMock(stdout="")
            assert docker_has_running_containers() is False

    def test_docker_not_installed(self) -> None:
        """Docker not on PATH → False."""
        with patch("ilum.core.cleanup.shutil.which", return_value=None):
            assert docker_has_running_containers() is False


# ── Core logic: count_files_in_dir ──────────────────────────────────


class TestCountFilesInDir:
    def test_counts_files(self, tmp_path: Path) -> None:
        (tmp_path / "a.txt").write_text("a")
        (tmp_path / "b.txt").write_text("b")
        assert count_files_in_dir(tmp_path) == 2

    def test_nonexistent_dir(self, tmp_path: Path) -> None:
        assert count_files_in_dir(tmp_path / "missing") == 0


# ── CLI: cleanup command ────────────────────────────────────────────


class TestCleanupCommand:
    """Integration tests for the cleanup Typer command."""

    def test_help_shows_all_flags(self, cli_runner) -> None:  # type: ignore[no-untyped-def]
        """--help shows all expected flags."""
        from ilum.cli.main import app

        result = cli_runner.invoke(app, ["cleanup", "--help"])
        assert result.exit_code == 0
        for flag in (
            "--cluster",
            "--config",
            "--deps",
            "--self",
            "--all",
            "--yes",
            "--dry-run",
        ):
            assert flag in result.output

    def test_dry_run_no_side_effects(self, cli_runner) -> None:  # type: ignore[no-untyped-def]
        """--dry-run shows preview text without executing."""
        from ilum.cli.main import app

        with (
            patch(
                "ilum.cli.cleanup_cmd.resolve_profile_defaults",
                return_value=("ilum", "default", ""),
            ),
            patch("ilum.cli.cleanup_cmd.ReleaseManager") as mock_rm_cls,
            patch("ilum.cli.cleanup_cmd.HelmClient"),
            patch("ilum.cli.cleanup_cmd.KubeClient"),
            patch("ilum.cli.cleanup_cmd.IlumPaths") as mock_paths_cls,
            patch("ilum.cli.cleanup_cmd.ConfigManager"),
            patch("ilum.cli.cleanup_cmd.ModuleResolver"),
        ):
            mock_mgr = MagicMock()
            mock_mgr.release_exists.return_value = True
            mock_mgr.k8s.list_pvcs.return_value = []
            mock_rm_cls.return_value = mock_mgr
            mp = MagicMock()
            mp.all_dirs.return_value = []
            mock_paths_cls.default.return_value = mp
            result = cli_runner.invoke(app, ["cleanup", "--dry-run"])

        assert result.exit_code == 0
        assert "Would" in result.output
        mock_mgr.execute.assert_not_called()

    def test_all_flag_does_not_include_self(self, cli_runner) -> None:  # type: ignore[no-untyped-def]
        """--all enables cluster, config, deps but NOT self."""
        from ilum.cli.main import app

        with (
            patch(
                "ilum.cli.cleanup_cmd.resolve_profile_defaults",
                return_value=("ilum", "default", ""),
            ),
            patch("ilum.cli.cleanup_cmd.ReleaseManager") as mock_rm_cls,
            patch("ilum.cli.cleanup_cmd.HelmClient"),
            patch("ilum.cli.cleanup_cmd.KubeClient"),
            patch("ilum.cli.cleanup_cmd.ModuleResolver"),
            patch("ilum.cli.cleanup_cmd.IlumPaths") as mock_paths_cls,
            patch("ilum.cli.cleanup_cmd.ConfigManager") as mock_cm,
            patch(
                "ilum.cli.cleanup_cmd.detect_dependencies",
                return_value=[],
            ),
            patch(
                "ilum.cli.cleanup_cmd.remove_cli_directories",
                return_value=[],
            ),
            patch(
                "ilum.cli.cleanup_cmd.remove_helm_repo",
                return_value=False,
            ),
            patch("ilum.cli.cleanup_cmd.uninstall_cli") as mock_self,
        ):
            mock_mgr = MagicMock()
            mock_mgr.release_exists.return_value = False
            mock_rm_cls.return_value = mock_mgr
            mp = MagicMock()
            mp.all_dirs.return_value = []
            mock_paths_cls.default.return_value = mp
            mock_cfg = MagicMock()
            mock_cfg.clusters = []
            mock_cm.return_value.ensure_config.return_value = mock_cfg
            result = cli_runner.invoke(app, ["cleanup", "--all", "--yes"])

        # --self should NOT have been called
        mock_self.assert_not_called()
        assert "Tier 5" not in result.output

    def test_dry_run_skips_protected_namespace(self, cli_runner) -> None:  # type: ignore[no-untyped-def]
        """--dry-run shows 'Would skip' for protected namespaces like 'default'."""
        from ilum.cli.main import app

        with (
            patch(
                "ilum.cli.cleanup_cmd.resolve_profile_defaults",
                return_value=("ilum", "default", ""),
            ),
            patch("ilum.cli.cleanup_cmd.ReleaseManager") as mock_rm_cls,
            patch("ilum.cli.cleanup_cmd.HelmClient"),
            patch("ilum.cli.cleanup_cmd.KubeClient"),
            patch("ilum.cli.cleanup_cmd.IlumPaths") as mock_paths_cls,
            patch("ilum.cli.cleanup_cmd.ConfigManager"),
            patch("ilum.cli.cleanup_cmd.ModuleResolver"),
        ):
            mock_mgr = MagicMock()
            mock_mgr.release_exists.return_value = True
            mock_mgr.k8s.list_pvcs.return_value = []
            mock_rm_cls.return_value = mock_mgr
            mp = MagicMock()
            mp.all_dirs.return_value = []
            mock_paths_cls.default.return_value = mp
            result = cli_runner.invoke(app, ["cleanup", "--dry-run"])

        assert result.exit_code == 0
        assert "Would skip deletion of protected namespace" in result.output

    def test_execute_skips_protected_namespace_delete(self, cli_runner) -> None:  # type: ignore[no-untyped-def]
        """Execute mode skips delete_namespace for 'default'."""
        from ilum.cli.main import app

        with (
            patch(
                "ilum.cli.cleanup_cmd.resolve_profile_defaults",
                return_value=("ilum", "default", ""),
            ),
            patch("ilum.cli.cleanup_cmd.ReleaseManager") as mock_rm_cls,
            patch("ilum.cli.cleanup_cmd.HelmClient"),
            patch("ilum.cli.cleanup_cmd.KubeClient"),
            patch("ilum.cli.cleanup_cmd.IlumPaths") as mock_paths_cls,
            patch("ilum.cli.cleanup_cmd.ConfigManager"),
            patch("ilum.cli.cleanup_cmd.ModuleResolver"),
        ):
            mock_mgr = MagicMock()
            mock_mgr.release_exists.return_value = True
            mock_mgr.k8s.list_pvcs.return_value = []
            mock_mgr.k8s.delete_pvcs.return_value = []
            mock_rm_cls.return_value = mock_mgr
            mp = MagicMock()
            mp.all_dirs.return_value = []
            mock_paths_cls.default.return_value = mp
            result = cli_runner.invoke(app, ["cleanup", "--yes"])

        assert result.exit_code == 0
        assert "Skipping deletion of protected namespace" in result.output
        mock_mgr.k8s.delete_namespace.assert_not_called()
