"""Real-world bank loan tape ingestion into Orxaq LoanExposure objects.

Maps FR Y-14M format CSVs (and JSON equivalents) to the LoanExposure
schema used by the CECL/CCAR pipeline.  Stdlib-only (ADR-007).

Typical usage::

    from orxaq.fabric.portfolio_ingest import ingest_portfolio, FR_Y14M_CONFIG

    result = ingest_portfolio("loans.csv", FR_Y14M_CONFIG)
    loans = result.loans          # List[LoanExposure]
    print(result.row_count)       # 5_000

    # Then feed into CECL:
    from orxaq.credit.cecl import CECLEngine
    engine = CECLEngine()
    ecl = engine.compute(loans)
"""

from __future__ import annotations

import csv
import json
from dataclasses import dataclass, field
from datetime import date, datetime
from pathlib import Path
from typing import Any

from orxaq.credit.portfolio import LoanExposure

__all__ = [
    "PortfolioIngestConfig",
    "IngestResult",
    "ingest_portfolio",
    "FR_Y14M_CONFIG",
]

# ── Config ────────────────────────────────────────────────────


@dataclass
class PortfolioIngestConfig:
    """Specification for mapping a bank CSV to LoanExposure fields.

    Attributes:
        field_map: Maps bank CSV column names → LoanExposure constructor
            kwargs.  Keys are source column names; values are target field
            names (``loan_id``, ``pd``, ``lgd``, ``ead``, ``sector``,
            ``vintage``).
        required_fields: Source column names that must be present and
            non-empty.  Missing values raise ``ValueError``.
        optional_fields: Source column names that may be absent; missing
            values default to ``None``.
        bounds: ``{target_field: (min, max)}`` numeric validation ranges.
            Applied after type coercion.
        date_format: ``strptime`` format for date columns (``origination_date``).
        default_sector: Fallback ``sector`` value when property_type absent.
    """

    field_map: dict[str, str] = field(
        default_factory=lambda: {
            "loan_id": "loan_id",
            "current_balance": "ead",
            "pd": "pd",
            "lgd": "lgd",
            "ead": "ead",
            "property_type": "sector",
            "origination_date": "origination_date",
            "fico_score": "fico_score",
            "interest_rate": "interest_rate",
            "ltv": "ltv",
            "dti": "dti",
            "delinquency_status": "delinquency_status",
        }
    )
    required_fields: list[str] = field(
        default_factory=lambda: [
            "loan_id",
            "fico_score",
            "interest_rate",
            "ltv",
            "dti",
        ]
    )
    optional_fields: list[str] = field(
        default_factory=lambda: [
            "pd",
            "lgd",
            "ead",
            "current_balance",
            "property_type",
            "origination_date",
            "delinquency_status",
        ]
    )
    bounds: dict[str, tuple[float, float]] = field(
        default_factory=lambda: {
            "fico_score": (300.0, 850.0),
            "ltv": (0.0, 2.0),
            "dti": (0.0, 1.0),
            "interest_rate": (0.0, 0.5),
            "pd": (0.0, 1.0),
            "lgd": (0.0, 1.0),
        }
    )
    date_format: str = "%Y-%m-%d"
    default_sector: str = "unknown"


# ── Result ────────────────────────────────────────────────────


@dataclass
class IngestResult:
    """Output of a successful ingest_portfolio call.

    Attributes:
        loans: Parsed and validated LoanExposure objects.
        row_count: Total rows read from the source file.
        error_count: Rows skipped due to validation errors (0 when
            ``raise_on_error=True``, which is the default).
        warnings: Non-fatal advisory messages (e.g. missing optional cols).
    """

    loans: list[LoanExposure]
    row_count: int
    error_count: int
    warnings: list[str]


# ── Default FR Y-14M configuration ───────────────────────────

FR_Y14M_CONFIG: PortfolioIngestConfig = PortfolioIngestConfig()
"""Pre-built config for standard FR Y-14M loan tape column names."""


# ── Helpers ───────────────────────────────────────────────────


def _parse_date(value: str, fmt: str) -> date:
    return datetime.strptime(value.strip(), fmt).date()


def _coerce_row(
    row: dict[str, str],
    config: PortfolioIngestConfig,
) -> dict[str, Any]:
    """Rename, coerce types, and validate one raw CSV/JSON row.

    Returns a dict of coerced values keyed by the *source* column names.
    Raises ``ValueError`` on missing required fields or out-of-bounds values.
    """
    coerced: dict[str, Any] = {}

    # Validate required fields present
    for src in config.required_fields:
        val = row.get(src, "")
        if val is None or str(val).strip() == "":
            raise ValueError(f"Required field '{src}' is missing or empty")
        coerced[src] = str(val).strip()

    # Pull optional fields (None if absent)
    for src in config.optional_fields:
        raw = row.get(src)
        coerced[src] = str(raw).strip() if raw is not None and str(raw).strip() else None

    # Type coerce known numeric fields
    float_fields = {
        "fico_score",
        "ltv",
        "dti",
        "interest_rate",
        "pd",
        "lgd",
        "ead",
        "current_balance",
    }
    for src in list(config.required_fields) + list(config.optional_fields):
        cell: Any = coerced.get(src)
        mapped = config.field_map.get(src, src)
        if (src in float_fields or mapped in float_fields) and cell is not None:
            try:
                coerced[src] = float(cell)
            except (ValueError, TypeError):
                raise ValueError(f"Field '{src}' cannot be coerced to float: {cell!r}")

    # Bounds check (using source names as specified in config.bounds)
    for field_name, (lo, hi) in config.bounds.items():
        cell = coerced.get(field_name)
        if cell is not None:
            fval = float(cell)
            if not (lo <= fval <= hi):
                raise ValueError(f"Field '{field_name}' value {fval} out of bounds [{lo}, {hi}]")

    # Apply field_map: rename source column names to canonical destination names.
    # First writer wins so that explicit destination columns beat fallback sources
    # (e.g. an explicit `ead` column beats `current_balance` → ead).
    renamed: dict[str, Any] = {}
    for src, val in coerced.items():
        dst = config.field_map.get(src, src)
        if dst not in renamed:
            renamed[dst] = val
    return renamed


def _row_to_loan_exposure(
    coerced: dict[str, Any],
    config: PortfolioIngestConfig,
) -> LoanExposure:
    """Convert a coerced row dict into a LoanExposure."""
    loan_id: str = coerced["loan_id"]

    # ead: field_map already merged current_balance → ead via first-writer rule
    ead_val = coerced.get("ead") or 0.0
    ead = float(ead_val) if ead_val is not None else 0.0

    pd_val = coerced.get("pd")
    pd = float(pd_val) if pd_val is not None else 0.05  # default 5%

    lgd_val = coerced.get("lgd")
    lgd = float(lgd_val) if lgd_val is not None else 0.45  # CECL industry default

    # sector: field_map renames property_type → sector (or custom mapping)
    sector = coerced.get("sector") or config.default_sector

    # vintage from origination_date
    orig = coerced.get("origination_date")
    if orig and str(orig).strip():
        try:
            d = _parse_date(str(orig), config.date_format)
            q = (d.month - 1) // 3 + 1
            vintage = f"{d.year}Q{q}"
        except ValueError:
            vintage = "unknown"
    else:
        vintage = "unknown"

    return LoanExposure(
        loan_id=loan_id,
        pd=pd,
        lgd=lgd,
        ead=ead,
        sector=sector,
        vintage=vintage,
    )


# ── Public API ────────────────────────────────────────────────


def ingest_portfolio(
    path: str | Path,
    config: PortfolioIngestConfig | None = None,
    audit_log: list[dict[str, Any]] | None = None,
    *,
    run_quality: bool = True,
    quality_threshold: float = 0.95,
) -> IngestResult:
    """Ingest a bank loan tape CSV or JSON file into LoanExposure objects.

    Args:
        path: Path to the loan tape file.  CSV detected by ``.csv`` extension;
              all others treated as JSON.
        config: Field mapping and validation config.  Defaults to
                ``FR_Y14M_CONFIG`` (standard FR Y-14M column names).
        audit_log: If provided, an audit entry is appended:
                   ``{action: 'portfolio_ingest', actor: 'system',
                      details: {path, row_count, fields}}``.
        run_quality: When ``True`` (default), raises ``ValueError`` if the
                     fraction of valid rows falls below ``quality_threshold``.
        quality_threshold: Minimum fraction of rows that must parse cleanly
                           (default ``0.95``).

    Returns:
        :class:`IngestResult` with parsed loans and metadata.

    Raises:
        FileNotFoundError: If ``path`` does not exist.
        ValueError: If required fields missing, values out of bounds, or
                    quality gate fails.
    """
    if config is None:
        config = FR_Y14M_CONFIG

    p = Path(path)
    if not p.exists():
        raise FileNotFoundError(f"Loan tape not found: {p}")

    # Read raw rows
    raw_rows: list[dict[str, Any]] = []
    if p.suffix.lower() == ".csv":
        with open(p, encoding="utf-8", newline="") as fh:
            reader = csv.DictReader(fh)
            for row in reader:
                raw_rows.append(dict(row))
    else:
        with open(p, encoding="utf-8") as fh:
            data = json.load(fh)
        if isinstance(data, list):
            raw_rows = data
        elif isinstance(data, dict) and "loans" in data:
            raw_rows = data["loans"]
        else:
            raise ValueError("JSON file must be a list or {'loans': [...]}")

    row_count = len(raw_rows)
    loans: list[LoanExposure] = []
    errors = 0
    warnings: list[str] = []
    missing_optional: set[str] = set()

    for i, row in enumerate(raw_rows):
        try:
            coerced = _coerce_row(row, config)
            loans.append(_row_to_loan_exposure(coerced, config))
        except ValueError as exc:
            errors += 1
            warnings.append(f"Row {i + 1}: {exc}")

    # Quality gate
    if run_quality and row_count > 0:
        valid_frac = (row_count - errors) / row_count
        if valid_frac < quality_threshold:
            raise ValueError(
                f"Quality gate failed: only {valid_frac:.1%} of rows valid "
                f"(threshold {quality_threshold:.1%}).  Errors: {warnings[:5]}"
            )

    # Warn about missing optional columns
    if raw_rows:
        sample = raw_rows[0]
        for opt in config.optional_fields:
            if opt not in sample:
                missing_optional.add(opt)
    if missing_optional:
        warnings.append(f"Optional columns absent from file: {sorted(missing_optional)}")

    # Audit trail
    if audit_log is not None:
        audit_log.append(
            {
                "action": "portfolio_ingest",
                "actor": "system",
                "details": {
                    "path": str(p),
                    "row_count": row_count,
                    "valid_count": row_count - errors,
                    "fields": list(config.field_map.keys()),
                },
            }
        )

    return IngestResult(
        loans=loans,
        row_count=row_count,
        error_count=errors,
        warnings=warnings,
    )
