"""Tests for sim.py. The three rules with no random part give one fixed answer, so the lesson's numbers for them are exact.
Jitter is random, so its numbers are checked over many seeds, not one."""
import re
import statistics
import textwrap
from pathlib import Path

import pytest

from sim import BASE, CAP, CLIENTS, FIXED, LIMIT, RULES, SEED, TRIES, random_numbers, simulate, wait_after

SEEDS = range(1, 1001)
RULE_IDS = [rule for rule, _ in RULES]
NO_RANDOM = ("once", "fixed", "backoff")


def test_the_generator_gives_the_same_numbers_everywhere():
    """Guards the seed: the widget, the lesson's numbers and these tests all depend on this exact sequence."""
    gen = random_numbers(1)
    assert [round(next(gen), 6) for _ in range(5)] == [0.627074, 0.002736, 0.527447, 0.981051, 0.968378]
    assert random_numbers(2 ** 32 + 1).__next__() == random_numbers(1).__next__()  # the seed is a 32-bit number


def test_the_numbers_are_in_range_and_spread_out():
    gen = random_numbers(7)
    values = [next(gen) for _ in range(10000)]
    assert min(values) >= 0 and max(values) < 1 and abs(statistics.mean(values) - 0.5) < 0.02


@pytest.mark.parametrize("refusals, expected", [(1, 4), (2, 8), (3, 16), (4, 16), (5, 16)])
def test_backoff_doubles_until_the_cap(refusals, expected):
    assert wait_after("backoff", refusals, None) == expected
    assert (BASE, CAP) == (4, 16)  # the lesson quotes these


def test_once_and_fixed_ignore_how_many_refusals_came_before():
    assert [wait_after("once", k, None) for k in range(1, 6)] == [1] * 5
    assert [wait_after("fixed", k, None) for k in range(1, 6)] == [FIXED] * 5 == [4] * 5


def test_jitter_waits_are_whole_ticks_from_one_up_to_the_backoff_wait_and_reach_both_ends():
    """Guards an off-by-one: a wait of 0 would retry in the same tick, and a wait that never reaches the top is not full jitter."""
    for refusals in range(1, TRIES):
        gen = random_numbers(refusals)
        longest = wait_after("backoff", refusals, None)
        waits = [wait_after("jitter", refusals, gen.__next__) for _ in range(2000)]
        assert min(waits) == 1 and max(waits) == longest and all(isinstance(w, int) for w in waits)
        assert abs(statistics.mean(waits) - (longest + 1) / 2) < 0.3  # the lesson says the average wait is (longest + 1) / 2


def test_the_three_rules_with_no_random_part_give_the_same_run_for_every_seed():
    """The lesson says this, so only jitter needs many seeds."""
    for rule in NO_RANDOM:
        assert all(simulate(rule, seed) == simulate(rule) for seed in SEEDS)


def test_the_lessons_one_run_for_the_rules_with_no_random_part():
    runs = {rule: simulate(rule) for rule in NO_RANDOM}
    assert {rule: (run.finished, run.refused_calls, run.gave_up, run.busiest_retry_tick) for rule, run in runs.items()} == {
        "once": (5, 57, 2, 17), "fixed": (20, 57, 2, 17), "backoff": (60, 57, 2, 17)}
    assert [s + r for s, r in zip(runs["once"].served, runs["once"].refused)] == [20, 17, 14, 11, 8, 5]
    idle = lambda run: sum(1 for s, r in zip(run.served, run.refused) if s + r == 0)
    assert (idle(runs["once"]), idle(runs["fixed"]), idle(runs["backoff"])) == (0, 15, 55)  # backoff leaves the server idle on 55 of 61 ticks


def test_the_lessons_one_jitter_run():
    run = simulate("jitter", SEED)
    assert (run.finished, run.refused_calls, run.gave_up, run.busiest_retry_tick) == (14, 25, 0, 7)
    assert [s + r for s, r in zip(run.served, run.refused)] == [20, 4, 7, 4, 5, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1]


def test_waiting_longer_does_not_cut_refused_calls_but_jitter_does_on_every_seed():
    """The claim behind the whole lesson, checked on 1,000 seeds so it is not one lucky run."""
    waste = {rule: {simulate(rule, seed).refused_calls for seed in SEEDS} for rule in RULE_IDS}
    assert waste["once"] == waste["fixed"] == waste["backoff"] == {57}
    assert max(waste["jitter"]) < 57 and statistics.median(waste["jitter"]) < 57 * 0.5


def test_jitter_serves_every_client_and_shrinks_the_biggest_retry_wave_on_every_seed():
    runs = [simulate("jitter", seed) for seed in SEEDS]
    assert all(run.gave_up == 0 for run in runs)
    assert max(run.busiest_retry_tick for run in runs) < simulate("once").busiest_retry_tick == 17


def test_retrying_at_once_finishes_first_only_because_it_gives_up_on_two_clients():
    """The honest cost of jitter, stated in the lesson: it never finishes before retry-at-once, in this toy."""
    once = simulate("once")
    assert once.gave_up == 2 and sum(once.served) == CLIENTS - 2
    assert all(simulate("jitter", seed).finished > once.finished for seed in SEEDS)


def test_jitter_usually_finishes_before_the_fixed_wait_and_always_before_plain_backoff():
    runs = [simulate("jitter", seed) for seed in SEEDS]
    assert statistics.median(run.finished for run in runs) < simulate("fixed").finished
    assert max(run.finished for run in runs) < simulate("backoff").finished


@pytest.mark.parametrize("rule", RULE_IDS)
def test_every_call_is_counted_once_and_the_server_never_answers_more_than_its_limit(rule):
    """Guards the bookkeeping: served + gave up is every client, no tick answers over the limit, nobody calls over their tries."""
    for seed in range(1, 31):
        run = simulate(rule, seed)
        assert sum(run.served) + run.gave_up == CLIENTS
        assert max(run.served) <= LIMIT and len(run.served) == len(run.refused)
        assert sum(run.served) + run.refused_calls <= CLIENTS * TRIES
        assert all(s == LIMIT for s, r in zip(run.served, run.refused) if r > 0)  # a refusal only happens when the server is full


@pytest.mark.parametrize("rule", RULE_IDS)
def test_no_refusals_when_the_server_can_answer_everyone_at_once(rule):
    run = simulate(rule, clients=10, limit=10)
    assert (run.finished, run.refused_calls, run.gave_up, run.served) == (0, 0, 0, (10,))


def test_a_small_crowd_by_hand_matches_the_lessons_table():
    once = simulate("once", clients=5, limit=2, tries=4)
    assert (once.served, once.refused, once.gave_up) == ((2, 2, 1), (3, 1, 0), 0)
    backoff = simulate("backoff", clients=5, limit=2, tries=4)
    assert backoff.served == (2, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 1)
    assert backoff.refused == (3, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0) and backoff.gave_up == 0
    # seed 1: clients 2, 3 and 4 are refused in that order and draw 0.627074, 0.002736, 0.527447, so they wait 3, 1 and 3 ticks
    jitter = simulate("jitter", 1, clients=5, limit=2, tries=4)
    assert (jitter.served, jitter.refused, jitter.gave_up) == ((2, 1, 0, 2), (3, 0, 0, 0), 0)


def test_a_client_gives_up_after_its_last_try_and_not_before():
    run = simulate("once", clients=4, limit=1, tries=2)
    assert (run.served, run.refused, run.gave_up) == ((1, 1), (3, 2), 2)


def test_the_run_is_the_same_every_time_for_one_seed():
    assert simulate("jitter", 5) == simulate("jitter", 5) != simulate("jitter", 6)


def test_the_module_uses_only_the_standard_library_and_no_network():
    source = Path(__file__).with_name("sim.py").read_text()
    assert set(re.findall(r"^(?:from|import) (\w+)", source, flags=re.M)) == {"dataclasses"}


def test_every_python_block_in_the_lesson_is_a_verbatim_excerpt_of_the_source():
    """The lesson quotes this file. If the code changes and the lesson does not, the reader sees code that was never tested."""
    lesson = (Path(__file__).parent.parent / "lesson.md").read_text()
    source = Path(__file__).with_name("sim.py").read_text().splitlines()
    blocks = re.findall(r"^```python\n(.*?)^```", lesson, flags=re.S | re.M)
    assert blocks, "the lesson has no python blocks to check"
    for block in blocks:
        lines = block.rstrip("\n").splitlines()
        windows = (textwrap.dedent("\n".join(source[i:i + len(lines)])) for i in range(len(source) - len(lines) + 1))
        assert textwrap.dedent("\n".join(lines)) in windows, f"not in sim.py:\n{block}"
