"""SSO/OIDC integration via Dex for Citadel.

Provides programmatic access to Dex OIDC identity provider configuration:
generating K3s OIDC flags, kubeconfig with OIDC authentication, Dex
connector definitions, and Kubernetes RBAC bindings for OIDC groups.

Integrates with the Dex deployment defined in security/sso/ and the
K3s API server OIDC configuration.

Design decisions:
  - OIDCConfig and DexConnector are frozen dataclasses (consistent with
    CertificateConfig, NodeInfo, DrainResult, etc.)
  - generate_k8s_oidc_flags and generate_kubeconfig are static methods --
    pure dict generation, no cluster access needed
  - list_connectors requires a live Dex API endpoint
  - create_rbac_binding is a static method -- pure YAML generation
  - Scopes default to ("openid", "profile", "email", "groups") which is
    the minimum useful set for K8s OIDC authentication with group mapping
  - Token expiry defaults (1h access, 24h refresh) are enforced in the
    Helm values, not in this module
"""

from __future__ import annotations

import copy
import json
import logging
import urllib.request
from dataclasses import dataclass, field
from typing import Any

logger = logging.getLogger(__name__)

# Supported Dex connector types
VALID_CONNECTOR_TYPES = frozenset({"saml", "ldap", "github", "oidc", "static"})

# Default OIDC scopes for K8s authentication
DEFAULT_SCOPES = ("openid", "profile", "email", "groups")

# Dex API paths
DEX_API_PATH = "/api/v1"

# Managed-by label for all generated resources
MANAGED_BY_LABEL = "citadel-operator"


@dataclass(frozen=True)
class OIDCConfig:
    """Configuration for OIDC authentication against a Dex issuer.

    Maps to the K3s API server --oidc-* flags and to kubeconfig
    OIDC auth provider sections. All fields are validated at
    construction time.

    Attributes:
        issuer_url: The Dex issuer URL (e.g., https://dex.citadel.local).
            Must be HTTPS for production use.
        client_id: OAuth2 client ID registered in Dex.
        client_secret: OAuth2 client secret. None for public clients
            (e.g., CLI with device code flow).
        scopes: OIDC scopes to request. Must include "openid".
        groups_claim: JWT claim containing group memberships.
        username_claim: JWT claim containing the username.
    """

    issuer_url: str
    client_id: str
    client_secret: str | None = None
    scopes: tuple[str, ...] = DEFAULT_SCOPES
    groups_claim: str = "groups"
    username_claim: str = "email"

    def __post_init__(self) -> None:
        """Validate configuration constraints."""
        if not self.issuer_url:
            raise ValueError("issuer_url must not be empty")
        if not self.issuer_url.startswith(("https://", "http://")):
            raise ValueError(
                f"issuer_url must start with https:// or http://, got '{self.issuer_url}'"
            )
        if not self.client_id:
            raise ValueError("client_id must not be empty")
        if not isinstance(self.scopes, tuple):
            raise TypeError("scopes must be a tuple")
        if "openid" not in self.scopes:
            raise ValueError("scopes must include 'openid'")
        if not self.groups_claim:
            raise ValueError("groups_claim must not be empty")
        if not self.username_claim:
            raise ValueError("username_claim must not be empty")


@dataclass(frozen=True)
class DexConnector:
    """Configuration for a Dex upstream identity provider connector.

    Represents a single connector definition that can be rendered into
    the Dex connectors configuration format.

    Attributes:
        connector_type: The connector type (saml, ldap, github, oidc, static).
        name: Human-readable display name for the connector.
        connector_id: Unique identifier for the connector. Auto-generated
            from name if not provided.
        config: Connector-specific configuration dict. Contents vary by
            connector_type (see Dex documentation).
    """

    connector_type: str
    name: str
    connector_id: str = ""
    config: dict[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        """Validate connector configuration."""
        if not self.connector_type:
            raise ValueError("connector_type must not be empty")
        if self.connector_type not in VALID_CONNECTOR_TYPES:
            raise ValueError(
                f"connector_type must be one of {sorted(VALID_CONNECTOR_TYPES)}, "
                f"got '{self.connector_type}'"
            )
        if not self.name:
            raise ValueError("name must not be empty")
        if not isinstance(self.config, dict):
            raise TypeError("config must be a dict")

    @property
    def effective_id(self) -> str:
        """Return the connector ID, auto-generating from name if not set."""
        if self.connector_id:
            return self.connector_id
        return self.name.lower().replace(" ", "-").replace("_", "-")

    def to_dex_config(self) -> dict[str, Any]:
        """Render this connector as a Dex configuration dict.

        Returns:
            A dict suitable for inclusion in Dex's connectors list.
        """
        return {
            "type": self.connector_type,
            "id": self.effective_id,
            "name": self.name,
            "config": copy.deepcopy(self.config),
        }


class SSOManager:
    """Manages SSO/OIDC integration for Citadel.

    Provides both static methods for offline configuration generation
    and instance methods for live Dex API operations.

    Usage::

        # Offline: generate K3s OIDC flags
        config = OIDCConfig(
            issuer_url="https://dex.citadel.local",
            client_id="kubernetes",
        )
        flags = SSOManager.generate_k8s_oidc_flags(config)

        # Offline: generate kubeconfig with OIDC auth
        kubeconfig = SSOManager.generate_kubeconfig(
            config, token="eyJhbGciOi..."
        )

        # Online: list Dex connectors
        manager = SSOManager(dex_url="https://dex.citadel.local")
        connectors = manager.list_connectors()
    """

    def __init__(self, dex_url: str | None = None) -> None:
        """Initialize with optional Dex API endpoint.

        Args:
            dex_url: Base URL for the Dex API. Required for
                list_connectors(). Not needed for static methods.
        """
        self._dex_url = dex_url.rstrip("/") if dex_url else None

    @staticmethod
    def generate_k8s_oidc_flags(config: OIDCConfig) -> dict[str, str]:
        """Generate K3s server OIDC flags for API server configuration.

        These flags are passed to the K3s server via --kube-apiserver-arg
        to enable OIDC authentication. The returned dict maps flag names
        to values suitable for inclusion in K3s server configuration.

        Args:
            config: OIDC configuration.

        Returns:
            A dict of K3s/K8s API server OIDC flag name-value pairs.
            Keys are the flag names without the leading '--'.
        """
        flags: dict[str, str] = {
            "oidc-issuer-url": config.issuer_url,
            "oidc-client-id": config.client_id,
            "oidc-username-claim": config.username_claim,
            "oidc-groups-claim": config.groups_claim,
        }

        if config.scopes:
            # K8s API server expects comma-separated scopes beyond "openid"
            extra_scopes = [s for s in config.scopes if s != "openid"]
            if extra_scopes:
                flags["oidc-groups-prefix"] = "oidc:"
                flags["oidc-username-prefix"] = "oidc:"

        return flags

    @staticmethod
    def generate_kubeconfig(
        config: OIDCConfig,
        token: str,
        *,
        cluster_name: str = "citadel-operator",
        cluster_server: str = "https://127.0.0.1:6443",
        cluster_ca_path: str | None = None,
        refresh_token: str | None = None,
    ) -> dict[str, Any]:
        """Generate a kubeconfig dict with OIDC authentication.

        Produces a kubeconfig suitable for kubectl access using an OIDC
        identity token obtained from Dex. The kubeconfig uses the
        oidc auth provider plugin.

        Args:
            config: OIDC configuration.
            token: The OIDC identity token (JWT).
            cluster_name: Name for the cluster entry.
            cluster_server: API server URL.
            cluster_ca_path: Path to the cluster CA certificate.
                If None, certificate verification details are omitted.
            refresh_token: Optional OIDC refresh token for automatic
                token renewal.

        Returns:
            A dict representing a kubeconfig file.
        """
        if not token:
            raise ValueError("token must not be empty")

        # Build cluster entry
        cluster_config: dict[str, Any] = {
            "server": cluster_server,
        }
        if cluster_ca_path:
            cluster_config["certificate-authority"] = cluster_ca_path
        else:
            cluster_config["insecure-skip-tls-verify"] = True

        # Build auth provider config
        auth_config: dict[str, str] = {
            "client-id": config.client_id,
            "id-token": token,
            "idp-issuer-url": config.issuer_url,
        }
        if config.client_secret:
            auth_config["client-secret"] = config.client_secret
        if refresh_token:
            auth_config["refresh-token"] = refresh_token

        user_name = f"oidc-{cluster_name}"

        return {
            "apiVersion": "v1",
            "kind": "Config",
            "current-context": cluster_name,
            "clusters": [
                {
                    "name": cluster_name,
                    "cluster": cluster_config,
                }
            ],
            "contexts": [
                {
                    "name": cluster_name,
                    "context": {
                        "cluster": cluster_name,
                        "user": user_name,
                    },
                }
            ],
            "users": [
                {
                    "name": user_name,
                    "user": {
                        "auth-provider": {
                            "name": "oidc",
                            "config": auth_config,
                        }
                    },
                }
            ],
        }

    def list_connectors(self) -> list[dict[str, Any]]:
        """Query the Dex API for configured connectors.

        Requires a live Dex instance. Returns the list of connectors
        from the Dex discovery endpoint.

        Returns:
            List of connector dicts from the Dex API. Empty list if
            Dex is unreachable or returns an error.

        Raises:
            RuntimeError: If no dex_url was provided at construction.
        """
        if not self._dex_url:
            raise RuntimeError(
                "dex_url is required for list_connectors(). "
                "Pass it to SSOManager(dex_url=...)"
            )

        url = f"{self._dex_url}{DEX_API_PATH}/connectors"
        logger.debug("Querying Dex connectors at %s", url)

        try:
            req = urllib.request.Request(url, method="GET")
            req.add_header("Accept", "application/json")
            with urllib.request.urlopen(req, timeout=10) as resp:
                data = json.loads(resp.read().decode("utf-8"))
                if isinstance(data, list):
                    return data
                # Some Dex versions wrap in {"connectors": [...]}
                return data.get("connectors", [])
        except urllib.error.HTTPError as e:
            logger.error("Dex API returned HTTP %d: %s", e.code, e.reason)
            return []
        except urllib.error.URLError as e:
            logger.error("Failed to connect to Dex at %s: %s", self._dex_url, e.reason)
            return []
        except (json.JSONDecodeError, ValueError) as e:
            logger.error("Invalid response from Dex API: %s", e)
            return []

    @staticmethod
    def create_rbac_binding(
        group: str,
        cluster_role: str,
        namespace: str | None = None,
    ) -> dict[str, Any]:
        """Generate a Kubernetes RBAC binding for an OIDC group.

        Creates either a ClusterRoleBinding (when namespace is None) or
        a namespaced RoleBinding (when namespace is provided). The binding
        maps an OIDC group claim to a Kubernetes ClusterRole.

        The group name is prefixed with "oidc:" to match the
        --oidc-groups-prefix configured on the API server.

        Args:
            group: The OIDC group name (without prefix).
            cluster_role: The ClusterRole to bind to.
            namespace: If provided, creates a namespaced RoleBinding.
                If None, creates a ClusterRoleBinding.

        Returns:
            A dict representing a ClusterRoleBinding or RoleBinding resource.
        """
        if not group:
            raise ValueError("group must not be empty")
        if not cluster_role:
            raise ValueError("cluster_role must not be empty")

        # Sanitize group name for use in resource name
        safe_group = group.lower().replace(" ", "-").replace("_", "-")
        safe_role = cluster_role.lower().replace(" ", "-").replace("_", "-")

        subject = {
            "kind": "Group",
            "name": f"oidc:{group}",
            "apiGroup": "rbac.authorization.k8s.io",
        }

        role_ref = {
            "kind": "ClusterRole",
            "name": cluster_role,
            "apiGroup": "rbac.authorization.k8s.io",
        }

        labels = {
            "app.kubernetes.io/managed-by": MANAGED_BY_LABEL,
            "citadel.dev/oidc-group": safe_group,
        }

        if namespace is None:
            # ClusterRoleBinding
            binding_name = f"oidc-{safe_group}-{safe_role}"
            return {
                "apiVersion": "rbac.authorization.k8s.io/v1",
                "kind": "ClusterRoleBinding",
                "metadata": {
                    "name": binding_name,
                    "labels": labels,
                },
                "subjects": [subject],
                "roleRef": role_ref,
            }
        else:
            # Namespace-scoped RoleBinding
            if not namespace:
                raise ValueError("namespace must not be empty when provided")
            binding_name = f"oidc-{safe_group}-{safe_role}"
            return {
                "apiVersion": "rbac.authorization.k8s.io/v1",
                "kind": "RoleBinding",
                "metadata": {
                    "name": binding_name,
                    "namespace": namespace,
                    "labels": labels,
                },
                "subjects": [subject],
                "roleRef": role_ref,
            }
