"""Private discovery specification and a smaller public conversation contract."""

from typing import Any, Literal
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field, model_validator

from src.domain.projects.entity_requirements import EntityFieldRequirement
from src.domain.projects.form_requirements import FormRequirement

Impact = Literal["low", "medium", "high", "blocking"]
DiscoveryDecision = Literal["clarify", "ready", "out_of_scope", "blocked"]
ProjectType = Literal[
    "react_app", "nuxt_app", "html", "shopify_app", "ai_assist_shopify", "ai_assist_wordpress"
]
RequestedCapability = Literal[
    "auth",
    "records",
    "storage",
    "functions",
    "feature_packs",
    "connectors",
    "realtime",
    "ai",
    "subscriptions",
]


class ProductDefinition(BaseModel):
    model_config = ConfigDict(extra="forbid")
    name: str | None = None
    name_status: Literal["user_defined", "working", "unknown"] = "unknown"
    purpose: str = ""
    target_audience: list[str] = Field(default_factory=list, max_length=8)
    first_build_goal: str = ""


class Actor(BaseModel):
    model_config = ConfigDict(extra="forbid")
    id: str = Field(max_length=64)
    name: str = Field(max_length=120)
    description: str | None = Field(default=None, max_length=500)
    access_scope: str | None = Field(default=None, max_length=500)


class WorkflowStep(BaseModel):
    model_config = ConfigDict(extra="forbid")
    name: str = Field(max_length=120)
    actor_id: str | None = Field(default=None, max_length=64)
    description: str = Field(max_length=500)


class Workflow(BaseModel):
    model_config = ConfigDict(extra="forbid")
    id: str = Field(max_length=64)
    name: str = Field(max_length=120)
    description: str = Field(max_length=1000)
    steps: list[WorkflowStep] = Field(default_factory=list, max_length=12)
    outcome: str | None = Field(default=None, max_length=500)


class Entity(BaseModel):
    model_config = ConfigDict(extra="forbid")
    id: str = Field(max_length=64)
    name: str = Field(
        max_length=120,
        description="Persistent business concept. Preserve the user's explicit noun phrase and "
        "grammatical number when available; do not substitute a broader/narrower synonym.",
    )
    description: str = Field(max_length=500)
    ownership: str | None = Field(default=None, max_length=500)
    lifecycle: list[str] = Field(default_factory=list, max_length=12)


class BusinessRule(BaseModel):
    model_config = ConfigDict(extra="forbid")
    id: str = Field(max_length=64)
    rule: str = Field(max_length=500)
    impact: Impact = "medium"
    related_entities: list[str] = Field(default_factory=list, max_length=12)
    related_actors: list[str] = Field(default_factory=list, max_length=12)


class AuthenticationSpec(BaseModel):
    model_config = ConfigDict(extra="forbid")
    required: bool
    methods: list[str] = Field(default_factory=list, max_length=8)
    audiences: list[str] = Field(default_factory=list, max_length=4)
    # Keep the Python-facing contract as a mapping, but expose only the
    # canonical audience keys to OpenAI Structured Outputs. Free-form object
    # keys are incompatible with strict schemas, which require explicit
    # properties, all properties required, and additionalProperties=false.
    audience_methods: dict[str, list[str]] = Field(
        default_factory=dict,
        json_schema_extra={
            "properties": {
                "staff": {"type": "array", "items": {"type": "string"}},
                "customer": {"type": "array", "items": {"type": "string"}},
            },
            "required": ["staff", "customer"],
            "additionalProperties": False,
        },
    )
    signup_audiences: list[str] = Field(default_factory=list, max_length=4)
    notes: str | None = Field(default=None, max_length=500)


class TenancySpec(BaseModel):
    model_config = ConfigDict(extra="forbid")
    model: Literal[
        "single_workspace",
        "organization_workspace",
        "individual_account",
        "mixed",
        "not_applicable",
        "unknown",
    ]
    description: str | None = Field(default=None, max_length=500)


class BillingSpec(BaseModel):
    model_config = ConfigDict(extra="forbid")
    required: bool = False
    model: str | None = Field(default=None, max_length=200)
    payer: str | None = Field(default=None, max_length=200)
    billing_owner: str | None = Field(default=None, max_length=200)
    notes: str | None = Field(default=None, max_length=500)


class IntegrationSpec(BaseModel):
    model_config = ConfigDict(extra="forbid")
    id: str = Field(max_length=64)
    provider: str | None = Field(default=None, max_length=120)
    purpose: str = Field(max_length=500)
    required_for_first_build: bool = True
    direction: Literal["inbound", "outbound", "bidirectional", "unknown"] = "unknown"


class NotificationSpec(BaseModel):
    model_config = ConfigDict(extra="forbid")
    trigger: str = Field(max_length=300)
    recipient: str = Field(max_length=200)
    channel: str | None = Field(default=None, max_length=80)


class DesignPreference(BaseModel):
    model_config = ConfigDict(extra="forbid")
    style: str | None = Field(default=None, max_length=200)
    brand_reference: str | None = Field(default=None, max_length=500)
    visual_constraints: list[str] = Field(default_factory=list, max_length=8)


class Assumption(BaseModel):
    model_config = ConfigDict(extra="forbid")
    id: str = Field(max_length=64)
    statement: str = Field(max_length=500)
    basis: Literal["conventional_default", "inferred_from_context", "user_delegated_default"]
    impact: Impact = "low"
    reversible: bool = True
    requires_confirmation: bool = False


class OpenDecisionOption(BaseModel):
    model_config = ConfigDict(extra="forbid")
    id: str = Field(max_length=64)
    label: str = Field(max_length=200)
    description: str | None = Field(default=None, max_length=300)


class OpenDecision(BaseModel):
    model_config = ConfigDict(extra="forbid")
    id: str = Field(max_length=64)
    question: str = Field(max_length=500)
    reason: str = Field(max_length=500)
    impact: Impact
    affects: list[str] = Field(default_factory=list, max_length=8)
    recommended_option_id: str | None = Field(default=None, max_length=64)
    options: list[OpenDecisionOption] = Field(default_factory=list, max_length=6)
    allow_free_text: bool = True


class DiscoverySpec(BaseModel):
    model_config = ConfigDict(extra="forbid")
    product: ProductDefinition = Field(default_factory=ProductDefinition)
    archetypes: list[str] = Field(
        default_factory=list,
        max_length=6,
        description="Established product archetypes from the Discovery prompt, used for readiness "
        "dimensions. Empty when none is established; not an implementation wish list.",
    )
    actors: list[Actor] = Field(
        default_factory=list,
        max_length=12,
        description=(
            "People or roles established by the request; "
            "do not invent a generic role ladder."
        ),
    )
    workflows: list[Workflow] = Field(
        default_factory=list,
        max_length=8,
        description="All established primary processes, including when decision is clarify. "
        "Actions in one end-to-end process are steps; independent processes remain separate. "
        "Empty only if no meaningful workflow is established.",
    )
    entities: list[Entity] = Field(
        default_factory=list,
        max_length=16,
        description=(
            "Persistent business entities supported by the request, "
            "including when decision is clarify. Retain distinct explicitly "
            "named concepts; exclude speculative technical tables. "
            "Empty when no business entity is established."
        ),
    )
    entity_requirements: list[EntityFieldRequirement] = Field(
        default_factory=list,
        max_length=16,
        description="Core-resolved, full (not incremental) field requirement per entity "
        "named in `entities`, built up over time from structured per-prompt deltas. "
        "Empty for an entity with no resolved fields yet -- see "
        "apply_entity_field_requirement_delta. Never written to directly.",
    )
    business_rules: list[BusinessRule] = Field(default_factory=list, max_length=16)
    authentication: AuthenticationSpec | None = None
    tenancy: TenancySpec | None = None
    billing: BillingSpec | None = None
    integrations: list[IntegrationSpec] = Field(default_factory=list, max_length=12)
    notifications: list[NotificationSpec] = Field(default_factory=list, max_length=12)
    design: DesignPreference | None = None
    original_intent: str = Field(
        default="",
        max_length=300,
        description="The customer's own product phrasing, retained even when purpose is "
        "paraphrased. Example: 'student admissions CRM'. Empty only when the request "
        "has no usable phrasing. Not an App Block, Feature Pack, or API name.",
    )
    domain: str = Field(
        default="",
        max_length=120,
        description="Vertical/domain clue in customer language such as student admissions, "
        "legal intake, construction punch list, or vendor onboarding. Never a backend, "
        "App Block, Feature Pack, or store name.",
    )
    requested_product_shape: str = Field(
        default="",
        max_length=80,
        description="Customer-requested product shape such as CRM, portal, intake, or "
        "dashboard. Not a canonical platform identity.",
    )
    forms: list[str] = Field(
        default_factory=list,
        max_length=8,
        description="Named forms the customer asked for, in their wording. Empty when "
        "no form is established. Do not invent a form engine or API.",
    )
    form_requirements: list[FormRequirement] = Field(
        default_factory=list,
        max_length=8,
        description="Core-resolved, full (not incremental) field requirement per form "
        "named in `forms`, built up over time from structured per-prompt deltas. "
        "Empty for a form with no customization yet -- the Forms runtime then keeps "
        "its historical canonical definition. Never written to directly; always "
        "through apply_form_requirement_delta so this stays the single resolved state.",
    )
    explicit_exclusions: list[str] = Field(
        default_factory=list,
        max_length=12,
        description="Explicit negatives copied verbatim, including the negation words: "
        "'do not add CRM/customer records'. Empty when the customer stated none.",
    )
    requires_configuration: list[str] = Field(
        default_factory=list,
        max_length=12,
        description="First-build items that need owner or provider setup, in customer "
        "language (connected email, booking calendar). Do not name App Blocks, Feature "
        "Packs, APIs, permissions, or capabilities.",
    )
    assumptions: list[Assumption] = Field(default_factory=list, max_length=20)
    open_decisions: list[OpenDecision] = Field(default_factory=list, max_length=12)
    requirements: list[str] = Field(default_factory=list, max_length=20)
    deferred_scope: list[str] = Field(default_factory=list, max_length=20)
    acceptance_outcomes: list[str] = Field(default_factory=list, max_length=16)


class DiscoveryQuestion(BaseModel):
    model_config = ConfigDict(extra="forbid")
    decision_id: str = Field(max_length=64)
    text: str = Field(max_length=500)
    options: list[OpenDecisionOption] = Field(default_factory=list, max_length=6)
    recommended_option_id: str | None = Field(default=None, max_length=64)
    allow_free_text: bool = True
    allow_choose_for_me: bool = True


class DiscoveryReadiness(BaseModel):
    model_config = ConfigDict(extra="forbid")
    buildable: bool = False
    blocking_decision_ids: list[str] = Field(default_factory=list, max_length=12)
    reason: str = Field(default="", max_length=500)


class DiscoveryTurnResult(BaseModel):
    """Model output for one discovery turn. Core still compiles the private brief."""

    model_config = ConfigDict(extra="forbid")
    decision: DiscoveryDecision
    message: str = Field(min_length=1, max_length=3000)
    question: DiscoveryQuestion | None = None
    spec: DiscoverySpec
    readiness: DiscoveryReadiness = Field(default_factory=DiscoveryReadiness)
    requested_capabilities: list[RequestedCapability] = Field(default_factory=list, max_length=9)
    project_type: ProjectType
    template_ref: str | None = None
    template_fit: Literal["none", "partial", "strong"] = "none"
    template_reason: str = Field(default="", max_length=1000)

    @model_validator(mode="after")
    def consistent_turn(self):
        if self.decision == "ready" and self.question is not None:
            raise ValueError("A ready discovery cannot ask a follow-up question")
        return self


class DiscoveryResult(DiscoveryTurnResult):
    """Persisted turn plus Core-authored brief fields consumed by apply/codegen."""

    product_name: str = Field(default="", max_length=255)
    summary: str = Field(default="", max_length=3000)
    specification: str = Field(default="", max_length=40000)
    assumptions: list[str] = Field(default_factory=list, max_length=20)
    questions: list[str] = Field(default_factory=list, max_length=1)
    # Core attaches these fields after the model turn is accepted. They are
    # persisted with the discovery result and must remain valid when the apply
    # worker reloads the approved result for the first codegen run.
    build_plan: dict[str, Any] = Field(default_factory=dict)
    cloud_contract: dict[str, Any] | None = None
    cloud_contract_sha256: str | None = None

    @model_validator(mode="after")
    def complete_brief(self):
        if self.decision == "ready" and (
            not self.product_name.strip()
            or not self.summary.strip()
            or not self.specification.strip()
            or self.question is not None
            or self.questions
        ):
            raise ValueError(
                "A ready discovery needs a name, summary, specification and no unanswered questions"
            )
        return self


class DiscoveryAnswerInput(BaseModel):
    model_config = ConfigDict(extra="forbid")
    type: Literal["message", "option", "choose_for_me"] = "message"
    question_id: str | None = Field(default=None, max_length=64)
    decision_id: str | None = Field(default=None, max_length=64)
    option_id: str | None = Field(default=None, max_length=64)
    content: str | None = Field(default=None, max_length=20000)


class DiscoveryInput(BaseModel):
    model_config = ConfigDict(extra="forbid")
    expected_version: int = Field(ge=0)
    request_id: UUID
    action: Literal["message", "answer", "approve", "retry", "reset"] = "message"
    message: str = Field(default="", max_length=20000)
    answer: DiscoveryAnswerInput | None = None
    attachment_ids: list[UUID] = Field(default_factory=list, max_length=20)


class PublicQuestionOption(BaseModel):
    id: str
    label: str
    description: str | None = None
    recommended: bool = False


class PublicQuestion(BaseModel):
    id: str
    text: str
    options: list[PublicQuestionOption] = Field(default_factory=list)
    recommended_option_id: str | None = None
    allow_free_text: bool = True
    allow_choose_for_me: bool = True


class CustomerComposition(BaseModel):
    """Customer Discovery copy. Planner ids are not fields on this model."""

    model_config = ConfigDict(extra="ignore")
    building: list[str] = Field(default_factory=list)
    later: list[str] = Field(default_factory=list)


class FirstBuildView(BaseModel):
    name: str = ""
    purpose: str = ""
    people: list[str] = Field(default_factory=list)
    workflow: str = ""
    around: list[str] = Field(default_factory=list)
    decisions: list[str] = Field(default_factory=list)
    deferred_scope: list[str] = Field(default_factory=list)
    capabilities: list[str] = Field(default_factory=list)
    composition: CustomerComposition = Field(default_factory=CustomerComposition)


class DiscoveryView(BaseModel):
    status: str
    version: int = 0
    messages: list[dict[str, str]] = Field(default_factory=list)
    questions: list[str] = Field(default_factory=list)
    question: PublicQuestion | None = None
    summary: str = ""
    assumptions: list[str] = Field(default_factory=list)
    first_build: FirstBuildView | None = None
    error: str | None = None
    error_code: str | None = None
    initial_prompt: str = ""


def public_question(raw: dict | None) -> PublicQuestion | None:
    if not raw or not raw.get("text"):
        return None
    recommended = raw.get("recommended_option_id")
    options = [
        PublicQuestionOption(
            id=item.get("id", ""),
            label=item.get("label", ""),
            description=item.get("description"),
            recommended=bool(recommended and item.get("id") == recommended),
        )
        for item in raw.get("options") or []
        if item.get("id") and item.get("label")
    ]
    return PublicQuestion(
        id=raw.get("decision_id") or raw.get("id") or "current",
        text=raw["text"],
        options=options,
        recommended_option_id=recommended,
        allow_free_text=raw.get("allow_free_text", True),
        allow_choose_for_me=raw.get("allow_choose_for_me", True),
    )


def public_discovery(state: dict | None) -> DiscoveryView:
    if not state:
        return DiscoveryView(status="not_required")
    result = state.get("result") or {}
    spec = result.get("spec")
    question = public_question(result.get("question"))
    return DiscoveryView(
        status=state["status"],
        version=state["version"],
        messages=state.get("messages", []),
        questions=[question.text] if question else result.get("questions") or [],
        question=question,
        summary=result.get("summary", ""),
        assumptions=result.get("assumptions", []),
        first_build=(
            public_first_build(
                spec,
                result,
                source_prompt=state.get("initial_prompt", ""),
                customer_composition=(
                    state.get("customer_composition")
                    if state.get("composition_recipe_pinned")
                    else None
                ),
            )
            if spec and state.get("status") == "ready"
            else None
        ),
        error=state.get("error"),
        error_code=state.get("error_code"),
        initial_prompt=state.get("initial_prompt", ""),
    )


def public_first_build(
    spec: dict,
    result: dict,
    *,
    source_prompt: str = "",
    customer_composition: dict | None = None,
) -> FirstBuildView:
    from src.domain.app_blocks.public import (
        customer_composition_for_spec,
        customer_composition_lists,
    )

    product = spec.get("product") or {}
    workflow = next(iter(spec.get("workflows") or []), {}) or {}
    steps = " → ".join(
        step.get("name", "") for step in workflow.get("steps") or [] if step.get("name")
    )
    people = []
    for actor in spec.get("actors") or []:
        scope = f" — {actor['access_scope']}" if actor.get("access_scope") else ""
        people.append(f"{actor.get('name', '')}{scope}".strip())
    deferred = list(spec.get("deferred_scope") or [])
    if customer_composition is not None:
        composition = customer_composition_lists(customer_composition)
    else:
        composition = customer_composition_for_spec(spec, source_prompt=source_prompt)
    return FirstBuildView(
        name=result.get("product_name") or product.get("name") or "",
        purpose=product.get("purpose") or result.get("summary") or "",
        people=people,
        workflow=steps or workflow.get("description") or "",
        around=[
            entity.get("name", "") for entity in spec.get("entities") or [] if entity.get("name")
        ],
        decisions=[
            rule.get("rule", "") for rule in spec.get("business_rules") or [] if rule.get("rule")
        ],
        deferred_scope=deferred,
        capabilities=list(result.get("requested_capabilities") or []),
        composition=composition,
    )
