#!/usr/bin/env python3
"""Measure the example art and generate the figures the lessons use.

Every number quoted in a lesson comes from here, not from memory. Re-run after
adding or replacing example art:

    .venv/bin/python assets/build-examples.py     # from gameboy-pa/

Outputs:
    examples/measurements.json          shade budgets, key and outline audit per file
    examples/generated/*.png            per-shade separation, silhouette and proportion figures
"""

import collections
import json
import pathlib

from PIL import Image

HERE = pathlib.Path(__file__).resolve().parent
EXAMPLES = HERE / "examples"
GENERATED = EXAMPLES / "generated"

# Pixels this opaque or more count as "drawn".
ALPHA_CUTOFF = 128

# Everything that is not the shade being isolated is dropped to this, so the
# separation figures read as "one shade against blank paper".
DROPPED = (232, 230, 223, 255)

# name, path, kind. Kind decides which contract the asset is judged against.
MEASURED = [
    # Chloe Wolfe (KIIRA), CC0 — the Lesson 01 reference set.
    ("kiira-character-idle-walk", "kiira-gb-platformer/fluffy/idle-walk-strip5.png", "character"),
    ("kiira-character-roll", "kiira-gb-platformer/fluffy/roll-strip4.png", "character"),
    ("kiira-character-jump", "kiira-gb-platformer/fluffy/jump.png", "character"),
    ("kiira-character-fall", "kiira-gb-platformer/fluffy/fall.png", "character"),
    ("kiira-environment-brown", "kiira-gb-platformer/tileset-b.png", "environment"),
    ("kiira-environment-green", "kiira-gb-platformer/tileset-a.png", "environment"),
    # LittleSadNerd, CC0 — made for GameBoy Jam 4. A bright environment.
    ("gbjam-character-run", "littlesadnerd-gbjam/ninja/run3.png", "character"),
    ("gbjam-character-jump", "littlesadnerd-gbjam/ninja/jump.png", "character"),
    ("gbjam-boss", "littlesadnerd-gbjam/sensei/spr_boss_0.png", "character"),
    ("gbjam-environment", "littlesadnerd-gbjam/tilesheet.png", "environment"),
    # stealthix, CC0 — a grim environment.
    ("dungeon-environment", "stealthix-dungeon/tileset-dungeon-green.png", "environment"),
    # Jana Ochse (2D-Retroperspectives), CC-BY — a cave that is not grim at all.
    ("cave-environment", "2dpixx-cave/cave-tiles.png", "environment"),
    # ArMM1998, CC0 — explicitly an attempt at the Pokemon look. Top-down.
    ("topdown-pokemon-like", "generated/topdown-pokemon-like.png", "environment"),
    # stealthix, CC0 — top-down interior, the grim end.
    ("topdown-grim-interior", "stealthix-interior/tileset-interior-green.png", "environment"),
    # Chasersgaming, CC0 — 4-directional top-down knight in the same bgb palette.
    ("knight-walk-block", "generated/knight-walk-block.png", "character"),
]

SEPARATION_SOURCE = "kiira-gb-platformer/fluffy/idle-walk-strip5.png"
SEPARATION_FRAME = (0, 0, 16, 16)

# ArMM1998 ships the sheet over a baked one-pixel white/grey dither instead of
# real transparency. Measuring it raw puts 80% of the sheet's pixels into the two
# lightest shades and reports a key of 0.58 for art that is really 0.87.
DITHER_BG = ((255, 255, 255, 255), (168, 168, 168, 255))

# The whole style kit — stealthix's tilesets and Chasersgaming's knight — is drawn
# in this ramp, so shade indices for kit art are taken from it rather than from
# each image's own colours. A single flat floor tile has one colour; ranking that
# tile on its own would call it shade 0 and report a contrast of 4.
BGB_RAMP = ((155, 188, 15), (139, 172, 15), (48, 98, 48), (15, 56, 15))

# Chasersgaming's sheet carries eight stray orange pixels. Left in, they become a
# fifth shade and push the knight's reported key to 3.20.
KIT_STRAY = ((217, 104, 15),)

# Crops from the style kit, pinned so the figures are reproducible. Tile
# coordinates are in 8x8 cells of stealthix's interior sheet; the key of each was
# read off the per-tile map this script prints.
KIT_CROPS = [
    # name,                    source sheet,                                        box
    ("knight-down.png", "chasersgaming-knight/knight-sheet.png", (33, 25, 47, 48)),
    ("knight-walk-block.png", "chasersgaming-knight/knight-sheet.png", (0, 25, 108, 119)),
    ("tile-floor.png", "stealthix-interior/tileset-interior-green.png", (56, 8, 64, 16)),
    ("tile-wall.png", "stealthix-interior/tileset-interior-green.png", (40, 40, 48, 48)),
    ("tile-void.png", "stealthix-interior/tileset-interior-green.png", (32, 56, 40, 64)),
]

# Figure-ground pairings tested in Lesson 03. Each is (sprite, ground).
CONTRAST_PAIRS = [
    ("fluffy on pokemon-like sheet",
     "kiira-gb-platformer/fluffy/idle-walk-strip5.png",
     "generated/topdown-pokemon-like.png"),
    ("fluffy on grim interior sheet",
     "kiira-gb-platformer/fluffy/idle-walk-strip5.png",
     "stealthix-interior/tileset-interior-green.png"),
    ("knight on grim interior sheet",
     "generated/knight-walk-block.png",
     "stealthix-interior/tileset-interior-green.png"),
    ("knight on floor tile",
     "generated/knight-walk-block.png",
     "generated/tile-floor.png"),
    ("knight on wall tile",
     "generated/knight-walk-block.png",
     "generated/tile-wall.png"),
    ("knight on void tile",
     "generated/knight-walk-block.png",
     "generated/tile-void.png"),
]

# Characters measured for shape and interior. Each is cropped to its own bounding
# box first, so "fill" means fill of the shape's own box, not of a padded canvas.
CHARACTER_SHAPES = [
    ("Fluffy, idle", "kiira-gb-platformer/fluffy/idle-walk-strip5.png", (0, 0, 16, 16)),
    ("GBJam ninja", "littlesadnerd-gbjam/ninja/run3.png", None),
    ("GBJam sensei", "littlesadnerd-gbjam/sensei/spr_boss_0.png", None),
    ("Knight", "generated/knight-down.png", None),
]

# Silhouette figures: the same characters with every shade flattened to Ink.
SILHOUETTE_INK = (26, 30, 22, 255)

# stealthix's sheets are 8x8. The per-tile key map is what tells you which cells
# are floor and which are wall.
TILE_MAPS = [
    ("interior", "stealthix-interior/tileset-interior-green.png", 8),
    ("dungeon", "stealthix-dungeon/tileset-dungeon-green.png", 8),
]


# Lesson 05 — proportion and shape in three-quarter view. One standing frame per
# direction for each character. The third value of a frame is the pinned head
# split: how many rows, counted from the top of the sprite's own bounding box,
# are head (hat and hood included). That split is the one human judgement in
# this measurement — it was read off the row grids this script can print — so
# it lives here, where it can be disputed and re-run, not in the lesson text.
#
# `clear` names a baked background colour to flood-fill to transparency from
# the border, for packs shipped without an alpha channel. Only pixels reachable
# from the edge are cleared, so that colour survives inside the drawing.
PROPORTION_CHARACTERS = [
    {
        "name": "Knight", "slug": "knight", "artist": "Chasersgaming",
        "canvas": [16, 24],
        "frames": {
            "down": ("chasersgaming-knight/knight-sheet.png", (32, 25, 48, 48), 7),
            "up": ("chasersgaming-knight/knight-sheet.png", (64, 25, 80, 48), 7),
            "side": ("chasersgaming-knight/knight-sheet.png", (48, 25, 64, 48), 7),
        },
    },
    {
        "name": "Ranger", "slug": "ranger", "artist": "Chasersgaming",
        "canvas": [16, 24],
        "frames": {
            "down": ("chasersgaming-ranger/walk-south.png", (0, 0, 16, 24), 9),
            "up": ("chasersgaming-ranger/walk-north.png", (0, 0, 16, 24), 8),
            "side": ("chasersgaming-ranger/walk-east.png", (0, 0, 16, 24), 9),
        },
    },
    {
        "name": "Kung Fu Man", "slug": "kungfu", "artist": "Chasersgaming",
        "canvas": [16, 24],
        "frames": {
            "down": ("chasersgaming-kungfu/walk-south.png", (0, 0, 16, 24), 7),
            "up": ("chasersgaming-kungfu/walk-north.png", (0, 0, 16, 24), 7),
            "side": ("chasersgaming-kungfu/walk-east.png", (0, 0, 16, 24), 7),
        },
    },
    {
        "name": "Mini hero", "slug": "mini-hero", "artist": "pebonius",
        "canvas": [16, 16], "clear": (155, 188, 15, 255),
        "frames": {
            "down": ("pebonius-gb-mini/pc-walk-down1.png", None, 8),
            "up": ("pebonius-gb-mini/pc-walk-up1.png", None, 8),
            "side": ("pebonius-gb-mini/pc-walk-left1.png", None, 8),
        },
    },
    {
        "name": "Mini villager", "slug": "mini-villager", "artist": "pebonius",
        "canvas": [16, 16], "clear": (155, 188, 15, 255),
        "frames": {
            "down": ("pebonius-gb-mini/tileset.png", (32, 0, 48, 16), 9),
        },
    },
]


def luminance(colour):
    r, g, b = colour[:3]
    return 0.299 * r + 0.587 * g + 0.114 * b


def load(relative_path):
    return Image.open(EXAMPLES / relative_path).convert("RGBA")


def strip_dither_background(image):
    """Erase a baked one-pixel checkerboard background, flood-filled from the edges.

    Only background reachable from the sheet border is removed, so dithered
    texture inside a tile survives.
    """
    width, height = image.size
    pixels = image.load()

    def expected(x, y):
        return DITHER_BG[(x + y) % 2]

    out = image.copy()
    target = out.load()
    seen = bytearray(width * height)
    queue = collections.deque()

    def push(x, y):
        if not (0 <= x < width and 0 <= y < height) or seen[y * width + x]:
            return
        if pixels[x, y][3] >= ALPHA_CUTOFF and pixels[x, y] != expected(x, y):
            return
        seen[y * width + x] = 1
        queue.append((x, y))

    for x in range(width):
        push(x, 0)
        push(x, height - 1)
    for y in range(height):
        push(0, y)
        push(width - 1, y)
    while queue:
        x, y = queue.popleft()
        push(x + 1, y)
        push(x - 1, y)
        push(x, y + 1)
        push(x, y - 1)

    for y in range(height):
        for x in range(width):
            if seen[y * width + x]:
                target[x, y] = (0, 0, 0, 0)
    return out


def shade_map(image, palette=None):
    """{rgb: index} for an image, lightest shade first.

    Pass `palette` to index against a fixed ramp instead of the image's own
    colours — necessary whenever an image uses only part of its pack's ramp.
    """
    if palette:
        return {colour: index for index, colour in enumerate(palette)}
    shades, _ = ramp(image)
    return {shade[:3]: index for index, shade in enumerate(shades)}


def shade_distribution(image, palette=None):
    """Share of drawn pixels at each shade index, lightest first."""
    if palette:
        lookup = shade_map(image, palette)
        counts = collections.Counter(
            lookup[px[:3]]
            for px in image.get_flattened_data()
            if px[3] >= ALPHA_CUTOFF and px[:3] in lookup
        )
        total = sum(counts.values())
        return [counts[index] / total for index in range(len(palette))]
    shades, counts = ramp(image)
    total = sum(counts.values())
    return [counts[shade] / total for shade in shades]


def silhouette_shades(image, palette=None):
    """The shade index of every silhouette-edge pixel."""
    lookup = shade_map(image, palette)
    width, height = image.size
    pixels = image.load()
    found = []
    for y in range(height):
        for x in range(width):
            if pixels[x, y][3] < ALPHA_CUTOFF:
                continue
            neighbours = ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1))
            exposed = any(
                nx < 0 or ny < 0 or nx >= width or ny >= height
                or pixels[nx, ny][3] < ALPHA_CUTOFF
                for nx, ny in neighbours
            )
            if exposed and pixels[x, y][:3] in lookup:
                found.append(lookup[pixels[x, y][:3]])
    return found


def silhouette_contrast(sprite, ground, invert=False, palette=BGB_RAMP):
    """Expected |shade difference| between a sprite's outline and its ground.

    Position-independent: each silhouette pixel is compared against the ground's
    whole shade distribution rather than against one placement. A value of 3 is
    the theoretical maximum; below about 1 the character stops reading.
    """
    distribution = shade_distribution(ground, palette)
    top = len(distribution) - 1
    total = 0.0
    outline = silhouette_shades(sprite, palette)
    for shade in outline:
        index = top - shade if invert else shade
        total += sum(
            share * abs(index - ground_index)
            for ground_index, share in enumerate(distribution)
        )
    return total / len(outline)


def ramp(image):
    """The image's opaque colours, ordered lightest to darkest."""
    counts = collections.Counter(
        px for px in image.get_flattened_data() if px[3] >= ALPHA_CUTOFF
    )
    return sorted(counts, key=luminance, reverse=True), counts


def outline_audit(image, ink):
    """Share of silhouette-edge pixels painted in the darkest shade.

    A silhouette-edge pixel is a drawn pixel with at least one transparent (or
    off-canvas) orthogonal neighbour. On a sprite these are exactly the pixels
    the player sees against the background.
    """
    width, height = image.size
    pixels = image.load()
    edge = 0
    edge_ink = 0
    for y in range(height):
        for x in range(width):
            if pixels[x, y][3] < ALPHA_CUTOFF:
                continue
            neighbours = ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1))
            exposed = any(
                nx < 0 or ny < 0 or nx >= width or ny >= height
                or pixels[nx, ny][3] < ALPHA_CUTOFF
                for nx, ny in neighbours
            )
            if not exposed:
                continue
            edge += 1
            if pixels[x, y][:3] == ink[:3]:
                edge_ink += 1
    return {"edge_pixels": edge, "ink_edge_pixels": edge_ink}


def pack_ramps():
    """The canonical four-shade ramp of each pack, keyed by pack folder.

    An asset may legitimately use only three of its pack's four shades — the
    reference roll cycle drops Paper entirely. Ranking that asset's own colours
    would then mislabel its darkest shade as index 2. So shade indices are taken
    from the pack's full ramp wherever one can be established.
    """
    ramps = {}
    for _, relative_path, _ in MEASURED:
        pack = relative_path.split("/")[0]
        shades, _ = ramp(load(relative_path))
        if len(shades) == 4:
            ramps.setdefault(pack, set()).add(tuple(s[:3] for s in shades))
    return ramps


def shade_indices(relative_path, shades, ramps):
    """Map each of an asset's shades onto its pack's ramp index."""
    if len(shades) == 4:
        return list(range(4))
    pack = relative_path.split("/")[0]
    for candidate in ramps.get(pack, ()):
        lookup = {colour: index for index, colour in enumerate(candidate)}
        if all(shade[:3] in lookup for shade in shades):
            return [lookup[shade[:3]] for shade in shades]
    return list(range(len(shades)))


def measure(name, relative_path, kind, ramps):
    image = load(relative_path)
    shades, counts = ramp(image)
    opaque = sum(counts.values())
    shares = [counts[shade] / opaque for shade in shades]
    indices = shade_indices(relative_path, shades, ramps)

    # Key: the mean shade index, 0 (all lightest) to 3 (all darkest). The single
    # number this course uses for "how dark does this read".
    key = sum(index * share for index, share in zip(indices, shares))

    record = {
        "name": name,
        "kind": kind,
        "file": relative_path,
        "size": list(image.size),
        "opaque_pixels": opaque,
        "transparent_share": round(
            100 * (1 - opaque / (image.size[0] * image.size[1])), 1
        ),
        "shade_count": len(shades),
        "key": round(key, 3),
        "shades": [
            {
                "index": indices[position],
                "hex": "#%02x%02x%02x" % shade[:3],
                "pixels": counts[shade],
                "share": round(100 * shares[position], 1),
            }
            for position, shade in enumerate(shades)
        ],
    }
    record.update(outline_audit(image, shades[-1]))
    return record


def separation_figures():
    """One 16x16 frame, six times: the whole sprite, then each shade alone."""
    GENERATED.mkdir(parents=True, exist_ok=True)
    frame = load(SEPARATION_SOURCE).crop(SEPARATION_FRAME)
    shades, _ = ramp(frame)
    written = [("frame", "sprite-whole.png")]
    frame.save(GENERATED / "sprite-whole.png")

    for index, shade in enumerate(shades):
        isolated = Image.new("RGBA", frame.size, (0, 0, 0, 0))
        source = frame.load()
        target = isolated.load()
        for y in range(frame.size[1]):
            for x in range(frame.size[0]):
                px = source[x, y]
                if px[3] < ALPHA_CUTOFF:
                    continue
                target[x, y] = shade if px[:3] == shade[:3] else DROPPED
        filename = f"sprite-shade-{index}.png"
        isolated.save(GENERATED / filename)
        written.append((f"shade-{index}", filename))
    return written


def contrast_table():
    rows = []
    for label, sprite_path, ground_path in CONTRAST_PAIRS:
        sprite = load(sprite_path)
        ground = load(ground_path)
        # Fluffy is in Chloe Wolfe's brown ramp, not the kit's, so those pairs are
        # indexed on each image's own four colours.
        palette = None if "fluffy" in label else BGB_RAMP
        rows.append({
            "pair": label,
            "sprite_file": sprite_path,
            "ground_file": ground_path,
            "ground_key": round(
                sum(
                    i * p
                    for i, p in enumerate(shade_distribution(ground, palette))
                ),
                3,
            ),
            "as_drawn": round(silhouette_contrast(sprite, ground, False, palette), 3),
            "inverted": round(silhouette_contrast(sprite, ground, True, palette), 3),
        })
    return rows


def kit_crops():
    GENERATED.mkdir(parents=True, exist_ok=True)
    written = []
    for name, path, box in KIT_CROPS:
        crop = load(path).crop(box)
        pixels = crop.load()
        for y in range(crop.size[1]):
            for x in range(crop.size[0]):
                if pixels[x, y][:3] in KIT_STRAY:
                    pixels[x, y] = (0, 0, 0, 0)
        crop.save(GENERATED / name)
        written.append({"file": f"generated/{name}", "from": path, "box": list(box)})
    return written


def enclosed_holes(image):
    """Transparent pixels not reachable from the canvas border — gaps in the shape."""
    width, height = image.size
    pixels = image.load()
    seen = set()
    queue = collections.deque()
    for x in range(width):
        for y in (0, height - 1):
            if pixels[x, y][3] < ALPHA_CUTOFF:
                queue.append((x, y))
    for y in range(height):
        for x in (0, width - 1):
            if pixels[x, y][3] < ALPHA_CUTOFF:
                queue.append((x, y))
    while queue:
        x, y = queue.popleft()
        if (x, y) in seen or not (0 <= x < width and 0 <= y < height):
            continue
        if pixels[x, y][3] >= ALPHA_CUTOFF:
            continue
        seen.add((x, y))
        queue.extend(((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)))
    return sum(
        1
        for y in range(height)
        for x in range(width)
        if pixels[x, y][3] < ALPHA_CUTOFF and (x, y) not in seen
    )


def character_shapes():
    """Shape and interior statistics, plus a silhouette figure per character."""
    GENERATED.mkdir(parents=True, exist_ok=True)
    rows = []
    for label, path, box in CHARACTER_SHAPES:
        image = load(path)
        if box:
            image = image.crop(box)
        image = image.crop(image.getbbox())
        width, height = image.size
        pixels = image.load()
        lookup = shade_map(image)

        drawn = 0
        edge = 0
        interior = []
        silhouette = Image.new("RGBA", image.size, (0, 0, 0, 0))
        stencil = silhouette.load()

        for y in range(height):
            for x in range(width):
                if pixels[x, y][3] < ALPHA_CUTOFF:
                    continue
                drawn += 1
                stencil[x, y] = SILHOUETTE_INK
                neighbours = ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1))
                exposed = any(
                    nx < 0 or ny < 0 or nx >= width or ny >= height
                    or pixels[nx, ny][3] < ALPHA_CUTOFF
                    for nx, ny in neighbours
                )
                if exposed:
                    edge += 1
                else:
                    interior.append(lookup[pixels[x, y][:3]])

        slug = label.lower().replace(",", "").replace(" ", "-")
        silhouette.save(GENERATED / f"silhouette-{slug}.png")
        image.save(GENERATED / f"shape-{slug}.png")

        counts = collections.Counter(interior)
        rows.append({
            "character": label,
            "file": path,
            "box": list(image.size),
            "drawn_pixels": drawn,
            "fill_share": round(100 * drawn / (width * height), 1),
            "edge_share": round(100 * edge / drawn, 1),
            "interior_share": round(100 * len(interior) / drawn, 1),
            "enclosed_holes": enclosed_holes(image),
            "interior_mix": [
                round(100 * counts.get(index, 0) / len(interior), 1) for index in range(4)
            ],
            "silhouette_figure": f"generated/silhouette-{slug}.png",
            "shape_figure": f"generated/shape-{slug}.png",
        })
    return rows


def frame_silhouette_change(path, count):
    """How much a strip's silhouette changes from frame to frame, as a share of
    the two frames' combined area. Zero means the animation happens entirely
    inside a fixed outline."""
    strip = load(path)
    width = strip.size[0] // count
    masks = []
    for index in range(count):
        frame = strip.crop((index * width, 0, (index + 1) * width, strip.size[1]))
        pixels = frame.load()
        masks.append({
            (x, y)
            for y in range(frame.size[1])
            for x in range(frame.size[0])
            if pixels[x, y][3] >= ALPHA_CUTOFF
        })
    changes = []
    for index, mask in enumerate(masks):
        other = masks[(index + 1) % len(masks)]
        union = mask | other
        changes.append(round(100 * len(mask ^ other) / len(union), 1))
    return changes


def tile_key_maps():
    """Per-tile key for each sheet — which cells are floor and which are wall."""
    maps = []
    for name, path, size in TILE_MAPS:
        image = load(path)
        width, height = image.size
        pixels = image.load()
        lookup = shade_map(image)
        grid = []
        for cy in range(0, height, size):
            row = []
            for cx in range(0, width, size):
                drawn = [
                    pixels[x, y]
                    for y in range(cy, min(cy + size, height))
                    for x in range(cx, min(cx + size, width))
                    if pixels[x, y][3] >= ALPHA_CUTOFF
                ]
                if len(drawn) < (size * size) // 2:
                    row.append(None)
                    continue
                indices = [lookup[px[:3]] for px in drawn]
                row.append(round(sum(indices) / len(indices), 2))
            grid.append(row)
        flat = [cell for row in grid for cell in row if cell is not None]
        maps.append({
            "sheet": name,
            "file": path,
            "tile": size,
            "min_key": min(flat),
            "max_key": max(flat),
            "grid": grid,
        })
    return maps


def flood_clear(image, colour):
    """Make `colour` transparent wherever it is reachable from the border.

    For packs shipped over a baked flat background. Pixels of that colour
    enclosed by the drawing are kept, so a Paper highlight inside a sprite
    survives.
    """
    width, height = image.size
    pixels = image.load()
    out = image.copy()
    target = out.load()
    seen = bytearray(width * height)
    queue = collections.deque()

    def push(x, y):
        if not (0 <= x < width and 0 <= y < height) or seen[y * width + x]:
            return
        if pixels[x, y][3] >= ALPHA_CUTOFF and pixels[x, y] != colour:
            return
        seen[y * width + x] = 1
        queue.append((x, y))

    for x in range(width):
        push(x, 0)
        push(x, height - 1)
    for y in range(height):
        push(0, y)
        push(width - 1, y)
    while queue:
        x, y = queue.popleft()
        push(x + 1, y)
        push(x - 1, y)
        push(x, y + 1)
        push(x, y - 1)
    for y in range(height):
        for x in range(width):
            if seen[y * width + x]:
                target[x, y] = (0, 0, 0, 0)
    return out


def proportion_frame(character, direction):
    """One standing frame, cropped to its own bounding box, stray pixels gone."""
    path, box, head_rows = character["frames"][direction]
    image = load(path)
    if box:
        image = image.crop(box)
    pixels = image.load()
    for y in range(image.size[1]):
        for x in range(image.size[0]):
            if pixels[x, y][:3] in KIT_STRAY:
                pixels[x, y] = (0, 0, 0, 0)
    if character.get("clear"):
        image = flood_clear(image, tuple(character["clear"]))
    image = image.crop(image.getbbox())
    return image, head_rows


def mask_of(image):
    width, height = image.size
    pixels = image.load()
    return {
        (x, y)
        for y in range(height)
        for x in range(width)
        if pixels[x, y][3] >= ALPHA_CUTOFF
    }


def row_spans(image):
    """Left-to-right extent of the drawing on each row, top to bottom."""
    pixels = image.load()
    spans = []
    for y in range(image.size[1]):
        xs = [x for x in range(image.size[0]) if pixels[x, y][3] >= ALPHA_CUTOFF]
        spans.append(max(xs) - min(xs) + 1 if xs else 0)
    return spans


def jaccard(a, b):
    return round(100 * len(a & b) / len(a | b), 1) if a | b else 0.0


def align_bottom_centre(mask, size, target):
    """Shift a mask so its box sits bottom-centred inside `target` (w, h)."""
    dx = (target[0] - size[0]) // 2
    dy = target[1] - size[1]
    return {(x + dx, y + dy) for x, y in mask}


def measure_proportion_frame(image, head_rows):
    width, height = image.size
    mask = mask_of(image)
    spans = row_spans(image)
    head_width = max(spans[:head_rows])
    body_width = max(spans[head_rows:]) if spans[head_rows:] else 0
    mirrored = {(width - 1 - x, y) for x, y in mask}
    return {
        "box": [width, height],
        "drawn_pixels": len(mask),
        "fill_share": round(100 * len(mask) / (width * height), 1),
        "head_rows": head_rows,
        "head_share": round(100 * head_rows / height, 1),
        "heads_tall": round(height / head_rows, 2),
        "head_width": head_width,
        "body_width": body_width,
        "head_to_body_width": round(head_width / body_width, 2) if body_width else None,
        "mirror_match": jaccard(mask, mirrored),
    }


def character_proportions():
    """Proportion and shape of three-quarter-view characters, plus figures.

    Everything here is a count of the artist's pixels or a crop of them. The
    figures move nothing: they place frames side by side, flatten rows below a
    pinned split to a neutral, or show where two silhouettes disagree.
    """
    GENERATED.mkdir(parents=True, exist_ok=True)
    rows = []
    for character in PROPORTION_CHARACTERS:
        slug = character["slug"]
        frames = {}
        for direction in ("down", "up", "side"):
            if direction not in character["frames"]:
                continue
            image, head_rows = proportion_frame(character, direction)
            frames[direction] = (image, head_rows)

        record = {
            "character": character["name"],
            "artist": character["artist"],
            "slug": slug,
            "canvas": character["canvas"],
            "frames": {},
        }
        for direction, (image, head_rows) in frames.items():
            entry = measure_proportion_frame(image, head_rows)
            entry["file"] = character["frames"][direction][0]
            # The single frame, cropped, for the lesson's checker to load as a sample.
            image.save(GENERATED / f"frame-{slug}-{direction}.png")
            entry["figure"] = f"generated/frame-{slug}-{direction}.png"
            record["frames"][direction] = entry

        # Figure 1: the directions side by side, feet on one line.
        gap = 2
        tallest = max(image.size[1] for image, _ in frames.values())
        total = sum(image.size[0] for image, _ in frames.values()) + gap * (len(frames) - 1)
        strip = Image.new("RGBA", (total, tallest), (0, 0, 0, 0))
        cursor = 0
        for direction in ("down", "up", "side"):
            if direction not in frames:
                continue
            image, _ = frames[direction]
            strip.paste(image, (cursor, tallest - image.size[1]), image)
            cursor += image.size[0] + gap
        strip.save(GENERATED / f"proportion-{slug}.png")
        record["strip_figure"] = f"generated/proportion-{slug}.png"

        # Figure 2: the front view with everything below the head split
        # flattened to a neutral, so the split itself is what you see.
        down, head_rows = frames["down"]
        band = down.copy()
        pixels = band.load()
        for y in range(head_rows, band.size[1]):
            for x in range(band.size[0]):
                if pixels[x, y][3] >= ALPHA_CUTOFF:
                    pixels[x, y] = DROPPED
        band.save(GENERATED / f"head-band-{slug}.png")
        record["head_band_figure"] = f"generated/head-band-{slug}.png"

        if "up" in frames:
            up, _ = frames["up"]
            target = (max(down.size[0], up.size[0]), max(down.size[1], up.size[1]))
            front = align_bottom_centre(mask_of(down), down.size, target)
            back = align_bottom_centre(mask_of(up), up.size, target)
            record["front_back_match"] = jaccard(front, back)
            record["front_back_differing_pixels"] = len(front ^ back)

            # Figure 3: front silhouette, back silhouette, and where they differ.
            panel = Image.new("RGBA", (target[0] * 3 + gap * 2, target[1]), (0, 0, 0, 0))
            paint = panel.load()
            for x, y in front:
                paint[x, y] = SILHOUETTE_INK
            for x, y in back:
                paint[x + target[0] + gap, y] = SILHOUETTE_INK
            for x, y in front | back:
                paint[x + 2 * (target[0] + gap), y] = (
                    SILHOUETTE_INK if (x, y) in (front ^ back) else DROPPED
                )
            panel.save(GENERATED / f"front-back-{slug}.png")
            record["front_back_figure"] = f"generated/front-back-{slug}.png"

        if "side" in frames:
            side, _ = frames["side"]
            record["side_to_front_width"] = round(side.size[0] / down.size[0], 2)
            record["head_rows_by_direction"] = {
                direction: frames[direction][1] for direction in frames
            }

        rows.append(record)

    fronts = [row["frames"]["down"] for row in rows]
    summary = {
        "characters": len(rows),
        "frames": sum(len(row["frames"]) for row in rows),
        "head_share_range": [
            min(f["head_share"] for f in fronts), max(f["head_share"] for f in fronts)
        ],
        "heads_tall_range": [
            min(f["heads_tall"] for f in fronts), max(f["heads_tall"] for f in fronts)
        ],
        "fill_range": [
            min(f["fill_share"] for f in fronts), max(f["fill_share"] for f in fronts)
        ],
        "head_to_body_width_range": [
            min(f["head_to_body_width"] for f in fronts),
            max(f["head_to_body_width"] for f in fronts),
        ],
        "mirror_match_range": [
            min(f["mirror_match"] for f in fronts), max(f["mirror_match"] for f in fronts)
        ],
        "front_back_match_range": [
            min(r["front_back_match"] for r in rows if "front_back_match" in r),
            max(r["front_back_match"] for r in rows if "front_back_match" in r),
        ],
        "side_to_front_width_range": [
            min(r["side_to_front_width"] for r in rows if "side_to_front_width" in r),
            max(r["side_to_front_width"] for r in rows if "side_to_front_width" in r),
        ],
    }
    return {"summary": summary, "characters": rows}


def main():
    # The Pokemon-alike sheet has to be cleaned before anything can measure it.
    GENERATED.mkdir(parents=True, exist_ok=True)
    strip_dither_background(
        load("armm1998-topdown/grayscale.png")
    ).save(GENERATED / "topdown-pokemon-like.png")

    kit = kit_crops()
    ramps = pack_ramps()
    records = [measure(*entry, ramps) for entry in MEASURED]
    figures = separation_figures()
    crops = kit
    contrasts = contrast_table()
    maps = tile_key_maps()
    shapes = character_shapes()
    proportions = character_proportions()
    frame_change = {
        "fluffy-walk": frame_silhouette_change(
            "kiira-gb-platformer/fluffy/idle-walk-strip5.png", 5
        ),
        "fluffy-roll": frame_silhouette_change(
            "kiira-gb-platformer/fluffy/roll-strip4.png", 4
        ),
    }
    payload = {
        "generated_by": "assets/build-examples.py",
        "separation_source": SEPARATION_SOURCE,
        "figures": [{"role": role, "file": f"generated/{name}"} for role, name in figures],
        "kit_crops": crops,
        "figure_ground_contrast": contrasts,
        "tile_key_maps": maps,
        "character_shapes": shapes,
        "character_proportions": proportions,
        "frame_silhouette_change": frame_change,
        "measurements": records,
    }
    (EXAMPLES / "measurements.json").write_text(json.dumps(payload, indent=2) + "\n")

    header = f"{'asset':<26} {'kind':<12} {'key':>5}  " + \
             "  ".join(f"{n:>6}" for n in ("paper", "light", "dark", "ink")) + "   outline"
    print(header)
    print("-" * len(header))
    for record in records:
        shares = {shade["index"]: shade["share"] for shade in record["shades"]}
        cells = "  ".join(
            f"{shares[i]:5.1f}%" if i in shares else "    — " for i in range(4)
        )
        edge = record["edge_pixels"]
        ink = record["ink_edge_pixels"]
        outline = f"{100 * ink / edge:.0f}%" if edge else "n/a"
        print(
            f"{record['name']:<26} {record['kind']:<12} {record['key']:5.2f}  "
            f"{cells}   {outline:>7}"
        )

    print()
    print("figure-ground silhouette contrast (higher reads better; below ~1 vanishes)")
    for row in contrasts:
        print(
            f"  {row['pair']:<32} ground key {row['ground_key']:.2f}   "
            f"dark outline {row['as_drawn']:.2f}   light outline {row['inverted']:.2f}"
        )
    print()
    print("character shape (cropped to the sprite's own bounding box)")
    print(f"  {'character':<14} {'box':>7} {'fill':>6} {'outline':>8} {'holes':>6}   interior mix")
    for row in shapes:
        mix = " ".join(f"{share:5.1f}%" for share in row["interior_mix"])
        print(
            f"  {row['character']:<14} {row['box'][0]:>3}x{row['box'][1]:<3} "
            f"{row['fill_share']:>5.1f}% {row['edge_share']:>7.1f}% "
            f"{row['enclosed_holes']:>6}   {mix}"
        )
    print()
    print("three-quarter proportion (standing frame, cropped to its own box; head split pinned)")
    print(f"  {'character':<14} {'dir':<5} {'box':>7} {'fill':>6} {'head':>5} {'share':>6} "
          f"{'heads':>6} {'hd/bd':>6} {'mirror':>7}")
    for row in proportions["characters"]:
        for direction, f in row["frames"].items():
            print(
                f"  {row['character']:<14} {direction:<5} {f['box'][0]:>3}x{f['box'][1]:<3} "
                f"{f['fill_share']:>5.1f}% {f['head_rows']:>5} {f['head_share']:>5.1f}% "
                f"{f['heads_tall']:>6.2f} {f['head_to_body_width']:>6.2f} {f['mirror_match']:>6.1f}%"
            )
        extra = []
        if "front_back_match" in row:
            extra.append(f"front/back match {row['front_back_match']}%")
        if "side_to_front_width" in row:
            extra.append(f"side/front width {row['side_to_front_width']}")
        if extra:
            print("  " + " " * 14 + "  " + ", ".join(extra))
    print("  summary:", json.dumps(proportions["summary"]))
    print()
    for name, changes in frame_change.items():
        print(f"  {name}: silhouette changes {changes} % between frames")

    print()
    for entry in maps:
        print(
            f"per-tile key, {entry['sheet']} sheet ({entry['tile']}px): "
            f"{entry['min_key']:.2f} to {entry['max_key']:.2f}"
        )


if __name__ == "__main__":
    main()
