"""
Merchant Onboarding router — PRD-HWONB-001 §7.3.

Mounted at prefix `/onboarding` in src/core/routers/v1.py, so paths below
resolve to e.g. `GET /api/v1/onboarding`.

Every endpoint requires `get_current_merchant()` — no endpoint accepts a
caller-supplied `merchant_id` or `application_id` (closes the IDOR class of
bug by construction, PRD-HWONB-001 §7.3 / §12 / T-12).

Every endpoint from PRD-HWONB-001 §7.3's ~19-endpoint table is implemented
here (this docstring previously described most of them as commented-out
placeholders — stale as of PRD-HWONB-003..013's rollout).
"""

from __future__ import annotations

import logging
from typing import Any, Dict, Optional

from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import Response
from sqlalchemy.orm import Session

from src.apps.auth.utils.auth import get_current_merchant
from src.apps.merchant_onboarding import crud, services
from src.apps.merchant_onboarding.client.base_boarding_client import base_boarding_client
from src.apps.merchant_onboarding.schemas.accounts import AccountsRequest, AccountsResponse
from src.apps.merchant_onboarding.schemas.addresses import AddressesRequest, AddressesResponse
from src.apps.merchant_onboarding.schemas.application import (
    LookupResponse,
    OnboardingApplicationResponse,
    SectionSaveResponse,
)
from src.apps.merchant_onboarding.schemas.application_setup import ApplicationSetupRequest
from src.apps.merchant_onboarding.schemas.business import (
    BusinessInformationRequest,
    BusinessInformationSaveResponse,
)
from src.apps.merchant_onboarding.schemas.card_types import CardTypesRequest, CardTypesResponse
from src.apps.merchant_onboarding.schemas.documents import (
    DocumentDeleteResponse,
    DocumentListResponse,
    DocumentResponse,
)
from src.apps.merchant_onboarding.schemas.fees import FeesRequest, FeesSaveResponse
from src.apps.merchant_onboarding.schemas.owners import (
    OwnerListResponse,
    OwnerResponse,
    OwnerSyncResponse,
    OwnerUpsertRequest,
)
from src.apps.merchant_onboarding.schemas.processing import (
    ProcessingInformationRequest,
    ProcessingInformationSaveResponse,
)
from src.apps.merchant_onboarding.schemas.products import ProductsSavePayload
from src.apps.merchant_onboarding.schemas.training_activation import TrainingActivationRequest
from src.apps.payment_providers.helpers.credentials import decrypt_credential
from src.core.database import get_db

logger = logging.getLogger(__name__)

router = APIRouter()

# Lookup types this router will proxy — PRD-HWONB-001 §7.3's table lists 14
# GP master-record/catalog types across the step PRDs that reference them.
# Types resolved via the generic masterRecord endpoint (client.get_master_record):
# "association" (PRD-HWONB-014) and "leadSource" (PRD-HWONB-015) only need
# `partner`, same as every other entry here — no extra dispatch code needed.
# "salesRep" (PRD-HWONB-015) is deliberately NOT in this set — GP's
# `masterRecord/salesRep` additionally requires an `association` query
# param, which needs its own dispatch branch below (see the `salesRep`
# special case, right after the `zipcode` one) rather than the generic
# partner-only branch this set feeds.
_MASTER_RECORD_LOOKUP_TYPES = {
    "sicCode",
    "ownershipType",
    "businessType",
    "contactType",
    "country",
    "association",
    "leadSource",
}
# Types with their own dedicated (currently stubbed) client method:
_CATALOG_LOOKUP_TYPES = {
    "zipcode": "lookup_zipcode",
    "standaloneEquipment": "get_standalone_equipment_catalog",
    "integratedProducts": "get_integrated_product_catalog",
    "feeProcessingDetail": "get_fee_processing_detail",
    "feeDetail": "get_fee_detail",
    "plansAndAddOns": "get_plans_and_addons",
    "addOns": "get_addons_catalog",
    "trainingAndActivationDetail": "get_training_activation_catalog",
    # C-04 — Genius add-ons are nested inside GET /feeSchedule/plans/addons
    # (`addonsDetails` per plan), NOT the standard-addons /product/addOns
    # catalog — corrected from the original skeleton, which pointed this at
    # get_addons_catalog (that method now maps to /product/addOns, the
    # STANDARD non-Genius catalog — see base_boarding_client.py docstrings).
    "geniusAddOns": "get_plans_and_addons",
}
VALID_LOOKUP_TYPES = _MASTER_RECORD_LOOKUP_TYPES | set(_CATALOG_LOOKUP_TYPES.keys()) | {"salesRep"}


@router.get("", response_model=OnboardingApplicationResponse, summary="Get or resume onboarding application")
async def get_onboarding(
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> OnboardingApplicationResponse:
    """
    Auto-creates the local draft application row on first access, returns
    the full resumable state. PRD-HWONB-015: this no longer also creates the
    GP application — that now happens when the merchant submits the
    Application Setup step (`PUT /onboarding/application-setup`), the new
    Step 1 of 12, since GP's Create Application requires the merchant's own
    `association`/`leadSource`/`salesRep` choices. A merchant with no
    `tsys_app_id` yet simply has `current_step == "application_setup"`.
    """
    application = await services.start_or_resume(db, merchant)
    return OnboardingApplicationResponse.model_validate(application)


@router.get(
    "/lookups/{lookup_type}",
    response_model=LookupResponse,
    summary="Proxy a GP master-record/catalog GET lookup",
)
async def get_lookup(
    lookup_type: str,
    zip: Optional[str] = Query(
        None,
        description=(
            "Required when lookup_type == 'zipcode' — the 5-digit ZIP to resolve "
            "City/State for (PRD-HWONB-005 §3A). Note: this HubWallet-facing query "
            "param is named `zip`; it is forwarded to GP as `zipCode` (N-3)."
        ),
    ),
    processing_type: Optional[str] = Query(
        None,
        description=(
            "Only used when lookup_type == 'feeProcessingDetail' — narrows the "
            "GP-side filter to one processingType (e.g. Retail/MOTO/Voice). "
            "Omit to fetch the full catalog (PRD-HWONB-011 §2)."
        ),
    ),
    fee_type: Optional[str] = Query(
        None,
        description=(
            "Only used when lookup_type == 'feeDetail' — one of "
            "{miscFee, authorizationFee, pinDebitFee, additionalServiceFee} "
            "(PRD-HWONB-011 §2)."
        ),
    ),
    fee_code: Optional[str] = Query(
        None,
        description=(
            "Only used when lookup_type == 'feeDetail' and fee_type == 'miscFee' "
            "— narrows to one specific fee code's detail (PRD-HWONB-011 §2)."
        ),
    ),
    association: Optional[str] = Query(
        None,
        description=(
            "Required when lookup_type == 'salesRep' — the association the "
            "merchant has (not yet submitted/persisted) selected earlier in "
            "the same Application Setup form (PRD-HWONB-015 §2), forwarded "
            "to GET /masterRecord/salesRep as GP's own required query param."
        ),
    ),
    brand: Optional[str] = Query(
        None,
        description=(
            "standaloneEquipment only — one of brand/model/industry/application/"
            "source supplied narrows the lookup to GP's *filtered* "
            "/product/standalone/equipment endpoint, which returns per-device "
            "constraints (communicationWithPOS, emv[], allowedFeature) instead "
            "of the plain brand/model list. All five must be supplied together "
            "or GP rejects the call with 40024."
        ),
    ),
    model: Optional[str] = Query(None, description="standaloneEquipment only — see `brand`."),
    industry: Optional[str] = Query(None, description="standaloneEquipment only — see `brand`."),
    equipment_application: Optional[str] = Query(
        None,
        alias="application",
        description="standaloneEquipment only — see `brand`.",
    ),
    source: Optional[str] = Query(None, description="standaloneEquipment only — see `brand`."),
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> LookupResponse:
    """
    Proxies GP master-record/catalog GETs so the browser never calls GP
    directly (PRD-HWONB-001 Goal 5 / AC-08). `lookup_type` is validated
    against an allowlist — anything else 404s. Every catalog client method
    behind `_CATALOG_LOOKUP_TYPES` is fully implemented (none are stubs
    anymore) — a GP-side failure surfaces as a 502 with `tsys_errors`, not a
    501.
    """
    if lookup_type not in VALID_LOOKUP_TYPES:
        raise HTTPException(status_code=404, detail=f"Unknown lookup type: {lookup_type!r}")

    # get_active_application-or-create the local row (PRD-HWONB-015: this no
    # longer creates a GP application as a side effect — see
    # services.start_or_resume's docstring). Every lookup type needs the row
    # to exist so later catalog lookups can read `application.tsys_association`.
    application = await services.start_or_resume(db, merchant)

    from src.core.config import settings

    # Mirrors every section-save endpoint's TSYS_BOARDING_ENABLED graceful
    # degrade (services.py's stub, and list_accounts_with_sync's read-path
    # equivalent) — a boarding-disabled environment must not turn every
    # catalog dropdown into an unrecoverable 502 dead end.
    if not settings.TSYS_BOARDING_ENABLED:
        return LookupResponse(type=lookup_type, data=None)

    # PRD-HWONB-015 — association/leadSource/salesRep are partner-level
    # master records the Application Setup screen needs BEFORE the GP
    # application exists (that screen's submission is what creates it), so
    # unlike every other lookup type below they must NOT be gated on
    # `application.tsys_app_id` already being set — only on GP boarding
    # being enabled at all (already checked above).
    if lookup_type not in {"association", "leadSource", "salesRep"} and application.tsys_app_id is None:
        return LookupResponse(type=lookup_type, data=None)

    # PRD-HWONB-005 special case — `zipcode` needs an extra query param
    # (`zip`) that no other catalog lookup takes, and the corrected GP
    # param name is `zipCode`, not `zip` (N-3). Handled here, ahead of the
    # generic catalog dispatch below, rather than restructuring that
    # dispatch to support arbitrary extra params for every lookup type.
    if lookup_type == "zipcode":
        if not zip:
            raise HTTPException(
                status_code=422,
                detail="Query param 'zip' is required for the zipcode lookup.",
            )
        envelope = await base_boarding_client.lookup_zipcode(
            partner=settings.TSYS_BOARDING_PARTNER, zip_code=zip, merchant_id=merchant.id, application_id=application.id
        )
        if not envelope["ok"]:
            raise HTTPException(status_code=502, detail=envelope["tsys_errors"])
        return LookupResponse(type=lookup_type, data=envelope["data"])

    # PRD-HWONB-015 special case — `salesRep` is deliberately excluded from
    # `_MASTER_RECORD_LOOKUP_TYPES` (see that set's comment): GP's
    # `masterRecord/salesRep` requires an `association` query param, and at
    # this point in the flow `application.tsys_association` isn't persisted
    # yet (the merchant is mid-Application-Setup-form, hasn't submitted) —
    # the frontend passes the in-progress, not-yet-saved association choice
    # explicitly instead.
    if lookup_type == "salesRep":
        if not association:
            raise HTTPException(
                status_code=422,
                detail="Query param 'association' is required for the salesRep lookup.",
            )
        envelope = await base_boarding_client.get_master_record(
            "salesRep", partner=settings.TSYS_BOARDING_PARTNER, association=association, merchant_id=merchant.id, application_id=application.id
        )
        if not envelope["ok"]:
            raise HTTPException(status_code=502, detail=envelope["tsys_errors"])
        return LookupResponse(type=lookup_type, data=envelope["data"])

    if lookup_type in _MASTER_RECORD_LOOKUP_TYPES:
        envelope = await base_boarding_client.get_master_record(
            lookup_type, partner=settings.TSYS_BOARDING_PARTNER, merchant_id=merchant.id, application_id=application.id
        )
        if not envelope["ok"]:
            raise HTTPException(status_code=502, detail=envelope["tsys_errors"])
        data = envelope["data"]
        if lookup_type == "association" and isinstance(data, dict):
            # A TransFreedom association can never complete self-service
            # Fees (no GP-documented transFreedomBundle value exists), so it
            # must never be offered as a choice here. Two layers, since a
            # live CERT trial (2026-07-20) found association 086386 renamed
            # "AC148 - Cash Advance" — passing a name check — while its
            # feeProcessingDetail catalog is still 100% TransFreedom
            # underneath:
            #   1. Cheap name filter on `associationName` (catches the
            #      common case, e.g. GP's own documented "086386 - AC148 -
            #      TransFreedom Flat Fee" sample, without an extra call).
            #   2. `association_is_viable()` catalog check on whatever
            #      survives #1 — this is what actually catches the renamed
            #      086386 case above.
            entries = data.get("association")
            if isinstance(entries, list):
                named_survivors = [
                    entry
                    for entry in entries
                    if not base_boarding_client._is_trans_freedom_description(entry.get("associationName"))
                ]
                viable_survivors = []
                for entry in named_survivors:
                    if await base_boarding_client.association_is_viable(
                        settings.TSYS_BOARDING_PARTNER, entry.get("association"), merchant_id=merchant.id, application_id=application.id
                    ):
                        viable_survivors.append(entry)
                data = {**data, "association": viable_survivors}
        return LookupResponse(type=lookup_type, data=data)

    method_name = _CATALOG_LOOKUP_TYPES[lookup_type]
    method = getattr(base_boarding_client, method_name)

    # PRD-HWONB-014 — every catalog method above accepts an optional
    # `association` param that otherwise defaults to the platform-wide
    # settings value; pass the merchant's own selected pricing tier
    # explicitly once they have one; before the Pricing Tier step is
    # complete, `application.tsys_association` is still None and this falls
    # back to the same settings default as before.
    merchant_association = application.tsys_association or settings.TSYS_BOARDING_ASSOCIATION

    # feeDetail/feeProcessingDetail/standaloneEquipment are the catalog
    # lookups whose client methods accept extra narrowing params beyond
    # `partner`/`association` — forward them through when the caller supplied
    # them.
    if lookup_type == "feeProcessingDetail":
        envelope = await method(
            settings.TSYS_BOARDING_PARTNER, association=merchant_association, processing_type=processing_type,
            merchant_id=merchant.id, application_id=application.id,
        )
    elif lookup_type == "feeDetail":
        envelope = await method(
            settings.TSYS_BOARDING_PARTNER,
            association=merchant_association,
            fee_type=fee_type,
            fee_code=fee_code,
            merchant_id=merchant.id, application_id=application.id,
        )
    elif lookup_type == "standaloneEquipment":
        equipment_filters = {
            "brand": brand,
            "model": model,
            "industry": industry,
            "application": equipment_application,
            "source": source,
        }
        supplied = {k: v for k, v in equipment_filters.items() if v}
        if supplied and len(supplied) != len(equipment_filters):
            missing = [k for k, v in equipment_filters.items() if not v]
            raise HTTPException(
                status_code=422,
                detail=(
                    "brand/model/industry/application/source must all be supplied "
                    f"together for a filtered standaloneEquipment lookup — missing: {missing}."
                ),
            )
        envelope = await method(
            settings.TSYS_BOARDING_PARTNER, association=merchant_association, **supplied, merchant_id=merchant.id, application_id=application.id
        )
    else:
        envelope = await method(
            settings.TSYS_BOARDING_PARTNER, association=merchant_association, merchant_id=merchant.id, application_id=application.id
        )

    if not envelope["ok"]:
        raise HTTPException(status_code=502, detail=envelope["tsys_errors"])
    return LookupResponse(type=lookup_type, data=envelope["data"])


# ---------------------------------------------------------------------------
# Placeholder route stubs — PRD-HWONB-001 §7.3 full endpoint table.
# NOT functional. Each is implemented by the developer assigned to the
# named step PRD. Left here (commented out, not registered) so the final
# shape of this router is visible without anyone needing to reverse the
# table out of the PRD by hand. Uncomment + implement in place — do not
# duplicate this router in a second file.
# ---------------------------------------------------------------------------

@router.put(
    "/business",
    response_model=BusinessInformationSaveResponse,
    summary="Save Business Information",
)
async def put_business(
    body: BusinessInformationRequest,
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> BusinessInformationSaveResponse:
    """
    PRD-HWONB-003 — Step 1 of 11.

    `BusinessInformationRequest` enforces every §1 field rule (AC-1) via
    Pydantic — FastAPI returns a standard 422 for a malformed/incomplete body
    before this handler even runs. `services.save_section` persists the local
    snapshot to `business_data` before pushing to GP (resume invariant,
    PRD-HWONB-001 §7.2), then returns `{saved, tsys_errors, section_state,
    status}` — a non-null `tsys_errors` here is a *GP* rejection (not a local
    validation failure), returned as a normal 200 so the wizard can map each
    entry onto React Hook Form's `setError` (AC-6) rather than surfacing a
    generic error toast.
    """
    application = await services.start_or_resume(db, merchant)
    result = await services.save_section(db, application, "business", body.to_gp_payload())
    return BusinessInformationSaveResponse(**result)


@router.put(
    "/processing",
    response_model=ProcessingInformationSaveResponse,
    summary="Save Processing Information",
)
async def put_processing(
    body: ProcessingInformationRequest,
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> ProcessingInformationSaveResponse:
    """
    PRD-HWONB-004 — Step 2 of 11.

    `ProcessingInformationRequest` enforces §1's field rules (100%-total hard
    gate AC-1, billPriorToShip conditionals AC-3, affiliated-merchant cap/dedup
    AC-4, etc.) via Pydantic. `services.save_section` additionally enforces
    the N-2 sequencing gate (Business must already be pushed — returns a 422
    "Please complete business information" otherwise, matching GP's own error
    wording) and the ownershipType-driven governmentIdType/businessIdType
    cross-check against the already-saved Business section before pushing to
    GP. As with Business, a non-null `tsys_errors` in a 200 response is a GP
    rejection meant for `setError`, not a raised exception.
    """
    application = await services.start_or_resume(db, merchant)
    result = await services.save_section(db, application, "processing", body.to_gp_payload())
    return ProcessingInformationSaveResponse(**result)

@router.put("/addresses", response_model=AddressesResponse, summary="Save Addresses")
async def put_addresses(
    payload: AddressesRequest,
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> AddressesResponse:
    """PRD-HWONB-005 (Addresses) — single-object section, no dedicated table."""
    application = await services.start_or_resume(db, merchant)
    result = await services.save_section(db, application, "addresses", payload.model_dump(mode="json"))
    return AddressesResponse(**result)


@router.put("/accounts", response_model=AccountsResponse, summary="Upsert Bank Accounts (list)")
async def put_accounts(
    payload: AccountsRequest,
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> AccountsResponse:
    """PRD-HWONB-006 (Bank Accounts) — list-upsert; enforces the <=3-account cap (C-23)."""
    application = await services.start_or_resume(db, merchant)
    result = await services.save_section(db, application, "accounts", payload.model_dump(mode="json"))
    return AccountsResponse(**result)


@router.get("/accounts", response_model=AccountsResponse, summary="Show/Sync Bank Accounts")
async def get_accounts_route(
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> AccountsResponse:
    """PRD-HWONB-006 (Bank Accounts) — Show/Sync reconcile against GP's stored copy."""
    application = await services.start_or_resume(db, merchant)
    result = await services.list_accounts_with_sync(db, application)
    return AccountsResponse(**result)

# ---------------------------------------------------------------------------
# PRD-HWONB-007 — Owners & Principals
# ---------------------------------------------------------------------------


def _ssn_last4(enc_value: Optional[str], owner_id: int) -> Optional[str]:
    """Decrypt-and-slice ssn_enc for the response — mirrors services._account_row_to_dict's _last4."""
    if not enc_value:
        return None
    try:
        return decrypt_credential(enc_value)[-4:]
    except Exception:
        logger.warning("_owner_row_to_response: failed to decrypt ssn_enc for owner_id=%s", owner_id)
        return None


def _owner_row_to_response(row: Any) -> OwnerResponse:
    """Shared ORM-row -> OwnerResponse mapping for both owners endpoints below."""
    return OwnerResponse(
        id=row.id,
        first_name=row.first_name,
        last_name=row.last_name,
        contact_title=row.contact_title,
        phone_country_code=row.phone_country_code,
        phone_number=row.phone_number,
        email=row.email,
        dob=row.dob,
        owner_percent=row.owner_percent,
        non_us_citizen=row.non_us_citizen,
        ssn_last4=_ssn_last4(row.ssn_enc, row.id),
        credit_pull_waive=row.credit_pull_waive,
        street_number=row.street_number,
        street_name=row.street_name,
        street_direction=row.street_direction,
        street_type=row.street_type,
        unit_number=row.unit_number,
        city=row.city,
        state=row.state,
        zip_code=row.zip_code,
        country=row.country,
        passport_country=row.passport_country,
        passport_number=row.passport_number,
        address_line1=row.address_line1,
        address_line2=row.address_line2,
        foreign_city=row.foreign_city,
        foreign_state=row.foreign_state,
        foreign_zip_code=row.foreign_zip_code,
        foreign_country=row.foreign_country,
        app_signer=row.app_signer,
        personal_guarantor=row.personal_guarantor,
        beneficial_owner=row.beneficial_owner,
        individual_with_control=row.individual_with_control,
        tsys_owner_ref=row.tsys_owner_ref,
        tsys_synced_at=row.tsys_synced_at,
        tsys_errors=row.tsys_errors,
        created_at=row.created_at,
        updated_at=row.updated_at,
    )


@router.put(
    "/owners",
    response_model=OwnerListResponse,
    summary="Upsert Owners & Principals (list)",
)
async def put_owners(
    request: OwnerUpsertRequest,
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> OwnerListResponse:
    """
    PRD-HWONB-007 §4 — list-upsert, per-row push to GP (POST .../owners × N).

    Role-count caps (<=5 owners, <=2 appSigner, <=2 personalGuarantor,
    <=4 beneficialOwner, exactly 1 individualWithControl) and the
    ownershipType-dependent SOLE/CNP forcing + ssn requirement are enforced
    server-side in crud.py (the authoritative check, PRD-HWONB-001 §7.3) —
    a cap violation surfaces as a 422 here, not a raw DB error.

    N-2 sequencing gate: requires `business` pushed first — a 400 with GP's
    own combined error phrasing if not.
    """
    application = await services.start_or_resume(db, merchant)
    rows = await services.save_owners(db, application, request.owners)
    responses = [_owner_row_to_response(r) for r in rows]
    return OwnerListResponse(owners=responses, any_errors=any(bool(r.tsys_errors) for r in responses))


@router.get(
    "/owners",
    response_model=OwnerSyncResponse,
    summary="Show/Sync Owners & Principals",
)
async def get_owners(
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> OwnerSyncResponse:
    """
    PRD-HWONB-007 §4 — Show/Sync reconcile. Not required before Continue
    (manual/background reconcile only, same role as PRD-HWONB-012 §2.5's
    attachments list endpoint) — returns HubWallet's local rows always, and
    GP's raw stored copy alongside them when the application already has a
    `tsys_app_id` (best-effort; a GP-side failure here does not 500 — it's
    surfaced as `gp_sync_error` so the local `owners` list is still usable).
    """
    application = await services.start_or_resume(db, merchant)
    local_rows = crud.list_owners(db, application.id)
    responses = [_owner_row_to_response(r) for r in local_rows]

    gp_owners = None
    gp_sync_error = None
    if application.tsys_app_id:
        envelope = await base_boarding_client.get_owners(application.tsys_app_id)
        if envelope["ok"]:
            gp_owners = envelope["data"]
        else:
            gp_sync_error = envelope["tsys_errors"]

    return OwnerSyncResponse(owners=responses, gp_owners=gp_owners, gp_sync_error=gp_sync_error)


# ---------------------------------------------------------------------------
# PRD-HWONB-008 — Card Types
# ---------------------------------------------------------------------------


@router.put(
    "/card-types",
    response_model=CardTypesResponse,
    summary="Save Card Types",
)
async def put_card_types(
    request: CardTypesRequest,
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> CardTypesResponse:
    """
    PRD-HWONB-008 §3 — flat object, 1:1 mapping onto GP's `POST .../cardTypes`.

    N-2 sequencing gate: requires BOTH `business` AND `processing` pushed
    first — GP's real error names both ("...business information and
    processing information"), surfaced here as a 400.
    """
    application = await services.start_or_resume(db, merchant)
    return await services.save_card_types(db, application, request)


@router.put(
    "/products",
    response_model=SectionSaveResponse,
    summary="Save Products & Equipment (Standalone/CP or Integrated/CNP)",
)
async def put_products(
    payload: ProductsSavePayload,
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> SectionSaveResponse:
    """
    PRD-HWONB-009 — `productType` in the body discriminates Standalone
    (Card-Present) vs Integrated (Card-Not-Present); see
    `schemas.products.ProductsSavePayload`. On success, `data.terminalNumber`
    carries GP's generated terminal id (Standalone; PRD §6). A GP rejection
    is still returned as `pushed: False` + `tsys_errors` (HTTP 200) so the
    wizard can render field-level errors inline (AC-07) rather than a hard
    error page.
    """
    application = await services.start_or_resume(db, merchant)
    result = await services.save_section(db, application, "products", payload.model_dump())
    return SectionSaveResponse(**result)


@router.put(
    "/training-activation",
    response_model=SectionSaveResponse,
    summary="Save Training & Activation",
)
async def put_training_activation(
    payload: TrainingActivationRequest,
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> SectionSaveResponse:
    """
    PRD-HWONB-010 — flat object, no reshaping (see
    `schemas.training_activation.TrainingActivationRequest.to_gp_payload()`).
    `equipShippedTo="NA"` with CP/standalone equipment present is a
    client-side (and this schema's server-side) UX guard only — GP itself
    does not reject it at section-save time (C-19).
    """
    application = await services.start_or_resume(db, merchant)
    result = await services.save_section(
        db, application, "training_activation", payload.to_gp_payload()
    )
    return SectionSaveResponse(**result)


@router.put(
    "/application-setup",
    response_model=SectionSaveResponse,
    summary="Create the Global Payments application (association / leadSource / salesRep)",
)
async def put_application_setup(
    payload: ApplicationSetupRequest,
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> SectionSaveResponse:
    """
    PRD-HWONB-015 — Step 1 of 12 (Application Setup), the new first screen
    of the wizard. Supersedes PRD-HWONB-002's "invisible bootstrap" (no
    screen ever rendered) and PRD-HWONB-014's Pricing Tier step (retired):
    the merchant now picks `association`, `leadSource`, and `salesRep`
    together here (options come from `GET /onboarding/lookups/{type}`), and
    submitting this step is the real `POST /applications` Create
    Application call — `partner`/`merchantApplicationType`/`sBank` remain
    config-driven and unaffected.

    Idempotent: a merchant who already has a `tsys_app_id` (resumed session)
    gets a no-op success, never a second GP application
    (`services._save_application_setup_section`).
    """
    application = await services.start_or_resume(db, merchant)
    result = await services.save_section(db, application, "application_setup", payload.model_dump())
    return SectionSaveResponse(**result)


@router.put("/fees", response_model=FeesSaveResponse, summary="Save Pricing & Fees")
async def put_fees(
    payload: FeesRequest,
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> Dict[str, Any]:
    """
    PRD-HWONB-011 — Step 9 of 11 (Pricing & Fees).

    `FeesRequest` validates the merchant-facing selectors only (processing
    plan/option, Amex opt-in, wireless/ACH/unsupported-POS/Genius
    selectors, the mandatory opt-in booleans) — see schemas/fees.py. The
    AC-14 rate-card injection (baseRates/perItemFees/authorizationFees/
    pinDebitFees/miscellaneousFees/additionalFees.ensureBill) happens
    server-side inside `base_boarding_client.push_fees()` via its private
    `_resolve_rate_card()` helper, called from `services.save_section()`.

    Returns `{ok, tsys_errors, section_state}` — a 422 surfaces GP-side
    validation errors (`tsys_errors`) as a field-error list for the wizard
    (AC-07), not a raw 500.
    """
    application = await services.start_or_resume(db, merchant)
    result = await services.save_section(
        db,
        application,
        "fees",
        payload.to_gp_payload(),
        named_misc_fee_overrides=payload.named_misc_fee_overrides(),
    )
    if not result["ok"]:
        raise HTTPException(status_code=422, detail=result["tsys_errors"])
    return result

# ---------------------------------------------------------------------------
# PRD-HWONB-012 — Documents & Agreement (incl. UMA)
# PRD-HWONB-013 — Review, Submit, Transmit & Status Polling
# ---------------------------------------------------------------------------


@router.get("/uma", summary="Download the GP-generated Unified Merchant Agreement PDF")
async def get_uma(
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> Response:
    """
    PRD-HWONB-012 §1 — proxies GP's binary UMA PDF. Idempotent/re-callable —
    GP returns the current state of the agreement every time, not a
    single-use generator (AC-5); the browser never calls GP directly (AC-6).
    """
    application = await services.start_or_resume(db, merchant)
    pdf_bytes = await services.get_uma(application)
    return Response(
        content=pdf_bytes,
        media_type="application/pdf",
        headers={"Content-Disposition": 'inline; filename="merchant-agreement.pdf"'},
    )


@router.post(
    "/documents",
    response_model=DocumentResponse,
    status_code=201,
    summary="Upload a supporting document (multipart)",
)
async def post_documents(
    doc_type: str = Form(
        ...,
        description=(
            "One of the 15 GP docType codes: SMA, FS, BL, SS, PB, BR, PS, MM, "
            "FD, Tax, NP, ACH, PC, CH, VITL, OTHER (note: 'Tax' is Title-case, "
            "not 'TAX' — C-20)."
        ),
    ),
    file: UploadFile = File(...),
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> DocumentResponse:
    """
    PRD-HWONB-012 §2 — validates the content-type/size allowlist (2MB
    production limit, PDF/JPG/XLS/XLSX) and the business-information
    ordering gate (§2.4) before ever forwarding to GP (AC-1/AC-4). GP's own
    multipart field key (`documents`, not `file` — C-21) is handled inside
    `base_boarding_client.upload_attachment()`; this endpoint's own inbound
    field name is a HubWallet-internal convention, not dictated by C-21.
    """
    application = await services.start_or_resume(db, merchant)
    file_bytes = await file.read()
    document = await services.upload_document(
        db,
        application,
        doc_type=doc_type,
        file_bytes=file_bytes,
        file_name=file.filename or "document",
        content_type=file.content_type or "application/octet-stream",
    )
    return DocumentResponse.model_validate(document)


@router.get("/documents", response_model=DocumentListResponse, summary="List uploaded documents")
async def get_documents(
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> DocumentListResponse:
    """
    PRD-HWONB-012 §4 — local rows are the source of truth; the GP list call
    is a best-effort audit reconciliation only (never blocks this response).
    """
    application = await services.start_or_resume(db, merchant)
    result = await services.list_documents(db, application)
    return DocumentListResponse(
        documents=[DocumentResponse.model_validate(d) for d in result["documents"]],
        sma_uploaded=result["sma_uploaded"],
    )


@router.delete(
    "/documents/{document_id}",
    response_model=DocumentDeleteResponse,
    summary="Delete a document (pre-transmit only)",
)
async def delete_document(
    document_id: int,
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> DocumentDeleteResponse:
    """PRD-HWONB-012 §2.5 — locked once the application has been transmitted."""
    application = await services.start_or_resume(db, merchant)
    document = await services.delete_document(db, application, document_id)
    return DocumentDeleteResponse(deleted=True, id=document.id, doc_type=document.doc_type)


@router.post("/transmit", summary="Transmit the application for underwriting")
async def post_transmit(
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> dict:
    """
    PRD-HWONB-013 §1 — point of no return, guarded server-side on all 9
    GP-required sections (§1.2) being pushed. A duplicate transmit on an
    already-transmitted application is a no-op success, not an error (AC-2).
    """
    application = await services.start_or_resume(db, merchant)
    return await services.transmit(db, application)


@router.post(
    "/restart",
    response_model=OnboardingApplicationResponse,
    summary="Restart after rejection",
)
async def post_restart(
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> OnboardingApplicationResponse:
    """PRD-HWONB-013 §3 — only valid when `status == 'rejected'`; never reuses the rejected appId (AC-5)."""
    application = await services.restart_after_rejection(db, merchant)
    return OnboardingApplicationResponse.model_validate(application)


@router.get("/status", summary="Manually refresh GP application status")
async def get_status(
    merchant: Any = Depends(get_current_merchant),
    db: Session = Depends(get_db),
) -> dict:
    """
    PRD-HWONB-013 §2 — calls both GP `status` (coarse, opaque strings —
    C-24) and `activity` (enumerated progress data, once a MID exists) and
    returns HubWallet's normalized state.
    """
    application = await services.start_or_resume(db, merchant)
    return await services.refresh_status(db, application)
