"""Microsoft 365 + Google OAuth providers (B-2.4).

Adds two named OAuth clients alongside the existing generic SSO at
``src/auth/sso.py``. Patterns match sso.py: env-var gated, separate
OAuth instance from the generic SSO, but with **Loi 25 consent capture
deferred** to ``/auth/oauth/finish-signup`` for new users (existing
users by sso_subject or email skip the consent page and log in directly).

The compatibility-audit (C2) explicitly forbids creating an
``src/auth_extended/`` directory or new User columns — we reuse
``User.sso_provider`` (max 100) and ``User.sso_subject`` (max 255, unique)
to store the provider name (``'microsoft'`` | ``'google'``) and the OAuth
``sub`` claim.
"""
import os
from typing import Dict, Optional

from authlib.integrations.flask_client import OAuth

from src.database import db
from src.models import User

# Single OAuth instance shared across providers — kept separate from
# src/auth/sso.py's _oauth (which serves the legacy generic SSO).
_oauth: Optional[OAuth] = None

# Provider configuration — server_metadata_url + scope baseline.
_PROVIDER_CONFIG = {
    'microsoft': {
        'env_client_id': 'MS_CLIENT_ID',
        'env_client_secret': 'MS_CLIENT_SECRET',
        'server_metadata_url': (
            'https://login.microsoftonline.com/common/v2.0/'
            '.well-known/openid-configuration'
        ),
        'scope': 'openid email profile',
        'display_name': 'Microsoft 365',
    },
    'google': {
        'env_client_id': 'GOOGLE_CLIENT_ID',
        'env_client_secret': 'GOOGLE_CLIENT_SECRET',
        'server_metadata_url': (
            'https://accounts.google.com/.well-known/openid-configuration'
        ),
        'scope': 'openid email profile',
        'display_name': 'Google',
    },
}


class EmailAlreadyExistsError(Exception):
    """Raised by create_oauth_user_with_consent when email is already taken
    between the OAuth callback (where the new-user check passed) and the
    finish-signup POST (where the User row is finally inserted).

    This protects against a race: a parallel /signup in another tab can
    create a User with the same email between callback and finish-signup,
    making the OAuth User insert fail with an IntegrityError on the
    email-unique constraint. Catching this allows a graceful flash + redirect
    instead of a 500.
    """


def is_oauth_provider_enabled(provider: str) -> bool:
    """Return True if the provider has client_id AND client_secret in env."""
    cfg = _PROVIDER_CONFIG.get(provider)
    if cfg is None:
        return False
    return bool(os.environ.get(cfg['env_client_id'])) and bool(
        os.environ.get(cfg['env_client_secret'])
    )


def get_oauth_provider_display_name(provider: str) -> str:
    """User-facing label for the provider (Microsoft 365 / Google)."""
    cfg = _PROVIDER_CONFIG.get(provider)
    return cfg['display_name'] if cfg else provider


def init_oauth_providers(app) -> Optional[OAuth]:
    """Register Microsoft + Google OAuth clients. Idempotent — call once at startup.

    Returns the OAuth instance, or None if no provider is enabled.
    """
    global _oauth
    enabled_providers = [p for p in _PROVIDER_CONFIG if is_oauth_provider_enabled(p)]
    if not enabled_providers:
        # Operability: log when no providers are enabled so operators don't
        # silently lose OAuth login on misconfigured deployments.
        app.logger.info(
            'OAuth providers: none enabled (set MS_CLIENT_ID/MS_CLIENT_SECRET '
            'or GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET to enable).'
        )
        return None
    if _oauth is None:
        _oauth = OAuth(app)
    for provider in enabled_providers:
        cfg = _PROVIDER_CONFIG[provider]
        # Idempotent: skip re-registration if already registered (Authlib caches
        # by name in `_clients`). Real registration errors (bad metadata URL,
        # network failure) now surface as exceptions instead of being silently
        # swallowed by a bare `except Exception: pass`.
        if provider in getattr(_oauth, '_clients', {}):
            app.logger.debug(
                'OAuth provider %r already registered (skipping)', provider
            )
            continue
        _oauth.register(
            name=provider,
            client_id=os.environ[cfg['env_client_id']],
            client_secret=os.environ[cfg['env_client_secret']],
            server_metadata_url=cfg['server_metadata_url'],
            client_kwargs={'scope': cfg['scope']},
        )
    app.logger.info(
        'OAuth providers initialized: %s', ', '.join(enabled_providers)
    )
    return _oauth


def get_oauth_client(provider: str):
    """Return the OAuth client for `provider`, or raise if not enabled."""
    if _oauth is None or not is_oauth_provider_enabled(provider):
        raise RuntimeError(f"OAuth provider {provider!r} is not enabled")
    return getattr(_oauth, provider)


def find_user_by_oauth(
    provider: str,
    subject: str,
    email: Optional[str],
    email_verified: bool,
) -> Optional[User]:
    """Lookup an existing user by sso_subject, then email (link path).

    Args:
        provider: 'microsoft' or 'google'.
        subject: OAuth ``sub`` claim — stable per (IdP, user) tuple.
        email: OAuth ``email`` claim (case-insensitive).
        email_verified: MUST be True (the literal boolean) for the
            email-link branch to fire. Caller is responsible for reading
            ``userinfo.get('email_verified') is True`` — we treat anything
            else as untrusted.

    Returns:
      - User object: known account (login directly).
      - None: brand-new account (caller defers to finish-signup) OR the
        email matched an existing account but ``email_verified is not True``
        (caller should refuse to silently link — see oauth callback handler).

    Raises:
      PermissionError: if an existing email-matched user already has a
        ``sso_subject`` set (linked to a different OAuth identity). Refusing
        to overwrite protects against account-hijack via a second IdP
        claiming the victim's email (C2 from the security review).

    Security notes:
      - Linking by email is gated on ``email_verified is True``. A hostile
        IdP that returns ``email_verified=False`` (or omits the claim) does
        NOT auto-link to an existing account. This blocks the takeover
        vector where an attacker creates a Microsoft personal account or
        Workspace tenant claiming a victim's mailbox without verification.
      - We refuse to overwrite an existing ``sso_subject``. If Alice is
        already linked to ms-sub-A, a second login claiming the same email
        from google or another tenant is rejected, not silently re-linked.
    """
    user = User.query.filter_by(sso_subject=subject, sso_provider=provider).first()
    if user:
        return user
    if email:
        existing_email_user = User.query.filter_by(email=email.lower().strip()).first()
        if existing_email_user:
            # C1: refuse to auto-link if the IdP did not assert email_verified.
            # The caller will refuse to fall through to finish-signup either
            # (since that would create a duplicate account on a different
            # identity), so returning None here triggers the friendly flash.
            if email_verified is not True:
                return None
            # C2: refuse to overwrite an existing linked OAuth identity.
            # If we got here the first branch (sso_subject lookup) didn't
            # match — meaning either the user has a different sso_subject
            # (account hijack attempt) or no sso_subject at all (legit link).
            if existing_email_user.sso_subject:
                raise PermissionError(
                    f"L'adresse {email} est déjà liée à une autre identité fédérée. "
                    f"Connectez-vous avec votre fournisseur d'origine, ou contactez le support."
                )
            existing_email_user.sso_provider = provider
            existing_email_user.sso_subject = subject
            db.session.commit()
            return existing_email_user
    return None


def create_oauth_user_with_consent(
    provider: str,
    subject: str,
    userinfo: Dict[str, str],
    consents: Dict[str, bool],
    ip: str,
    ua: str,
    legal_version: str,
) -> User:
    """Create a new User from OAuth claims AFTER Loi 25 consents are granted.

    Used by the ``/auth/oauth/finish-signup`` POST handler — never call from
    the OAuth callback (consent capture must precede User row creation per
    Loi 25 art. 14).

    Always writes 4 ConsentLog rows (one per consent_type), recording
    explicit refusal as ``granted=False`` for the audit trail.

    Raises:
        ValueError: if userinfo is missing the email claim.
        EmailAlreadyExistsError: if a User with this email already exists
            (race against /signup or another OAuth login between the
            callback and the finish-signup POST). Caller should handle
            with a friendly French flash + redirect to /login.
    """
    from src.models.consent import ConsentLog
    from src.auth.sso import generate_unique_username
    from sqlalchemy.exc import IntegrityError

    email = (userinfo.get('email') or '').lower().strip()
    if not email:
        raise ValueError('OAuth userinfo missing email')

    # I3: pre-check for the email-collision race. The username retry loop
    # below ONLY helps with username collisions; a duplicate email would
    # burn 5 attempts and then re-raise IntegrityError, which surfaces as
    # a 500. Detect it once here and raise the dedicated exception so the
    # caller can render a friendly "compte existe déjà" flash.
    existing = User.query.filter_by(email=email).first()
    if existing:
        raise EmailAlreadyExistsError(
            f"Account with email {email} already exists; cannot create via "
            f"OAuth signup. User should sign in with their original method "
            f"or contact support."
        )

    name = (userinfo.get('name') or '').strip()
    if not name:
        first = (userinfo.get('given_name') or '').strip()
        last = (userinfo.get('family_name') or '').strip()
        name = f'{first} {last}'.strip()

    preferred_username = email.split('@', 1)[0]
    max_attempts = 5
    user = None
    for attempt in range(max_attempts):
        username = generate_unique_username(preferred_username)
        user = User(
            username=username,
            email=email,
            password=None,
            sso_provider=provider,
            sso_subject=subject,
            name=name or None,
            email_verified=True,  # OAuth provider already verified the email
        )
        db.session.add(user)
        try:
            db.session.flush()
            break
        except IntegrityError:
            db.session.rollback()
            if attempt == max_attempts - 1:
                raise

    # 4 ConsentLog rows — one per Loi 25 consent_type (granular, art. 14).
    for ctype in ('cgu', 'confidentialite', 'marketing', 'analytics'):
        db.session.add(ConsentLog(
            user_id=user.id,
            consent_type=ctype,
            version=legal_version,
            granted=bool(consents.get(ctype, False)),
            ip_address=ip,
            user_agent=ua,
        ))
    db.session.commit()
    return user
