"""Tests on Lab 13's recorded run (02_fallbacks.py, October 2026): 120 support emails at 2 a second,
gpt-5.4-mini broken from 15 s to 40 s, gpt-5.4-nano as the backup. The breaker opens after 5 failed
emails in 10 s and tests mini again 5 s later.
Run: pytest test_breaker_practice.py"""
from breaker_practice import CircuitBreaker, CircuitOpen, fallback, seconds_per_failure, trip_time, typical_wait

# Each email's wait in seconds, sent → answered, in the hang outage. Email i was sent at i / 2 s.
BACKUP_SECONDS = [
    3.01, 1.25, 1.12, 1.1, 1.52, 1.01, 1.1, 1.96, 3.82, 3.77, 1.05, 1.28, 1.3, 2.34, 0.98, 1.33, 1.0, 1.04,
    0.97, 0.89, 1.03, 1.64, 1.14, 1.06, 1.03, 0.86, 0.86, 0.73, 1.08, 1.04, 18.9, 18.75, 18.1, 17.84, 18.02,
    17.44, 17.42, 17.41, 17.5, 17.25, 18.28, 17.42, 18.35, 17.33, 18.09, 17.64, 17.07, 17.29, 17.25, 17.11,
    17.35, 17.34, 17.63, 17.55, 17.66, 17.38, 17.46, 17.29, 12.4, 12.73, 12.4, 13.14, 12.53, 12.46, 12.34,
    12.44, 12.27, 12.52, 12.37, 12.55, 6.86, 6.51, 6.6, 6.61, 6.59, 6.45, 6.94, 6.58, 6.61, 6.59, 1.86, 1.39,
    1.24, 1.33, 1.17, 1.06, 1.17, 1.16, 1.08, 1.07, 1.22, 1.55, 1.31, 1.03, 1.54, 1.1, 1.06, 1.49, 1.35, 1.03,
    1.87, 1.02, 0.85, 1.18, 1.19, 1.58, 1.28, 1.89, 1.27, 1.88, 1.14, 1.52, 1.02, 1.07, 1.8, 3.44, 0.87, 1.77,
    1.54, 1.18,
]
BREAKER_SECONDS = [
    1.6, 1.22, 0.96, 0.98, 1.14, 1.08, 1.03, 0.97, 0.96, 1.27, 0.87, 1.27, 1.23, 1.22, 0.9, 1.1, 1.1, 1.11,
    1.08, 1.23, 1.09, 0.95, 1.01, 1.39, 0.89, 1.03, 1.18, 3.08, 1.22, 1.04, 17.63, 17.5, 17.46, 17.43, 17.57,
    17.91, 17.28, 17.53, 17.22, 17.35, 17.26, 17.47, 17.37, 17.45, 17.38, 17.15, 17.11, 17.46, 17.36, 17.18,
    17.79, 17.51, 17.37, 17.31, 17.5, 17.48, 17.7, 17.29, 12.36, 12.68, 12.49, 12.66, 12.37, 12.32, 14.42, 12.5,
    12.48, 0.85, 1.14, 0.85, 1.03, 0.92, 0.93, 0.88, 0.83, 1.88, 1.02, 1.08, 1.18, 0.97, 0.85, 1.06, 1.11, 1.15,
    1.02, 1.31, 1.5, 1.0, 0.85, 6.47, 1.09, 1.28, 1.46, 1.26, 1.23, 1.16, 0.87, 0.78, 0.75, 1.14, 1.12, 1.18,
    3.83, 1.07, 1.37, 1.09, 6.56, 1.04, 1.07, 1.4, 4.28, 1.11, 0.88, 1.04, 1.19, 1.27, 0.91, 1.12, 2.75, 1.17,
]
FAIL_FAST_SECONDS = [
    1.4, 1.54, 1.05, 1.19, 1.25, 1.05, 1.12, 1.13, 1.07, 1.1, 1.67, 1.88, 0.93, 1.18, 1.54, 1.0, 1.6, 1.21,
    1.73, 1.24, 1.8, 1.3, 1.14, 1.27, 1.12, 1.04, 1.29, 4.69, 1.01, 0.91, 4.25, 6.1, 4.35, 3.87, 4.33, 4.11,
    4.64, 4.3, 3.92, 4.23, 1.05, 1.51, 2.96, 1.14, 1.46, 2.69, 1.16, 1.25, 1.41, 0.97, 0.97, 1.0, 1.24, 1.14,
    1.13, 1.0, 3.9, 1.05, 1.18, 0.85, 0.8, 1.69, 1.01, 1.63, 1.08, 1.4, 0.9, 0.91, 1.2, 1.05, 2.43, 1.39, 4.29,
    1.36, 1.31, 0.94, 0.93, 1.0, 1.06, 0.88, 0.84, 1.68, 1.17, 1.46, 1.04, 0.93, 1.04, 1.09, 1.01, 2.22, 0.92,
    1.83, 0.93, 0.93, 1.0, 1.14, 1.5, 1.0, 2.99, 1.31, 0.98, 1.34, 1.18, 1.11, 1.13, 2.45, 1.09, 1.14, 1.06,
    1.45, 0.95, 1.14, 1.07, 1.05, 1.36, 1.32, 4.25, 1.21, 0.98, 1.23,
]
MARCUS = 34  # K0034, sent at 17 s


class Clock:
    def __init__(self):
        self.t = 0.0

    def __call__(self):
        return self.t


def boom(exc=TimeoutError):
    def fn():
        raise exc("no answer")
    return fn


def test_fallback_chain():
    out = fallback([("mini", lambda: "normal"), ("nano", lambda: "high")])
    assert out == {"served_by": "mini", "result": "normal", "errors": []}
    out = fallback([("mini", boom()), ("nano", lambda: "high")], canned="a person will reply")
    assert out["served_by"] == "nano" and out["result"] == "high"
    assert out["errors"] == [("mini", "TimeoutError")]
    out = fallback([("mini", boom()), ("nano", boom(ConnectionError))], canned="a person will reply")
    assert out == {"served_by": "canned", "result": "a person will reply",
                   "errors": [("mini", "TimeoutError"), ("nano", "ConnectionError")]}
    try:
        fallback([("mini", boom()), ("nano", boom(ConnectionError))])
    except ConnectionError:
        pass
    else:
        raise AssertionError("with no canned reply, the last error should be raised")


def test_breaker_opens_after_five_failures_in_ten_seconds():
    clock = Clock()
    b = CircuitBreaker(failures=5, window=10, cooldown=5, clock=clock)
    for t in (0, 3, 6, 9):           # 4 failures: still closed
        clock.t = t
        b.record(False)
    assert b.state == "closed" and b.allow()
    clock.t = 14                     # the one at 0 s has left the window: 4 in the last 10 s
    b.record(False)
    assert b.state == "closed"
    clock.t = 15                     # 6, 9, 14, 15... and 3 has left too: still 4
    b.record(False)
    assert b.state == "closed"
    clock.t = 15.5                   # 6, 9, 14, 15, 15.5: five within 10 s
    b.record(False)
    assert b.state == "open" and not b.allow()
    assert b.history == [(15.5, "open")]


def test_breaker_tests_once_then_closes_or_reopens():
    clock = Clock()
    b = CircuitBreaker(failures=5, window=10, cooldown=5, clock=clock)
    for i in range(5):
        clock.t = 18 + i * 0.5       # 18.0 ... 20.0
        b.record(False)
    assert b.state == "open"
    clock.t = 24.9
    assert not b.allow()             # still cooling down
    clock.t = 25.0
    assert b.allow() and b.state == "half_open"
    assert not b.allow()             # only one test call at a time
    b.record(False)                  # the test call failed: open again, a fresh 5 s
    assert b.state == "open"
    clock.t = 29.9
    assert not b.allow()
    clock.t = 30.0
    assert b.allow()
    b.record(True)                   # mini answered: back to normal
    assert b.state == "closed" and b.allow() and b.allow()
    assert b.history == [(20.0, "open"), (25.0, "half_open"), (25.0, "open"), (30.0, "half_open"), (30.0, "closed")]


def test_breaker_call_skips_the_service_while_open():
    clock = Clock()
    b = CircuitBreaker(failures=2, window=10, cooldown=5, clock=clock)
    calls = []

    def mini():
        calls.append(clock.t)
        raise TimeoutError("hung")

    for _ in range(2):
        try:
            b.call(mini)
        except TimeoutError:
            pass
    assert b.state == "open" and len(calls) == 2
    try:
        b.call(mini)
    except CircuitOpen:
        pass
    else:
        raise AssertionError("an open breaker should raise CircuitOpen")
    assert len(calls) == 2           # mini wasn't called
    clock.t = 5
    assert b.call(lambda: "normal") == "normal" and b.state == "closed"
    # With the breaker in a fallback chain, an open breaker sends the email straight to the backup.
    b.record(False)
    b.record(False)
    out = fallback([("mini", lambda: b.call(mini)), ("nano", lambda: "high")])
    assert out["served_by"] == "nano" and out["errors"] == [("mini", "CircuitOpen")] and len(calls) == 2


def test_why_the_breaker_tripped_late():
    # A hang, 5 s per attempt, the SDK's 2 retries: about 16.3 s before one email counts as failed.
    hang = seconds_per_failure(5, 2)
    assert round(hang, 2) == 16.31
    assert round(trip_time(15, hang), 1) == 33.3          # measured: 33.4, with 6.6 s of the outage left
    # Failing fast: 3 s, no retries.
    assert seconds_per_failure(3, 0) == 3
    assert trip_time(15, seconds_per_failure(3, 0)) == 20.0   # measured: 19.5 (a slow healthy call counted too)
    # A busy tone: each attempt errors at once, so only the SDK's backoff is left.
    assert round(trip_time(15, seconds_per_failure(5, 2, hangs=False)), 1) == 18.3   # measured: 18.3
    # The SDK's default limit is 600 s: one hanging email would take half an hour to count.
    assert round(seconds_per_failure(600, 2) / 60) == 30
    assert trip_time(15, 0, failures=1) == 15


def test_typical_waits_in_the_hang_outage():
    assert len(BREAKER_SECONDS) == 120
    assert round(typical_wait(BACKUP_SECONDS), 2) == 17.25
    assert round(typical_wait(BREAKER_SECONDS), 1) == 17.2
    assert round(typical_wait(FAIL_FAST_SECONDS), 1) == 1.2
    # More than half the emails sent during the outage waited over 17 s with the backup alone.
    assert sum(s > 17 for s in BACKUP_SECONDS[30:80]) == 28
    # Marcus: 18.0 s with the backup, 17.6 s with the breaker too, 4.3 s failing fast.
    assert [round(x[MARCUS], 1) for x in (BACKUP_SECONDS, BREAKER_SECONDS, FAIL_FAST_SECONDS)] == [18.0, 17.6, 4.3]
    assert typical_wait([5, 1, 3, 9], start=0, end=2) == 4
