#
#      DVR-Scan: Video Motion Event Detection & Extraction Tool
#   --------------------------------------------------------------
#       [  Site: https://www.dvr-scan.com/                 ]
#       [  Repo: https://github.com/Breakthrough/DVR-Scan  ]
#
# Copyright (C) 2016 Brandon Castellano <http://www.bcastell.com>.
# DVR-Scan is licensed under the BSD 2-Clause License; see the included
# LICENSE file, or visit one of the above pages for details.
#
"""``dvr_scan.scanner`` Module

Contains the motion scanning engine (`MotionScanner`) for DVR-Scan.
"""

import logging
import queue
import subprocess
import sys
import threading
import typing as ty
from dataclasses import dataclass
from enum import Enum
from pathlib import Path

import cv2
import numpy as np
from scenedetect import FrameTimecode
from scenedetect.platform import FakeTqdmObject
from tqdm import tqdm

from dvr_scan.detector import MotionDetector
from dvr_scan.overlays import BoundingBoxOverlay, TextOverlay
from dvr_scan.platform import (
    HAS_PILLOW,
    HAS_TKINTER,
    get_min_screen_bounds,
    is_ffmpeg_available,
)
from dvr_scan.region import Point, Size, bound_point, load_regions
from dvr_scan.subtractor import SubtractorCNT, SubtractorCudaMOG2, SubtractorMOG2
from dvr_scan.video_joiner import VideoJoiner

if HAS_TKINTER and HAS_PILLOW:
    from dvr_scan.app.region_editor import RegionEditor

logger = logging.getLogger("dvr_scan")

DEFAULT_VIDEOWRITER_CODEC = "XVID"
"""Default codec to use with OpenCV VideoWriter."""

MAX_DECODE_QUEUE_SIZE: int = 4
"""Maximum size of the queue of frames waiting to be processed after decoding."""

MAX_ENCODE_QUEUE_SIZE: int = 4
"""Maximum size of the queue of encode events waiting to be processed."""

DEFAULT_FFMPEG_INPUT_ARGS = "-v error"
"""Default arguments to add before input when invoking ffmpeg."""

DEFAULT_FFMPEG_OUTPUT_ARGS = (
    "-map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac"
)
"""Default arguments passed to ffmpeg when using OutputMode.FFMPEG."""

COPY_MODE_OUTPUT_ARGS = "-map 0:v:0 -map 0:a? -map 0:s? -c:v copy -c:a copy"
"""Default arguments passed to ffmpeg when using OutputMode.COPY."""

# TODO(#89): Add ability to set output name template.
OUTPUT_FILE_TEMPLATE = "{VIDEO_NAME}.DSME_{EVENT_NUMBER}.{EXTENSION}"
"""Template to use for generating output files."""

PROGRESS_BAR_DESCRIPTION = "Detected: %d | Progress"
"""Template to use for progress bar."""


class DetectorType(Enum):
    """Type of motion detector to use (see dvr_scan.detector for implementations)."""

    MOG2 = SubtractorMOG2
    CNT = SubtractorCNT
    MOG2_CUDA = SubtractorCudaMOG2


class OutputMode(Enum):
    """Mode to export each motion event using."""

    SCAN_ONLY = 1
    """Don't generate any output files."""
    OPENCV = 2
    """Output using OpenCV VideoWriter."""
    COPY = 3
    """Output using ffmpeg in codec-copy mode."""
    FFMPEG = 4
    """Output using ffmpeg."""

    def __str__(self):
        if self == OutputMode.SCAN_ONLY:
            return "SCAN_ONLY"
        if self == OutputMode.OPENCV:
            return "OPENCV"
        if self == OutputMode.COPY:
            return "COPY"
        if self == OutputMode.FFMPEG:
            return "FFMPEG"


@dataclass
class DecodeEvent:
    """Event generated by decode thread on each video frame."""

    frame_bgr: np.ndarray
    timecode: FrameTimecode


@dataclass
class EncodeFrameEvent:
    """Event generated by main thread for each frame in a motion event."""

    frame_bgr: np.ndarray
    timecode: FrameTimecode
    score: float
    bounding_box: ty.Tuple[int, int, int, int]
    end_of_event: bool = False


@dataclass
class MotionMaskEvent:
    """Event generated by main thread containing the motion mask for each decoded frame."""

    motion_mask: np.ndarray
    timecode: FrameTimecode
    score: float
    bounding_box: ty.Optional[ty.Tuple[int, int, int, int]]


@dataclass
class MotionEvent:
    """Contiguous sequence of frames where motion was detected."""

    start: FrameTimecode
    end: FrameTimecode


@dataclass
class DetectionResult:
    """Motion events detected from scanning `num_frames` consecutive frames."""

    event_list: ty.List[MotionEvent]
    num_frames: int


def _scale_kernel_size(kernel_size: int, downscale_factor: int):
    if kernel_size in (0, 1):
        return kernel_size
    corrected_size = round(kernel_size / float(downscale_factor))
    if corrected_size % 2 == 0:
        corrected_size -= 1
    return max(corrected_size, 3)


# TODO(#75): See if this needs to be tweaked for the CNT algorithm.
def _recommended_kernel_size(frame_width: int, downscale_factor: int) -> int:
    corrected_width = round(frame_width / float(downscale_factor))
    return 7 if corrected_width >= 1920 else 5 if corrected_width >= 1280 else 3


def _extract_event_ffmpeg(
    input_path: Path,
    output_path: Path,
    start_time: FrameTimecode,
    end_time: FrameTimecode,
    ffmpeg_input_args: str,
    ffmpeg_out_args: str,
    log_args: bool = False,
) -> bool:
    args: ty.List[str] = [
        "ffmpeg",
        "-y",
        "-nostdin",
        *ffmpeg_input_args.split(" "),
        "-ss",
        start_time.get_timecode(),
        "-i",
        str(input_path),
        "-t",
        (end_time - start_time).get_timecode(),
        *ffmpeg_out_args.split(" "),
        str(output_path),
    ]
    if log_args or logger.getEffectiveLevel() == logging.DEBUG:
        logger.info("%s", " ".join(args))
    # Invoke the command and capture the output (exception is raised on non-zero return code).
    output: str = subprocess.check_output(
        args=args,
        text=True,
        stderr=subprocess.STDOUT,
    )
    # Log any output we get from the ffmpeg process.
    if output:
        verbosity = logging.INFO
        # Upgrade verbosity depending on specified ffmpeg verbosity (if any).
        if any(["-v %s" % v in ffmpeg_input_args for v in ["panic", "fatal", "error"]]):
            verbosity = logging.ERROR
        elif "-v warning" in ffmpeg_input_args:
            verbosity = logging.WARNING
        logger.log(
            verbosity,
            "ffmpeg output:\n\n%s",
            output,
        )


# TODO: Pogram options should be separated out from those variables required for a motion scan.
class MotionScanner:
    """MotionScanner represents the DVR-Scan program state, including the current settings for
    various options, coordinating overall scanning logic (via the scan() method)."""

    def __init__(
        self,
        input_videos: ty.List[Path],
        input_mode: str = "opencv",
        frame_skip: int = 0,
        show_progress: bool = False,
        debug_mode: bool = False,
    ):
        self._show_progress: bool = show_progress
        self._debug_mode: bool = debug_mode

        # Scan state and options they come from:

        # Output Parameters (set_output)
        self._comp_file: ty.Optional[Path] = None  # -o/--output
        self._mask_file: ty.Optional[Path] = None  # -mo/--mask-output
        self._fourcc: ty.Any = None  # opencv-codec
        self._output_mode: OutputMode = None  # -m/--output-mode / -so/--scan-only
        self._ffmpeg_input_args: ty.Optional[str] = None  # input args for OutputMode.FFMPEG/COPY
        self._ffmpeg_output_args: ty.Optional[str] = None  # output args for OutputMode.FFMPEG
        self._output_dir: ty.Optional[Path] = None  # -d/--directory
        # TODO: Replace uses of self._output_dir with
        # a helper function called "get_output_path".

        # Overlay Parameters (set_overlays)
        self._timecode_overlay = None  # -tc/--time-code, None or TextOverlay
        self._metrics_overlay = None  # -fm/--frame-metrics, None or TextOverlay
        self._bounding_box = None  # -bb/--bounding-box, None or BoundingBoxOverlay

        # Motion Detection Parameters (set_detection_params)
        self._subtractor_type = DetectorType.MOG2  # -b/--bg-subtractor
        self._threshold = 0.15  # -t/--threshold
        self._variance_threshold = 16.0  # variance-threshold
        self._kernel_size = None  # -k/--kernel-size
        self._downscale_factor = 1  # -df/--downscale-factor
        self._learning_rate = -1  # learning-rate
        self._max_threshold = 255.0  # max-threshold
        self._max_area = 1.0  # max-area
        self._max_width = 1.0  # max-width
        self._max_height = 1.0  # max-height

        # Motion Event Parameters (set_event_params)
        self._min_event_len = None  # -l/--min-event-length
        self._time_before_event = None  # -tb/--time-before-event
        self._time_post_event = None  # -tp/--time-post-event
        self._use_pts = None  # --use_pts

        # Region Parameters (set_region)
        self._region_editor = False  # -w/--region-window
        self._regions: ty.List[ty.List[Point]] = []  # -a/--add-region, -w/--region-window
        self._load_region: ty.Optional[Path] = None  # -R/--load-region
        self._save_region: ty.Optional[Path] = None  # -s/--save-region
        self._max_roi_size_deprecated = None
        self._show_roi_window_deprecated = False
        self._roi_deprecated = None

        # Input Video Parameters (set_video_time)
        self._input: VideoJoiner = VideoJoiner(input_videos, backend=input_mode)  # -i/--input
        self._frame_skip: int = frame_skip  # -fs/--frame-skip
        self._start_time: FrameTimecode = None  # -st/--start-time
        self._end_time: FrameTimecode = None  # -et/--end-time

        # Internal Variables
        self._stop: threading.Event = threading.Event()
        self._decode_thread_exception = None
        self._encode_thread_exception = None
        self._video_writer: ty.Optional[cv2.VideoWriter] = None
        self._mask_size: ty.Tuple[int, int] = None
        self._mask_writer: ty.Optional[cv2.VideoWriter] = None
        self._num_events: int = 0

        # Thumbnail production (set_thumbnail_params)
        self._thumbnails = None
        self._highscore = 0
        self._highframe = None

        # Callbacks for UI integration
        self._scan_started = None
        self._processed_frame = None

        # Make sure we initialize defaults now that we loaded the input videos.
        self.set_detection_params()
        self.set_event_params()
        self.set_thumbnail_params()
        self.set_video_time()

    @property
    def output_mode(self) -> OutputMode:
        return self._output_mode

    @property
    def framerate(self) -> float:
        """Get the framerate of the currently loaded video(s)."""
        return self._input.framerate

    def set_output(
        self,
        output_dir: ty.Optional[Path] = None,
        comp_file: ty.Optional[Path] = None,
        mask_file: ty.Optional[Path] = None,
        output_mode: ty.Union[OutputMode, str] = OutputMode.SCAN_ONLY,
        opencv_fourcc: str = DEFAULT_VIDEOWRITER_CODEC,
        ffmpeg_input_args: str = DEFAULT_FFMPEG_INPUT_ARGS,
        ffmpeg_output_args: str = DEFAULT_FFMPEG_OUTPUT_ARGS,
    ):
        """Sets the path and encoder codec to use when exporting videos.

        Arguments:
            output_dir: If set, folder where output files will be written to.
                If empty string, output will be the current working directory.
            comp_file: If set, represents path to single video that all motion events will
                be written to. Otherwise each motion event will be saved as a separate file
                based on the (first) input video filename.
            output_mode: The mode to use when generating/encoding the output file. Certain
                features may not be compatible with all ouptut modes.
            opencv_fourcc: The four-letter identifier of the encoder/video
                codec to use when output_mode is OutputMode.OPENCV. Possible values are:
                XVID, MP4V, MP42, H264.
            ffmpeg_input_args: Arguments to pass to ffmpeg before the input video. Only used
                when output_mode is OutputMode.FFMPEG or OutputMode.COPY.
            ffmpeg_output_args: Arguments to pass to ffmpeg for the output video. Only used when
                output_mode is OutputMode.FFMPEG.

        Raises:
            ValueError:
             - codec is not four characters
             - comp_file is set but output_mode is not OutputMode.OPENCV
             - output_dir is set but either comp_file or mask_file are absolute paths
             - multiple input videos and output_mode is not OutputMode.OPENCV
             - output_mode is OutputMode.FFMPEG or OutputMode.COPY but ffmpeg is not available
            KeyError:
             - output_mode does not exist in OutputMode
        """
        if output_dir:
            if comp_file and comp_file.is_absolute():
                raise ValueError("output file cannot be absolute path if output directory is set!")
            if mask_file and mask_file.is_absolute():
                raise ValueError("mask file cannot be absolute path if output directory is set!")
        if len(opencv_fourcc) != 4:
            raise ValueError("codec must be exactly FOUR (4) characters")
        if not isinstance(output_mode, OutputMode):
            output_mode = OutputMode[output_mode.upper().replace("-", "_")]
        if len(self._input.paths) > 1 and output_mode not in (
            OutputMode.SCAN_ONLY,
            OutputMode.OPENCV,
        ):
            raise ValueError(
                "input concatenation is only supported in `scan-only` or `opencv` mode."
            )
        if comp_file is not None and output_mode != OutputMode.OPENCV:
            raise ValueError("output to single file is only supported with mode `opencv`")
        if output_mode in (OutputMode.FFMPEG, OutputMode.COPY) and not is_ffmpeg_available():
            raise ValueError("ffmpeg is required to use output mode FFMPEG/COPY")
        self._comp_file = comp_file
        self._mask_file = mask_file
        self._output_mode = output_mode
        self._fourcc = cv2.VideoWriter_fourcc(*opencv_fourcc.upper())
        self._ffmpeg_input_args = ffmpeg_input_args
        self._ffmpeg_output_args = ffmpeg_output_args
        # If an output directory is defined, ensure it exists, and if not, try to create it.
        if output_dir:
            output_dir.mkdir(parents=True, exist_ok=True)
        self._output_dir = output_dir

    def set_overlays(
        self,
        timecode_overlay: ty.Optional[TextOverlay] = None,
        metrics_overlay: ty.Optional[TextOverlay] = None,
        bounding_box: ty.Optional[BoundingBoxOverlay] = None,
    ):
        """Sets options to use if/when drawing overlays on the resulting frames.

        Arguments:
            draw_timecode: If True, draw a timecode (presentation time) on each frame.
            bounding_box_smoothing: Value to use for temporal smoothing (in time) for drawing a
                bounding box containing all detected motion in each frame. If None, no box will
                be drawn. If <= 1, smoothing will be disabled.
        """
        self._timecode_overlay = timecode_overlay
        self._metrics_overlay = metrics_overlay
        self._bounding_box = bounding_box

    def set_detection_params(
        self,
        detector_type: DetectorType = DetectorType.MOG2,
        threshold: float = 0.15,
        max_threshold: float = 255.0,
        max_area: float = 1.0,
        max_width: float = 1.0,
        max_height: float = 1.0,
        variance_threshold: float = 16.0,
        kernel_size: int = -1,
        downscale_factor: int = 1,
        learning_rate: float = -1,
    ):
        """Set detection parameters."""
        self._threshold = threshold
        self._max_threshold = max_threshold
        self._max_area = max_area
        self._max_width = max_width
        self._max_height = max_height
        self._subtractor_type = detector_type
        if downscale_factor < 0:
            raise ValueError("Downscale factor must be positive.")
        self._downscale_factor = max(downscale_factor, 1)
        assert kernel_size == -1 or kernel_size == 0 or kernel_size >= 3
        self._kernel_size = kernel_size
        self._variance_threshold = variance_threshold
        # TODO: Also allow ability to customize history size, as this is another factor that
        # influences how quickly the background model is updated. When calculated automatically,
        # OpenCV sets learning rate as:
        #
        #     learning_rate = 1.0 / min(num_frames, history_length)
        #
        # We should also investigate how this works for CNT and other subtractors.
        self._learning_rate = learning_rate

    def set_regions(
        self,
        region_editor: bool = False,
        regions: ty.Optional[ty.List[ty.List[Point]]] = None,
        load_region: ty.Optional[Path] = None,
        save_region: ty.Optional[Path] = None,
        roi_deprecated: ty.Optional[ty.List[int]] = None,
    ):
        """Set options for limiting detection regions."""
        self._region_editor = region_editor
        self._regions = regions if regions else []
        self._load_region = load_region
        self._save_region = save_region

        # Handle deprecated ROI option.
        # TODO(v2.0): Remove.
        if roi_deprecated is not None:
            if roi_deprecated:
                if not all(isinstance(i, int) for i in roi_deprecated):
                    raise TypeError("Error: Non-integral type found in specified roi.")
                if any(x < 0 for x in roi_deprecated):
                    raise ValueError("Error: Negative value found in roi.")
                if len(roi_deprecated) == 2:
                    self._max_roi_size_deprecated = roi_deprecated
                    self._show_roi_window_deprecated = True
                elif len(roi_deprecated) == 4:
                    self._roi_deprecated = roi_deprecated
                    self._show_roi_window_deprecated = False
                else:
                    raise ValueError("Error: Expected either 2 or 4 elements in roi.")
            # -roi with no arguments.
            else:
                self._show_roi_window_deprecated = True

    def set_event_params(
        self,
        min_event_len: ty.Union[int, float, str] = "0.1s",
        time_pre_event: ty.Union[int, float, str] = "1.5s",
        time_post_event: ty.Union[int, float, str] = "2s",
        use_pts: bool = False,
    ):
        """Set motion event parameters."""
        assert self._input.framerate is not None
        self._min_event_len = FrameTimecode(min_event_len, self._input.framerate)
        self._time_before_event = FrameTimecode(time_pre_event, self._input.framerate)
        self._post_event_len = FrameTimecode(time_post_event, self._input.framerate)
        self._use_pts = use_pts

    def set_thumbnail_params(self, thumbnails: str = None):
        self._thumbnails = thumbnails

    def set_video_time(
        self,
        start_time: ty.Optional[ty.Union[int, float, str]] = None,
        end_time: ty.Optional[ty.Union[int, float, str]] = None,
        duration: ty.Optional[ty.Union[int, float, str]] = None,
    ):
        """Set a sub-set of the video in time for processing."""
        assert self._input.framerate is not None
        if start_time is not None:
            self._start_time = FrameTimecode(start_time, self._input.framerate)
        if duration is not None:
            duration = FrameTimecode(duration, self._input.framerate)
            if self._start_time is not None:
                self._end_time = self._start_time + duration.frame_num
            else:
                self._end_time = duration
        elif end_time is not None:
            self._end_time = FrameTimecode(end_time, self._input.framerate)

    def _handle_regions(self) -> bool:
        # TODO(v2.0): Remove deprecated ROI selection handlers.
        if (self._show_roi_window_deprecated) and (
            self._load_region or self._regions or self._region_editor
        ):
            raise ValueError("Use -r/--region-editor instead of -roi.")
        if not self._select_roi_deprecated():
            return

        if self._load_region:
            if not self._load_region.exists():
                logger.error(f"File does not exist: {self._load_region}")
                raise ValueError(
                    "Could not find specified region file. Ensure the specified path is valid "
                    "and the file exists."
                )
            try:
                logger.info(f"Loading regions from file: {self._load_region}")
                region_editor = load_regions(self._load_region)
            except ValueError as ex:
                reason = " ".join(str(arg) for arg in ex.args)
                if not reason:
                    reason = "Could not parse region file!"
                logger.error(f"Error loading region from {self._load_region}: {reason}")
            else:
                logger.info(
                    "Loaded %d region%s:\n%s",
                    len(region_editor),
                    "s" if len(region_editor) > 1 else "",
                    "\n".join(f"[{i}] = {points}" for i, points in enumerate(region_editor)),
                )
            self._regions += region_editor
        if self._regions:
            self._regions = [
                [bound_point(point, Size(*self._input.resolution)) for point in shape]
                for shape in self._regions
            ]
        if self._region_editor:
            if not HAS_TKINTER:
                logger.error(
                    "Error: Region editor requires Tcl/Tk support to run. Try installing "
                    "the python3-tk package (sudo apt install python3-tk)."
                )
                raise SystemExit(1)
            if not HAS_PILLOW:
                logger.error(
                    "Error: Region editor requires pillow to run. Try installing "
                    "the pillow package (pip install pillow)."
                )
                raise SystemExit(1)

            logger.info("Selecting area of interest:")
            # TODO(#239): We should process this frame (right now it gets skipped).
            frame_for_crop = self._input.read()
            scale_factor = 1
            screen_bounds = get_min_screen_bounds()
            if screen_bounds is not None:
                max_w, max_h = screen_bounds[1], screen_bounds[0]
                frame_w, frame_h = frame_for_crop.shape[1], frame_for_crop.shape[0]
                if (max_h > 0 and frame_h > max_h) or (max_w > 0 and frame_w > max_w):
                    logger.debug("Max window size: %d x %d", max_w, max_h)
                    # Downscale the image if it's too large for the screen.
                    factor_h = frame_h / float(max_h) if max_h > 0 and frame_h > max_h else 1
                    factor_w = frame_w / float(max_w) if max_w > 0 and frame_w > max_w else 1
                    scale_factor = round(max(factor_h, factor_w))
            region_editor = RegionEditor(
                frame=frame_for_crop,
                initial_shapes=self._regions,
                initial_scale=scale_factor,
                debug_mode=self._debug_mode,
                video_path=self._input.paths[0],
                save_path=self._save_region,
            )
            if not region_editor.run():
                return False
            self._regions = list(region_editor.shapes)
        elif self._save_region:
            region_editor = (
                self._regions
                if self._regions
                else [
                    [
                        Point(0, 0),
                        Point(self._input.resolution[0] - 1, 0),
                        Point(self._input.resolution[0] - 1, self._input.resolution[1] - 1),
                        Point(0, self._input.resolution[1] - 1),
                    ]
                ]
            )
            path = self._save_region
            if self._output_dir:
                path = self._output_dir / path
            with open(path, "w") as region_file:
                for shape in self._regions:
                    region_file.write(" ".join(f"{x} {y}" for x, y in shape))
                    region_file.write("\n")
            logger.info(f"Saved region data to: {path}")
        if self._regions:
            logger.info(
                f"Limiting detection to {len(self._regions)} "
                f"region{'s' if len(self._regions) > 1 else ''}."
            )
        else:
            logger.debug("no regions selected")
        return True

    @property
    def frames_remaining(self) -> int:
        num_frames = self._input.total_frames
        # Correct for end time.
        if self._end_time and self._end_time.frame_num < num_frames:
            num_frames = self._end_time.frame_num
        # Correct for current seek position.
        if self._start_time:
            num_frames = max(0, num_frames - self._start_time.frame_num)
        return num_frames

    def _create_progress_bar(self) -> tqdm:
        return tqdm(
            total=self.frames_remaining,
            unit=" frames",
            desc=PROGRESS_BAR_DESCRIPTION % 0,
            dynamic_ncols=True,
        )

    def stop(self):
        """Stop the current scan call. Thread-safe."""
        self._stop.set()
        logger.debug("Stop event set.")

    def is_stopped(self):
        """Check if the current scan call was stopped, or `False` if one wasn't run. Thread-safe."""
        return self._stop.is_set()

    def set_callbacks(
        self, scan_started: ty.Callable[[int], None], processed_frame: ty.Callable[[int], None]
    ):
        self._scan_started = scan_started
        self._processed_frame = processed_frame

    def scan(self) -> ty.Optional[DetectionResult]:
        """Performs motion analysis on the MotionScanner's input video(s)."""
        self._stop.clear()
        buffered_frames: ty.List[np.ndarray] = []
        event_window: ty.List[float] = []
        event_list: ty.List[MotionEvent] = []
        num_frames_post_event = 0
        event_start = None

        in_motion_event = False
        frames_processed = 0

        # Seek to starting position if required.
        if self._start_time is not None:
            self._input.seek(self._start_time)

        if self._use_pts:
            start_frame_ms = self._input.position_ms
        else:
            start_frame = self._input.position.frame_num

        # Show ROI selection window if required.
        if not self._handle_regions():
            logger.info("Exiting...")
            return None

        if self._kernel_size == -1:
            # Calculate size of noise reduction kernel. Even if an ROI is set, the auto factor is
            # set based on the original video's input resolution.
            kernel_size = _recommended_kernel_size(
                self._input.resolution[0], self._downscale_factor
            )
        else:
            kernel_size = _scale_kernel_size(self._kernel_size, self._downscale_factor)

        test_width_height_area = (
            self._max_area < 1.0 or self._max_width < 1.0 or self._max_height < 1.0
        )

        # Create background subtractor and motion detector.
        # TODO: Figure out how to avoid logging unused parameters or emit a warning. For example,
        # the `variance_threshold` parameter is ignored by the `CNT` subtractor.
        detector = MotionDetector(
            subtractor=self._subtractor_type.value(
                variance_threshold=self._variance_threshold,
                kernel_size=kernel_size,
                learning_rate=self._learning_rate,
            ),
            frame_size=self._input.resolution,
            downscale=self._downscale_factor,
            regions=self._regions,
        )

        logger.info(
            "Using subtractor %s with kernel_size = %s%s, "
            "variance_threshold = %s and learning_rate = %s",
            self._subtractor_type.name,
            str(kernel_size) if kernel_size else "off",
            " (auto)" if self._kernel_size == -1 else "",
            str(self._variance_threshold) if self._variance_threshold != 16.0 else "auto",
            str(self._learning_rate) if self._learning_rate != -1 else "auto",
        )

        # Correct event length parameters to account frame skip.
        post_event_len: int = self._post_event_len.frame_num // (self._frame_skip + 1)
        pre_event_len: int = self._time_before_event.frame_num // (self._frame_skip + 1)
        min_event_len: int = max(self._min_event_len.frame_num // (self._frame_skip + 1), 1)

        # Calculations below rely on min_event_len always being >= 1 (cannot be zero)
        assert min_event_len >= 1, "min_event_len must be at least 1 frame"

        # Ensure that we include the exact amount of time specified in `-tb`/`--time-before` when
        # shifting the event start time. Instead of using `-l`/`--min-event-len` directly, we
        # need to compensate for rounding errors when we corrected it for frame skip. This is
        # important as this affects the number of frames we consider for the actual motion event.
        start_event_shift_ms: float = (
            self._time_before_event.get_seconds() + self._min_event_len.get_seconds()
        ) * 1000
        start_event_shift: int = self._time_before_event.frame_num + min_event_len * (
            self._frame_skip + 1
        )

        # Length of buffer we require in memory to keep track of all frames required for -l and -tb.
        buff_len = pre_event_len + min_event_len
        event_end = self._input.position
        last_frame_above_threshold_ms = 0
        last_frame_above_threshold = 0

        if self._bounding_box:
            self._bounding_box.set_corrections(
                downscale_factor=self._downscale_factor,
                shift=(detector.area[0].x, detector.area[0].y),
                frame_skip=self._frame_skip,
            )

        # Don't use the first result from the background subtractor.
        processed_first_frame = False

        # Motion event scanning/detection loop. Need to avoid CLI output/logging until end of the
        # main scanning loop below, otherwise it will interrupt the progress bar.
        logger.info(
            "Scanning %s for motion events...",
            "%d input videos" % len(self._input.paths)
            if len(self._input.paths) > 1
            else "input video",
        )
        logger.debug(
            f"input mode = {self._input._backend.BACKEND_NAME}, output mode = {self._output_mode}"
        )

        progress_bar = FakeTqdmObject() if not self._show_progress else self._create_progress_bar()
        num_frames_to_process = self.frames_remaining

        decode_queue = queue.Queue(MAX_DECODE_QUEUE_SIZE)
        decode_thread = threading.Thread(
            target=MotionScanner._decode_thread, args=(self, decode_queue), daemon=True
        )
        decode_thread.start()

        encode_thread = None
        if self._output_mode != OutputMode.SCAN_ONLY or self._mask_file is not None:
            encode_queue = queue.Queue(MAX_ENCODE_QUEUE_SIZE)
            encode_thread = threading.Thread(
                target=MotionScanner._encode_thread,
                args=(self, encode_queue),
                daemon=True,
            )
            encode_thread.start()

        if self._scan_started:
            self._scan_started(num_frames=num_frames_to_process)

        # TODO: The main scanning loop should be refactored into a state machine. The main loop
        # # has grown far too complicated and is difficult to maintain.
        while not self._stop.is_set():
            if self._processed_frame:
                num_events = len(event_list)
                if in_motion_event:
                    num_events += 1
                self._processed_frame(progress_bar=progress_bar, num_events=num_events)
            # Keep polling decode queue until it's empty (signaled via None).
            frame: ty.Optional[DecodeEvent] = decode_queue.get()
            if frame is None:
                break
            assert frame.frame_bgr is not None
            pts = frame.timecode.get_seconds() * 1000
            frame_size = (frame.frame_bgr.shape[1], frame.frame_bgr.shape[0])
            if frame_size != self._input.resolution:
                time = frame.timecode
                video_res = self._input.resolution
                logger.warn(
                    f"WARNING: Frame {time.frame_num} [{time.get_timecode()}] has unexpected size: "
                    f"{frame_size[0]}x{frame_size[1]}, expected {video_res[0]}x{video_res[1]}"
                )
            result = detector.update(frame.frame_bgr)
            frame_score = result.score

            if test_width_height_area:
                box_width = cv2.boundingRect(result.subtracted)[2] * self._downscale_factor
                box_height = cv2.boundingRect(result.subtracted)[3] * self._downscale_factor
                width_fraction = box_width / self._input.resolution[0]
                height_fraction = box_height / self._input.resolution[1]
                area_fraction = width_fraction * height_fraction

            # TODO: The rejection filter can be disabled by providing values > 255.0, but we should
            # provide a better method of disabling it. It might also be useful to allow users to
            # specify the amount of consecutive frames the filter can be active for.
            if frame_score >= self._max_threshold:
                frame_score = 0
            if test_width_height_area and (
                area_fraction > self._max_area
                or width_fraction > self._max_width
                or height_fraction > self._max_height
            ):
                frame_score = 0
            above_threshold = frame_score >= self._threshold

            if above_threshold and frame_score > self._highscore:
                self._highscore = frame_score
                self._highframe = frame.frame_bgr

            event_window.append(frame_score)
            # The first frame fed to the detector can sometimes produce unreliable results due
            # to it not having any previous information to compare against.
            if not processed_first_frame:
                above_threshold = False
                processed_first_frame = True
            event_window = event_window[-min_event_len:]

            bounding_box = None
            # TODO: Only call clear() when we exit the current motion event.
            # TODO: Include frames below the threshold for smoothing, or push a sentinel
            # value to update() to compensate the amount of smoothing accordingly.
            if self._bounding_box:
                bounding_box = (
                    self._bounding_box.update(result.subtracted)
                    if above_threshold
                    else self._bounding_box.clear()
                )

            if self._mask_file and not self._stop.is_set():
                encode_queue.put(
                    MotionMaskEvent(
                        motion_mask=np.ma.filled(result.masked, fill_value=63),
                        timecode=frame.timecode,
                        score=frame_score,
                        bounding_box=bounding_box,
                    )
                )

            # Last frame was part of a motion event, or still within the post-event window.
            if in_motion_event:
                # If this frame still has motion, reset the post-event window.
                if above_threshold:
                    num_frames_post_event = 0
                    last_frame_above_threshold = frame.timecode.frame_num
                    last_frame_above_threshold_ms = pts
                # Otherwise, we wait until the post-event window has passed before ending
                # this motion event and start looking for a new one.
                #
                # TODO(#72): We should wait until the max of *both* the pre-event and post-
                # event windows have passed. Right now we just consider the post-event window.
                else:
                    num_frames_post_event += 1
                    if num_frames_post_event >= post_event_len:
                        in_motion_event = False

                        logger.debug(
                            "event %d high score %f" % (1 + self._num_events, self._highscore)
                        )
                        if self._thumbnails == "highscore":
                            video_name = self._input.paths[0].stem
                            output_path: Path = (
                                self._comp_file
                                if self._comp_file
                                else Path(
                                    OUTPUT_FILE_TEMPLATE.format(
                                        VIDEO_NAME=video_name,
                                        EVENT_NUMBER="%04d" % (1 + self._num_events),
                                        EXTENSION="jpg",
                                    )
                                )
                            )
                            if self._output_dir:
                                output_path = self._output_dir / output_path
                            cv2.imwrite(str(output_path), self._highframe)
                            self._highscore = 0
                            self._highframe = None

                        # Calculate event end based on the last frame we had with motion plus
                        # the post event length time. We also need to compensate for the number
                        # of frames that we skipped that could have had motion.
                        # We also add 1 to include the presentation duration of the last frame.
                        if not self._use_pts:
                            event_end = FrameTimecode(
                                1
                                + last_frame_above_threshold
                                + self._post_event_len.frame_num
                                + self._frame_skip,
                                self._input.framerate,
                            )
                            assert event_end.frame_num >= event_start.frame_num, (
                                f"event_end frame {event_end.frame_num} < "
                                + f"event_start frame {event_start.frame_num}!"
                            )
                        else:
                            # TODO(#20): This is incorrect for VFR video, we need to work in seconds
                            # and right now FrameTimecode exclusively uses frames.
                            event_end = FrameTimecode(
                                (last_frame_above_threshold_ms / 1000)
                                + self._post_event_len.get_seconds(),
                                self._input.framerate,
                            )
                            # TODO(#254): This assertion fires when using VideoJoiner.
                            assert event_end.get_seconds() >= event_start.get_seconds(), (
                                f"event_end {event_end.get_seconds()}s < "
                                + f"event_start {event_start.get_seconds()}s!"
                            )
                        event_list.append(MotionEvent(start=event_start, end=event_end))
                        if self._output_mode != OutputMode.SCAN_ONLY:
                            encode_queue.put(MotionEvent(start=event_start, end=event_end))

                # Send frame to encode thread.
                if in_motion_event and self._output_mode == OutputMode.OPENCV:
                    encode_queue.put(
                        EncodeFrameEvent(
                            frame_bgr=frame.frame_bgr,
                            timecode=frame.timecode,
                            bounding_box=bounding_box,
                            score=frame_score,
                        )
                    )
            # Not already in a motion event, look for a new one.
            else:
                # Buffer the required amount of frames and overlay data until we find an event.
                if self._output_mode == OutputMode.OPENCV:
                    buffered_frames.append(
                        EncodeFrameEvent(
                            frame_bgr=frame.frame_bgr,
                            timecode=frame.timecode,
                            bounding_box=bounding_box,
                            score=frame_score,
                        )
                    )
                    buffered_frames = buffered_frames[-buff_len:]
                # Start a new event once all frames in the event window have motion.
                if len(event_window) >= min_event_len and all(
                    score >= self._threshold for score in event_window
                ):
                    in_motion_event = True
                    progress_bar.set_description(
                        PROGRESS_BAR_DESCRIPTION % (1 + len(event_list)), refresh=False
                    )
                    event_window = []
                    num_frames_post_event = 0
                    frames_since_last_event = frame.timecode.frame_num - event_end.frame_num
                    last_frame_above_threshold = frame.timecode.frame_num

                    if not self._use_pts:
                        shift_amount = min(frames_since_last_event, start_event_shift)
                        shifted_start = max(
                            start_frame, frame.timecode.frame_num + 1 - shift_amount
                        )
                        event_start = FrameTimecode(shifted_start, self._input.framerate)
                    else:
                        ms_since_last_event = pts - (event_end.get_seconds() * 1000)
                        last_frame_above_threshold_ms = pts
                        #  TODO:  not sure all of this is actually necessary?
                        shift_amount_ms = min(ms_since_last_event, start_event_shift_ms)
                        shifted_start_ms = max(start_frame_ms, pts - shift_amount_ms)
                        event_start = FrameTimecode(shifted_start_ms / 1000, self._input.framerate)
                    # Send buffered frames to encode thread.
                    for encode_frame in buffered_frames:
                        # We have to be careful here. Since we're putting multiple items
                        # into the queue, we have to keep making sure the encode thread
                        # is running. Otherwise, the queue will never empty we will block
                        # indefinitely here waiting for a spot.
                        if self._stop.is_set():
                            break
                        encode_queue.put(encode_frame)
                    buffered_frames = []

            frames_processed += 1 + self._frame_skip
            progress_bar.update(1 + self._frame_skip)

        # Close the progress bar before producing any more output.
        progress_bar.close()

        # Unblock any remaining puts, wait for decode thread, and re-raise any exceptions.
        while not decode_queue.empty():
            _ = decode_queue.get_nowait()
        decode_thread.join()
        if self._decode_thread_exception is not None:
            raise self._decode_thread_exception[1].with_traceback(self._decode_thread_exception[2])

        # Video ended, finished processing frames. If we're still in a motion event,
        # compute the duration and ending timecode and add it to the event list.
        if in_motion_event and not self._stop.is_set():
            # curr_pos already includes the presentation duration of the frame.
            if not self._use_pts:
                event_end = FrameTimecode(self._input.position.frame_num, self._input.framerate)
            else:
                event_end = FrameTimecode((pts / 1000), self._input.framerate)
            event_list.append(MotionEvent(start=event_start, end=event_end))

            logger.debug("event %d high score %f" % (1 + self._num_events, self._highscore))
            if self._thumbnails == "highscore":
                video_name = self._input.paths[0].stem
                output_path = (
                    self._comp_file
                    if self._comp_file
                    else Path(
                        OUTPUT_FILE_TEMPLATE.format(
                            VIDEO_NAME=video_name,
                            EVENT_NUMBER="%04d" % (1 + self._num_events),
                            EXTENSION="jpg",
                        )
                    )
                )
                if self._output_dir:
                    output_path = self._output_dir / output_path
                cv2.imwrite(str(output_path), self._highframe)
                self._highscore = 0
                self._highframe = None

            if self._output_mode != OutputMode.SCAN_ONLY:
                encode_queue.put(MotionEvent(start=event_start, end=event_end))

        # Push sentinel to queue, wait for encode thread, and re-raise any exceptions.
        if encode_thread is not None:
            encode_queue.put(None)
            encode_thread.join()
            if self._encode_thread_exception is not None:
                raise self._encode_thread_exception[1].with_traceback(
                    self._encode_thread_exception[2]
                )

        # Display an error if we got more than one decode failure / corrupt frame.
        # TODO: This will also fire if no frames are decoded. Add a check to make sure
        # the fourCC is valid. Also figure out a better way to handle the case where NO frames
        # are decoded (rather than reporting X frames failed to decode).
        if self._input.decode_failures > 1:
            logger.error(
                "Failed to decode %d frame(s) from video, timestamps may be incorrect. Try"
                " re-encoding or remuxing video (e.g. ffmpeg -i video.mp4 -c:v copy out.mp4). "
                "See https://github.com/Breakthrough/DVR-Scan/issues/62 for details.",
                self._input.decode_failures,
            )

        return DetectionResult(event_list, frames_processed)

    def _decode_thread(self, decode_queue: queue.Queue):
        try:
            while not self._stop.is_set():
                if self._end_time is not None and self._input.position >= self._end_time:
                    break
                for _ in range(self._frame_skip):
                    if self._input.read(decode=False) is None:
                        break
                frame_bgr = self._input.read()
                if frame_bgr is None:
                    break
                # self._input.position points to the time at the end of the current frame (i.e. the
                # first frame has a frame_num of 1), so we correct that for presentation time.
                assert self._input.position.frame_num > 0
                if not self._use_pts:
                    presentation_time = FrameTimecode(
                        timecode=self._input.position.frame_num - 1,
                        fps=self._input.framerate,
                    )
                else:
                    presentation_time = FrameTimecode(
                        self._input.position_ms / 1000, self._input.framerate
                    )
                if not self._stop.is_set():
                    decode_queue.put(DecodeEvent(frame_bgr, presentation_time))

        # We'll re-raise any exceptions from the main thread.
        except:  # noqa: E722
            self._stop.set()
            logger.critical("Fatal error: Exception raised in decode thread.")
            logger.debug(sys.exc_info())
            self._decode_thread_exception = sys.exc_info()
        finally:
            # Make sure main thread stops processing loop.
            decode_queue.put(None)

    def _init_video_writer(self, path: Path, frame_size: ty.Tuple[int, int]) -> cv2.VideoWriter:
        """Create a new cv2.VideoWriter using the correct framerate."""
        if self._output_dir:
            path = self._output_dir / path
        effective_framerate = (
            self._input.framerate
            if self._frame_skip < 1
            else self._input.framerate / (1 + self._frame_skip)
        )
        return cv2.VideoWriter(str(path), self._fourcc, effective_framerate, frame_size)

    def _on_encode_frame_event(self, event: EncodeFrameEvent):
        size = (event.frame_bgr.shape[1], event.frame_bgr.shape[0])
        if size != self._input.resolution:
            time = event.timecode
            video = self._input.resolution
            logger.warn(
                f"WARNING: Failed to write event at frame {time.frame_num} [{time.get_timecode()}] "
                f"due to size mismatch: {size[0]}x{size[1]}, expected {video[0]}x{video[1]}"
            )
            return
        if self._video_writer is None:
            # Use the first input video name as a filename template.
            video_name = self._input.paths[0].stem
            output_path = (
                self._comp_file
                if self._comp_file
                else Path(
                    OUTPUT_FILE_TEMPLATE.format(
                        VIDEO_NAME=video_name,
                        EVENT_NUMBER="%04d" % (1 + self._num_events),
                        EXTENSION="avi",
                    )
                )
            )
            self._video_writer = self._init_video_writer(output_path, size)
        # *NOTE*: Overlays are currently rendered in-place by modifying the event itself.
        self._draw_overlays(event.frame_bgr, event.timecode, event.score, event.bounding_box)
        # Encode and write frame to disk.

        self._video_writer.write(event.frame_bgr)

    def _draw_overlays(
        self,
        frame: np.ndarray,
        timecode: FrameTimecode,
        frame_score: float,
        bounding_box: ty.Optional[ty.Tuple[int, int, int, int]],
        use_shift=True,
    ):
        if self._timecode_overlay is not None:
            self._timecode_overlay.draw(frame, text=timecode.get_timecode())
        if self._metrics_overlay is not None:
            to_display = "Frame: %04d\nScore: %3.2f" % (
                timecode.get_frames(),
                frame_score,
            )
            self._metrics_overlay.draw(frame, text=to_display)
        if self._bounding_box is not None and bounding_box is not None:
            self._bounding_box.draw(frame, bounding_box, use_shift)

    def _on_mask_event(self, event: MotionMaskEvent):
        # Initialize the VideoWriter used for mask output.
        if self._mask_writer is None:
            self._mask_size = event.motion_mask.shape[1], event.motion_mask.shape[0]
            self._mask_writer = self._init_video_writer(self._mask_file, self._mask_size)
        # Write the motion mask to the output file.
        size = (event.motion_mask.shape[1], event.motion_mask.shape[0])
        if size != self._mask_size:
            time = event.timecode
            logger.warn(
                f"WARNING: Failed to write mask at frame {time.frame_num} [{time.get_timecode()}] "
                f"due to size mismatch: {size[0]}x{size[1]}, "
                f" expected {self._mask_size[0]}x{self._mask_size[1]}"
            )
            return
        out_frame = cv2.cvtColor(event.motion_mask, cv2.COLOR_GRAY2BGR)
        self._draw_overlays(
            out_frame, event.timecode, event.score, event.bounding_box, use_shift=False
        )
        self._mask_writer.write(out_frame)

    def _on_motion_event(self, event: MotionEvent):
        self._num_events += 1
        # Handle case where we're not using Ffmpeg.
        if self._output_mode not in (OutputMode.FFMPEG, OutputMode.COPY):
            # If we're at the end of the event, close the current VideoWriter to output the next
            # event to a new file (unless we're concatenating all events in the same output file).
            if not self._comp_file and self._video_writer is not None:
                self._video_writer.release()
                self._video_writer = None
            return
        # Output motion event using Ffmpeg.
        output_args = (
            self._ffmpeg_output_args
            if self._output_mode == OutputMode.FFMPEG
            else COPY_MODE_OUTPUT_ARGS
        )
        # Use the first input video name as a filename template.
        video_name = self._input.paths[0].stem
        output_path = (
            self._comp_file
            if self._comp_file
            else Path(
                OUTPUT_FILE_TEMPLATE.format(
                    VIDEO_NAME=video_name,
                    EVENT_NUMBER="%04d" % self._num_events,
                    EXTENSION="mp4",
                )
            )
        )
        if self._output_dir:
            output_path = self._output_dir / output_path
        # Only log the args passed to ffmpeg on the first event, to reduce log spam.
        log_args = False
        if self._num_events == 1:
            logger.info("Splitting events using ffmpeg, first event:")
            log_args = True
        _extract_event_ffmpeg(
            input_path=self._input.paths[0],
            output_path=output_path,
            start_time=event.start,
            end_time=event.end,
            ffmpeg_input_args=self._ffmpeg_input_args,
            ffmpeg_out_args=output_args,
            log_args=log_args,
        )

    def _encode_thread(self, encode_queue: queue.Queue):
        try:
            while True:
                event: ty.Optional[ty.Union[EncodeFrameEvent, MotionMaskEvent, MotionEvent]] = (
                    encode_queue.get()
                )
                if event is None:
                    break
                if isinstance(event, EncodeFrameEvent):
                    self._on_encode_frame_event(event)
                elif isinstance(event, MotionMaskEvent):
                    self._on_mask_event(event)
                elif isinstance(event, MotionEvent):
                    self._on_motion_event(event)
        # We'll re-raise any exceptions from the main thread.
        except:  # noqa: E722
            self._stop.set()
            logger.critical("Fatal error: Exception raised in encode thread.")
            logger.debug(sys.exc_info())
            self._encode_thread_exception = sys.exc_info()
        finally:
            if self._video_writer is not None:
                self._video_writer.release()
            if self._mask_writer is not None:
                self._mask_writer.release()
            # Unblock any waiting puts if we stopped early.
            while not encode_queue.empty():
                _ = encode_queue.get_nowait()

    # TODO(v2.0): Remove deprecated function, replaced by Region Editor.
    def _select_roi_deprecated(self) -> bool:
        # area selection
        if self._show_roi_window_deprecated:
            logger.warning(
                "**WARNING**: -roi/--region-of-interest is deprecated and will be removed.\n\n"
                "Use -r/--region-editor instead.\n"
            )
            logger.info("Selecting area of interest:")
            frame_for_crop = self._input.read()
            scale_factor = None
            if self._max_roi_size_deprecated is None:
                self._max_roi_size_deprecated = get_min_screen_bounds()
            if self._max_roi_size_deprecated is not None:
                frame_h, frame_w = (frame_for_crop.shape[0], frame_for_crop.shape[1])
                max_w, max_h = self._max_roi_size_deprecated
                # Downscale the image if it's too large for the screen.
                if frame_h > max_h or frame_w > max_w:
                    factor_h = frame_h / float(max_h)
                    factor_w = frame_w / float(max_w)
                    scale_factor = max(factor_h, factor_w)
                    new_height = round(frame_h / scale_factor)
                    new_width = round(frame_w / scale_factor)
                    frame_for_crop = cv2.resize(
                        frame_for_crop,
                        (new_width, new_height),
                        interpolation=cv2.INTER_CUBIC,
                    )
            roi = cv2.selectROI("DVR-Scan ROI Selection", frame_for_crop)
            cv2.destroyAllWindows()
            if any([coord == 0 for coord in roi[2:]]):
                logger.info("ROI selection cancelled. Aborting...")
                return False
            # Unscale coordinates if we downscaled the image.
            if scale_factor:
                roi = [round(x * scale_factor) for x in roi]
            self._roi_deprecated = roi
        if self._roi_deprecated:
            logger.info(
                "ROI set: (x,y)/(w,h) = (%d,%d)/(%d,%d)",
                self._roi_deprecated[0],
                self._roi_deprecated[1],
                self._roi_deprecated[2],
                self._roi_deprecated[3],
            )
            x, y, w, h = self._roi_deprecated
            region = [
                Point(x, y),
                Point(x + w, y),
                Point(x + w, y + h),
                Point(x, y + h),
            ]
            region_arg = " ".join(f"{x} {y}" for x, y in region)
            logger.warning(
                "**WARNING**: region-of-interest (-roi) is deprecated and will be removed.\n\n"
                "You can use the following equivalent region:\n"
                f"--add-region {region_arg}\n"
                "For config files, save this region to a file and set the load-region option.\n"
            )
            self._regions += [region]
        return True
