"""DomainsService — domain routing via Ingress.

Manages per-app Ingress resources with multiple rules for custom domains.
TLS via cert-manager annotations. Auto-domain on first web deploy.
"""

from __future__ import annotations

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

from kuberoku.exceptions import (
    AppNotFoundError,
    AutoDomainRemoveError,
    CertManagerNotFoundError,
    DomainAlreadyExistsError,
    DomainNotFoundError,
    IngressControllerNotFoundError,
)
from kuberoku.k8s import resources
from kuberoku.models import Domain
from kuberoku.sdk.ingress import detect_cert_manager, detect_ingress_controller

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


class DomainsService:
    """Business logic for domain routing via Ingress."""

    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 add(
        self,
        app: str,
        hostname: str,
        process_type: str = "web",
        port: int | None = None,
        no_tls: bool = False,
        issuer: str = "letsencrypt-prod",
        auto_generated: bool = False,
        on_step: Callable[[str], None] | None = None,
    ) -> Domain:
        """Add a domain to an app.

        Creates or updates the app's Ingress with a new rule.
        """
        self._check_app_exists(app)

        # Detect ingress controller
        if on_step:
            on_step("Detecting Ingress controller...")
        controller = detect_ingress_controller(self._k8s, self._ns)
        if controller is None:
            raise IngressControllerNotFoundError()

        # Check cert-manager for TLS
        use_tls = not no_tls
        actual_issuer: str | None = issuer if use_tls else None
        if use_tls:
            if on_step:
                on_step("Checking cert-manager...")
            if not detect_cert_manager(self._k8s):
                raise CertManagerNotFoundError()

        # Check hostname not already used by ANY ingress in namespace
        if on_step:
            on_step("Checking for hostname conflicts...")
        self._check_hostname_available(hostname, app)

        # Resolve backend service + port
        resolved_port = port or self._resolve_backend_port(app, process_type)
        svc_name = resources.safe_name(self._prefix, app, process_type)

        # Build rule
        rule = resources.build_ingress_rule(hostname, svc_name, resolved_port)

        # Build TLS entry if applicable
        tls_entry = None
        if use_tls:
            secret_name = f"tls-{hostname.replace('.', '-')}"
            tls_entry = resources.build_tls_entry(hostname, secret_name)

        # Get or create Ingress
        ingress_name = resources.safe_name(self._prefix, "ingress", app)
        try:
            ingress = self._k8s.get_ingress(self._ns, ingress_name)
            # Append rule
            ingress["spec"].setdefault("rules", []).append(rule)
            if tls_entry:
                ingress["spec"].setdefault("tls", []).append(tls_entry)
            # Track auto-generated hostnames
            if auto_generated:
                annots = ingress.setdefault("metadata", {}).setdefault("annotations", {})
                existing = annots.get(f"{self._domain}/auto-domains", "")
                autos = [h for h in existing.split(",") if h] + [hostname]
                annots[f"{self._domain}/auto-domains"] = ",".join(autos)
            # Update issuer annotation
            if actual_issuer:
                ingress.setdefault("metadata", {}).setdefault("annotations", {})[
                    "cert-manager.io/cluster-issuer"
                ] = actual_issuer
            if on_step:
                on_step("Updating Ingress...")
            self._k8s.update_ingress(self._ns, ingress_name, ingress)
        except Exception:
            # Create new Ingress
            auto_hostnames = [hostname] if auto_generated else None
            tls_list = [tls_entry] if tls_entry else None
            ingress_body = resources.build_ingress(
                self._prefix,
                self._domain,
                app,
                rules=[rule],
                tls=tls_list,
                ingress_class=controller.class_name,
                issuer=actual_issuer,
                auto_hostnames=auto_hostnames,
            )
            if on_step:
                on_step("Creating Ingress...")
            self._k8s.create_ingress(self._ns, ingress_body)

        # Create/update external-allow NetworkPolicy
        if on_step:
            on_step("Creating external network policy...")
        self._ensure_external_policy(app, process_type, resolved_port)

        return Domain(
            hostname=hostname,
            app=app,
            process_type=process_type,
            port=resolved_port,
            tls=use_tls,
            issuer=actual_issuer,
            auto_generated=auto_generated,
            status="active",
            created_at=datetime.now(UTC),
        )

    def remove(
        self,
        app: str,
        hostname: str,
        on_step: Callable[[str], None] | None = None,
    ) -> None:
        """Remove a domain from an app.

        Rejects auto-generated domains (use clear() instead).
        """
        self._check_app_exists(app)

        ingress_name = resources.safe_name(self._prefix, "ingress", app)
        try:
            ingress = self._k8s.get_ingress(self._ns, ingress_name)
        except Exception:
            raise DomainNotFoundError(hostname, app) from None

        # Check if auto-generated
        annots = ingress.get("metadata", {}).get("annotations", {})
        auto_domains = annots.get(f"{self._domain}/auto-domains", "")
        auto_list = [h for h in auto_domains.split(",") if h]
        if hostname in auto_list:
            raise AutoDomainRemoveError(hostname)

        # Find and remove the rule
        rules = ingress.get("spec", {}).get("rules", [])
        new_rules = [r for r in rules if r.get("host") != hostname]
        if len(new_rules) == len(rules):
            raise DomainNotFoundError(hostname, app)

        # Remove TLS entry
        tls = ingress.get("spec", {}).get("tls", [])
        new_tls = [t for t in tls if hostname not in t.get("hosts", [])]

        if not new_rules:
            # Last rule removed — delete Ingress
            if on_step:
                on_step("Deleting Ingress...")
            self._k8s.delete_ingress(self._ns, ingress_name)
        else:
            ingress["spec"]["rules"] = new_rules
            if new_tls:
                ingress["spec"]["tls"] = new_tls
            else:
                ingress["spec"].pop("tls", None)
            if on_step:
                on_step("Updating Ingress...")
            self._k8s.update_ingress(self._ns, ingress_name, ingress)

        # Clean up NetworkPolicy (remove "domains" owner)
        self._cleanup_external_policy_owner(app, on_step)

    def list(self, app: str) -> list[Domain]:
        """List all domains for an app."""
        self._check_app_exists(app)

        ingress_name = resources.safe_name(self._prefix, "ingress", app)
        try:
            ingress = self._k8s.get_ingress(self._ns, ingress_name)
        except Exception:
            return []

        annots = ingress.get("metadata", {}).get("annotations", {})
        auto_domains = annots.get(f"{self._domain}/auto-domains", "")
        auto_list = [h for h in auto_domains.split(",") if h]
        issuer = annots.get("cert-manager.io/cluster-issuer")

        # Build TLS hostname set
        tls_list = ingress.get("spec", {}).get("tls", [])
        tls_hostnames: set[str] = set()
        for entry in tls_list:
            for h in entry.get("hosts", []):
                tls_hostnames.add(h)

        domains: list[Domain] = []
        for rule in ingress.get("spec", {}).get("rules", []):
            host = rule.get("host", "")
            paths = rule.get("http", {}).get("paths", [])
            if paths:
                backend = paths[0].get("backend", {}).get("service", {})
                svc_name = backend.get("name", "")
                svc_port = backend.get("port", {}).get("number", 0)
                # Extract process type from service name
                ptype = self._process_type_from_svc(app, svc_name)
            else:
                svc_port = 0
                ptype = "web"

            has_tls = host in tls_hostnames

            domains.append(
                Domain(
                    hostname=host,
                    app=app,
                    process_type=ptype,
                    port=svc_port,
                    tls=has_tls,
                    issuer=issuer if has_tls else None,
                    auto_generated=host in auto_list,
                    status="active",
                    created_at=datetime.now(UTC),
                )
            )

        return sorted(domains, key=lambda d: d.hostname)

    def clear(
        self,
        app: str,
        on_step: Callable[[str], None] | None = None,
    ) -> None:
        """Remove ALL domains (auto + custom), deletes Ingress."""
        self._check_app_exists(app)

        ingress_name = resources.safe_name(self._prefix, "ingress", app)
        if on_step:
            on_step("Deleting Ingress...")
        try:
            self._k8s.delete_ingress(self._ns, ingress_name)
        except Exception:
            try:
                self._k8s.get_ingress(self._ns, ingress_name)
            except Exception:
                pass  # Confirmed gone
            else:
                raise

        # Clean up all NetworkPolicies (remove "domains" owner)
        self._cleanup_external_policy_owner(app, on_step)

    def auto_domain(
        self,
        app: str,
        on_step: Callable[[str], None] | None = None,
    ) -> Domain | None:
        """Create auto-domain if base_domain is configured.

        Best-effort: returns None if prerequisites are missing.
        Never raises (deploy should not fail due to this).
        """
        base_domain = self._factory.base_domain
        if not base_domain:
            return None

        hostname = f"{app}.{base_domain}"

        # Check if already exists (idempotent)
        existing = self.list(app)
        for d in existing:
            if d.hostname == hostname:
                return d

        # Best-effort: don't fail if no controller
        controller = detect_ingress_controller(self._k8s, self._ns)
        if controller is None:
            if on_step:
                on_step("Note: Auto-domain skipped (no Ingress controller)")
            return None

        # Best-effort TLS: skip if no cert-manager
        has_cm = detect_cert_manager(self._k8s)
        try:
            return self.add(
                app,
                hostname,
                process_type="web",
                no_tls=not has_cm,
                auto_generated=True,
                on_step=on_step,
            )
        except Exception:
            return None

    # ── 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 _check_hostname_available(self, hostname: str, app: str) -> None:
        """Check that hostname is not used by any Ingress in the namespace."""
        ingresses = self._k8s.list_ingresses(self._ns)
        for ing in ingresses:
            for rule in ing.get("spec", {}).get("rules", []):
                if rule.get("host") == hostname:
                    ing_app = (
                        ing.get("metadata", {})
                        .get("labels", {})
                        .get(f"{self._domain}/app", "unknown")
                    )
                    raise DomainAlreadyExistsError(hostname, ing_app)

    def _resolve_backend_port(self, app: str, process_type: str) -> int:
        """Get the first port from the process Service, defaulting to 8080."""
        svc_name = resources.safe_name(self._prefix, app, process_type)
        try:
            svc = self._k8s.get_service(self._ns, svc_name)
            ports = svc.get("spec", {}).get("ports", [])
            if ports:
                return int(ports[0].get("port", 8080))
        except Exception:
            pass
        return 8080

    def _process_type_from_svc(self, app: str, svc_name: str) -> str:
        """Extract process type from a service name."""
        prefix = resources.safe_name(self._prefix, app, "")
        if svc_name.startswith(prefix):
            remainder = svc_name[len(prefix) :]
            return remainder.lstrip("-") or "web"
        return "web"

    def _ensure_external_policy(
        self,
        app: str,
        process_type: str,
        port: int,
    ) -> None:
        """Create/update external-allow NetworkPolicy with 'domains' owner."""
        np_name = resources.safe_name(
            self._prefix,
            "netpol-external",
            f"{app}-{process_type}",
        )
        try:
            np = self._k8s.get_network_policy(self._ns, np_name)
            # Policy exists — add "domains" to exposed-by
            annots = np.get("metadata", {}).get("annotations", {})
            exposed_by = annots.get(f"{self._domain}/exposed-by", "")
            owners = {o.strip() for o in exposed_by.split(",") if o.strip()}
            owners.add("domains")
            np["metadata"].setdefault("annotations", {})[f"{self._domain}/exposed-by"] = ",".join(
                sorted(owners)
            )
            self._k8s.update_network_policy(self._ns, np_name, np)
        except Exception:
            # Create new policy
            netpol_ports = [{"port": port, "protocol": "TCP"}]
            body = resources.build_external_allow_policy(
                self._prefix,
                self._domain,
                app,
                process_type,
                netpol_ports,
                exposed_by="domains",
            )
            self._k8s.create_network_policy(self._ns, body)

    def _cleanup_external_policy_owner(
        self,
        app: str,
        on_step: Callable[[str], None] | None = None,
    ) -> None:
        """Remove 'domains' from exposed-by on external NetworkPolicies."""
        match_labels = {
            f"{self._domain}/managed-by": self._prefix,
            f"{self._domain}/app": app,
            f"{self._domain}/resource-type": "network-policy-external",
        }
        for np in self._k8s.list_network_policies(self._ns, labels=match_labels):
            np_name = np["metadata"]["name"]
            annots = np.get("metadata", {}).get("annotations", {})
            exposed_by = annots.get(f"{self._domain}/exposed-by", "")
            owners = {o.strip() for o in exposed_by.split(",") if o.strip()}
            owners.discard("domains")

            if owners:
                np["metadata"].setdefault("annotations", {})[f"{self._domain}/exposed-by"] = (
                    ",".join(sorted(owners))
                )
                self._k8s.update_network_policy(self._ns, np_name, np)
            else:
                if on_step:
                    on_step("Removing external network policy...")
                self._k8s.delete_network_policy(self._ns, np_name)
