"""
Pricing & Fees — merchant-facing request schema. PRD-HWONB-011 §1, §3, §4A.

This schema captures ONLY the fields a merchant actually selects on the
Pricing & Fees wizard screen (Step 9 of 11): the processing-type/plan/option
selection, the Amex opt-in flag, wireless/ACH/unsupported-POS/Genius
selectors, and the three mandatory-non-null "opt-in" booleans GP requires
(`earlyTermination`/`includeDailyDiscount`/`sameDayACHFlag`).

Rate-card fields (`baseRates`, `perItemFees`, `authorizationFees`,
`pinDebitFees`, `additionalFees.ensureBill`) are DELIBERATELY ABSENT from
this schema. PRD-HWONB-011 §6 / PRD-HWONB-001 §13.1 (C-01) CERT-confirmed
that GP rejects these as null (`"... cannot be empty"` on nine fields) but
they are sales/partner-negotiated values a merchant should never type in
during self-service signup. They are injected server-side by
`BaseBoardingClient._resolve_rate_card()` (see client/base_boarding_client.py)
immediately before the GP POST inside `push_fees()` — see that method's
docstring for exactly how each field is derived, and its prominent
CERT-flakiness comment before touching it.

`miscellaneousFees` is the one exception: it's still resolved/assembled
server-side (`_resolve_misc_fees()`), but 5 of its entries
(ACH Change/Annual/Application Setup/Breach Coverage/Monthly Service fees —
`NAMED_MISC_FEE_CODES` in `field_mapping.py`) map to real, CERT-confirmed
GP catalog fee codes a merchant can reasonably choose to opt into/adjust,
unlike the partner-negotiated rate card above — so this schema exposes
them as optional merchant overrides below.
"""

from __future__ import annotations

from typing import Any, Dict, List, Optional

from pydantic import BaseModel, ConfigDict, Field, model_validator

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


class GeniusPlanSelection(BaseModel):
    """§1.6 `geniusPlan` — required when the merchant selects a Genius Add-On Plan."""

    model_config = ConfigDict(populate_by_name=True)

    genius_plan_name: str = Field(alias="geniusPlanName")
    # Doc: "Format: Numeric (Decimal)... Range: 0.00-9999.99" — was
    # wrongly typed str (Base Boarding API Guide pp. 313-328, read in full
    # 2026-07-20).
    genius_plan_value: float = Field(alias="geniusPlanValue")


class GeniusAddonSelection(BaseModel):
    """
    §1.6 — one entry of the Fees endpoint's `geniusAddOns` array. The doc's
    own Sample Request/Response (pp. 327-328) confirm the key is
    `geniusAddOns` (capital O, matching the unrelated Products endpoint's
    casing) and each entry is `{addOnsName, addOnsValue}` — NOT
    `geniusAddOnName`/`geniusAddOnValue` as an earlier version of this
    schema assumed.
    """

    model_config = ConfigDict(populate_by_name=True)

    genius_addon_name: str = Field(alias="addOnsName")
    # Doc: "Format: Numeric (Decimal)... Default Value: $99.00" — was
    # wrongly typed str. No confirmed per-addon price source exists yet
    # (get_plans_and_addons()'s catalog only returns addOnFeeName/
    # addOnDescription, no fee amount) — callers send the doc's own
    # $99.00 default as a placeholder until a real CERT trial confirms one.
    genius_addon_value: float = Field(alias="addOnsValue")


class FeesRequest(BaseModel):
    """
    `PUT /onboarding/fees` request body — PRD-HWONB-011 §3/§4A.

    Every field uses `populate_by_name=True` + an explicit GP-matching
    `alias` so `.model_dump(by_alias=True, exclude_none=True)` (see
    `to_gp_payload()`) produces the exact GP-shaped merchant-input fragment
    that `base_boarding_client.push_fees()` then merges with the
    server-injected rate-card fields before POSTing to GP.
    """

    model_config = ConfigDict(populate_by_name=True)

    # --- §1.1 top level --------------------------------------------------
    processing_type: str = Field(
        alias="processingType",
        description=(
            "Title-case, exactly as returned by the feeProcessingDetail lookup "
            "(Retail/MOTO/Voice) — PRD-011 §1.1 / C-03. Never re-cased server-side."
        ),
    )
    pricing_plan_id: int = Field(
        alias="pricingPlanId",
        description=(
            "Resolved from the feeProcessingDetail lookup for the selected "
            "processingType — never hardcoded (AC-1)."
        ),
    )
    option_id: int = Field(
        alias="optionId",
        description=(
            "Scoped to (processingType, pricingPlanId) from the same lookup — "
            "a mismatched value returns GP error 40256."
        ),
    )
    amex_pricing_ind: bool = Field(
        alias="amexPricingInd",
        description=(
            "Defaults from Card Types' amexRequested. Drives whether "
            "_resolve_rate_card includes or omits amexBaseRate/amexPerItem (C-01)."
        ),
    )
    trans_freedom_bundle: Optional[str] = Field(
        default=None,
        alias="transFreedomBundle",
        description=(
            "Out of v1 scope (PRD-011 §1.1) — always null in the doc's own "
            "Sample Request; no GP-documented value exists for self-service. "
            "TransFreedom classification itself is handled server-side by "
            "zero-filling the doc-mandated fields "
            "(base_boarding_client._apply_trans_freedom_zero_fill), not by "
            "anything the merchant submits here."
        ),
    )

    # --- Mandatory non-null opt-in booleans — GP rejects these as null (C-01) ---
    early_termination: bool = Field(default=False, alias="earlyTermination")
    include_daily_discount: bool = Field(default=False, alias="includeDailyDiscount")
    same_day_ach_flag: bool = Field(default=False, alias="sameDayACHFlag")
    same_day_ach_fee: Optional[float] = Field(default=None, alias="sameDayACHFee")

    # --- Wireless fees (§4A fieldset #2 — gate extended to connectionMethod "W" too, §5.3) ---
    wireless_monthly_fee: Optional[float] = Field(default=None, alias="wirelessMonthlyFee")
    wireless_set_up_fee: Optional[float] = Field(
        default=None,
        alias="wirelessSetUpFee",  # exact casing confirmed — capital S/U/F (§1.6)
    )

    # --- Authorize.Net fees — Cond Mand when the Standalone product's
    # equipment `model` is "AUTHORIZE.NET" (guide's field table, same clause
    # used for unsupportedPos* below); gated client-side only, same as wireless/
    # unsupportedPos, since this schema has no visibility into products_data.
    authorize_net_monthly_fee: Optional[float] = Field(default=None, alias="authorizeNetMonthlyFee")
    authorize_net_setup_fee: Optional[float] = Field(default=None, alias="authorizeNetSetupFee")

    # --- ACH fees (§4A fieldset #3) --------------------------------------
    ach_discount_rate: Optional[float] = Field(default=None, alias="achDiscountRate")
    ach_transaction_fee: Optional[float] = Field(default=None, alias="achTransactionFee")
    ach_ccd: Optional[int] = Field(default=None, alias="achCcd", ge=0, le=100)
    ach_ppd: Optional[int] = Field(default=None, alias="achPpd", ge=0, le=100)
    ach_web: Optional[int] = Field(default=None, alias="achWeb", ge=0, le=100)
    ach_tel: Optional[int] = Field(default=None, alias="achTel", ge=0, le=100)

    # --- Unsupported-POS fees (§4A fieldset #4) ---------------------------
    unsupported_pos_monthly_fee: Optional[float] = Field(default=None, alias="unsupportedPosMonthlyFee")
    unsupported_pos_setup_fee: Optional[float] = Field(default=None, alias="unsupportedPosSetupFee")

    # --- Tokenization mutual exclusivity (AC-4) ---------------------------
    p2pe_per_item_fee: Optional[float] = Field(default=None, alias="p2pePerItemFee")
    data_protection_per_item_fee: Optional[float] = Field(default=None, alias="dataProtectionPerItemFee")

    # --- Genius Add-On Plan (§4A fieldset #5, §7 real 5-plan catalog) -----
    genius_plan: Optional[GeniusPlanSelection] = Field(default=None, alias="geniusPlan")
    genius_addons: Optional[List[GeniusAddonSelection]] = Field(default=None, alias="geniusAddOns")

    # --- Named miscellaneousFees overrides (see module docstring) ---------
    # Not GP payload keys themselves — consumed by push_fees()/
    # _resolve_misc_fees() to build miscellaneousFees[] entries keyed by
    # NAMED_MISC_FEE_CODES, then excluded from to_gp_payload() below.
    ach_change_fee: Optional[float] = Field(default=None, alias="achChangeFee", ge=0)
    annual_fee: Optional[float] = Field(default=None, alias="annualFee", ge=0)
    application_setup_fee: Optional[float] = Field(default=None, alias="applicationSetupFee", ge=0)
    breach_coverage_fee: Optional[float] = Field(default=None, alias="breachCoverageFee", ge=0)
    monthly_service_fee: Optional[float] = Field(default=None, alias="monthlyServiceFee", ge=0)

    @model_validator(mode="after")
    def _validate_business_rules(self) -> "FeesRequest":
        # T-1 / C-03 — processingType casing sanity check. Not re-derived —
        # just guards obvious client bugs/typos against the confirmed set.
        if self.processing_type not in PROCESSING_TYPE_CHOICES:
            raise ValueError(
                f"processingType must be one of {PROCESSING_TYPE_CHOICES} "
                f"(the lookup's own Title-case casing) — got {self.processing_type!r}"
            )

        # T-3 / AC-4 — p2pePerItemFee and dataProtectionPerItemFee are
        # mutually exclusive; never send both.
        if self.p2pe_per_item_fee is not None and self.data_protection_per_item_fee is not None:
            raise ValueError(
                "p2pePerItemFee and dataProtectionPerItemFee cannot both be set — "
                "GP requires exactly one or neither (PRD-011 §4/AC-4)."
            )

        # §1.6 — sameDayACHFlag=true requires sameDayACHFee.
        if self.same_day_ach_flag and self.same_day_ach_fee is None:
            raise ValueError("sameDayACHFee is required when sameDayACHFlag is true.")

        # T-2 / AC-3 — ACH SEC-code split: if any of the 4 is provided, all
        # 4 must be provided and must sum to exactly 100.
        ach_split = [self.ach_ccd, self.ach_ppd, self.ach_web, self.ach_tel]
        if any(v is not None for v in ach_split):
            if any(v is None for v in ach_split):
                raise ValueError(
                    "achCcd/achPpd/achWeb/achTel must all be provided together "
                    "whenever any one of them is set (§4A fieldset #3)."
                )
            total = sum(v for v in ach_split if v is not None)
            if total != 100:
                raise ValueError(
                    f"achCcd+achPpd+achWeb+achTel must sum to exactly 100, got {total} (AC-3/T-2)."
                )

        return self

    # Not real GP top-level keys — consumed into miscellaneousFees[] by
    # push_fees()/_resolve_misc_fees() instead, so to_gp_payload() excludes them.
    _NAMED_MISC_FEE_FIELDS = (
        "ach_change_fee",
        "annual_fee",
        "application_setup_fee",
        "breach_coverage_fee",
        "monthly_service_fee",
    )

    def named_misc_fee_overrides(self) -> Dict[str, float]:
        """{field alias -> merchant-entered amount} for whichever named misc fees were set."""
        return {
            self.model_fields[field].alias: value
            for field in self._NAMED_MISC_FEE_FIELDS
            if (value := getattr(self, field)) is not None
        }

    def to_gp_payload(self) -> Dict[str, Any]:
        """
        Merchant-input fragment only, GP camelCase keys, nulls dropped.
        `base_boarding_client.push_fees()` merges the server-injected
        rate-card fields into this before POSTing — see its docstring.
        """
        return self.model_dump(
            by_alias=True, exclude_none=True, exclude=set(self._NAMED_MISC_FEE_FIELDS)
        )


class FeesSaveResponse(BaseModel):
    """
    `PUT /onboarding/fees` response — mirrors the exact dict shape
    `services._save_fees_section()` returns (`{ok, tsys_errors,
    section_state}`), which is its own shape (not `SectionSaveResponse`,
    used by products/training_activation/etc.) because fees needs the raw
    per-row `section_state["fees"]` entry rather than the whole
    `section_state` dict or a `current_step`/`data` envelope.
    """

    ok: bool
    tsys_errors: Optional[List[Dict[str, Any]]] = None
    section_state: Optional[Dict[str, Any]] = None
