"""Draws each voice's resting 2D head with a bare screen: the eyes painted into the screen are filled with the screen's own colour,
so the eye layer alone shows the eyes and a blink changes only that layer, never the head or its screen.

Run with python3 scripts/mascots/bare_heads.py. Writes <voice>_head_bare.png beside each rig's <voice>_head_rest.png, and
<voice>_head_moving.png, the head the moves swap in, with the same screen.
"""
from collections import deque
from pathlib import Path

from PIL import Image

RIGS = Path(__file__).resolve().parents[2] / "src/web/public/voices/rigs"
VOICES = ("butler", "coach", "colleague", "homie", "squire")
SMALLEST = 150
SOLID = 0.4


def dark(pixel):
    return pixel[3] > 200 and sum(pixel[:3]) < 140


# The pieces of the image that are dark and large enough to be a screen or a lens, each as its set of points.
def screens(image):
    width, height = image.size
    px = image.load()
    seen, found = set(), []
    for y in range(height):
        for x in range(width):
            if (x, y) in seen or not dark(px[x, y]):
                continue
            queue, points = deque([(x, y)]), set()
            seen.add((x, y))
            while queue:
                a, b = queue.popleft()
                points.add((a, b))
                for c, d in ((a + 1, b), (a - 1, b), (a, b + 1), (a, b - 1)):
                    if 0 <= c < width and 0 <= d < height and (c, d) not in seen and dark(px[c, d]):
                        seen.add((c, d))
                        queue.append((c, d))
            xs, ys = [p[0] for p in points], [p[1] for p in points]
            box = (max(xs) - min(xs) + 1) * (max(ys) - min(ys) + 1)
            # A screen or a lens is a solid dark field; the thin outline ring round an ear cup is not.
            if len(points) >= SMALLEST and len(points) > SOLID * box:
                found.append(points)
    return found


# The points a screen encloses without being part of it: the eyes painted on it, reached from nowhere outside the screen's box.
def holes(points):
    xs, ys = [x for x, _ in points], [y for _, y in points]
    left, right, top, bottom = min(xs) - 1, max(xs) + 1, min(ys) - 1, max(ys) + 1
    outside, queue = set(), deque([(left, top)])
    while queue:
        x, y = queue.popleft()
        if (x, y) in outside or (x, y) in points or not (left <= x <= right and top <= y <= bottom):
            continue
        outside.add((x, y))
        queue.extend(((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)))
    return {(x, y) for x in range(left, right + 1) for y in range(top, bottom + 1)} - points - outside


# A painted eye is white or grey with no colour to it, or cut clear through; a lavender ear cup inside a ring is neither.
def colourless(pixel):
    return pixel[3] == 0 or max(pixel[:3]) - min(pixel[:3]) < 40


def bare(voice):
    image = Image.open(RIGS / voice / f"{voice}_head_rest.png").convert("RGBA")
    px = image.load()
    for points in screens(image):
        eyes = {point for point in holes(points) if colourless(px[point])}
        if not eyes:
            continue
        tones = sorted(px[x, y] for x, y in points)
        tone = tones[len(tones) // 2]
        whole = points | eyes
        # The screen's inside is laid flat in its own tone, so no ghost of the painted eyes is left; its rim keeps its antialiasing.
        inside = {(x, y) for x, y in whole if all(neighbour in whole for neighbour in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)))}
        for x, y in eyes | inside:
            px[x, y] = tone
    image.save(RIGS / voice / f"{voice}_head_bare.png", optimize=True)


# The head every move but the rest swaps in (the "complete" head) carries the very screen of the resting head, pixel for pixel, so
# no move and no blink can change the screen's size: its own screen is cleared to the head's colour round it, then the bare resting
# head's screen is laid over it.
def same_screen(voice):
    rest = Image.open(RIGS / voice / f"{voice}_head_bare.png").convert("RGBA")
    whole = Image.open(RIGS / voice / f"{voice}_head.png").convert("RGBA")
    rpx, wpx = rest.load(), whole.load()
    for points in screens(whole):
        filled = points | holes(points)
        xs, ys = [x for x, _ in filled], [y for _, y in filled]
        ring = [wpx[x, y] for x in range(min(xs) - 3, max(xs) + 4) for y in range(min(ys) - 3, max(ys) + 4)
                if (x, y) not in filled and wpx[x, y][3] > 200 and sum(wpx[x, y][:3]) > 500]
        skin = sorted(ring)[len(ring) // 2] if ring else (243, 240, 230, 255)
        for x, y in filled:
            wpx[x, y] = skin
    for points in screens(rest):
        for x, y in points | holes(points):
            wpx[x, y] = rpx[x, y]
    whole.save(RIGS / voice / f"{voice}_head_moving.png", optimize=True)
    boxes = [sorted(box(points) for points in screens(image)) for image in (rest, whole)]
    if boxes[0] != boxes[1]:
        raise ScreenMoved.of(voice, *boxes)


def box(points):
    xs, ys = [x for x, _ in points], [y for _, y in points]
    return min(xs), min(ys), max(xs) - min(xs) + 1, max(ys) - min(ys) + 1


class ScreenMoved(Exception):
    @classmethod
    def of(cls, voice, rest, moving):
        return cls(f"{voice}: the moving head's screen {moving} is not the resting head's {rest}")


if __name__ == "__main__":
    for voice in VOICES:
        bare(voice)
        same_screen(voice)
    print("drew bare and moving heads for", ", ".join(VOICES))
