# -*- coding: utf-8 -*-
#
#            PySceneDetect: Python-Based Video Scene Detector
#   -------------------------------------------------------------------
#     [  Site:    https://scenedetect.com                           ]
#     [  Docs:    https://scenedetect.com/docs/                     ]
#     [  Github:  https://github.com/Breakthrough/PySceneDetect/    ]
#
# Copyright (C) 2014-2024 Brandon Castellano <http://www.bcastell.com>.
# PySceneDetect is licensed under the BSD 3-Clause License; see the
# included LICENSE file, or visit one of the above pages for details.
#
"""Context of which command-line options and config settings the user provided."""

import logging
import os
import typing as ty
from typing import Any, AnyStr, Dict, Optional, Tuple, Type

import click

import scenedetect

from scenedetect import open_video, AVAILABLE_BACKENDS

from scenedetect.scene_detector import SceneDetector, FlashFilter
from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, init_logger
from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA
from scenedetect.video_stream import VideoStream, VideoOpenFailure, FrameRateUnavailable
from scenedetect.video_splitter import is_mkvmerge_available, is_ffmpeg_available
from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector, HistogramDetector
from scenedetect.stats_manager import StatsManager
from scenedetect.scene_manager import SceneManager, Interpolation

from scenedetect._cli.config import (ConfigRegistry, ConfigLoadFailure, TimecodeFormat, CHOICE_MAP,
                                     DEFAULT_JPG_QUALITY, DEFAULT_WEBP_QUALITY)

logger = logging.getLogger('pyscenedetect')

USER_CONFIG = ConfigRegistry(throw_exception=False)


def parse_timecode(value: ty.Optional[str],
                   frame_rate: float,
                   correct_pts: bool = False) -> FrameTimecode:
    """Parses a user input string into a FrameTimecode assuming the given framerate.

    If value is None, None will be returned instead of processing the value.

    Raises:
        click.BadParameter
     """
    if value is None:
        return None
    try:
        if correct_pts and value.isdigit():
            value = int(value)
            if value >= 1:
                value -= 1
        return FrameTimecode(timecode=value, fps=frame_rate)
    except ValueError as ex:
        raise click.BadParameter(
            'timecode must be in seconds (100.0), frames (100), or HH:MM:SS') from ex


def contains_sequence_or_url(video_path: str) -> bool:
    """Checks if the video path is a URL or image sequence."""
    return '%' in video_path or '://' in video_path


def check_split_video_requirements(use_mkvmerge: bool) -> None:
    """ Validates that the proper tool is available on the system to perform the
    `split-video` command.

    Arguments:
        use_mkvmerge: True if mkvmerge (-m), False otherwise.

    Raises: click.BadParameter if the proper video splitting tool cannot be found.
    """

    if (use_mkvmerge and not is_mkvmerge_available()) or not is_ffmpeg_available():
        error_strs = [
            "{EXTERN_TOOL} is required for split-video{EXTRA_ARGS}.".format(
                EXTERN_TOOL='mkvmerge' if use_mkvmerge else 'ffmpeg',
                EXTRA_ARGS=' when mkvmerge (-m) is set' if use_mkvmerge else '')
        ]
        error_strs += ['Ensure the program is available on your system and try again.']
        if not use_mkvmerge and is_mkvmerge_available():
            error_strs += ['You can specify mkvmerge (-m) to use mkvmerge for splitting.']
        elif use_mkvmerge and is_ffmpeg_available():
            error_strs += ['You can specify copy (-c) to use ffmpeg stream copying.']
        error_str = '\n'.join(error_strs)
        raise click.BadParameter(error_str, param_hint='split-video')


# pylint: disable=too-many-instance-attributes,too-many-arguments,too-many-locals
class CliContext:
    """Context of the command-line interface and config file parameters passed between sub-commands.

    Handles validation of options taken in from the CLI *and* configuration files.

    After processing the main program options via `handle_options`, the CLI will then call
    the respective `handle_*` method for each command. Once all commands have been
    processed, the main program actions are executed by passing this object to the
    `run_scenedetect` function in `scenedetect.cli.controller`.
    """

    def __init__(self):
        self.config = USER_CONFIG
        self.video_stream: VideoStream = None
        self.scene_manager: SceneManager = None
        self.stats_manager: StatsManager = None
        self.added_detector: bool = False

        # Global `scenedetect` Options
        self.output_dir: str = None                         # -o/--output
        self.quiet_mode: bool = None                        # -q/--quiet or -v/--verbosity quiet
        self.stats_file_path: str = None                    # -s/--stats
        self.drop_short_scenes: bool = None                 # --drop-short-scenes
        self.merge_last_scene: bool = None                  # --merge-last-scene
        self.min_scene_len: FrameTimecode = None            # -m/--min-scene-len
        self.frame_skip: int = None                         # -fs/--frame-skip
        self.default_detector: Tuple[Type[SceneDetector],
                                     Dict[str, Any]] = None # [global] default-detector

        # `time` Command Options
        self.time: bool = False
        self.start_time: FrameTimecode = None # time -s/--start
        self.end_time: FrameTimecode = None   # time -e/--end
        self.duration: FrameTimecode = None   # time -d/--duration

        # `save-images` Command Options
        self.save_images: bool = False
        self.image_extension: str = None        # save-images -j/--jpeg, -w/--webp, -p/--png
        self.image_dir: str = None              # save-images -o/--output
        self.image_param: int = None            # save-images -q/--quality if -j/-w,
                                                #   otherwise -c/--compression if -p
        self.image_name_format: str = None      # save-images -f/--name-format
        self.num_images: int = None             # save-images -n/--num-images
        self.frame_margin: int = 1              # save-images -m/--frame-margin
        self.scale: float = None                # save-images -s/--scale
        self.height: int = None                 # save-images -h/--height
        self.width: int = None                  # save-images -w/--width
        self.scale_method: Interpolation = None # [save-images] scale-method

        # `split-video` Command Options
        self.split_video: bool = False
        self.split_mkvmerge: bool = None   # split-video -m/--mkvmerge
        self.split_args: str = None        # split-video -a/--args, -c/--copy
        self.split_dir: str = None         # split-video -o/--output
        self.split_name_format: str = None # split-video -f/--filename
        self.split_quiet: bool = None      # split-video -q/--quiet

        # `list-scenes` Command Options
        self.list_scenes: bool = False
        self.list_scenes_quiet: bool = None                       # list-scenes -q/--quiet
        self.scene_list_dir: str = None                           # list-scenes -o/--output
        self.scene_list_name_format: str = None                   # list-scenes -f/--filename
        self.scene_list_output: bool = None                       # list-scenes -n/--no-output-file
        self.skip_cuts: bool = None                               # list-scenes -s/--skip-cuts
        self.display_cuts: bool = True                            # [list-scenes] display-cuts
        self.display_scenes: bool = True                          # [list-scenes] display-scenes
        self.cut_format: TimecodeFormat = TimecodeFormat.TIMECODE # [list-scenes] cut-format

        # `export-html` Command Options
        self.export_html: bool = False
        self.html_name_format: str = None     # export-html -f/--filename
        self.html_include_images: bool = None # export-html --no-images
        self.image_width: int = None          # export-html -w/--image-width
        self.image_height: int = None         # export-html -h/--image-height

        # `load-scenes` Command Options
        self.load_scenes_input: str = None       # load-scenes -i/--input
        self.load_scenes_column_name: str = None # load-scenes -c/--start-col-name

    #
    # Command Handlers
    #

    def handle_options(
        self,
        input_path: AnyStr,
        output: Optional[AnyStr],
        framerate: float,
        stats_file: Optional[AnyStr],
        downscale: Optional[int],
        frame_skip: int,
        min_scene_len: str,
        drop_short_scenes: bool,
        merge_last_scene: bool,
        backend: Optional[str],
        quiet: bool,
        logfile: Optional[AnyStr],
        config: Optional[AnyStr],
        stats: Optional[AnyStr],
        verbosity: Optional[str],
    ):
        """Parse all global options/arguments passed to the main scenedetect command,
        before other sub-commands (e.g. this function processes the [options] when calling
        `scenedetect [options] [commands [command options]]`).

        Raises:
            click.BadParameter: One of the given options/parameters is invalid.
            click.Abort: Fatal initialization failure.
        """

        # TODO(v1.0): Make the stats value optional (e.g. allow -s only), and allow use of
        # $VIDEO_NAME macro in the name.  Default to $VIDEO_NAME.csv.

        try:
            init_failure = not self.config.initialized
            init_log = self.config.get_init_log()
            quiet = not init_failure and quiet
            self._initialize_logging(quiet=quiet, verbosity=verbosity, logfile=logfile)

            # Configuration file was specified via CLI argument -c/--config.
            if config and not init_failure:
                self.config = ConfigRegistry(config)
                init_log += self.config.get_init_log()
                # Re-initialize logger with the correct verbosity.
                if verbosity is None and not self.config.is_default('global', 'verbosity'):
                    verbosity_str = self.config.get_value('global', 'verbosity')
                    assert verbosity_str in CHOICE_MAP['global']['verbosity']
                    self.quiet_mode = False
                    self._initialize_logging(verbosity=verbosity_str, logfile=logfile)

        except ConfigLoadFailure as ex:
            init_failure = True
            init_log += ex.init_log
            if ex.reason is not None:
                init_log += [(logging.ERROR, 'Error: %s' % str(ex.reason).replace('\t', '  '))]
        finally:
            # Make sure we print the version number even on any kind of init failure.
            logger.info('PySceneDetect %s', scenedetect.__version__)
            for (log_level, log_str) in init_log:
                logger.log(log_level, log_str)
            if init_failure:
                logger.critical("Error processing configuration file.")
                raise click.Abort()

        if self.config.config_dict:
            logger.debug("Current configuration:\n%s", str(self.config.config_dict))

        logger.debug('Parsing program options.')
        if stats is not None and frame_skip:
            error_strs = [
                'Unable to detect scenes with stats file if frame skip is not 0.',
                '  Either remove the -fs/--frame-skip option, or the -s/--stats file.\n'
            ]
            logger.error('\n'.join(error_strs))
            raise click.BadParameter(
                'Combining the -s/--stats and -fs/--frame-skip options is not supported.',
                param_hint='frame skip + stats file')

        # Handle the case where -i/--input was not specified (e.g. for the `help` command).
        if input_path is None:
            return

        # Have to load the input video to obtain a time base before parsing timecodes.
        self._open_video_stream(
            input_path=input_path,
            framerate=framerate,
            backend=self.config.get_value("global", "backend", backend, ignore_default=True))

        self.output_dir = output if output else self.config.get_value("global", "output")
        if self.output_dir:
            logger.info('Output directory set:\n  %s', self.output_dir)

        self.min_scene_len = parse_timecode(
            min_scene_len if min_scene_len is not None else self.config.get_value(
                "global", "min-scene-len"), self.video_stream.frame_rate)
        self.drop_short_scenes = drop_short_scenes or self.config.get_value(
            "global", "drop-short-scenes")
        self.merge_last_scene = merge_last_scene or self.config.get_value(
            "global", "merge-last-scene")
        self.frame_skip = self.config.get_value("global", "frame-skip", frame_skip)

        # Create StatsManager if --stats is specified.
        if stats_file:
            self.stats_file_path = stats_file
            self.stats_manager = StatsManager()

        # Initialize default detector with values in the config file.
        default_detector = self.config.get_value("global", "default-detector")
        if default_detector == 'detect-adaptive':
            self.default_detector = (AdaptiveDetector, self.get_detect_adaptive_params())
        elif default_detector == 'detect-content':
            self.default_detector = (ContentDetector, self.get_detect_content_params())
        elif default_detector == 'detect-hash':
            self.default_detector = (HashDetector, self.get_detect_hash_params())
        elif default_detector == 'detect-hist':
            self.default_detector = (HistogramDetector, self.get_detect_hist_params())
        elif default_detector == 'detect-threshold':
            self.default_detector = (ThresholdDetector, self.get_detect_threshold_params())
        else:
            raise click.BadParameter("Unknown detector type!", param_hint='default-detector')

        logger.debug('Initializing SceneManager.')
        scene_manager = SceneManager(self.stats_manager)

        if downscale is None and self.config.is_default("global", "downscale"):
            scene_manager.auto_downscale = True
        else:
            scene_manager.auto_downscale = False
            downscale = self.config.get_value("global", "downscale", downscale)
            try:
                scene_manager.downscale = downscale
            except ValueError as ex:
                logger.debug(str(ex))
                raise click.BadParameter(str(ex), param_hint='downscale factor')
        scene_manager.interpolation = Interpolation[self.config.get_value(
            'global', 'downscale-method').upper()]
        self.scene_manager = scene_manager

    def get_detect_content_params(
        self,
        threshold: Optional[float] = None,
        luma_only: bool = None,
        min_scene_len: Optional[str] = None,
        weights: Optional[Tuple[float, float, float, float]] = None,
        kernel_size: Optional[int] = None,
        filter_mode: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Handle detect-content command options and return args to construct one with."""
        self._ensure_input_open()

        if self.drop_short_scenes:
            min_scene_len = 0
        else:
            if min_scene_len is None:
                if self.config.is_default('detect-content', 'min-scene-len'):
                    min_scene_len = self.min_scene_len.frame_num
                else:
                    min_scene_len = self.config.get_value('detect-content', 'min-scene-len')
            min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num

        if weights is not None:
            try:
                weights = ContentDetector.Components(*weights)
            except ValueError as ex:
                logger.debug(str(ex))
                raise click.BadParameter(str(ex), param_hint='weights')

        return {
            'weights':
                self.config.get_value('detect-content', 'weights', weights),
            'kernel_size':
                self.config.get_value('detect-content', 'kernel-size', kernel_size),
            'luma_only':
                luma_only or self.config.get_value('detect-content', 'luma-only'),
            'min_scene_len':
                min_scene_len,
            'threshold':
                self.config.get_value('detect-content', 'threshold', threshold),
            'filter_mode':
                FlashFilter.Mode[self.config.get_value("detect-content", "filter-mode",
                                                       filter_mode).upper()],
        }

    def get_detect_adaptive_params(
        self,
        threshold: Optional[float] = None,
        min_content_val: Optional[float] = None,
        frame_window: Optional[int] = None,
        luma_only: bool = None,
        min_scene_len: Optional[str] = None,
        weights: Optional[Tuple[float, float, float, float]] = None,
        kernel_size: Optional[int] = None,
        min_delta_hsv: Optional[float] = None,
    ) -> Dict[str, Any]:
        """Handle detect-adaptive command options and return args to construct one with."""
        self._ensure_input_open()

        # TODO(v0.7): Remove these branches when removing -d/--min-delta-hsv.
        if min_delta_hsv is not None:
            logger.error('-d/--min-delta-hsv is deprecated, use -c/--min-content-val instead.')
            if min_content_val is None:
                min_content_val = min_delta_hsv
        # Handle case where deprecated min-delta-hsv is set, and use it to set min-content-val.
        if not self.config.is_default("detect-adaptive", "min-delta-hsv"):
            logger.error('[detect-adaptive] config file option `min-delta-hsv` is deprecated'
                         ', use `min-delta-hsv` instead.')
            if self.config.is_default("detect-adaptive", "min-content-val"):
                self.config.config_dict["detect-adaptive"]["min-content-val"] = (
                    self.config.config_dict["detect-adaptive"]["min-deleta-hsv"])

        if self.drop_short_scenes:
            min_scene_len = 0
        else:
            if min_scene_len is None:
                if self.config.is_default("detect-adaptive", "min-scene-len"):
                    min_scene_len = self.min_scene_len.frame_num
                else:
                    min_scene_len = self.config.get_value("detect-adaptive", "min-scene-len")
            min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num

        if weights is not None:
            try:
                weights = ContentDetector.Components(*weights)
            except ValueError as ex:
                logger.debug(str(ex))
                raise click.BadParameter(str(ex), param_hint='weights')
        return {
            'adaptive_threshold':
                self.config.get_value("detect-adaptive", "threshold", threshold),
            'weights':
                self.config.get_value("detect-adaptive", "weights", weights),
            'kernel_size':
                self.config.get_value("detect-adaptive", "kernel-size", kernel_size),
            'luma_only':
                luma_only or self.config.get_value("detect-adaptive", "luma-only"),
            'min_content_val':
                self.config.get_value("detect-adaptive", "min-content-val", min_content_val),
            'min_scene_len':
                min_scene_len,
            'window_width':
                self.config.get_value("detect-adaptive", "frame-window", frame_window),
        }

    def get_detect_threshold_params(
        self,
        threshold: Optional[float] = None,
        fade_bias: Optional[float] = None,
        add_last_scene: bool = None,
        min_scene_len: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Handle detect-threshold command options and return args to construct one with."""
        self._ensure_input_open()

        if self.drop_short_scenes:
            min_scene_len = 0
        else:
            if min_scene_len is None:
                if self.config.is_default("detect-threshold", "min-scene-len"):
                    min_scene_len = self.min_scene_len.frame_num
                else:
                    min_scene_len = self.config.get_value("detect-threshold", "min-scene-len")
            min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num
        # TODO(v1.0): add_last_scene cannot be disabled right now.
        return {
            'add_final_scene':
                add_last_scene or self.config.get_value("detect-threshold", "add-last-scene"),
            'fade_bias':
                self.config.get_value("detect-threshold", "fade-bias", fade_bias),
            'min_scene_len':
                min_scene_len,
            'threshold':
                self.config.get_value("detect-threshold", "threshold", threshold),
        }

    def handle_load_scenes(self, input: AnyStr, start_col_name: Optional[str]):
        """Handle `load-scenes` command options."""
        self._ensure_input_open()
        if self.added_detector:
            raise click.ClickException("The load-scenes command cannot be used with detectors.")
        if self.load_scenes_input:
            raise click.ClickException("The load-scenes command must only be specified once.")
        input = os.path.abspath(input)
        if not os.path.exists(input):
            raise click.BadParameter(
                f'Could not load scenes, file does not exist: {input}', param_hint='-i/--input')
        self.load_scenes_input = input
        self.load_scenes_column_name = self.config.get_value("load-scenes", "start-col-name",
                                                             start_col_name)

    def get_detect_hist_params(self, threshold: Optional[float], bins: Optional[int],
                               min_scene_len: Optional[str]) -> Dict[str, Any]:
        """Handle detect-hist command options and return args to construct one with."""
        self._ensure_input_open()
        if self.drop_short_scenes:
            min_scene_len = 0
        else:
            if min_scene_len is None:
                if self.config.is_default("detect-hist", "min-scene-len"):
                    min_scene_len = self.min_scene_len.frame_num
                else:
                    min_scene_len = self.config.get_value("detect-hist", "min-scene-len")
            min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num
        return {
            'bins': self.config.get_value("detect-hist", "bins", bins),
            'min_scene_len': min_scene_len,
            'threshold': self.config.get_value("detect-hist", "threshold", threshold),
        }

    def get_detect_hash_params(self, threshold: Optional[float], size: Optional[int],
                               lowpass: Optional[int],
                               min_scene_len: Optional[str]) -> Dict[str, Any]:
        """Handle detect-hash command options and return args to construct one with."""
        self._ensure_input_open()
        if self.drop_short_scenes:
            min_scene_len = 0
        else:
            if min_scene_len is None:
                if self.config.is_default("detect-hash", "min-scene-len"):
                    min_scene_len = self.min_scene_len.frame_num
                else:
                    min_scene_len = self.config.get_value("detect-hash", "min-scene-len")
            min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num
        return {
            "lowpass": self.config.get_value("detect-hash", "lowpass", lowpass),
            "min_scene_len": min_scene_len,
            "size": self.config.get_value("detect-hash", "size", size),
            "threshold": self.config.get_value("detect-hash", "threshold", threshold),
        }

    def handle_export_html(
        self,
        filename: Optional[AnyStr],
        no_images: bool,
        image_width: Optional[int],
        image_height: Optional[int],
    ):
        """Handle `export-html` command options."""
        self._ensure_input_open()
        if self.export_html:
            self._on_duplicate_command('export_html')

        no_images = no_images or self.config.get_value('export-html', 'no-images')
        self.html_include_images = not no_images

        self.html_name_format = self.config.get_value('export-html', 'filename', filename)
        self.image_width = self.config.get_value('export-html', 'image-width', image_width)
        self.image_height = self.config.get_value('export-html', 'image-height', image_height)

        if not self.save_images and not no_images:
            raise click.BadArgumentUsage(
                'The export-html command requires that the save-images command\n'
                'is specified before it, unless --no-images is specified.')
        logger.info('HTML file name format:\n %s', filename)

        self.export_html = True

    def handle_list_scenes(
        self,
        output: Optional[AnyStr],
        filename: Optional[AnyStr],
        no_output_file: bool,
        quiet: bool,
        skip_cuts: bool,
    ):
        """Handle `list-scenes` command options."""
        self._ensure_input_open()
        if self.list_scenes:
            self._on_duplicate_command("list-scenes")

        self.display_cuts = self.config.get_value("list-scenes", "display-cuts")
        self.display_scenes = self.config.get_value("list-scenes", "display-scenes")
        self.skip_cuts = skip_cuts or self.config.get_value("list-scenes", "skip-cuts")
        self.cut_format = TimecodeFormat[self.config.get_value("list-scenes", "cut-format").upper()]
        self.list_scenes_quiet = quiet or self.config.get_value("list-scenes", "quiet")
        no_output_file = no_output_file or self.config.get_value("list-scenes", "no-output-file")

        self.scene_list_dir = self.config.get_value(
            "list-scenes", "output", output, ignore_default=True)
        self.scene_list_name_format = self.config.get_value("list-scenes", "filename", filename)
        if self.scene_list_name_format is not None and not no_output_file:
            logger.info("Scene list filename format:\n  %s", self.scene_list_name_format)
        self.scene_list_output = not no_output_file
        if self.scene_list_dir is not None:
            logger.info("Scene list output directory:\n  %s", self.scene_list_dir)

        self.list_scenes = True

    def handle_split_video(
        self,
        output: Optional[AnyStr],
        filename: Optional[AnyStr],
        quiet: bool,
        copy: bool,
        high_quality: bool,
        rate_factor: Optional[int],
        preset: Optional[str],
        args: Optional[str],
        mkvmerge: bool,
    ):
        """Handle `split-video` command options."""
        self._ensure_input_open()
        if self.split_video:
            self._on_duplicate_command('split-video')

        check_split_video_requirements(use_mkvmerge=mkvmerge)

        if contains_sequence_or_url(self.video_stream.path):
            error_str = 'The split-video command is incompatible with image sequences/URLs.'
            raise click.BadParameter(error_str, param_hint='split-video')

        ##
        ## Common Arguments/Options
        ##

        self.split_video = True
        self.split_quiet = quiet or self.config.get_value('split-video', 'quiet')
        self.split_dir = self.config.get_value('split-video', 'output', output, ignore_default=True)
        if self.split_dir is not None:
            logger.info('Video output path set:  \n%s', self.split_dir)
        self.split_name_format = self.config.get_value('split-video', 'filename', filename)

        # We only load the config values for these flags/options if none of the other
        # encoder flags/options were set via the CLI to avoid any conflicting options
        # (e.g. if the config file sets `high-quality = yes` but `--copy` is specified).
        if not (mkvmerge or copy or high_quality or args or rate_factor or preset):
            mkvmerge = self.config.get_value('split-video', 'mkvmerge')
            copy = self.config.get_value('split-video', 'copy')
            high_quality = self.config.get_value('split-video', 'high-quality')
            rate_factor = self.config.get_value('split-video', 'rate-factor')
            preset = self.config.get_value('split-video', 'preset')
            args = self.config.get_value('split-video', 'args')

        # Disallow certain combinations of flags/options.
        if mkvmerge or copy:
            command = 'mkvmerge (-m)' if mkvmerge else 'copy (-c)'
            if high_quality:
                raise click.BadParameter(
                    'high-quality (-hq) cannot be used with %s' % (command),
                    param_hint='split-video')
            if args:
                raise click.BadParameter(
                    'args (-a) cannot be used with %s' % (command), param_hint='split-video')
            if rate_factor:
                raise click.BadParameter(
                    'rate-factor (crf) cannot be used with %s' % (command),
                    param_hint='split-video')
            if preset:
                raise click.BadParameter(
                    'preset (-p) cannot be used with %s' % (command), param_hint='split-video')

        ##
        ## mkvmerge-Specific Arguments/Options
        ##
        if mkvmerge:
            if copy:
                logger.warning('copy mode (-c) ignored due to mkvmerge mode (-m).')
            self.split_mkvmerge = True
            logger.info('Using mkvmerge for video splitting.')
            return

        ##
        ## ffmpeg-Specific Arguments/Options
        ##
        if copy:
            args = "-map 0:v:0 -map 0:a? -map 0:s? -c:v copy -c:a copy"
        elif not args:
            if rate_factor is None:
                rate_factor = 22 if not high_quality else 17
            if preset is None:
                preset = "veryfast" if not high_quality else "slow"
            args = ("-map 0:v:0 -map 0:a? -map 0:s? "
                    f"-c:v libx264 -preset {preset} -crf {rate_factor} -c:a aac")

        logger.info('ffmpeg arguments: %s', args)
        self.split_args = args
        if filename:
            logger.info('Output file name format: %s', filename)

    def handle_save_images(
        self,
        num_images: Optional[int],
        output: Optional[AnyStr],
        filename: Optional[AnyStr],
        jpeg: bool,
        webp: bool,
        quality: Optional[int],
        png: bool,
        compression: Optional[int],
        frame_margin: Optional[int],
        scale: Optional[float],
        height: Optional[int],
        width: Optional[int],
    ):
        """Handle `save-images` command options."""
        self._ensure_input_open()
        if self.save_images:
            self._on_duplicate_command('save-images')

        if '://' in self.video_stream.path:
            error_str = '\nThe save-images command is incompatible with URLs.'
            logger.error(error_str)
            raise click.BadParameter(error_str, param_hint='save-images')

        num_flags = sum([1 if flag else 0 for flag in [jpeg, webp, png]])
        if num_flags > 1:
            logger.error('Multiple image type flags set for save-images command.')
            raise click.BadParameter(
                'Only one image type (JPG/PNG/WEBP) can be specified.', param_hint='save-images')
        # Only use config params for image format if one wasn't specified.
        elif num_flags == 0:
            image_format = self.config.get_value('save-images', 'format').lower()
            jpeg = image_format == 'jpeg'
            webp = image_format == 'webp'
            png = image_format == 'png'

        # Only use config params for scale/height/width if none of them are specified explicitly.
        if scale is None and height is None and width is None:
            self.scale = self.config.get_value('save-images', 'scale')
            self.height = self.config.get_value('save-images', 'height')
            self.width = self.config.get_value('save-images', 'width')
        else:
            self.scale = scale
            self.height = height
            self.width = width

        self.scale_method = Interpolation[self.config.get_value('save-images',
                                                                'scale-method').upper()]

        default_quality = DEFAULT_WEBP_QUALITY if webp else DEFAULT_JPG_QUALITY
        quality = (
            default_quality if self.config.is_default('save-images', 'quality') else
            self.config.get_value('save-images', 'quality'))

        compression = self.config.get_value('save-images', 'compression', compression)
        self.image_param = compression if png else quality

        self.image_extension = 'jpg' if jpeg else 'png' if png else 'webp'
        valid_params = get_cv2_imwrite_params()
        if not self.image_extension in valid_params or valid_params[self.image_extension] is None:
            error_strs = [
                'Image encoder type `%s` not supported.' % self.image_extension.upper(),
                'The specified encoder type could not be found in the current OpenCV module.',
                'To enable this output format, please update the installed version of OpenCV.',
                'If you build OpenCV, ensure the the proper dependencies are enabled. '
            ]
            logger.debug('\n'.join(error_strs))
            raise click.BadParameter('\n'.join(error_strs), param_hint='save-images')

        self.image_dir = self.config.get_value('save-images', 'output', output, ignore_default=True)

        self.image_name_format = self.config.get_value('save-images', 'filename', filename)
        self.num_images = self.config.get_value('save-images', 'num-images', num_images)
        self.frame_margin = self.config.get_value('save-images', 'frame-margin', frame_margin)

        image_type = ('jpeg' if jpeg else self.image_extension).upper()
        image_param_type = 'Compression' if png else 'Quality'
        image_param_type = ' [%s: %d]' % (image_param_type, self.image_param)
        logger.info('Image output format set: %s%s', image_type, image_param_type)
        if self.image_dir is not None:
            logger.info('Image output directory set:\n  %s', os.path.abspath(self.image_dir))

        self.save_images = True

    def handle_time(self, start, duration, end):
        """Handle `time` command options."""
        self._ensure_input_open()
        if self.time:
            self._on_duplicate_command('time')
        if duration is not None and end is not None:
            raise click.BadParameter(
                'Only one of --duration/-d or --end/-e can be specified, not both.',
                param_hint='time')
        logger.debug('Setting video time:\n    start: %s, duration: %s, end: %s', start, duration,
                     end)
        # *NOTE*: The Python API uses 0-based frame indices, but the CLI uses 1-based indices to
        # match the default start number used by `ffmpeg` when saving frames as images. As such,
        # we must correct start time if set as frames. See the test_cli_time* tests for for details.
        self.start_time = parse_timecode(start, self.video_stream.frame_rate, correct_pts=True)
        self.end_time = parse_timecode(end, self.video_stream.frame_rate)
        self.duration = parse_timecode(duration, self.video_stream.frame_rate)
        if self.start_time and self.end_time and (self.start_time + 1) > self.end_time:
            raise click.BadParameter("-e/--end time must be greater than -s/--start")
        self.time = True

    #
    # Private Methods
    #

    def _initialize_logging(
        self,
        quiet: Optional[bool] = None,
        verbosity: Optional[str] = None,
        logfile: Optional[AnyStr] = None,
    ):
        """Setup logging based on CLI args and user configuration settings."""
        if quiet is not None:
            self.quiet_mode = bool(quiet)
        curr_verbosity = logging.INFO
        # Convert verbosity into it's log level enum, and override quiet mode if set.
        if verbosity is not None:
            assert verbosity in CHOICE_MAP['global']['verbosity']
            if verbosity.lower() == 'none':
                self.quiet_mode = True
                verbosity = 'info'
            else:
                # Override quiet mode if verbosity is set.
                self.quiet_mode = False
            curr_verbosity = getattr(logging, verbosity.upper())
        else:
            verbosity_str = USER_CONFIG.get_value('global', 'verbosity')
            assert verbosity_str in CHOICE_MAP['global']['verbosity']
            if verbosity_str.lower() == 'none':
                self.quiet_mode = True
            else:
                curr_verbosity = getattr(logging, verbosity_str.upper())
                # Override quiet mode if verbosity is set.
                if not USER_CONFIG.is_default('global', 'verbosity'):
                    self.quiet_mode = False
        # Initialize logger with the set CLI args / user configuration.
        init_logger(log_level=curr_verbosity, show_stdout=not self.quiet_mode, log_file=logfile)

    def add_detector(self, detector):
        """ Add Detector: Adds a detection algorithm to the CliContext's SceneManager. """
        if self.load_scenes_input:
            raise click.ClickException("The load-scenes command cannot be used with detectors.")
        self._ensure_input_open()
        self.scene_manager.add_detector(detector)
        self.added_detector = True

    def _ensure_input_open(self) -> None:
        """Ensure self.video_stream was initialized (i.e. -i/--input was specified),
        otherwise raises an exception. Should only be used from commands that require an
        input video to process the options (e.g. those that require a timecode).

        Raises:
            click.BadParameter: self.video_stream was not initialized.
        """
        if self.video_stream is None:
            raise click.ClickException('No input video (-i/--input) was specified.')

    def _open_video_stream(self, input_path: AnyStr, framerate: Optional[float],
                           backend: Optional[str]):
        if '%' in input_path and backend != 'opencv':
            raise click.BadParameter(
                'The OpenCV backend (`--backend opencv`) must be used to process image sequences.',
                param_hint='-i/--input')
        if framerate is not None and framerate < MAX_FPS_DELTA:
            raise click.BadParameter('Invalid framerate specified!', param_hint='-f/--framerate')
        try:
            if backend is None:
                backend = self.config.get_value('global', 'backend')
            else:
                if not backend in AVAILABLE_BACKENDS:
                    raise click.BadParameter(
                        'Specified backend %s is not available on this system!' % backend,
                        param_hint='-b/--backend')
            # Open the video with the specified backend, loading any required config settings.
            if backend == 'pyav':
                self.video_stream = open_video(
                    path=input_path,
                    framerate=framerate,
                    backend=backend,
                    threading_mode=self.config.get_value('backend-pyav', 'threading-mode'),
                    suppress_output=self.config.get_value('backend-pyav', 'suppress-output'),
                )
            elif backend == 'opencv':
                self.video_stream = open_video(
                    path=input_path,
                    framerate=framerate,
                    backend=backend,
                    max_decode_attempts=self.config.get_value('backend-opencv',
                                                              'max-decode-attempts'),
                )
            # Handle backends without any config options.
            else:
                self.video_stream = open_video(
                    path=input_path,
                    framerate=framerate,
                    backend=backend,
                )
            logger.debug('Video opened using backend %s', type(self.video_stream).__name__)
        except FrameRateUnavailable as ex:
            raise click.BadParameter(
                'Failed to obtain framerate for input video. Manually specify framerate with the'
                ' -f/--framerate option, or try re-encoding the file.',
                param_hint='-i/--input') from ex
        except VideoOpenFailure as ex:
            raise click.BadParameter(
                'Failed to open input video%s: %s' %
                (' using %s backend' % backend if backend else '', str(ex)),
                param_hint='-i/--input') from ex
        except OSError as ex:
            raise click.BadParameter('Input error:\n\n\t%s\n' % str(ex), param_hint='-i/--input')

    def _on_duplicate_command(self, command: str) -> None:
        """Called when a command is duplicated to stop parsing and raise an error.

        Arguments:
            command: Command that was duplicated for error context.

        Raises:
            click.BadParameter
        """
        error_strs = []
        error_strs.append('Error: Command %s specified multiple times.' % command)
        error_strs.append('The %s command may appear only one time.')

        logger.error('\n'.join(error_strs))
        raise click.BadParameter(
            '\n  Command %s may only be specified once.' % command,
            param_hint='%s command' % command)
