import math
import PIL
from PIL.Image import Image
from functools import lru_cache

RGB = tuple[int, int, int]

# Load image, palette
img: Image = PIL.Image.open('peacock-bayer.png')

# Get palette info
assert img.mode == 'P'
raw_pal = img.getpalette()
palette: list[RGB] = []
for i in range(0, len(raw_pal), 3):
    palette.append(tuple(raw_pal[i:i+3]))
palette_indexes = {c: i for i, c in enumerate(palette)}

# Get darkest/lightest colors
@lru_cache
def brightness(c: RGB):
    return c[0]*0.3 + c[1]*0.6 + c[2]*0.1

darkest_color = min(palette, key=brightness)
lightest_color = max(palette, key=brightness)

@lru_cache
def distance_sq(a: RGB, b: RGB) -> int:
    result = 0
    for i in range(3):
        result += (a[i] - b[i])**2
    return result

def nearest_color(color: RGB, colors: list[RGB]) -> RGB:
    return min(colors, key=lambda c: distance_sq(c, color))

def choose_color_pair_kmeans(counts: dict[RGB,int]) -> tuple[RGB, RGB]:
    colors = [k for k, v in counts.items() if v > 0]
    total_count = sum(counts.values())
    if len(colors) == 0:
        raise ValueError('no colors present')
    elif len(colors) == 1:
        return colors[0], None

    def weighted_avg(colors: list[RGB]) -> RGB:
        result = [0, 0, 0]
        total_weight = 0
        for color in colors:
            weight = counts[color]
            total_weight += weight
            for i in range(3):
                result[i] += weight*color[i]
        if total_weight == 0:
            total_weight += 1 # Hack to handle division-by-zero
        return tuple(round(x/total_weight) for x in result)

    # k-means to choose the two colors
    old_result = (None, None)
    result = (min(colors, key=brightness), max(colors, key=brightness))
    print(f"k-means using {counts=}")
    print(f"starting value: {result}")
    while True:
        lists = ([], [])
        for color in colors:
            d_dark = distance_sq(color, result[0])
            d_light = distance_sq(color, result[1])
            if d_dark < d_light:
                lists[0].append(color)
            else:
                lists[1].append(color)
        old_result = result
        print(f"{lists=}")
        result = tuple(weighted_avg(cs) for cs in lists)
        print(f"updated: {result}")
        if old_result == result:
            break
    return tuple(nearest_color(x, palette) for x in result)


def choose_color_pair_luminance(counts: dict[RGB, int]) -> tuple[RGB,RGB]:
    # Calculate brightness threshold
    mean_brightness = 0
    total_count = 0
    for rgb, count in counts.items():
        mean_brightness += brightness(rgb)*count
        total_count += count
    mean_brightness = mean_brightness/total_count

    # Separate colors into two lists
    lists = ([], [])
    for color in counts.keys():
        if brightness(color) <= mean_brightness:
            lists[0].append(color)
        else:
            lists[1].append(color)
    print(lists)

    # Find least-bad approximations for each color
    def choose_color(cs: list[RGB]) -> RGB:
        best = None
        best_cost = math.inf
        for rgb in palette:
            cost = sum(distance_sq(rgb, c) * counts[c] for c in cs)
            if cost < best_cost:
                best = rgb
                best_cost = cost
        return best
    dark, light = tuple(choose_color(l) for l in lists)
    if dark == light:
        light = None
    print(dark, light)
    return dark, light


def two_tone(img: Image, xy1: tuple[int,int] = None, xy2: tuple[int,int] = None) -> None:
    # choose the two colors
    x1, y1 = xy1 or (0, 0)
    x2, y2 = xy2 or (img.width, img.height)
    x1 = max(x1, 0)
    y1 = max(y1, 0)
    x2 = min(x2, img.width)
    y2 = min(y2, img.height)
    counts = {}
    for x in range(x1, x2):
        for y in range(y1, y2):
            color = palette[img.getpixel((x,y))]
            if color not in counts:
                counts[color] = 0
            counts[color] += 1
    #dark, light = choose_color_pair_luminance(counts)
    print(f"choosing color pair for {xy1=}")
    dark, light = choose_color_pair_kmeans(counts)
    print(f"final: {dark=}, {light=}\n")
    if light == None:
        # Only one color, *ought* to be a no-op
        light = dark
    colors = [dark, light]

    # change each pixel's color to nearest
    for x in range(x1, x2):
        for y in range(y1, y2):
            color = palette[img.getpixel((x, y))]
            replacement = nearest_color(color, colors)
            img.putpixel((x, y), palette_indexes[replacement])


BLOCK_SIZE = 10
blocked = img.copy()
for x in range(0, img.width, BLOCK_SIZE):
    for y in range(0, img.height, BLOCK_SIZE):
        two_tone(blocked, (x, y), (x + BLOCK_SIZE, y + BLOCK_SIZE))
blocked.save("blocked.png")
for x in range(5):
    for y in range(5):
        blocked.putpixel((x, y), 0)
