"""
Practice: fallbacks and a circuit breaker. Lab 13's script 02 (02_fallbacks.py): 120 support emails
at 2 a second, gpt-5.4-mini broken from 15 s to 40 s, a backup model (gpt-5.4-nano) and a canned
reply, and a breaker in front of mini. Build the chain and the breaker, then work out why the
breaker tripped so late when mini hung, and check it against the recorded waits.

Fill in the TODOs, then run the tests:
    pytest test_breaker_practice.py        (locally)
or press "Run tests" on the chapter page.
"""


class CircuitOpen(Exception):
    """Raised instead of calling the service while the breaker is open."""


def fallback(options, canned=None):
    """Try each (name, fn) in `options` in order; the first fn() that returns wins.

    Return {"served_by": name, "result": fn(), "errors": [(name, error class name), ...]} with one
    entry in "errors" for each option that raised before it. If every option raises, return
    {"served_by": "canned", "result": canned, "errors": [...]} when `canned` is not None, and
    otherwise raise the last error."""
    errors, last = [], None
    for name, fn in options:
        try:
            return {"served_by": name, "result": fn(), "errors": errors}
        except Exception as e:  # every failure moves to the next option
            errors.append((name, type(e).__name__))
            last = e
    if canned is not None:
        return {"served_by": "canned", "result": canned, "errors": errors}
    raise last


class CircuitBreaker:
    """closed:    calls go through. `failures` failures within `window` seconds → open.
    open:      allow() is False. After `cooldown` seconds → half_open.
    half_open: allow() is True once (a test call, the "probe"); then False until it's recorded.
               record(True) → closed; record(False) → open again, with a fresh cooldown.
    In any state, record(True) clears the failure count and closes the breaker.

    `clock` is a function returning the time in seconds. Every change of state is appended to
    `history` as (time, new state)."""

    def __init__(self, failures=5, window=10.0, cooldown=5.0, clock=None):
        self.failures, self.window, self.cooldown = failures, window, cooldown
        self.clock = clock
        self.state = "closed"
        self.history = []
        self._fails = []
        self._opened_at = 0.0
        self._probing = False

    def _set(self, state):
        if state != self.state:
            self.state = state
            self.history.append((self.clock(), state))

    def allow(self):
        if self.state == "open" and self.clock() - self._opened_at >= self.cooldown:
            self._set("half_open")
            self._probing = False
        if self.state == "closed":
            return True
        if self.state == "half_open" and not self._probing:
            self._probing = True
            return True
        return False

    def record(self, ok):
        now = self.clock()
        if ok:
            self._fails = []
            self._probing = False
            self._set("closed")
            return
        if self.state == "half_open":
            self._probing = False
            self._opened_at = now
            self._set("open")
            return
        self._fails = [t for t in self._fails if now - t < self.window] + [now]
        if len(self._fails) >= self.failures:
            self._opened_at = now
            self._set("open")

    def call(self, fn):
        """Raise CircuitOpen if allow() is False. Otherwise return fn(); record(True) if it
        returns, record(False) and re-raise if it raises."""
        if not self.allow():
            raise CircuitOpen("circuit open: not calling the service")
        try:
            result = fn()
        except Exception:
            self.record(False)
            raise
        self.record(True)
        return result


def seconds_per_failure(limit, retries, hangs=True):
    """How long one email takes to count as failed, on average. Each of the retries + 1 attempts
    waits `limit` seconds when the model hangs (0 when it errors at once). Before retry k
    (k = 0, 1, ...) the OpenAI SDK sleeps min(0.5 * 2**k, 8) * (1 - 0.25 * random()) seconds:
    use the average, 0.875 of min(0.5 * 2**k, 8)."""
    wait = (retries + 1) * limit if hangs else 0.0
    return wait + sum(min(0.5 * 2**k, 8) * 0.875 for k in range(retries))


def trip_time(outage_start, per_failure, failures=5, rate=2.0):
    """When the breaker opens, in seconds: emails arrive `rate` a second from `outage_start`,
    and each is counted as failed `per_failure` seconds after it was sent. The breaker opens on
    the `failures`-th failure (assume they all land within its window)."""
    return outage_start + (failures - 1) / rate + per_failure


def typical_wait(seconds, start=15.0, end=40.0, rate=2.0):
    """The median wait of the emails sent during the outage. Email i was sent at i / rate seconds;
    keep those with start <= i / rate < end. The median of an even count is the mean of the two
    middle values."""
    xs = sorted(s for i, s in enumerate(seconds) if start <= i / rate < end)
    m = len(xs) // 2
    return xs[m] if len(xs) % 2 else (xs[m - 1] + xs[m]) / 2
