#!/usr/bin/env python3
"""generate_composite_sheet.py — gpt-image-2 composite reference sheets.

Generates ONE multi-panel reference sheet per entity (character or location)
and persists it to:

    assets/{kind}/{entity_id}/sheet_v1.png  (kind: identity | loc)

Composite sheets are a single image with multiple views laid out as a grid.
Per the 2026-05-25 best-practices consult (see
consultations/recoil/gpt-image-2-seedance-2-best-practices-2026-05-25/SYNTHESIS.md):

- Seedance 2 treats a single composite sheet as one input with spatial
  attention over the grid — bypassing the "Identity Blend" face-averaging
  failure mode that triggers with multiple separate same-subject refs.
- 6-9 panels is the practitioner sweet spot (10-12 viable per JT empirical;
  >12 is too many).
- gpt-image-2 rewards concrete natural-language briefs and penalizes
  keyword-stack tags. This tool's prompt builder strips those.

The dispatcher path (dispatch_payload._collect_reference_images) consumes
these sheets when ``_composite_sheets_enabled(project)`` returns True —
either ``RECOIL_USE_COMPOSITE_SHEETS=1`` (ad-hoc override) OR
``project_config.json::use_composite_sheets: true`` (persistent per-project
opt-in). Until one of those is set, the existing two-pass angle collection
runs unchanged.

Usage:
    # Character sheet
    python3 recoil/pipeline/tools/generate_composite_sheet.py \\
        --project tartarus --entity-type characters --entity-id JADE

    # Location sheet
    python3 recoil/pipeline/tools/generate_composite_sheet.py \\
        --project tartarus --entity-type locations --entity-id int_lower_decks_corridor

    # Dry-run (print prompt + payload, do not call API)
    python3 recoil/pipeline/tools/generate_composite_sheet.py \\
        --project tartarus --entity-type characters --entity-id JADE --dry-run

    # Override quality (default: medium)
    python3 recoil/pipeline/tools/generate_composite_sheet.py \\
        --project tartarus --entity-type characters --entity-id WREN --quality high
"""

from __future__ import annotations

import argparse
import json
import shutil
import sys
from pathlib import Path

# Bootstrap import paths the same way dispatch_cli.py does.
_HERE = Path(__file__).resolve().parent
_RECOIL_ROOT = _HERE.parent.parent  # tools/ -> pipeline/ -> recoil/
_REPO_ROOT = _RECOIL_ROOT.parent
if str(_REPO_ROOT) not in sys.path:
    sys.path.insert(0, str(_REPO_ROOT))

from recoil.core.paths import ensure_pipeline_importable, projects_root, ProjectPaths  # noqa: E402


# Map legacy entity_type CLI strings to v3 asset classes.
_CLS_FROM_ENTITY_TYPE = {"characters": "char", "locations": "loc"}

ensure_pipeline_importable()


# ──────────────────────────────────────────────────────────────────────────
# Prompt builders — gpt-image-2-tuned (natural-language, no keyword stacks)
# ──────────────────────────────────────────────────────────────────────────

# Default aspect for sheets: landscape. Sheets are not output frames —
# Seedance reads them as a single identity input regardless of sheet AR,
# so we optimize for layout density, not for the downstream video's 9:16.
# "16:9" maps to 1536x1024 via the fal adapter's _resolve_gpt_image_2_size,
# which is roughly 3:2 — close to the production-design-doc reference
# format JT shared (BaoBao/coffee-deck style).
_DEFAULT_ASPECT = "16:9"


def _bible(project: str) -> dict:
    """Load the project's global visual bible."""
    bible_path = ProjectPaths.for_project(project).global_bible_path
    if not bible_path.exists():
        raise FileNotFoundError(f"global_bible not found: {bible_path}")
    return json.loads(bible_path.read_text())


def _character_prompt(
    bible: dict,
    char_id: str,
    aspect_ratio: str,
    phase_id: str | None = None,
    ref_count: int = 0,
) -> str:
    """Production-density character reference sheet prompt for gpt-image-2.

    Targets the industry-standard composite sheet structure (2026-05-25
    BaoBao reference): 2 full-body anchors + 6 expressions + 6 action poses
    + 7 prop/wardrobe details + 9 color/material swatches, organized into
    named sections on a single landscape page. Photoreal style leads the
    prompt, repeated throughout to fight gpt-image-2's illustration default.

    Picks the first wardrobe phase if phase_id is not provided. Per-character
    expression + action vocabularies fall back to a generic set when bible
    doesn't define them.
    """
    chars = bible.get("characters") or {}
    cid = char_id.upper()
    char = chars.get(cid)
    if not char:
        raise KeyError(
            f"{cid} not in bible.characters (available: {sorted(chars.keys())})"
        )

    phases = char.get("phases") or []
    if phase_id:
        phase = next((p for p in phases if p.get("phase_id") == phase_id), None)
        if phase is None:
            raise KeyError(
                f"phase {phase_id!r} not in {cid}.phases (available: "
                f"{[p.get('phase_id') for p in phases]})"
            )
    else:
        phase = phases[0] if phases else {"wardrobe_description": ""}

    display = char.get("display_name", cid.title())
    visual = char.get("visual_description", "").strip()
    wardrobe = (phase.get("wardrobe_description") or "").strip()

    # Per-character emotional + action vocabularies. Bible can override via
    # `sheet_expressions` / `sheet_actions` / `sheet_props` / `sheet_materials`.
    # Defaults are deliberately Tartarus-flavored (gritty, salvage-worker
    # affect); for other projects override in the bible per character.
    # Expression range: pick 6 that span a real emotional spectrum (counters
    # the clustering effect — all-grim variations look identical). Smiling
    # warmth + sarcastic smirk + sad + afraid alongside one wary and one
    # defiant gives the model true variation to render. JT correction
    # 2026-05-25 — the prior NEUTRAL/WARY/FOCUSED/EXHAUSTED/DEFIANT/GRIM
    # cluster was 6 flavors of one emotion.
    expressions = char.get("sheet_expressions") or [
        "SMILING",
        "SMIRK / SARCASTIC",
        "SAD",
        "AFRAID / STARTLED",
        "WARY / ALERT",
        "DEFIANT / FIERCE",
    ]
    actions = char.get("sheet_actions") or [
        "HOOK SWING",
        "CROUCHED INSPECTION",
        "WIRE PULL",
        "STANDING ALERT",
        "REBREATHER UP",
        "RUNNING",
    ]
    props = char.get("sheet_props") or [
        "TANKTOP",
        "CANVAS JACKET TIE",
        "REINFORCED CARGO PANTS",
        "SALVAGE HOOK",
        "DEBT COUNTER (amber digits)",
        "REBREATHER",
        "WORK BOOTS",
    ]
    materials = char.get("sheet_materials") or [
        "SKIN",
        "HAIR",
        "GRIME",
        "TANKTOP",
        "CANVAS",
        "METAL/STEEL",
        "RUST",
        "AMBER GLOW",
        "OIL STAINS",
    ]

    # Identity-reference framing. With i2i refs, lead with @Image1..@ImageN
    # and trust them as the strict identity source (face / age / hair / skin /
    # wardrobe). Per JT's feedback-trust-the-reference: don't fight the
    # refs with verbose textual description. With NO refs (t2i fallback),
    # include the bible description as the only identity signal.
    if ref_count > 0:
        ref_tokens = ", ".join(f"@Image{i + 1}" for i in range(ref_count))
        identity_block = (
            f"IDENTITY REFERENCE (CRITICAL): The uploaded photographs "
            f"{ref_tokens} ARE the strict identity reference for the subject. "
            f"Match the face, age, skin texture, hair color and length, body "
            f"type, proportions, and wardrobe of the references exactly across "
            f"every panel of this sheet. The references show the actor; "
            f"render every panel as the same actor."
        )
        subject_line = (
            f"SUBJECT: {display}. Identity, age, and wardrobe are defined by "
            f"the uploaded reference photographs above — do not invent "
            f"different features. {wardrobe} (use the references for visual "
            f"detail; this text is supplementary only.)"
        )
    else:
        identity_block = (
            "IDENTITY REFERENCE: No uploaded references — render the subject "
            "from the textual description alone, maintaining consistency "
            "across every panel."
        )
        subject_line = (
            f"SUBJECT: {display}. {visual}\n"
            f"WARDROBE (phase: {phase.get('phase_id', 'default')}): {wardrobe}"
        )

    return (
        f"{identity_block}\n\n"
        f"Photorealistic character reference sheet, single page, landscape "
        f"{aspect_ratio} layout, multi-section production design document.\n"
        f"Style: photorealism. Editorial cinematography aesthetic — shot like "
        f"a Hasselblad 90mm portrait, Kodak Portra 400 film stock, visible "
        f"film grain, soft diffused studio lighting from upper-left, plain "
        f"dark gray seamless studio backdrop behind every figure. Every "
        f"single panel must be photorealistic — no illustration, no anime, "
        f"no painterly or 3D-render look.\n\n"
        f"LAYOUT — five labeled sections arranged on a single sheet:\n\n"
        f"SECTION 1 (left column, large): CHARACTER OVERVIEW — two full-body "
        f"photographs side by side. (1) Full-body front view facing camera, "
        f"arms relaxed at sides, neutral pose. (2) Full-body three-quarter "
        f"angle, same wardrobe, same lighting. Both figures must use the "
        f"full vertical extent of this column — head near the top edge, "
        f"feet near the bottom edge, no empty space above or below. "
        f"Maintain attractive, elongated, photogenic human proportions "
        f"across every panel (approximately 1:7.5 head-to-body ratio for "
        f"an adult figure, long legs, slender silhouette — never short, "
        f"compressed, or foreshortened body proportions).\n\n"
        f"SECTION 2 (top right strip, 6 panels in a horizontal row): "
        f"EXPRESSIONS — head and shoulders only, same actor, same wardrobe, "
        f"each panel a distinct photorealistic facial expression with real "
        f"emotional range. Labels beneath each panel in small caps: "
        f"{', '.join(expressions)}.\n\n"
        f"SECTION 3 (middle right strip, 6 panels in a horizontal row): "
        f"ACTION POSES — full-body photographs of the character mid-action, "
        f"same wardrobe, same lighting, same elongated 1:7.5 head-to-body "
        f"proportions as SECTION 1 (long legs, slender silhouette — never "
        f"compressed or foreshortened). Labels beneath each panel: "
        f"{', '.join(actions)}.\n\n"
        f"SECTION 4 (bottom center, {len(props)} close-up crops in a row): "
        f"KEY DETAILS — tight photographic crops of specific wardrobe items "
        f"and props. CRITICAL: each crop must show the EXACT SAME ITEM as "
        f"worn/carried by the figures in SECTION 1 and SECTION 3 (the "
        f"close-up is a detail shot of the figure's own equipment, not a "
        f"separate wardrobe-department alternate). Labels beneath each crop: "
        f"{', '.join(props)}.\n\n"
        f"SECTION 5 (bottom right): COLOR PALETTE AND MATERIALS — "
        f"{len(materials)} round material swatches in a row, each labeled. "
        f"Swatches: {', '.join(materials)}.\n\n"
        f"{subject_line}\n\n"
        f"Critical: photorealism throughout — treat this as a contact sheet "
        f"of photographs taken in a single studio session, not as an "
        f"illustration. Every face must be the same human actor (matching "
        f"the uploaded references). Every full-body panel must maintain "
        f"attractive, elongated, photogenic human proportions (1:7.5 head-"
        f"to-body, long legs, slender silhouette — never compressed or "
        f"foreshortened body types). Every wardrobe item or prop shown in "
        f"the SECTION 4 detail crops must be the IDENTICAL item worn or "
        f"carried by the figures in SECTIONS 1 and 3 — the close-ups are "
        f"detail-shots of the figure's own equipment, NOT alternate items. "
        f"Clean small-caps section headers and panel labels in a single "
        f"neutral typeface. No watermarks, no logos, no extraneous text "
        f"beyond the section headers and panel labels."
    )


def _location_prompt(bible: dict, loc_id: str, aspect_ratio: str) -> str:
    """Production-density location reference sheet prompt for gpt-image-2.

    Mirrors `_character_prompt`'s BaoBao-density structure (2026-05-27
    rewrite). Four labeled sections on a single landscape page: coverage
    shots, texture / material crops, lighting study, color palette swatches.
    Photoreal style leads the prompt and is repeated throughout to fight
    gpt-image-2's illustration default.

    Bible can override per-location vocabularies via `sheet_loc_coverage`,
    `sheet_loc_textures`, `sheet_loc_lighting`, `sheet_loc_palette` fields.
    Defaults are Tartarus-flavored (industrial salvage, amber-glow palette);
    for other projects, populate the bible explicitly.
    """
    locs = bible.get("locations") or {}
    loc = locs.get(loc_id)
    if not loc:
        raise KeyError(
            f"{loc_id} not in bible.locations (available: {sorted(locs.keys())})"
        )

    display = loc.get("display_name") or loc_id.replace("_", " ").title()
    description = (loc.get("description") or "").strip()
    atmosphere = (loc.get("atmosphere") or "").strip()[:300]
    lp = loc.get("lighting_profile") or {}
    zone = loc.get("habitat_zone", "")

    # Coverage: 6 distinct shot scales for editorial flexibility downstream.
    # Mirrors a real location-scout contact sheet — every important angle the
    # DP would want before blocking. Bible override slot for non-Tartarus
    # locations (e.g. a kitchen wouldn't need "Low-angle establishing").
    coverage = loc.get("sheet_loc_coverage") or [
        "WIDE ESTABLISHING — full environment context",
        "MEDIUM — mid-space framing showing scale",
        "CLOSE — distinguishing surface or feature detail",
        "LOW ANGLE — environment from below, ceiling/structure visible",
        "HIGH ANGLE — environment from above, floor/layout visible",
        "INSERT — small but iconic detail (sign / fixture / object)",
    ]
    # Texture / material crops — what surfaces this location is made of.
    # Drives Seedance's material rendering when the sheet is used as a
    # location anchor. Tartarus-flavored defaults (industrial salvage).
    textures = loc.get("sheet_loc_textures") or [
        "RUSTED STEEL",
        "WORN CONCRETE",
        "GREASE-STAINED CANVAS",
        "FLAKING PAINT",
        "EXPOSED RIVETS",
        "OIL-SOAKED RAG",
    ]
    # Lighting study — same location across multiple lighting conditions.
    # Bible's `lighting_profile` is the primary; the swatches give the model
    # a range to interpolate from when shots call for different times of day.
    primary_lighting = ", ".join(
        s
        for s in [
            lp.get("primary_source"),
            f"{lp.get('direction', '')} direction" if lp.get("direction") else "",
            f"{lp.get('quality', '')} quality" if lp.get("quality") else "",
            f"{lp.get('color_temp', '')} color temperature"
            if lp.get("color_temp")
            else "",
        ]
        if s
    )
    lighting_studies = loc.get("sheet_loc_lighting") or [
        "DAY (primary)",
        "OVERCAST / DIFFUSE",
        "DUSK / GOLDEN HOUR",
        "NIGHT / PRACTICAL SOURCES ONLY",
    ]
    # Palette: bible's color_palette wins; defaults to a Tartarus-flavored
    # swatch set if the bible field is empty.
    palette = (
        loc.get("sheet_loc_palette")
        or loc.get("color_palette")
        or [
            "RUST ORANGE",
            "OIL BLACK",
            "STEEL GRAY",
            "AMBER GLOW",
            "DUST BEIGE",
        ]
    )

    return (
        f"Photorealistic location reference sheet, single page, landscape "
        f"{aspect_ratio} layout, multi-section production design document. "
        f"NO CHARACTERS present in any panel — environment only.\n\n"
        f"Style: photorealism. Documentary location-scout aesthetic — shot "
        f"on Hasselblad medium format, Kodak Portra 400 film stock, visible "
        f"film grain, natural environmental light unless otherwise specified, "
        f"no people in frame. Every single panel must be photorealistic — no "
        f"illustration, no concept-art, no 3D-render look. Consistent location "
        f"identity across every panel — same materials, same architectural "
        f"features, same overall character.\n\n"
        f"LAYOUT — four labeled sections arranged on a single sheet:\n\n"
        f"SECTION 1 (top, {len(coverage)} panels in a horizontal row): "
        f"COVERAGE — distinct shot scales of the same location. Labels "
        f"beneath each panel in small caps: {'; '.join(coverage)}.\n\n"
        f"SECTION 2 (middle left, {len(textures)} tight crops in a row): "
        f"MATERIALS — close-up photographic crops of the dominant surfaces "
        f"and textures of this location. Labels beneath each crop: "
        f"{', '.join(textures)}.\n\n"
        f"SECTION 3 (middle right, {len(lighting_studies)} panels in a row): "
        f"LIGHTING STUDY — same wide-establishing framing rendered under "
        f"different lighting conditions. Labels beneath each panel: "
        f"{', '.join(lighting_studies)}.\n\n"
        f"SECTION 4 (bottom, {len(palette)} round swatches in a row): "
        f"COLOR PALETTE — dominant colors of this location, each labeled. "
        f"Swatches: {', '.join(palette)}.\n\n"
        f"LOCATION: {display}{f' — {zone}' if zone else ''}. {description}\n"
        # Skip empty trailing label lines — an empty "ATMOSPHERE: " line tells
        # gpt-image-2 to render a panel labeled ATMOSPHERE with no content,
        # producing a stray empty caption in the output sheet.
        + (f"ATMOSPHERE: {atmosphere}\n" if atmosphere else "")
        + (f"PRIMARY LIGHTING: {primary_lighting}\n" if primary_lighting else "")
        + (
            "\nCritical: photorealism throughout — treat this as a contact "
            "sheet of location-scout photographs taken in a single session, "
            "not as concept art. Same physical location across every panel; "
            "only the framing, materials-detail, lighting, or palette focus "
            "changes. Clean small-caps section headers and panel labels in a "
            "single neutral typeface. No watermarks, no logos, no figures, "
            "no extraneous text beyond the section headers and panel labels."
        )
    )


# ──────────────────────────────────────────────────────────────────────────
# Dispatch + persist
# ──────────────────────────────────────────────────────────────────────────


def _sheet_dest(
    project: str,
    entity_type: str,
    entity_id: str,
    promote: bool = False,
) -> Path:
    """Destination for a composite sheet.

    Default → dated staging dir assets/_test_composite_sheets_YYYY-MM-DD/
    with auto-versioned sheet_vN.png filenames. Pass promote=True to land in
    the canonical sheet dir (char → assets/char/<id>/base/sheets/,
    loc → assets/loc/<id>/sheets/, prop → assets/prop/<id>/) — matching
    dispatch_payload's reader path. Reserve promotion for approved sheets.
    """
    from datetime import datetime

    paths = ProjectPaths.for_project(project)
    cls = _CLS_FROM_ENTITY_TYPE.get(entity_type, entity_type)
    if promote:
        # SSOT: the per-kind sheet layout lives ONLY in ProjectPaths.sheet_path —
        # reader (resolve_sheet_asset) and writer land at the same path by construction.
        dest = paths.sheet_path(cls, entity_id)
        dest.parent.mkdir(parents=True, exist_ok=True)
        return dest

    # Staging: dated dir + auto-versioned filename so re-runs never clobber.
    date_slug = datetime.now().strftime("%Y-%m-%d")
    staging_dir = (
        paths.assets_dir
        / f"_test_composite_sheets_{date_slug}"
        / cls
        / entity_id.lower()
    )
    staging_dir.mkdir(parents=True, exist_ok=True)
    n = 1
    while (staging_dir / f"sheet_v{n}.png").exists():
        n += 1
    return staging_dir / f"sheet_v{n}.png"


def _discover_canonical_refs(
    project: str,
    entity_type: str,
    entity_id: str,
) -> list[Path]:
    """Auto-discover canonical reference images for an entity.

    Looks for the standard filenames at
    assets/{kind}/{entity_id}/{hero,front,profile,closeup,back,three_quarter}.{jpg,jpeg,png}.
    Returns up to 16 refs in priority order. Multiple extensions per stem are
    ALL included — JT convention sometimes has hero.jpg AND hero.jpeg in the
    same dir where one is a single-panel beauty shot and the other is a
    multi-panel turnaround composite. gpt-image-2 reads grid composites
    natively as one input, so passing both is additive identity signal,
    not noise.
    """
    cls = _CLS_FROM_ENTITY_TYPE.get(entity_type, entity_type)
    root = ProjectPaths.for_project(project).asset_subject_dir(cls, entity_id.lower())
    if not root.is_dir():
        return []
    # Priority order: hero > front > profile > closeup > back > three_quarter
    priority = ("hero", "front", "profile", "closeup", "back", "three_quarter")
    exts = (".jpg", ".jpeg", ".png")
    found: list[Path] = []
    for stem in priority:
        for ext in exts:
            p = root / f"{stem}{ext}"
            if p.exists() and p.stat().st_size > 0:
                found.append(p)
    return found[:16]  # gpt-image-2 caps at 16 refs


def _dispatch_sheet(
    project: str,
    entity_type: str,
    entity_id: str,
    prompt: str,
    aspect_ratio: str,
    quality: str,
    reference_images: list[Path] | None = None,
    size_override: str | None = None,
) -> tuple[Path, float]:
    """Fire one gpt-image-2 generation and return the raw output path.

    When reference_images is non-empty, the fal adapter's
    _infer_gpt_image_2_action routes to the i2i edit endpoint
    (fal-ai/gpt-image-2/image-to-image) — the refs become @Image1..@ImageN
    identity anchors. With no refs, falls through to pure t2i.
    """
    from recoil.pipeline.core import dispatch, DispatchContext
    from recoil.execution.step_runner import StepRunner
    from recoil.execution.execution_store import ExecutionStore
    from recoil.execution.step_types import ProjectPaths

    store = ExecutionStore(
        project=project,
        db_path=Path(f"/tmp/sheet_gen_store_{project}_{entity_id}"),
    )
    paths = ProjectPaths.for_episode(project, 1)
    sr = StepRunner(store=store, paths=paths)
    ctx = DispatchContext(
        caller_id=f"generate_composite_sheet:{entity_type}:{entity_id}",
        step_runner=sr,
        project=project,
        episode=1,
        receipts_log_path="DISABLED",
    )

    shot_id = f"COMPOSITE_{entity_type.upper()}_{entity_id.upper()}"
    payload = {
        "shot_id": shot_id,
        "model": "gpt-image-2",
        "prompt": prompt,
        "aspect_ratio": aspect_ratio,
        "quality": quality,
    }
    if reference_images:
        payload["reference_images"] = [str(p) for p in reference_images]
    if size_override:
        # Threads to image_runner → execute_keyframe → UnifiedVideoPayload
        # hints → fal adapter's _resolve_gpt_image_2_size_with_hints.
        payload["size_override"] = size_override
    receipt = dispatch("image_t2i", payload, context=ctx)

    rr = receipt.run_result
    if not rr.success:
        raise RuntimeError(f"sheet generation failed: {rr.error}")

    raw_out = Path(rr.output_path) if rr.output_path else None
    if raw_out is None:
        raise RuntimeError("sheet generation returned no output_path")
    if not raw_out.is_absolute():
        # StepRunner returns project-relative paths
        raw_out = projects_root() / project / str(rr.output_path)
    if not raw_out.exists() or raw_out.stat().st_size == 0:
        raise RuntimeError(f"sheet generation output missing at {raw_out}")

    cost_usd = (rr.metadata or {}).get("cost_usd") or 0.0
    return raw_out, cost_usd


def main():
    p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    p.add_argument("--project", required=True, help="Project slug, e.g. tartarus")
    p.add_argument(
        "--entity-type",
        required=True,
        choices=["characters", "locations"],
        help="Which entity kind to render a sheet for.",
    )
    p.add_argument(
        "--entity-id",
        required=True,
        help="Bible entity id. Characters: uppercase (JADE, WREN). "
        "Locations: snake_case (int_lower_decks_corridor).",
    )
    p.add_argument(
        "--phase-id",
        default=None,
        help="Character wardrobe phase id (e.g. jade_phase_1_full_mask). "
        "Defaults to first phase if omitted. Ignored for locations.",
    )
    p.add_argument(
        "--aspect-ratio",
        default=_DEFAULT_ASPECT,
        choices=["1:1", "16:9", "9:16"],
        help=f"Output aspect ratio. Default: {_DEFAULT_ASPECT} (landscape, "
        "~3:2 actual via fal). Sheets ARE NOT output frames — Seedance reads "
        "them as one identity input regardless of sheet AR.",
    )
    p.add_argument(
        "--quality",
        default="high",
        choices=["low", "medium", "high"],
        help="gpt-image-2 quality tier. At 1024² (default preset): low $0.01, "
        "medium $0.06, high $0.22. At 4K (--size 3840x2160): low $0.02, "
        "medium $0.11, high $0.41. Full tariff in recoil/execution/providers/"
        "fal.py::_GPT_IMAGE_2_TARIFF_USD. Default: high — sheets are one-time-"
        "cost identity anchors reused across many downstream renders.",
    )
    p.add_argument(
        "--size",
        default=None,
        help="Custom output size 'WIDTHxHEIGHT' (e.g. '2048x1152' for 2K, "
        "'3840x2160' for 4K). Threads through payload.hints.size_override "
        "to the fal adapter. Constraints: ≤3840 edge, ≤8.29 MP, dimensions "
        "multiples of 16, long:short AR ≤3:1. Invalid sizes fall back to "
        "the aspect-ratio preset with a WARNING. Default: unset → use the "
        "--aspect-ratio preset (1024x1024 / 1024x1536 / 1536x1024). "
        "Recommend 2048x1152 (16:9) for BaoBao-density 21-panel location "
        "sheets — panel labels are illegible at the 1536x1024 preset.",
    )
    p.add_argument(
        "--promote",
        action="store_true",
        help="Land output in assets/{kind}/{entity_id}/ (the SSOT the env-gated "
        "dispatcher reads). Default OFF — output goes to a dated staging "
        "dir under assets/_test_composite_sheets_YYYY-MM-DD/ instead. "
        "Promote only after visual approval.",
    )
    p.add_argument(
        "--refs",
        default="auto",
        help="Reference images for i2i identity anchoring. 'auto' (default) "
        "discovers canonical refs at assets/{kind}/{entity_id}/"
        "{hero,front,profile,closeup,back}.{jpg,png}. "
        "'none' to force pure t2i with no refs. Or comma-separated absolute "
        "paths to use explicit refs. gpt-image-2 accepts up to 16.",
    )
    p.add_argument(
        "--dry-run",
        action="store_true",
        help="Print the prompt + payload; do not fire the API call.",
    )
    args = p.parse_args()

    # Resolve refs.
    refs: list[Path] = []
    if args.refs == "auto":
        refs = _discover_canonical_refs(
            args.project,
            args.entity_type,
            args.entity_id,
        )
    elif args.refs == "none":
        refs = []
    else:
        refs = [Path(p.strip()) for p in args.refs.split(",") if p.strip()]
        missing = [p for p in refs if not p.exists()]
        if missing:
            print(f"[REFUSE] --refs paths missing on disk: {missing}")
            return 2

    bible = _bible(args.project)
    if args.entity_type == "characters":
        prompt = _character_prompt(
            bible,
            args.entity_id,
            args.aspect_ratio,
            args.phase_id,
            ref_count=len(refs),
        )
    else:
        prompt = _location_prompt(bible, args.entity_id, args.aspect_ratio)

    dest = _sheet_dest(
        args.project,
        args.entity_type,
        args.entity_id,
        promote=args.promote,
    )
    if dest.exists() and args.promote:
        print(
            f"[REFUSE] canonical sheet already exists at {dest}. "
            "Move/delete it first or omit --promote to land in staging."
        )
        return 2

    print(f"\n=== Composite Sheet — {args.entity_type}/{args.entity_id} ===")
    print(f"Project:  {args.project}")
    print("Model:    gpt-image-2")
    print(f"Quality:  {args.quality}")
    print(f"Aspect:   {args.aspect_ratio}")
    if args.size:
        print(f"Size:     {args.size} (override)")
    print(
        f"Mode:     {'i2i (' + str(len(refs)) + ' refs)' if refs else 't2i (no refs)'}"
    )
    for i, p_ref in enumerate(refs, start=1):
        print(f"  @Image{i}: {p_ref}")
    print(f"Dest:     {dest}")
    print(f"\nPrompt ({len(prompt.split())} words):")
    print("  " + prompt.replace("\n", "\n  "))

    if args.dry_run:
        print("\n=== DRY RUN — not submitting ===")
        return 0

    try:
        raw_out, cost = _dispatch_sheet(
            args.project,
            args.entity_type,
            args.entity_id,
            prompt,
            args.aspect_ratio,
            args.quality,
            reference_images=refs or None,
            size_override=args.size,
        )
    except Exception as e:
        print(f"\n[FAIL] {type(e).__name__}: {e}")
        return 1

    # Copy from the StepRunner-managed output dir to the chosen destination
    # (staging by default, canonical assets/{kind}/ if --promote was passed).
    shutil.copy2(raw_out, dest)

    cost_str = f"${cost:.4f}" if cost > 0 else "$? (cost extractor gap)"
    print(f"\n[OK] sheet → {dest} ({cost_str})")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
