#!/usr/bin/env python3
# PYTHON_ARGCOMPLETE_OK
"""Build documentation for ansible-core CLI programs."""

from __future__ import annotations

import argparse
import dataclasses
import importlib
import inspect
import io
import itertools
import json
import pathlib
import sys
import typing as t
import warnings

import jinja2

if t.TYPE_CHECKING:
    from ansible.cli import CLI  # pragma: nocover

SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
SOURCE_DIR = SCRIPT_DIR.parent.parent


def main() -> None:
    """Main program entry point."""
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(required=True, metavar='command')

    man_parser = subparsers.add_parser('man', description=build_man.__doc__, help=build_man.__doc__)
    man_parser.add_argument('--output-dir', required=True, type=pathlib.Path, metavar='DIR', help='output directory')
    man_parser.add_argument('--template-file', default=SCRIPT_DIR / 'man.j2', type=pathlib.Path, metavar='FILE', help='template file')
    man_parser.set_defaults(func=build_man)

    rst_parser = subparsers.add_parser('rst', description=build_rst.__doc__, help=build_rst.__doc__)
    rst_parser.add_argument('--output-dir', required=True, type=pathlib.Path, metavar='DIR', help='output directory')
    rst_parser.add_argument('--template-file', default=SCRIPT_DIR / 'rst.j2', type=pathlib.Path, metavar='FILE', help='template file')
    rst_parser.set_defaults(func=build_rst)

    json_parser = subparsers.add_parser('json', description=build_json.__doc__, help=build_json.__doc__)
    json_parser.add_argument('--output-file', required=True, type=pathlib.Path, metavar='FILE', help='output file')
    json_parser.set_defaults(func=build_json)

    try:
        # noinspection PyUnresolvedReferences
        import argcomplete
    except ImportError:
        pass
    else:
        argcomplete.autocomplete(parser)

    args = parser.parse_args()
    kwargs = {name: getattr(args, name) for name in inspect.signature(args.func).parameters}

    sys.path.insert(0, str(SOURCE_DIR / 'lib'))

    args.func(**kwargs)


def build_man(output_dir: pathlib.Path, template_file: pathlib.Path) -> None:
    """Build man pages for ansible-core CLI programs."""
    if not template_file.resolve().is_relative_to(SCRIPT_DIR):
        warnings.warn("Custom templates are intended for debugging purposes only. The data model may change in future releases without notice.")

    import docutils.core
    import docutils.writers.manpage

    output_dir.mkdir(exist_ok=True, parents=True)

    for cli_name, source in generate_rst(template_file).items():
        with io.StringIO(source) as source_file:
            docutils.core.publish_file(
                source=source_file,
                destination_path=output_dir / f'{cli_name}.1',
                writer=docutils.writers.manpage.Writer(),
            )


def build_rst(output_dir: pathlib.Path, template_file: pathlib.Path) -> None:
    """Build RST documentation for ansible-core CLI programs."""
    if not template_file.resolve().is_relative_to(SCRIPT_DIR):
        warnings.warn("Custom templates are intended for debugging purposes only. The data model may change in future releases without notice.")

    output_dir.mkdir(exist_ok=True, parents=True)

    for cli_name, source in generate_rst(template_file).items():
        (output_dir / f'{cli_name}.rst').write_text(source)


def build_json(output_file: pathlib.Path) -> None:
    """Build JSON documentation for ansible-core CLI programs."""
    warnings.warn("JSON output is intended for debugging purposes only. The data model may change in future releases without notice.")

    output_file.parent.mkdir(exist_ok=True, parents=True)
    output_file.write_text(json.dumps(collect_programs(), indent=4))


def generate_rst(template_file: pathlib.Path) -> dict[str, str]:
    """Generate RST pages using the provided template."""
    results: dict[str, str] = {}

    for cli_name, template_vars in collect_programs().items():
        env = jinja2.Environment(loader=jinja2.FileSystemLoader(template_file.parent))
        template = env.get_template(template_file.name)
        results[cli_name] = template.render(template_vars)

    return results


def collect_programs() -> dict[str, dict[str, t.Any]]:
    """Return information about CLI programs."""
    programs: list[tuple[str, dict[str, t.Any]]] = []
    cli_bin_name_list: list[str] = []

    for source_file in (SOURCE_DIR / 'lib/ansible/cli').glob('*.py'):
        if not source_file.name.startswith('_'):
            programs.append(generate_options_docs(source_file, cli_bin_name_list))

    return dict(programs)


def generate_options_docs(source_file: pathlib.Path, cli_bin_name_list: list[str]) -> tuple[str, dict[str, t.Any]]:
    """Generate doc structure from CLI module options."""
    import ansible.release

    if str(source_file).endswith('/lib/ansible/cli/adhoc.py'):
        cli_name = 'ansible'
        cli_class_name = 'AdHocCLI'
        cli_module_fqn = 'ansible.cli.adhoc'
    else:
        cli_module_name = source_file.with_suffix('').name
        cli_name = f'ansible-{cli_module_name}'
        cli_class_name = f'{cli_module_name.capitalize()}CLI'
        cli_module_fqn = f'ansible.cli.{cli_module_name}'

    cli_bin_name_list.append(cli_name)

    cli_module = importlib.import_module(cli_module_fqn)
    cli_class: type[CLI] = getattr(cli_module, cli_class_name)

    cli = cli_class([cli_name])
    cli.init_parser()

    parser: argparse.ArgumentParser = cli.parser
    long_desc = cli.__doc__
    arguments: dict[str, str] | None = getattr(cli, 'ARGUMENTS', None)

    action_docs = get_action_docs(parser)
    option_names: tuple[str, ...] = tuple(itertools.chain.from_iterable(opt.options for opt in action_docs))
    actions: dict[str, dict[str, t.Any]] = {}

    content_depth = populate_subparser_actions(parser, option_names, actions)

    docs = dict(
        version=ansible.release.__version__,
        source=str(source_file.relative_to(SOURCE_DIR)),
        cli_name=cli_name,
        usage=parser.format_usage(),
        short_desc=parser.description,
        long_desc=trim_docstring(long_desc),
        actions=actions,
        options=[item.__dict__ for item in action_docs],
        arguments=arguments,
        option_names=option_names,
        cli_bin_name_list=cli_bin_name_list,
        content_depth=content_depth,
        inventory='-i' in option_names,
        library='-M' in option_names,
    )

    return cli_name, docs


def populate_subparser_actions(parser: argparse.ArgumentParser, shared_option_names: tuple[str, ...], actions: dict[str, dict[str, t.Any]]) -> int:
    """Generate doc structure from CLI module subparser options."""
    try:
        # noinspection PyProtectedMember
        subparsers: dict[str, argparse.ArgumentParser] = parser._subparsers._group_actions[0].choices  # type: ignore
    except AttributeError:
        subparsers = {}

    depth = 0

    for subparser_action, subparser in subparsers.items():
        subparser_option_names: set[str] = set()
        subparser_action_docs: set[ActionDoc] = set()
        subparser_actions: dict[str, dict[str, t.Any]] = {}

        for action_doc in get_action_docs(subparser):
            for option_alias in action_doc.options:
                if option_alias in shared_option_names:
                    continue

                subparser_option_names.add(option_alias)
                subparser_action_docs.add(action_doc)

        depth = populate_subparser_actions(subparser, shared_option_names, subparser_actions)

        actions[subparser_action] = dict(
            option_names=list(subparser_option_names),
            options=[item.__dict__ for item in subparser_action_docs],
            actions=subparser_actions,
            name=subparser_action,
            desc=trim_docstring(subparser.get_default("func").__doc__),
        )

    return depth + 1


@dataclasses.dataclass(frozen=True)
class ActionDoc:
    """Documentation for an action."""

    desc: str | None
    options: tuple[str, ...]
    arg: str | None


def get_action_docs(parser: argparse.ArgumentParser) -> list[ActionDoc]:
    """Get action documentation from the given argument parser."""
    action_docs = []

    # noinspection PyProtectedMember
    for action in parser._actions:
        if action.help == argparse.SUPPRESS:
            continue

        # noinspection PyProtectedMember, PyUnresolvedReferences
        args = action.dest.upper() if isinstance(action, argparse._StoreAction) else None

        if args or action.option_strings:
            action_docs.append(
                ActionDoc(
                    desc=action.help,
                    options=tuple(action.option_strings),
                    arg=args,
                )
            )

    return action_docs


def trim_docstring(docstring: str | None) -> str:
    """Trim and return the given docstring using the implementation from https://peps.python.org/pep-0257/#handling-docstring-indentation."""
    if not docstring:
        return ''  # pragma: nocover

    # Convert tabs to spaces (following the normal Python rules) and split into a list of lines
    lines = docstring.expandtabs().splitlines()

    # Determine minimum indentation (first line doesn't count)
    indent = sys.maxsize

    for line in lines[1:]:
        stripped = line.lstrip()

        if stripped:
            indent = min(indent, len(line) - len(stripped))

    # Remove indentation (first line is special)
    trimmed = [lines[0].strip()]

    if indent < sys.maxsize:
        for line in lines[1:]:
            trimmed.append(line[indent:].rstrip())

    # Strip off trailing and leading blank lines
    while trimmed and not trimmed[-1]:
        trimmed.pop()

    while trimmed and not trimmed[0]:
        trimmed.pop(0)

    # Return a single string
    return '\n'.join(trimmed)


if __name__ == '__main__':
    main()
