from pathlib import Path
from typing import Any, Dict, List

from PyQt6.QtCore import QThread, pyqtSignal

from app import constants as c
from app.data_structures.motion_detection import DetectedMotion, MotionDetectionResult
from app.data_structures.object_detection import (
    ObjectDetectionMoment,
    ObjectDetectionResult,
)
from app.data_structures.processing import (
    Configurations,
    DetectionResult,
    ImageToSave,
    ProcessingStages,
    Stage,
)
from app.data_structures.time_interval import VideoTimeInterval
from app.data_structures.video_cutting import CuttingResult
from app.loggers import processing as logger
from app.services.motion_detector import MotionDetector
from app.services.object_detector import ObjectDetector
from app.services.video_cutter import VideoCutter
from app.utils.time import filter_small_intervals, join_near_intervals
from app.utils.video import VideoProcessingStats


class DetectionProcessor(QThread):
    """
    Module responsible for processing video files in another thread for not blocking the main thread (GUI).
    """

    sendData = pyqtSignal(str, dict)

    def __init__(
        self,
        motion_detector: MotionDetector,
        video_cutter: VideoCutter,
        object_detector: ObjectDetector,
        configurations: Configurations,
        frames_number: int,
    ) -> None:
        super().__init__()
        self._motion_detector = motion_detector
        self._video_cutter = video_cutter
        self._object_detector = object_detector
        self._configurations = configurations
        self._frames_number = frames_number
        self._file_index: int = 0
        self.results = []

    def _notify(self, data_type: str, data: Dict[str, Any]) -> None:
        """
        Emits a signal with the given data.
        :param data_type: unique identifier of the data type
        :param data: data to send to the main thread
        :return:
        """
        self.sendData.emit(data_type, data)

    def _set_stage(self, stage: Stage, stage_frames_number: int = 0) -> None:
        """
        Notifies the main thread about the current stage of processing.
        :param stage: stage object
        :param stage_frames_number: number of frames in the current stage (if applicable)
        :return:
        """
        self._notify(
            data_type="stage",
            data={
                "file_index": self._file_index,
                "stage": stage.value,
                "total_number": stage_frames_number,
            },
        )

    def _motion_detection(self, file_path: Path) -> List[MotionDetectionResult]:
        """
        Detects motion in the given video file.
        :param file_path: path to the video file
        :return: list of motion detection results
        """
        stats = VideoProcessingStats(frames_number=self._frames_number)
        current_interval = None
        intervals = []
        last_motion_time = None
        try:
            for motion in self._motion_detector.detect(file_path):
                stats.update()
                motion_time_msec = motion.frame_time_msec
                if stats.occurrences % c.UPDATE_MOTION_DETECTION_STATUS_INTERVAL == 0:
                    self._notify(
                        data_type="motion_detection_status",
                        data={
                            "file_index": self._file_index,
                            "frame_number": stats.occurrences,
                            "fps": stats.fps,
                        },
                    )
                # if motion is processed
                if motion.has_motion:
                    # if it is the new interval
                    if current_interval is None:
                        time_interval = VideoTimeInterval()
                        time_interval.start_msec = motion_time_msec
                        current_interval = MotionDetectionResult(
                            motions=[],
                            interval=time_interval,
                        )
                    # add motion to current interval
                    current_interval.motions.append(motion)
                # if no motion
                else:
                    # if there is an open interval
                    if current_interval is not None:
                        # add motion to current interval
                        current_interval.motions.append(motion)
                        # end this interval
                        current_interval.interval.end_msec = motion_time_msec
                        intervals.append(current_interval)
                        current_interval = None
                # record last motion time
                if motion.frame_time_msec > 0:
                    last_motion_time = motion_time_msec
        finally:
            # if there is an interval, and it is not ended, end it
            if current_interval is not None:
                current_interval.interval.end_msec = last_motion_time
                intervals.append(current_interval)
        return intervals

    def _cut_video(
        self, file_path: Path, motion_intervals: List[MotionDetectionResult]
    ) -> List[CuttingResult]:
        """
        Cuts the given video file into clips based on the given motion intervals.
        :param file_path: path to the video file
        :param motion_intervals: list of motion intervals
        :return: list of cutting results
        """
        results = []
        time_intervals = [
            motion_interval.interval for motion_interval in motion_intervals
        ]
        for i, time_interval in enumerate(time_intervals):
            self._notify(data_type="cutting_status", data={"frame_number": i + 1})
            clip_path, cut_interval = self._video_cutter.cut(file_path, time_interval)
            results.append(
                CuttingResult(
                    file_path=clip_path,
                    interval=cut_interval,
                )
            )
        return results

    def _object_detection(
        self, data: List[ObjectDetectionMoment]
    ) -> List[ObjectDetectionResult]:
        """
        Detects objects in the given video clips.
        :param data: list of object detection moments
        :return: list of object detection results
        """
        results = []
        for i, data_item in enumerate(data):
            self._notify(
                data_type="object_detection_status", data={"frame_number": i + 1}
            )
            detection_result = self._object_detector.detect(
                file_path=data_item.file_path, timestamp=data_item.timestamp
            )
            results.append(detection_result)
        return results

    def start_process(self, file_index: int, file_path: Path) -> List[DetectionResult]:
        """
        Starts processing pipeline for the given video file.
        :param file_index: index of the file in the queue
        :param file_path: path to the video file
        :return: list of detection results
        """
        self._file_index = file_index
        self._set_stage(
            stage=ProcessingStages.MOTION_DETECTION,
            stage_frames_number=self._frames_number,
        )
        motion_intervals = self._motion_detection(file_path)
        if not motion_intervals:
            return []
        motion_intervals = _post_processing_motion_intervals(motion_intervals)
        logger.info(
            "Motion detection finished: {} intervals found".format(
                len(motion_intervals)
            )
        )
        self._set_stage(
            stage=ProcessingStages.CUTTING,
            stage_frames_number=len(motion_intervals),
        )
        cutting_results = self._cut_video(
            file_path=file_path, motion_intervals=motion_intervals
        )
        logger.info(
            "Video cutting finished: {} videos saved".format(len(cutting_results))
        )
        object_detection_results = []
        if self._configurations.object_detection is True:
            self._set_stage(
                stage=ProcessingStages.OBJECT_DETECTION,
                stage_frames_number=len(cutting_results),
            )
            object_detection_results = self._object_detection(
                _get_moments_for_object_detection(
                    motion_intervals=motion_intervals,
                    cutting_results=cutting_results,
                )
            )
            logger.info("Object detection finished")
        results = _prepare_results(
            motion_detection_results=motion_intervals,
            cutting_results=cutting_results,
            object_detection_results=object_detection_results,
        )
        self._set_stage(stage=ProcessingStages.FINISHED)

        return results


def _post_processing_motion_intervals(
    intervals: List[MotionDetectionResult],
) -> List[MotionDetectionResult]:
    """
    Post-processing of motion intervals.
    Performs joining of intervals that are close to each other and filtering by length.
    :param intervals: list of motion intervals to process
    :return: joined motion intervals data
    """
    intervals = join_near_intervals(intervals, c.JOIN_INTERVALS_THRESHOLD)
    return filter_small_intervals(intervals, c.MIN_MOTION_INTERVAL_LENGTH)


def _get_max_detected_motion(
    detects: List[DetectedMotion],
) -> DetectedMotion:
    """
    Returns the middle frame time of the given detected motions.
    :param detects: list of detected motions
    :return: detected motion object from middle of interval
    """
    middle_motion_index = int(len(detects) / 2)
    return detects[middle_motion_index]


def _get_moments_for_object_detection(
    motion_intervals: List[MotionDetectionResult],
    cutting_results: List[CuttingResult],
) -> List[ObjectDetectionMoment]:
    """
    Assembles and prepares data for object detection.
    :param motion_intervals: list of motion intervals
    :param cutting_results: list of cutting results
    :return: list of object detection moments to process
    """
    moments = []
    for motion_interval, cutting_result in zip(motion_intervals, cutting_results):
        motion_to_process = _get_max_detected_motion(
            detects=motion_interval.motions,
        )
        real_clip_frame_timestamp = (
            motion_to_process.frame_time_msec - cutting_result.interval.start_msec
        )
        moments.append(
            ObjectDetectionMoment(
                file_path=cutting_result.file_path,
                timestamp=real_clip_frame_timestamp,
            )
        )
    return moments


def _prepare_results(
    motion_detection_results: List[MotionDetectionResult],
    cutting_results: List[CuttingResult],
    object_detection_results: List[ObjectDetectionResult],
) -> List[DetectionResult]:
    """
    Assembles detection results from motion detection, cutting and object detection results.
    :param motion_detection_results: motion detection results
    :param cutting_results: cutting results
    :param object_detection_results: object detection results
    :return: list of detection results
    """
    results = []
    for i, (motion_detection_result, cutting_result) in enumerate(
        zip(motion_detection_results, cutting_results)
    ):
        object_detection_result = (
            object_detection_results[i] if object_detection_results else None
        )
        objects = (
            object_detection_result.objects_data.objects
            if object_detection_result
            else None
        )
        objects_image = (
            object_detection_result.objects_data.image
            if object_detection_result
            else None
        )
        results.append(
            DetectionResult(
                interval=motion_detection_result.interval,
                video_path=cutting_result.file_path,
                objects=objects,
                objects_image=ImageToSave(
                    file_name=cutting_result.file_path.stem,
                    image=objects_image,
                ),
            )
        )
    return results
