#!/usr/bin/env python3
"""Server-side WhaleTown V2 skin generation runner.

The NestJS skin generation service owns the API key and calls this script as a
worker. Novamailio generates three canonical poses for front/back and eight
frames for side views. The worker expands canonical poses deterministically,
uses the cutout tool, then assembles a transparent 8x4 spritesheet.
"""

from __future__ import annotations

import argparse
import json
import os
import re
import signal
import subprocess
import sys
import time
from pathlib import Path
from typing import Iterable

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

signal.signal(signal.SIGPIPE, signal.SIG_IGN)

DIRECTIONS = ("down", "up", "right", "left")
SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_NOVAMAILIO_SCRIPT = SCRIPT_DIR / "tools" / "novamailio_image_gen.py"
DEFAULT_ASSEMBLE_SCRIPT = SCRIPT_DIR / "tools" / "assemble_direction_strips.py"
DEFAULT_CUTOUT_SCRIPT = SCRIPT_DIR / "tools" / "birefnet_cutout.py"
DEFAULT_EXPAND_POSE_TRIPLET_SCRIPT = SCRIPT_DIR / "tools" / "expand_pose_triplet.py"
DEFAULT_REFERENCE_STRIPS_DIR = SCRIPT_DIR / "references"
DEFAULT_IDENTITY_REFERENCE_IMAGE = (
    DEFAULT_REFERENCE_STRIPS_DIR / "whaleboy_reference_down.png"
)
FRAME_SIZE = 160
SPRITESHEET_COLUMNS = 8
SPRITESHEET_ROWS = 4
REFERENCE_CANVAS_SIZE = (1536, 1024)
SINGLE_POSE_GENERATION_SIZE = "832x832"
SINGLE_POSE_REFERENCE_SIZE = 1024
SINGLE_POSE_REFERENCE_BODY_HEIGHT = 700
LOWER_BODY_EDIT_START_RATIO = 0.68
LOWER_BODY_EDIT_FEATHER_RATIO = 0.025
LOWER_BODY_EDIT_TOP_HALF_WIDTH_RATIO = 0.18
LOWER_BODY_EDIT_BOTTOM_HALF_WIDTH_RATIO = 0.34
MIN_BODY_WIDTH_BY_ROW = {
    "down": 73,
    "up": 76,
    "right": 82,
    "left": 75,
}
TARGET_BODY_HEIGHT = 116
TARGET_FOOT_Y = 137
MAX_ROW_WIDTH_DEVIATION = 8
MAX_UPPER_SILHOUETTE_XOR = 0.07
MAX_UPPER_RGB_MAD = 34.0
MIN_LOWER_MOTION_XOR = 0.04
MIN_OPPOSITE_POSE_XOR = 0.10
LOWER_PALETTE_START_Y_BY_DIRECTION = {"down": 115, "up": 105, "right": 110, "left": 110}
MAX_LOWER_PALETTE_DISTANCE_BY_DIRECTION = {
    "down": 0.22,
    "up": 0.28,
    "right": 0.35,
    "left": 0.35,
}
MAX_ACTION_PALETTE_DISTANCE_BY_DIRECTION = {"up": 0.14, "right": 0.16, "left": 0.16}

TARGET_SPECS_BY_DIRECTION = {
    "down": {
        "body_width": 73,
        "body_width_range": "72-76 px",
        "body_height": 116,
        "foot_y": 137,
        "description": "front view should preserve the identity master's head silhouette, readable shoulders, clothing outline, and two visible shoes; it must not be a narrow center figure",
    },
    "up": {
        "body_width": 76,
        "body_width_range": "74-78 px",
        "body_height": 116,
        "foot_y": 137,
        "description": "back view should preserve the identity master's head silhouette, upper-body shape, readable shoulders, clothing outline, and two visible shoes; it must not shrink compared with the front reference",
    },
    "right": {
        "body_width": 82,
        "body_width_range": "80-84 px",
        "body_height": 116,
        "foot_y": 137,
        "description": "right side view should match the side reference's body envelope, torso, legs, and shoe length while preserving the identity master's head and clothing exactly",
    },
    "left": {
        "body_width": 75,
        "body_width_range": "74-78 px",
        "body_height": 116,
        "foot_y": 137,
        "description": "left side view should match the side reference's body envelope, torso, legs, and shoe length while preserving the identity master's head and clothing exactly",
    },
}
DIRECTION_REFERENCE_ROWS = {
    "down": "DOWN / FRONT",
    "up": "UP / BACK",
    "right": "RIGHT",
    "left": "LEFT",
}
DIRECTION_LOCKS = {
    "down": "The result must read as a clear front view: visible eyes, face, front torso, and two front-facing shoes. Do not turn the character away from the viewer.",
    "up": "The result must read as a clear back view: no visible eyes, no face, no front chest details, and no front-facing shoes. Show back hair/head silhouette, back torso, and back-view shoes only.",
    "right": "The result must read as a clear right-facing side view: face profile, torso, knees, and shoes point screen-right. Do not show a front view or back view.",
    "left": "The result must read as a clear left-facing side view: face profile, torso, knees, and shoes point screen-left. Do not show a front view or back view.",
}
FOOT_TEMPLATE_LOCKS = {
    "down": "Copy the lower-body timing from Image 2 column by column. This is a walk FORWARD TOWARD THE VIEWER, never a sideways walk. Screen-left and screen-right identify which leg lifts forward in depth; they are not movement directions. Both ankles stay in their narrow vertical lanes under their hips. Mandatory state sequence: column 2/6 = [screen-left leg lifts FORWARD toward camera, screen-right leg neutral]; column 4/8 = [screen-left leg neutral, screen-right leg lifts FORWARD toward camera]. The lifted shoe may look larger/wider from foreshortening, but its center must remain under the same hip. Do not invent a lower/higher relationship that differs from Image 2.",
    "up": "Copy the lower-body timing from Image 2 column by column: columns 1/3/5/7 are neutral back-view shoes; columns 2/6 lift the shoe on the viewer's RIGHT side and show its dark sole like Image 2; columns 4/8 lift the shoe on the viewer's LEFT side and show its dark sole like Image 2.",
    "right": "Copy the lower-body timing from Image 2 column by column: columns 1/3/5/7 are neutral side standing; columns 2/6 use the same forward step toward screen-right as Image 2; columns 4/8 use the alternate-leg forward step toward screen-right as Image 2.",
    "left": "Copy the lower-body timing from Image 2 column by column: columns 1/3/5/7 are neutral side standing; columns 2/6 use the same forward step toward screen-left as Image 2; columns 4/8 use the alternate-leg forward step toward screen-left as Image 2.",
}
REFERENCE_IDENTITY_BAN = """
Image 2 identity ban:
- Image 2 is NOT a character reference. It is only a walking-pose reference.
- Never copy Image 2's hair color, hairstyle, face, head shape, hood, clothing, clothing colors, accessories, body identity, or character design.
- Never copy Image 2's black hair, white hoodie, black pants, or any other visible outfit/color details unless they are also clearly present in Image 1.
- The generated character must keep Image 1's identity in every direction, including the back view and side views.
- For back and side views, infer the back/side appearance of Image 1's hair and outfit. Do not replace them with Image 2's hair or outfit.
- If the output looks like Image 2's character with Image 1's pose or colors, the result is invalid.
""".strip()
IMAGE1_POSE_BAN = """
Image 1 pose ban:
- Image 1 provides appearance identity only: face design, hair design and color, skin tone, outfit design and colors, outline style, and rendering style.
- Ignore Image 1's body pose, leg pose, knee position, ankle position, shoe position, foot spacing, stance, and animation timing completely.
- Never preserve or copy Image 1's standing pose into the output motion frames.
- When Image 1 and Image 2 disagree about any pose or body-part position, Image 2 always wins.
- You are required to move and redraw the legs and shoes away from Image 1's positions so that they match Image 2's corresponding column.
""".strip()
COLUMN_ACTION_TABLES = {
    "down": """
Column action table for DOWN / FRONT:
- Column 1: neutral front stance, both front-facing shoes close together.
- Column 2: active step A. The SCREEN-LEFT LEG lifts FORWARD IN DEPTH toward the viewer/camera. Its shoe becomes slightly larger/wider because of perspective, but its ankle and shoe center remain directly below the screen-left hip. The screen-right leg stays neutral below the screen-right hip. Do not move either foot sideways. Do not force either shoe lower; preserve Image 2's actual vertical positions.
- Column 3: neutral front stance, visually matching column 1.
- Column 4: active step B. The screen-left leg stays neutral below the screen-left hip. The SCREEN-RIGHT LEG lifts FORWARD IN DEPTH toward the viewer/camera. Its shoe becomes slightly larger/wider because of perspective, but its ankle and shoe center remain directly below the screen-right hip. Do not move either foot sideways. Do not copy column 2's screen-left-leg action into this column. Do not force either shoe lower; preserve Image 2's actual vertical positions.
- Column 5: neutral front stance, visually matching column 1.
- Column 6: repeat column 2 exactly. Screen-left leg lifts forward toward camera without lateral displacement; screen-right leg remains neutral.
- Column 7: neutral front stance, visually matching column 1.
- Column 8: repeat column 4 exactly. Screen-right leg lifts forward toward camera without lateral displacement; screen-left leg remains neutral. Never reuse column 2's screen-left-leg action here.
- Mandatory screen-space sequence from column 1 through 8: NONE, LEFT, NONE, RIGHT, NONE, LEFT, NONE, RIGHT.
- The loop is exactly two repetitions of the same four-pose cycle. After drawing columns 1-4, duplicate their pose geometry into columns 5-8: column 5 pose = column 1 pose, column 6 pose = column 2 pose, column 7 pose = column 3 pose, and column 8 pose = column 4 pose. Only tiny rendering variation is acceptable; the active leg, ankle location, shoe size, shoe spacing, and outer silhouette must repeat.
- Here LEFT and RIGHT name the active leg only. They never mean that a foot travels toward the left or right edge of the cell.
- Lateral spacing lock: keep the two ankles almost as close together as in neutral columns. In the final 160x160 frame, an active shoe center may shift horizontally no more than about 3 px from that same leg's neutral shoe center. Do not splay the legs, make a V stance, or increase the gap between the feet.
- Perspective test: the active motion must read as one foot coming toward the camera through foreshortening and visible shoe-top/sole shape, while the legs remain narrow and nearly vertical.
- A result where the feet step outward to screen-left or screen-right is invalid, even if the correct leg alternates.
- Do not copy one active pose into columns 2, 4, 6, and 8. Alternating which leg lifts forward is mandatory.
""",
    "up": """
Column action table for UP / BACK:
- Column 1: neutral back stance, both small back-view shoes close together.
- Column 2: active step A. The lifted dark sole must appear in the RIGHT HALF of this cell, to the right of the character's vertical centerline. The other shoe remains in the left half on the baseline.
- Column 3: neutral back stance, visually matching column 1.
- Column 4: active step B. The lifted dark sole must appear in the LEFT HALF of this cell, to the left of the character's vertical centerline. The other shoe remains in the right half on the baseline. This must be the opposite side from column 2.
- Column 5: neutral back stance, visually matching column 1.
- Column 6: repeat column 2. The lifted dark sole must be in the RIGHT HALF of the cell.
- Column 7: neutral back stance, visually matching column 1.
- Column 8: repeat column 4. The lifted dark sole must be in the LEFT HALF of the cell.
- Mandatory spatial sequence of lifted dark soles from left to right: none, RIGHT, none, LEFT, none, RIGHT, none, LEFT.
- Do not lift the same screen-side shoe in columns 2, 4, 6, and 8. That result is invalid.
""",
    "right": """
Column action table for RIGHT:
- Column 1: neutral side stance, both shoes close under the body, toes point screen-right.
- Column 2: active step A. One shoe steps forward to screen-right with a visibly longer stride like Image 2. This column must not look neutral.
- Column 3: neutral side stance, visually matching column 1.
- Column 4: active step B. The opposite leg steps forward to screen-right like Image 2. This must be the alternate leg from column 2 and must not look neutral.
- Column 5: neutral side stance, visually matching column 1.
- Column 6: repeat active step A from column 2.
- Column 7: neutral side stance, visually matching column 1.
- Column 8: repeat active step B from column 4.
""",
    "left": """
Column action table for LEFT:
- Column 1: neutral side stance, both shoes close under the body, toes point screen-left.
- Column 2: active step A. One shoe steps forward to screen-left with a visibly longer stride like Image 2. This column must not look neutral.
- Column 3: neutral side stance, visually matching column 1.
- Column 4: active step B. The opposite leg steps forward to screen-left like Image 2. This must be the alternate leg from column 2 and must not look neutral.
- Column 5: neutral side stance, visually matching column 1.
- Column 6: repeat active step A from column 2.
- Column 7: neutral side stance, visually matching column 1.
- Column 8: repeat active step B from column 4.
""",
}


FRAME_PLANS = {
    "down": """
For DOWN / FRONT, the Required column actions table above is the sole pose sequence. Do not reinterpret LEFT or RIGHT as a travel direction. Render a compact walk toward the viewer with narrow, nearly vertical legs, then repeat columns 1-4 exactly as columns 5-8.
""",
    "up": """
Direction row: UP / BACK. The character faces away from the viewer in all 8 frames.
Animation frame concept: the output row is read from left to right. Each of the 8 equal columns is one animation frame, meaning one still pose in a walking loop. Keep the same character size and baseline in every column; only the leg and shoe pose changes.
Important coordinate rule: "screen-left" and "screen-right" mean the viewer's left and right sides of the image, not the character's anatomical left or right.
Frame 1 / column 1: neutral standing pose, both small back-view shoes close together on the same baseline.
Frame 2 / column 2: the shoe on the viewer's right side lifts upward/away and exposes its dark sole; the viewer-left shoe stays on the baseline.
Frame 3 / column 3: neutral standing pose again, matching Frame 1.
Frame 4 / column 4: the shoe on the viewer's left side lifts upward/away and exposes its dark sole; the viewer-right shoe stays on the baseline. This must be the mirror counterpart of Frame 2.
Frame 5 / column 5: neutral standing pose again, matching Frame 1.
Frame 6 / column 6: repeat the Frame 2 pose with the viewer-right shoe lifted and its dark sole visible.
Frame 7 / column 7: neutral standing pose again, matching Frame 1.
Frame 8 / column 8: repeat the Frame 4 pose with the viewer-left shoe lifted and its dark sole visible.
Feet: compact back-view walking motion. Frame 2 and Frame 4 must show different leg motion on opposite sides.
""",
    "right": """
Direction row: RIGHT. The character faces screen-right in all 8 frames.
Animation frame concept: the output row is read from left to right. Each of the 8 equal columns is one animation frame, meaning one still pose in a walking loop. Keep the same character size and baseline in every column; only the leg and shoe pose changes.
Frame 1 / column 1: neutral side standing pose, both feet close under the body.
Frame 2 / column 2: the near/front leg steps a little forward toward screen-right; the rear leg stays closer under the body.
Frame 3 / column 3: neutral side standing pose again, matching Frame 1.
Frame 4 / column 4: the far/back leg steps a little forward toward screen-right; this is the alternate step, not another neutral pose.
Frame 5 / column 5: neutral side standing pose again, matching Frame 1.
Frame 6 / column 6: repeat the Frame 2 pose with the near/front leg forward.
Frame 7 / column 7: neutral side standing pose again, matching Frame 1.
Frame 8 / column 8: repeat the Frame 4 pose with the far/back leg forward.
Feet: all toes and shoe tips point screen-right, compact stride, no direction reversal, no side-flipped shoes. Frame 2 and Frame 4 must show alternating legs.
""",
    "left": """
Direction row: LEFT. The character faces screen-left in all 8 frames.
Animation frame concept: the output row is read from left to right. Each of the 8 equal columns is one animation frame, meaning one still pose in a walking loop. Keep the same character size and baseline in every column; only the leg and shoe pose changes.
Frame 1 / column 1: neutral side standing pose, both feet close under the body.
Frame 2 / column 2: the near/front leg steps a little forward toward screen-left; the rear leg stays closer under the body.
Frame 3 / column 3: neutral side standing pose again, matching Frame 1.
Frame 4 / column 4: the far/back leg steps a little forward toward screen-left; this is the alternate step, not another neutral pose.
Frame 5 / column 5: neutral side standing pose again, matching Frame 1.
Frame 6 / column 6: repeat the Frame 2 pose with the near/front leg forward.
Frame 7 / column 7: neutral side standing pose again, matching Frame 1.
Frame 8 / column 8: repeat the Frame 4 pose with the far/back leg forward.
Feet: all toes and shoe tips point screen-left, compact stride, no direction reversal, no side-flipped shoes. Frame 2 and Frame 4 must show alternating legs.
""",
}

POSE_TRIPLET_DIRECTIONS = set(DIRECTIONS)
POSE_TRIPLET_ACTIONS = {
    "down": """
Canonical pose A / column 1 - NEUTRAL: both front-facing shoes close together, both legs narrow and vertical.
Canonical pose B / column 2 - SCREEN-LEFT LEG FORWARD: the leg on the viewer's left lifts forward toward the camera. Perspective may make that shoe slightly larger and show more shoe top, but its center remains directly below the screen-left hip. The screen-right leg and shoe remain neutral. Never step sideways.
Canonical pose C / column 3 - SCREEN-RIGHT LEG FORWARD: the leg on the viewer's right lifts forward toward the camera. Perspective may make that shoe slightly larger and show more shoe top, but its center remains directly below the screen-right hip. The screen-left leg and shoe remain neutral. Never step sideways.
""".strip(),
    "up": """
Canonical pose A / column 1 - NEUTRAL: both small back-view shoes close together on the baseline.
Canonical pose B / column 2 - VIEWER-RIGHT LEG LIFTED: only the leg on the viewer's right lifts upward/away and exposes its dark sole. The viewer-left leg remains straight and its small shoe stays on the baseline.
Canonical pose C / column 3 - VIEWER-LEFT LEG LIFTED: only the leg on the viewer's left lifts upward/away and exposes its dark sole. The viewer-right leg remains straight and its small shoe stays on the baseline.
""".strip(),
}
POSE_NAMES = ("neutral", "first_leg", "opposite_leg")
POSE_GENERATION_ORDER = (0, 2, 1)
POSE_SINGLE_ACTIONS = {
    "down": (
        "NEUTRAL: front view, both legs narrow and vertical, both front-facing shoes close together on the baseline.",
        "SCREEN-LEFT LEG FORWARD: only the leg on the viewer's left lifts forward toward the camera. Its shoe may appear slightly larger through foreshortening but remains directly below the screen-left hip. The screen-right leg stays neutral. Never step sideways.",
        "SCREEN-RIGHT LEG FORWARD: only the leg on the viewer's right lifts forward toward the camera. Its shoe may appear slightly larger through foreshortening but remains directly below the screen-right hip. The screen-left leg stays neutral. Never step sideways.",
    ),
    "up": (
        "NEUTRAL: back view, both legs straight and both small back-view shoes close together on the baseline.",
        "VIEWER-RIGHT LEG LIFTED: only the shoe on the viewer's right lifts slightly upward/away and exposes a small dark sole. Keep the knee low and the stride compact, like a gentle walking step rather than running. The lifted shoe must stay close to its neutral position and must not grow larger than the grounded shoe. The viewer-left leg remains straight with its shoe on the baseline.",
        "VIEWER-LEFT LEG LIFTED: only the shoe on the viewer's left lifts slightly upward/away and exposes a small dark sole. Keep the knee low and the stride compact, like a gentle walking step rather than running. The lifted shoe must stay close to its neutral position and must not grow larger than the grounded shoe. The viewer-right leg remains straight with its shoe on the baseline.",
    ),
    "right": (
        "NEUTRAL: clear right-facing side view, both feet close under the body and both shoe tips pointing screen-right.",
        "NEAR LEG FORWARD: in right-facing side view, the visible near leg steps forward toward screen-right. Its shoe moves forward with the toe pointing screen-right. The far leg stays close to neutral under the body.",
        "FAR LEG FORWARD: in right-facing side view, the far leg steps forward toward screen-right. Its shoe moves forward with the toe pointing screen-right. The near leg stays close to neutral under the body. This must be the opposite leg from the other action pose.",
    ),
    "left": (
        "NEUTRAL: clear left-facing side view, both feet close under the body and both shoe tips pointing screen-left.",
        "NEAR LEG FORWARD: in left-facing side view, the visible near leg steps forward toward screen-left. Its shoe moves forward with the toe pointing screen-left. The far leg stays close to neutral under the body.",
        "FAR LEG FORWARD: in left-facing side view, the far leg steps forward toward screen-left. Its shoe moves forward with the toe pointing screen-left. The near leg stays close to neutral under the body. This must be the opposite leg from the other action pose.",
    ),
}


def _write_json(path: Path | None, payload: dict) -> None:
    if path is None:
        return
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")


def _status(
    path: Path | None, stage: str, message: str, ok: bool = True, **extra: object
) -> None:
    payload = {"ok": ok, "stage": stage, "message": message, "updated_at": time.time()}
    payload.update(extra)
    _write_json(path, payload)
    try:
        print(message, flush=True)
    except BrokenPipeError:
        pass


def _sanitize_name(text: str) -> str:
    normalized = re.sub(r"[^a-zA-Z0-9_]+", "_", text.strip().lower())
    normalized = re.sub(r"_+", "_", normalized).strip("_")
    return normalized[:42] or "custom_whale_human"


def _run(command: list[str], *, cwd: Path, log_path: Path, env: dict[str, str]) -> None:
    log_path.parent.mkdir(parents=True, exist_ok=True)
    with log_path.open("a", encoding="utf-8") as log_file:
        log_file.write("\n$ " + " ".join(command) + "\n")
        log_file.flush()
        subprocess.run(
            command,
            cwd=str(cwd),
            stdout=log_file,
            stderr=subprocess.STDOUT,
            check=True,
            env=env,
        )


def _run_with_retries(
    command: list[str],
    *,
    cwd: Path,
    log_path: Path,
    env: dict[str, str],
    attempts: int = 3,
    retry_delay: float = 8.0,
) -> None:
    last_error: subprocess.CalledProcessError | None = None
    for attempt in range(1, attempts + 1):
        try:
            _run(command, cwd=cwd, log_path=log_path, env=env)
            return
        except subprocess.CalledProcessError as exc:
            last_error = exc
            if attempt >= attempts:
                break
            with log_path.open("a", encoding="utf-8") as log_file:
                log_file.write(
                    f"\nNovamailio command failed on attempt {attempt}/{attempts}; retrying in {retry_delay:.1f}s.\n"
                )
                log_file.flush()
            time.sleep(retry_delay)
    raise last_error or RuntimeError("Novamailio command failed")


def _direction_prompt(direction: str) -> str:
    if direction in POSE_TRIPLET_DIRECTIONS:
        return _pose_triplet_prompt(direction)

    spec = TARGET_SPECS_BY_DIRECTION[direction]
    reference_row = DIRECTION_REFERENCE_ROWS[direction]
    return f"""
Task:
Create the {direction.upper()} row of an 8-frame WhaleTown walking spritesheet.

Inputs:
- Image 1 is an appearance-only identity master. Preserve its character design, but ignore all of its poses, leg positions, shoe positions, stance, and animation frames.
- Image 2 is already cropped to the official WhaleTown {reference_row} motion reference row for this task.

Hard rule:
- This is animation/direction conversion only. Do not reinterpret the uploaded character reference. Do not redesign the character.
- Preserve the identity master's recognizable character traits as they should appear from the requested viewing direction: hair/head silhouette, outfit, color placement, proportions, outline style, and rendering style.
- Change only the viewing direction and the walking pose for this row.
- Direction lock: {DIRECTION_LOCKS[direction]}
- Image 1 identity lock: keep Image 1's hair color, hairstyle or head silhouette, outfit structure, outfit colors, skin tone, and overall character design in all 8 columns.
- Image 1 must never control pose. Do not preserve its standing legs or feet.
- If the identity master has no hat or hood, do not add any hat, hood, cap, helmet, animal ears, hair accessory, or new head accessory in any direction.
- Do not copy Image 2's character identity. Image 2 is only a pose/feet template.

Output layout:
- Exactly 8 equal columns x 1 row on a flat pure magenta #FF00FF background.
- No text, no labels, no dividers, no UI, no watermark, no props, no shadows.
- Each assembled frame will be 160x160 px.

Size target for this {direction.upper()} row:
- Visible character alpha box height: {spec["body_height"]} px.
- Visible character alpha box width: {spec["body_width"]} px, acceptable range {spec["body_width_range"]}.
- Character bottom/foot baseline: y={spec["foot_y"]} px.
- Direction-specific note: {spec["description"]}.

Motion reference:
- Authority split: Image 1 controls identity, outfit colors, hair, and rendering style. Image 2 controls pose, walking rhythm, lower-body silhouette, foot side, shoe spacing, stride size, and foot baseline.
- Pose priority is absolute: whenever identity preservation conflicts with pose matching, preserve Image 1's appearance but use Image 2's pose and body-part positions.
- Treat Image 2 as a strict pose template for this row, not a loose style reference.
- For every column, first reproduce the complete corresponding Image 2 body pose and lower-body silhouette, then apply Image 1's character appearance onto that pose.
- Match the positions and silhouettes of both legs, both ankles, and both shoes from the corresponding Image 2 column.
- Copy only the pose geometry from Image 2. Ignore Image 2's character design completely.
- {FOOT_TEMPLATE_LOCKS[direction]}
- The foot baseline, stepping side, toe direction, shoe spacing, stride size, and neutral-vs-step rhythm must match Image 2 column by column.
- Do not average the walking cycle into eight standing frames. Columns 2, 4, 6, and 8 must show visible foot displacement compared with columns 1, 3, 5, and 7.
- Changing only shoe color, sole color, lighting, shading, texture, or line details does not count as movement.
- In every active step frame, at least one shoe outline must visibly move in position relative to the neutral frame, and the leg silhouette must change with it.
- Silhouette test: if all colors and internal details were removed, the stepping action must still be obvious from the outer leg-and-shoe silhouette alone.
- If a step column looks like a neutral standing column, the result is invalid.
- If the feet do not match Image 2's column-by-column action, the result is invalid even if the character identity looks good.
- Do not copy the reference character identity.

{REFERENCE_IDENTITY_BAN}

{IMAGE1_POSE_BAN}

Required column actions:
{COLUMN_ACTION_TABLES[direction]}

Animation plan:
{FRAME_PLANS[direction]}
""".strip()


def _pose_triplet_prompt(direction: str) -> str:
    spec = TARGET_SPECS_BY_DIRECTION[direction]
    reference_row = DIRECTION_REFERENCE_ROWS[direction]
    source_columns = "1, 2, and 4"
    return f"""
Task:
Create exactly three canonical {direction.upper()} poses for a WhaleTown walking animation.

Inputs:
- Image 1 is appearance identity only. Preserve its hair, face or back-head design, outfit, colors, proportions, outline, and rendering style. Ignore every pose and foot position in Image 1.
- Image 2 is the official WhaleTown {reference_row} 8-frame motion row. Use pose geometry from Image 2 columns {source_columns}: neutral pose, first-leg action, opposite-leg action.

Output layout:
- Exactly 3 equal columns x 1 row, ordered A, B, C from left to right.
- One full-body character in each column, centered at identical scale and baseline.
- Flat pure magenta #FF00FF background.
- No text, labels, dividers, UI, watermark, props, or shadows.
- Do not output 8 columns. The backend will repeat these three canonical poses into 8 animation frames.

Identity and direction locks:
- {DIRECTION_LOCKS[direction]}
- Image 1 controls identity only. Image 2 controls body pose, legs, ankles, shoes, spacing, and perspective.
- Never copy Image 2's hair, face, hood, clothing, colors, accessories, or identity.
- Keep Image 1's exact character identity in all three columns.
- If Image 1 has no hat or hood, do not add one.

Size consistency:
- All three characters must have the same head size, shoulder width, body height, clothing size, centerline, and baseline.
- Target assembled character height is {spec["body_height"]} px, width about {spec["body_width"]} px, and foot baseline y={spec["foot_y"]} px in a 160x160 frame.
- Only legs and shoes may change between A, B, and C.

Canonical poses:
{POSE_TRIPLET_ACTIONS[direction]}

Hard comparison rules:
- B and C must use opposite legs. They must not show the same active leg.
- Pose C must not be a duplicate of pose B.
- Both feet must remain visible in every pose, including the grounded shoe and the lifted/forward shoe.
- Match Image 2's compact stride. Do not widen the stance or move a foot toward a cell edge.
- The active leg must be recognizable from the outer silhouette, not only from color, shading, or sole details.
- The result is invalid if B and C move the same screen-side leg.

{REFERENCE_IDENTITY_BAN}

{IMAGE1_POSE_BAN}
""".strip()


def _single_pose_prompt(direction: str, pose_index: int) -> str:
    spec = TARGET_SPECS_BY_DIRECTION[direction]
    reference_row = DIRECTION_REFERENCE_ROWS[direction]
    reference_column = (1, 2, 4)[pose_index]
    action = POSE_SINGLE_ACTIONS[direction][pose_index]
    return f"""
Task:
Create one single canonical {direction.upper()} pose for a WhaleTown walking animation.

Inputs:
- Image 1 is appearance identity only. Preserve its hair, face or back-head design, outfit, colors, proportions, outline, and rendering style. Ignore all poses in Image 1.
- Image 2 is an enlarged single-pose crop extracted from column {reference_column} of the official WhaleTown {reference_row} motion row. Copy the complete body, leg, ankle, shoe, spacing, and perspective geometry visible in Image 2.

Output:
- Exactly ONE full-body character on the entire canvas. Do not create a row, sequence, comparison, duplicate, or additional character.
- Center the character on a flat pure magenta #FF00FF background.
- No text, labels, dividers, UI, watermark, props, or shadows.
- Target assembled character height is {spec["body_height"]} px, width about {spec["body_width"]} px, and foot baseline y={spec["foot_y"]} px in a 160x160 frame.

Required pose:
- {action}
- {DIRECTION_LOCKS[direction]}
- Keep the head, hair, shoulders, torso, hips, and both arms in a perfectly neutral upright standing pose. Both arms hang straight down at the sides. Do not swing or bend an arm, rotate the torso, lean the body, or create a running pose.
- Only the active leg and shoe may differ from a neutral standing pose. The motion must remain compact enough for a calm game walk cycle.
- Both feet must remain visible: one active foot and one grounded/neutral foot where applicable.
- Match Image 2's compact stride and actual leg/shoe geometry. Do not widen the stance or move a foot toward a canvas edge.
- The active leg must be recognizable from the outer silhouette, not only from color or shading.

Identity locks:
- Image 1 controls identity and all neutral upper-body geometry; Image 2 controls the requested leg and shoe pose only.
- Preserve every existing non-leg feature from Image 1: head silhouette, hairstyle or headwear, facial or back-head details, upper-body proportions, outfit silhouette and garment geometry, shoulders, torso, arms, hands, accessories, outline thickness, colors, and rendering details. Do not redesign, add, remove, or reinterpret those features.
- For an active pose, redraw only the active leg and shoe as required. Keep the grounded leg as close to Image 1 as possible.
- Lower-body appearance is locked to Image 1. Preserve the exact trousers, socks, shoes, soles, trims, color blocks, materials, and outline colors from Image 1 on both legs. Change their geometry only as needed for the requested pose.
- Image 2 contributes no appearance information. Do not sample, blend, transfer, or invent any lower-body color or clothing detail from Image 2. A changed shoe, sock, trouser color, trim, or material makes the output invalid.
- The grounded leg and grounded shoe must remain visually identical to Image 1 apart from unavoidable overlap by the moving leg.
- When an edit mask is supplied, change pixels only inside its transparent lower-body region. Pixels outside that region must remain exactly unchanged.
- Never copy Image 2's hair, face, hood, clothing, colors, accessories, or identity.
- If Image 1 has no hat or hood, do not add one.

{REFERENCE_IDENTITY_BAN}
""".strip()


def _single_pose_from_sibling_prompt(direction: str, pose_index: int) -> str:
    if pose_index not in (1, 2):
        raise ValueError("Sibling pose generation only supports action poses B and C")
    spec = TARGET_SPECS_BY_DIRECTION[direction]
    action = POSE_SINGLE_ACTIONS[direction][pose_index]
    source_action = POSE_SINGLE_ACTIONS[direction][2 if pose_index == 1 else 1]
    return f"""
Task:
Create one single canonical {direction.upper()} action pose for a WhaleTown walking animation.

Inputs:
- Image 1 is the neutral pose A and the immutable appearance master.
- Image 2 is the already accepted opposite-leg action pose of the SAME character. It shows this source action: {source_action}
- Produce the opposite action shown below. Use Image 2 only to match stride size, forward-depth perspective, and motion strength; switch the active leg exactly as requested.

Required target action:
- {action}
- {DIRECTION_LOCKS[direction]}
- The result must not repeat Image 2's active leg.
- Keep the stance compact and both feet visible.

Immutable appearance contract:
- Both inputs depict the same character. Preserve their exact hair, face or back-head design, body proportions, outfit, trousers, socks, shoes, soles, trims, color blocks, materials, outline colors, and rendering style.
- This is a pose change only. Do not redesign, recolor, simplify, remove, add, or reinterpret any clothing or footwear detail.
- The grounded leg and grounded shoe must match Image 1 exactly. The active leg keeps the same clothing and shoe appearance while changing geometry.
- Keep the head, hair, shoulders, torso, hips, arms, and hands in Image 1's neutral positions.
- When an edit mask is supplied, change pixels only inside its transparent lower-body region. Pixels outside it must remain exactly unchanged.

Output:
- Exactly ONE full-body character, centered on flat pure magenta #FF00FF.
- No row, sequence, duplicate, text, labels, dividers, UI, watermark, props, or shadows.
- Target assembled character height is {spec["body_height"]} px, width about {spec["body_width"]} px, and foot baseline y={spec["foot_y"]} px in a 160x160 frame.
""".strip()


def _identity_prompt() -> str:
    return """
Task:
Create one locked front-facing identity master for a WhaleTown V2 player skin.

Input images:
- Image 1 is the player's uploaded character reference. Preserve its main identity: face impression, hairstyle or head silhouette, outfit idea, dominant colors, and overall character feeling.
- Image 2 is the official WhaleTown DOWN/front 8-frame reference row. Use it for sprite structure: body height, body width, baseline, frame spacing, simple rounded proportions, and clean 2D game rendering.

Output:
- Exactly 8 equal columns x 1 row.
- Same character identity in every column, front-facing, full-body, centered, same size and baseline.
- Human character only. If the uploaded image is animal-like, mascot-like, realistic, or non-human, adapt it into a human WhaleTown player character while preserving the visual inspiration.
- Do not invent new accessories, props, hats, tails, ears, weapons, or costume details that are not visible in the uploaded image.
- If the uploaded image and resulting identity have no hat or hood, keep the head uncovered in every frame. Do not add a hat, hood, cap, helmet, animal ears, hair accessory, or new head accessory.
- Match WhaleTown style: compact rounded 2D game sprite, clean dark outline, simple cel shading, low texture density.
- Target assembled frame size is 160x160 px; visible character box about 73 px wide, 116 px high, bottom baseline y=137.
- Flat pure magenta #FF00FF background.
- No text, labels, dividers, UI, watermark, props, shadows, green, or magenta/pink on the character.
""".strip()


def _parse_args(argv: Iterable[str]) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--source-image", required=True, type=Path)
    parser.add_argument("--out-dir", required=True, type=Path)
    parser.add_argument("--name", default="")
    parser.add_argument("--result-json", type=Path, required=True)
    parser.add_argument("--status-json", type=Path, required=True)
    parser.add_argument("--size", default="1536x1024")
    parser.add_argument(
        "--quality", default=os.getenv("SKIN_GENERATION_QUALITY", "medium")
    )
    parser.add_argument("--timeout", default="900")
    parser.add_argument(
        "--novamailio-script", type=Path, default=DEFAULT_NOVAMAILIO_SCRIPT
    )
    parser.add_argument("--assemble-script", type=Path, default=DEFAULT_ASSEMBLE_SCRIPT)
    parser.add_argument("--reference-strips-dir", type=Path, default=None)
    return parser.parse_args(list(argv))


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 _expand_frame_body_width(frame: Image.Image, minimum_width: int) -> Image.Image:
    bbox = frame.getchannel("A").getbbox()
    if bbox is None:
        return frame
    x0, y0, x1, y1 = bbox
    body_width = x1 - x0
    if body_width >= minimum_width:
        return frame

    trimmed = frame.crop(bbox)
    target_width = min(FRAME_SIZE - 10, minimum_width)
    resized = _resize_rgba_alpha_aware(trimmed, (target_width, trimmed.height))
    output = Image.new("RGBA", (FRAME_SIZE, FRAME_SIZE), (0, 0, 0, 0))
    x = (FRAME_SIZE - resized.width) // 2
    y = min(max(0, y1 - resized.height), FRAME_SIZE - resized.height)
    output.alpha_composite(resized, (x, y))
    return output


def _normalize_spritesheet_body_width(spritesheet_path: Path) -> None:
    """Validate final spritesheet without shape-distorting post-scaling.

    Earlier versions tried to fix narrow model output by stretching only the X
    axis. That made characters look flattened. Final proportions should now be
    solved by prompt/reference generation; post-processing must not distort the
    character's natural shape.
    """
    sheet = Image.open(spritesheet_path).convert("RGBA")
    if sheet.size != (FRAME_SIZE * SPRITESHEET_COLUMNS, FRAME_SIZE * SPRITESHEET_ROWS):
        raise ValueError(
            f"Unexpected spritesheet size for width normalization: {sheet.size}"
        )

    sheet.save(spritesheet_path)


def _frame_bbox_stats(sheet: Image.Image, row_index: int) -> list[dict[str, int]]:
    stats: list[dict[str, int]] = []
    for column in range(SPRITESHEET_COLUMNS):
        frame = sheet.crop(
            (
                column * FRAME_SIZE,
                row_index * FRAME_SIZE,
                (column + 1) * FRAME_SIZE,
                (row_index + 1) * FRAME_SIZE,
            )
        )
        bbox = frame.getchannel("A").getbbox()
        if bbox is None:
            stats.append({"width": 0, "height": 0, "bottom": 0})
        else:
            stats.append(
                {
                    "width": bbox[2] - bbox[0],
                    "height": bbox[3] - bbox[1],
                    "bottom": bbox[3],
                }
            )
    return stats


def _validate_spritesheet_size_targets(spritesheet_path: Path) -> dict[str, object]:
    sheet = Image.open(spritesheet_path).convert("RGBA")
    if sheet.size != (FRAME_SIZE * SPRITESHEET_COLUMNS, FRAME_SIZE * SPRITESHEET_ROWS):
        raise ValueError(f"Unexpected spritesheet size for QA: {sheet.size}")

    report: dict[str, object] = {"ok": True, "rows": {}, "warnings": []}
    failures: list[str] = []
    warnings: list[str] = []
    for row_index, direction in enumerate(DIRECTIONS):
        spec = TARGET_SPECS_BY_DIRECTION[direction]
        stats = _frame_bbox_stats(sheet, row_index)
        widths = np.asarray([entry["width"] for entry in stats], dtype=np.float32)
        heights = np.asarray([entry["height"] for entry in stats], dtype=np.float32)
        bottoms = np.asarray([entry["bottom"] for entry in stats], dtype=np.float32)
        target_width = int(spec["body_width"])
        target_height = int(spec["body_height"])
        target_foot_y = int(spec["foot_y"])
        row_report = {
            "target_width": target_width,
            "target_height": target_height,
            "target_foot_y": target_foot_y,
            "widths": [int(value) for value in widths.tolist()],
            "heights": [int(value) for value in heights.tolist()],
            "bottoms": [int(value) for value in bottoms.tolist()],
            "median_width": float(np.median(widths)),
            "median_height": float(np.median(heights)),
            "median_bottom": float(np.median(bottoms)),
        }
        rows_report = report["rows"]
        if isinstance(rows_report, dict):
            rows_report[direction] = row_report

        if abs(float(np.median(widths)) - target_width) > MAX_ROW_WIDTH_DEVIATION:
            warnings.append(
                f"{direction} median width {float(np.median(widths)):.1f}px is too far from target {target_width}px"
            )
        if abs(float(np.median(heights)) - target_height) > 4:
            failures.append(
                f"{direction} median height {float(np.median(heights)):.1f}px is too far from target {target_height}px"
            )
        if abs(float(np.median(bottoms)) - target_foot_y) > 4:
            failures.append(
                f"{direction} median baseline {float(np.median(bottoms)):.1f}px is too far from target {target_foot_y}px"
            )

    if failures:
        report["ok"] = False
        report["failures"] = failures
    if warnings:
        report["warnings"] = warnings
    return report


def _mask_xor_ratio(first: np.ndarray, second: np.ndarray) -> float:
    union = np.logical_or(first, second)
    if not union.any():
        return 0.0
    return float(np.logical_xor(first, second).sum() / union.sum())


def _lower_palette_distance(
    first: np.ndarray, second: np.ndarray, start_y: int
) -> float:
    """Compare coarse lower-body colors while tolerating pose-dependent pixel movement."""

    def histogram(frame: np.ndarray) -> np.ndarray:
        region = frame[start_y:, :, :]
        visible = region[:, :, 3] > 20
        if not visible.any():
            return np.zeros(64, dtype=np.float64)
        rgb = region[:, :, :3][visible]
        quantized = np.clip(rgb // 64, 0, 3)
        bins = quantized[:, 0] * 16 + quantized[:, 1] * 4 + quantized[:, 2]
        counts = np.bincount(bins, minlength=64).astype(np.float64)
        return counts / counts.sum()

    return float(np.abs(histogram(first) - histogram(second)).sum() / 2)


def _validate_direction_animations(spritesheet_path: Path) -> dict[str, object]:
    """Reject identity drift, missing motion, and duplicated B/C leg poses."""
    sheet = np.asarray(Image.open(spritesheet_path).convert("RGBA"))
    expected_size = (FRAME_SIZE * SPRITESHEET_COLUMNS, FRAME_SIZE * SPRITESHEET_ROWS)
    if (sheet.shape[1], sheet.shape[0]) != expected_size:
        raise ValueError(
            f"Unexpected spritesheet size for animation QA: {(sheet.shape[1], sheet.shape[0])}"
        )

    upper_end = round(FRAME_SIZE * 0.60)
    failures: list[str] = []
    rows: dict[str, object] = {}
    for row_index, direction in enumerate(DIRECTIONS):
        lower_palette_start_y = LOWER_PALETTE_START_Y_BY_DIRECTION[direction]
        max_lower_palette_distance = MAX_LOWER_PALETTE_DISTANCE_BY_DIRECTION[direction]
        frames = [
            sheet[
                row_index * FRAME_SIZE : (row_index + 1) * FRAME_SIZE,
                column * FRAME_SIZE : (column + 1) * FRAME_SIZE,
            ]
            for column in range(SPRITESHEET_COLUMNS)
        ]
        neutral = frames[0]
        neutral_mask = neutral[:, :, 3] > 20
        action_reports: list[dict[str, float | int]] = []
        for column in (1, 3):
            action = frames[column]
            action_mask = action[:, :, 3] > 20
            upper_neutral = neutral_mask[:upper_end]
            upper_action = action_mask[:upper_end]
            upper_silhouette_xor = _mask_xor_ratio(upper_neutral, upper_action)
            common_upper = np.logical_and(upper_neutral, upper_action)
            if common_upper.any():
                rgb_difference = np.abs(
                    neutral[:upper_end, :, :3].astype(np.float32)
                    - action[:upper_end, :, :3].astype(np.float32)
                ).mean(axis=2)
                upper_rgb_mad = float(rgb_difference[common_upper].mean())
            else:
                upper_rgb_mad = 255.0
            lower_motion_xor = _mask_xor_ratio(
                neutral_mask[upper_end:],
                action_mask[upper_end:],
            )
            lower_palette_distance = _lower_palette_distance(
                neutral, action, lower_palette_start_y
            )
            action_report = {
                "column": column + 1,
                "upper_silhouette_xor": upper_silhouette_xor,
                "upper_rgb_mad": upper_rgb_mad,
                "lower_motion_xor": lower_motion_xor,
                "lower_palette_distance": lower_palette_distance,
                "lower_palette_start_y": lower_palette_start_y,
            }
            action_reports.append(action_report)
            if upper_silhouette_xor > MAX_UPPER_SILHOUETTE_XOR:
                failures.append(
                    f"{direction} column {column + 1} upper silhouette drift "
                    f"{upper_silhouette_xor:.3f} exceeds {MAX_UPPER_SILHOUETTE_XOR:.3f}"
                )
            if upper_rgb_mad > MAX_UPPER_RGB_MAD:
                failures.append(
                    f"{direction} column {column + 1} upper color/detail drift "
                    f"{upper_rgb_mad:.1f} exceeds {MAX_UPPER_RGB_MAD:.1f}"
                )
            if lower_motion_xor < MIN_LOWER_MOTION_XOR:
                failures.append(
                    f"{direction} column {column + 1} lower-body motion "
                    f"{lower_motion_xor:.3f} is below {MIN_LOWER_MOTION_XOR:.3f}"
                )
            if lower_palette_distance > max_lower_palette_distance:
                failures.append(
                    f"{direction} column {column + 1} lower-body palette drift "
                    f"{lower_palette_distance:.3f} exceeds {max_lower_palette_distance:.3f}"
                )

        opposite_pose_xor = _mask_xor_ratio(
            frames[1][upper_end:, :, 3] > 20,
            frames[3][upper_end:, :, 3] > 20,
        )
        opposite_palette_distance = _lower_palette_distance(
            frames[1],
            frames[3],
            lower_palette_start_y,
        )
        if opposite_pose_xor < MIN_OPPOSITE_POSE_XOR:
            failures.append(
                f"{direction} B/C lower-body poses are too similar: "
                f"{opposite_pose_xor:.3f} is below {MIN_OPPOSITE_POSE_XOR:.3f}"
            )
        max_action_palette_distance = MAX_ACTION_PALETTE_DISTANCE_BY_DIRECTION.get(
            direction
        )
        if (
            max_action_palette_distance is not None
            and opposite_palette_distance > max_action_palette_distance
        ):
            failures.append(
                f"{direction} B/C lower-body palette mismatch {opposite_palette_distance:.3f} "
                f"exceeds {max_action_palette_distance:.3f}"
            )

        repeat_pairs = ((0, 2), (0, 4), (0, 6), (1, 5), (3, 7))
        repeated_exactly = all(
            np.array_equal(frames[first], frames[second])
            for first, second in repeat_pairs
        )
        if not repeated_exactly:
            failures.append(
                f"{direction} deterministic A/B/A/C repetition changed during assembly"
            )
        rows[direction] = {
            "actions": action_reports,
            "opposite_pose_xor": opposite_pose_xor,
            "opposite_palette_distance": opposite_palette_distance,
            "repeated_exactly": repeated_exactly,
        }

    return {
        "ok": not failures,
        "thresholds": {
            "max_upper_silhouette_xor": MAX_UPPER_SILHOUETTE_XOR,
            "max_upper_rgb_mad": MAX_UPPER_RGB_MAD,
            "min_lower_motion_xor": MIN_LOWER_MOTION_XOR,
            "min_opposite_pose_xor": MIN_OPPOSITE_POSE_XOR,
            "lower_palette_start_y_by_direction": LOWER_PALETTE_START_Y_BY_DIRECTION,
            "max_lower_palette_distance_by_direction": MAX_LOWER_PALETTE_DISTANCE_BY_DIRECTION,
            "max_action_palette_distance_by_direction": MAX_ACTION_PALETTE_DISTANCE_BY_DIRECTION,
        },
        "rows": rows,
        "failures": failures,
    }


def _validate_front_back_animation(spritesheet_path: Path) -> dict[str, object]:
    """Backward-compatible internal alias for callers while QA now covers all directions."""
    return _validate_direction_animations(spritesheet_path)


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_images(
    sheet_path: Path, review_path: Path, feet_zoom_path: Path
) -> None:
    sheet = Image.open(sheet_path).convert("RGBA")
    if sheet.size != (FRAME_SIZE * SPRITESHEET_COLUMNS, FRAME_SIZE * SPRITESHEET_ROWS):
        raise ValueError(f"Unexpected spritesheet size for review: {sheet.size}")

    review_scale = 2
    label_width = 68
    strip_height = FRAME_SIZE * review_scale
    review = Image.new(
        "RGB",
        (label_width + sheet.width * review_scale, strip_height * SPRITESHEET_ROWS),
        (246, 247, 250),
    )
    draw = ImageDraw.Draw(review)
    font = ImageFont.load_default()
    for row, name in enumerate(DIRECTIONS):
        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 * review_scale, strip_height), Image.Resampling.NEAREST
        )
        bg = _checkerboard(row_preview.width, row_preview.height, 16)
        bg.alpha_composite(row_preview)
        review.paste(bg.convert("RGB"), (label_width, y))
        for index in range(SPRITESHEET_COLUMNS + 1):
            x = label_width + index * FRAME_SIZE * review_scale
            draw.line((x, y, x, y + strip_height), fill=(205, 60, 60), width=1)
    review_path.parent.mkdir(parents=True, exist_ok=True)
    review.save(review_path)

    feet_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 * feet_scale
    cell_h = crop_h * feet_scale
    feet = Image.new(
        "RGB",
        (label_w + cell_w * SPRITESHEET_COLUMNS, label_h + cell_h * SPRITESHEET_ROWS),
        (246, 247, 250),
    )
    draw = ImageDraw.Draw(feet)
    for column in range(SPRITESHEET_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(DIRECTIONS):
        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(SPRITESHEET_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
            feet.paste(bg.convert("RGB"), (x, y))
            draw.rectangle(
                (x, y, x + cell_w - 1, y + cell_h - 1), outline=(188, 194, 202), width=1
            )
    feet_zoom_path.parent.mkdir(parents=True, exist_ok=True)
    feet.save(feet_zoom_path)


def _resolve_reference_direction_paths(
    reference_strips_dir: Path | None,
) -> dict[str, Path]:
    source_dir = (
        reference_strips_dir
        if reference_strips_dir is not None
        else DEFAULT_REFERENCE_STRIPS_DIR
    )
    if not source_dir.is_absolute():
        source_dir = SCRIPT_DIR / source_dir

    references: dict[str, Path] = {}
    for direction in DIRECTIONS:
        path = source_dir / f"human_whale_reference_{direction}.png"
        if not path.exists():
            raise FileNotFoundError(f"Direction reference image is missing: {path}")
        with Image.open(path) as image:
            if image.size != REFERENCE_CANVAS_SIZE:
                raise ValueError(
                    f"Direction reference must be {REFERENCE_CANVAS_SIZE}, got {image.size}: {path}"
                )
        references[direction] = path
    return references


def _resolve_identity_reference_path(reference_strips_dir: Path | None) -> Path:
    if reference_strips_dir is None:
        path = DEFAULT_IDENTITY_REFERENCE_IMAGE
    else:
        source_dir = reference_strips_dir
        if not source_dir.is_absolute():
            source_dir = SCRIPT_DIR / source_dir
        path = source_dir / "whaleboy_reference_down.png"
    if not path.exists():
        raise FileNotFoundError(f"Identity reference image is missing: {path}")
    with Image.open(path) as image:
        if image.size != REFERENCE_CANVAS_SIZE:
            raise ValueError(
                f"Identity reference must be {REFERENCE_CANVAS_SIZE}, got {image.size}: {path}"
            )
    return path


def _create_single_pose_reference(
    source_path: Path, column_index: int, output_path: Path
) -> None:
    """Crop one official reference frame and enlarge its character for image editing."""
    with Image.open(source_path) as source:
        image = source.convert("RGB")

    cell_width = image.width // SPRITESHEET_COLUMNS
    cell = image.crop(
        (column_index * cell_width, 0, (column_index + 1) * cell_width, image.height)
    )
    pixels = np.asarray(cell)
    magenta = (pixels[:, :, 0] > 220) & (pixels[:, :, 1] < 80) & (pixels[:, :, 2] > 180)
    ys, xs = np.where(~magenta)
    if xs.size == 0 or ys.size == 0:
        raise ValueError(
            f"Reference frame contains no character pixels: {source_path} column {column_index + 1}"
        )

    padding = 8
    left = max(0, int(xs.min()) - padding)
    top = max(0, int(ys.min()) - padding)
    right = min(cell.width, int(xs.max()) + 1 + padding)
    bottom = min(cell.height, int(ys.max()) + 1 + padding)
    character = cell.crop((left, top, right, bottom))
    foreground_height = int(ys.max() - ys.min() + 1)
    scale = SINGLE_POSE_REFERENCE_BODY_HEIGHT / foreground_height
    resized_height = max(1, round(character.height * scale))
    resized = character.resize(
        (max(1, round(character.width * scale)), resized_height),
        Image.Resampling.LANCZOS,
    )

    canvas = Image.new(
        "RGB",
        (SINGLE_POSE_REFERENCE_SIZE, SINGLE_POSE_REFERENCE_SIZE),
        (255, 0, 255),
    )
    canvas.paste(
        resized,
        ((canvas.width - resized.width) // 2, (canvas.height - resized.height) // 2),
    )
    output_path.parent.mkdir(parents=True, exist_ok=True)
    canvas.save(output_path)


def _create_lower_body_edit_mask(neutral_pose_path: Path, output_path: Path) -> None:
    """Protect a neutral pose except for a compact, character-relative leg region."""
    with Image.open(neutral_pose_path) as source:
        image = source.convert("RGB")

    pixels = np.asarray(image)
    magenta = (pixels[:, :, 0] > 220) & (pixels[:, :, 1] < 80) & (pixels[:, :, 2] > 180)
    ys, xs = np.where(~magenta)
    if xs.size == 0 or ys.size == 0:
        raise ValueError(
            f"Neutral pose contains no character pixels: {neutral_pose_path}"
        )

    left = int(xs.min())
    top = int(ys.min())
    right = int(xs.max()) + 1
    bottom = int(ys.max()) + 1
    body_width = right - left
    body_height = bottom - top
    vertical_padding = max(8, round(body_height * 0.06))
    edit_top = top + round(body_height * LOWER_BODY_EDIT_START_RATIO)
    feather = max(12, round(body_height * LOWER_BODY_EDIT_FEATHER_RATIO))
    edit_bottom = min(image.height - 1, bottom + vertical_padding)
    center_x = (left + right) / 2
    top_half_width = body_width * LOWER_BODY_EDIT_TOP_HALF_WIDTH_RATIO
    bottom_half_width = body_width * LOWER_BODY_EDIT_BOTTOM_HALF_WIDTH_RATIO

    mask = Image.new("RGBA", image.size, (255, 255, 255, 255))
    draw = ImageDraw.Draw(mask)
    editable_height = max(1, edit_bottom - edit_top)
    for y in range(edit_top, edit_bottom + 1):
        progress = (y - edit_top) / editable_height
        half_width = top_half_width + (bottom_half_width - top_half_width) * progress
        edit_left = max(0, round(center_x - half_width))
        edit_right = min(image.width - 1, round(center_x + half_width))
        offset = y - edit_top
        alpha = round(255 * (1 - min(1.0, (offset + 1) / feather)))
        draw.line((edit_left, y, edit_right, y), fill=(255, 255, 255, alpha))
    output_path.parent.mkdir(parents=True, exist_ok=True)
    mask.save(output_path)


def main(argv: Iterable[str]) -> int:
    args = _parse_args(argv)
    out_dir = args.out_dir.resolve()
    worker_root = SCRIPT_DIR
    skin_name = _sanitize_name(args.name or "uploaded_character")
    log_path = out_dir / "generation.log"
    result_payload: dict[str, object] = {
        "ok": False,
        "skin_name": skin_name,
        "out_dir": str(out_dir),
    }

    try:
        if not os.getenv("NOVAMAILIO_API_KEY"):
            raise RuntimeError("NOVAMAILIO_API_KEY is not configured on the server")
        if not args.novamailio_script.exists():
            raise FileNotFoundError(
                f"Novamailio script not found: {args.novamailio_script}"
            )
        if not args.assemble_script.exists():
            raise FileNotFoundError(
                f"Assembly script not found: {args.assemble_script}"
            )
        if not DEFAULT_EXPAND_POSE_TRIPLET_SCRIPT.exists():
            raise FileNotFoundError(
                f"Pose triplet expansion script not found: {DEFAULT_EXPAND_POSE_TRIPLET_SCRIPT}"
            )

        cutout_script = DEFAULT_CUTOUT_SCRIPT
        if not cutout_script.exists():
            raise FileNotFoundError(f"Backend cutout script not found: {cutout_script}")

        raw_dir = out_dir / "raw"
        prompt_dir = out_dir / "prompts"
        cutout_dir = out_dir / "cutout"
        expanded_dir = out_dir / "expanded"
        identity_dir = out_dir / "identity"
        pose_reference_dir = out_dir / "pose_references"
        for directory in (
            raw_dir,
            prompt_dir,
            cutout_dir,
            expanded_dir,
            identity_dir,
            pose_reference_dir,
        ):
            directory.mkdir(parents=True, exist_ok=True)

        cutout_paths: dict[str, Path] = {}
        child_env = os.environ.copy()
        if not args.source_image.exists():
            raise FileNotFoundError(
                f"Uploaded source character image not found: {args.source_image}"
            )
        reference_paths = _resolve_reference_direction_paths(args.reference_strips_dir)
        identity_reference_path = _resolve_identity_reference_path(
            args.reference_strips_dir
        )

        identity_prompt_path = prompt_dir / f"{skin_name}_identity.txt"
        identity_path = identity_dir / f"{skin_name}_identity_reference.png"
        identity_prompt_path.write_text(_identity_prompt(), encoding="utf-8")
        _status(
            args.status_json,
            "identity",
            "正在基于上传图片和whaleboy参考条生成角色身份母版",
        )
        _run_with_retries(
            [
                sys.executable,
                str(args.novamailio_script),
                "edit",
                "--image",
                str(args.source_image),
                "--image",
                str(identity_reference_path),
                "--prompt-file",
                str(identity_prompt_path),
                "--size",
                args.size,
                "--quality",
                args.quality,
                "--out",
                str(identity_path),
                "--force",
                "--timeout",
                args.timeout,
                "--max-attempts",
                "2",
            ],
            cwd=worker_root,
            log_path=log_path,
            env=child_env,
            attempts=3,
            retry_delay=10.0,
        )

        _status(args.status_json, "prompt", "正在规划四方向8帧动作")
        canonical_front_identity_path: Path | None = None
        for direction in DIRECTIONS:
            pose_cutouts: dict[int, Path] = {}
            pose_raw_paths: dict[int, Path] = {}
            neutral_pose_path: Path | None = None
            lower_body_mask_path = (
                pose_reference_dir / f"{direction}_lower_body_edit_mask.png"
            )
            for generation_step, pose_index in enumerate(
                POSE_GENERATION_ORDER, start=1
            ):
                pose_name = POSE_NAMES[pose_index]
                prompt_path = prompt_dir / f"{skin_name}_{direction}_{pose_name}.txt"
                pose_reference_path = (
                    pose_reference_dir / f"{direction}_{pose_name}_reference.png"
                )
                raw_path = raw_dir / f"{skin_name}_{direction}_{pose_name}_source.png"
                cutout_path = (
                    cutout_dir / f"{skin_name}_{direction}_{pose_name}_cutout.png"
                )
                mask_path = cutout_dir / f"{skin_name}_{direction}_{pose_name}_mask.png"
                preview_path = (
                    cutout_dir / f"{skin_name}_{direction}_{pose_name}_preview.png"
                )
                _create_single_pose_reference(
                    reference_paths[direction],
                    (0, 1, 3)[pose_index],
                    pose_reference_path,
                )
                if pose_index == 1:
                    sibling_pose_path = pose_raw_paths.get(2)
                    if sibling_pose_path is None:
                        raise RuntimeError(
                            f"{direction} opposite-leg sibling pose was not generated first"
                        )
                    prompt_path.write_text(
                        _single_pose_from_sibling_prompt(direction, pose_index),
                        encoding="utf-8",
                    )
                    motion_reference_path = sibling_pose_path
                else:
                    prompt_path.write_text(
                        _single_pose_prompt(direction, pose_index), encoding="utf-8"
                    )
                    motion_reference_path = pose_reference_path

                _status(
                    args.status_json,
                    "generate",
                    "正在生成%s方向独立姿势 %s (%d/3)"
                    % (direction, pose_name, generation_step),
                    direction=direction,
                    pose=pose_name,
                )
                generation_command = [
                    sys.executable,
                    str(args.novamailio_script),
                    "edit",
                    "--image",
                    str(
                        (
                            canonical_front_identity_path
                            if pose_index == 0
                            and direction != "down"
                            and canonical_front_identity_path is not None
                            else identity_path
                        )
                        if neutral_pose_path is None
                        else neutral_pose_path
                    ),
                    "--image",
                    str(motion_reference_path),
                    "--prompt-file",
                    str(prompt_path),
                    "--size",
                    SINGLE_POSE_GENERATION_SIZE,
                    "--quality",
                    args.quality,
                    "--out",
                    str(raw_path),
                    "--force",
                    "--timeout",
                    args.timeout,
                    "--max-attempts",
                    "2",
                ]
                if neutral_pose_path is not None:
                    generation_command.extend(["--mask", str(lower_body_mask_path)])
                _run_with_retries(
                    generation_command,
                    cwd=worker_root,
                    log_path=log_path,
                    env=child_env,
                    attempts=3,
                    retry_delay=10.0,
                )
                if pose_index == 0:
                    neutral_pose_path = raw_path
                    if direction == "down":
                        canonical_front_identity_path = raw_path
                    _create_lower_body_edit_mask(
                        neutral_pose_path, lower_body_mask_path
                    )
                pose_raw_paths[pose_index] = raw_path
                _status(
                    args.status_json,
                    "cutout",
                    "正在抠图%s方向独立姿势 %s (%d/3)"
                    % (direction, pose_name, generation_step),
                    direction=direction,
                    pose=pose_name,
                )
                _run(
                    [
                        sys.executable,
                        str(cutout_script),
                        "--input",
                        str(raw_path),
                        "--output",
                        str(cutout_path),
                        "--mask-out",
                        str(mask_path),
                        "--preview-out",
                        str(preview_path),
                        "--fallback-connected-matte",
                        "--fallback-threshold",
                        "34",
                    ],
                    cwd=worker_root,
                    log_path=log_path,
                    env=child_env,
                )
                pose_cutouts[pose_index] = cutout_path

            expanded_path = expanded_dir / f"{skin_name}_{direction}_8frame_cutout.png"
            expand_command = [
                sys.executable,
                str(DEFAULT_EXPAND_POSE_TRIPLET_SCRIPT),
            ]
            for pose_index in range(len(POSE_NAMES)):
                expand_command.extend(["--pose", str(pose_cutouts[pose_index])])
            expand_command.extend(["--output", str(expanded_path)])
            _status(
                args.status_json,
                "expand",
                "正在将%s方向三个独立姿势排成8帧动作" % direction,
                direction=direction,
            )
            _run(expand_command, cwd=worker_root, log_path=log_path, env=child_env)
            cutout_paths[direction] = expanded_path

        _status(args.status_json, "assemble", "正在对齐并拼接8x4角色皮肤")
        _run(
            [
                sys.executable,
                str(args.assemble_script),
                "--down",
                str(cutout_paths["down"]),
                "--up",
                str(cutout_paths["up"]),
                "--right",
                str(cutout_paths["right"]),
                "--left",
                str(cutout_paths["left"]),
                "--name",
                skin_name,
                "--output-dir",
                str(out_dir),
            ],
            cwd=worker_root,
            log_path=log_path,
            env=child_env,
        )

        spritesheet_path = out_dir / "processed" / f"{skin_name}_spritesheet.png"
        review_path = out_dir / "review" / f"{skin_name}_review.png"
        feet_zoom_path = out_dir / "review" / f"{skin_name}_feet_zoom.png"
        if not spritesheet_path.exists():
            raise FileNotFoundError(
                f"Assembled spritesheet missing: {spritesheet_path}"
            )

        _status(args.status_json, "normalize", "正在校正角色体型宽度")
        _normalize_spritesheet_body_width(spritesheet_path)
        _save_review_images(spritesheet_path, review_path, feet_zoom_path)
        size_qa_path = out_dir / "review" / f"{skin_name}_size_qa.json"
        size_qa = _validate_spritesheet_size_targets(spritesheet_path)
        _write_json(size_qa_path, size_qa)
        if not bool(size_qa.get("ok", False)):
            failures = size_qa.get("failures", [])
            failure_text = (
                "; ".join(str(item) for item in failures)
                if isinstance(failures, list)
                else str(failures)
            )
            raise RuntimeError("角色尺寸未达到方向目标: " + failure_text)

        animation_qa_path = out_dir / "review" / f"{skin_name}_animation_qa.json"
        animation_qa = _validate_direction_animations(spritesheet_path)
        _write_json(animation_qa_path, animation_qa)
        if not bool(animation_qa.get("ok", False)):
            failures = animation_qa.get("failures", [])
            failure_text = (
                "; ".join(str(item) for item in failures)
                if isinstance(failures, list)
                else str(failures)
            )
            raise RuntimeError("角色四方向动作未通过质量检查: " + failure_text)

        result_payload.update(
            {
                "ok": True,
                "source_image_path": str(args.source_image),
                "identity_reference_path": str(identity_reference_path),
                "identity_path": str(identity_path),
                "spritesheet_path": str(spritesheet_path),
                "review_path": str(review_path),
                "feet_zoom_path": str(feet_zoom_path),
                "size_qa_path": str(size_qa_path),
                "animation_qa_path": str(animation_qa_path),
                "log_path": str(log_path),
            }
        )
        _status(
            args.status_json,
            "done",
            "角色皮肤生成完成",
            spritesheet_path=str(spritesheet_path),
        )
        _write_json(args.result_json, result_payload)
        return 0
    except Exception as exc:
        result_payload.update(
            {"ok": False, "error": str(exc), "log_path": str(log_path)}
        )
        _status(args.status_json, "failed", "生成失败：%s" % exc, ok=False)
        _write_json(args.result_json, result_payload)
        return 1


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
