#!/usr/bin/env python3
import argparse
import os
import os.path
import ansible
from packaging import version
import ansible.modules
from ansible.utils.plugin_docs import get_docstring
from ansible.plugins.loader import fragment_loader
from typing import Any, List

OUTPUT_FILENAME = "ansible.snippets"
OUTPUT_STYLE = ["multiline", "dictionary"]
HEADER = [
    "# NOTE: This file is auto-generated. Modifications may be overwritten.",
    "priority -50",
]
MAX_DESCRIPTION_LENGTH = 512
ANSIBLE_VERSION = ansible.release.__version__


def get_files_builtin() -> List[str]:
    """Return the sorted list of all module files that ansible provides with the ansible package

    Returns
    -------
    List[str]
        A list of strings representing the Python module files provided by
        Ansible
    """

    file_names: List[str] = []
    for root, dirs, files in os.walk(os.path.dirname(ansible.modules.__file__)):
        files_without_symlinks = []
        for f in files:
            if not os.path.islink(os.path.join(root, f)):
                files_without_symlinks.append(f)
        file_names += [
            f"{root}/{file_name}"
            for file_name in files_without_symlinks
            if file_name.endswith(".py") and not file_name.startswith("__init__")
        ]

    return sorted(file_names)


def get_files_collections(user: bool = False) -> List[str]:
    """Return the sorted list of all module files provided by collections installed in either
    the system folder /usr/share/ansible/collections/ or user folder ~/.ansible/collections/

    Parameters
    ----------
    user: bool (default: False)
        A boolean indicating whether to get collections installed in the user folder

    Returns
    -------
    List[str]
        A list of strings representing the Python module files provided by collections
    """

    if user:
        collection_path = '~/.ansible/collections/ansible_collections/'
    else:
        collection_path = '/usr/share/ansible/collections/ansible_collections/'

    file_names: List[str] = []
    for root, dirs, files in os.walk(os.path.expanduser(collection_path)):
        files_without_symlinks = []
        for f in files:
            if not os.path.islink(os.path.join(root, f)):
                files_without_symlinks.append(f)
        file_names += [
            f"{root}/{file_name}"
            for file_name in files_without_symlinks
            if file_name.endswith(".py") and not file_name.startswith("__init__") and "plugins/modules" in root
        ]

    return sorted(file_names)


def get_module_docstring(file_path: str) -> Any:
    """Extract and return docstring information from a module file

    Parameters
    ----------
    file_names: file_path[str]
        string representing module file

    Returns
    -------
    Any
        An AnsibleMapping object, representing docstring information
        (in dict form), excluding those that are marked as deprecated.

    """

    docstring = get_docstring(file_path, fragment_loader)[0]

    if docstring and not docstring.get("deprecated"):
        return docstring


def escape_strings(escapist: str) -> str:
    """Escapes strings as required for ultisnips snippets

    Escapes instances of \\, `, {, }, $

    Parameters
    ----------
    escapist: str
        A string to apply string replacement on

    Returns
    -------
    str
        The input string with all defined replacements applied
    """

    return (
        escapist.replace("\\", "\\\\")
                .replace("`", r"\`")
                .replace("{", r"\{")
                .replace("}", r"\}")
                .replace("$", r"\$")
                .replace("\"", "'")
    )


def option_data_to_snippet_completion(option_data: Any) -> str:
    """Convert Ansible option info into a string used for ultisnip completion

    Converts data about an Ansible module option (retrieved from an
    AnsibleMapping object) into a formatted string that can be used within an
    UltiSnip macro.

    Parameters
    ----------
    option_data: Any
        The option parameters

    Returns
    -------
    str
        A string representing one formatted option parameter
    """

    # join descriptions that are provided as lists and crop them
    description = escape_strings(
        "".join(option_data.get("description"))[0:MAX_DESCRIPTION_LENGTH]
    )
    default = option_data.get("default")
    choices = option_data.get("choices")
    option_type = option_data.get("type")

    # if the option is of type "bool" return "yes" or "no"
    if option_type and "bool" in option_type:
        if default in [True, "True", "true", "yes"]:
            return "true"
        if default in [False, "False", "false", "no"]:
            return "false"

    # if there is no default and no choices, return the description
    if not choices and default is None and not args.no_description:
        return f"# {description}"

    # if there is a default but no choices return the default as string
    if default is not None and not choices:
        if len(str(default)) == 0:
            return '""'
        else:
            if isinstance(default, str) and "\\" in default:
                return f'"{escape_strings(str(default))}"'
            elif isinstance(default, str):
                return escape_strings(str(default))
            else:
                return default

    # if there is a default and there are choices return the list of choices
    # with the default prefixed with #
    if default is not None and choices:
        if isinstance(default, list):
            # prefix default choice(s)
            prefixed_choices = [
                f"#{choice}" if choice in default else f"{choice}" for choice in choices
            ]
            return str(prefixed_choices)
        else:
            # prefix default choice
            prefixed_choices = [
                f"#{choice}" if str(choice) == str(default) else f"{choice}"
                for choice in choices
            ]
            return "|".join(prefixed_choices)

    # if there are choices but no default, return the choices as pipe separated
    # list
    if choices and default is None:
        return "|".join([str(choice) for choice in choices])

    # as fallback return empty string
    return ""


def module_options_to_snippet_options(module_options: Any) -> List[str]:
    """Convert module options to UltiSnips snippet options

    Parameters
    ----------
    module_options: Any
        The "options" attribute of an AnsibleMapping object

    Returns
    -------
    List[str]
        A list of strings representing converted options
    """

    options: List[str] = []
    delimiter = ": " if args.style == "dictionary" else "="

    if not module_options:
        return options

    # order by option name
    module_options = sorted(module_options.items(), key=lambda x: x[0])
    # order by "required" attribute
    module_options = sorted(
        module_options, key=lambda x: x[1].get("required", False), reverse=True
    )

    # insert an empty option above the list of non-required options
    for index, (_, option) in enumerate(module_options):
        if not option.get("required") and not args.comment_non_required:
            if index != 0:
                module_options.insert(index, (None, None))
            break

    for index, (name, option_data) in enumerate(module_options, start=1):
        # insert a line to separate required/non-required options
        if not name and not option_data:
            options += [""]
        else:
            # set comment character for non-required options
            if not option_data.get("required") and args.comment_non_required:
                comment = "#"
            else:
                comment = ""
            # the free_form option in some modules are special
            if name == "free_form":
                options += [
                    f"\t{comment}${{{index}:{name}{delimiter}{option_data_to_snippet_completion(option_data)}}}"
                ]
            else:
                options += [
                    f"\t{comment}{name}{delimiter}${{{index}:{option_data_to_snippet_completion(option_data)}}}"
                ]

    return options


def convert_docstring_to_snippet(convert_docstring: Any, collection_name) -> List[str]:
    """Converts data about an Ansible module into an UltiSnips snippet string

    Parameters
    ----------
    convert_docstring: Any
        An AnsibleMapping object representing the docstring for an Ansible
        module

    Returns
    -------
    str
        A string representing an ultisnips compatible snippet of an Ansible
        module
    """

    snippet: List[str] = []
    snippet_options = "b"
    if "module" in convert_docstring.keys():
        module_name = convert_docstring["module"]
        module_short_description = convert_docstring["short_description"]

        # use only the module name if ansible version < 2.10
        if version.parse(ANSIBLE_VERSION) < version.parse("2.10"):
            snippet_module_name = f"{module_name}:"
        # use FQCN if ansible version is 2.10 or higher
        else:
            snippet_module_name = f"{collection_name}.{module_name}:"

        snippet += [f'snippet {module_name} "{escape_strings(module_short_description)}" {snippet_options}']
        if args.style == "dictionary":
            snippet += [f"{snippet_module_name}"]
        else:
            snippet += [f"{snippet_module_name}:{' >' if convert_docstring.get('options') else ''}"]
        module_options = module_options_to_snippet_options(convert_docstring.get("options"))
        snippet += module_options
        snippet += ["endsnippet"]

    return snippet

def get_collection_name(filepath:str) -> str:
    """ Returns the collection name for a full file path """

    path_splitted = filepath.split('/')

    collection_top_folder_index = path_splitted.index('ansible_collections')
    collection_namespace = path_splitted[collection_top_folder_index + 1]
    collection_name = path_splitted[collection_top_folder_index + 2]

    #  print(f"{collection_namespace}.{collection_name}")
    return f"{collection_namespace}.{collection_name}"


if __name__ == "__main__":

    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--output",
        help=f"Output filename (default: {OUTPUT_FILENAME})",
        default=OUTPUT_FILENAME,
    )
    parser.add_argument(
        "--style",
        help=f"YAML format used for snippets (default: {OUTPUT_STYLE[0]})",
        choices=OUTPUT_STYLE,
        default=OUTPUT_STYLE[0],
    )
    parser.add_argument(
        '--user',
        help="Include user modules",
        action="store_true",
        default=False
    )
    parser.add_argument(
        '--no-description',
        help="Remove options description",
        action="store_true",
        default=False
    )
    parser.add_argument(
        '--comment-non-required',
        help="Comment non-required options",
        action="store_true",
        default=False
    )
    args = parser.parse_args()


    if version.parse(ANSIBLE_VERSION) < version.parse("2.10"):
        print(f"ansible version {ANSIBLE_VERSION} doesn't support FQCN")
        print("generated snippets will only use the module name e.g. 'yum' instead of 'ansible.builtin.yum'")
    else:
        print(f"ansible version {ANSIBLE_VERSION} supports using FQCN")
        print("Generated snippets will use FQCN e.g. 'ansible.builtin.yum' instead of 'yum'")
        print("Still, you only need to type 'yum' to trigger the snippet")

    modules_docstrings = []

    builtin_modules_paths = get_files_builtin()
    for f in builtin_modules_paths:
        docstring_builtin = get_module_docstring(f)
        if docstring_builtin and docstring_builtin not in modules_docstrings:
            docstring_builtin['collection_name'] = "ansible.builtin"
            modules_docstrings.append(docstring_builtin)

    system_modules_paths = get_files_collections()
    for f in system_modules_paths:
        docstring_system = get_module_docstring(f)
        if docstring_system and docstring_system not in modules_docstrings:
            collection_name = get_collection_name(f)
            docstring_system['collection_name'] = collection_name
            modules_docstrings.append(docstring_system)

    if args.user:
        user_modules_paths = get_files_collections(user=True)
        for f in user_modules_paths:
            docstring_user = get_module_docstring(f)
            if docstring_user and docstring_user not in modules_docstrings:
                collection_name = get_collection_name(f)
                docstring_user['collection_name'] = collection_name
                modules_docstrings.append(docstring_user)

    with open(args.output, "w") as f:
        f.writelines(f"{header}\n" for header in HEADER)
        for docstring in modules_docstrings:
            f.writelines(
                f"{line}\n" for line in convert_docstring_to_snippet(docstring, docstring.get("collection_name"))
            )