"""
PRD-HWONB-007 — Owners & Principals request/response schemas.

Field names mirror GP's own camelCase (via `alias_generator=to_camel`) so
that the wizard/prototype's existing camelCase payload shape (e.g.
`ownerPercent`, `nonUsCitizen`, `individualWithControl` — see PRD-007 §1/§5A)
needs no client-side rewrite, matching the same "no reshaping" precedent
used for card_types (PRD-HWONB-008 §3/§5). `populate_by_name=True` so the
service layer can also construct/validate instances using the Python
(snake_case) attribute names.

What this schema validates vs. what crud.py validates (per the Developer
brief — cross-row caps do not belong here):
  - Per-owner (single-row) shape: required fields for whichever of the two
    branches (`nonUsCitizen` true/false) applies, field lengths/patterns,
    and the PRD-007 §1 cross-field rule "every owner must be
    beneficialOwner=true and/or individualWithControl=true" — this rule is
    checked ONE OWNER AT A TIME, so it belongs here even though it reads
    like a cross-row rule at first glance.
  - NOT validated here (needs the whole owner list, or the application's
    `ownershipType` from the business section, so it lives in crud.py):
      * <=5 owners total, <=2 appSigner, <=2 personalGuarantor,
        <=4 beneficialOwner, EXACTLY 1 individualWithControl across the
        whole list
      * ownerPercent summing to 100 across all owners
      * ssn required-unless-ownershipType-exempt (ownershipType lives on
        the business section, not on the owner row)
      * Sole Proprietorship / Non-Profit forced-value rules (PRD-007 §2)
"""

from __future__ import annotations

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

from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
from pydantic.alias_generators import to_camel

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

_SSN_RE = re.compile(r"^\d{3}-\d{2}-\d{4}$")
_PHONE_RE = re.compile(r"^\d{10}$")
# Plain digits only (5, or 5+4=9) — no dash. The DB column is String(9)
# (models/owner.py, migrations/versions/hwonb001_...py) and PRD-HWONB-007 §1
# specifies plain digits, not a dashed "12345-6789" format, so the regex
# itself is tightened here rather than widening the column.
_ZIP_RE = re.compile(r"^\d{5}(\d{4})?$")


class OwnerItem(BaseModel):
    """A single owner/principal row — request shape for `PUT /onboarding/owners`."""

    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

    # Present only when updating a previously-saved local row; omitted for a
    # brand-new owner being added in this same request (list-upsert, like
    # accounts — PRD-HWONB-001 §7.3). GP itself has no owner-update endpoint
    # in scope for this PRD (create-only, §4) — `id` is purely HubWallet's
    # local row identifier for the upsert.
    id: Optional[int] = None

    first_name: str = Field(..., min_length=1, max_length=11)
    last_name: str = Field(..., min_length=1, max_length=15)
    contact_title: str = Field(...)
    phone_country_code: str = Field(default="+1")
    phone_number: str = Field(...)
    email: EmailStr
    dob: date
    owner_percent: int = Field(..., ge=0, le=100)
    non_us_citizen: bool = False

    # --- US-citizen branch (nonUsCitizen = false) ---
    ssn: Optional[str] = None  # requiredness (ownershipType-dependent) enforced in crud.py
    credit_pull_waive: Optional[bool] = False
    street_number: Optional[str] = Field(default=None, max_length=6)
    street_name: Optional[str] = Field(default=None, max_length=19)
    street_direction: Optional[str] = None
    street_type: Optional[str] = Field(default=None, max_length=2)
    unit_number: Optional[str] = Field(default=None, max_length=5)
    city: Optional[str] = Field(default=None, max_length=16)
    # US 2-char state code + territories (models/owner.py String(2) / PRD-007 §1)
    state: Optional[str] = Field(default=None, max_length=2)
    zip_code: Optional[str] = None
    country: Optional[str] = Field(default=None, max_length=3)

    # --- Non-US-citizen branch (nonUsCitizen = true) ---
    passport_country: Optional[str] = Field(default=None, max_length=100)
    passport_number: Optional[str] = Field(default=None, max_length=50)
    address_line1: Optional[str] = Field(default=None, max_length=100)
    address_line2: Optional[str] = Field(default=None, max_length=100)
    foreign_city: Optional[str] = Field(default=None, max_length=100)
    foreign_state: Optional[str] = Field(default=None, max_length=50)
    foreign_zip_code: Optional[str] = Field(default=None, max_length=20)
    foreign_country: Optional[str] = Field(default=None, max_length=100)

    # --- Role flags (all 4 always sent — PRD-007 §1) ---
    app_signer: bool = False
    personal_guarantor: bool = False
    beneficial_owner: bool = False
    individual_with_control: bool = False

    @field_validator("contact_title")
    @classmethod
    def _validate_contact_title(cls, v: str) -> str:
        if v not in OWNER_CONTACT_TITLE_CHOICES:
            raise ValueError(
                f"contactTitle must be one of {OWNER_CONTACT_TITLE_CHOICES} "
                "(owners-specific enum — distinct from the Addresses screen's set, PRD-007 §1/C-10)"
            )
        return v

    @field_validator("phone_number")
    @classmethod
    def _validate_phone_number(cls, v: str) -> str:
        if not _PHONE_RE.match(v):
            raise ValueError("phoneNumber must be exactly 10 digits")
        return v

    @field_validator("ssn")
    @classmethod
    def _validate_ssn_format(cls, v: Optional[str]) -> Optional[str]:
        if v is not None and not _SSN_RE.match(v):
            raise ValueError("ssn must match ###-##-####")
        return v

    @field_validator("street_type")
    @classmethod
    def _validate_street_type(cls, v: Optional[str]) -> Optional[str]:
        # TSYS's streetType is a 2-char code (C-14) — reject anything else so we
        # don't ship USPS-style AVE/BLVD that TSYS bounces. Length rule, not
        # membership, so a valid-but-uncatalogued 2-char code still passes.
        if v is not None and v != "" and not re.fullmatch(r"[A-Z]{2}", v):
            raise ValueError("streetType must be exactly 2 uppercase letters (TSYS 2-char code, e.g. ST, AV)")
        return v

    @field_validator("zip_code")
    @classmethod
    def _validate_zip(cls, v: Optional[str]) -> Optional[str]:
        if v is not None and not _ZIP_RE.match(v):
            raise ValueError("zipCode must be 5 or 9 plain digits (no dash) — matches the String(9) column")
        return v

    @model_validator(mode="after")
    def _validate_branch_and_role_rule(self) -> "OwnerItem":
        if self.non_us_citizen:
            missing = [
                name
                for name, val in (
                    ("passportCountry", self.passport_country),
                    ("passportNumber", self.passport_number),
                    ("addressLine1", self.address_line1),
                    ("foreignCity", self.foreign_city),
                    ("foreignState", self.foreign_state),
                    ("foreignZipCode", self.foreign_zip_code),
                    ("foreignCountry", self.foreign_country),
                )
                if not val
            ]
            if missing:
                raise ValueError(
                    f"nonUsCitizen=true requires: {', '.join(missing)} (PRD-007 §1 foreign-address branch)"
                )
        else:
            missing = [
                name
                for name, val in (
                    ("streetNumber", self.street_number),
                    ("streetName", self.street_name),
                    ("city", self.city),
                    ("state", self.state),
                    ("zipCode", self.zip_code),
                    ("country", self.country),
                )
                if not val
            ]
            if missing:
                raise ValueError(
                    f"nonUsCitizen=false requires: {', '.join(missing)} (PRD-007 §1 home-address branch). "
                    "ssn is validated separately (server-side, ownershipType-dependent — PRD-007 §2.4)."
                )

        # PRD-007 §1 cross-field rule (checkable per-owner, not per-list):
        # "every owner must be beneficialOwner=true and/or individualWithControl=true —
        # an owner with both false is invalid."
        if not self.beneficial_owner and not self.individual_with_control:
            raise ValueError(
                "Each owner must be marked as Beneficial Owner and/or Individual with Control "
                "(PRD-007 §1 cross-field rule)."
            )
        return self


class OwnerUpsertRequest(BaseModel):
    """`PUT /onboarding/owners` request body — full owner array, list-upsert (PRD-007 §4)."""

    model_config = ConfigDict(populate_by_name=True)

    owners: List[OwnerItem] = Field(..., min_length=1, max_length=5)


class OwnerResponse(BaseModel):
    """A single owner row as returned by `PUT`/`GET /onboarding/owners`."""

    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

    id: int
    first_name: str
    last_name: str
    contact_title: str
    phone_country_code: str
    phone_number: str
    email: str
    dob: date
    owner_percent: int
    non_us_citizen: bool

    # ssn itself is never returned (PRD-HWONB-001 AC-13 — encrypted values
    # never appear in API responses). Last-4 only, mirroring
    # AccountRowResponse.routingNumberLast4/accountNumberLast4
    # (schemas/accounts.py) — lets the frontend show "SSN on file: ending in
    # ####" without ever round-tripping the plaintext. credit_pull_waive is
    # fine to echo back.
    ssn_last4: Optional[str] = None
    credit_pull_waive: Optional[bool] = None
    street_number: Optional[str] = None
    street_name: Optional[str] = None
    street_direction: Optional[str] = None
    street_type: Optional[str] = None
    unit_number: Optional[str] = None
    city: Optional[str] = None
    state: Optional[str] = None
    zip_code: Optional[str] = None
    country: Optional[str] = None

    passport_country: Optional[str] = None
    passport_number: Optional[str] = None
    address_line1: Optional[str] = None
    address_line2: Optional[str] = None
    foreign_city: Optional[str] = None
    foreign_state: Optional[str] = None
    foreign_zip_code: Optional[str] = None
    foreign_country: Optional[str] = None

    app_signer: bool
    personal_guarantor: bool
    beneficial_owner: bool
    individual_with_control: bool

    # Bookkeeping/metadata fields intentionally kept snake_case (explicit
    # alias override) rather than inheriting the to_camel generator above —
    # these never existed in the prototype's owner object (they're
    # HubWallet-only), so they follow this module's plain envelope
    # convention instead (matches SectionState / OnboardingApplicationResponse
    # in schemas/application.py: created_at/updated_at/tsys_errors all
    # snake_case there too).
    tsys_owner_ref: Optional[str] = Field(default=None, alias="tsys_owner_ref")
    tsys_synced_at: Optional[datetime] = Field(default=None, alias="tsys_synced_at")
    tsys_errors: Optional[List[Dict[str, Any]]] = Field(default=None, alias="tsys_errors")

    created_at: datetime = Field(alias="created_at")
    updated_at: Optional[datetime] = Field(default=None, alias="updated_at")


class OwnerListResponse(BaseModel):
    """`PUT /onboarding/owners` response — per-row tsys_errors surfaced (AC-07 pattern)."""

    owners: List[OwnerResponse]
    any_errors: bool = False


class OwnerSyncResponse(BaseModel):
    """
    `GET /onboarding/owners` response — PRD-007 §4 Show/Sync reconcile.

    `gp_owners` is GP's raw response passed through unmodified (same
    reconciliation pattern as PRD-HWONB-012 §2.5's attachments list) so the
    wizard/ops tooling can diff it against `owners` (HubWallet's local rows)
    without this schema needing to model GP's exact shape.
    """

    owners: List[OwnerResponse]
    gp_owners: Optional[Any] = None
    gp_sync_error: Optional[List[Dict[str, Any]]] = None
