"""
Tests for the padel court financial calculator.

Verifies the Python port matches the original JS calc() behavior
across all four venue/ownership combinations and edge cases.
"""
import json
import math

import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from padelnomics.planner.calculator import (
    DEFAULTS,
    _round,
    calc,
    calc_irr,
    pmt,
    validate_state,
)

# ── Helper ─────────────────────────────────────────────────

def default_state(**overrides):
    """Return a validated default state with optional overrides."""
    return validate_state(overrides)


def approx(val, rel=1e-6, abs=1e-9):
    return pytest.approx(val, rel=rel, abs=abs)


# ════════════════════════════════════════════════════════════
# _round — JS-compatible half-up rounding
# ════════════════════════════════════════════════════════════

class TestRound:
    def test_half_rounds_up(self):
        assert _round(0.5) == 1
        assert _round(1.5) == 2
        assert _round(2.5) == 3

    def test_below_half_rounds_down(self):
        assert _round(0.4) == 0
        assert _round(1.4) == 1

    def test_above_half_rounds_up(self):
        assert _round(0.6) == 1
        assert _round(1.7) == 2

    def test_negative(self):
        assert _round(-0.5) == 0  # JS: Math.round(-0.5) = 0
        assert _round(-1.5) == -1  # JS: Math.round(-1.5) = -1

    def test_integers_unchanged(self):
        assert _round(0) == 0
        assert _round(5) == 5
        assert _round(-3) == -3


# ════════════════════════════════════════════════════════════
# pmt — loan payment
# ════════════════════════════════════════════════════════════

class TestPmt:
    def test_zero_rate(self):
        assert pmt(0, 120, 100000) == approx(100000 / 120)

    def test_standard_loan(self):
        # 5% annual = 0.004167/mo, 10yr = 120 months, 100K loan
        monthly = pmt(0.05 / 12, 120, 100000)
        assert monthly == approx(1060.66, rel=1e-4)

    def test_high_rate(self):
        monthly = pmt(0.10 / 12, 60, 50000)
        assert monthly > 0
        # Total paid should exceed principal
        assert monthly * 60 > 50000

    def test_single_period(self):
        # 1 period, 5% rate, 10000 principal
        monthly = pmt(0.05, 1, 10000)
        assert monthly == approx(10500.0)


# ════════════════════════════════════════════════════════════
# calc_irr — Newton-Raphson IRR solver
# ════════════════════════════════════════════════════════════

class TestCalcIRR:
    def test_simple_doubling(self):
        # Invest 100, get 200 back in 1 year = 100% IRR
        irr = calc_irr([-100, 200])
        assert irr == approx(1.0, rel=1e-6)

    def test_even_cashflows(self):
        # Invest 1000, get 400/yr for 3 years
        irr = calc_irr([-1000, 400, 400, 400])
        assert irr == approx(0.09701, rel=1e-3)

    def test_negative_project(self):
        # Project that loses money
        irr = calc_irr([-1000, 100, 100, 100])
        assert irr < 0

    def test_breakeven(self):
        # Invest 1000, get exactly 1000 back next year = 0% IRR
        irr = calc_irr([-1000, 1000])
        assert abs(irr) < 1e-6

    def test_clamping(self):
        # Very bad project — IRR should be clamped, not crash
        irr = calc_irr([-1000, 1, 1, 1, 1])
        assert irr >= -0.99
        assert math.isfinite(irr)


# ════════════════════════════════════════════════════════════
# validate_state
# ════════════════════════════════════════════════════════════

class TestValidateState:
    def test_empty_input_returns_defaults(self):
        s = validate_state({})
        assert s == DEFAULTS

    def test_overrides_applied(self):
        s = validate_state({"dblCourts": 8, "venue": "outdoor"})
        assert s["dblCourts"] == 8
        assert s["venue"] == "outdoor"
        # Everything else stays default
        assert s["sglCourts"] == DEFAULTS["sglCourts"]

    def test_type_coercion_string_to_int(self):
        s = validate_state({"dblCourts": "6"})
        assert s["dblCourts"] == 6
        assert isinstance(s["dblCourts"], int)

    def test_type_coercion_string_to_float(self):
        s = validate_state({"ballCost": "2.5"})
        assert s["ballCost"] == 2.5
        assert isinstance(s["ballCost"], float)

    def test_invalid_value_keeps_default(self):
        s = validate_state({"dblCourts": "not_a_number"})
        assert s["dblCourts"] == DEFAULTS["dblCourts"]

    def test_list_coercion(self):
        s = validate_state({"ramp": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]})
        assert s["ramp"] == [1.0] * 12

    def test_non_list_for_list_field_keeps_default(self):
        s = validate_state({"ramp": "invalid"})
        assert s["ramp"] == DEFAULTS["ramp"]

    def test_unknown_keys_ignored(self):
        s = validate_state({"unknown_key": 42, "dblCourts": 3})
        assert "unknown_key" not in s
        assert s["dblCourts"] == 3

    def test_returns_new_dict(self):
        s = validate_state({})
        s["dblCourts"] = 999
        assert DEFAULTS["dblCourts"] != 999  # Original not mutated


# ════════════════════════════════════════════════════════════
# calc — full model, default scenario (indoor + rent)
# ════════════════════════════════════════════════════════════

class TestCalcDefaultScenario:
    @pytest.fixture()
    def d(self):
        return calc(default_state())

    def test_total_courts(self, d):
        assert d["totalCourts"] == DEFAULTS["dblCourts"] + DEFAULTS["sglCourts"]

    def test_sqm_is_hall(self, d):
        # Indoor venue → sqm is hallSqm
        dbl = DEFAULTS["dblCourts"]
        sgl = DEFAULTS["sglCourts"]
        total = dbl + sgl
        expected = dbl * DEFAULTS["sqmPerDblHall"] + sgl * DEFAULTS["sqmPerSglHall"] + 200 + total * 20
        assert d["hallSqm"] == expected
        assert d["sqm"] == expected

    def test_capex_positive(self, d):
        assert d["capex"] > 0

    def test_capex_items_sum(self, d):
        items_sum = sum(i["amount"] for i in d["capexItems"])
        assert items_sum == d["capex"]

    def test_capex_per_court(self, d):
        assert d["capexPerCourt"] == approx(d["capex"] / d["totalCourts"])

    def test_capex_per_sqm(self, d):
        assert d["capexPerSqm"] == approx(d["capex"] / d["sqm"])

    def test_opex_items_sum(self, d):
        items_sum = sum(i["amount"] for i in d["opexItems"])
        assert items_sum == d["opex"]

    def test_annual_opex(self, d):
        assert d["annualOpex"] == d["opex"] * 12

    def test_equity_plus_loan_equals_capex(self, d):
        assert d["equity"] + d["loanAmount"] == d["capex"]

    def test_ltv(self, d):
        assert d["ltv"] == approx(d["loanAmount"] / d["capex"])

    def test_monthly_payment_positive(self, d):
        assert d["monthlyPayment"] > 0

    def test_annual_debt_service(self, d):
        assert d["annualDebtService"] == approx(d["monthlyPayment"] * 12)

    def test_weighted_rate(self, d):
        dbl = DEFAULTS["dblCourts"]
        sgl = DEFAULTS["sglCourts"]
        total = dbl + sgl
        dbl_rate = DEFAULTS["ratePeak"] * (DEFAULTS["peakPct"] / 100) + DEFAULTS["rateOffPeak"] * (1 - DEFAULTS["peakPct"] / 100)
        expected = (dbl * dbl_rate + sgl * DEFAULTS["rateSingle"]) / total if total > 0 else 0
        assert d["weightedRate"] == approx(expected)

    def test_avail_hours_month(self, d):
        total = DEFAULTS["dblCourts"] + DEFAULTS["sglCourts"]
        assert d["availHoursMonth"] == DEFAULTS["hoursPerDay"] * DEFAULTS["daysPerMonthIndoor"] * total

    def test_booked_hours_month(self, d):
        assert d["bookedHoursMonth"] == approx(d["availHoursMonth"] * 0.4)

    def test_revenue_components(self, d):
        total = d["totalCourts"]
        assert d["courtRevMonth"] == approx(d["bookedHoursMonth"] * d["weightedRate"])
        assert d["feeDeduction"] == approx(d["courtRevMonth"] * DEFAULTS["bookingFee"] / 100)
        assert d["membershipRev"] == total * DEFAULTS["membershipRevPerCourt"]
        assert d["fbRev"] == total * DEFAULTS["fbRevPerCourt"]
        assert d["coachingRev"] == total * DEFAULTS["coachingRevPerCourt"]
        assert d["retailRev"] == total * DEFAULTS["retailRevPerCourt"]

    def test_gross_minus_fees_equals_net(self, d):
        assert d["netRevMonth"] == approx(d["grossRevMonth"] - d["feeDeduction"])

    def test_ebitda_month(self, d):
        assert d["ebitdaMonth"] == approx(d["netRevMonth"] - d["opex"])

    def test_net_cf_month(self, d):
        assert d["netCFMonth"] == approx(d["ebitdaMonth"] - d["monthlyPayment"])

    def test_months_count(self, d):
        assert len(d["months"]) == 60

    def test_months_year_assignment(self, d):
        assert d["months"][0]["yr"] == 1
        assert d["months"][11]["yr"] == 1
        assert d["months"][12]["yr"] == 2
        assert d["months"][59]["yr"] == 5

    def test_months_cm_range(self, d):
        cms = [m["cm"] for m in d["months"]]
        assert min(cms) == 1
        assert max(cms) == 12

    def test_ramp_applied_first_12_months(self, d):
        ramp = DEFAULTS["ramp"]
        for i in range(12):
            assert d["months"][i]["ramp"] == ramp[i]
        # Month 13+ should be 1.0
        assert d["months"][12]["ramp"] == 1.0
        assert d["months"][59]["ramp"] == 1.0

    def test_indoor_no_seasonality(self, d):
        # Indoor venues have seas=1 for all months
        for m in d["months"]:
            assert m["seas"] == 1

    def test_cumulative_starts_negative(self, d):
        assert d["months"][0]["cum"] < 0

    def test_cumulative_is_running_sum(self, d):
        # cum[0] = -capex + ncf[0]
        assert d["months"][0]["cum"] == approx(-d["capex"] + d["months"][0]["ncf"])
        # cum[i] = cum[i-1] + ncf[i]
        for i in range(1, 60):
            assert d["months"][i]["cum"] == approx(
                d["months"][i - 1]["cum"] + d["months"][i]["ncf"]
            )

    def test_annuals_count(self, d):
        assert len(d["annuals"]) == 5

    def test_annuals_revenue_is_sum_of_months(self, d):
        for annual in d["annuals"]:
            y = annual["year"]
            month_rev = sum(m["totalRev"] for m in d["months"] if m["yr"] == y)
            assert annual["revenue"] == approx(month_rev)

    def test_stab_ebitda_is_exit_year(self, d):
        # stabEbitda uses the terminal year (holdYears - 1), not hardcoded Year 3.
        s = default_state()
        exit_idx = min(s["holdYears"] - 1, len(d["annuals"]) - 1)
        assert d["stabEbitda"] == d["annuals"][exit_idx]["ebitda"]

    def test_exit_value(self, d):
        assert d["exitValue"] == approx(d["stabEbitda"] * 6)

    def test_irr_finite(self, d):
        assert math.isfinite(d["irr"])

    def test_moic_positive(self, d):
        assert d["moic"] > 0

    def test_dscr_count(self, d):
        assert len(d["dscr"]) == 5

    def test_payback_idx_valid(self, d):
        idx = d["paybackIdx"]
        if idx >= 0:
            assert d["months"][idx]["cum"] >= 0
            if idx > 0:
                assert d["months"][idx - 1]["cum"] < 0

    def test_break_even_util_in_range(self, d):
        assert 0 < d["breakEvenUtil"] < 2  # Should be a fraction, could exceed 1

    def test_ebitda_margin(self, d):
        assert d["ebitdaMargin"] == approx(d["ebitdaMonth"] / d["netRevMonth"])

    def test_opex_ratio(self, d):
        assert d["opexRatio"] == approx(d["opex"] / d["netRevMonth"])

    def test_rent_ratio_for_rental(self, d):
        # Default is rent, so there should be a Rent item
        rent_item = next(i for i in d["opexItems"] if i["name"] == "Rent")
        assert d["rentRatio"] == approx(rent_item["amount"] / d["netRevMonth"])

    def test_yield_on_cost(self, d):
        assert d["yieldOnCost"] == approx(d["stabEbitda"] / d["capex"])

    def test_avg_util_year3(self, d):
        y3 = d["annuals"][2]
        assert d["avgUtil"] == approx(y3["booked"] / y3["avail"])


# ════════════════════════════════════════════════════════════
# calc — indoor + buy scenario
# ════════════════════════════════════════════════════════════

class TestCalcIndoorBuy:
    @pytest.fixture()
    def d(self):
        return calc(default_state(venue="indoor", own="buy"))

    def test_capex_includes_hall_construction(self, d):
        names = [i["name"] for i in d["capexItems"]]
        assert "Hall Construction" in names
        assert "Foundation" in names
        assert "Land Purchase" in names
        assert "HVAC System" in names

    def test_no_rent_in_opex(self, d):
        names = [i["name"] for i in d["opexItems"]]
        assert "Rent" not in names
        assert "Property Tax" in names

    def test_capex_higher_than_rent_scenario(self, d):
        d_rent = calc(default_state(venue="indoor", own="rent"))
        assert d["capex"] > d_rent["capex"]

    def test_miscellaneous_is_8000_for_buy(self, d):
        misc = next(i for i in d["capexItems"] if i["name"] == "Miscellaneous")
        assert misc["amount"] == 8000


# ════════════════════════════════════════════════════════════
# calc — outdoor + rent scenario
# ════════════════════════════════════════════════════════════

class TestCalcOutdoorRent:
    @pytest.fixture()
    def d(self):
        return calc(default_state(venue="outdoor", own="rent"))

    def test_sqm_is_outdoor_land(self, d):
        dbl = DEFAULTS["dblCourts"]
        sgl = DEFAULTS["sglCourts"]
        expected = dbl * DEFAULTS["sqmPerDblOutdoor"] + sgl * DEFAULTS["sqmPerSglOutdoor"] + 100
        assert d["outdoorLandSqm"] == expected
        assert d["sqm"] == expected

    def test_capex_items_outdoor_specific(self, d):
        names = [i["name"] for i in d["capexItems"]]
        assert "Concrete Foundation" in names
        assert "Site Work" in names
        assert "Lighting" in names
        assert "Fencing" in names
        # Should NOT have indoor items
        assert "Hall Construction" not in names
        assert "Floor Preparation" not in names

    def test_no_land_purchase_for_rent(self, d):
        names = [i["name"] for i in d["capexItems"]]
        assert "Land Purchase" not in names

    def test_seasonality_applied(self, d):
        season = DEFAULTS["season"]
        for m in d["months"][:12]:
            cm = (m["m"] - 1) % 12
            assert m["seas"] == season[cm]

    def test_zero_season_months_have_zero_avail(self, d):
        for m in d["months"][:12]:
            if m["seas"] == 0:
                assert m["avail"] == 0
                assert m["booked"] == 0
                assert m["courtRev"] == 0

    def test_days_per_month_outdoor(self, d):
        assert d["daysPerMonth"] == 25

    def test_miscellaneous_is_6000_for_rent(self, d):
        misc = next(i for i in d["capexItems"] if i["name"] == "Miscellaneous")
        assert misc["amount"] == 6000

    def test_no_heating_cleaning_water_in_opex(self, d):
        names = [i["name"] for i in d["opexItems"]]
        assert "Heating" not in names
        assert "Cleaning" not in names
        assert "Water" not in names

    def test_outdoor_rent_in_opex(self, d):
        rent = next(i for i in d["opexItems"] if i["name"] == "Rent")
        assert rent["amount"] == 400


# ════════════════════════════════════════════════════════════
# calc — outdoor + buy scenario
# ════════════════════════════════════════════════════════════

class TestCalcOutdoorBuy:
    @pytest.fixture()
    def d(self):
        return calc(default_state(venue="outdoor", own="buy"))

    def test_land_purchase_included(self, d):
        names = [i["name"] for i in d["capexItems"]]
        assert "Land Purchase" in names
        assert "Transaction Costs" in names

    def test_property_tax_in_opex(self, d):
        names = [i["name"] for i in d["opexItems"]]
        assert "Property Tax" in names
        assert "Rent" not in names


# ════════════════════════════════════════════════════════════
# calc — indoor + rent (explicit, verifying capex items)
# ════════════════════════════════════════════════════════════

class TestCalcIndoorRent:
    @pytest.fixture()
    def d(self):
        return calc(default_state(venue="indoor", own="rent"))

    def test_fit_out_items(self, d):
        names = [i["name"] for i in d["capexItems"]]
        assert "Floor Preparation" in names
        assert "HVAC Upgrade" in names
        assert "Lighting Upgrade" in names
        assert "Fit-Out & Reception" in names
        # Should NOT have buy items
        assert "Hall Construction" not in names
        assert "Land Purchase" not in names

    def test_rent_in_opex(self, d):
        rent = next(i for i in d["opexItems"] if i["name"] == "Rent")
        expected = d["hallSqm"] * DEFAULTS["rentSqm"]
        assert rent["amount"] == _round(expected)

    def test_indoor_opex_has_heating_and_water(self, d):
        names = [i["name"] for i in d["opexItems"]]
        assert "Heating" in names
        assert "Water" in names
        assert "Cleaning" in names

    def test_permits_compliance_in_indoor_rent(self, d):
        names = [i["name"] for i in d["capexItems"]]
        assert "Permits & Compliance" in names

    def test_permits_compliance_amount(self, d):
        permits = next(i for i in d["capexItems"] if i["name"] == "Permits & Compliance")
        assert permits["amount"] == DEFAULTS["permitsCompliance"]


# ════════════════════════════════════════════════════════════
# calc — permits & compliance across scenarios
# ════════════════════════════════════════════════════════════

class TestPermitsCompliance:
    def test_indoor_rent_has_permits(self):
        d = calc(default_state(venue="indoor", own="rent"))
        names = [i["name"] for i in d["capexItems"]]
        assert "Permits & Compliance" in names

    def test_outdoor_rent_has_permits(self):
        d = calc(default_state(venue="outdoor", own="rent"))
        names = [i["name"] for i in d["capexItems"]]
        assert "Permits & Compliance" in names

    def test_outdoor_buy_has_permits(self):
        d = calc(default_state(venue="outdoor", own="buy"))
        names = [i["name"] for i in d["capexItems"]]
        assert "Permits & Compliance" in names

    def test_indoor_buy_no_permits_compliance(self):
        """Indoor Buy already has Planning + Permits, so no separate Permits & Compliance."""
        d = calc(default_state(venue="indoor", own="buy"))
        names = [i["name"] for i in d["capexItems"]]
        assert "Permits & Compliance" not in names
        assert "Planning + Permits" in names

    def test_permits_compliance_value_adjustable(self):
        d = calc(default_state(venue="indoor", own="rent", permitsCompliance=25000))
        permits = next(i for i in d["capexItems"] if i["name"] == "Permits & Compliance")
        assert permits["amount"] == 25000

    def test_country_in_defaults(self):
        assert "country" in DEFAULTS
        assert DEFAULTS["country"] == "DE"

    def test_permits_compliance_in_defaults(self):
        assert "permitsCompliance" in DEFAULTS
        assert DEFAULTS["permitsCompliance"] == 12000


# ════════════════════════════════════════════════════════════
# calc — edge cases
# ════════════════════════════════════════════════════════════

class TestCalcEdgeCases:
    def test_zero_courts(self):
        d = calc(default_state(dblCourts=0, sglCourts=0))
        assert d["totalCourts"] == 0
        assert d["hallSqm"] == 0
        assert d["sqm"] == 0
        assert d["availHoursMonth"] == 0
        assert d["bookedHoursMonth"] == 0
        assert d["courtRevMonth"] == 0
        assert d["membershipRev"] == 0
        assert len(d["months"]) == 60
        assert len(d["annuals"]) == 5

    def test_zero_loan(self):
        d = calc(default_state(loanPct=0))
        assert d["loanAmount"] == 0
        assert d["monthlyPayment"] == 0
        assert d["annualDebtService"] == 0
        assert d["ltv"] == 0

    def test_full_loan(self):
        d = calc(default_state(loanPct=100))
        assert d["equity"] == 0
        assert d["loanAmount"] == d["capex"]

    def test_zero_contingency(self):
        d = calc(default_state(contingencyPct=0))
        # No contingency item
        names = [i["name"] for i in d["capexItems"]]
        assert not any("Contingency" in n for n in names)
        # capex = sum of items (no extra)
        items_sum = sum(i["amount"] for i in d["capexItems"])
        assert items_sum == d["capex"]

    def test_zero_utilization(self):
        d = calc(default_state(utilTarget=0))
        assert d["bookedHoursMonth"] == 0
        assert d["courtRevMonth"] == 0
        # breakEvenUtil is breakEvenHrs / availHoursMonth; availHoursMonth > 0
        assert d["breakEvenUtil"] > 0

    def test_full_utilization(self):
        d = calc(default_state(utilTarget=100))
        assert d["bookedHoursMonth"] == approx(d["availHoursMonth"])

    def test_zero_interest_rate(self):
        d = calc(default_state(interestRate=0))
        # pmt with 0 rate = simple division
        expected = d["loanAmount"] / (max(DEFAULTS["loanTerm"], 1) * 12)
        assert d["monthlyPayment"] == approx(expected)

    def test_staff_included_when_positive(self):
        d = calc(default_state(staff=5000))
        names = [i["name"] for i in d["opexItems"]]
        assert "Staff" in names
        staff = next(i for i in d["opexItems"] if i["name"] == "Staff")
        assert staff["amount"] == 5000

    def test_staff_excluded_when_zero(self):
        d = calc(default_state(staff=0))
        names = [i["name"] for i in d["opexItems"]]
        assert "Staff" not in names

    def test_single_court_only(self):
        d = calc(default_state(dblCourts=0, sglCourts=1))
        assert d["totalCourts"] == 1
        assert d["weightedRate"] == 30  # single court rate

    def test_double_court_only(self):
        d = calc(default_state(dblCourts=1, sglCourts=0))
        assert d["totalCourts"] == 1
        # Weighted rate = peak*peakPct + offPeak*(1-peakPct) for all dbl
        expected = 50 * 0.4 + 35 * 0.6
        assert d["weightedRate"] == approx(expected)

    def test_hold_years_beyond_annuals(self):
        # holdYears=10 but only 5 years of annuals
        d = calc(default_state(holdYears=10))
        assert math.isfinite(d["irr"])
        assert d["moic"] > 0

    def test_loan_term_minimum_one(self):
        # loanTerm=0 should be clamped to 1 via max()
        d = calc(default_state(loanTerm=0))
        assert d["monthlyPayment"] > 0


# ════════════════════════════════════════════════════════════
# calc — financing math
# ════════════════════════════════════════════════════════════

class TestCalcFinancing:
    def test_equity_calculation(self):
        d = calc(default_state(loanPct=85))
        expected_equity = _round(d["capex"] * (1 - 85 / 100))
        assert d["equity"] == expected_equity

    def test_remaining_loan_decreases_with_hold_years(self):
        d1 = calc(default_state(holdYears=1))
        d5 = calc(default_state(holdYears=5))
        assert d5["remainingLoan"] < d1["remainingLoan"]

    def test_remaining_loan_zero_when_fully_repaid(self):
        # holdYears >= loanTerm * 1.5 → remaining = 0
        d = calc(default_state(holdYears=20, loanTerm=10))
        assert d["remainingLoan"] == approx(0, abs=1)

    def test_net_exit(self):
        d = calc(default_state())
        assert d["netExit"] == approx(d["exitValue"] - d["remainingLoan"])


# ════════════════════════════════════════════════════════════
# calc — revenue model details
# ════════════════════════════════════════════════════════════

class TestCalcRevenue:
    def test_racket_revenue(self):
        d = calc(default_state())
        expected = d["bookedHoursMonth"] * (15 / 100) * 2 * 5
        assert d["racketRev"] == approx(expected)

    def test_ball_margin(self):
        d = calc(default_state())
        expected = d["bookedHoursMonth"] * (10 / 100) * (3 - 1.5)
        assert d["ballMargin"] == approx(expected)

    def test_gross_rev_is_sum_of_components(self):
        d = calc(default_state())
        expected = (
            d["courtRevMonth"]
            + d["racketRev"]
            + d["ballMargin"]
            + d["membershipRev"]
            + d["fbRev"]
            + d["coachingRev"]
            + d["retailRev"]
        )
        assert d["grossRevMonth"] == approx(expected)


# ════════════════════════════════════════════════════════════
# calc — monthly projection details
# ════════════════════════════════════════════════════════════

class TestCalcMonthly:
    def test_month_fields_present(self):
        d = calc(default_state())
        m = d["months"][0]
        expected_keys = {
            "m", "cm", "yr", "ramp", "seas", "effUtil", "avail", "booked",
            "courtRev", "fees", "ancillary", "membership", "totalRev",
            "opex", "loan", "ebitda", "ncf", "cum",
        }
        assert set(m.keys()) == expected_keys

    def test_month_ebitda_is_rev_plus_opex(self):
        d = calc(default_state())
        for m in d["months"]:
            assert m["ebitda"] == approx(m["totalRev"] + m["opex"])  # opex is negative

    def test_month_ncf_is_ebitda_plus_loan(self):
        d = calc(default_state())
        for m in d["months"]:
            assert m["ncf"] == approx(m["ebitda"] + m["loan"])

    def test_fees_are_negative(self):
        d = calc(default_state())
        for m in d["months"]:
            if m["courtRev"] > 0:
                assert m["fees"] < 0

    def test_month_ramp_progression(self):
        d = calc(default_state())
        # Ramp should generally increase over first 12 months
        ramps = [m["ramp"] for m in d["months"][:12]]
        assert ramps == sorted(ramps)


# ════════════════════════════════════════════════════════════
# calc — annual summary integrity
# ════════════════════════════════════════════════════════════

class TestCalcAnnuals:
    def test_annual_ebitda_is_sum_of_months(self):
        d = calc(default_state())
        for annual in d["annuals"]:
            y = annual["year"]
            expected = sum(m["ebitda"] for m in d["months"] if m["yr"] == y)
            assert annual["ebitda"] == approx(expected)

    def test_annual_ncf_is_sum_of_months(self):
        d = calc(default_state())
        for annual in d["annuals"]:
            y = annual["year"]
            expected = sum(m["ncf"] for m in d["months"] if m["yr"] == y)
            assert annual["ncf"] == approx(expected)

    def test_annual_ds_is_sum_of_abs_loan(self):
        d = calc(default_state())
        for annual in d["annuals"]:
            y = annual["year"]
            expected = sum(abs(m["loan"]) for m in d["months"] if m["yr"] == y)
            assert annual["ds"] == approx(expected)

    def test_year3_revenue_higher_than_year1(self):
        d = calc(default_state())
        assert d["annuals"][2]["revenue"] > d["annuals"][0]["revenue"]

    def test_dscr_calculation(self):
        d = calc(default_state())
        for dscr_entry in d["dscr"]:
            annual = d["annuals"][dscr_entry["year"] - 1]
            if annual["ds"] > 0:
                assert dscr_entry["dscr"] == approx(annual["ebitda"] / annual["ds"])
            else:
                assert dscr_entry["dscr"] == 999


# ════════════════════════════════════════════════════════════
# calc — efficiency metrics
# ════════════════════════════════════════════════════════════

class TestCalcEfficiency:
    @pytest.fixture()
    def d(self):
        return calc(default_state())

    def test_rev_pah(self, d):
        assert d["revPAH"] == approx(d["netRevMonth"] / d["availHoursMonth"])

    def test_rev_per_sqm(self, d):
        assert d["revPerSqm"] == approx((d["netRevMonth"] * 12) / d["sqm"])

    def test_cost_per_booked_hr(self, d):
        expected = (d["opex"] + d["monthlyPayment"]) / d["bookedHoursMonth"]
        assert d["costPerBookedHr"] == approx(expected)

    def test_cash_on_cash(self, d):
        expected = d["annuals"][2]["ncf"] / d["equity"]
        assert d["cashOnCash"] == approx(expected)

    def test_debt_yield(self, d):
        expected = d["stabEbitda"] / d["loanAmount"]
        assert d["debtYield"] == approx(expected)

    def test_break_even_hrs(self, d):
        w_rate = d["weightedRate"]
        rev_per_hr = (
            w_rate * (1 - 10 / 100)
            + (15 / 100) * 2 * 5
            + (10 / 100) * (3 - 1.5)
        )
        fixed = d["opex"] + d["monthlyPayment"]
        assert d["breakEvenHrs"] == approx(fixed / rev_per_hr)

    def test_break_even_hrs_per_court(self, d):
        total = d["totalCourts"]
        expected = d["breakEvenHrs"] / total / DEFAULTS["daysPerMonthIndoor"]
        assert d["breakEvenHrsPerCourt"] == approx(expected)


# ════════════════════════════════════════════════════════════
# calc — known-value regression (default scenario)
# ════════════════════════════════════════════════════════════

class TestCalcRegression:
    """
    Pin specific numeric outputs for the default scenario to catch
    unintended changes. Values computed from the current Python implementation
    which was verified against the original JS version.
    """

    @pytest.fixture()
    def d(self):
        return calc(default_state())

    def test_capex_positive(self, d):
        assert d["capex"] > 0

    def test_total_courts_matches_defaults(self, d):
        assert d["totalCourts"] == DEFAULTS["dblCourts"] + DEFAULTS["sglCourts"]

    def test_hall_sqm_positive(self, d):
        assert d["hallSqm"] > 0

    def test_opex_value(self, d):
        assert d["opex"] > 0

    def test_payback_idx_valid(self, d):
        idx = d["paybackIdx"]
        assert idx >= 0
        assert idx < 60

    def test_irr_in_range(self, d):
        # Default scenario should have very high IRR (rent scenario, low capex)
        assert d["irr"] > 1.0

    def test_moic_in_range(self, d):
        assert d["moic"] > 10

    def test_months_60(self, d):
        assert len(d["months"]) == 60

    def test_annuals_5(self, d):
        assert len(d["annuals"]) == 5

    def test_dscr_5_entries(self, d):
        assert len(d["dscr"]) == 5


# ════════════════════════════════════════════════════════════
# calc — contingency
# ════════════════════════════════════════════════════════════

class TestCalcContingency:
    def test_contingency_amount(self):
        d = calc(default_state(contingencyPct=10))
        cont_item = next(i for i in d["capexItems"] if "Contingency" in i["name"])
        sub = sum(i["amount"] for i in d["capexItems"] if "Contingency" not in i["name"])
        assert cont_item["amount"] == _round(sub * 10 / 100)

    def test_higher_contingency_increases_capex(self):
        d10 = calc(default_state(contingencyPct=10))
        d20 = calc(default_state(contingencyPct=20))
        assert d20["capex"] > d10["capex"]


# ════════════════════════════════════════════════════════════
# calc — JSON serializable output
# ════════════════════════════════════════════════════════════

class TestCalcJsonSerializable:
    def test_output_is_json_serializable(self):
        import json
        d = calc(default_state())
        # Should not raise
        serialized = json.dumps(d)
        assert len(serialized) > 0

    def test_no_nan_or_inf_in_defaults(self):
        d = calc(default_state())
        self._check_no_nan_inf(d)

    def _check_no_nan_inf(self, obj, path=""):
        if isinstance(obj, float):
            assert math.isfinite(obj), f"Non-finite float at {path}: {obj}"
        elif isinstance(obj, dict):
            for k, v in obj.items():
                self._check_no_nan_inf(v, f"{path}.{k}")
        elif isinstance(obj, list):
            for i, v in enumerate(obj):
                self._check_no_nan_inf(v, f"{path}[{i}]")


# ════════════════════════════════════════════════════════════
# Parameterized: all 4 venue/ownership combos
# ════════════════════════════════════════════════════════════

ALL_COMBOS = [
    ("indoor", "rent"),
    ("indoor", "buy"),
    ("outdoor", "rent"),
    ("outdoor", "buy"),
]


class TestAllCombosParameterized:
    """Structural invariants that must hold for every venue/ownership combo."""

    @pytest.fixture(params=ALL_COMBOS, ids=lambda c: f"{c[0]}-{c[1]}")
    def d(self, request):
        venue, own = request.param
        return calc(default_state(venue=venue, own=own))

    def test_capex_positive(self, d):
        assert d["capex"] > 0

    def test_capex_items_sum_to_capex(self, d):
        assert sum(i["amount"] for i in d["capexItems"]) == d["capex"]

    def test_opex_items_sum_to_opex(self, d):
        assert sum(i["amount"] for i in d["opexItems"]) == d["opex"]

    def test_equity_plus_loan_equals_capex(self, d):
        assert d["equity"] + d["loanAmount"] == d["capex"]

    def test_60_months(self, d):
        assert len(d["months"]) == 60

    def test_5_annuals(self, d):
        assert len(d["annuals"]) == 5

    def test_5_dscr_entries(self, d):
        assert len(d["dscr"]) == 5

    def test_cumulative_running_sum(self, d):
        assert d["months"][0]["cum"] == approx(-d["capex"] + d["months"][0]["ncf"])
        for i in range(1, 60):
            assert d["months"][i]["cum"] == approx(
                d["months"][i - 1]["cum"] + d["months"][i]["ncf"]
            )

    def test_annual_revenue_sums_match_months(self, d):
        for annual in d["annuals"]:
            y = annual["year"]
            expected = sum(m["totalRev"] for m in d["months"] if m["yr"] == y)
            assert annual["revenue"] == approx(expected)

    def test_annual_ncf_sums_match_months(self, d):
        for annual in d["annuals"]:
            y = annual["year"]
            expected = sum(m["ncf"] for m in d["months"] if m["yr"] == y)
            assert annual["ncf"] == approx(expected)

    def test_json_serializable(self, d):
        json.dumps(d)  # should not raise

    def test_no_nan_or_inf(self, d):
        _assert_finite(d)

    def test_irr_finite(self, d):
        assert math.isfinite(d["irr"])

    def test_moic_non_negative(self, d):
        assert d["moic"] >= 0 or d["capex"] == 0

    def test_total_courts_matches_input(self, d):
        assert d["totalCourts"] == DEFAULTS["dblCourts"] + DEFAULTS["sglCourts"]

    def test_sqm_positive(self, d):
        assert d["sqm"] > 0


def _assert_finite(obj, path=""):
    if isinstance(obj, float):
        assert math.isfinite(obj), f"Non-finite at {path}: {obj}"
    elif isinstance(obj, dict):
        for k, v in obj.items():
            _assert_finite(v, f"{path}.{k}")
    elif isinstance(obj, list):
        for i, v in enumerate(obj):
            _assert_finite(v, f"{path}[{i}]")


# ════════════════════════════════════════════════════════════
# Parameterized: utilization sweep
# ════════════════════════════════════════════════════════════

UTILIZATIONS = [0, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]


@pytest.mark.parametrize("util", UTILIZATIONS)
def test_utilization_sweep_no_crash(util):
    d = calc(default_state(utilTarget=util))
    assert d["bookedHoursMonth"] == approx(d["availHoursMonth"] * util / 100)
    _assert_finite(d)


# ════════════════════════════════════════════════════════════
# Parameterized: court count sweep
# ════════════════════════════════════════════════════════════

COURT_CONFIGS = [(0, 0), (1, 0), (0, 1), (4, 2), (10, 5), (30, 0), (0, 30)]


@pytest.mark.parametrize("dbl,sgl", COURT_CONFIGS, ids=lambda x: f"{x}c" if isinstance(x, int) else None)
def test_court_count_sweep(dbl, sgl):
    d = calc(default_state(dblCourts=dbl, sglCourts=sgl))
    assert d["totalCourts"] == dbl + sgl
    assert len(d["months"]) == 60
    _assert_finite(d)


# ════════════════════════════════════════════════════════════
# Hypothesis: property-based fuzz testing
# ════════════════════════════════════════════════════════════

# Strategy for a plausible state dict
plausible_state = st.fixed_dictionaries({
    "venue": st.sampled_from(["indoor", "outdoor"]),
    "own": st.sampled_from(["rent", "buy"]),
    "dblCourts": st.integers(0, 30),
    "sglCourts": st.integers(0, 30),
    "ratePeak": st.integers(0, 150),
    "rateOffPeak": st.integers(0, 150),
    "rateSingle": st.integers(0, 150),
    "peakPct": st.integers(0, 100),
    "hoursPerDay": st.integers(0, 24),
    "bookingFee": st.integers(0, 30),
    "utilTarget": st.integers(0, 100),
    "courtCostDbl": st.integers(0, 80000),
    "courtCostSgl": st.integers(0, 60000),
    "loanPct": st.integers(0, 100),
    "interestRate": st.floats(0, 15, allow_nan=False, allow_infinity=False),
    "loanTerm": st.integers(0, 30),
    "holdYears": st.integers(1, 20),
    "exitMultiple": st.floats(0, 20, allow_nan=False, allow_infinity=False),
    "contingencyPct": st.integers(0, 30),
    "staff": st.integers(0, 20000),
    "budgetTarget": st.integers(0, 5000000),
    "glassType": st.sampled_from(["standard", "panoramic"]),
    "lightingType": st.sampled_from(["led_standard", "led_competition", "natural"]),
    "country": st.sampled_from(["DE", "ES", "IT", "FR", "NL", "SE", "UK", "US"]),
    "permitsCompliance": st.integers(0, 50000),
})


class TestHypothesisFuzz:
    @given(raw_state=plausible_state)
    @settings(max_examples=200, deadline=2000)
    def test_never_crashes(self, raw_state):
        s = validate_state(raw_state)
        d = calc(s)
        assert isinstance(d, dict)

    @given(raw_state=plausible_state)
    @settings(max_examples=200, deadline=2000)
    def test_always_60_months(self, raw_state):
        d = calc(validate_state(raw_state))
        assert len(d["months"]) == 60

    @given(raw_state=plausible_state)
    @settings(max_examples=200, deadline=2000)
    def test_always_5_annuals(self, raw_state):
        d = calc(validate_state(raw_state))
        assert len(d["annuals"]) == 5

    @given(raw_state=plausible_state)
    @settings(max_examples=200, deadline=2000)
    def test_capex_items_sum(self, raw_state):
        d = calc(validate_state(raw_state))
        assert sum(i["amount"] for i in d["capexItems"]) == d["capex"]

    @given(raw_state=plausible_state)
    @settings(max_examples=200, deadline=2000)
    def test_opex_items_sum(self, raw_state):
        d = calc(validate_state(raw_state))
        assert sum(i["amount"] for i in d["opexItems"]) == d["opex"]

    @given(raw_state=plausible_state)
    @settings(max_examples=200, deadline=2000)
    def test_equity_loan_identity(self, raw_state):
        d = calc(validate_state(raw_state))
        assert d["equity"] + d["loanAmount"] == d["capex"]

    @given(raw_state=plausible_state)
    @settings(max_examples=200, deadline=2000)
    def test_no_nan_or_inf(self, raw_state):
        d = calc(validate_state(raw_state))
        _assert_finite(d)

    @given(raw_state=plausible_state)
    @settings(max_examples=200, deadline=2000)
    def test_json_serializable(self, raw_state):
        d = calc(validate_state(raw_state))
        json.dumps(d)  # must not raise

    @given(raw_state=plausible_state)
    @settings(max_examples=200, deadline=2000)
    def test_cumulative_cf_integrity(self, raw_state):
        d = calc(validate_state(raw_state))
        months = d["months"]
        assert months[0]["cum"] == approx(-d["capex"] + months[0]["ncf"])
        for i in range(1, 60):
            assert months[i]["cum"] == approx(months[i - 1]["cum"] + months[i]["ncf"])

    @given(raw_state=plausible_state)
    @settings(max_examples=200, deadline=2000)
    def test_total_courts_matches_input(self, raw_state):
        s = validate_state(raw_state)
        d = calc(s)
        assert d["totalCourts"] == s["dblCourts"] + s["sglCourts"]

    @given(raw_state=plausible_state)
    @settings(max_examples=100, deadline=2000)
    def test_monthly_ebitda_is_rev_plus_opex(self, raw_state):
        d = calc(validate_state(raw_state))
        for m in d["months"]:
            assert m["ebitda"] == approx(m["totalRev"] + m["opex"])

    @given(raw_state=plausible_state)
    @settings(max_examples=100, deadline=2000)
    def test_monthly_ncf_is_ebitda_plus_loan(self, raw_state):
        d = calc(validate_state(raw_state))
        for m in d["months"]:
            assert m["ncf"] == approx(m["ebitda"] + m["loan"])
