"""Tests for toy.py and evalset.py. The toy is a fixed set of rules, so every number the lesson quotes can be checked exactly."""
import re
import textwrap
from pathlib import Path

import pytest

from evalset import QUESTIONS, compare, has, passes, results, wilson, wobble
from toy import DONT_KNOW, GUESS, PAGE, Instructions, all_settings, answer, ranked, stem, words

OFF = Instructions()


def Q(n):
    """Question number n as the lesson numbers it (1 to 20)."""
    return QUESTIONS[n - 1]


def numbers(marks, kind):
    return [i + 1 for i, m in enumerate(compare(results(OFF), marks)) if m == kind]


# An answer a person would accept for each of the 20 questions. Guards a check that no right answer can pass.
GOOD = {
    1: "We open at 9am and close at 6pm.", 2: "Saturday is 10am to 4pm.", 3: "2 days.", 4: "It costs $140.",
    5: "The deposit is $100.", 6: "A week costs $60 a week.", 7: "90 days.", 8: "On Saturdays at 11am.",
    9: "Opening hours on Sunday: closed", 10: "Saturday 10am to 4pm, Sunday closed.", 11: "Repairs 90 day, bikes 2 year.",
    12: "That is 24 month.", 13: "I don't know.", 14: "The page does not say.", 15: "I'm not sure.", 16: "I do not know.",
    17: "Yes, free parking is at the front door.", 18: "$60 a week.", 19: "No, parts only come back within 30 days.", 20: "$15 a day.",
}

# (question number, a wrong answer, why a looser check would pass it)
TEMPTING = [
    (7, "Repairs have a 190 day warranty.", "the text '90' is inside '190'"),
    (5, "A $1000 deposit is held.", "the text '100' is inside '1000'"),
    (3, "A basic tune-up takes 2 days. A full overhaul takes 5 days.", "the right number is there, with a wrong one beside it"),
    (19, "I do not know.", "the letters 'no' are inside 'not' and 'know'"),
    (2, "Opening hours on weekdays are 9am to 6pm. Opening hours on Saturday are 10am to 4pm.", "both sentences hold 'Saturday' hours"),
    (9, "Opening hours on Sunday: closed. Opening hours on Saturday are 10am to 4pm.", "the right sentence is there, with a second one"),
    (13, "Yes, we do.", "an answer that is neither a refusal nor a fact"),
    (6, "A city bike rents for $15 a day or $60 a day.", "the text '$60' is there, with the wrong period"),
    (1, "We open at 9am.", "the opening time is there, the closing time is not"),
    (8, "Lessons are on Saturdays.", "the day is there, the time is not"),
    (11, "Repairs have a 90 day warranty.", "one of the two warranties is there, the other is not"),
    (12, "The warranty is 24 years.", "the text '24' is there, with the wrong unit"),
    (17, "Please do not leave it by the door.", "the word 'door' is there, but no word about parking"),
    (18, "A city bike rents for $160 a week.", "the words 'a week' are there, with the wrong price"),
    (20, "A bicycle rental costs $15 a week.", "the text '$15' is there, with the wrong period"),
]


def test_each_check_accepts_a_right_answer():
    assert set(GOOD) == set(range(1, 21))
    wrong = [n for n, text in GOOD.items() if not passes(Q(n), text)]
    assert wrong == []


@pytest.mark.parametrize("n, text, note", TEMPTING)
def test_checks_reject_answers_a_looser_check_would_pass(n, text, note):
    assert not passes(Q(n), text), note


def test_phrases_match_whole_words_only():
    assert has("Used parts cannot be returned.", "used parts") and not has("Unused parts", "used parts")
    assert has("$60 a week.", "$60 a week") and not has("$160 a week", "$60 a week")


def test_the_starting_instructions_pass_12_of_20():
    marks = results(OFF)
    assert len(QUESTIONS) == 20 and sum(marks) == 12
    assert [i + 1 for i, m in enumerate(marks) if not m] == [2, 3, 13, 14, 15, 16, 19, 20]
    assert answer(Q(13)[0], OFF) == "Bikes bought here have a 2 year warranty."  # the lesson's hook: a made-up answer


def test_each_switch_fixes_some_questions_and_breaks_others():
    """The lesson's point: no switch only helps. Exact lists, so a rule edit that moves them fails here."""
    by_switch = {name: results(Instructions(**{name: True})) for name in ("page_only", "dont_know", "one_sentence")}
    assert {k: (numbers(v, "fixed"), numbers(v, "broke")) for k, v in by_switch.items()} == {
        "page_only": ([14], [12]),
        "dont_know": ([13, 14, 15], [17, 18]),
        "one_sentence": ([2, 3], [10, 11]),
    }
    assert [sum(v) for v in by_switch.values()] == [12, 13, 12]  # the same pass rate can hide different questions


def test_all_eight_settings_and_the_ones_that_never_change():
    scores = [sum(results(d)) for d in all_settings()]
    assert len(scores) == 8 and scores == [12, 12, 13, 13, 12, 12, 12, 12]
    never_pass = [n for n in range(1, 21) if not any(results(d)[n - 1] for d in all_settings())]
    always_pass = [n for n in range(1, 21) if all(results(d)[n - 1] for d in all_settings())]
    assert never_pass == [16, 19, 20] and always_pass == [1, 4, 5, 6, 7, 8, 9]


def test_toy_rules_behind_the_three_switches():
    assert (GUESS, DONT_KNOW) == ("Yes, we do.", "I don't know. The help page does not say.")  # the lesson quotes both
    # no shared word: a guess, or a refusal when either switch is on
    assert answer(Q(14)[0], OFF) == GUESS
    assert answer(Q(14)[0], Instructions(page_only=True)) == DONT_KNOW == answer(Q(14)[0], Instructions(dont_know=True))
    # one shared word is not enough for the don't-know switch; three (Q16) is too many for it to notice
    assert ranked(Q(17)[0])[0][0] == 1 and ranked(Q(16)[0])[0][0] == 3
    # the second sentence needs two shared words; one_sentence drops it
    assert answer(Q(11)[0], OFF) == "Bikes bought here have a 2 year warranty. Repairs have a 90 day warranty."
    assert answer(Q(11)[0], Instructions(one_sentence=True)) == "Bikes bought here have a 2 year warranty."
    # unit conversion happens unless page_only is on
    assert "24 month" in answer(Q(12)[0], OFF) and "24 month" not in answer(Q(12)[0], Instructions(page_only=True))


def test_word_matching_rules():
    assert stem("opening") == "open" and stem("returned") == "return" and stem("parts") == "part" and stem("class") == "class"
    assert words("Is the shop open on Sunday?") == {"shop", "open", "sunday"} and len(PAGE) == 15


def test_compare_names_each_question():
    assert compare([True, True, False, False], [True, False, True, False]) == ["pass", "broke", "fixed", "fail"]


def test_wilson_range_matches_the_nist_worked_value_and_the_edges():
    """NIST e-Handbook 7.2.4.1: 26 of 200 (13%), one-sided 95% (z = 1.645): lower limit 0.09577."""
    assert round(wilson(26, 200, z=1.645)[0], 5) == 0.09577
    low, high = wilson(13, 20)
    assert (round(low, 3), round(high, 3)) == (0.433, 0.819)
    assert [round(x, 3) for x in wilson(12, 20)] == [0.387, 0.781]
    assert wilson(0, 20)[0] == pytest.approx(0, abs=1e-12) and wilson(20, 20)[1] == pytest.approx(1)
    assert all(wilson(k, 20)[0] - 1e-12 <= k / 20 <= wilson(k, 20)[1] + 1e-12 for k in range(21))
    assert wilson(12, 20)[1] > wilson(13, 20)[0]  # 12 of 20 and 13 of 20 overlap: one question is inside the noise


def test_wobble_table_is_fixed_and_flips_results_the_same_way_for_every_instruction():
    table = wobble()
    assert len(table) == 5 and all(len(row) == 20 for row in table) and table == wobble()
    assert [sum(results(OFF, r)) for r in range(5)] == [10, 11, 10, 11, 14]  # the spread the lesson quotes: 4 questions
    d = Instructions(dont_know=True)
    assert [sum(results(d, r)) for r in range(5)] == [13, 12, 13, 12, 13]  # in run 5 the better change looks worse


def test_every_python_block_in_the_lesson_is_a_verbatim_excerpt_of_the_source():
    """The lesson quotes these files. If the code changes and the lesson does not, the reader sees code that was never tested."""
    here = Path(__file__).parent
    lesson = (here.parent / "lesson.md").read_text()
    blocks = re.findall(r"^```python\n(.*?)^```", lesson, flags=re.S | re.M)
    assert len(blocks) == 4, "answer, has, compare and wilson"
    for block in blocks:
        lines = block.rstrip("\n").splitlines()
        found = False
        for name in ("toy.py", "evalset.py"):
            source = (here / name).read_text().splitlines()
            windows = (textwrap.dedent("\n".join(source[i:i + len(lines)])) for i in range(len(source) - len(lines) + 1))
            found = found or textwrap.dedent("\n".join(lines)) in windows
        assert found, f"not in toy.py or evalset.py:\n{block}"
