"""
Pengi Lesson 02 -- Comprehensions + Class Variables  (STUDENT COPY / boilerplate)

Prereqs: Lesson 01 (operators, builtins, list comprehensions) and the
         dunder/__init__ lesson (class vs instance state, super(), mixins).

The idea this time: the colony is not four parallel lists anymore. It is a
class with a list on it. Every exercise is "reach into Pengi.colony and get
a shape out of it" -- list, dict, sorted list, grouped dict.

Run it:  python pengi_lesson_02_comprehensions_and_classvars.py
Stubs raise NotImplementedError; the checker prints TODO instead of crashing,
so you can run the file after every single exercise.

Timing (~60 min):
  Part 0  read the scaffold         5
  Part 1  operator warm-up          5
  Part 2  builtins on raw lists     8
  Part 3  loop -> comprehension     8
  Part 4  add a condition           7
  Part 5  builtin inside the comp   7
  Part 6  dict comprehensions      10
  Part 7  map / filter / key=       8
  Part 8  fold it onto the class   10
  Part 9  stretch + discussion      -
"""

from __future__ import annotations


# ---------------------------------------------------------------------------
# Checker -- ignore this, it just keeps the file runnable while stubs exist.
# ---------------------------------------------------------------------------
_SCORE = {"PASS": 0, "FAIL": 0, "TODO": 0, "ERROR": 0}


def check(label, fn):
    try:
        ok = fn()
    except NotImplementedError:
        _SCORE["TODO"] += 1
        print(f"[ TODO ] {label}")
        return
    except Exception as e:
        _SCORE["ERROR"] += 1
        print(f"[ERROR ] {label} -> {type(e).__name__}: {e}")
        return
    tag = "PASS" if ok else "FAIL"
    _SCORE[tag] += 1
    print(f"[ {tag} ] {label}")


def report():
    print("\n" + "-" * 60)
    print("  ".join(f"{k}: {v}" for k, v in _SCORE.items()))


# ===========================================================================
# Part 0 -- The scaffold (read it, don't change it yet)
# ===========================================================================
NAMES = [
    "Akira", "Botan", "Chihiro", "Daichi", "Emi", "Fuyuki", "Genki", "Haruto",
    "Hoshi", "Ichiro", "Jun", "Kaito", "Kohaku", "Mio", "Mochi", "Nanami",
    "Natsu", "Osamu", "Ren", "Rin", "Sakura", "Sora", "Taro", "Tsubasa",
    "Umi", "Wakana", "Yamato", "Yuki", "Zen", "Hikari",
]

SIZES = [
    14, 22, 9, 31, 18, 27, 40, 12,
    35, 8, 24, 30, 16, 21, 11, 38,
    26, 13, 44, 19, 20, 33, 10, 28,
    42, 15, 23, 36, 17, 25,
]

ROOKERIES = ["Kita", "Minami", "Higashi", "Nishi"]   # north, south, east, west


class Pengi:
    """One penguin. The colony lives on the CLASS, not in a linked list.

    colony is a class variable: one list, shared by every instance, and by
    every subclass that doesn't shadow it. No nodes, no .next, no traversal --
    a list is already a sequence, so builtins and comprehensions work on it
    for free.
    """

    colony: list["Pengi"] = []     # <- class variable (the whole point)
    count: int = 0                 # <- class variable (a plain int, watch it)

    def __init__(self, name: str, size: int, rookery: str) -> None:
        self.name = name           # <- instance variables
        self.size = size
        self.rookery = rookery
        Pengi.colony.append(self)  # the object files itself away on birth
        Pengi.count += 1

    def __repr__(self) -> str:
        return f"Pengi({self.name!r}, {self.size!r}, {self.rookery!r})"

    def __str__(self) -> str:
        return f"{self.name} [{self.rookery}] {self.size}cm"

    @classmethod
    def reset(cls) -> None:
        """Wipe the colony. cls.colony.clear() mutates the ONE list;
        cls.colony = [] would rebind and orphan anything holding the old one."""
        cls.colony.clear()
        cls.count = 0


# Populate. Note this is a plain loop, NOT a comprehension: we want the side
# effect (objects registering themselves), and a comprehension built purely
# for side effects is a smell -- it builds a throwaway list of Nones.
for i, (name, size) in enumerate(zip(NAMES, SIZES)):
    Pengi(name, size, ROOKERIES[i % len(ROOKERIES)])

print(f"colony size: {len(Pengi.colony)}  count: {Pengi.count}")
print(Pengi.colony[0])        # __str__
print(repr(Pengi.colony[0]))  # __repr__
print(Pengi.colony[:3])       # a list prints __repr__ of each item


# ===========================================================================
# Part 1 -- Operator warm-up (5 min).  Predict, then run.
# ===========================================================================
print(44 // 3, 44 % 3)                     # floor division, modulo
print(2 ** 6)                              # exponent
print(10 < 22 < 40)                        # chained comparison
print("Pen" in "Pengi")                    # membership
print(Pengi.colony[0] is Pengi.colony[0])  # identity -- True, same object
print(Pengi("Ghost", 1, "Kita") is Pengi("Ghost", 1, "Kita"))  # False. why?
Pengi.colony.pop()                         # undo the two ghosts
Pengi.colony.pop()
Pengi.count -= 2

"""
Exercise 1.1 -- one expression: True only if a size is even AND greater than 10.
(This is last lesson's is_big_even, renamed. It comes back in Part 4.)
"""


def is_chonky(size: int) -> bool:
    raise NotImplementedError("Ex 1.1")


check("1.1 is_chonky", lambda: is_chonky(22) and not is_chonky(8) and not is_chonky(31))


# ===========================================================================
# Part 2 -- Builtins, still on the raw lists (8 min)
# ===========================================================================
print(len(NAMES), sum(SIZES))
print(max(SIZES), min(SIZES))
print(sorted(SIZES, reverse=True)[:5])
print(list(zip(NAMES, SIZES))[:3])
print(any(s > 40 for s in SIZES), all(s > 0 for s in SIZES))

"""
Exercise 2.1 -- using zip and max, return the NAME of the biggest penguin.
Two ways: a comprehension that filters on max(SIZES), or max(..., key=...).
Do the comprehension first, then try the key= version in Part 7.
"""


def biggest_name(names: list[str], sizes: list[int]) -> str:
    raise NotImplementedError("Ex 2.1")


check("2.1 biggest_name", lambda: biggest_name(NAMES, SIZES) == "Ren")


# ===========================================================================
# Part 3 -- Loop first, then fold it in (8 min)
# ===========================================================================
# The rule never changes: write the for-loop, get it right, THEN collapse it.

# loop version -- everyone in the Kita rookery
kita_loop = []
for p in Pengi.colony:
    if p.rookery == "Kita":
        kita_loop.append(p.name)
print(kita_loop)

"""
Exercise 3.1 -- same thing, one comprehension, any rookery.
Source is Pengi.colony (a list of OBJECTS), so you pull p.name, not name.
"""


def names_in(rookery: str) -> list[str]:
    raise NotImplementedError("Ex 3.1")


check("3.1 names_in", lambda: names_in("Kita") == kita_loop and len(names_in("Nishi")) == 7)


# ===========================================================================
# Part 4 -- Add a condition (7 min).  Part 1's operator comes back.
# ===========================================================================
"""
Exercise 4.1 -- every chonky size in the colony (use is_chonky).
Exercise 4.2 -- the NAMES of the chonky ones.
Exercise 4.3 -- chonky AND in a given rookery (two conditions, one if).
"""


def chonky_sizes() -> list[int]:
    raise NotImplementedError("Ex 4.1")


def chonky_names() -> list[str]:
    raise NotImplementedError("Ex 4.2")


def chonky_names_in(rookery: str) -> list[str]:
    raise NotImplementedError("Ex 4.3")


check("4.1 chonky_sizes", lambda: len(chonky_sizes()) == 15 and all(is_chonky(s) for s in chonky_sizes()))
check("4.2 chonky_names", lambda: "Ren" in chonky_names() and "Chihiro" not in chonky_names())
check("4.3 chonky_names_in", lambda: set(chonky_names_in("Kita")) <= set(names_in("Kita")))


# ===========================================================================
# Part 5 -- A builtin INSIDE the comprehension (7 min)
# ===========================================================================
tagged = [f"{i}: {p.name}" for i, p in enumerate(Pengi.colony)]
ranked = [p.name for p in sorted(Pengi.colony, key=lambda p: p.size, reverse=True)]
print(tagged[:3])
print(ranked[:5])

# reference loop for the next one -- compute the average ONCE, outside
_avg = sum(p.size for p in Pengi.colony) / len(Pengi.colony)
_above_loop = []
for p in Pengi.colony:
    if p.size > _avg:
        _above_loop.append(f"{p.name} ({p.size})")

"""
Exercise 5.1 -- "Name (size)" strings for everyone above the average size.
Compute the average with sum()/len() over the colony.
Gotcha: if you inline sum(...)/len(...) in the if, it re-computes per item.
"""


def above_average() -> list[str]:
    raise NotImplementedError("Ex 5.1")


check("5.1 above_average", lambda: above_average() == _above_loop and len(above_average()) == 14)


# ===========================================================================
# Part 6 -- Dict comprehensions (10 min)
# ===========================================================================
# Same syntax, two expressions and a colon:  {key: value for x in src if ...}
demo = {p.name: p.size for p in Pengi.colony[:3]}
print(demo)

"""
Exercise 6.1 -- name -> size, whole colony.
Exercise 6.2 -- name -> "Big" / "Small" (ternary in the VALUE slot, above avg = Big).
Exercise 6.3 -- rookery -> list of names.  Nested: the outer loop walks
                ROOKERIES, the inner comprehension builds each value.
                Reuse names_in() if you like -- that is what functions are for.
Exercise 6.4 -- rookery -> total size of that rookery (sum inside the value).
"""


def size_table() -> dict[str, int]:
    raise NotImplementedError("Ex 6.1")


def big_or_small() -> dict[str, str]:
    raise NotImplementedError("Ex 6.2")


def by_rookery() -> dict[str, list[str]]:
    raise NotImplementedError("Ex 6.3")


def rookery_weights() -> dict[str, int]:
    raise NotImplementedError("Ex 6.4")


check("6.1 size_table", lambda: size_table()["Ren"] == 44 and len(size_table()) == 30)
check("6.2 big_or_small", lambda: big_or_small()["Ren"] == "Big" and big_or_small()["Ichiro"] == "Small")
check("6.3 by_rookery", lambda: set(by_rookery()) == set(ROOKERIES) and len(by_rookery()["Kita"]) == 8)
check("6.4 rookery_weights", lambda: sum(rookery_weights().values()) == sum(SIZES))


# ===========================================================================
# Part 7 -- map / filter / sorted(key=) / lambda (8 min)
# ===========================================================================
# map and filter are the older spelling of "comprehension". They return lazy
# iterators, so wrap them in list() to see anything.
print(list(map(lambda p: p.name, Pengi.colony))[:4])
print(list(filter(lambda p: p.size > 40, Pengi.colony)))

# key= is the real workhorse: it decides what to sort BY without changing
# what you get BACK.
print(max(Pengi.colony, key=lambda p: p.size))
print(sorted(Pengi.colony, key=lambda p: (p.rookery, -p.size))[:4])  # two-level sort

"""
Exercise 7.1 -- top n names by size, biggest first (sorted + key + slice).
Exercise 7.2 -- biggest penguin in a given rookery. Return the OBJECT, not the name.
                Filter first, then max(..., key=...).
Exercise 7.3 -- rewrite 7.1 without a comprehension, using map(). Which reads better?
"""


def top_n(n: int) -> list[str]:
    raise NotImplementedError("Ex 7.1")


def biggest_in(rookery: str) -> "Pengi":
    raise NotImplementedError("Ex 7.2")


def top_n_map(n: int) -> list[str]:
    raise NotImplementedError("Ex 7.3")


check("7.1 top_n", lambda: top_n(3) == ["Ren", "Umi", "Genki"])
check("7.2 biggest_in", lambda: isinstance(biggest_in("Kita"), Pengi) and biggest_in("Kita").rookery == "Kita")
check("7.3 top_n_map", lambda: top_n_map(3) == ["Ren", "Umi", "Genki"])


# ===========================================================================
# Part 8 -- Fold it back onto the class (10 min)
# ===========================================================================
# Everything above was a loose function taking the colony implicitly. That is
# fine while learning and wrong once it stabilises: the data lives on Pengi,
# so the queries belong on Pengi too. classmethod gets cls (the class), not an
# instance -- which is exactly what you want when the data IS the class.


class ColonyQueries:
    """Mix-in of query methods. In real code you'd write
    `class Pengi(ColonyQueries):` at the top of the file -- they're split out
    here only so you can fill them in without scrolling back."""

    @classmethod
    def roster(cls) -> list[str]:
        raise NotImplementedError("Ex 8.1")

    @classmethod
    def average_size(cls) -> float:
        raise NotImplementedError("Ex 8.2")

    @classmethod
    def ranked(cls) -> list["Pengi"]:
        """Biggest first."""
        raise NotImplementedError("Ex 8.3")

    @classmethod
    def grouped(cls) -> dict[str, list["Pengi"]]:
        """rookery -> list of Pengi objects, each group sorted biggest first."""
        raise NotImplementedError("Ex 8.4")

    @classmethod
    def stats(cls) -> dict[str, float]:
        """{'count':..., 'total':..., 'avg':..., 'min':..., 'max':...} in ONE dict literal."""
        raise NotImplementedError("Ex 8.5")


# Lesson wiring: bolt the classmethods onto Pengi so Pengi.roster() works.
# This is monkeypatching -- it works, and it is still worse than inheritance.
# Ask why: what does `help(Pengi)` know here that it wouldn't with a proper
# base class, and what does an IDE lose?
for _name, _member in list(vars(ColonyQueries).items()):
    if isinstance(_member, classmethod):
        setattr(Pengi, _name, _member)

check("8.1 roster", lambda: len(Pengi.roster()) == 30 and Pengi.roster()[0] == "Akira")
check("8.2 average_size", lambda: abs(Pengi.average_size() - 707 / 30) < 1e-9)
check("8.3 ranked", lambda: Pengi.ranked()[0].name == "Ren" and Pengi.ranked()[-1].name == "Ichiro")
check("8.4 grouped", lambda: Pengi.grouped()["Kita"][0].size >= Pengi.grouped()["Kita"][1].size)
check("8.5 stats", lambda: Pengi.stats()["count"] == 30 and Pengi.stats()["max"] == 44)


# ===========================================================================
# Part 9 -- Stretch: subclassing a shared class variable
# ===========================================================================
"""
Exercise 9.1 -- give Pengi __lt__ so sorted(Pengi.colony) works with no key=.
                Then ask: is "smaller than" obviously about size? When is a
                dunder clearer than a key=, and when is it a lie?
"""


def _lt(self, other):
    raise NotImplementedError("Ex 9.1")


Pengi.__lt__ = _lt
check("9.1 __lt__", lambda: sorted(Pengi.colony)[-1].name == "Ren")

"""
Exercise 9.2 -- write EmperorPengi(Pengi) with an extra `dive_depth`, calling
super().__init__(...). Then answer, BEFORE running it:

    class EmperorPengi(Pengi):
        colony = []          # its own list?

    EmperorPengi("Shirase", 120, "Kita", dive_depth=500)
    len(EmperorPengi.colony)   # ?
    len(Pengi.colony)          # ?

Pengi.__init__ says `Pengi.colony.append(self)` -- hardcoded to the parent.
Change it to `type(self).colony.append(self)` and re-answer. Then decide which
behaviour you actually want for a colony census.
"""


# ---------------------------------------------------------------------------
# DISCUSSION
# ---------------------------------------------------------------------------
# Q1. `Pengi.count += 1` inside __init__ vs `self.count += 1`. What does the
#     second one do on the second call? Where does the value end up?
# Q2. `cls.colony.clear()` vs `cls.colony = []` in reset(). Both look like
#     "empty the list". Only one of them is.
# Q3. Last lesson's Sensor had __del__ removing itself from the class list.
#     The class list holds a reference to every sensor forever, so the
#     refcount never hits zero. When does that __del__ actually run? What does
#     that tell you about class-variable registries and memory?
# Q4. A comprehension over Pengi.colony reads the list at that moment. If a
#     penguin is born mid-lesson, which of your Part 8 classmethods change
#     their answers, and which cached something they shouldn't have?
# Q5. by_rookery() walks the colony once per rookery -- 4 passes. Write the
#     one-pass version with a plain loop and a dict. At what colony size does
#     the pretty comprehension stop being the right call?

report()
