#!/usr/bin/env python3
"""
Video Generation Pipeline V2 - Multi-Stage Prompting

4-Stage architecture:
1. Data Extraction - Fetch product data + reviews + description from WB
2. Story Vision - Claude selects archetype and creates 2-act story
3. Frame Design - Claude creates NanoBanana prompts
4. Video Prompt - Claude assembles Veo3 prompt with negatives

Key improvements over v1:
- Category-aware prompts
- User feedback integration
- Negative prompt library
- Comprehensive logging
"""

import json
import os
import subprocess
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
import random
import string
import io
import logging
import re

import requests
from PIL import Image
from dotenv import load_dotenv

# Load .env
load_dotenv(Path(__file__).parent.parent / ".env")

# Import local modules
from laozhang_proxy import (
    call_nanobanana_laozhang,
    call_seedream_laozhang,
    call_veo3_laozhang,
    download_video_laozhang
)
from laozhang_veo3_async import (
    call_veo3_laozhang_async,
    poll_veo3_laozhang_async
)
from pollo_proxy import (
    call_veo3_pollo,
    poll_veo3_pollo,
    call_kling_v26_pollo,
    poll_kling_pollo,
    download_video_pollo,
    path_to_public_url
)
from kling_direct import (
    call_kling_direct,
    poll_kling_direct,
    download_video_kling_direct
)
from wb_to_video_prompts import extract_product_id, construct_image_urls
from feedback_parser import load_product_feedback, save_product_feedback
from generation_modes import GENERATION_MODES, DEFAULT_MODE, get_mode_config
from creative_styles import (
    VISUAL_STYLES, VIDEO_KINDS,
    select_random_style, select_random_kind,
    select_style_from_feedback,
    select_multiple_styles,
    format_style_options_for_critic,
    get_style_config, get_kind_config,
    get_style_prompt_context,
    format_style_for_caption,
    get_style_emoji, get_kind_emoji
)

# Try Sellematics fetcher first (requires tunnel), then fallback to Playwright
try:
    from sellematics_fetcher import fetch_wb_product_sellematics, fetch_ozon_product_sellematics, fetch_ozon_product_public, is_sellematics_available
    HAS_SELLEMATICS = True
except ImportError:
    HAS_SELLEMATICS = False

# Fallback to enhanced Playwright fetcher
try:
    from wb_fetcher_enhanced import fetch_wb_product_enhanced
    HAS_ENHANCED_FETCHER = True
except ImportError:
    HAS_ENHANCED_FETCHER = False
    from wb_fetcher import fetch_wb_product_playwright

# Avito fetcher
try:
    from avito_fetcher import fetch_avito_product, is_avito_url
    HAS_AVITO_FETCHER = True
except ImportError:
    HAS_AVITO_FETCHER = False

# URL detection for multiple marketplaces
from url_detector import detect_url_source, is_supported_url, get_directory_prefix


# Content policy errors that warrant Kling fallback (not AUDIO_FILTERED — that's transient)
CONTENT_POLICY_PATTERNS = [
    "PUBLIC_ERROR_SEXUAL",
    "PUBLIC_ERROR_IP_INPUT_IMAGE",
    "PUBLIC_ERROR_PROMINENT_PEOPLE_FILTER_FAILED",
    "图片中包含未成年人",  # Image contains minors
    "content policy",
    "NSFW",
    "guardrails related to third-party content",  # Pollo Veo3 IP error
]


def _is_content_policy_error(errors: list) -> bool:
    """Check if any error is a content policy rejection (not transient)."""
    error_text = "\n".join(errors)
    return any(p in error_text for p in CONTENT_POLICY_PATTERNS)


# === FRAME PROCESSING ===

# Target dimensions for exact aspect ratios (used by Veo3)
ASPECT_RATIO_TARGETS = {
    "9:16": (720, 1280),   # Vertical (WB card)
    "16:9": (1280, 720),   # Horizontal (cinematic)
}


def crop_frame_to_aspect_ratio(image_data: bytes, aspect_ratio: str) -> bytes:
    """
    Crop image to exact aspect ratio to avoid black ribbons in Veo3 output.

    NanoBanana generates frames with slightly off ratios (e.g., 768x1376 instead of 768x1365).
    This causes Veo3 to add black ribbons when scaling to 720x1280.

    Args:
        image_data: Raw image bytes
        aspect_ratio: Target ratio ("9:16", "16:9", or "3:4")

    Returns:
        Cropped image bytes (JPEG)
    """
    img = Image.open(io.BytesIO(image_data))
    w, h = img.size

    # Calculate target ratio
    if aspect_ratio == "9:16":
        target_ratio = 9 / 16  # 0.5625
    elif aspect_ratio == "16:9":
        target_ratio = 16 / 9  # 1.7778
    elif aspect_ratio == "3:4":
        target_ratio = 3 / 4  # 0.75
    else:
        # Unknown ratio, return as-is
        return image_data

    current_ratio = w / h

    # Check if crop is needed (tolerance of 0.1%)
    if abs(current_ratio - target_ratio) < 0.001:
        return image_data

    if current_ratio > target_ratio:
        # Image is too wide, crop width
        new_w = int(h * target_ratio)
        crop_x = (w - new_w) // 2
        img = img.crop((crop_x, 0, crop_x + new_w, h))
    else:
        # Image is too tall, crop height
        new_h = int(w / target_ratio)
        crop_y = (h - new_h) // 2
        img = img.crop((0, crop_y, w, crop_y + new_h))

    # Save to bytes
    output = io.BytesIO()
    img.save(output, format='JPEG', quality=95)
    return output.getvalue()


def crop_video_to_34(video_path: Path) -> Path:
    """
    Scale and crop video to 3:4 (720x960) for Wildberries product cards.

    Handles various input resolutions (Veo3: 720x1280, Kling: 1244x1660, etc.)
    by first scaling to 720px width, then cropping height to 960px.
    Auto-detects black bars (e.g. from Pollo letterboxing) and crops from
    where content actually starts.

    Args:
        video_path: Path to input video (any resolution)

    Returns:
        Path to cropped 3:4 video (720x960)
    """
    video_cropped_path = video_path.parent / f"{video_path.stem}_3x4{video_path.suffix}"

    # Get input video dimensions
    probe_cmd = [
        "ffprobe", "-v", "error",
        "-select_streams", "v:0",
        "-show_entries", "stream=width,height",
        "-of", "csv=p=0",
        str(video_path)
    ]
    probe_result = subprocess.run(probe_cmd, capture_output=True, text=True)
    try:
        input_w, input_h = map(int, probe_result.stdout.strip().split(","))
    except ValueError:
        # Fallback to assumed 720x1280
        input_w, input_h = 720, 1280
        print(f"  Warning: couldn't detect video dimensions, assuming {input_w}x{input_h}")

    print(f"  Input video: {input_w}x{input_h}")

    # Calculate scaled height (scale to 720 width, preserve aspect ratio)
    scaled_h = int(input_h * 720 / input_w)
    # Ensure even number for codec compatibility
    scaled_h = scaled_h + (scaled_h % 2)

    # Detect black bars using cropdetect on first 2 seconds
    detect_cmd = [
        "ffmpeg", "-i", str(video_path),
        "-t", "2", "-vf", f"scale=720:{scaled_h},cropdetect=24:2:0",
        "-f", "null", "-"
    ]
    detect_result = subprocess.run(detect_cmd, capture_output=True, text=True)

    # Parse cropdetect output to find content area (last line wins)
    crop_y = 0
    content_h = scaled_h
    for line in detect_result.stderr.split("\n"):
        if "crop=" in line:
            # Format: crop=720:960:0:160
            m = re.search(r"crop=(\d+):(\d+):(\d+):(\d+)", line)
            if m:
                content_h = int(m.group(2))
                crop_y = int(m.group(4))

    # If content is shorter than scaled height (letterboxed), start from detected content top
    if content_h < scaled_h and crop_y > 0:
        print(f"  Detected letterboxing: content at y={crop_y}, height={content_h}")

    # Build filter: scale to 720 width, then crop to 720x960
    # crop_y is in scaled coordinates, so apply after scale
    vf_filter = f"scale=720:{scaled_h},crop=720:960:0:{crop_y}"

    cmd = [
        "ffmpeg", "-y",
        "-i", str(video_path),
        "-vf", vf_filter,
        "-c:a", "copy",
        str(video_cropped_path)
    ]

    result = subprocess.run(cmd, capture_output=True, text=True)

    if result.returncode != 0:
        raise RuntimeError(f"Video crop failed: {result.stderr}")

    print(f"  Cropped to 3:4 (720x960): {video_cropped_path.name}")
    return video_cropped_path


def validate_video(video_path: Path, min_size_kb: int = 100) -> None:
    """Validate video file is a proper playable MP4.

    Raises RuntimeError if video is corrupted, truncated, or too small.
    """
    if not video_path.exists():
        raise RuntimeError(f"Video file not found: {video_path}")

    size_kb = video_path.stat().st_size / 1024
    if size_kb < min_size_kb:
        raise RuntimeError(
            f"Video file too small ({size_kb:.0f}KB < {min_size_kb}KB), likely corrupted: {video_path.name}"
        )

    result = subprocess.run(
        ["ffprobe", "-v", "error", "-show_entries", "format=duration",
         "-of", "default=noprint_wrappers=1:nokey=1", str(video_path)],
        capture_output=True, text=True, timeout=30
    )
    if result.returncode != 0:
        raise RuntimeError(f"Video file invalid (ffprobe failed): {result.stderr.strip()}")

    duration = float(result.stdout.strip() or 0)
    if duration < 1.0:
        raise RuntimeError(f"Video too short ({duration:.1f}s), likely corrupted: {video_path.name}")


def optimize_video_for_telegram(video_path: Path, max_size_mb: float = 4.0) -> None:
    """Re-encode video if it exceeds max_size_mb for faster Telegram upload.

    Skips videos already under the size limit (typical Veo3 output is 2-3MB).
    Replaces the file in-place on success.
    """
    size_mb = video_path.stat().st_size / (1024 * 1024)
    if size_mb <= max_size_mb:
        return

    temp_path = video_path.with_suffix('.optimized.mp4')
    try:
        cmd = [
            "ffmpeg", "-y", "-i", str(video_path),
            "-vf", "scale='min(720,iw)':-2",
            "-c:v", "libx264", "-preset", "medium", "-crf", "23",
            "-c:a", "aac", "-b:a", "128k",
            "-movflags", "+faststart",
            "-pix_fmt", "yuv420p",
            str(temp_path)
        ]
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
        if result.returncode != 0:
            raise RuntimeError(f"ffmpeg failed: {result.stderr[-200:]}")

        new_size_mb = temp_path.stat().st_size / (1024 * 1024)
        temp_path.rename(video_path)
        print(f"  Optimized for Telegram: {size_mb:.1f}MB → {new_size_mb:.1f}MB")
    except Exception:
        # Clean up temp file on failure
        if temp_path.exists():
            temp_path.unlink()
        raise


def generate_kids_video(
    act1_path: Path,
    act2_path: Path,
    veo3_prompt: str,
    story_vision: dict,
    negatives: dict,
    pollo_key: str,
    output_dir: Path,
    logger=None,
) -> Path:
    """Generate kids-safe video using two 5s Kling clips (start frame only) concatenated.

    Instead of Veo3 (which blocks minors), we use Kling v2.6 with:
    - Act 1: start frame = act1_path, no end frame, 5s
    - Act 2: start frame = act2_path, no end frame, 5s
    Then normalize both clips and concatenate with ffmpeg.
    """
    videos_dir = output_dir / "videos"
    videos_dir.mkdir(exist_ok=True)

    # Split prompt into act1 and act2 portions
    act1_prompt, act2_prompt = _split_kids_prompt(veo3_prompt, story_vision)

    # Build safe negatives for Kling — standard production negatives only.
    # Do NOT include kids safety negatives ("no nudity", "no violence" etc.)
    # as Kling's risk control flags those terms as suspicious content.
    safe_neg_sections = ["always", "anatomical", "scene"]
    # Filter out children-related terms from "always" (we want children here)
    children_terms = {"no children", "no babies", "no toddlers", "no minors", "no kids"}
    safe_negs = []
    for section in safe_neg_sections:
        for n in negatives.get(section, []):
            if n not in children_terms:
                safe_negs.append(f"--{n}")
    neg_string = " ".join(safe_negs)

    clip_paths = []
    for act_num, (frame_path, prompt) in enumerate(
        [(act1_path, act1_prompt), (act2_path, act2_prompt)], start=1
    ):
        act_label = f"Act {act_num}"
        clip_prompt = f"{prompt} {neg_string}".strip()
        # Truncate to Kling's 2000 char limit
        if len(clip_prompt) > 2000:
            clip_prompt = clip_prompt[:2000]

        msg = f"\n[Kids Pipeline] Generating {act_label} clip (Kling v2.6, 5s)..."
        print(msg)
        if logger:
            logger.info(msg)

        if frame_path is None:
            raise RuntimeError(f"Kids pipeline requires {act_label} frame but it's missing")

        kling_video = None
        provider_used = None

        # Try Pollo Kling first, then Kling Direct fallback
        if pollo_key:
            try:
                kling_result = call_kling_v26_pollo(
                    prompt=clip_prompt,
                    api_key=pollo_key,
                    first_frame_path=str(frame_path),
                    last_frame_path=None,
                    length=5,
                    generate_audio=True,
                )
                msg = f"  Kling task ({act_label}): {kling_result['task_id']}"
                print(msg)
                if logger:
                    logger.info(msg)

                kling_video = poll_kling_pollo(
                    task_id=kling_result["task_id"],
                    api_key=pollo_key,
                    poll_interval=10,
                    timeout=600,
                )
                provider_used = "pollo"
            except Exception as e:
                print(f"  Pollo Kling failed for {act_label}: {e}")
                if logger:
                    logger.warning(f"  Pollo Kling failed for {act_label}: {e}")

        if not kling_video:
            print(f"  [{act_label}] Trying Kling Direct fallback...")
            if logger:
                logger.info(f"  [{act_label}] Trying Kling Direct fallback...")
            kling_result = call_kling_direct(
                prompt=clip_prompt,
                first_frame_path=str(frame_path),
                last_frame_path=None,
                duration=5,
                generate_audio=True,
            )
            msg = f"  Kling Direct task ({act_label}): {kling_result['task_id']}"
            print(msg)
            if logger:
                logger.info(msg)

            kling_video = poll_kling_direct(
                task_id=kling_result["task_id"],
                poll_interval=10,
                timeout=600,
            )
            provider_used = "kling_direct"

        msg = f"  {act_label} clip ready! (provider: {provider_used})"
        print(msg)
        if logger:
            logger.info(msg)

        # Download clip
        clip_path = videos_dir / f"kids_clip_act{act_num}.mp4"
        if provider_used == "kling_direct":
            download_video_kling_direct(kling_video["uri"], str(clip_path))
        else:
            download_video_pollo(kling_video["uri"], str(clip_path))
        msg = f"  Downloaded: {clip_path.name} ({clip_path.stat().st_size // 1024}KB)"
        print(msg)
        if logger:
            logger.info(msg)

        clip_paths.append(clip_path)

    # Normalize both clips to same resolution/codec
    normalized_paths = []
    for i, clip_path in enumerate(clip_paths, start=1):
        normalized_path = videos_dir / f"kids_clip_act{i}_normalized.mp4"
        _normalize_clip(clip_path, normalized_path)
        normalized_paths.append(normalized_path)

    # Concatenate with ffmpeg concat demuxer
    combined_path = videos_dir / "kids_combined.mp4"
    _concat_clips(normalized_paths, combined_path)

    msg = f"  Combined video: {combined_path.name} ({combined_path.stat().st_size // (1024*1024):.1f}MB)"
    print(msg)
    if logger:
        logger.info(msg)

    return combined_path


def _split_kids_prompt(veo3_prompt: str, story_vision: dict) -> tuple:
    """Split combined veo3 prompt into Act 1 and Act 2 portions."""
    act1 = story_vision.get("act1", {})
    act2 = story_vision.get("act2", {})

    # Build separate prompts from story_vision data (more reliable than splitting text)
    clothing1 = act1.get("clothing", "")
    clothing2 = act2.get("clothing", "")
    clothing_suffix1 = f" Clothing: {clothing1}." if clothing1 else ""
    clothing_suffix2 = f" Clothing: {clothing2}." if clothing2 else ""

    act1_prompt = f"{act1.get('scene', '')}. {act1.get('emotion', '')}.{clothing_suffix1}"
    act2_prompt = f"{act2.get('scene', '')}. {act2.get('product_usage', '')}. {act2.get('emotion', '')}.{clothing_suffix2}"

    return act1_prompt.strip(), act2_prompt.strip()


def _normalize_clip(input_path: Path, output_path: Path) -> None:
    """Normalize a clip to consistent resolution/codec for concatenation."""
    cmd = [
        "ffmpeg", "-y", "-i", str(input_path),
        "-vf", "scale=720:960:force_original_aspect_ratio=increase,crop=720:960",
        "-c:v", "libx264", "-preset", "medium", "-crf", "23",
        "-c:a", "aac", "-b:a", "128k",
        "-r", "24", "-pix_fmt", "yuv420p",
        str(output_path)
    ]
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
    if result.returncode != 0:
        raise RuntimeError(f"ffmpeg normalize failed: {result.stderr[-300:]}")
    print(f"  Normalized: {output_path.name}")


def _concat_clips(clip_paths: list, output_path: Path) -> None:
    """Concatenate clips using ffmpeg concat demuxer."""
    concat_file = output_path.parent / "concat.txt"
    with open(concat_file, 'w') as f:
        for path in clip_paths:
            f.write(f"file '{path.resolve()}'\n")

    cmd = [
        "ffmpeg", "-y",
        "-f", "concat", "-safe", "0",
        "-i", str(concat_file),
        "-c", "copy",
        str(output_path)
    ]
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
    if result.returncode != 0:
        raise RuntimeError(f"ffmpeg concat failed: {result.stderr[-300:]}")

    # Clean up concat file
    concat_file.unlink(missing_ok=True)
    print(f"  Concatenated: {output_path.name}")


# === LOGGING ===

class ThreadLocalTeeStdout:
    """Thread-aware stdout tee — each thread writes to its own log file.

    Solves the race condition where concurrent pipelines (run via
    run_in_executor in separate threads) would overwrite sys.stdout
    and mix log output into the wrong pipeline.log files.
    """

    _local = threading.local()

    def __init__(self):
        self._original = sys.__stdout__

    @classmethod
    def register(cls, log_file: Path, task_id: str):
        """Register log file for current thread."""
        cls._local.log = open(log_file, 'a', encoding='utf-8')
        cls._local.task_id = task_id

    @classmethod
    def unregister(cls):
        """Close and unregister log file for current thread."""
        log = getattr(cls._local, 'log', None)
        if log and not log.closed:
            log.close()
        cls._local.log = None
        cls._local.task_id = None

    def write(self, text):
        self._original.write(text)
        log = getattr(self._local, 'log', None)
        task_id = getattr(self._local, 'task_id', None)
        if text.strip() and log and not log.closed:
            try:
                ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
                log.write(f"{ts} [{task_id}] {text.rstrip()}\n")
                log.flush()
            except ValueError:
                pass  # Log file closed — ignore

    def flush(self):
        self._original.flush()
        log = getattr(self._local, 'log', None)
        if log and not log.closed:
            try:
                log.flush()
            except ValueError:
                pass


def setup_task_logging(output_dir: Path, task_id: str) -> logging.Logger:
    """
    Set up real-time logging to task-specific file.

    Creates logger that writes to both:
    - stdout (for bot to capture)
    - {output_dir}/pipeline.log (for real-time debugging)

    Also tees print() output to pipeline.log via ThreadLocalTeeStdout.

    Every line is prefixed with timestamp and task_id.
    """
    log_file = output_dir / "pipeline.log"

    # Install thread-local tee once (idempotent), register this thread's log file
    if not isinstance(sys.stdout, ThreadLocalTeeStdout):
        sys.stdout = ThreadLocalTeeStdout()
    ThreadLocalTeeStdout.register(log_file, task_id)

    # Create logger for this task
    logger = logging.getLogger(f"pipeline_{task_id}")
    logger.setLevel(logging.INFO)
    logger.propagate = False  # Don't propagate to root logger

    # Remove existing handlers
    logger.handlers.clear()

    # File handler - real-time writes
    file_handler = logging.FileHandler(log_file, mode='a', encoding='utf-8')
    file_handler.setLevel(logging.INFO)

    # Console handler - for bot to capture (write to original stdout, not tee)
    console_handler = logging.StreamHandler(sys.__stdout__)
    console_handler.setLevel(logging.INFO)

    # Format: timestamp [task_id] message
    formatter = logging.Formatter(
        f'%(asctime)s [{task_id}] %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S'
    )
    file_handler.setFormatter(formatter)
    console_handler.setFormatter(logging.Formatter('%(message)s'))  # Clean stdout

    logger.addHandler(file_handler)
    logger.addHandler(console_handler)

    return logger


# === BUILD INFO ===

def get_build_info() -> Dict:
    """Get git and environment info for debugging."""
    info = {
        "source": "unknown",
        "git_commit": "unknown",
        "git_branch": "unknown",
        "git_dirty": False,
        "working_dir": str(Path.cwd()),
    }

    # Detect source from path
    cwd = str(Path(__file__).parent.parent)
    if "/prod" in cwd:
        info["source"] = "prod"
    elif "/dev" in cwd:
        info["source"] = "dev"

    # Get git info
    try:
        git_dir = Path(__file__).parent.parent
        result = subprocess.run(
            ["git", "rev-parse", "HEAD"],
            cwd=git_dir, capture_output=True, text=True, timeout=5
        )
        if result.returncode == 0:
            info["git_commit"] = result.stdout.strip()[:8]

        result = subprocess.run(
            ["git", "rev-parse", "--abbrev-ref", "HEAD"],
            cwd=git_dir, capture_output=True, text=True, timeout=5
        )
        if result.returncode == 0:
            info["git_branch"] = result.stdout.strip()

        result = subprocess.run(
            ["git", "status", "--porcelain"],
            cwd=git_dir, capture_output=True, text=True, timeout=5
        )
        if result.returncode == 0:
            info["git_dirty"] = len(result.stdout.strip()) > 0
    except Exception:
        pass

    return info


# === CONFIGURATION ===

PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
CATEGORIES_DIR = PROMPTS_DIR / "categories"
NEGATIVES_PATH = PROMPTS_DIR / "negatives" / "library.json"

# Critic mode: how many style options to generate and self-evaluate in first generation
USE_CRITIC_MODE = True
CRITIC_NUM_OPTIONS = 3



# === UTILITY FUNCTIONS ===

def generate_task_id() -> str:
    """Generate unique task ID."""
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=4))
    return f"{timestamp}_{suffix}"


def create_output_directory(base_path: str, product_id: str, task_id: str, source: str = "wb") -> Path:
    """Create output directory structure."""
    prefix = get_directory_prefix(source)
    output_dir = Path(base_path) / f"{prefix}_{product_id}" / task_id
    (output_dir / "images").mkdir(parents=True, exist_ok=True)
    (output_dir / "processed").mkdir(parents=True, exist_ok=True)
    (output_dir / "videos").mkdir(parents=True, exist_ok=True)
    return output_dir


def download_image(url: str, output_path: str, timeout: int = 30) -> bool:
    """Download image from URL."""
    try:
        response = requests.get(url, timeout=timeout, headers={
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
        })
        if response.status_code == 200:
            with open(output_path, 'wb') as f:
                f.write(response.content)
            return True
    except Exception as e:
        print(f"  Failed to download: {e}")
    return False


def download_images_smart(image_urls: list, output_dir: Path, is_avito: bool = False) -> list:
    """Download images with smart filtering for Avito (keeps only largest).

    For Avito: Downloads ALL URL variants, keeps only files > 15KB or top 7 largest.
    For others: Normal download.
    """
    import os
    import re
    from pathlib import Path

    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    if not is_avito:
        # Normal download for non-Avito
        downloaded = []
        for i, url in enumerate(image_urls, 1):
            output_path = output_dir / f"original_{i}.webp"
            if download_image(url, str(output_path)):
                downloaded.append(str(output_path))
        return downloaded

    # AVITO: Generate ALL URL variants
    print("  Avito detected: generating all URL variants...")
    all_variants = []
    for img_url in image_urls:
        # Variant 1: Original without params
        base_url = img_url.split('?')[0]
        all_variants.append(base_url)

        # Variant 2: With dimension transformation
        transformed = re.sub(r'([\.\/])(\d+x\d+)([\.\/])', r'\g<1>1280x960\g<3>', base_url)
        if transformed != base_url:
            all_variants.append(transformed)

        # Variant 3: Different CDN subdomains
        if 'img.avito.st' in base_url:
            for cdn_num in ['00', '20', '40', '50']:
                variant = re.sub(r'\d+\.img\.avito\.st', f'{cdn_num}.img.avito.st', base_url)
                all_variants.append(variant)

    # Deduplicate
    all_variants = list(dict.fromkeys(all_variants))
    print(f"  Generated {len(all_variants)} URL variants")

    # Download ALL to temp
    temp_dir = output_dir / "temp_all"
    temp_dir.mkdir(exist_ok=True)

    candidates = []
    for i, url in enumerate(all_variants, 1):
        try:
            temp_path = temp_dir / f"candidate_{i}.webp"
            if download_image(url, str(temp_path), timeout=10):
                file_size = os.path.getsize(temp_path)
                candidates.append({
                    'path': temp_path,
                    'size': file_size,
                    'url': url
                })
        except Exception as e:
            pass

    print(f"  Downloaded {len(candidates)} candidates")

    # Sort by size and filter
    candidates.sort(key=lambda x: x['size'], reverse=True)

    # Deduplicate by file size (if two files have same size, they're likely the same image)
    kept = []
    seen_sizes = set()
    MIN_SIZE = 15 * 1024  # 15KB threshold
    MAX_IMAGES = 7

    for candidate in candidates:
        # Skip if we've seen this exact file size (likely duplicate)
        size_kb = round(candidate['size'] / 100)  # Round to nearest 100 bytes for dedup
        if size_kb in seen_sizes:
            continue

        # Only keep files above threshold
        if candidate['size'] >= MIN_SIZE:
            kept.append(candidate)
            seen_sizes.add(size_kb)

            if len(kept) >= MAX_IMAGES:
                break

    # If we got fewer than 3 images, relax threshold to 5KB
    if len(kept) < 3:
        print(f"  Only {len(kept)} large images, relaxing threshold to 5KB...")
        for candidate in candidates:
            size_kb = round(candidate['size'] / 100)
            if size_kb not in seen_sizes and candidate['size'] >= 5 * 1024:
                kept.append(candidate)
                seen_sizes.add(size_kb)
                if len(kept) >= MAX_IMAGES:
                    break

    print(f"  Keeping {len(kept)} unique images (sizes: {', '.join(f'{c['size']/1024:.1f}KB' for c in kept)})")

    # Move to final location
    downloaded = []
    for i, candidate in enumerate(kept, 1):
        final_path = output_dir / f"original_{i}.webp"
        try:
            import shutil
            shutil.move(str(candidate['path']), str(final_path))
            downloaded.append(str(final_path))
        except Exception as e:
            print(f"  Warning: Failed to move {candidate['path']}: {e}")

    # Cleanup temp
    try:
        import shutil
        shutil.rmtree(temp_dir)
    except:
        pass

    return downloaded


def load_category_style(category: str) -> Optional[Dict]:
    """Load category style registry."""
    category_file = CATEGORIES_DIR / f"{category}.json"
    if category_file.exists():
        with open(category_file, 'r', encoding='utf-8') as f:
            return json.load(f)
    return None


def load_all_category_styles() -> Dict[str, Dict]:
    """Load all category style registries."""
    styles = {}
    if CATEGORIES_DIR.exists():
        for category_file in CATEGORIES_DIR.glob("*.json"):
            try:
                with open(category_file, 'r', encoding='utf-8') as f:
                    styles[category_file.stem] = json.load(f)
            except Exception as e:
                print(f"  Warning: Failed to load {category_file}: {e}")
    return styles


def load_negative_prompts() -> Dict:
    """Load negative prompt library."""
    if NEGATIVES_PATH.exists():
        with open(NEGATIVES_PATH, 'r', encoding='utf-8') as f:
            return json.load(f)
    return {"always": [], "anatomical": [], "scene": []}


def get_mime_type(filepath: str) -> str:
    """Get MIME type based on file extension."""
    ext = Path(filepath).suffix.lower()
    return {
        ".webp": "image/webp",
        ".jpg": "image/jpeg",
        ".jpeg": "image/jpeg",
        ".png": "image/png",
    }.get(ext, "image/webp")


# === LLM INTEGRATION ===

# Fail-fast when LLM fails (don't waste expensive NanoBanana/Veo3 calls on generic prompts)
# Set ALLOW_CLAUDE_FALLBACK=1 to continue with fallback prompts when LLM fails
ALLOW_CLAUDE_FALLBACK = os.environ.get("ALLOW_CLAUDE_FALLBACK", "0") == "1"

# Default Claude model for pipeline
CLAUDE_MODEL = os.environ.get("CLAUDE_MODEL", "claude-opus-4-5-20251101")

# --- Provider config ---

# LaoZhang (primary) — Anthropic Messages API format
LAOZHANG_API_KEY = os.environ.get("LAOZHANG_KEY", "")
LAOZHANG_MESSAGES_URL = "https://api.laozhang.ai/v1/messages"

# OpenRouter (fallback 1) — OpenAI Chat Completions API format
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
OPENROUTER_MODEL = os.environ.get("OPENROUTER_MODEL", "anthropic/claude-opus-4.6")
OPENROUTER_PROXY = os.environ.get("OPENROUTER_PROXY", "socks5h://PROXY_HOST:1080")

# GLM-5 via z.ai (fallback 2) — Anthropic Messages API format
ZAI_API_KEY = os.environ.get("ZAI_API_KEY", "")
ZAI_URL = "https://api.z.ai/api/anthropic/v1/messages"
ZAI_MODEL = "glm-5"


def call_nanobanana_openrouter(
    image_data: bytes,
    prompt: str,
    mime_type: str = "image/webp",
    aspect_ratio: str = "16:9",
    image_size: str = "1K",
    reference_image_data: bytes = None,
    reference_mime_type: str = None
) -> tuple:
    """
    Call NanoBanana (Gemini 3 Pro) via OpenRouter as last-resort fallback.
    Returns (image_bytes, mime_type) to match call_nanobanana_laozhang interface.
    """
    import base64

    if not OPENROUTER_API_KEY:
        raise Exception("OPENROUTER_API_KEY not set")

    # Build OpenRouter content parts
    or_content = []

    # Main product image
    image_b64 = base64.b64encode(image_data).decode('utf-8')
    or_content.append({
        "type": "image_url",
        "image_url": {"url": f"data:{mime_type};base64,{image_b64}"}
    })

    # Reference image for scene consistency
    if reference_image_data and reference_mime_type:
        ref_b64 = base64.b64encode(reference_image_data).decode('utf-8')
        or_content.append({
            "type": "image_url",
            "image_url": {"url": f"data:{reference_mime_type};base64,{ref_b64}"}
        })
        print(f"  Using reference image for scene consistency (OpenRouter)")

    # Prompt text
    or_content.append({"type": "text", "text": prompt})

    payload = {
        "model": "google/gemini-3-pro-image-preview",
        "messages": [{"role": "user", "content": or_content}],
        "modalities": ["image", "text"],
        "image_config": {
            "aspect_ratio": aspect_ratio,
            "image_size": image_size
        }
    }
    headers = {
        "Authorization": f"Bearer {OPENROUTER_API_KEY}",
        "Content-Type": "application/json",
        "HTTP-Referer": "https://example.com",
        "X-Title": "Video Pipeline",
    }

    proxies = {}
    if OPENROUTER_PROXY and OPENROUTER_PROXY.lower() != "none":
        proxies = {"http": OPENROUTER_PROXY, "https": OPENROUTER_PROXY}

    print(f"  Calling NanoBanana via OpenRouter (proxy: {OPENROUTER_PROXY})...")
    print(f"  Aspect ratio: {aspect_ratio}, Size: {image_size}")

    # Retry logic
    last_error = None
    response = None
    for attempt in range(3):
        try:
            response = requests.post(
                f"{OPENROUTER_URL.replace('/chat/completions', '')}/chat/completions",
                headers=headers, json=payload, timeout=180, proxies=proxies
            )
            if response.status_code in (429, 502, 503, 504):
                last_error = f"HTTP {response.status_code}"
                if attempt < 2:
                    wait = 5 * (attempt + 1)
                    print(f"  OpenRouter retry {attempt + 1}/3 after {last_error} (wait {wait}s)")
                    time.sleep(wait)
                    continue
            break
        except (requests.exceptions.SSLError, requests.exceptions.ConnectionError,
                requests.exceptions.Timeout) as e:
            last_error = str(e)
            if attempt < 2:
                print(f"  OpenRouter retry {attempt + 1}/3 after error (wait 5s)")
                time.sleep(5)
            else:
                raise Exception(f"OpenRouter NanoBanana failed after 3 attempts: {last_error}")

    if response is None or response.status_code != 200:
        status = response.status_code if response else "no response"
        text = response.text[:500] if response else str(last_error)
        raise Exception(f"OpenRouter NanoBanana error: HTTP {status} - {text}")

    data = response.json()
    choices = data.get("choices", [])
    if not choices:
        raise Exception(f"OpenRouter NanoBanana: no choices in response")

    message = choices[0].get("message", {})
    for img in message.get("images", []):
        url = img.get("image_url", {}).get("url", "")
        if url.startswith("data:"):
            header, b64data = url.split(",", 1)
            img_mime = header.split(":")[1].split(";")[0] if ":" in header else "image/png"
            img_bytes = base64.b64decode(b64data)
            print(f"  NanoBanana OpenRouter returned image: {len(img_bytes)//1024}KB, {img_mime}")
            return (img_bytes, img_mime)

    raise Exception("OpenRouter NanoBanana: no image in response")


class ContentPolicyError(Exception):
    """Raised when LLM refuses due to content policy. Stops the fallback chain."""
    pass


def encode_image_base64(image_path: str) -> tuple:
    """Encode image to base64 for Anthropic API. Returns (base64_data, media_type)."""
    import base64
    suffix = Path(image_path).suffix.lower()
    media_types = {
        ".webp": "image/webp",
        ".jpg": "image/jpeg",
        ".jpeg": "image/jpeg",
        ".png": "image/png",
    }
    media_type = media_types.get(suffix, "image/webp")
    with open(image_path, "rb") as f:
        data = base64.b64encode(f.read()).decode()
    return data, media_type


def _is_content_policy_error(status_code: int, response_text: str, result_text: str = "") -> bool:
    """Check if error is content policy related (should NOT fall through to next provider)."""
    policy_keywords = ["content policy", "content_policy", "safety", "harmful",
                       "inappropriate", "violat", "refused"]
    if status_code == 400:
        return any(kw in response_text.lower() for kw in policy_keywords)
    if result_text:
        refusal_markers = ["I cannot", "I can't", "I'm unable to",
                           "against my guidelines", "content policy"]
        return any(m in result_text for m in refusal_markers)
    return False


def _call_laozhang(system_text: str, user_parts: list, model: str, max_tokens: int, timeout: int) -> dict:
    """Anthropic Messages API format via LaoZhang. Returns {text, usage}."""
    if not LAOZHANG_API_KEY:
        raise Exception("LAOZHANG_KEY not set")

    system_blocks = [{"type": "text", "text": system_text, "cache_control": {"type": "ephemeral"}}]

    payload = {
        "model": model,
        "max_tokens": max_tokens,
        "system": system_blocks,
        "messages": [{"role": "user", "content": user_parts}],
    }
    headers = {
        "x-api-key": LAOZHANG_API_KEY,
        "Content-Type": "application/json",
        "anthropic-version": "2023-06-01",
    }

    resp = requests.post(LAOZHANG_MESSAGES_URL, headers=headers, json=payload, timeout=timeout)

    if resp.status_code != 200:
        if _is_content_policy_error(resp.status_code, resp.text):
            err = ContentPolicyError(f"LaoZhang content policy: {resp.text[:300]}")
            err.is_content_policy = True
            raise err
        raise Exception(f"LaoZhang API error {resp.status_code}: {resp.text[:500]}")

    data = resp.json()
    if "content" not in data:
        raise Exception(f"LaoZhang unexpected response: {json.dumps(data)[:500]}")

    text_parts = [b["text"] for b in data["content"] if b.get("type") == "text"]
    return {"text": "\n".join(text_parts), "usage": data.get("usage", {})}


def _call_openrouter(system_text: str, user_parts: list, model: str, max_tokens: int, timeout: int) -> dict:
    """OpenAI Chat Completions API format via OpenRouter. Returns {text, usage}."""
    if not OPENROUTER_API_KEY:
        raise Exception("OPENROUTER_API_KEY not set")

    # Convert Anthropic user_parts to OpenAI content blocks
    openai_content = []
    for block in user_parts:
        if block.get("type") == "text":
            openai_content.append({"type": "text", "text": block["text"]})
        elif block.get("type") == "image":
            src = block.get("source", {})
            media_type = src.get("media_type", "image/webp")
            b64 = src.get("data", "")
            openai_content.append({
                "type": "image_url",
                "image_url": {"url": f"data:{media_type};base64,{b64}"}
            })

    payload = {
        "model": model,
        "max_tokens": max_tokens,
        "messages": [
            {"role": "system", "content": system_text},
            {"role": "user", "content": openai_content},
        ],
    }
    headers = {
        "Authorization": f"Bearer {OPENROUTER_API_KEY}",
        "Content-Type": "application/json",
        "HTTP-Referer": "https://example.com",
        "X-Title": "Video Pipeline",
    }

    proxies = {}
    if OPENROUTER_PROXY and OPENROUTER_PROXY.lower() != "none":
        proxies = {"http": OPENROUTER_PROXY, "https": OPENROUTER_PROXY}

    # Retry with proxy for SSL/connection errors (local DNS intercepts openrouter.ai)
    last_err = None
    for attempt in range(3):
        try:
            resp = requests.post(OPENROUTER_URL, headers=headers, json=payload,
                                 timeout=timeout, proxies=proxies)
            break
        except (requests.exceptions.SSLError, requests.exceptions.ConnectionError) as e:
            last_err = e
            if attempt < 2:
                print(f"  OpenRouter connection error (attempt {attempt + 1}/3): {e}")
                time.sleep(2)
                continue
            raise Exception(f"OpenRouter connection failed after 3 attempts: {e}") from e

    if resp.status_code != 200:
        if _is_content_policy_error(resp.status_code, resp.text):
            err = ContentPolicyError(f"OpenRouter content policy: {resp.text[:300]}")
            err.is_content_policy = True
            raise err
        raise Exception(f"OpenRouter API error {resp.status_code}: {resp.text[:500]}")

    data = resp.json()
    choices = data.get("choices", [])
    if not choices:
        raise Exception(f"OpenRouter empty response: {json.dumps(data)[:500]}")

    text = choices[0].get("message", {}).get("content", "")
    return {"text": text, "usage": data.get("usage", {})}


def _call_glm5(system_text: str, user_parts: list, model: str, max_tokens: int, timeout: int) -> dict:
    """Anthropic Messages API format via z.ai (GLM-5). Returns {text, usage}."""
    if not ZAI_API_KEY:
        raise Exception("ZAI_API_KEY not set")

    system_blocks = [{"type": "text", "text": system_text}]

    payload = {
        "model": model,
        "max_tokens": max_tokens,
        "system": system_blocks,
        "messages": [{"role": "user", "content": user_parts}],
    }
    headers = {
        "x-api-key": ZAI_API_KEY,
        "Content-Type": "application/json",
        "anthropic-version": "2023-06-01",
    }

    resp = requests.post(ZAI_URL, headers=headers, json=payload, timeout=timeout)

    if resp.status_code != 200:
        if _is_content_policy_error(resp.status_code, resp.text):
            err = ContentPolicyError(f"GLM-5 content policy: {resp.text[:300]}")
            err.is_content_policy = True
            raise err
        raise Exception(f"GLM-5 API error {resp.status_code}: {resp.text[:500]}")

    data = resp.json()
    if "content" not in data:
        raise Exception(f"GLM-5 unexpected response: {json.dumps(data)[:500]}")

    text_parts = [b["text"] for b in data["content"] if b.get("type") == "text"]
    return {"text": "\n".join(text_parts), "usage": data.get("usage", {})}


def _log_attempt(output_dir, attempt_num: int, provider: str, status: str, data):
    """Save attempt details to {output_dir}/prompts/attempt_{N}_{provider}.json"""
    if not output_dir:
        return
    prompts_dir = Path(output_dir) / "prompts"
    prompts_dir.mkdir(parents=True, exist_ok=True)

    log_entry = {
        "attempt": attempt_num,
        "provider": provider,
        "status": status,
        "timestamp": datetime.now().isoformat(),
    }
    if status == "success" and isinstance(data, dict):
        log_entry["text"] = data.get("text", "")
        log_entry["usage"] = data.get("usage", {})
    else:
        log_entry["error"] = str(data) if data else None

    filepath = prompts_dir / f"attempt_{attempt_num}_{provider}.json"
    filepath.write_text(json.dumps(log_entry, ensure_ascii=False, indent=2))


def call_llm_with_fallback(
    system_text: str,
    user_parts: list,
    max_tokens: int = 8192,
    timeout: int = 240,
    logger=None,
    output_dir: Path = None,
) -> dict:
    """
    Call LLM with retry chain: LaoZhang(x2) -> OpenRouter(x1) -> GLM-5(x1).
    Content policy errors stop the chain immediately.

    Returns: {"text": str, "usage": dict, "provider": str, "attempt": int}
    Raises: ContentPolicyError or Exception if all attempts fail.
    """
    CHAIN = [
        ("laozhang", _call_laozhang, CLAUDE_MODEL, 2),
        ("openrouter", _call_openrouter, OPENROUTER_MODEL, 1),
        ("glm5", _call_glm5, ZAI_MODEL, 1),
    ]

    if logger:
        text_size = sum(len(b.get("text", "")) for b in user_parts if b.get("type") == "text")
        img_count = sum(1 for b in user_parts if b.get("type") == "image")
        logger.info(f"LLM call: system={len(system_text)} chars, user_text={text_size} chars, "
                    f"images={img_count}, max_tokens={max_tokens}")

    attempt = 0
    errors = []

    for provider_name, call_fn, model, max_retries in CHAIN:
        for retry in range(max_retries):
            attempt += 1
            t0 = time.time()
            try:
                if logger:
                    logger.info(f"Attempt {attempt}: {provider_name} (model={model}, retry={retry+1}/{max_retries})")

                result = call_fn(system_text, user_parts, model, max_tokens, timeout)
                elapsed = time.time() - t0

                # Check for soft content policy refusal
                if _is_content_policy_error(200, "", result.get("text", "")):
                    _log_attempt(output_dir, attempt, provider_name, "content_policy", "Soft refusal detected")
                    raise ContentPolicyError("Soft refusal detected in response")

                result["provider"] = provider_name
                result["attempt"] = attempt

                if logger:
                    usage = result.get("usage", {})
                    logger.info(f"Success via {provider_name} in {elapsed:.1f}s, "
                               f"{len(result.get('text', ''))} chars")
                    logger.info(f"Usage: input={usage.get('input_tokens', '?')}, "
                               f"output={usage.get('output_tokens', '?')}, "
                               f"cache_read={usage.get('cache_read_input_tokens', 0)}, "
                               f"cache_creation={usage.get('cache_creation_input_tokens', 0)}")

                _log_attempt(output_dir, attempt, provider_name, "success", result)
                return result

            except ContentPolicyError:
                raise
            except Exception as e:
                elapsed = time.time() - t0
                error_msg = f"attempt {attempt} ({provider_name}): {e}"
                errors.append(error_msg)
                if logger:
                    logger.warning(f"Failed: {error_msg} ({elapsed:.1f}s)")
                _log_attempt(output_dir, attempt, provider_name, "error", str(e))

                if hasattr(e, 'is_content_policy') and e.is_content_policy:
                    raise ContentPolicyError(str(e))

    raise Exception(f"All {attempt} LLM attempts failed: {'; '.join(errors)}")


def extract_json(text: str) -> Optional[Dict]:
    """Extract JSON from Claude response."""
    try:
        start = text.find('{')
        end = text.rfind('}') + 1
        if start >= 0 and end > start:
            return json.loads(text[start:end])
    except json.JSONDecodeError:
        pass
    return None


# === COMBINED STAGE 2+3: STORY VISION + FRAME DESIGN IN ONE CALL ===

# Static system prompt — identical for ALL products, ALL calls → cached by Anthropic
STAGE23_SYSTEM_TEMPLATE = PROMPTS_DIR / "stages" / "stage23_system.md"

# Cache the system template in memory (loaded once)
_stage23_system_cache = None


def _get_stage23_system() -> Optional[str]:
    """Load and cache the static system template."""
    global _stage23_system_cache
    if _stage23_system_cache is None:
        if not STAGE23_SYSTEM_TEMPLATE.exists():
            return None
        _stage23_system_cache = STAGE23_SYSTEM_TEMPLATE.read_text(encoding='utf-8')
    return _stage23_system_cache


def _build_user_message_parts(
    product_data: Dict,
    all_category_styles: Dict[str, Dict],
    feedback: Optional[Dict],
    image_paths: List[str],
    mode_context: str,
    aspect_ratio: str,
    awesome_prompts: List[Dict] = None,
    num_options: int = 3,
    generation_attempt: int = 1,
    visual_style: str = None,
    video_kind: str = None,
) -> tuple[list, str, list]:
    """
    Build the user message content blocks for combined Stage 2+3.

    Returns (user_content_blocks, user_text_for_logging, style_kind_pairs).
    user_content_blocks is a list ready for call_llm_with_fallback().
    """
    first_generation = feedback is None

    # === Format product data ===
    reviews_text = "No reviews available"
    if product_data.get("reviews"):
        reviews_with_text = [r for r in product_data["reviews"] if r.get("text", "").strip()]
        if reviews_with_text:
            reviews_text = "\n".join([
                f"• {r.get('text', '')}"
                for r in reviews_with_text[:50]
            ])

    specs_text = "Not available"
    if product_data.get("specifications"):
        specs_lines = []
        for spec in product_data["specifications"][:15]:
            name = spec.get("name", "")
            value = spec.get("value", "")
            if name and value:
                specs_lines.append(f"- {name}: {value}")
        if specs_lines:
            specs_text = "\n".join(specs_lines)

    if all_category_styles:
        style_text = "Available categories (choose the best fit):\n\n"
        for cat_name, cat_style in all_category_styles.items():
            style_text += f"**{cat_name}**: {cat_style.get('description', 'No description')}\n"
            style_text += f"  - Recommended archetype: {cat_style.get('archetype', 'any')}\n"
            style_text += f"  - Visual style: {cat_style.get('visual_style', 'standard')}\n\n"
    else:
        style_text = "No category styles available - choose archetype based on product type"

    awesome_text = "No reference prompts available"
    if awesome_prompts:
        awesome_text = "\n\n".join([
            f"**{p.get('title', 'Example')}**\n```\n{p.get('prompt', '')}```"
            for p in awesome_prompts[:3]
        ])

    # === Format feedback / style direction ===
    style_kind_pairs = []

    if feedback:
        all_gens = feedback.get("all_generations", [])
        if all_gens:
            latest = all_gens[0]
            latest_rating = latest.get("rating", "unknown")
            latest_comment = latest.get("comment", "")
            latest_catchy = latest.get("catchy_name", "")

            if latest_rating == "bad":
                feedback_direction = f"**MOST RECENT:** User clicked «🔄 Try different» on «{latest_catchy}»"
                if latest_comment:
                    feedback_direction += f"\n**User's request:** {latest_comment}"
                feedback_direction += "\n\n→ Create something DIFFERENT from recent attempts. Try a new approach based on their request."
            elif latest_rating == "good":
                feedback_direction = f"**MOST RECENT:** User clicked «✏️ Поправить» on «{latest_catchy}»"
                if latest_comment:
                    feedback_direction += f"\n**User's feedback:** {latest_comment}"
                feedback_direction += f"\n\n→ KEEP the same style ({latest.get('visual_style', '?')}) and kind ({latest.get('video_kind', '?')}). Use a DIFFERENT emotional tone from «{latest_catchy}». Apply their suggested improvements."
            else:
                feedback_direction = "Previous generations available (see list below)"
        else:
            feedback_direction = "No previous generations"

        prev_gens_lines = []
        if all_gens:
            prev_gens_lines.append("**Previous generations (user can reference ANY of these):**")
            prev_gens_lines.append("User directions: 🔍 Dig in = refine | 🔄 Try different = explore\n")
            for gen in all_gens:
                rating = gen.get("rating", "unknown")
                direction = "🔍 Dig in" if rating == "good" else "🔄 Try different" if rating == "bad" else "⚠️"
                catchy = gen.get("catchy_name", "")
                if catchy:
                    prev_gens_lines.append(f"\n- **«{catchy}»** [{direction}] (task: {gen.get('task_id', '?')})")
                else:
                    prev_gens_lines.append(f"\n- **Task: {gen.get('task_id', 'unknown')}** [{direction}]")
                if gen.get("visual_style") or gen.get("video_kind"):
                    prev_gens_lines.append(f"  Style: **{gen.get('visual_style', '?')}** | Kind: **{gen.get('video_kind', '?')}**")
                if gen.get("archetype"):
                    prev_gens_lines.append(f"  Archetype: {gen.get('archetype')} | Category: {gen.get('category', '?')}")
                if gen.get("act1_scene"):
                    prev_gens_lines.append(f"  Act 1: {gen.get('act1_scene')}")
                if gen.get("act2_scene"):
                    prev_gens_lines.append(f"  Act 2: {gen.get('act2_scene')}")
                if gen.get("comment"):
                    prev_gens_lines.append(f"  User feedback: \"{gen.get('comment')}\"")
        previous_generations = "\n".join(prev_gens_lines) if prev_gens_lines else "No previous generations available."

        # Feedback mode: generate 1 vision based on feedback
        creative_section = f"""## Mode: Feedback Refinement

Generate exactly 1 story vision based on the user feedback below. Then generate frame prompts.

## Previous User Feedback

{feedback_direction}

## Previous Generations

{previous_generations}"""

        # If specific style/kind given by feedback (dig-in), mention it
        if visual_style and video_kind:
            creative_section += f"\n\n**Use style:** {visual_style} + {video_kind} (from feedback)"
            style_kind_pairs = [(visual_style, video_kind)]

    else:
        # First generation: multi-option self-critic mode
        style_kind_pairs = select_multiple_styles(
            count=num_options,
            photorealistic_only=first_generation or generation_attempt <= 2
        )

        style_blocks_parts = []
        for i, (style_key, kind_key) in enumerate(style_kind_pairs, 1):
            sc = get_style_config(style_key)
            kc = get_kind_config(kind_key)
            print(f"  Option {i}: {sc['name']} + {kc['name']}")

            block = f"### Option {i}: {style_key} + {kind_key}\n\n"
            style_context = get_style_prompt_context(style_key, kind_key)
            style_context = style_context.replace("## Creative Direction\n\n", "")
            block += style_context
            style_blocks_parts.append(block)

        style_blocks = "\n\n".join(style_blocks_parts)

        creative_section = f"""## Mode: First Generation (Multi-Option Self-Critic)

Generate {num_options} DIFFERENT story visions, one for each style/kind combination below.
Each must be a complete, independent concept with a different archetype and scene approach.

**Your task:**
1. Identify the product correctly (category, function, who uses it)
2. Generate {num_options} different story visions — one per style/kind option
3. Self-evaluate on: Veo-3 Feasibility (1-5), Product Appeal (1-5), Style Fit (1-5), Originality (1-5)
4. Select the BEST option. If needed, improve it. Output ONLY the final polished vision.

{style_blocks}

## Previous User Feedback

This is the first generation for this product.

## Previous Generations

This is the first generation for this product."""

    # === Build user text ===
    product_id = product_data.get("product_id", "unknown")
    user_text = f"""## Product Information

**Name:** {product_data.get("name", "Unknown Product")}
**Brand:** {product_data.get("brand", "")}
**Description:** {product_data.get("description", "")[:1500]}
**Price:** {product_data.get("price", "")}

**Product Specifications:**
{specs_text}

**Video Format:** {mode_context}
**Aspect Ratio:** {aspect_ratio}
**Product ID:** {product_id}

**Customer Reviews (Russian):**

{reviews_text}

## Category Style Guide

{style_text}

{creative_section}

## Reference Prompts (for frame design style inspiration)

{awesome_text}

## Product Images

{len(image_paths)} product images are provided above (as inline images). Analyze them carefully for:
- Product appearance, colors, materials, size
- Brand labels and text (preserve exactly)
- Key features visible

---

Now follow the system instructions: output PART 1 (Story Vision JSON) then PART 2 (FRAME ACT 1 + FRAME ACT 2)."""

    # === Build content blocks: images + text ===
    user_content = []

    for img_path in image_paths:
        try:
            b64_data, media_type = encode_image_base64(img_path)
            user_content.append({
                "type": "image",
                "source": {
                    "type": "base64",
                    "media_type": media_type,
                    "data": b64_data,
                }
            })
        except Exception as e:
            print(f"  Warning: Failed to encode image {img_path}: {e}")

    user_content.append({"type": "text", "text": user_text})

    return user_content, user_text, style_kind_pairs


def stage2_and_stage3_combined(
    product_data: Dict,
    all_category_styles: Dict[str, Dict],
    feedback: Optional[Dict],
    image_paths: List[str],
    mode_context: str = "",
    num_options: int = 3,
    generation_attempt: int = 1,
    awesome_prompts: List[Dict] = None,
    aspect_ratio: str = "3:4",
    visual_style: str = None,
    video_kind: str = None,
    logger=None,
    output_dir: Path = None
) -> tuple[Optional[Dict], Optional[Dict], Optional[str], Optional[str]]:
    """
    Combined Stage 2+3: Single API call generates story vision + frame prompts.

    Uses direct LaoZhang API with inline base64 images (no CLI, no Read tool).
    System prompt is static (~15KB) and cached by Anthropic (90% discount on repeat calls).
    Works for both first-generation (multi-option self-critic) and feedback modes.

    Returns tuple of (story_vision, frame_prompts, user_prompt_text, None)
    """
    mode = "feedback" if feedback else f"{num_options}-option self-critic"
    print(f"\n[Stage 2+3 Combined] {mode} + frame design in one API call...")

    # Load static system prompt (cached in memory + cached by Anthropic API)
    system_prompt = _get_stage23_system()
    if not system_prompt:
        print("  Error: System template not found, falling back")
        return None, None, None, None

    # Build user message
    user_content, user_text, style_kind_pairs = _build_user_message_parts(
        product_data=product_data,
        all_category_styles=all_category_styles,
        feedback=feedback,
        image_paths=image_paths,
        mode_context=mode_context,
        aspect_ratio=aspect_ratio,
        awesome_prompts=awesome_prompts,
        num_options=num_options,
        generation_attempt=generation_attempt,
        visual_style=visual_style,
        video_kind=video_kind,
    )

    # Save prompts for debugging (same file structure)
    prompts_dir = None
    if output_dir:
        prompts_dir = output_dir / "prompts"
        prompts_dir.mkdir(exist_ok=True)

        # Save user text as stage2 prompt (system template is static, no need to save every time)
        stage2_filename = "stage2_story_vision_multi.txt" if not feedback else "stage2_story_vision.txt"
        with open(prompts_dir / stage2_filename, 'w', encoding='utf-8') as f:
            f.write(f"# System prompt: {len(system_prompt)} chars (cached, see prompts/stages/stage23_system.md)\n\n")
            f.write(user_text)

        if logger:
            logger.info(f"Combined prompt saved to: {prompts_dir / stage2_filename}")
            logger.info(f"System prompt: {len(system_prompt)} chars (cached)")
            logger.info(f"User message: {len(user_text)} chars")

    # Make API call with fallback chain: LaoZhang(x2) → OpenRouter(x1) → GLM-5(x1)
    try:
        result = call_llm_with_fallback(
            system_text=system_prompt,
            user_parts=user_content,
            max_tokens=8192,
            timeout=300,
            logger=logger,
            output_dir=output_dir,
        )
        response = result["text"]
        provider = result["provider"]
        llm_provider = provider
        if logger:
            logger.info(f"Stage 2+3 completed via {provider} (attempt {result['attempt']})")
    except ContentPolicyError as e:
        if logger:
            logger.error(f"Content policy error: {e}")
        return None, None, user_text, None
    except Exception as e:
        if logger:
            logger.error(f"All LLM attempts failed: {e}")
        return None, None, user_text, None

    if not response:
        if logger:
            logger.error("LLM returned empty response")
        return None, None, user_text, None

    # === Parse response ===

    # 1. Extract story vision JSON
    story_vision = extract_json(response)

    if story_vision:
        if "visual_style" not in story_vision or "video_kind" not in story_vision:
            print("  Warning: Vision missing style/kind, using fallback values")
            if not story_vision.get("visual_style") and style_kind_pairs:
                story_vision["visual_style"] = style_kind_pairs[0][0]
            if not story_vision.get("video_kind") and style_kind_pairs:
                story_vision["video_kind"] = style_kind_pairs[0][1]

        print(f"  Vision: {story_vision.get('archetype', '?')} | {story_vision.get('visual_style', '?')} + {story_vision.get('video_kind', '?')}")
        print(f"  Category: {story_vision.get('category', '?')}")
        print(f"  Name: {story_vision.get('catchy_name', '?')}")
    else:
        if logger:
            logger.error("Failed to extract story vision JSON from combined response")
            logger.error(f"Response preview: {response[:1000]}")
        return None, None, user_text, None

    # 2. Extract frame prompts (FRAME ACT 1 / FRAME ACT 2)
    frame_prompts = None
    act1_match = response.find("FRAME ACT 1:")
    act2_match = response.find("FRAME ACT 2:")

    if act1_match >= 0 and act2_match >= 0:
        act1_text = response[act1_match + 13:act2_match].strip()
        act2_text = response[act2_match + 13:].strip()

        # Clean trailing ``` or markdown
        for marker in ["```", "---"]:
            if marker in act2_text:
                act2_text = act2_text[:act2_text.find(marker)].strip()

        frame_prompts = {
            "frame_act1": act1_text.strip(),
            "frame_act2": act2_text.strip(),
        }
        print(f"  Frame Act 1: {len(frame_prompts['frame_act1'])} chars")
        print(f"  Frame Act 2: {len(frame_prompts['frame_act2'])} chars")
    else:
        if logger:
            logger.warning("Could not find FRAME ACT markers in combined response")
            logger.warning(f"Response tail: {response[-500:]}")

    # Save stage3-equivalent prompt for debugging
    if prompts_dir and story_vision:
        stage3_equivalent = f"""# Stage 3: Frame Design (combined with Stage 2)

## Story Vision

{json.dumps(story_vision, indent=2, ensure_ascii=False)}

## Product Images
{chr(10).join(f'- {p}' for p in image_paths)}

## Note
Frame design was generated in combined Stage 2+3 API call.
System prompt: prompts/stages/stage23_system.md ({len(system_prompt)} chars, cached)
"""
        with open(prompts_dir / "stage3_frame_design.txt", 'w', encoding='utf-8') as f:
            f.write(stage3_equivalent)

    return story_vision, frame_prompts, user_text, llm_provider


# === STAGE 4: VIDEO PROMPT ===

def stage4_video_prompt(
    story_vision: Dict,
    frame_prompts: Dict,
    negatives: Dict,
    feedback: Optional[Dict],
    output_dir: Path = None,
    logger=None
) -> Optional[str]:
    """
    Stage 4: Assemble final Veo3 prompt.

    Returns the complete video prompt string.
    """
    print("\n[Stage 4] Assembling video prompt...")

    # Build negative prompts
    has_children = story_vision.get("has_children", False)

    # When has_children, remove blanket children ban from always negatives
    always_negs = negatives.get("always", [])
    if has_children:
        children_terms = {"no children", "no babies", "no toddlers", "no minors", "no kids"}
        always_negs = [n for n in always_negs if n not in children_terms]

    neg_always = " ".join([f"--{n}" for n in always_negs])
    neg_anatomical = " ".join([f"--{n}" for n in negatives.get("anatomical", [])])
    neg_scene = " ".join([f"--{n}" for n in negatives.get("scene", [])])

    # Category-specific negatives
    category = story_vision.get("category", "")
    neg_category = ""
    if category in negatives.get("by_product_type", {}):
        neg_category = " ".join([
            f"--{n}" for n in negatives["by_product_type"][category]
        ])

    # Feedback-derived negatives
    neg_feedback = ""
    if feedback and feedback.get("issues"):
        # Extract key issues for negatives
        neg_feedback = " ".join([
            f"--avoid: {issue[:50]}" for issue in feedback["issues"][:2]
        ])

    all_negatives = f"{neg_always} {neg_anatomical} {neg_scene} {neg_category} {neg_feedback}".strip()

    # Append kids-specific negatives when children are in scenes
    if has_children:
        kids_negs = negatives.get("kids", [])
        neg_kids = " ".join(f"--{n}" for n in kids_negs)
        all_negatives = f"{all_negatives} {neg_kids}".strip()

    # Build video prompt
    act1 = story_vision.get("act1", {})
    act2 = story_vision.get("act2", {})
    transition = story_vision.get("transition", "gentle cross-dissolve")

    duration1 = act1.get("duration_sec", 3)
    duration2 = act2.get("duration_sec", 5)

    veo3_prompt = f"""ACT 1 ({duration1} sec): {act1.get('scene', '')}. {act1.get('emotion', '')}. Duration: {duration1} seconds.

TRANSITION: {transition}.

ACT 2 ({duration2} sec): {act2.get('scene', '')}. {act2.get('product_usage', '')}. {act2.get('emotion', '')}. Duration: {duration2} seconds.

{all_negatives}"""

    print(f"  Prompt length: {len(veo3_prompt)} chars")
    print(f"  Transition: {transition}")

    # Save Veo3 prompt to file
    if output_dir and logger:
        prompts_dir = output_dir / "prompts"
        prompts_dir.mkdir(exist_ok=True)
        veo3_file = prompts_dir / "stage4_veo3_prompt.txt"
        with open(veo3_file, 'w', encoding='utf-8') as f:
            f.write(veo3_prompt)
        logger.info(f"Veo3 prompt saved to: {veo3_file}")

    return veo3_prompt


# === MAIN PIPELINE ===

def run_pipeline_v2(
    product_url: str,
    laozhang_key: str,
    output_base: str = "/mnt/video",
    task_id: str = None,
    image_nums: List[int] = None,
    mode: str = DEFAULT_MODE,
    visual_style: str = None,
    video_kind: str = None,
    pollo_key: str = None
) -> Dict:
    """
    Run the V2 multi-stage pipeline.

    Returns metadata dict with all results.
    """
    if image_nums is None:
        image_nums = [1, 2, 3, 4, 5, 6, 7]

    if not task_id:
        task_id = generate_task_id()

    # Get mode configuration
    mode_config = get_mode_config(mode)

    # Initialize logging
    build_info = get_build_info()
    log = {
        "task_id": task_id,
        "product_url": product_url,
        "started_at": datetime.now().isoformat(),
        "build_info": build_info,
        "mode": mode,
        "mode_config": mode_config,
        "stages": {},
        "errors": []
    }

    # Detect URL source and extract product ID
    url_source, product_id = detect_url_source(product_url)
    if not url_source or not product_id:
        # Fallback to WB-only extraction for backwards compatibility
        product_id = extract_product_id(product_url)
        url_source = "wb" if product_id else None

    if not product_id:
        log["errors"].append("Could not extract product ID from URL")
        return log

    log["product_id"] = product_id
    log["url_source"] = url_source

    # Create output directory
    output_dir = create_output_directory(output_base, product_id, task_id, source=url_source)
    log["output_dir"] = str(output_dir)

    # Set up real-time logging to task file
    logger = setup_task_logging(output_dir, task_id)

    logger.info("="*60)
    logger.info(f"Pipeline V2 - Product: {product_id}")
    logger.info(f"Task ID: {task_id}")
    logger.info(f"Mode: {mode_config['name']} ({mode_config['aspect_ratio']})")
    logger.info(f"Source: {build_info['source']} | Commit: {build_info['git_commit']} | Dirty: {build_info['git_dirty']}")
    logger.info("="*60)

    # === STAGE 1: DATA EXTRACTION ===
    logger.info("")
    logger.info("[Stage 1] Extracting product data...")
    logger.info(f"  URL source: {url_source}")
    log["stages"]["stage1"] = {"started_at": datetime.now().isoformat()}

    product_data = None
    data_source = "none"

    # Avito URL - use Avito fetcher
    if url_source == "avito" and HAS_AVITO_FETCHER:
        try:
            logger.info("  Fetching from Avito...")
            logger.info(f"  HAS_AVITO_FETCHER: {HAS_AVITO_FETCHER}")
            logger.info(f"  fetch_avito_product function: {fetch_avito_product}")
            product_data = fetch_avito_product(product_url, headless=True)
            logger.info(f"  fetch_avito_product returned: {type(product_data)}")
            if product_data:
                data_source = "avito"
                log["stages"]["stage1"]["has_description"] = bool(product_data.get("description"))
                logger.info(f"  Name: {product_data.get('name', 'Unknown')}")
                logger.info(f"  Price: {product_data.get('price', 'N/A')}")
                logger.info(f"  Seller: {product_data.get('seller', 'N/A')}")
            else:
                logger.warning("  Avito fetch returned None - check avito_fetcher logs")
        except Exception as e:
            logger.error(f"  Avito fetch exception: {e}")
            import traceback
            logger.error(traceback.format_exc())

    # Ozon URL - try Sellematics tunnel first, then public API fallback
    elif url_source == "ozon":
        if HAS_SELLEMATICS and is_sellematics_available():
            try:
                logger.info("  Fetching from Ozon via Sellematics...")
                product_data = fetch_ozon_product_sellematics(product_url)
                if product_data:
                    data_source = "sellematics"
                    log["stages"]["stage1"]["reviews_count"] = product_data.get("reviews_count", 0)
                    log["stages"]["stage1"]["has_description"] = bool(product_data.get("description"))
                    log["stages"]["stage1"]["rating"] = product_data.get("rating")
                    log["stages"]["stage1"]["specifications_count"] = len(product_data.get("specifications", []))
                    logger.info(f"  Name: {product_data.get('name', 'Unknown')}")
                    logger.info(f"  Rating: {product_data.get('rating')} ({product_data.get('rating_count', 0)} ratings)")
                    logger.info(f"  Reviews count: {product_data.get('reviews_count', 0)}")
            except Exception as e:
                logger.error(f"  Ozon Sellematics fetch failed: {e}")
        # Fallback: public Sellematics API (no tunnel needed, no reviews)
        if not product_data and HAS_SELLEMATICS:
            try:
                logger.info("  Trying Ozon public API fallback...")
                product_data = fetch_ozon_product_public(product_url)
                if product_data:
                    data_source = "sellematics_public"
                    log["stages"]["stage1"]["has_description"] = bool(product_data.get("description"))
                    log["stages"]["stage1"]["specifications_count"] = len(product_data.get("specifications", []))
                    logger.info(f"  Name: {product_data.get('name', 'Unknown')}")
                    logger.info(f"  (Public API - no reviews/rating)")
            except Exception as e:
                logger.error(f"  Ozon public API fallback failed: {e}")
        if not product_data:
            log["errors"].append("Failed to fetch Ozon product data")
            ThreadLocalTeeStdout.unregister()
            return log

    # Wildberries URL - try Sellematics API first (richer data)
    if url_source == "wb" and not product_data:
        if HAS_SELLEMATICS and is_sellematics_available():
            try:
                print("  Trying Sellematics API...")
                product_data = fetch_wb_product_sellematics(product_url)
                if product_data:
                    data_source = "sellematics"
                    log["stages"]["stage1"]["reviews_count"] = product_data.get("reviews_count", 0)
                    log["stages"]["stage1"]["has_description"] = bool(product_data.get("description"))
                    log["stages"]["stage1"]["rating"] = product_data.get("rating")
                    log["stages"]["stage1"]["specifications_count"] = len(product_data.get("specifications", []))
                    print(f"  Name: {product_data.get('name', 'Unknown')}")
                    print(f"  Rating: {product_data.get('rating')} ({product_data.get('rating_count', 0)} ratings)")
                    print(f"  Reviews count: {product_data.get('reviews_count', 0)}")
                    print(f"  Specifications: {len(product_data.get('specifications', []))}")
            except Exception as e:
                print(f"  Sellematics fetch failed: {e}")

        # Fallback to enhanced Playwright fetcher
        if not product_data and HAS_ENHANCED_FETCHER:
            try:
                print("  Trying Playwright fetcher...")
                product_data = fetch_wb_product_enhanced(product_url, headless=True)
                if product_data:
                    data_source = "playwright"
                    log["stages"]["stage1"]["reviews_count"] = len(product_data.get("reviews", []))
                    log["stages"]["stage1"]["has_description"] = bool(product_data.get("description"))
                    print(f"  Name: {product_data.get('name', 'Unknown')}")
                    print(f"  Reviews: {len(product_data.get('reviews', []))}")
            except Exception as e:
                print(f"  Enhanced fetch failed: {e}")

        # Last resort: basic fetcher
        if not product_data:
            try:
                from wb_fetcher import fetch_wb_product_playwright
                product_data = fetch_wb_product_playwright(product_url, headless=True)
                data_source = "basic"
            except Exception as e:
                print(f"  Basic fetch failed: {e}")

    log["stages"]["stage1"]["data_source"] = data_source

    if not product_data:
        product_data = {"name": f"Product {product_id}"}

    # Ensure product_id is always in product_data
    product_data["product_id"] = product_id

    log["stages"]["stage1"]["product_name"] = product_data.get("name", "Unknown")
    # Save full description and reviews for debugging
    log["stages"]["stage1"]["description"] = product_data.get("description", "")
    log["stages"]["stage1"]["reviews"] = product_data.get("reviews", [])

    # Get images (Avito provides images directly, WB can construct URLs)
    image_urls = product_data.get("images")
    if not image_urls and url_source == "wb":
        image_urls = construct_image_urls(product_id)
    if not image_urls:
        image_urls = []
    print(f"  Images: {len(image_urls)}")

    # Download images (smart filtering for Avito)
    images_to_download = [image_urls[i-1] for i in image_nums if i <= len(image_urls)]
    is_avito = url_source == "avito"

    downloaded_paths = download_images_smart(
        images_to_download,
        output_dir / "images",
        is_avito=is_avito
    )

    if not downloaded_paths:
        log["errors"].append("No images downloaded")
        ThreadLocalTeeStdout.unregister()
        return log

    log["stages"]["stage1"]["images_downloaded"] = len(downloaded_paths)
    log["stages"]["stage1"]["completed_at"] = datetime.now().isoformat()

    # Load feedback for this product
    prefix = get_directory_prefix(url_source)
    product_dir = Path(output_base) / f"{prefix}_{product_id}"
    feedback = load_product_feedback(str(product_dir))
    if feedback:
        print(f"  Previous feedback: {feedback.get('success_rate', 0)*100:.0f}% success")

    # === STYLE/KIND SELECTION & STAGE 2+3: STORY VISION + FRAME DESIGN ===
    first_generation = feedback is None
    style_reason = ""

    # Load all category styles - let Claude choose the best one
    all_category_styles = load_all_category_styles()
    logger.info(f"Loaded {len(all_category_styles)} category styles")

    # Determine generation attempt number from feedback
    generation_attempt = 1
    if feedback:
        total_gens = feedback.get("total_generations", 0)
        generation_attempt = total_gens + 1

    logger.info("")
    logger.info("[Stage 2+3] Story Vision + Frame Design in one API call")
    log["stages"]["stage2"] = {"started_at": datetime.now().isoformat()}
    log["combined_stage"] = True

    # Single code path: combined Stage 2+3 with LLM fallback chain
    num_options = CRITIC_NUM_OPTIONS if first_generation and USE_CRITIC_MODE else 1

    story_vision, combined_frame_prompts, stage2_prompt, llm_provider = stage2_and_stage3_combined(
        product_data=product_data,
        all_category_styles=all_category_styles,
        feedback=feedback,
        image_paths=downloaded_paths,
        mode_context=mode_config["context_hint"],
        num_options=num_options,
        generation_attempt=generation_attempt,
        aspect_ratio=mode_config["aspect_ratio"],
        visual_style=visual_style,
        video_kind=video_kind,
        logger=logger,
        output_dir=output_dir,
    )

    log["stages"]["stage2"]["prompt_sent_to_claude"] = stage2_prompt
    if llm_provider:
        log["stages"]["stage2"]["llm_provider"] = llm_provider

    if story_vision:
        visual_style = story_vision.get("visual_style", visual_style or "cinematic")
        video_kind = story_vision.get("video_kind", video_kind or "standard")
        if first_generation:
            style_reason = f"Self-critic selected from {num_options} options (1 API call)"
        else:
            style_reason = "Claude selected based on feedback (1 API call)"
    else:
        visual_style = visual_style or "cinematic"
        video_kind = video_kind or "standard"
        style_reason = "Fallback (LLM call failed)"

    # Get style/kind configs for logging
    style_config = get_style_config(visual_style)
    kind_config = get_kind_config(video_kind)

    print(f"\n[Style Selection]")
    print(f"  Visual Style: {get_style_emoji(visual_style)} {style_config['name']}")
    print(f"  Video Kind: {get_kind_emoji(video_kind)} {kind_config['name']}")
    print(f"  Reason: {style_reason}")

    log["visual_style"] = visual_style
    log["video_kind"] = video_kind
    log["style_selection_reason"] = style_reason

    if not story_vision:
        if not ALLOW_CLAUDE_FALLBACK:
            # FAIL FAST: Don't waste expensive NanoBanana/Veo3 calls on generic prompts
            log["stages"]["stage2"]["failed"] = True
            log["stages"]["stage2"]["completed_at"] = datetime.now().isoformat()
            error_msg = "Stage 2 failed: Claude could not generate story vision (check API quota/connectivity)"
            log["errors"].append(error_msg)
            logger.error(f"ERROR: {error_msg}")
            logger.info("Aborting pipeline to avoid wasting API costs on generic prompts")
            log["completed_at"] = datetime.now().isoformat()
            ThreadLocalTeeStdout.unregister()
            return log

        # Fallback to simple vision (only if ALLOW_CLAUDE_FALLBACK=1)
        logger.warning("WARNING: Using fallback story vision (ALLOW_CLAUDE_FALLBACK=1)")
        story_vision = {
            "category": "general",
            "archetype": "product_lifestyle",
            "visual_style": visual_style,
            "video_kind": video_kind,
            "act1": {
                "scene": "Product hero shot on elegant surface",
                "emotion": "desire, anticipation",
                "product_visible": True,
                "duration_sec": 3
            },
            "act2": {
                "scene": "Person enjoying the product",
                "emotion": "satisfied, content",
                "product_usage": "product in use",
                "duration_sec": 5
            },
            "transition": "gentle cross-dissolve"
        }
        log["stages"]["stage2"]["fallback"] = True

    log["stages"]["stage2"]["category_detected"] = story_vision.get("category", "unknown")
    log["stages"]["stage2"]["story_vision"] = story_vision
    log["stages"]["stage2"]["completed_at"] = datetime.now().isoformat()

    # Detect kids mode from story vision
    has_children = story_vision.get("has_children", False)
    if has_children:
        print("  [Kids mode] Children detected in story vision — will use Kling two-clip pipeline")
        log["has_children"] = True
    else:
        log["has_children"] = False

    # === STAGE 3: FRAME DESIGN (already done in combined Stage 2+3 call) ===
    logger.info("")
    log["stages"]["stage3"] = {"started_at": datetime.now().isoformat()}
    logger.info("[Stage 3] Frame Design - already done in combined call")
    frame_prompts = combined_frame_prompts
    stage3_prompt = "(included in combined Stage 2+3 call)"

    log["stages"]["stage3"]["prompt_sent_to_claude"] = stage3_prompt

    if not frame_prompts:
        if not ALLOW_CLAUDE_FALLBACK:
            # FAIL FAST: Don't waste expensive NanoBanana/Veo3 calls on generic prompts
            log["stages"]["stage3"]["failed"] = True
            log["stages"]["stage3"]["completed_at"] = datetime.now().isoformat()
            # Try to get Claude's actual response for better error message
            claude_reason = ""
            stage3_log = output_dir / "prompts" / "stage3_frame_design_response.txt" if output_dir else None
            if stage3_log and stage3_log.exists():
                claude_reason = stage3_log.read_text(encoding='utf-8').strip()[:200]
            if not claude_reason:
                # Check pipeline log for "Response was:" line
                pipeline_log = output_dir / "pipeline.log" if output_dir else None
                if pipeline_log and pipeline_log.exists():
                    for line in reversed(pipeline_log.read_text(encoding='utf-8').splitlines()[-20:]):
                        if "Response was:" in line:
                            claude_reason = line.split("Response was:", 1)[1].strip()[:200]
                            break
            if claude_reason:
                error_msg = f"Stage 3 failed: {claude_reason}"
            else:
                error_msg = "Stage 3 failed: Claude could not generate frame prompts (check API quota/connectivity)"
            log["errors"].append(error_msg)
            logger.error(f"ERROR: {error_msg}")
            logger.info("Aborting pipeline to avoid wasting API costs")
            log["completed_at"] = datetime.now().isoformat()
            ThreadLocalTeeStdout.unregister()
            return log

        # Fallback prompts (only if ALLOW_CLAUDE_FALLBACK=1)
        print("  WARNING: Using fallback frame prompts (ALLOW_CLAUDE_FALLBACK=1)")
        frame_prompts = {
            "frame_act1": f"A beautiful {product_data.get('name', 'product')} resting firmly on elegant surface. Soft natural lighting. Product is centered. 16:9 aspect ratio, photorealistic, keep product labels exactly as original",
            "frame_act2": f"A person enjoying the {product_data.get('name', 'product')}. Natural warm lighting, subtle satisfied expression. Lifestyle moment. 16:9 aspect ratio, photorealistic, keep product labels exactly as original"
        }
        log["stages"]["stage3"]["fallback"] = True

    log["stages"]["stage3"]["frame_prompts"] = frame_prompts
    log["stages"]["stage3"]["completed_at"] = datetime.now().isoformat()

    # === STAGE 4: VIDEO PROMPT ===
    logger.info("")
    logger.info("[Stage 4] Video Prompt - Assembling Veo3 prompt")
    log["stages"]["stage4"] = {"started_at": datetime.now().isoformat()}

    negatives = load_negative_prompts()
    veo3_prompt = stage4_video_prompt(story_vision, frame_prompts, negatives, feedback, output_dir=output_dir, logger=logger)

    if not veo3_prompt:
        veo3_prompt = "ACT 1 (3 sec): Product hero shot. TRANSITION: gentle cross-dissolve. ACT 2 (5 sec): Person using product. --no text --no watermark"

    log["stages"]["stage4"]["veo3_prompt"] = veo3_prompt
    log["stages"]["stage4"]["completed_at"] = datetime.now().isoformat()

    # === GENERATION: NANOBANANA ===
    logger.info("")
    logger.info("[Generation] Creating frames with NanoBanana...")

    source_image_path = downloaded_paths[0]
    with open(source_image_path, 'rb') as f:
        source_image_data = f.read()

    act1_path = None
    act2_path = None
    act1_data = None
    act1_mime = None

    # ACT 1
    try:
        print(f"  Generating ACT 1 frame ({mode_config['aspect_ratio']})...")
        act1_data, act1_mime = call_nanobanana_laozhang(
            image_data=source_image_data,
            prompt=frame_prompts["frame_act1"],
            api_key=laozhang_key,
            mime_type=get_mime_type(source_image_path),
            aspect_ratio=mode_config["aspect_ratio"]
        )
        ext = ".jpg" if "jpeg" in act1_mime else ".png"

        # Save original NanoBanana output
        act1_original_path = output_dir / "processed" / f"frame_act1_original{ext}"
        with open(act1_original_path, 'wb') as f:
            f.write(act1_data)
        print(f"  Saved original: {act1_original_path.name}")

        # Crop to exact aspect ratio (fixes black ribbon in Veo3)
        act1_cropped = crop_frame_to_aspect_ratio(act1_data, mode_config["aspect_ratio"])
        act1_path = output_dir / "processed" / "frame_act1.jpg"
        with open(act1_path, 'wb') as f:
            f.write(act1_cropped)
        print(f"  Saved cropped: {act1_path.name}")
    except Exception as e:
        log["errors"].append(f"NanoBanana ACT 1 failed: {e}")
        print(f"  Error: {e}")
        # NanoBanana via OpenRouter — fallback for any error (LaoZhang adds own content filter)
        try:
            print(f"  [Fallback] Trying NanoBanana via OpenRouter for ACT 1...")
            act1_data, act1_mime = call_nanobanana_openrouter(
                image_data=source_image_data,
                prompt=frame_prompts["frame_act1"],
                mime_type=get_mime_type(source_image_path),
                aspect_ratio=mode_config["aspect_ratio"]
            )
            act1_original_path = output_dir / "processed" / "frame_act1_original.jpg"
            with open(act1_original_path, 'wb') as f:
                f.write(act1_data)
            act1_cropped = crop_frame_to_aspect_ratio(act1_data, mode_config["aspect_ratio"])
            act1_path = output_dir / "processed" / "frame_act1.jpg"
            with open(act1_path, 'wb') as f:
                f.write(act1_cropped)
            log["stages"]["stage3"]["act1_provider"] = "openrouter"
            print(f"  OpenRouter NanoBanana ACT 1 OK ({len(act1_data)//1024}KB)")
        except Exception as e_or:
            log["errors"].append(f"OpenRouter NanoBanana ACT 1 also failed: {e_or}")
            print(f"  OpenRouter NanoBanana also failed: {e_or}")

        # SeeDream fallback — last resort
        if not act1_path:
            try:
                print(f"  [Fallback] Trying SeeDream for ACT 1...")
                source_url = path_to_public_url(str(source_image_path))
                act1_data, act1_mime = call_seedream_laozhang(
                    prompt=frame_prompts["frame_act1"],
                    api_key=laozhang_key,
                    aspect_ratio=mode_config["aspect_ratio"],
                    image_url=source_url,
                )
                act1_original_path = output_dir / "processed" / "frame_act1_original.jpg"
                with open(act1_original_path, 'wb') as f:
                    f.write(act1_data)
                act1_cropped = crop_frame_to_aspect_ratio(act1_data, mode_config["aspect_ratio"])
                act1_path = output_dir / "processed" / "frame_act1.jpg"
                with open(act1_path, 'wb') as f:
                    f.write(act1_cropped)
                log["stages"]["stage3"]["act1_provider"] = "seedream"
                print(f"  SeeDream ACT 1 OK ({len(act1_data)//1024}KB)")
            except Exception as e2:
                log["errors"].append(f"SeeDream ACT 1 also failed: {e2}")
                print(f"  SeeDream fallback also failed: {e2}")

    # ACT 2
    # Check if we need scene consistency (same person, same room, etc.)
    scene_consistency = story_vision.get("scene_consistency", {})
    use_reference = bool(scene_consistency.get("needed", False) and act1_path and act1_data)

    # Get consistency description - support both new format (description) and old format (maintain list)
    consistency_description = scene_consistency.get("description", "")
    if not consistency_description and scene_consistency.get("maintain"):
        # Convert old maintain list to description
        maintain_list = scene_consistency.get("maintain", [])
        consistency_description = f"Keep these elements identical to the reference image: {', '.join(maintain_list)}"

    # Build Act 2 prompt - prepend consistency instruction if using reference
    act2_prompt = frame_prompts["frame_act2"]
    if use_reference and consistency_description:
        act2_prompt = f"IMPORTANT - Use the reference image to maintain consistency: {consistency_description}\n\n{act2_prompt}"

    try:
        print(f"  Generating ACT 2 frame ({mode_config['aspect_ratio']})...")

        if use_reference:
            print(f"  Using Act 1 as reference for scene consistency")
            print(f"  Consistency: {consistency_description[:100]}...")

        act2_data, act2_mime = call_nanobanana_laozhang(
            image_data=source_image_data,
            prompt=act2_prompt,
            api_key=laozhang_key,
            mime_type=get_mime_type(source_image_path),
            aspect_ratio=mode_config["aspect_ratio"],
            reference_image_data=act1_data if use_reference else None,
            reference_mime_type=act1_mime if use_reference else None
        )
        ext = ".jpg" if "jpeg" in act2_mime else ".png"

        # Save original NanoBanana output
        act2_original_path = output_dir / "processed" / f"frame_act2_original{ext}"
        with open(act2_original_path, 'wb') as f:
            f.write(act2_data)
        print(f"  Saved original: {act2_original_path.name}")

        # Crop to exact aspect ratio (fixes black ribbon in Veo3)
        act2_cropped = crop_frame_to_aspect_ratio(act2_data, mode_config["aspect_ratio"])
        act2_path = output_dir / "processed" / "frame_act2.jpg"
        with open(act2_path, 'wb') as f:
            f.write(act2_cropped)
        print(f"  Saved cropped: {act2_path.name}")
    except Exception as e:
        log["errors"].append(f"NanoBanana ACT 2 failed: {e}")
        print(f"  Error: {e}")
        # NanoBanana via OpenRouter — fallback for any error (LaoZhang adds own content filter)
        try:
            print(f"  [Fallback] Trying NanoBanana via OpenRouter for ACT 2...")
            act2_data, act2_mime = call_nanobanana_openrouter(
                image_data=source_image_data,
                prompt=act2_prompt,
                mime_type=get_mime_type(source_image_path),
                aspect_ratio=mode_config["aspect_ratio"],
                reference_image_data=act1_data if use_reference else None,
                reference_mime_type=act1_mime if use_reference else None
            )
            act2_original_path = output_dir / "processed" / "frame_act2_original.jpg"
            with open(act2_original_path, 'wb') as f:
                f.write(act2_data)
            act2_cropped = crop_frame_to_aspect_ratio(act2_data, mode_config["aspect_ratio"])
            act2_path = output_dir / "processed" / "frame_act2.jpg"
            with open(act2_path, 'wb') as f:
                f.write(act2_cropped)
            log["stages"]["stage3"]["act2_provider"] = "openrouter"
            print(f"  OpenRouter NanoBanana ACT 2 OK ({len(act2_data)//1024}KB)")
        except Exception as e_or:
            log["errors"].append(f"OpenRouter NanoBanana ACT 2 also failed: {e_or}")
            print(f"  OpenRouter NanoBanana also failed: {e_or}")

        # SeeDream fallback — last resort
        if not act2_path:
            try:
                print(f"  [Fallback] Trying SeeDream for ACT 2...")
                source_url = path_to_public_url(str(source_image_path))
                act2_data, act2_mime = call_seedream_laozhang(
                    prompt=frame_prompts["frame_act2"],
                    api_key=laozhang_key,
                    aspect_ratio=mode_config["aspect_ratio"],
                    image_url=source_url,
                )
                act2_original_path = output_dir / "processed" / "frame_act2_original.jpg"
                with open(act2_original_path, 'wb') as f:
                    f.write(act2_data)
                act2_cropped = crop_frame_to_aspect_ratio(act2_data, mode_config["aspect_ratio"])
                act2_path = output_dir / "processed" / "frame_act2.jpg"
                with open(act2_path, 'wb') as f:
                    f.write(act2_cropped)
                log["stages"]["stage3"]["act2_provider"] = "seedream"
                print(f"  SeeDream ACT 2 OK ({len(act2_data)//1024}KB)")
            except Exception as e2:
                log["errors"].append(f"SeeDream ACT 2 also failed: {e2}")
                print(f"  SeeDream fallback also failed: {e2}")

    # Log scene consistency usage
    log["scene_consistency_used"] = use_reference

    # === GENERATION: VIDEO ===
    if has_children and act1_path:
        # Kids pipeline: two Kling clips concatenated (Veo3 blocks minors)
        print("\n[Kids Pipeline] Generating two Kling clips...")
        try:
            import shutil as _shutil
            video_result_path = generate_kids_video(
                act1_path=act1_path,
                act2_path=act2_path,
                veo3_prompt=veo3_prompt,
                story_vision=story_vision,
                negatives=negatives,
                pollo_key=pollo_key,
                output_dir=output_dir,
                logger=logger,
            )
            # Save original as video_generated.mp4, then copy to video.mp4
            video_generated_path = output_dir / "videos" / "video_generated.mp4"
            _shutil.copy(video_result_path, video_generated_path)
            video_path = output_dir / "videos" / "video.mp4"
            _shutil.copy(video_result_path, video_path)
            log["video_provider"] = "kling_kids"
            log["video_path"] = str(video_path)
            print(f"  Kids video saved: {video_path}")

            # Optimize for Telegram if video is too large
            try:
                optimize_video_for_telegram(video_path)
            except Exception as e:
                print(f"  Warning: Telegram optimization failed: {e} (keeping original)")
        except Exception as e:
            log["errors"].append(f"Kids pipeline failed: {e}")
            print(f"  Kids pipeline error: {e}")
            if logger:
                logger.error(f"Kids pipeline failed: {e}")

    elif act1_path:
        print("\n[Generation] Creating video with Veo3...")

        max_retries = 3
        video_result = None

        for attempt in range(max_retries):
            try:
                if attempt > 0:
                    print(f"  Retry {attempt + 1}/{max_retries}...")
                    time.sleep(3)

                # Use async API (no charge on failure)
                task_result = call_veo3_laozhang_async(
                    prompt=veo3_prompt,
                    api_key=laozhang_key,
                    first_frame_path=str(act1_path),
                    last_frame_path=str(act2_path) if act2_path else None,
                    model=mode_config["veo3_model"],
                    logger=logger
                )
                task_id = task_result["task_id"]
                msg = f"  LaoZhang task: {task_id}"
                print(msg)
                if logger:
                    logger.info(msg)

                # Poll for completion
                video_result = poll_veo3_laozhang_async(
                    task_id=task_id,
                    api_key=laozhang_key,
                    poll_interval=5,
                    timeout=600,
                    logger=logger
                )
                msg = "  Video ready!"
                print(msg)
                if logger:
                    logger.info(msg)
                break
            except Exception as e:
                error_str = str(e)
                if any(x in error_str for x in ["IP_INPUT_IMAGE", "未成年人", "AUDIO_FILTERED"]):
                    if attempt < max_retries - 1:
                        continue
                log["errors"].append(f"Veo3 LaoZhang async failed: {e}")
                print(f"  Error: {e}")
                break

        # Fallback to Pollo if LaoZhang failed and POLLO_KEY is available
        video_provider = "laozhang"
        if not video_result and pollo_key:
            print("\n[Veo3] LaoZhang failed, trying Pollo.ai fallback...")
            try:
                # Pollo only supports 16:9 and 9:16; derive from veo3 model name
                pollo_aspect = "16:9" if "landscape" in mode_config["veo3_model"] else "9:16"
                # Pollo has 2000 char prompt limit — truncate if needed
                pollo_prompt = veo3_prompt[:2000] if len(veo3_prompt) > 2000 else veo3_prompt
                if len(veo3_prompt) > 2000:
                    print(f"  Prompt truncated for Pollo: {len(veo3_prompt)} -> 2000 chars")
                pollo_result = call_veo3_pollo(
                    prompt=pollo_prompt,
                    api_key=pollo_key,
                    first_frame_path=str(act1_path),
                    last_frame_path=str(act2_path) if act2_path else None,
                    aspect_ratio=pollo_aspect,
                    length=8,
                    resolution="720p"
                )
                task_id = pollo_result["task_id"]
                print(f"  Pollo task: {task_id}")

                pollo_video = poll_veo3_pollo(
                    task_id=task_id,
                    api_key=pollo_key,
                    poll_interval=5,
                    timeout=600
                )
                video_result = pollo_video
                video_provider = "pollo"
                print(f"  Pollo video ready!")
            except Exception as e:
                log["errors"].append(f"Veo3 Pollo fallback failed: {e}")
                print(f"  Pollo fallback error: {e}")

        # Kling v2.6 last-resort fallback (content policy, timeouts, any Veo3 failure)
        if not video_result and pollo_key:
            print("\n[Kling] Veo3 failed, trying Kling v2.6 last-resort fallback...")
            try:
                kling_result = call_kling_v26_pollo(
                    prompt=veo3_prompt[:2000],
                    api_key=pollo_key,
                    first_frame_path=str(act1_path),
                    last_frame_path=str(act2_path) if act2_path else None,
                    length=10,
                    generate_audio=True
                )
                print(f"  Kling task: {kling_result['task_id']}")
                kling_video = poll_kling_pollo(
                    task_id=kling_result["task_id"],
                    api_key=pollo_key,
                    poll_interval=10,
                    timeout=600
                )
                video_result = kling_video
                video_provider = "kling"
                print(f"  Kling v2.6 video ready!")
            except Exception as e:
                log["errors"].append(f"Kling v2.6 fallback failed: {e}")
                print(f"  Kling fallback error: {e}")

        # Kling Direct last-resort fallback (no third-party proxy)
        if not video_result:
            print("\n[Kling Direct] Trying Kling v2.6 Direct API fallback...")
            try:
                kling_result = call_kling_direct(
                    prompt=veo3_prompt[:2500],
                    first_frame_path=str(act1_path),
                    last_frame_path=str(act2_path) if act2_path else None,
                    duration=10,
                    generate_audio=True
                )
                kling_video = poll_kling_direct(
                    task_id=kling_result["task_id"],
                    poll_interval=10,
                    timeout=600
                )
                video_result = kling_video
                video_provider = "kling_direct"
                print(f"  Kling Direct video ready!")
            except Exception as e:
                log["errors"].append(f"Kling Direct fallback failed: {e}")
                print(f"  Kling Direct fallback error: {e}")

        if video_result:
            print(f"  Video ready (provider: {video_provider})!")
            log["video_uri"] = video_result["uri"]
            log["video_provider"] = video_provider

            # Download raw video
            video_generated_path = output_dir / "videos" / "video_generated.mp4"
            download_start = time.time()
            if video_provider in ("pollo", "kling"):
                download_video_pollo(video_result["uri"], str(video_generated_path))
            elif video_provider == "kling_direct":
                download_video_kling_direct(video_result["uri"], str(video_generated_path))
            else:
                download_video_laozhang(video_result["uri"], str(video_generated_path))
            download_time = time.time() - download_start
            file_size_mb = video_generated_path.stat().st_size / (1024 * 1024)
            if logger:
                logger.info(f"  Downloaded: {video_generated_path.name} ({file_size_mb:.1f}MB in {download_time:.1f}s)")
            print(f"  Downloaded: {video_generated_path.name} ({file_size_mb:.1f}MB in {download_time:.1f}s)")
            log["download_time_sec"] = round(download_time, 1)

            # Validate downloaded video
            validate_video(video_generated_path)

            # Crop to 3:4 if needed (wbcard mode), otherwise just copy
            # Skip crop for Kling — it already outputs ~3:4 aspect ratio
            video_path = output_dir / "videos" / "video.mp4"
            if mode_config.get("crop_from_916", False) and video_provider not in ("kling", "kling_direct"):
                crop_start = time.time()
                if logger:
                    logger.info("  Cropping 9:16 video to 3:4 for WB card format...")
                print("  Cropping 9:16 video to 3:4 for WB card format...")
                video_cropped = crop_video_to_34(video_generated_path)
                # Rename cropped file to video.mp4
                video_cropped.rename(video_path)
                crop_time = time.time() - crop_start
                if logger:
                    logger.info(f"  Cropped in {crop_time:.1f}s")
                print(f"  Cropped in {crop_time:.1f}s")
                log["crop_time_sec"] = round(crop_time, 1)

                # Validate cropped video
                validate_video(video_path)
            else:
                # No crop needed (non-wbcard mode or Kling provider), just copy
                import shutil
                shutil.copy(video_generated_path, video_path)

            log["video_path"] = str(video_path)

            # Optimize for Telegram if video is too large
            try:
                optimize_video_for_telegram(video_path)
            except Exception as e:
                print(f"  Warning: Telegram optimization failed: {e} (keeping original)")

    # Track fallback providers used
    fallback_providers = []
    if llm_provider and llm_provider != "laozhang":
        fallback_providers.append(f"LLM: {llm_provider}")
    act1_prov = log.get("stages", {}).get("stage3", {}).get("act1_provider")
    act2_prov = log.get("stages", {}).get("stage3", {}).get("act2_provider")
    if act1_prov:
        fallback_providers.append(f"Frame ACT1: {act1_prov}")
    if act2_prov:
        fallback_providers.append(f"Frame ACT2: {act2_prov}")
    if fallback_providers:
        log["fallback_providers"] = fallback_providers

    # === FINALIZE ===
    log["completed_at"] = datetime.now().isoformat()

    # Save generation log
    log_path = output_dir / "generation.log"
    with open(log_path, 'w', encoding='utf-8') as f:
        json.dump(log, f, indent=2, ensure_ascii=False)

    # Save metadata (backward compatible)
    metadata = {
        "product_url": product_url,
        "product_id": product_id,
        "task_id": task_id,
        "output_dir": str(output_dir),
        "product_name": product_data.get("name", "Unknown"),
        "started_at": log["started_at"],
        "completed_at": log["completed_at"],
        "build_info": build_info,
        "mode": mode,
        "mode_config": mode_config,
        "visual_style": visual_style,
        "video_kind": video_kind,
        "style_selection_reason": style_reason,
        "story_vision": story_vision,  # Top-level for easy access
        "prompts": {
            "creative": {
                "story_vision": story_vision,
                "frame_act1": frame_prompts.get("frame_act1", ""),
                "frame_act2": frame_prompts.get("frame_act2", ""),
                "veo3_prompt": veo3_prompt
            }
        },
        "images_processed": [
            {"type": "act1", "output": str(act1_path)} if act1_path else None,
            {"type": "act2", "output": str(act2_path)} if act2_path else None,
        ],
        "videos_generated": [
            {"path": log.get("video_path"), "prompt": veo3_prompt}
        ] if log.get("video_path") else [],
        "video_uri": log.get("video_uri"),
        "errors": log["errors"],
        "fallback_providers": log.get("fallback_providers", [])
    }
    metadata["images_processed"] = [x for x in metadata["images_processed"] if x]

    metadata_path = output_dir / "metadata.json"
    with open(metadata_path, 'w', encoding='utf-8') as f:
        json.dump(metadata, f, indent=2, ensure_ascii=False)

    print(f"\n{'='*60}")
    print("PIPELINE V2 COMPLETE")
    print(f"Output: {output_dir}")
    print(f"Errors: {len(log['errors'])}")
    print(f"{'='*60}")

    ThreadLocalTeeStdout.unregister()

    return metadata


def retry_veo3_only(
    output_dir: str,
    laozhang_key: str,
    pollo_key: str = None,
) -> Dict:
    """
    Retry only the Veo3 video generation step using existing frames and prompts.

    Args:
        output_dir: Path to existing task directory (e.g., /mnt/video/wb_123/20260117_123456_abc)
        laozhang_key: LaoZhang API key
        pollo_key: Pollo API key for fallback (optional)

    Returns:
        Dict with video_path, video_uri, or errors
    """
    output_dir = Path(output_dir)
    build_info = get_build_info()

    print(f"\n{'='*60}")
    print("VEO3 RETRY - Using existing frames")
    print(f"Task dir: {output_dir}")
    print(f"Source: {build_info['source']} | Commit: {build_info['git_commit']} | Dirty: {build_info['git_dirty']}")
    print(f"{'='*60}")

    result = {
        "success": False,
        "video_path": None,
        "video_uri": None,
        "build_info": build_info,
        "errors": []
    }

    # Load existing generation log or metadata
    gen_log_path = output_dir / "generation.log"
    metadata_path = output_dir / "metadata.json"

    veo3_prompt = None

    # Load mode from generation log (default to cinematic for backward compatibility)
    mode = DEFAULT_MODE

    if gen_log_path.exists():
        with open(gen_log_path, 'r', encoding='utf-8') as f:
            gen_log = json.load(f)
        veo3_prompt = gen_log.get("stages", {}).get("stage4", {}).get("veo3_prompt")
        mode = gen_log.get("mode", DEFAULT_MODE)

    if not veo3_prompt and metadata_path.exists():
        with open(metadata_path, 'r', encoding='utf-8') as f:
            metadata = json.load(f)
        veo3_prompt = metadata.get("prompts", {}).get("creative", {}).get("veo3_prompt")

    # Fallback: read from saved prompt file (survives pipeline crashes)
    if not veo3_prompt:
        prompt_file = output_dir / "prompts" / "stage4_veo3_prompt.txt"
        if prompt_file.exists():
            veo3_prompt = prompt_file.read_text(encoding='utf-8').strip()
            print(f"  Loaded veo3_prompt from {prompt_file.name}")

    # Check if this is a kids generation
    has_children = False
    story_vision = {}
    if gen_log_path.exists():
        has_children = gen_log.get("has_children", False)
        story_vision = gen_log.get("stages", {}).get("stage2", {}).get("story_vision", {})
    if not has_children and metadata_path.exists():
        with open(metadata_path, 'r', encoding='utf-8') as f:
            metadata = json.load(f)
        story_vision = metadata.get("story_vision", {})
        has_children = story_vision.get("has_children", False)

    mode_config = get_mode_config(mode)
    print(f"  Mode: {mode_config['name']} ({mode_config['aspect_ratio']})")
    if has_children:
        print(f"  [Kids mode] Will use Kling two-clip pipeline")

    if not veo3_prompt:
        result["errors"].append("No veo3_prompt found in task directory")
        return result

    # Find existing frames
    processed_dir = output_dir / "processed"
    act1_path = processed_dir / "frame_act1.jpg"
    act2_path = processed_dir / "frame_act2.jpg"

    if not act1_path.exists():
        act1_path = processed_dir / "frame_act1.png"
    if not act2_path.exists():
        act2_path = processed_dir / "frame_act2.png"

    if not act1_path.exists():
        result["errors"].append("No frame_act1 found")
        return result

    print(f"  ACT 1 frame: {act1_path.name}")
    print(f"  ACT 2 frame: {act2_path.name if act2_path.exists() else 'N/A'}")
    print(f"  Prompt: {veo3_prompt[:100]}...")

    # Setup logger for this retry (append to existing log)
    task_id = output_dir.name
    log_file = output_dir / "pipeline.log"

    # Tee print() output to pipeline.log (thread-local, safe for concurrent pipelines)
    if not isinstance(sys.stdout, ThreadLocalTeeStdout):
        sys.stdout = ThreadLocalTeeStdout()
    ThreadLocalTeeStdout.register(log_file, task_id)

    logger = logging.getLogger(f"retry_{task_id}")
    logger.setLevel(logging.INFO)
    logger.propagate = False
    logger.handlers.clear()

    # Append to existing log file
    file_handler = logging.FileHandler(log_file, mode='a', encoding='utf-8')
    file_handler.setLevel(logging.INFO)
    formatter = logging.Formatter(
        fmt='%(asctime)s [%(name)s] %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S'
    )
    file_handler.setFormatter(formatter)
    logger.addHandler(file_handler)

    # Kids pipeline: use Kling two-clip approach instead of Veo3
    if has_children:
        print("\n[Kids Pipeline] Retry using Kling two-clip pipeline...")
        try:
            negatives = load_negative_prompts()
            video_result_path = generate_kids_video(
                act1_path=act1_path,
                act2_path=act2_path if act2_path.exists() else None,
                veo3_prompt=veo3_prompt,
                story_vision=story_vision,
                negatives=negatives,
                pollo_key=pollo_key,
                output_dir=output_dir,
                logger=logger,
            )
            videos_dir = output_dir / "videos"
            video_path = videos_dir / "video.mp4"
            if video_path.exists():
                video_path.unlink()
            import shutil
            shutil.copy(video_result_path, video_path)

            # Optimize for Telegram
            try:
                optimize_video_for_telegram(video_path)
            except Exception:
                pass

            result["video_path"] = str(video_path)
            result["video_provider"] = "kling_kids"
            result["success"] = True

            # Update generation log
            if gen_log_path.exists():
                gen_log["video_path"] = str(video_path)
                gen_log["video_provider"] = "kling_kids"
                gen_log["veo3_retry_at"] = datetime.now().isoformat()
                with open(gen_log_path, 'w', encoding='utf-8') as f:
                    json.dump(gen_log, f, indent=2, ensure_ascii=False)

            print(f"\n{'='*60}")
            print("KIDS RETRY COMPLETE")
            print(f"Success: {result['success']}")
            print(f"{'='*60}")

            ThreadLocalTeeStdout.unregister()
            return result
        except Exception as e:
            result["errors"].append(f"Kids pipeline retry failed: {e}")
            print(f"  Kids pipeline error: {e}")
            ThreadLocalTeeStdout.unregister()
            return result

    # Call Veo3 using async API (no charge on failure)
    veo3_model = mode_config["veo3_model"]
    print(f"\n[Veo3] Generating video with model: {veo3_model}")

    max_retries = 3
    video_result = None

    for attempt in range(max_retries):
        try:
            if attempt > 0:
                msg = f"  Retry {attempt + 1}/{max_retries}..."
                print(msg)
                if logger:
                    logger.info(msg)
                time.sleep(3)

            # Use async API (no charge on failure)
            task_result = call_veo3_laozhang_async(
                prompt=veo3_prompt,
                api_key=laozhang_key,
                first_frame_path=str(act1_path),
                last_frame_path=str(act2_path) if act2_path.exists() else None,
                model=mode_config["veo3_model"],
                logger=logger
            )
            task_id = task_result["task_id"]

            # Poll for completion
            video_result = poll_veo3_laozhang_async(
                task_id=task_id,
                api_key=laozhang_key,
                poll_interval=5,
                timeout=600,
                logger=logger
            )
            break
        except Exception as e:
            error_str = str(e)
            if any(x in error_str for x in ["IP_INPUT_IMAGE", "未成年人", "AUDIO_FILTERED"]):
                if attempt < max_retries - 1:
                    continue
            result["errors"].append(f"Veo3 LaoZhang failed: {e}")
            msg = f"  Error: {e}"
            print(msg)
            if logger:
                logger.error(msg)
            break

    # Fallback to Pollo if LaoZhang failed and POLLO_KEY is available
    video_provider = "laozhang"
    if not video_result and pollo_key:
        print("\n[Veo3] LaoZhang failed, trying Pollo.ai fallback...")
        try:
            # Pollo only supports 16:9 and 9:16; derive from veo3 model name
            pollo_aspect = "16:9" if "landscape" in mode_config["veo3_model"] else "9:16"
            # Pollo has 2000 char prompt limit — truncate if needed
            pollo_prompt = veo3_prompt[:2000] if len(veo3_prompt) > 2000 else veo3_prompt
            if len(veo3_prompt) > 2000:
                print(f"  Prompt truncated for Pollo: {len(veo3_prompt)} -> 2000 chars")
            pollo_result = call_veo3_pollo(
                prompt=pollo_prompt,
                api_key=pollo_key,
                first_frame_path=str(act1_path),
                last_frame_path=str(act2_path) if act2_path.exists() else None,
                aspect_ratio=pollo_aspect,
                length=8,
                resolution="720p"
            )
            task_id = pollo_result["task_id"]
            print(f"  Pollo task: {task_id}")

            pollo_video = poll_veo3_pollo(
                task_id=task_id,
                api_key=pollo_key,
                poll_interval=5,
                timeout=600
            )
            video_result = pollo_video
            video_provider = "pollo"
            print(f"  Pollo video ready!")
        except Exception as e:
            result["errors"].append(f"Veo3 Pollo fallback failed: {e}")
            print(f"  Pollo fallback error: {e}")

    # Kling v2.6 last-resort fallback (content policy, timeouts, any Veo3 failure)
    if not video_result and pollo_key:
        print("\n[Kling] Veo3 failed, trying Kling v2.6 last-resort fallback...")
        try:
            kling_result = call_kling_v26_pollo(
                prompt=veo3_prompt[:2000],
                api_key=pollo_key,
                first_frame_path=str(act1_path),
                last_frame_path=str(act2_path) if act2_path.exists() else None,
                length=10,
                generate_audio=True
            )
            print(f"  Kling task: {kling_result['task_id']}")
            kling_video = poll_kling_pollo(
                task_id=kling_result["task_id"],
                api_key=pollo_key,
                poll_interval=10,
                timeout=600
            )
            video_result = kling_video
            video_provider = "kling"
            print(f"  Kling v2.6 video ready!")
        except Exception as e:
            result["errors"].append(f"Kling v2.6 fallback failed: {e}")
            print(f"  Kling fallback error: {e}")

    # Kling Direct last-resort fallback (no third-party proxy)
    if not video_result:
        print("\n[Kling Direct] Trying Kling v2.6 Direct API fallback...")
        try:
            kling_result = call_kling_direct(
                prompt=veo3_prompt[:2500],
                first_frame_path=str(act1_path),
                last_frame_path=str(act2_path) if act2_path.exists() else None,
                duration=10,
                generate_audio=True
            )
            kling_video = poll_kling_direct(
                task_id=kling_result["task_id"],
                poll_interval=10,
                timeout=600
            )
            video_result = kling_video
            video_provider = "kling_direct"
            print(f"  Kling Direct video ready!")
        except Exception as e:
            result["errors"].append(f"Kling Direct fallback failed: {e}")
            print(f"  Kling Direct fallback error: {e}")

    if video_result:
        print(f"  Video ready (provider: {video_provider})!")
        result["video_uri"] = video_result["uri"]
        result["video_provider"] = video_provider

        # Download
        videos_dir = output_dir / "videos"
        videos_dir.mkdir(exist_ok=True)

        # Download raw video
        video_generated_path = videos_dir / "video_generated.mp4"
        if video_generated_path.exists():
            video_generated_path.unlink()

        download_start = time.time()
        if video_provider in ("pollo", "kling"):
            download_video_pollo(video_result["uri"], str(video_generated_path))
        elif video_provider == "kling_direct":
            download_video_kling_direct(video_result["uri"], str(video_generated_path))
        else:
            download_video_laozhang(video_result["uri"], str(video_generated_path))
        download_time = time.time() - download_start
        file_size_mb = video_generated_path.stat().st_size / (1024 * 1024)
        print(f"  Downloaded: {video_generated_path.name} ({file_size_mb:.1f}MB in {download_time:.1f}s)")
        result["download_time_sec"] = round(download_time, 1)

        # Validate downloaded video
        validate_video(video_generated_path)

        # Crop to 3:4 if needed (wbcard mode), otherwise just copy
        # Skip crop for Kling — it already outputs ~3:4 aspect ratio
        video_path = videos_dir / "video.mp4"
        if video_path.exists():
            video_path.unlink()

        if mode_config.get("crop_from_916", False) and video_provider not in ("kling", "kling_direct"):
            crop_start = time.time()
            print("  Cropping 9:16 video to 3:4 for WB card format...")
            video_cropped = crop_video_to_34(video_generated_path)
            video_cropped.rename(video_path)
            crop_time = time.time() - crop_start
            print(f"  Cropped in {crop_time:.1f}s")
            result["crop_time_sec"] = round(crop_time, 1)

            # Validate cropped video
            validate_video(video_path)
        else:
            # No crop needed (non-wbcard mode or Kling provider), just copy
            import shutil
            shutil.copy(video_generated_path, video_path)

        # Optimize for Telegram if video is too large
        try:
            optimize_video_for_telegram(video_path)
        except Exception as e:
            print(f"  Warning: Telegram optimization failed: {e} (keeping original)")

        result["video_path"] = str(video_path)
        result["success"] = True

        # Update generation log
        if gen_log_path.exists():
            with open(gen_log_path, 'r', encoding='utf-8') as f:
                gen_log = json.load(f)
            gen_log["video_uri"] = video_result["uri"]
            gen_log["video_path"] = str(video_path)
            gen_log["video_provider"] = video_provider
            gen_log["veo3_retry_at"] = datetime.now().isoformat()
            with open(gen_log_path, 'w', encoding='utf-8') as f:
                json.dump(gen_log, f, indent=2, ensure_ascii=False)

        # Update metadata
        if metadata_path.exists():
            with open(metadata_path, 'r', encoding='utf-8') as f:
                metadata = json.load(f)
            metadata["video_uri"] = video_result["uri"]
            metadata["videos_generated"] = [{"path": str(video_path), "prompt": veo3_prompt}]
            with open(metadata_path, 'w', encoding='utf-8') as f:
                json.dump(metadata, f, indent=2, ensure_ascii=False)

    print(f"\n{'='*60}")
    print("VEO3 RETRY COMPLETE")
    print(f"Success: {result['success']}")
    print(f"{'='*60}")

    ThreadLocalTeeStdout.unregister()

    return result


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description="Video Generation Pipeline V2")
    parser.add_argument("--url", required=True, help="Wildberries product URL")
    parser.add_argument("--laozhang-key", help="LaoZhang API key (or LAOZHANG_KEY env)")
    parser.add_argument("--pollo-key", help="Pollo API key for fallback (or POLLO_KEY env)")
    parser.add_argument("--output", default="/mnt/video", help="Output base directory")
    parser.add_argument("--images", default="1,2,3,4,5,6,7", help="Image numbers to download")

    args = parser.parse_args()

    laozhang_key = args.laozhang_key or os.environ.get("LAOZHANG_KEY")
    if not laozhang_key:
        print("Error: LaoZhang key required. Use --laozhang-key or set LAOZHANG_KEY")
        exit(1)

    pollo_key = args.pollo_key or os.environ.get("POLLO_KEY")

    image_nums = [int(x.strip()) for x in args.images.split(",")]

    run_pipeline_v2(
        product_url=args.url,
        laozhang_key=laozhang_key,
        pollo_key=pollo_key,
        output_base=args.output,
        image_nums=image_nums
    )
