"""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 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:"

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


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 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 verify_totp_code(secret: str, code: str, *, valid_window: int = 1) -> bool:
    code = (code or "").strip().replace(" ", "")
    if not re.fullmatch(r"\d{6}", code):
        return False
    return bool(pyotp.TOTP(secret).verify(code, valid_window=valid_window))


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
    return decrypt_token(row.totp_secret_enc)


# ---------------------------------------------------------------------------
# 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 _hash_otp(code: str) -> str:
    material = f"{code}:{getattr(mfa_settings(), 'panel_login_csrf_secret', '') or 'limristem-mfa'}"
    return hashlib.sha256(material.encode("utf-8")).hexdigest()


def issue_sms_otp(db: Session, *, phone: str, provider_code: str | None, challenge_id: str) -> dict[str, Any]:
    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)
    payload = {
        "username": username,
        "purpose": purpose,
        "issued_at": int(time()),
    }
    _cache_set(f"{ACCESS_TOKEN_PREFIX}{token}", payload, access_token_ttl())
    return {
        "access_token": token,
        "token_type": "bearer",
        "expires_in": access_token_ttl(),
        "username": username,
    }


def load_access_token(token: str) -> dict | None:
    if not token:
        return None
    data = _cache_get(f"{ACCESS_TOKEN_PREFIX}{token}")
    if not data:
        return None
    # sliding expiration
    try:
        get_cache().expire(f"{ACCESS_TOKEN_PREFIX}{token}", access_token_ttl())
    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)
    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)),
    }
