"""
PUT/GET /onboarding/accounts (list-upsert) request/response schemas —
PRD-HWONB-006.

`accountType`/`usageTypes` are fixed enums, not partner-scoped master data
(§3) — no GET lookup backs this screen, so nothing here needs a lookup
proxy. The `<=3 accounts` cap (CERT-confirmed real GP rule, C-23) is
double-enforced: a fast client-side-equivalent check here (so an obviously
oversized payload never reaches the DB layer), and authoritatively in
`crud.upsert_accounts` (the single source of truth per models/account.py's
docstring, since a second developer could otherwise call the CRUD function
directly and bypass a request-schema-only check).
"""

from __future__ import annotations

import re
from typing import Any, Dict, List, Optional

from pydantic import BaseModel, Field, field_validator, model_validator

from src.apps.merchant_onboarding.helpers.field_mapping import (
    ACCOUNT_TYPE_CHOICES,
    MAX_BANK_ACCOUNTS,
    USAGE_TYPE_CHOICES,
)

_ROUTING_NUMBER_RE = re.compile(r"^\d{9}$")
_ACCOUNT_NUMBER_RE = re.compile(r"^\d{1,17}$")


class AccountItem(BaseModel):
    """
    One repeatable account object. `id` identifies an existing
    `merchant_onboarding_accounts` row for update-in-place; omit/None for a
    new account (list-upsert semantics — crud.upsert_accounts §4).
    """

    id: Optional[int] = None
    # Optional — an existing account (`id` set) may omit these to mean
    # "keep what's on file" (crud.upsert_accounts preserves the stored
    # encrypted value); a new account (no `id`) must still provide both,
    # enforced in crud.upsert_accounts since that check needs to know
    # whether `id` actually resolves to an existing row.
    routingNumber: Optional[str] = None
    accountNumber: Optional[str] = None
    accountType: str
    usageTypes: List[str] = Field(default_factory=list)
    defaultAccount: Optional[bool] = None

    @field_validator("routingNumber")
    @classmethod
    def _validate_routing_number(cls, v: Optional[str]) -> Optional[str]:
        if v is not None and not _ROUTING_NUMBER_RE.match(v):
            raise ValueError("routingNumber must be exactly 9 digits")
        return v

    @field_validator("accountNumber")
    @classmethod
    def _validate_account_number(cls, v: Optional[str]) -> Optional[str]:
        if v is not None and not _ACCOUNT_NUMBER_RE.match(v):
            raise ValueError("accountNumber must be 1-17 numeric digits")
        return v

    @field_validator("accountType")
    @classmethod
    def _validate_account_type(cls, v: str) -> str:
        if v not in ACCOUNT_TYPE_CHOICES:
            raise ValueError(f"accountType must be one of {ACCOUNT_TYPE_CHOICES}")
        return v

    @field_validator("usageTypes")
    @classmethod
    def _validate_usage_types(cls, v: List[str]) -> List[str]:
        for u in v:
            if u not in USAGE_TYPE_CHOICES:
                raise ValueError(f"usageTypes values must be one of {USAGE_TYPE_CHOICES}")
        if len(v) != len(set(v)):
            raise ValueError("usageTypes must not repeat the same value within one account")
        return v


class AccountsRequest(BaseModel):
    """`PUT /onboarding/accounts` request body — the full desired account list."""

    accounts: List[AccountItem]

    @model_validator(mode="after")
    def _validate_set_rules(self) -> "AccountsRequest":
        # §2 / C-23 — cap check mirrored here for a fast client-facing 422;
        # crud.upsert_accounts is the authoritative enforcement point.
        if len(self.accounts) > MAX_BANK_ACCOUNTS:
            raise ValueError(
                f"No more than {MAX_BANK_ACCOUNTS} bank accounts are allowed per application."
            )

        # §1 / AC-2 — each usageType value may be assigned to only one
        # account across the whole set.
        seen: Dict[str, int] = {}
        for idx, acct in enumerate(self.accounts):
            for u in acct.usageTypes:
                if u in seen:
                    raise ValueError(
                        f"usageType {u!r} is assigned to more than one account "
                        f"(indexes {seen[u]} and {idx}); each usageType may only "
                        "be used on one account."
                    )
                seen[u] = idx

        # §1 — usageTypes mandatory once more than one account exists.
        if len(self.accounts) > 1:
            missing_idx = [idx for idx, acct in enumerate(self.accounts) if not acct.usageTypes]
            if missing_idx:
                raise ValueError(
                    f"usageTypes is required once more than one account exists "
                    f"(missing on account index(es): {missing_idx})."
                )

        # AC-1 — exactly one account may be defaultAccount=true (0 is fine —
        # crud.upsert_accounts promotes the first account to default when
        # none is explicitly flagged, per §1's "first account defaults to
        # true if omitted" rule).
        default_count = sum(1 for acct in self.accounts if acct.defaultAccount)
        if default_count > 1:
            raise ValueError("Only one account may have defaultAccount=true at a time.")

        return self


class AccountRowResponse(BaseModel):
    """
    One account row in the response. Routing/account numbers are never
    echoed back in full — only the last 4 digits — since this is sensitive
    financial data at rest (Fernet-encrypted in the DB) and there is no
    legitimate reason for the API response to round-trip the full values
    once saved (judgment call — not specified by the PRD).
    """

    id: int
    routingNumberLast4: Optional[str] = None
    accountNumberLast4: Optional[str] = None
    accountType: str
    usageTypes: List[str] = Field(default_factory=list)
    defaultAccount: bool = False
    tsysAccountRef: Optional[str] = None
    tsysErrors: Optional[List[Dict[str, str]]] = None


class AccountsResponse(BaseModel):
    """
    `PUT /onboarding/accounts` / `GET /onboarding/accounts` response.

    `tsys_errors` aggregates per-account GP errors keyed by the account's
    index (string) in the submitted list, per §4's "Response aggregates all
    per-account tsys_errors keyed by array index" — in addition to each
    row's own `tsysErrors` for convenience when rendering inline.

    `gp_accounts` is only populated by the GET (Show/Sync) endpoint — GP's
    own stored copy, passed through as-is for manual drift reconciliation
    (§4); it is intentionally not auto-merged into `accounts` since there is
    no confirmed reconciliation rule for which side should win a conflict.
    """

    accounts: List[AccountRowResponse]
    tsys_errors: Optional[Dict[str, List[Dict[str, str]]]] = None
    gp_accounts: Optional[Any] = None
