# logger.setLevel(logging.DEBUG)
import logging
import time
from queue import Empty, Queue
from threading import Event, Thread

import numpy as np

from caliscope.packets import SyncPacket

logger = logging.getLogger(__name__)

DROPPED_FRAME_TRACK_WINDOW = 100  # trailing frames tracked for reporting purposes


class Synchronizer:
    def __init__(self, streams: dict):
        """Initialize synchronizer without starting threads.

        Caller must explicitly call start() to begin processing. This avoids
        the anti-pattern of constructors with side effects (thread spawning)
        and gives the caller control over the lifecycle.
        """
        self.streams = streams
        self.current_synched_frames = None

        self.synched_frames_subscribers = []  # queues that will receive actual frame data

        self.all_frame_packets = {}
        self.stop_event = Event()
        self.frames_complete = False  # only relevant for video playback, but provides a way to wrap up the thread

        self.cam_ids = []
        self.frame_packet_queues = {}
        for cam_id, stream in self.streams.items():
            self.cam_ids.append(cam_id)
            q = Queue(-1)
            self.frame_packet_queues[cam_id] = q

        self.subscribed_to_streams = False  # not subscribed yet
        self.subscribe_to_streams()

        # place to store a recent history of dropped frames
        self.dropped_frame_history = {cam_id: [] for cam_id in sorted(self.cam_ids)}

        self.initialize_ledgers()
        # Note: Caller must call start() explicitly - no auto-start in constructor

    def update_dropped_frame_history(self):
        if self.current_sync_packet is None:
            return

        current_dropped: dict = self.current_sync_packet.dropped

        for cam_id, dropped in current_dropped.items():
            self.dropped_frame_history[cam_id].append(dropped)
            self.dropped_frame_history[cam_id] = self.dropped_frame_history[cam_id][-DROPPED_FRAME_TRACK_WINDOW:]

    @property
    def dropped_fps(self):
        """
        Averages dropped frame count across the observed history
        """
        return {cam_id: np.mean(drop_history) for cam_id, drop_history in self.dropped_frame_history.items()}

    # def set_stream_fps(self, fps_target):
    #     self.fps_target = fps_target
    #     logger.info(f"Attempting to change target fps in streams to {fps_target}")
    #     for cam_id, stream in self.streams.items():
    #         stream.set_fps_target(fps_target)

    def subscribe_to_streams(self):
        for cam_id, stream in self.streams.items():
            logger.info(f"Subscribing synchronizer to stream from cam_id {cam_id}")
            stream.subscribe(self.frame_packet_queues[cam_id])
        self.subscribed_to_streams = True

    def unsubscribe_from_streams(self):
        for cam_id, stream in self.streams.items():
            logger.info(f"unsubscribe synchronizer from cam_id {cam_id}")
            stream.unsubscribe(self.frame_packet_queues[cam_id])
        self.subscribed_to_streams = False

    def stop(self):
        """Stop all synchronizer threads.

        Uses timeouts on joins to prevent deadlock if threads are stuck
        on blocking queue.get() calls. Threads are daemon threads so they
        will be killed when the main process exits regardless.
        """
        logger.info("Synchronizer stop initiated")
        self.stop_event.set()

        # Join main synchronizer thread (with timeout)
        if hasattr(self, "thread") and self.thread is not None:
            self.thread.join(timeout=5.0)
            if self.thread.is_alive():
                logger.warning("Synchronizer main thread did not terminate within timeout")
            else:
                logger.info("Synchronizer main thread terminated")

        # Join harvester threads (with timeout)
        if hasattr(self, "threads"):
            for i, t in enumerate(self.threads):
                t.join(timeout=2.0)
                if t.is_alive():
                    logger.warning(f"Harvester thread {i} did not terminate within timeout")
                else:
                    logger.debug(f"Harvester thread {i} terminated")

        logger.info("Synchronizer stop complete")

    def initialize_ledgers(self):
        self.cam_id_frame_count = {cam_id: 0 for cam_id in self.cam_ids}
        self.cam_id_current_frame = {cam_id: 0 for cam_id in self.cam_ids}
        self.mean_frame_times = []

    def start(self):
        logger.info("About to submit Threadpool of frame Harvesters")
        self.threads = []
        for cam_id, stream in self.streams.items():
            t = Thread(target=self.harvest_frame_packets, args=(stream,), daemon=True)
            t.start()
            self.threads.append(t)
        logger.info("Frame harvesters just submitted")

        logger.info("Starting frame synchronizer...")
        self.thread = Thread(target=self.synch_frames_worker, args=(), daemon=True)
        self.thread.start()

    def subscribe_to_sync_packets(self, q):
        logger.info("Adding queue to receive synched frames")
        self.synched_frames_subscribers.append(q)

    def release_sync_packet_q(self, q):
        logger.info("Releasing record queue")
        self.synched_frames_subscribers.remove(q)

    def harvest_frame_packets(self, stream):
        """Harvest frame packets from a stream and store them.

        Uses timeout on queue.get() to periodically check stop_event,
        allowing clean shutdown when stop() is called.
        """
        cam_id = stream.cam_id

        logger.info(f"Beginning to collect data generated at cam_id {cam_id}")

        while not self.stop_event.is_set():
            try:
                # Use timeout to periodically check stop_event
                frame_packet = self.frame_packet_queues[cam_id].get(timeout=0.5)
            except Empty:
                # No frame available, loop back to check stop_event
                continue

            frame_index = self.cam_id_frame_count[cam_id]

            self.all_frame_packets[f"{cam_id}_{frame_index}"] = frame_packet
            self.cam_id_frame_count[cam_id] += 1

            logger.debug(
                f"Frame data harvested from reel {frame_packet.cam_id} with index {frame_index} and frame time of {frame_packet.frame_time}"  # noqa E501
            )

        logger.info(f"Frame harvester for cam_id {cam_id} completed")

    # get minimum value of frame_time for next layer
    def earliest_next_frame(self, cam_id):
        """Looks at next unassigned frame across cameras to determine
        the earliest time at which each of them was read"""
        times_of_next_frames = []
        for c in self.cam_ids:
            next_index = self.cam_id_current_frame[c] + 1
            frame_data_key = f"{c}_{next_index}"

            # problem with outpacing the threads reading data in, so wait if need be
            while frame_data_key not in self.all_frame_packets.keys():
                logger.debug(f"Waiting in a loop for frame data to populate with key: {frame_data_key}")
                if self.subscribed_to_streams:
                    time.sleep(0.1)
                else:
                    # provide infrequent updates of busy waiting
                    if int(time.time()) % 10 == 0:
                        logger.info("Synchronizer not subscribed to any streams and busy waiting...")
                    time.sleep(1)

            next_frame_time = self.all_frame_packets[frame_data_key].frame_time

            if next_frame_time == -1:
                logger.info(f"End of frames at cam_id {c} detected; ending synchronization")

                self.frames_complete = True
                self.stop_event.set()

            if c != cam_id:
                times_of_next_frames.append(next_frame_time)

        return min(times_of_next_frames)

    def latest_current_frame(self, cam_id):
        """Provides the latest frame_time of the current frames not inclusive of the provided camera"""
        times_of_current_frames = []
        for c in self.cam_ids:
            current_index = self.cam_id_current_frame[c]
            frame_data_key = f"{c}_{current_index}"
            current_frame_time = self.all_frame_packets[frame_data_key].frame_time
            if c != cam_id:
                times_of_current_frames.append(current_frame_time)

        return max(times_of_current_frames)

    def frame_slack(self):
        """Determine how many unassigned frames are sitting in self.dataframe"""
        slack = [self.cam_id_frame_count[cam_id] - self.cam_id_current_frame[cam_id] for cam_id in self.cam_ids]
        logger.debug(f"Slack in frames is {slack}")
        return min(slack)

    def average_fps(self):
        if len(self.mean_frame_times) < 2:
            return 0.0  # can't get fps if only one frame

        if len(self.mean_frame_times) > 10:  # only looking at the most recent layers
            self.mean_frame_times = self.mean_frame_times[-10:]

        delta_t = np.diff(self.mean_frame_times)

        mean_delta_t = np.mean(delta_t)

        return 1 / mean_delta_t

    def synch_frames_worker(self):
        logger.info("Waiting for all cameras to begin harvesting corners...")

        sync_index = 0

        logger.info("About to start synchronizing frames...")
        while not self.stop_event.is_set():
            current_frame_packets = {}

            layer_frame_times = []

            # build earliest next/latest current dictionaries for each cam_id to determine where to put frames
            # must be done before going in and making any updates to the frame index
            earliest_next = {}
            latest_current = {}

            for cam_id in self.cam_ids:
                earliest_next[cam_id] = self.earliest_next_frame(cam_id)
                latest_current[cam_id] = self.latest_current_frame(cam_id)
                current_frame_index = self.cam_id_current_frame[cam_id]

            for cam_id in self.cam_ids:
                current_frame_index = self.cam_id_current_frame[cam_id]

                cam_id_index_key = f"{cam_id}_{current_frame_index}"
                current_frame_packet = self.all_frame_packets[cam_id_index_key]
                frame_time = current_frame_packet.frame_time

                # don't put a frame in a synched frame packet if the next packet has a frame before it
                if frame_time > earliest_next[cam_id]:
                    # definitly should be put in the next layer and not this one
                    current_frame_packets[cam_id] = None
                    logger.warning(f"Skipped frame at cam_id {cam_id}: > earliest_next")
                elif (
                    earliest_next[cam_id] - frame_time < frame_time - latest_current[cam_id]
                ):  # frame time is closer to earliest next than latest current
                    # if it's closer to the earliest next frame than the latest current frame, bump it up
                    # only applying for 2 camera setup where I noticed this was an issue (frames stay out of synch)
                    current_frame_packets[cam_id] = None
                    logger.warning(f"Skipped frame at cam_id {cam_id}: delta < time-latest_current")
                else:
                    # add the data and increment the index
                    current_frame_packets[cam_id] = self.all_frame_packets.pop(cam_id_index_key)
                    # frame_packets[cam_id]["sync_index"] = sync_index
                    self.cam_id_current_frame[cam_id] += 1
                    layer_frame_times.append(frame_time)
                    logger.debug(
                        f"Adding to layer from cam_id {cam_id} "
                        f"at index {current_frame_index} and frame time: {frame_time}"
                    )

            logger.debug(f"Unassigned Frames: {len(self.all_frame_packets)}")

            # only calculate mean and FPS if frames were actually synched this layer
            if layer_frame_times:
                self.mean_frame_times.append(np.mean(layer_frame_times))
                self.fps_mean = self.average_fps()
            else:
                logger.warning("No frames were synchronized this cycle; skipping FPS update")

            logger.debug(f"Updating sync packet for sync_index {sync_index}")
            self.current_sync_packet = SyncPacket(sync_index, current_frame_packets)

            self.update_dropped_frame_history()

            sync_index += 1

            if self.stop_event.is_set():
                logger.info("Sending `None` on queue to signal end of synced frames.")
                self.current_sync_packet = None

            for q in self.synched_frames_subscribers:
                q.put(self.current_sync_packet)
                if self.current_sync_packet is not None:
                    logger.debug(
                        f"Placing new synched frames packet on queue with {self.current_sync_packet.frame_packet_count} frames"  # noqa E501
                    )
                    logger.debug(f"Placing new synched frames with index {self.current_sync_packet.sync_index}")

                    # provide infrequent notice of synchronizer activity
                    if self.current_sync_packet.sync_index % 100 == 0:
                        logger.info(f"Placing new synched frames with index {self.current_sync_packet.sync_index}")
                else:
                    logger.info("signaling end of frames with `None` packet on subscriber queue.")
                    for cam_id, q in self.frame_packet_queues.items():
                        logger.info(f"Currently {q.qsize()} frame packets unprocessed for cam_id {cam_id}")

        logger.info("Frame synch worker successfully ended")
