"""PsService — business logic for process management.

Handles: set_commands, get_commands, scale, stop, restart, list_dynos,
set_type, get_type.
"""

from __future__ import annotations

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

from kuberoku.exceptions import AppNotFoundError, ProcessTypeNotFoundError
from kuberoku.k8s import labels, resources
from kuberoku.models import Dyno, ProcessFormation

if TYPE_CHECKING:
    from kuberoku.factory import KuberokuFactory
    from kuberoku.k8s.protocols import K8sClientProtocol


def parse_resources(spec: str) -> dict[str, str]:
    """Parse a resource spec like '250m' or '250m/500m'.

    Returns {'requests': val} or {'requests': req, 'limits': lim}.
    """
    if "/" in spec:
        req, lim = spec.split("/", 1)
        return {"requests": req.strip(), "limits": lim.strip()}
    return {"requests": spec.strip()}


class PsService:
    """Business logic for process management."""

    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 set_commands(
        self,
        app: str,
        commands: dict[str, str],
        no_restart: bool = False,
        on_step: Callable[[str], None] | None = None,
    ) -> dict[str, ProcessFormation]:
        """Set commands for process types. Creates a release."""
        self._check_app_exists(app)
        formation = self._get_formation(app)

        for ptype, cmd in commands.items():
            if ptype in formation:
                old = formation[ptype]
                formation[ptype] = ProcessFormation(
                    replicas=old.replicas,
                    command=cmd,
                    command_source="manual",
                )
            else:
                formation[ptype] = ProcessFormation(
                    replicas=1,
                    command=cmd,
                    command_source="manual",
                )

            # Patch Deployment command if it exists
            dep_name = resources.safe_name(self._prefix, app, ptype)
            try:
                dep = self._k8s.get_deployment(self._ns, dep_name)
                container = dep["spec"]["template"]["spec"]["containers"][0]
                container["command"] = ["/bin/sh", "-c", cmd]
                if not no_restart:
                    # Add restart annotation
                    ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
                    annotations = (
                        dep.get("spec", {})
                        .get("template", {})
                        .get("metadata", {})
                        .get("annotations")
                    )
                    if annotations is None:
                        dep["spec"]["template"]["metadata"]["annotations"] = {}
                    dep["spec"]["template"]["metadata"]["annotations"][
                        "kubectl.kubernetes.io/restartedAt"
                    ] = ts
                self._k8s.update_deployment(self._ns, dep_name, dep)
            except Exception:
                pass  # Deployment doesn't exist yet, OK

        self._save_formation(app, formation)

        # Create release
        images = self._collect_current_images(app)
        desc_parts = [f"{t}={c}" for t, c in commands.items()]
        description = f"Set commands: {', '.join(desc_parts)}"
        self._factory.releases.create_release(
            app=app,
            images=images,
            formation=formation,
            description=description,
            on_step=on_step,
        )

        if on_step:
            on_step("Commands updated.")
        return formation

    def get_commands(self, app: str) -> dict[str, ProcessFormation]:
        """Get process formation (commands) for an app."""
        self._check_app_exists(app)
        return self._get_formation(app)

    def scale(
        self,
        app: str,
        scaling: dict[str, int],
        on_step: Callable[[str], None] | None = None,
    ) -> dict[str, ProcessFormation]:
        """Scale process types."""
        self._check_app_exists(app)
        formation = self._get_formation(app)

        for ptype, count in scaling.items():
            if ptype not in formation:
                raise ProcessTypeNotFoundError(ptype, app)

            old = formation[ptype]
            formation[ptype] = ProcessFormation(
                replicas=count,
                command=old.command,
                command_source=old.command_source,
            )

            # Patch Deployment replicas
            dep_name = resources.safe_name(self._prefix, app, ptype)
            try:
                dep = self._k8s.get_deployment(self._ns, dep_name)
                dep["spec"]["replicas"] = count
                self._k8s.update_deployment(self._ns, dep_name, dep)
                if on_step:
                    on_step(f"Scaled {ptype} to {count}")
            except Exception:
                pass  # Deployment doesn't exist yet

        self._save_formation(app, formation)

        # Create release
        images = self._collect_current_images(app)
        parts = [f"{t}={c}" for t, c in scaling.items()]
        description = f"Scale: {', '.join(parts)}"
        self._factory.releases.create_release(
            app=app,
            images=images,
            formation=formation,
            description=description,
            on_step=on_step,
        )

        return formation

    def stop(
        self,
        app: str,
        process_type: str,
        on_step: Callable[[str], None] | None = None,
    ) -> None:
        """Stop a process type (scale to 0)."""
        self.scale(app, {process_type: 0}, on_step=on_step)

    def restart(
        self,
        app: str,
        process_type: str | None = None,
        dyno: str | None = None,
        on_step: Callable[[str], None] | None = None,
    ) -> int:
        """Restart dynos. Returns count of pods deleted."""
        self._check_app_exists(app)
        count = 0

        if dyno:
            # Restart specific dyno — find the pod
            pods = self._list_app_pods(app)
            dyno_map = self._build_dyno_map(pods)
            if dyno not in dyno_map:
                raise ProcessTypeNotFoundError(dyno.split(".")[0], app)
            pod_name = dyno_map[dyno]
            self._k8s.delete_pod(self._ns, pod_name)
            count = 1
            if on_step:
                on_step(f"Restarted {dyno}")
        elif process_type:
            # Restart all pods of a process type
            pod_labels = labels.selector(self._domain, app, process_type)
            pods = self._k8s.list_pods(self._ns, labels=pod_labels)
            for pod in pods:
                pod_name = pod["metadata"]["name"]
                try:
                    self._k8s.delete_pod(self._ns, pod_name)
                except Exception:
                    # Only tolerate "already gone" (pod may have terminated
                    # between list and delete)
                    if any(
                        p.get("metadata", {}).get("name") == pod_name
                        for p in self._k8s.list_pods(self._ns, labels=pod_labels)
                    ):
                        raise
                count += 1
            if on_step:
                on_step(f"Restarted {count} {process_type} dynos")
        else:
            # Restart all pods for the app
            pods = self._list_app_pods(app)
            for pod in pods:
                pod_name = pod["metadata"]["name"]
                try:
                    self._k8s.delete_pod(self._ns, pod_name)
                except Exception:
                    # Only tolerate "already gone"
                    if any(
                        p.get("metadata", {}).get("name") == pod_name
                        for p in self._list_app_pods(app)
                    ):
                        raise
                count += 1
            if on_step:
                on_step(f"Restarted {count} dynos")
        return count

    def list_dynos(self, app: str) -> list[Dyno]:
        """List all running dynos for an app."""
        self._check_app_exists(app)
        pods = self._list_app_pods(app)

        # Filter out pods being terminated (graceful shutdown).
        # K8s keeps phase=Running during graceful termination, but sets
        # deletionTimestamp.  These pods are draining, not serving.
        pods = [p for p in pods if not p.get("metadata", {}).get("deletionTimestamp")]

        # Sort by process type, then start time
        pods.sort(
            key=lambda p: (
                p.get("metadata", {}).get("labels", {}).get(f"{self._domain}/process-type", ""),
                p.get("status", {}).get("startTime", ""),
            )
        )

        dynos: list[Dyno] = []
        type_counters: dict[str, int] = {}
        for pod in pods:
            pod_labels = pod.get("metadata", {}).get("labels", {})
            ptype = pod_labels.get(f"{self._domain}/process-type", "unknown")
            type_counters[ptype] = type_counters.get(ptype, 0) + 1
            idx = type_counters[ptype]

            # Extract state
            phase = pod.get("status", {}).get("phase", "Pending")
            state = _pod_phase_to_state(phase)

            # Extract container info
            containers = pod.get("spec", {}).get("containers", [{}])
            image = containers[0].get("image", "") if containers else ""
            command = None
            if containers and "command" in containers[0]:
                cmd_list = containers[0]["command"]
                if len(cmd_list) >= 3 and cmd_list[0] == "/bin/sh":
                    command = cmd_list[2]
                else:
                    command = " ".join(cmd_list)

            started_at_str = pod.get("status", {}).get("startTime", "")
            try:
                started_at = datetime.fromisoformat(started_at_str.replace("Z", "+00:00"))
            except (ValueError, AttributeError):
                started_at = None

            dynos.append(
                Dyno(
                    name=f"{ptype}.{idx}",
                    app=app,
                    process_type=ptype,
                    command=command,
                    state=state,
                    started_at=started_at,
                    image=image,
                    node=pod.get("spec", {}).get("nodeName", ""),
                )
            )

        return dynos

    def set_type(
        self,
        app: str,
        process_type: str,
        cpu: str | None = None,
        memory: str | None = None,
        on_step: Callable[[str], None] | None = None,
    ) -> None:
        """Set CPU/memory resources for a process type."""
        self._check_app_exists(app)
        dep_name = resources.safe_name(self._prefix, app, process_type)
        try:
            dep = self._k8s.get_deployment(self._ns, dep_name)
        except Exception:
            raise ProcessTypeNotFoundError(process_type, app) from None

        container = dep["spec"]["template"]["spec"]["containers"][0]
        res: dict[str, Any] = container.get("resources", {})

        if cpu is not None:
            parsed = parse_resources(cpu)
            res.setdefault("requests", {})["cpu"] = parsed["requests"]
            if "limits" in parsed:
                res.setdefault("limits", {})["cpu"] = parsed["limits"]
            elif "limits" in res and "cpu" in res["limits"]:
                del res["limits"]["cpu"]

        if memory is not None:
            parsed = parse_resources(memory)
            res.setdefault("requests", {})["memory"] = parsed["requests"]
            if "limits" in parsed:
                res.setdefault("limits", {})["memory"] = parsed["limits"]
            elif "limits" in res and "memory" in res["limits"]:
                del res["limits"]["memory"]

        container["resources"] = res

        # Trigger rolling restart
        ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
        annotations = (
            dep.get("spec", {}).get("template", {}).get("metadata", {}).get("annotations")
        )
        if annotations is None:
            dep["spec"]["template"]["metadata"]["annotations"] = {}
        dep["spec"]["template"]["metadata"]["annotations"]["kubectl.kubernetes.io/restartedAt"] = (
            ts
        )

        self._k8s.update_deployment(self._ns, dep_name, dep)
        if on_step:
            on_step(f"Updated resources for {process_type}")

    def get_type(self, app: str, process_type: str | None = None) -> dict[str, dict[str, Any]]:
        """Get CPU/memory resources for process types."""
        self._check_app_exists(app)
        match_labels = {
            f"{self._domain}/managed-by": self._prefix,
            f"{self._domain}/app": app,
        }
        if process_type:
            match_labels[f"{self._domain}/process-type"] = process_type

        deps = self._k8s.list_deployments(self._ns, labels=match_labels)
        result: dict[str, dict[str, Any]] = {}
        for dep in deps:
            ptype = (
                dep.get("metadata", {}).get("labels", {}).get(f"{self._domain}/process-type", "")
            )
            if not ptype:
                continue
            container = dep["spec"]["template"]["spec"]["containers"][0]
            res = container.get("resources", {})
            result[ptype] = res
        return result

    # ── 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]:
        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:
        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_current_images(self, app: str) -> dict[str, str]:
        match_labels = {
            f"{self._domain}/managed-by": self._prefix,
            f"{self._domain}/app": app,
        }
        images: dict[str, str] = {}
        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:
                container = dep["spec"]["template"]["spec"]["containers"][0]
                images[ptype] = container.get("image", "")
        return images

    def _list_app_pods(self, app: str) -> list[dict[str, Any]]:
        match_labels = {
            f"{self._domain}/managed-by": self._prefix,
            f"{self._domain}/app": app,
        }
        return self._k8s.list_pods(self._ns, labels=match_labels)

    def _build_dyno_map(self, pods: list[dict[str, Any]]) -> dict[str, str]:
        """Build a map of dyno name → pod name for restart by dyno."""
        pods.sort(
            key=lambda p: (
                p.get("metadata", {}).get("labels", {}).get(f"{self._domain}/process-type", ""),
                p.get("status", {}).get("startTime", ""),
            )
        )
        dyno_map: dict[str, str] = {}
        type_counters: dict[str, int] = {}
        for pod in pods:
            pod_labels = pod.get("metadata", {}).get("labels", {})
            ptype = pod_labels.get(f"{self._domain}/process-type", "unknown")
            type_counters[ptype] = type_counters.get(ptype, 0) + 1
            idx = type_counters[ptype]
            dyno_map[f"{ptype}.{idx}"] = pod["metadata"]["name"]
        return dyno_map


def _pod_phase_to_state(phase: str) -> str:
    """Map K8s pod phase to Kuberoku dyno state."""
    mapping = {
        "Running": "up",
        "Pending": "starting",
        "Succeeded": "down",
        "Failed": "crashed",
        "Unknown": "crashed",
    }
    return mapping.get(phase, "starting")
