"""
PUT /onboarding/products request schema — PRD-HWONB-009.

Discriminated union on `productType`: `standalone`/`integrated` (the
wizard's original CP/CNP toggle) plus `webpass`/`genius`, added later at
explicit user request — PRD-HWONB-009 §0/§5 and PRD-HWONB-001 originally
scoped these two out as non-goals; that decision has been overridden.

CERT findings baked in here (PRD-HWONB-001 §13.1, PRD-HWONB-009):
  - C-16: standalone catalog is `{equipment:[{brand,model,sourceBillTo,
    industryApplication}]}`; integrated catalog is `{integratedProduct:[
    {productName,integrationProductID,configuration.allowedFeature{...}}]}`.
  - C-16 (round 2): create is `POST /applications/{appId}/products` (no id
    in the path) and requires the FULL `features` block (13 booleans,
    including amex/ebt/pinDebit/debitEbtCashback) plus a `pinpadEmvReader`.
  - PRD-HWONB-009 §2 redesign (2026-07-16): `pinpadEmvReader.{brand,model}`
    are NOT a fixed static list and NOT the terminal's own brand/model — they
    must come from the `emv[]` array in GP's *filtered*
    `GET /product/standalone/equipment?brand=&model=&industry=&application=&source=`
    lookup response (all five params required together, or GP 40024s). Each
    terminal's `emv[]` is its own CERT-confirmed, per-device set of valid
    pinpadEmvReader brand/model/sourceBillTo combinations — e.g. live CERT
    confirmed INGENICO/DESK5000 accepts only Ingenico Desk1500/Integrated/
    iPP315, while a device with an empty `emv[]` has no pinpad section at
    all. The Products & Equipment UI (`useEquipmentCascade.ts`) drives this
    dropdown from that response; there is no longer a static
    brand/model fallback list for this field.
  - C-17: `source="STR"` is invalid — omitted from PRODUCT_SOURCE_CHOICES.
  - CERT trial (2026-07-16): the Integrated create path's real write-field
    name is `integrationSwId` — GP rejects the literal `integrationProductID`
    key with "Request expects the mandatory parameter integrationSwId".
    This schema keeps `IntegratedProduct.integrationProductID` as the
    internal/local field name (matching the catalog's own field name and
    the UI form state); `services.py`'s `_save_products_section` renames it
    to `integrationSwId` only on the outbound GP body. The two newer
    `WebPassProduct`/`GeniusProduct` schemas below name the field
    `integrationSwId` directly instead — the guide is unambiguous for both,
    so there's no equivalent judgment call to carry forward for them.
"""

from __future__ import annotations

from decimal import Decimal, InvalidOperation
from typing import Annotated, Any, List, Literal, Optional, Union

from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator

from src.apps.merchant_onboarding.helpers.field_mapping import PRODUCT_SOURCE_CHOICES

# ---------------------------------------------------------------------------
# Shared enums
# ---------------------------------------------------------------------------

# PRD-HWONB-009 §1 — expanded from the prototype's 4-value subset (IP/D/G/W)
# to the full 10-value GP enum so Bluetooth/Ethernet/Serial/USB terminals
# aren't forced into the wrong bucket.
ConnectionMethod = Literal["B", "D", "Q", "G", "I", "N", "S", "U", "F", "W"]
_WIRELESS_CONNECTION_METHODS = {"G", "W"}

ProductSource = Literal["EE", "MO", "PN", "PRF", "PO", "RTL", "TFD"]  # C-17 — no "STR"
_ZERO_FORCED_SOURCES = {"MO", "PO"}  # billTo="N/A"/price omitted forced (AC-3)

# GP Base Boarding API Developer Guide v1.9 p.182 — billTo is a closed enum,
# not free text. "TF" (TransFirst) is a valid GP value but isn't reachable
# from this wizard (no UI path selects it) — included for completeness/type
# safety only.
BillToCode = Literal["MER", "AGT", "TF", "N/A"]

# CERT trial (2026-07-16): for source outside {MO, PO} (i.e. EE, PN, PRF,
# RTL, TFD), GP requires billTo in {MER, AGT} with a real price — sending
# billTo="N/A" here (the earlier bug) gets "Price should not be provided
# when billTo is N/A for standalone". TFD isn't explicitly named in the
# guide's "Conditional Logic (PN, RTL, STR, PRF, EE)" list, but since it's
# also not in the forced-N/A {MO, PO} group, it falls into this bucket by
# elimination.
_MERCHANT_AGENT_BILLTO = {"MER", "AGT"}

assert set(ProductSource.__args__) == set(PRODUCT_SOURCE_CHOICES), (
    "ProductSource enum must stay in sync with helpers.field_mapping.PRODUCT_SOURCE_CHOICES"
)

_MONEY_RE_MSG = "Must be a decimal string with exactly two decimal places, e.g. '12.50'."


def _validate_money_string(value: Optional[str], *, min_value: Decimal = Decimal("0.00")) -> Optional[str]:
    if value is None:
        return value
    try:
        amount = Decimal(value)
    except (InvalidOperation, ValueError):
        raise ValueError(_MONEY_RE_MSG)
    if amount < min_value:
        raise ValueError(f"Must be >= {min_value}.")
    if amount > Decimal("999999.99"):
        raise ValueError("Must be <= 999999.99.")
    # Normalize to exactly two decimal places, matching GP's quoted-string
    # money convention (PRD examples show "0.00", not a bare number).
    return f"{amount.quantize(Decimal('0.01'))}"


def _validate_fee_amount(v: Optional[float]) -> Optional[float]:
    """
    Shared `monthlyFee`/`setupFee` validator for Integrated/WebPass/Genius.

    Base Boarding API Developer Guide V1.9 pp.188-191/222-224: both fields
    are a JSON `number`, Mandatory, range 0.01-999,999.99 (no zero — a prior
    "0.00" default sent as a quoted string is exactly what GP rejects with
    `40213 "setupFee contains unexpected data."`, confirmed by re-reading
    those pages directly). No zero-default special case: the field must be a
    real fee or the request must not be submitted at all.
    """
    if v is None:
        return v
    if not (0.01 <= v <= 999999.99):
        raise ValueError("Must be between 0.01 and 999999.99.")
    return round(v, 2)


# ---------------------------------------------------------------------------
# Standalone (Card-Present) — PRD-HWONB-009 §1/§2
# ---------------------------------------------------------------------------


class StandaloneConfigurations(BaseModel):
    """
    PRD-009 §1 — hidden-defaulted POS-configuration fields, not merchant-
    facing per the prototype's current (accepted-as-v1) behavior.
    """

    # CERT trial (2026-07-16): defaults to "M" (Manual) — every observed
    # successful product create uses Manual, and Manual never requires
    # batchCloseTime. A prior version of this default ("A"/Auto) combined
    # with the validator below checking the wrong branch meant every
    # Standalone create silently shipped an invalid Auto-without-time
    # combination and was rejected by GP; do not revert the default without
    # also collecting batchCloseTime in the UI first.
    batchCloseMethod: Literal["A", "M"] = "M"
    batchCloseTime: Optional[str] = None  # "HH:MM", required if batchCloseMethod == "A"
    customerReceipt: Literal["A", "C", "O"] = "A"
    printSize: Literal["S", "M", "L"] = "M"
    # v1 hidden-defaults to "N" (single terminal) — multi-terminal linking
    # (P/C) is explicitly out of scope for this PRD (§5).
    multiTerminalStatus: Literal["N", "P", "C"] = "N"
    parentMerchantId: Optional[str] = None
    parentTerminalNumber: Optional[str] = None

    @model_validator(mode="after")
    def _validate_conditionals(self) -> "StandaloneConfigurations":
        if self.batchCloseMethod == "A" and not self.batchCloseTime:
            raise ValueError("configurations.batchCloseTime is required when batchCloseMethod='A'.")
        if self.multiTerminalStatus == "C" and (not self.parentMerchantId or not self.parentTerminalNumber):
            raise ValueError(
                "configurations.parentMerchantId and .parentTerminalNumber are required when "
                "multiTerminalStatus='C' (out of scope for v1 — this should never be reachable "
                "while the UI hidden-defaults multiTerminalStatus to 'N')."
            )
        return self


class StandaloneFeatures(BaseModel):
    """
    PRD-009 §1 + CERT round-2 (C-16) — the full 13-boolean `features` block
    GP requires on create. The first 9 mirror the prototype's current
    hidden-defaulted POS-configuration flags; `amex`/`ebt`/`pinDebit`/
    `debitEbtCashback` are the 4 CERT round-2 additions that were missing
    from the earlier PRD draft (their absence caused the earlier 502s).
    """

    avsSupport: bool = True
    promptForInvoiceNumber: bool = False
    promptForCorpCard: bool = False
    promptForECommerce: bool = False
    promptForVerificationCode: bool = True
    tipAtTimeOfSale: bool = False
    tipCalculator: bool = False
    emvContact: bool = True
    nfcContactless: bool = True
    amex: bool = False
    ebt: bool = False
    pinDebit: bool = False
    debitEbtCashback: bool = False


class PinpadEmvReader(BaseModel):
    """
    PRD-009 §1/CERT round-2. When the selected terminal's catalog `emv[]` is
    non-empty, brand/model/source/billTo must come from that `emv[]` array of
    GP's *filtered* standaloneEquipment lookup (see the module docstring's
    PRD-HWONB-009 §2 redesign note) — NOT the terminal's own brand/model, and
    NOT a fixed global enum.

    CERT trial (2026-07-22): when `emv[]` is empty (no separate PIN pad
    applies), GP does NOT accept this object being omitted — it still
    requires `pinpadEmvReader` present with brand="N/A"/model="N/A"
    ("EmvBrand"/"EmvModel must match an accepted value: [N/A]" otherwise),
    billTo="N/A", and `source` mirroring the product's own top-level
    `source` (confirmed live: source="MO" here was accepted once brand/model
    were set to the "N/A" sentinel — omitting the object entirely, or
    sending source alone without brand/model/billTo, both get rejected).
    `StandaloneProduct._apply_derived_and_forced_fields` builds this
    N/A-sentinel object automatically whenever the merchant's selection
    (i.e. an empty `emv[]`) leaves `pinpadEmvReader` unset — never build it
    by hand elsewhere.
    """

    brand: Optional[str] = None
    model: Optional[str] = None
    source: Optional[ProductSource] = None
    billTo: Optional[BillToCode] = None
    price: Optional[str] = None

    @field_validator("price")
    @classmethod
    def _validate_price(cls, v: Optional[str]) -> Optional[str]:
        return _validate_money_string(v)


class StandaloneProduct(BaseModel):
    """PRD-HWONB-009 §1 — Standalone (Card-Present) product body."""

    productType: Literal["standalone"] = "standalone"

    # Derived (AC-4) — always recomputed in the validator below from
    # connectionMethod; never independently merchant-settable. Accepted as
    # an input field only so a naive client echo doesn't 422; its value is
    # ignored and overwritten.
    wirelessActivationRequired: bool = False

    connectionMethod: ConnectionMethod
    version: Optional[str] = None  # SIM ICCID — required if connectionMethod='G' or brand='UNSUPPORTED'
    unsupportedPos: Optional[Literal["Y", "N"]] = None

    configurations: StandaloneConfigurations = Field(default_factory=StandaloneConfigurations)
    features: StandaloneFeatures = Field(default_factory=StandaloneFeatures)

    brand: str  # DB-driven catalog (GET .../standalone/equipment/list)
    model: str  # DB-driven catalog, filtered by brand

    source: ProductSource
    billTo: Optional[BillToCode] = None  # forced "N/A" when source in {MO, PO} (AC-3)
    price: Optional[str] = None  # forced to omitted (null) when source in {MO, PO} (AC-3)
    priceIncludesMarkUp: Optional[bool] = False  # forced to omitted (null) alongside price for {MO, PO}

    industry: str
    # CERT trial (2026-07-16): GP requires this whenever brand/model/industry/
    # source are sent — "Either all parameters need to be given, or none
    # should be given: brand, model, industry, application, source" (guide
    # p.183). It is NOT resolved by GP itself (the prior comment here was
    # wrong) — it must be one of the values under the standalone equipment
    # catalog's `industryApplication[industry]` list for the chosen
    # brand+model (e.g. GET .../standalone/equipment/list).
    application: str = Field(..., min_length=1, max_length=15)

    # Optional as a merchant input (catalog-driven — see PinpadEmvReader
    # docstring), but never null in the outbound GP body: the validator below
    # fills in the N/A-sentinel object when the merchant's selection has no
    # separate PIN pad (`emv[]` empty).
    pinpadEmvReader: Optional[PinpadEmvReader] = None

    @field_validator("price")
    @classmethod
    def _validate_price(cls, v: Optional[str]) -> Optional[str]:
        return _validate_money_string(v)

    @model_validator(mode="after")
    def _apply_derived_and_forced_fields(self) -> "StandaloneProduct":
        # AC-4 — wirelessActivationRequired is derived, never merchant-settable.
        self.wirelessActivationRequired = self.connectionMethod in _WIRELESS_CONNECTION_METHODS

        # See PinpadEmvReader docstring (CERT trial 2026-07-22) — GP requires
        # this object even when no separate PIN pad applies; fill in the
        # N/A-sentinel shape rather than omitting it.
        if self.pinpadEmvReader is None:
            self.pinpadEmvReader = PinpadEmvReader(brand="N/A", model="N/A", source=self.source, billTo="N/A")

        # PRD §1 — version (SIM ICCID) required when connectionMethod='G' or
        # using non-catalog ("UNSUPPORTED") equipment; unsupportedPos flag
        # required in the latter case too.
        if self.connectionMethod == "G" and not self.version:
            raise ValueError("version (SIM ICCID) is required when connectionMethod='G'.")
        if self.brand == "UNSUPPORTED":
            if not self.version:
                raise ValueError("version is required when brand='UNSUPPORTED'.")
            if not self.unsupportedPos:
                raise ValueError("unsupportedPos is required when brand='UNSUPPORTED'.")

        # AC-3 — force-lock billTo/price server-side regardless of what the
        # client sent, for source in {MO, PO}. This mirrors (and does not
        # rely solely on) the client-side disabled-input behavior.
        #
        # CERT trial (2026-07-16): GP's actual accepted requests for MO/PO
        # send price as `null` (or omit it) — NOT "0.00" as an earlier
        # reading of the guide's prose assumed. Sending a non-null price
        # alongside billTo="N/A" gets rejected with "Price should not be
        # provided when billTo is N/A for standalone", regardless of the
        # price's value. priceIncludesMarkUp is dropped the same way (no
        # Postman success example for MO/PO includes that key either).
        if self.source in _ZERO_FORCED_SOURCES:
            self.billTo = "N/A"
            self.price = None
            self.priceIncludesMarkUp = None
        else:
            # CERT trial (2026-07-16) — guide p.182 "Conditional Logic (PN,
            # RTL, STR, PRF, EE)": billTo must be Merchant/Agent and a real
            # price is required. This is the inverse of the AC-3 branch
            # above and was previously unenforced, letting a merchant type
            # billTo="N/A" for e.g. source="EE" straight through to GP.
            if self.billTo not in _MERCHANT_AGENT_BILLTO:
                raise ValueError(
                    f"billTo must be 'MER' or 'AGT' when source='{self.source}' "
                    "('N/A'/'TF' is only valid for source in {MO, PO})."
                )
            if self.price is None or Decimal(self.price) <= 0:
                raise ValueError(
                    f"price is required and must be greater than 0.00 when source='{self.source}'."
                )

        # Confirmed-absent fields (do not add, PRD §1): encryption,
        # p2peDeploymentFee/p2peMonthlyFee, staticIp{} — these belong only
        # to the Integrated/WebPASS/Genius Equipment sub-resource, never to
        # this Standalone product body. Nothing in this schema defines
        # them, so there is nothing further to strip here — documented for
        # anyone tempted to add them back (a prior iteration correctly
        # removed a staticIp block from this screen; do not revert that).
        return self


# ---------------------------------------------------------------------------
# Integrated (Card-Not-Present) — PRD-HWONB-009 §3/§4
# ---------------------------------------------------------------------------


class IntegratedWebIntegration(BaseModel):
    integratorWebsite: Optional[str] = None
    domainUrl: str
    integratorEmail: EmailStr
    integrationWebsites: Optional[List[str]] = None


class IntegratedTokenConfig(BaseModel):
    """PRD-009 §3 — Custom Token Zone / Genius Zone; advanced, out of v1 UI scope, hidden-null-defaulted."""

    tokenType: Optional[Literal["D", "C", "P"]] = None
    customTokenZone: Optional[Literal["A", "N"]] = None
    alternateMid: Optional[str] = None

    @model_validator(mode="after")
    def _validate_conditionals(self) -> "IntegratedTokenConfig":
        if self.tokenType == "C" and not self.customTokenZone:
            raise ValueError("token.customTokenZone is required when tokenType='C'.")
        if self.customTokenZone == "A":
            if not self.alternateMid or not (10 <= len(self.alternateMid) <= 16):
                raise ValueError("token.alternateMid (10-16 digits) is required when customTokenZone='A'.")
        return self


class IntegratedConfigurations(BaseModel):
    partialAuthRequired: bool = True
    enhancedDataRequired: bool = False
    mandatorySecurityCodeRequired: Optional[Literal["Y", "N"]] = "N"
    locationType: Literal["S", "H", "O"]
    headquarterMid: Optional[str] = None  # required if locationType == 'O', 10-16 digits
    welcomeEmailReceiver: Optional[str] = None  # defaulted to the integrator email (see IntegratedProduct validator)
    clientId: Optional[str] = None
    # CERT-confirmed (2026-07-28): the guide's own sample requests/responses
    # (Integrated pp.190-192, WebPASS pp.199-204, Genius pp.202-204) always
    # send `token` as an object (empty or all-null sub-fields) — never
    # omitted/null. A prior `None` default serialized to JSON `null` via
    # `payload.model_dump()`, which GP now rejects with
    # `MANDATORY_DATA_MISSING "configurations.token is mandatory"`.
    token: IntegratedTokenConfig = Field(default_factory=IntegratedTokenConfig)

    @model_validator(mode="after")
    def _validate_conditionals(self) -> "IntegratedConfigurations":
        if self.locationType == "O":
            if not self.headquarterMid or not (10 <= len(self.headquarterMid) <= 16):
                raise ValueError("configurations.headquarterMid (10-16 digits) is required when locationType='O'.")
        return self


class IntegratedProduct(BaseModel):
    """
    PRD-HWONB-009 §3 — Integrated (Card-Not-Present) product body.

    `equipments` is deliberately left empty — the Integrated/WebPASS/Genius
    Equipment sub-resource (encryption, P2PE fees, staticIp, etc.) is out of
    scope for this PRD (§5); build as a separate fast-follow if HubWallet
    later needs Integrated-with-hardware merchants.
    """

    productType: Literal["integrated"] = "integrated"

    # See module docstring — write-field-name judgment call. Sourced from
    # the integrated catalog's `integrationProductID` (GET /product/integrated,
    # C-16), min 1 per PRD §3 (AC-2: never null).
    integrationProductID: int = Field(..., ge=1)

    # CERT-confirmed (2026-07-28, swId=129 "TSEP Test"): GP's real accepted
    # value is "MultiPASS" (all-caps PASS), not the Developer Guide's
    # "MultiPass" spelling — sending the guide's casing gets rejected with
    # "tsepIntegratedWith must be MultiPASS or Sierra".
    tsepIntegratedWith: Literal["MultiPASS", "Sierra", "NONE"] = "NONE"

    # AC-5 — this must reflect whether any of the merchant's bank accounts
    # have an ACH usage type (ACHF/ACHS), NOT a hardcoded default. The
    # client-submitted value here is advisory only; services.save_section()
    # always recomputes and overwrites it from the merchant_onboarding_accounts
    # rows before persisting/pushing, since that check requires DB access
    # this schema doesn't have.
    ach: bool = False

    monthlyFee: float
    setupFee: float

    webIntegration: IntegratedWebIntegration
    configurations: IntegratedConfigurations
    equipments: List[Any] = Field(default_factory=list)

    @field_validator("monthlyFee", "setupFee")
    @classmethod
    def _validate_fee(cls, v: float) -> float:
        return _validate_fee_amount(v)

    @model_validator(mode="after")
    def _apply_defaults(self) -> "IntegratedProduct":
        if not self.configurations.welcomeEmailReceiver:
            self.configurations.welcomeEmailReceiver = self.webIntegration.integratorEmail
        return self


# ---------------------------------------------------------------------------
# WebPASS / Genius — added at explicit user request, overriding this
# module's prior "explicitly out of scope (PRD-HWONB-009 §0/§5,
# PRD-HWONB-001 non-goals)" stance. Field shapes per the Base Boarding API
# Developer Guide V1.9's own "Create Product - WebPASS/Genius - POST"
# sections, reusing `IntegratedWebIntegration`/`IntegratedConfigurations`
# wherever the guide documents an identical shape (both product types share
# the same `webIntegration`/`configurations`/token block as Integrated).
# ---------------------------------------------------------------------------


class CardTypesFlags(BaseModel):
    """Shared `cardTypes` (WebPass) / `additionalCardTypes` (Genius) block — GP names the key
    differently per product type; the field shape underneath is identical."""

    pinDebitReq: bool = False
    ebtReq: bool = False
    debitEbtCashBackReq: bool = False
    amexReq: bool = False


class WebPassEquipmentItem(BaseModel):
    """
    WebPASS's own flat equipment-item shape — distinct from Standalone's
    nested `configurations`/`features`/`pinpadEmvReader` split; GP documents
    every equipment attribute directly on the item itself.
    """

    equipId: Optional[int] = None
    brand: str
    model: str
    source: ProductSource
    billTo: BillToCode
    price: Optional[str] = None
    priceIncludesMarkUp: Optional[bool] = False
    industry: str
    application: str
    encryption: Optional[int] = None
    connectionMethod: ConnectionMethod
    wirelessActivationRequired: bool = False
    protocolType: Optional[str] = None
    # Same corrected batchCloseMethod/batchCloseTime rule as
    # StandaloneConfigurations — default Manual, no reason to reintroduce
    # the always-fails-on-Auto-without-time bug here.
    batchCloseMethod: Literal["A", "M"] = "M"
    batchCloseTime: Optional[str] = None
    emvContact: bool = False
    nfcContactless: bool = False
    partialAuth: bool = False
    avsSupport: bool = False
    invoiceSupport: bool = False
    tipAdjustment: bool = False
    tipAtTimeOfSale: bool = False
    signatureCapture: bool = False
    corpPurchaseSupport: bool = False
    debitEbtCashbackSupport: bool = False
    pinDebit: bool = False
    ebtInd: bool = False
    tipCalculator: bool = False
    accessories: List[Any] = Field(default_factory=list)

    @field_validator("price")
    @classmethod
    def _validate_price(cls, v: Optional[str]) -> Optional[str]:
        return _validate_money_string(v)

    @model_validator(mode="after")
    def _validate_batch_close(self) -> "WebPassEquipmentItem":
        if self.batchCloseMethod == "A" and not self.batchCloseTime:
            raise ValueError("equipments[].batchCloseTime is required when batchCloseMethod='A'.")
        return self


class WebPassProduct(BaseModel):
    """PRD-009 (user-requested addition) — WebPASS product body."""

    productType: Literal["webpass"] = "webpass"

    # Guide names this field `integrationSwId` directly for WebPASS (unlike
    # Integrated's ambiguous `integrationProductID`-vs-`integrationSwId`
    # judgment call above) — no rename hack needed here.
    integrationSwId: int = Field(..., ge=1)
    productName: Optional[str] = None

    ach: bool = False
    monthlyFee: float
    setupFee: float

    cardTypes: CardTypesFlags = Field(default_factory=CardTypesFlags)
    webIntegration: IntegratedWebIntegration
    configurations: IntegratedConfigurations
    equipments: List[WebPassEquipmentItem] = Field(default_factory=list)
    accessories: List[Any] = Field(default_factory=list)

    @field_validator("monthlyFee", "setupFee")
    @classmethod
    def _validate_fee(cls, v: float) -> float:
        return _validate_fee_amount(v)

    @model_validator(mode="after")
    def _apply_defaults(self) -> "WebPassProduct":
        # Guide: welcomeEmailReceiver is Mandatory for WebPASS (unlike the
        # soft default-from-email on plain Integrated) — defaulting it from
        # integratorEmail (required on webIntegration) satisfies that in
        # practice without forcing a redundant UI field.
        if not self.configurations.welcomeEmailReceiver:
            self.configurations.welcomeEmailReceiver = self.webIntegration.integratorEmail
        return self


class GeniusProduct(BaseModel):
    """PRD-009 (user-requested addition) — Genius product body."""

    productType: Literal["genius"] = "genius"

    integrationSwId: int = Field(..., ge=1)
    productName: Optional[str] = None

    # Guide: "Genius does not support ACH, value should always be false" —
    # enforced as a fixed value, not just a default.
    ach: Literal[False] = False

    # Guide: "If Genius Product then monthlyFee is not allowed."
    monthlyFee: Optional[float] = None
    setupFee: float

    # Note the different key name vs. WebPass/Integrated's `cardTypes` — this
    # is GP's own inconsistency across product types in the guide itself,
    # not something to normalize away.
    additionalCardTypes: CardTypesFlags = Field(default_factory=CardTypesFlags)
    webIntegration: IntegratedWebIntegration
    configurations: IntegratedConfigurations
    equipments: List[Any] = Field(default_factory=list)

    @field_validator("setupFee")
    @classmethod
    def _validate_setup_fee(cls, v: float) -> float:
        return _validate_fee_amount(v)

    @model_validator(mode="after")
    def _apply_defaults_and_forbid_monthly_fee(self) -> "GeniusProduct":
        if not self.configurations.welcomeEmailReceiver:
            self.configurations.welcomeEmailReceiver = self.webIntegration.integratorEmail
        if self.monthlyFee not in (None, 0):
            raise ValueError("monthlyFee is not allowed for Genius products.")
        self.monthlyFee = None
        return self


# ---------------------------------------------------------------------------
# Discriminated union — request body for PUT /onboarding/products
# ---------------------------------------------------------------------------

ProductsSavePayload = Annotated[
    Union[StandaloneProduct, IntegratedProduct, WebPassProduct, GeniusProduct],
    Field(discriminator="productType"),
]
