# @File: backend/services/mfa.py
# @Desc: Business logic for SMS-based multi-factor authentication.
#
# Responsibilities:
#   * Enrollment: save pending phone + hashed code, send SMS, verify code.
#   * Login challenge: issue a short-lived "mfa_token" JWT that carries
#     only the user's id + a purpose claim; trading it + a correct code
#     for the real session JWT happens in `routers/mfa.py`.
#   * Admin-enforcement toggle stored in the existing encrypted API Hub
#     credential table under the synthetic provider id "security".
#
# All OTP codes are stored ONLY as SHA-256 hashes; the plaintext is sent
# by SMS and never persisted.
from __future__ import annotations

import hashlib
import hmac
import logging
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple

from core.auth import AccessTokenError, create_access_token, decode_access_token
from models.auth import User
from models.mfa import UserMFA
from services.api_hub_credentials import resolve_credential, set_credential
from services.sms_sender import send_otp_sms
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

logger = logging.getLogger(__name__)

# Code and token lifetimes are intentionally short to minimise the window
# of opportunity for interception.
CODE_TTL_MINUTES = 10
MFA_CHALLENGE_TTL_MINUTES = 10
MAX_PENDING_ATTEMPTS = 5
CHALLENGE_RESEND_COOLDOWN_SECONDS = 45

MFA_TOKEN_PURPOSE = "mfa_challenge"

# Global admin-enforcement flag is stored encrypted alongside API keys.
SECURITY_PROVIDER_ID = "security"
REQUIRE_ADMIN_MFA_KEY = "REQUIRE_ADMIN_MFA"


# ---------------- helpers ----------------
def _normalize_phone(phone: str) -> str:
    """Normalise a phone number to strict E.164 with leading '+'."""
    if not phone:
        raise ValueError("Phone number is required.")
    trimmed = phone.strip().replace(" ", "").replace("-", "")
    if not trimmed.startswith("+"):
        # Allow "00" international prefix.
        if trimmed.startswith("00"):
            trimmed = "+" + trimmed[2:]
        else:
            trimmed = "+" + trimmed
    digits = trimmed[1:]
    if not digits.isdigit() or not (6 <= len(digits) <= 15):
        raise ValueError("Phone number must be in international E.164 format, e.g. +966501234567.")
    return trimmed


def _mask_phone(phone: Optional[str]) -> Optional[str]:
    if not phone:
        return None
    if len(phone) <= 4:
        return "•" * len(phone)
    return f"{phone[:3]}•••{phone[-2:]}"


def _hash_code(code: str) -> str:
    return hashlib.sha256(code.encode("utf-8")).hexdigest()


def _generate_code() -> str:
    """Return a zero-padded 6-digit numeric OTP."""
    return f"{secrets.randbelow(1_000_000):06d}"


def _now() -> datetime:
    return datetime.now(timezone.utc)


# ---------------- JWT helpers for the challenge token ----------------
def issue_mfa_challenge_token(user_id: str) -> str:
    """Issue a short-lived JWT used only for the MFA challenge step.

    Reuses the project's `create_access_token` so signing key/algorithm stay
    centralised, but adds a `purpose` claim so the token can never be used
    as a regular session JWT.
    """
    return create_access_token(
        {"sub": user_id, "purpose": MFA_TOKEN_PURPOSE},
        expires_minutes=MFA_CHALLENGE_TTL_MINUTES,
    )


def decode_mfa_challenge_token(token: str) -> str:
    """Decode the challenge token and return the user_id. Raises ValueError."""
    try:
        data = decode_access_token(token)
    except AccessTokenError as exc:
        raise ValueError(f"Invalid MFA token: {exc}") from exc
    if data.get("purpose") != MFA_TOKEN_PURPOSE:
        raise ValueError("Token purpose mismatch.")
    sub = data.get("sub")
    if not sub:
        raise ValueError("Token missing subject.")
    return str(sub)


# ---------------- DB access ----------------
async def get_mfa(db: AsyncSession, user_id: str) -> Optional[UserMFA]:
    result = await db.execute(select(UserMFA).where(UserMFA.user_id == user_id))
    return result.scalar_one_or_none()


async def get_or_create_mfa(db: AsyncSession, user_id: str) -> UserMFA:
    row = await get_mfa(db, user_id)
    if row is not None:
        return row
    row = UserMFA(user_id=user_id, enabled=False, method="sms")
    db.add(row)
    await db.flush()
    return row


# ---------------- Admin-enforcement flag ----------------
async def get_require_admin_mfa() -> bool:
    val = await resolve_credential(SECURITY_PROVIDER_ID, REQUIRE_ADMIN_MFA_KEY)
    return bool(val and val.strip().lower() in {"1", "true", "yes", "on"})


async def set_require_admin_mfa(value: bool, updated_by: Optional[str] = None) -> None:
    await set_credential(
        SECURITY_PROVIDER_ID,
        REQUIRE_ADMIN_MFA_KEY,
        "true" if value else "",
        updated_by=updated_by,
    )


# ---------------- Enrollment ----------------
async def start_enrollment(db: AsyncSession, user: User, phone_e164: str) -> Tuple[UserMFA, str]:
    """Persist a pending enrollment and send an SMS code.

    Returns the updated row plus the raw code (for tests only — never
    logged in production).
    """
    normalized = _normalize_phone(phone_e164)
    row = await get_or_create_mfa(db, user.id)
    code = _generate_code()
    row.pending_phone = normalized
    row.pending_code_hash = _hash_code(code)
    row.pending_expires_at = _now() + timedelta(minutes=CODE_TTL_MINUTES)
    row.pending_attempts = 0
    await db.commit()
    await db.refresh(row)

    result = await send_otp_sms(normalized, code, purpose="enroll")
    if not result.ok:
        logger.warning(
            "[mfa] Enrollment SMS failed for user=%s provider=%s: %s",
            user.id,
            result.provider,
            result.message,
        )
        raise RuntimeError(f"Failed to send verification SMS: {result.message}")
    return row, code


async def verify_enrollment(db: AsyncSession, user: User, code: str) -> UserMFA:
    """Confirm the pending phone + code and enable MFA on success."""
    row = await get_mfa(db, user.id)
    if row is None or not row.pending_phone or not row.pending_code_hash:
        raise ValueError("No pending enrollment. Start setup again.")
    expires_at = row.pending_expires_at
    if expires_at is not None and expires_at.tzinfo is None:
        expires_at = expires_at.replace(tzinfo=timezone.utc)
    if expires_at is None or expires_at < _now():
        raise ValueError("Verification code has expired. Please request a new code.")
    if row.pending_attempts >= MAX_PENDING_ATTEMPTS:
        raise ValueError("Too many incorrect attempts. Restart the setup.")
    if not hmac.compare_digest(_hash_code(code.strip()), row.pending_code_hash):
        row.pending_attempts += 1
        await db.commit()
        raise ValueError("Incorrect verification code.")

    row.phone_e164 = row.pending_phone
    row.enabled = True
    row.method = "sms"
    row.pending_phone = None
    row.pending_code_hash = None
    row.pending_expires_at = None
    row.pending_attempts = 0
    row.last_verified_at = _now()
    await db.commit()
    await db.refresh(row)
    logger.info("[mfa] User %s enrolled SMS MFA (phone=%s).", user.id, _mask_phone(row.phone_e164))
    return row


async def disable_mfa(db: AsyncSession, user: User) -> UserMFA:
    """Disable MFA for a user. Preserves the row so audits / re-enroll are easy."""
    row = await get_or_create_mfa(db, user.id)
    row.enabled = False
    row.phone_e164 = None
    row.pending_phone = None
    row.pending_code_hash = None
    row.pending_expires_at = None
    row.pending_attempts = 0
    await db.commit()
    await db.refresh(row)
    logger.info("[mfa] User %s disabled MFA.", user.id)
    return row


# ---------------- Login challenge ----------------
async def send_login_challenge(db: AsyncSession, user: User) -> UserMFA:
    """Send a fresh OTP for a login challenge. Enforces a simple cooldown."""
    row = await get_mfa(db, user.id)
    if row is None or not row.enabled or not row.phone_e164:
        raise ValueError("MFA is not configured for this account.")

    last_sent = row.last_challenge_sent_at
    if last_sent is not None and last_sent.tzinfo is None:
        last_sent = last_sent.replace(tzinfo=timezone.utc)
    if last_sent is not None and (_now() - last_sent).total_seconds() < CHALLENGE_RESEND_COOLDOWN_SECONDS:
        remaining = CHALLENGE_RESEND_COOLDOWN_SECONDS - int((_now() - last_sent).total_seconds())
        raise ValueError(f"Please wait {remaining}s before requesting a new code.")

    code = _generate_code()
    row.pending_code_hash = _hash_code(code)
    row.pending_expires_at = _now() + timedelta(minutes=CODE_TTL_MINUTES)
    row.pending_attempts = 0
    row.last_challenge_sent_at = _now()
    await db.commit()
    await db.refresh(row)

    result = await send_otp_sms(row.phone_e164, code, purpose="login")
    if not result.ok:
        logger.warning(
            "[mfa] Login challenge SMS failed for user=%s provider=%s: %s",
            user.id,
            result.provider,
            result.message,
        )
        raise RuntimeError(f"Failed to send verification SMS: {result.message}")
    return row


async def verify_login_challenge(db: AsyncSession, user_id: str, code: str) -> UserMFA:
    """Verify the OTP submitted during the login challenge step."""
    row = await get_mfa(db, user_id)
    if row is None or not row.enabled:
        raise ValueError("MFA is not configured for this account.")
    if not row.pending_code_hash or not row.pending_expires_at:
        raise ValueError("No active challenge. Request a new code.")
    expires_at = row.pending_expires_at
    if expires_at.tzinfo is None:
        expires_at = expires_at.replace(tzinfo=timezone.utc)
    if expires_at < _now():
        raise ValueError("Verification code has expired. Please request a new code.")
    if row.pending_attempts >= MAX_PENDING_ATTEMPTS:
        raise ValueError("Too many incorrect attempts. Request a new code.")
    if not hmac.compare_digest(_hash_code(code.strip()), row.pending_code_hash):
        row.pending_attempts += 1
        await db.commit()
        raise ValueError("Incorrect verification code.")

    row.pending_code_hash = None
    row.pending_expires_at = None
    row.pending_attempts = 0
    row.last_verified_at = _now()
    await db.commit()
    await db.refresh(row)
    return row


async def should_require_mfa(user: User, mfa_row: Optional[UserMFA]) -> bool:
    """Decide whether a user must complete MFA to finalise login."""
    # Per-user MFA always wins when enabled.
    if mfa_row and mfa_row.enabled:
        return True
    # Global admin-enforcement: applies to any user flagged as admin.
    if (getattr(user, "role", "") or "").lower() == "admin" and await get_require_admin_mfa():
        return True
    return False