#!/usr/bin/env python3
# Copyright (c) 2023-2026, AgiBot Inc. All Rights Reserved.
# Author: Genie Sim Team
# License: Mozilla Public License Version 2.0

import os
import sys
import time
import signal
import subprocess
import threading
import logging
import shutil
import argparse
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from datetime import datetime

import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image, CompressedImage
from std_msgs.msg import String
from cv_bridge import CvBridge
import cv2
import numpy as np

from rosbags.highlevel import AnyReader
from rosbags.typesys import Stores, get_typestore
from rosbags.image import compressed_image_to_cvimage

# Configure logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logger = logging.getLogger("auto_record_extractor")

# Constant definitions
NANOSEC_TO_SEC = 1e-9
DEFAULT_JPEG_QUALITY = 85
DEFAULT_TIMEOUT = 10.0
DEFAULT_FPS = 30
DEFAULT_MAX_IO_WORKERS = 4
PROGRESS_LOG_INTERVAL = 500


def map_topic_to_camera_name(topic_name: str) -> str:
    name = topic_name.split("/")[-1]
    if "camera_rgb" == name:
        return "world_img"
    if "Head" in name or "head" in name:
        return "head"
    if "Right" in name or "right" in name:
        return "hand_right"
    if "Left" in name or "left" in name:
        return "hand_left"
    return name


class ImageForwardRecorderNode(Node):
    def __init__(
        self,
        output_dir,
        timeout=DEFAULT_TIMEOUT,
        jpeg_quality=DEFAULT_JPEG_QUALITY,
        final_output_dir: str | None = None,
        delete_db3_after: bool = False,
    ):
        super().__init__("image_forward_recorder")
        self.output_dir = output_dir
        self.timeout = timeout
        self.jpeg_quality = jpeg_quality
        self.final_output_dir = final_output_dir
        self.delete_db3_after = delete_db3_after
        self.max_io_workers = DEFAULT_MAX_IO_WORKERS

        os.makedirs(self.output_dir, exist_ok=True)
        self.bridge = CvBridge()
        self.subscribers = {}
        self.genie_sim_subscribers = {}
        self.record_publishers = {}
        self.last_genie_sim_message_time = time.time()
        self.message_lock = threading.Lock()
        self.record_process = None
        self.is_recording = False
        self.should_stop = False
        self.sub_task_name = ""
        self.sub_task_name_received = False
        self.sub_task_name_lock = threading.Lock()
        
        self.sub_task_name_subscription = self.create_subscription(
            String, "/record/sub_task_name", self.sub_task_name_callback, 10
        )
        self.topic_discovery_timer = self.create_timer(2.0, self.discover_topics)
        self.timeout_check_timer = self.create_timer(1.0, self.check_timeout)

    def sub_task_name_callback(self, msg):
        if self.should_stop: return
        with self.sub_task_name_lock:
            if msg and hasattr(msg, "data"):
                self.sub_task_name = msg.data
                self.sub_task_name_received = True

    def discover_topics(self):
        topic_names_and_types = self.get_topic_names_and_types()
        for topic_name, topic_types in topic_names_and_types:
            if topic_name.startswith("/record/") and "sensor_msgs/msg/CompressedImage" in topic_types:
                if topic_name not in self.subscribers:
                    self.subscribers[topic_name] = self.create_subscription(
                        CompressedImage, topic_name, lambda msg, tn=topic_name: self.image_callback(msg, tn), 10
                    )
            if topic_name.startswith("/genie_sim/") and "sensor_msgs/msg/Image" in topic_types:
                if topic_name not in self.genie_sim_subscribers:
                    suffix = topic_name.split("/genie_sim/")[-1]
                    rt = f"/record/{suffix}"
                    self.genie_sim_subscribers[topic_name] = self.create_subscription(
                        Image, topic_name, lambda msg, tn=topic_name, rt=rt: self.genie_sim_callback(msg, tn, rt), 10
                    )
                    if rt not in self.record_publishers:
                        self.record_publishers[rt] = self.create_publisher(CompressedImage, rt, 10)

    def genie_sim_callback(self, msg, topic_name, record_topic):
        with self.message_lock: self.last_genie_sim_message_time = time.time()
        try:
            cv_img = self.bridge.imgmsg_to_cv2(msg, "bgr8")
            _, compressed_data = cv2.imencode(".jpg", cv_img, [int(cv2.IMWRITE_JPEG_QUALITY), self.jpeg_quality])
            c_msg = CompressedImage()
            c_msg.header, c_msg.format, c_msg.data = msg.header, "jpeg", compressed_data.tobytes()
            if record_topic in self.record_publishers: self.record_publishers[record_topic].publish(c_msg)
        except Exception as e: logger.error(f"Error forwarding: {e}")

    def image_callback(self, msg, topic_name):
        if not self.is_recording: self.start_recording()

    def start_recording(self):
        with self.sub_task_name_lock:
            if not self.sub_task_name_received or not self.sub_task_name: return
            sub_task_name = self.sub_task_name
        
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        recording_dir = os.path.join(self.output_dir, "recording_data", sub_task_name)
        os.makedirs(recording_dir, exist_ok=True)
        bag_path = os.path.join(recording_dir, f"recording_{timestamp}")
        topics = " ".join(self.subscribers.keys())
        cmd = f"source /opt/ros/jazzy/setup.bash && ros2 bag record -o {bag_path} {topics}"
        self.record_process = subprocess.Popen(cmd, shell=True, executable="/bin/bash", preexec_fn=os.setsid)
        self.is_recording, self.bag_path = True, bag_path
        logger.info(f"Started recording to {bag_path}")

    def check_timeout(self):
        if self.should_stop: return
        with self.message_lock: elapsed = time.time() - self.last_genie_sim_message_time
        if elapsed > self.timeout:
            if self.is_recording: self.stop_recording()
            self.should_stop = True

    def stop_recording(self):
        if not self.is_recording: return
        os.killpg(os.getpgid(self.record_process.pid), signal.SIGINT)
        self.record_process.wait(timeout=10)
        self.is_recording = False
        time.sleep(2)
        extract_and_convert_standalone(self.bag_path, self.final_output_dir, self.delete_db3_after)

    def cleanup(self):
        self.should_stop = True
        if self.is_recording: self.stop_recording()
        try:
            self.topic_discovery_timer.cancel()
            self.timeout_check_timer.cancel()
            self.sub_task_name_subscription.destroy()
        except: pass


def extract_and_convert_standalone(bag_path, final_output_dir=None, delete_db3=False):
    """Standalone extraction function to avoid memory issues and allow manual trigger."""
    logger.info(f"Starting extraction for bag: {bag_path}")
    bag_path_obj = Path(bag_path)
    images_dir = bag_path_obj / "camera"
    video_dir = bag_path_obj / "video"
    os.makedirs(images_dir, exist_ok=True)
    os.makedirs(video_dir, exist_ok=True)

    typestore = get_typestore(Stores.ROS2_JAZZY)
    
    # --- Pass 1: Collect timestamps for alignment ---
    logger.info("Pass 1: Collecting timestamps...")
    camera_timestamps = {} # camera_name -> [(header_ts, raw_ts)]
    topic_to_cam = {}
    
    with AnyReader([bag_path_obj], default_typestore=typestore) as reader:
        for connection in reader.connections:
            if connection.msgtype == "sensor_msgs/msg/CompressedImage" and connection.topic.startswith("/record/"):
                cam_name = map_topic_to_camera_name(connection.topic.replace("/record/", ""))
                topic_to_cam[connection.topic] = cam_name
                camera_timestamps[cam_name] = []

        for connection, timestamp, msg in reader.messages():
            if connection.topic in topic_to_cam:
                c_msg = reader.deserialize(msg, "sensor_msgs/msg/CompressedImage")
                header_ts = c_msg.header.stamp.sec + c_msg.header.stamp.nanosec * NANOSEC_TO_SEC
                camera_timestamps[topic_to_cam[connection.topic]].append((header_ts, timestamp))

    if not camera_timestamps:
        logger.error("No camera data found in bag.")
        return

    # --- Alignment Logic ---
    cam_names = list(camera_timestamps.keys())
    global_start = max(ts[0][0] for ts in camera_timestamps.values())
    global_end = min(ts[-1][0] for ts in camera_timestamps.values())
    
    ref_cam = "world_img" if "world_img" in camera_timestamps else cam_names[0]
    ref_data = [t for t in camera_timestamps[ref_cam] if global_start <= t[0] <= global_end]
    
    logger.info(f"Aligned range: {global_start:.2f} to {global_end:.2f}. Frames: {len(ref_data)}")
    
    # Map raw_timestamp -> list of (camera_name, frame_idx)
    needed_raw_ts = {} 
    for frame_idx, (ref_header_ts, _) in enumerate(ref_data):
        for cam in cam_names:
            # Find closest frame in this camera
            closest = min(camera_timestamps[cam], key=lambda x: abs(x[0] - ref_header_ts))
            needed_raw_ts.setdefault(closest[1], []).append((cam, frame_idx))

    # --- Pass 2: Extract and write images ---
    logger.info(f"Pass 2: Extracting {len(needed_raw_ts)} unique messages...")
    topic_images = {cam: [] for cam in cam_names}
    
    with AnyReader([bag_path_obj], default_typestore=typestore) as reader:
        count = 0
        for connection, timestamp, msg in reader.messages():
            if timestamp in needed_raw_ts:
                c_msg = reader.deserialize(msg, "sensor_msgs/msg/CompressedImage")
                cv_img = compressed_image_to_cvimage(c_msg, "bgr8")
                
                for cam_name, frame_idx in needed_raw_ts[timestamp]:
                    frame_dir = images_dir / str(frame_idx)
                    os.makedirs(frame_dir, exist_ok=True)
                    img_path = frame_dir / f"{cam_name}.jpg"
                    cv2.imwrite(str(img_path), cv_img)
                    topic_images[cam_name].append(str(img_path))
                
                count += 1
                if count % PROGRESS_LOG_INTERVAL == 0:
                    logger.info(f"Extracted {count} messages...")

    # --- Video Conversion ---
    for cam, paths in topic_images.items():
        if not paths: continue
        logger.info(f"Converting {cam} to video...")
        out_video = video_dir / f"{cam}.webm"
        # Create symlinks for ffmpeg
        tmp_cam_dir = images_dir / cam
        os.makedirs(tmp_cam_dir, exist_ok=True)
        sorted_paths = sorted(paths, key=lambda x: int(Path(x).parent.name))
        for i, p in enumerate(sorted_paths):
            target = tmp_cam_dir / f"frame_{i:06d}.jpg"
            if target.exists(): target.unlink()
            os.link(p, target)
        
        subprocess.run([
            "ffmpeg", "-y", "-framerate", str(DEFAULT_FPS), "-i", str(tmp_cam_dir / "frame_%06d.jpg"),
            "-c:v", "libvpx-vp9", "-b:v", "2000k", "-crf", "28", "-speed", "4", "-an", "-loglevel", "error", str(out_video)
        ])
        logger.info(f"Video saved: {out_video}")

    if delete_db3:
        for f in bag_path_obj.glob("*.db3"): f.unlink()
    
    if final_output_dir:
        dst = Path(final_output_dir) / bag_path_obj.name
        if dst.exists(): shutil.rmtree(dst)
        shutil.move(str(bag_path_obj), str(dst))
        logger.info(f"Moved to final destination: {dst}")


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--output_dir", type=str, default="auto_recordings")
    parser.add_argument("--timeout", type=float, default=10.0)
    parser.add_argument("--jpeg_quality", type=int, default=85)
    parser.add_argument("--final_output_dir", type=str, default=None)
    parser.add_argument("--delete_db3_after", action="store_true")
    parser.add_argument("--bag_path", type=str, help="Manual extraction mode: path to an existing rosbag directory")
    
    args = parser.parse_args()

    if args.bag_path:
        # Manual extraction mode
        extract_and_convert_standalone(args.bag_path, args.final_output_dir, args.delete_db3_after)
        return

    # Normal recording mode
    rclpy.init()
    node = ImageForwardRecorderNode(args.output_dir, args.timeout, args.jpeg_quality, args.final_output_dir, args.delete_db3_after)
    try:
        while rclpy.ok() and not node.should_stop:
            rclpy.spin_once(node, timeout_sec=0.1)
        node.cleanup()
    except KeyboardInterrupt:
        node.cleanup()
    finally:
        node.destroy_node()
        rclpy.shutdown()

if __name__ == "__main__":
    main()