"""Credit portfolio analytics — concentration, vintage, migration.

Provides portfolio-level risk metrics: expected/unexpected loss,
economic capital (99% VaR), HHI concentration, Gini coefficient,
vintage analysis, and rating migration matrices.

Stdlib-only per ADR-007.
"""

from __future__ import annotations

import math
from dataclasses import dataclass


@dataclass(frozen=True)
class LoanExposure:
    """Single loan exposure record."""

    loan_id: str
    pd: float  # probability of default [0, 1]
    lgd: float  # loss given default [0, 1]
    ead: float  # exposure at default (currency units)
    sector: str  # industry/sector classification
    vintage: str  # origination cohort (e.g. "2024Q1")


@dataclass(frozen=True)
class PortfolioMetrics:
    """Aggregate portfolio risk metrics."""

    expected_loss: float
    unexpected_loss: float
    economic_capital: float  # 99% VaR
    hhi: float  # Herfindahl-Hirschman Index [0, 1]
    gini: float  # Gini coefficient [0, 1]
    count: int
    total_ead: float


_EMPTY_METRICS = PortfolioMetrics(
    expected_loss=0.0,
    unexpected_loss=0.0,
    economic_capital=0.0,
    hhi=0.0,
    gini=0.0,
    count=0,
    total_ead=0.0,
)


class PortfolioAnalyzer:
    """Compute portfolio-level risk metrics from loan exposures.

    EL  = sum(PD_i * LGD_i * EAD_i)
    UL  = sqrt(sum(PD_i * (1-PD_i) * LGD_i^2 * EAD_i^2))  (uncorrelated)
    EC  = z_99 * UL  where z_99 = 2.326
    HHI = sum((EAD_i / total_EAD)^2)
    """

    _Z99 = 2.326  # Normal z-score for 99th percentile

    def __init__(self, exposures: list[LoanExposure]) -> None:
        self._exposures = exposures

    def metrics(self) -> PortfolioMetrics:
        """Calculate full portfolio metrics."""
        if not self._exposures:
            return _EMPTY_METRICS

        total_ead = sum(e.ead for e in self._exposures)
        if total_ead <= 0.0:
            return _EMPTY_METRICS

        el = sum(e.pd * e.lgd * e.ead for e in self._exposures)
        ul = math.sqrt(sum(e.pd * (1.0 - e.pd) * e.lgd**2 * e.ead**2 for e in self._exposures))
        ec = self._Z99 * ul

        hhi = sum((e.ead / total_ead) ** 2 for e in self._exposures)
        gini = self._gini()

        return PortfolioMetrics(
            expected_loss=el,
            unexpected_loss=ul,
            economic_capital=ec,
            hhi=hhi,
            gini=gini,
            count=len(self._exposures),
            total_ead=total_ead,
        )

    def _gini(self) -> float:
        """Gini coefficient of PD distribution.

        Uses the sorted-cumulative method:
        G = (2 * sum(i * pd_i) - (n+1) * sum(pd_i)) / (n * sum(pd_i))
        """
        if not self._exposures:
            return 0.0

        pds = sorted(e.pd for e in self._exposures)
        n = len(pds)
        total = sum(pds)
        if total <= 0.0:
            return 0.0

        weighted_sum = sum((i + 1) * pd for i, pd in enumerate(pds))
        return (2.0 * weighted_sum - (n + 1) * total) / (n * total)


class VintageAnalyzer:
    """Analyze portfolio metrics by origination vintage."""

    def __init__(self, exposures: list[LoanExposure]) -> None:
        self._exposures = exposures

    def by_vintage(self) -> dict[str, PortfolioMetrics]:
        """Group exposures by vintage and compute metrics per cohort."""
        groups: dict[str, list[LoanExposure]] = {}
        for e in self._exposures:
            groups.setdefault(e.vintage, []).append(e)
        return {v: PortfolioAnalyzer(loans).metrics() for v, loans in groups.items()}

    def cumulative_default_rate(self, vintage: str, outcomes: list[dict[str, object]]) -> float:
        """Compute cumulative default rate for a vintage.

        Args:
            vintage: Cohort identifier (e.g. "2024Q1").
            outcomes: List of dicts with "loan_id" and "defaulted" (bool) keys.

        Returns:
            Fraction of vintage loans that defaulted [0, 1].
        """
        vintage_ids = {e.loan_id for e in self._exposures if e.vintage == vintage}
        if not vintage_ids:
            return 0.0

        defaulted = sum(
            1 for o in outcomes if o.get("loan_id") in vintage_ids and o.get("defaulted") is True
        )
        return defaulted / len(vintage_ids)


class MigrationMatrix:
    """Rating migration (transition) matrix tracker.

    Records state transitions and builds the empirical transition matrix.
    """

    def __init__(self) -> None:
        self._counts: dict[str, dict[str, int]] = {}
        self._all_states: set[str] = set()
        self._from_states: set[str] = set()

    def record(self, entity_id: str, from_state: str, to_state: str) -> None:  # noqa: ARG002
        """Record a single state transition."""
        self._all_states.update((from_state, to_state))
        self._from_states.add(from_state)

        row = self._counts.setdefault(from_state, {})
        row[to_state] = row.get(to_state, 0) + 1

    def matrix(self) -> dict[str, dict[str, int]]:
        """Return the raw transition count matrix."""
        return {s: dict(row) for s, row in self._counts.items()}

    def probability_matrix(self) -> dict[str, dict[str, float]]:
        """Return row-normalized transition probability matrix."""
        result: dict[str, dict[str, float]] = {}
        for state, row in self._counts.items():
            total = sum(row.values())
            if total > 0:
                result[state] = {s: c / total for s, c in row.items()}
            else:
                result[state] = {}
        return result

    def states(self) -> list[str]:
        """Return all observed states, sorted."""
        return sorted(self._all_states)

    def absorbing_states(self) -> set[str]:
        """Return states with no outgoing transitions to different states.

        A state is absorbing if it has never appeared as a from_state
        (or only transitions to itself).
        """
        absorbing = set()
        for state in self._all_states:
            if state not in self._counts:
                absorbing.add(state)
            else:
                row = self._counts[state]
                # Only self-transitions count as absorbing
                if all(target == state for target in row):
                    absorbing.add(state)
        return absorbing
