"""Authentication and Authorization configuration.

Defines dataclasses for auth configuration and functions to load
auth settings from YAML configuration files.
"""

import os
from dataclasses import dataclass, field
from typing import Any

from mcp_hangar.logging_config import get_logger

logger = get_logger(__name__)


@dataclass
class ApiKeyAuthConfig:
    """API Key authentication configuration.

    Attributes:
        enabled: Whether API key authentication is enabled.
        header_name: Name of the HTTP header containing the API key.
    """

    enabled: bool = True
    header_name: str = "X-API-Key"


@dataclass
class OIDCIssuerConfig:
    """Per-issuer OIDC trust entry.

    Represents a single authorization server that this resource server
    trusts. Multiple entries enable multi-issuer support.

    Attributes:
        issuer: OIDC issuer URL (e.g., https://auth.company.com).
        audience: Expected audience claim value.
        jwks_uri: JWKS endpoint URL (auto-discovered from issuer if None).
        client_id: Optional client ID for additional validation.
        subject_claim: JWT claim for subject identifier.
        groups_claim: JWT claim for group memberships.
        tenant_claim: JWT claim for tenant identifier.
        email_claim: JWT claim for email address.
        session_id_claim: JWT claim carrying the session id a session suspension
            matches (GHSA-fhwh-fmq2-7m5c). Default ``sid``.
        max_token_lifetime_seconds: Maximum allowed token lifetime (exp - iat) in seconds.
            Value of 0 means disabled. Default: 3600.
        require_tenant: Fail-closed multi-tenant gate. When True, tokens from this
            issuer that carry no (or an empty) ``tenant_claim`` are rejected instead
            of being admitted as a global/no-tenant principal. Default False.
        strict_tenant_audience: Opt-in strict per-tenant audience binding (RFC 8707).
            When True, the token's ``aud`` must equal the resource explicitly mapped
            to the claimed tenant in ``tenant_audiences``; a token minted for tenant
            A's resource is rejected when presented for any other tenant. Default
            False (single-global-audience behavior is unchanged).
        tenant_audiences: Explicit tenant -> expected audience/resource URI map used
            when ``strict_tenant_audience`` is True. Explicit (auditable) mapping is
            preferred over templating. A tenant absent from this map is rejected
            fail-closed; the global ``audience`` is never a fallback in strict mode.
    """

    issuer: str = ""
    audience: str = ""
    jwks_uri: str | None = None
    client_id: str | None = None

    # Claim mappings
    subject_claim: str = "sub"
    groups_claim: str = "groups"
    tenant_claim: str = "tenant_id"
    email_claim: str = "email"
    session_id_claim: str = "sid"

    # Lifetime enforcement
    max_token_lifetime_seconds: int = 3600

    # Tolerance for clock drift against the issuer, applied to exp/iat/nbf.
    # PyJWT defaults to 0, which demands second-level agreement between this host
    # and the IdP; ordinary drift then rejects every token at once. 0 restores the
    # exact-agreement behaviour.
    clock_skew_leeway_seconds: int = 60

    # Multi-tenant fail-closed gate
    require_tenant: bool = False

    # Strict per-tenant audience binding (RFC 8707), opt-in
    strict_tenant_audience: bool = False
    tenant_audiences: dict[str, str] = field(default_factory=dict)


@dataclass
class OIDCAuthConfig:
    """OIDC/JWT authentication configuration.

    Attributes:
        enabled: Whether OIDC/JWT authentication is enabled.
        issuer: OIDC issuer URL (e.g., https://auth.company.com).
        audience: Expected audience claim value.
        jwks_uri: JWKS endpoint URL (auto-discovered from issuer if None).
        client_id: Optional client ID for additional validation.
        subject_claim: JWT claim for subject identifier.
        groups_claim: JWT claim for group memberships.
        tenant_claim: JWT claim for tenant identifier.
        email_claim: JWT claim for email address.
        session_id_claim: JWT claim carrying the session id a session suspension
            matches. Default ``sid``; inherited by per-issuer entries that do not
            set their own.
        max_token_lifetime_seconds: Maximum allowed token lifetime (exp - iat) in seconds.
            Value of 0 means disabled. Default: 3600.
        resource_uri: Public URI of this resource server (RFC 9728 "resource" field).
            When set, this overrides the request Host for PRM and WWW-Authenticate
            construction (preferred: proxies make the Host header unreliable).
            When unset, the URI is derived from the incoming request's scheme+host.
        issuers: List of trusted authorization servers (multi-issuer support).
            When non-empty, takes precedence over the legacy single-issuer fields.
        require_tenant: Fail-closed multi-tenant gate applied to every trusted
            issuer (unless a per-issuer entry overrides it). When True, a validated
            token with no (or empty) tenant claim is rejected rather than admitted
            as a global/no-tenant principal, preventing cross-tenant token use.
            Default False so single-tenant / no-OIDC deployments are unaffected.
        strict_tenant_audience: Opt-in strict per-tenant audience binding (RFC 8707),
            inherited by per-issuer entries. When True, a token's ``aud`` must match
            the resource mapped to its claimed tenant in ``tenant_audiences``,
            rejecting cross-tenant token replay at the token layer. Default False.
        tenant_audiences: Explicit tenant -> expected audience/resource URI map used
            when ``strict_tenant_audience`` is True (inherited by per-issuer entries
            unless overridden). Explicit and auditable; a tenant absent from the map
            is rejected fail-closed with no fallback to the global ``audience``.
    """

    enabled: bool = False
    issuer: str = ""
    audience: str = ""
    jwks_uri: str | None = None
    client_id: str | None = None
    resource_uri: str = ""

    # Claim mappings
    subject_claim: str = "sub"
    groups_claim: str = "groups"
    tenant_claim: str = "tenant_id"
    email_claim: str = "email"
    session_id_claim: str = "sid"

    # Lifetime enforcement
    max_token_lifetime_seconds: int = 3600

    # Tolerance for clock drift against the issuer, applied to exp/iat/nbf.
    # PyJWT defaults to 0, which demands second-level agreement between this host
    # and the IdP; ordinary drift then rejects every token at once. 0 restores the
    # exact-agreement behaviour.
    clock_skew_leeway_seconds: int = 60

    # Multi-tenant fail-closed gate (inherited by per-issuer entries)
    require_tenant: bool = False

    # Strict per-tenant audience binding (RFC 8707), opt-in (inherited per issuer)
    strict_tenant_audience: bool = False
    tenant_audiences: dict[str, str] = field(default_factory=dict)

    # Multi-issuer trust entries
    issuers: list[OIDCIssuerConfig] = field(default_factory=list)

    def resolved_issuers(self) -> list[OIDCIssuerConfig]:
        """Return the effective list of trusted issuers.

        Prefers the explicit ``issuers`` list. Falls back to synthesizing a
        single entry from the legacy top-level fields for backward
        compatibility. Returns an empty list if neither is configured.
        """
        if self.issuers:
            return self.issuers
        if self.issuer:
            return [
                OIDCIssuerConfig(
                    issuer=self.issuer,
                    audience=self.audience,
                    jwks_uri=self.jwks_uri,
                    client_id=self.client_id,
                    subject_claim=self.subject_claim,
                    groups_claim=self.groups_claim,
                    tenant_claim=self.tenant_claim,
                    email_claim=self.email_claim,
                    session_id_claim=self.session_id_claim,
                    max_token_lifetime_seconds=self.max_token_lifetime_seconds,
                    clock_skew_leeway_seconds=self.clock_skew_leeway_seconds,
                    require_tenant=self.require_tenant,
                    strict_tenant_audience=self.strict_tenant_audience,
                    tenant_audiences=dict(self.tenant_audiences),
                )
            ]
        return []


@dataclass
class OPAConfig:
    """OPA (Open Policy Agent) configuration.

    Attributes:
        enabled: Whether OPA policy engine is enabled.
        url: URL of the OPA server.
        policy_path: Path to the policy decision endpoint.
        timeout: HTTP request timeout in seconds.
    """

    enabled: bool = False
    url: str = "http://localhost:8181"
    policy_path: str = "v1/data/mcp/authz/allow"
    timeout: float = 5.0


@dataclass
class RoleAssignment:
    """A single role assignment configuration.

    Attributes:
        principal: Principal ID (e.g., "user:admin@company.com", "group:platform-engineering").
        role: Role name (e.g., "admin", "developer").
        scope: Scope of the assignment (e.g., "global", "tenant:data-team").
            A ``tenant:<id>`` assignment grants its permissions within that
            tenant only, to a principal carrying that tenant. It opens the
            routes that confine what they serve to the caller's tenant (the
            event stream, tool-invocation history, runtime tool withdraw and
            restore, approvals), confined to that tenant, and nothing that acts
            on the whole fleet. Those need a ``global`` assignment.
    """

    principal: str
    role: str
    scope: str = "global"


@dataclass
class StorageConfig:
    """Storage backend configuration for auth data.

    Attributes:
        driver: Storage driver ("memory", "sqlite", "postgresql").
        path: Path for SQLite database file (only for sqlite driver).
        host: Database host (only for postgresql driver).
        port: Database port (only for postgresql driver).
        database: Database name (only for postgresql driver).
        user: Database user (only for postgresql driver).
        password: Database password (only for postgresql driver).
        min_connections: Minimum pool connections (only for postgresql driver).
        max_connections: Maximum pool connections (only for postgresql driver).
    """

    driver: str = "memory"  # memory, sqlite, postgresql

    # SQLite options
    path: str = "data/auth.db"

    # PostgreSQL options
    host: str = "localhost"
    port: int = 5432
    database: str = "mcp_hangar"
    user: str = "mcp_hangar"
    password: str = ""
    min_connections: int = 2
    max_connections: int = 10


@dataclass
class RateLimitConfig:
    """Rate limiting configuration for auth attempts.

    Attributes:
        enabled: Whether rate limiting is enabled.
        max_attempts: Maximum failed attempts per window.
        window_seconds: Time window for counting attempts.
        lockout_seconds: How long to lock out after exceeding limit.
    """

    enabled: bool = True
    max_attempts: int = 10
    window_seconds: int = 60
    lockout_seconds: int = 300


@dataclass
class StdioPrincipalConfig:
    """The caller a stdio session is declared to be (ADR-026).

    Attributes:
        id: Principal id, e.g. "local-user".
        tenant_id: Tenant the caller belongs to; scopes policy and pins.
        roles: Role names resolved through the ordinary RBAC path. Defaults to
            read-only ``viewer`` so a first run can ask the gateway what it
            thinks is happening without being able to change anything.
    """

    id: str
    tenant_id: str
    roles: list[str] = field(default_factory=lambda: ["viewer"])


@dataclass
class AuthConfig:
    """Authentication and authorization configuration.

    This is the main configuration container for all auth settings.

    Authentication is OPT-IN by default (enabled=False). Set enabled=True
    in your configuration to activate authentication.

    Attributes:
        enabled: Master switch for auth (if False, all requests are allowed). Default: False (opt-in).
        allow_anonymous: If True, allow unauthenticated requests as anonymous.
        storage: Storage backend configuration.
        rate_limit: Rate limiting configuration.
        api_key: API key authentication configuration.
        oidc: OIDC/JWT authentication configuration.
        opa: OPA policy engine configuration.
        role_assignments: Static role assignments from configuration.
    """

    enabled: bool = False  # OPT-IN: auth disabled by default
    allow_anonymous: bool = False

    storage: StorageConfig = field(default_factory=StorageConfig)
    rate_limit: RateLimitConfig = field(default_factory=RateLimitConfig)

    api_key: ApiKeyAuthConfig = field(default_factory=ApiKeyAuthConfig)
    oidc: OIDCAuthConfig = field(default_factory=OIDCAuthConfig)
    opa: OPAConfig = field(default_factory=OPAConfig)

    role_assignments: list[RoleAssignment] = field(default_factory=list)

    #: Declared principal for a stdio session (ADR-026). Ignored over HTTP,
    #: which has a credential channel of its own.
    stdio: StdioPrincipalConfig | None = None


def _parse_tenant_audiences(raw: Any) -> dict[str, str]:
    """Coerce a raw config value into a ``{tenant_id: audience}`` string map.

    Non-dict inputs yield an empty map. Only entries whose key and value are both
    non-empty strings are kept -- a malformed mapping must never silently admit a
    tenant with an implicit/empty expected audience (fail-closed by omission).
    """
    if not isinstance(raw, dict):
        return {}
    result: dict[str, str] = {}
    for key, value in raw.items():
        if isinstance(key, str) and isinstance(value, str) and key.strip() and value.strip():
            result[key] = value
    return result


def parse_auth_config(config_dict: dict[str, Any] | None) -> AuthConfig:
    """Parse auth configuration from dictionary.

    Args:
        config_dict: The 'auth' section of the configuration file.

    Returns:
        Parsed AuthConfig object with defaults for missing values.
    """
    if config_dict is None:
        return AuthConfig()

    # Parse storage config
    storage_dict = config_dict.get("storage", {})
    storage_config = StorageConfig(
        driver=storage_dict.get("driver", "memory"),
        path=storage_dict.get("path", "data/auth.db"),
        host=storage_dict.get("host", "localhost"),
        port=storage_dict.get("port", 5432),
        database=storage_dict.get("database", "mcp_hangar"),
        user=storage_dict.get("user", "mcp_hangar"),
        password=storage_dict.get("password", ""),
        min_connections=storage_dict.get("min_connections", 2),
        max_connections=storage_dict.get("max_connections", 10),
    )

    # Parse rate limit config
    rate_limit_dict = config_dict.get("rate_limit", {})
    rate_limit_config = RateLimitConfig(
        enabled=rate_limit_dict.get("enabled", True),
        max_attempts=rate_limit_dict.get("max_attempts", 10),
        window_seconds=rate_limit_dict.get("window_seconds", 60),
        lockout_seconds=rate_limit_dict.get("lockout_seconds", 300),
    )

    # Parse API key config
    api_key_dict = config_dict.get("api_key", {})
    api_key_config = ApiKeyAuthConfig(
        enabled=api_key_dict.get("enabled", True),
        header_name=api_key_dict.get("header_name", "X-API-Key"),
    )

    # Parse OIDC config
    oidc_dict = config_dict.get("oidc", {})

    # Get max_token_lifetime with env var override
    default_lifetime = oidc_dict.get("max_token_lifetime_seconds", 3600)
    max_token_lifetime_seconds = int(os.environ.get("MCP_JWT_MAX_TOKEN_LIFETIME", str(default_lifetime)))

    # Top-level strict per-tenant audience map (RFC 8707). Coerce to a plain
    # {str: str} dict; malformed entries are dropped rather than trusted.
    top_tenant_audiences = _parse_tenant_audiences(oidc_dict.get("tenant_audiences", {}))
    top_strict_tenant_audience = oidc_dict.get("strict_tenant_audience", False)

    # Parse per-issuer trust entries. Omitted fields inherit the top-level
    # oidc.* values so per-issuer claim mappings fall back to the globals.
    issuers: list[OIDCIssuerConfig] = []
    for issuer_dict in oidc_dict.get("issuers", []):
        if isinstance(issuer_dict, dict):
            issuers.append(
                OIDCIssuerConfig(
                    issuer=issuer_dict.get("issuer", oidc_dict.get("issuer", "")),
                    audience=issuer_dict.get("audience", oidc_dict.get("audience", "")),
                    jwks_uri=issuer_dict.get("jwks_uri", oidc_dict.get("jwks_uri")),
                    client_id=issuer_dict.get("client_id", oidc_dict.get("client_id")),
                    subject_claim=issuer_dict.get("subject_claim", oidc_dict.get("subject_claim", "sub")),
                    groups_claim=issuer_dict.get("groups_claim", oidc_dict.get("groups_claim", "groups")),
                    tenant_claim=issuer_dict.get("tenant_claim", oidc_dict.get("tenant_claim", "tenant_id")),
                    email_claim=issuer_dict.get("email_claim", oidc_dict.get("email_claim", "email")),
                    session_id_claim=issuer_dict.get("session_id_claim", oidc_dict.get("session_id_claim", "sid")),
                    max_token_lifetime_seconds=issuer_dict.get(
                        "max_token_lifetime_seconds", max_token_lifetime_seconds
                    ),
                    require_tenant=issuer_dict.get("require_tenant", oidc_dict.get("require_tenant", False)),
                    strict_tenant_audience=issuer_dict.get("strict_tenant_audience", top_strict_tenant_audience),
                    tenant_audiences=(
                        _parse_tenant_audiences(issuer_dict["tenant_audiences"])
                        if "tenant_audiences" in issuer_dict
                        else dict(top_tenant_audiences)
                    ),
                )
            )

    oidc_config = OIDCAuthConfig(
        enabled=oidc_dict.get("enabled", False),
        issuer=oidc_dict.get("issuer", ""),
        audience=oidc_dict.get("audience", ""),
        jwks_uri=oidc_dict.get("jwks_uri"),
        client_id=oidc_dict.get("client_id"),
        subject_claim=oidc_dict.get("subject_claim", "sub"),
        groups_claim=oidc_dict.get("groups_claim", "groups"),
        tenant_claim=oidc_dict.get("tenant_claim", "tenant_id"),
        email_claim=oidc_dict.get("email_claim", "email"),
        session_id_claim=oidc_dict.get("session_id_claim", "sid"),
        max_token_lifetime_seconds=max_token_lifetime_seconds,
        resource_uri=oidc_dict.get("resource_uri", ""),
        require_tenant=oidc_dict.get("require_tenant", False),
        strict_tenant_audience=top_strict_tenant_audience,
        tenant_audiences=top_tenant_audiences,
        issuers=issuers,
    )

    # Parse OPA config
    opa_dict = config_dict.get("opa", {})
    opa_config = OPAConfig(
        enabled=opa_dict.get("enabled", False),
        url=opa_dict.get("url", "http://localhost:8181"),
        policy_path=opa_dict.get("policy_path", "v1/data/mcp/authz/allow"),
        timeout=opa_dict.get("timeout", 5.0),
    )

    # Parse role assignments
    role_assignments: list[RoleAssignment] = []
    for assignment_dict in config_dict.get("role_assignments", []):
        if isinstance(assignment_dict, dict):
            role_assignments.append(
                RoleAssignment(
                    principal=assignment_dict.get("principal", ""),
                    role=assignment_dict.get("role", ""),
                    scope=assignment_dict.get("scope", "global"),
                )
            )

    return AuthConfig(
        enabled=config_dict.get("enabled", False),  # OPT-IN: default to disabled
        allow_anonymous=config_dict.get("allow_anonymous", False),
        storage=storage_config,
        rate_limit=rate_limit_config,
        api_key=api_key_config,
        oidc=oidc_config,
        opa=opa_config,
        role_assignments=role_assignments,
        stdio=_parse_stdio_principal(config_dict.get("stdio")),
    )


def _parse_stdio_principal(raw: Any) -> StdioPrincipalConfig | None:
    """Parse `auth.stdio.principal` (ADR-026), or None when it is absent.

    Fail-closed on a malformed block: a principal missing an id or a tenant is
    not a partially-declared caller, it is an undeclared one, and admitting it
    with a default id would hand a config typo an identity nobody wrote down.
    Dropping it restores exactly the pre-ADR-026 behaviour -- an anonymous
    caller and an empty front door -- which is loud enough to notice.
    """
    if not isinstance(raw, dict):
        return None
    principal = raw.get("principal")
    if not isinstance(principal, dict):
        logger.warning("stdio_principal_block_ignored", reason="no_principal_mapping")
        return None

    principal_id = principal.get("id")
    tenant_id = principal.get("tenant_id")
    if not isinstance(principal_id, str) or not principal_id.strip():
        logger.warning("stdio_principal_block_ignored", reason="missing_id")
        return None
    if not isinstance(tenant_id, str) or not tenant_id.strip():
        logger.warning("stdio_principal_block_ignored", reason="missing_tenant_id")
        return None

    raw_roles = principal.get("roles", ["viewer"])
    if not isinstance(raw_roles, list):
        logger.warning("stdio_principal_roles_ignored", reason="not_a_list")
        raw_roles = []
    roles = [r for r in raw_roles if isinstance(r, str) and r.strip()]

    return StdioPrincipalConfig(id=principal_id, tenant_id=tenant_id, roles=roles)


def get_default_auth_config() -> AuthConfig:
    """Get default auth configuration.

    Returns a disabled auth configuration suitable for development
    where authentication is not required.

    Returns:
        AuthConfig with auth disabled.
    """
    return AuthConfig(enabled=False, allow_anonymous=True)


# Example configuration (for documentation):
EXAMPLE_AUTH_CONFIG = """
auth:
  enabled: true
  allow_anonymous: false

  api_key:
    enabled: true
    header_name: X-API-Key

  oidc:
    enabled: true
    issuer: https://auth.company.com
    audience: mcp-hangar
    # jwks_uri auto-discovered from issuer if not specified
    groups_claim: groups
    tenant_claim: org_id
    # The claim a session suspension matches; sid unless your IdP differs.
    session_id_claim: sid

  opa:
    enabled: false  # Use built-in RBAC by default
    url: http://opa:8181
    policy_path: v1/data/mcp/authz/allow

  role_assignments:
    # Bootstrap admin
    - principal: "user:admin@company.com"
      role: admin
      scope: global

    # Platform team
    - principal: "group:platform-engineering"
      role: provider-admin
      scope: global

    # Data team - scoped to their tenant
    - principal: "group:data-science"
      role: developer
      scope: "tenant:data-team"
"""
