#!/usr/bin/env python3
#
# Copyright (c) 2024 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#

"""Automated generation of a zsh_completion file for yunohost.

Using the actionsmap yaml file and a jinja template.

INSTALL:
  This script creates a zsh completion file for yunohost.
  To install, copy (and rename) the created file to:
    - (Debian) `/usr/share/zsh/vendor-completions/_yunohost`
    - (Fedora) `/usr/share/zsh/site-functions/_yunohost`
    - (other distribution) `/usr/local/share/zsh/site-functions/_yunohost`

DOCS:
- https://github.com/zsh-users/zsh/blob/master/Etc/completion-style-guide
- http://zsh.sourceforge.net/Doc/Release/Completion-System.html#Completion-System
  or `man zshcompsys`
- http://zsh.sourceforge.net/Guide/zshguide06.html

MISC:
- http://zsh.sourceforge.net/Doc/Release/Parameters.html#Array-Parameters

MISSING:
- use the extra:required:True pattern (similar to `nargs`?)
- In `yunohost.yml`, consider merging:
  - metavar
  - pattern
  - autocomplete
- Make use of `type`, maybe using `_guard`
- Use `pattern`, maybe with `_guard`. This seems hard though, as ZSH has
its own globbing language...
Link about this globbing system:
http://zsh.sourceforge.net/Doc/Release/Expansion.html#Filename-Generation

Notes:
- Command for debugging zsh: `unfunction _yunohost; autoload -U _yunohost`

- Optimization:
  - caching mecanism: invalidate the cache afer some commands? Hard, the
  cache is local to user
  - implement a zstyle switch, to change the cache validity period?

AUTHORS:
 - buzuck (Fol)
 - kayou
 - getzze

"""

from __future__ import annotations

import argparse
import re
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast

import yaml
from jinja2 import Template

if TYPE_CHECKING:
    from typing import NotRequired, TypedDict

    class Function(TypedDict):
        """Details of a helper function."""

        name: str
        shell_call: NotRequired[str]
        aggregated: NotRequired[str]

    class Case(TypedDict):
        """Details of dynamic argument completion function."""

        name: str
        shell_call: str

    class Action(TypedDict):
        """Command line action."""

        name: str
        help: str
        arguments: list[str]
        cases: list[Case]

    class Category(TypedDict):
        """Command line category.

        Categories have different level:
         - level 1: main category, e.g. `yunohost user`
         - level 2: sub-category, e.g. `yunohost user group`

        Only categories of level 1 have a `subs` key.

        Reminder:
            yunohost     user        group       list    --full --short
               ^          ^            ^          ^
            (script) | category | subcategory | action | parameters

        """

        name: str
        help: str
        level: int
        actions: list[Action]
        subs: NotRequired[dict[str, str]]


YUNOHOST_SRCDIR = Path(__file__).resolve().parent.parent


def get_actions_zsh(
    ynh_map: dict[str, dict[str, Any]],
) -> tuple[list[Category], list[Function]]:
    """Parse categories, subcategories and actions from an actionsmap yml file.

    Parameters
    ----------
    ynh_map: dict[str, dict[str, Any]]
        A dict loaded from an actionsmap yml file.

    Returns
    -------
    tuple[list[Category], list[Function]]
        A tuple of categories dict and helper functions dict.

    """
    categories: list[Category] = []
    functions: list[Function] = []

    for category, cat_info in ynh_map.items():
        if category.startswith("_") or cat_info.get("hide_in_help", False):
            continue

        cats, funcs = parse_category(category, cat_info)

        categories.extend(cats)
        functions.extend(funcs)

    # Remove duplicates in functions
    functions = [
        cast("Function", dict(t)) for t in {tuple(d.items()) for d in functions}
    ]

    return categories, functions


def parse_category(
    name: str,
    info: dict[str, Any],
) -> tuple[list[Category], list[Function]]:
    """Parse a Category  (level 1) for its actions and subcategories.

    Reminder:
        yunohost   monitor    info    --cpu --ram
           ^          ^         ^          ^
        (script) | category | action | parameters

    A Category may contain subcategories (of level 2), so a list of categories
    (of level 1 and 2) is returned.
    A category may need to define help functions that are needed to build
    the list of options. The list of help functions is returned.

    Parameters
    ----------
    name: str
        the category name
    info: dict[str, Any]
        the information dict about the category

    Returns
    -------
    tuple[list[Category], list[Function]]
        A tuple of the list of category and subcategories dicts
        and the list of category and subcategories helper functions.

    """
    cat: Category = {
        "name": name,
        "level": 1,
        "help": _escape(info.get("category_help", "")),
        "actions": [],
        "subs": {},
    }
    # Add the category first, the subcategories will be appended later
    categories: list[Category] = [cat]
    functions: list[Function] = []

    # Parse actions (before subcategories)
    actions = []
    for action, action_info in info.get("actions", {}).items():
        if action_info.get("hide_in_help", False):
            continue

        act, funcs = parse_actions(action, action_info)
        actions.append(act)
        functions.extend(funcs)
    cat["actions"] = actions

    # Parse subcategories
    subs = {}
    for subcategory, subcategory_info in info.get("subcategories", {}).items():
        if subcategory.startswith("_") or subcategory_info.get("hide_in_help", False):
            continue

        help, subcategory_dict, funcs = parse_subcategory(  # noqa: A001
            name,
            subcategory,
            subcategory_info,
        )
        subs[subcategory] = help
        functions.extend(funcs)
        # Append subcategory below the category
        categories.append(subcategory_dict)
    # Add the list of subcategories to the category
    cat["subs"] = subs

    return categories, functions


def parse_subcategory(
    category: str,
    name: str,
    info: dict[str, Any],
) -> tuple[str, Category, list[Function]]:
    """Parse a sub-category (level 2) for its actions.

    Reminder:
        yunohost     user        group       list    --full --short
           ^          ^            ^          ^
        (script) | category | subcategory | action | parameters

    A subcategory is treated as a Category (of level 2), with an 'actions' key,
    but no 'subs' key.
    The help text of the subcategory is needed to construct the 'subs' dict
    of the parent category.
    Like a level-1 category, subcategories may need to define help functions.
    The list of help functions is returned.

    Parameters
    ----------
    category: str
        the name of the parent category
    name: str
        the subcategory name
    info: dict[str, Any]
        the information dict about the subcategory

    Returns
    -------
    tuple[str, Category, list[Function]]
        A tuple of the subcategory help text, the subcategory dict
        and the list of subcategory helper functions.

    """
    full_name = f"{category}_{name}"
    help = _escape(info.get("subcategory_help", ""))  # noqa: A001

    subcat: Category = {"name": full_name, "level": 2, "help": help, "actions": []}
    functions: list[Function] = []

    # Parse actions (before subcategories)
    actions = []
    for action, action_info in info.get("actions", {}).items():
        if action_info.get("hide_in_help", False):
            continue

        act, funcs = parse_actions(action, action_info)
        actions.append(act)
        functions.extend(funcs)
    subcat["actions"] = actions

    return help, subcat, functions


def parse_actions(
    name: str,
    info: dict[str, Any],
) -> tuple[Action, list[Function]]:
    """Parse an Action for it's help text and arguments.

    Returns
    -------
    tuple[Action, list[Function]]
        A tuple of the action dict and the list of action helper functions.

    """
    functions: list[Function] = []

    # This is a counter, in case of position dependent paremeters (the ones not
    # beginning with a `-`)
    position = 0

    arguments: list[str] = []
    cases: list[Case] = []

    for _argument_name, argument_info in info.get("arguments", {}).items():
        #
        # Forcing to str, as the yaml parser inteprets numbers as integers
        # (eg.: `firewall allow... -4`)
        argument_name = str(_argument_name)
        case: Case | None = None
        funcs: list[Function] = []

        #
        # This is an optional parameter, beginning with a `-`
        if argument_name.startswith("-"):
            full_argument, case, funcs = parse_argument_optional(
                argument_name,
                argument_info,
            )
        #
        # A parameter not beginning with `-` is considered mandatory.
        else:
            position += 1
            full_argument, case, funcs = parse_argument_mandatory(
                argument_name,
                argument_info,
                position,
            )

        # If action is None, do not display the parameter
        if not full_argument:
            continue

        # If case is not None, add a case below the arguments list
        if case:
            cases.append(case)

        # Add helper functions
        functions.extend(funcs)

        # Append argument
        arguments.append(full_argument)

    help = _escape(info.get("action_help", ""))  # noqa: A001
    action_dict: Action = {
        "name": name,
        "help": help,
        "arguments": arguments,
        "cases": cases,
    }
    return action_dict, functions


def parse_argument_mandatory(
    name: str,
    info: dict[str, Any],
    position: int = 0,
) -> tuple[str, Case | None, list[Function]]:
    """Parse a mandatory argument."""
    #
    # Initializing the argument dict to make sure all fields are defined
    # - id: identifier (`-n` or `--name`). If none (e.g. `ynh app install
    # APP_NAME`), this field is the arguments position or cardinality (from
    # `nargs`)
    # - excludes: usually the argument itself. Only used for optional args
    # - desc: the argument description
    # - completion: the completion function name
    #
    arg = {"excludes": "", "spec": "", "desc": "", "mess": "", "action": "", "func": ""}

    #
    # Generation of the completion hints
    #
    arg["action"], case, functions = parse_argument_action(name, info)

    # Hidden argument
    if arg["action"] is None:
        return ("", None, [])

    # This parameter may be used more than once, else we use the position counter
    if info.get("nargs", "") in ["+", "*"]:
        if info["nargs"] == "+":
            arg["spec"] = f"'{{{position!s},*}}'"
        else:  # argument_details["nargs"] == "*":
            arg["spec"] = "*"
    else:
        arg["spec"] = str(position)
    arg["mess"] = info.get("help", name)

    #
    # If defined, add the default value as a hint
    if "default" in info:
        arg["mess"] += f" (default: {info['default']})"
    # Escape special character in the description
    arg["mess"] = _escape(arg["mess"])

    # ----
    # NOTE: a double colon marks for an optional argument:
    #     ::Username to update:__ynh_user_list
    # ----
    placeholder = "'{}{}{}:{}:{}'"

    # Escape special character in the description
    arg["desc"] = _escape(arg["desc"])
    argument = placeholder.format(
        arg["excludes"],
        arg["spec"],
        arg["desc"],
        arg["mess"],
        arg["action"],
    )
    return (argument, case, functions)


def parse_argument_optional(
    name: str,
    info: dict[str, Any],
) -> tuple[str, Case | None, list[Function]]:
    """Parse an optional argument."""
    #
    # Initializing the argument dict to make sure all fields are defined
    # - id: identifier (`-n` or `--name`). If none (e.g. `ynh app install
    # APP_NAME`), this field is the arguments position or cardinality (from
    # `nargs`)
    # - excludes: usually the argument itself. Only used for optional args
    # - desc: the argument description
    # - completion: the completion function name
    #
    arg = {"excludes": "", "spec": "", "desc": "", "mess": "", "action": "", "func": ""}

    #
    # Generation of the completion hints
    #
    arg["action"], case, functions = parse_argument_action(name, info)

    # Hidden argument
    if arg["action"] is None:
        return ("", None, [])

    # `full` is the extended form of the argument (e.g.: -n is short for --number)
    if "full" in info:
        full_name = info["full"]
        arg["mess"] = str(full_name).lstrip("-")
        arg["spec"] = f"'{{{name},{full_name}}}'"
        arg["excludes"] = f"({name} {full_name})"
    else:
        arg["mess"] = str(name).lstrip("-")
        arg["spec"] = name
    # Escape special character in the description
    arg["mess"] = _escape(arg["mess"])

    # The description of the parameter
    # Getting the `help` field if any, else simply by using it's name
    help = info.get("help", arg["mess"])  # noqa: A001
    arg["desc"] = f"[{help}]"

    has_action = True
    # Add a pattern field to match multiple arguments
    if info.get("nargs", "") in ["+", "*"]:
        if arg["excludes"]:
            # suppose that `arg["excludes"] = (-f --foo)`
            arg["excludes"] = "(* " + arg["excludes"][1:]
        else:
            arg["excludes"] = "(*)"
        arg["mess"] = "*:" + arg["mess"]
        has_action = True

    # Options without arguments should skip the message and action fields
    elif info.get("action", "").startswith("store_"):
        has_action = False

    # Place holder for the parameters
    placeholder = "'{}{}{}:{}:{}'" if has_action else "'{}{}{}'"
    # Escape special character in the description
    arg["desc"] = _escape(arg["desc"])
    argument = placeholder.format(
        arg["excludes"],
        arg["spec"],
        arg["desc"],
        arg["mess"],
        arg["action"],
    )
    return (argument, case, functions)


def parse_argument_action(  # noqa: C901, PLR0911, PLR0912
    name: str,
    info: dict[str, Any],
) -> tuple[str, Case | None, list[Function]]:
    """Parse an argument action."""
    functions: list[Function] = []
    #
    # Finds the completion function for the given argument, if defined.
    #
    # `functions` hold the elements needed to generate it.  The
    # actual creation of this function will be done by build_completion_functions(),
    # called near the end of this script.
    # `choices` and `autocomplete` should not be present at the same time
    # (`choices` takes precedence)

    #
    # A list of choices is defined
    if "choices" in info:
        all_choices = " ".join(info["choices"])
        action = f"({all_choices})"
        return (action, None, functions)

    #
    # Look for an autocompletion function, but it is not defined
    if "extra" not in info or "autocomplete" not in info["extra"]:
        return ("", None, functions)

    #
    # An autocompletion function is defined
    autocomplete = info["extra"]["autocomplete"]

    #
    # Check if the argument should be hidden (API only)
    if autocomplete.get("hide_in_help", False):
        return ("", None, functions)

    #
    # This is a combinaision of YunoHost and jq commands
    #
    if "ynh_selector" in autocomplete and "jq_selector" in autocomplete:
        #
        # Function dependent on previous arguments
        #
        if "depends" in autocomplete and autocomplete["depends"] == "previous":
            # Create cases that depend on the previous argument.
            #
            # First, build the shell command that returns the completions
            call = (
                f"sudo yunohost {autocomplete['ynh_selector']} "
                f'"${{previous}}" --output-as json '
                f"| jq -cr '{autocomplete['jq_selector']}' | xargs"
            )
            # If a cache is needed, wrap the call in the caching function
            if autocomplete.get("use_cache", False):
                call = '__get_ynh_cache YNH_{}_"${{previous}}" "{}"'.format(
                    _norm_name(autocomplete["ynh_selector"]),
                    # Remove the double-quote in "{previous}"
                    # because the whole cmd will be encased in quotes.
                    call.replace('"', ""),
                )

            function_name = f"->{name}"
            case: Case = {"name": name, "shell_call": call}
            return (function_name, case, functions)

        # Create this function's name
        function_name = _remove_special_chars(
            "__ynh_" + _norm_name(autocomplete["ynh_selector"]),
        )
        #
        # Add a helper function
        #
        # First, build the shell command that returns the completions
        call = "sudo yunohost {} --output-as json | jq -cr '{}'".format(
            autocomplete["ynh_selector"],
            autocomplete["jq_selector"],
        )
        # If a cache is needed, wrap the call in the caching function
        if autocomplete.get("use_cache", False):
            call = "__get_ynh_cache 'YNH_{}' \"{}\"".format(
                _norm_name(autocomplete["ynh_selector"]),
                call,
            )
        # Lastly, save the content
        func: Function = {"name": function_name, "shell_call": call}
        functions.append(func)
        return (function_name, None, functions)

    #
    # The autocompletion is done by a grep
    #
    if "shell_call" in autocomplete:
        # Create this function's name
        function_name = _remove_special_chars(
            "__ynh_" + _norm_name(autocomplete["shell_call"]),
        )
        #
        # Add a helper function
        #
        # First, build the shell command that returns the completions
        call = autocomplete["shell_call"]

        # If a cache is needed, wrap the call in the caching function
        # Note: not tested with grep, only with YunoHost's commands
        if autocomplete.get("use_cache", False):
            call = "__get_ynh_cache 'YNH_{}' \"{}\"".format(
                _remove_special_chars(autocomplete["shell_call"]),
                call,
            )
        # Lastly, save the content
        func = {"name": function_name, "shell_call": call}
        functions.append(func)
        return (function_name, None, functions)

    #
    # This is a combinaision of two other completion functions
    #
    if "aggregate" in autocomplete:
        # Create this function's name
        function_name = "__ynh"
        for subcall in autocomplete["aggregate"]:
            if "ynh_selector" in subcall:
                function_name += "_" + _norm_name(subcall["ynh_selector"])

        #
        # Add a helper function
        aggregation = ""
        for subcall in autocomplete["aggregate"]:
            if "name" in subcall and "ynh_selector" in subcall:
                aggregation += "\n'{}:{}:{}' \\".format(
                    subcall["name"],
                    subcall["name"],
                    _norm_name("__ynh_" + subcall["ynh_selector"]),
                )
        # Lastly, save the content
        func = {"name": function_name, "aggregated": aggregation}
        functions.append(func)
        return (function_name, None, functions)

    #
    # The autocompletion is done by a ZSH function
    #
    if "zsh_completion" in autocomplete:
        return (autocomplete["zsh_completion"], None, functions)

    #
    # No autocompletion schema was defined
    #
    return ("", None, functions)


def render_zsh(categories: list[Category], functions: list[Function]) -> str:
    """Render the jinja template with the parsed categories and helper functions."""
    template_file = YUNOHOST_SRCDIR / "doc" / "zsh_completion.j2"
    template = Template(
        template_file.read_text(),
        keep_trailing_newline=True,
        comment_start_string="disabled because bash contains {#",
    )

    return template.render(
        categories=categories,
        functions=functions,
    )


#
# Utility functions, mainly string manipulation
#


def _norm_name(string: str) -> str:
    """Normalize a string to make it look like a function name.

    Apply the transformations:
      - lowercase
      - spaces replaced by underscores
      - no dashs

    :param str string: the string to norm.
    :return str: The normed string
    """
    return (
        string.lower()
        .replace(" ", "_")
        .replace("-", "")
        .replace("/", "_")
        .replace(".", "_")
    )


def _escape(string: str) -> str:
    r"""Escape any special character.

    Escape the characters:
      - single quotes (') are put in a separate double quoted string ('"'"')
      - colons (:) and other characters are preceded by a backslash (\:)

    :param str string: The string to escape
    :return str: The escaped string
    """
    return string.replace("'", "'\"'\"'").replace(":", r"\:")


def _remove_special_chars(string: str) -> str:
    """Remove any character with a special meaning in ZSH.

    Example of characters to remove:
        `$`, `{`, `(`, `[`, ...

    :param str string: The string to clean
    :return str: The cleaned string
    """
    # NOTE: this list may not be comprehensive and should be extended if needed
    return re.sub(r'[- =\^+:\?\'"$(){}\[\]/\\\\]', "", string).replace(".", "")


#
# Get action map
#


def get_action_map() -> dict[str, Any]:
    """Load the actionmap from a YAML file."""
    actionsmap = YUNOHOST_SRCDIR / "share" / "actionsmap.yml"
    return cast("dict[str, Any]", yaml.safe_load(actionsmap.open()))


def main() -> None:
    """Generate the completion file for Zsh."""
    parser = argparse.ArgumentParser()
    parser.add_argument("--output", "-o", type=Path, required=True)

    args = parser.parse_args()

    yunohost_map = get_action_map()

    categories, functions = get_actions_zsh(yunohost_map)
    result = render_zsh(categories, functions)

    args.output.write_text(result)


if __name__ == "__main__":
    main()
