"""Normalize the project manifest into the codegen backend contract.

Generated apps should depend on this contract instead of reaching into P2HS
Cloud implementation details. Core remains the policy/source-of-truth layer;
Cloud remains the runtime implementation behind the advertised surface.
"""
from __future__ import annotations

from typing import Any

from src.domain.manifest.schemas import ManifestResponse


def _resolve_backend(raw: dict[str, Any]) -> dict[str, Any]:
    """Omitted/empty customer_backend means the generated app uses P2HS Cloud."""
    backend = dict(raw or {})
    attached = str(backend.get("customer_backend") or "").strip() or None
    backend["customer_backend"] = attached
    backend["uses"] = attached or backend.get("provider") or "p2hs_cloud"
    return backend


def build_platform_backend_capabilities(
    manifest: ManifestResponse, runtime_contract: dict[str, Any] | None = None
) -> dict[str, Any]:
    """Return the stable backend slice injected into codegen prompts.

    ``runtime_contract`` (see runtime_contract.py) resolves this project's persisted
    admin path / email capability / auth mode into concrete values -- the agent sees
    the real `/be-admin` and the real email binding here, not just in the files it's
    handed. ``None`` (unresolved/legacy caller) leaves the key null and keeps
    ``contract_version`` at "3" -- every other field is unaffected either way.
    """
    raw = manifest.model_dump(mode="json")
    credentials = raw.get("credentials") or {}
    project = raw.get("project") or {}

    return {
        "contract_name": "PlatformBackendCapabilities",
        "contract_version": "4" if runtime_contract else "3",
        "runtime_contract": runtime_contract,
        "backend": _resolve_backend(raw.get("backend") or {}),
        "dependency_inversion": {
            "generated_app_depends_on": "installed, immutable Platform Port Catalog artifacts",
            "generated_app_owns": [
                "UI",
                "calls to the installed /api routes",
            ],
            "core_owns": [
                "manifest",
                "auth policy",
                "workspace/project tenancy",
                "entitlements",
                "connector credential availability",
                "usage authorization",
            ],
            "cloud_owns": [
                "job execution",
                "queues",
                "record engine including pull/push/upsert",
                "storage artifacts",
                "connector dispatch",
                "webhooks",
                "realtime delivery",
                "booking slot reservation",
                "scheduled reminders",
                "connector catalog (PayPal, Twilio, Calendly, Google Calendar)",
            ],
            "customer_backend": (
                "none/omitted means use P2HS Cloud. Do not invent a local replica or "
                "call a customer origin. Adopt mode keeps customer_backend null and "
                "routes existing APIs only through a verified attachment."
            ),
        },
        "project": project,
        "runtime_env": {
            "cloud_gateway_url": credentials.get("cloud_gateway_env", "CLOUD_GATEWAY_URL"),
            "cloud_service_token": credentials.get("cloud_service_token_env", "CLOUD_SERVICE_TOKEN"),
            "project_id": credentials.get("project_id_env", "PROJECT_ID"),
        },
        "env_vars": raw.get("env_vars") or {},
        "bff_conventions": raw.get("bff_conventions") or {},
        "api_surface": raw.get("api_surface") or {},
        "platform_sdk": raw.get("platform_sdk") or {},
        "auth": raw.get("auth") or {},
        "database": raw.get("database") or {},
        "provisioning": {
            "file": "p2hs/provision.json",
            "version": 1,
            "entities": "Add new entities or additive fields with key, label, fields, rls_enabled. Existing fields are never dropped, renamed, or type-narrowed.",
            "schema_status": {
                "ready": "Use listed entity keys; declare additive new entities only. schema_source cached means Core remembered the catalog.",
                "empty": "Cloud confirmed zero entities and Core has no last-known catalog; declare entities, never a local database.",
                "unavailable": "Do not invent or replace schema. If entities are listed, reuse those keys.",
                "omitted": "Compact manifest omitted schema. Obtain the full manifest before declaring entities.",
            },
            "schema_source": {
                "live": "Fetched from Cloud for this run.",
                "cached": "Last-known catalog from Core; treat keys as live. Do not greenfield.",
                "none": "No catalog available.",
            },
            "readiness": "Core must confirm declared entities and buckets before push or preview/deployment.",
            "feature_pack_entities": "Use the feature-pack install path.",
        },
        "query_dsl": raw.get("query_dsl") or {},
        "data_import": raw.get("data_import") or {},
        "email_templates": raw.get("email_templates") or {},
        "storage": raw.get("storage") or {},
        "functions": raw.get("functions") or {},
        "feature_packs": raw.get("feature_packs") or {},
        "connectors": raw.get("connectors") or {},
        "integrations": raw.get("integrations") or {},
        "connections": raw.get("connections") or {},
        "realtime": raw.get("realtime") or {},
        "ai": raw.get("ai") or {},
        "schedules": raw.get("schedules") or [],
        "entitlements": raw.get("entitlements") or {},
        "rules": [
            "Call only installed same-origin /api routes or POST /api/projects/{project_id}/capabilities/{capability}. Never construct a Core host from P2HS_BACKEND_URL, CORE_API_BASE_URL, or beapipie. The Platform Port Catalog owns Cloud wiring; do not create p2hsClient or replace installed artifacts.",
            "Each api_surface entry's schema field is the exact request_body/response JSON Schema the installed adapter sends and expects, including notes on fields it auto-injects (for example app_origin, or an entity_key taken from the URL) or silently overrides. Build requests and parse responses against that schema, not an assumed or invented shape.",
            "An absent port is unavailable. Report the missing capability instead of generating a backend implementation or fake success.",
            "Use platform auth for identities and Cloud records for durable application data. No local users/password database, JSON/KV/SQLite fallback, or shadow authoritative store.",
            "Forward the live session to installed session routes. Staff-only ports require staff; never claim a customer operation is supported by bypassing that policy.",
            "Keep provider credentials and service tokens out of browser code. Installed BFFs enforce their own authentication policy and propagate errors.",
            "Show failed or unavailable operations accurately. Optional side effects may show a separate error, but must not be reported as sent or completed.",
            "Use the installed job routes with a stable Idempotency-Key and poll the returned /api/jobs URL. Do not implement a local durable queue or cron.",
            "For generic transactional email use email.send (POST /api/jobs/email.send / sendEmail). connectors.provider_actions such as resend.send_email are an escape hatch for explicit provider APIs. Switching the connected email adapter must not change generated application code.",
            "For generic operational/chat messages use messaging.send (POST /api/projects/{project_id}/capabilities/messaging.send). Do not use slack.post_message for generic sends. slack.post_message is a provider_actions escape hatch for Slack Block Kit, attachments, or Slack-specific API semantics. Switching Slack for a future Teams connection must not change generated application code. Never construct a Core host.",
            "For generic workflow triggers use automation.workflow.trigger (POST /api/projects/{project_id}/capabilities/automation.workflow.trigger) with a logical workflow name and an input object. Do not emit POST /api/jobs/automation.workflow.trigger. Do not put Make scenario IDs, n8n workflow IDs, or webhook URLs in generated application code. make_com.trigger_scenario and n8n.trigger_workflow are provider_actions escape hatches for vendor webhook/API semantics. Switching Make for n8n must not change generated application code. Success is capability-shaped (status + opaque execution_ref).",
            "For generic document work use document.create / document.get / document.insert_text / document.append_text / document.replace_text (POST /api/projects/{project_id}/capabilities/<verb>). Logical document aliases only. Do not emit POST /api/jobs/document.*. google_docs.insert_text is a provider_actions escape hatch for a numeric Google Docs index; google_docs.replace_all_text is an escape hatch for match_case. document.create does not persist a logical-alias binding.",
            "For generic CRM work use crm.contact.* / crm.company.* / crm.deal.* / crm.association.create (POST /api/projects/{project_id}/capabilities/<verb>). Installed HubSpot vs Salesforce must not change generated code. Stages are logical aliases only; do not emit dealstage, StageName, pipeline defaults, SOQL, filterGroups, AccountId, or association_type_id. hubspot.* and salesforce.* are provider_actions only for explicit provider semantics. Never construct a Core, Cloud, or CRM-provider host. Do not call /api/connectors to select a CRM.",
            "For new generic public submission forms (including new forms.contact / quote_request installs) use canonical Forms: installed-app POST /api/forms/{form_key}/submissions (host-relative). Do not send project_id or action config. Do not call CRM/email/automation capabilities from the form client. Do not generate POST /api/contact or a parallel notify helper for new installs. Leave POST /api/contact for already-generated apps. Missing composition binding correctly returns form_unavailable.",  # noqa: E501
            "Reuse database.entities and declare additive resources in p2hs/provision.json. Never create a second authoritative store.",
            "For an rls_enabled entity, a customer-facing BFF route must forward the signed-in app-user's own Authorization header to Cloud (the same token already used for auth/me) so Cloud auto-scopes reads/writes to that user. Do not substitute CLOUD_SERVICE_TOKEN and hand-roll an owner filter for this case.",
            "CLOUD_SERVICE_TOKEN bypasses row-level security entirely. Creating a record on an rls_enabled entity with the service token requires an explicit top-level owner_user_id in the request body (not inside data) — Cloud rejects the create with 400 otherwise. Only use the service token path when there is no live user session to forward (e.g. a webhook handler), and add your own where scoping if the read must stay per-user.",
            "An owner_managed entity (e.g. business-directory's business_profile) is the opposite of rls_enabled for reads: everyone can list/query/read every record — it's a public directory, not private data. Only create/patch/delete are owner-restricted. Forward the signed-in app-user's own Authorization header to Cloud for create/patch/delete, exactly like an rls_enabled entity (Cloud stamps the owner on create and 403s a patch/delete from anyone else) — but write your discovery/browse/search routes with no owner filter at all, and never call bulk-update or bulk-delete as an app_user on such an entity (Cloud rejects both; use the single-record routes). An owner_managed entity that is ALSO owner_singleton (business_profile is; most owner_managed entities are not) additionally rejects a second create from the same owner (409) — one profile per user — so a \"create profile\" route should check for that and route to \"edit\" instead of erroring.",
            "connections-networking's business_connection is owner_managed but NOT owner_singleton (a business has many connections) — model a connection as two independent rows, one per side, never a single shared row both parties edit: the requester creates {my_business_record_id: their own, counterparty_business_record_id: the other business, status: \"pending\"}; the recipient can read that row (owner_managed reads are public) but cannot patch it (not their owner_user_id) — to accept or decline they create their OWN row with the same two ids swapped and status \"accepted\"/\"declined\". Read the relationship between two businesses as: accepted if either row has status accepted, otherwise whatever the most recent row says. Before creating a business_connection row, verify my_business_record_id actually belongs to the signed-in user — fetch GET .../entities/business_profile/records/{id} and check owner_user_id === session.user.id — the same verify-before-trust pattern as client-portal-create-account's app_user_id check; Cloud does not check this field's contents for you, only the reserved owner_user_id column.",
            "social-feed's business_post/post_reaction/post_comment are all owner_managed, none owner_singleton — same verify-before-trust rule as business_connection applies to author_business_record_id/business_record_id on all three. A \"share\" is just a new business_post with shared_from_post_record_id set to the original — do not build a separate share entity or a share counter field on the original post; count shares by querying business_post where shared_from_post_record_id equals the original's id. \"Like\" and \"save\" are one post_reaction entity distinguished by reaction_type — create with upsert_keys: [\"post_record_id\", \"business_record_id\", \"reaction_type\"] so double-clicking doesn't duplicate the row, and remove a reaction by deleting that row (owner_managed delete already permits this), never by patching a status field. A post's own author cannot delete comments left by other businesses on it — owner_managed only lets a row's own creator delete it — so comment moderation on your own post is not supported in v1; route true moderation (staff removing abusive content) through an admin route using CLOUD_SERVICE_TOKEN instead.",
            "messaging's conversation_membership/message are rls_enabled, NOT owner_managed — messages are private to participants, unlike business-directory/social-feed's public content. A conversation has multiple readers, and rls_enabled only scopes to one owner, so both entities are fanned out: starting a conversation or sending a message creates one row PER PARTICIPANT (including the sender), each with owner_user_id set explicitly to that participant. An app_user's own JWT can only stamp its own identity as owner (Cloud rejects anything else), so this fan-out write must go through your BFF using CLOUD_SERVICE_TOKEN with an explicit owner_user_id per call — and it must be N separate single-record POSTs, not one bulk create (bulk-create applies one resolved owner to the whole array). Reads are the opposite: forward the signed-in app-user's own Authorization header exactly like any other rls_enabled entity — \"my conversations\" and \"my messages in conversation X\" are plain scoped queries, no service token needed. Marking your own message copy read is a normal owner-JWT PATCH (you own that row). Since v1.1.0 messaging is generic and standalone (no requires_packs): participant_ref/sender_ref (conversation_membership/message respectively) are optional, plain-text DISPLAY fields, not a required relation to business_profile — ownership/privacy come entirely from owner_user_id, never from these fields, so there is no verify-before-trust check needed on them unless your own project chooses to populate one with another entity's id. conversation_membership.other_participant_refs is a json list of the other participants' identifiers for display only, not an ACL. v1 has no add-participant-later flow (fixed at conversation start) and doesn't suit very large groups (one write per member per message) — both are known, deliberate limitations, not bugs to work around.",
            "content-recommendations' content_relation entity is plain (no owner_managed/rls_enabled) — it stores only curated relations (source_entity_key/source_record_id -> target_entity_key/target_record_id, relation_type: upsell/cross_sell/related, optional rank), written through an admin/CLOUD_SERVICE_TOKEN route, never by app users directly. Two other recommendation modes need NO stored entity and must not be built as one: \"same category\"/\"same tag\" discovery is a live records.query filtering the install's configured content_entity_key on its category/tags field (tag matching works via plain eq/in because multi_select fields are indexed one row per selected value); \"similar items\" is a live call to POST .../ai/search over whatever field the project embedded via ai/embed, always fresh and never persisted here — treat AI being unconfigured as a graceful no-results case, not an error, since this pack declares no connector requirement and runs on Cloud's own project-native AI credits, not a BYOK connector. Resolve content_entity_key/content_category_field_key/content_tags_field_key/content_text_field_key from this install's own config; never hardcode \"product\" or \"article\".",
            "classifieds' classified_listing is owner_managed but NOT owner_singleton (a seller posts many listings) — same public-read/owner-write mechanics as business-directory/social-feed above, and deliberately standalone (no requires_packs on business-directory): a classifieds seller is just a signed-in user, so seller_ref is an optional plain-text display field like messaging's participant_ref, not a relation to business_profile. tags is multi_select with a fixed marketplace vocabulary already declared on the entity — Cloud requires multi_select fields to have a fixed, non-empty options list, so true free-text tagging is not supported here; do not let sellers type arbitrary tags. \"Message the seller\" is an optional integration with the (now-generic) messaging pack — start a fresh conversation when a buyer clicks it, there is no stored link between a listing and its conversations, and the listing works fine standalone if messaging isn't installed; fall back to displaying seller_contact_name in that case.",
            "UI state and query caches are allowed; they are not authoritative persistence.",
            "email_templates.catalog lists every customizable transactional/pack email (Cloud-owned defaults). email_templates.current is this project's live D1 overrides. p2hs/email-templates.json is replace-all desired state: the listed template_key entries become the entire override set; keys omitted from the file revert to the platform default (they are not kept). An absent file leaves Cloud state unchanged; an empty templates array clears all overrides. Every protected_placeholder for a template_key must still appear literally in its source text_body (and html_body, if set). Overrides may only interpolate catalog variables for that key; unknown placeholders are rejected. Missing required render variables fail Cloud render — they do not leak {{tokens}} into email.send. enabled:false on an optional override means ignore that override and use the platform default — it does not skip email.send. Cloud HTML-escapes runtime variable values inserted into html_body; subject and text_body stay raw text. Variables are string data, not HTML fragments — do not pre-escape. To consume a catalog email, declare an EmailTemplateInvocation (template_key + field/literal variable bindings), POST Cloud render, then email.send the concrete subject/text/html. Forms email.send actions may use additive template content mode (same invocation) or frozen literal subject/text/html, never both. Do not put template_key on email.send and do not implement {{var}} substitution in generated app code. Never hardcode new subject/body copy into a function for these keys.",
            "Git commits are authored by P2HS only. Never add Co-authored-by Cursor or Made-with: Cursor.",
        ],
    }


def _compact_entities(database: Any) -> dict[str, Any]:
    if not isinstance(database, dict):
        return {}
    entities = []
    for entity in database.get("entities") or []:
        if not isinstance(entity, dict):
            continue
        fields = entity.get("fields") or []
        entities.append({
            "key": entity.get("key"),
            "label": entity.get("label"),
            "rls_enabled": entity.get("rls_enabled"),
            "owner_managed": entity.get("owner_managed"),
            "fields": [
                field.get("key") if isinstance(field, dict) else field
                for field in fields
            ],
        })
    return {
        "schema_status": database.get("schema_status"),
        "schema_source": database.get("schema_source"),
        "persistence": database.get("persistence"),
        "entities": entities,
    }


def _compact_surface(surface: Any) -> dict[str, Any]:
    if not isinstance(surface, dict):
        return {}
    compact: dict[str, Any] = {}
    for group, operations in surface.items():
        if not isinstance(operations, dict):
            compact[group] = operations
            continue
        compact[group] = {}
        for name, operation in operations.items():
            if not isinstance(operation, dict):
                compact[group][name] = operation
                continue
            compact[group][name] = {
                key: operation[key]
                for key in ("port_id", "method", "path", "auth", "transport")
                if key in operation
            }
    return compact


def _compact_packs(raw: Any) -> dict[str, Any]:
    if not isinstance(raw, dict):
        return {}
    installed = [
        {
            "slug": pack.get("slug"),
            "status": pack.get("status"),
            "config": pack.get("config"),
        }
        for pack in (raw.get("installed") or [])
        if isinstance(pack, dict)
    ]
    catalog = [
        {"slug": pack.get("slug"), "name": pack.get("name")}
        for pack in (raw.get("catalog") or [])
        if isinstance(pack, dict)
    ]
    return {"installed": installed, "catalog": catalog}


def _compact_connectors(raw: Any) -> dict[str, Any]:
    if not isinstance(raw, dict):
        return {}
    provider_actions = raw.get("provider_actions") or {}
    compact_actions: dict[str, Any] = {}
    if isinstance(provider_actions, dict):
        for slug, meta in provider_actions.items():
            if not isinstance(meta, dict):
                continue
            compact_actions[str(slug)] = {
                "role": meta.get("role") or "escape_hatch",
                "implements": meta.get("implements") or [],
                "when": meta.get("when"),
            }
    items = raw.get("items") or {}
    capabilities = raw.get("capabilities") or {}
    compact_capabilities: dict[str, Any] = {}
    if isinstance(capabilities, dict):
        for key, cap in capabilities.items():
            if not isinstance(cap, dict):
                compact_capabilities[str(key)] = cap
                continue
            compact_capabilities[str(key)] = {
                field: cap[field]
                for field in (
                    "id",
                    "status",
                    "invocable",
                    "request_type",
                    "method",
                    "runtime",
                    "role",
                    "implemented_by",
                    "codegen",
                )
                if field in cap
            }
    return {
        "capabilities": compact_capabilities,
        "provider_actions": compact_actions,
        "provider_actions_role": "escape_hatch",
        "execution_contract": raw.get("execution_contract") or {},
        "connected": list(items.keys()) if isinstance(items, dict) else [],
    }


def compact_platform_backend_for_prompt(capabilities: dict[str, Any]) -> dict[str, Any]:
    """Drop request schemas and pack novels so Cursor stays under context limits.

    Agents call installed `/api` routes from `p2hs/app-recipe.json`. Full JSON
    Schema remains in the Platform Port Catalog, not in `.cursorrules`.
    """
    keep = (
        "contract_name",
        "contract_version",
        "runtime_contract",
        "backend",
        "dependency_inversion",
        "project",
        "runtime_env",
        "env_vars",
        "auth",
        "provisioning",
        "entitlements",
        "adopt",
    )
    compact = {key: capabilities[key] for key in keep if key in capabilities}
    compact["database"] = _compact_entities(capabilities.get("database"))
    compact["api_surface"] = _compact_surface(capabilities.get("api_surface"))
    compact["feature_packs"] = _compact_packs(capabilities.get("feature_packs"))
    compact["connectors"] = _compact_connectors(capabilities.get("connectors"))
    functions = capabilities.get("functions") or {}
    if isinstance(functions, dict):
        compact["functions"] = {
            "project": [
                {"slug": fn.get("slug"), "trigger_type": fn.get("trigger_type")}
                if isinstance(fn, dict)
                else fn
                for fn in (functions.get("project") or [])
            ],
            "system": [
                fn.get("slug") if isinstance(fn, dict) else fn
                for fn in (functions.get("system") or [])
            ],
        }
    compact["rules"] = list(capabilities.get("rules") or [])[:14]
    compact["ui_recipes"] = "p2hs/app-recipe.json"
    return compact
