"""Tests for ``ilum install`` command."""

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.config.models import IlumConfig, ProfileConfig
from ilum.core.helm import HelmResult
from ilum.core.release import ReleasePlan
from ilum.errors import ClusterConnectionError, ReleaseExistsError


@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 = []
    mgr.plan_install.return_value = ReleasePlan(
        action="install",
        release="ilum",
        namespace="default",
        chart="ilum/ilum",
        chart_version="6.7.0",
        set_flags=["ilum-core.enabled=true"],
        modules=["core"],
        warnings=[],
        computed_values={},
        atomic=True,
    )
    mgr.execute.return_value = HelmResult(
        returncode=0,
        stdout="ok",
        stderr="",
        command=[],
    )
    # Mock k8s client — namespace exists and no NodePort conflicts by default
    mgr.k8s = MagicMock()
    mgr.k8s.namespace_exists.return_value = True
    mgr.k8s.list_node_ports.return_value = []
    # Mock config_mgr for release_name persistence
    mgr.config_mgr = MagicMock()
    mgr.config_mgr.load.return_value = IlumConfig(
        active_profile="default",
        profiles={"default": ProfileConfig(name="default")},
    )
    return mgr


class TestInstallCommand:
    def test_install_help(self, runner: CliRunner) -> None:
        result = runner.invoke(app, ["install", "--help"])
        assert result.exit_code == 0
        assert "Install the Ilum platform" in result.output

    def test_install_dry_run(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--dry-run"])
        assert result.exit_code == 0
        assert "Dry-run" in result.output
        mock_mgr.execute.assert_not_called()

    def test_install_with_yes(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--yes"])
        assert result.exit_code == 0
        assert "installed successfully" in result.output
        mock_mgr.execute.assert_called_once()

    def test_install_with_modules(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--yes", "-m", "core", "-m", "sql"])
        assert result.exit_code == 0
        call_kwargs = mock_mgr.plan_install.call_args
        assert "core" in call_kwargs.kwargs["modules"]
        assert "sql" in call_kwargs.kwargs["modules"]

    def test_install_with_version(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--yes", "--version", "6.7.0"])
        assert result.exit_code == 0
        call_kwargs = mock_mgr.plan_install.call_args
        assert call_kwargs.kwargs["version"] == "6.7.0"

    def test_install_with_set_flags(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--yes", "--set", "foo=bar"])
        assert result.exit_code == 0

    def test_install_with_values_file(
        self, runner: CliRunner, mock_mgr: MagicMock, tmp_path
    ) -> None:
        vf = tmp_path / "values.yaml"
        vf.write_text("foo: bar")
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--yes", "-f", str(vf)])
        assert result.exit_code == 0

    def test_install_already_exists_error(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.plan_install.side_effect = ReleaseExistsError(
            "Release already exists", suggestion="Use 'ilum upgrade'"
        )
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--yes"])
        assert result.exit_code == 1

    def test_install_shows_summary(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--dry-run"])
        assert "Install Summary" in result.output
        assert "ilum" in result.output

    def test_install_shows_warnings(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        mock_mgr.plan_install.return_value = ReleasePlan(
            action="install",
            release="ilum",
            namespace="default",
            chart="ilum/ilum",
            modules=["core"],
            warnings=["Test warning"],
            computed_values={},
        )
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--dry-run"])
        assert "Test warning" in result.output

    def test_install_uses_default_modules(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        """When no --module is given and no profile modules, use default_enabled."""
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            runner.invoke(app, ["install", "--yes"])
        call_kwargs = mock_mgr.plan_install.call_args.kwargs
        # Should use resolver.default_enabled() since get_enabled_modules returns []
        assert len(call_kwargs["modules"]) > 0

    def test_install_custom_release_and_namespace(
        self, runner: CliRunner, mock_mgr: MagicMock
    ) -> None:
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(
                app,
                [
                    "install",
                    "--yes",
                    "-r",
                    "myrelease",
                    "-n",
                    "mynamespace",
                ],
            )
        assert result.exit_code == 0

    def test_install_saves_modules_on_success(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            runner.invoke(app, ["install", "--yes"])
        mock_mgr.save_enabled_modules.assert_called_once()

    def test_install_aborted_by_user(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install"], input="n\n")
        assert "Aborted" in result.output
        mock_mgr.execute.assert_not_called()

    def test_install_summary_shows_set_flags(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--dry-run", "--set", "foo=bar"])
        assert "foo=bar" in result.output

    def test_install_saves_release_name(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            runner.invoke(app, ["install", "--yes", "-r", "myrelease"])
        # Verify config_mgr.load() and save() were called (release_name persistence)
        mock_mgr.config_mgr.load.assert_called()
        mock_mgr.config_mgr.save.assert_called()

    def test_install_creates_namespace_when_missing(
        self, runner: CliRunner, mock_mgr: MagicMock
    ) -> None:
        mock_mgr.k8s.namespace_exists.return_value = False
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--yes", "-n", "ilum"])
        assert result.exit_code == 0
        mock_mgr.k8s.create_namespace.assert_called_once_with("ilum")
        assert "created" in result.output

    def test_install_skips_namespace_creation_when_exists(
        self, runner: CliRunner, mock_mgr: MagicMock
    ) -> None:
        mock_mgr.k8s.namespace_exists.return_value = True
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--yes"])
        assert result.exit_code == 0
        mock_mgr.k8s.create_namespace.assert_not_called()

    def test_install_namespace_creation_failure(
        self, runner: CliRunner, mock_mgr: MagicMock
    ) -> None:
        mock_mgr.k8s.namespace_exists.return_value = False
        mock_mgr.k8s.create_namespace.side_effect = ClusterConnectionError(
            "Failed to create namespace 'ilum': Forbidden",
            suggestion="Create the namespace manually with: kubectl create namespace ilum",
        )
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--yes", "-n", "ilum"])
        assert result.exit_code == 1
        assert "kubectl create namespace" in result.output
        mock_mgr.execute.assert_not_called()

    def test_install_dry_run_skips_namespace_creation(
        self, runner: CliRunner, mock_mgr: MagicMock
    ) -> None:
        mock_mgr.k8s.namespace_exists.return_value = False
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--dry-run", "-n", "ilum"])
        assert result.exit_code == 0
        mock_mgr.k8s.create_namespace.assert_not_called()

    def test_install_nodeport_conflict_auto_resolves_with_yes(
        self, runner: CliRunner, mock_mgr: MagicMock
    ) -> None:
        from ilum.core.kubernetes import KubeClient

        mock_mgr.k8s.list_node_ports.return_value = [
            KubeClient.NodePortInfo(port=31777, service_name="other-ui", namespace="other-ns"),
        ]
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--yes"])
        assert result.exit_code == 0
        assert "31777" in result.output
        assert "other-ui" in result.output
        assert "Reassigned" in result.output
        assert "30000" in result.output  # first free port
        mock_mgr.execute.assert_called_once()
        # Verify the override was appended to plan's set_flags
        plan = mock_mgr.execute.call_args[0][0]
        assert "ilum-ui.service.nodePort=30000" in plan.set_flags

    def test_install_nodeport_conflict_interactive_accept_suggested(
        self, runner: CliRunner, mock_mgr: MagicMock
    ) -> None:
        from ilum.core.kubernetes import KubeClient

        mock_mgr.k8s.list_node_ports.return_value = [
            KubeClient.NodePortInfo(port=31777, service_name="other-ui", namespace="other-ns"),
        ]
        # User presses Enter to accept the suggested free port
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install"], input="y\n\n")
        assert result.exit_code == 0
        assert "Reassigned" in result.output
        mock_mgr.execute.assert_called_once()

    def test_install_nodeport_conflict_interactive_custom_port(
        self, runner: CliRunner, mock_mgr: MagicMock
    ) -> None:
        from ilum.core.kubernetes import KubeClient

        mock_mgr.k8s.list_node_ports.return_value = [
            KubeClient.NodePortInfo(port=31777, service_name="other-ui", namespace="other-ns"),
        ]
        # User enters custom port 31999
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install"], input="y\n31999\n")
        assert result.exit_code == 0
        assert "31999" in result.output
        assert "Reassigned" in result.output
        plan = mock_mgr.execute.call_args[0][0]
        assert "ilum-ui.service.nodePort=31999" in plan.set_flags

    def test_install_nodeport_conflict_interactive_abort(
        self, runner: CliRunner, mock_mgr: MagicMock
    ) -> None:
        from ilum.core.kubernetes import KubeClient

        mock_mgr.k8s.list_node_ports.return_value = [
            KubeClient.NodePortInfo(port=31777, service_name="other-ui", namespace="other-ns"),
        ]
        # User types 'q' to abort
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install"], input="y\nq\n")
        assert "Aborted" in result.output
        mock_mgr.execute.assert_not_called()

    def test_install_no_nodeport_conflict(self, runner: CliRunner, mock_mgr: MagicMock) -> None:
        from ilum.core.kubernetes import KubeClient

        # Port 32000 is allocated but doesn't conflict with Ilum's 31777
        mock_mgr.k8s.list_node_ports.return_value = [
            KubeClient.NodePortInfo(port=32000, service_name="some-svc", namespace="default"),
        ]
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--yes"])
        assert result.exit_code == 0
        mock_mgr.execute.assert_called_once()

    def test_install_dry_run_skips_nodeport_check(
        self, runner: CliRunner, mock_mgr: MagicMock
    ) -> None:
        from ilum.core.kubernetes import KubeClient

        mock_mgr.k8s.list_node_ports.return_value = [
            KubeClient.NodePortInfo(port=31777, service_name="other-ui", namespace="other-ns"),
        ]
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--dry-run"])
        assert result.exit_code == 0
        mock_mgr.k8s.list_node_ports.assert_not_called()

    def test_install_dry_run_shows_command_preview(
        self, runner: CliRunner, mock_mgr: MagicMock
    ) -> None:
        mock_mgr.preview_command.return_value = [
            "helm",
            "install",
            "ilum",
            "ilum/ilum",
            "--atomic",
        ]
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--dry-run"])
        assert result.exit_code == 0
        assert "Command:" in result.output
        assert "helm install" in result.output

    def test_install_with_yes_shows_command_preview(
        self, runner: CliRunner, mock_mgr: MagicMock
    ) -> None:
        mock_mgr.preview_command.return_value = [
            "helm",
            "install",
            "ilum",
            "ilum/ilum",
            "--atomic",
        ]
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--yes"])
        assert result.exit_code == 0
        assert "Command:" in result.output


class TestInstallPreset:
    def test_install_with_preset_uses_preset_modules(self, runner, mock_mgr):
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--dry-run", "--preset", "development"])
        assert result.exit_code == 0
        call_kwargs = mock_mgr.plan_install.call_args.kwargs
        modules = call_kwargs["modules"]
        assert "core" in modules
        assert "jupyter" in modules

    def test_install_with_preset_adds_set_flags(self, runner, mock_mgr):
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--dry-run", "--preset", "production"])
        assert result.exit_code == 0
        call_kwargs = mock_mgr.plan_install.call_args.kwargs
        flags = call_kwargs["set_flags"]
        assert any("global.security.enabled=true" in f for f in flags)

    def test_install_preset_plus_extra_modules(self, runner, mock_mgr):
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(
                app, ["install", "--dry-run", "--preset", "development", "-m", "sql"]
            )
        assert result.exit_code == 0
        call_kwargs = mock_mgr.plan_install.call_args.kwargs
        modules = call_kwargs["modules"]
        assert "core" in modules
        assert "sql" in modules

    def test_install_user_set_flags_override_preset(self, runner, mock_mgr):
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(
                app,
                [
                    "install",
                    "--dry-run",
                    "--preset",
                    "production",
                    "--set",
                    "ilum-core.replicaCount=5",
                ],
            )
        assert result.exit_code == 0
        call_kwargs = mock_mgr.plan_install.call_args.kwargs
        flags = call_kwargs["set_flags"]
        assert any("ilum-core.replicaCount=5" in f for f in flags)

    def test_install_invalid_preset_fails(self, runner, mock_mgr):
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--dry-run", "--preset", "nonexistent"])
        assert result.exit_code == 1
        assert "Unknown preset" in result.output

    def test_install_airgapped_preset_requires_registry(self, runner, mock_mgr):
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--dry-run", "--preset", "air-gapped", "--yes"])
        assert result.exit_code == 1
        assert "global.imageRegistry" in result.output

    def test_install_airgapped_preset_with_registry_succeeds(self, runner, mock_mgr):
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(
                app,
                [
                    "install",
                    "--dry-run",
                    "--preset",
                    "air-gapped",
                    "--set",
                    "global.imageRegistry=registry.local:5000",
                ],
            )
        assert result.exit_code == 0

    def test_install_summary_shows_preset_name(self, runner, mock_mgr):
        with patch("ilum.cli.install_cmd._build_manager", return_value=mock_mgr):
            result = runner.invoke(app, ["install", "--dry-run", "--preset", "production"])
        assert "production" in result.output
