#!/usr/bin/env python3
"""Experiment 01 — the iterator and generator protocol, demonstrated.

Predict every output before you run it. Each miss goes into ../../../review/
at the 1-day interval.

    python3 exp01_generators.py
"""

from __future__ import annotations

import gc
import itertools
import sys


def section(title: str) -> None:
    print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")


def claim(text: str) -> None:
    print(f"\nCLAIM: {text}")


# ---------------------------------------------------------------------------
section("1. Iterable vs iterator — why your object is single-use")
# ---------------------------------------------------------------------------

claim("An ITERABLE returns a fresh iterator each time. An ITERATOR returns itself.")

data = [1, 2, 3]
print(f"  iter(list) is list        -> {iter(data) is data}   (list is an iterable)")
it = iter(data)
print(f"  iter(iterator) is itself  -> {iter(it) is it}   (an iterator is its own iterator)")

claim("Keeping iteration state on the INSTANCE makes the object single-use.")


class BrokenCountdown:
    """The classic bug: __iter__ is a generator but reads mutable instance state."""

    def __init__(self, n: int) -> None:
        self.n = n

    def __iter__(self):
        while self.n > 0:
            yield self.n
            self.n -= 1


broken = BrokenCountdown(3)
print(f"  first pass  -> {list(broken)}")
print(f"  second pass -> {list(broken)}   <-- state was consumed")


class FixedCountdown:
    """Iteration state is local to the generator, so each pass is independent."""

    def __init__(self, n: int) -> None:
        self.n = n

    def __iter__(self):
        for i in range(self.n, 0, -1):
            yield i


fixed = FixedCountdown(3)
print(f"  fixed, first  -> {list(fixed)}")
print(f"  fixed, second -> {list(fixed)}")


# ---------------------------------------------------------------------------
section("2. zip() silently over-consumes")
# ---------------------------------------------------------------------------

claim("zip pulls from each iterator in order and DISCARDS what it already took "
      "when a later one is exhausted.")

a = iter([1, 2, 3, 4])
b = [10, 20]
pairs = list(zip(a, b))
print(f"  pairs        -> {pairs}")
print(f"  next(a)      -> {next(a)}   <-- 3 was pulled and thrown away")
print("  This is the bug behind 'my chunked reader loses a record at the boundary'.")
print("  itertools.zip_longest, or buffering the pulled item, avoids it.")


# ---------------------------------------------------------------------------
section("3. send() and priming")
# ---------------------------------------------------------------------------

claim("A fresh generator is suspended BEFORE its first yield, so there is no "
      "expression waiting to receive a sent value.")


def echo():
    total = 0
    while True:
        received = yield total
        if received is None:
            continue
        total += received


gen = echo()
try:
    gen.send("hello")
except TypeError as exc:
    print(f"  g.send('hello') on a fresh generator -> TypeError: {exc}")

primed = echo()
print(f"  next(g)   primes it, returns -> {next(primed)}")
print(f"  g.send(5) returns            -> {primed.send(5)}")
print(f"  g.send(7) returns            -> {primed.send(7)}")
print("  The generator is a coroutine here: a suspended frame with live locals.")


# ---------------------------------------------------------------------------
section("4. throw() has three outcomes")
# ---------------------------------------------------------------------------


def swallower():
    while True:
        try:
            yield "ok"
        except ValueError:
            yield "caught"


def propagator():
    yield "ok"
    yield "ok"


def returner():
    try:
        yield "ok"
    except ValueError:
        return


claim("throw() raises AT the suspended yield. The generator decides what happens next.")

g = swallower()
next(g)
print(f"  (a) generator catches and yields -> throw() returns {g.throw(ValueError())!r}")

g = propagator()
next(g)
try:
    g.throw(ValueError("boom"))
except ValueError as exc:
    print(f"  (b) generator does not catch     -> propagates: {exc}")

g = returner()
next(g)
try:
    g.throw(ValueError())
except StopIteration:
    print("  (c) generator catches and returns -> raises StopIteration")


# ---------------------------------------------------------------------------
section("5. close(), GeneratorExit, and whether your finally runs")
# ---------------------------------------------------------------------------

claim("Dropping the last reference to a suspended generator calls close(), which "
      "throws GeneratorExit at the yield — so `finally` DOES run.")


def with_cleanup():
    try:
        yield 1
        yield 2
    finally:
        print("    >>> finally ran (cleanup released)")


g = with_cleanup()
print(f"  next(g) -> {next(g)}")
print("  del g ...")
del g
gc.collect()

claim("A generator that swallows GeneratorExit and yields again is an error.")


def bad_citizen():
    try:
        yield 1
    except GeneratorExit:
        yield 2  # illegal


g = bad_citizen()
next(g)
try:
    g.close()
except RuntimeError as exc:
    print(f"  g.close() -> RuntimeError: {exc}")

print("\n  Consequence for real code: a `for` loop that breaks early triggers this")
print("  path. If your generator holds a file handle or a lock, `finally` is what")
print("  releases it — and only if you did not swallow GeneratorExit.")


# ---------------------------------------------------------------------------
section("6. yield from — delegation and the return value")
# ---------------------------------------------------------------------------

claim("`yield from` delegates iteration AND hands back the sub-generator's return "
      "value as the value of the expression (PEP 380).")


def inner():
    yield 1
    yield 2
    return "inner-done"


def outer():
    result = yield from inner()
    print(f"    >>> outer received the return value: {result!r}")
    yield result


print(f"  list(outer()) -> {list(outer())}")
print("  Note 'inner-done' is NOT yielded by inner. It is returned to outer,")
print("  which chose to yield it. Delegation also forwards send/throw/close.")


# ---------------------------------------------------------------------------
section("7. Generators as state machines")
# ---------------------------------------------------------------------------

claim("A generator holds its position AND its locals across suspensions, which is "
      "exactly a state machine with the state implicit in the program counter.")


def protocol_parser():
    """Three-state protocol: HEADER -> BODY -> DONE."""
    header = yield "awaiting header"
    size = int(header)
    body = []
    while len(body) < size:
        chunk = yield f"awaiting body ({len(body)}/{size})"
        body.append(chunk)
    yield f"complete: {body}"


parser = protocol_parser()
print(f"  {next(parser)}")
print(f"  {parser.send('2')}")
print(f"  {parser.send('a')}")
print(f"  {parser.send('b')}")
print("  Compare with the class-based version: no explicit state enum, no dispatch")
print("  table, and the 'current state' is just where the frame is suspended.")


# ---------------------------------------------------------------------------
section("8. itertools.tee is a memory hazard")
# ---------------------------------------------------------------------------

claim("tee must buffer everything one branch has read that the other has not. "
      "Draining one branch first materializes the entire stream.")

left, right = itertools.tee(range(100_000), 2)
consumed = sum(1 for _ in left)  # drain the left branch entirely
buffered = sys.getsizeof(right)
print(f"  drained left ({consumed} items); the right branch's internal buffer now")
print(f"  holds them all — you have materialized the stream you used a lazy")
print(f"  iterator to avoid. tee is only safe when branches advance in lockstep.")
print(f"  (sys.getsizeof on the tee object itself: {buffered} bytes — it lies, "
      f"see exp04)")


print("\n" + "=" * 70)
print("Interview form of every claim above lives in ../README.md § B1.")
print("=" * 70)
