# SPDX-FileCopyrightText: 2017 James Turner <james@flightgear.org>
# SPDX-License-Identifier: GPL-2.0-or-later

"""Module for FlightGear aircraft catalog handling."""

import argparse
import datetime
import fnmatch
import lxml.etree as ET
import os
from os.path import exists, join, relpath
from os import F_OK, access
import re
import sys
import zipfile

from dataclasses import dataclass

from flightgear.meta import sgprops, strutils
from flightgear.meta.sgprops import PropsHandlerMode
from . import catalogTags

CATALOG_VERSION = 4
quiet = False
verbose = False

# The Python version.
PY_VERSION = sys.version_info[0]

# Python 2 and 3 compatibility.
if PY_VERSION == 3:
    long = int


def warning(msg):
    if not quiet:
        print(msg)

def log(msg):
    if verbose:
        print(msg)

# xml node (robust) get text helper
def get_xml_text(e):
    if hasattr(e, "text") and e.text is not None:
        return e.text
    else:
        return ''

# return all available aircraft information from the set file as a
# dict
def scan_set_file(aircraft_dir, set_file, includes, *,
                  mode=PropsHandlerMode.RESILIENT):
    base_file = os.path.basename(set_file)
    base_id = base_file[:-8]
    set_path = os.path.join(aircraft_dir, set_file)

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

    root_node = sgprops.readProps(set_path, includePaths=includesCopy,
                                  mode=mode, baseDir=aircraft_dir).node

    if not root_node.hasChild("sim"):
        return None

    sim_node = root_node.getChild("sim")
    if sim_node is None:
        return None

    # allow -set.xml files to specifcially exclude themselves from
    # the creation process, by setting <exclude-from-catalog>true</>
    if (sim_node.getValue("exclude-from-catalog", False) == True):
        return None

    variant = {}
    name = sim_node.getValue("description", None)
    if (name is None or len(name) == 0):
        warning("Set file " + set_file + " is missing a <description>, skipping")
        return None

    variant['name'] =  name
    variant['status'] = sim_node.getValue("status", None)

    if sim_node.hasChild('authors'):
        # aircraft has structured authors data, handle that
        variant['authors'] = sim_node.getChild('authors')

    # can have legacy author tag alongside new structured data for
    # backwards FG compatibility
    if sim_node.hasChild('author'):
        variant['author'] = sim_node.getValue("author", None)

    if sim_node.hasChild('maintainers'):
        variant['maintainers'] = sim_node.getChild('maintainers')

    if sim_node.hasChild('urls'):
        variant['urls'] = sim_node.getChild('urls')

    if sim_node.hasChild('long-description'):
        variant['description'] = sim_node.getValue("long-description", None)
    variant['id'] = base_id

    # allow -set.xml files to declare themselves as primary.
    # we use this avoid needing a variant-of in every other -set.xml
    variant['primary-set'] = sim_node.getValue('primary-set', False)

    # extract and record previews for each variant
    if sim_node.hasChild('previews'):
        variant['previews'] = extract_previews(sim_node.getChild('previews'), aircraft_dir)

    if sim_node.hasChild('rating'):
        variant['rating'] = sim_node.getChild("rating")

    if sim_node.hasChild('tags'):
        variant['tags'] = extract_tags(sim_node.getChild('tags'), set_file)

    if sim_node.hasChild('thumbnail'):
        variant['thumbnail'] = sim_node.getValue("thumbnail", None)

    variant['variant-of'] = sim_node.getValue("variant-of", None)

    # Prior to 2024.2 we have a minimum version
    if sim_node.hasChild('minimum-fg-version'):
        variant['minimum-fg-version'] = sim_node.getValue('minimum-fg-version', None)

    # 2024.2 and above we have explicit compatibility properties
    try:
        compatNode = sim_node.getChild('compatibility', 0)
    except sgprops.NoSuchChildError:
        pass
    else:
        variant['compatibility'] = { childNode.name: childNode.value
                                     for childNode in compatNode.getChildren() }

    if sim_node.hasChild('localized'):
        variant['localized'] = extract_localized_strings(sim_node.getChild('localized'))

    #print("    %s" % variant)
    return variant


def extract_previews(previews_node, aircraft_dir):
    result = []
    for node in previews_node.getChildren("preview"):
        previewType = node.getValue("type", None)
        previewPath = node.getValue("path", None)

        # check path exists in base-name-dir
        fullPath = os.path.join(aircraft_dir, previewPath)
        if not os.path.isfile(fullPath):
            warning("Bad preview path, skipping:" + fullPath)
            continue
        result.append({'type':previewType, 'path':previewPath})

    return result


def extract_tags(tags_node, set_path):
    result = []
    for node in tags_node.getChildren("tag"):
        tag = node.value
        # check tag is in the allowed list
        if not catalogTags.isValidTag(tag):
            fixed = catalogTags.fixupTag(tag)
            if fixed is None:
                warning("Invalid tag: {!r} in {!r}".format(tag, set_path))
                continue
            tag = fixed
        result.append(tag)

    return result


def extract_localized_strings(localized_node):
    result = {}
    # iterate languages below <localized>
    for lang in localized_node.getChildren():
        strings = {}

        # iterate strings below <de> etc
        for s in lang.getChildren():
            # fix up the name/description confusion here
            if s.name == 'description':
                strings['name'] = strutils.simplify(s.value)
            elif s.name == 'long-description':
                strings['description'] = strutils.simplify(s.value)
            else:
                strings[s.name] = strutils.simplify(s.value)

        if strings:
            result[lang.name] = strings

    return result

# scan all the -set.xml files in an aircraft directory.  Returns a
# package dict and a list of variants.
def scan_aircraft_dir(aircraft_dir, includes):
    setDicts = []
    primaryAircraft = []
    package = None

    files = os.listdir(aircraft_dir)
    for file in sorted(files, key=lambda s: s.lower()):
        if file.endswith('-set.xml'):
            # print('trying: %s' % file)
            try:
                d = scan_set_file(aircraft_dir, file, includes)
                if d is None:
                    continue
            except Exception as e:
                print("Skipping set file since couldn't be parsed: {}".format(e))
                continue

            setDicts.append(d)
            if d['primary-set']:
                # ensure explicit primary-set aircraft goes first
                primaryAircraft.insert(0, d)
            elif d['variant-of'] is None:
                primaryAircraft.append(d)

    # print(setDicts)
    if not setDicts:
        return None, None

    # use the first one
    if not primaryAircraft:
        print("Aircraft has no primary aircraft at all: %s" % aircraft_dir)
        primaryAircraft = [setDicts[0]]

    package = primaryAircraft[0]
    if not 'thumbnail' in package:
        if (os.path.exists(os.path.join(aircraft_dir, "thumbnail.jpg"))):
            package['thumbnail'] = "thumbnail.jpg"

    # variants is just all the set dicts except the first one
    variants = setDicts
    variants.remove(package)
    return (package, variants)

# create an xml node with text content
def make_xml_leaf(name, text):
    leaf = ET.Element(name)
    if text is not None:
        if isinstance(text, bool):
            leaf.text = "true" if text else "false"
        elif isinstance(text, (int, float)):
            leaf.text = str(text)
        else:
            leaf.text = text
    else:
        leaf.text = ''
    return leaf


def append_preview_nodes(node, variant, download_base, package_name):
    if not 'previews' in variant:
        return

    for preview in variant['previews']:
        preview_node = ET.Element('preview')
        preview_url = download_base + 'previews/' + package_name + '_' + preview['path']
        preview_node.append( make_xml_leaf('type', preview['type']) )
        preview_node.append( make_xml_leaf('url', preview_url) )
        preview_node.append( make_xml_leaf('path', preview['path']) )
        node.append(preview_node)


def append_tag_nodes(node, variant):
    if not 'tags' in variant:
        return

    for tag in variant['tags']:
        node.append(make_xml_leaf('tag', tag))


def append_author_nodes(node, info):
    if 'authors' in info:
        node.append(info['authors']._createXMLElement())
    if 'author' in info:
        # traditional single author string
        node.append( make_xml_leaf('author', info['author']) )


def append_compatibility_nodes(node, variant):
    if 'compatibility' not in variant:
        return

    # The <compatibility> tag contains generic key/value pairs.
    for k,v in variant['compatibility'].items():
        node.append(make_xml_leaf(k, v))

def append_localized_strings(node, variant):
    if not 'localized' in variant:
        return

    localized_node = ET.Element('localized')
    for lang, v in variant['localized'].items():
        lang_node = ET.Element(lang)
        for skey, s in v.items():
            lang_node.append(make_xml_leaf(skey, s))

        localized_node.append(lang_node)

    node.append(localized_node)


def make_aircraft_node(aircraftDirName, package, variants, downloadBase, mirrors):
    #print("package: %s" % package)
    #print("variants: %s" % variants)
    package_node = ET.Element('package')
    package_node.append( make_xml_leaf('name', package['name']) )
    package_node.append( make_xml_leaf('status', package['status']) )

    append_author_nodes(package_node, package)

    if 'description' in package:
        package_node.append( make_xml_leaf('description', package['description']) )

    # Prior to 2024.2 we have a minimum FG version
    if 'minimum-fg-version' in package:
        package_node.append( make_xml_leaf('minimum-fg-version', package['minimum-fg-version']) )

    # 2024.2 and above we have explicit compatibility properties in a compatibility subtree
    append_compatibility_nodes(package_node, package)

    if 'rating' in package:
        package_node.append(package['rating']._createXMLElement())

    package_node.append( make_xml_leaf('id', package['id']) )
    for variant in variants:
        variant_node = ET.Element('variant')
        package_node.append(variant_node)
        variant_node.append( make_xml_leaf('id', variant['id']) )
        variant_node.append( make_xml_leaf('name', variant['name']) )
        if 'description' in variant:
            variant_node.append( make_xml_leaf('description', variant['description']) )

        if 'author' in variant:
            variant_node.append( make_xml_leaf('author', variant['author']) )

        if 'thumbnail' in variant:
            # note here we prefix with the package name, since the thumbnail path
            # is assumed to be unique within the package
            thumbUrl = downloadBase + "thumbnails/" + aircraftDirName + '_' + variant['thumbnail']
            variant_node.append(make_xml_leaf('thumbnail', thumbUrl))
            variant_node.append(make_xml_leaf('thumbnail-path', variant['thumbnail']))

        variantOf = variant['variant-of']
        if variantOf is None:
            variant_node.append(make_xml_leaf('variant-of', '_primary_'))
        else:
            variant_node.append(make_xml_leaf('variant-of', variantOf))

        append_preview_nodes(variant_node, variant, downloadBase, aircraftDirName)
        append_tag_nodes(variant_node, variant)
        append_author_nodes(variant_node, variant)
        append_localized_strings(variant_node, variant)

    package_node.append( make_xml_leaf('dir', aircraftDirName) )

    # primary URL is first
    download_url = downloadBase + aircraftDirName + '.zip'
    package_node.append( make_xml_leaf('url', download_url) )

    for m in mirrors:
        mu = m + aircraftDirName + '.zip'
        package_node.append( make_xml_leaf('url', mu) )


    if 'thumbnail' in package:
        thumbnail_url = downloadBase + 'thumbnails/' + aircraftDirName + '_' + package['thumbnail']
        package_node.append( make_xml_leaf('thumbnail', thumbnail_url) )
        package_node.append( make_xml_leaf('thumbnail-path', package['thumbnail']))

    append_preview_nodes(package_node, package, downloadBase, aircraftDirName)
    append_tag_nodes(package_node, package)
    append_localized_strings(package_node, package)

    if 'maintainers' in package:
        package_node.append(package['maintainers']._createXMLElement())

    if 'urls' in package:
        package_node.append(package['urls']._createXMLElement())

    return package_node


def _osWalkDumbErrorHandling(exception):
    raise exception


def make_aircraft_zip(repoPath: str, craftName: str, zipFile: str,
                      globalExcludeFile: str, /, *,
                      verbose : bool = True) -> None:
    """Create a zip archive of the given aircraft.

    The global (repository-wide) 'zip-excludes.lst' file is combined
    with the aircraft-specific one, if any, to determine what to exclude
    from the zip archive. These files have the same syntax where blank
    lines are ignored as well as comment lines, which are those whose
    first non-blank character is a '#', where blank characters are
    defined as spaces and tabs.

    Each non-ignored line is strip()ped and the result specifies an
    exclusion pattern. Pattern syntax and semantics are those of
    fnmatch.fnmatchcase()[1] with one addition: a pattern that ends with
    a '/' can only match directories. Patterns are matched on relative
    paths that start with './' inside the aircraft being packaged, for
    instance:

    # '*' matches anything, including path component separators.
    *.xcf
    *~
    ./Models/whatever-?-?-?/some-file
    ./Nasal/*/some[-_]directory/

    # This pattern matches at any level:
    */Resources/
    # That one can only match at top level:
    ./Only/at/top-level/

    As shown in this example, path components in exclusion patterns must
    be separated by '/' characters regardless of the operating system
    (filesystem paths are converted as needed before performing the
    pattern matching operations).

    [1] https://docs.python.org/3/library/fnmatch.html#fnmatch.fnmatchcase

    """
    if verbose:
        print("Zip file creation: {}.zip".format(craftName))

    # Clear out the old file.
    if os.path.exists(zipFile):
        os.remove(zipFile)

    # Use the Python 'zipfile' module to create the zip file.
    with zipfile.ZipFile(zipFile, 'w', zipfile.ZIP_DEFLATED) as zipFileObj:
        make_aircraft_zip_inner(repoPath, craftName, zipFileObj,
                                globalExcludeFile, verbose)


def make_aircraft_zip_inner(repoPath, craftName, zipFileObj,
                            globalExcludeFile, verbose):
    craftPath = join(repoPath, craftName) # note: this cannot end with os.sep
    aircraftExcludeFile = join(craftPath, 'zip-excludes.lst')
    excludeRegexps = regexpsForExcludedFilesAndDirs(
        globalExcludeFile, aircraftExcludeFile, verbose=verbose)

    # We'll replace '\' with '/' on Windows in order to be “compatible”
    # with the exclusion patterns, which are mandated to use '/' separators.
    replacePathSeparators = (os.sep != '/')

    printFormat = "  {:20s} {!r}" # Skipping file/dir: 'the_file_or_dir'

    for dirPath, dirNames, fileNames in os.walk(
            craftPath, onerror=_osWalkDumbErrorHandling):
        # Check for subdirectories that should be completely excluded. Iterate
        # this way because we modify the 'dirNames' list during the loop.
        i = 0
        while i < len(dirNames):
            dirName = dirNames[i]
            isExcluded, _ = make_aircraft_zip__isDirectoryExcluded(
                dirName, dirPath, repoPath, craftPath, excludeRegexps,
                replacePathSeparators, printFormat, verbose)
            if isExcluded:
                del dirNames[i] # prune this branch of the search tree
            else:
                i += 1

        # Now loop over the files
        for fileName in fileNames:
            isExcluded, relativePath = make_aircraft_zip__isFileExcluded(
                fileName, dirPath, repoPath, craftPath, excludeRegexps,
                replacePathSeparators, printFormat, verbose)
            if not (isExcluded or fileName == "zip-excludes.lst"):
                zipFileObj.write(join(dirPath, fileName), relativePath)


@dataclass
class ExcludeRegexps:
    """Container class for zip-excludes regular expressions."""
    # Regexps for exclusion patterns that can match files and directories.
    universal: frozenset[re.Pattern]
    # Regexps for exclusion patterns that can only match directories (i.e.,
    # those that end with '/' in a 'zip-excludes.lst' file)
    directoryOnly: frozenset[re.Pattern]


def make_aircraft_zip__isFileExcluded(*args):
    """Determine if a file is matched by one of the zip exclusion patterns."""
    return make_aircraft_zip__isFileOrDirectoryExcluded(
        False, "Skipping file:", *args)

def make_aircraft_zip__isDirectoryExcluded(*args):
    """
    Determine if a directory is matched by one of the zip exclusion patterns."""
    return make_aircraft_zip__isFileOrDirectoryExcluded(
        True, "Skipping directory:", *args)

def make_aircraft_zip__isFileOrDirectoryExcluded(
        isDir: bool, actionDescription: str, fileOrDirName: str,
        dirPath: str, repoPath: str, craftPath: str,
        excludeRegexps: ExcludeRegexps, replacePathSeparators: bool,
        printFormat: str, verbose: bool) -> tuple[bool, str]:
    """
    Determine if one of the zip exclusion patterns matches a file or directory.

    The file or directory is in directory 'dirPath' and its leaf name is
    'fileOrDirName'. 'isDir' tells whether it is a directory.
    'actionDescription' is a string used for reporting when the file or
    directory is excluded. 'repoPath' is a path to the repository, that
    is, the parent of the aircraft directories. 'craftPath' is the same
    with one more path component: the aircraft name. 'excludeRegexps'
    contains compiled versions of the exclusion patterns applicable to
    the aircraft being packaged. 'replacePathSeparators' is True if, and
    only if, os.sep is not '/'. 'printFormat' is an str.format() string
    used in conjunction with 'actionDescription'.

    The first component of the return value is true if at least one of
    the patterns matches the file or directory.

    """
    fileOrDir = join(dirPath, fileOrDirName)
    # Path to the file or directory, relatively to the aircraft directory.
    # This will be used for pattern matching.
    relPath = os.path.relpath(fileOrDir, start=craftPath)
    # Path to the file or directory, relatively to the repository root
    relPath_fromRepo = os.path.relpath(fileOrDir, start=repoPath)
    # The exclusion patterns use '/' as component separators; ensure the
    # paths obtained from the filesystem also do.
    if replacePathSeparators:
        relPath = relPath.replace(os.sep, '/')
    assert not relPath.startswith('./'), repr(relPath)
    assert not relPath.endswith('/'), repr(relPath)
    # Prepend './' to allow patterns like '*/Resources/' to match at top level
    relPath = './' + relPath

    # Test the patterns that apply to files as well as directories
    for regexp in excludeRegexps.universal:
        if regexp.match(relPath):
            if verbose:
                # Print “Skipping file/dir: 'the_file_or_dir'”
                print(printFormat.format(actionDescription, relPath_fromRepo))
            return True, relPath_fromRepo

    # If 'fileOrDirName' is a directory, test the patterns that only apply to
    # directories. These patterns all end with a '/'.
    if isDir:
        for regexp in excludeRegexps.directoryOnly:
            if regexp.match(relPath + '/'):
                if verbose:
                    print(printFormat.format(actionDescription,
                                             relPath_fromRepo))
                return True, relPath_fromRepo

    return False, relPath_fromRepo


def _regexpsForExcludedFilesAndDirs_addPattern(
        universalExcludes: list[re.Pattern],
        directoryOnlyExcludes: list[re.Pattern],
        pattern: str) -> None:
    """Append a compiled exclusion pattern to a list of compiled patterns."""
    compiledRegexp = re.compile(fnmatch.translate(pattern))

    if pattern.endswith('/'):
        directoryOnlyExcludes.append(compiledRegexp)
    else:
        universalExcludes.append(compiledRegexp)


def regexpsForExcludedFilesAndDirs(
        globalExcludeFile: str, aircraftExcludeFile: str, /, *,
        verbose: bool = True) -> ExcludeRegexps:
    """Return regular expression objects for all patterns in the files.

    Convert patterns in fnmatch.fnmatchcase() style to regular
    expressions because this improves performance a little bit when
    checking many files with the same patterns.

    """
    universalExcludes = list()
    directoryOnlyExcludes = list()

    # Fetch the aircraft-specific patterns, if any
    if os.path.exists(aircraftExcludeFile):
        if verbose:
            print("Found the aircraft-specific exclusion list {!r}".format(
                aircraftExcludeFile))
        for pat in readExclusionPatterns(aircraftExcludeFile):
            _regexpsForExcludedFilesAndDirs_addPattern(
                universalExcludes, directoryOnlyExcludes, pat)

    # Ditto for the global exclusion patterns
    if os.path.exists(globalExcludeFile):
        for pat in readExclusionPatterns(globalExcludeFile):
            _regexpsForExcludedFilesAndDirs_addPattern(
                universalExcludes, directoryOnlyExcludes, pat)

    return ExcludeRegexps(frozenset(universalExcludes),
                          frozenset(directoryOnlyExcludes))


# Regular expression for lines to ignore in zip exclude lists
_excludeList_blankOrCommentLine_cre = re.compile(r"^[ \t]*(#.*)?$")

def readExclusionPatterns(excludeListFile: str) -> list[str]:
    """Read all exclusion patterns contained in the specified file.

    Skip blank and comment lines. Return a list of patterns.

    """
    with open(excludeListFile, "r", encoding="utf-8") as f:
        patterns = [ line.strip() for line in f if not
                     _excludeList_blankOrCommentLine_cre.match(line) ]

    return patterns


def parse_config_file(parser=None, file_name=None):
    """Test and parse the catalog configuration file."""

    # Check for the file.
    if not access(file_name, F_OK):
        print("CatalogError: The catalog configuration file '%s' cannot be found." % file_name)
        sys.exit(1)

    # Parse the XML and return the root node.
    config = ET.parse(file_name, parser)
    return config.getroot()


def parse_template_file(parser=None, file_name=None):
    """Test and parse the catalog configuration file."""

    # Check for the file.
    if not access(file_name, F_OK):
        print("CatalogError: The catalog template file '%s' cannot be found." % file_name)
        sys.exit(1)

    # Parse the XML and return the template node.
    template = ET.parse(file_name, parser)
    template_root = template.getroot()
    return template_root.find('template')
