from pathlib import Path
from typing import Generator, List, Optional

import cv2
import numpy as np

from app import constants as c
from app.data_structures.motion_detection import DetectedMotion
from app.exceptions import MotionDetectionError, OpenCVError, VideoProcessingError
from app.loggers import motion_detector as logger


class MotionDetector:
    """
    Module for detecting motion in video.
    """

    def __init__(self):
        self.mog = cv2.createBackgroundSubtractorMOG2(
            history=c.BACKGROUND_SUBTRACTION_HISTORY,
            detectShadows=c.BACKGROUND_SUBTRACTION_DETECT_SHADOWS,
            varThreshold=c.BACKGROUND_SUBTRACTION_THRESHOLD,
        )

    def detect(self, file_path: Path) -> Generator[DetectedMotion, None, None]:
        """
        Detects motion in video and yields DetectedMotion objects using background subtraction.
        :param file_path: path to the video file
        :return: generator of DetectedMotion objects
        """
        logger.info(f"Start detecting motion in {file_path}")
        cap = cv2.VideoCapture(str(file_path))
        does_first_frame_grabbed = False
        try:
            while True:
                # get current frame time
                frame_time_msec = cap.get(cv2.CAP_PROP_POS_MSEC)
                # read frame
                grabbed, frame = cap.read()
                # if frame is not grabbed, break (end of video)
                if grabbed is False:
                    if not does_first_frame_grabbed:
                        raise VideoProcessingError("Failed to read frames with OpenCV")
                    break
                # if frame is None, skip it (it shouldn't be, but just in case)
                if frame is None:
                    continue
                if not does_first_frame_grabbed:
                    does_first_frame_grabbed = True
                contours = self.process_frame(frame)
                # return frame time and status of motion
                yield DetectedMotion(frame_time_msec, has_motion=bool(contours))
        except cv2.error as ex:
            raise MotionDetectionError(
                f"Failed to detect motion in {file_path}"
            ) from ex
        finally:
            # release video capture and close all windows if any opened
            cap.release()
            cv2.destroyAllWindows()

    def process_frame(self, frame: np.ndarray) -> List[np.ndarray]:
        """
        Processes frame and returns it.
        :param frame: numpy array with image data
        :return: contours of detected motion
        """
        resized = self._resize(
            frame, width=c.RESIZE_IMAGE_WIDTH, height=c.RESIZE_IMAGE_HEIGHT
        )
        prepared_frame = self._prepare_frame(resized)
        fg_mask = self.mog.apply(prepared_frame)
        contours = self._get_contours(fg_mask)
        return contours

    def _resize(
        self,
        image: np.ndarray,
        width: Optional[int] = None,
        height: Optional[int] = None,
    ) -> np.ndarray:
        """
        Resizes image to given width and height.
        :param image: numpy array with image data
        :param width: width of the image in pixels
        :param height: height of the image in pixels
        :return:
        """
        if width is None or height is None:
            return image
        # resize for faster computing
        image = cv2.resize(image, (width, height))
        return image

    def _prepare_frame(self, frame: np.ndarray) -> np.ndarray:
        """
        Prepares frame for motion detection (converts to gray and blurs).
        :param frame: numpy array with image data
        :return: processed frame
        """
        # convert to GRAY format
        gray = cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY)
        # blur to reduce a noise
        gray = cv2.GaussianBlur(gray, (21, 21), 0)
        return gray

    def _get_contours(self, fg_mask: np.ndarray) -> List[np.ndarray]:
        """
        Gets motion contours from frame.
        :param fg_mask: numpy array with differences mask
        :return: list of contours
        """
        contours = cv2.findContours(
            fg_mask.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
        )
        contours = self._grab_contours(contours)
        # sort contours by area and keep only 5 largest ones
        contours = sorted(contours, key=cv2.contourArea, reverse=True)[:5]
        # leave only contours with area greater than threshold
        contours = [
            contour
            for contour in contours
            if cv2.contourArea(contour) > c.CONTOUR_SIZE_THRESHOLD
        ]
        return contours

    @staticmethod
    def _grab_contours(contours):
        # OpenCV v3, v4-pre, or v4-alpha
        if len(contours) == 3:
            return contours[1]
        # OpenCV v2.4, v4-beta, or v4-official
        if len(contours) == 2:
            return contours[0]
        raise OpenCVError(
            "Refer to OpenCV documentation of cv2.findContours for more information"
        )
