"""AliothPress — Optional two-factor authentication (TOTP).

Isolated so app.py stays untouched except for one registration line and a
three-line intercept inside admin_login (the single choke point of the
whole auth system: after the password check, before the session is issued).

Design goals, in order:
  1. OPTIONAL — a site-level master switch (off by default, so shipping
     this release changes nothing for anyone), and per-user opt-in on top.
     Nobody ends up behind 2FA without asking for it twice.
  2. IMPOSSIBLE TO LOCK YOURSELF OUT — four independent recovery layers:
       a) one-time recovery codes (setup cannot be completed without
          confirming they were saved, and the first live TOTP code must
          be entered — no half-configured states);
       b) one-time code to the account e-mail via the site's own SMTP
          (site-level toggle, on by default);
       c) an owner/admin can reset 2FA for any other user from the
          Users page;
       d) break-glass for the solo owner: create an empty file named
          DISABLE_2FA next to app.py (hosting file manager is enough,
          no terminal) and the second factor is skipped — loudly, with
          a flash warning and an audit entry — until the file is removed.
  3. NO EXTERNAL SERVICES — TOTP (RFC 6238) is implemented with the
     standard library only; the QR code is rendered client-side by a
     vendored MIT library; e-mails go through the site's existing SMTP.

Security notes:
  * The TOTP secret is stored encrypted with the same Fernet scheme the
    core uses for the SMTP password (helpers are injected from app.py).
  * Recovery codes are stored as one-way hashes and burn on use.
  * "Trust this browser" is a signed cookie (itsdangerous, SECRET_KEY)
    carrying the user id and a per-user generation counter; disabling or
    resetting 2FA bumps the counter, which invalidates every old cookie.
  * A clock-skew window of ±1 step (30 s) is accepted — a large share of
    real-world "wrong code" lockouts are just drifting clocks.
  * The verify endpoint is rate-limited and a pending login dies after
    5 wrong codes or 5 minutes.

Models and most helpers are injected via init_app to avoid import cycles
with app.py (same pattern as webmcp.py).
"""

import base64
import hashlib
import hmac
import os
import secrets
import struct
import time

from flask import (flash, redirect, render_template, request, session,
                   url_for)
from werkzeug.security import check_password_hash, generate_password_hash

from i18n import t, t_for

# Filled by init_app()
_d = {}

PENDING_KEY = 'ap2fa_pending'          # {'uid', 'ts', 'next'}
ATTEMPTS_KEY = 'ap2fa_attempts'
EMAIL_CODE_KEY = 'ap2fa_email'         # {'hash', 'ts'}
TRUST_COOKIE = 'ap_2fa_trust'
TRUST_MAX_AGE = 30 * 24 * 3600         # 30 days
PENDING_MAX_AGE = 300                  # 5 minutes
MAX_ATTEMPTS = 5
EMAIL_CODE_MAX_AGE = 600               # 10 minutes
BREAK_GLASS_FILENAME = 'DISABLE_2FA'
RECOVERY_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'  # no 0/O, 1/I/L
RECOVERY_COUNT = 10


# ---------------------------------------------------------------------------
# TOTP — RFC 6238, standard library only
# ---------------------------------------------------------------------------

def generate_secret():
    """New 160-bit secret, base32 without padding (what authenticator
    apps expect in otpauth:// URIs)."""
    return base64.b32encode(secrets.token_bytes(20)).decode().rstrip('=')


def _totp_at(secret_b32, unix_time, step=30, digits=6):
    pad = '=' * ((8 - len(secret_b32) % 8) % 8)
    key = base64.b32decode(secret_b32.upper() + pad)
    counter = int(unix_time // step)
    digest = hmac.new(key, struct.pack('>Q', counter), hashlib.sha1).digest()
    offset = digest[-1] & 0x0F
    code = (struct.unpack('>I', digest[offset:offset + 4])[0]
            & 0x7FFFFFFF) % (10 ** digits)
    return str(code).zfill(digits)


def totp_verify(secret_b32, code, window=1, now=None):
    """Constant-time check of a 6-digit code, accepting ±window steps of
    clock skew."""
    code = (code or '').strip().replace(' ', '')
    if not code.isdigit() or len(code) != 6 or not secret_b32:
        return False
    now = time.time() if now is None else now
    ok = False
    for i in range(-window, window + 1):
        # No early exit: compare every candidate to keep timing flat.
        if hmac.compare_digest(_totp_at(secret_b32, now + i * 30), code):
            ok = True
    return ok


def otpauth_uri(secret_b32, account_label, issuer):
    from urllib.parse import quote
    return ('otpauth://totp/' + quote(issuer, safe='') + ':'
            + quote(account_label, safe='')
            + '?secret=' + secret_b32
            + '&issuer=' + quote(issuer, safe='')
            + '&algorithm=SHA1&digits=6&period=30')


# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------

def _site_enabled():
    return _d['Settings'].get('twofa_site_enabled', '') == '1'


def _email_fallback_enabled():
    return _d['Settings'].get('twofa_email_fallback', '1') == '1'


def _break_glass_active():
    return os.path.exists(
        os.path.join(_d['app'].root_path, BREAK_GLASS_FILENAME))


def _user_secret(user):
    return _d['decrypt'](user.twofa_secret or '')


def _trust_serializer():
    from itsdangerous import URLSafeTimedSerializer
    return URLSafeTimedSerializer(_d['app'].config['SECRET_KEY'],
                                  salt='aliothpress.twofa.trust')


def _trust_cookie_valid(user):
    raw = request.cookies.get(TRUST_COOKIE, '')
    if not raw:
        return False
    try:
        data = _trust_serializer().loads(raw, max_age=TRUST_MAX_AGE)
        return (data.get('u') == user.id
                and data.get('g') == (user.twofa_generation or 0))
    except Exception:
        return False


def _set_trust_cookie(response, user):
    value = _trust_serializer().dumps(
        {'u': user.id, 'g': user.twofa_generation or 0})
    response.set_cookie(
        TRUST_COOKIE, value, max_age=TRUST_MAX_AGE, httponly=True,
        samesite='Lax',
        secure=(request.is_secure
                or request.headers.get('X-Forwarded-Proto') == 'https'))


def _clear_pending():
    session.pop(PENDING_KEY, None)
    session.pop(ATTEMPTS_KEY, None)
    session.pop(EMAIL_CODE_KEY, None)


def _pending_user():
    """Return the User of a fresh pending 2FA login, or None."""
    data = session.get(PENDING_KEY)
    if not data:
        return None
    if time.time() - data.get('ts', 0) > PENDING_MAX_AGE:
        _clear_pending()
        return None
    return _d['User'].query.get(data.get('uid'))


def _new_recovery_codes(user):
    """Replace the user's recovery codes; return the plaintext list
    (shown exactly once)."""
    RC = _d['RecoveryCode']
    RC.query.filter_by(user_id=user.id).delete()
    plain = []
    for _ in range(RECOVERY_COUNT):
        code = ''.join(secrets.choice(RECOVERY_ALPHABET) for _ in range(10))
        code = code[:5] + '-' + code[5:]
        plain.append(code)
        _d['db'].session.add(RC(user_id=user.id,
                                code_hash=generate_password_hash(code)))
    return plain


def _try_recovery_code(user, code):
    code = (code or '').strip().upper().replace(' ', '')
    if len(code) == 10:
        code = code[:5] + '-' + code[5:]
    if len(code) != 11:
        return False
    RC = _d['RecoveryCode']
    for row in RC.query.filter_by(user_id=user.id, used_at=None).all():
        if check_password_hash(row.code_hash, code):
            row.used_at = _d['utc_now']()
            return True
    return False


def _disable_for(user):
    """Common teardown for disable / admin reset."""
    user.twofa_enabled = False
    user.twofa_secret = ''
    user.twofa_generation = (user.twofa_generation or 0) + 1
    _d['RecoveryCode'].query.filter_by(user_id=user.id).delete()


def _email_paragraph(text):
    """One escaped paragraph in the engine's e-mail typography."""
    import html as _html
    return ('<p style="font-size: 15px; color: #2d3748; margin: 0 0 16px; '
            'line-height: 1.6;">'
            + _html.escape(text).replace('\n', '<br>') + '</p>')


def _email_html(lang, inner_html):
    """Wrap e-mail content in the engine's branded layout — the same
    header / card / footer used by the invitation and SMTP-test e-mails
    in app.py, so 2FA mail is indistinguishable in style from the rest
    of the CMS."""
    import html as _html
    site = _html.escape(_d['Settings'].get('site_name', 'AliothPress'))
    subtitle = _html.escape(t_for('twofa.email_subtitle', lang))
    sent_by = _html.escape(
        t_for('email.sent_by', lang, site_name=_d['Settings'].get(
            'site_name', 'AliothPress')))
    powered_by = '' if _d.get('license_ok', lambda: True)() else (
        ' &bull; Powered by <a href="https://aliothpress.com" '
        'style="color: #a0aec0; text-decoration: underline;">'
        'AliothPress</a>')
    return f"""
    <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 40px 20px;">
        <div style="background: #667eea; color: white; padding: 30px; border-radius: 12px 12px 0 0; text-align: center;">
            <h1 style="margin: 0; font-size: 24px;">{site}</h1>
            <p style="margin: 8px 0 0; opacity: 0.9;">{subtitle}</p>
        </div>
        <div style="background: white; padding: 30px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 12px 12px;">
            {inner_html}
            <hr style="border: none; border-top: 1px solid #e2e8f0; margin: 20px 0;">
            <p style="font-size: 12px; color: #a0aec0; text-align: center;">{sent_by}{powered_by}</p>
        </div>
    </div>
    """


def _notify(user, subject_key, body_key):
    """Best-effort e-mail in the recipient's admin language; failures are
    logged, never raised (2FA state changes must not depend on SMTP)."""
    try:
        lang = getattr(user, 'admin_language', None) or 'en'
        site = _d['Settings'].get('site_name', 'AliothPress')
        subject = t_for(subject_key, lang, site=site)
        body = t_for(body_key, lang, site=site,
                     name=user.get_display_name())
        inner = ''.join(_email_paragraph(p)
                        for p in body.split('\n\n') if p.strip())
        _d['send_email'](user.email, subject, _email_html(lang, inner))
    except Exception:
        _d['app'].logger.exception('twofa: notification e-mail failed')


# ---------------------------------------------------------------------------
# The choke point — called from admin_login after the password check
# ---------------------------------------------------------------------------

def intercept_login(user, ip, next_url=None):
    """Decide whether a second factor is required.

    Returns a redirect response to the verification step, or None to let
    admin_login proceed with the normal (single-factor) login.
    """
    if not _site_enabled():
        return None
    if not user.twofa_enabled or not (user.twofa_secret or ''):
        return None
    if _break_glass_active():
        # Loud bypass: the owner placed the DISABLE_2FA file to recover
        # access. Flash on every login and leave an audit trail so the
        # file does not silently linger forever.
        flash(t('twofa.bypass_file_warning'), 'warning')
        _d['AuditLog'].log('2fa_bypass_file', 'user', user.id,
                           _d['audit_detail']('twofa_bypass_file'),
                           user=user, ip=ip)
        _d['db'].session.commit()
        return None
    if _trust_cookie_valid(user):
        return None
    session[PENDING_KEY] = {'uid': user.id, 'ts': time.time(),
                            'next': next_url or ''}
    session[ATTEMPTS_KEY] = 0
    session.pop(EMAIL_CODE_KEY, None)
    return redirect(url_for('twofa_verify'))


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

def init_app(app, **deps):
    """Wire routes. deps: db, User, Settings, AuditLog, RecoveryCode,
    LoginAttempt, login_user, login_required, admin_required, limiter,
    send_email, encrypt, decrypt, validate_next_url, audit_detail,
    utc_now, license_ok (optional — hides the "Powered by" e-mail footer
    on licensed installations, same rule as the core's own e-mails)."""
    _d.update(deps)
    _d['app'] = app
    limiter = deps['limiter']

    def _complete_login(user, ip, method):
        deps['login_user'](user)
        session['_session_version'] = user.session_version or 0
        deps['LoginAttempt'].record(ip, user.username, success=True)
        deps['AuditLog'].log('login', details=deps['audit_detail'](
            'login_success'), user=user, ip=ip)
        if method == 'recovery':
            deps['AuditLog'].log('2fa_recovery_used', 'user', user.id,
                                 deps['audit_detail']('twofa_recovery_used'),
                                 user=user, ip=ip)
        deps['db'].session.commit()

    # ── Login: second step ────────────────────────────────────────────
    @app.route('/admin/login/2fa', methods=['GET', 'POST'])
    @limiter.limit('10 per minute')
    def twofa_verify():
        user = _pending_user()
        if user is None:
            flash(t('twofa.login_expired'), 'error')
            return redirect(url_for('admin_login'))

        email_available = _email_fallback_enabled()
        email_sent = bool(session.get(EMAIL_CODE_KEY))

        if request.method == 'POST':
            ip = request.remote_addr
            code = (request.form.get('code') or '').strip()
            ok_method = None

            secret = _user_secret(user)
            if totp_verify(secret, code):
                ok_method = 'totp'
            elif _try_recovery_code(user, code):
                ok_method = 'recovery'
            else:
                pending_email = session.get(EMAIL_CODE_KEY)
                if (pending_email and email_available
                        and time.time() - pending_email['ts']
                        <= EMAIL_CODE_MAX_AGE
                        and hmac.compare_digest(
                            hashlib.sha256(code.encode()).hexdigest(),
                            pending_email['hash'])):
                    ok_method = 'email'

            if ok_method:
                data = session.get(PENDING_KEY) or {}
                _clear_pending()
                _complete_login(user, ip, ok_method)
                next_page = deps['validate_next_url'](data.get('next'))
                resp = redirect(next_page or url_for('admin_dashboard'))
                if request.form.get('trust') == '1':
                    _set_trust_cookie(resp, user)
                return resp

            attempts = int(session.get(ATTEMPTS_KEY) or 0) + 1
            session[ATTEMPTS_KEY] = attempts
            deps['AuditLog'].log('2fa_login_failed', 'user', user.id,
                                 deps['audit_detail']('twofa_login_failed'),
                                 user=user, ip=ip)
            deps['db'].session.commit()
            if attempts >= MAX_ATTEMPTS:
                _clear_pending()
                flash(t('twofa.attempts_exceeded'), 'error')
                return redirect(url_for('admin_login'))
            flash(t('twofa.login_failed'), 'error')

        return render_template('admin/login_2fa.html',
                               email_available=email_available,
                               email_sent=email_sent)

    # ── Login: send a one-time code to the account e-mail ─────────────
    @app.route('/admin/login/2fa/email', methods=['POST'])
    @limiter.limit('3 per minute')
    def twofa_email_code():
        user = _pending_user()
        if user is None:
            flash(t('twofa.login_expired'), 'error')
            return redirect(url_for('admin_login'))
        if not _email_fallback_enabled():
            flash(t('twofa.login_email_unavailable'), 'error')
            return redirect(url_for('twofa_verify'))

        code = str(secrets.randbelow(10 ** 6)).zfill(6)
        session[EMAIL_CODE_KEY] = {
            'hash': hashlib.sha256(code.encode()).hexdigest(),
            'ts': time.time()}
        lang = getattr(user, 'admin_language', None) or 'en'
        site = deps['Settings'].get('site_name', 'AliothPress')
        # The translated body contains the code inline ("...:\n\n{code}\n\n...").
        # Substitute a marker instead, split on it, and render the code as a
        # prominent block between the surrounding paragraphs — every existing
        # translation keeps working untouched.
        marker = '\x00CODE\x00'
        body = t_for('twofa.email_code_body', lang, site=site, code=marker)
        before, _sep, after = body.partition(marker)
        code_box = ('<div style="text-align: center; margin: 24px 0;">'
                    '<span style="display: inline-block; padding: 16px 28px; '
                    'background: #f7fafc; border: 1px solid #e2e8f0; '
                    'border-radius: 8px; font-family: SFMono-Regular, Consolas, '
                    'Menlo, monospace; font-size: 32px; letter-spacing: 8px; '
                    'color: #2d3748; font-weight: 600;">'
                    + code + '</span></div>')
        inner = ''.join(_email_paragraph(p)
                        for p in before.split('\n\n') if p.strip())
        inner += code_box
        inner += ''.join(_email_paragraph(p)
                         for p in after.split('\n\n') if p.strip())
        ok, _msg = deps['send_email'](
            user.email,
            t_for('twofa.email_code_subject', lang, site=site),
            _email_html(lang, inner))
        deps['AuditLog'].log('2fa_email_code', 'user', user.id,
                             deps['audit_detail']('twofa_email_code'),
                             user=user, ip=request.remote_addr)
        deps['db'].session.commit()
        flash(t('twofa.login_email_sent') if ok
              else t('twofa.login_email_failed'), 'success' if ok else 'error')
        return redirect(url_for('twofa_verify'))

    # ── Enrollment (profile) ──────────────────────────────────────────
    @app.route('/admin/profile/2fa', methods=['GET'])
    @deps['login_required']
    def twofa_setup():
        from flask_login import current_user
        if not _site_enabled():
            flash(t('twofa.site_disabled'), 'error')
            return redirect(url_for('admin_profile'))
        if current_user.twofa_enabled:
            return redirect(url_for('admin_profile'))
        # (Re)generate an unconfirmed secret. Enabled stays False until
        # the whole ceremony — live code + saved recovery codes — is done,
        # so abandoning this page leaves the account exactly as it was.
        secret = generate_secret()
        current_user.twofa_secret = deps['encrypt'](secret)
        deps['db'].session.commit()
        site = deps['Settings'].get('site_name', 'AliothPress')
        return render_template(
            'admin/twofa_setup.html', secret=secret,
            otpauth=otpauth_uri(secret, current_user.email, site))

    @app.route('/admin/profile/2fa/confirm', methods=['POST'])
    @deps['login_required']
    def twofa_confirm():
        from flask_login import current_user
        if current_user.twofa_enabled or not _site_enabled():
            return redirect(url_for('admin_profile'))
        secret = _user_secret(current_user)
        if not totp_verify(secret, request.form.get('code')):
            flash(t('twofa.setup_wrong_code'), 'error')
            return redirect(url_for('twofa_setup'))
        codes = _new_recovery_codes(current_user)
        deps['db'].session.commit()
        return render_template('admin/twofa_recovery.html', codes=codes)

    @app.route('/admin/profile/2fa/finalize', methods=['POST'])
    @deps['login_required']
    def twofa_finalize():
        from flask_login import current_user
        if current_user.twofa_enabled or not _site_enabled():
            return redirect(url_for('admin_profile'))
        if request.form.get('codes_saved') != '1':
            flash(t('twofa.recovery_confirm_required'), 'error')
            return redirect(url_for('twofa_setup'))
        has_codes = _d['RecoveryCode'].query.filter_by(
            user_id=current_user.id).count() > 0
        if not _user_secret(current_user) or not has_codes:
            return redirect(url_for('twofa_setup'))
        current_user.twofa_enabled = True
        current_user.twofa_generation = \
            (current_user.twofa_generation or 0) + 1
        deps['AuditLog'].log('2fa_enabled', 'user', current_user.id,
                             deps['audit_detail']('twofa_enabled'),
                             user=current_user, ip=request.remote_addr)
        deps['db'].session.commit()
        _notify(current_user, 'twofa.email_enabled_subject',
                'twofa.email_enabled_body')
        flash(t('twofa.enabled_ok'), 'success')
        return redirect(url_for('admin_profile'))

    @app.route('/admin/profile/2fa/disable', methods=['POST'])
    @deps['login_required']
    def twofa_disable():
        from flask_login import current_user
        if not check_password_hash(current_user.password,
                                   request.form.get('current_password', '')):
            flash(t('flash.current_password_wrong'), 'error')
            return redirect(url_for('admin_profile'))
        _disable_for(current_user)
        deps['AuditLog'].log('2fa_disabled', 'user', current_user.id,
                             deps['audit_detail']('twofa_disabled'),
                             user=current_user, ip=request.remote_addr)
        deps['db'].session.commit()
        _notify(current_user, 'twofa.email_disabled_subject',
                'twofa.email_disabled_body')
        flash(t('twofa.disabled_ok'), 'success')
        return redirect(url_for('admin_profile'))

    # ── Admin reset for another user (recovery layer c) ───────────────
    @app.route('/admin/users/<int:user_id>/2fa/reset', methods=['POST'])
    @deps['admin_required']
    def twofa_admin_reset(user_id):
        from flask_login import current_user
        target = deps['User'].query.get_or_404(user_id)
        if target.id == current_user.id:
            return redirect(url_for('admin_users_list'))
        # An admin must not be able to strip the owner's second factor —
        # same direction of protection as role changes.
        if target.is_owner() and not current_user.is_owner():
            flash(t('flash.access_denied'), 'error')
            return redirect(url_for('admin_users_list'))
        _disable_for(target)
        deps['AuditLog'].log('2fa_reset_by_admin', 'user', target.id,
                             deps['audit_detail']('twofa_reset_by_admin',
                                                  name=target.username),
                             user=current_user, ip=request.remote_addr)
        deps['db'].session.commit()
        _notify(target, 'twofa.email_reset_subject',
                'twofa.email_reset_body')
        flash(t('twofa.reset_done', name=target.username), 'success')
        return redirect(url_for('admin_users_list'))
