"""Guards for leak_sim.py: the counts the lesson quotes, and the two promises it makes (the user in the key stops every leak and
every timing signal)."""
import random
import re
from pathlib import Path

from leak_sim import (EXPERIMENTS, GUESSES, INSTRUCTION, NOTE, PUBLIC, QUESTIONS, QUEUE_MS, RIGHT, SEED, SEQUENCE, TAIL, TRIES, USERS,
                      PromptCache, answer, cache_key, ideal_waits, percent, prompt, question, simulate, timing, totals, wait_ms)

LESSON = Path(__file__).resolve().parent.parent


def test_the_lessons_counts():
    """Guards a table the lesson quotes drifting: 6 leaks in 10 hits with the shared key, none in 5 hits with the user in it. Also guards
    prose and figures that drift from the code: the numbers the lesson quotes from `show_numbers.py`, in the sentence, table row or figure label that
    quotes it, is rebuilt here from the code's results and must appear in lesson.md or in the figures' text."""
    shared, per_user = simulate(False), simulate(True)
    assert totals(shared) == {"requests": 14, "hits": 10, "leaks": 6, "rate": 71}
    assert totals(per_user) == {"requests": 14, "hits": 5, "leaks": 0, "rate": 36}

    lesson = (LESSON / "lesson.md").read_text(encoding="utf-8")
    figures = {f.name: " ".join(re.findall(r"<text[^>]*>([^<]*)</text>", f.read_text(encoding="utf-8"))) for f in (LESSON / "figs").glob("*.svg")}
    ts, tu, n = totals(shared), totals(per_user), len(SEQUENCE)
    right_shared, right_user = sum(s.hit and not s.leak for s in shared), sum(s.hit and not s.leak for s in per_user)
    given_up = [s.n for s, p in zip(shared, per_user) if s.hit and not s.leak and not p.hit]
    won = [s.n for s, p in zip(shared, per_user) if s.leak and p.hit]
    assert len(USERS) == 3 and len(QUESTIONS) == 4 and given_up == [4] and won == [12, 14]
    tokens = INSTRUCTION + NOTE + TAIL
    hit_ms, miss_ms, block_ms = wait_ms(tokens, INSTRUCTION + NOTE), wait_ms(tokens, 0), wait_ms(tokens, INSTRUCTION)
    sh, us = timing(False), timing(True)
    chance, gap, spread = percent(1, len(GUESSES)), miss_ms - hit_ms, QUEUE_MS / TRIES ** 0.5
    label = lambda s: "LEAK " + str(s.written_by) if s.leak else f"hit {s.written_by}" if s.hit else "miss"
    short = ["order", "street", "hours", "gift"]
    a, b = USERS[0], USERS[1]

    quoted = [
        # the opening paragraph, the explanation of each key, and the timing section
        f"In a toy run of {n} requests from three made-up customers, the shelf answered {ts['hits']} of them without asking the bot, "
        f"and {ts['leaks']} of those answers went to the wrong customer",
        f"Over the {n} requests, this key gave {ts['hits']} hits, {ts['leaks']} of them leaks, a hit rate (the share of requests the shelf answered) of {ts['rate']}%",
        f"The same {n} requests give {tu['hits']} hits, no leaks and a hit rate of {tu['rate']}%",
        f"In the toy a hit waits {hit_ms:.1f} milliseconds (ms) at best and a miss {miss_ms:.1f} ms",
        f"the quickest of three guesses is the real one {sh['one']}% of the time, and {sh['many']}% after averaging {TRIES} waits each. Chance is {chance}%",
        f"every guess waits the same: {us['many']}% either way",
        # Go deeper: the counts
        f"The {n} requests come in a fixed order",
        f"With the shared key the {n} requests give {ts['hits']} hits, {ts['leaks']} leaks and a hit rate of {ts['rate']}%. "
        f"With the user in the key they give {tu['hits']} hits, {tu['leaks']} leaks and {tu['rate']}%",
        f"because {ts['leaks']} of its {ts['hits']} hits were wrong",
        f"in this sequence the user key does not lose: {right_user} against {right_shared}",
        f"It gives up request {given_up[0]}, the harmless hours hit, and wins requests {won[0]} and {won[1]}, which were leaks under the shared key",
        # Go deeper: the timing toy
        f"Each prompt has {INSTRUCTION} tokens that start every request of the shop's bot, {NOTE} tokens of the user's note and {TAIL} tokens of the new question: {tokens:,} in all",
        f"the server stored the first {INSTRUCTION + NOTE:,} tokens",
        f"matches only the {INSTRUCTION}-token instruction",
        f"so all {tokens:,} tokens are read",
        f"{hit_ms:.1f} ms for {TAIL} tokens and {miss_ms:.1f} ms for {tokens:,}",
        f"B's guesses all read {tokens:,}",
        f"with a mean of {QUEUE_MS:g} ms",
        f"One experiment draws {TRIES} waits for each guess",
        f"over {EXPERIMENTS:,} experiments, with the fixed seed {SEED}",
        *(f"| {g} | {round(x)} | {round(y)} |" for g, x, y in zip(GUESSES, sh["average"], us["average"])),
        f"| One try | {sh['one']}% | {us['one']}% |", f"| {TRIES} tries | {sh['many']}% | {us['many']}% |",
        f"The gap between a hit and a miss is {gap:.1f} ms, and the delay has a mean and a spread of about {QUEUE_MS:g} ms",
        f"so {TRIES} tries cut the spread to about {spread:.0f} ms",
        f"{us['many']}% is a blind guess",
        # What the toy leaves out: the block-storage sentence, with its own gap
        f"a wrong guess would still reuse the shared {INSTRUCTION}-token instruction and read only {NOTE + TAIL} tokens",
        f"The toy's model puts those {NOTE + TAIL} tokens at {block_ms:.1f} ms, like the right guess's {TAIL}",
        f"would then show a gap of {block_ms - hit_ms:.1f} ms, so the toy's {gap:.1f} ms gap comes from its whole-or-nothing rule",
        # the 14 request rows
        *(f"| {x.n}. {x.user}: {short[q]} | {label(x)} | {label(y)} |" for (_, q), x, y in zip(SEQUENCE, shared, per_user)),
        # Proof it works, and the Check-yourself answers
        f"{n}, {ts['hits']}, {ts['leaks']} and {ts['rate']}% with the shared key, and {n}, {tu['hits']}, {tu['leaks']} and {tu['rate']}% with the user in it",
        f"{sh['one']}% and {sh['many']}% shared, {us['one']}% and {us['many']}% salted",
        f"the hit rate falls from {ts['rate']}% to {tu['rate']}% when the user goes in the key",
        f"of the shared key's {ts['hits']} hits were leaks, so only {right_shared} were right. The user key has {tu['hits']} hits and all are right: "
        f"it gives up one harmless hit (request {given_up[0]}) and wins two (requests {won[0]} and {won[1]})",
        f"one try per guess finds the real street {sh['one']}% of the time and the average of {TRIES} finds it {sh['many']}%. With the user in the key both give {us['one']}%",
        f"The real guess waits {gap:.1f} ms less, but the random delay of about {QUEUE_MS:g} ms hides that in one try",
        f"Averaging {TRIES} waits cuts the noise to about {spread:.0f} ms",
    ]
    missing = [text for text in quoted if text not in lesson]
    assert not missing, missing

    # the figures: the key, the stored answer and the hit in the two cache figures, and the bars and their numbers in the wait figure
    text_a = question(a, 0)
    in_figs = [
        ("shared-shelf.svg", f"1. User {a.name} asks: {text_a}"), ("shared-shelf.svg", f"2. User {b.name} asks: {question(b, 0)}"),
        ("shared-shelf.svg", f"Key: {cache_key(a, text_a, False)}"), ("shared-shelf.svg", f"Kept: {answer(a, 0)}"),
        ("shared-shelf.svg", f"Hit. {b.name} gets Order {a.order}, not {b.order}."),
        ("user-in-key.svg", f"Key: {cache_key(a, text_a, True)}"), ("user-in-key.svg", f"Key: {cache_key(b, question(b, 0), True)}"),
        ("user-in-key.svg", f"Kept: {answer(a, 0)}"), ("user-in-key.svg", f"Kept: {answer(b, 0)}"),
        ("wait-by-guess.svg", f"Wait in ms: average of {TRIES} tries, {EXPERIMENTS:,} runs"),
        ("wait-by-guess.svg", " ".join(g + (" (real)" if i == RIGHT else "") for i, g in enumerate(GUESSES))),
    ]
    missing_figs = [(name, text) for name, text in in_figs if text not in figures[name]]
    assert not missing_figs, missing_figs
    waits_svg = (LESSON / "figs" / "wait-by-guess.svg").read_text(encoding="utf-8")
    averages = [round(w) for w in sh["average"] + us["average"]]
    assert [int(w) for w in re.findall(r">(\d+)</text>", waits_svg)] == averages
    assert [int(w) for w in re.findall(r'<rect x="190" [^>]*?width="(\d+)"', waits_svg)] == [round(w * 2.4) for w in averages]


def test_a_leak_is_another_users_private_fact():
    """Guards counting a leak wrongly: a leak must be a hit that hands one user another user's order code or street,
    and the opening-hours answer, the same for everyone, must never count."""
    by_name = {u.name: u for u in USERS}
    rng = random.Random(7)
    sequence = [(rng.choice(USERS).name, rng.randrange(len(QUESTIONS))) for _ in range(300)]
    steps = simulate(False, sequence)
    leaks = [s for s in steps if s.leak]
    assert len(leaks) > 100
    for s in leaks:
        assert s.hit and s.served != s.right
        others = [u for u in USERS if u.name != s.user]
        assert any(u.order in s.served or u.street in s.served for u in others)
        assert by_name[s.user].order not in s.served and by_name[s.user].street not in s.served
    asked_public = [i for i, (_, q) in enumerate(sequence) if q == PUBLIC]
    assert asked_public and not any(steps[i].leak for i in asked_public)


def test_the_user_in_the_key_removes_every_leak_and_lowers_the_hit_count():
    """Guards the lesson's claim for any request order, not only the fixed one: no leak with the user in the key, and never more hits."""
    rng = random.Random(11)
    for _ in range(40):
        sequence = [(rng.choice(USERS).name, rng.randrange(len(QUESTIONS))) for _ in range(rng.randrange(1, 60))]
        shared, per_user = totals(simulate(False, sequence)), totals(simulate(True, sequence))
        assert per_user["leaks"] == 0 and per_user["hits"] <= shared["hits"]
    assert all(s.served == s.right for s in simulate(True))


def test_a_stored_start_is_reused_whole_or_not_at_all_and_only_under_the_same_salt():
    """Guards the timing toy's cache rules (lesson 8's rule, plus the salt): a wrong note reuses nothing, a right note under another salt reuses nothing."""
    cache = PromptCache()
    cache.store("A", prompt(GUESSES[RIGHT])[:INSTRUCTION + NOTE])
    assert cache.reused("A", prompt(GUESSES[RIGHT])) == INSTRUCTION + NOTE
    assert cache.reused("A", prompt(GUESSES[0])) == 0          # shares the instruction, but not the whole stored start
    assert cache.reused("B", prompt(GUESSES[RIGHT])) == 0


def test_the_wait_shows_a_hit_only_without_the_user_in_the_key():
    """Guards the timing view: shared, only the right guess is quick; salted, all three guesses wait exactly the same."""
    shared, salted = ideal_waits(False), ideal_waits(True)
    miss = wait_ms(INSTRUCTION + NOTE + 20, 0)
    assert shared[RIGHT] < 10 and miss > 50 and [w for i, w in enumerate(shared) if i != RIGHT] == [miss, miss]
    assert salted == [miss, miss, miss]


def test_noise_hides_one_try_and_many_tries_find_the_guess_unless_the_user_is_in_the_key():
    """Guards the lesson's numbers: shared key, the fastest of one try is right about two times in three and of 25 tries almost always;
    with the user in the key both stay at chance, one in three. Also pins the fixed seed's outcome."""
    shared, salted = timing(False), timing(True)
    assert (shared["one"], shared["many"]) == (68, 99)
    assert (salted["one"], salted["many"]) == (33, 33)
    assert timing(False) == shared
    assert max(salted["average"]) - min(salted["average"]) < 1 and shared["average"][1] < min(shared["average"][0], shared["average"][2]) - 30
