"""Certificate management for Citadel.

Provides programmatic access to cert-manager certificate lifecycle operations:
generating Certificate YAML manifests, listing certificates, checking expiry,
and triggering forced renewal.

Integrates with the cert-manager ClusterIssuers defined in
security/certificates/cluster-issuers.yaml (letsencrypt-staging,
letsencrypt-prod, internal-ca).

Design decisions:
  - CertificateConfig is a frozen dataclass (consistent with NodeInfo, DrainResult, etc.)
  - create_certificate is a static method — pure YAML generation, no cluster access needed
  - list_certificates, check_expiry, rotate_certificate require a live Kubernetes client
  - Duration/renewal use hours (cert-manager's native unit) not days
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import TYPE_CHECKING

from kubernetes import client, config
from kubernetes.client.exceptions import ApiException

if TYPE_CHECKING:
    pass

logger = logging.getLogger(__name__)

# Valid ClusterIssuers defined in security/certificates/cluster-issuers.yaml
VALID_ISSUERS = frozenset({
    "letsencrypt-staging",
    "letsencrypt-prod",
    "internal-ca",
    "citadel-selfsigned-bootstrap",
})

# cert-manager API group and version
CERT_MANAGER_GROUP = "cert-manager.io"
CERT_MANAGER_VERSION = "v1"
CERT_MANAGER_PLURAL = "certificates"

# Default certificate parameters (aligned with certificate-templates.yaml)
DEFAULT_DURATION_HOURS = 2160    # 90 days
DEFAULT_RENEW_BEFORE_HOURS = 720  # 30 days
MIN_DURATION_HOURS = 1
MAX_DURATION_HOURS = 87600       # 10 years (root CA ceiling)
MIN_RENEW_BEFORE_HOURS = 1


@dataclass(frozen=True)
class CertificateConfig:
    """Configuration for a cert-manager Certificate resource.

    All parameters map directly to cert-manager Certificate spec fields.
    Duration and renewal are specified in hours because cert-manager uses
    Go duration format (e.g., "2160h"), and hours avoid ambiguity around
    day boundaries.

    Attributes:
        issuer_name: Name of the ClusterIssuer to use.
        common_name: The CN for the certificate's subject.
        dns_names: SAN DNS names. Must include at least one entry.
        duration_hours: Certificate validity period in hours. Default 90 days.
        renew_before_hours: Time before expiry to trigger renewal. Default 30 days.
        is_ca: Whether this certificate is a CA certificate.
        secret_name: Kubernetes Secret name to store the cert. Auto-generated if None.
        namespace: Kubernetes namespace for the Certificate resource.
        private_key_algorithm: Key algorithm (ECDSA or RSA).
        private_key_size: Key size (256 for ECDSA, 2048/4096 for RSA).
        usages: Certificate key usages.
    """

    issuer_name: str
    common_name: str
    dns_names: tuple[str, ...] = ()
    duration_hours: int = DEFAULT_DURATION_HOURS
    renew_before_hours: int = DEFAULT_RENEW_BEFORE_HOURS
    is_ca: bool = False
    secret_name: str | None = None
    namespace: str = "default"
    private_key_algorithm: str = "ECDSA"
    private_key_size: int = 256
    usages: tuple[str, ...] = ("server auth", "digital signature", "key encipherment")
    extra_labels: dict[str, str] = field(default_factory=dict)

    def __post_init__(self) -> None:
        """Validate configuration constraints."""
        if not isinstance(self.extra_labels, dict):
            raise TypeError("extra_labels must be a dict")
        if not self.issuer_name:
            raise ValueError("issuer_name must not be empty")
        if not self.common_name:
            raise ValueError("common_name must not be empty")
        if not isinstance(self.dns_names, tuple):
            raise TypeError("dns_names must be a tuple")
        if self.duration_hours < MIN_DURATION_HOURS:
            raise ValueError(
                f"duration_hours must be >= {MIN_DURATION_HOURS}, got {self.duration_hours}"
            )
        if self.duration_hours > MAX_DURATION_HOURS:
            raise ValueError(
                f"duration_hours must be <= {MAX_DURATION_HOURS}, got {self.duration_hours}"
            )
        if self.renew_before_hours < MIN_RENEW_BEFORE_HOURS:
            raise ValueError(
                f"renew_before_hours must be >= {MIN_RENEW_BEFORE_HOURS}, "
                f"got {self.renew_before_hours}"
            )
        if self.renew_before_hours >= self.duration_hours:
            raise ValueError(
                f"renew_before_hours ({self.renew_before_hours}) must be less than "
                f"duration_hours ({self.duration_hours})"
            )
        if self.private_key_algorithm not in ("ECDSA", "RSA"):
            raise ValueError(
                f"private_key_algorithm must be 'ECDSA' or 'RSA', "
                f"got '{self.private_key_algorithm}'"
            )
        if self.private_key_algorithm == "ECDSA" and self.private_key_size not in (256, 384):
            raise ValueError(
                f"ECDSA key size must be 256 or 384, got {self.private_key_size}"
            )
        if self.private_key_algorithm == "RSA" and self.private_key_size not in (2048, 4096):
            raise ValueError(
                f"RSA key size must be 2048 or 4096, got {self.private_key_size}"
            )

    @property
    def effective_secret_name(self) -> str:
        """Return the secret name, auto-generating from common_name if not set."""
        if self.secret_name is not None:
            return self.secret_name
        # Sanitize common_name for use as a Kubernetes resource name:
        # replace dots and wildcards, lowercase, append -tls
        sanitized = self.common_name.replace("*.", "wildcard-").replace(".", "-").lower()
        return f"{sanitized}-tls"

    @property
    def effective_dns_names(self) -> tuple[str, ...]:
        """Return dns_names, falling back to (common_name,) if empty."""
        if self.dns_names:
            return self.dns_names
        return (self.common_name,)


class CertificateManager:
    """Manages cert-manager Certificate resources in a Kubernetes cluster.

    Provides both static methods for offline YAML generation and instance
    methods for live cluster operations (list, check expiry, rotate).

    Usage::

        # Offline: generate a Certificate manifest
        config = CertificateConfig(
            issuer_name="internal-ca",
            common_name="my-service.default.svc.cluster.local",
            dns_names=("my-service.default.svc.cluster.local", "my-service"),
        )
        manifest = CertificateManager.create_certificate(config)

        # Online: check certificate expiry
        manager = CertificateManager()
        expiring = manager.check_expiry(namespace="default")
    """

    def __init__(self, kubeconfig: str | None = None) -> None:
        """Initialize with Kubernetes client configuration.

        Args:
            kubeconfig: Path to kubeconfig file. If None, tries in-cluster
                config first, then falls back to default kubeconfig.
        """
        if kubeconfig:
            config.load_kube_config(config_file=kubeconfig)
        else:
            try:
                config.load_incluster_config()
            except config.ConfigException:
                config.load_kube_config()

        self._custom = client.CustomObjectsApi()
        self._core = client.CoreV1Api()

    @staticmethod
    def create_certificate(cert_config: CertificateConfig) -> dict:
        """Generate a cert-manager Certificate resource as a dict.

        This is a pure function — no cluster access required. The returned
        dict can be serialized to YAML and applied via kubectl, or passed
        directly to the Kubernetes API.

        Args:
            cert_config: Certificate configuration.

        Returns:
            A dict representing a cert-manager Certificate resource.
        """
        spec: dict = {
            "secretName": cert_config.effective_secret_name,
            "duration": f"{cert_config.duration_hours}h",
            "renewBefore": f"{cert_config.renew_before_hours}h",
            "isCA": cert_config.is_ca,
            "privateKey": {
                "algorithm": cert_config.private_key_algorithm,
                "size": cert_config.private_key_size,
                "rotationPolicy": "Always",
            },
            "commonName": cert_config.common_name,
            "dnsNames": list(cert_config.effective_dns_names),
            "issuerRef": {
                "name": cert_config.issuer_name,
                "kind": "ClusterIssuer",
                "group": CERT_MANAGER_GROUP,
            },
        }

        if cert_config.usages:
            spec["usages"] = list(cert_config.usages)

        # Derive a resource name from common_name
        resource_name = (
            cert_config.common_name
            .replace("*.", "wildcard-")
            .replace(".", "-")
            .lower()
        )

        labels = {"app.kubernetes.io/managed-by": "citadel-operator"}
        labels.update(cert_config.extra_labels)

        return {
            "apiVersion": f"{CERT_MANAGER_GROUP}/{CERT_MANAGER_VERSION}",
            "kind": "Certificate",
            "metadata": {
                "name": resource_name,
                "namespace": cert_config.namespace,
                "labels": labels,
            },
            "spec": spec,
        }

    def list_certificates(self, namespace: str = "default") -> list[dict]:
        """List cert-manager Certificate resources in a namespace.

        Args:
            namespace: Kubernetes namespace to query.

        Returns:
            List of Certificate resource dicts. Empty list if none found
            or if the cert-manager CRDs are not installed.
        """
        try:
            result = self._custom.list_namespaced_custom_object(
                group=CERT_MANAGER_GROUP,
                version=CERT_MANAGER_VERSION,
                namespace=namespace,
                plural=CERT_MANAGER_PLURAL,
            )
            return result.get("items", [])
        except ApiException as e:
            if e.status == 404:
                logger.warning(
                    "cert-manager CRDs not found. Is cert-manager installed?"
                )
                return []
            logger.error("Failed to list certificates in %s: %s", namespace, e.reason)
            raise

    def check_expiry(
        self, namespace: str = "default"
    ) -> list[tuple[str, datetime, bool]]:
        """Check certificate expiry status for all certificates in a namespace.

        Reads the TLS secrets referenced by each Certificate and extracts
        the notAfter timestamp from the certificate's status conditions.

        Args:
            namespace: Kubernetes namespace to query.

        Returns:
            List of (certificate_name, expiry_datetime, needs_renewal) tuples.
            needs_renewal is True if the certificate's renewalTime has passed
            or if the certificate is not in a Ready state.
        """
        certificates = self.list_certificates(namespace)
        results: list[tuple[str, datetime, bool]] = []

        for cert in certificates:
            name = cert.get("metadata", {}).get("name", "unknown")
            status = cert.get("status", {})

            # Extract notAfter from status
            not_after_str = status.get("notAfter")
            renewal_time_str = status.get("renewalTime")

            if not not_after_str:
                # Certificate may not have been issued yet
                logger.debug("Certificate %s has no notAfter — may be pending", name)
                continue

            try:
                not_after = datetime.fromisoformat(
                    not_after_str.replace("Z", "+00:00")
                )
            except (ValueError, TypeError):
                logger.warning(
                    "Certificate %s has unparseable notAfter: %s", name, not_after_str
                )
                continue

            # Determine renewal status
            now = datetime.now(timezone.utc)
            needs_renewal = False

            if renewal_time_str:
                try:
                    renewal_time = datetime.fromisoformat(
                        renewal_time_str.replace("Z", "+00:00")
                    )
                    needs_renewal = now >= renewal_time
                except (ValueError, TypeError):
                    pass

            # Also check Ready condition
            conditions = status.get("conditions", [])
            for condition in conditions:
                if condition.get("type") == "Ready" and condition.get("status") != "True":
                    needs_renewal = True
                    break

            # If past expiry, definitely needs renewal
            if now >= not_after:
                needs_renewal = True

            results.append((name, not_after, needs_renewal))

        return results

    def rotate_certificate(self, name: str, namespace: str = "default") -> bool:
        """Force renewal of a certificate by deleting its backing Secret.

        cert-manager watches for missing Secrets and automatically re-issues
        the certificate when the Secret is deleted. This is the standard
        approach for forcing certificate rotation.

        Args:
            name: Certificate resource name.
            namespace: Kubernetes namespace.

        Returns:
            True if the Secret was successfully deleted (rotation triggered),
            False if the certificate or secret was not found.
        """
        # Look up the Certificate to find its secretName
        try:
            cert = self._custom.get_namespaced_custom_object(
                group=CERT_MANAGER_GROUP,
                version=CERT_MANAGER_VERSION,
                namespace=namespace,
                plural=CERT_MANAGER_PLURAL,
                name=name,
            )
        except ApiException as e:
            if e.status == 404:
                logger.error("Certificate %s not found in namespace %s", name, namespace)
                return False
            raise

        secret_name = cert.get("spec", {}).get("secretName")
        if not secret_name:
            logger.error(
                "Certificate %s has no secretName in spec", name
            )
            return False

        # Delete the Secret to trigger re-issuance
        try:
            self._core.delete_namespaced_secret(
                name=secret_name,
                namespace=namespace,
            )
            logger.info(
                "Deleted secret %s/%s to trigger certificate rotation for %s",
                namespace,
                secret_name,
                name,
            )
            return True
        except ApiException as e:
            if e.status == 404:
                logger.warning(
                    "Secret %s/%s not found — certificate %s may not have been issued yet",
                    namespace,
                    secret_name,
                    name,
                )
                return False
            logger.error(
                "Failed to delete secret %s/%s: %s",
                namespace,
                secret_name,
                e.reason,
            )
            raise
