"""Python port of packages/engine (the CMAH guidance engine).

Must produce output identical to the JS engine for every vector in
packages/engine/vectors/cases.json (see tests/test_engine_vectors.py). The
server uses it to recompute runs on finalize (never trusting the browser),
to backfill suggestions for imported forecasts, and to replay proposed
config versions against history.
"""

from __future__ import annotations

import math
from typing import Any

ENGINE_VERSION = "1.0.0"

ASPECTS = ["north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest"]
BANDS = ["upper", "middle", "lower"]
STUBBORN = 1
WIDESPREAD = 2


def js_round(v: float) -> int:
    """JavaScript Math.round (half rounds toward +infinity)."""
    return math.floor(v + 0.5)


def round4(v: float | None) -> float | None:
    if v is None:
        return None
    return math.floor(v * 1e4 + 0.5) / 1e4


def is_num(v: Any) -> bool:
    if v is None or v == "" or isinstance(v, bool):
        return False
    try:
        return not math.isnan(float(v))
    except (TypeError, ValueError):
        return False


def _num(v: Any) -> float | None:
    return float(v) if is_num(v) else None


def clamp(v: float, lo: float, hi: float) -> float:
    return max(lo, min(hi, v))


# --- rose --------------------------------------------------------------------


def parse_location(loc: str) -> tuple[str, str] | None:
    parts = str(loc).strip().lower().split()
    if len(parts) != 2 or parts[0] not in ASPECTS or parts[1] not in BANDS:
        return None
    return parts[0], parts[1]


def bands_of(locations: list[str] | None) -> set[str]:
    out = set()
    for loc in locations or []:
        p = parse_location(loc)
        if p:
            out.add(p[1])
    return out


def cell_to_location(cell: int) -> str | None:
    if not isinstance(cell, int) or cell < 0 or cell > 23:
        return None
    return f"{ASPECTS[cell % 8]} {BANDS[cell // 8]}"


def location_to_cell(loc: str) -> int | None:
    p = parse_location(loc)
    if not p:
        return None
    return BANDS.index(p[1]) * 8 + ASPECTS.index(p[0])


# --- likelihood --------------------------------------------------------------


def interp_likelihood(dist_pos: float, sens_pos: float, config: dict) -> float:
    sens = config["sensitivity"]
    dist = config["distribution"]
    m = config["likelihoodMatrix"]
    dist_pos = clamp(float(dist_pos), 0, len(dist) - 1)
    sens_pos = clamp(float(sens_pos), 0, len(sens) - 1)
    d0 = math.floor(dist_pos)
    d1 = min(len(dist) - 1, d0 + 1)
    fd = dist_pos - d0
    s0 = math.floor(sens_pos)
    s1 = min(len(sens) - 1, s0 + 1)
    fs = sens_pos - s0

    def cell(di: int, si: int) -> float:
        return float((m.get(dist[di]["key"]) or {}).get(sens[si]["key"]) or 0)

    bottom = cell(d0, s0) * (1 - fs) + cell(d0, s1) * fs
    top = cell(d1, s0) * (1 - fs) + cell(d1, s1) * fs
    return bottom * (1 - fd) + top * fd


def likelihood_range(problem: dict, config: dict) -> dict | None:
    s = problem.get("sensitivity") or {}
    d = problem.get("distribution") or {}
    if not all(is_num(x) for x in (s.get("min"), s.get("max"), d.get("min"), d.get("max"))):
        return None
    lo = interp_likelihood(d["min"], s["min"], config)
    hi = interp_likelihood(d["max"], s["max"], config)
    return {"min": min(lo, hi), "max": max(lo, hi)}


def likelihood_options(r: dict | None) -> list[int]:
    if not r:
        return []
    lo = int(clamp(math.floor(r["min"]), 1, 5))
    hi = int(clamp(math.ceil(r["max"]), 1, 5))
    return list(range(lo, hi + 1))


def likelihood_for(problem: dict, config: dict) -> int | None:
    r = likelihood_range(problem, config)
    if not r:
        return None
    choice = problem.get("likelihoodChoice")
    if is_num(choice):
        c = int(clamp(js_round(float(choice)), 1, 5))
        if c in likelihood_options(r):
            return c
    return int(clamp(js_round(r["max"]), 1, 5))


# --- danger ------------------------------------------------------------------


def size_class(size: float, config: dict) -> int:
    cols = len(config["dangerTable"]["1"])
    return int(max(1, min(cols, math.ceil(float(size) - 1e-9))))


def danger_for(likelihood: int, size: float, config: dict) -> int:
    row = config["dangerTable"].get(str(likelihood))
    if not row:
        return 0
    return int(row[size_class(size, config) - 1])


def danger_suggestions(problems: list[dict], config: dict) -> dict:
    out = {"upper": 0, "middle": 0, "lower": 0, "overall": 0}
    for band in BANDS:
        best = 0
        for p in problems:
            like = likelihood_for(p, config)
            size = p.get("size") or {}
            if not like or not is_num(size.get("max")):
                continue
            if band not in bands_of(p.get("locations")):
                continue
            best = max(best, danger_for(like, size["max"], config))
        out[band] = best
        out["overall"] = max(out["overall"], best)
    return out


# --- pairing -----------------------------------------------------------------


def _shares_band(a: dict, b: dict) -> bool:
    return bool(bands_of(a.get("locations")) & bands_of(b.get("locations")))


def _likelihood_overlap(a: dict, b: dict, config: dict) -> bool:
    ra, rb = likelihood_range(a, config), likelihood_range(b, config)
    if not ra or not rb:
        return False
    return ra["min"] <= rb["max"] + 1 and rb["min"] <= ra["max"] + 1


def _size_overlap(a: dict, b: dict) -> bool:
    sa, sb = a.get("size") or {}, b.get("size") or {}
    if not all(is_num(x) for x in (sa.get("min"), sb.get("min"), sa.get("max"), sb.get("max"))):
        return False
    return float(sa["min"]) <= float(sb["max"]) and float(sb["min"]) <= float(sa["max"])


def pairing_flags(problems: list[dict], config: dict) -> list[dict]:
    by_type: dict[int, dict] = {}
    for p in problems:
        if p.get("type") is not None:
            by_type[int(p["type"])] = p
    flags = []
    for rule in config.get("pairingRules") or []:
        pa, pb = by_type.get(int(rule["a"])), by_type.get(int(rule["b"]))
        if not pa or not pb:
            continue
        overlap = {
            "location": _shares_band(pa, pb),
            "likelihood": _likelihood_overlap(pa, pb, config),
            "size": _size_overlap(pa, pb),
        }
        strong = rule.get("requireJustification") is not False and all(overlap.values())
        flags.append(
            {
                "key": f"{rule['a']}-{rule['b']}",
                "a": int(rule["a"]),
                "b": int(rule["b"]),
                "overlap": overlap,
                "strong": strong,
                "source": rule.get("source"),
            }
        )
    return flags


# --- guidance checks ---------------------------------------------------------


def guidance_checks(inputs: dict, config: dict, ratings: dict | None = None) -> list[dict]:
    out: list[dict] = []
    problems = inputs.get("problems") or []
    conditions = set(inputs.get("conditions") or [])

    def add(id_: str, level: str, text: str, source: str, problem_index: int | None = None) -> None:
        out.append({"id": id_, "level": level, "text": text, "source": source, "problemIndex": problem_index})

    for i, p in enumerate(problems):
        t = int(p["type"]) if p.get("type") is not None else None
        size = p.get("size") or {}
        s_max, s_min = _num(size.get("max")), _num(size.get("min"))
        sens_min = _num((p.get("sensitivity") or {}).get("min"))
        dist_max = _num((p.get("distribution") or {}).get("max"))

        if s_min is not None and s_max is not None and s_max - s_min > 1:
            add("size_range", "warn", "Keep the range of sizes to two or less and describe outliers in the problem description.", "FG §3.4", i)
        if t in (1, 2) and s_max is not None and s_max < 1.5 and dist_max is not None and dist_max < WIDESPREAD:
            add("small_loose_or_storm", "info", "Table 1 lists Dry Loose and Storm Slab when there is potential for ~D1.5 or larger avalanches, or a widespread pattern of small avalanches.", "FG Table 1", i)
        if t == 5:
            if s_max is not None and s_max < 3:
                add("dps_size", "warn", "Deep Persistent Slab is for slabs capable of high-consequence avalanches (D3+).", "FG Table 1", i)
            if sens_min is not None and sens_min > STUBBORN:
                add("dps_sensitivity", "info", "Deep Persistent Slab is reserved for low likelihood / high consequence scenarios and is normally stubborn to triggers. Temporary increases are acceptable if likelihood is expected to return to low.", "FG Table 1", i)
        if t == 9:
            add("glide_duration", "info", "Glide timing is highly uncertain: consider keeping the problem for at least 48 hours after the onset of glide avalanches.", "FG Table 1", i)
        if not (p.get("locations") or []):
            add("no_location", "warn", "Mark the aspects and elevations where the problem is most likely to be encountered.", "FG §3.4", i)

    types = {int(p["type"]) for p in problems if p.get("type") is not None}
    if "new_snow_24h" in conditions and 4 in types and 2 not in types:
        add("first_storm_on_pwl", "info", "The first storm on a persistent weak layer should be a Storm Slab unless you expect the terrain-management considerations of a Persistent Slab.", "FG Table 1")
    if "no_hazard" in conditions and problems:
        add("structure_not_problem", "info", "Assign a problem only when there is an associated avalanche hazard; poor structure alone belongs in the Forecast Discussion.", "FG §3.4")

    if ratings:
        big = any(
            _num((p.get("size") or {}).get("max")) is not None
            and float(p["size"]["max"]) >= 3
            and likelihood_for(p, config)
            for p in problems
        )
        levels = [int(ratings.get(b) or 0) for b in BANDS]
        top = max(levels)
        if big and top >= 4 and any(lvl < top for lvl in levels):
            name = "EXTREME" if top == 5 else "HIGH"
            add("high_all_bands", "warn", f"{name} danger with D3+ avalanches should be assigned to all elevation bands unless there is a specific, explainable reason these avalanches will not affect the other elevations.", "FG §2.1")
        touched: set[str] = set()
        for p in problems:
            touched |= bands_of(p.get("locations"))
        for b in BANDS:
            if int(ratings.get(b) or 0) >= 3 and problems and b not in touched:
                add("band_without_problem", "info", f"The {b} band is rated Considerable or higher but no problem is located there — check the locations or explain in the text.", "FG §3.2")
    return out


# --- entry points ------------------------------------------------------------


def compute_run(inputs: dict, config: dict) -> dict:
    problems = list(inputs.get("problems") or [])[: config.get("maxProblems") or 3]
    per_problem = []
    for p in problems:
        r = likelihood_range(p, config)
        per_problem.append(
            {
                "type": int(p["type"]),
                "likelihoodRange": {"min": round4(r["min"]), "max": round4(r["max"])} if r else None,
                "likelihoodOptions": likelihood_options(r),
                "likelihood": likelihood_for(p, config),
            }
        )
    suggestions = danger_suggestions(problems, config)
    return {
        "engineVersion": ENGINE_VERSION,
        "problems": per_problem,
        "suggestions": suggestions,
        "pairingFlags": pairing_flags(problems, config),
        "checks": guidance_checks({**inputs, "problems": problems}, config, suggestions),
    }


def readiness(inputs: dict, computed: dict, justifications: dict | None = None) -> list[dict]:
    justifications = justifications or {}
    issues: list[dict] = []
    for i, p in enumerate(inputs.get("problems") or []):
        c = computed["problems"][i] if i < len(computed["problems"]) else {}
        if not c.get("likelihood"):
            issues.append({"problemIndex": i, "code": "likelihood", "text": "Set the sensitivity and distribution ranges."})
        size = p.get("size") or {}
        if size.get("max") in (None, ""):
            issues.append({"problemIndex": i, "code": "size", "text": "Set the expected size range."})
        if not (p.get("locations") or []):
            issues.append({"problemIndex": i, "code": "location", "text": "Mark at least one aspect/elevation."})
    for f in computed["pairingFlags"]:
        if f["strong"] and not (justifications.get(f["key"]) or "").strip():
            issues.append({"code": "justification", "pairing": f["key"], "text": "Explain why both problems in the flagged pairing are needed."})
    return issues
