#
#            PySceneDetect: Python-Based Video Scene Detector
#   -------------------------------------------------------------------
#     [  Site:    https://scenedetect.com                           ]
#     [  Docs:    https://scenedetect.com/docs/                     ]
#     [  Github:  https://github.com/Breakthrough/PySceneDetect/    ]
#
# Copyright (C) 2016 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.
#
"""``scenedetect.platform`` Module

This module contains all platform/library specific compatibility fixes, as well as some utility
functions to handle logging and invoking external commands.
"""

import importlib.metadata
import logging
import os
import os.path
import platform
import re
import string
import subprocess
import sys

import cv2

StrPath = str | os.PathLike[str]
"""Type hint for filesystem paths. Accepts a `str` or any object implementing :class:`os.PathLike`
(e.g. :class:`pathlib.Path`)."""

DEBUG_MODE: bool = os.environ.get("SCENEDETECT_DEBUG", "").strip().lower() not in (
    "",
    "0",
    "false",
    "no",
    "off",
)
"""True when the `SCENEDETECT_DEBUG` environment variable is set to a truthy value
(`1`, `true`, `yes`, `on`, etc.); False when unset or set to `0`/`false`/`no`/`off`/empty.
Use this to gate behavior intended only for development - e.g. re-raising unhandled
exceptions for debuggers/pytest instead of logging gracefully and exiting. Default-off so
end users on any install path (pip, pipx, the Windows .exe) get clean error output; pytest
opts in via `tests/conftest.py`."""

##
## tqdm Library
##


class FakeTqdmObject:
    """Provides a no-op tqdm-like object."""

    def __init__(self, **kwargs):
        """No-op."""

    def update(self, n=1):
        """No-op."""

    def close(self):
        """No-op."""

    def set_description(self, desc=None, refresh=True):
        """No-op."""


class FakeTqdmLoggingRedirect:
    """Provides a no-op tqdm context manager for redirecting log messages."""

    def __init__(self, **kwargs):
        """No-op."""

    def __enter__(self):
        """No-op."""

    def __exit__(self, type, value, traceback):
        """No-op."""


# Try to import tqdm and the logging redirect, otherwise provide fake implementations..
try:
    from tqdm import tqdm
    from tqdm.contrib.logging import logging_redirect_tqdm
except ModuleNotFoundError:
    tqdm = FakeTqdmObject
    logging_redirect_tqdm = FakeTqdmLoggingRedirect

##
## OpenCV imwrite Supported Image Types & Quality/Compression Parameters
##


# TODO: Move this into scene_manager.
def get_cv2_imwrite_params() -> dict[str, int | None]:
    """Get OpenCV imwrite Params: Returns a dict of supported image formats and
    their associated quality/compression parameter index, or None if that format
    is not supported.

    Returns:
        Dictionary of supported image formats/extensions ('jpg', 'png', etc...) mapped to the
        respective OpenCV quality or compression parameter as {'jpg': cv2.IMWRITE_JPEG_QUALITY,
        'png': cv2.IMWRITE_PNG_COMPRESSION, ...}. Parameter will be None if not found on the
        current system library (e.g. {'jpg': None}).
    """

    def _get_cv2_param(param_name: str) -> int | None:
        if param_name.startswith("CV_"):
            param_name = param_name[3:]
        try:
            return getattr(cv2, param_name)
        except AttributeError:
            return None

    return {
        "jpg": _get_cv2_param("IMWRITE_JPEG_QUALITY"),
        "png": _get_cv2_param("IMWRITE_PNG_COMPRESSION"),
        "webp": _get_cv2_param("IMWRITE_WEBP_QUALITY"),
    }


##
## File I/O
##


def get_file_name(file_path: StrPath, include_extension: bool = True) -> str:
    """Return the file name that `file_path` refers to, optionally removing the extension.

    E.g. /tmp/foo.bar -> foo"""
    file_name = os.path.basename(os.fspath(file_path))
    if not include_extension:
        last_dot_pos = file_name.rfind(".")
        if last_dot_pos >= 0:
            file_name = file_name[:last_dot_pos]
    return file_name


def get_and_create_path(file_path: StrPath, output_directory: StrPath | None = None) -> str:
    """Get & Create Path: Gets and returns the full/absolute path to file_path
    in the specified output_directory if set, creating any required directories
    along the way.

    If file_path is already an absolute path, then output_directory is ignored.

    Arguments:
        file_path: File name to get path for.  If file_path is an absolute
            path (e.g. starts at a drive/root), no modification of the path
            is performed, only ensuring that all output directories are created.
        output_dir: An optional output directory to override the
            directory of file_path if it is relative to the working directory.

    Returns:
        Full path to output file suitable for writing.

    """
    file_path = os.fspath(file_path)
    # If an output directory is defined and the file path is a relative path, open
    # the file handle in the output directory instead of the working directory.
    if output_directory is not None and not os.path.isabs(file_path):
        file_path = os.path.join(os.fspath(output_directory), file_path)
    # Now that file_path is an absolute path, let's make sure all the directories
    # exist for us to start writing files there.
    os.makedirs(os.path.split(os.path.abspath(file_path))[0], exist_ok=True)
    return file_path


##
## Logging
##


def init_logger(
    log_level: int = logging.INFO, show_stdout: bool = False, log_file: str | None = None
):
    """Initializes logging for PySceneDetect. The logger instance used is named 'pyscenedetect'.
    By default the logger has no handlers to suppress output. All existing log handlers are replaced
    every time this function is invoked.

    Arguments:
        log_level: Verbosity of log messages. Should be one of [logging.INFO, logging.DEBUG,
            logging.WARNING, logging.ERROR, logging.CRITICAL].
        show_stdout: If True, add handler to show log messages on stdout (default: False).
        log_file: If set, add handler to dump debug log messages to given file path.
    """
    # Format of log messages depends on verbosity.
    INFO_TEMPLATE = "[PySceneDetect] %(message)s"
    DEBUG_TEMPLATE = "%(levelname)s: %(module)s.%(funcName)s(): %(message)s"
    # Get the named logger and remove any existing handlers.
    logger_instance = logging.getLogger("pyscenedetect")
    logger_instance.handlers = []
    logger_instance.setLevel(log_level)
    # Add stdout handler if required.
    if show_stdout:
        handler = logging.StreamHandler(stream=sys.stdout)
        handler.setLevel(log_level)
        handler.setFormatter(
            logging.Formatter(fmt=DEBUG_TEMPLATE if log_level == logging.DEBUG else INFO_TEMPLATE)
        )
        logger_instance.addHandler(handler)
    # Add debug log handler if required.
    if log_file:
        log_file = get_and_create_path(log_file)
        handler = logging.FileHandler(log_file)
        handler.setLevel(logging.DEBUG)
        handler.setFormatter(logging.Formatter(fmt=DEBUG_TEMPLATE))
        logger_instance.addHandler(handler)


##
## Running External Commands
##


class CommandTooLong(Exception):
    """Raised if the length of a command line argument exceeds the limit allowed on Windows."""


def invoke_command(args: list[str]) -> int:
    """Same as calling Python's subprocess.call() method, but explicitly
    raises a different exception when the command length is too long.

    See https://github.com/Breakthrough/PySceneDetect/issues/164 for details.

    Arguments:
        args: List of strings to pass to subprocess.call().

    Returns:
        Return code of command.

    Raises:
        CommandTooLong: `args` exceeds built in command line length limit on Windows.
    """
    try:
        return subprocess.call(args)
    except OSError as err:
        if os.name != "nt":
            raise
        exception_string = str(err)
        # Error 206: The filename or extension is too long
        # Error 87:  The parameter is incorrect
        to_match = ("206", "87")
        if any([x in exception_string for x in to_match]):
            raise CommandTooLong() from err
        raise


def get_ffmpeg_path() -> str | None:
    """Get path to ffmpeg if available on the current system. First looks at PATH, then checks if
    one is available from the `imageio_ffmpeg` package. Returns None if ffmpeg couldn't be found.
    """
    # Try invoking ffmpeg with the current environment.
    try:
        subprocess.call(["ffmpeg", "-v", "quiet"])
        return "ffmpeg"
    except OSError:
        pass  # Failed to invoke ffmpeg with current environment, try another possibility.

    # Try invoking ffmpeg using the one from `imageio_ffmpeg` if available.
    try:
        from imageio_ffmpeg import get_ffmpeg_exe

        subprocess.call([get_ffmpeg_exe(), "-v", "quiet"])
        return get_ffmpeg_exe()
    # Gracefully handle case where imageio_ffmpeg is not available.
    except ModuleNotFoundError:
        pass
    # Handle case where path might be wrong/non-existent.
    except OSError:
        pass
    # get_ffmpeg_exe may throw a RuntimeError if the executable is not available.
    except RuntimeError:
        pass

    return None


def get_ffmpeg_version() -> str | None:
    """Get ffmpeg version identifier, or None if ffmpeg is not found. Uses `get_ffmpeg_path()`."""
    ffmpeg_path = get_ffmpeg_path()
    if ffmpeg_path is None:
        return None
    # If get_ffmpeg_path() returns a value, the path it returns should be invocable.
    output = subprocess.check_output(args=[ffmpeg_path, "-version"], text=True)
    output_split = output.split()
    if len(output_split) >= 3 and output_split[1] == "version":
        return output_split[2]
    # If parsing the version fails, return the entire first line of output.
    return output.splitlines()[0]


def get_mkvmerge_path() -> str | None:
    """Get path to mkvmerge if available on the current system by checking PATH. Returns None if
    mkvmerge couldn't be found."""
    try:
        subprocess.call(["mkvmerge", "--quiet"])
        return "mkvmerge"
    except OSError:
        return None


def get_mkvmerge_version() -> str | None:
    """Get mkvmerge version identifier, or None if mkvmerge is not found in PATH."""
    tool_name = "mkvmerge"
    try:
        output = subprocess.check_output(args=[tool_name, "--version"], text=True)
    except FileNotFoundError:
        # mkvmerge doesn't exist on the system
        return None
    output_split = output.split()
    if len(output_split) >= 1 and output_split[0] == tool_name:
        return " ".join(output_split[1:])
    # If parsing the version fails, return the entire first line of output.
    return output.splitlines()[0]


def _query_package_version(dist_name: str, fallback_module: str | None) -> str | None:
    """Return version of an installed package, querying PyPI metadata first then
    falling back to the module's `__version__` attribute when metadata is missing.

    PyInstaller bundles ship modules but not the `.dist-info` directories that
    `importlib.metadata` reads, so the fallback is required for frozen builds.
    Returns None when the package isn't installed.
    """
    try:
        return importlib.metadata.version(dist_name)
    except importlib.metadata.PackageNotFoundError:
        pass
    if fallback_module is None:
        return None
    try:
        module = importlib.import_module(fallback_module)
    except ModuleNotFoundError:
        return None
    return getattr(module, "__version__", None)


def get_system_version_info() -> str:
    """Get the system's operating system, Python, packages, and external tool versions.
    Useful for debugging or filing bug reports.

    Used for the `scenedetect version -a` command.
    """
    line_separator = "-" * 60
    not_found_str = "Not Installed"
    out_lines = []

    system_info = (
        ("OS", f"{platform.platform()}"),
        ("Python", f"{platform.python_implementation()} {platform.python_version()}"),
        ("Architecture", " + ".join(platform.architecture())),
    )

    # Third-Party Packages: queried via PyPI distribution names with a module-attribute
    # fallback. PyInstaller bundles ship the modules but not the `.dist-info` metadata
    # directories, so `importlib.metadata.version()` alone reports "Not Installed" for
    # every package in a frozen build; reading `module.__version__` recovers the version
    # there. `scenedetect` is read from the package attribute since it must report a
    # version even when run uninstalled (e.g. from a source checkout). The import is
    # deferred to avoid a circular import at module load time.
    from scenedetect import __version__ as scenedetect_version

    # (dist_name, fallback_module_name). Module fallback is only used when that dist's
    # metadata is missing. Known quirk: `cv2` cannot reveal which distribution provided
    # it, so whenever cv2 is importable but `opencv-python-headless` is not installed
    # (e.g. only `opencv-python` is), the headless row still shows cv2's version via the
    # fallback - both opencv rows then report a version even though only one is
    # installed. Kept intentionally: the fallback is what recovers the version in the
    # frozen Windows build (which ships cv2 without any `.dist-info`), and the
    # `opencv-python` row is metadata-only, so it remains accurate on its own.
    # The same code ships in three distributions: `scenedetect-core` (minimal deps)
    # and the `scenedetect`/`scenedetect-headless` variants (OpenCV variant + CLI
    # deps). Metadata-only lookups (no module fallback) so each row reflects which
    # distribution is actually installed - e.g. frozen builds show "Not Installed"
    # here rather than misattributing the module version.
    scenedetect_packages = (
        ("scenedetect-core", None),
        ("scenedetect-headless", None),
    )
    third_party_packages = (
        ("av", "av"),
        ("click", "click"),
        ("opencv-python", None),
        ("opencv-python-headless", "cv2"),
        ("imageio", "imageio"),
        ("imageio-ffmpeg", "imageio_ffmpeg"),
        ("moviepy", "moviepy"),
        ("numpy", "numpy"),
        ("platformdirs", "platformdirs"),
        ("tqdm", "tqdm"),
    )
    package_versions = [("scenedetect", scenedetect_version)] + [
        (dist_name, _query_package_version(dist_name, fallback_module) or not_found_str)
        for dist_name, fallback_module in (*scenedetect_packages, *third_party_packages)
    ]

    tool_versions = (
        ("ffmpeg", get_ffmpeg_version() or not_found_str),
        ("mkvmerge", get_mkvmerge_version() or not_found_str),
    )

    # Size the label column to the longest label across every section so all three tables
    # align consistently - `opencv-python-headless` exceeds the previous fixed width of 16.
    label_width = max(len(name) for name, _ in (*system_info, *package_versions, *tool_versions))
    output_template = f"{{:<{label_width}}} {{}}"

    out_lines += ["System Info", line_separator]
    out_lines += [output_template.format(name, value) for name, value in system_info]
    out_lines += ["", "Packages", line_separator]
    out_lines += [output_template.format(name, value) for name, value in package_versions]
    out_lines += ["", "Tools", line_separator]
    out_lines += [output_template.format(name, value) for name, value in tool_versions]

    return "\n".join(out_lines)


class Template(string.Template):
    """Template matcher used to replace instances of $TEMPLATES in filenames."""

    idpattern = "[A-Z0-9_]+"
    flags = re.ASCII
