"""Admin and mailbox multi-factor authentication (TOTP + SMS OTP).

Panel/API:
  Password → optional MFA challenge (TOTP and/or SMS OTP) → session / access token.

Mail protocols (SMTP/IMAP/POP):
  Interactive mid-protocol OTP is not supported by standard clients.
  When mailbox MFA is enabled, prefer app passwords (argon2id) accepted by Dovecot.
  Optional flag require_app_password disables the primary password for mail auth.
"""

from __future__ import annotations

import hashlib
import hmac
import json
import logging
import re
import secrets
from time import time
from typing import Any, Iterable

import pyotp
from fastapi import HTTPException, status
from redis.exceptions import RedisError
from sqlalchemy.orm import Session

from .cache import get_cache
from .crypto import CryptoUnavailableError, decrypt_token, encrypt_token
from .settings import get_settings
from .sms_providers import SmsProviderError, resolve_provider_config, send_sms
from .utils import hash_password

logger = logging.getLogger(__name__)

CHALLENGE_PREFIX = "mfa-challenge:"
ACCESS_TOKEN_PREFIX = "api-access:"
PANEL_PENDING_PREFIX = "panel-mfa-pending:"
SMS_OTP_PREFIX = "mfa-sms-otp:"
TOTP_ENROLL_PREFIX = "mfa-totp-enroll:"
TOTP_USED_PREFIX = "mfa-totp-used:"
SMS_SEND_STATE_PREFIX = "mfa-sms-send:"

PHONE_RE = re.compile(r"^\+?[1-9]\d{6,14}$")

# A TOTP code stays valid for the whole step (plus the drift window), so it must be
# burned after first use — otherwise a code that leaks (phishing, shoulder surfing,
# a proxy) can be replayed for up to ~90 seconds.
TOTP_REPLAY_SCOPE_ADMIN = "admin"

# SMS costs money and reaches a real phone: throttle resends per login attempt.
SMS_OTP_MIN_INTERVAL_SECONDS = 60
SMS_OTP_MAX_SENDS_PER_CHALLENGE = 3

# Every mail login verifies the candidate against each stored app-password hash,
# and argon2id is deliberately expensive. Cap how many hashes a single attempt can
# force the server to compute.
MAX_APP_PASSWORDS_PER_ACCOUNT = 20
MAX_APP_PASSWORD_VERIFY_CANDIDATES = 20

# Used only when no stable admin secret exists yet (fresh install, pre-enrolment).
# Per-process, so pending OTPs do not survive a restart — acceptable for a 5 min code.
_OTP_FALLBACK_SECRET = secrets.token_hex(32)


def mfa_settings() -> Any:
    return get_settings()


def challenge_ttl() -> int:
    return int(getattr(mfa_settings(), "mfa_challenge_ttl_seconds", 300) or 300)


def access_token_ttl() -> int:
    return int(getattr(mfa_settings(), "mfa_access_token_ttl_seconds", 28800) or 28800)


def access_token_max_lifetime() -> int:
    return int(getattr(mfa_settings(), "mfa_access_token_max_lifetime_seconds", 86400) or 86400)


def sms_otp_ttl() -> int:
    return int(getattr(mfa_settings(), "mfa_sms_otp_ttl_seconds", 300) or 300)


def sms_otp_length() -> int:
    return int(getattr(mfa_settings(), "mfa_sms_otp_length", 6) or 6)


def totp_issuer() -> str:
    settings = mfa_settings()
    return getattr(settings, "mfa_totp_issuer", None) or settings.server_name or "Limristem eMail"


# ---------------------------------------------------------------------------
# Persistence helpers
# ---------------------------------------------------------------------------


def get_or_create_admin_mfa(db: Session):
    from . import models

    row = db.query(models.MfaAdmin).filter(models.MfaAdmin.id == 1).first()
    if row:
        return row
    row = models.MfaAdmin(id=1)
    db.add(row)
    try:
        db.commit()
    except Exception:
        db.rollback()
        row = db.query(models.MfaAdmin).filter(models.MfaAdmin.id == 1).first()
        if row:
            return row
        raise
    db.refresh(row)
    return row


def admin_mfa_status(db: Session) -> dict[str, Any]:
    row = get_or_create_admin_mfa(db)
    methods: list[str] = []
    if row.totp_enabled and row.totp_secret_enc and row.totp_confirmed_at:
        methods.append("totp")
    if row.sms_enabled and row.sms_phone:
        methods.append("sms")
    return {
        "mfa_enabled": bool(methods),
        "methods": methods,
        "totp_enabled": bool(row.totp_enabled and row.totp_confirmed_at),
        "totp_pending": bool(row.totp_secret_enc and not row.totp_confirmed_at),
        "sms_enabled": bool(row.sms_enabled and row.sms_phone),
        "sms_phone_masked": mask_phone(row.sms_phone) if row.sms_phone else None,
        "sms_provider": row.sms_provider or "crisaleo",
    }


def mask_phone(phone: str | None) -> str | None:
    if not phone:
        return None
    digits = re.sub(r"\D", "", phone)
    if len(digits) <= 4:
        return "***"
    return f"+{'*' * max(0, len(digits) - 4)}{digits[-4:]}"


def normalize_phone(phone: str) -> str:
    value = (phone or "").strip().replace(" ", "").replace("-", "")
    if value.startswith("00"):
        value = "+" + value[2:]
    if not PHONE_RE.fullmatch(value):
        raise HTTPException(status_code=400, detail="Invalid phone number (use E.164, e.g. +393331234567)")
    return value


# ---------------------------------------------------------------------------
# TOTP
# ---------------------------------------------------------------------------


def generate_totp_secret() -> str:
    return pyotp.random_base32()


def totp_provisioning_uri(secret: str, account_name: str) -> str:
    return pyotp.TOTP(secret).provisioning_uri(name=account_name, issuer_name=totp_issuer())


def _claim_totp_counter(scope: str, counter: int, ttl: int) -> bool:
    """Burn a TOTP counter. Returns False when it was already used (replay)."""
    key = f"{TOTP_USED_PREFIX}{scope}:{counter}"
    try:
        return bool(get_cache().set(key, "1", ex=max(ttl, 30), nx=True))
    except RedisError as exc:
        # Fail closed: without the replay store freshness cannot be proven.
        raise HTTPException(status_code=503, detail="Challenge storage unavailable") from exc


def verify_totp_code(
    secret: str,
    code: str,
    *,
    valid_window: int = 1,
    replay_scope: str | None = None,
) -> bool:
    """Verify a TOTP code and, when replay_scope is given, allow it exactly once."""
    code = (code or "").strip().replace(" ", "")
    if not re.fullmatch(r"\d{6}", code):
        return False
    totp = pyotp.TOTP(secret)
    interval = int(getattr(totp, "interval", 30) or 30)
    current_counter = int(time()) // interval
    for offset in range(-valid_window, valid_window + 1):
        counter = current_counter + offset
        if not hmac.compare_digest(totp.generate_otp(counter), code):
            continue
        if replay_scope is not None:
            # Keep the marker alive past the drift window so it cannot be re-presented.
            ttl = interval * (2 * valid_window + 2)
            if not _claim_totp_counter(replay_scope, counter, ttl):
                logger.warning("Rejected replayed TOTP code for scope %s", replay_scope)
                return False
        return True
    return False


def store_totp_secret(row, secret: str) -> None:
    row.totp_secret_enc = encrypt_token(secret)


def load_totp_secret(row) -> str | None:
    if not row or not row.totp_secret_enc:
        return None
    try:
        return decrypt_token(row.totp_secret_enc)
    except CryptoUnavailableError:
        logger.error("Unable to decrypt TOTP secret; re-enroll TOTP")
        return None


# ---------------------------------------------------------------------------
# Redis helpers
# ---------------------------------------------------------------------------


def _cache_set(key: str, payload: dict, ttl: int) -> None:
    try:
        get_cache().set(key, json.dumps(payload), ex=ttl)
    except RedisError as exc:
        raise HTTPException(status_code=503, detail="Challenge storage unavailable") from exc


def _cache_get(key: str) -> dict | None:
    try:
        raw = get_cache().get(key)
    except RedisError as exc:
        raise HTTPException(status_code=503, detail="Challenge storage unavailable") from exc
    if not raw:
        return None
    try:
        data = json.loads(raw)
    except (TypeError, json.JSONDecodeError):
        return None
    return data if isinstance(data, dict) else None


def _cache_delete(key: str) -> None:
    try:
        get_cache().delete(key)
    except RedisError:
        pass


# ---------------------------------------------------------------------------
# Challenges (after password verified)
# ---------------------------------------------------------------------------


def create_challenge(
    *,
    subject: str,
    methods: Iterable[str],
    purpose: str = "admin",
    extra: dict | None = None,
) -> dict[str, Any]:
    methods_list = [m for m in methods if m in {"totp", "sms"}]
    if not methods_list:
        raise HTTPException(status_code=400, detail="No MFA methods available")
    challenge_id = secrets.token_urlsafe(24)
    payload = {
        "subject": subject,
        "methods": methods_list,
        "purpose": purpose,
        "created_at": int(time()),
        "sms_sent": False,
        "attempts": 0,
        **(extra or {}),
    }
    _cache_set(f"{CHALLENGE_PREFIX}{challenge_id}", payload, challenge_ttl())
    return {
        "challenge_id": challenge_id,
        "mfa_required": True,
        "methods": methods_list,
        "expires_in": challenge_ttl(),
    }


def load_challenge(challenge_id: str) -> dict:
    data = _cache_get(f"{CHALLENGE_PREFIX}{challenge_id}")
    if not data:
        raise HTTPException(status_code=401, detail="Invalid or expired MFA challenge")
    return data


def bump_challenge_attempts(challenge_id: str, data: dict, *, max_attempts: int = 8) -> None:
    data["attempts"] = int(data.get("attempts") or 0) + 1
    if data["attempts"] >= max_attempts:
        _cache_delete(f"{CHALLENGE_PREFIX}{challenge_id}")
        raise HTTPException(status_code=401, detail="Too many MFA attempts")
    remaining = challenge_ttl()
    created = int(data.get("created_at") or time())
    remaining = max(30, challenge_ttl() - (int(time()) - created))
    _cache_set(f"{CHALLENGE_PREFIX}{challenge_id}", data, remaining)


def consume_challenge(challenge_id: str) -> dict:
    data = load_challenge(challenge_id)
    _cache_delete(f"{CHALLENGE_PREFIX}{challenge_id}")
    return data


# ---------------------------------------------------------------------------
# SMS OTP codes
# ---------------------------------------------------------------------------


def _otp_hash_secret() -> str:
    """Keyed material for OTP hashes. Never falls back to a constant.

    With a hardcoded key, anyone able to read Redis could brute-force a 6-digit
    code's hash offline in microseconds.
    """
    settings = mfa_settings()
    return (
        getattr(settings, "panel_login_csrf_secret", None)
        or getattr(settings, "api_admin_pass_hash", None)
        or getattr(settings, "panel_admin_pass_hash", None)
        or _OTP_FALLBACK_SECRET
    )


def _hash_otp(code: str) -> str:
    return hmac.new(_otp_hash_secret().encode("utf-8"), code.encode("utf-8"), hashlib.sha256).hexdigest()


def _enforce_sms_send_quota(challenge_id: str) -> None:
    """Rate limit SMS OTP delivery per login attempt (cost and SMS-bombing control)."""
    key = f"{SMS_SEND_STATE_PREFIX}{challenge_id}"
    state = _cache_get(key) or {}
    now = int(time())
    last_sent = int(state.get("last_sent") or 0)
    sends = int(state.get("sends") or 0)
    if last_sent and now - last_sent < SMS_OTP_MIN_INTERVAL_SECONDS:
        raise HTTPException(
            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
            detail=f"Wait {SMS_OTP_MIN_INTERVAL_SECONDS - (now - last_sent)}s before requesting another SMS code",
        )
    if sends >= SMS_OTP_MAX_SENDS_PER_CHALLENGE:
        raise HTTPException(
            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
            detail="SMS code limit reached for this login attempt; start a new sign-in",
        )
    # Counted before delivery so a failing provider cannot be hammered either.
    _cache_set(key, {"last_sent": now, "sends": sends + 1}, challenge_ttl())


def issue_sms_otp(db: Session, *, phone: str, provider_code: str | None, challenge_id: str) -> dict[str, Any]:
    _enforce_sms_send_quota(challenge_id)
    length = max(6, min(8, sms_otp_length()))
    # numeric OTP without leading-zero ambiguity issues for length 6-8
    code = "".join(secrets.choice("0123456789") for _ in range(length))
    if code[0] == "0":
        code = "1" + code[1:]

    message = f"{totp_issuer()} code: {code} (valid {sms_otp_ttl() // 60 or 1} min)"
    config = resolve_provider_config(db, provider_code)
    try:
        result = send_sms(config, phone, message)
    except SmsProviderError as exc:
        logger.warning("Failed to send MFA SMS via %s: %s", config.code, exc)
        raise HTTPException(status_code=502, detail=f"Unable to send SMS OTP: {exc}") from exc

    otp_payload = {
        "code_hash": _hash_otp(code),
        "phone": phone,
        "provider": config.code,
        "created_at": int(time()),
        "attempts": 0,
    }
    _cache_set(f"{SMS_OTP_PREFIX}{challenge_id}", otp_payload, sms_otp_ttl())

    # Mark challenge as SMS sent
    challenge = _cache_get(f"{CHALLENGE_PREFIX}{challenge_id}")
    if challenge:
        challenge["sms_sent"] = True
        created = int(challenge.get("created_at") or time())
        remaining = max(30, challenge_ttl() - (int(time()) - created))
        _cache_set(f"{CHALLENGE_PREFIX}{challenge_id}", challenge, remaining)

    return {
        "sent": True,
        "provider": config.code,
        "phone_masked": mask_phone(phone),
        "expires_in": sms_otp_ttl(),
        "provider_message_id": (result.get("id") if isinstance(result, dict) else None),
    }


def verify_sms_otp(challenge_id: str, code: str) -> bool:
    data = _cache_get(f"{SMS_OTP_PREFIX}{challenge_id}")
    if not data:
        return False
    data["attempts"] = int(data.get("attempts") or 0) + 1
    if data["attempts"] > 8:
        _cache_delete(f"{SMS_OTP_PREFIX}{challenge_id}")
        return False
    code = (code or "").strip().replace(" ", "")
    ok = hmac.compare_digest(data.get("code_hash") or "", _hash_otp(code))
    if ok:
        _cache_delete(f"{SMS_OTP_PREFIX}{challenge_id}")
        return True
    created = int(data.get("created_at") or time())
    remaining = max(10, sms_otp_ttl() - (int(time()) - created))
    _cache_set(f"{SMS_OTP_PREFIX}{challenge_id}", data, remaining)
    return False


# ---------------------------------------------------------------------------
# Access tokens (API Bearer after MFA)
# ---------------------------------------------------------------------------


def issue_access_token(username: str, *, purpose: str = "admin") -> dict[str, Any]:
    token = secrets.token_urlsafe(32)
    issued_at = int(time())
    expires_at = issued_at + access_token_max_lifetime()
    payload = {
        "username": username,
        "purpose": purpose,
        "issued_at": issued_at,
        "expires_at": expires_at,
    }
    initial_ttl = min(access_token_ttl(), access_token_max_lifetime())
    _cache_set(f"{ACCESS_TOKEN_PREFIX}{token}", payload, initial_ttl)
    return {
        "access_token": token,
        "token_type": "bearer",
        "expires_in": initial_ttl,
        "expires_at": expires_at,
        "username": username,
    }


def load_access_token(token: str) -> dict | None:
    if not token:
        return None
    key = f"{ACCESS_TOKEN_PREFIX}{token}"
    data = _cache_get(key)
    if not data:
        return None
    now = int(time())
    # Absolute lifetime: the idle TTL below slides on every request, so without this
    # a token that keeps being used would never expire.
    expires_at = int(data.get("expires_at") or 0)
    if expires_at and now >= expires_at:
        _cache_delete(key)
        return None
    # Sliding idle expiration, never beyond the absolute deadline.
    remaining = access_token_ttl()
    if expires_at:
        remaining = min(remaining, max(1, expires_at - now))
    try:
        get_cache().expire(key, remaining)
    except RedisError:
        pass
    return data


def revoke_access_token(token: str) -> None:
    _cache_delete(f"{ACCESS_TOKEN_PREFIX}{token}")


# ---------------------------------------------------------------------------
# Panel pending MFA cookie session
# ---------------------------------------------------------------------------


def issue_panel_pending(username: str, challenge_id: str, methods: list[str]) -> str:
    pending_id = secrets.token_urlsafe(24)
    payload = {
        "username": username,
        "challenge_id": challenge_id,
        "methods": methods,
        "created_at": int(time()),
    }
    _cache_set(f"{PANEL_PENDING_PREFIX}{pending_id}", payload, challenge_ttl())
    return pending_id


def load_panel_pending(pending_id: str) -> dict | None:
    return _cache_get(f"{PANEL_PENDING_PREFIX}{pending_id}") if pending_id else None


def consume_panel_pending(pending_id: str) -> dict | None:
    data = load_panel_pending(pending_id)
    if data:
        _cache_delete(f"{PANEL_PENDING_PREFIX}{pending_id}")
    return data


# ---------------------------------------------------------------------------
# Verification entry points
# ---------------------------------------------------------------------------


def verify_admin_mfa_code(db: Session, challenge_id: str, method: str, code: str) -> dict:
    data = load_challenge(challenge_id)
    methods = data.get("methods") or []
    method = (method or "").lower().strip()
    if method not in methods:
        raise HTTPException(status_code=400, detail=f"Method {method} not allowed for this challenge")

    row = get_or_create_admin_mfa(db)
    ok = False
    if method == "totp":
        secret = load_totp_secret(row)
        if not secret or not row.totp_enabled or not row.totp_confirmed_at:
            raise HTTPException(status_code=400, detail="TOTP is not enabled")
        ok = verify_totp_code(secret, code, replay_scope=TOTP_REPLAY_SCOPE_ADMIN)
    elif method == "sms":
        ok = verify_sms_otp(challenge_id, code)
    else:
        raise HTTPException(status_code=400, detail="Unsupported MFA method")

    if not ok:
        bump_challenge_attempts(challenge_id, data)
        raise HTTPException(status_code=401, detail="Invalid MFA code")

    return consume_challenge(challenge_id)


def ensure_admin_mfa_or_issue_challenge(db: Session, username: str) -> dict | None:
    """Return None if MFA not required, else a challenge payload."""
    status_info = admin_mfa_status(db)
    if not status_info["mfa_enabled"]:
        return None
    return create_challenge(subject=username, methods=status_info["methods"], purpose="admin")


# ---------------------------------------------------------------------------
# App passwords for mail clients
# ---------------------------------------------------------------------------


def generate_app_password_plaintext() -> str:
    # 16 chars groups of 4 — easy to type, high entropy
    raw = secrets.token_hex(8)  # 16 hex chars
    return f"{raw[0:4]}-{raw[4:8]}-{raw[8:12]}-{raw[12:16]}"


def hash_app_password(password: str) -> str:
    return hash_password(password)


def mailbox_mfa_fields(account) -> dict[str, Any]:
    return {
        "require_app_password": bool(getattr(account, "require_app_password", False)),
        "mfa_hint": bool(getattr(account, "mfa_hint", False)),
    }
