"""Verify-first registration service.

The account (``users`` row + profile) is created **only after** the emailed OTP
is confirmed, so the database never accumulates unverified users. A short-lived
:class:`~app.models.auth.TemporaryRegistration` holds the pending data; the OTP
is generated, hashed, delivered and audited by the shared verification framework
(:mod:`app.auth.otp_service`) — this module contains **no** OTP or email logic of
its own, only orchestration and the registration-specific rules (expiry,
duplicate handling, send-failure rollback).
"""

from __future__ import annotations

import hmac
from datetime import timedelta

from flask import current_app
from werkzeug.security import generate_password_hash

from app.auth import otp_service
from app.auth.services import create_user, email_exists
from app.extensions import db
from app.models import TemporaryRegistration, User, VerificationPurpose
from app.models.base import utcnow

PURPOSE = VerificationPurpose.EMAIL_VERIFICATION

# Outcome codes returned to the route (which maps them to user-facing messages).
ERR_EXISTS = "exists"                 # a verified account already owns the email
ERR_SEND_FAILED = "send_failed"       # SMTP delivery failed → registration rolled back
ERR_EXPIRED = "expired"               # registration session expired → register again
ERR_CODE_EXPIRED = "code_expired"     # OTP expired but session alive → resend
ERR_RATE_LIMITED = "rate_limited"     # resend cooldown / hourly cap
ERR_LOCKED = "locked"                 # too many wrong attempts → resend
ERR_BAD_CODE = "bad_code"             # wrong / malformed code


def _mask(email: str) -> str:
    local, _, domain = email.partition("@")
    if not domain:
        return email
    masked = local[0] + "*" if len(local) <= 2 else local[0] + "*" * (len(local) - 2) + local[-1]
    return f"{masked}@{domain}"


def _audit(event: str, *, temp: TemporaryRegistration | None = None, **extra) -> None:
    """Structured registration audit (reuses the app logger — the same audit
    infrastructure the verification framework uses; no OTP value, no email body,
    no password is ever logged)."""
    parts = []
    if temp is not None:
        parts += [f"temp_id={temp.id}", f"email={_mask(temp.email)}"]
    parts += [f"{k}={v}" for k, v in extra.items()]
    current_app.logger.info("[REGISTRATION] %s %s", event, " ".join(parts))


def _identity(temp: TemporaryRegistration) -> str:
    """Hashing identity namespace for a pre-user registration."""
    return f"reg:{temp.id}"


def _issue_code(temp: TemporaryRegistration) -> str:
    """Generate a fresh OTP for the pending registration and store only its hash.

    Reuses the framework's code generation and HMAC hashing — one algorithm."""
    code = otp_service.generate_code()
    now = utcnow()
    temp.code_hash = otp_service.hash_code(_identity(temp), PURPOSE, code)
    temp.code_expires_at = now + timedelta(minutes=current_app.config.get("OTP_EXPIRY_MINUTES", 5))
    temp.last_sent_at = now
    db.session.commit()
    return code


# --- Housekeeping --------------------------------------------------------------

def cleanup_expired() -> int:
    """Delete pending registrations whose session has expired (on-access GC)."""
    result = db.session.execute(
        db.delete(TemporaryRegistration).where(TemporaryRegistration.expires_at < utcnow())
    )
    db.session.commit()
    count = result.rowcount or 0
    if count:
        _audit("temporary_registration_expired", count=count)
    return count


def get_pending(temp_id) -> TemporaryRegistration | None:
    """Load a live pending registration by id; delete + return None if expired."""
    if not temp_id:
        return None
    temp = db.session.get(TemporaryRegistration, temp_id)
    if temp is None:
        return None
    if temp.expires_at < utcnow():
        db.session.delete(temp)
        db.session.commit()
        return None
    return temp


def seconds_until_resend(temp: TemporaryRegistration) -> int:
    cooldown = current_app.config.get("OTP_RESEND_COOLDOWN_SECONDS", 60)
    elapsed = (utcnow() - temp.last_sent_at).total_seconds()
    return max(0, int(cooldown - elapsed + 0.999))


# --- Flow steps ----------------------------------------------------------------

def start_registration(full_name: str, email: str, password: str):
    """Begin a verify-first registration. Returns ``(temp, error_code)``.

    Creates no user. On email-delivery failure the pending registration is
    rolled back (deleted) so nothing is left dangling (PART 9)."""
    cleanup_expired()
    normalized = email.strip().lower()

    if email_exists(normalized):                       # PART 11: verified account owns it
        return None, ERR_EXISTS

    # Replace any prior pending registration for this email (re-register wins).
    db.session.execute(
        db.delete(TemporaryRegistration).where(TemporaryRegistration.email == normalized)
    )

    now = utcnow()
    ttl = current_app.config.get("TEMP_REGISTRATION_TTL_MINUTES", 10)
    temp = TemporaryRegistration(
        full_name=full_name.strip(),
        email=normalized,
        password_hash=generate_password_hash(password),   # never plaintext
        code_hash="",                                      # set by _issue_code (needs id)
        code_expires_at=now,
        expires_at=now + timedelta(minutes=ttl),
        last_sent_at=now,
    )
    db.session.add(temp)
    db.session.flush()                                     # assign temp.id
    code = _issue_code(temp)
    _audit("temporary_registration_created", temp=temp)

    if not otp_service.send_verification_email(temp.email, PURPOSE, code):
        db.session.delete(temp)                            # rollback: leave nothing behind
        db.session.commit()
        _audit("email_delivery_failed", email=_mask(normalized))
        return None, ERR_SEND_FAILED

    _audit("email_sent", temp=temp)
    return temp, None


def resend_code(temp: TemporaryRegistration):
    """Resend the OTP for a pending registration. Returns ``(ok, error_code)``."""
    if temp.expires_at < utcnow():
        return False, ERR_EXPIRED
    if temp.resend_count >= current_app.config.get("OTP_MAX_PER_HOUR", 5):
        return False, ERR_RATE_LIMITED
    if seconds_until_resend(temp) > 0:
        return False, ERR_RATE_LIMITED

    code = _issue_code(temp)
    temp.resend_count += 1
    db.session.commit()
    _audit("resend_requested", temp=temp, resend_count=temp.resend_count)

    if not otp_service.send_verification_email(temp.email, PURPOSE, code):
        return False, ERR_SEND_FAILED
    _audit("email_sent", temp=temp)
    return True, None


def change_email(temp: TemporaryRegistration, new_email: str):
    """Correct the pending registration's email and resend. ``(ok, error_code)``.

    Lets the user fix a typo without re-entering their name or password (PART 4)."""
    if temp.expires_at < utcnow():
        return False, ERR_EXPIRED
    normalized = new_email.strip().lower()
    if email_exists(normalized):
        return False, ERR_EXISTS
    if normalized != temp.email:
        db.session.execute(
            db.delete(TemporaryRegistration).where(
                TemporaryRegistration.email == normalized,
                TemporaryRegistration.id != temp.id,
            )
        )
        temp.email = normalized
    temp.attempts = 0
    temp.resend_count = 0
    code = _issue_code(temp)
    _audit("email_changed", temp=temp)

    if not otp_service.send_verification_email(temp.email, PURPOSE, code):
        return False, ERR_SEND_FAILED
    _audit("email_sent", temp=temp)
    return True, None


def verify_registration(temp: TemporaryRegistration, code: str):
    """Confirm the OTP and, on success, create the verified user + profile.

    Returns ``(user, error_code)``. Deletes the pending registration on success.
    Applies purpose-scoped constant-time comparison via the shared framework —
    no duplicated OTP verification."""
    if temp.expires_at < utcnow():
        db.session.delete(temp)
        db.session.commit()
        return None, ERR_EXPIRED

    code = (code or "").strip()
    length = current_app.config.get("OTP_LENGTH", 6)
    if not code.isdigit() or len(code) != length:
        return None, ERR_BAD_CODE

    temp.attempts += 1
    db.session.commit()

    if temp.attempts > current_app.config.get("OTP_MAX_VERIFY_ATTEMPTS", 5):
        temp.code_expires_at = utcnow()                    # disable the current code
        db.session.commit()
        _audit("verification_locked", temp=temp, attempts=temp.attempts)
        return None, ERR_LOCKED

    if temp.code_expires_at < utcnow():
        _audit("code_expired", temp=temp)
        return None, ERR_CODE_EXPIRED

    expected = otp_service.hash_code(_identity(temp), PURPOSE, code)
    if not hmac.compare_digest(temp.code_hash, expected):
        _audit("verification_failed", temp=temp)
        return None, ERR_BAD_CODE

    # Success — race-safe: re-check the email isn't taken, then create the user.
    if email_exists(temp.email):
        db.session.delete(temp)
        db.session.commit()
        return None, ERR_EXISTS

    _audit("otp_verified", temp=temp)
    temp_id, full_name, temp_email, password_hash = (
        temp.id, temp.full_name, temp.email, temp.password_hash)
    user = create_user(full_name, temp_email, password_hash, is_verified=True)
    db.session.delete(temp)
    db.session.commit()
    _audit("user_created", email=_mask(temp_email), user_id=user.id)
    _audit("temporary_registration_deleted", temp_id=temp_id)
    return user, None
