#!/usr/bin/env python3
"""Decode 61-frame windows around issue frame ids for D4Q2 network-share cases."""

from __future__ import annotations

import argparse
import io
import json
import re
import sys
from collections import Counter, deque
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Iterable, Optional

import cv2
import numpy as np

FILE = Path(__file__).resolve()
ROOT = FILE.parents[2]
if str(ROOT) not in sys.path:
    sys.path.append(str(ROOT))

from tools.model_inference.adapters.video_dir_inference_utils import (
    get_video_frame_info,
    read_video_frame_index,
)

try:
    from pdcl_pyclip.decoder_struct import StructDecoder
    from pdcl_pyclip.msg_camera import VideoMessage
    from pdcl_pyclip.reader import ClipReader
except ImportError:
    ClipReader = None
    StructDecoder = None
    VideoMessage = None


NETWORK_SHARE_MARKERS = ("hfs.minieye.tech", "192.168.2.122")
WINDOW_RADIUS = 30
WINDOW_SIZE = WINDOW_RADIUS * 2 + 1
DEFAULT_INPUT_JSON = ROOT / "tools" / "feishu_project" / "dongying_d4q2_zhibao_issue_list.json"
DEFAULT_DOWNLOAD_ROOT = Path("/data1/dongying/Mono3d/D4Q2/feishu_project/downloaded_issue_data")
DEFAULT_OUTPUT_ROOT = Path("/data1/dongying/Mono3d/D4Q2/feishu_project/decoded_issue_frame_windows")
FRAME_ID_CAMERA4_RE = re.compile(r"camera4\s*:\s*(\d+)", re.IGNORECASE)
FRAME_ID_ANY_CAMERA_RE = re.compile(r"(camera\d+)\s*:\s*(\d+)", re.IGNORECASE)
PURE_DIGIT_RE = re.compile(r"^\d+$")


@dataclass(frozen=True)
class TargetFrame:
    camera: str
    frame_id: int
    raw_text: str


@dataclass(frozen=True)
class CaseSource:
    issue_id: int
    issue_name: str
    issue_dir: Path
    path_dir: Path
    case_dir: Path
    relative_case_dir: Path
    target_frame: TargetFrame
    decode_all: bool
    source_mode: str
    source_paths: tuple[Path, ...]


@dataclass
class CaseResult:
    issue_id: int
    issue_name: str
    case_dir: str
    relative_case_dir: str
    target_camera: str
    target_frame_id: int | None
    decode_mode: str
    source_mode: str | None
    source_paths: list[str]
    output_dir: str
    status: str
    detail: str
    matched_field: str | None = None
    matched_frame_idx: int | None = None
    matched_topic: str | None = None
    extracted_count: int = 0

    def to_dict(self) -> dict[str, Any]:
        return {
            "issue_id": self.issue_id,
            "issue_name": self.issue_name,
            "case_dir": self.case_dir,
            "relative_case_dir": self.relative_case_dir,
            "target_camera": self.target_camera,
            "target_frame_id": self.target_frame_id,
            "decode_mode": self.decode_mode,
            "source_mode": self.source_mode,
            "source_paths": self.source_paths,
            "output_dir": self.output_dir,
            "status": self.status,
            "detail": self.detail,
            "matched_field": self.matched_field,
            "matched_frame_idx": self.matched_frame_idx,
            "matched_topic": self.matched_topic,
            "extracted_count": self.extracted_count,
        }


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Decode 61-frame windows around issue frame ids for downloaded D4Q2 network-share cases."
    )
    parser.add_argument("--input-json", default=str(DEFAULT_INPUT_JSON))
    parser.add_argument("--download-root", default=str(DEFAULT_DOWNLOAD_ROOT))
    parser.add_argument("--output-root", default=str(DEFAULT_OUTPUT_ROOT))
    parser.add_argument("--manifest-path", default="")
    parser.add_argument("--issue-id", action="append", dest="issue_ids", type=int)
    parser.add_argument("--decode-all-issue-id", action="append", dest="decode_all_issue_ids", type=int)
    parser.add_argument("--window-radius", type=int, default=WINDOW_RADIUS)
    parser.add_argument("--jpg-quality", type=int, default=95)
    parser.add_argument("--dry-run", action="store_true")
    parser.add_argument("--skip-existing", action="store_true")
    return parser.parse_args()


def ensure_dir(path: Path, dry_run: bool) -> None:
    if dry_run:
        return
    path.mkdir(parents=True, exist_ok=True)


def load_issue_items(path: Path) -> list[dict[str, Any]]:
    payload = json.loads(path.read_text(encoding="utf-8"))
    return payload["items"]


def parse_target_frame(frame_text: object) -> TargetFrame | None:
    text = "" if frame_text is None else str(frame_text).strip()
    if not text:
        return None
    camera4_match = FRAME_ID_CAMERA4_RE.search(text)
    if camera4_match:
        return TargetFrame(camera="camera4", frame_id=int(camera4_match.group(1)), raw_text=text)
    if PURE_DIGIT_RE.fullmatch(text):
        return TargetFrame(camera="any", frame_id=int(text), raw_text=text)
    any_camera_match = FRAME_ID_ANY_CAMERA_RE.search(text)
    if any_camera_match:
        return TargetFrame(camera=any_camera_match.group(1).lower(), frame_id=int(any_camera_match.group(2)), raw_text=text)
    return None


def is_network_share_issue(item: dict[str, Any]) -> bool:
    address = str(item.get("问题数据地址") or "")
    return any(marker in address for marker in NETWORK_SHARE_MARKERS)


def find_candidate_mcaps(case_dir: Path) -> list[Path]:
    root_level = sorted(
        path
        for path in case_dir.iterdir()
        if path.is_file() and path.suffix.lower() == ".mcap" and "_PB_" not in path.name
    )
    if root_level:
        return root_level

    recursive = sorted(
        path
        for path in case_dir.rglob("*.mcap")
        if path.is_file() and "_PB_" not in path.name
    )
    return recursive


def find_camera4_bin(case_dir: Path) -> Path | None:
    candidates = sorted(case_dir.rglob("camera4.bin"), key=lambda p: (len(p.relative_to(case_dir).parts), str(p)))
    return candidates[0] if candidates else None


def discover_case_sources(
    items: list[dict[str, Any]],
    download_root: Path,
    issue_filter: set[int] | None,
    decode_all_issue_filter: set[int],
) -> tuple[list[CaseSource], list[CaseResult]]:
    discovered: list[CaseSource] = []
    skipped: list[CaseResult] = []

    for item in items:
        issue_id = int(item["id"])
        if issue_filter and issue_id not in issue_filter:
            continue
        if not is_network_share_issue(item):
            continue

        issue_name = str(item["name"])
        issue_dir = download_root / f"issue_{issue_id}"
        target_frame = parse_target_frame(item.get("问题发生frameid"))
        if target_frame is None:
            skipped.append(
                CaseResult(
                    issue_id=issue_id,
                    issue_name=issue_name,
                    case_dir="",
                    relative_case_dir="",
                    target_camera="",
                    target_frame_id=None,
                    decode_mode="window",
                    source_mode=None,
                    source_paths=[],
                    output_dir="",
                    status="skipped_missing_frame_id",
                    detail=f"unparseable frame id: {item.get('问题发生frameid')!r}",
                )
            )
            continue

        if target_frame.camera not in {"camera4", "camera1", "any"}:
            skipped.append(
                CaseResult(
                    issue_id=issue_id,
                    issue_name=issue_name,
                    case_dir="",
                    relative_case_dir="",
                    target_camera=target_frame.camera,
                    target_frame_id=target_frame.frame_id,
                    decode_mode="all" if issue_id in decode_all_issue_filter else "window",
                    source_mode=None,
                    source_paths=[],
                    output_dir="",
                    status="skipped_unsupported_camera",
                    detail=f"unsupported camera selector in frame id: {target_frame.raw_text}",
                )
            )
            continue

        if not issue_dir.is_dir():
            skipped.append(
                CaseResult(
                    issue_id=issue_id,
                    issue_name=issue_name,
                    case_dir=str(issue_dir),
                    relative_case_dir=str(issue_dir.name),
                    target_camera=target_frame.camera,
                    target_frame_id=target_frame.frame_id,
                    decode_mode="all" if issue_id in decode_all_issue_filter else "window",
                    source_mode=None,
                    source_paths=[],
                    output_dir="",
                    status="skipped_missing_issue_dir",
                    detail=f"issue download directory not found: {issue_dir}",
                )
            )
            continue

        path_dirs = sorted(path for path in issue_dir.iterdir() if path.is_dir() and path.name.startswith("path_"))
        if not path_dirs:
            skipped.append(
                CaseResult(
                    issue_id=issue_id,
                    issue_name=issue_name,
                    case_dir=str(issue_dir),
                    relative_case_dir=str(issue_dir.name),
                    target_camera=target_frame.camera,
                    target_frame_id=target_frame.frame_id,
                    decode_mode="all" if issue_id in decode_all_issue_filter else "window",
                    source_mode=None,
                    source_paths=[],
                    output_dir="",
                    status="skipped_no_path_cases",
                    detail="no path_* directories found for network-share issue",
                )
            )
            continue

        for path_dir in path_dirs:
            case_dirs = sorted(path for path in path_dir.iterdir() if path.is_dir())
            if not case_dirs:
                skipped.append(
                    CaseResult(
                        issue_id=issue_id,
                        issue_name=issue_name,
                        case_dir=str(path_dir),
                        relative_case_dir=str(path_dir.relative_to(download_root)),
                        target_camera=target_frame.camera,
                        target_frame_id=target_frame.frame_id,
                        decode_mode="all" if issue_id in decode_all_issue_filter else "window",
                        source_mode=None,
                        source_paths=[],
                        output_dir="",
                        status="skipped_empty_path_dir",
                        detail="path_* directory does not contain any case subdirectory",
                    )
                )
                continue

            for case_dir in case_dirs:
                relative_case_dir = case_dir.relative_to(download_root)
                candidate_mcaps = find_candidate_mcaps(case_dir)
                if candidate_mcaps:
                    discovered.append(
                        CaseSource(
                            issue_id=issue_id,
                            issue_name=issue_name,
                            issue_dir=issue_dir,
                            path_dir=path_dir,
                            case_dir=case_dir,
                            relative_case_dir=relative_case_dir,
                            target_frame=target_frame,
                            decode_all=issue_id in decode_all_issue_filter,
                            source_mode="mcap_stream",
                            source_paths=tuple(candidate_mcaps),
                        )
                    )
                    continue

                camera4_bin = find_camera4_bin(case_dir)
                if camera4_bin is not None:
                    discovered.append(
                        CaseSource(
                            issue_id=issue_id,
                            issue_name=issue_name,
                            issue_dir=issue_dir,
                            path_dir=path_dir,
                            case_dir=case_dir,
                            relative_case_dir=relative_case_dir,
                            target_frame=target_frame,
                            decode_all=issue_id in decode_all_issue_filter,
                            source_mode="camera4_bin",
                            source_paths=(camera4_bin,),
                        )
                    )
                    continue

                skipped.append(
                    CaseResult(
                        issue_id=issue_id,
                        issue_name=issue_name,
                        case_dir=str(case_dir),
                        relative_case_dir=str(relative_case_dir),
                        target_camera=target_frame.camera,
                        target_frame_id=target_frame.frame_id,
                        decode_mode="all" if issue_id in decode_all_issue_filter else "window",
                        source_mode=None,
                        source_paths=[],
                        output_dir="",
                        status="skipped_no_source",
                        detail="no non-PB .mcap or camera4.bin found under case directory",
                    )
                )
    return discovered, skipped


def _plane_to_ndarray(plane) -> np.ndarray:
    stride = plane.line_size
    height = plane.height
    width = plane.width
    array = np.frombuffer(plane, dtype=np.uint8)
    if stride == width:
        return array.reshape(height, width)
    return array.reshape(height, stride)[:, :width]


def _h265_payload_to_bgr(payload: bytes) -> np.ndarray:
    try:
        import av
    except ImportError as exc:
        raise ImportError("PyAV is required for MCAP frame decoding.") from exc

    container = av.open(io.BytesIO(payload))
    for frame in container.decode(video=0):
        y_plane = _plane_to_ndarray(frame.planes[0])
        u_plane = _plane_to_ndarray(frame.planes[1])
        v_plane = _plane_to_ndarray(frame.planes[2])
        uv_plane = np.zeros((u_plane.shape[0], u_plane.shape[1] * 2), dtype=np.uint8)
        uv_plane[:, 0::2] = u_plane
        uv_plane[:, 1::2] = v_plane
        yuv_image = np.concatenate((y_plane.copy(), uv_plane), axis=0)
        return cv2.cvtColor(yuv_image, cv2.COLOR_YUV2BGR_NV12)
    raise ValueError("decode failed: no video frame in payload")


def iter_mcap_frames(mcap_paths: Iterable[Path], topic_candidates: tuple[str, ...]) -> Iterable[dict[str, Any]]:
    if ClipReader is None or StructDecoder is None or VideoMessage is None:
        raise ImportError("pdcl_pyclip is required for MCAP extraction.")

    decoder = StructDecoder()
    for mcap_path in mcap_paths:
        reader = ClipReader(str(mcap_path))
        for schema, channel, msg in reader.iter_messages():
            data = decoder.decode(schema, channel, msg)
            if not isinstance(data, VideoMessage):
                continue
            frame_id = getattr(data, "frame_id", None)
            if frame_id is None:
                continue
            yield {
                "source_path": mcap_path,
                "topic": getattr(channel, "topic", ""),
                "frame_id": int(frame_id),
                "payload": data.payload,
                "timestamp": str(getattr(data, "timestamp", "")),
            }


def topic_matches_camera(topic: str, target_camera: str) -> bool:
    normalized_topic = str(topic or "").lower()
    if target_camera == "any":
        return "camera" in normalized_topic
    return target_camera in normalized_topic


def collect_mcap_window(
    mcap_paths: tuple[Path, ...],
    target_camera: str,
    target_frame_id: int,
    window_radius: int,
) -> tuple[list[dict[str, Any]], str, str]:
    buffer_before: deque[dict[str, Any]] = deque(maxlen=window_radius)
    selected: list[dict[str, Any]] = []
    trailing_needed = window_radius
    found = False
    matched_topic = ""

    for frame in iter_mcap_frames(mcap_paths, tuple()):
        if not topic_matches_camera(frame["topic"], target_camera):
            continue
        if not found:
            if frame["frame_id"] == target_frame_id:
                selected = list(buffer_before) + [frame]
                found = True
                matched_topic = str(frame["topic"])
            else:
                buffer_before.append(frame)
        else:
            selected.append(frame)
            trailing_needed -= 1
            if trailing_needed <= 0:
                break

    if not found:
        raise FileNotFoundError(f"target frame_id {target_frame_id} not found in MCAP sources for camera selector {target_camera}")

    return selected, "frame_id", matched_topic


def collect_mcap_all_frames(
    mcap_paths: tuple[Path, ...],
    target_camera: str,
) -> tuple[list[dict[str, Any]], str]:
    selected: list[dict[str, Any]] = []
    matched_topic = ""
    for frame in iter_mcap_frames(mcap_paths, tuple()):
        if not topic_matches_camera(frame["topic"], target_camera):
            continue
        if not matched_topic:
            matched_topic = str(frame["topic"])
        selected.append(frame)

    if not selected:
        raise FileNotFoundError(f"no MCAP frames found for camera selector {target_camera}")

    return selected, matched_topic


def find_video_frame_match(index_payload: Optional[dict[str, Any]], target_frame_id: int) -> tuple[int | None, str | None]:
    if not index_payload:
        return None, None

    fields = index_payload.get("fields", {}) or {}
    index_list = index_payload.get("index", []) or []
    for field_name in ("frame_id", "cve_frame_id"):
        if field_name not in fields:
            continue
        for frame_idx in range(len(index_list)):
            info = get_video_frame_info(index_payload, frame_idx)
            if info is None:
                continue
            value = info.get(field_name)
            try:
                if int(value) == target_frame_id:
                    return frame_idx, field_name
            except (TypeError, ValueError):
                continue
    return None, None


def collect_video_window(
    video_path: Path,
    target_frame_id: int,
    window_radius: int,
) -> tuple[list[dict[str, Any]], str, int]:
    index_payload = read_video_frame_index(video_path)
    matched_frame_idx, matched_field = find_video_frame_match(index_payload, target_frame_id)
    if matched_frame_idx is None or matched_field is None:
        raise FileNotFoundError(f"target frame_id {target_frame_id} not found in video index")

    start_idx = max(0, matched_frame_idx - window_radius)
    end_idx = matched_frame_idx + window_radius

    cap = cv2.VideoCapture(str(video_path))
    if not cap.isOpened():
        raise RuntimeError(f"failed to open video file: {video_path}")

    selected: list[dict[str, Any]] = []
    try:
        cap.set(cv2.CAP_PROP_POS_FRAMES, start_idx)
        current_idx = start_idx
        while current_idx <= end_idx:
            ret, frame = cap.read()
            if not ret:
                break
            frame_info = get_video_frame_info(index_payload, current_idx) if index_payload else None
            selected.append(
                {
                    "source_path": video_path,
                    "frame_idx": current_idx,
                    "frame_id": None if frame_info is None else frame_info.get("frame_id"),
                    "cve_frame_id": None if frame_info is None else frame_info.get("cve_frame_id"),
                    "timestamp": "" if frame_info is None else str(frame_info.get("timestamp", "")),
                    "image": frame,
                }
            )
            current_idx += 1
    finally:
        cap.release()

    return selected, matched_field, matched_frame_idx


def collect_video_all_frames(video_path: Path) -> tuple[list[dict[str, Any]], int]:
    index_payload = read_video_frame_index(video_path)
    cap = cv2.VideoCapture(str(video_path))
    if not cap.isOpened():
        raise RuntimeError(f"failed to open video file: {video_path}")

    selected: list[dict[str, Any]] = []
    current_idx = 0
    try:
        while True:
            ret, frame = cap.read()
            if not ret:
                break
            frame_info = get_video_frame_info(index_payload, current_idx) if index_payload else None
            selected.append(
                {
                    "source_path": video_path,
                    "frame_idx": current_idx,
                    "frame_id": None if frame_info is None else frame_info.get("frame_id"),
                    "cve_frame_id": None if frame_info is None else frame_info.get("cve_frame_id"),
                    "timestamp": "" if frame_info is None else str(frame_info.get("timestamp", "")),
                    "image": frame,
                }
            )
            current_idx += 1
    finally:
        cap.release()

    if not selected:
        raise FileNotFoundError(f"no readable frames found in video file: {video_path}")
    return selected, len(selected)


def save_decoded_frames(
    issue_id: int,
    output_dir: Path,
    frames: list[dict[str, Any]],
    target_frame_id: int,
    window_radius: int,
    jpg_quality: int,
    dry_run: bool,
    decode_all: bool,
) -> int:
    images_dir = output_dir / ("frames_all" if decode_all else "frames_window")
    if dry_run:
        return len(frames)

    ensure_dir(images_dir, dry_run=False)
    encode_params = [int(cv2.IMWRITE_JPEG_QUALITY), int(jpg_quality)]
    target_index = None if decode_all else min(window_radius, len(frames) - 1)
    for index, frame in enumerate(frames):
        offset = 0 if target_index is None else index - target_index
        frame_id = frame.get("frame_id")
        if frame_id is None:
            frame_id = frame.get("cve_frame_id")
        frame_id_token = "na" if frame_id is None else str(frame_id)
        topic = str(frame.get("topic") or "")
        camera_id_match = re.search(r"camera(\d+)", topic, re.IGNORECASE)
        if camera_id_match:
            camera_id_token = f"camera{camera_id_match.group(1)}"
        else:
            source_path = Path(str(frame.get("source_path") or ""))
            camera_id_token = source_path.stem if source_path.stem else "camera"
        if decode_all:
            filename = f"{issue_id}_{camera_id_token}_{frame_id_token}.jpg"
        else:
            filename = f"{issue_id}_{camera_id_token}_{frame_id_token}.jpg"

        image = frame.get("image")
        if image is None:
            image = _h265_payload_to_bgr(frame["payload"])
        if not cv2.imwrite(str(images_dir / filename), image, encode_params):
            raise IOError(f"failed to write image: {images_dir / filename}")
    return len(frames)


def process_case(case: CaseSource, output_root: Path, window_radius: int, jpg_quality: int, dry_run: bool, skip_existing: bool) -> CaseResult:
    output_dir = output_root / case.relative_case_dir
    images_dir = output_dir / ("frames_all" if case.decode_all else "frames_window")
    if skip_existing and images_dir.is_dir():
        existing_images = [path for path in images_dir.iterdir() if path.is_file()]
        if existing_images:
            return CaseResult(
                issue_id=case.issue_id,
                issue_name=case.issue_name,
                case_dir=str(case.case_dir),
                relative_case_dir=str(case.relative_case_dir),
                target_camera=case.target_frame.camera,
                target_frame_id=case.target_frame.frame_id,
                decode_mode="all" if case.decode_all else "window",
                source_mode=case.source_mode,
                source_paths=[str(path) for path in case.source_paths],
                output_dir=str(output_dir),
                status="skipped_existing",
                detail="existing extracted frames found",
                extracted_count=len(existing_images),
            )

    try:
        if case.source_mode == "mcap_stream":
            try:
                if case.decode_all:
                    frames, matched_topic = collect_mcap_all_frames(case.source_paths, case.target_frame.camera)
                    matched_field = None
                else:
                    frames, matched_field, matched_topic = collect_mcap_window(
                        case.source_paths,
                        case.target_frame.camera,
                        case.target_frame.frame_id,
                        window_radius,
                    )
                extracted_count = save_decoded_frames(
                    case.issue_id, output_dir, frames, case.target_frame.frame_id, window_radius, jpg_quality, dry_run, case.decode_all
                )
                detail = (
                    f"decoded {extracted_count} frames from {len(case.source_paths)} mcap file(s)"
                    if case.decode_all
                    else f"decoded {extracted_count} frames from {len(case.source_paths)} mcap file(s)"
                )
                return CaseResult(
                    issue_id=case.issue_id,
                    issue_name=case.issue_name,
                    case_dir=str(case.case_dir),
                    relative_case_dir=str(case.relative_case_dir),
                    target_camera=case.target_frame.camera,
                    target_frame_id=case.target_frame.frame_id,
                    decode_mode="all" if case.decode_all else "window",
                    source_mode=case.source_mode,
                    source_paths=[str(path) for path in case.source_paths],
                    output_dir=str(output_dir),
                    status="planned" if dry_run else "decoded_mcap",
                    detail=("would " + detail) if dry_run else detail,
                    matched_field=matched_field,
                    matched_frame_idx=None,
                    matched_topic=matched_topic,
                    extracted_count=extracted_count,
                )
            except Exception as mcap_exc:
                fallback_camera4 = find_camera4_bin(case.case_dir)
                if fallback_camera4 is None or case.target_frame.camera not in {"camera4", "any"}:
                    raise mcap_exc

                if case.decode_all:
                    frames, extracted_count_all = collect_video_all_frames(fallback_camera4)
                    matched_field = None
                    matched_frame_idx = None
                    extracted_count = save_decoded_frames(
                        case.issue_id, output_dir, frames, case.target_frame.frame_id, window_radius, jpg_quality, dry_run, case.decode_all
                    )
                else:
                    frames, matched_field, matched_frame_idx = collect_video_window(
                        fallback_camera4,
                        case.target_frame.frame_id,
                        window_radius,
                    )
                    extracted_count = save_decoded_frames(
                        case.issue_id, output_dir, frames, case.target_frame.frame_id, window_radius, jpg_quality, dry_run, case.decode_all
                    )
                detail = (
                    f"decoded {extracted_count} frames from camera4.bin fallback after mcap lookup failed: "
                    f"{type(mcap_exc).__name__}: {mcap_exc}"
                )
                return CaseResult(
                    issue_id=case.issue_id,
                    issue_name=case.issue_name,
                    case_dir=str(case.case_dir),
                    relative_case_dir=str(case.relative_case_dir),
                    target_camera=case.target_frame.camera,
                    target_frame_id=case.target_frame.frame_id,
                    decode_mode="all" if case.decode_all else "window",
                    source_mode="camera4_bin_fallback",
                    source_paths=[str(path) for path in case.source_paths] + [str(fallback_camera4)],
                    output_dir=str(output_dir),
                    status="planned" if dry_run else "decoded_camera4_bin_fallback",
                    detail=("would " + detail) if dry_run else detail,
                    matched_field=matched_field,
                    matched_frame_idx=matched_frame_idx,
                    matched_topic=None,
                    extracted_count=extracted_count,
                )

        if case.source_mode == "camera4_bin":
            if case.decode_all:
                frames, _ = collect_video_all_frames(case.source_paths[0])
                matched_field, matched_frame_idx = None, None
            else:
                frames, matched_field, matched_frame_idx = collect_video_window(case.source_paths[0], case.target_frame.frame_id, window_radius)
            extracted_count = save_decoded_frames(
                case.issue_id, output_dir, frames, case.target_frame.frame_id, window_radius, jpg_quality, dry_run, case.decode_all
            )
            detail = f"decoded {extracted_count} frames from camera4.bin"
            return CaseResult(
                issue_id=case.issue_id,
                issue_name=case.issue_name,
                case_dir=str(case.case_dir),
                relative_case_dir=str(case.relative_case_dir),
                target_camera=case.target_frame.camera,
                target_frame_id=case.target_frame.frame_id,
                decode_mode="all" if case.decode_all else "window",
                source_mode=case.source_mode,
                source_paths=[str(path) for path in case.source_paths],
                output_dir=str(output_dir),
                status="planned" if dry_run else "decoded_camera4_bin",
                detail=("would " + detail) if dry_run else detail,
                matched_field=matched_field,
                matched_frame_idx=matched_frame_idx,
                matched_topic=None,
                extracted_count=extracted_count,
            )

        return CaseResult(
            issue_id=case.issue_id,
            issue_name=case.issue_name,
            case_dir=str(case.case_dir),
            relative_case_dir=str(case.relative_case_dir),
            target_camera=case.target_frame.camera,
            target_frame_id=case.target_frame.frame_id,
            decode_mode="all" if case.decode_all else "window",
            source_mode=case.source_mode,
            source_paths=[str(path) for path in case.source_paths],
            output_dir=str(output_dir),
            status="skipped_no_source",
            detail=f"unsupported source mode: {case.source_mode}",
        )
    except Exception as exc:
        return CaseResult(
            issue_id=case.issue_id,
            issue_name=case.issue_name,
            case_dir=str(case.case_dir),
            relative_case_dir=str(case.relative_case_dir),
            target_camera=case.target_frame.camera,
            target_frame_id=case.target_frame.frame_id,
            decode_mode="all" if case.decode_all else "window",
            source_mode=case.source_mode,
            source_paths=[str(path) for path in case.source_paths],
            output_dir=str(output_dir),
            status="failed",
            detail=f"{type(exc).__name__}: {exc}",
        )


def build_manifest(args: argparse.Namespace, input_json: Path, download_root: Path, output_root: Path, discovered: list[CaseSource], results: list[CaseResult]) -> dict[str, Any]:
    summary = Counter(result.status for result in results)
    return {
        "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
        "input_json": str(input_json),
        "download_root": str(download_root),
        "output_root": str(output_root),
        "issue_filter": args.issue_ids or [],
        "decode_all_issue_filter": args.decode_all_issue_ids or [],
        "window_radius": args.window_radius,
        "window_size": args.window_radius * 2 + 1,
        "jpg_quality": args.jpg_quality,
        "dry_run": args.dry_run,
        "skip_existing": args.skip_existing,
        "total_discovered_cases": len(discovered),
        "summary": dict(summary),
        "cases": [result.to_dict() for result in results],
    }


def print_summary(manifest: dict[str, Any]) -> None:
    print(f"input_json: {manifest['input_json']}")
    print(f"download_root: {manifest['download_root']}")
    print(f"output_root: {manifest['output_root']}")
    print(f"window_size: {manifest['window_size']}")
    print(f"dry_run: {manifest['dry_run']}")
    print(f"total_discovered_cases: {manifest['total_discovered_cases']}")
    for status, count in sorted(manifest["summary"].items()):
        print(f"{status}: {count}")


def main() -> int:
    args = parse_args()
    input_json = Path(args.input_json).resolve()
    download_root = Path(args.download_root).resolve()
    output_root = Path(args.output_root).resolve()
    manifest_path = (
        Path(args.manifest_path).resolve()
        if args.manifest_path
        else output_root / "decode_manifest.json"
    )

    items = load_issue_items(input_json)
    issue_filter = set(args.issue_ids) if args.issue_ids else None
    decode_all_issue_filter = set(args.decode_all_issue_ids or [])
    discovered, skipped = discover_case_sources(items, download_root, issue_filter, decode_all_issue_filter)
    results = list(skipped)
    for case in discovered:
        results.append(process_case(case, output_root, args.window_radius, args.jpg_quality, args.dry_run, args.skip_existing))

    manifest = build_manifest(args, input_json, download_root, output_root, discovered, results)
    if not args.dry_run:
        ensure_dir(manifest_path.parent, dry_run=False)
        manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")

    print_summary(manifest)
    if args.dry_run:
        print(f"manifest (not written in dry-run): {manifest_path}")
    else:
        print(f"manifest: {manifest_path}")
    return 0


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