#!/opt/local/bin/python3.14
############################################################################
#
# MODULE:	g.search.modules
# AUTHOR(S):	Jachym Cepicky <jachym.cepicky gmail.com>
# PURPOSE:	g.search.modules in grass modules using keywords
# COPYRIGHT:	(C) 2015-2019 by the GRASS Development Team
#
# 		This program is free software under the GNU General
# 		Public License (>=v2). Read the file COPYING that
# 		comes with GRASS for details.
#
#############################################################################

# %module
# % description: Search in GRASS modules using keywords
# % keyword: general
# % keyword: modules
# % keyword: search
# %end
# %option
# % key: keyword
# % multiple: yes
# % type: string
# % label: Keyword to be searched
# % description: List all modules if not given
# % required : no
# %end
# %flag
# % key: a
# % description: Display only modules where all keywords are available (AND), default: OR
# % guisection: Output
# %end
# %flag
# % key: n
# % description: Invert selection (logical NOT)
# % guisection: Output
# %end
# %flag
# % key: m
# % description: Search in manual pages too (can be slow)
# % guisection: Output
# %end
# %flag
# % key: k
# % label: Search only for the exact keyword in module keyword list
# % description: Instead of full text search, search only in actual keywords
# % guisection: Output
# %end
# %flag
# % key: c
# % description: Use colorized (more readable) output to terminal
# % guisection: Output
# %end
# %flag
# % key: g
# % description: Shell script format
# % guisection: Output
# %end
# %flag
# % key: j
# % description: JSON format
# % guisection: Output
# %end

import os
import sys
import xml.etree.ElementTree as ET
from operator import itemgetter
from pathlib import Path

from grass.exceptions import CalledModuleError
from grass.script import core as grass

COLORIZE = False


def main():
    global COLORIZE
    AND = flags["a"]
    NOT = flags["n"]
    manpages = flags["m"]
    exact_keywords = flags["k"]
    out_format = None
    if flags["g"]:
        out_format = "shell"
    elif flags["j"]:
        out_format = "json"
    else:
        COLORIZE = flags["c"]

    keywords = None
    if options["keyword"]:
        if exact_keywords:
            keywords = options["keyword"].split(",")
        else:
            keywords = options["keyword"].lower().split(",")
    else:
        NOT = None

    modules = _search_module(keywords, AND, NOT, manpages, exact_keywords)

    print_results(modules, out_format)


def print_results(data, out_format=None):
    """
    Print result of the searching method

    each data item should have

    {
        'name': name of the item,
        'attributes': {
            # list of attributes to be shown too
        }
    }

    :param list.<dict> data: input list of found data items
    :param str out_format: output format 'shell', 'json', None
    """

    if not out_format:
        _print_results(data)

    elif out_format == "shell":
        _print_results_shell(data)

    elif out_format == "json":
        _print_results_json(data)


def _print_results_shell(data):
    """Print just the name attribute"""

    for item in data:
        print(item["name"])


def _print_results_json(data):
    """Print JSON output"""

    import json

    print(json.dumps(data, sort_keys=True, indent=4, separators=(",", ": ")))


def _print_results(data):
    import textwrap

    for item in data:
        print("\n{0}".format(colorize(item["name"], attrs=["bold"])))
        for attr in item["attributes"]:
            out = "{0}: {1}".format(attr, item["attributes"][attr])
            out = textwrap.wrap(
                out,
                width=79,
                initial_indent=4 * " ",
                subsequent_indent=4 * " " + len(attr) * " " + "  ",
            )
            for line in out:
                print(line)


def colorize(text, attrs=None, pattern=None):
    """Colorize given text input

    :param string text: input text to be colored
    :param list.<string> attrs: list of attributes as defined in termcolor package
    :param string pattern: text to be highlighted in input text
    :return: colored string
    """

    if COLORIZE:
        try:
            from termcolor import colored
        except ImportError:
            grass.fatal(_("Cannot colorize, python-termcolor is not installed"))
    else:

        def colored(pattern, attrs):
            return pattern

    if pattern:
        return text.replace(pattern, colored(pattern, attrs=attrs))
    return colored(text, attrs=attrs)


def _search_module(
    keywords=None, logical_and=False, invert=False, manpages=False, exact_keywords=False
):
    """Search modules by given keywords

    :param list.<str> keywords: list of keywords
    :param boolean logical_and: use AND (default OR)
    :param boolean manpages: search in manpages too
    :return dict: modules
    """

    WXGUIDIR = os.path.join(os.getenv("GISBASE"), "gui", "wxpython")
    filename = os.path.join(WXGUIDIR, "xml", "module_items.xml")
    with open(filename) as menudata_file:
        menudata = ET.parse(menudata_file)

    items = menudata.findall("module-item")

    # add installed addons to modules list
    if os.getenv("GRASS_ADDON_BASE"):
        filename_addons = os.path.join(os.getenv("GRASS_ADDON_BASE"), "modules.xml")
        if Path(filename_addons).is_file():
            with open(filename_addons) as addon_menudata_file:
                addon_menudata = ET.parse(addon_menudata_file)
            addon_items = addon_menudata.findall("task")
            items.extend(addon_items)

    # add system-wide installed addons to modules list
    filename_addons_s = os.path.join(os.getenv("GISBASE"), "modules.xml")
    if Path(filename_addons_s).is_file():
        with open(filename_addons_s) as addon_menudata_file_s:
            addon_menudata_s = ET.parse(addon_menudata_file_s)
        addon_items_s = addon_menudata_s.findall("task")
        items.extend(addon_items_s)

    found_modules = []
    for item in items:
        name = item.attrib["name"]
        description = item.find("description").text
        module_keywords = item.find("keywords").text

        if not keywords:
            # list all modules
            found = [True]
        else:
            found = [False]
            if logical_and:
                found = [False] * len(keywords)

            for idx in range(len(keywords)):
                keyword = keywords[idx]
                keyword_found = False

                if exact_keywords:
                    keyword_found = _exact_search(keyword, module_keywords)
                else:
                    keyword_found = _basic_search(
                        keyword, name, description, module_keywords
                    )

                # meta-modules (i.sentinel, r.modis, ...) do not have descriptions
                # and keywords, but they have a manpage
                # TODO change the handling of meta-modules
                if (description and module_keywords) and not keyword_found and manpages:
                    keyword_found = _manpage_search(keyword, name)

                if keyword_found:
                    if logical_and:
                        found[idx] = True
                    else:
                        found = [True]

                    description = colorize(
                        description, attrs=["underline"], pattern=keyword
                    )
                    module_keywords = colorize(
                        module_keywords, attrs=["underline"], pattern=keyword
                    )

        add = False not in found
        if invert:
            add = not add
        if add:
            found_modules.append(
                {
                    "name": name,
                    "attributes": {
                        "keywords": module_keywords,
                        "description": description,
                    },
                }
            )

    return sorted(found_modules, key=itemgetter("name"))


def _basic_search(pattern, name, description, module_keywords) -> bool:
    """Search for a string in all the provided strings.

    This lowercases the strings before searching in them, so the pattern
    string should be lowercased too.
    """
    return bool(
        name
        and description
        and module_keywords
        and (
            name.lower().find(pattern) > -1
            or description.lower().find(pattern) > -1
            or module_keywords.lower().find(pattern) > -1
        )
    )


def _exact_search(keyword, module_keywords):
    """Compare exact keyword with module keywords

    :param keyword: exact keyword to find in the list (not lowercased)
    :param module_keywords: comma separated list of keywords
    """
    module_keywords = module_keywords.split(",")
    return keyword in module_keywords


def _manpage_search(pattern, name):
    try:
        manpage = grass.read_command("g.manual", flags="m", entry=name)
    except CalledModuleError:
        # in case man page is missing
        return False

    return manpage.lower().find(pattern) > -1


if __name__ == "__main__":
    options, flags = grass.parser()
    sys.exit(main())
