"""WebAuthn / Passkey service layer (B-2.6).

Wraps the python-webauthn==2.5.2 library to provide:
- Registration options + verification (post-login enrollment)
- Authentication options + verification (used during /2fa/verify)
- Credential persistence in User.webauthn_credentials (JSON column)

Each credential dict stored is:
  {
    'id': str (base64url, <= 1023 chars by RFC),
    'public_key': str (base64url-encoded COSE key),
    'sign_count': int,
    'transports': list[str] (e.g., ['usb', 'nfc', 'ble', 'internal']),
    'name': str (user-supplied label, e.g., 'YubiKey 5C'),
    'created_at': str (ISO 8601 UTC),
  }

Anti-cloning: every successful authentication updates sign_count from the
authenticator's monotonic counter (RFC 8809). A regression would indicate
a cloned authenticator and is rejected by python-webauthn at verify time.
"""
import base64
import json
import os
from datetime import datetime, timezone
from typing import Dict, List, Tuple

from webauthn import (
    generate_registration_options,
    generate_authentication_options,
    verify_registration_response,
    verify_authentication_response,
    options_to_json,
)
from webauthn.helpers.structs import (
    AuthenticatorSelectionCriteria,
    PublicKeyCredentialDescriptor,
    ResidentKeyRequirement,
    UserVerificationRequirement,
)


# --- RP / origin configuration -------------------------------------------------

def get_rp_id() -> str:
    """Relying Party ID — host name only (no scheme, no port).

    For dictia.ca this is 'dictia.ca'. Browsers strictly enforce that the
    RP ID matches the registrable domain of the page making the request,
    so this MUST be configured per-environment.
    """
    return os.environ.get('WEBAUTHN_RP_ID', 'localhost')


def get_rp_name() -> str:
    """Display name shown in the user's authenticator UI."""
    return os.environ.get('WEBAUTHN_RP_NAME', 'DictIA')


def get_expected_origin() -> str:
    """Full origin (scheme + host + optional port).

    MUST match window.location.origin on the client side. Browsers reject
    auth if these do not match. Example: 'https://dictia.ca'.
    """
    return os.environ.get('WEBAUTHN_ORIGIN', 'http://localhost:8899')


# --- base64url helpers (no padding, RFC 4648 §5) -------------------------------

def _b64url_encode(b: bytes) -> str:
    return base64.urlsafe_b64encode(b).rstrip(b'=').decode('ascii')


def _b64url_decode(s: str) -> bytes:
    pad = '=' * (-len(s) % 4)
    return base64.urlsafe_b64decode(s + pad)


# --- Stored credential accessors -----------------------------------------------

def list_user_credentials(user) -> List[Dict]:
    """Return the user's stored credentials (or empty list if None)."""
    return user.webauthn_credentials or []


def has_passkeys(user) -> bool:
    """True iff the user has at least one registered WebAuthn credential."""
    return bool(list_user_credentials(user))


# --- Registration --------------------------------------------------------------

def begin_registration(user) -> Tuple[dict, str]:
    """Generate registration options for a new credential.

    Returns (json-safe options dict, challenge_b64url). Caller stores the
    challenge in session keyed by user_id; passes it back to
    finish_registration() for verification. Existing credentials are listed
    in `excludeCredentials` so the authenticator can refuse to re-enroll
    something it has already provisioned for this user.
    """
    existing = list_user_credentials(user)
    exclude = [
        PublicKeyCredentialDescriptor(id=_b64url_decode(c['id']))
        for c in existing
    ]
    options = generate_registration_options(
        rp_id=get_rp_id(),
        rp_name=get_rp_name(),
        user_id=str(user.id).encode('utf-8'),
        user_name=user.email,
        user_display_name=(user.name or user.username),
        exclude_credentials=exclude,
        authenticator_selection=AuthenticatorSelectionCriteria(
            user_verification=UserVerificationRequirement.PREFERRED,
            resident_key=ResidentKeyRequirement.PREFERRED,
        ),
    )
    challenge_b64 = _b64url_encode(options.challenge)
    return json.loads(options_to_json(options)), challenge_b64


def finish_registration(user, response_json: dict, expected_challenge_b64: str,
                        label: str = '') -> Dict:
    """Verify the authenticator's registration response and persist the cred.

    Raises webauthn.exceptions.InvalidRegistrationResponse on failure.
    Returns the credential dict that was added to user.webauthn_credentials.
    """
    from src.database import db
    expected_challenge = _b64url_decode(expected_challenge_b64)
    verification = verify_registration_response(
        credential=response_json,
        expected_challenge=expected_challenge,
        expected_origin=get_expected_origin(),
        expected_rp_id=get_rp_id(),
        require_user_verification=False,
    )
    new_cred = {
        'id': _b64url_encode(verification.credential_id),
        'public_key': _b64url_encode(verification.credential_public_key),
        'sign_count': verification.sign_count,
        'transports': response_json.get('response', {}).get('transports', []),
        'name': (label or 'Passkey').strip()[:80],
        'created_at': datetime.now(timezone.utc).isoformat(timespec='seconds'),
    }
    creds = list(user.webauthn_credentials or [])
    creds.append(new_cred)
    user.webauthn_credentials = creds
    db.session.commit()
    return new_cred


# --- Authentication ------------------------------------------------------------

def begin_authentication(user) -> Tuple[dict, str]:
    """Generate authentication options scoped to this user's credentials.

    Returns (json-safe options dict, challenge_b64url) — store the challenge
    in session keyed by pending_totp_user_id during /2fa/verify.

    Raises ValueError if the user has no registered credentials.
    """
    existing = list_user_credentials(user)
    if not existing:
        raise ValueError('User has no registered passkeys')
    allow = [
        PublicKeyCredentialDescriptor(id=_b64url_decode(c['id']))
        for c in existing
    ]
    options = generate_authentication_options(
        rp_id=get_rp_id(),
        allow_credentials=allow,
        user_verification=UserVerificationRequirement.PREFERRED,
    )
    challenge_b64 = _b64url_encode(options.challenge)
    return json.loads(options_to_json(options)), challenge_b64


def finish_authentication(user, response_json: dict,
                          expected_challenge_b64: str) -> Dict:
    """Verify the authenticator assertion, increment sign_count, return cred.

    Anti-cloning per WebAuthn §6.1.1: every successful auth must update
    the stored sign_count from the new monotonic counter. python-webauthn
    raises if the new counter is not strictly greater than the stored one
    (when both are non-zero).
    """
    from src.database import db
    expected_challenge = _b64url_decode(expected_challenge_b64)

    cred_id_b64 = response_json.get('id') or response_json.get('rawId')
    if not cred_id_b64:
        raise ValueError('Missing credential id in authentication response')
    # Take a fresh list with shallow-copied dicts so SQLAlchemy's JSON-column
    # change detection sees a new value (mutating the existing list in place
    # would not flag the row dirty under the default JSON type).
    creds = [dict(c) for c in list_user_credentials(user)]
    matched_idx = None
    for i, c in enumerate(creds):
        if c['id'] == cred_id_b64:
            matched_idx = i
            break
    if matched_idx is None:
        raise ValueError('Credential not registered for this user')
    matched = creds[matched_idx]

    verification = verify_authentication_response(
        credential=response_json,
        expected_challenge=expected_challenge,
        expected_origin=get_expected_origin(),
        expected_rp_id=get_rp_id(),
        credential_public_key=_b64url_decode(matched['public_key']),
        credential_current_sign_count=matched['sign_count'],
        require_user_verification=False,
    )
    matched['sign_count'] = verification.new_sign_count
    creds[matched_idx] = matched
    user.webauthn_credentials = creds
    db.session.commit()
    return matched


# --- Deletion ------------------------------------------------------------------

def delete_credential(user, credential_id_b64: str) -> bool:
    """Remove a credential by its base64url id.

    Returns True if removed, False if not found. Sets the JSON column to
    None when the last credential is removed (matches the column's
    nullable=True semantics).
    """
    from src.database import db
    creds = list_user_credentials(user)
    new_creds = [c for c in creds if c['id'] != credential_id_b64]
    if len(new_creds) == len(creds):
        return False
    user.webauthn_credentials = new_creds if new_creds else None
    db.session.commit()
    return True
