"""One-time codes that confirm what's typed into a form's email or phone
field (FormFieldDef.require_verification) — for a signed-in person and a
public, signed-out visitor alike:

  1. send_code: a 6-digit code goes to the address (the workspace's first
     enabled email_smtp / sms Connector, as account verification uses —
     app/crud/user_verification.py). At most one code per address every
     RESEND_COOLDOWN, and MAX_SENDS an hour.
  2. confirm_code: the right code, within CODE_TTL and MAX_ATTEMPTS tries,
     gives back a token — a JWT naming the workspace, form, field and the
     address's hash, good for TOKEN_TTL.
  3. check_tokens: on submit, each verified field whose value is new must
     come with a token for exactly that value.

The code is stored as an HMAC keyed by the JWT secret and the address as a
SHA-256 — neither is kept in plain text."""

import asyncio
import hashlib
import hmac
import secrets
import smtplib
import uuid
from datetime import datetime, timedelta, timezone

import httpx
from fastapi import HTTPException
from jose import JWTError, jwt
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.config import settings
from app.core.form_validation import field_errors_detail
from app.crud.connectors import first_enabled_connector
from app.crud.form_integrity import normalize_phone
from app.crud.secrets import connector_config
from app.crud.tasks import _send_email_sync, send_sms
from app.models.form_verification import FormVerification

CODE_TTL = timedelta(minutes=10)
TOKEN_TTL = timedelta(hours=2)
RESEND_COOLDOWN = timedelta(seconds=30)
MAX_SENDS = 5  # per address and form, per hour
MAX_ATTEMPTS = 5
_PURPOSE = "form_verify"


class VerificationError(Exception):
    def __init__(self, message: str, status_code: int = 400):
        super().__init__(message)
        self.status_code = status_code


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


def _hash_code(code: str) -> str:
    return hmac.new(settings.jwt_secret.encode(), f"form:{code}".encode(), hashlib.sha256).hexdigest()


def target_hash(target: str) -> str:
    return hashlib.sha256(target.encode()).hexdigest()


def verified_field(field_defs: list[dict], name: str) -> dict:
    field = next((f for f in field_defs if f.get("name") == name), None)
    if field is None or not field.get("require_verification") or field.get("type") not in ("email", "phone"):
        raise VerificationError("This field doesn't need a code.", 404)
    return field


def normalized_target(field: dict, value: object) -> str | None:
    """The address as the server stores it — an email in lower case, a phone
    as "+<code> <digits>" — or None when it isn't a valid one."""
    if not isinstance(value, str) or not value.strip():
        return None
    if field.get("type") == "email":
        email = value.strip().lower()
        local, _, domain = email.partition("@")
        return email if local and "." in domain and " " not in email else None
    error, phone = normalize_phone(field, value)
    return phone if not error and phone else None


def _mask(channel: str, target: str) -> str:
    if channel == "email":
        local, _, domain = target.partition("@")
        return f"{local[:1]}•••@{domain}"
    digits = "".join(c for c in target if c.isdigit())
    return f"•••• {digits[-4:]}"


async def send_code(db: AsyncSession, tenant_id: uuid.UUID, tenant_name: str, form_id: uuid.UUID, form_name: str, field: dict, value: object) -> tuple[uuid.UUID, str]:
    """(verification id, where it went, masked). Raises VerificationError."""
    target = normalized_target(field, value)
    if target is None:
        raise VerificationError("Enter a valid email address first." if field["type"] == "email" else "Enter a valid phone number first.", 422)
    channel = "email" if field["type"] == "email" else "sms"
    connector = await first_enabled_connector(db, tenant_id, "email_smtp" if channel == "email" else "sms")
    if connector is None:
        raise VerificationError(
            f"{'Email' if channel == 'email' else 'SMS'} codes can't be sent yet — the workspace needs an enabled {'Email (SMTP)' if channel == 'email' else 'SMS'} connector.",
            503,
        )
    th = target_hash(target)
    recent = (
        await db.execute(
            select(func.count(), func.max(FormVerification.created_at)).where(
                FormVerification.tenant_id == tenant_id,
                FormVerification.form_id == form_id,
                FormVerification.target_hash == th,
                FormVerification.created_at > _now() - timedelta(hours=1),
            )
        )
    ).one()
    if recent[1] is not None and _now() - recent[1] < RESEND_COOLDOWN:
        wait = int((RESEND_COOLDOWN - (_now() - recent[1])).total_seconds()) + 1
        raise VerificationError(f"A code was just sent — wait {wait}s before asking for another.", 429)
    if recent[0] >= MAX_SENDS:
        raise VerificationError("Too many codes sent to this address — try again in an hour.", 429)

    code = str(secrets.randbelow(10**6)).zfill(6)
    row = FormVerification(
        tenant_id=tenant_id, form_id=form_id, field_name=field["name"], channel=channel, target_hash=th, code_hash=_hash_code(code), expires_at=_now() + CODE_TTL
    )
    db.add(row)
    await db.flush()
    minutes = int(CODE_TTL.total_seconds() // 60)
    config = await connector_config(db, tenant_id, connector)
    try:
        if channel == "email":
            body = f"Your code for \"{form_name}\" on {tenant_name} is:\n\n    {code}\n\nIt expires in {minutes} minutes. If you didn't ask for it, you can ignore this email."
            await asyncio.to_thread(_send_email_sync, config, target, f"Your {tenant_name} verification code", body)
        else:
            status = await send_sms(config, target.replace(" ", ""), f"{code} is your {tenant_name} verification code. It expires in {minutes} minutes.")
            if status >= 400:
                raise VerificationError(f"The SMS gateway refused the message (HTTP {status}) — try again later.", 503)
    except (smtplib.SMTPException, OSError, ValueError, httpx.HTTPError) as exc:
        raise VerificationError("The code couldn't be sent right now — try again later.", 503) from exc
    return row.id, _mask(channel, target)


def _token(tenant_id: uuid.UUID, form_id: uuid.UUID, field: str, th: str) -> str:
    claims = {"purpose": _PURPOSE, "tid": str(tenant_id), "fid": str(form_id), "f": field, "th": th, "exp": _now() + TOKEN_TTL}
    return jwt.encode(claims, settings.jwt_secret, algorithm=settings.jwt_algorithm)


async def confirm_code(db: AsyncSession, tenant_id: uuid.UUID, form_id: uuid.UUID, verification_id: uuid.UUID, code: str) -> str:
    """The token for a right code. A wrong one counts toward MAX_ATTEMPTS —
    the caller must commit even when this raises, so the count sticks."""
    row = await db.get(FormVerification, verification_id)
    if row is None or row.tenant_id != tenant_id or row.form_id != form_id:
        raise VerificationError("Ask for a new code.", 404)
    if row.verified_at is not None:
        raise VerificationError("This code was already used — ask for a new one.", 409)
    if row.expires_at < _now():
        raise VerificationError("This code has expired — ask for a new one.")
    if row.attempts >= MAX_ATTEMPTS:
        raise VerificationError("Too many wrong codes — ask for a new one.", 429)
    if not hmac.compare_digest(_hash_code((code or "").strip()), row.code_hash):
        row.attempts += 1
        left = MAX_ATTEMPTS - row.attempts
        raise VerificationError(f"That code isn't right — {left} attempt{'s' if left != 1 else ''} left." if left else "Too many wrong codes — ask for a new one.")
    row.verified_at = _now()
    return _token(tenant_id, form_id, row.field_name, row.target_hash)


def _token_ok(token: str | None, tenant_id: uuid.UUID, form_id: uuid.UUID, field: str, target: str) -> bool:
    if not token:
        return False
    try:
        claims = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
    except JWTError:
        return False
    return (
        claims.get("purpose") == _PURPOSE
        and claims.get("tid") == str(tenant_id)
        and claims.get("fid") == str(form_id)
        and claims.get("f") == field
        and hmac.compare_digest(str(claims.get("th", "")), target_hash(target))
    )


def check_tokens(tenant_id: uuid.UUID, form_id: uuid.UUID, field_defs: list[dict], values: dict, tokens: dict[str, str], previous: dict | None = None) -> None:
    """HTTPException 422 naming each verified field whose (new) value has no
    token for exactly that address. A value kept from before needs none."""
    errors: dict[str, str] = {}
    for field in field_defs:
        name = field.get("name")
        if not field.get("require_verification") or field.get("type") not in ("email", "phone"):
            continue
        value = values.get(name)
        if value in (None, "") or (previous is not None and previous.get(name) == value):
            continue
        target = normalized_target(field, value)
        if target is None or not _token_ok(tokens.get(name), tenant_id, form_id, name, target):
            errors[name] = "Confirm this " + ("email address" if field["type"] == "email" else "phone number") + " with the code we send to it."
    if errors:
        raise HTTPException(status_code=422, detail=field_errors_detail(field_defs, errors))
