#!/usr/bin/env python3
from __future__ import annotations

import argparse
from collections import deque
from pathlib import Path

import numpy as np
from PIL import Image, ImageDraw, ImageFont


ROWS = ["down", "up", "right", "left"]


def resize_rgba_alpha_aware(image: Image.Image, size: tuple[int, int]) -> Image.Image:
    rgba = image.convert("RGBA")
    arr = np.asarray(rgba).astype(np.float32)
    alpha = arr[:, :, 3:4] / 255.0
    premultiplied = arr[:, :, :3] * alpha
    premul_image = Image.fromarray(np.clip(premultiplied, 0, 255).astype(np.uint8), "RGB")
    alpha_image = Image.fromarray(arr[:, :, 3].astype(np.uint8), "L")
    resized_premul = np.asarray(premul_image.resize(size, Image.Resampling.LANCZOS)).astype(np.float32)
    resized_alpha = np.asarray(alpha_image.resize(size, Image.Resampling.LANCZOS)).astype(np.float32)
    alpha_fraction = resized_alpha[:, :, None] / 255.0
    rgb = np.zeros_like(resized_premul)
    np.divide(resized_premul, alpha_fraction, out=rgb, where=alpha_fraction > 0.001)
    output = np.dstack([np.clip(rgb, 0, 255), resized_alpha])
    return Image.fromarray(np.clip(output, 0, 255).astype(np.uint8), "RGBA")


def largest_component_mask(alpha: np.ndarray, threshold: int = 8) -> np.ndarray:
    foreground = alpha > threshold
    visited = np.zeros(foreground.shape, dtype=bool)
    best: list[tuple[int, int]] = []
    height, width = foreground.shape
    for y in range(height):
        for x in range(width):
            if not foreground[y, x] or visited[y, x]:
                continue
            component: list[tuple[int, int]] = []
            queue: deque[tuple[int, int]] = deque([(y, x)])
            visited[y, x] = True
            while queue:
                cy, cx = queue.popleft()
                component.append((cy, cx))
                for ny, nx in ((cy - 1, cx), (cy + 1, cx), (cy, cx - 1), (cy, cx + 1)):
                    if 0 <= ny < height and 0 <= nx < width and foreground[ny, nx] and not visited[ny, nx]:
                        visited[ny, nx] = True
                        queue.append((ny, nx))
            if len(component) > len(best):
                best = component
    mask = np.zeros(foreground.shape, dtype=bool)
    for y, x in best:
        mask[y, x] = True
    return mask


def keep_largest_component(cell: Image.Image, dilate: int) -> Image.Image:
    arr = np.asarray(cell.convert("RGBA")).copy()
    keep = largest_component_mask(arr[:, :, 3], threshold=10)
    for _ in range(dilate):
        expanded = keep.copy()
        expanded[:-1, :] |= keep[1:, :]
        expanded[1:, :] |= keep[:-1, :]
        expanded[:, :-1] |= keep[:, 1:]
        expanded[:, 1:] |= keep[:, :-1]
        keep = expanded
    arr[:, :, 3] = np.where(keep, arr[:, :, 3], 0).astype(np.uint8)
    return Image.fromarray(arr, "RGBA")


def remove_magenta_contamination(cell: Image.Image) -> Image.Image:
    arr = np.asarray(cell.convert("RGBA")).copy()
    rgb = arr[:, :, :3].astype(np.int16)
    alpha = arr[:, :, 3]
    magenta = (alpha > 0) & (rgb[:, :, 0] > 120) & (rgb[:, :, 1] < 80) & (rgb[:, :, 2] > 120)
    if magenta.any():
        arr[:, :, 3] = np.where(magenta, 0, alpha).astype(np.uint8)
    return Image.fromarray(arr, "RGBA")


def fit_cell(
    cell: Image.Image,
    *,
    frame_size: int,
    target_body_height: int,
    target_foot_y: int,
    component_dilate: int,
) -> Image.Image:
    cell = remove_magenta_contamination(keep_largest_component(cell, component_dilate))
    bbox = cell.getchannel("A").getbbox()
    if bbox is None:
        return Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0))
    trimmed = cell.crop(bbox)
    scale = min(target_body_height / trimmed.height, (frame_size - 8) / trimmed.width, 1.0)
    next_size = (max(1, round(trimmed.width * scale)), max(1, round(trimmed.height * scale)))
    resized = resize_rgba_alpha_aware(trimmed, next_size)
    output = Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0))
    x = (frame_size - resized.width) // 2
    y = target_foot_y - resized.height
    y = max(0, min(frame_size - resized.height, y))
    output.alpha_composite(resized, (x, y))
    return output


def extract_row(path: Path, args: argparse.Namespace) -> list[Image.Image]:
    image = Image.open(path).convert("RGBA")
    width, height = image.size
    x_edges = [round(i * width / args.columns) for i in range(args.columns + 1)]
    frames: list[Image.Image] = []
    for column in range(args.columns):
        cell = image.crop((x_edges[column], 0, x_edges[column + 1], height))
        frames.append(
            fit_cell(
                cell,
                frame_size=args.frame_size,
                target_body_height=args.target_body_height,
                target_foot_y=args.target_foot_y,
                component_dilate=args.component_dilate,
            )
        )
    return align_frames_to_neutral_upper_body(frames)


def shift_frame_horizontally(frame: Image.Image, offset: int) -> Image.Image:
    if offset == 0:
        return frame
    shifted = Image.new("RGBA", frame.size, (0, 0, 0, 0))
    shifted.alpha_composite(frame, (offset, 0))
    return shifted


def align_frames_to_neutral_upper_body(frames: list[Image.Image], max_shift: int = 8) -> list[Image.Image]:
    """Undo per-frame recentering caused by wider walking-leg silhouettes."""
    if not frames:
        return frames
    upper_end = round(frames[0].height * 0.60)
    neutral = np.asarray(frames[0].convert("RGBA"))[:upper_end, :, 3] > 20
    aligned = [frames[0]]
    for frame in frames[1:]:
        candidate = np.asarray(frame.convert("RGBA"))[:upper_end, :, 3] > 20
        best_score = float("inf")
        best_shift = 0
        for offset in range(-max_shift, max_shift + 1):
            shifted = np.zeros_like(candidate)
            if offset < 0:
                shifted[:, :offset] = candidate[:, -offset:]
            elif offset > 0:
                shifted[:, offset:] = candidate[:, :-offset]
            else:
                shifted = candidate
            union = np.logical_or(neutral, shifted)
            score = float(np.logical_xor(neutral, shifted).sum() / union.sum()) if union.any() else 0.0
            if score < best_score:
                best_score = score
                best_shift = offset
        aligned.append(shift_frame_horizontally(frame, best_shift if abs(best_shift) >= 2 else 0))
    return aligned


def checkerboard(width: int, height: int, tile: int = 8) -> Image.Image:
    image = Image.new("RGBA", (width, height), (226, 226, 226, 255))
    draw = ImageDraw.Draw(image)
    for y in range(0, height, tile):
        for x in range(0, width, tile):
            if (x // tile + y // tile) % 2 == 0:
                draw.rectangle((x, y, x + tile - 1, y + tile - 1), fill=(248, 248, 248, 255))
    return image


def save_review(sheet: Image.Image, path: Path, *, frame_size: int, columns: int) -> None:
    scale = 2
    label_width = 68
    strip_height = frame_size * scale
    canvas = Image.new("RGB", (label_width + sheet.width * scale, strip_height * 4), (246, 247, 250))
    draw = ImageDraw.Draw(canvas)
    font = ImageFont.load_default()
    for row, name in enumerate(ROWS):
        y = row * strip_height
        draw.text((8, y + strip_height // 2 - 5), name, fill=(28, 32, 36), font=font)
        row_sheet = sheet.crop((0, row * frame_size, sheet.width, (row + 1) * frame_size))
        row_preview = row_sheet.resize((sheet.width * scale, strip_height), Image.Resampling.NEAREST)
        bg = checkerboard(row_preview.width, row_preview.height, 16)
        bg.alpha_composite(row_preview)
        canvas.paste(bg.convert("RGB"), (label_width, y))
        for index in range(columns + 1):
            x = label_width + index * frame_size * scale
            draw.line((x, y, x, y + strip_height), fill=(205, 60, 60), width=1)
    path.parent.mkdir(parents=True, exist_ok=True)
    canvas.save(path)


def save_feet_zoom(sheet: Image.Image, path: Path, *, frame_size: int, columns: int) -> None:
    scale = 4
    crop_y0 = round(frame_size * 0.49)
    crop_h = frame_size - crop_y0
    label_w = 64
    label_h = 22
    cell_w = frame_size * scale
    cell_h = crop_h * scale
    canvas = Image.new("RGB", (label_w + cell_w * columns, label_h + cell_h * 4), (246, 247, 250))
    draw = ImageDraw.Draw(canvas)
    font = ImageFont.load_default()
    for column in range(columns):
        draw.text((label_w + column * cell_w + 4, 5), f"F{column + 1}", fill=(72, 76, 82), font=font)
    for row, row_name in enumerate(ROWS):
        y = label_h + row * cell_h
        draw.text((6, y + cell_h // 2 - 5), row_name, fill=(28, 32, 36), font=font)
        for column in range(columns):
            cell = sheet.crop(
                (
                    column * frame_size,
                    row * frame_size + crop_y0,
                    (column + 1) * frame_size,
                    (row + 1) * frame_size,
                )
            ).resize((cell_w, cell_h), Image.Resampling.NEAREST)
            bg = checkerboard(cell_w, cell_h, 20)
            bg.alpha_composite(cell)
            x = label_w + column * cell_w
            canvas.paste(bg.convert("RGB"), (x, y))
            draw.rectangle((x, y, x + cell_w - 1, y + cell_h - 1), outline=(188, 194, 202), width=1)
    path.parent.mkdir(parents=True, exist_ok=True)
    canvas.save(path)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Assemble four WhaleTown direction strips into an 8x4 spritesheet.")
    parser.add_argument("--down", type=Path, required=True)
    parser.add_argument("--up", type=Path, required=True)
    parser.add_argument("--right", type=Path, required=True)
    parser.add_argument("--left", type=Path, required=True)
    parser.add_argument("--name", required=True)
    parser.add_argument("--output-dir", type=Path, required=True)
    parser.add_argument("--frame-size", type=int, default=160)
    parser.add_argument("--columns", type=int, default=8)
    parser.add_argument("--target-body-height", type=int, default=116)
    parser.add_argument("--target-foot-y", type=int, default=137)
    parser.add_argument("--component-dilate", type=int, default=2)
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    inputs = {"down": args.down, "up": args.up, "right": args.right, "left": args.left}
    for name, path in inputs.items():
        if not path.exists():
            raise FileNotFoundError(f"{name} strip not found: {path}")

    sheet = Image.new("RGBA", (args.frame_size * args.columns, args.frame_size * 4), (0, 0, 0, 0))
    for row, direction in enumerate(ROWS):
        frames = extract_row(inputs[direction], args)
        for column, frame in enumerate(frames):
            sheet.alpha_composite(frame, (column * args.frame_size, row * args.frame_size))

    processed_dir = args.output_dir / "processed"
    review_dir = args.output_dir / "review"
    processed_dir.mkdir(parents=True, exist_ok=True)
    review_dir.mkdir(parents=True, exist_ok=True)

    sheet_path = processed_dir / f"{args.name}_spritesheet.png"
    review_path = review_dir / f"{args.name}_review.png"
    feet_path = review_dir / f"{args.name}_feet_zoom.png"

    sheet.save(sheet_path)
    save_review(sheet, review_path, frame_size=args.frame_size, columns=args.columns)
    save_feet_zoom(sheet, feet_path, frame_size=args.frame_size, columns=args.columns)

    print(sheet_path)
    print(review_path)
    print(feet_path)


if __name__ == "__main__":
    main()
