"""How often past forecasts diverge from the CURRENT Forecast Guidance.

Each check is something the guidance states that can be tested from the
fields a forecast actually stores (problem types, likelihood, size, location,
danger per band, bottom line). Imported forecasts carry no sensitivity or
distribution, so checks that depend on them are marked partial.

These are divergences from today's guidance, not errors: most archived
forecasts were written under earlier conventions.
"""

from __future__ import annotations

import re
from collections import Counter, defaultdict

from psycopg import Connection

from .db import fetchall
from .engine import BANDS, bands_of

# id -> (label, guidance source, note)
CHECKS = {
    "normal_caution": ("Used “Normal Caution” as a problem", "FG §1.2.1", "Not one of the nine CMAH problem types."),
    "rated_without_problem": ("Moderate or higher with no avalanche problem listed", "FG §2.1, §3.4",
                              "Problems should cover the travel advice behind the rating."),
    "discouraged_pairing": ("Listed a discouraged problem pairing", "FG Table 1", "Storm+Wind, Wet Loose+Wet Slab, Persistent+Deep Persistent, Dry Loose+Wet Loose."),
    "pairing_overlap": ("…and the pair overlapped in location, likelihood and size", "FG Table 1",
                        "Table 1: don’t pair unless there is a significant difference in distribution, likelihood and size."),
    "size_range": ("Size range wider than two sizes", "FG §3.4", "Keep the range of sizes to two or less."),
    "dps_small": ("Deep Persistent Slab smaller than D3", "FG Table 1", "Deep Persistent is for high-consequence (D3+) avalanches."),
    "dps_likely": ("Deep Persistent Slab rated Likely or higher", "FG Table 1", "Reserved for low likelihood / high consequence (temporary increases allowed)."),
    "small_storm_or_loose": ("Storm Slab or Dry Loose with only D1 avalanches", "FG Table 1",
                             "Partial check: Table 1 also allows a widespread pattern of small avalanches, and distribution isn’t stored."),
    "no_location": ("A problem with no aspect/elevation marked", "FG §3.4", ""),
    "high_not_all_bands": ("HIGH/EXTREME with D3+ problems, but not in all bands", "FG §2.1",
                           "Allowed with a specific, explainable reason — check the text."),
    "band_without_problem": ("A band rated Considerable+ with no problem located there", "FG §3.2", "Travel advice should be consistent across danger and problems."),
    "bottom_line_long": ("Bottom line longer than three sentences", "FG §3.3", "Aim for three sentences or fewer."),
}
STYLE_ONLY = {"bottom_line_long"}
PAIRS = [(2, 3), (6, 7), (4, 5), (1, 6)]
PAIR_NAMES = {(2, 3): "Storm + Wind Slab", (6, 7): "Wet Loose + Wet Slab", (4, 5): "Persistent + Deep Persistent", (1, 6): "Dry Loose + Wet Loose"}

_SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9\"'(“])")


def sentence_count(text: str | None) -> int:
    t = re.sub(r"\s+", " ", text or "").strip()
    if not t:
        return 0
    return sum(1 for part in _SENTENCE_SPLIT.split(t) if len(part.split()) >= 3)


def _num(v):
    return None if v is None else float(v)


def forecast_checks(f: dict) -> tuple[list[str], list[tuple[int, int]]]:
    """Check ids a forecast diverges on, plus the discouraged pairs it listed."""
    out: list[str] = []
    problems = f.get("problems") or []
    danger = {b: f.get(f"danger_{b}") or 0 for b in BANDS}
    top = max(danger.values())

    if f.get("normal_caution"):
        out.append("normal_caution")
    if top >= 2 and not problems:
        out.append("rated_without_problem")

    by_type = {int(p["type"]): p for p in problems}
    pairs = [pair for pair in PAIRS if pair[0] in by_type and pair[1] in by_type]
    if pairs:
        out.append("discouraged_pairing")
    for a, b in pairs:
        pa, pb = by_type[a], by_type[b]
        loc = bool(bands_of(pa["locations"]) & bands_of(pb["locations"]))
        like = pa["likelihood"] is not None and pb["likelihood"] is not None and abs(pa["likelihood"] - pb["likelihood"]) <= 1
        sa = (_num(pa["size_min"]), _num(pa["size_max"]))
        sb = (_num(pb["size_min"]), _num(pb["size_max"]))
        size = None not in sa + sb and sa[0] <= sb[1] and sb[0] <= sa[1]
        if loc and like and size:
            out.append("pairing_overlap")
            break

    for p in problems:
        t, smin, smax = int(p["type"]), _num(p["size_min"]), _num(p["size_max"])
        if smin is not None and smax is not None and smax - smin > 1:
            out.append("size_range")
        if t == 5 and smax is not None and smax < 3:
            out.append("dps_small")
        if t == 5 and (p["likelihood"] or 0) >= 3:
            out.append("dps_likely")
        if t in (1, 2) and smax is not None and smax < 1.5:
            out.append("small_storm_or_loose")
        if not p["locations"]:
            out.append("no_location")

    big = any(_num(p["size_max"]) is not None and _num(p["size_max"]) >= 3 for p in problems)
    if big and top >= 4 and any(v < top for v in danger.values()):
        out.append("high_not_all_bands")
    touched = set()
    for p in problems:
        touched |= bands_of(p["locations"])
    if problems and any(danger[b] >= 3 and b not in touched for b in BANDS):
        out.append("band_without_problem")
    if sentence_count(f.get("bottom_line")) > 3:
        out.append("bottom_line_long")
    return list(dict.fromkeys(out)), pairs


def audit(conn: Connection, center_id: int, era: str | None = None, source: str | None = None) -> dict:
    rows = fetchall(
        conn,
        """SELECT f.id, f.valid_date, f.season, f.guidance_era, f.normal_caution, f.bottom_line,
                  f.danger_upper, f.danger_middle, f.danger_lower,
                  coalesce(json_agg(json_build_object('type', p.problem_type, 'locations', p.locations,
                      'likelihood', p.likelihood, 'size_min', p.size_min, 'size_max', p.size_max) ORDER BY p.rank)
                      FILTER (WHERE p.id IS NOT NULL), '[]') AS problems
           FROM forecasts f LEFT JOIN forecast_problems p ON p.forecast_id = f.id
           WHERE f.center_id = %s AND (%s::text IS NULL OR f.guidance_era = %s) AND (%s::text IS NULL OR f.source = %s)
           GROUP BY f.id ORDER BY f.valid_date DESC""",
        (center_id, era, era, source, source),
    )
    counts: Counter = Counter()
    pair_counts: Counter = Counter()
    examples: dict[str, list] = defaultdict(list)
    seasons: dict[str, Counter] = defaultdict(Counter)
    any_div = any_substantive = 0
    for f in rows:
        ids, pairs = forecast_checks(f)
        season = seasons[f["season"]]
        season["forecasts"] += 1
        for pair in pairs:
            pair_counts[PAIR_NAMES[pair]] += 1
        for cid in ids:
            counts[cid] += 1
            season[cid] += 1
            if len(examples[cid]) < 5:
                examples[cid].append({"id": f["id"], "date": f["valid_date"]})
        if ids:
            any_div += 1
            season["any"] += 1
        if set(ids) - STYLE_ONLY:
            any_substantive += 1
            season["substantive"] += 1
    n = len(rows)
    return {
        "forecasts": n,
        "any": any_div,
        "substantive": any_substantive,
        "checks": [
            {"id": cid, "label": label, "source": src, "note": note, "count": counts[cid],
             "share": round(counts[cid] / n, 4) if n else None, "examples": examples[cid]}
            for cid, (label, src, note) in CHECKS.items()
        ],
        "pairs": dict(pair_counts),
        "by_season": {s: dict(c) for s, c in sorted(seasons.items())},
    }
