"""Tests for ``ilum module`` sub-commands."""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest
from typer.testing import CliRunner

from ilum.cli.main import app
from ilum.core.helm import HelmResult
from ilum.core.release import ReleasePlan
from ilum.core.safety import DriftResult, ValuesDiff
from ilum.errors import ReleaseNotFoundError


@pytest.fixture()
def runner() -> CliRunner:
    return CliRunner()


@pytest.fixture()
def mock_mgr() -> MagicMock:
    from ilum.core.modules import ModuleResolver
    from ilum.core.release import ReleaseManager

    mgr = MagicMock(spec=ReleaseManager)
    mgr.resolver = ModuleResolver()
    mgr.get_enabled_modules.return_value = ["core", "ui"]
    mgr.execute.return_value = HelmResult(
        returncode=0,
        stdout="ok",
        stderr="",
        command=[],
    )
    return mgr


# ---------------------------------------------------------------------------
# module list
# ---------------------------------------------------------------------------


class TestModuleList:
    def test_list_all_modules(self, runner: CliRunner) -> None:
        result = runner.invoke(app, ["module", "list"])
        assert result.exit_code == 0
        assert "Ilum Modules" in result.output
        assert "core" in result.output
        assert "jupyter" in result.output

    def test_list_filter_by_category(self, runner: CliRunner) -> None:
        result = runner.invoke(app, ["module", "list", "--category", "sql"])
        assert result.exit_code == 0
        assert "sql" in result.output

    def test_list_invalid_category(self, runner: CliRunner) -> None:
        result = runner.invoke(app, ["module", "list", "--category", "bogus"])
        assert result.exit_code == 1
        assert "Unknown category" in result.output

    def test_list_filter_default_enabled(self, runner: CliRunner) -> None:
        result = runner.invoke(app, ["module", "list", "--enabled"])
        assert result.exit_code == 0
        # core, ui, mongodb, minio, postgresql, jupyter, gitea are default
        assert "core" in result.output

    def test_list_filter_disabled(self, runner: CliRunner) -> None:
        result = runner.invoke(app, ["module", "list", "--disabled"])
        assert result.exit_code == 0
        # airflow is not default enabled
        assert "airflow" in result.output


# ---------------------------------------------------------------------------
# module show
# ---------------------------------------------------------------------------


class TestModuleShow:
    def test_show_module(self, runner: CliRunner) -> None:
        result = runner.invoke(app, ["module", "show", "sql"])
        assert result.exit_code == 0
        assert "sql" in result.output
        assert "Kyuubi" in result.output
        assert "Requires" in result.output or "postgresql" in result.output

    def test_show_unknown_module(self, runner: CliRunner) -> None:
        result = runner.invoke(app, ["module", "show", "nonexistent"])
        assert result.exit_code == 1

    def test_show_dependency_chain(self, runner: CliRunner) -> None:
        result = runner.invoke(app, ["module", "show", "sql"])
        assert result.exit_code == 0
        assert "Resolved enable chain" in result.output


# ---------------------------------------------------------------------------
# module enable
# ---------------------------------------------------------------------------


class TestModuleEnable:
    def test_enable_module(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.plan_enable.return_value = ReleasePlan(
            action="enable",
            release="ilum",
            namespace="default",
            chart="ilum/ilum",
            modules=["sql"],
            set_flags=["ilum-sql.enabled=true"],
            computed_values={},
            effective_diff=ValuesDiff(added={"ilum-sql.enabled": True}),
            drift=DriftResult(
                has_drift=False, snapshot_exists=True, diff=ValuesDiff(), live_values={}
            ),
        )
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "enable", "sql", "--yes"])
        assert result.exit_code == 0
        assert "Enabled" in result.output

    def test_enable_release_not_found(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.plan_enable.side_effect = ReleaseNotFoundError("Not found")
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "enable", "sql", "--yes"])
        assert result.exit_code == 1

    def test_enable_no_changes(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.plan_enable.return_value = ReleasePlan(
            action="enable",
            release="ilum",
            namespace="default",
            chart="ilum/ilum",
            modules=["core"],
            set_flags=[],
            computed_values={},
            effective_diff=ValuesDiff(),
            drift=DriftResult(
                has_drift=False, snapshot_exists=True, diff=ValuesDiff(), live_values={}
            ),
        )
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "enable", "core", "--yes"])
        assert result.exit_code == 0
        assert "No values changes" in result.output
        mock_mgr.execute.assert_not_called()

    def test_enable_updates_config(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.plan_enable.return_value = ReleasePlan(
            action="enable",
            release="ilum",
            namespace="default",
            chart="ilum/ilum",
            modules=["sql"],
            set_flags=["ilum-sql.enabled=true"],
            computed_values={},
            effective_diff=ValuesDiff(added={"ilum-sql.enabled": True}),
            drift=DriftResult(
                has_drift=False, snapshot_exists=True, diff=ValuesDiff(), live_values={}
            ),
        )
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            runner.invoke(app, ["module", "enable", "sql", "--yes"])
        mock_mgr.save_enabled_modules.assert_called_once()

    def test_enable_shows_summary(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.plan_enable.return_value = ReleasePlan(
            action="enable",
            release="ilum",
            namespace="default",
            chart="ilum/ilum",
            modules=["sql"],
            set_flags=["ilum-sql.enabled=true"],
            computed_values={},
            effective_diff=ValuesDiff(added={"ilum-sql.enabled": True}),
            drift=DriftResult(
                has_drift=False, snapshot_exists=True, diff=ValuesDiff(), live_values={}
            ),
        )
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "enable", "sql", "--yes"])
        assert "Enable Summary" in result.output

    def test_enable_shows_auto_deps(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.plan_enable.return_value = ReleasePlan(
            action="enable",
            release="ilum",
            namespace="default",
            chart="ilum/ilum",
            modules=["sql"],
            set_flags=["ilum-sql.enabled=true"],
            computed_values={},
            effective_diff=ValuesDiff(added={"ilum-sql.enabled": True}),
            drift=DriftResult(
                has_drift=False, snapshot_exists=True, diff=ValuesDiff(), live_values={}
            ),
        )
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "enable", "sql", "--yes"])
        # sql depends on postgresql and core
        assert "Auto-resolved dependencies" in result.output
        assert "postgresql" in result.output
        assert "core" in result.output

    def test_enable_multiple_modules(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.plan_enable.return_value = ReleasePlan(
            action="enable",
            release="ilum",
            namespace="default",
            chart="ilum/ilum",
            modules=["sql", "airflow"],
            set_flags=["ilum-sql.enabled=true", "airflow.enabled=true"],
            computed_values={},
            effective_diff=ValuesDiff(added={"ilum-sql.enabled": True, "airflow.enabled": True}),
            drift=DriftResult(
                has_drift=False, snapshot_exists=True, diff=ValuesDiff(), live_values={}
            ),
        )
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "enable", "sql", "airflow", "--yes"])
        assert result.exit_code == 0

    def test_enable_dry_run(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.plan_enable.return_value = ReleasePlan(
            action="enable",
            release="ilum",
            namespace="default",
            chart="ilum/ilum",
            modules=["sql"],
            set_flags=["ilum-sql.enabled=true"],
            computed_values={},
            effective_diff=ValuesDiff(added={"ilum-sql.enabled": True}),
            drift=DriftResult(
                has_drift=False, snapshot_exists=True, diff=ValuesDiff(), live_values={}
            ),
        )
        mock_mgr.preview_command.return_value = [
            "helm",
            "upgrade",
            "ilum",
            "ilum/ilum",
            "--set",
            "ilum-sql.enabled=true",
        ]
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "enable", "sql", "--dry-run"])
        assert result.exit_code == 0
        assert "Dry-run" in result.output
        assert "Command:" in result.output
        mock_mgr.execute.assert_not_called()

    def test_enable_no_changes_takes_priority_over_dry_run(
        self, runner: CliRunner, mock_mgr: MagicMock
    ) -> None:
        mock_mgr.plan_enable.return_value = ReleasePlan(
            action="enable",
            release="ilum",
            namespace="default",
            chart="ilum/ilum",
            modules=["core"],
            set_flags=[],
            computed_values={},
            effective_diff=ValuesDiff(),
            drift=DriftResult(
                has_drift=False, snapshot_exists=True, diff=ValuesDiff(), live_values={}
            ),
        )
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "enable", "core", "--dry-run"])
        assert result.exit_code == 0
        assert "No values changes" in result.output
        # Should NOT show "Dry-run" because no-changes early return took priority
        mock_mgr.execute.assert_not_called()


# ---------------------------------------------------------------------------
# module disable
# ---------------------------------------------------------------------------


class TestModuleDisable:
    def test_disable_module(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.plan_disable.return_value = ReleasePlan(
            action="disable",
            release="ilum",
            namespace="default",
            chart="ilum/ilum",
            modules=["jupyter"],
            set_flags=["ilum-jupyter.enabled=false"],
            computed_values={},
            effective_diff=ValuesDiff(changed={"ilum-jupyter.enabled": (True, False)}),
            drift=DriftResult(
                has_drift=False, snapshot_exists=True, diff=ValuesDiff(), live_values={}
            ),
            warnings=[],
        )
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "disable", "jupyter", "--yes"])
        assert result.exit_code == 0
        assert "Disabled" in result.output

    def test_disable_release_not_found(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.plan_disable.side_effect = ReleaseNotFoundError("Not found")
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "disable", "jupyter", "--yes"])
        assert result.exit_code == 1

    def test_disable_default_module_warns(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.plan_disable.return_value = ReleasePlan(
            action="disable",
            release="ilum",
            namespace="default",
            chart="ilum/ilum",
            modules=["core"],
            set_flags=["ilum-core.enabled=false"],
            computed_values={},
            effective_diff=ValuesDiff(changed={"ilum-core.enabled": (True, False)}),
            drift=DriftResult(
                has_drift=False, snapshot_exists=True, diff=ValuesDiff(), live_values={}
            ),
            warnings=["Module 'core' is a default module."],
        )
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "disable", "core", "--yes"])
        assert result.exit_code == 0
        assert "default module" in result.output

    def test_disable_updates_config(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.plan_disable.return_value = ReleasePlan(
            action="disable",
            release="ilum",
            namespace="default",
            chart="ilum/ilum",
            modules=["jupyter"],
            set_flags=["ilum-jupyter.enabled=false"],
            computed_values={},
            effective_diff=ValuesDiff(changed={"ilum-jupyter.enabled": (True, False)}),
            drift=DriftResult(
                has_drift=False, snapshot_exists=True, diff=ValuesDiff(), live_values={}
            ),
            warnings=[],
        )
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            runner.invoke(app, ["module", "disable", "jupyter", "--yes"])
        mock_mgr.save_enabled_modules.assert_called_once()

    def test_disable_shows_summary(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.plan_disable.return_value = ReleasePlan(
            action="disable",
            release="ilum",
            namespace="default",
            chart="ilum/ilum",
            modules=["jupyter"],
            set_flags=["ilum-jupyter.enabled=false"],
            computed_values={},
            effective_diff=ValuesDiff(changed={"ilum-jupyter.enabled": (True, False)}),
            drift=DriftResult(
                has_drift=False, snapshot_exists=True, diff=ValuesDiff(), live_values={}
            ),
            warnings=[],
        )
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "disable", "jupyter", "--yes"])
        assert "Disable Summary" in result.output

    def test_disable_no_changes(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.plan_disable.return_value = ReleasePlan(
            action="disable",
            release="ilum",
            namespace="default",
            chart="ilum/ilum",
            modules=["sql"],
            set_flags=[],
            computed_values={},
            effective_diff=ValuesDiff(),
            drift=DriftResult(
                has_drift=False, snapshot_exists=True, diff=ValuesDiff(), live_values={}
            ),
            warnings=[],
        )
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "disable", "sql", "--yes"])
        assert "No values changes" in result.output
        mock_mgr.execute.assert_not_called()

    def test_disable_dry_run(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.plan_disable.return_value = ReleasePlan(
            action="disable",
            release="ilum",
            namespace="default",
            chart="ilum/ilum",
            modules=["jupyter"],
            set_flags=["ilum-jupyter.enabled=false"],
            computed_values={},
            effective_diff=ValuesDiff(changed={"ilum-jupyter.enabled": (True, False)}),
            drift=DriftResult(
                has_drift=False, snapshot_exists=True, diff=ValuesDiff(), live_values={}
            ),
            warnings=[],
        )
        mock_mgr.preview_command.return_value = [
            "helm",
            "upgrade",
            "ilum",
            "ilum/ilum",
            "--set",
            "ilum-jupyter.enabled=false",
        ]
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "disable", "jupyter", "--dry-run"])
        assert result.exit_code == 0
        assert "Dry-run" in result.output
        assert "Command:" in result.output
        mock_mgr.execute.assert_not_called()

    def test_disable_no_changes_takes_priority_over_dry_run(
        self, runner: CliRunner, mock_mgr: MagicMock
    ) -> None:
        mock_mgr.plan_disable.return_value = ReleasePlan(
            action="disable",
            release="ilum",
            namespace="default",
            chart="ilum/ilum",
            modules=["sql"],
            set_flags=[],
            computed_values={},
            effective_diff=ValuesDiff(),
            drift=DriftResult(
                has_drift=False, snapshot_exists=True, diff=ValuesDiff(), live_values={}
            ),
            warnings=[],
        )
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "disable", "sql", "--dry-run"])
        assert result.exit_code == 0
        assert "No values changes" in result.output
        mock_mgr.execute.assert_not_called()


# ---------------------------------------------------------------------------
# module status
# ---------------------------------------------------------------------------


class TestModuleStatus:
    def test_status_shows_enabled_modules(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.fetch_computed_values.return_value = {
            "ilum-jupyter": {"enabled": True},
            "ilum-core": {"enabled": True},
        }
        mock_mgr.get_enabled_modules.return_value = ["core", "jupyter"]
        mock_mgr.k8s = MagicMock()
        mock_mgr.k8s.list_pods_by_label.return_value = []
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "status"])
        assert result.exit_code == 0
        assert "Enabled" in result.output

    def test_status_shows_disabled_modules(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.fetch_computed_values.return_value = {}
        mock_mgr.get_enabled_modules.return_value = []
        mock_mgr.k8s = MagicMock()
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "status"])
        assert result.exit_code == 0
        assert "Disabled" in result.output

    def test_status_release_not_found(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        from ilum.errors import ReleaseNotFoundError

        mock_mgr.fetch_computed_values.side_effect = ReleaseNotFoundError("Not found")
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "status"])
        assert result.exit_code == 1

    def test_status_category_filter(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.fetch_computed_values.return_value = {"ilum-sql": {"enabled": True}}
        mock_mgr.get_enabled_modules.return_value = ["sql"]
        mock_mgr.k8s = MagicMock()
        mock_mgr.k8s.list_pods_by_label.return_value = []
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "status", "--category", "sql"])
        assert result.exit_code == 0
        assert "sql" in result.output
        # core category modules should not appear
        assert "mongodb" not in result.output

    def test_status_uses_pod_label(
        self, runner: CliRunner, mock_mgr: MagicMock, tmp_config_dir
    ) -> None:
        """Verify the pod query uses mod.pod_label, not a hardcoded label."""
        mock_mgr.fetch_computed_values.return_value = {
            "ilum-core": {"enabled": True},
        }
        mock_mgr.get_enabled_modules.return_value = ["core"]
        mock_mgr.k8s = MagicMock()
        mock_mgr.k8s.list_pods_by_label.return_value = []
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "status"])
        assert result.exit_code == 0
        # core uses the simple 'app=ilum-core' label
        mock_mgr.k8s.list_pods_by_label.assert_called_with("default", "app=ilum-core")

    def test_status_auto_syncs_when_config_empty(
        self, runner: CliRunner, mock_mgr: MagicMock
    ) -> None:
        """When config has no tracked modules but live modules exist, auto-sync."""
        mock_mgr.fetch_computed_values.return_value = {
            "ilum-core": {"enabled": True},
            "ilum-ui": {"enabled": True},
        }
        mock_mgr.get_enabled_modules.return_value = []
        mock_mgr.k8s = MagicMock()
        mock_mgr.k8s.list_pods_by_label.return_value = []
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "status"])
        assert result.exit_code == 0
        assert "Synced" in result.output
        mock_mgr.save_enabled_modules.assert_called_once()
        # Should not show drift warnings
        assert "Config drift" not in result.output

    def test_status_shows_drift_warning(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        # Config tracks 'sql' but live doesn't have it
        mock_mgr.fetch_computed_values.return_value = {
            "ilum-core": {"enabled": True},
        }
        mock_mgr.get_enabled_modules.return_value = ["core", "sql"]
        mock_mgr.k8s = MagicMock()
        mock_mgr.k8s.list_pods_by_label.return_value = []
        with patch("ilum.cli.module_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["module", "status"])
        assert "Config drift" in result.output
        assert "sql" in result.output
