from __future__ import annotations

import argparse
import json
import zipfile
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any

import numpy as np
from PIL import Image

from src.perfect_pixel import get_perfect_pixel


@dataclass
class SliceConfig:
    source: str
    output_dir: str
    slice_mode: str
    manual_mode: str | None
    export_mode: str
    pixel_output_width: int | None
    pixel_output_height: int | None
    pixel_sample_method: str
    pixel_refine_intensity: float
    rows: int | None
    cols: int | None
    origin_x: int
    origin_y: int
    region_width: int | None
    region_height: int | None
    cell_width: float | None
    cell_height: float | None
    margin_left: int
    margin_top: int
    margin_right: int
    margin_bottom: int
    spacing_x: int
    spacing_y: int
    index_base: int
    visual_boundary_match: bool
    visual_search_padding: int
    color_threshold: int
    alpha_threshold: int
    auto_close_radius: int
    auto_min_area: int
    auto_padding: int


def positive_int(value: str) -> int:
    parsed = int(value)
    if parsed <= 0:
        raise argparse.ArgumentTypeError("value must be > 0")
    return parsed


def non_negative_int(value: str) -> int:
    parsed = int(value)
    if parsed < 0:
        raise argparse.ArgumentTypeError("value must be >= 0")
    return parsed


def non_negative_float(value: str) -> float:
    parsed = float(value)
    if parsed < 0:
        raise argparse.ArgumentTypeError("value must be >= 0")
    return parsed


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "Slice a texture atlas into individual PNG sprites while preserving "
            "original resolution, color data, and alpha."
        )
    )
    parser.add_argument("source", type=Path, help="Source atlas PNG path.")
    parser.add_argument(
        "--output-dir",
        type=Path,
        default=None,
        help="Directory for sliced PNGs. Defaults to <source_stem>_slices.",
    )
    parser.add_argument(
        "--slice-mode",
        choices=("auto", "manual"),
        default="auto",
        help="Auto-detect sprites by segmentation, or slice using manual grid parameters.",
    )
    parser.add_argument(
        "--manual-mode",
        choices=("grid", "cell_size"),
        default="grid",
        help="For manual slicing, either use explicit rows/cols or infer them from target cell size.",
    )
    parser.add_argument(
        "--export-mode",
        choices=("original", "pixel"),
        default="original",
        help="Export original slices or pixelized slice results.",
    )
    parser.add_argument("--pixel-output-width", type=positive_int, default=None)
    parser.add_argument("--pixel-output-height", type=positive_int, default=None)
    parser.add_argument(
        "--pixel-sample-method",
        choices=("center", "majority", "median"),
        default="center",
    )
    parser.add_argument("--pixel-refine-intensity", type=float, default=0.25)
    parser.add_argument("--rows", type=positive_int, default=8)
    parser.add_argument("--cols", type=positive_int, default=8)
    parser.add_argument("--origin-x", type=non_negative_int, default=0)
    parser.add_argument("--origin-y", type=non_negative_int, default=0)
    parser.add_argument(
        "--region-width",
        type=positive_int,
        default=None,
        help="Width of the atlas region to slice. Defaults to image width - origin-x.",
    )
    parser.add_argument(
        "--region-height",
        type=positive_int,
        default=None,
        help="Height of the atlas region to slice. Defaults to image height - origin-y.",
    )
    parser.add_argument(
        "--cell-width",
        type=non_negative_float,
        default=None,
        help="Explicit cell width. If omitted, width is auto-distributed across columns.",
    )
    parser.add_argument(
        "--cell-height",
        type=non_negative_float,
        default=None,
        help="Explicit cell height. If omitted, height is auto-distributed across rows.",
    )
    parser.add_argument("--margin-left", type=non_negative_int, default=0)
    parser.add_argument("--margin-top", type=non_negative_int, default=0)
    parser.add_argument("--margin-right", type=non_negative_int, default=0)
    parser.add_argument("--margin-bottom", type=non_negative_int, default=0)
    parser.add_argument("--spacing-x", type=non_negative_int, default=0)
    parser.add_argument("--spacing-y", type=non_negative_int, default=0)
    parser.add_argument("--index-base", type=int, default=0)
    parser.add_argument(
        "--visual-boundary-match",
        action="store_true",
        help=(
            "Re-center each slice around the detected visual content inside a search "
            "window, useful for irregular or visually misaligned atlases."
        ),
    )
    parser.add_argument("--visual-search-padding", type=non_negative_int, default=4)
    parser.add_argument(
        "--color-threshold",
        type=non_negative_int,
        default=18,
        help="Foreground/background RGB delta threshold.",
    )
    parser.add_argument(
        "--alpha-threshold",
        type=non_negative_int,
        default=8,
        help="Foreground/background alpha delta threshold.",
    )
    parser.add_argument(
        "--auto-close-radius",
        type=non_negative_int,
        default=6,
        help="Morphological close radius for auto sprite detection.",
    )
    parser.add_argument(
        "--auto-min-area",
        type=non_negative_int,
        default=24,
        help="Minimum connected-component area for auto sprite detection.",
    )
    parser.add_argument(
        "--auto-padding",
        type=non_negative_int,
        default=2,
        help="Padding added around each auto-detected sprite on the exported canvas.",
    )
    return parser.parse_args()


def normalize_manual_mode(value: str | None) -> str:
    return str(value or "grid").strip().lower().replace("-", "_")


def normalize_export_mode(value: str | None) -> str:
    return str(value or "original").strip().lower().replace("-", "_")


def validate_region(image: Image.Image, args: argparse.Namespace) -> tuple[int, int, int, int]:
    origin_x = args.origin_x
    origin_y = args.origin_y
    region_width = args.region_width or (image.width - origin_x)
    region_height = args.region_height or (image.height - origin_y)

    if origin_x >= image.width or origin_y >= image.height:
        raise ValueError("origin is outside the source image bounds")

    if origin_x + region_width > image.width or origin_y + region_height > image.height:
        raise ValueError("slice region exceeds the source image bounds")

    return origin_x, origin_y, region_width, region_height


def infer_axis_count_from_cell_size(
    *,
    region_extent: int,
    cell_extent: float | None,
    leading_margin: int,
    trailing_margin: int,
    spacing: int,
    axis_label: str,
) -> int:
    if cell_extent is None or cell_extent <= 0:
        raise ValueError(f"{axis_label} cell-size mode requires a positive cell extent")

    usable_extent = region_extent - leading_margin - trailing_margin
    if usable_extent <= 0:
        raise ValueError(f"{axis_label} usable extent is <= 0 after margins")

    step = cell_extent + spacing
    if step <= 0:
        raise ValueError(f"{axis_label} slice step must be > 0")

    inferred = (usable_extent + spacing) / step
    rounded = int(round(inferred))
    if rounded <= 0:
        raise ValueError(f"{axis_label} inferred slice count must be > 0")

    if abs(inferred - rounded) > 1e-4:
        raise ValueError(
            f"{axis_label} extent cannot be evenly sliced with the requested cell size, spacing, and margins"
        )

    return rounded


def build_axis_bounds(
    *,
    start: int,
    region_extent: int,
    count: int,
    fixed_cell_extent: float | None,
    leading_margin: int,
    trailing_margin: int,
    spacing: int,
) -> list[tuple[int, int]]:
    usable_extent = region_extent - leading_margin - trailing_margin - spacing * (count - 1)
    if usable_extent <= 0:
        raise ValueError("usable atlas extent is <= 0 after margins and spacing")

    axis_start = start + leading_margin
    axis_end = start + region_extent - trailing_margin
    bounds: list[tuple[int, int]] = []

    if fixed_cell_extent is None:
        step = usable_extent / count
        for index in range(count):
            left = axis_start + round(index * step) + index * spacing
            right = axis_start + round((index + 1) * step) + index * spacing
            bounds.append((left, right))
    else:
        for index in range(count):
            left = axis_start + round(index * (fixed_cell_extent + spacing))
            right = left + round(fixed_cell_extent)
            bounds.append((left, right))

    for left, right in bounds:
        if left < axis_start or right > axis_end or right <= left:
            raise ValueError("computed slice bounds are invalid; adjust region/cell/margins")

    return bounds


def detect_visual_bbox(
    image_rgba: Image.Image,
    rect: tuple[int, int, int, int],
    region_rect: tuple[int, int, int, int],
    search_padding: int,
    color_threshold: int,
    alpha_threshold: int,
) -> tuple[int, int, int, int]:
    left, top, right, bottom = rect
    region_left, region_top, region_right, region_bottom = region_rect

    search_left = max(region_left, left - search_padding)
    search_top = max(region_top, top - search_padding)
    search_right = min(region_right, right + search_padding)
    search_bottom = min(region_bottom, bottom + search_padding)

    search = image_rgba.crop((search_left, search_top, search_right, search_bottom))
    pixels = np.asarray(search, dtype=np.uint8)
    if pixels.size == 0:
        return rect

    background = sample_border_background_rgba(search)
    rgb_delta = np.max(np.abs(pixels[:, :, :3].astype(np.int16) - background[:3]), axis=2)
    alpha_delta = np.abs(pixels[:, :, 3].astype(np.int16) - background[3])
    foreground_mask = (rgb_delta > color_threshold) | (alpha_delta > alpha_threshold)

    points = np.argwhere(foreground_mask)
    if points.size == 0:
        return rect

    min_y, min_x = points.min(axis=0)
    max_y, max_x = points.max(axis=0)

    original_width = right - left
    original_height = bottom - top
    center_x = search_left + (min_x + max_x + 1) / 2.0
    center_y = search_top + (min_y + max_y + 1) / 2.0

    new_left = round(center_x - original_width / 2.0)
    new_top = round(center_y - original_height / 2.0)
    new_left = max(region_left, min(new_left, region_right - original_width))
    new_top = max(region_top, min(new_top, region_bottom - original_height))

    return (
        new_left,
        new_top,
        new_left + original_width,
        new_top + original_height,
    )


def save_png(image: Image.Image, destination: Path, source_info: dict[str, Any]) -> None:
    save_kwargs: dict[str, Any] = {"optimize": False}
    icc_profile = source_info.get("icc_profile")
    if icc_profile:
        save_kwargs["icc_profile"] = icc_profile
    image.save(destination, format="PNG", **save_kwargs)


def clamp_refine_intensity(value: float | int | None) -> float:
    try:
        refine_intensity = float(value if value is not None else 0.25)
    except (TypeError, ValueError) as exc:
        raise ValueError("pixel refine intensity must be numeric") from exc

    return max(0.0, min(0.5, refine_intensity))


def estimate_border_rgba(image_array: np.ndarray) -> np.ndarray:
    border_pixels = np.concatenate(
        [
            image_array[0, :, :],
            image_array[-1, :, :],
            image_array[:, 0, :],
            image_array[:, -1, :],
        ],
        axis=0,
    ).astype(np.float32)
    return np.median(border_pixels, axis=0).astype(np.uint8)


def build_working_rgb(image_rgba: np.ndarray) -> np.ndarray:
    rgb = image_rgba[:, :, :3].copy()
    alpha = image_rgba[:, :, 3]

    if np.any(alpha < 255):
        border_rgba = estimate_border_rgba(image_rgba)
        rgb[alpha == 0] = border_rgba[:3]

    return rgb.astype(np.uint8)


def resize_alpha_channel(alpha_channel: np.ndarray, width: int, height: int) -> np.ndarray:
    alpha_image = Image.fromarray(alpha_channel.astype(np.uint8), mode="L")
    alpha_image = alpha_image.resize((width, height), resample=Image.Resampling.NEAREST)
    return np.asarray(alpha_image, dtype=np.uint8)


def enforce_output_size(image_array: np.ndarray, width: int, height: int) -> np.ndarray:
    if image_array.shape[1] == width and image_array.shape[0] == height:
        return image_array

    image = Image.fromarray(image_array.astype(np.uint8), mode="RGB")
    image = image.resize((width, height), resample=Image.Resampling.NEAREST)
    return np.asarray(image, dtype=np.uint8)


def compute_contain_size(
    source_width: int,
    source_height: int,
    target_width: int,
    target_height: int,
) -> tuple[int, int]:
    if source_width <= 0 or source_height <= 0:
        raise ValueError("source image must have positive dimensions")
    if target_width <= 0 or target_height <= 0:
        raise ValueError("target image must have positive dimensions")

    scale = min(target_width / float(source_width), target_height / float(source_height))
    fit_width = max(1, min(target_width, int(round(source_width * scale))))
    fit_height = max(1, min(target_height, int(round(source_height * scale))))
    return fit_width, fit_height


def transform_export_image(
    image: Image.Image,
    args: argparse.Namespace,
    *,
    background_fill_rgba: Any | None = None,
    preserve_transparency: bool | None = None,
) -> tuple[Image.Image, dict[str, Any]]:
    export_mode = normalize_export_mode(getattr(args, "export_mode", "original"))
    if export_mode != "pixel":
        return image, {
            "mode": "original",
            "width": image.width,
            "height": image.height,
            "strategy": "original",
        }

    target_width = int(getattr(args, "pixel_output_width", 0) or 0)
    target_height = int(getattr(args, "pixel_output_height", 0) or 0)
    if target_width <= 0 or target_height <= 0:
        raise ValueError("pixel export mode requires positive pixel output width and height")

    fit_width, fit_height = compute_contain_size(image.width, image.height, target_width, target_height)
    sample_method = str(getattr(args, "pixel_sample_method", "center") or "center").strip().lower()
    if sample_method not in {"center", "majority", "median"}:
        raise ValueError("unsupported pixel sample method")

    refine_intensity = clamp_refine_intensity(getattr(args, "pixel_refine_intensity", 0.25))
    rgba_image = image.convert("RGBA")
    rgba_array = np.asarray(rgba_image, dtype=np.uint8)
    source_alpha = rgba_array[:, :, 3]
    source_has_transparency = bool(np.any(source_alpha < 255))
    if preserve_transparency is not None:
        source_has_transparency = bool(preserve_transparency) or source_has_transparency
    strategy = "perfect_pixel"

    try:
        working_rgb = build_working_rgb(rgba_array)
        refined_width, refined_height, output = get_perfect_pixel(
            working_rgb,
            sample_method=sample_method,
            grid_size=(fit_width, fit_height),
            refine_intensity=refine_intensity,
            fix_square=False,
            debug=False,
        )

        if refined_width is None or refined_height is None:
            raise ValueError("pixel export failed to generate the requested slice output")

        output = np.asarray(output, dtype=np.uint8)
        if output.ndim == 2:
            output = np.repeat(output[..., None], 3, axis=2)
        if output.shape[-1] > 3:
            output = output[..., :3]

        output = enforce_output_size(output, fit_width, fit_height)
        if source_has_transparency:
            output_alpha = resize_alpha_channel(source_alpha, fit_width, fit_height)
        else:
            output_alpha = np.full((fit_height, fit_width), 255, dtype=np.uint8)

        contained = Image.fromarray(np.dstack([output, output_alpha]), mode="RGBA")
    except Exception:
        strategy = "nearest_fallback_contain"
        contained = rgba_image.resize((fit_width, fit_height), resample=Image.Resampling.NEAREST)

    paste_x = (target_width - fit_width) // 2
    paste_y = (target_height - fit_height) // 2
    if source_has_transparency:
        result = Image.new("RGBA", (target_width, target_height), (0, 0, 0, 0))
        result.paste(contained, (paste_x, paste_y), contained)
    else:
        fill_rgba = background_fill_rgba
        if fill_rgba is None:
            fill_rgba = sample_border_background_rgba(rgba_image)

        result = Image.new("RGBA", (target_width, target_height), to_rgba_fill(fill_rgba))
        result.paste(contained, (paste_x, paste_y))

    return result, {
        "mode": "pixel",
        "width": target_width,
        "height": target_height,
        "content_width": fit_width,
        "content_height": fit_height,
        "paste_x": paste_x,
        "paste_y": paste_y,
        "sample_method": sample_method,
        "refine_intensity": refine_intensity,
        "source_width": image.width,
        "source_height": image.height,
        "strategy": strategy,
    }


def make_zip(output_dir: Path, zip_path: Path) -> None:
    with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
        for file_path in sorted(output_dir.iterdir()):
            if file_path.is_file():
                archive.write(file_path, arcname=file_path.name)


def sanitize_archive_part(value: str | None, fallback: str = "atlas") -> str:
    raw = str(value or "").strip()
    if not raw:
        return fallback

    normalized_chars: list[str] = []
    last_was_separator = False

    for char in raw:
        if char.isalnum():
            normalized_chars.append(char)
            last_was_separator = False
            continue

        if char in {"-", "_", " "}:
            if not last_was_separator:
                normalized_chars.append("-")
                last_was_separator = True
            continue

        if not last_was_separator:
            normalized_chars.append("-")
            last_was_separator = True

    cleaned = "".join(normalized_chars).strip("-_")
    return cleaned or fallback


def build_archive_stem(args: argparse.Namespace, manifest: dict[str, Any]) -> str:
    config = manifest.get("config", {})
    slice_mode = str(config.get("slice_mode", getattr(args, "slice_mode", "auto")) or "auto")
    manual_mode = config.get("manual_mode", getattr(args, "manual_mode", None))
    export_mode = str(config.get("export_mode", getattr(args, "export_mode", "original")) or "original")
    source_stem = sanitize_archive_part(
        getattr(args, "archive_stem", None) or getattr(args.source, "stem", "atlas"),
        fallback="atlas",
    )
    parts = [source_stem, sanitize_archive_part(slice_mode, fallback="auto")]

    if slice_mode == "manual" and manual_mode:
        parts.append(sanitize_archive_part(str(manual_mode), fallback="grid"))

    if slice_mode == "auto":
        auto_meta = manifest.get("auto_detection") or {}
        rows = auto_meta.get("detected_rows")
        cols = auto_meta.get("detected_cols")
    else:
        rows = config.get("rows")
        cols = config.get("cols")

    if rows and cols:
        parts.append(f"{int(rows)}x{int(cols)}")

    if export_mode == "pixel":
        pixel_width = config.get("pixel_output_width")
        pixel_height = config.get("pixel_output_height")
        if pixel_width and pixel_height:
            parts.append(f"pixel-{int(pixel_width)}x{int(pixel_height)}")
        else:
            parts.append("pixel")
    else:
        parts.append("original")

    slice_count = len(manifest.get("slices", []))
    if slice_count:
        parts.append(f"{slice_count}slices")

    return "-".join(parts)


def build_pixel_sheet_image(output_dir: Path, manifest: dict[str, Any]) -> Image.Image | None:
    slices = manifest.get("slices", [])
    if not slices:
        return None

    sorted_slices = sorted(slices, key=lambda item: (int(item["row"]), int(item["col"])))
    tile_width = max(int(item.get("canvas", {}).get("width", item["box"]["width"])) for item in sorted_slices)
    tile_height = max(int(item.get("canvas", {}).get("height", item["box"]["height"])) for item in sorted_slices)
    if tile_width <= 0 or tile_height <= 0:
        return None

    slice_count = len(sorted_slices)
    grid_cols = max(1, int(np.ceil(np.sqrt(slice_count))))
    grid_rows = max(1, int(np.ceil(slice_count / float(grid_cols))))
    total_width = tile_width * grid_cols
    total_height = tile_height * grid_rows
    sheet = Image.new("RGBA", (total_width, total_height), (0, 0, 0, 0))

    for index, item in enumerate(sorted_slices):
        row_index = index // grid_cols
        col_index = index % grid_cols
        slice_path = output_dir / item["file"]
        with Image.open(slice_path) as slice_image:
            tile = slice_image.convert("RGBA")

        paste_x = col_index * tile_width + max(0, (tile_width - tile.width) // 2)
        paste_y = row_index * tile_height + max(0, (tile_height - tile.height) // 2)
        sheet.paste(tile, (paste_x, paste_y), tile)

    return sheet


def sample_border_background_rgba(image_rgba: Image.Image) -> np.ndarray:
    pixels = np.asarray(image_rgba, dtype=np.uint8)
    border_samples = np.concatenate(
        [
            pixels[0, :, :],
            pixels[-1, :, :],
            pixels[:, 0, :],
            pixels[:, -1, :],
        ],
        axis=0,
    ).astype(np.int16)
    return np.median(border_samples, axis=0)


def image_has_transparency(image: Image.Image) -> bool:
    if "A" not in image.getbands():
        return False

    alpha_channel = np.asarray(image.getchannel("A"), dtype=np.uint8)
    return bool(np.any(alpha_channel < 255))


def composite_over_background(image: Image.Image, background_fill: Any) -> Image.Image:
    rgba_image = image.convert("RGBA")
    flattened = Image.new("RGBA", rgba_image.size, to_rgba_fill(background_fill))
    flattened.alpha_composite(rgba_image)
    return flattened


def build_foreground_mask(
    image_rgba: Image.Image,
    *,
    color_threshold: int,
    alpha_threshold: int,
) -> tuple[np.ndarray, np.ndarray]:
    pixels = np.asarray(image_rgba, dtype=np.uint8)
    background = sample_border_background_rgba(image_rgba)

    rgb_delta = np.max(np.abs(pixels[:, :, :3].astype(np.int16) - background[:3]), axis=2)
    alpha_delta = np.abs(pixels[:, :, 3].astype(np.int16) - background[3])
    mask = (rgb_delta > color_threshold) | (alpha_delta > alpha_threshold)

    if background[3] <= alpha_threshold:
        mask |= pixels[:, :, 3] > alpha_threshold

    if not np.any(mask) and np.any(pixels[:, :, 3] > alpha_threshold):
        mask = pixels[:, :, 3] > alpha_threshold

    return mask.astype(bool), background


def binary_dilate(mask: np.ndarray, radius: int) -> np.ndarray:
    if radius <= 0:
        return mask.copy()

    horiz = np.zeros_like(mask, dtype=bool)
    padded_x = np.pad(mask, ((0, 0), (radius, radius)), constant_values=False)
    for offset in range(radius * 2 + 1):
        horiz |= padded_x[:, offset:offset + mask.shape[1]]

    dilated = np.zeros_like(mask, dtype=bool)
    padded_y = np.pad(horiz, ((radius, radius), (0, 0)), constant_values=False)
    for offset in range(radius * 2 + 1):
        dilated |= padded_y[offset:offset + mask.shape[0], :]

    return dilated


def binary_erode(mask: np.ndarray, radius: int) -> np.ndarray:
    if radius <= 0:
        return mask.copy()

    horiz = np.ones_like(mask, dtype=bool)
    padded_x = np.pad(mask, ((0, 0), (radius, radius)), constant_values=True)
    for offset in range(radius * 2 + 1):
        horiz &= padded_x[:, offset:offset + mask.shape[1]]

    eroded = np.ones_like(mask, dtype=bool)
    padded_y = np.pad(horiz, ((radius, radius), (0, 0)), constant_values=True)
    for offset in range(radius * 2 + 1):
        eroded &= padded_y[offset:offset + mask.shape[0], :]

    return eroded


def binary_close(mask: np.ndarray, radius: int) -> np.ndarray:
    if radius <= 0:
        return mask.copy()
    return binary_erode(binary_dilate(mask, radius), radius)


def find_connected_components(mask: np.ndarray, min_area: int) -> list[dict[str, int]]:
    height, width = mask.shape
    visited = np.zeros_like(mask, dtype=bool)
    components: list[dict[str, int]] = []

    for y in range(height):
        for x in range(width):
            if not mask[y, x] or visited[y, x]:
                continue

            stack = [(x, y)]
            visited[y, x] = True
            left = right = x
            top = bottom = y
            area = 0

            while stack:
                current_x, current_y = stack.pop()
                area += 1
                left = min(left, current_x)
                right = max(right, current_x)
                top = min(top, current_y)
                bottom = max(bottom, current_y)

                for next_y in range(max(0, current_y - 1), min(height, current_y + 2)):
                    for next_x in range(max(0, current_x - 1), min(width, current_x + 2)):
                        if visited[next_y, next_x] or not mask[next_y, next_x]:
                            continue
                        visited[next_y, next_x] = True
                        stack.append((next_x, next_y))

            if area < min_area:
                continue

            components.append(
                {
                    "left": left,
                    "top": top,
                    "right": right + 1,
                    "bottom": bottom + 1,
                    "width": right - left + 1,
                    "height": bottom - top + 1,
                    "area": area,
                }
            )

    return components


def find_labeled_components(mask: np.ndarray, min_area: int = 1) -> tuple[list[dict[str, Any]], np.ndarray]:
    height, width = mask.shape
    visited = np.zeros_like(mask, dtype=bool)
    labels = np.full(mask.shape, -1, dtype=np.int32)
    components: list[dict[str, Any]] = []
    label_index = 0

    for y in range(height):
        for x in range(width):
            if not mask[y, x] or visited[y, x]:
                continue

            stack = [(x, y)]
            visited[y, x] = True
            left = right = x
            top = bottom = y
            area = 0
            pixels: list[tuple[int, int]] = []

            while stack:
                current_x, current_y = stack.pop()
                area += 1
                pixels.append((current_x, current_y))
                left = min(left, current_x)
                right = max(right, current_x)
                top = min(top, current_y)
                bottom = max(bottom, current_y)

                for next_y in range(max(0, current_y - 1), min(height, current_y + 2)):
                    for next_x in range(max(0, current_x - 1), min(width, current_x + 2)):
                        if visited[next_y, next_x] or not mask[next_y, next_x]:
                            continue
                        visited[next_y, next_x] = True
                        stack.append((next_x, next_y))

            if area < min_area:
                continue

            for pixel_x, pixel_y in pixels:
                labels[pixel_y, pixel_x] = label_index

            component = {
                "label": label_index,
                "left": left,
                "top": top,
                "right": right + 1,
                "bottom": bottom + 1,
                "width": right - left + 1,
                "height": bottom - top + 1,
                "area": area,
                "center_x": (left + right + 1) / 2.0,
                "center_y": (top + bottom + 1) / 2.0,
            }
            components.append(component)
            label_index += 1

    return components, labels


def to_rgba_fill(fill_color: Any) -> tuple[int, int, int, int]:
    if isinstance(fill_color, (tuple, list)):
        if len(fill_color) == 4:
            return tuple(int(value) for value in fill_color)
        if len(fill_color) == 3:
            return (int(fill_color[0]), int(fill_color[1]), int(fill_color[2]), 255)
        if len(fill_color) == 2:
            return (int(fill_color[0]), int(fill_color[0]), int(fill_color[0]), int(fill_color[1]))
        if len(fill_color) == 1:
            value = int(fill_color[0])
            return (value, value, value, 255)

    if isinstance(fill_color, np.ndarray):
        return to_rgba_fill(fill_color.tolist())

    value = int(fill_color)
    return (value, value, value, 255)


def extract_foreground_to_transparent_crop(
    image: Image.Image,
    *,
    color_threshold: int,
    alpha_threshold: int,
) -> tuple[Image.Image, dict[str, int]]:
    rgba_image = image.convert("RGBA")
    mask, _background = build_foreground_mask(
        rgba_image,
        color_threshold=color_threshold,
        alpha_threshold=alpha_threshold,
    )

    if not np.any(mask):
        empty = Image.new("RGBA", (1, 1), (0, 0, 0, 0))
        return empty, {
            "foreground_width": 0,
            "foreground_height": 0,
            "foreground_area": 0,
        }

    points = np.argwhere(mask)
    min_y, min_x = points.min(axis=0)
    max_y, max_x = points.max(axis=0)
    output = np.asarray(rgba_image, dtype=np.uint8).copy()
    output[~mask, 0:4] = 0
    cropped = Image.fromarray(output, mode="RGBA").crop((int(min_x), int(min_y), int(max_x) + 1, int(max_y) + 1))

    return cropped, {
        "foreground_width": int(max_x - min_x + 1),
        "foreground_height": int(max_y - min_y + 1),
        "foreground_area": int(np.count_nonzero(mask)),
    }


def select_primary_component(
    components: list[dict[str, Any]],
    image_width: int,
    image_height: int,
) -> dict[str, Any]:
    if not components:
        raise ValueError("components must not be empty")

    center_left = image_width * 0.25
    center_top = image_height * 0.25
    center_right = image_width * 0.75
    center_bottom = image_height * 0.75
    viewport_center_x = image_width / 2.0
    viewport_center_y = image_height / 2.0

    def component_score(component: dict[str, Any]) -> tuple[float, float, float]:
        overlap_width = max(
            0.0,
            min(float(component["right"]), center_right) - max(float(component["left"]), center_left),
        )
        overlap_height = max(
            0.0,
            min(float(component["bottom"]), center_bottom) - max(float(component["top"]), center_top),
        )
        center_overlap = overlap_width * overlap_height
        distance = abs(float(component["center_x"]) - viewport_center_x) + abs(float(component["center_y"]) - viewport_center_y)
        return (center_overlap, float(component["area"]), -distance)

    return max(components, key=component_score)


def strip_disconnected_edge_pixels(
    image: Image.Image,
    *,
    color_threshold: int,
    alpha_threshold: int,
) -> tuple[Image.Image, dict[str, int]]:
    rgba_image = image.convert("RGBA")
    mask, _background = build_foreground_mask(
        rgba_image,
        color_threshold=color_threshold,
        alpha_threshold=alpha_threshold,
    )
    if not np.any(mask):
        return rgba_image, {
            "components": 0,
            "removed_components": 0,
            "removed_pixels": 0,
            "edge_margin": 0,
        }

    components, labels = find_labeled_components(mask, min_area=1)
    if len(components) <= 1:
        return rgba_image, {
            "components": len(components),
            "removed_components": 0,
            "removed_pixels": 0,
            "edge_margin": 0,
        }

    primary_component = select_primary_component(components, rgba_image.width, rgba_image.height)
    edge_margin = max(2, min(4, int(round(min(rgba_image.width, rgba_image.height) * 0.06))))
    removable_labels = [
        int(component["label"])
        for component in components
        if component["label"] != primary_component["label"]
        and (
            int(component["left"]) <= edge_margin
            or int(component["top"]) <= edge_margin
            or int(component["right"]) >= rgba_image.width - edge_margin
            or int(component["bottom"]) >= rgba_image.height - edge_margin
        )
    ]

    if not removable_labels:
        return rgba_image, {
            "components": len(components),
            "removed_components": 0,
            "removed_pixels": 0,
            "edge_margin": edge_margin,
        }

    output = np.asarray(rgba_image, dtype=np.uint8).copy()
    removal_mask = np.isin(labels, np.asarray(removable_labels, dtype=np.int32))
    removed_pixels = int(np.count_nonzero(removal_mask))
    output[removal_mask, 0:4] = 0

    return Image.fromarray(output, mode="RGBA"), {
        "components": len(components),
        "removed_components": len(removable_labels),
        "removed_pixels": removed_pixels,
        "edge_margin": edge_margin,
    }


def tighten_box_from_mask(mask: np.ndarray, box: tuple[int, int, int, int]) -> tuple[int, int, int, int] | None:
    left, top, right, bottom = box
    region = mask[top:bottom, left:right]
    if region.size == 0 or not np.any(region):
        return None

    active_rows = np.where(np.any(region, axis=1))[0]
    active_cols = np.where(np.any(region, axis=0))[0]

    new_top = top + int(active_rows[0])
    new_bottom = top + int(active_rows[-1]) + 1
    new_left = left + int(active_cols[0])
    new_right = left + int(active_cols[-1]) + 1
    return (new_left, new_top, new_right, new_bottom)


def iqr_inlier_mask(values: np.ndarray) -> np.ndarray:
    if values.size < 4:
        return np.ones_like(values, dtype=bool)

    q1 = np.percentile(values, 25)
    q3 = np.percentile(values, 75)
    iqr = q3 - q1
    if iqr <= 0:
        return np.ones_like(values, dtype=bool)

    lower = q1 - 1.5 * iqr
    upper = q3 + 1.5 * iqr
    return (values >= lower) & (values <= upper)


def filter_component_outliers(boxes: list[dict[str, int]]) -> list[dict[str, int]]:
    if len(boxes) < 4:
        return boxes

    widths = np.asarray([box["width"] for box in boxes], dtype=float)
    heights = np.asarray([box["height"] for box in boxes], dtype=float)
    areas = np.asarray([box["area"] for box in boxes], dtype=float)
    median_width = float(np.median(widths))
    median_height = float(np.median(heights))
    median_area = float(np.median(areas))

    keep_mask = (
        (widths >= max(1.0, median_width * 0.55))
        & (widths <= max(2.0, median_width * 1.85))
        & (heights >= max(1.0, median_height * 0.55))
        & (heights <= max(2.0, median_height * 1.85))
        & (areas >= max(1.0, median_area * 0.25))
        & (areas <= max(4.0, median_area * 3.5))
    )

    if int(np.count_nonzero(keep_mask)) < max(2, len(boxes) // 2):
        return boxes

    return [box for box, keep in zip(boxes, keep_mask, strict=False) if keep]


def group_boxes_into_rows(boxes: list[dict[str, int]]) -> list[list[dict[str, int]]]:
    if not boxes:
        return []

    median_height = float(np.median([box["height"] for box in boxes]))
    row_threshold = max(6.0, median_height * 0.7)
    sorted_boxes = sorted(boxes, key=lambda item: (item["center_y"], item["center_x"]))
    rows: list[list[dict[str, int]]] = []

    for box in sorted_boxes:
        if not rows:
            rows.append([box])
            continue

        current_row = rows[-1]
        row_center = float(np.mean([item["center_y"] for item in current_row]))
        if abs(box["center_y"] - row_center) <= row_threshold:
            current_row.append(box)
        else:
            rows.append([box])

    for row in rows:
        row.sort(key=lambda item: item["center_x"])

    return rows


def close_1d_mask(mask: np.ndarray, radius: int) -> np.ndarray:
    if radius <= 0:
        return mask.astype(bool, copy=True)

    active = mask.astype(bool, copy=False)
    padded = np.pad(active, (radius, radius), constant_values=False)
    dilated = np.zeros_like(active, dtype=bool)
    for offset in range(radius * 2 + 1):
        dilated |= padded[offset:offset + active.shape[0]]

    padded_dilated = np.pad(dilated, (radius, radius), constant_values=True)
    eroded = np.ones_like(active, dtype=bool)
    for offset in range(radius * 2 + 1):
        eroded &= padded_dilated[offset:offset + active.shape[0]]

    return eroded


def find_projection_bands(
    mask: np.ndarray,
    *,
    axis: str,
    close_radius: int = 0,
) -> list[dict[str, float]]:
    if axis == "y":
        active = np.any(mask, axis=1)
    elif axis == "x":
        active = np.any(mask, axis=0)
    else:
        raise ValueError(f"unsupported axis: {axis}")

    active = close_1d_mask(active, close_radius)
    bands: list[dict[str, float]] = []
    start: int | None = None

    for index, is_active in enumerate(active.tolist()):
        if is_active and start is None:
            start = index
        elif not is_active and start is not None:
            end = index
            bands.append(
                {
                    "start": start,
                    "end": end,
                    "size": end - start,
                    "center": (start + end) / 2.0,
                }
            )
            start = None

    if start is not None:
        end = int(active.shape[0])
        bands.append(
            {
                "start": start,
                "end": end,
                "size": end - start,
                "center": (start + end) / 2.0,
            }
        )

    return bands


def group_boxes_by_projection_bands(
    boxes: list[dict[str, int]],
    bands: list[dict[str, float]],
    *,
    axis: str,
) -> list[list[dict[str, int]]]:
    if not boxes or not bands:
        return []

    if axis == "y":
        start_key = "top"
        end_key = "bottom"
        center_key = "center_y"
        sort_key = "center_x"
    elif axis == "x":
        start_key = "left"
        end_key = "right"
        center_key = "center_x"
        sort_key = "center_y"
    else:
        raise ValueError(f"unsupported axis: {axis}")

    grouped: list[list[dict[str, int]]] = [[] for _ in bands]

    for box in boxes:
        best_index = 0
        best_overlap = -1.0
        best_distance = float("inf")

        for index, band in enumerate(bands):
            overlap = max(
                0.0,
                min(float(box[end_key]), float(band["end"]))
                - max(float(box[start_key]), float(band["start"])),
            )
            distance = abs(float(box[center_key]) - float(band["center"]))
            if overlap > best_overlap or (overlap == best_overlap and distance < best_distance):
                best_index = index
                best_overlap = overlap
                best_distance = distance

        grouped[best_index].append(box)

    non_empty_groups = [group for group in grouped if group]
    for group in non_empty_groups:
        group.sort(key=lambda item: item[sort_key])

    return non_empty_groups


def normalize_auto_boxes(
    boxes: list[dict[str, int]],
    *,
    padding: int,
) -> tuple[list[dict[str, int]], int, int]:
    target_width = max(box["width"] for box in boxes) + padding * 2
    target_height = max(box["height"] for box in boxes) + padding * 2

    for box in boxes:
        box["target_width"] = target_width
        box["target_height"] = target_height

    return boxes, target_width, target_height


def add_auto_crop_safety_padding(
    boxes: list[dict[str, int]],
    image_width: int,
    image_height: int,
) -> int:
    if not boxes:
        return 0

    median_width = float(np.median([box["width"] for box in boxes]))
    median_height = float(np.median([box["height"] for box in boxes]))
    safety_padding = max(1, min(4, int(round(min(median_width, median_height) * 0.04))))

    for box in boxes:
        left = max(0, int(box["left"]) - safety_padding)
        top = max(0, int(box["top"]) - safety_padding)
        right = min(image_width, int(box["right"]) + safety_padding)
        bottom = min(image_height, int(box["bottom"]) + safety_padding)

        box["left"] = left
        box["top"] = top
        box["right"] = right
        box["bottom"] = bottom
        box["width"] = right - left
        box["height"] = bottom - top
        box["area"] = max(1, box["width"] * box["height"])
        box["center_x"] = (left + right) / 2.0
        box["center_y"] = (top + bottom) / 2.0

    return safety_padding


def build_refined_boxes_from_components(
    mask: np.ndarray,
    components: list[dict[str, int]],
) -> list[dict[str, int]]:
    refined_boxes: list[dict[str, int]] = []

    for component in components:
        refined = tighten_box_from_mask(
            mask,
            (component["left"], component["top"], component["right"], component["bottom"]),
        )
        if refined is None:
            continue

        left, top, right, bottom = refined
        refined_boxes.append(
            {
                "left": left,
                "top": top,
                "right": right,
                "bottom": bottom,
                "width": right - left,
                "height": bottom - top,
                "area": (right - left) * (bottom - top),
                "center_x": (left + right) / 2.0,
                "center_y": (top + bottom) / 2.0,
            }
        )

    return refined_boxes


def make_background_fill(mode: str, background_rgba: np.ndarray) -> Any:
    if mode == "RGBA":
        return tuple(int(value) for value in background_rgba)
    if mode == "RGB":
        return tuple(int(value) for value in background_rgba[:3])
    if mode == "LA":
        luminance = int(round(float(np.mean(background_rgba[:3]))))
        return (luminance, int(background_rgba[3]))
    if mode == "L":
        return int(round(float(np.mean(background_rgba[:3]))))
    return tuple(int(value) for value in background_rgba)


def build_close_radius_candidates(close_radius: int) -> list[int]:
    if close_radius <= 0:
        return [0]

    candidates = {0, close_radius}
    for radius in range(1, min(close_radius, 4) + 1):
        candidates.add(radius)
    if close_radius > 4:
        candidates.add(max(1, close_radius // 2))

    return sorted(candidates)


def evaluate_auto_boxes(boxes: list[dict[str, int]], mask: np.ndarray | None = None) -> dict[str, Any]:
    rows = group_boxes_into_rows(boxes)
    row_lengths = [len(row) for row in rows]
    width_values = np.asarray([box["width"] for box in boxes], dtype=float)
    height_values = np.asarray([box["height"] for box in boxes], dtype=float)
    median_width = float(np.median(width_values))
    median_height = float(np.median(height_values))
    width_cv = float(np.std(width_values) / max(np.mean(width_values), 1.0))
    height_cv = float(np.std(height_values) / max(np.mean(height_values), 1.0))
    column_lengths: list[int] = []
    projection_rows = 0
    projection_cols = 0

    if mask is not None:
        projection_row_radius = max(1, int(round(median_height * 0.03)))
        projection_col_radius = max(1, int(round(median_width * 0.03)))
        row_bands = find_projection_bands(mask, axis="y", close_radius=projection_row_radius)
        col_bands = find_projection_bands(mask, axis="x", close_radius=projection_col_radius)
        projected_rows = group_boxes_by_projection_bands(boxes, row_bands, axis="y")
        projected_cols = group_boxes_by_projection_bands(boxes, col_bands, axis="x")

        if projected_rows and sum(len(row) for row in projected_rows) == len(boxes):
            rows = projected_rows
            row_lengths = [len(row) for row in rows]

        if projected_cols and sum(len(col) for col in projected_cols) == len(boxes):
            column_lengths = [len(col) for col in projected_cols]

        projection_rows = len(row_bands)
        projection_cols = len(col_bands)

    row_cv = 0.0
    if len(row_lengths) > 1:
        row_lengths_arr = np.asarray(row_lengths, dtype=float)
        row_cv = float(np.std(row_lengths_arr) / max(np.mean(row_lengths_arr), 1.0))

    rows_count = len(rows)
    cols_count = len(column_lengths) if column_lengths else max(row_lengths, default=0)
    col_cv = 0.0
    if len(column_lengths) > 1:
        column_lengths_arr = np.asarray(column_lengths, dtype=float)
        col_cv = float(np.std(column_lengths_arr) / max(np.mean(column_lengths_arr), 1.0))

    regularity = 1.0 / (1.0 + width_cv + height_cv + row_cv + col_cv)
    size_factor = max(1.0, float(np.sqrt(max(1.0, median_width * median_height))))
    count_factor = float(np.log1p(len(boxes)))
    grid_bonus = 1.0
    if rows_count > 1:
        grid_bonus += 0.75
    if cols_count > 1:
        grid_bonus += 0.75

    grid_fill = float(len(boxes) / max(float(rows_count * cols_count), 1.0))
    completeness = max(0.2, min(1.0, grid_fill))
    projection_bonus = 1.0
    if projection_rows > 1 and projection_rows == rows_count:
        projection_bonus += 0.3
    if projection_cols > 1 and projection_cols == cols_count:
        projection_bonus += 0.3

    score = regularity * size_factor * count_factor * grid_bonus * completeness * projection_bonus
    return {
        "rows": rows,
        "row_lengths": row_lengths,
        "column_lengths": column_lengths,
        "detected_rows": rows_count,
        "detected_cols": cols_count,
        "median_width": median_width,
        "median_height": median_height,
        "projection_rows": projection_rows,
        "projection_cols": projection_cols,
        "score": score,
    }


def detect_auto_sprite_boxes(
    source_image: Image.Image,
    args: argparse.Namespace,
) -> tuple[list[dict[str, int]], dict[str, Any]]:
    source_rgba = source_image.convert("RGBA")
    mask, background_rgba = build_foreground_mask(
        source_rgba,
        color_threshold=args.color_threshold,
        alpha_threshold=args.alpha_threshold,
    )

    min_area = max(1, args.auto_min_area)
    close_radius = args.auto_close_radius
    best_candidate: dict[str, Any] | None = None

    for candidate_radius in build_close_radius_candidates(close_radius):
        candidate_mask = binary_close(mask, candidate_radius) if candidate_radius > 0 else mask
        components = find_connected_components(candidate_mask, min_area)
        if not components:
            continue

        candidate_boxes = build_refined_boxes_from_components(mask, components)
        candidate_boxes = filter_component_outliers(candidate_boxes)
        if not candidate_boxes:
            continue

        evaluation = evaluate_auto_boxes(candidate_boxes, mask)
        evaluation["boxes"] = candidate_boxes
        evaluation["close_radius"] = candidate_radius

        if best_candidate is None or evaluation["score"] > best_candidate["score"]:
            best_candidate = evaluation

    if best_candidate is None:
        raise ValueError("auto segmentation could not find any sprite regions")

    refined_boxes = best_candidate["boxes"]
    close_radius = int(best_candidate["close_radius"])
    crop_safety_padding = add_auto_crop_safety_padding(
        refined_boxes,
        source_image.width,
        source_image.height,
    )

    refined_boxes, target_width, target_height = normalize_auto_boxes(
        refined_boxes,
        padding=args.auto_padding,
    )
    rows = best_candidate["rows"]

    for row_index, row in enumerate(rows):
        for col_index, box in enumerate(row):
            box["row"] = row_index + args.index_base
            box["col"] = col_index + args.index_base

    return refined_boxes, {
        "background_rgba": [int(value) for value in background_rgba.tolist()],
        "close_radius": close_radius,
        "min_area": min_area,
        "target_width": target_width,
        "target_height": target_height,
        "detected_rows": best_candidate["detected_rows"],
        "detected_cols": best_candidate["detected_cols"],
        "row_lengths": best_candidate["row_lengths"],
        "crop_safety_padding": crop_safety_padding,
    }


def export_auto_slices(
    source_image: Image.Image,
    output_dir: Path,
    args: argparse.Namespace,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
    boxes, auto_meta = detect_auto_sprite_boxes(source_image, args)
    export_mode = normalize_export_mode(getattr(args, "export_mode", "original"))
    export_source = source_image if source_image.mode in {"RGBA", "RGB", "LA", "L"} else source_image.convert("RGBA")
    fill_color = make_background_fill(export_source.mode, np.asarray(auto_meta["background_rgba"], dtype=np.uint8))

    rows_map: dict[int, list[dict[str, int]]] = {}
    for box in boxes:
        rows_map.setdefault(int(box["row"]), []).append(box)

    for row_boxes in rows_map.values():
        row_boxes.sort(key=lambda item: item["col"])

    manifest: dict[str, Any] = {
        "config": asdict(
            SliceConfig(
                source=str(args.source.resolve()),
                output_dir=str(output_dir.resolve()),
                slice_mode="auto",
                manual_mode=None,
                export_mode=normalize_export_mode(getattr(args, "export_mode", "original")),
                pixel_output_width=getattr(args, "pixel_output_width", None),
                pixel_output_height=getattr(args, "pixel_output_height", None),
                pixel_sample_method=str(getattr(args, "pixel_sample_method", "center")),
                pixel_refine_intensity=clamp_refine_intensity(getattr(args, "pixel_refine_intensity", 0.25)),
                rows=None,
                cols=None,
                origin_x=args.origin_x,
                origin_y=args.origin_y,
                region_width=args.region_width,
                region_height=args.region_height,
                cell_width=args.cell_width,
                cell_height=args.cell_height,
                margin_left=args.margin_left,
                margin_top=args.margin_top,
                margin_right=args.margin_right,
                margin_bottom=args.margin_bottom,
                spacing_x=args.spacing_x,
                spacing_y=args.spacing_y,
                index_base=args.index_base,
                visual_boundary_match=False,
                visual_search_padding=args.visual_search_padding,
                color_threshold=args.color_threshold,
                alpha_threshold=args.alpha_threshold,
                auto_close_radius=args.auto_close_radius,
                auto_min_area=args.auto_min_area,
                auto_padding=args.auto_padding,
            )
        ),
        "source_image": {
            "width": source_image.width,
            "height": source_image.height,
            "mode": source_image.mode,
        },
        "auto_detection": auto_meta,
        "slices": [],
    }

    exported_slices: list[dict[str, Any]] = []
    for row_index in sorted(rows_map):
        row_boxes = rows_map[row_index]
        for box in row_boxes:
            crop = export_source.crop((box["left"], box["top"], box["right"], box["bottom"]))
            crop_rgba = crop.convert("RGBA")
            crop_has_transparency = image_has_transparency(crop_rgba)
            crop_background_rgba = sample_border_background_rgba(crop_rgba)
            extracted_crop, extracted_meta = extract_foreground_to_transparent_crop(
                crop,
                color_threshold=args.color_threshold,
                alpha_threshold=args.alpha_threshold,
            )
            if extracted_meta["foreground_area"] < max(1, args.auto_min_area):
                continue

            cleaned_crop, cleanup_meta = strip_disconnected_edge_pixels(
                extracted_crop,
                color_threshold=args.color_threshold,
                alpha_threshold=args.alpha_threshold,
            )
            cleaned_crop, final_meta = extract_foreground_to_transparent_crop(
                cleaned_crop,
                color_threshold=args.color_threshold,
                alpha_threshold=args.alpha_threshold,
            )
            if final_meta["foreground_area"] < max(1, args.auto_min_area):
                continue

            if not crop_has_transparency:
                cleaned_crop = composite_over_background(cleaned_crop, crop_background_rgba)

            paste_x = 0
            paste_y = 0

            if export_mode == "pixel":
                export_image, export_meta = transform_export_image(
                    cleaned_crop,
                    args,
                    background_fill_rgba=None if crop_has_transparency else crop_background_rgba,
                    preserve_transparency=crop_has_transparency,
                )
            else:
                min_canvas_width = max(cleaned_crop.width, int(getattr(args, "pixel_output_width", 0) or 0))
                min_canvas_height = max(cleaned_crop.height, int(getattr(args, "pixel_output_height", 0) or 0))
                canvas_width = min_canvas_width + args.auto_padding * 2
                canvas_height = min_canvas_height + args.auto_padding * 2
                canvas_fill = fill_color if crop_has_transparency else crop_background_rgba
                canvas = Image.new("RGBA", (canvas_width, canvas_height), to_rgba_fill(canvas_fill))
                paste_x = (canvas_width - cleaned_crop.width) // 2
                paste_y = (canvas_height - cleaned_crop.height) // 2
                canvas.paste(cleaned_crop, (paste_x, paste_y), cleaned_crop)
                export_image, export_meta = transform_export_image(
                    canvas,
                    args,
                    background_fill_rgba=None if crop_has_transparency else crop_background_rgba,
                    preserve_transparency=crop_has_transparency,
                )

            filename = f"{box['row']}_{box['col']}.png"
            destination = output_dir / filename
            save_png(export_image, destination, source_image.info)

            slice_payload = {
                "file": filename,
                "row": box["row"],
                "col": box["col"],
                "box": {
                    "left": box["left"],
                    "top": box["top"],
                    "right": box["right"],
                    "bottom": box["bottom"],
                    "width": box["width"],
                    "height": box["height"],
                },
                "canvas": {
                    "width": export_image.width,
                    "height": export_image.height,
                    "paste_x": int(export_meta.get("paste_x", paste_x)),
                    "paste_y": int(export_meta.get("paste_y", paste_y)),
                },
                "foreground": final_meta,
                "extracted": extracted_meta,
                "cleanup": cleanup_meta,
                "export": export_meta,
            }
            manifest["slices"].append(slice_payload)
            exported_slices.append(slice_payload)

    return manifest, exported_slices


def export_manual_slices(
    source_image: Image.Image,
    output_dir: Path,
    args: argparse.Namespace,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
    origin_x, origin_y, region_width, region_height = validate_region(source_image, args)
    source_rgba = source_image.convert("RGBA")
    manual_mode = normalize_manual_mode(getattr(args, "manual_mode", "grid"))
    resolved_rows = args.rows
    resolved_cols = args.cols

    if manual_mode == "cell_size":
        resolved_cols = infer_axis_count_from_cell_size(
            region_extent=region_width,
            cell_extent=args.cell_width,
            leading_margin=args.margin_left,
            trailing_margin=args.margin_right,
            spacing=args.spacing_x,
            axis_label="horizontal",
        )
        resolved_rows = infer_axis_count_from_cell_size(
            region_extent=region_height,
            cell_extent=args.cell_height,
            leading_margin=args.margin_top,
            trailing_margin=args.margin_bottom,
            spacing=args.spacing_y,
            axis_label="vertical",
        )

    x_bounds = build_axis_bounds(
        start=origin_x,
        region_extent=region_width,
        count=resolved_cols,
        fixed_cell_extent=args.cell_width,
        leading_margin=args.margin_left,
        trailing_margin=args.margin_right,
        spacing=args.spacing_x,
    )
    y_bounds = build_axis_bounds(
        start=origin_y,
        region_extent=region_height,
        count=resolved_rows,
        fixed_cell_extent=args.cell_height,
        leading_margin=args.margin_top,
        trailing_margin=args.margin_bottom,
        spacing=args.spacing_y,
    )

    region_rect = (origin_x, origin_y, origin_x + region_width, origin_y + region_height)
    manifest: dict[str, Any] = {
        "config": asdict(
            SliceConfig(
                source=str(args.source.resolve()),
                output_dir=str(output_dir.resolve()),
                slice_mode="manual",
                manual_mode=manual_mode,
                export_mode=normalize_export_mode(getattr(args, "export_mode", "original")),
                pixel_output_width=getattr(args, "pixel_output_width", None),
                pixel_output_height=getattr(args, "pixel_output_height", None),
                pixel_sample_method=str(getattr(args, "pixel_sample_method", "center")),
                pixel_refine_intensity=clamp_refine_intensity(getattr(args, "pixel_refine_intensity", 0.25)),
                rows=resolved_rows,
                cols=resolved_cols,
                origin_x=origin_x,
                origin_y=origin_y,
                region_width=region_width,
                region_height=region_height,
                cell_width=args.cell_width,
                cell_height=args.cell_height,
                margin_left=args.margin_left,
                margin_top=args.margin_top,
                margin_right=args.margin_right,
                margin_bottom=args.margin_bottom,
                spacing_x=args.spacing_x,
                spacing_y=args.spacing_y,
                index_base=args.index_base,
                visual_boundary_match=args.visual_boundary_match,
                visual_search_padding=args.visual_search_padding,
                color_threshold=args.color_threshold,
                alpha_threshold=args.alpha_threshold,
                auto_close_radius=args.auto_close_radius,
                auto_min_area=args.auto_min_area,
                auto_padding=args.auto_padding,
            )
        ),
        "source_image": {
            "width": source_image.width,
            "height": source_image.height,
            "mode": source_image.mode,
        },
        "auto_detection": None,
        "slices": [],
    }

    exported_slices: list[dict[str, Any]] = []
    for row_index, (top, bottom) in enumerate(y_bounds):
        for col_index, (left, right) in enumerate(x_bounds):
            rect = (left, top, right, bottom)
            if args.visual_boundary_match:
                rect = detect_visual_bbox(
                    source_rgba,
                    rect,
                    region_rect,
                    args.visual_search_padding,
                    args.color_threshold,
                    args.alpha_threshold,
                )

            cropped = source_image.crop(rect)
            export_image, export_meta = transform_export_image(cropped, args)
            filename = f"{row_index + args.index_base}_{col_index + args.index_base}.png"
            destination = output_dir / filename
            save_png(export_image, destination, source_image.info)

            slice_payload = {
                "file": filename,
                "row": row_index + args.index_base,
                "col": col_index + args.index_base,
                "box": {
                    "left": rect[0],
                    "top": rect[1],
                    "right": rect[2],
                    "bottom": rect[3],
                    "width": rect[2] - rect[0],
                    "height": rect[3] - rect[1],
                },
                "canvas": {
                    "width": export_image.width,
                    "height": export_image.height,
                },
                "export": export_meta,
            }
            manifest["slices"].append(slice_payload)
            exported_slices.append(slice_payload)

    return manifest, exported_slices


def slice_atlas(args: argparse.Namespace) -> tuple[Path, Path]:
    if not args.source.exists():
        raise FileNotFoundError(f"source atlas not found: {args.source}")

    output_dir = args.output_dir or args.source.with_name(f"{args.source.stem}_slices")
    output_dir.mkdir(parents=True, exist_ok=True)

    with Image.open(args.source) as source_image:
        source_image.load()

        if getattr(args, "slice_mode", "auto") == "auto":
            manifest, _exported_slices = export_auto_slices(source_image, output_dir, args)
        else:
            manifest, _exported_slices = export_manual_slices(source_image, output_dir, args)

        if normalize_export_mode(getattr(args, "export_mode", "original")) == "pixel":
            pixel_sheet = build_pixel_sheet_image(output_dir, manifest)
            if pixel_sheet is not None:
                pixel_sheet_name = f"{build_archive_stem(args, manifest)}-sheet.png"
                pixel_sheet_path = output_dir / pixel_sheet_name
                save_png(pixel_sheet, pixel_sheet_path, source_image.info)
                manifest["processed_image"] = {
                    "file": pixel_sheet_name,
                    "width": pixel_sheet.width,
                    "height": pixel_sheet.height,
                }

    manifest_path = output_dir / "atlas_slice_manifest.json"
    manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")

    zip_path = output_dir.with_name(f"{build_archive_stem(args, manifest)}.zip")
    make_zip(output_dir, zip_path)
    return output_dir, zip_path


def main() -> None:
    args = parse_args()
    output_dir, zip_path = slice_atlas(args)
    print(f"Slices written to: {output_dir}")
    print(f"ZIP written to: {zip_path}")


if __name__ == "__main__":
    main()
