# Generate formatted CLI documentation for PySceneDetect.
#
# Inspired by sphinx-click: https://github.com/click-contrib/sphinx-click
#
# Copyright (C) 2023 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.
"""Generates CLI reference documentation file docs/cli.rst.

Run from main repo folder as working directory."""

import inspect
import os
import re
import sys
import typing as ty
from dataclasses import dataclass

# Add parent folder to path so we can resolve `scenedetect` imports.
currentdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
parentdir = os.path.dirname(currentdir)
sys.path.insert(0, parentdir)
# Third-party imports (must follow sys.path mutation above).
import click  # noqa: E402

from scenedetect._cli import scenedetect  # noqa: E402

StrGenerator = ty.Generator[str, None, None]

INDENT = " " * 4

PAGE_SEP = "*" * 72
TITLE_SEP = "=" * 72
HEADING_SEP = "-" * 72

OPTION_HELP_OVERRIDES = {
    "scenedetect": {
        "config": "Path to config file. See :ref:`config file reference <scenedetect_cli-config_file>` for details."
    },
}

TITLE_LEVELS = ["*", "=", "-"]

INFO_COMMANDS = ["help", "about", "version"]

INFO_COMMAND_OVERRIDE = """
.. _command-help:

``help``, ``version``, and ``about``
=======================================================================

.. program:: scenedetect help

``scenedetect --help`` will print PySceneDetect options, commands, and examples. You can also specify:

 * ``scenedetect [command] --help`` to show options and examples *for* a command or detector

 * ``scenedetect help`` command to print full reference of all options, commands, and examples

.. program:: scenedetect version

``scenedetect version`` prints the version of PySceneDetect that is installed, as well as system dependencies.

.. program:: scenedetect about

``scenedetect about`` prints PySceneDetect copyright, licensing, and redistribution information. This includes a list of all third-party software components that PySceneDetect uses or interacts with, as well as a reference to the license and copyright information for each component.
"""


def patch_help(s: str, commands: list[str]) -> str:
    # Patch some TODOs still not handled correctly below.
    pos = 0
    while True:
        pos = s.find("global option :option:", pos)
        if pos < 0:
            break
        pos = s.find("<-", pos)
        assert pos > 0
        s = s[: pos + 1] + "scenedetect " + s[pos + 1 :]

    for command in [command for command in commands if command not in INFO_COMMANDS]:

        def add_link(_match: re.Match, command: str = command) -> str:
            return f":ref:`{command} <command-{command}>`"

        s = re.sub(f"``{command}``(?!\\n)", add_link, s)
    return s


def generate_title(s: str, level: int = 0, len: int = 72) -> StrGenerator:
    yield "\n"
    if level == 0:
        yield TITLE_LEVELS[level] * len + "\n"
    yield s + "\n"
    yield TITLE_LEVELS[level] * len + "\n\n"


@dataclass
class ReplaceWithReference:
    range: tuple[int, int]
    ref: str
    ref_type: str


def transform_backquotes(s: str) -> str:
    return s.replace("``", "`").replace("`", "``")


def add_backquotes(match: re.Match) -> str:
    return f"``{match.string[match.start() : match.end()]}``"


def add_backquotes_with_refs(refs: set[str]) -> ty.Callable[[str], str]:
    """Returns a transformation function that backquotes command examples, adding backquotes and
    references to any found options."""

    def _add_backquotes(s: re.Match) -> str:
        to_add: str = s.string[s.start() : s.end()]
        flag = re.search(r"-+[\w-]+[^\.\=\s\/]*", to_add)
        if flag is not None and flag.string[flag.start() : flag.end()] in refs:
            # add cross reference
            cross_ref = flag.string[flag.start() : flag.end()]
            option = s.string[s.start() : s.end()]
            return f":option:`{option} <{cross_ref}>`"
        else:
            return add_backquotes(s)

    return _add_backquotes


def extract_default_value(s: str) -> tuple[str, str | None]:
    default = re.search(r"\[default: .*\]", s)
    if default is not None:
        span = default.span()
        assert span[1] == len(s)
        s, default = s[: span[0]].strip(), s[span[0] : span[1]][len("[default: ") : -1]
        # Double-quote any default values that contain spaces.
        if " " in default and '"' not in default and "," not in default:
            default = f'"{default}"'
    return (s, default)


def transform_add_option_refs(s: str, refs: list[str]) -> str:
    transform = add_backquotes_with_refs(refs)
    # TODO: Match prefix of `global option` and add ref to parent `scenedetect` command option.
    # Replace patch to complete this.
    #  -c/--command
    s = re.sub(r"-\w/--\w[\w-]*", transform, s)
    #  --arg=value, --arg=1.2.3, --arg=1,2,3
    s = re.sub(r'-+[\w-]+=[^"\s\)]+(?<![\.\,])', transform, s)
    # --args=" command with spaces"
    s = re.sub(r'--[\w-]+[=]+".*?"', transform, s)
    return s


def format_option(command: click.Command, opt: click.Option, flags: list[str]) -> StrGenerator:
    if isinstance(opt, click.Argument):
        yield f"\n.. option:: {opt.name}\n"
        return
    yield "\n.. option:: {}\n".format(
        ", ".join(
            arg if opt.metavar is None else f"{arg} {opt.metavar}"
            for arg in sorted(opt.opts, reverse=True)
        )
    )

    help = (
        OPTION_HELP_OVERRIDES[command.name][opt.name]
        if command.name in OPTION_HELP_OVERRIDES and opt.name in OPTION_HELP_OVERRIDES[command.name]
        else opt.help.strip()
    )

    # TODO: Make metavars link to the option as well.
    help, default = extract_default_value(help)
    help = transform_add_option_refs(help, flags)

    yield f"\n  {help}\n"
    if default is not None:
        yield f"\n  Default: ``{default}``\n"


def generate_command_help(
    ctx: click.Context, command: click.Command, parent_name: str | None = None
) -> StrGenerator:
    # TODO: Add references to long options. Requires splitting out examples.
    # TODO: Add references to subcommands. Need to add actual refs, since programs can't be ref'd.
    # TODO: Handle dollar signs in examples by having both escaped and unescaped versions
    yield f"\n.. _command-{command.name}:\n"
    yield "\n.. program:: %s\n\n" % (
        command.name if parent_name is None else f"{parent_name} {command.name}"
    )
    if parent_name:
        yield from generate_title(f"``{command.name}``", 1)

    replacements = [
        opt
        for opts in [param.opts for param in command.params if hasattr(param, "opts")]
        for opt in opts
    ]

    help = command.help
    help = help.replace(
        "Examples:\n", "".join(generate_title("Examples", 0 if not parent_name else 2))
    )
    help = help.replace("\b\n", "")
    help = help.format(scenedetect="scenedetect", scenedetect_with_video="scenedetect -i video.mp4")
    help = transform_backquotes(help)
    help = transform_add_option_refs(help, replacements)

    for line in help.strip().splitlines():
        if line.startswith(INDENT):
            indent = line.count(INDENT)
            line = line.strip()
            yield f"{indent * INDENT}``{line}``\n" if line else "\n"
        else:
            yield f"{line}\n"

    if command.params:
        yield "\n"
        yield from generate_title("Options", 0 if not parent_name else 2)
        for param in command.params:
            yield from format_option(command, param, replacements)
    yield "\n"


def generate_subcommands(ctx: click.Context, commands: list[str]) -> StrGenerator:
    processed = set()

    for info_command in INFO_COMMANDS:
        assert info_command in commands
        processed.add(info_command)
    yield INFO_COMMAND_OVERRIDE

    yield from generate_title("Detectors", 0)
    detectors = [command for command in commands if command.startswith("detect-")]
    for detector in detectors:
        yield from generate_command_help(ctx, ctx.command.get_command(ctx, detector), ctx.info_name)
        processed.add(detector)

    yield from generate_title("Commands", 0)
    output_commands = [
        command
        for command in commands
        if (not command.startswith("detect-") and command not in INFO_COMMANDS)
    ]
    for command in output_commands:
        yield from generate_command_help(ctx, ctx.command.get_command(ctx, command), ctx.info_name)
        processed.add(command)

    assert set(commands) == processed


def create_help() -> tuple[str, list[str]]:
    ctx = click.Context(scenedetect, info_name=scenedetect.name)

    commands: list[str] = ctx.command.list_commands(ctx)
    commands = list(
        filter(lambda command: not ctx.command.get_command(ctx, command).hidden, commands)
    )
    # ctx.to_info_dict lacks metavar so we have to use the context directly.
    actions = [
        generate_title("``scenedetect`` \N{CLAPPER BOARD} Command", level=0),
        generate_command_help(ctx, ctx.command),
        generate_subcommands(ctx, commands),
    ]
    lines = []
    for action in actions:
        lines.extend(action)
    return "".join(lines), commands


def main():
    help, commands = create_help()
    help = patch_help(help, commands)
    help = (
        ".. NOTE: This file is auto-generated by docs/generate_cli_docs.py and should not be modified.\n"
        + help
    )
    with open("docs/cli.rst", "wb") as f:
        f.write(help.encode())


if __name__ == "__main__":
    main()
