# retoor <retoor@molodetz.nl>

from __future__ import annotations

import json
import random
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone


class NeedsAiGrading(Exception):
    pass


@dataclass(frozen=True)
class QuestionKind:
    key: str
    label: str
    icon: str
    has_options: bool
    graded_by: str


@dataclass(frozen=True)
class GradeResult:
    score: float
    is_correct: bool
    feedback: str
    graded_by: str
    confidence: float


QUESTION_KINDS: tuple[QuestionKind, ...] = (
    QuestionKind("single_choice", "Single choice", "\U0001F518", True, "auto"),
    QuestionKind("multiple_choice", "Multiple choice", "☑️", True, "auto"),
    QuestionKind("true_false", "True or false", "⚖️", False, "auto"),
    QuestionKind("free_text", "Free text", "✍️", False, "ai"),
    QuestionKind("fill_blank", "Fill in the blanks", "\U0001F4DD", True, "auto"),
    QuestionKind("numeric", "Numeric", "\U0001F522", False, "auto"),
    QuestionKind("ordering", "Ordering", "\U0001F500", True, "auto"),
    QuestionKind("matching", "Matching", "\U0001F517", True, "auto"),
)

KIND_KEYS: tuple[str, ...] = tuple(kind.key for kind in QUESTION_KINDS)

KINDS_BY_KEY: dict[str, QuestionKind] = {kind.key: kind for kind in QUESTION_KINDS}

TRUE_WORDS = {"1", "true", "yes", "on", "t"}

_WORD = re.compile(r"[a-z0-9]+")

_STOP_WORDS = frozenset(
    {
        "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "in", "is",
        "it", "its", "of", "on", "or", "that", "the", "this", "to", "was", "were",
        "will", "with",
    }
)


def clamp(value: float, low: float, high: float) -> float:
    return low if value < low else (high if value > high else value)


def kind_label(key: str) -> str:
    kind = KINDS_BY_KEY.get(key)
    return kind.label if kind else key


def awarded_points(points: int, score: float) -> float:
    return round(max(0, int(points or 0)) * clamp(float(score or 0.0), 0.0, 1.0), 4)


def score_percent(score_points: float, max_points: int) -> float:
    if not max_points or max_points <= 0:
        return 0.0
    return round(clamp(float(score_points or 0.0) / float(max_points) * 100.0, 0.0, 100.0), 2)


def passed(percent: float, pass_percent: int) -> bool:
    if not pass_percent or pass_percent <= 0:
        return False
    return float(percent or 0.0) >= float(pass_percent)


def attempt_expires_at(started_at: str, time_limit_seconds: int) -> str:
    limit = max(0, int(time_limit_seconds or 0))
    if not limit or not started_at:
        return ""
    started = _parse_iso(started_at)
    if not started:
        return ""
    return (started + timedelta(seconds=limit)).isoformat()


def is_expired(expires_at: str, now: datetime) -> bool:
    if not expires_at:
        return False
    deadline = _parse_iso(expires_at)
    if not deadline:
        return False
    return now >= deadline


def remaining_seconds(expires_at: str, now: datetime) -> int:
    if not expires_at:
        return 0
    deadline = _parse_iso(expires_at)
    if not deadline:
        return 0
    return max(0, int((deadline - now).total_seconds()))


def question_order(question_uids: list[str], shuffle: bool, seed: str) -> list[str]:
    items = [uid for uid in (question_uids or []) if uid]
    if not shuffle or len(items) < 2:
        return items
    shuffled = list(items)
    random.Random(f"questions:{seed}").shuffle(shuffled)
    return shuffled


def option_order(option_uids: list[str], shuffle: bool, seed: str) -> list[str]:
    items = [uid for uid in (option_uids or []) if uid]
    if not shuffle or len(items) < 2:
        return items
    shuffled = list(items)
    random.Random(f"options:{seed}").shuffle(shuffled)
    return shuffled


def token_overlap_score(expected: str, given: str) -> float:
    expected_tokens = _tokens(expected)
    given_tokens = _tokens(given)
    if not expected_tokens:
        return 1.0 if given_tokens else 0.0
    if not given_tokens:
        return 0.0
    hits = len(expected_tokens & given_tokens)
    return clamp(hits / len(expected_tokens), 0.0, 1.0)


def grade_answer(question: dict, options: list[dict], submission: dict) -> GradeResult:
    kind = (question or {}).get("kind", "")
    if kind == "free_text":
        raise NeedsAiGrading(question.get("uid", ""))
    grader = _GRADERS.get(kind)
    if not grader:
        return GradeResult(0.0, False, "Unknown question type.", "auto", 1.0)
    score, feedback = grader(question or {}, list(options or []), submission or {})
    bounded = clamp(float(score), 0.0, 1.0)
    return GradeResult(bounded, bounded >= 1.0, feedback, "auto", 1.0)


def fallback_result(expected_answer: str, answer_text: str, reason: str) -> GradeResult:
    score = token_overlap_score(expected_answer, answer_text)
    return GradeResult(
        score,
        score >= 1.0,
        f"Automatic review was unavailable ({reason}). Scored on keyword overlap with "
        "the reference answer.",
        "fallback",
        0.0,
    )


def _parse_iso(value: str) -> datetime | None:
    if not value:
        return None
    try:
        parsed = datetime.fromisoformat(value)
    except ValueError:
        return None
    return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)


def _tokens(text: str) -> set[str]:
    words = _WORD.findall((text or "").lower())
    meaningful = {word for word in words if word not in _STOP_WORDS}
    return meaningful or set(words)


def _normalize(text: str, case_sensitive: bool) -> str:
    collapsed = " ".join((text or "").split())
    return collapsed if case_sensitive else collapsed.lower()


def _chosen_uids(submission: dict) -> list[str]:
    raw = submission.get("option_uids")
    if isinstance(raw, str):
        raw = [part.strip() for part in raw.split(",") if part.strip()]
    seen: list[str] = []
    for uid in raw or []:
        if isinstance(uid, str) and uid and uid not in seen:
            seen.append(uid)
    return seen


def _payload(submission: dict):
    raw = submission.get("answer_text") or ""
    if not isinstance(raw, str):
        return raw
    text = raw.strip()
    if not text:
        return None
    try:
        return json.loads(text)
    except ValueError:
        return None


def _ordered(options: list[dict]) -> list[dict]:
    return sorted(options, key=lambda option: int(option.get("position") or 0))


def _grade_single_choice(question: dict, options: list[dict], submission: dict):
    chosen = _chosen_uids(submission)
    if len(chosen) != 1:
        return 0.0, "Pick exactly one option."
    picked = next((o for o in options if o.get("uid") == chosen[0]), None)
    if not picked:
        return 0.0, "That option does not belong to this question."
    if int(picked.get("is_correct") or 0):
        return 1.0, "Correct."
    return 0.0, "That is not the right option."


def _grade_multiple_choice(question: dict, options: list[dict], submission: dict):
    correct = {o["uid"] for o in options if int(o.get("is_correct") or 0)}
    valid = {o["uid"] for o in options}
    chosen = {uid for uid in _chosen_uids(submission) if uid in valid}
    if not correct:
        return 0.0, "This question has no correct option configured."
    hits = len(chosen & correct)
    wrong = len(chosen - correct)
    score = clamp((hits - wrong) / len(correct), 0.0, 1.0)
    if score >= 1.0 and chosen == correct:
        return 1.0, "All correct options picked."
    return score, f"{hits} of {len(correct)} correct options picked, {wrong} wrong."


def _grade_true_false(question: dict, options: list[dict], submission: dict):
    given = (submission.get("answer_text") or "").strip().lower()
    if not given:
        return 0.0, "No answer given."
    chosen = given in TRUE_WORDS
    if chosen == bool(int(question.get("correct_boolean") or 0)):
        return 1.0, "Correct."
    return 0.0, "That is not right."


def _grade_fill_blank(question: dict, options: list[dict], submission: dict):
    blanks = _ordered(options)
    if not blanks:
        return 0.0, "This question has no blanks configured."
    case_sensitive = bool(int(question.get("case_sensitive") or 0))
    payload = _payload(submission)
    if not isinstance(payload, list):
        raw = submission.get("answer_text") or ""
        payload = [part for part in str(raw).split("|")] if raw else []
    hits = 0
    for index, blank in enumerate(blanks):
        given = payload[index] if index < len(payload) else ""
        if not isinstance(given, str):
            continue
        expected = blank.get("match_value") or blank.get("label") or ""
        if _normalize(given, case_sensitive) == _normalize(expected, case_sensitive):
            hits += 1
    score = hits / len(blanks)
    return score, f"{hits} of {len(blanks)} blanks correct."


def _grade_numeric(question: dict, options: list[dict], submission: dict):
    raw = (submission.get("answer_text") or "").strip().replace(",", ".")
    try:
        given = float(raw)
    except (TypeError, ValueError):
        return 0.0, "That is not a number."
    expected = float(question.get("numeric_value") or 0.0)
    tolerance = abs(float(question.get("numeric_tolerance") or 0.0))
    if abs(given - expected) <= tolerance:
        return 1.0, "Correct."
    return 0.0, "Outside the accepted range."


def _grade_ordering(question: dict, options: list[dict], submission: dict):
    expected = [option["uid"] for option in _ordered(options)]
    if not expected:
        return 0.0, "This question has no items configured."
    given = [uid for uid in _chosen_uids(submission) if uid in set(expected)]
    prefix = 0
    for index, uid in enumerate(expected):
        if index < len(given) and given[index] == uid:
            prefix += 1
        else:
            break
    score = prefix / len(expected)
    return score, f"{prefix} of {len(expected)} items in the right position."


def _grade_matching(question: dict, options: list[dict], submission: dict):
    pairs = _ordered(options)
    if not pairs:
        return 0.0, "This question has no pairs configured."
    payload = _payload(submission)
    given = payload if isinstance(payload, dict) else {}
    hits = 0
    for pair in pairs:
        chosen = given.get(pair["uid"])
        if isinstance(chosen, str) and _normalize(chosen, False) == _normalize(
            pair.get("match_value") or "", False
        ):
            hits += 1
    score = hits / len(pairs)
    return score, f"{hits} of {len(pairs)} pairs matched."


_GRADERS = {
    "single_choice": _grade_single_choice,
    "multiple_choice": _grade_multiple_choice,
    "true_false": _grade_true_false,
    "fill_blank": _grade_fill_blank,
    "numeric": _grade_numeric,
    "ordering": _grade_ordering,
    "matching": _grade_matching,
}
