# retoor <retoor@molodetz.nl>

import json
from datetime import datetime, timedelta, timezone

import pytest

from devplacepy.services.quiz import scoring

OPTIONS = [
    {"uid": "o0", "position": 0, "label": "L0", "match_value": "M0", "is_correct": 1},
    {"uid": "o1", "position": 1, "label": "L1", "match_value": "M1", "is_correct": 1},
    {"uid": "o2", "position": 2, "label": "L2", "match_value": "M2", "is_correct": 0},
    {"uid": "o3", "position": 3, "label": "L3", "match_value": "M3", "is_correct": 0},
]

NOW = datetime(2026, 7, 25, 12, 0, 0, tzinfo=timezone.utc)


def question(kind, **extra):
    base = {
        "uid": "q1",
        "kind": kind,
        "points": 3,
        "correct_boolean": 0,
        "numeric_value": 0.0,
        "numeric_tolerance": 0.0,
        "case_sensitive": 0,
    }
    base.update(extra)
    return base


def test_question_kinds_are_the_eight_documented_keys():
    assert scoring.KIND_KEYS == (
        "single_choice",
        "multiple_choice",
        "true_false",
        "free_text",
        "fill_blank",
        "numeric",
        "ordering",
        "matching",
    )


def test_only_free_text_is_ai_graded():
    ai = [kind.key for kind in scoring.QUESTION_KINDS if kind.graded_by == "ai"]
    assert ai == ["free_text"]


def test_awarded_points_is_monotonic_and_bounded():
    for points in (1, 7, 100):
        previous = -1.0
        for step in range(0, 201):
            awarded = scoring.awarded_points(points, step / 200.0)
            assert 0.0 <= awarded <= points
            assert awarded >= previous
            previous = awarded


def test_awarded_points_clamps_outside_the_unit_interval():
    assert scoring.awarded_points(5, 4.0) == 5
    assert scoring.awarded_points(5, -4.0) == 0.0


def test_score_percent_stays_within_bounds():
    for max_points in (0, 1, 14, 199):
        for score_points in (0, 1, 14, 400):
            assert 0.0 <= scoring.score_percent(score_points, max_points) <= 100.0


def test_score_percent_is_zero_without_max_points():
    assert scoring.score_percent(10, 0) == 0.0


def test_score_percent_is_exactly_full_at_max():
    assert scoring.score_percent(14, 14) == 100.0


def test_passed_needs_a_pass_mark():
    assert scoring.passed(100.0, 0) is False
    assert scoring.passed(70.0, 70) is True
    assert scoring.passed(69.9, 70) is False


def test_single_choice_grades_the_correct_option():
    result = scoring.grade_answer(question("single_choice"), OPTIONS, {"option_uids": ["o0"]})
    assert result.score == 1.0 and result.is_correct and result.graded_by == "auto"


def test_single_choice_rejects_more_than_one_pick():
    result = scoring.grade_answer(question("single_choice"), OPTIONS, {"option_uids": ["o0", "o1"]})
    assert result.score == 0.0


def test_single_choice_rejects_an_unknown_option():
    result = scoring.grade_answer(question("single_choice"), OPTIONS, {"option_uids": ["nope"]})
    assert result.score == 0.0


def test_multiple_choice_gives_partial_credit():
    result = scoring.grade_answer(question("multiple_choice"), OPTIONS, {"option_uids": ["o0"]})
    assert result.score == 0.5 and not result.is_correct


def test_multiple_choice_penalises_wrong_picks_but_never_below_zero():
    result = scoring.grade_answer(
        question("multiple_choice"), OPTIONS, {"option_uids": ["o2", "o3"]}
    )
    assert result.score == 0.0


def test_multiple_choice_is_correct_only_on_the_exact_set():
    result = scoring.grade_answer(
        question("multiple_choice"), OPTIONS, {"option_uids": ["o0", "o1"]}
    )
    assert result.score == 1.0 and result.is_correct


def test_multiple_choice_ignores_duplicate_picks():
    result = scoring.grade_answer(
        question("multiple_choice"), OPTIONS, {"option_uids": ["o0", "o0", "o1"]}
    )
    assert result.score == 1.0


def test_true_false_matches_the_stored_boolean():
    prompt = question("true_false", correct_boolean=1)
    assert scoring.grade_answer(prompt, [], {"answer_text": "true"}).score == 1.0
    assert scoring.grade_answer(prompt, [], {"answer_text": "false"}).score == 0.0


def test_true_false_without_an_answer_scores_zero():
    assert scoring.grade_answer(question("true_false"), [], {"answer_text": ""}).score == 0.0


def test_free_text_raises_for_the_ai_grader():
    with pytest.raises(scoring.NeedsAiGrading):
        scoring.grade_answer(question("free_text"), [], {"answer_text": "anything"})


def test_numeric_accepts_within_tolerance():
    prompt = question("numeric", numeric_value=10.0, numeric_tolerance=0.5)
    assert scoring.grade_answer(prompt, [], {"answer_text": "10.4"}).score == 1.0
    assert scoring.grade_answer(prompt, [], {"answer_text": "10.6"}).score == 0.0


def test_numeric_accepts_a_comma_decimal_separator():
    prompt = question("numeric", numeric_value=1.5, numeric_tolerance=0.0)
    assert scoring.grade_answer(prompt, [], {"answer_text": "1,5"}).score == 1.0


def test_numeric_rejects_a_non_number():
    prompt = question("numeric", numeric_value=1.0)
    assert scoring.grade_answer(prompt, [], {"answer_text": "ten"}).score == 0.0


def test_fill_blank_scores_per_blank():
    prompt = question("fill_blank")
    payload = json.dumps(["M0", "wrong", "M2", "M3"])
    result = scoring.grade_answer(prompt, OPTIONS, {"answer_text": payload})
    assert result.score == 0.75


def test_fill_blank_normalizes_whitespace_and_case_by_default():
    prompt = question("fill_blank")
    payload = json.dumps(["  m0 ", "M1", "M2", "M3"])
    assert scoring.grade_answer(prompt, OPTIONS, {"answer_text": payload}).score == 1.0


def test_fill_blank_honours_case_sensitivity():
    prompt = question("fill_blank", case_sensitive=1)
    payload = json.dumps(["m0", "M1", "M2", "M3"])
    assert scoring.grade_answer(prompt, OPTIONS, {"answer_text": payload}).score == 0.75


def test_ordering_scores_the_longest_correct_prefix():
    prompt = question("ordering")
    order = ["o0", "o1", "o3", "o2"]
    assert scoring.grade_answer(prompt, OPTIONS, {"option_uids": order}).score == 0.5


def test_ordering_is_correct_on_the_exact_sequence():
    prompt = question("ordering")
    order = ["o0", "o1", "o2", "o3"]
    result = scoring.grade_answer(prompt, OPTIONS, {"option_uids": order})
    assert result.score == 1.0 and result.is_correct


def test_matching_scores_per_pair():
    prompt = question("matching")
    payload = json.dumps({"o0": "M0", "o1": "M1", "o2": "wrong", "o3": "wrong"})
    assert scoring.grade_answer(prompt, OPTIONS, {"answer_text": payload}).score == 0.5


def test_every_kind_survives_a_malformed_submission():
    malformed = [{}, {"answer_text": None, "option_uids": None}, {"answer_text": "junk"}]
    for kind in scoring.KIND_KEYS:
        if kind == "free_text":
            continue
        for submission in malformed:
            result = scoring.grade_answer(question(kind), OPTIONS, submission)
            assert 0.0 <= result.score <= 1.0


def test_an_unknown_kind_scores_zero_instead_of_raising():
    assert scoring.grade_answer(question("telepathy"), OPTIONS, {}).score == 0.0


def test_question_order_is_a_stable_permutation():
    uids = [f"u{index}" for index in range(9)]
    order = scoring.question_order(uids, True, "attempt-1")
    assert sorted(order) == sorted(uids)
    assert order == scoring.question_order(uids, True, "attempt-1")


def test_question_order_differs_per_seed():
    uids = [f"u{index}" for index in range(12)]
    orders = {tuple(scoring.question_order(uids, True, f"a{index}")) for index in range(40)}
    assert len(orders) > 1


def test_question_order_is_untouched_without_shuffle():
    uids = ["a", "b", "c"]
    assert scoring.question_order(uids, False, "seed") == uids


def test_option_order_is_a_stable_permutation():
    uids = ["o0", "o1", "o2", "o3"]
    order = scoring.option_order(uids, True, "answer-1")
    assert sorted(order) == sorted(uids)
    assert order == scoring.option_order(uids, True, "answer-1")


def test_token_overlap_scores_a_perfect_match():
    assert scoring.token_overlap_score("write ahead logging", "write ahead logging") == 1.0


def test_token_overlap_scores_an_empty_answer_zero():
    assert scoring.token_overlap_score("write ahead logging", "") == 0.0


def test_token_overlap_stays_within_bounds():
    score = scoring.token_overlap_score("alpha beta gamma", "alpha delta")
    assert 0.0 < score < 1.0


def test_attempt_expires_at_is_empty_without_a_limit():
    assert scoring.attempt_expires_at(NOW.isoformat(), 0) == ""


def test_attempt_expires_at_adds_the_limit():
    expires = scoring.attempt_expires_at(NOW.isoformat(), 900)
    assert expires == (NOW + timedelta(seconds=900)).isoformat()


def test_is_expired_reads_the_stored_deadline():
    expires = scoring.attempt_expires_at(NOW.isoformat(), 60)
    assert scoring.is_expired(expires, NOW) is False
    assert scoring.is_expired(expires, NOW + timedelta(seconds=61)) is True


def test_is_expired_is_false_without_a_deadline():
    assert scoring.is_expired("", NOW) is False


def test_remaining_seconds_never_goes_negative():
    expires = scoring.attempt_expires_at(NOW.isoformat(), 60)
    assert scoring.remaining_seconds(expires, NOW) == 60
    assert scoring.remaining_seconds(expires, NOW + timedelta(seconds=600)) == 0


def test_fallback_result_is_stamped_and_explains_itself():
    result = scoring.fallback_result("write ahead logging", "write ahead logging", "gateway down")
    assert result.graded_by == "fallback"
    assert result.confidence == 0.0
    assert "unavailable" in result.feedback
