"""Deterministic score computation (design §8.2, spec SC-08…SC-13, SG-06).
Pure functions: no DB, no AI. The Laravel OverrideService mirrors round_to_step/validate_override."""
from __future__ import annotations
import unicodedata
from dataclasses import dataclass
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal

FACTORS = ("correctness", "completeness", "spelling", "grammar", "handwriting")


def effective_weights(category_default: dict, set_override: dict | None, unit_override: dict | None) -> dict[str, float]:
    """Weights are stored as percentages; the most specific level wins as a whole (FW-02).
    Returns fractions summing to 1."""
    chosen = unit_override or set_override or category_default
    total = sum(float(chosen.get(f, 0)) for f in FACTORS)
    if abs(total - 100) > 0.01:
        raise ValueError(f"factor weights must sum to 100, got {total}")
    return {f: float(chosen.get(f, 0)) / 100 for f in FACTORS}


def _norm_word(s: str | None) -> str:
    return unicodedata.normalize("NFC", (s or "").strip().lower())


def count_errors(error_items: list[dict]) -> dict[str, int]:
    """Spelling counts distinct misspelled forms (a repeated identical misspelling counts once)."""
    spelled = {_norm_word(e.get("as_written")) or f"#{i}"
               for i, e in enumerate(error_items) if e["type"] == "spelling"}
    return {"spelling": len(spelled), "grammar": sum(1 for e in error_items if e["type"] == "grammar")}


@dataclass
class UnitScore:
    ratings: dict[str, float | None]
    weights: dict[str, float]
    error_counts: dict[str, int]
    raw: float
    final: float
    gated: bool


def compute_unit(*, max_mark: float, weights: dict[str, float], correctness: float, completeness: float,
                 error_items: list[dict], legibility_avg: float | None, step: float, rounding: str,
                 content_gate: bool, spelling_penalty: float = 0.1, grammar_penalty: float = 0.1,
                 max_spelling_penalty: float = 0.5) -> UnitScore:
    counts = count_errors(error_items)
    ratings: dict[str, float | None] = {
        "correctness": _clip(correctness),
        "completeness": _clip(completeness),
        "spelling": max(1 - max_spelling_penalty, 1 - counts["spelling"] * spelling_penalty, 0.0),
        "grammar": max(0.0, 1 - counts["grammar"] * grammar_penalty),
        "handwriting": None if legibility_avg is None else _clip(legibility_avg / 10),
    }
    w = dict(weights)
    if ratings["handwriting"] is None and w["handwriting"] > 0:
        # No legibility available: spread the handwriting weight over the other factors proportionally.
        rest = 1 - w["handwriting"]
        w = {f: (w[f] / rest if f != "handwriting" else 0.0) for f in FACTORS} if rest > 0 else w
    raw = max_mark * sum(w[f] * (ratings[f] or 0.0) for f in FACTORS)
    gated = content_gate and ratings["correctness"] == 0
    if gated:
        raw = 0.0
    final = clamp(round_to_step(raw, step, rounding), 0, max_mark)
    return UnitScore(ratings=ratings, weights=w, error_counts=counts, raw=round(raw, 4), final=final, gated=gated)


def _clip(x: float) -> float:
    return min(max(float(x), 0.0), 1.0)


def round_to_step(value: float, step: float, mode: str = "nearest") -> float:
    """nearest = ties round up (SC-11). Decimal avoids 2.25/0.25 float artefacts."""
    v, s = Decimal(str(value)), Decimal(str(step))
    q = v / s
    if mode == "down":
        n = q.to_integral_value(rounding=ROUND_FLOOR)
    elif mode == "up":
        n = q.to_integral_value(rounding=ROUND_CEILING)
    else:
        n = (q + Decimal("0.5")).to_integral_value(rounding=ROUND_FLOOR)
    return float(n * s)


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


def is_on_step(value: float, step: float) -> bool:
    return (Decimal(str(value)) / Decimal(str(step))) % 1 == 0


def validate_override(value: float, max_mark: float, step: float) -> str | None:
    """Return an error message, or None if the teacher value is acceptable (SC-12 for overrides)."""
    if value < 0 or value > max_mark:
        return f"mark must be between 0 and {max_mark}"
    if not is_on_step(value, step):
        return f"mark must be a multiple of {step}"
    return None


def apply_choice_rule(answered: list[tuple[str, float, int]], n: int | None, mode: str) -> set[str]:
    """answered: [(question_id, question_mark, first_appearance_order)]. Returns ids that count (SG-06).
    best: highest marks (ties -> earlier answer); first: first N in answer order."""
    if n is None or len(answered) <= n:
        return {q for q, _, _ in answered}
    if mode == "first":
        chosen = sorted(answered, key=lambda t: t[2])[:n]
    else:
        chosen = sorted(answered, key=lambda t: (-t[1], t[2]))[:n]
    return {q for q, _, _ in chosen}


def uncertainty_matters(content_text: str, max_mark: float, step: float) -> bool:
    """Flag when unreadable text could move the mark by at least one step (SC-04).
    Any uncertain/illegible marker can change the mark of a unit worth at least one step."""
    has = "⟦uncertain" in content_text or "⟦illegible" in content_text
    return has and max_mark >= step
