#!/usr/bin/env python3
"""Track exported inference outputs at event scope across multiple clips.

This tool aggregates all clip-level per-frame JSON predictions under one
event directory, orders frames globally by timestamp parsed from filenames,
and then reuses the existing tracking logic from track_objects.py to produce
one tracking result set per event.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional


FILE = Path(__file__).resolve()
THIS_DIR = FILE.parent
if str(THIS_DIR) not in sys.path:
    sys.path.insert(0, str(THIS_DIR))

from merge_tracking_results import TRACK_ID_OFFSET_PER_SOURCE  # noqa: E402
from track_objects import (  # noqa: E402
    TRACKED_CLASS_IDS,
    count_unique_tracks,
    parse_det_format,
    save_tracking_results,
    track_objects,
)


SOURCE_SPECS = (
    ("roi0", "roi0.json"),
    ("roi1", "roi1.json"),
    ("merge", "merge.json"),
)
DEFAULT_EVENT_OUTPUT_DIRNAME = "event_tracking"


@dataclass
class EventFrameRecord:
    key: str
    clip_case_name: str
    clip_token: str
    original_image_name: str
    original_frame_id: Optional[int]
    timestamp: Optional[int]
    source_files: dict[str, Path] = field(default_factory=dict)
    event_frame_index: int = -1
    event_frame_id: int = -1
    image_name: str = ""


def _safe_int(value: Any) -> Optional[int]:
    if value is None:
        return None
    try:
        return int(str(value).strip())
    except (TypeError, ValueError):
        return None


def _normalize_output_dir_token(value: Any) -> str:
    token = re.sub(r'[\\/:*?"<>|\s]+', "_", str(value or "").strip())
    return token.strip("._")


def _extract_date_name_from_records(records: Any) -> Optional[str]:
    if not isinstance(records, list):
        return None

    for record in records:
        if not isinstance(record, dict):
            continue
        source_record = record.get("source_record")
        if not isinstance(source_record, dict):
            continue
        for key in ("date_name", "datename", "datetime", "date"):
            value = source_record.get(key)
            normalized = _normalize_output_dir_token(value)
            if normalized:
                return normalized
    return None


def parse_frame_name_metadata(image_name: str) -> tuple[str, Optional[int], Optional[int]]:
    """Parse clip token, frame_id, and timestamp from an exported frame stem."""
    stem = str(image_name or "").strip()
    if not stem:
        return "", None, None

    parts = stem.split("_")
    numeric_tail = []
    while parts and parts[-1].isdigit() and len(numeric_tail) < 2:
        numeric_tail.append(parts.pop())

    numeric_tail.reverse()
    clip_token = "_".join(parts).strip("_")
    if len(numeric_tail) >= 2:
        return clip_token, _safe_int(numeric_tail[0]), _safe_int(numeric_tail[1])
    if len(numeric_tail) == 1:
        return clip_token, _safe_int(numeric_tail[0]), None
    return stem, None, None


def build_event_sort_key(record: EventFrameRecord) -> tuple[Any, ...]:
    timestamp_missing = record.timestamp is None
    timestamp_value = record.timestamp if record.timestamp is not None else float("inf")
    frame_id_missing = record.original_frame_id is None
    frame_id_value = record.original_frame_id if record.original_frame_id is not None else float("inf")
    return (
        timestamp_missing,
        timestamp_value,
        record.clip_case_name,
        frame_id_missing,
        frame_id_value,
        record.original_image_name,
    )


def find_event_case_dirs(event_dir: Path) -> list[Path]:
    """Return all clip-case directories directly under one event directory."""
    case_dirs = []
    for predictions_dir in sorted(event_dir.glob("*/predictions")):
        if not predictions_dir.is_dir():
            continue
        case_dirs.append(predictions_dir.parent)
    return case_dirs


def collect_event_frames(
    event_dir: Path,
    pattern: str,
    *,
    verbose: bool = True,
) -> tuple[list[EventFrameRecord], list[Path]]:
    """Collect and globally order all per-frame JSON files under one event."""
    case_dirs = find_event_case_dirs(event_dir)
    if not case_dirs:
        raise FileNotFoundError(f"No clip case predictions found under event directory: {event_dir}")

    frame_map: dict[str, EventFrameRecord] = {}
    source_file_count = 0

    for case_dir in case_dirs:
        predictions_dir = case_dir / "predictions"
        for source_name, _ in SOURCE_SPECS:
            source_dir = predictions_dir / source_name
            if not source_dir.is_dir():
                continue

            for json_file in sorted(source_dir.glob(pattern)):
                source_file_count += 1
                original_image_name = json_file.stem
                clip_token, frame_id, timestamp = parse_frame_name_metadata(original_image_name)
                key = f"{case_dir.name}:{original_image_name}"
                record = frame_map.get(key)
                if record is None:
                    record = EventFrameRecord(
                        key=key,
                        clip_case_name=case_dir.name,
                        clip_token=clip_token,
                        original_image_name=original_image_name,
                        original_frame_id=frame_id,
                        timestamp=timestamp,
                    )
                    frame_map[key] = record
                elif record.timestamp is None and timestamp is not None:
                    record.timestamp = timestamp
                elif record.original_frame_id is None and frame_id is not None:
                    record.original_frame_id = frame_id

                record.source_files[source_name] = json_file

    ordered_frames = sorted(frame_map.values(), key=build_event_sort_key)
    if not ordered_frames:
        raise FileNotFoundError(
            f"No source frame JSON files matching {pattern!r} were found under event directory: {event_dir}"
        )

    for frame_index, record in enumerate(ordered_frames):
        event_frame_id = frame_index + 1
        timestamp_token = record.timestamp if record.timestamp is not None else event_frame_id
        record.event_frame_index = frame_index
        record.event_frame_id = event_frame_id
        record.image_name = f"camera4_{event_frame_id:06d}_{int(timestamp_token)}"

    if verbose:
        print(f"Discovered {len(case_dirs)} clip case(s) under {event_dir}")
        print(f"Collected {source_file_count} source frame JSON file(s)")
        print(f"Built {len(ordered_frames)} event frame(s) after cross-clip ordering")

    return ordered_frames, case_dirs


def load_event_metadata(event_dir: Path) -> dict[str, Any]:
    """Load optional event manifest metadata for reporting only."""
    manifest_path = event_dir / "_status" / "event_manifest.json"
    payload: dict[str, Any] = {}
    if manifest_path.is_file():
        with manifest_path.open("r", encoding="utf-8") as file:
            payload = json.load(file)

    event_id = payload.get("event_id", event_dir.name)
    scene = payload.get("scene", event_dir.parent.name)
    date_name = _extract_date_name_from_records(payload.get("records"))

    if not date_name:
        scene_manifest_path = event_dir.parent / "_status" / "scene_event_manifest.json"
        if scene_manifest_path.is_file():
            with scene_manifest_path.open("r", encoding="utf-8") as file:
                scene_payload = json.load(file)
            scene_records = scene_payload.get("records", [])
            matched_records = [
                record for record in scene_records
                if isinstance(record, dict) and str(record.get("event_id", "")).strip() == str(event_id)
            ]
            date_name = _extract_date_name_from_records(matched_records)

    if not date_name:
        date_name = DEFAULT_EVENT_OUTPUT_DIRNAME

    return {
        "event_id": event_id,
        "scene": scene,
        "manifest_path": str(manifest_path) if manifest_path.is_file() else "",
        "clip_ids": payload.get("clip_ids", []),
        "clip_count": int(payload.get("clip_count", 0) or 0),
        "date_name": date_name,
    }


def build_frame_info(record: EventFrameRecord, source_name: str) -> dict[str, Any]:
    """Build frame metadata that will be copied through track_objects.py."""
    return {
        "event_frame_index": record.event_frame_index,
        "event_frame_id": record.event_frame_id,
        "source": source_name,
        "clip_case_name": record.clip_case_name,
        "clip_token": record.clip_token,
        "original_image_name": record.original_image_name,
        "original_frame_id": record.original_frame_id,
        "timestamp": record.timestamp,
        "source_json_path": str(record.source_files[source_name]),
    }


def load_source_predictions(
    ordered_frames: list[EventFrameRecord],
    source_name: str,
    *,
    model_version: Optional[str] = None,
) -> list[dict[str, Any]]:
    """Load all available frames for one source in event-global temporal order."""
    predictions_data: list[dict[str, Any]] = []
    for record in ordered_frames:
        source_file = record.source_files.get(source_name)
        if source_file is None:
            continue

        with source_file.open("r", encoding="utf-8") as file:
            det_dict = json.load(file)
        frame_info = build_frame_info(record, source_name)
        frame_data = parse_det_format(
            det_dict,
            image_name=record.image_name,
            timestamp_lookup=None,
            model_version=model_version,
            frame_info=frame_info,
        )
        frame_data["frame_info"] = frame_info
        predictions_data.append(frame_data)
    return predictions_data


def merge_event_tracking_results(
    *,
    ordered_frames: list[EventFrameRecord],
    tracking_results_by_source: dict[str, list[dict[str, Any]]],
) -> list[dict[str, Any]]:
    """Merge per-source event tracking results while preserving event order."""
    frame_maps = {
        source_name: {
            frame.get("image_name"): frame
            for frame in tracking_results
        }
        for source_name, tracking_results in tracking_results_by_source.items()
    }

    merged_frames: list[dict[str, Any]] = []
    for ordered_frame in ordered_frames:
        image_name = ordered_frame.image_name
        merged_detections = []
        merged_stats = {}
        frame_info = None

        for source_idx, (source_name, _) in enumerate(SOURCE_SPECS):
            frame = frame_maps.get(source_name, {}).get(image_name)
            if frame is None:
                continue

            frame_info = frame_info or frame.get("frame_info")
            for det in frame.get("detections", []):
                tagged = dict(det)
                tagged["lane_assignment"] = source_idx
                if "track_id" in tagged and tagged["track_id"] is not None:
                    tagged["track_id"] = tagged["track_id"] + source_idx * TRACK_ID_OFFSET_PER_SOURCE
                merged_detections.append(tagged)

            if "tracking_stats" in frame:
                merged_stats[source_name] = frame["tracking_stats"]

        if not merged_detections and not merged_stats:
            continue

        merged_frame = {
            "image_name": image_name,
            "detections": merged_detections,
        }
        if frame_info is not None:
            merged_frame["frame_info"] = frame_info
        if merged_stats:
            merged_frame["tracking_stats"] = merged_stats
        merged_frames.append(merged_frame)

    return merged_frames


def build_frame_manifest_payload(
    *,
    event_dir: Path,
    output_dir: Path,
    event_metadata: dict[str, Any],
    case_dirs: list[Path],
    ordered_frames: list[EventFrameRecord],
    source_summaries: dict[str, dict[str, Any]],
    merge_output_path: Path,
) -> dict[str, Any]:
    return {
        "event_dir": str(event_dir),
        "output_dir": str(output_dir),
        "event_id": event_metadata.get("event_id", event_dir.name),
        "scene": event_metadata.get("scene", event_dir.parent.name),
        "date_name": event_metadata.get("date_name", DEFAULT_EVENT_OUTPUT_DIRNAME),
        "event_manifest_path": event_metadata.get("manifest_path", ""),
        "clip_ids": event_metadata.get("clip_ids", []),
        "clip_count": event_metadata.get("clip_count", len(case_dirs)),
        "clip_case_dirs": [str(case_dir) for case_dir in case_dirs],
        "source_summaries": source_summaries,
        "merge_output_path": str(merge_output_path),
        "event_frame_count": len(ordered_frames),
        "frames": [
            {
                "event_frame_index": record.event_frame_index,
                "event_frame_id": record.event_frame_id,
                "image_name": record.image_name,
                "timestamp": record.timestamp,
                "clip_case_name": record.clip_case_name,
                "clip_token": record.clip_token,
                "original_image_name": record.original_image_name,
                "original_frame_id": record.original_frame_id,
                "source_files": {
                    source_name: str(path)
                    for source_name, path in sorted(record.source_files.items())
                },
            }
            for record in ordered_frames
        ],
    }


def run_event_tracking(
    *,
    event_dir: Path,
    output_dir: Path,
    file_pattern: str,
    classes: list[int],
    iou_threshold: float,
    max_age: int,
    min_hits: int,
    distance_threshold: float,
    use_3d: bool,
    max_3d_distance: float,
    model_version: Optional[str],
    merge_output_name: str,
    manifest_name: str,
    verbose: bool = True,
) -> dict[str, Any]:
    event_metadata = load_event_metadata(event_dir)
    ordered_frames, case_dirs = collect_event_frames(event_dir, file_pattern, verbose=verbose)
    output_dir.mkdir(parents=True, exist_ok=True)

    tracking_results_by_source: dict[str, list[dict[str, Any]]] = {}
    source_summaries: dict[str, dict[str, Any]] = {}

    for source_name, output_name in SOURCE_SPECS:
        predictions_data = load_source_predictions(
            ordered_frames,
            source_name,
            model_version=model_version,
        )
        output_path = output_dir / output_name

        if not predictions_data:
            source_summaries[source_name] = {
                "ok": False,
                "reason": "no_frames",
                "frames": 0,
                "unique_tracks": 0,
                "output_path": str(output_path),
            }
            if verbose:
                print(f"Warning: no frames found for source {source_name} under {event_dir}")
            continue

        if verbose:
            print("")
            print(f"--- Tracking {source_name} at event scope ---")
            print(f"Frames: {len(predictions_data)}")
            print(f"Output: {output_path}")

        tracking_results = track_objects(
            predictions_data,
            target_classes=classes,
            iou_threshold=iou_threshold,
            max_age=max_age,
            min_hits=min_hits,
            distance_threshold=distance_threshold,
            use_3d=use_3d,
            max_3d_distance=max_3d_distance,
            verbose=verbose,
        )
        save_tracking_results(tracking_results, output_path)
        tracking_results_by_source[source_name] = tracking_results
        source_summaries[source_name] = {
            "ok": True,
            "frames": len(predictions_data),
            "unique_tracks": count_unique_tracks(tracking_results),
            "output_path": str(output_path),
        }

    combined_output_path = output_dir / merge_output_name
    if not tracking_results_by_source:
        raise RuntimeError(f"No valid source predictions were loaded for event: {event_dir}")

    combined_tracking = merge_event_tracking_results(
        ordered_frames=ordered_frames,
        tracking_results_by_source=tracking_results_by_source,
    )
    save_tracking_results(combined_tracking, combined_output_path)

    manifest_path = output_dir / manifest_name
    manifest_payload = build_frame_manifest_payload(
        event_dir=event_dir,
        output_dir=output_dir,
        event_metadata=event_metadata,
        case_dirs=case_dirs,
        ordered_frames=ordered_frames,
        source_summaries=source_summaries,
        merge_output_path=combined_output_path,
    )
    with manifest_path.open("w", encoding="utf-8") as file:
        json.dump(manifest_payload, file, indent=2, ensure_ascii=False)

    if verbose:
        print("")
        print("==========================================")
        print(f"Event  : {event_metadata.get('event_id', event_dir.name)}")
        print(f"Scene  : {event_metadata.get('scene', event_dir.parent.name)}")
        print(f"Date   : {event_metadata.get('date_name', DEFAULT_EVENT_OUTPUT_DIRNAME)}")
        print(f"Clips  : {len(case_dirs)}")
        print(f"Frames : {len(ordered_frames)}")
        print(f"Merge  : {combined_output_path}")
        print(f"Manifest: {manifest_path}")
        for source_name, _ in SOURCE_SPECS:
            summary = source_summaries.get(source_name, {})
            status = "ok" if summary.get("ok") else summary.get("reason", "skipped")
            print(
                f"  - {source_name}: {status}, frames={summary.get('frames', 0)}, "
                f"tracks={summary.get('unique_tracks', 0)}"
            )
        print("==========================================")

    return {
        "event_dir": str(event_dir),
        "output_dir": str(output_dir),
        "manifest_path": str(manifest_path),
        "merge_output_path": str(combined_output_path),
        "event_frame_count": len(ordered_frames),
        "clip_case_count": len(case_dirs),
        "source_summaries": source_summaries,
    }


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Track all clip-level exported inference results under one event directory."
    )
    parser.add_argument("--event-dir", required=True, help="Event directory containing multiple clip-case outputs")
    parser.add_argument(
        "--output-dir",
        default=None,
        help="Output directory for event-level tracking results (default: <event-dir>/event_tracking)",
    )
    parser.add_argument("--file-pattern", default="*.json", help="Glob pattern for per-frame JSONs in each source dir")
    parser.add_argument(
        "--classes",
        type=int,
        nargs="+",
        default=None,
        help="Class IDs to track (default: track_objects.py defaults)",
    )
    parser.add_argument("--iou-threshold", type=float, default=0.3)
    parser.add_argument("--max-age", type=int, default=5)
    parser.add_argument("--min-hits", type=int, default=1)
    parser.add_argument("--distance-threshold", type=float, default=100.0)
    parser.add_argument("--model-version", type=str, default=None)
    parser.add_argument("--use-3d", action="store_true")
    parser.add_argument("--max-3d-distance", type=float, default=10.0)
    parser.add_argument("--merge-output-name", type=str, default="combined_tracking.json")
    parser.add_argument("--manifest-name", type=str, default="frame_order_manifest.json")
    parser.add_argument("--quiet", action="store_true", help="Reduce progress logging")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    event_dir = Path(args.event_dir).resolve()
    if not event_dir.is_dir():
        raise FileNotFoundError(f"Event directory does not exist: {event_dir}")

    output_dir = (
        Path(args.output_dir).resolve()
        if args.output_dir is not None
        else event_dir / load_event_metadata(event_dir).get("date_name", DEFAULT_EVENT_OUTPUT_DIRNAME)
    )
    classes = list(TRACKED_CLASS_IDS) if args.classes is None else [int(cls_id) for cls_id in args.classes]

    run_event_tracking(
        event_dir=event_dir,
        output_dir=output_dir,
        file_pattern=args.file_pattern,
        classes=classes,
        iou_threshold=args.iou_threshold,
        max_age=args.max_age,
        min_hits=args.min_hits,
        distance_threshold=args.distance_threshold,
        use_3d=args.use_3d,
        max_3d_distance=args.max_3d_distance,
        model_version=args.model_version,
        merge_output_name=args.merge_output_name,
        manifest_name=args.manifest_name,
        verbose=not args.quiet,
    )


if __name__ == "__main__":
    main()
