"""
Authentication and user management routes.

This blueprint handles user registration, login, logout, account management,
and password changes.
"""

import os
import re
import hashlib
import mimetypes
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session, current_app
from flask_login import login_user, logout_user, login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField, BooleanField, SelectField
from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError
from werkzeug.security import generate_password_hash, check_password_hash
from sqlalchemy.exc import IntegrityError
from urllib.parse import urlparse, urljoin
import markdown

from src.database import db
from src.models import User, SystemSetting, GroupMembership
from src.models.consent import ConsentLog
from src.utils import password_check
from src.auth.sso import (
    init_sso_client,
    is_sso_enabled,
    get_sso_config,
    get_sso_client,
    create_or_update_sso_user,
    is_domain_allowed,
    link_sso_to_existing_user,
    update_user_profile_from_claims,
)
from src.services.audit import (
    audit_login, audit_logout, audit_failed_login,
    audit_register, audit_password_change, audit_password_reset, audit_sso_login,
)
from src.services.email import (
    is_email_verification_enabled,
    is_email_verification_required,
    is_smtp_configured,
    send_verification_email,
    send_password_reset_email,
    send_magic_link_email,
    verify_email_token,
    verify_reset_token,
    can_resend_verification,
    can_resend_password_reset,
)
from src.auth.oauth_providers import (
    is_oauth_provider_enabled,
    get_oauth_client,
    find_user_by_oauth,
    create_oauth_user_with_consent,
    get_oauth_provider_display_name,
    EmailAlreadyExistsError,
)
from src.auth.magic_link import (
    generate_magic_link_token,
    consume_magic_link_token,
)

# Create blueprint
auth_bp = Blueprint('auth', __name__)

# Import these from app after initialization
bcrypt = None
csrf = None
limiter = None

def init_auth_extensions(_bcrypt, _csrf, _limiter):
    """Initialize extensions after app creation.

    Also wires the no-op @csrf_exempt decorator into a real
    Flask-WTF csrf.exempt() call for any view function flagged with
    _csrf_exempt=True. This is what actually disables CSRF on the
    JSON-only WebAuthn / logout endpoints; without it the decorator is
    just a label.
    """
    global bcrypt, csrf, limiter
    bcrypt = _bcrypt
    csrf = _csrf
    limiter = _limiter
    if csrf is not None:
        # Walk module-level functions defined in THIS file and exempt any
        # tagged with _csrf_exempt=True via Flask-WTF's csrf.exempt().
        # We restrict to FunctionType (not Flask proxies / classes / modules)
        # and to objects whose __module__ is this module to avoid touching
        # imported names like `current_app` which raise on attribute access
        # outside an app context.
        import sys as _sys
        from types import FunctionType as _FT
        _mod = _sys.modules[__name__]
        for _name, _obj in list(vars(_mod).items()):
            if (isinstance(_obj, _FT)
                    and getattr(_obj, '__module__', None) == __name__
                    and getattr(_obj, '_csrf_exempt', False)):
                try:
                    csrf.exempt(_obj)
                except Exception:
                    # Defensive: don't crash app boot on a single exemption fail
                    pass


def rate_limit(limit_string):
    """Decorator that applies rate limiting if limiter is available."""
    def decorator(f):
        from functools import wraps
        @wraps(f)
        def wrapper(*args, **kwargs):
            return f(*args, **kwargs)
        # Store the limit string for later application
        wrapper._rate_limit = limit_string
        return wrapper
    return decorator


def csrf_exempt(f):
    """Decorator placeholder for CSRF exemption - applied after initialization."""
    from functools import wraps
    @wraps(f)
    def wrapper(*args, **kwargs):
        return f(*args, **kwargs)
    wrapper._csrf_exempt = True
    return wrapper


# --- Constants ---

# Canonical LEGAL_VERSION lives in src/legal/__init__.py (B-2.9). Re-exported
# here under the historical name so existing call sites in this file
# (consent_log.version, signup view) keep working without touching them.
from src.legal import LEGAL_VERSION as SIGNUP_LEGAL_VERSION  # noqa: E402


# --- Forms ---

class SignupLoi25Form(FlaskForm):
    """Loi 25 art. 14 — granular consent (4 separate checkboxes).

    Replaces the legacy RegistrationForm. Username is auto-generated from the
    email local-part by the view (User.username is required + max 20 + unique).
    Two consents are hard-required (CGU + politique de confidentialité); the
    other two (marketing, analytics) are optional and recorded either way.
    """
    email = StringField('Courriel', validators=[
        DataRequired(), Email(), Length(max=120)
    ])
    password = PasswordField('Mot de passe', validators=[
        DataRequired(), password_check
    ])
    confirm_password = PasswordField('Confirmer le mot de passe', validators=[
        DataRequired(),
        EqualTo('password', message='Les mots de passe ne correspondent pas.'),
    ])
    first_name = StringField('Prénom', validators=[DataRequired(), Length(max=49)])
    last_name = StringField('Nom', validators=[DataRequired(), Length(max=49)])
    cabinet = StringField('Cabinet / Organisation', validators=[Length(max=255)])
    ordre_pro = SelectField('Ordre professionnel', choices=[
        ('', 'Aucun ou autre'),
        ('barreau', 'Barreau du Québec'),
        ('cpa', 'CPA Québec'),
        ('chad', 'ChAD'),
        ('oaciq', 'OACIQ'),
        ('cmq', 'CMQ'),
        ('oiiq', 'OIIQ'),
        ('oppq', 'OPPQ'),
        ('oeq', 'OEQ'),
        ('opq', 'OPQ'),
    ], default='', validators=[])
    consent_cgu = BooleanField('CGU')
    consent_confidentialite = BooleanField('Confidentialité')
    consent_marketing = BooleanField('Marketing')
    consent_analytics = BooleanField('Analytics')
    submit = SubmitField('Créer mon compte')

    def validate_email(self, email):
        user = User.query.filter_by(email=email.data.lower().strip()).first()
        if user:
            raise ValidationError('Ce courriel est déjà utilisé.')


class LoginForm(FlaskForm):
    email = StringField('Email', validators=[DataRequired(), Email()])
    password = PasswordField('Mot de passe', validators=[DataRequired()])
    remember = BooleanField('Se souvenir de moi')
    submit = SubmitField('Se connecter')


# --- Helper Functions ---

def is_safe_url(target):
    ref_url = urlparse(request.host_url)
    test_url = urlparse(urljoin(request.host_url, target))
    return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc


def is_registration_domain_allowed(email: str) -> bool:
    """Check if email domain is allowed for registration.

    Returns True if no domain restrictions are configured or if the
    email domain is in the allowed list.
    """
    if not email:
        return False

    domains_env = os.environ.get('REGISTRATION_ALLOWED_DOMAINS', '')
    if not domains_env or not domains_env.strip():
        return True  # No restriction configured

    allowed = [d.strip().lower() for d in domains_env.split(',') if d.strip()]
    if not allowed:
        return True  # Empty after parsing

    parts = email.lower().rsplit('@', 1)
    if len(parts) != 2:
        return False  # Invalid email format

    domain = parts[1]
    return domain in allowed


# --- Routes ---

def _registration_open() -> bool:
    """ALLOW_REGISTRATION env var (default: true) — gate for new signups."""
    return os.environ.get('ALLOW_REGISTRATION', 'true').lower() == 'true'


def _client_ip() -> str:
    """Return the originating client IP, honoring Cloudflare's CF-Connecting-IP header.

    Falls back to remote_addr (which is already corrected by ProxyFix when
    behind a reverse proxy). Used for Loi 25 ConsentLog.ip_address.
    """
    return request.headers.get('CF-Connecting-IP') or request.remote_addr or '0.0.0.0'


def _generate_unique_username(email: str) -> str:
    """Derive a unique username from the email local-part (max 20 chars).

    Append a numeric suffix on collision. User.username is required, unique,
    and capped at 20 chars by the schema.

    Note: this lookup-then-pick is inherently racy under concurrent signups
    (two requests can both read no-collision and both pick `jane`, then one
    flush wins and the other raises IntegrityError). The signup() view wraps
    the call site in an IntegrityError retry loop to absorb that race.
    """
    local = re.sub(r'[^a-z0-9]', '', email.split('@', 1)[0].lower())[:20] or 'user'
    candidate = local
    suffix = 0
    while User.query.filter_by(username=candidate).first():
        suffix += 1
        tail = str(suffix)
        candidate = f"{local[:20 - len(tail)]}{tail}"
    return candidate


@auth_bp.route('/register')
def register():
    """Backward-compat alias: /register → /signup (Loi 25 redesign B-2.2)."""
    return redirect(url_for('auth.signup'), code=302)


@auth_bp.route('/signup', methods=['GET', 'POST'])
@rate_limit("10 per minute")
def signup():
    """Loi 25-compliant signup with 4 separate consent checkboxes.

    Each signup creates exactly 4 ConsentLog rows (cgu, confidentialite,
    marketing, analytics) with the user's explicit choice. CGU and
    confidentialité are hard-required; marketing and analytics are optional
    but their refusal is also recorded (granted=False) for the audit trail.
    """
    if not _registration_open():
        flash('La création de comptes est temporairement désactivée.', 'danger')
        return redirect(url_for('auth.login'))

    if current_user.is_authenticated:
        return redirect(url_for('recordings.index'))

    form = SignupLoi25Form()

    if form.validate_on_submit():
        # Hard-stop: required Loi 25 consents (CGU + politique de confidentialité).
        # Errors are attached to the field directly (not flashed) so that the
        # message stays scoped to this form-render and never leaks to other
        # navigations via the session-backed flash queue.
        consent_errors_present = False
        if not form.consent_cgu.data:
            form.consent_cgu.errors.append(
                "Vous devez accepter les conditions d'utilisation pour créer un compte."
            )
            consent_errors_present = True
        if not form.consent_confidentialite.data:
            form.consent_confidentialite.errors.append(
                "Vous devez accepter la politique de confidentialité pour créer un compte."
            )
            consent_errors_present = True
        if consent_errors_present:
            return render_template(
                'register.html', title='Créer un compte', form=form
            ), 400

        if not is_registration_domain_allowed(form.email.data):
            flash("Le domaine de votre courriel n'est pas autorisé.", 'danger')
            return render_template(
                'register.html', title='Créer un compte', form=form
            ), 400

        email_normalized = form.email.data.lower().strip()
        full_name = (
            f"{form.first_name.data.strip()} {form.last_name.data.strip()}".strip()
        )
        hashed_password = bcrypt.generate_password_hash(
            form.password.data
        ).decode('utf-8')

        # Username generation can race under concurrent signups (two requests
        # can both pick `jane` before either flushes). Retry up to 5 times on
        # unique-constraint conflict; cap iterations to prevent an infinite
        # loop on pathological username churn.
        max_username_attempts = 5
        for attempt in range(max_username_attempts):
            user = User(
                username=_generate_unique_username(email_normalized),
                email=email_normalized,
                password=hashed_password,
                name=full_name,
                cabinet=(form.cabinet.data.strip() or None) if form.cabinet.data else None,
                ordre_pro=(form.ordre_pro.data or None),
                email_verified=not is_email_verification_enabled(),
            )
            db.session.add(user)
            try:
                db.session.flush()  # need user.id for ConsentLog FK
                break
            except IntegrityError:
                db.session.rollback()
                if attempt == max_username_attempts - 1:
                    raise

        ip = _client_ip()
        ua = (request.headers.get('User-Agent') or '')[:500]
        consent_state = {
            'cgu': bool(form.consent_cgu.data),
            'confidentialite': bool(form.consent_confidentialite.data),
            'marketing': bool(form.consent_marketing.data),
            'analytics': bool(form.consent_analytics.data),
        }
        for ctype, granted in consent_state.items():
            db.session.add(ConsentLog(
                user_id=user.id,
                consent_type=ctype,
                version=SIGNUP_LEGAL_VERSION,
                granted=granted,
                ip_address=ip,
                user_agent=ua,
            ))
        db.session.commit()
        audit_register(user.id)

        if is_email_verification_enabled() and is_smtp_configured():
            if send_verification_email(user):
                return render_template(
                    'auth/check_email.html',
                    title='Vérifiez votre courriel',
                    email=user.email,
                    action='verification',
                )
            flash(
                "Compte créé, mais l'envoi du courriel de vérification a échoué. "
                "Contactez le support.",
                'warning',
            )
            return redirect(url_for('auth.login'))

        flash('Votre compte a été créé. Vous pouvez vous connecter.', 'success')
        return redirect(url_for('auth.login'))

    return render_template('register.html', title='Créer un compte', form=form)


@auth_bp.route('/login', methods=['GET', 'POST'])
@rate_limit("10 per minute")
def login():
    if current_user.is_authenticated:
        return redirect(url_for('recordings.index'))

    sso_enabled = is_sso_enabled()
    sso_config = get_sso_config()
    if sso_enabled:
        init_sso_client(current_app)

    password_login_disabled = sso_enabled and sso_config.get('disable_password_login', False)

    form = LoginForm()
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data).first()
        if user and user.password:
            # Check if password login is disabled for non-admins
            if password_login_disabled and not user.is_admin:
                flash('La connexion par mot de passe est désactivée. Utilisez le SSO.', 'warning')
            elif bcrypt.check_password_hash(user.password, form.password.data):
                # Check email verification if required
                if is_email_verification_required() and not user.email_verified:
                    # Store user email in session for resend functionality
                    session['unverified_email'] = user.email
                    return render_template('auth/check_email.html',
                                         title='Vérification du courriel requise',
                                         email=user.email,
                                         action='verification_required',
                                         show_resend=True)

                # B-2.5/B-2.6: 2FA gate — defer login until 2nd factor verified.
                # Fires when the user has TOTP enabled OR at least one passkey.
                # Same pending session contract for both methods; the verify
                # page offers whichever options the user has registered.
                from src.auth.webauthn import has_passkeys
                if user.totp_enabled or has_passkeys(user):
                    session['pending_totp_user_id'] = user.id
                    session['pending_totp_remember'] = bool(form.remember.data)
                    next_page = request.args.get('next')
                    if next_page and is_safe_url(next_page):
                        session['pending_totp_next'] = next_page
                    return redirect(url_for('auth.totp_verify_login'))

                login_user(user, remember=form.remember.data)
                audit_login(user.id)
                next_page = request.args.get('next')
                if not is_safe_url(next_page):
                    return redirect(url_for('recordings.index'))
                return redirect(next_page) if next_page else redirect(url_for('recordings.index'))
            else:
                _email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16]
                audit_failed_login(details={'email_hash': _email_hash, 'reason': 'wrong_password'})
                flash('Connexion impossible. Vérifiez votre courriel et votre mot de passe.', 'danger')
        elif user and not user.password:
            _email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16]
            audit_failed_login(details={'email_hash': _email_hash, 'reason': 'sso_only_account'})
            flash("Ce compte utilise une connexion fédérée (SSO ou Microsoft/Google). Utilisez l'un des boutons ci-dessus.", 'warning')
        else:
            _email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16]
            audit_failed_login(details={'email_hash': _email_hash, 'reason': 'user_not_found'})
            flash('Connexion impossible. Vérifiez votre courriel et votre mot de passe.', 'danger')

    return render_template(
        'login.html',
        title='Connexion',
        form=form,
        sso_enabled=sso_enabled,
        sso_provider_name=sso_config.get('provider_name', 'SSO'),
        password_login_disabled=password_login_disabled,
        oauth_microsoft_enabled=is_oauth_provider_enabled('microsoft'),
        oauth_google_enabled=is_oauth_provider_enabled('google'),
    )


@auth_bp.route('/auth/sso/login')
@rate_limit("10 per minute")
def sso_login():
    if not is_sso_enabled():
        flash('SSO is not configured. Please contact the administrator.', 'danger')
        return redirect(url_for('auth.login'))

    oauth = get_sso_client() or init_sso_client(current_app)
    if not oauth:
        flash('Failed to initialize SSO client. Check server logs.', 'danger')
        return redirect(url_for('auth.login'))

    next_url = request.args.get('next')
    if next_url and is_safe_url(next_url):
        session['sso_next'] = next_url
    else:
        session.pop('sso_next', None)

    try:
        return oauth.sso.authorize_redirect(redirect_uri=get_sso_config().get('redirect_uri'))
    except Exception as e:
        current_app.logger.error(f"SSO redirect failed: {e}")
        flash('Impossible de joindre le fournisseur SSO. Vérifiez la connexion réseau du serveur.', 'danger')
        return redirect(url_for('auth.login'))


@auth_bp.route('/auth/sso/callback')
@rate_limit("20 per minute")
def sso_callback():
    if not is_sso_enabled():
        flash('SSO is not configured. Please contact the administrator.', 'danger')
        return redirect(url_for('auth.login'))

    oauth = get_sso_client() or init_sso_client(current_app)
    if not oauth:
        flash('Failed to initialize SSO client. Check server logs.', 'danger')
        return redirect(url_for('auth.login'))

    try:
        token = oauth.sso.authorize_access_token()
        userinfo = token.get('userinfo') or oauth.sso.userinfo()
    except Exception as e:
        current_app.logger.warning(f"SSO callback error: {e}")
        flash('SSO login failed. Please try again.', 'danger')
        return redirect(url_for('auth.login'))

    subject = userinfo.get('sub')
    if not subject:
        flash('SSO response did not include a subject identifier.', 'danger')
        return redirect(url_for('auth.login'))

    link_user_id = session.pop('sso_link_user_id', None)
    next_url = session.pop('sso_next', None)
    cfg = get_sso_config()

    if link_user_id:
        target_user = db.session.get(User, int(link_user_id))
        if not target_user:
            flash('Could not link account: user not found.', 'danger')
            return redirect(url_for('auth.account'))

        existing = User.query.filter_by(sso_subject=subject).first()
        if existing and existing.id != target_user.id:
            flash('This SSO account is already linked to another user.', 'danger')
            return redirect(url_for('auth.account'))

        update_user_profile_from_claims(target_user, userinfo)
        target_user.sso_provider = cfg.get('provider_name', 'SSO')
        target_user.sso_subject = subject
        db.session.commit()
        flash('SSO account linked successfully.', 'success')
        return redirect(url_for('auth.account'))

    try:
        user = create_or_update_sso_user(userinfo)
    except PermissionError as e:
        flash(str(e), 'danger')
        return redirect(url_for('auth.login'))
    except ValueError as e:
        flash(str(e), 'danger')
        return redirect(url_for('auth.login'))
    except Exception as e:
        current_app.logger.warning(f"SSO login error: {e}")
        flash('Could not complete SSO login. Please try again.', 'danger')
        return redirect(url_for('auth.login'))

    login_user(user, remember=True)
    audit_sso_login(user.id, details={'provider': cfg.get('provider_name', 'SSO')})

    if next_url and is_safe_url(next_url):
        return redirect(next_url)
    return redirect(url_for('recordings.index'))


@auth_bp.route('/auth/sso/link', methods=['POST'])
@login_required
def sso_link():
    if not is_sso_enabled():
        flash('SSO is not configured. Please contact the administrator.', 'danger')
        return redirect(url_for('auth.account'))

    session['sso_link_user_id'] = current_user.id
    session['sso_next'] = url_for('auth.account')

    return redirect(url_for('auth.sso_login'))


@auth_bp.route('/auth/sso/unlink', methods=['POST'])
@login_required
def sso_unlink():
    if not current_user.sso_subject:
        flash('Your account is not linked to SSO.', 'warning')
        return redirect(url_for('auth.account'))

    if not current_user.password:
        flash('Cannot unlink SSO - you have no password set. Please set a password first.', 'danger')
        return redirect(url_for('auth.account'))

    current_user.sso_provider = None
    current_user.sso_subject = None
    db.session.commit()
    flash('SSO account unlinked successfully.', 'success')
    return redirect(url_for('auth.account'))


# --- B-2.4: Microsoft 365 + Google OAuth + Magic Link ---

@auth_bp.route('/auth/oauth/<provider>/login')
@rate_limit("10 per minute")
def oauth_provider_login(provider):
    """Start OAuth flow with the named provider (microsoft|google).

    Disabled providers redirect to /login with a French flash. Authenticated
    users are redirected to the app to avoid an infinite OAuth bounce.
    """
    if not is_oauth_provider_enabled(provider):
        flash(
            f"La connexion via {get_oauth_provider_display_name(provider)} n'est pas activée.",
            'danger',
        )
        return redirect(url_for('auth.login'))
    if current_user.is_authenticated:
        return redirect(url_for('recordings.index'))
    redirect_uri = url_for(
        'auth.oauth_provider_callback', provider=provider, _external=True
    )
    return get_oauth_client(provider).authorize_redirect(redirect_uri)


@auth_bp.route('/auth/oauth/<provider>/callback')
def oauth_provider_callback(provider):
    """Receive OAuth callback.

    - Existing user (matched by sso_subject OR email): log in directly.
    - New user: store userinfo in session and redirect to /auth/oauth/finish-signup
      for Loi 25 consent capture BEFORE creating the User row.
    """
    if not is_oauth_provider_enabled(provider):
        flash("Fournisseur OAuth indisponible.", 'danger')
        return redirect(url_for('auth.login'))
    try:
        token = get_oauth_client(provider).authorize_access_token()
    except Exception as e:
        current_app.logger.warning('OAuth callback failed for %s: %s', provider, e)
        flash("La connexion a échoué. Réessayez.", 'danger')
        return redirect(url_for('auth.login'))

    userinfo = token.get('userinfo') or {}
    subject = userinfo.get('sub')
    email = (userinfo.get('email') or '').lower().strip()
    if not subject or not email:
        flash(
            "Le fournisseur n'a pas retourné d'identifiant ou de courriel.",
            'danger',
        )
        return redirect(url_for('auth.login'))

    # C1: read email_verified strictly. We accept ONLY the literal boolean
    # True; None / missing / 'true' string / etc. are treated as False so
    # that a hostile or misconfigured IdP cannot upgrade an unverified
    # claim into an auto-link to an existing account.
    email_verified_raw = userinfo.get('email_verified')
    email_verified = email_verified_raw is True

    # C2: find_user_by_oauth raises PermissionError if the email matches
    # an existing user that is already linked to a different OAuth identity.
    try:
        user = find_user_by_oauth(
            provider, subject, email, email_verified=email_verified,
        )
    except PermissionError as e:
        flash(str(e), 'danger')
        return redirect(url_for('auth.login'))

    if user:
        login_user(user)
        audit_sso_login(user.id, details={'provider': provider})
        return redirect(url_for('recordings.index'))

    # C1: if no user was returned but an account exists for this email AND
    # email_verified is False, the IdP couldn't (or wouldn't) prove the
    # user controls the mailbox. Falling through to finish-signup would
    # create a duplicate account on a different identity AND mask the
    # takeover attempt; instead refuse explicitly with a friendly flash.
    existing_with_email = User.query.filter_by(email=email).first()
    if existing_with_email and not email_verified:
        provider_display_name = get_oauth_provider_display_name(provider)
        flash(
            f"Un compte DictIA existe déjà pour ce courriel. "
            f"Connectez-vous d'abord avec votre mot de passe pour lier votre "
            f"compte {provider_display_name}, ou demandez l'aide du support.",
            'danger',
        )
        return redirect(url_for('auth.login'))

    # New user — defer creation until Loi 25 consents are captured.
    # I4: include created_at so finish-signup can reject stale sessions.
    import time as _time
    session['oauth_signup_pending'] = {
        'provider': provider,
        'subject': subject,
        'userinfo': {
            'email': email,
            'name': userinfo.get('name', ''),
            'given_name': userinfo.get('given_name', ''),
            'family_name': userinfo.get('family_name', ''),
        },
        'created_at': _time.time(),
    }
    return redirect(url_for('auth.oauth_finish_signup'))


@auth_bp.route('/auth/oauth/finish-signup', methods=['GET', 'POST'])
@rate_limit("10 per minute")
def oauth_finish_signup():
    """Capture Loi 25 consents for OAuth signups before creating the User row.

    Falls back to /signup if no pending OAuth session exists. Refuses without
    CGU + politique de confidentialité (hard-required by Loi 25 art. 14).
    """
    pending = session.get('oauth_signup_pending')
    if not pending:
        return redirect(url_for('auth.signup'))

    # I4: reject stale OAuth signup sessions (>15 min). Operator might have
    # restarted between callback and finish-signup, or user abandoned then
    # came back hours later. Either way, restart the OAuth flow rather
    # than trust a stale subject claim.
    import time as _time
    if pending.get('created_at', 0) < _time.time() - 15 * 60:
        session.pop('oauth_signup_pending', None)
        flash(
            "Votre session d'inscription OAuth a expiré. "
            "Recommencez avec votre fournisseur.",
            'warning',
        )
        return redirect(url_for('auth.login'))

    if current_user.is_authenticated:
        session.pop('oauth_signup_pending', None)
        return redirect(url_for('recordings.index'))

    if request.method == 'POST':
        consent_cgu = request.form.get('consent_cgu') == 'y'
        consent_confidentialite = request.form.get('consent_confidentialite') == 'y'
        consent_marketing = request.form.get('consent_marketing') == 'y'
        consent_analytics = request.form.get('consent_analytics') == 'y'

        errors = {}
        if not consent_cgu:
            errors['consent_cgu'] = (
                "Vous devez accepter les conditions d'utilisation pour créer un compte."
            )
        if not consent_confidentialite:
            errors['consent_confidentialite'] = (
                "Vous devez accepter la politique de confidentialité pour créer un compte."
            )
        if errors:
            return render_template(
                'auth/oauth_finish_signup.html',
                title='Finaliser votre inscription DictIA',
                provider=pending['provider'],
                provider_display=get_oauth_provider_display_name(pending['provider']),
                userinfo=pending['userinfo'],
                errors=errors,
            ), 400

        consents = {
            'cgu': consent_cgu,
            'confidentialite': consent_confidentialite,
            'marketing': consent_marketing,
            'analytics': consent_analytics,
        }
        ip = _client_ip()
        ua = (request.headers.get('User-Agent') or '')[:500]
        try:
            user = create_oauth_user_with_consent(
                provider=pending['provider'],
                subject=pending['subject'],
                userinfo=pending['userinfo'],
                consents=consents,
                ip=ip,
                ua=ua,
                legal_version=SIGNUP_LEGAL_VERSION,
            )
        except EmailAlreadyExistsError:
            # I3: race — a parallel /signup created the email between OAuth
            # callback and this POST. Don't 500; redirect to /login with a
            # helpful flash so the user knows their existing account is fine.
            session.pop('oauth_signup_pending', None)
            flash(
                "Un compte DictIA existe déjà pour ce courriel. "
                "Connectez-vous avec votre mot de passe ou utilisez votre "
                "fournisseur d'origine.",
                'warning',
            )
            return redirect(url_for('auth.login'))
        except ValueError as e:
            current_app.logger.warning('OAuth signup failed: %s', e)
            flash(
                "L'inscription via OAuth a échoué. Réessayez ou utilisez le formulaire d'inscription.",
                'danger',
            )
            session.pop('oauth_signup_pending', None)
            return redirect(url_for('auth.signup'))

        session.pop('oauth_signup_pending', None)
        login_user(user)
        audit_register(user.id)
        audit_sso_login(user.id, details={'provider': pending['provider']})
        flash('Bienvenue chez DictIA !', 'success')
        return redirect(url_for('recordings.index'))

    return render_template(
        'auth/oauth_finish_signup.html',
        title='Finaliser votre inscription DictIA',
        provider=pending['provider'],
        provider_display=get_oauth_provider_display_name(pending['provider']),
        userinfo=pending['userinfo'],
        errors={},
    )


@auth_bp.route('/auth/magic-link', methods=['GET', 'POST'])
@rate_limit("5 per minute")
def magic_link_request():
    """Request a magic-link email.

    Anti-enumeration (Loi 25 / OWASP A01): same generic French message
    whether the email matches a real account or not, and whether the
    account is verified or not. The email is only sent for known
    + verified users with SMTP configured.
    """
    if current_user.is_authenticated:
        return redirect(url_for('recordings.index'))

    if request.method == 'POST':
        email = (request.form.get('email') or '').lower().strip()
        if not email:
            flash("L'adresse courriel est requise.", 'danger')
            return render_template(
                'auth/magic_link_request.html',
                title='Lien de connexion DictIA',
            ), 400

        # Anti-enumeration: lookup user but always show the same flash
        user = User.query.filter_by(email=email).first()
        if user and user.email_verified and is_smtp_configured():
            token = generate_magic_link_token(user.id)
            magic_url = url_for(
                'auth.magic_link_consume', token=token, _external=True
            )
            send_magic_link_email(user, magic_url)

        flash(
            "Si un compte vérifié existe pour ce courriel, un lien de "
            "connexion vous a été envoyé. Le lien expire dans 15 minutes.",
            'info',
        )
        return render_template(
            'auth/check_email.html',
            title='Vérifiez votre courriel',
            email=email,
            action='magic_link',
        )

    return render_template(
        'auth/magic_link_request.html',
        title='Lien de connexion DictIA',
    )


@auth_bp.route('/auth/magic-link/<token>')
@rate_limit("10 per minute")
def magic_link_consume(token):
    """Consume a magic-link token. Logs in the user on success.

    Rejects invalid, expired, and unverified-user tokens with the same
    generic French flash to avoid leaking which one of those failure
    modes occurred.
    """
    if current_user.is_authenticated:
        return redirect(url_for('recordings.index'))

    user_id = consume_magic_link_token(token)
    if not user_id:
        flash("Le lien de connexion est invalide ou expiré.", 'danger')
        return redirect(url_for('auth.magic_link_request'))

    user = db.session.get(User, user_id)
    if not user or not user.email_verified:
        flash("Le lien de connexion est invalide ou expiré.", 'danger')
        return redirect(url_for('auth.magic_link_request'))

    login_user(user)
    audit_login(user.id)
    return redirect(url_for('recordings.index'))


# --- B-2.5: TOTP MFA routes ---


class TotpVerifyForm(FlaskForm):
    """Form definition kept for CSRF coverage / future template binding.

    Routes parse request.form directly (no WTForms validators) because the
    same endpoint accepts EITHER `code` (6 digits) OR `recovery_code`
    (XXXXX-XXXXX), and WTForms would reject one when the other is sent.
    """
    code = StringField('Code à 6 chiffres')
    recovery_code = StringField('Code de récupération')
    submit = SubmitField('Vérifier')


@auth_bp.route('/2fa/setup', methods=['GET', 'POST'])
@login_required
@rate_limit("10 per minute")
def totp_setup():
    """Enroll the current user in TOTP MFA (two-step flow).

    GET: generate a fresh secret + QR + 10 recovery codes; cache the secret
    and recovery-code hashes in the session (NOT yet persisted).
    POST: verify the user-submitted 6-digit code against the pending secret;
    on success, persist the encrypted secret + recovery code hashes and
    flip ``totp_enabled`` to True.

    Already-enrolled users are redirected to /account with an info flash so
    that this endpoint never silently rotates a working enrollment.
    """
    from src.auth.totp import (
        generate_totp_secret, build_provisioning_uri, render_qr_data_url,
        verify_totp_code, generate_recovery_codes, set_user_totp,
    )

    if current_user.totp_enabled:
        flash('La double authentification est déjà activée pour votre compte.', 'info')
        return redirect(url_for('auth.account'))

    if request.method == 'POST':
        pending_secret = session.get('totp_pending_secret')
        pending_recovery_hashes = session.get('totp_pending_recovery_hashes')
        pending_display_codes = session.get('totp_pending_display_codes', [])
        if not pending_secret or not pending_recovery_hashes:
            flash('Session expirée. Veuillez recommencer la configuration.', 'warning')
            return redirect(url_for('auth.totp_setup'))

        code = (request.form.get('code') or '').strip()
        if not verify_totp_code(pending_secret, code):
            qr = render_qr_data_url(
                build_provisioning_uri(pending_secret, current_user.email)
            )
            return render_template(
                'auth/totp_setup.html',
                title='Configurer la double authentification',
                qr_data_url=qr,
                secret=pending_secret,
                recovery_codes=pending_display_codes,
                error="Code invalide. Vérifiez l'horloge de votre appareil et réessayez.",
            ), 400

        # Persist
        set_user_totp(current_user, pending_secret, pending_recovery_hashes)
        # Clear pending session (one-shot enrollment)
        for k in (
            'totp_pending_secret',
            'totp_pending_recovery_hashes',
            'totp_pending_display_codes',
        ):
            session.pop(k, None)
        flash(
            'Double authentification activée. Conservez vos codes de récupération en lieu sûr.',
            'success',
        )
        return redirect(url_for('auth.account'))

    # GET: fresh enrollment — generate secret + recovery codes, cache in session
    secret = generate_totp_secret()
    display_codes, hashed_codes = generate_recovery_codes()
    session['totp_pending_secret'] = secret
    session['totp_pending_recovery_hashes'] = hashed_codes
    session['totp_pending_display_codes'] = display_codes
    qr = render_qr_data_url(build_provisioning_uri(secret, current_user.email))
    return render_template(
        'auth/totp_setup.html',
        title='Configurer la double authentification',
        qr_data_url=qr,
        secret=secret,
        recovery_codes=display_codes,
    )


@auth_bp.route('/2fa/disable', methods=['POST'])
@login_required
@rate_limit("5 per minute")
def totp_disable():
    """Disable TOTP MFA. Requires re-authentication via current password."""
    from src.auth.totp import disable_user_totp
    if not current_user.totp_enabled:
        flash("La double authentification n'est pas activée.", 'info')
        return redirect(url_for('auth.account'))
    password = request.form.get('password', '')
    if (
        not current_user.password
        or not bcrypt.check_password_hash(current_user.password, password)
    ):
        flash('Mot de passe incorrect. La double authentification reste activée.', 'danger')
        return redirect(url_for('auth.account'))
    disable_user_totp(current_user)
    flash('Double authentification désactivée.', 'success')
    return redirect(url_for('auth.account'))


@auth_bp.route('/2fa/verify', methods=['GET', 'POST'])
@rate_limit("5 per minute")
def totp_verify_login():
    """Second factor during login. Reads pending_totp_user_id from session.

    Accepts EITHER a 6-digit TOTP code OR a single-use recovery code. On
    success, finalises ``login_user`` (preserving the remember-me intent
    captured at the password step) and redirects to the next URL or the
    recordings index.
    """
    from src.auth.totp import (
        verify_totp_code, get_user_totp_secret, consume_recovery_code,
    )

    from src.auth.webauthn import has_passkeys

    pending_user_id = session.get('pending_totp_user_id')
    pending_remember = bool(session.get('pending_totp_remember', False))
    if not pending_user_id:
        flash('Session de connexion expirée. Veuillez vous reconnecter.', 'warning')
        return redirect(url_for('auth.login'))

    user = db.session.get(User, pending_user_id)
    # B-2.6: gate fires for either TOTP OR passkey enrolment, so the verify
    # page must accept users with EITHER second factor (or both). Reject only
    # if the user has neither (defensive — shouldn't happen if login is correct).
    if not user or (not user.totp_enabled and not has_passkeys(user)):
        for k in ('pending_totp_user_id', 'pending_totp_remember', 'pending_totp_next'):
            session.pop(k, None)
        flash('Erreur de session. Veuillez vous reconnecter.', 'danger')
        return redirect(url_for('auth.login'))

    recovery_remaining = len(user.totp_recovery_codes or [])
    user_has_passkeys = has_passkeys(user)
    user_has_totp = bool(user.totp_enabled)

    if request.method == 'POST':
        code = (request.form.get('code') or '').strip()
        recovery = (request.form.get('recovery_code') or '').strip()

        success = False
        if code and user_has_totp:
            secret = get_user_totp_secret(user)
            if secret and verify_totp_code(secret, code):
                success = True
        elif recovery and user_has_totp:
            success = consume_recovery_code(user, recovery)

        if success:
            next_page = session.pop('pending_totp_next', None)
            session.pop('pending_totp_user_id', None)
            session.pop('pending_totp_remember', None)
            login_user(user, remember=pending_remember)
            audit_login(user.id)
            if next_page and is_safe_url(next_page):
                return redirect(next_page)
            return redirect(url_for('recordings.index'))

        _user_hash = hashlib.sha256(str(user.id).encode()).hexdigest()[:16]
        audit_failed_login(
            details={'user_id_hash': _user_hash, 'reason': 'totp_invalid'}
        )
        return render_template(
            'auth/totp_verify.html',
            title='Vérification 2FA',
            recovery_codes_remaining=len(user.totp_recovery_codes or []),
            has_passkeys=user_has_passkeys,
            has_totp=user_has_totp,
            error=(
                "Code invalide. Vérifiez votre application authenticator ou "
                "utilisez un code de récupération."
            ),
        ), 400

    return render_template(
        'auth/totp_verify.html',
        title='Vérification 2FA',
        recovery_codes_remaining=recovery_remaining,
        has_passkeys=user_has_passkeys,
        has_totp=user_has_totp,
    )


# --- B-2.6: WebAuthn / Passkey routes ---
#
# CSRF strategy: the 4 JSON endpoints (begin/finish for register & auth) are
# decorated with @csrf_exempt because Flask-WTF's CSRFProtect cannot read a
# token from a JSON body without app-wide config changes. The X-CSRFToken
# header is still sent by the client (defense-in-depth) but not enforced.
# The form-POST delete route DOES enforce CSRF via the standard form token.
# Real CSRF exemption is wired in init_auth_extensions() — it walks the
# blueprint and calls csrf.exempt() on functions tagged with _csrf_exempt.


@auth_bp.route('/2fa/passkey/setup', methods=['GET'])
@login_required
def passkey_setup():
    """Page where the user manages and enrolls passkeys (post-login)."""
    from src.auth.webauthn import list_user_credentials
    return render_template(
        'auth/passkey_setup.html',
        title='Gérer mes passkeys',
        credentials=list_user_credentials(current_user),
    )


@auth_bp.route('/2fa/passkey/register/begin', methods=['POST'])
@login_required
@csrf_exempt
@rate_limit('10 per minute')
def passkey_register_begin():
    """Generate registration options for a NEW passkey (post-login enrolment).

    Stores the b64url-encoded challenge in the session under
    'webauthn_register_challenge'. The /finish route pops it.
    """
    from src.auth.webauthn import begin_registration
    options, challenge_b64 = begin_registration(current_user)
    session['webauthn_register_challenge'] = challenge_b64
    return jsonify(options)


@auth_bp.route('/2fa/passkey/register/finish', methods=['POST'])
@login_required
@csrf_exempt
@rate_limit('10 per minute')
def passkey_register_finish():
    """Verify the authenticator response and persist the credential."""
    from src.auth.webauthn import finish_registration
    challenge_b64 = session.pop('webauthn_register_challenge', None)
    if not challenge_b64:
        return jsonify({
            'error': 'no_challenge',
            'message': 'Aucun défi WebAuthn en attente.',
        }), 400
    payload = request.get_json(silent=True) or {}
    label = (payload.get('label') or '').strip()
    response_json = payload.get('response')
    if not response_json:
        return jsonify({
            'error': 'missing_response',
            'message': "Réponse de l'authentificateur manquante.",
        }), 400
    try:
        cred = finish_registration(
            current_user, response_json, challenge_b64, label=label
        )
    except Exception as e:
        current_app.logger.warning(
            'WebAuthn register failed for user %s: %s', current_user.id, e
        )
        return jsonify({
            'error': 'verification_failed',
            'message': 'Échec de la vérification.',
        }), 400
    return jsonify({
        'ok': True,
        'credential': {
            'id': cred['id'],
            'name': cred['name'],
            'created_at': cred['created_at'],
        },
    })


@auth_bp.route(
    '/2fa/passkey/credentials/<credential_id>/delete', methods=['POST']
)
@login_required
@rate_limit('10 per minute')
def passkey_delete(credential_id):
    """Delete one of the user's passkeys (form POST — CSRF enforced)."""
    from src.auth.webauthn import delete_credential
    if delete_credential(current_user, credential_id):
        flash('Passkey supprimée.', 'success')
    else:
        flash('Passkey introuvable.', 'warning')
    return redirect(url_for('auth.passkey_setup'))


@auth_bp.route('/2fa/passkey/auth/begin', methods=['POST'])
@csrf_exempt
@rate_limit('10 per minute')
def passkey_auth_begin():
    """Generate authentication options during /2fa/verify (login flow).

    Reads pending_totp_user_id from session — same handshake the TOTP gate
    uses. No @login_required because the user is in the post-password,
    pre-2FA limbo state.
    """
    from src.auth.webauthn import begin_authentication
    pending_user_id = session.get('pending_totp_user_id')
    if not pending_user_id:
        return jsonify({
            'error': 'no_session',
            'message': 'Session expirée.',
        }), 400
    user = db.session.get(User, pending_user_id)
    if not user:
        return jsonify({
            'error': 'no_user',
            'message': 'Utilisateur introuvable.',
        }), 400
    try:
        options, challenge_b64 = begin_authentication(user)
    except ValueError:
        return jsonify({
            'error': 'no_passkeys',
            'message': 'Aucune passkey enregistrée.',
        }), 400
    session['webauthn_auth_challenge'] = challenge_b64
    return jsonify(options)


@auth_bp.route('/2fa/passkey/auth/finish', methods=['POST'])
@csrf_exempt
@rate_limit('10 per minute')
def passkey_auth_finish():
    """Verify the assertion. On success, finalise login + clear pending state.

    Mirrors the TOTP verify-success path: pops pending_totp_*, calls
    login_user with the captured remember-me intent, audit_login, and
    returns the safe redirect target as JSON for the client to follow.
    """
    from src.auth.webauthn import finish_authentication
    pending_user_id = session.get('pending_totp_user_id')
    pending_remember = bool(session.get('pending_totp_remember', False))
    pending_next = session.get('pending_totp_next')
    challenge_b64 = session.pop('webauthn_auth_challenge', None)

    if not pending_user_id or not challenge_b64:
        return jsonify({
            'error': 'no_session',
            'message': 'Session expirée.',
        }), 400

    user = db.session.get(User, pending_user_id)
    if not user:
        return jsonify({
            'error': 'no_user',
            'message': 'Utilisateur introuvable.',
        }), 400

    payload = request.get_json(silent=True) or {}
    response_json = payload.get('response')
    if not response_json:
        return jsonify({
            'error': 'missing_response',
            'message': "Réponse de l'authentificateur manquante.",
        }), 400

    try:
        finish_authentication(user, response_json, challenge_b64)
    except Exception as e:
        current_app.logger.warning(
            'WebAuthn auth failed for user %s: %s', user.id, e
        )
        _user_hash = hashlib.sha256(str(user.id).encode()).hexdigest()[:16]
        audit_failed_login(details={
            'user_id_hash': _user_hash, 'reason': 'passkey_invalid',
        })
        return jsonify({
            'error': 'verification_failed',
            'message': 'Échec de la vérification.',
        }), 400

    for k in ('pending_totp_user_id', 'pending_totp_remember', 'pending_totp_next'):
        session.pop(k, None)
    login_user(user, remember=pending_remember)
    audit_login(user.id)
    redirect_url = (
        pending_next if (pending_next and is_safe_url(pending_next))
        else url_for('recordings.index')
    )
    return jsonify({'ok': True, 'redirect': redirect_url})


@auth_bp.route('/logout')
@csrf_exempt
def logout():
    if current_user.is_authenticated:
        audit_logout(current_user.id)
    logout_user()
    return redirect(url_for('auth.login'))


# --- Email Verification Routes ---

@auth_bp.route('/verify-email/<token>')
def verify_email(token):
    """Verify email address using token from email link."""
    user_id = verify_email_token(token)

    if user_id is None:
        flash('Le lien de vérification est invalide ou expiré.', 'danger')
        return redirect(url_for('auth.login'))

    user = db.session.get(User, user_id)
    if not user:
        flash('Utilisateur introuvable.', 'danger')
        return redirect(url_for('auth.login'))

    if user.email_verified:
        flash('Votre courriel a déjà été vérifié.', 'info')
        return redirect(url_for('auth.login'))

    # Verify the email
    user.email_verified = True
    user.email_verification_token = None  # Clear the token
    db.session.commit()

    return render_template('auth/verify_success.html', title='Courriel vérifié')


@auth_bp.route('/resend-verification', methods=['POST'])
@rate_limit("3 per minute")
def resend_verification():
    """Resend verification email."""
    if not is_email_verification_enabled():
        flash("La vérification de courriel n'est pas activée.", 'danger')
        return redirect(url_for('auth.login'))

    if not is_smtp_configured():
        flash("Le service de courriel n'est pas configuré.", 'danger')
        return redirect(url_for('auth.login'))

    # Get email from session (set during failed login) or form
    email = session.get('unverified_email') or request.form.get('email')

    if not email:
        flash("L'adresse courriel est requise.", 'danger')
        return redirect(url_for('auth.login'))

    user = User.query.filter_by(email=email).first()

    if not user:
        # Don't reveal if user exists (anti-enumeration — Loi 25 / OWASP A01)
        flash('Si un compte existe pour ce courriel, un lien de vérification a été envoyé.', 'info')
        return redirect(url_for('auth.login'))

    if user.email_verified:
        flash('Votre courriel a déjà été vérifié.', 'info')
        return redirect(url_for('auth.login'))

    # Check cooldown
    can_resend, remaining = can_resend_verification(user)
    if not can_resend:
        flash(f'Veuillez attendre {remaining} secondes avant de demander un nouveau courriel.', 'warning')
        return render_template('auth/check_email.html',
                             title='Vérification requise',
                             email=email,
                             action='verification_required',
                             show_resend=True)

    if send_verification_email(user):
        flash('Un nouveau courriel de vérification a été envoyé.', 'success')
    else:
        flash("L'envoi du courriel de vérification a échoué. Réessayez plus tard.", 'danger')

    return render_template('auth/check_email.html',
                         title='Confirmez votre courriel',
                         email=email,
                         action='verification',
                         show_resend=True)


# --- Password Reset Routes ---

@auth_bp.route('/forgot-password', methods=['GET', 'POST'])
@rate_limit("5 per minute")
def forgot_password():
    """Show and handle forgot password form."""
    if current_user.is_authenticated:
        return redirect(url_for('recordings.index'))

    if not is_smtp_configured():
        flash("La réinitialisation de mot de passe n'est pas disponible. Contactez l'administrateur.", 'warning')
        return redirect(url_for('auth.login'))

    if request.method == 'POST':
        email = request.form.get('email')

        if not email:
            flash("L'adresse courriel est requise.", 'danger')
            return render_template('auth/forgot_password.html', title='Mot de passe oublié')

        user = User.query.filter_by(email=email).first()

        # Always show the same message to prevent email enumeration
        if user:
            # Check if user has a password (not SSO-only)
            if user.password:
                # Check cooldown — silently skip resend; we still show the
                # same generic flash to avoid leaking that an account exists.
                can_resend, _remaining = can_resend_password_reset(user)
                if can_resend:
                    send_password_reset_email(user)

        flash('Si un compte existe pour ce courriel, un lien de réinitialisation a été envoyé.', 'info')
        return render_template('auth/check_email.html',
                             title='Vérifiez votre courriel',
                             email=email,
                             action='password_reset')

    return render_template('auth/forgot_password.html', title='Mot de passe oublié')


@auth_bp.route('/reset-password/<token>', methods=['GET', 'POST'])
@rate_limit("10 per minute")
def reset_password(token):
    """Handle password reset form."""
    if current_user.is_authenticated:
        return redirect(url_for('recordings.index'))

    user_id = verify_reset_token(token)

    if user_id is None:
        flash('Le lien de réinitialisation est invalide ou expiré.', 'danger')
        return redirect(url_for('auth.forgot_password'))

    user = db.session.get(User, user_id)
    if not user:
        flash('Utilisateur introuvable.', 'danger')
        return redirect(url_for('auth.forgot_password'))

    # Single-use token enforcement: URLSafeTimedSerializer is stateless, so
    # without this check a valid token could be replayed within the 1h window.
    # We invalidate the token on use by clearing user.password_reset_token, so
    # a mismatch here means the token has already been consumed (or never set).
    # Applies to BOTH GET (don't render the form) and POST (don't accept reset).
    if not user.password_reset_token or user.password_reset_token != token:
        flash('Le lien de réinitialisation est invalide ou expiré.', 'danger')
        return redirect(url_for('auth.forgot_password'))

    if request.method == 'POST':
        password = request.form.get('password')
        confirm_password = request.form.get('confirm_password')

        if not password or not confirm_password:
            flash('Les deux champs de mot de passe sont requis.', 'danger')
            return render_template('auth/reset_password.html', title='Nouveau mot de passe', token=token)

        if password != confirm_password:
            flash('Les mots de passe ne correspondent pas.', 'danger')
            return render_template('auth/reset_password.html', title='Nouveau mot de passe', token=token)

        # Validate password
        try:
            password_check(None, type('obj', (object,), {'data': password}))
        except ValidationError as e:
            flash(str(e), 'danger')
            return render_template('auth/reset_password.html', title='Nouveau mot de passe', token=token)

        # Update password
        hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
        user.password = hashed_password
        user.password_reset_token = None  # Clear the token
        user.password_reset_sent_at = None

        # Also verify email if not already verified
        if not user.email_verified:
            user.email_verified = True

        db.session.commit()
        audit_password_reset(user.id)

        flash('Votre mot de passe a été réinitialisé. Vous pouvez vous connecter.', 'success')
        return redirect(url_for('auth.login'))

    return render_template('auth/reset_password.html', title='Nouveau mot de passe', token=token)


@auth_bp.route('/account', methods=['GET', 'POST'])
@login_required
def account():
    # Import here to avoid circular imports
    from flask import current_app

    if request.method == 'POST':
        # Only update fields that are present in the form submission
        # This prevents clearing data when switching between tabs

        # Check if this is the account information form (has user_name field)
        if 'user_name' in request.form:
            # Handle personal information updates
            user_name = request.form.get('user_name')
            user_job_title = request.form.get('user_job_title')
            user_company = request.form.get('user_company')
            ui_lang = request.form.get('ui_language')
            transcription_lang = request.form.get('transcription_language')
            output_lang = request.form.get('output_language')

            current_user.name = user_name if user_name else None
            current_user.job_title = user_job_title if user_job_title else None
            current_user.company = user_company if user_company else None
            current_user.ui_language = ui_lang if ui_lang else 'en'
            current_user.transcription_language = transcription_lang if transcription_lang else None
            current_user.output_language = output_lang if output_lang else None

        # Check if this is the custom prompts form (has summary_prompt field)
        elif 'summary_prompt' in request.form:
            # Handle custom prompt updates
            summary_prompt_text = request.form.get('summary_prompt')
            current_user.summary_prompt = summary_prompt_text if summary_prompt_text else None
            # Handle event extraction setting
            current_user.extract_events = 'extract_events' in request.form
            # Handle transcription hints
            hotwords = request.form.get('transcription_hotwords')
            current_user.transcription_hotwords = hotwords if hotwords else None
            initial_prompt = request.form.get('transcription_initial_prompt')
            current_user.transcription_initial_prompt = initial_prompt if initial_prompt else None

        # Only update diarize if it's not locked by env var
        if 'ASR_DIARIZE' not in os.environ:
            current_user.diarize = 'diarize' in request.form

        db.session.commit()

        # Return JSON response for AJAX requests
        if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.accept_mimetypes.best == 'application/json':
            return jsonify({'success': True, 'message': 'Account details updated successfully!'})

        # Regular form submission with redirect
        flash('Account details updated successfully!', 'success')

        # Preserve the active tab when redirecting
        if 'summary_prompt' in request.form:
            return redirect(url_for('auth.account') + '#prompts')
        else:
            return redirect(url_for('auth.account'))

    # Get admin default prompt from system settings
    admin_default_prompt = SystemSetting.get_setting('admin_default_summary_prompt', None)
    if admin_default_prompt:
        default_summary_prompt_text = admin_default_prompt
    else:
        # Fallback to hardcoded default if admin hasn't set one
        default_summary_prompt_text = """Generate a comprehensive summary that includes the following sections:
- **Key Issues Discussed**: A bulleted list of the main topics
- **Key Decisions Made**: A bulleted list of any decisions reached
- **Action Items**: A bulleted list of tasks assigned, including who is responsible if mentioned"""

    asr_diarize_locked = 'ASR_DIARIZE' in os.environ
    ASR_DIARIZE = os.environ.get('ASR_DIARIZE', 'false').lower() == 'true'
    USE_ASR_ENDPOINT = os.environ.get('USE_ASR_ENDPOINT', 'false').lower() == 'true'
    USE_NEW_TRANSCRIPTION_ARCHITECTURE = os.environ.get('USE_NEW_TRANSCRIPTION_ARCHITECTURE', 'true').lower() == 'true'
    ENABLE_AUTO_DELETION = os.environ.get('ENABLE_AUTO_DELETION', 'false').lower() == 'true'
    ENABLE_INTERNAL_SHARING = os.environ.get('ENABLE_INTERNAL_SHARING', 'false').lower() == 'true'
    ASR_RETURN_SPEAKER_EMBEDDINGS = os.environ.get('ASR_RETURN_SPEAKER_EMBEDDINGS', 'false').lower() == 'true'
    ENABLE_AUTO_EXPORT = os.environ.get('ENABLE_AUTO_EXPORT', 'false').lower() == 'true'

    # Get connector diarization support (new architecture)
    connector_supports_diarization = USE_ASR_ENDPOINT  # Default to USE_ASR_ENDPOINT for backwards compat
    if USE_NEW_TRANSCRIPTION_ARCHITECTURE:
        try:
            from src.services.transcription import get_registry
            registry = get_registry()
            connector = registry.get_active_connector()
            if connector:
                connector_supports_diarization = connector.supports_diarization
        except Exception as e:
            current_app.logger.warning(f"Could not get connector diarization support: {e}")

    # Check if user is a team admin and get their admin groups
    admin_memberships = GroupMembership.query.filter_by(
        user_id=current_user.id,
        role='admin'
    ).all()

    is_team_admin = len(admin_memberships) > 0

    # Build list of groups where user is admin (for tag assignment)
    user_admin_groups = []
    for membership in admin_memberships:
        if membership.group:
            user_admin_groups.append({
                'id': membership.group.id,
                'name': membership.group.name
            })

    sso_config = get_sso_config()
    sso_enabled = is_sso_enabled()
    if sso_enabled:
        init_sso_client(current_app)
    sso_linked = bool(current_user.sso_subject)

    password_login_disabled = sso_enabled and sso_config.get('disable_password_login', False)

    # Check if admin has globally disabled auto-summarization
    admin_setting = SystemSetting.get_setting('disable_auto_summarization', False)
    admin_disabled_auto_summarization = admin_setting if isinstance(admin_setting, bool) else str(admin_setting).lower() == 'true'

    # Get user's UI language preference
    user_language = current_user.ui_language if current_user.ui_language else 'en'

    return render_template('account.html',
                           title='Account',
                           default_summary_prompt_text=default_summary_prompt_text,
                           use_asr_endpoint=USE_ASR_ENDPOINT,
                           connector_supports_diarization=connector_supports_diarization,
                           enable_auto_deletion=ENABLE_AUTO_DELETION,
                           enable_internal_sharing=ENABLE_INTERNAL_SHARING,
                           user_admin_groups=user_admin_groups,
                           asr_diarize_locked=asr_diarize_locked,
                           asr_diarize_env_value=ASR_DIARIZE,
                           is_team_admin=is_team_admin,
                           sso_enabled=sso_enabled,
                           sso_provider_name=sso_config.get('provider_name', 'SSO'),
                           sso_linked=sso_linked,
                           sso_subject=current_user.sso_subject,
                           has_password=bool(current_user.password),
                           password_login_disabled=password_login_disabled,
                           speaker_embeddings_enabled=ASR_RETURN_SPEAKER_EMBEDDINGS,
                           auto_speaker_labelling=current_user.auto_speaker_labelling,
                           auto_speaker_labelling_threshold=current_user.auto_speaker_labelling_threshold or 'medium',
                           admin_disabled_auto_summarization=admin_disabled_auto_summarization,
                           auto_summarization=current_user.auto_summarization if current_user.auto_summarization is not None else True,
                           user_language=user_language,
                           enable_auto_export=ENABLE_AUTO_EXPORT)


@auth_bp.route('/api/user/auto-speaker-labelling', methods=['POST'])
@login_required
def update_auto_speaker_labelling():
    """Update user's auto speaker labelling settings."""
    data = request.get_json()

    if data is None:
        return jsonify({'success': False, 'error': 'Invalid JSON'}), 400

    # Update enabled state
    if 'enabled' in data:
        current_user.auto_speaker_labelling = bool(data['enabled'])

    # Update threshold (validate value)
    if 'threshold' in data:
        threshold = data['threshold']
        if threshold in ('low', 'medium', 'high'):
            current_user.auto_speaker_labelling_threshold = threshold
        else:
            return jsonify({'success': False, 'error': 'Invalid threshold value'}), 400

    db.session.commit()

    return jsonify({
        'success': True,
        'auto_speaker_labelling': current_user.auto_speaker_labelling,
        'auto_speaker_labelling_threshold': current_user.auto_speaker_labelling_threshold
    })


@auth_bp.route('/api/user/auto-summarization', methods=['POST'])
@login_required
def update_auto_summarization():
    """Update user's auto summarization setting."""
    data = request.get_json()

    if data is None:
        return jsonify({'success': False, 'error': 'Invalid JSON'}), 400

    if 'enabled' in data:
        current_user.auto_summarization = bool(data['enabled'])
        db.session.commit()

    return jsonify({
        'success': True,
        'auto_summarization': current_user.auto_summarization
    })


@auth_bp.route('/change_password', methods=['POST'])
@login_required
@rate_limit("10 per minute")
def change_password():
    # Check if password management is disabled for non-admins
    sso_config = get_sso_config()
    password_login_disabled = is_sso_enabled() and sso_config.get('disable_password_login', False)
    if password_login_disabled and not current_user.is_admin:
        flash('Password management is disabled. Please use SSO to sign in.', 'warning')
        return redirect(url_for('auth.account'))

    current_password = request.form.get('current_password')
    new_password = request.form.get('new_password')
    confirm_password = request.form.get('confirm_password')

    # Check if user has an existing password
    has_existing_password = bool(current_user.password)

    # Validate form data - current password only required if user has one
    if has_existing_password and not current_password:
        flash('Current password is required.', 'danger')
        return redirect(url_for('auth.account'))

    if not new_password or not confirm_password:
        flash('New password and confirmation are required.', 'danger')
        return redirect(url_for('auth.account'))

    if new_password != confirm_password:
        flash('New password and confirmation do not match.', 'danger')
        return redirect(url_for('auth.account'))

    # Custom validation for new password
    try:
        password_check(None, type('obj', (object,), {'data': new_password}))
    except ValidationError as e:
        flash(str(e), 'danger')
        return redirect(url_for('auth.account'))

    # Verify current password only if user has one
    if has_existing_password:
        if not bcrypt.check_password_hash(current_user.password, current_password):
            flash('Current password is incorrect.', 'danger')
            return redirect(url_for('auth.account'))

    # Update password
    hashed_password = bcrypt.generate_password_hash(new_password).decode('utf-8')
    current_user.password = hashed_password
    db.session.commit()
    audit_password_change(current_user.id)

    flash('Your password has been updated!', 'success')
    return redirect(url_for('auth.account'))


@auth_bp.route('/docs/transcript-templates-guide')
def transcript_templates_guide():
    """Serve the transcript templates documentation."""
    from flask import current_app

    docs_path = os.path.join(current_app.root_path, '..', 'docs', 'transcript-templates-guide.md')

    if not os.path.exists(docs_path):
        return "Documentation not found", 404

    with open(docs_path, 'r', encoding='utf-8') as f:
        content = f.read()

    # Convert markdown to HTML
    html_content = markdown.markdown(content, extensions=['tables', 'fenced_code', 'codehilite'])

    # Wrap in basic HTML template with Speakr styling
    html_template = f'''
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Transcript Templates Guide - Speakr</title>
        <link rel="stylesheet" href="/static/css/output.css">
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
        <style>
            .markdown-body {{
                max-width: 900px;
                margin: 0 auto;
                padding: 2rem;
                line-height: 1.6;
            }}
            .markdown-body h1 {{ font-size: 2.5rem; margin-bottom: 1rem; }}
            .markdown-body h2 {{ font-size: 2rem; margin-top: 2rem; margin-bottom: 1rem; }}
            .markdown-body h3 {{ font-size: 1.5rem; margin-top: 1.5rem; margin-bottom: 0.75rem; }}
            .markdown-body pre {{ background: #f4f4f4; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; }}
            .markdown-body code {{ background: #f4f4f4; padding: 0.2rem 0.4rem; border-radius: 0.25rem; }}
            .markdown-body pre code {{ background: none; padding: 0; }}
            .markdown-body ul, .markdown-body ol {{ margin-left: 2rem; margin-bottom: 1rem; }}
            .markdown-body li {{ margin-bottom: 0.5rem; }}
            .markdown-body blockquote {{ border-left: 4px solid #ddd; padding-left: 1rem; margin: 1rem 0; }}
            .markdown-body table {{ border-collapse: collapse; width: 100%; margin: 1rem 0; }}
            .markdown-body th, .markdown-body td {{ border: 1px solid #ddd; padding: 0.5rem; }}
            .markdown-body th {{ background: #f4f4f4; font-weight: bold; }}
        </style>
    </head>
    <body>
        <div class="markdown-body">
            <a href="/" class="btn-primary" style="display: inline-block; margin-bottom: 1rem; padding: 0.5rem 1rem; background: #3b82f6; color: white; text-decoration: none; border-radius: 0.5rem;">← Back to App</a>
            {html_content}
        </div>
    </body>
    </html>
    '''

    return html_template
