"""DeployService — business logic for deploying applications.

Handles: deploy (--image and build-from-git), image application,
rollout waiting.
"""

from __future__ import annotations

import json
import time
from collections.abc import Callable
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any

from kuberoku.exceptions import AppNotFoundError, RolloutTimeoutError
from kuberoku.k8s import labels, resources
from kuberoku.models import ProcessFormation, Release
from kuberoku.sdk.procfile import parse_procfile

if TYPE_CHECKING:
    from pathlib import Path

    from kuberoku.factory import KuberokuFactory
    from kuberoku.k8s.protocols import K8sClientProtocol


# ── Port parsing ────────────────────────────────────────────────────


def parse_port(spec: str) -> dict[str, Any]:
    """Parse a port spec like '8080/tcp' into a container port dict.

    Format: PORT[/PROTOCOL]. Protocol defaults to TCP. Valid protocols: TCP, UDP.
    """
    if "/" in spec:
        port_str, proto = spec.rsplit("/", 1)
        proto = proto.upper()
    else:
        port_str = spec
        proto = "TCP"

    if proto not in ("TCP", "UDP"):
        raise ValueError(f"Invalid protocol '{proto}'. Must be TCP or UDP.")

    try:
        port = int(port_str)
    except ValueError:
        raise ValueError(f"Invalid port number '{port_str}'.") from None

    if port < 1 or port > 65535:
        raise ValueError(f"Port {port} out of range (1-65535).")

    return {"containerPort": port, "protocol": proto}


def default_web_ports() -> list[dict[str, Any]]:
    """Default ports for a web process type."""
    return [{"containerPort": 8080, "protocol": "TCP"}]


def _service_port_name(port: int, protocol: str) -> str:
    """Generate a K8s-compliant port name like ``tcp-8080`` or ``udp-53``."""
    return f"{protocol.lower()}-{port}"


def _service_ports_from_container_ports(
    container_ports: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    """Convert container ports to Service ports.

    Always includes a ``name`` field so that K8s accepts the Service
    regardless of how many ports it has (K8s requires names when >1 port).
    """
    return [
        {
            "name": _service_port_name(cp["containerPort"], cp["protocol"]),
            "port": cp["containerPort"],
            "targetPort": cp["containerPort"],
            "protocol": cp["protocol"],
        }
        for cp in container_ports
    ]


def _netpol_ports_from_container_ports(
    container_ports: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    """Convert container ports to NetworkPolicy ports."""
    return [{"port": cp["containerPort"], "protocol": cp["protocol"]} for cp in container_ports]


# ── DeployService ───────────────────────────────────────────────────


class DeployService:
    """Business logic for deploy operations."""

    def __init__(self, factory: KuberokuFactory) -> None:
        self._factory = factory

    @property
    def _k8s(self) -> K8sClientProtocol:
        return self._factory.k8s

    @property
    def _ns(self) -> str:
        return self._factory.namespace

    @property
    def _prefix(self) -> str:
        return self._factory.prefix

    @property
    def _domain(self) -> str:
        return self._factory.domain

    def _update_deployment_with_retry(
        self,
        dep_name: str,
        image: str,
        command: str | None,
        ports: list[dict[str, Any]] | None,
    ) -> None:
        """Update a deployment, retrying on 409 Conflict (stale resourceVersion)."""
        for _attempt in range(5):
            dep = self._k8s.get_deployment(self._ns, dep_name)
            container = dep["spec"]["template"]["spec"]["containers"][0]
            container["image"] = image
            if command:
                container["command"] = ["/bin/sh", "-c", command]
            elif "command" in container:
                del container["command"]
            if ports:
                container["ports"] = ports
            try:
                self._k8s.update_deployment(self._ns, dep_name, dep)
                return
            except Exception as e:
                if "conflict" in str(e).lower() or "409" in str(e):
                    continue
                raise
        # Final attempt — let it raise
        dep = self._k8s.get_deployment(self._ns, dep_name)
        container = dep["spec"]["template"]["spec"]["containers"][0]
        container["image"] = image
        if command:
            container["command"] = ["/bin/sh", "-c", command]
        elif "command" in container:
            del container["command"]
        if ports:
            container["ports"] = ports
        self._k8s.update_deployment(self._ns, dep_name, dep)

    def deploy(
        self,
        app: str,
        image: str | None = None,
        ref: str | None = None,
        process_type: str | None = None,
        ports: list[dict[str, Any]] | None = None,
        replicas: int = 1,
        procfile_path: Path | None = None,
        no_procfile: bool = False,
        wait: bool = True,
        timeout: int = 300,
        description: str | None = None,
        on_step: Callable[[str], None] | None = None,
    ) -> Release:
        """Deploy an application.

        If image is provided, deploy that image directly.
        If not, delegate to BuildService (build-from-git).
        """
        self._check_app_exists(app)

        commit: str | None = None
        ref_name: str | None = ref

        # Step 1: Resolve image
        if image is None:
            # Build from git
            if on_step:
                on_step("Building from git...")
            build_result = self._factory.build.build(app, ref=ref or "HEAD", on_step=on_step)
            image = build_result.image
            commit = build_result.commit_sha
            ref_name = build_result.ref_name

        # Step 2: Read current formation
        formation = self._get_formation(app)

        # Step 3: Resolve process types
        if process_type:
            target_types = [process_type]
        elif formation:
            target_types = list(formation.keys())
        else:
            target_types = ["web"]

        # Step 4: Apply Procfile if present
        if not no_procfile and procfile_path is not None:
            procfile_cmds = parse_procfile(procfile_path)
            if procfile_cmds:
                if on_step:
                    on_step("Applying Procfile...")
                for ptype, cmd in procfile_cmds.items():
                    if ptype in formation and formation[ptype].command_source == "manual":
                        continue  # manual overrides win
                    formation[ptype] = ProcessFormation(
                        replicas=formation[ptype].replicas if ptype in formation else replicas,
                        command=cmd,
                        command_source="procfile",
                    )
                    if ptype not in target_types:
                        target_types.append(ptype)

        # Step 5: Apply images for each process type
        if on_step:
            on_step(f"Deploying {image}...")
        images: dict[str, str] = {}
        for ptype in target_types:
            if ptype not in formation:
                formation[ptype] = ProcessFormation(
                    replicas=replicas,
                    command=None,
                    command_source="default",
                )
            pf = formation[ptype]

            # Resolve ports for this type
            type_ports = ports
            if type_ports is None and ptype == "web":
                type_ports = default_web_ports()

            dep_name = resources.safe_name(self._prefix, app, ptype)
            try:
                self._update_deployment_with_retry(dep_name, image, pf.command, type_ports)
            except Exception:
                # Create new Deployment
                dep_body = resources.build_deployment(
                    self._prefix,
                    self._domain,
                    app,
                    ptype,
                    image,
                    pf.replicas,
                    pf.command,
                    ports=type_ports,
                )
                self._k8s.create_deployment(self._ns, dep_body)

                # Create Service if ports are declared
                if type_ports:
                    svc_ports = _service_ports_from_container_ports(type_ports)
                    svc_body = resources.build_service(
                        self._prefix, self._domain, app, ptype, svc_ports
                    )
                    try:
                        self._k8s.create_service(self._ns, svc_body)
                    except Exception:
                        # Only tolerate "already exists"
                        svc_name = svc_body["metadata"]["name"]
                        self._k8s.get_service(self._ns, svc_name)  # raises if truly gone

                    # Create process-allow NetworkPolicy
                    np_ports = _netpol_ports_from_container_ports(type_ports)
                    np_body = resources.build_process_allow_policy(
                        self._prefix, self._domain, app, ptype, np_ports
                    )
                    try:
                        self._k8s.create_network_policy(self._ns, np_body)
                    except Exception:
                        # Only tolerate "already exists"
                        np_name = np_body["metadata"]["name"]
                        self._k8s.get_network_policy(self._ns, np_name)  # raises if truly gone

            images[ptype] = image

        # Step 6: Collect ALL images (including unchanged types from previous releases)
        all_images = self._collect_all_images(app, images)

        # Step 7: Update formation ConfigMap
        self._save_formation(app, formation)

        # Step 8: Create release
        if description is None:
            description = f"Deploy {image}"
        release = self._factory.releases.create_release(
            app=app,
            images=all_images,
            formation=formation,
            description=description,
            commit=commit,
            ref=ref_name,
            on_step=on_step,
        )

        # Step 8.5: Auto-domain if base_domain configured + web process
        if "web" in target_types:
            auto = self._factory.domains.auto_domain(app, on_step=on_step)
            if auto and on_step:
                on_step(f"App URL: https://{auto.hostname}")

        # Step 9: Wait for rollout
        if wait:
            self._wait_for_rollout(app, target_types, timeout, on_step)

        return release

    def _apply_images(
        self,
        app: str,
        images: dict[str, str],
        formation: dict[str, ProcessFormation],
        ports: list[dict[str, Any]] | None = None,
        on_step: Callable[[str], None] | None = None,
    ) -> None:
        """Apply images to Deployments. Used by deploy() and rollback()."""
        for ptype, image in images.items():
            pf = formation.get(ptype, ProcessFormation(1, None, "default"))

            type_ports = ports
            if type_ports is None and ptype == "web":
                type_ports = default_web_ports()

            dep_name = resources.safe_name(self._prefix, app, ptype)
            try:
                self._update_deployment_for_rollback(dep_name, image, pf)
            except Exception:
                dep_body = resources.build_deployment(
                    self._prefix,
                    self._domain,
                    app,
                    ptype,
                    image,
                    pf.replicas,
                    pf.command,
                    ports=type_ports,
                )
                self._k8s.create_deployment(self._ns, dep_body)
                if type_ports:
                    svc_ports = _service_ports_from_container_ports(type_ports)
                    svc_body = resources.build_service(
                        self._prefix, self._domain, app, ptype, svc_ports
                    )
                    try:
                        self._k8s.create_service(self._ns, svc_body)
                    except Exception:
                        # Only tolerate "already exists"
                        svc_name = svc_body["metadata"]["name"]
                        self._k8s.get_service(self._ns, svc_name)  # raises if truly gone

            if on_step:
                on_step(f"Updated {ptype} to {image}")

    def _update_deployment_for_rollback(
        self,
        dep_name: str,
        image: str,
        pf: ProcessFormation,
    ) -> None:
        """Update a deployment for rollback, retrying on 409 Conflict."""
        for _attempt in range(5):
            dep = self._k8s.get_deployment(self._ns, dep_name)
            container = dep["spec"]["template"]["spec"]["containers"][0]
            container["image"] = image
            if pf.command:
                container["command"] = ["/bin/sh", "-c", pf.command]
            elif "command" in container:
                del container["command"]
            dep["spec"]["replicas"] = pf.replicas
            try:
                self._k8s.update_deployment(self._ns, dep_name, dep)
                return
            except Exception as e:
                if "conflict" in str(e).lower() or "409" in str(e):
                    continue
                raise
        # Final attempt — let it raise
        dep = self._k8s.get_deployment(self._ns, dep_name)
        container = dep["spec"]["template"]["spec"]["containers"][0]
        container["image"] = image
        if pf.command:
            container["command"] = ["/bin/sh", "-c", pf.command]
        elif "command" in container:
            del container["command"]
        dep["spec"]["replicas"] = pf.replicas
        self._k8s.update_deployment(self._ns, dep_name, dep)

    def _wait_for_rollout(
        self,
        app: str,
        process_types: list[str],
        timeout: int,
        on_step: Callable[[str], None] | None = None,
    ) -> None:
        """Wait for all Deployments to complete rollout.

        FakeK8sClient doesn't have a controller, so in tests we seed pods manually.
        """

        deadline = time.monotonic() + timeout
        if on_step:
            on_step("Waiting for rollout...")

        while time.monotonic() < deadline:
            all_ready = True
            for ptype in process_types:
                dep_name = resources.safe_name(self._prefix, app, ptype)
                try:
                    dep = self._k8s.get_deployment(self._ns, dep_name)
                except Exception:
                    all_ready = False
                    continue
                desired = dep.get("spec", {}).get("replicas", 1)
                pod_labels = labels.selector(self._domain, app, ptype)
                pods = self._k8s.list_pods(self._ns, labels=pod_labels)

                if len(pods) < desired:
                    all_ready = False
                    continue

                for pod in pods:
                    phase = pod.get("status", {}).get("phase", "Pending")
                    if phase in ("Failed",):
                        # Check for crash conditions
                        container_statuses = pod.get("status", {}).get("containerStatuses", [])
                        for cs in container_statuses:
                            waiting = cs.get("state", {}).get("waiting", {})
                            reason = waiting.get("reason", "")
                            if reason in (
                                "CrashLoopBackOff",
                                "ImagePullBackOff",
                                "ErrImagePull",
                                "OOMKilled",
                            ):
                                raise RolloutTimeoutError(f"Rollout failed: {ptype} pod {reason}")
                        raise RolloutTimeoutError(f"Rollout failed: {ptype} pod in Failed state")
                    if phase != "Running":
                        all_ready = False
                    else:
                        # Check container readiness
                        container_statuses = pod.get("status", {}).get("containerStatuses", [])
                        for cs in container_statuses:
                            if not cs.get("ready", False):
                                all_ready = False

            if all_ready:
                if on_step:
                    on_step("Rollout complete.")
                return

            time.sleep(0.1)  # In tests, use short sleep

        raise RolloutTimeoutError(f"Rollout timed out after {timeout} seconds")

    # ── Private helpers ──────────────────────────────────────────

    def _check_app_exists(self, app: str) -> None:
        cm_name = resources.safe_name(self._prefix, "app", app)
        try:
            self._k8s.get_configmap(self._ns, cm_name)
        except Exception:
            raise AppNotFoundError(app) from None

    def _get_formation(self, app: str) -> dict[str, ProcessFormation]:
        """Read formation from the formation ConfigMap."""
        cm_name = resources.safe_name(self._prefix, "formation", app)
        try:
            cm = self._k8s.get_configmap(self._ns, cm_name)
        except Exception:
            return {}
        formation: dict[str, ProcessFormation] = {}
        for key, value in cm.get("data", {}).items():
            try:
                entry = json.loads(value)
                formation[key] = ProcessFormation(
                    replicas=entry.get("replicas", 1),
                    command=entry.get("command"),
                    command_source=entry.get("command_source", "default"),
                )
            except (json.JSONDecodeError, TypeError):
                continue
        return formation

    def _save_formation(self, app: str, formation: dict[str, ProcessFormation]) -> None:
        """Write formation to the formation ConfigMap."""
        cm_name = resources.safe_name(self._prefix, "formation", app)
        cm = self._k8s.get_configmap(self._ns, cm_name)
        data: dict[str, str] = {}
        for ptype, pf in formation.items():
            data[ptype] = json.dumps(
                {
                    "replicas": pf.replicas,
                    "command": pf.command,
                    "command_source": pf.command_source,
                }
            )
        cm["data"] = data
        self._k8s.update_configmap(self._ns, cm_name, cm)

    def _collect_all_images(self, app: str, new_images: dict[str, str]) -> dict[str, str]:
        """Collect all images, merging new with existing from Deployments."""
        all_images = dict(new_images)
        match_labels = {
            f"{self._domain}/managed-by": self._prefix,
            f"{self._domain}/app": app,
        }
        for dep in self._k8s.list_deployments(self._ns, labels=match_labels):
            ptype = (
                dep.get("metadata", {}).get("labels", {}).get(f"{self._domain}/process-type", "")
            )
            if ptype and ptype not in all_images:
                container = dep["spec"]["template"]["spec"]["containers"][0]
                all_images[ptype] = container.get("image", "")
        return all_images

    def _restart_deployments(
        self,
        app: str,
        on_step: Callable[[str], None] | None = None,
    ) -> int:
        """Trigger a rolling restart on all app Deployments. Returns count."""
        match_labels = {
            f"{self._domain}/managed-by": self._prefix,
            f"{self._domain}/app": app,
        }
        deps = self._k8s.list_deployments(self._ns, labels=match_labels)
        ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
        count = 0
        for dep in deps:
            dep_name = dep["metadata"]["name"]
            self._restart_single_deployment(dep_name, ts)
            count += 1
            if on_step:
                on_step(f"Restarted {dep_name}")
        return count

    def _restart_single_deployment(self, dep_name: str, ts: str) -> None:
        """Restart a single deployment, retrying on 409 Conflict."""
        for _attempt in range(5):
            dep = self._k8s.get_deployment(self._ns, dep_name)
            annotations = (
                dep.get("spec", {}).get("template", {}).get("metadata", {}).get("annotations")
            )
            if annotations is None:
                dep["spec"]["template"]["metadata"]["annotations"] = {}
                annotations = dep["spec"]["template"]["metadata"]["annotations"]
            annotations["kubectl.kubernetes.io/restartedAt"] = ts
            try:
                self._k8s.update_deployment(self._ns, dep_name, dep)
                return
            except Exception as e:
                if "conflict" in str(e).lower() or "409" in str(e):
                    continue
                raise
        # Final attempt — let it raise
        dep = self._k8s.get_deployment(self._ns, dep_name)
        annotations = (
            dep.get("spec", {}).get("template", {}).get("metadata", {}).get("annotations")
        )
        if annotations is None:
            dep["spec"]["template"]["metadata"]["annotations"] = {}
            annotations = dep["spec"]["template"]["metadata"]["annotations"]
        annotations["kubectl.kubernetes.io/restartedAt"] = ts
        self._k8s.update_deployment(self._ns, dep_name, dep)
