import datetime

import numpy as np
import polars as pl
import pytest

from data import grading


# ---- empirical percentile ----------------------------------------------------

def test_percentile_mid_rank_handles_ties():
    samples = np.array([1.0, 2.0, 2.0, 3.0])
    # less=1, equal=2 -> (1 + 0.5*2) / 4 = 50%
    assert grading.empirical_percentile(samples, 2.0) == 50.0


def test_percentile_extremes_and_empties():
    samples = np.array([1.0, 2.0, 3.0])
    assert grading.empirical_percentile(samples, 0.0) == 0.0
    assert grading.empirical_percentile(samples, 4.0) == 100.0
    assert grading.empirical_percentile(np.array([]), 1.0) is None
    assert grading.empirical_percentile(samples, None) is None
    assert grading.empirical_percentile(samples, float("nan")) is None


# ---- tier bands ---------------------------------------------------------------

@pytest.mark.parametrize("pct,label,css", [
    (99.5, "Near Record", "rec-hot"),   # top tier is strict: > 99 only
    (99.0, "Very High", "very-hot"),    # p99 exactly is NOT near-record
    (90.0, "Very High", "very-hot"),
    (75.0, "High", "hot"),
    (60.0, "Above Normal", "warm"),
    (59.9, "Normal", "normal"),
    (40.0, "Normal", "normal"),
    (39.9, "Below Normal", "cool"),
    (10.0, "Low", "cold"),
    (1.0, "Very Low", "very-cold"),
    (0.5, "Near Record", "rec-cold"),   # strictly below the 1st percentile
])
def test_temp_band_boundaries(pct, label, css):
    assert grading._band(pct, grading.TEMP_BANDS) == (label, css)


def test_ladders_stay_aligned_with_bands():
    """The detail-view ladders re-encode the band tables by hand; catch drift."""
    for bands, ladder in [(grading.TEMP_BANDS, grading._TEMP_LADDER),
                          (grading.RAIN_BANDS, grading._RAIN_LADDER)]:
        assert len(bands) == len(ladder)
        for (_, label, css), (lcss, llabel, *_rest) in zip(bands, ladder):
            assert (label, css) == (llabel, lcss)


# ---- seasonal window ----------------------------------------------------------

def test_window_mask_wraps_across_year_end():
    doys = np.array([1, 180, 360, 366])
    mask = grading.window_mask(doys, target_doy=1, half=7)
    assert mask.tolist() == [True, False, True, True]


# ---- precip grading -----------------------------------------------------------

def test_only_zero_precip_is_dry():
    # Dry means no rain at all; any measurable rain, however slight, is at least a
    # Trace day (not Dry).
    dry = grading._grade_precip(np.array([0.0, 0.5, 1.0]), 0.0)
    assert dry["class"] == "dry" and dry["percentile"] is None and dry["grade"] == "Dry"
    trace = grading._grade_precip(np.array([0.0, 0.5, 1.0]), 0.005)
    assert trace["class"] != "dry" and trace["grade"] == "Trace"


def test_rain_percentile_ranks_among_rain_days_only():
    # 7 dry days + rain days [0.1, 0.2, 0.4]; 0.2 ranks among the 3 rain days:
    # less=1, equal=1 -> (1 + 0.5) / 3 = 50% -> Typical, unaffected by the dry mass.
    samples = np.array([0.0] * 7 + [0.1, 0.2, 0.4])
    g = grading._grade_precip(samples, 0.2)
    assert g["percentile"] == 50.0
    assert g["grade"] == "Typical"


def test_rain_with_no_historical_rain_days_is_extreme():
    g = grading._grade_precip(np.zeros(10), 0.3)
    assert g["percentile"] == 100.0 and g["class"] == "wet-9"


def test_rain_scale_eight_tiers_with_severe():
    # Eight tiers filling the wet-2..wet-9 ramp: Trace / Light / Brisk / Typical /
    # Heavy / Very Heavy / Severe / Extreme. Very Heavy was split, its top half
    # becoming Severe (95–99).
    assert [b[1] for b in grading.RAIN_BANDS] == \
        ["Extreme", "Severe", "Very Heavy", "Heavy", "Typical", "Brisk", "Light", "Trace"]
    assert [b[2] for b in grading.RAIN_BANDS] == \
        ["wet-9", "wet-8", "wet-7", "wet-6", "wet-5", "wet-4", "wet-3", "wet-2"]

    rain = np.arange(1, 201, dtype=float) / 100.0      # 200 rain days: 0.01 .. 2.00
    samples = np.concatenate([np.zeros(20), rain])

    def grade(v):
        g = grading._grade_precip(samples, v)
        return g["grade"], g["class"]
    assert grade(0.01) == ("Trace", "wet-2")           # <1st pct -> the bottom tier
    assert grade(1.35) == ("Heavy", "wet-6")           # ~67th pct
    assert grade(1.85) == ("Very Heavy", "wet-7")      # ~92nd pct (lower half)
    assert grade(1.96) == ("Severe", "wet-8")          # ~98th pct (top half -> Severe)
    assert grade(2.00) == ("Extreme", "wet-9")         # >99th pct


# ---- dry streaks ---------------------------------------------------------------

def test_dry_streaks_walk():
    dates = [datetime.date(2024, 1, 1) + datetime.timedelta(days=i) for i in range(5)]
    precips = [0.5, 0.0, float("nan"), 0.02, 0.005]
    out = grading.dry_streaks(dates, precips)
    # Any rain > 0 breaks the streak (matching the dry/rain grading split), so the
    # 0.005" trace day resets to 0; only exact 0.0 and NaN count as dry.
    assert list(out.values()) == [0, 1, 2, 0, 0]


# ---- range + day grading over a synthetic record --------------------------------

def test_grade_range_compact_shape(history):
    end = history["date"].max()
    start = end - datetime.timedelta(days=30)
    days = grading.grade_range(history, start, end)
    assert len(days) == 31
    first = days[0]
    assert set(first) == {"date", "dsr", *grading.TEMP_METRICS, "precip"}
    assert first["dsr"] >= 0
    g = first["tmax"]
    assert set(g) == {"v", "pct", "c", "g"}
    for rec in days:   # dry days carry no rain percentile, wet days always do
        if rec["precip"]["c"] == "dry":
            assert rec["precip"]["pct"] is None
        else:
            assert rec["precip"]["pct"] is not None


def test_grade_day_normals_and_departure(history):
    target = history["date"].max()
    row = history.filter(pl.col("date") == target).row(0, named=True)
    result = grading.grade_day(history, target, {"tmax": row["tmax"], "tmin": row["tmin"],
                                                 "precip": row["precip"]})
    assert set(result["normals"]["tmax"]) == {"p1", "p10", "p25", "p40", "p50", "p60",
                                              "p75", "p90", "p95", "p99"}
    expected = max(abs(result["tmax"]["percentile"] - 50), abs(result["tmin"]["percentile"] - 50))
    assert result["departure"] == round(expected, 1)


def test_day_detail_with_and_without_observation(history):
    target = history["date"].max()
    detail = grading.day_detail(history, target, {"tmax": 60.0, "precip": 0.0})
    assert detail["metrics"]["tmax"]["obs"]["value"] == 60.0
    assert detail["metrics"]["tmax"]["ladder"]["tiers"][0]["c"] == "rec-hot"
    assert detail["metrics"]["precip"]["ladder"]["tiers"][-1]["c"] == "dry"

    bare = grading.day_detail(history, target, None)
    assert bare["metrics"]["tmax"]["obs"] is None
    assert bare["metrics"]["tmax"]["ladder"] is not None


def test_climatology_summary(history):
    climo = grading.climatology(history, 180)
    # ±7-day window over ~20 years -> ~15 samples per year.
    assert climo["n_samples"] >= 15 * 19
    assert climo["tmax"]["p40"] <= climo["tmax"]["p50"] <= climo["tmax"]["p60"]
    assert climo["feels"] is None   # column absent from this record


def test_climatology_frequencies(history):
    climo = grading.climatology(history, 180)
    # ~30% of days carry rain in the fixture; sunshine is absent from this record.
    assert 0.0 < climo["rain_freq"] < 1.0
    assert climo["sun_freq"] is None


def test_month_climate(history):
    mc = grading.month_climate(history)
    assert sorted(int(k) for k in mc) == list(range(1, 13))
    for k, e in mc.items():
        assert 0.0 <= e["rain_freq"] <= 1.0
        assert e["sun_freq"] is None            # no sunshine column in the fixture
        assert e["tmax_med"] is not None
        # rainy_days is the rain fraction scaled to the month's length, stored
        # rounded to one decimal (so allow for that 0.1 rounding).
        assert e["rainy_days"] == pytest.approx(
            e["rain_freq"] * grading._DAYS_IN_MONTH[int(k) - 1], abs=0.06)
    # Summer (Jul) is warmer than winter (Jan) given the seasonal fixture.
    assert mc["7"]["tmax_med"] > mc["1"]["tmax_med"]
