"""
AuthOrchestrator: chains credential strategies (Local, LDAP) and
delegates to JITProvisioner for SSO callbacks.

Lookup order is configurable via env `AUTH_LOOKUP_ORDER`:
- "local,ldap" (default per user preference: local first = emergency admin)
- "ldap,local"

`AUTH_PROVIDERS` env var lists *enabled* strategies. A strategy is only
attempted if listed there AND its is_enabled returns True.
"""
from __future__ import annotations

import logging
import os
from datetime import datetime
from typing import List, Optional

from sqlalchemy.orm import Session

from app.auth import mfa_policy
from app.auth.jit_provisioner import JITProvisioner
from app.auth.strategies.base import AuthError, AuthResult, AuthStrategy, ExternalIdentity
from app.auth.strategies.local import LocalAuthStrategy
from app.models.audit_log import AuditEventType, AuditLog
from app.models.user import AuthProvider, User, UserRole

logger = logging.getLogger(__name__)


def _env_list(name: str, default: str) -> List[str]:
    raw = os.getenv(name, default)
    return [x.strip().lower() for x in raw.split(",") if x.strip()]


class AuthOrchestrator:
    """Routes credential logins through configured strategies in order."""

    def __init__(self, db: Session):
        self.db = db
        self.enabled = set(_env_list("AUTH_PROVIDERS", "local"))
        self.lookup_order = _env_list("AUTH_LOOKUP_ORDER", "local,ldap")
        self.jit_enabled = os.getenv("AUTH_JIT_PROVISIONING", "true").lower() == "true"

        default_role_str = os.getenv("AUTH_JIT_DEFAULT_ROLE", "readonly").lower()
        try:
            self.default_role = UserRole(default_role_str)
        except ValueError:
            logger.warning("Invalid AUTH_JIT_DEFAULT_ROLE '%s', falling back to readonly", default_role_str)
            self.default_role = UserRole.READONLY

        # Build strategy registry (lazy LDAP — only built when needed to keep
        # import-time dependencies optional for installations not using it).
        self._strategies: dict[AuthProvider, AuthStrategy] = {}
        self._strategies[AuthProvider.LOCAL] = LocalAuthStrategy(db)

    # ---------- public API ----------

    def authenticate_with_credentials(
        self,
        username: str,
        password: str,
        *,
        actor_ip: Optional[str] = None,
        actor_user_agent: Optional[str] = None,
    ) -> AuthResult:
        """
        Try each credential-capable strategy in lookup_order. Returns
        the first success. On total failure, raises AuthError with the
        SAME safe message regardless of cause (anti-enumeration).

        Audit log records the actual reason (not exposed to client).
        """
        last_error: Optional[AuthError] = None
        provider_tried: List[str] = []

        for prov_name in self.lookup_order:
            if prov_name not in self.enabled:
                continue
            try:
                prov = AuthProvider(prov_name)
            except ValueError:
                continue
            strategy = self._get_strategy(prov)
            if strategy is None or not strategy.is_enabled or not strategy.supports_credentials():
                continue

            provider_tried.append(prov.value)
            try:
                identity = strategy.authenticate_credentials(username, password)
            except AuthError as e:
                last_error = e
                logger.info(
                    "Auth attempt failed via %s for username='%s': %s",
                    prov.value, username, e.detail,
                )
                continue

            # Success
            user = self._materialise_user(identity, actor_ip)

            # MFA enforcement policy (Settings → Security). If the admin
            # has flipped `mfa_enforced=true` and this user's role falls
            # into the enforced set without TOTP enrolled, the router
            # short-circuits the normal session and forces enrolment
            # before any JWT is issued.
            mfa_setup_required = mfa_policy.requires_setup(self.db, user)

            # MFA gate. Applies to any credential-authenticated user (local,
            # ldap) who has TOTP enrolled. SAML/OIDC users get MFA from their
            # IdP and never reach this code path.
            mfa_required = bool(user.totp_enabled and user.totp_secret)

            self._audit_login_success(user, prov, actor_ip, actor_user_agent)
            return AuthResult(
                user=user,
                provider=prov,
                mfa_required=mfa_required,
                mfa_setup_required=mfa_setup_required,
                audit_message=f"login via {prov.value}",
                raw_identity=identity,
            )

        # All strategies failed
        self._audit_login_failure(username, provider_tried, last_error, actor_ip, actor_user_agent)
        # Generic safe message (anti-enumeration)
        raise AuthError(
            detail=f"all providers failed for '{username}': {[p for p in provider_tried]}",
            safe_message="Invalid credentials",
        )

    def complete_sso_login(
        self,
        identity: ExternalIdentity,
        *,
        actor_ip: Optional[str] = None,
        actor_user_agent: Optional[str] = None,
    ) -> AuthResult:
        """Called by SAML/OIDC routers after IdP redirect validation passes."""
        if identity.provider.value not in self.enabled:
            raise AuthError(
                detail=f"provider {identity.provider.value} not enabled",
                safe_message="Authentication provider is disabled",
            )

        user = self._materialise_user(identity, actor_ip)
        # Local enforcement policy can override "IdP handles MFA" when the
        # admin explicitly flips `mfa_enforce_sso_users=true`.
        mfa_setup_required = mfa_policy.requires_setup(self.db, user)
        self._audit_login_success(user, identity.provider, actor_ip, actor_user_agent)
        return AuthResult(
            user=user,
            provider=identity.provider,
            mfa_required=False,  # IdP handles MFA (unless setup is forced below)
            mfa_setup_required=mfa_setup_required,
            audit_message=f"SSO login via {identity.provider.value}",
            raw_identity=identity,
        )

    # ---------- internals ----------

    def _get_strategy(self, provider: AuthProvider) -> Optional[AuthStrategy]:
        if provider in self._strategies:
            return self._strategies[provider]
        # Lazy-load LDAP (Phase 2). SAML/OIDC are wired up via separate routers
        # (callbacks, not credential checks).
        if provider == AuthProvider.LDAP:
            try:
                from app.auth.strategies.ldap_strategy import LdapStrategy  # type: ignore
            except ImportError as e:
                logger.warning("LDAP strategy unavailable: %s", e)
                return None
            try:
                strat = LdapStrategy(self.db)
                self._strategies[provider] = strat
                return strat
            except Exception as e:
                logger.error("LDAP strategy init failed: %s", e)
                return None
        return None

    def _materialise_user(self, identity: ExternalIdentity, ip: Optional[str]) -> User:
        """Either look up existing local user or provision via JIT."""
        if identity.provider == AuthProvider.LOCAL:
            # local strategy stamps external_id = user.id; load directly.
            return self.db.query(User).filter(User.id == int(identity.external_id)).first()  # type: ignore[arg-type]

        if not self.jit_enabled:
            existing = (
                self.db.query(User)
                .filter(
                    User.auth_provider == identity.provider,
                    User.external_id == identity.external_id,
                )
                .first()
            )
            if existing is None:
                raise AuthError(
                    detail=f"JIT disabled and user not pre-provisioned: {identity.username}",
                    safe_message="Account is not yet provisioned. Contact an administrator.",
                )
            return existing

        provisioner = JITProvisioner(self.db, default_role=self.default_role)
        return provisioner.provision(identity, actor_ip=ip)

    def _audit_login_success(self, user, provider: AuthProvider, ip, ua):
        evt = AuditEventType.LOGIN_SUCCESS
        if provider == AuthProvider.LDAP:
            evt = AuditEventType.LOGIN_LDAP_SUCCESS
        elif provider in (AuthProvider.SAML, AuthProvider.OIDC):
            evt = AuditEventType.LOGIN_SSO_SUCCESS

        self.db.add(
            AuditLog(
                user_id=user.id,
                event_type=evt,
                event_description=f"login OK via {provider.value} as {user.username}",
                ip_address=ip,
                user_agent=ua,
                resource_type="user",
                resource_id=str(user.id),
                timestamp=datetime.now(),
            )
        )
        self.db.commit()

    def _audit_login_failure(
        self,
        username: str,
        providers_tried: List[str],
        error: Optional[AuthError],
        ip,
        ua,
    ):
        self.db.add(
            AuditLog(
                user_id=None,
                event_type=AuditEventType.LOGIN_FAILED,
                event_description=(
                    f"login failed for '{username}' "
                    f"(tried: {', '.join(providers_tried) or 'none'}; "
                    f"reason: {error.detail if error else 'no providers configured'})"
                ),
                ip_address=ip,
                user_agent=ua,
                resource_type="user",
                resource_id=username[:50],
                timestamp=datetime.now(),
            )
        )
        self.db.commit()
