"""Deterministic DiscoverySpec → product capabilities → App Blocks. No LLM."""

import re

from src.domain.app_blocks.schemas import AppBlockManifest, ProductIntent
from src.domain.app_blocks.vertical_recipes import (
    intent_corpus,
    match_recipe_for_intent,
    recipe_slot_overlay,
)
from src.domain.projects.discovery_schemas import DiscoverySpec

_WORD = re.compile(r"[a-z0-9]+")
_NEGATION_MARKERS = (
    "do not add ",
    "do not implement ",
    "do not build ",
    "do not include ",
    "do not track ",
    "do not need ",
    "don't add ",
    "don't include ",
    "don't track ",
    "don't need ",
    "doesn't track ",
    "does not track ",
    "does not need ",
    "without ",
    "no ",
)


def source_prompt_from_discovery(state: dict | None) -> str:
    """Customer text preserved on Discovery state. Spec paraphrase is separate."""
    if not isinstance(state, dict):
        return ""
    initial = str(state.get("initial_prompt") or "").strip()
    if initial:
        return initial
    # Projects created through the reset/answer flow may intentionally have no
    # initial_prompt. The original customer wording is still preserved in the
    # conversation and remains authoritative evidence for deterministic
    # capability selection (for example, an explicitly named quote_request).
    messages = state.get("messages") or []
    return "\n".join(
        str(message.get("content") or "").strip()
        for message in messages
        if isinstance(message, dict)
        and message.get("role") == "user"
        and str(message.get("content") or "").strip()
    ).strip()


def product_intent_from_spec(
    spec: DiscoverySpec | dict | None,
    *,
    source_prompt: str = "",
) -> ProductIntent:
    if spec is None:
        return ProductIntent(source_prompt=source_prompt)
    model = spec if isinstance(spec, DiscoverySpec) else DiscoverySpec.model_validate(spec)
    actor_names = [actor.name for actor in model.actors]
    staff_review = any(
        token in " ".join(actor_names).lower()
        for token in ("staff", "admin", "team", "agent", "counselor")
    ) or any("review" in workflow.name.lower() for workflow in model.workflows)
    auth_required = bool(model.authentication and model.authentication.required)
    requirement_lines = [
        *model.requirements,
        *model.explicit_exclusions,
        *[rule.rule for rule in model.business_rules],
        *model.acceptance_outcomes,
    ]
    return ProductIntent(
        purpose=model.product.purpose,
        first_build_goal=model.product.first_build_goal,
        actor_names=actor_names,
        workflow_names=[workflow.name for workflow in model.workflows],
        workflow_descriptions=[
            " ".join(
                part
                for part in (
                    workflow.description,
                    workflow.outcome,
                    *[f"{step.name} {step.description}" for step in workflow.steps],
                )
                if part
            )
            for workflow in model.workflows
        ],
        entity_names=[entity.name for entity in model.entities],
        entity_descriptions=[entity.description for entity in model.entities],
        entity_lifecycles=[
            " ".join(entity.lifecycle) for entity in model.entities if entity.lifecycle
        ],
        archetypes=list(model.archetypes),
        target_audience=list(model.product.target_audience),
        access_scopes=[actor.access_scope or "" for actor in model.actors],
        requirement_lines=requirement_lines,
        source_prompt=source_prompt,
        original_intent=model.original_intent,
        domain=model.domain,
        requested_product_shape=model.requested_product_shape,
        form_names=list(model.forms),
        authentication_required=auth_required,
        staff_review=staff_review,
    )


def _corpus(intent: ProductIntent) -> str:
    """Feature-intent text only.

    Actor role labels (including the default Discovery actor ``Website visitor``)
    must not select product capabilities. ``staff_review`` already reads actor
    names separately for admin chrome. ``source_prompt`` is the customer's
    original request; Discovery paraphrase lives in the other fields. Structured
    original_intent / domain / product-shape evidence is appended only when
    Discovery populated it.
    """
    return intent_corpus(intent)


class BlockRule:
    def __init__(self, capability: str, block_id: str, matcher):
        self.capability = capability
        self.block_id = block_id
        self.matcher = matcher


def _words(text: str) -> set[str]:
    return set(_WORD.findall(text.replace("-", " ")))


def _has_word(text: str, word: str) -> bool:
    return bool(re.search(rf"\b{re.escape(word)}\b", text))


_UNIQUENESS_CONSTRAINT = ("double ", "duplicate ")


def _negated_clauses(text: str) -> str:
    clauses: list[str] = []
    for marker in _NEGATION_MARKERS:
        start = 0
        while True:
            found = text.find(marker, start)
            if found < 0:
                break
            remainder = text[found + len(marker) :]
            start = found + len(marker)
            if marker == "no " and remainder.startswith(_UNIQUENESS_CONSTRAINT):
                # "no double/duplicate X" is a uniqueness constraint on X (the
                # product still wants X; it must not allow two of it at once),
                # not a negation of wanting X at all. Do not treat it as one.
                continue
            if marker == "no " and remainder.startswith("show "):
                # "no-show" is a domain term for a missed appointment, not a
                # negation of the following capability (e.g. no-show calculator).
                continue
            clauses.append(remainder.split(".")[0])
    return " ".join(clauses)


def _negated(text: str, terms: tuple[str, ...]) -> bool:
    clauses = _negated_clauses(text)
    return any(term in clauses for term in terms)


def _wants_case_tracking(text: str) -> bool:
    remainder = text.replace("use cases", " ").replace("use case", " ")
    if _negated(remainder, ("case management", "case tracking", "cases")):
        return False
    if "not case management" in remainder:
        return False
    if any(
        token in remainder
        for token in (
            "case management",
            "case tracking",
            "case file",
            "enrollment",
            "legal matter",
            "insurance claim",
            "application tracking",
        )
    ):
        return True
    words = _words(remainder)
    if "use" in words:
        return False
    return bool(words & {"cases", "matters", "claims", "enrollments"})


def _sales_crm_language(text: str, intent: ProductIntent | None = None) -> bool:
    archetypes = [item.lower() for item in (intent.archetypes if intent else [])]
    return any(
        token in text
        for token in ("sales crm", "internal crm", "staff sales crm")
    ) or any("crm" in item for item in archetypes)


def _prospect_language(text: str) -> bool:
    # "potential clients" is deliberately excluded: it is generic landing-page/
    # public-acquisition audience language (any contact or quote form targets
    # "potential clients"), not a specific internal sales-tracking signal.
    # Discovery's paraphrased target_audience field routinely produces this
    # exact phrase for public forms, and it must not flip them to crm.lead.
    # "potential buyers"/"people who might buy" stay -- both are corpus-
    # witnessed (13A-006) descriptions of a *maintained list's contents*
    # ("lists of existing customers and potential buyers"), not an audience
    # label, and Discovery paraphrases freely between the two.
    return any(
        _has_word(text, word)
        for word in ("prospect", "prospects")
    ) or any(
        token in text
        for token in (
            "prospect tracker",
            "people who might buy",
            "potential buyers",
        )
    )


def _pipeline_stage_language(text: str, intent: ProductIntent | None = None) -> bool:
    lifecycle = " ".join(intent.entity_lifecycles).lower() if intent else ""
    haystack = f"{text} {lifecycle}"
    if "pipeline stages" in haystack or "change stages" in haystack:
        return True
    contacted_qualified = "contacted" in haystack and "qualified" in haystack
    won_lost = any(token in haystack for token in ("won or lost", "won/lost", "ends won"))
    new_then = any(
        token in haystack
        for token in (
            "new to contacted",
            "new, then contacted",
            "new then contacted",
            "starts new, then contacted",
            "go new then contacted",
        )
    )
    return new_then or (contacted_qualified and (won_lost or _has_word(haystack, "new")))


def _wants_lead_pipeline(text: str, intent: ProductIntent | None = None) -> bool:
    # Bare "CRM" / "sync to CRM" is external contact sync, not local lead records.
    if _negated(text, ("leads", "lead records", "prospects")):
        return False
    if "lead capture" in text and not (
        _prospect_language(text)
        or _sales_crm_language(text, intent)
        or _pipeline_stage_language(text, intent)
    ):
        return False
    if any(
        token in text
        for token in (
            "lead pipeline",
            "sales lead",
            "lead manager",
            "manage leads",
            "track leads",
            "capture leads",
            "inspect leads",
            "internal crm",
            "pipeline of leads",
        )
    ):
        return True
    if _prospect_language(text):
        return True
    if _sales_crm_language(text, intent) and (
        _pipeline_stage_language(text, intent)
        or _has_word(text, "leads")
        or "pipeline" in text
    ):
        return True
    if _pipeline_stage_language(text, intent) and (
        _has_word(text, "leads") or _sales_crm_language(text, intent)
    ):
        return True
    return False


def _wants_sales_pipeline(text: str, intent: ProductIntent | None = None) -> bool:
    if _negated(text, ("sales pipeline", "pipeline", "pipeline stages")):
        return False
    if any(token in text for token in ("sales pipeline", "lead pipeline board")):
        return True
    if _pipeline_stage_language(text, intent) and (
        _prospect_language(text)
        or _sales_crm_language(text, intent)
        or _has_word(text, "leads")
    ):
        return True
    return False


def _wants_customer_directory(text: str) -> bool:
    if _negated(text, ("customer records", "customer list", "customer directory")):
        return False
    return any(
        token in text
        for token in (
            "customer records",
            "customer list",
            "customer directory",
            "manage customers",
            "spreadsheet of customers",
            "existing customers",
        )
    )


def _wants_organization_directory(text: str) -> bool:
    if _negated(text, ("organization records", "company records", "account records")):
        return False
    return any(
        token in text
        for token in (
            "organization records",
            "company records",
            "account records",
            "manage companies",
            "manage accounts",
            "company directory",
            "list of companies",
        )
    )


def _wants_organization_team(text: str) -> bool:
    return any(
        phrase in text
        for phrase in ("organization teams", "organizational teams", "teams within each organization")
    )


def _wants_organization_unit(text: str) -> bool:
    return any(
        phrase in text
        for phrase in ("organizational units", "organization units", "departments within organizations")
    )


def _wants_quote_lifecycle(text: str) -> bool:
    if _negated(text, ("quotes", "quote records", "quote lifecycle")):
        return False
    return any(
        token in text
        for token in (
            "track quotes",
            "quote records",
            "send quotes",
            "quote lifecycle",
            "manage quotes",
            "accept or reject a quote",
        )
    )


def _wants_comment_threads(text: str) -> bool:
    if _negated(text, ("comments", "internal notes", "customer-visible comments")):
        return False
    return any(
        token in text
        for token in (
            "internal comments",
            "internal notes",
            "customer-visible comments",
            "staff-only comments",
            "comment on cases",
            "comment on tasks",
        )
    )


def _wants_governance_groups(text: str) -> bool:
    if _negated(text, ("board management", "committee management", "governance groups")):
        return False
    return any(
        token in text
        for token in (
            "board management",
            "committee management",
            "governance groups",
            "manage committees",
            "manage board members",
        )
    )


def _wants_customer_success_accounts(text: str) -> bool:
    if _negated(text, ("customer success", "customer onboarding", "customer health")):
        return False
    return any(
        token in text
        for token in (
            "customer success",
            "customer onboarding",
            "customer health",
            "track escalations",
            "onboarding stage",
        )
    )


def _wants_contract_management(text: str) -> bool:
    if _negated(text, ("contract records", "contract management", "contract lifecycle")):
        return False
    return any(
        token in text
        for token in (
            "contract records",
            "contract management",
            "contract lifecycle",
            "track contracts",
            "manage contracts",
            "contract renewals",
        )
    )


def _wants_workforce_directory(text: str) -> bool:
    if _negated(text, ("worker directory", "workforce directory", "employee directory")):
        return False
    return any(
        token in text
        for token in (
            "worker directory",
            "workforce directory",
            "employee directory",
            "track workers",
            "manage workers",
            "worker skills",
            "worker credentials",
            "employee credentials",
            "credential expiry",
        )
    )


def _wants_activity_timeline(text: str) -> bool:
    # Tight phrases only -- bare "activity"/"history" appear in too much
    # unrelated copy ("business activity", "medical history") to match loosely.
    # Deliberately excludes "audit trail"/"compliance log" -- those name a
    # stronger, separate capability (immutability/retention) this pack does
    # not provide; do not let this matcher blur that distinction.
    if _negated(text, ("activity timeline", "record history", "activity history", "activity")):
        return False
    return any(
        token in text
        for token in (
            "activity timeline",
            "activity log",
            "activity feed",
            "history of changes",
            "record history",
            "activity history",
            "inspect activity",
        )
    )


def _wants_in_app_notifications(text: str) -> bool:
    # Tight phrases only. Deliberately excludes bare "notify"/"notification"
    # (appears constantly in unrelated copy) and excludes generic "alert"/
    # "reminder" (too broad, and email-only asks belong to a later notifications
    # slice, not this in-app-specific one).
    #
    # Also deliberately excludes "notification inbox"/"notification bell"/
    # "notification center": the admitted notifications.in_app capability is a
    # staff admin table over *all* recipients (see
    # manifests/notifications.in_app.v1.yaml -- "Staff can view all
    # recipients' in-app notifications"), not a per-recipient inbox/bell UI.
    # Those phrases describe the unsupported "recipient inbox" product-surface
    # candidate (docs/audits/POST_B2_PRODUCT_SURFACE_CANDIDATES.md) and must
    # not silently resolve to this different, admitted capability.
    return any(
        token in text
        for token in (
            "in-app notification",
            "in app notification",
        )
    )


def _wants_documents(text: str) -> bool:
    # Structured member/staff records, not Google Docs, legal paperwork, or
    # consulting copy. Negation ("do not build a document management app")
    # and static/marketing mentions stay out.
    if _negated(
        text,
        (
            "document management",
            "document library",
            "document records",
            "documents",
            "docs app",
        ),
    ):
        return False
    positives = (
        "document management",
        "member documents",
        "internal documents",
        "staff documents",
        "document records",
        "managed documents",
        "manage documents",
        "document library",
        "text records",
        "title-and-body records",
        "title and body",
        "internal docs",
        "docs app",
    )
    if any(token in text for token in positives):
        return True
    if any(token in text for token in ("documentation", "google docs")):
        return False
    return False


def _wants_file_attachments(text: str) -> bool:
    return any(
        token in text
        for token in (
            "file attachment",
            "file attachments",
            "attach files to",
            "attach a file",
            "record attachments",
            "attachment on records",
            "file relationships",
            "link an already admitted file",
        )
    )


def _wants_task_management(text: str, intent: ProductIntent | None = None) -> bool:
    # Tight phrases only -- bare "task"/"tasks" is far too common (any todo-list
    # copy, "task" as a synonym for "job") to match as a bag of words.
    if _negated(text, ("task tracking", "task board", "task management", "tasks")):
        return False
    if any(
        token in text
        for token in (
            "task board",
            "task management",
            "task tracking",
            "internal tasks",
            "staff tasks",
            "work queue",
            "ops checklist",
            "operational checklist",
            "operational checklists",
            "to-do list for staff",
            "follow-up tasks",
            "follow up tasks",
            "create tasks",
            "create a task",
        )
    ):
        return True
    # A declared "Task"/"Tasks" entity is a deliberate product-modeling decision
    # (Discovery/the customer named a first-class Task record), not incidental
    # prose use of the word -- unlike bare "task" in running text, this is a
    # precise, non-bag-of-words signal. Raw-text prompts never populate entity
    # names (see raw_spec()), so this only ever fires on a finalized spec.
    if intent and any(name.strip().lower() in ("task", "tasks") for name in intent.entity_names):
        return True
    return False


def _wants_appointment_booking(text: str) -> bool:
    # Tight phrases only. Bare "booking"/"calendar" matches Calendly copy,
    # hotel bookings, and Google Calendar. This block is Cloud slot reservation.
    if _negated(
        text,
        (
            "appointment booking",
            "booking",
            "bookings",
            "reservations",
            "bookable slots",
        ),
    ):
        return False
    if any(
        token in text
        for token in (
            "appointment booking",
            "book an appointment",
            "book appointments",
            "schedule an appointment",
            "schedule appointments",
            "bookable slots",
            "appointment ledger",
        )
    ):
        return True
    return "reservation form" in text and any(
        token in text for token in ("resource", "timestamp", "reserve")
    )


_CATALOG_PHRASES = (
    "product catalog",
    "catalog of products",
    "catalog items",
    "manage catalog items",
    "item directory",
    "directory of the items",
    "directory of items",
    "spreadsheet of goods",
)
# Vendor/storefront catalog copy is a connector, not catalog.product. Strip those
# phrases before matching so "Shopify product catalog later" still misses, while
# an independent "product catalog" plus unsupported checkout wording still hits.
_VENDOR_CATALOG_PHRASES = (
    "shopify product catalog",
    "woocommerce product catalog",
    "stripe checkout products",
)


_ORDER_PHRASES = (
    "customer orders",
    "customer order history",
    "order records",
    "order tracker",
    "staff order list",
    "order book",
    "manage orders",
)
_VENDOR_ORDER_PHRASES = (
    "shopify orders",
    "salesforce order",
    "stripe checkout",
    "paypal order",
)


def _wants_application_orders(text: str) -> bool:
    haystack = text.lower()
    if _negated(
        haystack,
        ("customer orders", "order records", "order tracker", "orders", "order book"),
    ):
        return False
    remainder = haystack
    for vendor in _VENDOR_ORDER_PHRASES:
        remainder = remainder.replace(vendor, " ")
    return any(token in remainder for token in _ORDER_PHRASES)


_BILLING_PHRASES = (
    "create invoices",
    "invoices for clients",
    "invoice management",
    "track invoices",
    "invoice records",
    "manage invoices",
    "staff invoice",
    "billing dashboard",
    "invoice amount",
    "invoice due",
)
_VENDOR_BILLING_PHRASES = (
    "stripe invoice",
    "quickbooks invoice",
    "xero invoice",
    "paypal invoice",
    "p2hs invoice",
)


def _wants_application_billing(text: str) -> bool:
    haystack = text.lower()
    if _negated(
        haystack,
        (
            "create invoices",
            "invoice management",
            "track invoices",
            "invoices",
            "invoicing",
            "billing",
        ),
    ):
        return False
    remainder = haystack
    for vendor in _VENDOR_BILLING_PHRASES:
        remainder = remainder.replace(vendor, " ")
    return any(token in remainder for token in _BILLING_PHRASES)


_REPORTING_PHRASES = (
    "saved report",
    "report definition",
    "reporting dashboard",
    "build a report",
    "aggregate report",
    "summary report",
    "reports and dashboards",
    "staff dashboard",
)
_VENDOR_REPORTING_PHRASES = (
    "power bi",
    "tableau",
    "looker",
    "google analytics",
    "google data studio",
    "metabase",
    "bi warehouse",
)


def _wants_reporting_dashboard(text: str) -> bool:
    haystack = text.lower()
    if _negated(
        haystack,
        (
            "saved report",
            "report definition",
            "reporting dashboard",
            "reports",
            "dashboards",
            "reporting",
        ),
    ):
        return False
    remainder = haystack
    for vendor in _VENDOR_REPORTING_PHRASES:
        remainder = remainder.replace(vendor, " ")
    return any(token in remainder for token in _REPORTING_PHRASES)


_CHART_PHRASES = (
    "bar chart",
    "line chart",
    "pie chart",
    "area chart",
    "add a chart",
    "show a chart",
    "charts of",
    "visualization of",
    "visualize the dataset",
)
_NOT_CHART_PHRASES = (
    "org chart",
    "organisation chart",
    "organization chart",
    "chart of accounts",
    "flow chart",
    "flowchart",
    "seating chart",
    "reporting dashboard",
    "saved report",
)


def _wants_visualization_chart(text: str) -> bool:
    haystack = text.lower()
    if _negated(haystack, ("chart", "charts", "visualization", "bar chart", "pie chart")):
        return False
    remainder = haystack
    for token in _NOT_CHART_PHRASES:
        remainder = remainder.replace(token, " ")
    return any(token in remainder for token in _CHART_PHRASES)


_CALCULATOR_PHRASES = (
    "calculator",
    "roi calculator",
    "mortgage calculator",
    "loan calculator",
    "break-even",
    "break even",
    "pricing estimator",
    "savings calculator",
    "loan payment",
    "project margin",
    "estimates consulting project margin",
)
_NOT_CALCULATOR_PHRASES = (
    "calculate payroll",
    "invoice calculation",
    "tax engine",
    "commission payout",
)


def _wants_calculation_calculator(text: str) -> bool:
    haystack = text.lower()
    if _negated(
        haystack,
        ("calculator", "calculators", "mortgage calculator", "roi calculator"),
    ):
        return False
    remainder = haystack
    for token in _NOT_CALCULATOR_PHRASES:
        remainder = remainder.replace(token, " ")
    return any(token in remainder for token in _CALCULATOR_PHRASES)


_CONTENT_COLLECTION_PHRASES = (
    "blog",
    "blogs",
    "blog posts",
    "categories",
    "case studies",
    "resource center",
    "resource centre",
    "knowledge base",
    "service pages",
    "industry pages",
    "location pages",
    "directory",
    "directories",
    "portfolio projects",
)


def _wants_content_collection(text: str) -> bool:
    """Select collection composition, not a new content or taxonomy model."""
    haystack = text.lower()
    return any(token in haystack for token in _CONTENT_COLLECTION_PHRASES)


# content.* thin preset blocks (require content.collection + content-presets).
# Tight phrase lists, not bare words ("services", "reviews") that would
# false-positive across nearly every marketing-site prompt.
_TESTIMONIALS_PHRASES = (
    "testimonial", "testimonials", "customer reviews", "client reviews",
    "guest reviews", "patient testimonials", "patient stories",
    "client stories", "what our customers say", "what clients say",
    "social proof",
)
_FAQ_PHRASES = (
    "faq", "faqs", "frequently asked questions", "common questions",
    "questions and answers",
)
_SERVICES_SECTION_PHRASES = (
    "services section", "our services", "list of services",
    "range of services", "services offered", "services page",
)
_TEAM_PHRASES = (
    "meet the team", "meet our team", "our team", "team section",
    "staff profiles", "team members", "dentist profiles", "attorney profiles",
    "physician profiles", "consultant profiles", "agent profiles",
)
_LOCATIONS_PHRASES = (
    "our locations", "multiple locations", "locations page", "find a location",
    "service areas", "service area",
)
_PRICING_PHRASES = (
    "pricing section", "pricing plans", "pricing tiers", "our pricing",
    "membership plans", "service plans", "price guidance", "pricing guidance",
)
_GALLERY_PHRASES = ("gallery", "galleries", "photo gallery", "image gallery")
_EVENTS_PHRASES = (
    "events section", "upcoming events", "events calendar", "our events",
)


def _wants_testimonials(text: str) -> bool:
    haystack = text.lower()
    return any(token in haystack for token in _TESTIMONIALS_PHRASES)


def _wants_faq(text: str) -> bool:
    haystack = text.lower()
    return any(token in haystack for token in _FAQ_PHRASES)


def _wants_services_section(text: str) -> bool:
    haystack = text.lower()
    return any(token in haystack for token in _SERVICES_SECTION_PHRASES)


def _wants_team_section(text: str) -> bool:
    haystack = text.lower()
    return any(token in haystack for token in _TEAM_PHRASES)


def _wants_locations_section(text: str) -> bool:
    haystack = text.lower()
    return any(token in haystack for token in _LOCATIONS_PHRASES)


def _wants_pricing_section(text: str) -> bool:
    haystack = text.lower()
    return any(token in haystack for token in _PRICING_PHRASES)


def _wants_gallery_section(text: str) -> bool:
    haystack = text.lower()
    return any(token in haystack for token in _GALLERY_PHRASES)


def _wants_events_section(text: str) -> bool:
    haystack = text.lower()
    return any(token in haystack for token in _EVENTS_PHRASES)


# Three-group semantic matcher for the external.entity_ingest primitive, not a
# flat phrase list: "Send an email using Make.com" and "send form submissions
# to n8n" both contain a source + an inbound-looking verb, but neither names an
# actual entity/content target, so requiring all three groups excludes them
# without needing a separate override/negation list. Deliberately not a
# BlockRule/RULES entry: content-ingest is a backend-only Feature Pack with no
# product-facing App Block of its own (the App Block catalog's id list is a
# frozen, asserted-complete allowlist — see canonical.py's
# compile_default_catalog), so its selection happens at the Feature-Pack-
# catalog layer (Codegen's own catalog search, or a future vertical recipe's
# required_feature_packs), not through select_capabilities.
_EXTERNAL_INGEST_SOURCES = (
    "airtable",
    "make.com",
    "make scenario",
    "make",
    "n8n",
    "zapier",
    "webhook",
    "external system",
    "external automation",
    "external backend",
    "cms",
    "erp",
    "another system",
    "our backend",
    "my backend",
    "her backend",
    "his backend",
    "their backend",
)
_EXTERNAL_INGEST_ACTIONS = (
    "send",
    "sends",
    "sent",
    "sending",
    "push",
    "pushes",
    "pushed",
    "pushing",
    "sync",
    "syncs",
    "synced",
    "syncing",
    "import",
    "imports",
    "imported",
    "importing",
    "receive",
    "receives",
    "received",
    "receiving",
    "ingest",
    "ingests",
    "ingested",
    "ingesting",
    "populate",
    "populates",
    "populated",
    "populating",
    "upsert",
    "upserts",
    "upserted",
    "upserting",
    "create or update",
)
# Generic entity/data nouns only -- deliberately excludes business-object nouns
# like email/order/form/submission/booking/checkout so those stay unrelated
# automation, not ingestion.
_EXTERNAL_INGEST_TARGETS = (
    "content",
    "article",
    "articles",
    "post",
    "posts",
    "record",
    "records",
    "listing",
    "listings",
    "resource",
    "resources",
    "product",
    "products",
    "event",
    "events",
    "job",
    "jobs",
    "directory",
    "collection",
    "entry",
    "entries",
    "data",
)
_CONTENT_INGEST_TARGETS = (
    "content",
    "article",
    "articles",
    "post",
    "posts",
    "blog",
    "resource",
    "resources",
    "entry",
    "entries",
)


def _any_word(text: str, terms: tuple[str, ...]) -> bool:
    return any(_has_word(text, term) for term in terms)


def wants_external_entity_ingest(text: str) -> bool:
    """The generic external.entity_ingest primitive: external source + inbound
    action + an entity/data target, all present. E.g. "Push products from our
    ERP into the app" or "Upsert listings from an external system"."""
    haystack = text.lower()
    if not (_any_word(haystack, _EXTERNAL_INGEST_SOURCES) and _any_word(haystack, _EXTERNAL_INGEST_TARGETS)):
        return False
    if _any_word(haystack, _EXTERNAL_INGEST_ACTIONS):
        return True
    # "Webhook new articles into the site" -- webhook used informally as a verb
    # rather than a noun. Anchored to "webhook" + "into" specifically, not a
    # generic preposition check, to avoid broadening the action vocabulary.
    return _has_word(haystack, "webhook") and _any_word(haystack, ("into",))


def wants_external_content_ingest(text: str) -> bool:
    """True when the prompt wants an external automation to push *content*
    in specifically (blog/article/post/resource language on top of the
    generic ingestion signal) -- the case that should additionally compose
    content-ingest + content.collection, not just the bare primitive.
    Consumed by Codegen's own catalog search, not select_capabilities."""
    haystack = text.lower()
    return wants_external_entity_ingest(haystack) and _any_word(haystack, _CONTENT_INGEST_TARGETS)


_INVENTORY_PHRASES = (
    "track inventory",
    "inventory quantities",
    "inventory management",
    "inventory records",
    "stock levels",
    "manage stock",
    "stock at each location",
    "reorder level",
    "stock management",
    "staff inventory",
)
_VENDOR_INVENTORY_PHRASES = (
    "shopify inventory",
    "warehouse management system",
    "wms",
)


def _wants_application_inventory(text: str) -> bool:
    haystack = text.lower()
    if _negated(
        haystack,
        (
            "track inventory",
            "inventory management",
            "stock management",
            "inventory",
            "warehouse features",
            "stock",
        ),
    ):
        return False
    remainder = haystack
    for vendor in _VENDOR_INVENTORY_PHRASES:
        remainder = remainder.replace(vendor, " ")
    return any(token in remainder for token in _INVENTORY_PHRASES)


_MEMBERSHIP_PHRASES = (
    "membership management",
    "association members",
    "club members",
    "alumni members",
    "community members",
    "membership status",
    "membership type",
    "member type",
    "membership records",
    "member records",
    "membership roster",
    "member roster",
    "membership database",
    "membership dates",
    "membership plan",
    "track members",
    "track memberships",
    "manage members",
    "active and inactive members",
    "association memberships",
    "club membership",
    "alumni membership",
)
_VENDOR_MEMBERSHIP_PHRASES = (
    "saas membership",
    "stripe membership",
    "workspace member",
    "staff member",
    "team member",
    "organization member",
    "conversation member",
    "project member",
    "member portal",
    "member function",
    "board member",
    "family member",
)
_MEMBERSHIP_NEGATION_TERMS = (
    "membership management",
    "member tracking",
    "membership system",
    "membership functionality",
    "member roster",
    "membership",
    "members",
    "member portal",
)


def _strip_membership_vendor_language(text: str) -> str:
    remainder = text
    for vendor in _VENDOR_MEMBERSHIP_PHRASES:
        remainder = re.sub(rf"\b{re.escape(vendor)}s?\b", " ", remainder)
    return remainder


def _wants_application_membership(text: str) -> bool:
    haystack = text.lower()
    if _negated(haystack, _MEMBERSHIP_NEGATION_TERMS):
        return False
    remainder = _strip_membership_vendor_language(haystack)
    return any(token in remainder for token in _MEMBERSHIP_PHRASES)


_CHECKOUT_PHRASES = (
    "stripe checkout",
    "paypal checkout",
    "shopping checkout",
    "accept payments",
    "payment checkout",
    "payments checkout",
    "checkout session",
)
_PLATFORM_BILLING_CHECKOUT_PHRASES = (
    "billing portal",
    "workspace invoices",
    "plan upgrade checkout",
    "p2hs plan",
    "save credit cards",
    "charge subscriptions",
)


def _wants_checkout(text: str) -> bool:
    haystack = text.lower()
    if _negated(
        haystack,
        (
            "checkout",
            "payments checkout",
            "stripe checkout",
            "paypal checkout",
            "payment checkout",
            "accept payments",
        ),
    ):
        return False
    if "order history" in haystack:
        return False
    if any(token in haystack for token in _PLATFORM_BILLING_CHECKOUT_PHRASES):
        return False
    remainder = " ".join(
        clause
        for clause in re.split(r"[.!?]", haystack)
        if "later" not in clause
    )
    return any(token in remainder for token in _CHECKOUT_PHRASES)


def _wants_product_catalog(text: str) -> bool:
    # Catalog intent and unsupported checkout intent are independent:
    # stripe/checkout wording must not erase an explicit catalog phrase.
    # SKU names a catalog record only when item/goods/directory language is
    # also present — do not infer Commerce from catalog.
    if _negated(
        text,
        ("product catalog", "catalog items", "catalog", "item directory"),
    ):
        return False
    remainder = text
    for vendor in _VENDOR_CATALOG_PHRASES:
        remainder = remainder.replace(vendor, " ")
    if any(token in remainder for token in _CATALOG_PHRASES):
        return True
    sku = "sku" in _words(remainder)
    item_language = any(
        token in remainder
        for token in (
            "item directory",
            "item names",
            "directory of the items",
            "goods we offer",
            "in the catalog",
        )
    )
    return sku and item_language


_SUPPORT_TICKET_PHRASES = (
    "support ticket",
    "support tickets",
    "ticketing system",
    "ticket queue",
    "help desk",
    "helpdesk",
)


def _wants_support_ticket(text: str) -> bool:
    # Tight phrases only. Bare "ticket"/"tickets" is deliberately excluded --
    # it appears in unrelated copy (e.g. "no external ticket integration",
    # a real corpus witness that must stay case.management, not
    # support.ticket) far too often to match as a bag of words.
    if _negated(text, _SUPPORT_TICKET_PHRASES):
        return False
    return any(token in text for token in _SUPPORT_TICKET_PHRASES)


_WORKFLOW_APPROVAL_PHRASES = (
    "approval workflow",
    "approval queue",
    "approval request",
    "approval requests",
    "requires approval",
    "require approval",
    "needs approval",
    "need approval",
    "approve or reject",
)


def _wants_workflow_approval(text: str) -> bool:
    # Tight phrases only, same discipline as every other rule -- bare
    # "approve"/"approval" appears in copy that has nothing to do with a
    # staff approval-request workflow (e.g. "an approved vendor list").
    if _negated(text, _WORKFLOW_APPROVAL_PHRASES):
        return False
    return any(token in text for token in _WORKFLOW_APPROVAL_PHRASES)


def _wants_assignment(text: str) -> bool:
    # Tight phrases only -- bare "assign"/"assigned" appears in too many unrelated
    # sentences (e.g. "assign a role") to match as a bag of words.
    if _negated(text, ("assignment", "assign responsibility")):
        return False
    return any(
        token in text
        for token in (
            "assign to staff",
            "staff assignment",
            "assign leads to",
            "assign cases to",
            "assign cases",
            "assign tickets to",
            "case owner",
            "assigned to a staff",
            "work assignment",
            "assignment tracking",
            "assign responsibility",
            "assign responsibilities",
        )
    )


def _static_quotation_copy(text: str) -> bool:
    return any(
        token in text
        for token in (
            "famous quotation",
            "famous quotations",
            "explaining quotation",
            "explaining famous",
        )
    )


def _wants_quote_request(text: str) -> bool:
    if _negated(text, ("quote", "quotes", "quotation", "quotations", "estimate", "bid")):
        return False
    if _static_quotation_copy(text):
        return False
    return any(token in text for token in ("quote", "estimate", "bid", "roof", "quotation"))


def _internal_sales_intent(text: str, intent: ProductIntent | None = None) -> bool:
    return (
        _sales_crm_language(text, intent)
        or _prospect_language(text)
        or _wants_lead_pipeline(text, intent)
        or _wants_sales_pipeline(text, intent)
    )


def _wants_public_contact(text: str, intent: ProductIntent | None = None) -> bool:
    # Public acquisition, not a staff sales CRM and not incidental "website"
    # or pipeline-stage "contacted".
    if _negated(
        text,
        ("contact", "contact form", "contact forms", "forms", "enquiry", "inquiry"),
    ):
        return False
    if _internal_sales_intent(text, intent) and not any(
        token in text
        for token in ("contact form", "contact page", "get in touch", "lead capture")
    ):
        return False
    if any(
        token in text
        for token in (
            "contact form",
            "contact page",
            "get in touch",
            "lead capture",
            "leave a message",
            "send their name and message",
            "requests for information",
        )
    ):
        return True
    if any(token in text for token in ("enquir", "inquiry")):
        return True
    if re.search(r"\bcontact\b", text) and not _has_word(text, "contacted"):
        return True
    if "from the site" in text and any(token in text for token in ("msg", "message")):
        return True
    return False


RULES = [
    BlockRule("crm_customer", "crm.customer", _wants_customer_directory),
    BlockRule("crm_organization", "crm.organization", _wants_organization_directory),
    BlockRule("crm_team", "crm.team", _wants_organization_team),
    BlockRule("crm_org_unit", "crm.org_unit", _wants_organization_unit),
    BlockRule("crm_quote", "crm.quote", _wants_quote_lifecycle),
    BlockRule("collaboration_comment", "collaboration.comment", _wants_comment_threads),
    BlockRule("governance_group", "governance.group", _wants_governance_groups),
    BlockRule(
        "customer_success_account", "customer_success.account", _wants_customer_success_accounts
    ),
    BlockRule("contract_contract", "contract.contract", _wants_contract_management),
    BlockRule("workforce_worker", "workforce.worker", _wants_workforce_directory),
    BlockRule("crm_pipeline", "crm.pipeline", _wants_sales_pipeline),
    BlockRule("quote_request_submission", "forms.quote_request", _wants_quote_request),
    BlockRule("public_contact_submission", "forms.contact", _wants_public_contact),
    BlockRule("lead_lifecycle", "crm.lead", _wants_lead_pipeline),
    BlockRule("case_management", "case.management", _wants_case_tracking),
    BlockRule("workflow_assignment", "workflow.assignment", _wants_assignment),
    BlockRule("task_management", "task.management", _wants_task_management),
    BlockRule("activity_timeline", "activity.timeline", _wants_activity_timeline),
    BlockRule("notifications_in_app", "notifications.in_app", _wants_in_app_notifications),
    BlockRule("files_attachments", "files.attachments", _wants_file_attachments),
    BlockRule("documents", "documents", _wants_documents),
    BlockRule("booking_appointment", "booking.appointment", _wants_appointment_booking),
    BlockRule("catalog_product", "catalog.product", _wants_product_catalog),
    BlockRule("orders_order", "orders.order", _wants_application_orders),
    BlockRule("billing_invoice", "billing.invoice", _wants_application_billing),
    BlockRule("inventory_stock", "inventory.stock", _wants_application_inventory),
    BlockRule("membership_member", "membership.member", _wants_application_membership),
    BlockRule("payments_checkout", "payments.checkout", _wants_checkout),
    BlockRule("support_ticket", "support.ticket", _wants_support_ticket),
    BlockRule("workflow_approval", "workflow.approval", _wants_workflow_approval),
    BlockRule("reporting_dashboard", "reporting.dashboard", _wants_reporting_dashboard),
    BlockRule("visualization_chart", "visualization.chart", _wants_visualization_chart),
    BlockRule("calculation_calculator", "calculation.calculator", _wants_calculation_calculator),
    BlockRule("content_collection", "content.collection", _wants_content_collection),
    BlockRule("content_testimonials", "content.testimonials", _wants_testimonials),
    BlockRule("content_faq", "content.faq", _wants_faq),
    BlockRule("content_services", "content.services", _wants_services_section),
    BlockRule("content_team", "content.team", _wants_team_section),
    BlockRule("content_locations", "content.locations", _wants_locations_section),
    BlockRule("content_pricing", "content.pricing", _wants_pricing_section),
    BlockRule("content_gallery", "content.gallery", _wants_gallery_section),
    BlockRule("content_events", "content.events", _wants_events_section),
]


# Recipe extras yield to existing negation guards. Positive RULE misses still
# compose from a ready Vertical Recipe; "do not add X" does not.
_RECIPE_NEGATION_TERMS: dict[str, tuple[str, ...]] = {
    "crm.customer": ("customer records", "customer list", "customer directory", "customers"),
    "crm.organization": ("organization records", "company records", "account records", "companies"),
    "crm.pipeline": ("sales pipeline", "pipeline"),
    "crm.lead": ("leads", "lead records", "prospects"),
    "forms.contact": ("contact form", "contact forms"),
    "forms.quote_request": ("quote request", "quotation"),
    "case.management": ("case management", "case tracking", "cases"),
    "booking.appointment": ("appointment booking", "booking", "appointments"),
    "catalog.product": ("product catalog", "catalog items", "catalog"),
    "orders.order": ("customer orders", "order records", "order tracker", "orders"),
    "billing.invoice": ("create invoices", "invoice management", "track invoices", "invoices", "billing"),
    "inventory.stock": ("track inventory", "inventory management", "stock management", "inventory", "stock"),
    "membership.member": _MEMBERSHIP_NEGATION_TERMS,
    "payments.checkout": (
        "checkout",
        "stripe checkout",
        "paypal checkout",
        "payment checkout",
        "accept payments",
        "shopping checkout",
    ),
    "task.management": ("task tracking", "tasks"),
    "activity.timeline": ("activity timeline", "record history"),
    "documents": ("documents", "document management"),
    "files.attachments": ("attachments", "file uploads"),
    "notifications.in_app": ("in-app notification", "notifications"),
    "workflow.assignment": ("assign", "assignment"),
    "support.ticket": (
        "support ticket",
        "support tickets",
        "help desk",
        "helpdesk",
        "ticketing system",
        "ticket queue",
    ),
    "workflow.approval": (
        "approval workflow",
        "approval queue",
        "approval request",
        "approval requests",
    ),
    "reporting.dashboard": (
        "saved report",
        "report definition",
        "reporting dashboard",
        "reports",
        "dashboards",
        "reporting",
    ),
    "visualization.chart": (
        "chart",
        "charts",
        "bar chart",
        "pie chart",
        "visualization",
    ),
    "calculation.calculator": (
        "calculator",
        "calculators",
        "mortgage calculator",
        "roi calculator",
    ),
    "content.collection": (
        "no blog",
        "without blog",
        "no categories",
        "without categories",
        "no resource center",
        "without resource center",
    ),
    "crm.quote": ("quotes", "quote records", "quote lifecycle"),
    "collaboration.comment": ("comments", "internal notes", "customer-visible comments"),
    "governance.group": ("board management", "committee management", "governance groups"),
    "customer_success.account": ("customer success", "customer onboarding", "customer health"),
    "contract.contract": ("contract records", "contract management", "contract lifecycle"),
    "workforce.worker": ("worker directory", "workforce directory", "employee directory"),
}


def select_capabilities(
    intent: ProductIntent, *, include_matched_recipe: bool = True
) -> list[tuple[str, str, str]]:
    """Return (capability, block_id, reason) in selection order.

    A pinned composition recipe owns required blocks. Callers that already
    passed one set ``include_matched_recipe`` false so a YAML match cannot
    add a second closure.
    """
    text = _corpus(intent)
    selected: list[tuple[str, str, str]] = []
    seen_blocks: set[str] = set()
    for rule in RULES:
        if not _rule_matches(rule, text, intent):
            continue
        if rule.block_id in seen_blocks:
            continue
        if rule.block_id == "forms.contact" and "forms.quote_request" in seen_blocks:
            continue
        seen_blocks.add(rule.block_id)
        selected.append((rule.capability, rule.block_id, _reason(rule, intent)))
    recipe = match_recipe_for_intent(intent) if include_matched_recipe else None
    if recipe is not None:
        for block_id in recipe.required_app_blocks:
            if block_id in seen_blocks:
                continue
            guard = _RECIPE_NEGATION_TERMS.get(block_id)
            if guard and _negated(text, guard):
                continue
            seen_blocks.add(block_id)
            selected.append(
                (
                    block_id.replace(".", "_"),
                    block_id,
                    f"{recipe.name} composes admitted {block_id}.",
                )
            )
    if intent.staff_review and any(
        block_id in seen_blocks
        for block_id in (
            "forms.contact",
            "forms.quote_request",
            "crm.lead",
            "case.management",
            "support.ticket",
            "workflow.approval",
        )
    ):
        for capability, block_id in (
            ("admin_shell", "admin.shell"),
            ("admin_record_listing", "admin.record_table"),
            ("admin_record_detail", "admin.record_detail"),
        ):
            if block_id not in seen_blocks:
                seen_blocks.add(block_id)
                selected.append(
                    (capability, block_id, "Staff need to review and update submitted records.")
                )
    if intent.authentication_required:
        for capability, block_id in (
            ("authenticated_sign_in", "auth.sign_in"),
            ("user_profile", "auth.profile"),
        ):
            if block_id not in seen_blocks:
                seen_blocks.add(block_id)
                selected.append((capability, block_id, "The product requires signed-in users."))
    return selected


def _rule_matches(rule: BlockRule, text: str, intent: ProductIntent) -> bool:
    try:
        return bool(rule.matcher(text, intent))
    except TypeError:
        return bool(rule.matcher(text))


def _reason(rule: BlockRule, intent: ProductIntent) -> str:
    goal = intent.first_build_goal or intent.purpose or "the first build"
    return f"{goal} maps to {rule.capability}."


def default_slots(manifest: AppBlockManifest, intent: ProductIntent) -> dict:
    slots: dict = {}
    if manifest.id == "forms.contact":
        slots = {
            "route": "/contact",
            "heading": "Contact us",
            "submit_label": "Send message",
        }
    elif manifest.id == "forms.quote_request":
        slots = {
            "route": "/request-quote",
            "heading": "Request a quote",
            "submit_label": "Request quote",
        }
    elif manifest.id == "admin.record_table":
        if any("quote" in name.lower() for name in intent.entity_names):
            slots = {"empty_state": "No quotes yet"}
    elif manifest.id == "crm.lead":
        slots = {
            "heading": "Leads",
            "empty_state": "No leads yet",
        }
    elif manifest.id == "crm.customer":
        slots = {"heading": "Customers", "empty_state": "No customers yet"}
    elif manifest.id == "content.collection":
        slots = {
            "route": "/blog",
            "item_route": "/blog/{item.slug}",
            "presentation": "grid",
            "empty_state": "No published items yet",
        }
    elif manifest.id == "content.testimonials":
        slots = {"heading": "What our customers say", "empty_state": "No testimonials yet"}
    elif manifest.id == "content.faq":
        slots = {"heading": "Frequently asked questions", "empty_state": "No questions yet"}
    elif manifest.id == "content.services":
        slots = {"heading": "Our services", "empty_state": "No services yet"}
    elif manifest.id == "content.team":
        slots = {"heading": "Meet the team", "empty_state": "No team members yet"}
    elif manifest.id == "content.locations":
        slots = {"heading": "Our locations", "empty_state": "No locations yet"}
    elif manifest.id == "content.pricing":
        slots = {"heading": "Pricing", "empty_state": "No pricing tiers yet"}
    elif manifest.id == "content.gallery":
        slots = {"heading": "Gallery", "empty_state": "No gallery items yet"}
    elif manifest.id == "content.events":
        slots = {"heading": "Upcoming events", "empty_state": "No events yet"}
    elif manifest.id == "crm.pipeline":
        slots = {"heading": "Sales pipeline", "empty_state": "No leads yet"}
    elif manifest.id == "case.management":
        slots = {
            "heading": "Cases",
            "empty_state": "No cases yet",
        }
    elif manifest.id == "workflow.assignment":
        slots = {
            "heading": "Assignments",
            "empty_state": "No assignments yet",
        }
    elif manifest.id == "task.management":
        slots = {
            "heading": "Tasks",
            "empty_state": "No tasks yet",
        }
    elif manifest.id == "activity.timeline":
        slots = {
            "heading": "Activity",
            "empty_state": "No activity yet",
        }
    elif manifest.id == "notifications.in_app":
        slots = {
            "heading": "Notifications",
            "empty_state": "No notifications yet",
        }
    elif manifest.id == "files.attachments":
        slots = {
            "heading": "Attachments",
            "empty_state": "No attachments yet",
        }
    elif manifest.id == "documents":
        slots = {
            "heading": "Documents",
            "empty_state": "No documents yet",
        }
    elif manifest.id == "booking.appointment":
        slots = {
            "heading": "Book an appointment",
            "submit_label": "Reserve",
            "success_message": "Appointment reserved.",
            "route": "/book",
        }
    elif manifest.id == "catalog.product":
        slots = {"heading": "Catalog", "empty_state": "No catalog items yet"}
    elif manifest.id == "orders.order":
        slots = {"heading": "Orders", "empty_state": "No orders yet"}
    elif manifest.id == "billing.invoice":
        slots = {"heading": "Invoices", "empty_state": "No invoices yet"}
    elif manifest.id == "inventory.stock":
        slots = {"heading": "Inventory", "empty_state": "No inventory records yet"}
    elif manifest.id == "membership.member":
        slots = {"heading": "Membership", "empty_state": "No membership records yet"}
    elif manifest.id == "payments.checkout":
        slots = {
            "heading": "Checkout",
            "submit_label": "Continue to payment",
            "route": "/checkout",
        }
    elif manifest.id == "support.ticket":
        slots = {
            "heading": "Support tickets",
            "empty_state": "No tickets yet",
        }
    elif manifest.id == "workflow.approval":
        slots = {
            "heading": "Approvals",
            "empty_state": "No approval requests yet",
        }
    elif manifest.id == "visualization.chart":
        slots = {"heading": "Chart", "empty_state": "No data to chart yet"}
    elif manifest.id == "calculation.calculator":
        slots = {
            "heading": "Calculator",
            "empty_state": "Enter values to calculate.",
            "route": "/calculator",
        }
    if intent.first_build_goal and "heading" in {slot.id for slot in manifest.slots}:
        slots.setdefault("heading", intent.first_build_goal[:80])
    recipe = match_recipe_for_intent(intent)
    if recipe is not None:
        slots.update(recipe_slot_overlay(recipe, manifest.id))
    return {
        key: value
        for key, value in slots.items()
        if any(slot.id == key and slot.constraints.editable for slot in manifest.slots)
    }


_REMOVAL_MARKERS = ("remove ", "drop ")


def blocks_explicitly_removed(text: str, block_ids) -> list[str]:
    """Installed blocks a later sentence names for removal.

    Uses the same negation clauses as recipe selection, plus remove/drop.
    A sentence that does not name a block leaves that block installed.
    """
    lowered = (text or "").lower()
    clauses = _negated_clauses(lowered)
    for marker in _REMOVAL_MARKERS:
        start = 0
        while True:
            found = lowered.find(marker, start)
            if found < 0:
                break
            clauses = f"{clauses} {lowered[found + len(marker):].split('.')[0]}"
            start = found + len(marker)
    removed = []
    for block_id in block_ids:
        terms = _RECIPE_NEGATION_TERMS.get(block_id)
        if terms and any(term in clauses for term in terms):
            removed.append(block_id)
    return sorted(set(removed))
