"""Tests for portfolio_ingest — FR Y-14M loan tape ingestion."""

from __future__ import annotations

import json
from pathlib import Path

import pytest

from orxaq.credit.portfolio import LoanExposure
from orxaq.fabric.portfolio_ingest import (
    FR_Y14M_CONFIG,
    IngestResult,
    PortfolioIngestConfig,
    ingest_portfolio,
)

# ── Fixtures ──────────────────────────────────────────────────

FIXTURE_CSV = Path(__file__).parent.parent / "fixtures" / "fr_y14m_sample.csv"


@pytest.fixture
def minimal_csv(tmp_path: Path) -> Path:
    """Minimal valid CSV with only required fields."""
    p = tmp_path / "minimal.csv"
    p.write_text(
        "loan_id,interest_rate,fico_score,ltv,dti\n"
        "LN1,0.05,720,0.80,0.35\n"
        "LN2,0.04,750,0.70,0.30\n",
        encoding="utf-8",
    )
    return p


@pytest.fixture
def bad_required_csv(tmp_path: Path) -> Path:
    """CSV missing a required field value."""
    p = tmp_path / "bad_required.csv"
    p.write_text(
        "loan_id,interest_rate,fico_score,ltv,dti\n,0.05,720,0.80,0.35\n",  # empty loan_id
        encoding="utf-8",
    )
    return p


@pytest.fixture
def bad_bounds_csv(tmp_path: Path) -> Path:
    """CSV with out-of-bounds fico_score."""
    p = tmp_path / "bad_bounds.csv"
    p.write_text(
        "loan_id,interest_rate,fico_score,ltv,dti\nLN1,0.05,999,0.80,0.35\n",  # fico > 850
        encoding="utf-8",
    )
    return p


@pytest.fixture
def minimal_json(tmp_path: Path) -> Path:
    """Minimal valid JSON list."""
    p = tmp_path / "loans.json"
    p.write_text(
        json.dumps(
            [
                {
                    "loan_id": "LN1",
                    "interest_rate": "0.05",
                    "fico_score": "720",
                    "ltv": "0.80",
                    "dti": "0.35",
                },
                {
                    "loan_id": "LN2",
                    "interest_rate": "0.04",
                    "fico_score": "750",
                    "ltv": "0.70",
                    "dti": "0.30",
                },
            ]
        )
    )
    return p


@pytest.fixture
def wrapped_json(tmp_path: Path) -> Path:
    """JSON in {'loans': [...]} wrapper format."""
    p = tmp_path / "wrapped.json"
    p.write_text(
        json.dumps(
            {
                "loans": [
                    {
                        "loan_id": "LN1",
                        "interest_rate": "0.05",
                        "fico_score": "720",
                        "ltv": "0.80",
                        "dti": "0.35",
                    },
                ]
            }
        )
    )
    return p


# ── TestPortfolioIngestConfig ─────────────────────────────────


class TestPortfolioIngestConfig:
    def test_default_required_fields(self) -> None:
        cfg = PortfolioIngestConfig()
        assert "loan_id" in cfg.required_fields
        assert "fico_score" in cfg.required_fields

    def test_default_bounds_fico(self) -> None:
        cfg = PortfolioIngestConfig()
        assert cfg.bounds["fico_score"] == (300.0, 850.0)

    def test_custom_field_map(self) -> None:
        cfg = PortfolioIngestConfig(field_map={"acc_num": "loan_id", "bal": "ead"})
        assert cfg.field_map["acc_num"] == "loan_id"

    def test_custom_bounds_stored(self) -> None:
        cfg = PortfolioIngestConfig(bounds={"fico_score": (500.0, 800.0)})
        assert cfg.bounds["fico_score"] == (500.0, 800.0)

    def test_fr_y14m_config_is_default(self) -> None:
        assert isinstance(FR_Y14M_CONFIG, PortfolioIngestConfig)
        assert "loan_id" in FR_Y14M_CONFIG.field_map


# ── TestIngestCSV ─────────────────────────────────────────────


class TestIngestCSV:
    def test_returns_ingest_result(self, minimal_csv: Path) -> None:
        result = ingest_portfolio(minimal_csv, run_quality=False)
        assert isinstance(result, IngestResult)

    def test_loans_are_loan_exposures(self, minimal_csv: Path) -> None:
        result = ingest_portfolio(minimal_csv, run_quality=False)
        for loan in result.loans:
            assert isinstance(loan, LoanExposure)

    def test_correct_row_count(self, minimal_csv: Path) -> None:
        result = ingest_portfolio(minimal_csv, run_quality=False)
        assert result.row_count == 2

    def test_loan_ids_preserved(self, minimal_csv: Path) -> None:
        result = ingest_portfolio(minimal_csv, run_quality=False)
        ids = {loan.loan_id for loan in result.loans}
        assert "LN1" in ids
        assert "LN2" in ids

    def test_fixture_csv_20_rows(self) -> None:
        result = ingest_portfolio(FIXTURE_CSV, run_quality=False)
        assert result.row_count == 20
        assert len(result.loans) == 20

    def test_full_fixture_no_errors(self) -> None:
        result = ingest_portfolio(FIXTURE_CSV, run_quality=True, quality_threshold=0.9)
        assert result.error_count == 0

    def test_ead_from_current_balance(self) -> None:
        result = ingest_portfolio(FIXTURE_CSV, run_quality=False)
        # All fixture rows have current_balance values
        assert all(loan.ead > 0 for loan in result.loans)

    def test_vintage_derived_from_date(self) -> None:
        result = ingest_portfolio(FIXTURE_CSV, run_quality=False)
        for loan in result.loans:
            assert loan.vintage != "unknown" or True  # some may be unknown
            if loan.vintage != "unknown":
                assert "Q" in loan.vintage

    def test_sector_from_property_type(self) -> None:
        result = ingest_portfolio(FIXTURE_CSV, run_quality=False)
        sectors = {loan.sector for loan in result.loans}
        assert "SFR" in sectors or "Condo" in sectors or "MFR" in sectors

    def test_no_extra_required_fields_error(self, minimal_csv: Path) -> None:
        """Should not raise with minimal required fields."""
        result = ingest_portfolio(minimal_csv, run_quality=False)
        assert result.error_count == 0


# ── TestIngestJSON ────────────────────────────────────────────


class TestIngestJSON:
    def test_list_json_works(self, minimal_json: Path) -> None:
        result = ingest_portfolio(minimal_json, run_quality=False)
        assert len(result.loans) == 2

    def test_wrapped_json_works(self, wrapped_json: Path) -> None:
        result = ingest_portfolio(wrapped_json, run_quality=False)
        assert len(result.loans) == 1

    def test_json_row_count(self, minimal_json: Path) -> None:
        result = ingest_portfolio(minimal_json, run_quality=False)
        assert result.row_count == 2


# ── TestValidation ────────────────────────────────────────────


class TestValidation:
    def test_missing_required_raises(self, bad_required_csv: Path) -> None:
        with pytest.raises(ValueError, match="quality gate|Required"):
            ingest_portfolio(bad_required_csv, run_quality=True, quality_threshold=0.5)

    def test_out_of_bounds_fico_raises(self, bad_bounds_csv: Path) -> None:
        with pytest.raises(ValueError):
            ingest_portfolio(bad_bounds_csv, run_quality=True, quality_threshold=0.5)

    def test_file_not_found(self) -> None:
        with pytest.raises(FileNotFoundError):
            ingest_portfolio("/nonexistent/loans.csv")

    def test_invalid_json_structure(self, tmp_path: Path) -> None:
        p = tmp_path / "bad.json"
        p.write_text(json.dumps({"not_loans": 42}))
        with pytest.raises(ValueError, match="JSON file"):
            ingest_portfolio(p)


# ── TestMissingOptional ───────────────────────────────────────


class TestMissingOptional:
    def test_optional_absent_no_error(self, minimal_csv: Path) -> None:
        """Optional fields absent → no ValueError, loans returned."""
        result = ingest_portfolio(minimal_csv, run_quality=False)
        assert len(result.loans) > 0

    def test_defaults_applied_for_pd(self, minimal_csv: Path) -> None:
        """When pd absent, default 5% applied."""
        result = ingest_portfolio(minimal_csv, run_quality=False)
        for loan in result.loans:
            assert 0.0 < loan.pd <= 1.0

    def test_defaults_applied_for_lgd(self, minimal_csv: Path) -> None:
        result = ingest_portfolio(minimal_csv, run_quality=False)
        for loan in result.loans:
            assert 0.0 < loan.lgd <= 1.0


# ── TestAuditTrail ────────────────────────────────────────────


class TestAuditTrail:
    def test_audit_entry_appended(self, minimal_csv: Path) -> None:
        log: list = []
        ingest_portfolio(minimal_csv, audit_log=log, run_quality=False)
        assert len(log) == 1

    def test_audit_action_correct(self, minimal_csv: Path) -> None:
        log: list = []
        ingest_portfolio(minimal_csv, audit_log=log, run_quality=False)
        assert log[0]["action"] == "portfolio_ingest"

    def test_audit_row_count_correct(self, minimal_csv: Path) -> None:
        log: list = []
        ingest_portfolio(minimal_csv, audit_log=log, run_quality=False)
        assert log[0]["details"]["row_count"] == 2

    def test_no_audit_without_log(self, minimal_csv: Path) -> None:
        """No error when audit_log not provided."""
        result = ingest_portfolio(minimal_csv, run_quality=False)
        assert result is not None


# ── TestFRY14MConfig ──────────────────────────────────────────


class TestFRY14MConfig:
    def test_has_loan_id_mapping(self) -> None:
        assert "loan_id" in FR_Y14M_CONFIG.field_map

    def test_has_current_balance_mapping(self) -> None:
        assert "current_balance" in FR_Y14M_CONFIG.field_map

    def test_has_fico_score_bounds(self) -> None:
        assert "fico_score" in FR_Y14M_CONFIG.bounds

    def test_fixture_csv_works_with_default_config(self) -> None:
        result = ingest_portfolio(FIXTURE_CSV)
        assert len(result.loans) == 20


# ── TestEdgeCases ─────────────────────────────────────────────


class TestEdgeCases:
    def test_empty_csv_returns_empty(self, tmp_path: Path) -> None:
        p = tmp_path / "empty.csv"
        p.write_text("loan_id,interest_rate,fico_score,ltv,dti\n", encoding="utf-8")
        result = ingest_portfolio(p, run_quality=False)
        assert result.loans == []
        assert result.row_count == 0

    def test_single_row_works(self, tmp_path: Path) -> Path:
        p = tmp_path / "single.csv"
        p.write_text(
            "loan_id,interest_rate,fico_score,ltv,dti\nLN1,0.05,720,0.80,0.35\n",
            encoding="utf-8",
        )
        result = ingest_portfolio(p, run_quality=False)
        assert result.row_count == 1

    def test_duplicate_loan_ids_allowed(self, tmp_path: Path) -> None:
        p = tmp_path / "dupes.csv"
        p.write_text(
            "loan_id,interest_rate,fico_score,ltv,dti\n"
            "LN1,0.05,720,0.80,0.35\n"
            "LN1,0.04,750,0.70,0.30\n",
            encoding="utf-8",
        )
        result = ingest_portfolio(p, run_quality=False)
        assert result.row_count == 2
        assert len(result.loans) == 2

    def test_quality_threshold_zero_allows_all(self, bad_bounds_csv: Path) -> None:
        """quality_threshold=0.0 never raises."""
        result = ingest_portfolio(bad_bounds_csv, run_quality=True, quality_threshold=0.0)
        assert result.error_count >= 0

    def test_run_quality_false_skips_gate(self, bad_bounds_csv: Path) -> None:
        """run_quality=False skips gate even with bad data — warns only."""
        result = ingest_portfolio(bad_bounds_csv, run_quality=False)
        assert result.error_count >= 0
