"""Per-build-prompt detection/extraction/merge of structured entity-field intent,
plus the Core-authoritative resolver every entity-provisioning mechanism must call
before sending field declarations to Cloud.

Mirrors ``src.domain.projects.form_requirement_sync`` for business entities. Entities
reach Cloud through three independent, pre-existing mechanisms (see
``src.domain.feature_packs.service.PackInstallService.install_pack`` for catalog Feature
Packs, ``src.domain.feature_packs.composer.compose_and_register_pack`` for agent-authored
``compose`` entries, and ``src.domain.codegen.provision_manifest.provision_entities`` for
``p2hs/provision.json``) -- all three ultimately call
``CloudControlClient.create_project_entity``/``patch_project_entity``, and today none of
them is checked against an independently Core-resolved field list: whatever the coding
agent declares in a given run is simply trusted. That is the same class of bug Forms had.

Unlike Forms, an entity has no canonical default schema to fall back to, so
``resolve_entity_fields`` cannot simply "override with Core's value, else clear it" the
way ``install_feature_pack`` does for ``form_definitions``. Instead it merges at the
individual-field level: a field Core already has an opinion on always wins (closing the
drift hole), but a field Core has never seen before is *adopted* from the declaration
(there being nothing else that could ever originate a brand-new entity's first fields),
and a field Core knows about that this run's declaration omits is never silently dropped
(matching the provisioning layer's own additive-only semantics).
"""
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.codegen.provision_manifest import ProvisionField
from src.domain.projects.discovery_schemas import DiscoverySpec
from src.domain.projects.entity_requirements import (
    EntityFieldRequirement,
    EntityFieldRequirementExtractionResult,
    apply_entity_field_requirement_delta,
)

logger = structlog.get_logger()

_FIELD_HINT_RE = re.compile(
    r"\bentit(?:y|ies)\b|\bfields?\b|\brecords?\b|\bschema\b|\bcolumns?\b|"
    r"\blead\b|\border\b|\bbooking\b|\bcustomer\b|\bproduct\b|\bdropdown\b|\brelation\b",
    re.IGNORECASE,
)


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

    Deliberately generous, same reasoning as the Forms pre-filter: a false
    positive only costs one extra structured-extraction call; a false
    negative silently drops a real field change.
    """
    text = (user_prompt or "").lower()
    if not text.strip():
        return False
    if any(key.replace("_", " ") in text for key in known_entity_keys):
        return True
    return bool(_FIELD_HINT_RE.search(text))


def known_entities_payload(requirements: tuple[EntityFieldRequirement, ...]) -> list[dict]:
    """The extractor's `known_entities` context: each entity's current resolved fields."""
    return [
        {
            "entity_key": requirement.entity_key,
            "fields": [item.model_dump(mode="json") for item in requirement.fields],
        }
        for requirement in requirements
    ]


def _entity_names_payload(entity_names: tuple[tuple[str, str], ...]) -> list[dict]:
    """Discovery's conceptual entity list (id, name) -- no fields, possibly not even
    snake_case yet -- as `known_entity_names` context. See `extract_entity_delta_for_prompt`."""
    return [{"id": entity_id, "name": name} for entity_id, name in entity_names]


async def extract_entity_delta_for_prompt(
    user_prompt: str,
    existing: tuple[EntityFieldRequirement, ...],
    settings,
    *,
    entity_names: tuple[tuple[str, str], ...] = (),
    trace: dict | None = None,
) -> EntityFieldRequirementExtractionResult:
    """Pure extraction: cheap pre-filter + structured-output call. No DB access.

    ``entity_names`` is Discovery's conceptual entity list (``DiscoverySpec.entities``,
    as ``(id, name)`` pairs) -- entities named during onboarding that have no
    Core-resolved *fields* yet. Without this, a project's first-ever
    field-change prompt for any entity sends the extractor an empty
    ``known_entities``/no grounding at all, and the model cannot resolve "the
    lead entity" to anything -- confirmed live: it returns ``applies=false``
    for an unambiguous field-change prompt, the exact same failure mode Forms
    had for its own ``known_forms`` (see ``form_requirement_sync.known_forms_payload``).
    Unlike Forms there is no small fixed set of entity keys to seed with --
    Discovery's own entity list is the closest equivalent "known universe."

    A provider failure is reported identically to the model's own low
    confidence (``applies=True, confident=False``) -- the pre-filter already
    decided the prompt plausibly concerns an entity's fields, so a
    network/parse failure must still surface as "ambiguous, don't guess."
    """
    known_keys = frozenset(req.entity_key for req in existing) | frozenset(
        entity_id for entity_id, _ in entity_names
    )
    if not looks_entity_field_related(user_prompt, known_keys):
        return EntityFieldRequirementExtractionResult(applies=False)

    from src.integrations.project_intake_client import extract_entity_field_requirement_delta

    try:
        return await extract_entity_field_requirement_delta(
            {
                "user_prompt": user_prompt,
                "known_entities": known_entities_payload(existing),
                "known_entity_names": _entity_names_payload(entity_names),
            },
            settings,
            trace=trace,
        )
    except Exception as exc:  # noqa: BLE001 - a provider failure is ambiguous, never confident
        logger.warning("entities.requirement_extraction_failed", error=str(exc))
        return EntityFieldRequirementExtractionResult(applies=True, confident=False)


@dataclass(frozen=True)
class EntityFieldRequirementSyncOutcome:
    changed: bool
    ambiguous: bool
    entity_requirements: tuple[EntityFieldRequirement, ...] = field(default_factory=tuple)


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


async def _load_spec(db, project_id: UUID):
    """Returns (project, state, result_block, spec) or None if unavailable. Always
    locks the row -- every caller in this module either writes or is about to decide
    whether to."""
    from src.domain.projects.discovery_repository import DiscoveryRepository

    project = await DiscoveryRepository(db).get(project_id, lock=True)
    if project is None:
        return None
    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 None
    spec = DiscoverySpec.model_validate(raw_spec)
    return project, state, result_block, spec


async def sync_entity_field_requirements_from_prompt(
    db,
    project_id: UUID,
    user_prompt: str,
    settings,
    *,
    trace: dict | None = None,
) -> EntityFieldRequirementSyncOutcome:
    """Detect, extract, and merge an entity-field delta for one build prompt.

    Same contract as ``form_requirement_sync.sync_form_requirements_from_prompt``:
    ``_NOOP`` for a prompt the pre-filter rules out; on ambiguous/failed
    extraction, persists only the visibility marker and leaves
    ``entity_requirements`` untouched; idempotent for a repeated identical
    prompt/state.
    """
    loaded = await _load_spec(db, project_id)
    if loaded is None:
        return _NOOP
    project, state, result_block, spec = loaded
    existing = tuple(spec.entity_requirements)
    entity_names = tuple((entity.id, entity.name) for entity in spec.entities)

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

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

    merged = apply_entity_field_requirement_delta(existing, extraction.delta)
    spec.entity_requirements = list(merged)
    result_block["spec"] = spec.model_dump(mode="json")
    state["result"] = result_block
    state.pop("entity_requirement_sync", None)
    project.discovery_json = state
    await db.commit()
    logger.info(
        "entities.requirement_delta_applied",
        project_id=str(project.id),
        entity_key=extraction.delta.entity_key,
    )
    return EntityFieldRequirementSyncOutcome(
        changed=True, ambiguous=False, entity_requirements=merged
    )


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


async def resolve_entity_fields(
    db, project_id: UUID, entity_key: str, declared_fields: list[dict]
) -> list[dict]:
    """Core-authoritative field resolution for one entity, called by every
    provisioning mechanism right before an ``entity_def`` is sent to Cloud.

    Field-level merge, Core wins on conflict:
    - A field Core already has an opinion on (same key) always wins, replacing
      whatever this run's declaration says for that key -- closes the "Codegen
      invents/drifts the schema" hole.
    - A field Core has never seen for this entity is adopted from the
      declaration (there is no other way a brand-new entity's first fields
      could ever enter Core's resolved state).
    - A field Core already knows that this run's declaration simply omits is
      never dropped -- matches the provisioning layer's own additive-only
      contract (``provision_manifest.merged_entity_schema`` never removes a
      live field either).

    Persists the merged result as Core's new resolved state when it changed
    (i.e. Core "learns" new fields from declarations, not only from explicit
    deltas), and returns the merged field list for the caller to actually
    provision. Never raises on a malformed individual declared field -- that
    field is simply not adopted; existing provisioning validation (e.g.
    ``ProvisionField``/``CompositionRequirement``) has already run upstream of
    this call for anything that must hard-fail.
    """
    loaded = await _load_spec(db, project_id)
    if loaded is None:
        return declared_fields
    project, state, result_block, spec = loaded
    existing = {req.entity_key: req for req in spec.entity_requirements}
    core_fields_by_key = {
        f.key: f for f in (existing[entity_key].fields if entity_key in existing else ())
    }

    merged: list[dict] = []
    seen_keys: set[str] = set()
    for raw in declared_fields:
        key = raw.get("key") if isinstance(raw, dict) else None
        if key and key in core_fields_by_key:
            merged.append(core_fields_by_key[key].model_dump(mode="json"))
        else:
            merged.append(raw)
        if key:
            seen_keys.add(key)
    for key, provision_field in core_fields_by_key.items():
        if key not in seen_keys:
            merged.append(provision_field.model_dump(mode="json"))

    try:
        new_fields = tuple(ProvisionField.model_validate(item) for item in merged)
    except Exception:
        # A malformed declared field must not block provisioning (upstream
        # validation already gates that) or corrupt Core's resolved state --
        # just don't adopt anything new this call.
        return declared_fields

    if entity_key in existing and existing[entity_key].fields == new_fields:
        return merged

    new_requirement = EntityFieldRequirement(entity_key=entity_key, fields=new_fields)
    spec.entity_requirements = [
        req for req in spec.entity_requirements if req.entity_key != entity_key
    ] + [new_requirement]
    result_block["spec"] = spec.model_dump(mode="json")
    state["result"] = result_block
    project.discovery_json = state
    await db.commit()
    logger.info(
        "entities.fields_resolved",
        project_id=str(project.id),
        entity_key=entity_key,
        field_count=len(new_fields),
    )
    return merged


__all__ = [
    "EntityFieldRequirementSyncOutcome",
    "extract_entity_delta_for_prompt",
    "known_entities_payload",
    "looks_entity_field_related",
    "resolve_entity_fields",
    "sync_entity_field_requirements_from_prompt",
]
