# SPDX-FileCopyrightText: 2026 Florent Rougon
# SPDX-License-Identifier: GPL-2.0-or-later

"""Check directory trees containing PropertyList files."""

import enum
import lxml
import os
import pathlib
import xml

from dataclasses import dataclass

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


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


def containsSetXmlFile(dir_):
    """Return True if the specified directory contains a -set.xml file."""
    with os.scandir(dir_) as it:
        return any( entry.name.endswith('-set.xml') for entry in it )

def osWalkDumbErrorHandling(exception):
    raise exception

class VerboseLevel(enum.Enum):
    QUIET, DEFAULT, HIGH = range(3)


@dataclass(order=True, frozen=True)
class ErrorRecord:
    """Class holding info about an error we found.

    This is hashable to allow easy filtering of duplicate errors, and
    sortable for nicely sorting of error messages by file, line and
    message.

    """
    # 'file' tells where the error physically occurs; it will be
    # relative if user input, regarding which files to check, was
    # relative.
    file: str
    line: int
    message: str


class CheckerBase:
    """Class for checking PropertyList files and directory trees thereof.

    The actual checking work is done by sgprops.readProps(). This class
    provides helper functions for iterating over directory trees and
    nicely showing errors found, and this without duplicates despite
    those that would normally occur because of PropertyList includes.

    """
    def __init__(self, verboseLevel, alreadyParsed=None, errorCollection=None):
        self.verboseLevel = verboseLevel

        # A set of return values from os.path.realpath()
        if alreadyParsed is None:
            self.alreadyParsed = set()
        else:
            self.alreadyParsed = alreadyParsed

        # A dict[ErrorRecord, str]
        if errorCollection is None:
            self.errorCollection = dict()
        else:
            self.errorCollection = errorCollection

    def resetErrorCollection(self):
        self.errorCollection = dict()

    def resetState(self):
        self.alreadyParsed = set()
        self.errorCollection = dict()

    def _isQuiet(self):
        return self.verboseLevel.value <= VerboseLevel.QUIET.value

    def _isVerbose(self):
        return self.verboseLevel.value >= VerboseLevel.HIGH.value

    def collectErrors(self, newExceptions, origin, /):
        """Collect errors in a convenient format in 'self.errorCollection'.

        Return True if there was at least one error.

        """
        for exc in newExceptions:
            assert isinstance(exc, sgprops.ParseError), repr(exc)
            error = ErrorRecord(exc.file, exc.line, exc.altStr())
            # This prevents duplicate errors. Besides, sorting by the dictionary
            # keys sorts by file first, then line number, then error message.
            # Replace the origin of the previous identical error, if any.
            self.errorCollection[error] = origin

        return bool(newExceptions)

    def checkXMLFile(self, file_, includePaths, /, *, baseDir=None):
        """Workhorse function for checking a PropertyList file."""
        if os.path.realpath(file_) in self.alreadyParsed:
            return True
        res = True

        if isPropertyListFile(file_, self._isQuiet()):
            try:
                r = sgprops.readProps(file_, includePaths=includePaths,
                                      mode=PropsHandlerMode.RESILIENT,
                                      baseDir=baseDir)
            except xml.sax._exceptions.SAXParseException as e:
                print(e)
                res = False
            else:
                self.alreadyParsed |= r.parsedFiles
                if self.collectErrors(r.errors, file_):
                    res = False
                elif self._isVerbose():
                    print("{}: OK".format(file_))
        elif self._isVerbose():
            print("{}: skipped because it doesn't appear to be in "
                  "PropertyList format".format(file_))

        return res

    def printErrors(self):
        if self._isQuiet():
            return

        for error in sorted(self.errorCollection.keys()):
            origin = self.errorCollection[error]
            if error.file == origin:
                print(error.message)
            else:
                print(f"# Reached from {origin!r}\n{error.message}")


    def _ordinaryDirCheck(self, dir_, includePaths, baseDir):
        """Recursively check an ordinary directory tree (not FGData)."""
        success = True

        for dirPath, dirs, files in os.walk(dir_,
                                            onerror=osWalkDumbErrorHandling):
            for file_ in files:
                if file_.lower().endswith(".xml"):
                    pathToFile = os.path.join(dirPath, file_)
                    if not self.checkXMLFile(pathToFile, includePaths,
                                             baseDir=baseDir):
                        success = False

        return success

    def _checkDirsGenericDriver(self, dirs, includePaths, oneDirCheckFunction):
        """Generic function for checking multiple directories independently.

        includePaths        -- list of paths for processing PropertyList
                               include directives
        dirs                -- directories to check
        oneDirCheckFunction -- callback function invoked for each directory
                               in 'dirs'

        'oneDirCheckFunction' is a function that should perform the
        actual checking; it is expected to return a boolean, True
        indicating success for the checked directory. The second
        argument passed to it will be a list of include paths that
        contains the directory to check.

        This driver resets the instance state (list of already parsed
        files and encountered errors) before checking each element of
        'dirs'.

        Return a suitable process exit status corresponding to the
        overall result of the checks:
          - 0 if the checks passed for all directories;
          - 1 if at least one of the checks failed;
          - 2 if we aborted because an element of 'dirs' is not an existing
            directory.

        """
        success = True
        firstDir = True

        for dir_ in dirs:
            self.resetState() # the directories are checked independently

            if not os.path.isdir(dir_):
                print(f"{dir_!r}: not a directory, {PROGNAME} will exit")
                return 2

            tmpIncludes = includePaths.copy()
            # If dir_ is not in the list of include paths, prepend it
            for d in tmpIncludes:
                if os.path.samefile(d, dir_):
                    break
            else:
                tmpIncludes.insert(0, dir_)

            if self._isVerbose():
                if not firstDir: print() # separator line between dirs
                print("Entering directory {!r}...".format(dir_))

            if not oneDirCheckFunction(dir_, tmpIncludes):
                success = False

            firstDir = False

        return 0 if success else 1


class SimpleDirectoryChecker(CheckerBase):
    """Class for checking PropertyList files in directory trees.

    The directory trees are checked independently and each of them is
    treated uniformly. In particular, there is no special handling for
    any 'Aircraft' subdirectories that might exist.

    """
    def __init__(self, verboseLevel, alreadyParsed=None, errorCollection=None):
        CheckerBase.__init__(self, verboseLevel, alreadyParsed, errorCollection)

    def checkAll(self, directories, includePaths):
        """
        Recursively check PropertyList files in the specified directory trees.

        Each directory is temporarily prepended to the list of include
        paths for the duration of its check unless it is already present
        therein.

        """
        return self._checkDirsGenericDriver(directories, includePaths,
                                            self.checkOneDir)

    # Callback function for CheckerBase._checkDirsGenericDriver()
    def checkOneDir(self, dir_, includePaths):
        # The baseDir (third argument) is set to dir_, the directory to check.
        res = self._ordinaryDirCheck(dir_, includePaths, dir_)
        self.printErrors()
        return res


class FGDataDirectoryChecker(CheckerBase):
    """Class for checking a directory that is supposed to be an FGData.

    FGData is special because of some aircraft directories it may have
    in the top-level 'Aircraft' directory. When checking files in these
    subdirectories, the list of include paths is made to start with the
    aircraft directory and sgprops.readProps() is called with the
    aircraft directory as its 'baseDir'.

    """
    def __init__(self, verboseLevel, alreadyParsed=None, errorCollection=None):
        CheckerBase.__init__(self, verboseLevel, alreadyParsed, errorCollection)

    def checkAll(self, directories, includePaths):
        """
        Recursively check PropertyList files in the specified FGData trees.

        Each directory is temporarily prepended to the list of include
        paths for the duration of its check unless it is already present
        therein. This is done before the additional prepending for
        aircraft directories under the top-level 'Aircraft' directory.

        """
        return self._checkDirsGenericDriver(directories, includePaths,
                                            self.checkOneDir)

    # Callback function for CheckerBase._checkDirsGenericDriver()
    def checkOneDir(self, root, includePaths):
        """Recursively check a directory that is supposed to be an FGData.

        'root' is a path to the base directory of the FGData to check.
        It is assumed to be in 'includePaths'.

        """
        success = True

        for dirPath, dirs, files in os.walk(root,
                                            onerror=osWalkDumbErrorHandling):
            if dirPath == root:     # we are in the base directory of FGData
                if ".git" in dirs:
                    dirs.remove(".git")
                # The top-level 'Aircraft' directory will be handled separately
                if "Aircraft" in dirs:
                    dirs.remove("Aircraft")

            for file_ in files:
                if file_.lower().endswith(".xml"):
                    pathToFile = os.path.join(dirPath, file_)
                    if not self.checkXMLFile(pathToFile, includePaths,
                                             baseDir=root):
                        success = False

        # Check the contents of the top-level 'Aircraft' subdirectory, if any.
        if not self._AircraftDirCheck(root, includePaths):
            success = False

        self.printErrors()
        return success

    def _AircraftDirCheck(self, root, includePaths):
        """Recursively check the top-level 'Aircraft' subdirectory of FGData.

        'root' is a path to the base directory of the FGData being checked.
        It is assumed to be in 'includePaths'.

        """
        success = True
        aircraftTopLevelDir = os.path.join(root, "Aircraft")

        if not os.path.isdir(aircraftTopLevelDir):
            return success

        with os.scandir(aircraftTopLevelDir) as it:
            for entry in it:
                if entry.is_file() and entry.name.lower().endswith(".xml"):
                    if not self.checkXMLFile(entry.path, includePaths,
                                             baseDir=root):
                        success = False
                elif entry.is_dir(): # subdir of the 'Aircraft' top-level dir
                    includes = includePaths.copy()
                    if containsSetXmlFile(entry.path): # is it an aircraft dir?
                        includes.insert(0, entry.path)
                        baseDir = entry.path
                    else:
                        # The entry is a priori not an aircraft dir.
                        baseDir = root

                    if not self._ordinaryDirCheck(entry.path, includes,
                                                  baseDir):
                        success = False

        return success

    def printErrors(self):
        """Override for CheckerBase.printErrors().

        When checking a whole FGData, the “Reached from” comments of
        CheckerBase.printErrors() are disturbing and normally unneeded
        because each printed error must originate from an FGData file
        (an FGData is supposed to be self-contained). Put this in
        contrast with the situation where one checks for instance an
        add-on directory. Errors could then be “imported” from FGData
        files; there, the “Reached from” comments would help.

        """
        if self._isQuiet():
            return

        for error in sorted(self.errorCollection.keys()):
            print(error.message)


class FGDataFileSetChecker(CheckerBase):
    """Class for checking a set of files inside an FGData tree.

    FGData is special for reasons explained in the documentation of the
    FGDataDirectoryChecker class. This class' check() method takes a
    list of paths to files relative to the FGData root, contrary to
    FGDataDirectoryChecker.checkAll() which only takes the root path and
    checks all XML files below it. The check() method of this class is
    suitable for implementing a `pre-commit`_ hook.

    .. _pre-commit: https://pre-commit.com/

    """
    def __init__(self, verboseLevel, alreadyParsed=None, errorCollection=None):
        CheckerBase.__init__(self, verboseLevel, alreadyParsed, errorCollection)

    def check(self, root, files, includePaths):
        """Check the specified FGData XML files for PropertyList conformance.

        root         -- base directory of an FGData tree
        files        -- iterable of paths to XML files, relative to 'root'
                        (important!). Each element must start with a
                        real file or directory name so that we can
                        efficiently recognize 'Aircraft' in the first
                        component (don't start with ./ or ../, etc.).
        includePaths -- list of paths for processing PropertyList
                        include directives

        First, 'root' is prepended to the list of include paths unless
        it is already the first element. Then each of the specified
        files is checked. When checking a file under 'Aircraft/d' where
        'Aircraft' is a subdirectory of 'root' and 'd' an aircraft
        directory, 'Aircraft/d' is temporarily prepended to the list of
        include paths.

        Return 0 if all checks passed, 1 otherwise.

        """
        success = True
        baseIncludes = includePaths.copy()

        # Prepend the root of FGData if necessary
        if not (baseIncludes and os.path.samefile(baseIncludes[0], root)):
            baseIncludes.insert(0, root)

        for file_ in files:
            # We'll show errors in file_ immediately after checking it; no
            # need to accumulate errors from several files.
            self.resetErrorCollection()
            parts = pathlib.PurePath(file_).parts # split the components

            if len(parts) > 2 and parts[0] == "Aircraft":
                d = os.path.join(parts[0], parts[1])
                if containsSetXmlFile(d): # is 'd' an aircraft dir?
                    # Prepend it to the list of include paths and use it as
                    # baseDir.
                    includes = baseIncludes.copy()
                    includes.insert(0, d)
                    if not self.checkXMLFile(file_, includes, baseDir=d):
                        success = False
            elif not self.checkXMLFile(file_, baseIncludes, baseDir=root):
                success = False

            self.printErrors(file_)

        return 0 if success else 1

    def printErrors(self, file_):
        """Print errors located in the specified file.

        file_ -- a file that has been explicitly checked by self.check()

        Print detailed errors only for those that are physically located
        in file_. Since files checked by a pre-commit hook can be spread
        among several invocations of the hook that are run in parallel
        (default pre-commit behaviour), thus being taken care of by
        instances of this class that live in distinct Python processes,
        this is the only realistic way to avoid printing duplicate
        errors.

        Also report, without details, if file_ has errors originating in
        files it includes (directly or indirectly); otherwise, if the
        pre-commit hook were run on a file having no error in itself but
        including other files that have errors, pre-commit would report
        a failure (because we want to detect these cases) and users
        couldn't easily see why.

        """
        if self._isQuiet():
            return

        externalError = None

        for error in sorted(self.errorCollection.keys()):
            # 'error.file' is the file where the error physically occurs.
            if error.file == file_: # no need for os.path.samefile()
                print(error.message)
            else:
                # No need to keep more than one such tuple
                externalError = (error.file, error.line)

        if not (externalError is None or self._isQuiet()):
            # The erroneous file is directly or indirectly included from file_
            print("{}: indirect error from {}:{}".format(
                file_, externalError[0], externalError[1]))
