"""Tests for credit portfolio analytics module."""

from __future__ import annotations

import math

import pytest

from orxaq.credit.portfolio import (
    LoanExposure,
    MigrationMatrix,
    PortfolioAnalyzer,
    VintageAnalyzer,
)

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


def _loan(
    lid: str,
    pd: float,
    lgd: float,
    ead: float,
    sector: str = "Retail",
    vintage: str = "2024Q1",
) -> LoanExposure:
    return LoanExposure(loan_id=lid, pd=pd, lgd=lgd, ead=ead, sector=sector, vintage=vintage)


@pytest.fixture()
def single_loan() -> list[LoanExposure]:
    return [_loan("L1", 0.05, 0.4, 100_000)]


@pytest.fixture()
def uniform_portfolio() -> list[LoanExposure]:
    return [
        _loan("L1", 0.10, 0.5, 100_000),
        _loan("L2", 0.10, 0.5, 150_000),
        _loan("L3", 0.10, 0.5, 200_000),
    ]


@pytest.fixture()
def mixed_portfolio() -> list[LoanExposure]:
    return [
        _loan("L1", 0.05, 0.4, 100_000, vintage="2023Q4"),
        _loan("L2", 0.10, 0.5, 150_000, vintage="2023Q4"),
        _loan("L3", 0.20, 0.6, 200_000, vintage="2024Q1"),
    ]


# ---------------------------------------------------------------------------
# LoanExposure dataclass
# ---------------------------------------------------------------------------


class TestLoanExposure:
    def test_frozen(self) -> None:
        loan = _loan("L1", 0.05, 0.4, 100_000)
        with pytest.raises(AttributeError):
            loan.pd = 0.1  # type: ignore[misc]

    def test_fields(self) -> None:
        loan = _loan("L1", 0.05, 0.4, 100_000, "Tech", "2024Q2")
        assert loan.loan_id == "L1"
        assert loan.pd == 0.05
        assert loan.lgd == 0.4
        assert loan.ead == 100_000
        assert loan.sector == "Tech"
        assert loan.vintage == "2024Q2"


# ---------------------------------------------------------------------------
# PortfolioAnalyzer
# ---------------------------------------------------------------------------


class TestPortfolioAnalyzerEmpty:
    def test_empty_returns_zeros(self) -> None:
        m = PortfolioAnalyzer([]).metrics()
        assert m.expected_loss == 0.0
        assert m.unexpected_loss == 0.0
        assert m.economic_capital == 0.0
        assert m.hhi == 0.0
        assert m.gini == 0.0
        assert m.count == 0
        assert m.total_ead == 0.0


class TestPortfolioAnalyzerSingle:
    def test_expected_loss(self, single_loan: list[LoanExposure]) -> None:
        m = PortfolioAnalyzer(single_loan).metrics()
        expected = 0.05 * 0.4 * 100_000  # 2000
        assert math.isclose(m.expected_loss, expected)

    def test_unexpected_loss(self, single_loan: list[LoanExposure]) -> None:
        m = PortfolioAnalyzer(single_loan).metrics()
        ul = math.sqrt(0.05 * 0.95 * 0.4**2 * 100_000**2)
        assert math.isclose(m.unexpected_loss, ul)

    def test_economic_capital(self, single_loan: list[LoanExposure]) -> None:
        m = PortfolioAnalyzer(single_loan).metrics()
        ul = math.sqrt(0.05 * 0.95 * 0.4**2 * 100_000**2)
        assert math.isclose(m.economic_capital, 2.326 * ul)

    def test_hhi_single_loan_is_one(self, single_loan: list[LoanExposure]) -> None:
        m = PortfolioAnalyzer(single_loan).metrics()
        assert math.isclose(m.hhi, 1.0)

    def test_gini_single_loan_is_zero(self, single_loan: list[LoanExposure]) -> None:
        m = PortfolioAnalyzer(single_loan).metrics()
        assert math.isclose(m.gini, 0.0)

    def test_count_and_ead(self, single_loan: list[LoanExposure]) -> None:
        m = PortfolioAnalyzer(single_loan).metrics()
        assert m.count == 1
        assert m.total_ead == 100_000


class TestPortfolioAnalyzerUniform:
    def test_expected_loss(self, uniform_portfolio: list[LoanExposure]) -> None:
        m = PortfolioAnalyzer(uniform_portfolio).metrics()
        el = 0.1 * 0.5 * (100_000 + 150_000 + 200_000)
        assert math.isclose(m.expected_loss, el)

    def test_hhi_diversified(self, uniform_portfolio: list[LoanExposure]) -> None:
        m = PortfolioAnalyzer(uniform_portfolio).metrics()
        total = 450_000
        hhi = (100_000 / total) ** 2 + (150_000 / total) ** 2 + (200_000 / total) ** 2
        assert math.isclose(m.hhi, hhi)
        assert m.hhi < 1.0  # diversified

    def test_gini_zero_for_uniform_pds(self, uniform_portfolio: list[LoanExposure]) -> None:
        m = PortfolioAnalyzer(uniform_portfolio).metrics()
        assert math.isclose(m.gini, 0.0, abs_tol=1e-10)


class TestPortfolioAnalyzerMixed:
    def test_expected_loss(self, mixed_portfolio: list[LoanExposure]) -> None:
        m = PortfolioAnalyzer(mixed_portfolio).metrics()
        el = 0.05 * 0.4 * 100_000 + 0.10 * 0.5 * 150_000 + 0.20 * 0.6 * 200_000
        assert math.isclose(m.expected_loss, el)

    def test_unexpected_loss(self, mixed_portfolio: list[LoanExposure]) -> None:
        m = PortfolioAnalyzer(mixed_portfolio).metrics()
        ul = math.sqrt(
            0.05 * 0.95 * 0.4**2 * 100_000**2
            + 0.10 * 0.90 * 0.5**2 * 150_000**2
            + 0.20 * 0.80 * 0.6**2 * 200_000**2
        )
        assert math.isclose(m.unexpected_loss, ul)

    def test_gini_positive(self, mixed_portfolio: list[LoanExposure]) -> None:
        m = PortfolioAnalyzer(mixed_portfolio).metrics()
        assert m.gini > 0.0  # nonuniform PDs produce positive Gini

    def test_gini_bounded(self, mixed_portfolio: list[LoanExposure]) -> None:
        m = PortfolioAnalyzer(mixed_portfolio).metrics()
        assert 0.0 <= m.gini <= 1.0


class TestPortfolioAnalyzerEdge:
    def test_zero_pd_portfolio(self) -> None:
        loans = [_loan("L1", 0.0, 0.4, 100_000), _loan("L2", 0.0, 0.5, 200_000)]
        m = PortfolioAnalyzer(loans).metrics()
        assert m.expected_loss == 0.0
        assert m.unexpected_loss == 0.0
        assert m.gini == 0.0  # all PDs equal (0)

    def test_zero_ead_returns_empty(self) -> None:
        loans = [_loan("L1", 0.1, 0.4, 0.0)]
        m = PortfolioAnalyzer(loans).metrics()
        assert m.total_ead == 0.0


# ---------------------------------------------------------------------------
# VintageAnalyzer
# ---------------------------------------------------------------------------


class TestVintageAnalyzer:
    def test_by_vintage_groups(self, mixed_portfolio: list[LoanExposure]) -> None:
        va = VintageAnalyzer(mixed_portfolio)
        vintages = va.by_vintage()
        assert set(vintages.keys()) == {"2023Q4", "2024Q1"}

    def test_by_vintage_counts(self, mixed_portfolio: list[LoanExposure]) -> None:
        va = VintageAnalyzer(mixed_portfolio)
        vintages = va.by_vintage()
        assert vintages["2023Q4"].count == 2
        assert vintages["2024Q1"].count == 1

    def test_by_vintage_el(self, mixed_portfolio: list[LoanExposure]) -> None:
        va = VintageAnalyzer(mixed_portfolio)
        v = va.by_vintage()
        el_2023 = 0.05 * 0.4 * 100_000 + 0.10 * 0.5 * 150_000
        assert math.isclose(v["2023Q4"].expected_loss, el_2023)

    def test_cumulative_default_rate(self, mixed_portfolio: list[LoanExposure]) -> None:
        va = VintageAnalyzer(mixed_portfolio)
        outcomes = [
            {"loan_id": "L1", "defaulted": True},
            {"loan_id": "L2", "defaulted": False},
            {"loan_id": "L3", "defaulted": True},
        ]
        cdr = va.cumulative_default_rate("2023Q4", outcomes)
        assert math.isclose(cdr, 0.5)  # 1 of 2 defaulted

    def test_cumulative_default_rate_none(self, mixed_portfolio: list[LoanExposure]) -> None:
        va = VintageAnalyzer(mixed_portfolio)
        outcomes = [{"loan_id": "L1", "defaulted": False}]
        cdr = va.cumulative_default_rate("2023Q4", outcomes)
        assert math.isclose(cdr, 0.0)

    def test_cdr_unknown_vintage(self, mixed_portfolio: list[LoanExposure]) -> None:
        va = VintageAnalyzer(mixed_portfolio)
        cdr = va.cumulative_default_rate("1999Q1", [])
        assert cdr == 0.0

    def test_empty_portfolio(self) -> None:
        va = VintageAnalyzer([])
        assert va.by_vintage() == {}


# ---------------------------------------------------------------------------
# MigrationMatrix
# ---------------------------------------------------------------------------


class TestMigrationMatrix:
    def test_empty_matrix(self) -> None:
        mm = MigrationMatrix()
        assert mm.matrix() == {}
        assert mm.states() == []
        assert mm.absorbing_states() == set()

    def test_record_and_matrix(self) -> None:
        mm = MigrationMatrix()
        mm.record("E1", "A", "B")
        mm.record("E2", "A", "A")
        mm.record("E3", "B", "C")
        assert mm.matrix() == {"A": {"B": 1, "A": 1}, "B": {"C": 1}}

    def test_states_sorted(self) -> None:
        mm = MigrationMatrix()
        mm.record("E1", "C", "A")
        mm.record("E2", "B", "C")
        assert mm.states() == ["A", "B", "C"]

    def test_probability_matrix(self) -> None:
        mm = MigrationMatrix()
        mm.record("E1", "A", "B")
        mm.record("E2", "A", "A")
        probs = mm.probability_matrix()
        assert math.isclose(probs["A"]["A"], 0.5)
        assert math.isclose(probs["A"]["B"], 0.5)

    def test_absorbing_state_no_outgoing(self) -> None:
        mm = MigrationMatrix()
        mm.record("E1", "A", "B")
        mm.record("E2", "A", "C")
        # B and C have no outgoing transitions → absorbing
        assert mm.absorbing_states() == {"B", "C"}

    def test_absorbing_state_self_only(self) -> None:
        mm = MigrationMatrix()
        mm.record("E1", "D", "D")
        mm.record("E2", "D", "D")
        assert mm.absorbing_states() == {"D"}

    def test_no_absorbing_states(self) -> None:
        mm = MigrationMatrix()
        mm.record("E1", "A", "B")
        mm.record("E2", "B", "A")
        assert mm.absorbing_states() == set()

    def test_all_absorbing(self) -> None:
        mm = MigrationMatrix()
        mm.record("E1", "X", "X")
        mm.record("E2", "Y", "Y")
        assert mm.absorbing_states() == {"X", "Y"}

    def test_multiple_transitions_same_pair(self) -> None:
        mm = MigrationMatrix()
        mm.record("E1", "A", "B")
        mm.record("E2", "A", "B")
        mm.record("E3", "A", "B")
        assert mm.matrix() == {"A": {"B": 3}}
        probs = mm.probability_matrix()
        assert math.isclose(probs["A"]["B"], 1.0)

    def test_probability_matrix_empty(self) -> None:
        mm = MigrationMatrix()
        assert mm.probability_matrix() == {}


# ── Targeted coverage (line 99) ──────────────────────────


class TestPortfolioEmptyGini:
    """Cover line 99: _gini returns 0.0 for empty exposures."""

    def test_gini_empty_portfolio(self) -> None:
        pa = PortfolioAnalyzer([])
        assert pa._gini() == 0.0
