"""ServicesService — expose/unexpose, port management, logs, exec.

Patches Service type (ClusterIP ↔ LoadBalancer/NodePort) and manages
external-allow NetworkPolicies for process types. Manages per-process
port declarations. Provides log streaming and exec operations.
"""

from __future__ import annotations

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

from kuberoku.exceptions import (
    AmbiguousProcessTypeError,
    AppNotFoundError,
    DynoNotFoundError,
    InvalidDurationError,
    NameNotFoundError,
    NoDynosError,
    NoPortsError,
    NotExposedError,
    PortAlreadyExistsError,
    PortNotFoundError,
    ProcessTypeNotFoundError,
)
from kuberoku.k8s import resources
from kuberoku.models import (
    ConnectionTarget,
    DetachedJob,
    ExecResult,
    ExposedEndpoint,
    LogLine,
    PortMapping,
)
from kuberoku.sdk.deploy import (
    _netpol_ports_from_container_ports,
    _service_port_name,
    _service_ports_from_container_ports,
    parse_port,
)

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

_DURATION_RE = re.compile(r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$")


def _parse_duration(text: str) -> int:
    """Parse a human-readable duration string to seconds.

    Supports: 5m, 1h, 30s, 2h30m, 1h30m15s, etc.
    Returns total seconds. Raises InvalidDurationError on bad input.
    """
    text = text.strip()
    if not text:
        raise InvalidDurationError(text)
    m = _DURATION_RE.match(text)
    if not m or not any(m.groups()):
        raise InvalidDurationError(text)
    hours = int(m.group(1) or 0)
    minutes = int(m.group(2) or 0)
    seconds = int(m.group(3) or 0)
    total = hours * 3600 + minutes * 60 + seconds
    if total <= 0:
        raise InvalidDurationError(text)
    return total


class ServicesService:
    """Business logic for process expose/unexpose, connect, and port 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 expose_on(
        self,
        app: str,
        name: str,
        method: str = "loadbalancer",
        port: int | None = None,
        wait: bool = True,
        timeout: int = 120,
        on_step: Callable[[str], None] | None = None,
    ) -> ExposedEndpoint:
        """Expose a process type externally.

        method="gateway": allocate a gateway port (10000-10999).
        method="loadbalancer"/"nodeport": patch Service type directly.
        Creates an external-allow NetworkPolicy in all cases.
        """
        self._check_app_exists(app)

        if on_step:
            on_step("Resolving process type...")
        svc_name, svc = self._find_process_service(app, name)

        if method == "gateway":
            svc_ports = svc.get("spec", {}).get("ports", [])
            target_port = port or (svc_ports[0]["port"] if svc_ports else 0)

            alloc = self._factory.gateway.allocate(
                app,
                svc_name,
                target_port,
                on_step=on_step,
            )

            # Create external-allow NetworkPolicy
            if on_step:
                on_step("Creating external network policy...")
            netpol_ports = [
                {"port": p["port"], "protocol": p.get("protocol", "TCP")} for p in svc_ports
            ]
            self._create_external_policy(app, name, netpol_ports)

            return ExposedEndpoint(
                name=name,
                app=app,
                kind="process",
                service_type="LoadBalancer",
                method="gateway",
                external_ip=self._factory.gateway.get_gateway_ip(),
                hostname=None,
                ports=(alloc.external_port,),
                gateway_port=alloc.external_port,
            )

        target_type = "LoadBalancer" if method == "loadbalancer" else "NodePort"

        # Idempotent: if already the target type, return current state
        current_type = svc.get("spec", {}).get("type", "ClusterIP")
        if current_type == target_type:
            return self._build_endpoint(name, app, "process", svc, method)

        # Patch Service type (retry on 409 conflict)
        if on_step:
            on_step(f"Patching Service to {target_type}...")
        for _attempt in range(5):
            svc["spec"]["type"] = target_type
            try:
                svc = self._k8s.update_service(self._ns, svc_name, svc)
                break
            except Exception as e:
                if "conflict" in str(e).lower() or "409" in str(e):
                    svc = self._k8s.get_service(self._ns, svc_name)
                    continue
                raise

        # Create external-allow NetworkPolicy
        if on_step:
            on_step("Creating external network policy...")
        svc_ports = svc.get("spec", {}).get("ports", [])
        netpol_ports = [
            {"port": p["port"], "protocol": p.get("protocol", "TCP")} for p in svc_ports
        ]
        self._create_external_policy(app, name, netpol_ports)

        return self._build_endpoint(name, app, "process", svc, method)

    def expose_off(
        self,
        app: str,
        name: str,
        on_step: Callable[[str], None] | None = None,
    ) -> ExposedEndpoint:
        """Revert an exposed process back to internal-only.

        Checks gateway first — if allocated, deallocates.
        Otherwise reverts Service type from LB/NodePort to ClusterIP.
        Removes the external-allow NetworkPolicy (if not owned by other sources).
        """
        self._check_app_exists(app)

        if on_step:
            on_step("Resolving process type...")
        svc_name, svc = self._find_process_service(app, name)

        # Check if exposed via gateway
        alloc = self._factory.gateway.get_allocation(app, svc_name)
        if alloc is not None:
            if on_step:
                on_step("Deallocating gateway port...")
            self._factory.gateway.deallocate(app, svc_name, on_step=on_step)

            # Delete external-allow NetworkPolicy
            if on_step:
                on_step("Removing external network policy...")
            self._delete_external_policy(app, name)

            return ExposedEndpoint(
                name=name,
                app=app,
                kind="process",
                service_type="ClusterIP",
                method="clusterip",
                external_ip=None,
                hostname=None,
                ports=tuple(p.get("port", 0) for p in svc.get("spec", {}).get("ports", [])),
            )

        current_type = svc.get("spec", {}).get("type", "ClusterIP")
        if current_type == "ClusterIP":
            raise NotExposedError(name, app)

        # Patch Service back to ClusterIP (retry on 409 conflict)
        if on_step:
            on_step("Reverting Service to ClusterIP...")
        for _attempt in range(5):
            svc["spec"]["type"] = "ClusterIP"
            # Remove nodePort allocations (K8s auto-assigns them for LB/NodePort)
            for port_spec in svc.get("spec", {}).get("ports", []):
                port_spec.pop("nodePort", None)
            try:
                svc = self._k8s.update_service(self._ns, svc_name, svc)
                break
            except Exception as e:
                if "conflict" in str(e).lower() or "409" in str(e):
                    svc = self._k8s.get_service(self._ns, svc_name)
                    continue
                raise

        # Delete external-allow NetworkPolicy
        if on_step:
            on_step("Removing external network policy...")
        self._delete_external_policy(app, name)

        return self._build_endpoint(name, app, "process", svc, "clusterip")

    def resolve_connect(
        self,
        app: str,
        name: str,
    ) -> ConnectionTarget:
        """Resolve a process connect target for port-forwarding.

        Returns a ConnectionTarget with service name, namespace, and ports.
        The CLI uses this to run `kubectl port-forward`.
        """
        self._check_app_exists(app)
        svc_name, svc = self._find_process_service(app, name)

        svc_ports = svc.get("spec", {}).get("ports", [])
        if not svc_ports:
            raise NoPortsError(name, app)

        port_mappings = tuple(
            PortMapping(
                port=p.get("port", 0),
                protocol=p.get("protocol", "TCP"),
            )
            for p in svc_ports
        )

        return ConnectionTarget(
            name=name,
            app=app,
            kind="process",
            service_name=svc_name,
            namespace=self._ns,
            ports=port_mappings,
        )

    # ── Open URL ────────────────────────────────────────────────

    def maintenance_on(
        self,
        app: str,
        process_type: str,
        on_step: Callable[[str], None] | None = None,
    ) -> None:
        """Put a single process type into maintenance (save replicas, scale to 0)."""
        self._check_app_exists(app)

        # Get the deployment for this process type
        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

        current_replicas = dep.get("spec", {}).get("replicas", 1)

        if on_step:
            on_step(f"Saving replicas for {process_type}...")

        # Read current manifest
        manifest_name = resources.safe_name(self._prefix, "app", app)
        manifest = self._k8s.get_configmap(self._ns, manifest_name)
        annotations = manifest.get("metadata", {}).setdefault("annotations", {})

        # Read existing saved replicas
        saved_json = annotations.get(f"{self._domain}/maintenance-saved", "{}")
        try:
            saved: dict[str, int] = json.loads(saved_json)
        except (json.JSONDecodeError, TypeError):
            saved = {}
        saved[process_type] = current_replicas

        # Read existing services list
        services_json = annotations.get(f"{self._domain}/maintenance-services", "[]")
        try:
            services_list: list[str] = json.loads(services_json)
        except (json.JSONDecodeError, TypeError):
            services_list = []
        if process_type not in services_list:
            services_list.append(process_type)

        # Update annotations
        annotations[f"{self._domain}/maintenance"] = "true"
        if f"{self._domain}/maintenance-since" not in annotations:
            annotations[f"{self._domain}/maintenance-since"] = datetime.now(UTC).isoformat()
        annotations[f"{self._domain}/maintenance-saved"] = json.dumps(saved)
        annotations[f"{self._domain}/maintenance-services"] = json.dumps(services_list)
        self._k8s.update_configmap(self._ns, manifest_name, manifest)

        # Scale to 0
        if on_step:
            on_step(f"Scaling {process_type} to 0...")
        self._scale_deployment_with_retry(dep_name, 0)

    def maintenance_off(
        self,
        app: str,
        process_type: str,
        on_step: Callable[[str], None] | None = None,
    ) -> None:
        """Restore a single process type from maintenance."""
        self._check_app_exists(app)

        # Read manifest
        manifest_name = resources.safe_name(self._prefix, "app", app)
        manifest = self._k8s.get_configmap(self._ns, manifest_name)
        annotations = manifest.get("metadata", {}).get("annotations", {})

        # Read saved replicas
        saved_json = annotations.get(f"{self._domain}/maintenance-saved", "{}")
        try:
            saved: dict[str, int] = json.loads(saved_json)
        except (json.JSONDecodeError, TypeError):
            saved = {}

        target_replicas = saved.pop(process_type, 1)

        # Restore deployment
        dep_name = resources.safe_name(self._prefix, app, process_type)
        try:
            self._k8s.get_deployment(self._ns, dep_name)
        except Exception:
            raise ProcessTypeNotFoundError(process_type, app) from None

        if on_step:
            on_step(f"Restoring {process_type} to {target_replicas} replicas...")
        self._scale_deployment_with_retry(dep_name, target_replicas)

        # Update services list
        services_json = annotations.get(f"{self._domain}/maintenance-services", "[]")
        try:
            services_list: list[str] = json.loads(services_json)
        except (json.JSONDecodeError, TypeError):
            services_list = []
        if process_type in services_list:
            services_list.remove(process_type)

        # Update annotations
        annotations[f"{self._domain}/maintenance-saved"] = json.dumps(saved)
        annotations[f"{self._domain}/maintenance-services"] = json.dumps(services_list)

        # If no services remain in maintenance, clear all flags
        if not services_list:
            for key in [
                f"{self._domain}/maintenance",
                f"{self._domain}/maintenance-since",
                f"{self._domain}/maintenance-saved",
                f"{self._domain}/maintenance-services",
            ]:
                annotations.pop(key, None)

        manifest["metadata"]["annotations"] = annotations
        self._k8s.update_configmap(self._ns, manifest_name, manifest)

    def open_url(
        self,
        app: str,
        process_type: str = "web",
    ) -> str:
        """Resolve the browser URL for an HTTP process type.

        Resolution order:
        1. Domain from Ingress -> https://domain
        2. Gateway allocation -> http://{gateway_ip}:{gateway_port}
        3. LoadBalancer external IP -> http://{ip}:{port}
        4. Fallback -> http://localhost:{first_port}
        """
        self._check_app_exists(app)

        # 1. Check for Ingress domain
        domains = self._factory.domains.list(app)
        for d in domains:
            if d.process_type == process_type:
                scheme = "https" if d.tls else "http"
                return f"{scheme}://{d.hostname}"

        # 2. Check for gateway allocation
        svc_name = resources.safe_name(self._prefix, app, process_type)
        alloc = self._factory.gateway.get_allocation(app, svc_name)
        if alloc is not None:
            gw_ip = self._factory.gateway.get_gateway_ip()
            if gw_ip:
                return f"http://{gw_ip}:{alloc.external_port}"

        # 3. Check for LoadBalancer external IP
        try:
            svc = self._k8s.get_service(self._ns, svc_name)
            svc_type = svc.get("spec", {}).get("type", "ClusterIP")
            if svc_type in ("LoadBalancer", "NodePort"):
                status = svc.get("status", {})
                lb = status.get("loadBalancer", {})
                for ing in lb.get("ingress", []):
                    ip = ing.get("ip") or ing.get("hostname")
                    if ip:
                        svc_ports = svc.get("spec", {}).get("ports", [])
                        port = svc_ports[0]["port"] if svc_ports else 80
                        return f"http://{ip}:{port}"
        except Exception:
            pass

        # 4. Fallback: localhost with first port
        try:
            svc = self._k8s.get_service(self._ns, svc_name)
            svc_ports = svc.get("spec", {}).get("ports", [])
            port = svc_ports[0]["port"] if svc_ports else 8080
        except Exception:
            port = 8080
        return f"http://localhost:{port}"

    # ── Port management ─────────────────────────────────────────

    def list_ports(self, app: str) -> dict[str, tuple[PortMapping, ...]]:
        """List declared ports for each process type.

        Returns {process_type: (PortMapping(...), ...)} dict.
        """
        self._check_app_exists(app)
        match_labels = {
            f"{self._domain}/managed-by": self._prefix,
            f"{self._domain}/app": app,
            f"{self._domain}/resource-type": "process",
        }
        services = self._k8s.list_services(self._ns, labels=match_labels)
        result: dict[str, tuple[PortMapping, ...]] = {}
        for svc in services:
            svc_labels = svc.get("metadata", {}).get("labels", {})
            ptype = svc_labels.get(f"{self._domain}/process-type")
            if ptype is None:
                continue
            svc_ports = svc.get("spec", {}).get("ports", [])
            mappings = tuple(
                PortMapping(
                    port=p.get("port", 0),
                    protocol=p.get("protocol", "TCP"),
                )
                for p in svc_ports
            )
            result[ptype] = mappings
        return result

    def add_port(
        self,
        app: str,
        port_spec: str,
        process_type: str | None = None,
        on_step: Callable[[str], None] | None = None,
    ) -> None:
        """Add a port to a process type's Service and Deployment.

        Also updates the process-allow NetworkPolicy.
        """
        self._check_app_exists(app)
        parsed = parse_port(port_spec)
        port_num: int = parsed["containerPort"]
        proto: str = parsed["protocol"]

        ptype = self._resolve_process_type(app, process_type)
        if on_step:
            on_step(f"Adding {port_num}/{proto.lower()} to {ptype}...")

        dep_name = resources.safe_name(self._prefix, app, ptype)

        # Patch Deployment (retry on 409 conflict)
        for _attempt in range(5):
            dep = self._k8s.get_deployment(self._ns, dep_name)
            container = dep["spec"]["template"]["spec"]["containers"][0]
            existing_ports: list[dict[str, Any]] = container.get("ports", [])

            # Check for duplicate
            for ep in existing_ports:
                if ep.get("containerPort") == port_num and ep.get("protocol", "TCP") == proto:
                    raise PortAlreadyExistsError(port_num, proto, ptype, app)

            existing_ports.append(parsed)
            container["ports"] = existing_ports
            try:
                self._k8s.update_deployment(self._ns, dep_name, dep)
                break
            except Exception as e:
                if "conflict" in str(e).lower() or "409" in str(e):
                    continue
                raise

        # Patch or create Service
        svc_name = resources.safe_name(self._prefix, app, ptype)
        new_svc_port: dict[str, Any] = {
            "name": _service_port_name(port_num, proto),
            "port": port_num,
            "targetPort": port_num,
            "protocol": proto,
        }
        try:
            svc = self._k8s.get_service(self._ns, svc_name)
            svc_ports: list[dict[str, Any]] = svc.get("spec", {}).get("ports", [])
            # Ensure existing ports have names (they may lack names when
            # the Service was created with a single port).
            for sp in svc_ports:
                if not sp.get("name"):
                    sp["name"] = _service_port_name(sp["port"], sp.get("protocol", "TCP"))
            svc_ports.append(new_svc_port)
            svc["spec"]["ports"] = svc_ports
            self._k8s.update_service(self._ns, svc_name, svc)
        except Exception:
            # No Service yet — create one
            svc_body = resources.build_service(
                self._prefix,
                self._domain,
                app,
                ptype,
                _service_ports_from_container_ports(existing_ports),
            )
            self._k8s.create_service(self._ns, svc_body)

        # Update process-allow NetworkPolicy
        self._update_process_allow_policy(app, ptype, existing_ports)

        if on_step:
            on_step(f"Added {port_num}/{proto.lower()} to {ptype}.")

    def remove_port(
        self,
        app: str,
        port_spec: str,
        process_type: str | None = None,
        on_step: Callable[[str], None] | None = None,
    ) -> None:
        """Remove a port from a process type's Service and Deployment.

        If last port is removed, deletes the Service and process-allow NetworkPolicy.
        """
        self._check_app_exists(app)
        parsed = parse_port(port_spec)
        port_num: int = parsed["containerPort"]
        proto: str = parsed["protocol"]

        ptype = self._resolve_process_type(app, process_type)
        if on_step:
            on_step(f"Removing {port_num}/{proto.lower()} from {ptype}...")

        dep_name = resources.safe_name(self._prefix, app, ptype)

        # Update Deployment (retry on 409 conflict)
        new_ports: list[dict[str, Any]] = []
        for _attempt in range(5):
            dep = self._k8s.get_deployment(self._ns, dep_name)
            container = dep["spec"]["template"]["spec"]["containers"][0]
            existing_ports: list[dict[str, Any]] = container.get("ports", [])

            # Find and remove the port
            found = False
            new_ports = []
            for ep in existing_ports:
                if ep.get("containerPort") == port_num and ep.get("protocol", "TCP") == proto:
                    found = True
                else:
                    new_ports.append(ep)
            if not found:
                raise PortNotFoundError(port_num, proto, ptype, app)

            if new_ports:
                container["ports"] = new_ports
            else:
                container.pop("ports", None)
            try:
                self._k8s.update_deployment(self._ns, dep_name, dep)
                break
            except Exception as e:
                if "conflict" in str(e).lower() or "409" in str(e):
                    continue
                raise

        svc_name = resources.safe_name(self._prefix, app, ptype)
        if new_ports:
            # Update Service
            svc = self._k8s.get_service(self._ns, svc_name)
            svc["spec"]["ports"] = _service_ports_from_container_ports(new_ports)
            self._k8s.update_service(self._ns, svc_name, svc)
            # Update process-allow NetworkPolicy
            self._update_process_allow_policy(app, ptype, new_ports)
        else:
            # Last port removed — delete Service and process-allow NetworkPolicy
            try:
                self._k8s.delete_service(self._ns, svc_name)
            except Exception:
                try:
                    self._k8s.get_service(self._ns, svc_name)
                except Exception:
                    pass  # Confirmed gone
                else:
                    raise
            np_name = resources.safe_name(self._prefix, "netpol-allow", f"{app}-{ptype}")
            try:
                self._k8s.delete_network_policy(self._ns, np_name)
            except Exception:
                try:
                    self._k8s.get_network_policy(self._ns, np_name)
                except Exception:
                    pass  # Confirmed gone
                else:
                    raise

        if on_step:
            on_step(f"Removed {port_num}/{proto.lower()} from {ptype}.")

    # ── Logs ─────────────────────────────────────────────────────

    def get_logs(
        self,
        app: str,
        *,
        process_type: str | None = None,
        dyno: str | None = None,
        num: int = 100,
        since: str | None = None,
        timestamps: bool = False,
        previous: bool = False,
    ) -> list[LogLine]:
        """Get recent logs from app pods (non-follow mode).

        Returns a flat list of LogLine objects sorted by dyno name.
        """
        self._check_app_exists(app)

        since_seconds: int | None = None
        if since:
            since_seconds = _parse_duration(since)

        pods = self._list_app_pods(app, process_type=process_type)
        if not pods:
            ptype = process_type or "(any)"
            raise NoDynosError(ptype, app)

        # Build dyno names: sorted pods within each type → web.1, web.2, ...
        dyno_map = self._build_dyno_map(pods)

        # Filter by specific dyno name if requested
        if dyno:
            matching = {k: v for k, v in dyno_map.items() if v == dyno}
            if not matching:
                raise DynoNotFoundError(dyno, app)
            dyno_map = matching

        log_lines: list[LogLine] = []
        now = datetime.now(UTC)
        for pod_name, dyno_name in sorted(dyno_map.items(), key=lambda x: x[1]):
            lines = self._k8s.stream_pod_logs(
                self._ns,
                pod_name,
                follow=False,
                tail_lines=num,
                since_seconds=since_seconds,
                previous=previous,
            )
            for line_text in lines:
                # Try to extract timestamp prefix if present
                ts = now
                msg = line_text
                if timestamps and " " in line_text:
                    parts = line_text.split(" ", 1)
                    try:
                        ts = datetime.fromisoformat(parts[0].replace("Z", "+00:00"))
                        msg = parts[1] if len(parts) > 1 else ""
                    except (ValueError, IndexError):
                        pass

                log_lines.append(
                    LogLine(
                        timestamp=ts,
                        source="app",
                        dyno=dyno_name,
                        message=msg,
                    )
                )

        return log_lines

    def stream_logs(
        self,
        app: str,
        *,
        process_type: str | None = None,
        dyno: str | None = None,
        num: int = 100,
        since: str | None = None,
        previous: bool = False,
        on_line: Callable[[LogLine], None] | None = None,
        stop_event: Any = None,
    ) -> None:
        """Stream logs in follow mode (blocking).

        Calls on_line for each new log line. Blocks until stop_event is set.
        """
        self._check_app_exists(app)

        since_seconds: int | None = None
        if since:
            since_seconds = _parse_duration(since)

        if stop_event is None:
            stop_event = threading.Event()

        pods = self._list_app_pods(app, process_type=process_type)
        if not pods:
            ptype = process_type or "(any)"
            raise NoDynosError(ptype, app)

        dyno_map = self._build_dyno_map(pods)

        if dyno:
            matching = {k: v for k, v in dyno_map.items() if v == dyno}
            if not matching:
                raise DynoNotFoundError(dyno, app)
            dyno_map = matching

        def _follow_pod(pod_name: str, dyno_name: str) -> None:
            try:
                lines = self._k8s.stream_pod_logs(
                    self._ns,
                    pod_name,
                    follow=True,
                    tail_lines=num,
                    since_seconds=since_seconds,
                    previous=previous,
                )
                now = datetime.now(UTC)
                for line_text in lines:
                    if stop_event.is_set():
                        break
                    if on_line:
                        on_line(
                            LogLine(
                                timestamp=now,
                                source="app",
                                dyno=dyno_name,
                                message=line_text,
                            )
                        )
            except Exception:
                pass  # Pod terminated or connection lost

        threads: list[threading.Thread] = []
        for pod_name, dyno_name in dyno_map.items():
            t = threading.Thread(target=_follow_pod, args=(pod_name, dyno_name), daemon=True)
            t.start()
            threads.append(t)

        # Block until stop event
        stop_event.wait()
        for t in threads:
            t.join(timeout=2.0)

    # ── Exec ─────────────────────────────────────────────────────

    def exec_interactive(
        self,
        app: str,
        command: list[str],
        *,
        process_type: str | None = None,
        dyno: str | None = None,
        tty: bool = True,
    ) -> ExecResult:
        """Exec a command in a running pod.

        If dyno is specified, exec in that specific pod.
        Otherwise, picks the first pod of the given process type.
        """
        self._check_app_exists(app)

        ptype = process_type or "web"
        pods = self._list_app_pods(app, process_type=ptype)
        if not pods:
            raise NoDynosError(ptype, app)

        dyno_map = self._build_dyno_map(pods)

        if dyno:
            matching = {k: v for k, v in dyno_map.items() if v == dyno}
            if not matching:
                raise DynoNotFoundError(dyno, app)
            pod_name = next(iter(matching))
        else:
            # Pick first pod (sorted by dyno name → web.1)
            pod_name = min(dyno_map, key=lambda k: dyno_map[k])

        output = self._k8s.exec_in_pod(
            self._ns,
            pod_name,
            command,
            tty=tty,
        )

        return ExecResult(
            output=output,
            pod_name=pod_name,
            process_type=ptype,
        )

    def exec_detached(
        self,
        app: str,
        command: list[str],
        *,
        process_type: str | None = None,
        extra_env: dict[str, str] | None = None,
        timeout: int = 3600,
    ) -> DetachedJob:
        """Run a command as a detached background Job.

        Creates a K8s Job with the same image and env as the process type.
        """
        self._check_app_exists(app)

        ptype = process_type or "web"
        # Get image from deployment
        dep_name = resources.safe_name(self._prefix, app, ptype)
        try:
            dep = self._k8s.get_deployment(self._ns, dep_name)
        except Exception:
            raise ProcessTypeNotFoundError(ptype, app) from None

        image = dep["spec"]["template"]["spec"]["containers"][0]["image"]
        run_id = uuid.uuid4().hex[:8]

        env_list: list[dict[str, str]] | None = None
        if extra_env:
            env_list = [{"name": k, "value": v} for k, v in extra_env.items()]

        job_body = resources.build_job(
            self._prefix,
            self._domain,
            app,
            run_id,
            image,
            command,
            extra_env=env_list,
            timeout=timeout,
        )
        created = self._k8s.create_job(self._ns, job_body)
        job_name: str = created["metadata"]["name"]

        return DetachedJob(
            job_name=job_name,
            app=app,
            command=command,
        )

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

    def _list_app_pods(
        self,
        app: str,
        process_type: str | None = None,
    ) -> list[dict[str, Any]]:
        """List pods belonging to an app, optionally filtered by process type."""
        match_labels: dict[str, str] = {
            f"{self._domain}/managed-by": self._prefix,
            f"{self._domain}/app": app,
        }
        if process_type:
            match_labels[f"{self._domain}/process-type"] = process_type
        return self._k8s.list_pods(self._ns, labels=match_labels)

    def _build_dyno_map(self, pods: list[dict[str, Any]]) -> dict[str, str]:
        """Build {pod_name: dyno_name} map from pods.

        Dyno names: sorted pods within each type → web.1, web.2, worker.1.
        """
        # Group pods by process type
        by_type: dict[str, list[str]] = {}
        for pod in pods:
            pod_labels = pod.get("metadata", {}).get("labels", {})
            ptype = pod_labels.get(f"{self._domain}/process-type", "unknown")
            pod_name: str = pod.get("metadata", {}).get("name", "")
            by_type.setdefault(ptype, []).append(pod_name)

        dyno_map: dict[str, str] = {}
        for ptype in sorted(by_type):
            for idx, pod_name in enumerate(sorted(by_type[ptype]), 1):
                dyno_map[pod_name] = f"{ptype}.{idx}"

        return dyno_map

    def _resolve_process_type(
        self,
        app: str,
        process_type: str | None,
    ) -> str:
        """Resolve or validate the process type for port operations.

        If process_type is given, verify it exists.
        If None and exactly 1 type → auto-select.
        If None and multiple → raise AmbiguousProcessTypeError.
        """
        match_labels = {
            f"{self._domain}/managed-by": self._prefix,
            f"{self._domain}/app": app,
        }
        deps = self._k8s.list_deployments(self._ns, labels=match_labels)
        types: list[str] = []
        for dep in deps:
            dep_labels = dep.get("metadata", {}).get("labels", {})
            ptype: str | None = dep_labels.get(f"{self._domain}/process-type")
            if ptype:
                types.append(ptype)

        if process_type is not None:
            if process_type not in types:
                raise ProcessTypeNotFoundError(process_type, app)
            return process_type

        if len(types) == 1:
            return types[0]

        if len(types) == 0:
            raise ProcessTypeNotFoundError("(none)", app)

        raise AmbiguousProcessTypeError(app, tuple(sorted(types)))

    def _update_process_allow_policy(
        self,
        app: str,
        process: str,
        container_ports: list[dict[str, Any]],
    ) -> None:
        """Create or replace the process-allow NetworkPolicy with current ports."""
        np_name = resources.safe_name(self._prefix, "netpol-allow", f"{app}-{process}")
        try:
            self._k8s.delete_network_policy(self._ns, np_name)
        except Exception:
            try:
                self._k8s.get_network_policy(self._ns, np_name)
            except Exception:
                pass  # Confirmed gone — OK for delete-before-create
            else:
                raise
        np_ports = _netpol_ports_from_container_ports(container_ports)
        np_body = resources.build_process_allow_policy(
            self._prefix,
            self._domain,
            app,
            process,
            np_ports,
        )
        self._k8s.create_network_policy(self._ns, np_body)

    def _scale_deployment_with_retry(
        self,
        dep_name: str,
        replicas: int,
        max_retries: int = 5,
    ) -> None:
        """Scale a deployment with retry on 409 Conflict.

        Re-reads the deployment on each attempt so the resourceVersion is fresh.
        Uses exponential backoff (0.5s, 1s, 1.5s, ...) between retries.
        """
        for attempt in range(max_retries):
            dep = self._k8s.get_deployment(self._ns, dep_name)
            dep["spec"]["replicas"] = replicas
            try:
                self._k8s.update_deployment(self._ns, dep_name, dep)
                return
            except Exception as exc:
                status = getattr(exc, "status", None)
                if status == 409 and attempt < max_retries - 1:
                    time.sleep(0.5 * (attempt + 1))
                    continue
                raise
        raise RuntimeError(  # pragma: no cover
            f"_scale_deployment_with_retry: exhausted {max_retries} retries for {dep_name}"
        )

    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 _find_process_service(
        self,
        app: str,
        name: str,
    ) -> tuple[str, dict[str, Any]]:
        """Find a process Service for the given name. Raises if not found."""
        svc_name = resources.safe_name(self._prefix, app, name)
        try:
            svc = self._k8s.get_service(self._ns, svc_name)
        except Exception:
            raise NameNotFoundError(name, app) from None
        # Verify it's a process service by checking labels
        svc_labels = svc.get("metadata", {}).get("labels", {})
        if svc_labels.get(f"{self._domain}/process-type") == name:
            return (svc_name, svc)
        raise NameNotFoundError(name, app)

    def _create_external_policy(
        self,
        app: str,
        name: str,
        ports: list[dict[str, Any]],
    ) -> None:
        """Create the external-allow NetworkPolicy for a process type."""
        np_name = resources.safe_name(
            self._prefix,
            "netpol-external",
            f"{app}-{name}",
        )
        # Delete existing if present (idempotent)
        try:
            self._k8s.delete_network_policy(self._ns, np_name)
        except Exception:
            try:
                self._k8s.get_network_policy(self._ns, np_name)
            except Exception:
                pass  # Confirmed gone — OK for delete-before-create
            else:
                raise

        body = resources.build_external_allow_policy(
            self._prefix,
            self._domain,
            app,
            name,
            ports,
        )
        self._k8s.create_network_policy(self._ns, body)

    def _delete_external_policy(self, app: str, name: str) -> None:
        """Delete the external-allow NetworkPolicy.

        Only deletes if the policy's exposed-by annotation is "expose" only.
        If other owners exist (e.g. "domains"), leave it.
        """
        np_name = resources.safe_name(
            self._prefix,
            "netpol-external",
            f"{app}-{name}",
        )
        try:
            np = self._k8s.get_network_policy(self._ns, np_name)
        except Exception:
            return  # Already gone

        annotations = np.get("metadata", {}).get("annotations", {})
        exposed_by = annotations.get(f"{self._domain}/exposed-by", "")
        owners = {o.strip() for o in exposed_by.split(",") if o.strip()}
        owners.discard("expose")

        if owners:
            # Other owners remain — update annotation, keep policy
            np["metadata"].setdefault("annotations", {})[f"{self._domain}/exposed-by"] = ",".join(
                sorted(owners)
            )
            self._k8s.update_network_policy(self._ns, np_name, np)
        else:
            self._k8s.delete_network_policy(self._ns, np_name)

    @staticmethod
    def _build_endpoint(
        name: str,
        app: str,
        kind: str,
        svc: dict[str, Any],
        method: str,
    ) -> ExposedEndpoint:
        """Build an ExposedEndpoint from a Service dict."""
        svc_type = svc.get("spec", {}).get("type", "ClusterIP")
        svc_ports = svc.get("spec", {}).get("ports", [])
        port_nums = tuple(p.get("port", 0) for p in svc_ports)

        # Extract external IP if LoadBalancer
        external_ip: str | None = None
        hostname: str | None = None
        status = svc.get("status", {})
        lb = status.get("loadBalancer", {})
        for ingress in lb.get("ingress", []):
            if ingress.get("ip"):
                external_ip = ingress["ip"]
                break
            if ingress.get("hostname"):
                hostname = ingress["hostname"]
                break

        return ExposedEndpoint(
            name=name,
            app=app,
            kind=kind,
            service_type=svc_type,
            method=method,
            external_ip=external_ip,
            hostname=hostname,
            ports=port_nums,
        )
