from __future__ import annotations

import argparse
import io
import json
import os
import traceback
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from threading import Lock
from typing import Any, Callable, Optional

import cv2
import numpy as np


CALIB_ATTACHMENT_NAMES = (
    "sigmastar.1/calibs/camera4.json",
    "test_data/calibs/camera4.json",
    "calibs/camera4.json",
)
BEIJING_TZ = timezone(timedelta(hours=8))
DEFAULT_DATE_NAME = "unknown_date"
DEFAULT_VEHICLE_NAME = "unknown_vehicle"
LOCAL_MCAP_VEHICLE_NAME = "local_mcap"


@dataclass(frozen=True)
class ClipTask:
    clip_uuid: str
    date_name: str
    vehicle_name: str
    clip_path: str

    @property
    def task_id(self) -> str:
        return self.clip_uuid


@dataclass
class TaskResult:
    task_id: str
    success: bool
    message: str
    output_dir: Optional[str] = None


class StatusStore:
    """Persistent status tracker for resumable batch inference."""

    def __init__(self, status_file: Path):
        self.status_file = status_file
        self.lock = Lock()
        self._status: dict[str, dict[str, str]] = {}
        self.status_file.parent.mkdir(parents=True, exist_ok=True)
        if self.status_file.exists():
            try:
                self._status = json.loads(self.status_file.read_text())
            except Exception:
                self._status = {}

    def is_done(self, task_id: str) -> bool:
        return self._status.get(task_id, {}).get("state") == "done"

    def get(self, task_id: str) -> Optional[dict[str, str]]:
        return self._status.get(task_id)

    def mark_running(self, task_id: str, detail: str) -> None:
        with self.lock:
            self._status[task_id] = {"state": "running", "detail": detail}
            self._flush()

    def mark_done(self, task_id: str, detail: str) -> None:
        with self.lock:
            self._status[task_id] = {"state": "done", "detail": detail}
            self._flush()

    def mark_failed(self, task_id: str, detail: str) -> None:
        with self.lock:
            self._status[task_id] = {"state": "failed", "detail": detail}
            self._flush()

    def summary(self) -> dict[str, int]:
        done = 0
        running = 0
        failed = 0
        for item in self._status.values():
            state = item.get("state")
            if state == "done":
                done += 1
            elif state == "running":
                running += 1
            elif state == "failed":
                failed += 1
        return {"done": done, "running": running, "failed": failed, "total": len(self._status)}

    def _flush(self) -> None:
        self.status_file.write_text(json.dumps(self._status, indent=2, ensure_ascii=False))


def _ensure_pdcl_auth_defaults() -> None:
    os.environ.setdefault("STS_UID", "dis-uploader")
    os.environ.setdefault("STS_SECRET_KEY", "277310cc09724d315514a79701fecb0f")


def validate_pdcl_auth_env() -> None:
    _ensure_pdcl_auth_defaults()
    required_vars = ["STS_UID", "STS_SECRET_KEY"]
    missing = [name for name in required_vars if not os.getenv(name)]
    if missing:
        raise ValueError(
            "Missing required PDCL auth env vars: "
            + ", ".join(missing)
            + ". Please export them in shell or set them in .env before running."
        )


def _normalize_metadata_value(value: Any) -> str:
    text = str(value or "").strip()
    return text if text and text.lower() != "none" else ""


def _normalize_frame_name_token(value: Any) -> str:
    text = str(value or "").strip()
    if not text or text.lower() == "none":
        return ""
    return text.replace("/", "_").replace("\\", "_").replace(" ", "")


def _format_ms_to_date_name(timestamp_ms: Any) -> str:
    if not isinstance(timestamp_ms, (int, float)) or timestamp_ms <= 0:
        return ""
    dt = datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc).astimezone(BEIJING_TZ)
    return dt.strftime("%Y%m%d%H%M%S")


def _build_clip_task(clip_uuid: str) -> Optional[ClipTask]:
    _ensure_pdcl_auth_defaults()
    from pdcl_dss import Clip

    date_name = DEFAULT_DATE_NAME
    vehicle_name = DEFAULT_VEHICLE_NAME

    try:
        clip = Clip(clip_uuid)
        clip_meta = dict(clip.meta)
        files, _ = clip.list_files()
        if not files:
            print(f"Skip clip_id={clip_uuid}: no files found")
            return None
        clip_path = clip.get_cache_path(files[0])
    except Exception as exc:
        print(f"Skip clip_id={clip_uuid}: failed to load clip metadata. {type(exc).__name__}: {exc}")
        return None

    clip_date_name = _format_ms_to_date_name(clip_meta.get("start_time_ms"))
    if clip_date_name:
        date_name = clip_date_name

    try:
        group_id = clip.get_group_id()
        if group_id:
            group_meta = dict(clip.get_group().meta)
            for key in ("plate_number", "vehicle_name", "car_name", "plateNumber", "vehicleName", "carName"):
                value = _normalize_metadata_value(group_meta.get(key))
                if value:
                    vehicle_name = value
                    break

            project_name = _normalize_metadata_value(group_meta.get("project_name"))
            if vehicle_name == DEFAULT_VEHICLE_NAME and project_name:
                vehicle_name = project_name

            group_date_name = _format_ms_to_date_name(group_meta.get("collection_time"))
            if group_date_name:
                date_name = group_date_name
    except Exception as exc:
        print(
            f"[warn] clip_id={clip_uuid}: failed to load group metadata, "
            f"fallback vehicle={vehicle_name} date={date_name}. {type(exc).__name__}: {exc}"
        )

    return ClipTask(
        clip_uuid=clip_uuid,
        date_name=date_name,
        vehicle_name=vehicle_name,
        clip_path=clip_path,
    )


def parse_clip_list(clip_list_file: str) -> list[ClipTask]:
    tasks: list[ClipTask] = []
    with open(clip_list_file, "r", encoding="utf-8") as file:
        for line in file:
            line = line.strip()
            if not line or line.startswith("#"):
                continue

            clip_uuid = line.split()[0]
            task = _build_clip_task(clip_uuid)
            if task is not None:
                tasks.append(task)
    return tasks


def build_clip_tasks_from_clip_ids(clip_ids: list[str]) -> list[ClipTask]:
    tasks: list[ClipTask] = []
    seen: set[str] = set()
    for clip_id in clip_ids:
        clip_uuid = str(clip_id).strip()
        if not clip_uuid or clip_uuid in seen:
            continue
        seen.add(clip_uuid)
        task = _build_clip_task(clip_uuid)
        if task is not None:
            tasks.append(task)
    return tasks


def build_clip_task_from_mcap_file(
    mcap_file: str | Path,
    *,
    clip_id: str = "",
    date_name: str = "",
    vehicle_name: str = "",
) -> ClipTask:
    mcap_path = Path(mcap_file).expanduser().resolve()
    if not mcap_path.is_file():
        raise FileNotFoundError(f"MCAP file not found: {mcap_path}")

    stem = mcap_path.stem
    resolved_clip_id = _normalize_metadata_value(clip_id) or stem
    resolved_date_name = _normalize_metadata_value(date_name)
    if not resolved_date_name and len(stem) == 14 and stem.isdigit():
        resolved_date_name = stem
    resolved_vehicle_name = _normalize_metadata_value(vehicle_name) or LOCAL_MCAP_VEHICLE_NAME

    return ClipTask(
        clip_uuid=resolved_clip_id,
        date_name=resolved_date_name or DEFAULT_DATE_NAME,
        vehicle_name=resolved_vehicle_name,
        clip_path=str(mcap_path),
    )


def build_case_dir_name(prefix: str, clip_task: ClipTask) -> str:
    safe_clip_uuid = clip_task.clip_uuid.replace("/", "_")
    return f"{prefix}_{clip_task.vehicle_name}_{clip_task.date_name}_{safe_clip_uuid}"


def _plane_to_ndarray(plane: Any) -> 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 _yuvj420p_to_nv12(
    y_plane: np.ndarray,
    u_plane: np.ndarray,
    v_plane: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
    uv_height = u_plane.shape[0]
    uv_width = u_plane.shape[1]
    uv_plane = np.zeros((uv_height, uv_width * 2), dtype=np.uint8)
    uv_plane[:, 0::2] = u_plane
    uv_plane[:, 1::2] = v_plane
    return y_plane.copy(), uv_plane


def _h265_payload_to_bgr(payload: bytes) -> np.ndarray:
    try:
        import av
    except ImportError as exc:
        raise ImportError("PyAV is required for mcap decoding. Please install: pip install av") 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])
        y_nv12, uv_nv12 = _yuvj420p_to_nv12(y_plane, u_plane, v_plane)
        yuv_image = np.concatenate((y_nv12, uv_nv12), axis=0)
        return cv2.cvtColor(yuv_image, cv2.COLOR_YUV2BGR_NV12)
    raise ValueError("decode failed: no video frame in payload")


def _resolve_calib_file_for_clip(clip_path: str) -> Optional[Path]:
    base = Path(clip_path).resolve()
    search_dir = base.parent
    candidates: list[Path] = []

    for _ in range(4):
        candidates.extend(
            [
                search_dir / "calibs" / "camera4.json",
                search_dir / "test_data" / "calibs" / "camera4.json",
                search_dir / "L2_calib" / "camera4.json",
                search_dir / "calib" / "L2_calib" / "camera4.json",
            ]
        )
        parent = search_dir.parent
        if parent == search_dir:
            break
        search_dir = parent

    for calib_path in candidates:
        if calib_path.exists():
            return calib_path
    return None


def _candidate_camera_topics(camera_topic: str) -> list[str]:
    topic = str(camera_topic or "").strip()
    if not topic:
        return []

    topics = [topic]
    if "." in topic:
        topics.append(topic.rsplit(".", 1)[-1])
    else:
        topics.append(f"sigmastar.1.{topic}")
    return list(dict.fromkeys(topics))


def _resolve_reader_camera_topics(reader: Any, camera_topic: str) -> list[str]:
    candidates = _candidate_camera_topics(camera_topic)
    topic_map = getattr(reader, "_topic_map", None)
    if isinstance(topic_map, dict):
        for candidate in candidates:
            if candidate in topic_map:
                return [candidate]
        for key, values in topic_map.items():
            if any(candidate in values for candidate in candidates):
                return [key]
    return candidates


def _decode_mcap_to_images(
    clip_uuid: str,
    clip_path: str,
    output_dir: Path,
    camera_topic: str,
    max_frames: int,
) -> tuple[int, Optional[dict[str, Any]], list[dict[str, Any]]]:
    from pdcl_pyclip.decoder_struct import StructDecoder
    from pdcl_pyclip.msg_camera import VideoMessage
    from pdcl_pyclip.reader import ClipReader

    output_dir.mkdir(parents=True, exist_ok=True)
    reader = ClipReader(clip_path)
    struct_decoder = StructDecoder()
    saved = 0
    camera4_json = None
    decode_errors: list[dict[str, Any]] = []
    camera_topics = _resolve_reader_camera_topics(reader, camera_topic)

    for schema, channel, msg in reader.iter_messages(topics=camera_topics):
        if schema.encoding != "struct":
            continue
        data = struct_decoder.decode(schema, channel, msg)
        if not isinstance(data, VideoMessage):
            continue

        try:
            image = _h265_payload_to_bgr(data.payload)
        except Exception as exc:
            frame_id = getattr(data, "frame_id", None)
            error_record = {
                "frame_id": None if frame_id is None else str(frame_id),
                "error_type": type(exc).__name__,
                "error": str(exc),
            }
            decode_errors.append(error_record)
            print(
                f"[warn] clip_id={clip_uuid} frame_id={error_record['frame_id']} "
                f"-> skip bad frame: {error_record['error_type']}: {error_record['error']}"
            )
            continue

        frame_id_token = _normalize_frame_name_token(getattr(data, "frame_id", None))
        timestamp_token = _normalize_frame_name_token(getattr(msg, "log_time", None))
        if frame_id_token and timestamp_token:
            frame_name = f"{clip_uuid}_{frame_id_token}_{timestamp_token}.png"
        elif frame_id_token:
            frame_name = f"{clip_uuid}_{frame_id_token}.png"
        elif timestamp_token:
            frame_name = f"{clip_uuid}_{saved:06d}_{timestamp_token}.png"
        else:
            frame_name = f"{clip_uuid}_{saved:06d}.png"
        if not cv2.imwrite(str(output_dir / frame_name), image):
            raise IOError(f"Failed to write image: {output_dir / frame_name}")

        saved += 1
        if max_frames > 0 and saved >= max_frames:
            break

    for attachment in reader.iter_attachments():
        if attachment.name in CALIB_ATTACHMENT_NAMES:
            camera4_json = json.loads(attachment.data.decode("utf-8"))
            break

    return saved, camera4_json, decode_errors


def _save_decode_errors(output_dir: Path, clip_uuid: str, decode_errors: list[dict[str, Any]]) -> Optional[Path]:
    if not decode_errors:
        return None

    output_dir.mkdir(parents=True, exist_ok=True)
    decode_errors_path = output_dir / "decode_errors.json"
    payload = {
        "clip_uuid": clip_uuid,
        "skipped_bad_frame_count": len(decode_errors),
        "errors": decode_errors,
    }
    with open(decode_errors_path, "w", encoding="utf-8") as file:
        json.dump(payload, file, indent=2, ensure_ascii=False)
    return decode_errors_path


def _save_calib_for_clip(
    clip_path: str,
    calib_output_path: Path,
    calib_file_override: str,
    embedded_calib: Optional[dict[str, Any]],
) -> str:
    calib_output_path.parent.mkdir(parents=True, exist_ok=True)

    if calib_file_override:
        calib_src = Path(calib_file_override).resolve()
        if not calib_src.exists():
            raise FileNotFoundError(f"Calibration file {calib_src} does not exist.")
        calib_output_path.write_bytes(calib_src.read_bytes())
        return f"override:{calib_src}"

    calib_src = _resolve_calib_file_for_clip(clip_path)
    if calib_src is not None:
        calib_output_path.write_bytes(calib_src.read_bytes())
        return f"file:{calib_src}"

    if embedded_calib is None:
        raise FileNotFoundError(
            "Calibration file camera4.json not found near clip and not found in mcap attachments. "
            "Please provide --calib-file explicitly."
        )

    with open(calib_output_path, "w", encoding="utf-8") as file:
        json.dump(embedded_calib, file, indent=2, ensure_ascii=False)
    return "mcap_attachment"


def _build_calib_summary(calib_payload: Optional[dict[str, Any]]) -> dict[str, Any]:
    if not isinstance(calib_payload, dict):
        return {
            "format": "unknown",
            "has_distortion": False,
        }

    intrinsics = calib_payload
    extrinsics = None
    calib_format = "flat_camera4"

    if "intrinsics" in calib_payload:
        intrinsics = calib_payload.get("intrinsics", {}).get("camera4.json", {}) or {}
        extrinsics = calib_payload.get("extrinsics", {}).get("camera4.json", {}) or {}
        calib_format = "combined_calibration"

    distort_coeffs = intrinsics.get("distort_coeffs", calib_payload.get("distort_coeffs", [])) or []
    return {
        "format": calib_format,
        "focal_u": intrinsics.get("focal_u"),
        "focal_v": intrinsics.get("focal_v"),
        "cu": intrinsics.get("cu"),
        "cv": intrinsics.get("cv"),
        "pitch": calib_payload.get("pitch", extrinsics.get("rpy", [None, None, None])[1] if extrinsics else None),
        "yaw": calib_payload.get("yaw", extrinsics.get("rpy", [None, None, None])[2] if extrinsics else None),
        "image_width": calib_payload.get("image_width", calib_payload.get("img_width")),
        "image_height": calib_payload.get("image_height", calib_payload.get("img_height")),
        "distort_coeff_count": len(distort_coeffs),
        "has_distortion": bool(distort_coeffs),
    }


def _save_export_manifest(
    output_dir: Path,
    clip_task: ClipTask,
    frame_count: int,
    calib_source: str,
    camera_topic: str,
    calib_summary: Optional[dict[str, Any]] = None,
    skipped_bad_frame_count: int = 0,
    decode_errors_path: Optional[str] = None,
) -> None:
    manifest = {
        "clip_uuid": clip_task.clip_uuid,
        "date_name": clip_task.date_name,
        "vehicle_name": clip_task.vehicle_name,
        "clip_path": clip_task.clip_path,
        "camera_topic": camera_topic,
        "frame_count": frame_count,
        "skipped_bad_frame_count": skipped_bad_frame_count,
        "calib_source": calib_source,
        "calib_summary": calib_summary,
    }
    if decode_errors_path:
        manifest["decode_errors_path"] = decode_errors_path
    with open(output_dir / "manifest.json", "w", encoding="utf-8") as file:
        json.dump(manifest, file, indent=2, ensure_ascii=False)


def _input_source_label(args: argparse.Namespace) -> str:
    return str(getattr(args, "mcap_file", "") or getattr(args, "clip_list_file", ""))


def _input_source_mode(args: argparse.Namespace) -> str:
    if getattr(args, "mcap_file", ""):
        return "mcap_file"
    return "clip_list"


def _save_calib_summary(output_dir: Path, calib_source: str, calib_summary: dict[str, Any]) -> None:
    payload = {
        "calib_source": calib_source,
        "calib_summary": calib_summary,
    }
    with open(output_dir / "calib_summary.json", "w", encoding="utf-8") as file:
        json.dump(payload, file, indent=2, ensure_ascii=False)


def export_one_clip(
    clip_task: ClipTask,
    args: argparse.Namespace,
    status: StatusStore,
) -> TaskResult:
    task_id = clip_task.task_id
    if args.skip_done and status.is_done(task_id):
        info = status.get(task_id) or {}
        return TaskResult(task_id=task_id, success=True, message=f"skip done: {info.get('detail', '')}")

    case_dir = Path(args.output_root) / build_case_dir_name(args.output_prefix, clip_task)
    images_dir = case_dir / "images"
    calib_path = case_dir / "calib" / "L2_calib" / "camera4.json"
    status.mark_running(task_id, f"running source={clip_task.clip_path}")

    try:
        frame_count, embedded_calib, decode_errors = _decode_mcap_to_images(
            clip_uuid=clip_task.clip_uuid,
            clip_path=clip_task.clip_path,
            output_dir=images_dir,
            camera_topic=args.camera_topic,
            max_frames=args.max_frames_per_clip,
        )
        decode_errors_path = _save_decode_errors(case_dir, clip_task.clip_uuid, decode_errors)
        if frame_count <= 0:
            if decode_errors:
                first_error = decode_errors[0]
                raise RuntimeError(
                    f"Failed to decode any frames; skipped {len(decode_errors)} bad frames. "
                    f"First error: {first_error['error_type']}: {first_error['error']}. "
                    f"See {decode_errors_path}"
                )
            raise RuntimeError(
                f"No frames were decoded from clip {clip_task.clip_uuid} on topic "
                f"{args.camera_topic!r} (tried: {', '.join(_candidate_camera_topics(args.camera_topic))})."
            )
        calib_source = _save_calib_for_clip(
            clip_path=clip_task.clip_path,
            calib_output_path=calib_path,
            calib_file_override=args.calib_file,
            embedded_calib=embedded_calib,
        )
        with open(calib_path, "r", encoding="utf-8") as file:
            calib_payload = json.load(file)
        calib_summary = _build_calib_summary(calib_payload)
        _save_export_manifest(
            output_dir=case_dir,
            clip_task=clip_task,
            frame_count=frame_count,
            calib_source=calib_source,
            camera_topic=args.camera_topic,
            calib_summary=calib_summary,
            skipped_bad_frame_count=len(decode_errors),
            decode_errors_path=str(decode_errors_path) if decode_errors_path else None,
        )
        _save_calib_summary(case_dir, calib_source=calib_source, calib_summary=calib_summary)
        status.mark_done(task_id, str(case_dir))
        return TaskResult(
            task_id=task_id,
            success=True,
            message=f"ok frames={frame_count} skipped_bad_frames={len(decode_errors)}",
            output_dir=str(case_dir),
        )
    except Exception as exc:
        message = f"{type(exc).__name__}: {exc}"
        status.mark_failed(task_id, message)
        err_log = Path(args.output_root) / "_status" / "errors.log"
        err_log.parent.mkdir(parents=True, exist_ok=True)
        with open(err_log, "a", encoding="utf-8") as file:
            file.write(f"[{task_id}] {message}\n{traceback.format_exc()}\n")
        return TaskResult(task_id=task_id, success=False, message=message)


def save_run_manifest(args: argparse.Namespace, clip_tasks: list[ClipTask]) -> None:
    manifest_path = Path(args.output_root) / "_status" / "run_manifest.json"
    manifest_path.parent.mkdir(parents=True, exist_ok=True)
    payload = {
        "source_mode": _input_source_mode(args),
        "source": _input_source_label(args),
        "clip_list_file": getattr(args, "clip_list_file", ""),
        "mcap_file": getattr(args, "mcap_file", ""),
        "output_root": args.output_root,
        "camera_topic": args.camera_topic,
        "max_frames_per_clip": args.max_frames_per_clip,
        "num_tasks": len(clip_tasks),
        "tasks": [
            {
                "task_id": task.task_id,
                "clip_uuid": task.clip_uuid,
                "date_name": task.date_name,
                "vehicle_name": task.vehicle_name,
                "clip_path": task.clip_path,
            }
            for task in clip_tasks
        ],
    }
    with open(manifest_path, "w", encoding="utf-8") as file:
        json.dump(payload, file, indent=2, ensure_ascii=False)


def has_reusable_exported_case(case_dir: Path) -> bool:
    images_dir = case_dir / "images"
    calib_path = case_dir / "calib" / "L2_calib" / "camera4.json"
    if not images_dir.is_dir() or not calib_path.is_file():
        return False
    return any(path.is_file() for path in images_dir.iterdir())


def save_exported_batch_manifest(args: argparse.Namespace, clip_tasks: list[ClipTask], output_root: Path) -> None:
    manifest_path = output_root / "_status" / "run_manifest.json"
    manifest_path.parent.mkdir(parents=True, exist_ok=True)
    payload = {
        "source_mode": _input_source_mode(args),
        "source": _input_source_label(args),
        "clip_list_file": getattr(args, "clip_list_file", ""),
        "mcap_file": getattr(args, "mcap_file", ""),
        "export_root": args.export_root,
        "output_dir": str(output_root),
        "camera_topic": args.camera_topic,
        "max_frames_per_clip": args.max_frames_per_clip,
        "exported_model": args.exported_model,
        "edge_yaw_max_lateral_dist_m": args.edge_yaw_max_lateral_dist,
        "roi_models": {
            "roi0": args.roi0_model,
            "roi1": args.roi1_model,
        },
        "num_tasks": len(clip_tasks),
        "tasks": [
            {
                "task_id": task.task_id,
                "clip_uuid": task.clip_uuid,
                "date_name": task.date_name,
                "vehicle_name": task.vehicle_name,
                "clip_path": task.clip_path,
            }
            for task in clip_tasks
        ],
    }
    with manifest_path.open("w", encoding="utf-8") as file:
        json.dump(payload, file, indent=2, ensure_ascii=False)


def run_clip_tasks_inference_exported(
    *,
    context: Any,
    args: argparse.Namespace,
    clip_tasks: list[ClipTask],
    load_env: Callable[[], Any],
    run_case_inference: Callable[..., dict[str, Any]],
    export_root: str | Path | None = None,
    output_root: str | Path | None = None,
    on_before_run: Optional[Callable[[Path, Path, list[ClipTask]], None]] = None,
    require_pdcl_auth: bool = True,
) -> dict[str, Any]:
    load_env()
    if require_pdcl_auth:
        validate_pdcl_auth_env()

    export_root = Path(args.export_root if export_root is None else export_root).resolve()
    output_root = Path(args.output_dir if output_root is None else output_root).resolve()
    export_root.mkdir(parents=True, exist_ok=True)
    output_root.mkdir(parents=True, exist_ok=True)

    args.output_root = str(export_root)

    if not clip_tasks:
        print("No clip tasks discovered. Exit.")
        return {
            "source_mode": _input_source_mode(args),
            "source": _input_source_label(args),
            "clip_list_file": getattr(args, "clip_list_file", ""),
            "mcap_file": getattr(args, "mcap_file", ""),
            "output_dir": str(output_root),
            "total": 0,
            "success": 0,
            "failed": 0,
        }

    export_status = StatusStore(export_root / "_status" / "task_status.json")
    infer_status = StatusStore(output_root / "_status" / "task_status.json")
    if on_before_run is not None:
        on_before_run(export_root, output_root, clip_tasks)
    save_run_manifest(args, clip_tasks)
    save_exported_batch_manifest(args, clip_tasks, output_root)

    print(f"Discovered {len(clip_tasks)} clips.")
    print(f"Export root: {export_root}")
    print(f"Visualization root: {output_root}")

    success = 0
    fail = 0
    for index, clip_task in enumerate(clip_tasks, start=1):
        task_id = clip_task.task_id
        if args.skip_done and infer_status.is_done(task_id):
            info = infer_status.get(task_id) or {}
            print(f"[{index}/{len(clip_tasks)}] clip_id={task_id} -> skip done: {info.get('detail', '')}")
            continue

        print(f"[{index}/{len(clip_tasks)}] clip_id={task_id} source={clip_task.clip_path}")
        infer_status.mark_running(task_id, "exporting clip and running exported two-roi inference")
        try:
            case_dir = export_root / build_case_dir_name(args.output_prefix, clip_task)
            if has_reusable_exported_case(case_dir):
                export_status.mark_done(task_id, f"reuse existing export: {case_dir}")
                print(f"  -> reuse exported clip data from {case_dir}")
            else:
                export_result = export_one_clip(clip_task, args, export_status)
                if not export_result.success or not export_result.output_dir:
                    raise RuntimeError(export_result.message)
                case_dir = Path(export_result.output_dir)

            infer_result = run_case_inference(
                context=context,
                case_dir=str(case_dir),
                output_dir=output_root / case_dir.name,
                glob_pattern=args.glob,
                max_images=args.max_images,
                save_aggregate_predictions=bool(getattr(args, "save_aggregate_predictions", False)),
                save_visualizations=not bool(getattr(args, "skip_visualizations", False)),
            )
            infer_status.mark_done(task_id, infer_result["output_dir"])
            success += 1
            print(f"  -> saved {infer_result['num_frames']} frames to {infer_result['output_dir']}")
        except Exception as exc:
            fail += 1
            message = f"{type(exc).__name__}: {exc}"
            infer_status.mark_failed(task_id, message)
            err_log = output_root / "_status" / "errors.log"
            err_log.parent.mkdir(parents=True, exist_ok=True)
            with err_log.open("a", encoding="utf-8") as file:
                file.write(f"[{task_id}] {message}\n{traceback.format_exc()}\n")
            print(f"  -> failed: {message}")

    print("\nBatch inference done.")
    print(f"success={success}, fail={fail}")
    print(f"infer_status_summary={infer_status.summary()}")
    return {
        "source_mode": _input_source_mode(args),
        "source": _input_source_label(args),
        "clip_list_file": getattr(args, "clip_list_file", ""),
        "mcap_file": getattr(args, "mcap_file", ""),
        "export_root": str(export_root),
        "output_dir": str(output_root),
        "total": len(clip_tasks),
        "success": success,
        "failed": fail,
    }


def run_clip_list_inference_exported(
    *,
    context: Any,
    args: argparse.Namespace,
    load_env: Callable[[], Any],
    run_case_inference: Callable[..., dict[str, Any]],
) -> dict[str, Any]:
    clip_tasks = parse_clip_list(args.clip_list_file)
    if args.limit_clips > 0:
        clip_tasks = clip_tasks[: args.limit_clips]
    return run_clip_tasks_inference_exported(
        context=context,
        args=args,
        clip_tasks=clip_tasks,
        load_env=load_env,
        run_case_inference=run_case_inference,
    )


def run_mcap_file_inference_exported(
    *,
    context: Any,
    args: argparse.Namespace,
    load_env: Callable[[], Any],
    run_case_inference: Callable[..., dict[str, Any]],
) -> dict[str, Any]:
    clip_task = build_clip_task_from_mcap_file(
        args.mcap_file,
        clip_id=args.mcap_clip_id,
        date_name=args.mcap_date_name,
        vehicle_name=args.mcap_vehicle_name,
    )
    result = run_clip_tasks_inference_exported(
        context=context,
        args=args,
        clip_tasks=[clip_task],
        load_env=load_env,
        run_case_inference=run_case_inference,
        require_pdcl_auth=False,
    )
    if result.get("failed", 0) > 0 or result.get("success", 0) < result.get("total", 0):
        raise RuntimeError(f"MCAP inference failed for {args.mcap_file}")
    return result
