"""
alloggiati/transformer.py
=========================
Single source of truth transformation layer between the internal Django Guest
model and the Alloggiati Web API payload format.

This module is a PURE transformation layer:
  - No database writes
  - No API calls
  - No logging of sensitive data (document numbers, names)
  - No modifications to models or serializers

Design notes
------------
- get_field_value() is used for EVERY guest-originating field that may exist
  in extra_data.  This ensures the extra_data override rule is applied
  consistently across all fields, not just some.

- Normalization (country codes, document type codes, gender) happens BEFORE
  the post-normalization validation step.  This is intentional: we must know
  the normalized value to determine whether it is valid.  Validating the raw
  value first would allow invalid raw strings to pass the presence check and
  then silently corrupt the payload.

- normalize_country_code() and normalize_document_type() return "" on failure
  (not the raw input).  This is a deliberate strict policy: leaking a human-
  readable string like "United States" or "random document" into the payload
  would cause silent rejection by the Alloggiati portal with no actionable
  error.  Returning "" causes the post-normalization validation to fire with a
  specific reason code instead.

- Identity validation (first_name + last_name) is handled separately from
  _REQUIRED_OUTPUT_FIELDS because the system intentionally allows a single-
  word name where first_name is "" and last_name is the full name.  Including
  first_name/last_name in _REQUIRED_OUTPUT_FIELDS would incorrectly reject
  valid single-name guests.

Field mapping notes (actual model vs. original spec assumptions):
  - booking.reference_code    → does not exist; mapped to booking.uid (UUID)
  - booking.structure.external_id → does not exist; mapped to
    booking.structure.istat_code (the Alloggiati/ISTAT structure identifier)

Usage:
    from alloggiati.transformer import to_alloggiati_guest, transform_booking_guests
"""

from __future__ import annotations

import datetime
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING

try:
    from typing import TypedDict
except ImportError:                          # Python < 3.8 fallback
    from typing_extensions import TypedDict  # type: ignore[assignment]

from alloggiati.normalizers import (
    _safe_str,                  # type-safe string cleaning used throughout
    normalize_gender,
    normalize_country_code,
    normalize_document_type,
)

if TYPE_CHECKING:
    from guests.models import Guest
    from bookings.models import Booking


# ---------------------------------------------------------------------------
# TypedDict schemas — strict internal contracts (do not change field names)
# ---------------------------------------------------------------------------

class AlloggiatiPayload(TypedDict):
    first_name: str
    last_name: str
    gender: str                    # "M" | "F" | "U"
    date_of_birth: str             # "YYYY-MM-DD"
    place_of_birth_city: str
    place_of_birth_country: str    # 9-digit ISTAT country code or ""
    nationality: str               # 9-digit ISTAT country code
    document_type: str             # ISTAT document type code
    document_number: str
    document_issuing_country: str  # 9-digit ISTAT country code or ""
    booking_reference: str
    arrival_date: Optional[str]    # "YYYY-MM-DD"
    departure_date: Optional[str]  # "YYYY-MM-DD"
    structure_id: str


class ValidResult(TypedDict):
    valid: bool          # always True
    data: AlloggiatiPayload


class InvalidResult(TypedDict):
    valid: bool          # always False
    reason: str


# Union alias kept for public API compatibility
TransformResult = Dict[str, Any]


# ---------------------------------------------------------------------------
# Required output fields for the final consistency gate.
#
# first_name and last_name are intentionally excluded: the system allows
# single-name guests where first_name="" and last_name=<full name>.
# Identity is validated separately via the "missing_identity" check.
# ---------------------------------------------------------------------------

_REQUIRED_OUTPUT_FIELDS: Tuple[str, ...] = (
    "gender",
    "date_of_birth",
    "nationality",
    "document_type",
    "document_number",
    "structure_id",
    "booking_reference",
)


# ---------------------------------------------------------------------------
# Human-readable messages for every reason code.
#
# These are shown directly to hotel staff and support teams.
# They must be clear, actionable, and free of internal jargon.
# Never include raw field values (PII / document numbers).
# ---------------------------------------------------------------------------

_REASON_MESSAGES: Dict[str, Dict[str, str]] = {
    # Pre-normalization presence failures
    "missing_id_number": {
        "field":   "id_number",
        "message": "Document number is required.",
    },
    "missing_date_of_birth": {
        "field":   "date_of_birth",
        "message": "Date of birth is required.",
    },
    "missing_nationality": {
        "field":   "nationality",
        "message": "Nationality is required.",
    },
    "missing_document_type": {
        "field":   "document_type",
        "message": "Document type is required.",
    },
    "missing_document_issuing_country": {
        "field": "document_issuing_country",
        "message": "Document issuing country is required.",
    },
    # Date format failures
    "invalid_date_format": {
        "field":   "date_of_birth",
        "message": "Date of birth is not a valid date (expected YYYY-MM-DD).",
    },
    # Identity failures
    "missing_identity": {
        "field":   "full_name",
        "message": "Guest name is required (first name or last name must be provided).",
    },
    # Booking / structure failures
    "missing_booking_relation": {
        "field":   "booking",
        "message": "Guest is not linked to a booking.",
    },
    "missing_booking_uid": {
        "field":   "booking",
        "message": "Booking reference is missing.",
    },
    "missing_structure": {
        "field":   "structure",
        "message": "Booking is not linked to a structure.",
    },
    "missing_structure_istat_code": {
        "field":   "structure",
        "message": "Structure ISTAT code is not configured.",
    },
    # Post-normalization failures
    "invalid_nationality_code": {
        "field":   "nationality",
        "message": "Nationality could not be resolved to a valid Alloggiati country code.",
    },
    "invalid_document_type": {
        "field":   "document_type",
        "message": "Document type is not recognised. Use a standard type such as Passport or Identity Card.",
    },
    "invalid_birth_country": {
        "field":   "country_of_birth",
        "message": "Country of birth could not be resolved to a valid Alloggiati country code.",
    },
    "invalid_document_issuing_country": {
        "field":   "document_issuing_country",
        "message": "Document issuing country could not be resolved to a valid Alloggiati country code.",
    },
    # Final guard failures
    "incomplete_payload": {
        "field":   "payload",
        "message": "One or more required fields are missing from the guest record.",
    },
    "final_validation_failed": {
        "field":   "payload",
        "message": "Guest record failed final validation. Please review all required fields.",
    },
}

_FALLBACK_ERROR = {"field": "unknown", "message": "An unexpected validation error occurred."}


def reason_to_error(reason: str) -> Dict[str, str]:
    """
    Convert an internal reason code to a staff-readable {field, message} dict.

    Never raises — unknown codes produce a safe fallback message.
    Never exposes raw field values, credentials, or stack traces.
    """
    return dict(_REASON_MESSAGES.get(reason, _FALLBACK_ERROR))


# ---------------------------------------------------------------------------
# 1. split_full_name
# ---------------------------------------------------------------------------

def split_full_name(full_name: str) -> Tuple[str, str]:
    """
    Parse a full name string into (first_name, last_name).

    Rules:
      - Strips surrounding whitespace.
      - Two or more tokens: first token → first_name, remainder → last_name.
      - Exactly one token:  first_name = "", last_name = that token.
      - Empty / whitespace-only: returns ("", "").

    Args:
        full_name: Raw full name string from Guest.full_name.

    Returns:
        Tuple of (first_name, last_name), both stripped strings.
    """
    if not full_name or not full_name.strip():
        return ("", "")

    parts = full_name.strip().split()

    if len(parts) == 1:
        return ("", parts[0])

    return (parts[0], " ".join(parts[1:]))


# ---------------------------------------------------------------------------
# 2. get_field_value
# ---------------------------------------------------------------------------

def get_field_value(guest: "Guest", field_name: str) -> Any:
    """
    Retrieve a guest field value applying the extra_data override rule.

    Priority:
      1. extra_data[field_name] — if present and non-empty (not None, not "").
      2. getattr(guest, field_name) — model field fallback.
      3. None — if neither source has the field.

    This function MUST be used for every guest-originating field that may
    exist in extra_data.  Using getattr() directly would bypass the override
    rule and produce inconsistent behaviour when guests submit data via the
    check-in form (which stores values in extra_data).

    Args:
        guest:      Guest model instance.
        field_name: Name of the field to retrieve.

    Returns:
        The resolved value, or None if not found in either source.
    """
    extra_data: Dict[str, Any] = getattr(guest, "extra_data", None) or {}

    extra_value = extra_data.get(field_name)
    if extra_value is not None and extra_value != "":
        return extra_value

    return getattr(guest, field_name, None)


# ---------------------------------------------------------------------------
# 3. _resolve_identity
# ---------------------------------------------------------------------------

def _resolve_identity(guest: "Guest") -> Tuple[str, str]:
    """
    Resolve first_name and last_name using the priority chain:

      1. extra_data.first_name / extra_data.last_name  (if non-empty)
      2. Parsed Guest.full_name
      3. Empty strings as final fallback (caller must validate)

    Args:
        guest: Guest model instance.

    Returns:
        Tuple of (first_name, last_name), both stripped.
    """
    extra_data: Dict[str, Any] = getattr(guest, "extra_data", None) or {}

    extra_first: str = (extra_data.get("first_name") or "").strip()
    extra_last: str = (extra_data.get("last_name") or "").strip()

    if extra_first and extra_last:
        return (extra_first, extra_last)

    parsed_first, parsed_last = split_full_name(getattr(guest, "full_name", "") or "")

    first_name = extra_first if extra_first else parsed_first
    last_name = extra_last if extra_last else parsed_last

    return (first_name, last_name)


# ---------------------------------------------------------------------------
# 4. _parse_date_strict
# ---------------------------------------------------------------------------

def _parse_date_strict(value: Any) -> Tuple[Optional[str], bool]:
    """
    Strictly parse and validate a date value into "YYYY-MM-DD" format.

    Accepts:
      - datetime.date / datetime.datetime objects  → always valid
      - Strings in "YYYY-MM-DD" format with a valid calendar date

    Rejects:
      - Strings that are not valid ISO dates ("2026-99-99", "abc", "")
      - None → treated as missing, not invalid (returns None, True)

    Args:
        value: Raw date value.

    Returns:
        (formatted_str, True)  — valid date
        (None, True)           — value was None/missing (caller decides)
        (None, False)          — value present but malformed
    """
    if value is None:
        return (None, True)

    if isinstance(value, (datetime.date, datetime.datetime)):
        return (value.strftime("%Y-%m-%d"), True)

    if isinstance(value, str):
        stripped = value.strip()
        if not stripped:
            return (None, True)

        if len(stripped) < 10:
            return (None, False)

        date_part = stripped[:10]

        try:
            parsed = datetime.date.fromisoformat(date_part)
        except ValueError:
            return (None, False)

        if parsed.strftime("%Y-%m-%d") != date_part:
            return (None, False)

        return (date_part, True)

    return (None, False)


# ---------------------------------------------------------------------------
# 5. _validate_guest_fields  (fail-fast — used by to_alloggiati_guest)
# ---------------------------------------------------------------------------

def _validate_guest_fields(guest: "Guest") -> Optional[str]:
    """
    Validate presence of required guest-level fields BEFORE normalization.

    Uses get_field_value() for all fields so that extra_data overrides are
    respected during validation — the same source used during payload assembly.
    This prevents the mismatch where validation passes on the model field but
    payload assembly uses a different (extra_data) value.

    Required fields:
      document_number  → "missing_id_number"
      date_of_birth    → "missing_date_of_birth"
      nationality      → "missing_nationality"
      document_type    → "missing_document_type"

    Date format is also validated here:
      date_of_birth must be a valid ISO date → "invalid_date_format"

    Args:
        guest: Guest model instance.

    Returns:
        None if all fields pass, or a reason string on the first failure.
    """
    scalar_required: List[Tuple[str, str]] = [
        ("document_number", "missing_id_number"),
        ("date_of_birth",   "missing_date_of_birth"),
        ("nationality",     "missing_nationality"),
        ("document_type",   "missing_document_type"),
        ("document_issuing_country", "missing_document_issuing_country"),
    ]

    for field, reason in scalar_required:
        raw = get_field_value(guest, field)
        if raw is None or (isinstance(raw, str) and not raw.strip()):
            return reason

    # Validate date format using the same source as payload assembly.
    dob_raw = get_field_value(guest, "date_of_birth")
    _, date_valid = _parse_date_strict(dob_raw)
    if not date_valid:
        return "invalid_date_format"

    return None


# ---------------------------------------------------------------------------
# 5b. collect_guest_validation_errors  (full-scan — used by service layer)
# ---------------------------------------------------------------------------

def collect_guest_validation_errors(guest: "Guest") -> List[Dict[str, str]]:
    """
    Collect ALL validation errors for a guest in a single pass.

    Unlike _validate_guest_fields() which stops at the first failure,
    this function checks every required field and returns the complete
    list of problems.  This is used by the service layer to build
    structured, staff-readable error reports.

    Validation order mirrors the transformer pipeline:
      1. Identity (name)
      2. Required scalar fields (document_number, date_of_birth,
         nationality, document_type, document_issuing_country)
      3. Date format for date_of_birth
      4. Booking + structure references
      5. Post-normalization checks (nationality code, document type code,
         document_issuing_country code)

    Args:
        guest: Guest model instance.

    Returns:
        List of {"field": str, "message": str} dicts.
        Empty list means the guest is fully valid.
        Never raises.
    """
    errors: List[Dict[str, str]] = []

    # ── 1. Identity ──────────────────────────────────────────────────────────
    first_name, last_name = _resolve_identity(guest)
    if not first_name.strip() and not last_name.strip():
        errors.append(reason_to_error("missing_identity"))

    # ── 2. Required scalar fields ─────────────────────────────────────────────
    scalar_checks: List[Tuple[str, str]] = [
        ("document_number",         "missing_id_number"),
        ("date_of_birth",           "missing_date_of_birth"),
        ("nationality",             "missing_nationality"),
        ("document_type",           "missing_document_type"),
        ("document_issuing_country","missing_document_issuing_country"),
    ]

    missing_dob = False
    for field_name, reason_code in scalar_checks:
        raw = get_field_value(guest, field_name)
        if raw is None or (isinstance(raw, str) and not raw.strip()):
            errors.append(reason_to_error(reason_code))
            if field_name == "date_of_birth":
                missing_dob = True

    # ── 3. Date format ────────────────────────────────────────────────────────
    if not missing_dob:
        dob_raw = get_field_value(guest, "date_of_birth")
        _, date_valid = _parse_date_strict(dob_raw)
        if not date_valid:
            errors.append(reason_to_error("invalid_date_format"))

    # ── 4. Booking + structure ────────────────────────────────────────────────
    booking = getattr(guest, "booking", None)
    booking_reason = _validate_booking_fields(booking)
    if booking_reason:
        errors.append(reason_to_error(booking_reason))
        # Cannot proceed to normalization checks without a valid booking
        return errors

    # ── 5. Post-normalization checks ──────────────────────────────────────────
    # Only run if the raw field was present (presence already checked above).
    raw_nationality = _safe_str(get_field_value(guest, "nationality")) or None
    if raw_nationality:
        norm_nat = normalize_country_code(raw_nationality)
        if not norm_nat:
            errors.append(reason_to_error("invalid_nationality_code"))

    raw_doc_type = _safe_str(get_field_value(guest, "document_type")) or None
    if raw_doc_type:
        norm_doc = normalize_document_type(raw_doc_type)
        if not norm_doc:
            errors.append(reason_to_error("invalid_document_type"))

    raw_issuing = _safe_str(get_field_value(guest, "document_issuing_country")) or None
    if raw_issuing:
        norm_issuing = normalize_country_code(raw_issuing)
        if not norm_issuing:
            errors.append(reason_to_error("invalid_document_issuing_country"))

    return errors


# ---------------------------------------------------------------------------
# 6. _validate_booking_fields
# ---------------------------------------------------------------------------

def _validate_booking_fields(booking: Any) -> Optional[str]:
    """
    Validate required booking- and structure-level fields.

    Checks (fails on first error):
      booking exists                         → "missing_booking_relation"
      booking.uid non-empty                  → "missing_booking_uid"
      booking.structure exists               → "missing_structure"
      booking.structure.istat_code non-empty → "missing_structure_istat_code"

    Args:
        booking: Booking model instance (or None).

    Returns:
        None if all fields pass, or a reason string on the first failure.
    """
    if booking is None:
        return "missing_booking_relation"

    uid = getattr(booking, "uid", None)
    if uid is None or str(uid).strip() == "":
        return "missing_booking_uid"

    structure = getattr(booking, "structure", None)
    if structure is None:
        return "missing_structure"

    istat_code = getattr(structure, "istat_code", None)
    if istat_code is None or str(istat_code).strip() == "":
        return "missing_structure_istat_code"

    return None


# ---------------------------------------------------------------------------
# 7. _validate_normalized_fields
# ---------------------------------------------------------------------------

def _validate_normalized_fields(
    nationality: str,
    document_type: str,
    place_of_birth_country: str,
    document_issuing_country: str,
) -> Optional[str]:
    """
    Validate that normalization produced non-empty coded values for required
    fields.

    This step runs AFTER normalization.  It is necessary because a field can
    pass the presence check in _validate_guest_fields() (e.g. "Mars" is a
    non-empty string) but then fail normalization (normalize_country_code
    returns "" for an unrecognised value).  Without this step, an invalid
    raw value would silently produce an empty field in the payload.

    Required to be non-empty after normalization:
      nationality              → "invalid_nationality_code"
      document_type            → "invalid_document_type"

    Optional fields (empty is allowed — not all guests have these):
      place_of_birth_country   → "invalid_birth_country"   (only if non-empty raw)
      document_issuing_country → "invalid_document_issuing_country" (only if non-empty raw)

    Note: place_of_birth_country and document_issuing_country are validated
    only when the caller passes a non-empty string, indicating the raw value
    was present but failed normalization.

    Args:
        nationality:              Normalized nationality code (or "").
        document_type:            Normalized document type code (or "").
        place_of_birth_country:   Normalized birth country code (or "").
        document_issuing_country: Normalized issuing country code (or "").

    Returns:
        None if all checks pass, or a reason string on the first failure.
    """
    if not nationality:
        return "invalid_nationality_code"

    if not document_type:
        return "invalid_document_type"

    # place_of_birth_country and document_issuing_country are optional fields.
    # We only fail if the caller explicitly signals that a non-empty raw value
    # was present but could not be normalized (indicated by passing a sentinel).
    # The transformer passes "" for both when the raw value was also empty,
    # so an empty result here is acceptable.
    # Callers that need strict validation of these fields can check the return
    # value of normalize_country_code() directly before calling this function.

    return None


# ---------------------------------------------------------------------------
# 8. _final_payload_guard
# ---------------------------------------------------------------------------

def _final_payload_guard(payload: Dict[str, Any]) -> Optional[str]:
    """
    Final safety gate: ensure every required output field is non-empty.

    Checks _REQUIRED_OUTPUT_FIELDS (which intentionally excludes first_name
    and last_name — identity is validated separately).

    Also enforces that at least one of first_name / last_name is non-empty.

    Args:
        payload: The assembled AlloggiatiPayload dict.

    Returns:
        None if the payload passes all checks.
        "missing_identity"        — both first_name and last_name are empty.
        "incomplete_payload"      — a required field is empty/missing.
        "final_validation_failed" — a critical field subset is empty
                                    (document_number, document_type,
                                     structure_id, booking_reference).
    """
    # Identity check — separate from _REQUIRED_OUTPUT_FIELDS (see module note).
    first = _safe_str(payload.get("first_name"))
    last = _safe_str(payload.get("last_name"))
    if not first and not last:
        return "missing_identity"

    # All required output fields must be non-empty.
    for field in _REQUIRED_OUTPUT_FIELDS:
        value = payload.get(field)
        if not _safe_str(value):
            return "incomplete_payload"

    # Critical field subset — extra hard stop before any external call.
    critical = ("document_number", "document_type", "structure_id", "booking_reference")
    for field in critical:
        if not _safe_str(payload.get(field)):
            return "final_validation_failed"

    return None


# ---------------------------------------------------------------------------
# 9. to_alloggiati_guest  (MAIN FUNCTION)
# ---------------------------------------------------------------------------

def to_alloggiati_guest(guest: "Guest") -> TransformResult:
    """
    Transform a single Guest instance into an Alloggiati Web payload dict.

    Validation + transformation pipeline (fails fast on first error):

      STEP 1 — Pre-normalization field presence check
               Ensures required raw fields exist before we attempt normalization.
               Uses get_field_value() so extra_data overrides are respected.

      STEP 2 — Identity resolution
               Resolves first_name / last_name from extra_data or full_name.

      STEP 3 — Booking + structure validation
               Checks uid, structure, istat_code.

      STEP 4 — Strict date parsing
               Validates date_of_birth, check_in_date, check_out_date.
               Uses get_field_value() for date_of_birth to match STEP 1.

      STEP 5 — Normalization
               Converts raw values to ISTAT coded values.
               All guest fields use get_field_value() for consistent
               extra_data override behaviour.

      STEP 6 — Post-normalization validation
               Ensures normalization produced non-empty coded values for
               required fields.  Catches cases where a field was present
               but unrecognised (e.g. nationality="Mars").

      STEP 7 — Final payload guard
               Last-resort check that no required output field is empty.

    Args:
        guest: Guest model instance with a related Booking and Structure.

    Returns:
        {"valid": True,  "data": AlloggiatiPayload}  on success.
        {"valid": False, "reason": str}               on any validation failure.
    """
    # ------------------------------------------------------------------
    # STEP 1: Pre-normalization presence check
    # Uses get_field_value() — same source as payload assembly — so that
    # extra_data overrides are respected during validation.
    # ------------------------------------------------------------------
    field_error = _validate_guest_fields(guest)
    if field_error:
        return {"valid": False, "reason": field_error}

    # ------------------------------------------------------------------
    # STEP 2: Resolve and validate identity
    # ------------------------------------------------------------------
    first_name, last_name = _resolve_identity(guest)

    if not first_name.strip() and not last_name.strip():
        return {"valid": False, "reason": "missing_identity"}

    # ------------------------------------------------------------------
    # STEP 3: Validate booking + structure
    # ------------------------------------------------------------------
    booking = getattr(guest, "booking", None)
    booking_error = _validate_booking_fields(booking)
    if booking_error:
        return {"valid": False, "reason": booking_error}

    structure = booking.structure  # safe — validated above

    # ------------------------------------------------------------------
    # STEP 4: Strict date parsing
    # Uses get_field_value() for date_of_birth to match STEP 1 exactly.
    # ------------------------------------------------------------------
    dob_raw = get_field_value(guest, "date_of_birth")
    dob_str, dob_valid = _parse_date_strict(dob_raw)
    if not dob_valid or dob_str is None:
        return {"valid": False, "reason": "invalid_date_format"}

    arrival_str, arrival_valid = _parse_date_strict(
        getattr(booking, "check_in_date", None)
    )
    if not arrival_valid:
        return {"valid": False, "reason": "invalid_date_format"}

    departure_str, departure_valid = _parse_date_strict(
        getattr(booking, "check_out_date", None)
    )
    if not departure_valid:
        return {"valid": False, "reason": "invalid_date_format"}

    # ------------------------------------------------------------------
    # STEP 5: Normalize all guest-originating fields.
    # get_field_value() is used for every field that may exist in extra_data
    # so that the override rule is applied consistently.
    # normalize_*() functions return "" on failure — never the raw string.
    # ------------------------------------------------------------------
    norm_nationality = normalize_country_code(
        _safe_str(get_field_value(guest, "nationality")) or None
    )
    norm_country_of_birth = normalize_country_code(
        _safe_str(get_field_value(guest, "country_of_birth")) or None
    )
    norm_doc_issuing_country = normalize_country_code(
        _safe_str(get_field_value(guest, "document_issuing_country")) or None
    )
    norm_document_type = normalize_document_type(
        _safe_str(get_field_value(guest, "document_type")) or None
    )
    norm_gender = normalize_gender(get_field_value(guest, "gender"))

    # ------------------------------------------------------------------
    # STEP 6: Post-normalization validation.
    # Runs AFTER normalization so we can detect "field present but invalid"
    # cases (e.g. nationality="Mars" passes presence check but normalizes to "").
    # ------------------------------------------------------------------
    norm_error = _validate_normalized_fields(
        nationality=norm_nationality,
        document_type=norm_document_type,
        place_of_birth_country=norm_country_of_birth,
        document_issuing_country=norm_doc_issuing_country,
    )
    if norm_error:
        return {"valid": False, "reason": norm_error}

    # ------------------------------------------------------------------
    # STEP 7: Assemble payload
    # ------------------------------------------------------------------
    payload: Dict[str, Any] = {
        "first_name":              first_name,
        "last_name":               last_name,
        "gender":                  norm_gender,
        "date_of_birth":           dob_str,
        "place_of_birth_city":     _safe_str(get_field_value(guest, "place_of_birth")),
        "place_of_birth_country":  norm_country_of_birth,
        "nationality":             norm_nationality,
        "document_type":           norm_document_type,
        "document_number":         _safe_str(get_field_value(guest, "document_number")),
        "document_issuing_country": norm_doc_issuing_country,
        "booking_reference":       _safe_str(booking.uid),
        "arrival_date":            arrival_str,
        "departure_date":          departure_str,
        "structure_id":            _safe_str(structure.istat_code),
    }

    # ------------------------------------------------------------------
    # STEP 8: Final payload guard — last-resort check before returning.
    # ------------------------------------------------------------------
    guard_error = _final_payload_guard(payload)
    if guard_error:
        return {"valid": False, "reason": guard_error}

    return {"valid": True, "data": payload}


# ---------------------------------------------------------------------------
# 10. transform_booking_guests
# ---------------------------------------------------------------------------

def transform_booking_guests(booking: "Booking") -> Dict[str, List[Any]]:
    """
    Transform all guests associated with a booking.

    Iterates booking.guests.all(), applies to_alloggiati_guest() to each,
    and separates results into valid and invalid buckets.

    For invalid guests, collect_guest_validation_errors() is called to
    produce the full structured error list (all fields, not just the first).

    Country normalization is O(1) per call — the snapshot is an immutable
    in-memory dict loaded once at startup.  Document type lookups are cached
    via lru_cache for the process lifetime.  No N+1 DB queries occur.

    Args:
        booking: Booking model instance with a prefetchable guests relation.

    Returns:
        {
            "valid":   [AlloggiatiPayload, ...],
            "invalid": [
                {
                    "guest_id":    int,
                    "booking_id":  int,
                    "guest_name":  str,   # display name only, no doc numbers
                    "reason":      str,   # legacy single-reason code
                    "errors":      [{"field": str, "message": str}, ...],
                },
                ...
            ],
        }
    """
    valid_payloads: List[AlloggiatiPayload] = []
    invalid_records: List[Dict[str, Any]] = []

    booking_id = getattr(booking, "id", None)

    for guest in booking.guests.all():
        result = to_alloggiati_guest(guest)

        if result.get("valid"):
            valid_payloads.append(result["data"])
        else:
            # Collect all errors for this guest (full-scan, not fail-fast).
            structured_errors = collect_guest_validation_errors(guest)

            # Safe display name — full_name only, never document numbers.
            guest_name = _safe_str(getattr(guest, "full_name", "")) or "Unknown Guest"

            invalid_records.append(
                {
                    "guest_id":   getattr(guest, "id", None),
                    "booking_id": booking_id,
                    "guest_name": guest_name,
                    "reason":     result.get("reason", "unknown_error"),
                    "errors":     structured_errors,
                }
            )

    return {
        "valid":   valid_payloads,
        "invalid": invalid_records,
    }
