"""``ilum install`` command."""

from __future__ import annotations

import contextlib
from pathlib import Path

import typer

import ilum.cli.output as output_mod
from ilum.cli.defaults import resolve_profile_defaults
from ilum.config.manager import ConfigManager
from ilum.config.paths import IlumPaths
from ilum.constants import DEFAULT_CHART_REF, DEFAULT_NODEPORTS, is_local_chart
from ilum.core.helm import HelmClient
from ilum.core.kubernetes import KubeClient
from ilum.core.modules import ModuleResolver
from ilum.core.release import ReleaseManager, ReleasePlan
from ilum.errors import IlumError
from ilum.wizard.deps import ensure_tools


def _build_manager(
    context: str,
    namespace: str,
    dry_run: bool,
    timeout: str,
) -> ReleaseManager:
    paths = IlumPaths.default()
    paths.ensure_dirs()
    return ReleaseManager(
        helm=HelmClient(
            kubecontext=context,
            namespace=namespace,
            dry_run=dry_run,
            timeout=timeout,
        ),
        k8s=KubeClient(kubecontext=context),
        resolver=ModuleResolver(),
        config_mgr=ConfigManager(paths),
        paths=paths,
    )


_NODEPORT_RANGE = (30000, 32767)


def _find_free_nodeport(allocated_ports: set[int]) -> int | None:
    """Return the first free port in the Kubernetes NodePort range."""
    for port in range(_NODEPORT_RANGE[0], _NODEPORT_RANGE[1] + 1):
        if port not in allocated_ports:
            return port
    return None


def _resolve_nodeport_conflicts(
    mgr: ReleaseManager,
    plan: ReleasePlan,
    console: output_mod.IlumConsole,
    yes: bool,
) -> None:
    """Detect NodePort conflicts and interactively resolve them.

    When a default Ilum NodePort is already allocated on the cluster the user
    is offered to auto-assign a free port, enter a custom one, or abort.
    Resolved overrides are appended to *plan.set_flags* so Helm picks them up.
    """
    # Build a mapping of values-key → user-provided port from --set flags
    user_overrides: dict[str, int] = {}
    for flag in plan.set_flags or []:
        for _default_port, (_, helm_key) in DEFAULT_NODEPORTS.items():
            if flag.startswith(f"{helm_key}="):
                with contextlib.suppress(ValueError):
                    user_overrides[helm_key] = int(flag.split("=", 1)[1])

    # Determine which ports to check: user override or chart default
    ports_to_check: dict[int, tuple[str, str]] = {}
    for default_port, (description, helm_key) in DEFAULT_NODEPORTS.items():
        actual_port = user_overrides.get(helm_key, default_port)
        if actual_port:
            ports_to_check[actual_port] = (description, helm_key)

    if not ports_to_check:
        return

    allocated = mgr.k8s.list_node_ports()
    allocated_ports = {np.port for np in allocated}
    allocated_map = {np.port: np for np in allocated}

    for port, (description, helm_key) in ports_to_check.items():
        if port not in allocated_map:
            continue

        holder = allocated_map[port]
        console.warning(
            f"NodePort {port} ({description}) is already used by "
            f"service '{holder.service_name}' in namespace '{holder.namespace}'."
        )

        free_port = _find_free_nodeport(allocated_ports)
        if free_port is None:
            raise IlumError(
                "No free NodePort available in the 30000-32767 range.",
                suggestion=f"Free up a port or override manually: --set {helm_key}=<port>",
            )

        if yes:
            # Non-interactive: auto-assign
            chosen = free_port
        else:
            console.info(f"Suggested free port: {free_port}")
            response = console.prompt_text(
                f"Enter a replacement port for {description} "
                f"(press Enter for {free_port}, or 'q' to abort)"
            )
            response = response.strip()
            if response.lower() == "q":
                console.info("Aborted.")
                raise typer.Exit()
            if response == "":
                chosen = free_port
            else:
                try:
                    chosen = int(response)
                except ValueError:
                    raise IlumError(
                        f"Invalid port number: {response}",
                        suggestion="Provide a numeric port in the 30000-32767 range.",
                    ) from None
                if not (_NODEPORT_RANGE[0] <= chosen <= _NODEPORT_RANGE[1]):
                    raise IlumError(
                        f"Port {chosen} is outside the NodePort range "
                        f"({_NODEPORT_RANGE[0]}-{_NODEPORT_RANGE[1]}).",
                    )
                if chosen in allocated_ports:
                    raise IlumError(
                        f"Port {chosen} is also already allocated.",
                        suggestion=f"Try a different port or use the suggested {free_port}.",
                    )

        plan.set_flags.append(f"{helm_key}={chosen}")
        allocated_ports.add(chosen)
        console.success(f"Reassigned {description} NodePort: {port} -> {chosen}")


def install(
    release: str | None = typer.Option(
        None, "--release", "-r", show_default="from profile", help="Helm release name."
    ),
    namespace: str | None = typer.Option(
        None, "--namespace", "-n", show_default="from profile", help="Kubernetes namespace."
    ),
    context: str | None = typer.Option(
        None, "--context", show_default="from profile", help="Kubernetes context."
    ),
    version: str = typer.Option("", "--version", help="Chart version to install."),
    values: list[Path] | None = typer.Option(  # noqa: B008
        None,
        "--values",
        "-f",
        help="Values file (repeatable).",
    ),
    set_flags: list[str] | None = typer.Option(  # noqa: B008
        None,
        "--set",
        help="Helm --set flag (repeatable).",
    ),
    module: list[str] | None = typer.Option(  # noqa: B008
        None,
        "--module",
        "-m",
        help="Module to enable (repeatable).",
    ),
    preset: str = typer.Option(
        "",
        "--preset",
        help="Deployment preset (development, production, data-engineering, air-gapped).",
    ),
    atomic: bool = typer.Option(False, help="Use --atomic for Helm install."),
    chart: str = typer.Option(
        "", "--chart", help="Chart reference or local path (default: ilum/ilum)."
    ),
    devel: bool = typer.Option(False, "--devel", help="Include pre-release chart versions."),
    yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
    dry_run: bool = typer.Option(False, "--dry-run", help="Preview without executing."),
    timeout: str = typer.Option("10m", "--timeout", help="Helm timeout."),
) -> None:
    """Install the Ilum platform via Helm."""
    release, namespace, context = resolve_profile_defaults(release, namespace, context)
    console = output_mod.console

    try:
        # Validate values files exist
        for vf in values or []:
            if not vf.exists():
                from ilum.errors import ConfigError

                raise ConfigError(
                    f"Values file not found: {vf}",
                    suggestion="Check the file path and try again.",
                    error_code="ILUM-030",
                )

        ensure_tools(["helm"], console, interactive=not yes)
        mgr = _build_manager(context, namespace, dry_run, timeout)
        chart_ref = chart or DEFAULT_CHART_REF
        if not is_local_chart(chart_ref):
            mgr.ensure_repo()

        # Determine modules: --module > --preset > profile > defaults
        from ilum.core.presets import get_preset

        cli_modules = list(module or [])
        preset_obj = None
        preset_set_flags: list[str] = []

        if preset:
            preset_obj = get_preset(preset)
            if preset_obj is None:
                console.error(f"Unknown preset: '{preset}'")
                available = ", ".join(
                    ["development", "production", "data-engineering", "air-gapped"]
                )
                console.info(f"Available presets: {available}")
                raise typer.Exit(code=1)

        # Module priority: --module + --preset, else --preset, else profile, else defaults
        if cli_modules and preset_obj:
            # Both: preset modules + extra CLI modules
            base = list(preset_obj.modules)
            extra = [m for m in cli_modules if m not in base]
            modules = base + extra
        elif cli_modules:
            modules = cli_modules
        elif preset_obj:
            modules = list(preset_obj.modules)
        else:
            modules = mgr.get_enabled_modules()
            if not modules:
                modules = mgr.resolver.default_enabled()

        # Merge preset set_flags (user --set flags override preset flags by key)
        if preset_obj and preset_obj.set_flags:
            user_keys = set()
            for flag in set_flags or []:
                key = flag.split("=", 1)[0]
                user_keys.add(key)
            for pflag in preset_obj.set_flags:
                pkey = pflag.split("=", 1)[0]
                if pkey not in user_keys:
                    preset_set_flags.append(pflag)

        # Validate requires_input (air-gapped preset needs global.imageRegistry)
        if preset_obj and preset_obj.requires_input:
            user_provided_keys = {f.split("=", 1)[0] for f in (set_flags or [])}
            missing = [k for k in preset_obj.requires_input if k not in user_provided_keys]
            if missing:
                # Remove placeholder preset flags for keys that need user input
                preset_set_flags = [
                    f for f in preset_set_flags if f.split("=", 1)[0] not in missing
                ]
                if yes or dry_run:
                    console.error(f"Preset '{preset}' requires values: {', '.join(missing)}")
                    console.info(f"Provide via --set, e.g.: --set {missing[0]}=<value>")
                    raise typer.Exit(code=1)
                # Interactive: prompt for each missing key
                for key in missing:
                    value = console.prompt_text(f"Enter value for {key}")
                    preset_set_flags.append(f"{key}={value}")

        # Combine all set_flags
        combined_set_flags = list(set_flags or []) + preset_set_flags

        plan = mgr.plan_install(
            release=release,
            chart=chart_ref,
            modules=modules,
            values_files=values,
            set_flags=combined_set_flags or None,
            version=version,
            atomic=atomic,
            devel=devel,
        )

        # Display summary
        rows = [
            ["Release", plan.release],
            ["Namespace", plan.namespace],
        ]
        if preset:
            rows.insert(0, ["Preset", preset])
        rows += [
            ["Chart", plan.chart],
            ["Version", plan.chart_version or "latest"],
            ["Modules", ", ".join(plan.modules) or "none"],
            ["Atomic", str(plan.atomic)],
        ]
        if plan.set_flags:
            # Show user-provided --set flags (not module-generated ones)
            user_flags = list(set_flags or [])
            if user_flags:
                rows.append(["Helm --set flags", "; ".join(user_flags)])
        console.operation_summary(rows, title="Install Summary")

        if plan.warnings:
            for w in plan.warnings:
                console.warning(w)

        console.command_preview(mgr.preview_command(plan))

        if dry_run:
            console.info("Dry-run mode — no changes applied.")
            return

        if not yes and not console.confirm("Proceed with installation?"):
            console.info("Aborted.")
            raise typer.Exit()

        # Ensure the target namespace exists before Helm install
        try:
            if not mgr.k8s.namespace_exists(namespace):
                console.info(f"Namespace '{namespace}' does not exist — creating it.")
                mgr.k8s.create_namespace(namespace)
                console.success(f"Namespace '{namespace}' created.")
        except IlumError as ns_exc:
            console.handle_error(ns_exc)
            raise typer.Exit(code=1) from ns_exc

        # Detect and resolve NodePort conflicts before Helm install
        flags_before = list(plan.set_flags or [])
        try:
            _resolve_nodeport_conflicts(mgr, plan, console, yes)
        except IlumError as np_exc:
            console.handle_error(np_exc)
            raise typer.Exit(code=1) from np_exc

        # Show updated command only if nodeport resolution changed the flags
        if list(plan.set_flags or []) != flags_before:
            console.command_preview(mgr.preview_command(plan))

        from ilum.cli.progress import execute_with_progress

        execute_with_progress(mgr, plan, console, message="Installing Ilum...")

        # Persist release_name on the active profile
        config = mgr.config_mgr.load()
        profile_name = config.active_profile
        if profile_name in config.profiles:
            config.profiles[profile_name].release_name = release
            config.profiles[profile_name].cluster.namespace = namespace
            mgr.config_mgr.save(config)

        mgr.save_enabled_modules(modules, release=release)
        console.success(f"Ilum installed successfully (release: {release}).")

    except IlumError as exc:
        console.handle_error(exc)
        raise typer.Exit(code=1) from exc
