#!/usr/bin/env python3
"""
visual_gate.py — Two-Gate Automated Visual QC Pipeline

Gate 1: Mechanical QC (artifact detection)
  - Face blur/distortion, hand deformation, body proportions
  - Text artifacts, generation failures
  - HARD FAIL if any score < 6 → auto-reject + queue regen

Gate 2: Semantic QC (prompt-image alignment)
  - Character identity vs refs, wardrobe match, lighting, emotion
  - ALL >= 8 → auto_pass (skip human review)
  - MIXED 5-7 → edge_case (route to human)
  - ANY < 5 → auto_reject

Usage:
    # Single frame check (Gate 1+2)
    python3 visual_gate.py check \\
        --image shot_03.png \\
        --storyboard storyboard_ep_001.json \\
        --shot-id 3 \\
        --refs front.png profile.png three_quarter.png \\
        --character JINX

    # Batch: all frames for an episode
    python3 visual_gate.py batch \\
        --project leviathan \\
        --episode 1

Exit codes: 0 = all pass, 1 = some failures/edge cases, 2 = error
"""

# ╔════════════════════════════════════════════════════════════════════╗
# ║ DEPRECATED — Superseded by Starsend equivalents (Feb 2026).      ║
# ║ Kept alive for Recoil agent protocols + referencing scripts.     ║
# ║ Do NOT delete until agents/breakdown_agent.md, storyboard_agent, ║
# ║ engine_checks/structural.py, and batch_threepass.py are updated. ║
# ╚════════════════════════════════════════════════════════════════════╝

import argparse
import base64
import json
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional, Tuple

from cost_tracker import CostTracker


# ── Shared helpers (from visual_qc.py patterns) ─────────────────────────

def encode_image_base64(path: str) -> str:
    """Read image file and return base64 encoded string."""
    with open(path, "rb") as f:
        return base64.standard_b64encode(f.read()).decode("utf-8")


def get_media_type(path: str) -> str:
    """Determine media type from file extension."""
    ext = Path(path).suffix.lower()
    types = {
        ".png": "image/png",
        ".jpg": "image/jpeg",
        ".jpeg": "image/jpeg",
        ".webp": "image/webp",
        ".gif": "image/gif",
    }
    return types.get(ext, "image/png")


def get_gemini_model():
    """Initialize Gemini 2.5 Flash for vision tasks."""
    try:
        import google.generativeai as genai
        api_key = os.environ.get("GOOGLE_API_KEY")
        if not api_key:
            print("ERROR: GOOGLE_API_KEY not set", file=sys.stderr)
            sys.exit(2)
        genai.configure(api_key=api_key)
        return genai.GenerativeModel("gemini-2.5-flash")
    except ImportError:
        print("ERROR: google-generativeai package not installed. Run: pip install google-generativeai", file=sys.stderr)
        sys.exit(2)


def call_gemini_vision(model, prompt: str, image_paths: List[str]) -> Tuple[dict, dict]:
    """Send images + prompt to Gemini vision API and parse JSON response.

    Returns:
        (parsed_json, usage) where usage has 'tokens_in' and 'tokens_out' keys.
    """
    import PIL.Image
    parts = []
    for path in image_paths:
        img = PIL.Image.open(path)
        parts.append(img)
    parts.append(prompt)

    response = model.generate_content(parts)
    text = response.text.strip()
    # Strip markdown code fences if present
    if text.startswith("```"):
        first_newline = text.index("\n") if "\n" in text else 3
        text = text[first_newline + 1:]
        if text.endswith("```"):
            text = text[:-3]
        text = text.strip()

    # Extract token usage from response metadata
    usage = {"tokens_in": 0, "tokens_out": 0}
    try:
        meta = response.usage_metadata
        if meta:
            usage["tokens_in"] = getattr(meta, "prompt_token_count", 0) or 0
            usage["tokens_out"] = getattr(meta, "candidates_token_count", 0) or 0
    except Exception:
        # Estimate if metadata unavailable: ~250 tokens prompt + ~260/image, ~200 out
        usage["tokens_in"] = 250 + 260 * len(image_paths)
        usage["tokens_out"] = 200

    return json.loads(text), usage


# ── Project root detection ───────────────────────────────────────────────

def find_project_root() -> Path:
    """Walk up from this file to find the Recoil project root."""
    candidate = Path(__file__).resolve().parent
    for _ in range(10):
        if (candidate / "tools").is_dir() and (candidate / "editors").is_dir():
            return candidate
        candidate = candidate.parent
    print("ERROR: Could not locate project root (no  found).", file=sys.stderr)
    sys.exit(2)


# ── Gate 1: Mechanical QC (Artifact Detection) ──────────────────────────

GATE1_PROMPT = """Analyze this AI-generated image for technical defects ONLY. Do NOT evaluate artistic quality or prompt adherence.

Score each dimension 1-10 (10 = no issues detected):

- face_quality: Check for blur, distortion, asymmetry, uncanny valley effects, melted features, double/merged faces. If no face is visible in the shot, score 10.
- hand_quality: Check for wrong finger count (should be 5 per hand), deformation, fused fingers, extra thumbs, proportion errors. If no hands visible, score 10.
- body_proportion: Check for wrong limb lengths, oversized/undersized head, anatomical impossibilities, torso/limb ratio errors. If no full body visible, score 10.
- artifacts: Check for text/watermark embedded in image, visible seam lines, tiling patterns, color banding, JPEG-like compression blocks, glitch patterns.
- generation_quality: Check for overall image coherence — no partial/floating objects, no impossible geometry, no abrupt style transitions, consistent perspective.

Return ONLY valid JSON with this exact structure:
{
  "face_quality": <int 1-10>,
  "hand_quality": <int 1-10>,
  "body_proportion": <int 1-10>,
  "artifacts": <int 1-10>,
  "generation_quality": <int 1-10>,
  "issues": ["brief description of any issue found"]
}

Be strict but fair. AI-generated images commonly have minor imperfections — score 7-8 for minor issues, 4-6 for noticeable problems, 1-3 for severe defects."""


def run_gate1(model, image_path: str, tracker: Optional[CostTracker] = None,
              episode: Optional[int] = None, shot_id: Optional[int] = None) -> dict:
    """Run Gate 1 mechanical QC on a single image. Returns gate1 result dict."""
    try:
        scores, usage = call_gemini_vision(model, GATE1_PROMPT, [image_path])
    except Exception as e:
        return {
            "pass": False,
            "scores": {},
            "error": str(e),
            "reject_reason": f"Gate 1 API error: {e}",
        }

    dimensions = ["face_quality", "hand_quality", "body_proportion", "artifacts", "generation_quality"]
    result_scores = {}
    reject_reasons = []

    for dim in dimensions:
        score = scores.get(dim, 0)
        if not isinstance(score, (int, float)):
            score = 0
        result_scores[dim] = int(score)
        if score < 6:
            reject_reasons.append(f"{dim}: {score}")

    gate_pass = len(reject_reasons) == 0

    # Log cost
    if tracker:
        tracker.log(
            category="qc",
            provider="gemini",
            model="gemini-2.5-flash",
            tokens_in=usage["tokens_in"],
            tokens_out=usage["tokens_out"],
            episode=episode,
            shot_id=shot_id,
            detail="Visual gate 1 — artifact detection",
            success=gate_pass,
        )

    return {
        "pass": gate_pass,
        "scores": result_scores,
        "issues": scores.get("issues", []),
        "reject_reasons": reject_reasons if reject_reasons else None,
    }


# ── Gate 2: Semantic QC (Prompt-Image Alignment) ────────────────────────

GATE2_PROMPT_TEMPLATE = """Compare this generated image against the reference images and shot direction.

SHOT DIRECTION:
- Shot type: {shot_type}
- Camera angle: {camera_angle}
- Emotion: {emotion}
- Lighting: {lighting}

GENERATION PROMPT:
{first_frame_prompt}

CHARACTER DESCRIPTION:
{character_description}

The first image is the GENERATED FRAME to evaluate.
The remaining {ref_count} images are CHARACTER REFERENCE images (identity ground truth).

Score each dimension 1-10 (10 = perfect match):

- identity_match: Does the character's face/body/build in the generated image match the reference images? Same person?
- wardrobe_match: Are clothing, accessories, props correct as described? Right garments, right damage state?
- lighting_match: Does the lighting match the shot direction? Correct light source, color temperature, mood?
- emotion_match: Does the expression and body language convey the intended emotion ({emotion})?
- composition_match: Is the framing correct for a {shot_type} at {camera_angle} angle?
- element_completeness: Are ALL described elements present in the frame? Nothing missing from the prompt?

Return ONLY valid JSON with this exact structure:
{{
  "identity_match": <int 1-10>,
  "wardrobe_match": <int 1-10>,
  "lighting_match": <int 1-10>,
  "emotion_match": <int 1-10>,
  "composition_match": <int 1-10>,
  "element_completeness": <int 1-10>,
  "notes": "brief observations about match quality"
}}

Be calibrated: 8-10 for good matches, 5-7 for acceptable but imperfect, 1-4 for clear mismatches."""


GATE2_NO_REFS_PROMPT_TEMPLATE = """Evaluate this generated image against its shot direction.

SHOT DIRECTION:
- Shot type: {shot_type}
- Camera angle: {camera_angle}
- Emotion: {emotion}
- Lighting: {lighting}

GENERATION PROMPT:
{first_frame_prompt}

There are no character reference images for this shot. Evaluate what you can.

Score each dimension 1-10 (10 = perfect match):

- identity_match: Score 7 (no refs to compare — assume reasonable). Only lower if face/body look obviously wrong.
- wardrobe_match: Do visible clothing/props match the prompt description?
- lighting_match: Does the lighting match the shot direction?
- emotion_match: Does the mood/atmosphere convey the intended emotion ({emotion})?
- composition_match: Is the framing correct for a {shot_type} at {camera_angle} angle?
- element_completeness: Are ALL described elements present?

Return ONLY valid JSON with this exact structure:
{{
  "identity_match": <int 1-10>,
  "wardrobe_match": <int 1-10>,
  "lighting_match": <int 1-10>,
  "emotion_match": <int 1-10>,
  "composition_match": <int 1-10>,
  "element_completeness": <int 1-10>,
  "notes": "brief observations"
}}"""


def run_gate2(model, image_path: str, shot: dict, ref_paths: List[str],
              character_desc: str = "", tracker: Optional[CostTracker] = None,
              episode: Optional[int] = None, shot_id: Optional[int] = None) -> dict:
    """Run Gate 2 semantic QC. Returns gate2 result dict."""
    dir_data = shot.get("direction", {})
    shot_type = dir_data.get("shot_type") or shot.get("shot_type", "")
    camera_angle = dir_data.get("camera_angle") or shot.get("camera_angle", "")
    emotion = dir_data.get("emotion") or shot.get("emotion", "")
    lighting = dir_data.get("lighting") or shot.get("lighting", "")
    first_frame = dir_data.get("first_frame") or shot.get("first_frame", "")

    # Build image list: generated frame first, then refs
    all_images = [image_path] + ref_paths

    if ref_paths:
        prompt = GATE2_PROMPT_TEMPLATE.format(
            shot_type=shot_type,
            camera_angle=camera_angle,
            emotion=emotion,
            lighting=lighting,
            first_frame_prompt=first_frame,
            character_description=character_desc or "(no character description)",
            ref_count=len(ref_paths),
        )
    else:
        prompt = GATE2_NO_REFS_PROMPT_TEMPLATE.format(
            shot_type=shot_type,
            camera_angle=camera_angle,
            emotion=emotion,
            lighting=lighting,
            first_frame_prompt=first_frame,
        )

    try:
        scores, usage = call_gemini_vision(model, prompt, all_images)
    except Exception as e:
        return {
            "result": "error",
            "scores": {},
            "notes": f"Gate 2 API error: {e}",
        }

    dimensions = ["identity_match", "wardrobe_match", "lighting_match",
                   "emotion_match", "composition_match", "element_completeness"]
    result_scores = {}
    edge_reasons = []
    reject_reasons = []

    for dim in dimensions:
        score = scores.get(dim, 0)
        if not isinstance(score, (int, float)):
            score = 0
        result_scores[dim] = int(score)
        if score < 5:
            reject_reasons.append(f"{dim}: {score}")
        elif score < 8:
            edge_reasons.append(f"{dim}: {score}")

    if reject_reasons:
        result = "auto_reject"
    elif edge_reasons:
        result = "edge_case"
    else:
        result = "auto_pass"

    # Log cost
    if tracker:
        tracker.log(
            category="qc",
            provider="gemini",
            model="gemini-2.5-flash",
            tokens_in=usage["tokens_in"],
            tokens_out=usage["tokens_out"],
            episode=episode,
            shot_id=shot_id,
            detail="Visual gate 2 — semantic alignment",
            success=(result != "error"),
        )

    return {
        "result": result,
        "scores": result_scores,
        "notes": scores.get("notes", ""),
        "edge_reasons": edge_reasons if edge_reasons else None,
        "reject_reasons": reject_reasons if reject_reasons else None,
    }


# ── Combined Gate Logic ──────────────────────────────────────────────────

def run_gates(model, image_path: str, shot: dict, ref_paths: List[str],
              character_desc: str = "", tracker: Optional[CostTracker] = None,
              episode: Optional[int] = None, shot_id: Optional[int] = None) -> dict:
    """Run Gate 1, then Gate 2 if Gate 1 passes. Returns combined result."""
    result = {"gate1": None, "gate2": None, "final": None, "reject_reasons": []}

    # Gate 1
    g1 = run_gate1(model, image_path, tracker=tracker, episode=episode, shot_id=shot_id)
    result["gate1"] = {k: v for k, v in g1.items() if v is not None}

    if not g1["pass"]:
        result["final"] = "auto_reject"
        result["reject_reasons"] = g1.get("reject_reasons", [])
        return result

    # Gate 2
    g2 = run_gate2(model, image_path, shot, ref_paths, character_desc,
                   tracker=tracker, episode=episode, shot_id=shot_id)
    result["gate2"] = {k: v for k, v in g2.items() if v is not None}

    if g2["result"] == "auto_reject":
        result["final"] = "auto_reject"
        result["reject_reasons"] = g2.get("reject_reasons", [])
    elif g2["result"] == "edge_case":
        result["final"] = "edge_case"
        result["edge_reasons"] = g2.get("edge_reasons", [])
    elif g2["result"] == "auto_pass":
        result["final"] = "auto_pass"
    else:
        result["final"] = "error"

    return result


# ── Batch Mode Helpers ───────────────────────────────────────────────────

def resolve_ref_paths(shot: dict, storyboard: dict, project_dir: Path) -> Tuple[List[str], str]:
    """Resolve character reference image paths and description for a shot.

    Returns (ref_paths, character_description).
    Looks at generation_metadata.reference_slots for character refs,
    falling back to the storyboard-level character data.
    """
    ref_paths = []
    char_desc = ""

    # Get reference_slots from shot metadata
    gen_meta = shot.get("generation_metadata", {})
    ref_slots = gen_meta.get("reference_slots", {})

    # Filter to character refs only (paths containing /characters/)
    char_ref_slots = {k: v for k, v in ref_slots.items() if "characters/" in v}

    if char_ref_slots:
        for _slot, rel_path in sorted(char_ref_slots.items()):
            full_path = project_dir / "visual" / rel_path
            if full_path.is_file():
                ref_paths.append(str(full_path))

    # If no refs from slots, try storyboard-level character data
    if not ref_paths:
        characters = storyboard.get("characters", {})
        # Try to determine character from shot name or subject
        shot_name = (shot.get("name") or "").lower()
        shot_subject = (shot.get("subject") or "").lower()

        for char_key, char_data in characters.items():
            if char_key.lower() in shot_name or char_key.lower() in shot_subject:
                char_desc = char_data.get("visual", "")
                char_refs = char_data.get("reference_images", [])
                for rel_path in char_refs[:3]:  # Max 3 refs
                    full_path = project_dir / "visual" / rel_path
                    if full_path.is_file():
                        ref_paths.append(str(full_path))
                break

    # Get character description from storyboard if not found
    if not char_desc:
        characters = storyboard.get("characters", {})
        shot_name = (shot.get("name") or "").lower()
        shot_subject = (shot.get("subject") or "").lower()
        for char_key, char_data in characters.items():
            if char_key.lower() in shot_name or char_key.lower() in shot_subject:
                char_desc = char_data.get("visual", "")
                break

    return ref_paths, char_desc


def find_frame_path(shot_id: int, manifest: dict, project_dir: Path) -> Optional[str]:
    """Resolve the generated frame path for a shot from the manifest."""
    frames = manifest.get("frames", {})
    frame_data = frames.get(str(shot_id))
    if not frame_data:
        return None

    first_frame = frame_data.get("first_frame")
    if not first_frame:
        return None

    full_path = project_dir / first_frame
    if full_path.is_file():
        return str(full_path)
    return None


# ── Commands ─────────────────────────────────────────────────────────────

def cmd_check(args):
    """Run Gate 1+2 on a single frame."""
    if not os.path.exists(args.image):
        print(f"ERROR: Image not found: {args.image}", file=sys.stderr)
        return 2

    # Load storyboard to get shot data
    shot = {}
    if args.storyboard and os.path.exists(args.storyboard):
        try:
            with open(args.storyboard) as f:
                sb = json.load(f)
        except json.JSONDecodeError as e:
            print(f"WARNING: Invalid storyboard JSON: {e}", file=sys.stderr)
            sb = {}
        for s in sb.get("shots", []):
            if s.get("id") == args.shot_id:
                shot = s
                break

    ref_paths = []
    if args.refs:
        for r in args.refs:
            if os.path.exists(r):
                ref_paths.append(r)
            else:
                print(f"WARNING: Ref not found: {r}", file=sys.stderr)

    char_desc = ""
    if args.character and args.storyboard:
        try:
            with open(args.storyboard) as f:
                sb = json.load(f)
        except json.JSONDecodeError:
            sb = {}
        characters = sb.get("characters", {})
        char_data = characters.get(args.character.lower(), {})
        char_desc = char_data.get("visual", "")

    model = get_gemini_model()
    print(f"Running Gate 1+2 on {args.image}...", file=sys.stderr)

    # Derive project path for cost tracking: walk up from image looking for visual/ dir
    tracker = None
    img_path = Path(args.image).resolve()
    for parent in img_path.parents:
        if (parent / "visual").is_dir() and (parent / "treatment.md").is_file():
            tracker = CostTracker(parent)
            break

    result = run_gates(model, args.image, shot, ref_paths, char_desc,
                       tracker=tracker, shot_id=args.shot_id)
    result["image"] = args.image
    result["shot_id"] = args.shot_id
    result["timestamp"] = datetime.now(timezone.utc).isoformat()

    print(json.dumps(result, indent=2))

    if result["final"] == "auto_pass":
        print(f"\nAUTO_PASS — All gates cleared", file=sys.stderr)
        return 0
    elif result["final"] == "edge_case":
        print(f"\nEDGE_CASE — Needs human review", file=sys.stderr)
        return 1
    else:
        print(f"\nAUTO_REJECT — {result.get('reject_reasons', [])}", file=sys.stderr)
        return 1


def cmd_batch(args):
    """Run Gate 1+2 on all frames for an episode."""
    project_root = find_project_root()
    project_dir = project_root / args.project

    if not project_dir.is_dir():
        print(f"ERROR: Project not found: {args.project}", file=sys.stderr)
        return 2

    ep_str = str(args.episode).zfill(3)

    # Load storyboard
    sb_path = project_dir / "storyboards" / f"storyboard_ep_{ep_str}.json"
    if not sb_path.is_file():
        print(f"ERROR: Storyboard not found: {sb_path}", file=sys.stderr)
        return 2

    try:
        with open(sb_path) as f:
            storyboard = json.load(f)
    except json.JSONDecodeError as e:
        print(f"ERROR: Invalid JSON in {sb_path}: {e}", file=sys.stderr)
        return 2

    shots = storyboard.get("shots", [])
    if not shots:
        print("ERROR: No shots in storyboard", file=sys.stderr)
        return 2

    # Find frame manifests (try multiple sources)
    manifest = {}
    manifest_sources = [
        project_dir / "storyboards" / "flux2_frames" / f"ep_{ep_str}" / "manifest.json",
        project_dir / "storyboards" / "rough_frames" / f"ep_{ep_str}" / "manifest.json",
    ]
    for mp in manifest_sources:
        if mp.is_file():
            try:
                with open(mp) as f:
                    m = json.load(f)
            except json.JSONDecodeError:
                continue
            # Merge frames (first source wins per shot)
            for shot_id, frame_data in m.get("frames", {}).items():
                if shot_id not in manifest.get("frames", {}):
                    if "frames" not in manifest:
                        manifest["frames"] = {}
                    manifest["frames"][shot_id] = frame_data
            if "model" not in manifest and "model" in m:
                manifest["model"] = m["model"]

    if not manifest.get("frames"):
        print("ERROR: No frame manifests found. Generate frames first.", file=sys.stderr)
        return 2

    # Initialize Gemini + cost tracker
    model = get_gemini_model()
    tracker = CostTracker(project_dir)

    # Run gates on each shot
    results = {}
    summary = {"auto_pass": 0, "auto_reject": 0, "edge_case": 0, "skipped": 0, "error": 0}

    for i, shot in enumerate(shots):
        shot_id = shot.get("id", i + 1)
        frame_path = find_frame_path(shot_id, manifest, project_dir)

        if not frame_path:
            print(f"  Shot {shot_id}: SKIP (no frame)", file=sys.stderr)
            summary["skipped"] += 1
            continue

        ref_paths, char_desc = resolve_ref_paths(shot, storyboard, project_dir)

        print(f"  Shot {shot_id}: Gate 1...", end="", file=sys.stderr, flush=True)

        try:
            result = run_gates(model, frame_path, shot, ref_paths, char_desc,
                               tracker=tracker, episode=args.episode, shot_id=shot_id)
            results[str(shot_id)] = result
            final = result.get("final", "error")

            if final in summary:
                summary[final] += 1
            else:
                summary["error"] += 1

            g1_status = "PASS" if result.get("gate1", {}).get("pass") else "FAIL"
            g2_status = result.get("gate2", {}).get("result", "skipped") if result.get("gate2") else "skipped"
            print(f" {g1_status} | Gate 2... {g2_status} | Final: {final.upper()}", file=sys.stderr)

        except Exception as e:
            print(f" ERROR: {e}", file=sys.stderr)
            results[str(shot_id)] = {"gate1": None, "gate2": None, "final": "error", "error": str(e)}
            summary["error"] += 1

        # Rate limit: 5s between requests to stay within Gemini RPM limits
        if i < len(shots) - 1:
            time.sleep(5)

    # Build output
    output = {
        "episode": args.episode,
        "project": args.project,
        "model": "gemini-2.5-flash",
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "summary": summary,
        "shots": results,
    }

    # Save to reviews directory
    reviews_dir = project_dir / "storyboards" / "reviews"
    reviews_dir.mkdir(parents=True, exist_ok=True)
    output_path = reviews_dir / f"visual_gate_ep_{ep_str}.json"
    with open(output_path, "w") as f:
        json.dump(output, f, indent=2)

    # Also print to stdout
    print(json.dumps(output, indent=2))

    # Summary
    total = sum(summary.values())
    print(f"\n{'='*50}", file=sys.stderr)
    print(f"Episode {args.episode} — {total} shots processed", file=sys.stderr)
    print(f"  Auto-pass:   {summary['auto_pass']}", file=sys.stderr)
    print(f"  Auto-reject: {summary['auto_reject']}", file=sys.stderr)
    print(f"  Edge case:   {summary['edge_case']}", file=sys.stderr)
    print(f"  Skipped:     {summary['skipped']}", file=sys.stderr)
    print(f"  Errors:      {summary['error']}", file=sys.stderr)
    print(f"Saved to: {output_path}", file=sys.stderr)

    has_issues = summary["auto_reject"] > 0 or summary["edge_case"] > 0 or summary["error"] > 0
    return 1 if has_issues else 0


# ── CLI ──────────────────────────────────────────────────────────────────

def build_parser():
    parser = argparse.ArgumentParser(
        description="Visual Gate — Automated two-gate QC for AI-generated keyframes",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )

    sub = parser.add_subparsers(dest="command", help="Gate mode")

    # check — single frame
    check = sub.add_parser("check", help="Run Gate 1+2 on a single frame")
    check.add_argument("--image", required=True, help="Path to generated frame")
    check.add_argument("--storyboard", default=None, help="Storyboard JSON for shot context")
    check.add_argument("--shot-id", type=int, default=0, help="Shot ID in storyboard")
    check.add_argument("--refs", nargs="+", default=[], help="Character reference image paths")
    check.add_argument("--character", default=None, help="Character key (e.g., JINX)")

    # batch — all frames for an episode
    batch = sub.add_parser("batch", help="Run Gate 1+2 on all episode frames")
    batch.add_argument("--project", required=True, help="Project name (e.g., leviathan)")
    batch.add_argument("--episode", type=int, required=True, help="Episode number")

    return parser


def main():
    parser = build_parser()
    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        return 0

    commands = {
        "check": cmd_check,
        "batch": cmd_batch,
    }

    handler = commands.get(args.command)
    if not handler:
        parser.print_help()
        return 2

    return handler(args)


if __name__ == "__main__":
    sys.exit(main())
