"""Per-build-prompt detection, extraction, and merge of structured form intent.

Every build prompt that might touch a form ("add a phone field to the quote
form"), including the very first one in the onboarding conversation, needs a
way to change the Core-owned form schema without Codegen inventing it -- this
module is that path. It is the only writer of ``DiscoverySpec.form_requirements``;
nothing else should mutate that field directly, so the resolved state stays
the single thing ``compile_generation_plan`` reads.

Deliberately excluded here: letting a low-confidence extraction silently
mutate the schema. A failed or ambiguous extraction leaves the prior resolved
``FormRequirement`` untouched and records a non-fatal marker for
``FormsProvisioner.status()`` to surface, rather than guessing and shipping a
UI/server mismatch.

``extract_delta_for_prompt`` is the pure half (pre-filter + network call, no
DB) so a caller that must not hold a row lock across a network call --
``workers/discovery_apply.py``'s ``start_first_build`` -- can run it before
acquiring the lock, then merge the result against a freshly re-read
``DiscoverySpec`` once the lock is held. ``sync_form_requirements_from_prompt``
is the DB-owning half for every later build prompt, where no such lock-ordering
constraint exists.
"""
from __future__ import annotations

import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from uuid import UUID

import structlog

from src.domain.projects.discovery_schemas import DiscoverySpec
from src.domain.projects.form_requirements import (
    FormRequirement,
    FormRequirementExtractionResult,
    apply_form_requirement_delta,
)

logger = structlog.get_logger()

# The Forms runtime only resolves these two keys today (see
# src.domain.forms.canonical.project_form_definition); included even when a
# project has no FormRequirement yet so the extractor/pre-filter can still
# recognize a prompt naming one of them by its canonical key.
_KNOWN_RUNTIME_FORM_KEYS = frozenset({"contact", "quote_request"})

_FORM_HINT_RE = re.compile(
    r"\bforms?\b|\bfields?\b|\bquote\b|\bcontact\s*(form|us)\b|\bsubmission\b|"
    r"\bbudget\b|\battachment\b|\bdropdown\b|\bcheckbox\b|\btextarea\b|"
    r"\bsign[\s-]?in\b|\brequire\s+(login|auth)\b",
    re.IGNORECASE,
)


def looks_form_related(user_prompt: str, known_form_keys: frozenset[str]) -> bool:
    """Cheap pre-filter so an unrelated prompt never pays for an extra AI call.

    Deliberately generous: a false positive here only costs one extra
    structured-extraction call; a false negative silently drops a real
    form-field change, which is the one failure mode this module exists to
    avoid.
    """
    text = (user_prompt or "").lower()
    if not text.strip():
        return False
    if any(key.replace("_", " ") in text for key in known_form_keys):
        return True
    return bool(_FORM_HINT_RE.search(text))


def known_forms_payload(requirements: tuple[FormRequirement, ...]) -> list[dict]:
    """The extractor's `known_forms` context: each form's current resolved state,
    always including the canonical runtime form keys even when Core has no custom
    requirement for them yet.

    Without this, a project that has never customized a form (the common,
    day-one case for every project) sends the extractor an empty `known_forms`
    list, and "the quote form" has nothing to resolve against -- confirmed live:
    the model returns `applies=false` for an unambiguous field-change prompt
    rather than resolving the reference. Seeding with the *actual* canonical
    fields (not an empty list) also keeps the model's add/update classification
    consistent with what `apply_form_requirement_delta` will actually bootstrap
    from (see `form_requirements._canonical_seed_fields`) -- otherwise the model
    can mistake an existing canonical field for a new one, or vice versa.
    """
    from src.domain.projects.form_requirements import _canonical_seed_fields

    by_key = {req.form_key: req for req in requirements}
    all_keys = set(by_key) | _KNOWN_RUNTIME_FORM_KEYS
    payload = []
    for form_key in sorted(all_keys):
        requirement = by_key.get(form_key)
        fields = requirement.fields if requirement else _canonical_seed_fields(form_key)
        access = requirement.access if requirement else "public_anonymous"
        payload.append(
            {
                "form_key": form_key,
                "fields": [item.model_dump(mode="json") for item in fields],
                "access": access,
            }
        )
    return payload


async def extract_delta_for_prompt(
    user_prompt: str,
    existing: tuple[FormRequirement, ...],
    settings,
    *,
    trace: dict | None = None,
) -> FormRequirementExtractionResult:
    """Pure extraction: cheap pre-filter + structured-output call. No DB access,
    no lock -- safe to call before acquiring a project row lock.

    A provider failure is reported identically to the model's own low
    confidence (``applies=True, confident=False``) rather than raised: the
    pre-filter already decided the prompt plausibly concerns a form, so a
    network/parse failure here must still surface as "ambiguous, don't guess",
    never silently collapse to "doesn't apply".
    """
    known_keys = frozenset({req.form_key for req in existing} | _KNOWN_RUNTIME_FORM_KEYS)
    if not looks_form_related(user_prompt, known_keys):
        return FormRequirementExtractionResult(applies=False)

    from src.domain.projects.form_field_enumeration import enumerated_field_delta

    enumerated = enumerated_field_delta(user_prompt)
    if enumerated is not None:
        return FormRequirementExtractionResult(applies=True, confident=True, delta=enumerated)

    from src.integrations.project_intake_client import extract_form_requirement_delta

    try:
        return await extract_form_requirement_delta(
            {"user_prompt": user_prompt, "known_forms": known_forms_payload(existing)},
            settings,
            trace=trace,
        )
    except Exception as exc:  # noqa: BLE001 - a provider failure is ambiguous, never confident
        logger.warning("forms.requirement_extraction_failed", error=str(exc))
        return FormRequirementExtractionResult(applies=True, confident=False)


@dataclass(frozen=True)
class FormRequirementSyncOutcome:
    changed: bool
    ambiguous: bool
    form_requirements: tuple[FormRequirement, ...] = field(default_factory=tuple)


_NOOP = FormRequirementSyncOutcome(changed=False, ambiguous=False)


async def sync_form_requirements_from_prompt(
    db,
    project_id: UUID,
    user_prompt: str,
    settings,
    *,
    trace: dict | None = None,
) -> FormRequirementSyncOutcome:
    """Detect, extract, and merge a form-field delta for one later build prompt.

    Returns ``_NOOP`` (no DB write) for a prompt the cheap pre-filter rules
    out. On an ambiguous or failed extraction, persists only a visibility
    marker (``discovery_json.form_requirement_sync``) and leaves
    ``form_requirements`` untouched -- never a guessed mutation. Commits the
    DB session itself on a real change, mirroring the existing
    ``discovery_service``/``discovery_apply`` convention of reassigning the
    whole ``project.discovery_json`` dict rather than mutating it in place.
    Idempotent: re-running the same prompt against the same resolved state
    re-applies the same upsert and lands on the same result.
    """
    from src.domain.projects.discovery_repository import DiscoveryRepository

    repository = DiscoveryRepository(db)
    # Read without a lock while the structured extractor runs. Provider calls
    # can take seconds (or retry), and holding the project row lock across that
    # network boundary can block unrelated project operations. The state is
    # re-read under lock immediately before applying the result below.
    project = await repository.get(project_id)
    if project is None:
        return _NOOP
    state = dict(project.discovery_json or {})
    result_block = dict(state.get("result") or {})
    raw_spec = result_block.get("spec")
    if not isinstance(raw_spec, dict):
        return _NOOP
    existing = tuple(DiscoverySpec.model_validate(raw_spec).form_requirements)

    extraction = await extract_delta_for_prompt(user_prompt, existing, settings, trace=trace)
    if not extraction.applies:
        return _NOOP

    # Re-read after extraction and serialize only the short merge/commit under
    # the project lock. A concurrent build may have changed the requirements
    # while the provider call was in flight; applying against this fresh state
    # keeps the delta additive and avoids stale overwrites.
    project = await repository.get(project_id, lock=True)
    if project is None:
        return _NOOP
    state = dict(project.discovery_json or {})
    result_block = dict(state.get("result") or {})
    raw_spec = result_block.get("spec")
    if not isinstance(raw_spec, dict):
        return _NOOP
    spec = DiscoverySpec.model_validate(raw_spec)
    existing = tuple(spec.form_requirements)

    now = datetime.now(UTC).isoformat()
    if not extraction.confident or extraction.delta is None:
        return await _persist_ambiguous(db, project, state, now)

    merged = apply_form_requirement_delta(existing, extraction.delta)
    spec.form_requirements = list(merged)
    result_block["spec"] = spec.model_dump(mode="json")
    state["result"] = result_block
    state.pop("form_requirement_sync", None)
    project.discovery_json = state
    await db.commit()
    logger.info(
        "forms.requirement_delta_applied",
        project_id=str(project.id),
        form_key=extraction.delta.form_key,
    )
    return FormRequirementSyncOutcome(changed=True, ambiguous=False, form_requirements=merged)


async def _persist_ambiguous(db, project, state, now) -> FormRequirementSyncOutcome:
    state = dict(state)
    state["form_requirement_sync"] = {
        "ambiguous": True,
        "reason": "extraction_ambiguous",
        "at": now,
    }
    project.discovery_json = state
    await db.commit()
    return FormRequirementSyncOutcome(changed=False, ambiguous=True)


__all__ = [
    "FormRequirementSyncOutcome",
    "extract_delta_for_prompt",
    "known_forms_payload",
    "looks_form_related",
    "sync_form_requirements_from_prompt",
]
