# SPDX-FileCopyrightText: 2018 James Turner
# SPDX-License-Identifier: GPL-2.0-or-later

"""Script for performing checks in aircraft directories."""

import argparse
import locale
import lxml
import os
import textwrap
import xml

from flightgear.meta import sgprops
from flightgear.meta.sgprops import PropsHandlerMode


fgDataAbsoluteIncludePrefixes = [
    "Aircraft/" + n for n in ("Generic", "Instruments", "Instruments-3d")
]

def isFgDataAbsoluteInclude(includePath):
    return any(
        includePath.startswith(n) for n in fgDataAbsoluteIncludePrefixes)


def check_meta_data(aircraft_dir, set_file, includes):
    """Parse a -set.xml file and perform sanity checks on it."""
    base_file = os.path.basename(set_file)
    base_id = base_file[:-8]
    set_path = os.path.join(aircraft_dir, set_file)

    # The aircraft dir is already in 'includes'
    root_node = sgprops.readProps(set_path, includePaths=includes,
                                  mode=PropsHandlerMode.RESILIENT,
                                  baseDir=aircraft_dir).node

    if not root_node.hasChild("sim"):
        print("-set.xml has no <sim> node:", set_path)
        return

    sim_node = root_node.getChild("sim")
    if not sim_node.hasChild('description'):
        print("{}: -set.xml missing <description>:".format(set_path))

    if not sim_node.hasChild('long-description'):
        print("{}: -set.xml missing <long-description>:".format(set_path))

    if not sim_node.hasChild('authors'):
        print("{}: -set.xml is missing structured <authors> data:".format(
            set_path))

    if not sim_node.hasChild('tags'):
        print("{}: -set.xml does not define any tags".format(set_path))

    # check for non-standard tags

    if not sim_node.hasChild('thumbnail'):
        print("{}: -set.xml does not define a thumbnail".format(set_path))

    # check thumbnail size and format

    if not sim_node.hasChild('rating'):
        print("{}: -set.xml does not define any ratings".format(set_path))

    if not sim_node.hasChild('minimum-fg-version'):
        print("{}: -set.xml does not define a minimum FG version".format(
            set_path))


def isPropertyListFile(file_):
    try:
        tree = lxml.etree.parse(file_)
    except lxml.etree.XMLSyntaxError as e:
        print(f"{e.filename}: XML syntax error: {e.msg}")
        return False
    else:
        root = tree.getroot()
        return root.tag == "PropertyList"


def osWalkDumbErrorHandling(exception):
    raise exception


def checkAllPropertyListFiles(aircraftDir, includes, /, *, verbose=False):
    absoluteIncludes = set()
    errors = set()
    # The “already parsed” record of files is per aircraft dir. If we were to
    # have a single one for the whole FGAddon, it would grow very big for, I
    # believe, little gain (possibly a performance loss?). For files included
    # from several aircraft, errors would be shown only in the report of the
    # first aircraft doing the include.
    alreadyParsed = set()

    for dirPath, dirs, files in os.walk(aircraftDir,
                                        onerror=osWalkDumbErrorHandling):
        for file_ in files:
            if file_.lower().endswith(".xml"):
                xmlFile = os.path.join(dirPath, file_)
                if os.path.realpath(xmlFile) in alreadyParsed:
                    continue
                elif isPropertyListFile(xmlFile):
                    try:
                        # The aircraft dir is already in 'includes'
                        r = sgprops.readProps(xmlFile, includePaths=includes,
                                              mode=PropsHandlerMode.RESILIENT,
                                              baseDir=aircraftDir)
                    except xml.sax._exceptions.SAXParseException as e:
                        print(e)
                    else:
                        for exc in r.errors:
                            errors.add((exc.file, exc.line, exc.altStr()))
                        absoluteIncludes |= r.absoluteIncludes
                        alreadyParsed |= r.parsedFiles

    for t in sorted(errors):    # sorted by file path, then line number
        print(t[2])

    reportOnAbsoluteIncludes(aircraftDir, absoluteIncludes, verbose=verbose)


def reportOnAbsoluteIncludes(aircraftDir, absoluteIncludes, /, *,
                             verbose=False):
    aircraftDirName = os.path.basename(aircraftDir)

    if verbose:
        l = sorted(i for i in absoluteIncludes
                   if not isFgDataAbsoluteInclude(i.includedPath))
        if l:
            print("Non-FGData absolute includes (these are deprecated):")
            for i in l:
                p = i.includedPath
                if p.startswith("Aircraft/" + aircraftDirName):
                    complement = ""
                else:
                    complement = " (cross-aircraft)"
                print("  {}:{}: {}{}".format(i.file, i.line, p, complement))
    else:
        nonFgdataAbsoluteIncludes = []
        crossAircraftIncludes = []  # the nasty ones

        for p in sorted(frozenset(i.includedPath for i in absoluteIncludes)):
            if not isFgDataAbsoluteInclude(p):
                nonFgdataAbsoluteIncludes.append(p)
                if not p.startswith("Aircraft/" + aircraftDirName):
                    crossAircraftIncludes.append(p)

        if nonFgdataAbsoluteIncludes:
            print("Non-FGData absolute includes:\n" +
                  textwrap.indent('\n'.join(nonFgdataAbsoluteIncludes), "  "))

        if crossAircraftIncludes:
            print("Cross-aircraft absolute includes:\n" +
                  textwrap.indent('\n'.join(crossAircraftIncludes), "  "))


def check_aircraft_dir(d, includes_, /, *, verbose=False):
    """
    Check the -set.xml and other PropertyList files in an aircraft directory."""
    print("Checking aircraft directory {!r}...".format(d))

    # Make sure the list of include paths starts with the aircraft dir 'd'
    includes = includes_.copy()
    if not (includes and os.path.samefile(d, includes[0])):
        includes.insert(0, d)

    # Check the -set.xml files
    with os.scandir(d) as it:
        for entry in sorted(it, key=lambda entry: entry.name.lower()):
            if entry.is_file() and entry.name.endswith('-set.xml'):
                check_meta_data(d, entry.name, includes)

    checkAllPropertyListFiles(d, includes, verbose=verbose)
    print()


def check_all_subdirs(d, includes, *, verbose=False):
    """Check all subdirectories of the specified directory."""
    # Visit subdirs of 'd' in lexicographic order
    with os.scandir(d) as it:
        for entry in sorted(it, key=lambda entry: entry.name.lower()):
            if entry.is_dir():
                check_aircraft_dir(entry.path, includes, verbose=verbose)


def processCommandLine():
    parser = argparse.ArgumentParser(
        usage="""\
%(prog)s [OPTION ...] DIR...
Perform checks on aircraft directories.""",
        description="""\
By default, each DIR argument is considered as an aircraft directory and
checked. If option -r is specified, however, each DIR argument is supposed
to _contain_ aircraft directories; accordingly, the program then checks
all subdirectories of each DIR argument.

Aircraft directories are expected to directly contain files whose names
end in '-set.xml'. Such files are loaded using
flightgear.meta.sgprops.readProps(): this is where some of the checks
start.

When using this program, you should almost always specify a --include
option that points to FGData, in a version compatible with the aircraft
that are going to be checked.

Regarding the deprecated “absolute includes”, i.e. those of the form
<tag-name include="Aircraft/foo/..."> where foo is the aircraft being
checked: providing a --include option for the parent of the 'Aircraft'
directory is not necessary. This is because flightgear.meta.sgprops has
specific code for such includes. Note that this code is only active if
there is an exact match for the “foo” part: just as in FlightGear, the
name of the aircraft directory has to match the path component following
“Aircraft/” in the absolute include.""",
        formatter_class=argparse.RawDescriptionHelpFormatter)

    parser.add_argument("-I", "--include", help="""\
      add an include directory, used to find the target files of PropertyList
      'include' directives (passing this option to specify a path to FGData is
      needed in most cases)""",
      action="append", dest='include', default=[])
    parser.add_argument("-r", "--recursive", action='store_true', help="""\
      check all subdirectories of each DIR argument (in other words, with this
      option, the DIR arguments are treated as directories of aircraft
      directories, rather than as aircraft directories)""")
    parser.add_argument("-v", "--verbose", action="store_true", help="""\
      enable verbose output""")
    parser.add_argument("dir", metavar="DIR", nargs='+',
                        help="aircraft directory")

    return parser.parse_args()


def main():
    locale.setlocale(locale.LC_ALL, '')
    params = processCommandLine()

    if params.recursive:
        for d in params.dir:
            if params.verbose:
                print("Entering directory {!r}".format(d))
            check_all_subdirs(d, params.include, verbose=params.verbose)
    else:
        for d in params.dir:
            check_aircraft_dir(d, params.include, verbose=params.verbose)

    return 0


if __name__ == "__main__":
    sys.exit(main())
