"""The test set (20 questions with known answers), four graders, the compare step and the Wilson interval.

Python standard library only. The questions are written for toy.py's help page; each has the right answer written
down first and a check that a program can run on the toy's answer.
"""
import math
import random
import re

from toy import answer

# kind: "contains" (every phrase in need appears as whole words, none in avoid), "numbers" (the numbers in the answer
# are exactly these), "unknown" (the answer says it does not know), "exact" (the answer is this sentence, ignoring
# capitals and punctuation).
QUESTIONS = (
    ("What time do you open on weekdays?", "9am to 6pm", "contains", {"need": ["9am", "6pm"]}),
    ("What are the opening hours on Saturday?", "10am to 4pm", "contains", {"need": ["10am", "4pm"], "avoid": ["9am"]}),
    ("How many days does a basic tune-up take?", "2 days", "numbers", {"numbers": [2]}),
    ("What does a full overhaul cost?", "$140", "contains", {"need": ["$140"]}),
    ("How much is the deposit on a rental?", "$100", "numbers", {"numbers": [100]}),
    ("What does a city bike cost to rent for a week?", "$60 a week", "contains", {"need": ["$60 a week"]}),
    ("How long is the warranty on repairs?", "90 days", "numbers", {"numbers": [90]}),
    ("When are the riding lessons?", "Saturdays at 11am", "contains", {"need": ["Saturdays", "11am"]}),
    ("Is the shop open on Sunday?", "No, closed", "exact", {"text": "Opening hours on Sunday: closed."}),
    ("What are your hours on Saturday and Sunday?", "10am to 4pm; closed Sunday", "contains", {"need": ["10am", "4pm", "closed"]}),
    ("What is the warranty on repairs and on a bike bought here?", "90 days; 2 years", "contains", {"need": ["90 day", "2 year"]}),
    ("How many months is the warranty on a bike bought here?", "24 months", "contains", {"need": ["24 month"]}),
    ("Do you deliver bikes?", "The page does not say", "unknown", {}),
    ("Do you sell helmets?", "The page does not say", "unknown", {}),
    ("Are you open on public holidays?", "The page does not say", "unknown", {}),
    ("Is there a student discount on a basic tune-up?", "The page does not say", "unknown", {}),
    ("Can I leave my bicycle by the door?", "Yes, free parking at the front door", "contains", {"need": ["parking"]}),
    ("What do I pay for a whole week?", "$60 a week", "contains", {"need": ["$60 a week"]}),
    ("Can I return a part after 40 days?", "No, only within 30 days", "contains", {"need": ["no", "30 days"]}),
    ("What does a bicycle rental cost for one day?", "$15 a day", "contains", {"need": ["$15 a day"]}),
)

UNKNOWN_PHRASES = ("i don't know", "i do not know", "does not say", "not sure")
NUMBER = re.compile(r"(?<![\w.])\d+(?:\.\d+)?(?!\w)")  # a whole number: "$60." gives 60, "9am" gives nothing
Z = 1.96  # the normal quantile for a 95% interval
WOBBLE_SEED, WOBBLE_RUNS, WOBBLE_CHANCE = 5, 5, 0.1


def norm(text):
    """Lower case, punctuation turned into spaces, one space between words ($ is kept)."""
    return " ".join(re.sub(r"[^a-z0-9$]+", " ", text.lower()).split())


def has(text, phrase):
    """True when phrase appears in text as whole words: "no" is not found in "I do not know"."""
    return f" {norm(phrase)} " in f" {norm(text)} "


def passes(question, text):
    """Grade one answer. question is one row of QUESTIONS."""
    _, _, kind, args = question
    if kind == "contains":
        return all(has(text, p) for p in args["need"]) and not any(has(text, p) for p in args.get("avoid", []))
    if kind == "numbers":
        return sorted({float(x) for x in NUMBER.findall(text)}) == sorted({float(n) for n in args["numbers"]})
    if kind == "unknown":
        return any(has(text, p) for p in UNKNOWN_PHRASES)
    if kind == "exact":
        return norm(text) == norm(args["text"])
    raise ValueError(kind)


def describe(question):
    """One line saying what the check looks for (the widget and the lesson's table show it)."""
    _, _, kind, args = question
    if kind == "contains":
        need = [f"“{p}”" for p in args["need"]]
        return "Answer has " + ", ".join(need[:-1]) + (" and " if len(need) > 1 else "") + need[-1] + "".join(f", not “{p}”" for p in args.get("avoid", []))
    if kind == "numbers":
        return "The numbers in the answer are exactly " + " and ".join(str(n) for n in args["numbers"])
    if kind == "unknown":
        return "Answer says it does not know"
    return f"Answer is exactly “{args['text']}”"


def wobble(seed=WOBBLE_SEED, runs=WOBBLE_RUNS, n=len(QUESTIONS), chance=WOBBLE_CHANCE):
    """runs x n table of True/False: in this run, this question's result comes out the other way."""
    rng = random.Random(seed)
    return [[rng.random() < chance for _ in range(n)] for _ in range(runs)]


def results(d, run=None):
    """[True/False per question] under instructions d. With run set, apply that row of the wobble table."""
    marks = [passes(q, answer(q[0], d)) for q in QUESTIONS]
    if run is None:
        return marks
    return [m != flip for m, flip in zip(marks, wobble()[run])]


def compare(before, after):
    """Per question: "fixed", "broke", "pass" (still passing) or "fail" (still failing)."""
    return [("fixed" if not b else "pass") if a else ("broke" if b else "fail") for b, a in zip(before, after)]


def wilson(passed, n, z=Z):
    """Wilson score interval for a proportion: (low, high) as fractions."""
    p = passed / n
    centre = p + z * z / (2 * n)
    spread = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
    shrink = 1 + z * z / n
    return (centre - spread) / shrink, (centre + spread) / shrink
