import shutil
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path

from PIL import Image, ImageChops, ImageDraw

from .assets import cover_resize, find_art_for_json, hex_color, load_json, load_rgb, meta_unit_scale, render_work_scale, safe_name, save_temp_png
from .boards import board_target_mm, crop_board_bleed
from .settings import (
    BASE_CARD_SIZE,
    BASEMENT_SINGLE_STARTER_COLOR,
    BOARD_DECK,
    CARD_DECKS,
    CHARACTER_DECK,
    CHARACTER_EDGE_REPLACE_PX,
    CHECK_COLORS,
    DECKS_DIR,
    FIXED_CROP,
    OFFICE_SINGLE_STARTER_COLOR,
    PLAYER_COLORS,
    RENDER_DIR,
    SIDE_EDGE_REPLACE_BANDS,
    STARTER_DECKS,
    TOP_BOTTOM_EDGE_REPLACE_PX,
    BuildConfig,
    PrintItem,
    mm_to_px,
    worker_count,
)
from .text import (
    FONTS,
    TEXT_INDENT_CHARS,
    draw_body_and_flavour_text,
    draw_text_box,
    font_size_y,
    format_card_text,
    format_flavour_text,
    standard_text_layout,
    text_line_height,
    th,
    title_font_for_target_width,
    title_width_limit,
    transformed_text_area,
    tw,
    tx,
    ty,
)


def draw_polygon(draw: ImageDraw.ImageDraw, points, fill: tuple[int, int, int]) -> None:
    draw.polygon(list(points), fill=fill)


def draw_banner_shape(image: Image.Image, meta: dict, y_offset: int, fill: tuple[int, int, int]) -> None:
    draw = ImageDraw.Draw(image)
    scale = meta_unit_scale(meta)
    x = int(meta["checkOffsetX"])
    y = int(meta["checkOffsetY"]) + y_offset
    w = int(meta["checkWidth"])
    h = int(meta["checkHeight"])
    points = [(x, y), (x * 2 + w - round(30 * scale), y), (x + w, y + h), (x, y + h)]
    draw_polygon(draw, points, fill)


def draw_banner_text(
    image: Image.Image,
    meta: dict,
    layout: dict,
    text: str,
    y_offset: int,
    color: tuple[int, int, int],
) -> None:
    scale = meta_unit_scale(meta)
    x = int(meta["checkOffsetX"])
    y = int(meta["checkOffsetY"]) + y_offset
    w = int(meta["checkWidth"])
    h = int(meta["checkHeight"])
    font = FONTS.title(font_size_y(layout, int(meta.get("titleFontSize") or meta.get("fontSize") or 32)))
    draw_text_box(image, text, (tx(layout, x + round(50 * scale)), ty(layout, y), tw(layout, w), th(layout, h)), font, color, 0.0, 0.5, paragraph_margin=0)


def draw_banner(
    image: Image.Image,
    meta: dict,
    text: str,
    y_offset: int,
    fill: tuple[int, int, int],
    text_color: tuple[int, int, int] | None = None,
    draw_text: bool = True,
) -> None:
    draw_banner_shape(image, meta, y_offset, fill)
    if not draw_text:
        return
    color = text_color or hex_color(meta.get("foregroundColor"), "#ffffff")
    font = FONTS.title(int(meta.get("titleFontSize") or meta.get("fontSize") or 32))
    scale = meta_unit_scale(meta)
    x = int(meta["checkOffsetX"])
    y = int(meta["checkOffsetY"]) + y_offset
    w = int(meta["checkWidth"])
    h = int(meta["checkHeight"])
    draw_text_box(image, text, (x + round(50 * scale), y, w, h), font, color, 0.0, 0.5, paragraph_margin=0)


def draw_starter_banner(image: Image.Image, meta: dict, y_offset: int, color: str, draw_text: bool = True) -> None:
    draw = ImageDraw.Draw(image)
    scale = meta_unit_scale(meta)
    x = int(meta["checkOffsetX"])
    y = int(meta["checkOffsetY"]) + y_offset
    w = int(meta["checkWidth"])
    h = int(meta["checkHeight"])
    black = (0, 0, 0)
    banner = hex_color(color)
    draw_banner(image, meta, "STARTER", y_offset, black, text_color=(255, 255, 255), draw_text=draw_text)
    draw_polygon(draw, [(x * 2 + w - round(32 * scale), y), (x * 2 + w - round(26 * scale), y), (x + w, y + h), (x + w - round(6 * scale), y + h)], banner)
    draw_polygon(draw, [(x * 2 + w - round(50 * scale), y), (x * 2 + w - round(38 * scale), y), (x + w - round(12 * scale), y + h), (x + w - round(24 * scale), y + h)], banner)
    draw_polygon(draw, [(x, y), (x * 2 + w - round(30 * scale), y), (x * 2 + w - round(30 * scale), y + round(4 * scale)), (x, y + round(4 * scale))], banner)
    if not draw_text:
        return
    font = FONTS.title(int(meta.get("titleFontSize") or meta.get("fontSize") or 32))
    draw_text_box(image, "STARTER", (x + round(50 * scale), y, w, h), font, (255, 255, 255), 0.0, 0.5, paragraph_margin=0)


def render_card_image(
    deck_dir: Path,
    meta: dict,
    card: dict,
    front_template: Image.Image,
    starter_color: str | None,
    target_size: tuple[int, int],
    crop_edges_before_text: bool = False,
) -> Image.Image:
    layout = standard_text_layout(meta, target_size, crop_edges_before_text)
    source_canvas = Image.new("RGB", (int(meta["cardWidth"]), int(meta["cardHeight"])), (0, 0, 0)).convert("RGBA")
    banner_texts: list[tuple[str, int, tuple[int, int, int]]] = []

    art_path = find_art_for_json(Path(card["_json_path"]))
    if art_path is not None:
        art = cover_resize(load_rgb(art_path), (int(meta["artWidth"]), int(meta["artHeight"])))
        source_canvas.alpha_composite(art, (int(meta["artOffsetX"]), int(meta["artOffsetY"])))

    check_offset = 0
    if starter_color is not None:
        draw_starter_banner(source_canvas, meta, check_offset, starter_color, draw_text=False)
        banner_texts.append(("STARTER", check_offset, (255, 255, 255)))
        check_offset += round(10 * meta_unit_scale(meta)) + int(meta["checkHeight"])
    if card.get("encounterThreshold") is not None:
        draw_banner(source_canvas, meta, f"Threat {card['encounterThreshold']}", check_offset, hex_color("#ead500"), draw_text=False)
        banner_texts.append((f"Threat {card['encounterThreshold']}", check_offset, hex_color(meta.get("foregroundColor"), "#ffffff")))
        check_offset += round(10 * meta_unit_scale(meta)) + int(meta["checkHeight"])
    if card.get("checkLevel") is not None and card.get("checkType") is not None:
        check_color = hex_color(CHECK_COLORS.get(str(card["checkType"]), "#ffffff"))
        draw_banner(
            source_canvas,
            meta,
            f"{card['checkType']} {card['checkLevel']}",
            check_offset,
            check_color,
            draw_text=False,
        )
        banner_texts.append((f"{card['checkType']} {card['checkLevel']}", check_offset, hex_color(meta.get("foregroundColor"), "#ffffff")))
        check_offset += round(10 * meta_unit_scale(meta)) + int(meta["checkHeight"])
    if card.get("type") == "VIRUS":
        draw_banner(source_canvas, meta, "VIRUS", check_offset, (255, 255, 255), text_color=hex_color(meta.get("foregroundColor"), "#000000"), draw_text=False)
        banner_texts.append(("VIRUS", check_offset, hex_color(meta.get("foregroundColor"), "#000000")))

    source_canvas.alpha_composite(front_template.convert("RGBA"), (0, 0))

    if crop_edges_before_text:
        canvas = replace_standard_card_edges(fixed_crop_card(source_canvas)).convert("RGBA")
    else:
        canvas = source_canvas
    canvas = canvas.convert("RGB").resize(target_size, Image.Resampling.LANCZOS).convert("RGBA")

    for banner_text, y_offset, text_color in banner_texts:
        draw_banner_text(canvas, meta, layout, banner_text, y_offset, text_color)

    title = card.get("title")
    if title:
        max_title_width = tw(layout, title_width_limit(deck_dir, meta))
        font = title_font_for_target_width(str(title), meta, layout["sy"], max_title_width)
        title_color = hex_color(meta.get("titleColor"), "#000000")
        title_y = ty(layout, int(meta["titleOffset"]))
        title_box_h = th(layout, 16)
        draw_text_box(canvas, title, (0, title_y, target_size[0], title_box_h), font, title_color, 0.5, 0.5, 0, 0)

    fg = hex_color(meta.get("foregroundColor"), "#ffffff")
    text_left = int(meta["textPadding"])
    text_right = int(meta["cardWidth"]) - int(meta["textPadding"])
    text_x = tx(layout, text_left)
    text_y, text_bottom_y, flavour_bottom_gap = transformed_text_area(deck_dir, meta, layout)
    text_w = max(1, tx(layout, text_right) - text_x)
    text_h = max(1, text_bottom_y - text_y)

    flavour_text = format_flavour_text(card.get("flavourText"))
    body_text = format_card_text(card.get("text"))
    if body_text or flavour_text:
        body_font = FONTS.text(font_size_y(layout, int(meta.get("fontSize") or 32)))
        flavour_font = FONTS.text(font_size_y(layout, int(meta.get("flavourTextFontSize") or 24)))
        draw_body_and_flavour_text(
            canvas,
            body_text,
            flavour_text,
            (text_x, text_y, text_w, text_h),
            body_font,
            flavour_font,
            fg,
            th(layout, 10),
            indent_chars=TEXT_INDENT_CHARS,
            flavour_bottom_gap=flavour_bottom_gap,
        )

    old_footer_font = FONTS.text(font_size_y(layout, 16))
    small = FONTS.text(font_size_y(layout, 16 * 0.9))
    footer_top_compensation = round((text_line_height(old_footer_font) - text_line_height(small)) / 2)
    footer_y = ty(layout, int(meta["cardHeight"]) - 95) - footer_top_compensation
    if config_project_version := card.get("_project_version"):
        draw_text_box(
            canvas,
            str(config_project_version),
            (
                tx(layout, int(meta["cardWidth"]) - 252),
                footer_y,
                tw(layout, 150),
                th(layout, 30),
            ),
            small,
            (255, 255, 255),
            1.0,
            0.5,
            0,
        )
    if card.get("credit"):
        draw_text_box(
            canvas,
            str(card["credit"]),
            (
                tx(layout, 100),
                footer_y,
                tw(layout, 500),
                th(layout, 30),
            ),
            small,
            (255, 255, 255),
            0.0,
            0.5,
            0,
        )
    return canvas.convert("RGB")


def smart_trim(img: Image.Image) -> Image.Image:
    rgb = img.convert("RGB")
    diff = ImageChops.difference(rgb, Image.new("RGB", rgb.size, (0, 0, 0)))
    bbox = diff.getbbox()
    if bbox is None:
        return rgb
    rgb = rgb.crop(bbox)

    def black_fraction_row(image: Image.Image, y: int, x0: int = 0, x1: int | None = None) -> float:
        x1 = image.width if x1 is None else x1
        if x1 <= x0:
            return 0.0
        pixels = image.crop((x0, y, x1, y + 1)).getdata()
        return sum(1 for p in pixels if p == (0, 0, 0)) / (x1 - x0)

    def black_fraction_col(image: Image.Image, x: int, y0: int = 0, y1: int | None = None) -> float:
        y1 = image.height if y1 is None else y1
        if y1 <= y0:
            return 0.0
        pixels = image.crop((x, y0, x + 1, y1)).getdata()
        return sum(1 for p in pixels if p == (0, 0, 0)) / (y1 - y0)

    changed = True
    while changed and rgb.width > 1 and rgb.height > 1:
        changed = False
        if black_fraction_row(rgb, 0) >= 0.9:
            rgb = rgb.crop((0, 1, rgb.width, rgb.height))
            changed = True
        if rgb.height > 1 and black_fraction_row(rgb, rgb.height - 1) >= 0.9:
            rgb = rgb.crop((0, 0, rgb.width, rgb.height - 1))
            changed = True

    changed = True
    while changed and rgb.width > 1 and rgb.height > 1:
        changed = False
        y0 = int(rgb.height * 0.05)
        y1 = rgb.height - y0
        if black_fraction_col(rgb, 0, y0, y1) >= 0.05:
            rgb = rgb.crop((1, 0, rgb.width, rgb.height))
            changed = True
        if rgb.width > 1 and black_fraction_col(rgb, rgb.width - 1, y0, y1) >= 0.05:
            rgb = rgb.crop((0, 0, rgb.width - 1, rgb.height))
            changed = True

    changed = True
    while changed and rgb.width > 1 and rgb.height > 1:
        changed = False
        x0 = int(rgb.width * 0.05)
        x1 = rgb.width - x0
        if black_fraction_row(rgb, 0, x0, x1) >= 0.05:
            rgb = rgb.crop((0, 1, rgb.width, rgb.height))
            changed = True
        if rgb.height > 1 and black_fraction_row(rgb, rgb.height - 1, x0, x1) >= 0.05:
            rgb = rgb.crop((0, 0, rgb.width, rgb.height - 1))
            changed = True

    if rgb.height > 1:
        x0 = int(rgb.width * 0.05)
        x1 = max(x0 + 1, rgb.width - x0)
        prev = row_brightness(rgb, 0, x0, x1)
        cut = 0
        for y in range(1, rgb.height):
            current = row_brightness(rgb, y, x0, x1)
            if current >= prev:
                cut = y
                prev = current
            else:
                break
        if cut:
            rgb = rgb.crop((0, cut, rgb.width, rgb.height))

    return rgb


def row_brightness(img: Image.Image, y: int, x0: int, x1: int) -> float:
    pixels = img.crop((x0, y, x1, y + 1)).getdata()
    count = max(1, x1 - x0)
    return sum(0.299 * r + 0.587 * g + 0.114 * b for r, g, b in pixels) / count


def scaled_fixed_crop_box(img: Image.Image) -> tuple[int, int, int, int]:
    sx = img.width / BASE_CARD_SIZE[0]
    sy = img.height / BASE_CARD_SIZE[1]
    left = round(FIXED_CROP[0] * sx)
    top = round(FIXED_CROP[1] * sy)
    right = img.width - round(FIXED_CROP[2] * sx)
    bottom = img.height - round(FIXED_CROP[3] * sy)
    return (left, top, right, bottom)


def fixed_crop_card(img: Image.Image) -> Image.Image:
    return img.convert("RGB").crop(scaled_fixed_crop_box(img))


def cropped_card_scale(img: Image.Image) -> float:
    cropped_base_w = BASE_CARD_SIZE[0] - FIXED_CROP[0] - FIXED_CROP[2]
    cropped_base_h = BASE_CARD_SIZE[1] - FIXED_CROP[1] - FIXED_CROP[3]
    return min(img.width / cropped_base_w, img.height / cropped_base_h)


def full_card_scale(img: Image.Image) -> float:
    return min(img.width / BASE_CARD_SIZE[0], img.height / BASE_CARD_SIZE[1])


def scaled_card_px(img: Image.Image, px: int) -> int:
    return max(1, round(px * cropped_card_scale(img)))


def scaled_full_card_px(img: Image.Image, px: int) -> int:
    return max(1, round(px * full_card_scale(img)))


def replace_side_edge_band(out: Image.Image, y0: int, y1: int, px: int) -> None:
    if y1 <= y0 or px <= 0:
        return
    px = min(px, out.width // 4)
    if px <= 0:
        return
    out.paste(out.crop((px, y0, px + 1, y1)).resize((px, y1 - y0)), (0, y0))
    out.paste(out.crop((out.width - px - 1, y0, out.width - px, y1)).resize((px, y1 - y0)), (out.width - px, y0))


def replace_standard_card_edges(img: Image.Image) -> Image.Image:
    out = img.copy()
    for start, end, band_px in SIDE_EDGE_REPLACE_BANDS:
        y0 = round(out.height * start)
        y1 = round(out.height * end)
        replace_side_edge_band(out, y0, y1, scaled_card_px(out, band_px))

    px = min(scaled_card_px(out, TOP_BOTTOM_EDGE_REPLACE_PX), out.height // 4)
    if px > 0:
        out.paste(out.crop((0, px, out.width, px + 1)).resize((out.width, px)), (0, 0))
        out.paste(out.crop((0, out.height - px - 1, out.width, out.height - px)).resize((out.width, px)), (0, out.height - px))

    return out


def replace_uniform_edges(img: Image.Image, px: int) -> Image.Image:
    px = min(px, img.width // 4, img.height // 4)
    if px <= 0:
        return img
    out = img.copy()
    out.paste(out.crop((px, 0, px + 1, out.height)).resize((px, out.height)), (0, 0))
    out.paste(out.crop((out.width - px - 1, 0, out.width - px, out.height)).resize((px, out.height)), (out.width - px, 0))
    out.paste(out.crop((0, px, out.width, px + 1)).resize((out.width, px)), (0, 0))
    out.paste(out.crop((0, out.height - px - 1, out.width, out.height - px)).resize((out.width, px)), (0, out.height - px))
    return out


def replace_character_edges(img: Image.Image) -> Image.Image:
    px = scaled_full_card_px(img, CHARACTER_EDGE_REPLACE_PX)
    return replace_uniform_edges(img, px)


def draw_corner_triangles(img: Image.Image, size: int, color: tuple[int, int, int]) -> Image.Image:
    size = min(size, img.width // 4, img.height // 4)
    if size <= 0:
        return img
    out = img.copy()
    draw = ImageDraw.Draw(out)
    draw.polygon([(0, 0), (size, 0), (0, size)], fill=color)
    draw.polygon([(out.width - 1, 0), (out.width - 1 - size, 0), (out.width - 1, size)], fill=color)
    draw.polygon([(0, out.height - 1), (size, out.height - 1), (0, out.height - 1 - size)], fill=color)
    draw.polygon(
        [(out.width - 1, out.height - 1), (out.width - 1 - size, out.height - 1), (out.width - 1, out.height - 1 - size)],
        fill=color,
    )
    return out


def character_bleed_crop(img: Image.Image, target_size: tuple[int, int], bleed_px: int) -> Image.Image:
    crop_box = scaled_fixed_crop_box(img)
    crop_w = crop_box[2] - crop_box[0]
    crop_h = crop_box[3] - crop_box[1]
    extra_x = round(bleed_px * crop_w / target_size[0])
    extra_y = round(bleed_px * crop_h / target_size[1])
    expanded = (
        max(0, crop_box[0] - extra_x),
        max(0, crop_box[1] - extra_y),
        min(img.width, crop_box[2] + extra_x),
        min(img.height, crop_box[3] + extra_y),
    )
    return img.convert("RGB").crop(expanded).resize(
        (target_size[0] + 2 * bleed_px, target_size[1] + 2 * bleed_px),
        Image.Resampling.LANCZOS,
    )


def apply_print_adjustments(
    img: Image.Image,
    config: BuildConfig,
    target_size: tuple[int, int],
    crop_mode: str = "board",
) -> Image.Image:
    out = img.convert("RGB")
    if crop_mode == "character_card":
        if config.crop_black_borders:
            out = fixed_crop_card(out)
        else:
            out = replace_character_edges(out)
    elif crop_mode == "rendered_standard_card":
        pass
    elif config.crop_black_borders:
        if crop_mode == "pre_cropped_standard_card":
            pass
        elif crop_mode == "standard_card":
            out = fixed_crop_card(out)
            out = replace_standard_card_edges(out)
        else:
            out = smart_trim(out)
    out = out.resize(target_size, Image.Resampling.LANCZOS)
    if crop_mode in {"rendered_standard_card", "standard_card", "pre_cropped_standard_card", "character_card"}:
        triangle_color = (0, 0, 0) if config.crop_black_borders else (255, 255, 255)
        out = draw_corner_triangles(out, mm_to_px(config.bleed_mm, config.dpi), triangle_color)
    if crop_mode == "board" and config.clean_edge_mm > 0:
        out = clean_edges(out, mm_to_px(config.clean_edge_mm, config.dpi))
    if config.gamma_enabled:
        out = apply_gamma(out, config.gamma, config.black_point)
    return out


def apply_gamma(img: Image.Image, gamma: float, black_point: float) -> Image.Image:
    scale = max(1e-9, 1.0 - black_point)
    lut = []
    for value in range(256):
        normalized = max(0.0, ((value / 255.0) - black_point) / scale)
        lut.append(round((normalized ** gamma) * 255))
    return img.point(lut * 3)


def clean_edges(img: Image.Image, px: int) -> Image.Image:
    if px <= 0:
        return img
    px = min(px, img.width // 4, img.height // 4)
    if px <= 0:
        return img
    out = img.copy()
    out.paste(out.crop((px, 0, px + 1, out.height)).resize((px, out.height)), (0, 0))
    out.paste(out.crop((out.width - px - 1, 0, out.width - px, out.height)).resize((px, out.height)), (out.width - px, 0))
    out.paste(out.crop((0, px, out.width, px + 1)).resize((out.width, px)), (0, 0))
    out.paste(out.crop((0, out.height - px - 1, out.width, out.height - px)).resize((out.width, px)), (0, out.height - px))
    return out


def make_bleed(img: Image.Image, bleed_px: int) -> Image.Image:
    if bleed_px <= 0:
        return img
    w, h = img.size
    out = Image.new("RGB", (w + 2 * bleed_px, h + 2 * bleed_px))
    out.paste(img, (bleed_px, bleed_px))
    out.paste(img.crop((0, 0, 1, h)).resize((bleed_px, h)), (0, bleed_px))
    out.paste(img.crop((w - 1, 0, w, h)).resize((bleed_px, h)), (bleed_px + w, bleed_px))
    out.paste(img.crop((0, 0, w, 1)).resize((w, bleed_px)), (bleed_px, 0))
    out.paste(img.crop((0, h - 1, w, h)).resize((w, bleed_px)), (bleed_px, bleed_px + h))
    out.paste(img.crop((0, 0, 1, 1)).resize((bleed_px, bleed_px)), (0, 0))
    out.paste(img.crop((w - 1, 0, w, 1)).resize((bleed_px, bleed_px)), (bleed_px + w, 0))
    out.paste(img.crop((0, h - 1, 1, h)).resize((bleed_px, bleed_px)), (0, bleed_px + h))
    out.paste(img.crop((w - 1, h - 1, w, h)).resize((bleed_px, bleed_px)), (bleed_px + w, bleed_px + h))
    return out


def render_standard_front_job(job: dict) -> Path:
    deck_dir = job["deck_dir"]
    meta = job["meta"]
    card = dict(job["card"])
    config = job["config"]
    card_px = job["card_px"]
    starter_color = job["starter_color"]
    out = job["out"]
    front_template = load_rgb(deck_dir / "front.png")
    image = render_card_image(
        deck_dir,
        meta,
        card,
        front_template,
        starter_color,
        card_px,
        crop_edges_before_text=config.crop_black_borders,
    )
    adjusted = apply_print_adjustments(image, config, card_px, "rendered_standard_card")
    out.parent.mkdir(parents=True, exist_ok=True)
    save_temp_png(adjusted, out, config.dpi)
    return out


def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem]]:
    if RENDER_DIR.exists():
        shutil.rmtree(RENDER_DIR)
    RENDER_DIR.mkdir(parents=True, exist_ok=True)

    card_px = (mm_to_px(config.card_mm[0], config.dpi), mm_to_px(config.card_mm[1], config.dpi))
    card_items: list[PrintItem] = []
    board_items: list[PrintItem] = []

    for group, deck_names in CARD_DECKS.items():
        for deck_name in deck_names:
            deck_dir = DECKS_DIR / deck_name
            meta = load_json(deck_dir / "deck.json")
            back_template = load_rgb(deck_dir / "back.png").convert("RGB")
            work_scale = render_work_scale(meta, card_px)
            deck_cards = sorted(p for p in deck_dir.glob("*.json") if p.name != "deck.json")
            rendered_fronts: list[Path] = []
            front_jobs: list[dict] = []

            for card_json in deck_cards:
                card = load_json(card_json)
                card["_json_path"] = str(card_json)
                card["_project_version"] = config.project_version
                copies = int(card.get("copies", 1))
                if deck_name == "encounterCards" and card_json.stem == "rentRaised":
                    copies = config.rent_raised_count
                is_starter = bool(card.get("isStarter"))
                colors = starter_colors_for_deck(group, deck_name, meta, config) if is_starter else [None]
                rendered_by_color: dict[str | None, Path] = {}
                for color_index, starter_color in enumerate(colors):
                    out = rendered_by_color.get(starter_color)
                    if out is None:
                        out = RENDER_DIR / "cards" / group / "fronts" / f"{deck_name}_{safe_name(card.get('title', card_json.stem))}_{color_index}.png"
                        rendered_by_color[starter_color] = out
                        front_jobs.append(
                            {
                                "deck_dir": deck_dir,
                                "meta": meta,
                                "card": card,
                                "config": config,
                                "card_px": card_px,
                                "starter_color": starter_color,
                                "out": out,
                            }
                        )
                    for _ in range(copies):
                        rendered_fronts.append(out)
                        label = card.get("title", card_json.stem)
                        card_items.append(PrintItem(out, group, label, "front", is_starter, label.lower()))

            with ProcessPoolExecutor(max_workers=worker_count(len(front_jobs))) as executor:
                list(executor.map(render_standard_front_job, front_jobs))

            back_work = back_template.resize(
                (round(back_template.width * work_scale), round(back_template.height * work_scale)),
                Image.Resampling.LANCZOS,
            )
            adjusted_back = apply_print_adjustments(back_work, config, card_px, "standard_card")
            back_out = RENDER_DIR / "cards" / group / "backs" / f"{deck_name}_back.png"
            back_out.parent.mkdir(parents=True, exist_ok=True)
            save_temp_png(adjusted_back, back_out, config.dpi)
            for i in range(len(rendered_fronts)):
                card_items.append(PrintItem(back_out, group, f"{deck_name} back {i + 1}", "back", False, deck_name.lower()))

    char_dir = DECKS_DIR / CHARACTER_DECK
    char_meta = load_json(char_dir / "deck.json")
    char_layout = standard_text_layout(char_meta, card_px, config.crop_black_borders)
    for card_json in sorted(p for p in char_dir.glob("*.json") if p.name != "deck.json"):
        art = find_art_for_json(card_json)
        if art is None:
            continue
        canvas = Image.new("RGB", (int(char_meta["cardWidth"]), int(char_meta["cardHeight"])), (0, 0, 0)).convert("RGBA")
        face = cover_resize(load_rgb(art), (int(char_meta["artWidth"]), int(char_meta["artHeight"])))
        canvas.alpha_composite(face, (int(char_meta["artOffsetX"]), int(char_meta["artOffsetY"])))
        character_source = canvas.convert("RGB")
        adjusted = apply_print_adjustments(character_source, config, card_px, "character_card")
        if config.project_version:
            small = FONTS.text(font_size_y(char_layout, 16))
            draw_text_box(
                adjusted,
                config.project_version,
                (
                    tx(char_layout, int(char_meta["cardWidth"]) - 252),
                    ty(char_layout, int(char_meta["cardHeight"]) - 99),
                    tw(char_layout, 150),
                    th(char_layout, 30),
                ),
                small,
                (255, 255, 255),
                1.0,
                0.5,
                0,
            )
        out = RENDER_DIR / "cards" / "characters" / f"{safe_name(card_json.stem)}.png"
        out.parent.mkdir(parents=True, exist_ok=True)
        save_temp_png(adjusted, out, config.dpi)
        bleed_out = None
        if config.crop_black_borders and config.bleed_mm > 0:
            bleed_px = mm_to_px(config.bleed_mm, config.dpi)
            bleed_img = character_bleed_crop(character_source, card_px, bleed_px)
            corner_size = 2 * bleed_px
            ImageDraw.Draw(bleed_img).rectangle(
                (0, bleed_img.height - corner_size, corner_size - 1, bleed_img.height - 1),
                fill=(0, 0, 0),
            )
            if config.gamma_enabled:
                bleed_img = apply_gamma(bleed_img, config.gamma, config.black_point)
            bleed_out = RENDER_DIR / "cards" / "characters" / "bleed" / f"{safe_name(card_json.stem)}.png"
            bleed_out.parent.mkdir(parents=True, exist_ok=True)
            save_temp_png(bleed_img, bleed_out, config.dpi)
        # Character edges are unique and not cut-neighbor compatible, so each
        # duplicate is treated as its own print group.
        card_items.append(PrintItem(out, f"character:{card_json.stem}:1", card_json.stem, "front", False, card_json.stem.lower(), bleed_out))
        card_items.append(PrintItem(out, f"character:{card_json.stem}:2", card_json.stem, "back", False, card_json.stem.lower(), bleed_out))

    board_dir = DECKS_DIR / BOARD_DECK
    for board_path in sorted(board_dir.glob("*-jumbo-front.png")):
        target_mm = board_target_mm(board_path, config)
        target_px = (mm_to_px(target_mm[0], config.dpi), mm_to_px(target_mm[1], config.dpi))
        image = crop_board_bleed(load_rgb(board_path).convert("RGB"))
        adjusted = apply_print_adjustments(image, config, target_px, "board")
        out = RENDER_DIR / "boards" / board_path.name
        out.parent.mkdir(parents=True, exist_ok=True)
        save_temp_png(adjusted, out, config.dpi)
        board_items.append(PrintItem(out, "board", board_path.stem, "board"))

    return card_items, board_items


def starter_colors_for_deck(group: str, deck_name: str, meta: dict, config: BuildConfig) -> list[str | None]:
    colors = list(meta.get("starterBannerColors") or PLAYER_COLORS)
    if deck_name not in STARTER_DECKS:
        return [None]
    if config.starter_unique_colors:
        return colors
    single = BASEMENT_SINGLE_STARTER_COLOR if group == "basement" else OFFICE_SINGLE_STARTER_COLOR
    return [single for _ in colors]
