import uuid
from typing import Literal

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import JSONResponse, Response
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_db
from app.core.form_validation import field_errors_detail, missing_required_fields, validate_field_values
from app.core.permissions import check_portal_action, require_permission
from app.core.security import AuthContext, get_auth_context
from app.crud import field_access, record_sql
from app.crud.access_policy import require_record_access
from app.crud.audit import write_audit_log
from app.crud.base import CRUDBase
from app.crud.change_log import check_fresh, values_diff
from app.crud.expr_context import user_scope
from app.crud.form_integrity import check_submission_values
from app.core.expr import SEQ_SCOPE, SEQ_WANTED, is_blank, uses_sequence
from app.crud.field_constraints import assign_form_autonumbers
from app.crud.form_stored_rules import stored_data_errors
from app.crud import application_review, form_documents, form_schedule, form_verification
from app.crud.formula import compute_formula_fields
from app.crud.sequence import next_sequence_value
from app.models.workflow import Form, FormSubmission
from app.schemas.workflow import (
    FormSubmissionCreate,
    FormSubmissionRead,
    FormSubmissionUpdate,
    FormVerificationConfirm,
    FormVerificationSend,
    FormVerificationSent,
    FormVerificationToken,
    SubmissionResubmit,
    SubmissionReviewAction,
    SubmissionReviewRead,
)

router = APIRouter(prefix="/form-submissions", tags=["forms"])
crud = CRUDBase[FormSubmission, FormSubmissionCreate, FormSubmissionUpdate](FormSubmission)


async def _get_form_version(db: AsyncSession, tenant_id: uuid.UUID, form_id: uuid.UUID, version: int) -> Form:
    form = (
        await db.execute(
            select(Form).where(Form.tenant_id == tenant_id, Form.form_id == form_id, Form.version == version)
        )
    ).scalar_one_or_none()
    if form is None:
        raise HTTPException(status_code=404, detail="Form version not found")
    return form


def _enforce_validation_rules(form: Form, field_values: dict, scope: dict | None = None) -> None:
    """Each field's Form Designer validation rules (length, pattern, email,
    ranges, expressions, ...) — the server-side copy of what the form
    runtime already checked in the browser. See app/core/form_validation.py."""
    errors = validate_field_values(form.field_definitions, field_values, ctx=scope)
    if errors:
        raise HTTPException(status_code=422, detail=field_errors_detail(form.field_definitions, errors))


async def _enforce_stored_data_rules(db: AsyncSession, tenant_id: uuid.UUID, form: Form, values: dict, submission_id: uuid.UUID | None, scope: dict | None) -> None:
    """The "unique" / "exists_in" rules — see app/crud/form_stored_rules.py."""
    errors = await stored_data_errors(db, tenant_id, form.form_id, form.field_definitions, values, submission_id, scope)
    if errors:
        raise HTTPException(status_code=422, detail=field_errors_detail(form.field_definitions, errors))


def _check_required(form: Form, values: dict, hidden: set[str], scope: dict | None = None) -> None:
    """FR-FRM-008, for the fields the person could see and fill — a field
    hidden to them by its access rules (or its "show when") can't be."""
    missing = [n for n in missing_required_fields(form.field_definitions, values, scope) if n not in hidden]
    if missing:
        raise HTTPException(status_code=422, detail={"missing_required_fields": missing})


async def _with_formulas(db: AsyncSession, tenant_id: uuid.UUID, form: Form, values: dict, *, number: bool, previous: dict | None = None) -> dict:
    """Formula fields are server-computed; a client-supplied value is dropped.

    A formula taking SEQ('name', digits) gets its running number once: when
    the submission is submitted (`number` — never for a draft), from the
    tenant's counter of that name (app/crud/sequence.py, kept apart from
    Task sequences as "formula:<name>"), and keeps it on every later save.
    Autonumber fields are numbered the same way, in their own format."""
    defs = form.field_definitions
    clean = {k: v for k, v in values.items() if k not in {f["name"] for f in defs if f.get("type") in ("formula", "autonumber")}}
    numbers = [f["name"] for f in defs if f.get("type") == "autonumber"]
    if numbers:
        if number:
            clean = await assign_form_autonumbers(db, tenant_id, form.form_id, defs, clean, previous)
        else:
            clean.update({n: previous[n] for n in numbers if previous and not is_blank(previous.get(n))})
    numbered = [f["name"] for f in defs if f.get("type") == "formula" and uses_sequence(f.get("formula_expression"))]
    kept = {n: previous[n] for n in numbered if previous and not is_blank(previous.get(n))}
    scope: dict = {}
    if number and len(kept) < len(numbered):
        wanted: list[str] = []
        compute_formula_fields(defs, clean, {SEQ_WANTED: wanted})
        scope = {SEQ_SCOPE: {name: await next_sequence_value(db, tenant_id, f"formula:{name}") for name in wanted}}
    computed = compute_formula_fields(defs, clean, scope)
    # Not numbered yet (a draft): blank, never a half-built "RTI/2026//DR".
    for name in numbered:
        if SEQ_SCOPE not in scope:
            computed[name] = None
    return {**clean, **computed, **kept}


def _source(form_id: uuid.UUID) -> record_sql.RecordSource:
    return record_sql.RecordSource("form", form_id, [])


def _own_draft(ctx: AuthContext, submission: FormSubmission) -> bool:
    return submission.status == "draft" and submission.submitted_by == ctx.user_id


async def _visible_submission(db: AsyncSession, ctx: AuthContext, submission_id: uuid.UUID, purpose: str) -> FormSubmission:
    submission = await crud.get(db, ctx.tenant_id, submission_id)
    # A vendor may only ever see their own organization's submissions —
    # ownership, not the forms item-grant, is what matters for a single
    # already-submitted record.
    if ctx.vendor_id is not None and submission.vendor_id != ctx.vendor_id:
        raise HTTPException(status_code=404, detail="FormSubmission not found")
    if submission.is_deleted:
        raise HTTPException(status_code=404, detail="FormSubmission not found")
    # A draft belongs to whoever saved it, and no one else.
    if submission.status == "draft" and submission.submitted_by != ctx.user_id:
        raise HTTPException(status_code=404, detail="FormSubmission not found")
    await require_record_access(db, ctx.tenant_id, ctx.user_id, _source(submission.form_id), submission.id, purpose)
    return submission


async def _readable(db: AsyncSession, ctx: AuthContext, submission: FormSubmission, form: Form | None = None) -> FormSubmissionRead:
    """The submission as this person may see it — without fields hidden to them."""
    if form is None:
        form = await _get_form_version(db, ctx.tenant_id, submission.form_id, submission.form_version)
    step = await field_access.submission_step(db, ctx.tenant_id, submission)
    access = await field_access.access_for(db, ctx.tenant_id, ctx.user_id, form.field_definitions, step)
    read = FormSubmissionRead.model_validate(submission)
    return read.model_copy(update={"field_values": field_access.without_hidden(submission.field_values, access)})


# ---- Embedded forms -------------------------------------------------------------------------------

# How deep forms may embed forms (A embeds B embeds C) — also what stops a
# form that (through others) embeds itself.
_EMBED_DEPTH = 3


async def _live_form(db: AsyncSession, tenant_id: uuid.UUID, form_id: uuid.UUID) -> Form:
    form = (
        await db.execute(
            select(Form).where(Form.tenant_id == tenant_id, Form.form_id == form_id, Form.status == "published").order_by(Form.version.desc()).limit(1)
        )
    ).scalar_one_or_none()
    if form is None:
        raise HTTPException(status_code=422, detail="An embedded form has no published version — publish it first.")
    return form


def _embedded_error(parent: Form, field: dict, exc: HTTPException) -> HTTPException:
    """The embedded form's own 422, as an error on the parent's field."""
    detail = exc.detail
    if isinstance(detail, list) and detail:
        message = "; ".join(str(d.get("msg", "")) for d in detail if isinstance(d, dict))
    elif isinstance(detail, dict) and detail.get("missing_required_fields"):
        message = "fill in " + ", ".join(detail["missing_required_fields"])
    else:
        message = str(detail)
    return HTTPException(status_code=exc.status_code, detail=field_errors_detail(parent.field_definitions, {field["name"]: message}))


async def _save_embedded(db: AsyncSession, ctx: AuthContext, form: Form, values: dict, previous: dict | None, depth: int) -> dict:
    """Each embedded form's answers become (or update) that form's own
    submission, in this same transaction; the field then holds its id.

    Which submission a field updates is the one it already holds — never an
    id the caller sends — so an edit can only reach its own embedded records."""
    out = dict(values)
    for field in form.field_definitions:
        if field.get("type") != "embedded_form":
            continue
        name = field["name"]
        answers = out.get(name)
        linked = (previous or {}).get(name)
        linked_id = linked if isinstance(linked, str) else None
        if not isinstance(answers, dict):
            # Nothing new: keep the linked submission (an unchanged id, or blank).
            if linked_id:
                out[name] = linked_id
            continue
        if depth >= _EMBED_DEPTH or str(field.get("embedded_form_id")) == str(form.form_id):
            raise HTTPException(status_code=422, detail=field_errors_detail(form.field_definitions, {name: "Forms can't embed themselves."}))
        try:
            if linked_id:
                child = await _visible_submission(db, ctx, uuid.UUID(linked_id), "edit")
                child_form = await _get_form_version(db, ctx.tenant_id, child.form_id, child.form_version)
                child = await _update(db, ctx, child, child_form, answers, submit=True, depth=depth + 1)
            else:
                child_form = await _live_form(db, ctx.tenant_id, uuid.UUID(str(field["embedded_form_id"])))
                child = await _create(db, ctx, child_form, answers, draft=False, depth=depth + 1)
        except HTTPException as exc:
            if exc.status_code != 422:
                raise
            raise _embedded_error(form, field, exc) from exc
        out[name] = str(child.id)
    return out


# ---- Create and update ----------------------------------------------------------------------------


async def _live_or(db: AsyncSession, form: Form) -> Form:
    """The form's live (latest published) version — its schedule is the one
    that counts — or `form` itself when it has none."""
    live = (
        await db.execute(
            select(Form)
            .where(Form.tenant_id == form.tenant_id, Form.form_id == form.form_id, Form.status == "published")
            .order_by(Form.version.desc())
            .limit(1)
        )
    ).scalar_one_or_none()
    return live or form



async def _create(
    db: AsyncSession,
    ctx: AuthContext,
    form: Form,
    field_values: dict,
    *,
    draft: bool,
    workflow_instance_id: uuid.UUID | None = None,
    depth: int = 0,
    verifications: dict[str, str] | None = None,
) -> FormSubmission:
    if draft and not (form.settings or {}).get("allow_drafts"):
        raise HTTPException(status_code=422, detail="This form doesn't allow saving drafts.")
    form_schedule.ensure_open(await _live_or(db, form))
    access = await field_access.access_for(db, ctx.tenant_id, ctx.user_id, form.field_definitions)
    values = await check_submission_values(db, ctx.tenant_id, ctx.user_id, form.field_definitions, field_values)
    if not draft:
        scope = await user_scope(db, ctx.tenant_id, ctx.user_id)
        _check_required(form, values, field_access.hidden_names(access), scope)
        _enforce_validation_rules(form, values, scope)
        await _enforce_stored_data_rules(db, ctx.tenant_id, form, values, None, scope)
        form_verification.check_tokens(ctx.tenant_id, form.form_id, form.field_definitions, values, verifications or {})

    computed = await _with_formulas(db, ctx.tenant_id, form, values, number=not draft)
    # A draft keeps embedded answers in place; they're saved when it's submitted.
    if not draft:
        computed = await _save_embedded(db, ctx, form, computed, None, depth)
    submission = FormSubmission(
        tenant_id=ctx.tenant_id,
        form_id=form.form_id,
        form_version=form.version,
        workflow_instance_id=workflow_instance_id,
        field_values=computed,
        vendor_id=ctx.vendor_id,
        submitted_by=ctx.user_id,
        status="draft" if draft else "submitted",
    )
    db.add(submission)
    await db.flush()
    await db.refresh(submission)
    await write_audit_log(
        db, ctx.tenant_id, ctx.user_id, "submission.draft" if draft else "submission.create", "form_submission", submission.id,
        {"form_id": str(form.form_id), "form_version": form.version},
    )
    if not draft:
        await application_review.on_submitted(db, form, submission, ctx.user_id)
        await form_documents.queue_receipt(db, ctx.tenant_id, form, submission)
    return submission


async def _update(
    db: AsyncSession,
    ctx: AuthContext,
    existing: FormSubmission,
    form: Form,
    field_values: dict,
    *,
    submit: bool,
    depth: int = 0,
    verifications: dict[str, str] | None = None,
) -> FormSubmission:
    step = await field_access.submission_step(db, ctx.tenant_id, existing)
    access = await field_access.access_for(db, ctx.tenant_id, ctx.user_id, form.field_definitions, step)
    before = dict(existing.field_values)
    values = await check_submission_values(
        db, ctx.tenant_id, ctx.user_id, form.field_definitions, field_values, previous=before, locked=set(access)
    )
    # A draft stays a draft (unchecked) until it's submitted.
    stays_draft = existing.status == "draft" and not submit
    if existing.status == "draft" and submit:
        # Finishing a draft is a new submission: the form must still be open
        # (checked on the live version — the schedule is the form's, not the draft's).
        form_schedule.ensure_open(await _live_or(db, form))
    if not stays_draft:
        scope = await user_scope(db, ctx.tenant_id, ctx.user_id)
        _check_required(form, values, field_access.hidden_names(access), scope)
        _enforce_validation_rules(form, values, scope)
        await _enforce_stored_data_rules(db, ctx.tenant_id, form, values, existing.id, scope)
        # Codes are asked for when a form is submitted: a draft's values were
        # never checked, so submitting one checks them all. Editing a
        # submission already in — a correction by someone allowed to edit it,
        # e.g. from the form's Submissions page — doesn't ask the submitter
        # for a code again.
        if existing.status == "draft":
            form_verification.check_tokens(ctx.tenant_id, form.form_id, form.field_definitions, values, verifications or {})
    after = await _with_formulas(db, ctx.tenant_id, form, values, number=not stays_draft, previous=before)
    if not stays_draft:
        after = await _save_embedded(db, ctx, form, after, before, depth)

    submitting = existing.status == "draft" and submit
    existing.field_values = after
    existing.updated_by = ctx.user_id
    if submitting:
        existing.status = "submitted"
        existing.submitted_at = func.now()
    await db.flush()
    await db.refresh(existing)
    await write_audit_log(
        db, ctx.tenant_id, ctx.user_id, "submission.submit" if submitting else "submission.update", "form_submission", existing.id,
        {"form_id": str(form.form_id), "changes": values_diff(before, after)},
    )
    if submitting:
        await application_review.on_submitted(db, form, existing, ctx.user_id)
        await form_documents.queue_receipt(db, ctx.tenant_id, form, existing)
    return existing


@router.post("", response_model=FormSubmissionRead, status_code=status.HTTP_201_CREATED)
async def create_form_submission(
    payload: FormSubmissionCreate,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("forms", "create")),
) -> FormSubmissionRead:
    form = await _get_form_version(db, ctx.tenant_id, payload.form_id, payload.form_version)
    if ctx.vendor_id is not None:
        # form_id isn't a path param here (it's in the body), so this can't
        # be a require_portal_item() Depends — same item-level Portal check,
        # done inline once the form is resolved.
        await check_portal_action(db, ctx, "forms", payload.form_id, "submit")
    submission = await _create(
        db, ctx, form, payload.field_values, draft=payload.draft, workflow_instance_id=payload.workflow_instance_id, verifications=payload.verifications
    )
    return await _readable(db, ctx, submission, form)


async def _expand_embedded(db: AsyncSession, ctx: AuthContext, read: FormSubmissionRead, form: Form) -> FormSubmissionRead:
    """The submission with each embedded form's id replaced by that
    submission's answers (as this person may see them) — what a form opened
    to edit it fills in."""
    values = dict(read.field_values)
    for field in form.field_definitions:
        linked = values.get(field.get("name")) if field.get("type") == "embedded_form" else None
        if not isinstance(linked, str):
            continue
        try:
            child = await _visible_submission(db, ctx, uuid.UUID(linked), "view")
        except (HTTPException, ValueError):
            continue
        values[field["name"]] = (await _readable(db, ctx, child)).field_values
    return read.model_copy(update={"field_values": values})


@router.get("/{submission_id}", response_model=FormSubmissionRead)
async def get_form_submission(
    submission_id: uuid.UUID,
    expand: Literal["embedded"] | None = None,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("forms", "view")),
) -> FormSubmissionRead:
    """`expand=embedded` puts each embedded form's answers in place of its id."""
    submission = await _visible_submission(db, ctx, submission_id, "view")
    form = await _get_form_version(db, ctx.tenant_id, submission.form_id, submission.form_version)
    read = await _readable(db, ctx, submission, form)
    return await _expand_embedded(db, ctx, read, form) if expand == "embedded" else read


@router.get("/{submission_id}/document.pdf", response_class=Response)
async def get_submission_document(
    submission_id: uuid.UUID,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("forms", "view")),
) -> Response:
    """The submission as its form's PDF document (Form settings › Document) —
    without the fields hidden from this person."""
    submission = await _visible_submission(db, ctx, submission_id, "view")
    form = await _get_form_version(db, ctx.tenant_id, submission.form_id, submission.form_version)
    if not form_documents.document_enabled(form):
        raise HTTPException(status_code=404, detail="This form has no document to download.")
    readable = await _readable(db, ctx, submission, form)
    pdf = await form_documents.submission_pdf(db, ctx.tenant_id, form, submission, readable.field_values)
    name = form_documents.pdf_file_name(form.name, form_documents.reference_of(form.field_definitions, readable.field_values))
    return Response(pdf, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{name}"', "Cache-Control": "no-store"})


# Editing/deleting a submission after the fact — needed so a Page Designer
# Data List block bound to a Form can offer the same add/edit/delete parity
# a Master-sourced Data List already has (see PageBlockRenderer.tsx's
# PreviewDataList/PreviewDataGrid). Validates against the submission's own
# pinned form_version rather than whatever the form's current version is.
@router.patch("/{submission_id}", response_model=FormSubmissionRead)
async def update_form_submission(
    submission_id: uuid.UUID,
    payload: FormSubmissionUpdate,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("forms", "edit")),
) -> FormSubmissionRead:
    existing = await _visible_submission(db, ctx, submission_id, "edit")
    check_fresh(existing, payload.expected_updated_at)
    # Finishing one's own draft is submitting it; anything else is editing.
    await check_portal_action(db, ctx, "forms", existing.form_id, "submit" if _own_draft(ctx, existing) else "edit")
    form = await _get_form_version(db, ctx.tenant_id, existing.form_id, existing.form_version)
    updated = await _update(db, ctx, existing, form, payload.field_values, submit=payload.submit, verifications=payload.verifications)
    return await _readable(db, ctx, updated, form)


@router.delete("/{submission_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_form_submission(
    submission_id: uuid.UUID,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("forms", "delete")),
) -> None:
    existing = await _visible_submission(db, ctx, submission_id, "delete")
    # Throwing away one's own draft needs only what saving it did.
    await check_portal_action(db, ctx, "forms", existing.form_id, "submit" if _own_draft(ctx, existing) else "delete")
    await crud.soft_delete(db, ctx.tenant_id, submission_id, updated_by=ctx.user_id)
    await write_audit_log(db, ctx.tenant_id, ctx.user_id, "submission.delete", "form_submission", submission_id, {"form_id": str(existing.form_id)})


# ---- Review: officers' decisions, the applicant's corrections (app/crud/application_review.py) ------


async def correct_returned(
    db: AsyncSession, ctx: AuthContext, submission: FormSubmission, field_values: dict, verifications: dict[str, str] | None = None
) -> tuple[FormSubmission, Form]:
    """The applicant's corrections to a submission returned to them — only
    the fields the officer named change, checked like a submission. Also
    used by the public status page (app/routers/public_forms.py)."""
    if not application_review.can_correct(submission):
        raise HTTPException(status_code=409, detail="This application can't be corrected now — it isn't returned for correction, or the time to correct it has passed.")
    form = await _get_form_version(db, ctx.tenant_id, submission.form_id, submission.form_version)
    before = dict(submission.field_values or {})
    values = application_review.corrected_values(submission, form, field_values)
    # A changed verified email / phone must be confirmed again; kept ones needn't.
    form_verification.check_tokens(ctx.tenant_id, form.form_id, form.field_definitions, values, verifications or {}, previous=before)
    updated = await _update(db, ctx, submission, form, values, submit=True, verifications=verifications)
    await application_review.on_resubmitted(db, form, updated, ctx.user_id)
    return updated, form


@router.post("/{submission_id}/review", response_model=FormSubmissionRead)
async def review_form_submission(
    submission_id: uuid.UUID,
    payload: SubmissionReviewAction,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("forms", "edit")),
) -> FormSubmissionRead:
    """An officer's decision: under review, returned for correction (with
    the fields the applicant may change), approved, rejected or closed."""
    existing = await _visible_submission(db, ctx, submission_id, "edit")
    check_fresh(existing, payload.expected_updated_at)
    await check_portal_action(db, ctx, "forms", existing.form_id, "edit")
    form = await _get_form_version(db, ctx.tenant_id, existing.form_id, existing.form_version)
    updated = await application_review.set_status(db, form, existing, payload.status, payload.note, payload.correction_fields, ctx.user_id)
    await db.flush()
    await db.refresh(updated)
    return await _readable(db, ctx, updated, form)


@router.get("/{submission_id}/reviews", response_model=list[SubmissionReviewRead])
async def list_submission_reviews(
    submission_id: uuid.UUID,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("forms", "view")),
) -> list[SubmissionReviewRead]:
    existing = await _visible_submission(db, ctx, submission_id, "view")
    rows = await application_review.history(db, ctx.tenant_id, existing.id)
    names = await application_review.actor_names(db, ctx.tenant_id, rows)
    return [SubmissionReviewRead.model_validate(r).model_copy(update={"actor_name": names.get(str(r.actor_id)) if r.actor_id else None}) for r in rows]


@router.post("/{submission_id}/resubmit", response_model=FormSubmissionRead)
async def resubmit_form_submission(
    submission_id: uuid.UUID,
    payload: SubmissionResubmit,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
) -> FormSubmissionRead:
    """The signed-in submitter corrects their own returned submission."""
    existing = await crud.get(db, ctx.tenant_id, submission_id)
    if existing.is_deleted or existing.submitted_by is None or existing.submitted_by != ctx.user_id:
        raise HTTPException(status_code=404, detail="FormSubmission not found")
    updated, form = await correct_returned(db, ctx, existing, payload.field_values, payload.verifications)
    return await _readable(db, ctx, updated, form)


# ---- Verification codes for email / phone fields (app/crud/form_verification.py) ------------------


def _verification_http(exc: form_verification.VerificationError) -> HTTPException:
    return HTTPException(status_code=exc.status_code, detail=str(exc))


@router.post("/verify/send", response_model=FormVerificationSent)
async def send_verification_code(
    payload: FormVerificationSend,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("forms", "view")),
) -> FormVerificationSent:
    """Sends a code to what's typed into one of the live form's verified
    email / phone fields."""
    from app.models.identity import Tenant

    form = await _live_form(db, ctx.tenant_id, payload.form_id)
    tenant = await db.get(Tenant, ctx.tenant_id)
    try:
        field = form_verification.verified_field(form.field_definitions, payload.field)
        verification_id, sent_to = await form_verification.send_code(db, ctx.tenant_id, tenant.name if tenant else "", form.form_id, form.name, field, payload.value)
    except form_verification.VerificationError as exc:
        raise _verification_http(exc) from exc
    return FormVerificationSent(verification_id=verification_id, sent_to=sent_to)


@router.post("/verify/confirm", response_model=FormVerificationToken)
async def confirm_verification_code(
    payload: FormVerificationConfirm,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("forms", "view")),
) -> FormVerificationToken | JSONResponse:
    try:
        token = await form_verification.confirm_code(db, ctx.tenant_id, payload.form_id, payload.verification_id, payload.code)
    except form_verification.VerificationError as exc:
        # A wrong code still counts toward the attempt limit: answered, not
        # raised, so the request's transaction commits that count.
        return JSONResponse(status_code=exc.status_code, content={"detail": str(exc)})
    return FormVerificationToken(token=token)

