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

"""Tools for parsing PropertyList files.

Terminology:

absolute include
   An include of the form include="Aircraft/…" (leading slashes in
   the value are ignored). These includes, with the particular
   'Aircraft/' prefix, receive special treatment: see
   PropsHandler.handleInclude(). The … part in such includes is the
   “aircraft name”. In some cases, it doesn't name an aircraft: see
   Aircraft/{Generic,Instruments,Instruments-3d} in FGData.

relative include
   Any include that is not an absolute include.

"""

import collections
import enum
import os
import re

from dataclasses import dataclass
from pathlib import PurePosixPath

# SAX for parsing
from xml.sax import make_parser, handler, expatreader
# lxml for writing
import lxml.etree as ET


class error(Exception):
    """Base class for some exceptions raised by the 'sgprops' module."""
    pass

class BadAPIUsage(error):
    pass

# Unfortunately, we currently have a fair amount of code outside sgprops.py
# that expects IndexError, so derive from it rather than from 'error'.
class NoSuchChildError(IndexError):
    """
    Exception raised when Node.getChild(..., create=False) can't find the child."""
    def __init__(self, /, name, index):
        error.__init__(self, name, index)
        self.name = name
        self.index = index

    def __str__(self):
        return "no such child: name={!r}, index={}".format(self.name,
                                                           self.index)

class TooManyChildrenError(error):
    """
    Exception raised when Node.addChild() can't find a reasonable free index."""
    def __init__(self, /, name):
        error.__init__(self, name)
        self.name = name

    def __str__(self):
        return "too many children with name " + repr(self.name)

class ParseError(error):
    """Base class for exceptions raised when parsing a PropertyList file."""

    def __init__(self, /, file_, line, message):
        error.__init__(self, file_, line, message)
        self.file = file_
        self.line = line
        self.message = message

    def __str__(self):
        return "{} at {}:{}".format(self.message, self.file, self.line)

    # Alternate format, very often useful
    def altStr(self):
        return "{}:{}: {}".format(self.file, self.line, self.message)

class InvalidValueForBooleanPropertyError(ParseError):
    pass

class InvalidValueForIntegerPropertyError(ParseError):
    pass

class InvalidValueForFloatingPointPropertyError(ParseError):
    pass

class InvalidIndexStringError(ParseError):
    """
    Exception raised when the value in n="..." attributes is not an integer."""
    pass

class IncludeFileNotFoundError(ParseError):
    pass

class UnknownOrUnhandledPropertyTypeError(ParseError):
    pass

class ProblemResolvingRelativeIncludeError(ParseError):
    pass

class AbsoluteIncludeHasDotDotComponentError(ParseError):
    """
    Exception raised when we find an include="Aircraft/…" containing '..' components."""
    pass

class AbsoluteIncludeWithoutBaseDirError(ParseError):
    """
    Exception raised when we find an include="Aircraft/…" but baseDir wasn't provided."""
    pass


class Node(object):
    def __init__(self, name = '', index = 0, parent = None):
        self._parent = parent
        self._name = name
        self._value = None
        self._index = index
        self._children = []

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, v):
        self._value = v

    @property
    def name(self):
        return self._name

    @property
    def index(self):
        return self._index

    @property
    def parent(self):
        return self._parent

    def getChild(self, n, i=None, create=False):
        if i is None:
            i = 0
            # parse name as foo[999] if necessary
            m = re.match(R"(\w+)\[(\d+)\]", n)
            if m is not None:
                n = m.group(1)
                i = int(m.group(2))

        for c in self._children:
            if (c.name == n) and (c.index == i):
                return c

        if create:
            c = Node(n, i, self)
            self._children.append(c)
            return c
        else:
            raise NoSuchChildError(n, i)

    def addChild(self, n):
        # adding an existing instance
        if isinstance(n, Node):
            n._parent = self
            n._index = self.firstUnusedIndex(n.name)
            self._children.append(n)
            return n

        i = self.firstUnusedIndex(n)
        # create it via getChild
        return self.getChild(n, i, create=True)

    def firstUnusedIndex(self, name):
        assert name is not None, repr(name)

        usedIndices = frozenset(c.index for c in self.getChildren(name))

        for i in range(1000):
            if i not in usedIndices:
                 return i

        raise TooManyChildrenError(name)

    def hasChild(self, /, name):
        for c in self._children:
            if (c.name == name):
                return True

        return False

    def hasChildren(self):
        """Optimized method for testing if a node has any children."""
        return bool(self._children)

    def getChildren(self, name=None):
        if name is None:
            return self._children

        return [c for c in self._children if c.name == name]

    def getNode(self, path, create=False):
        """Get a descendant node by relative path."""
        axes = path.split('/')
        node = self
        for ax in axes:
            node = node.getChild(ax, create=create)

        return node

    def getValue(self, path, default=None):
        try:
            node = self.getNode(path)
        except IndexError:
            return default

        return node.value

    def write(self, path):
        root = self._createXMLElement('PropertyList')
        t = ET.ElementTree(root)
        t.write(path, 'utf-8', xml_declaration = True)

    def _createXMLElement(self, name=None):
        if name is None:
            name = self.name

        n = ET.Element(name)

        # value and type specification
        try:
            if self._value is not None:
                if isinstance(self._value, str):
                    # don't call str() on strings, breaks the
                    # encoding
                    n.text = self._value
                else:
                    # use str() to turn non-string types into text
                    n.text = str(self._value)
                    if isinstance(self._value, int):
                        n.set('type', 'int')
                    elif isinstance(self._value, float):
                        n.set('type', 'double')
                    elif isinstance(self._value, bool):
                        n.set('type', "bool")
        except UnicodeEncodeError:
            print("Encoding error with %s %s" % (self._value, type(self._value)))
        except Exception:     # this 'except' clause could be just removed IMHO
            print("Unexpected exception in sgprops._createXMLElement():")
            raise

        # index in parent
        if (self.index != 0):
            n.set('n', str(self.index))

        # children
        for c in self._children:
            n.append(c._createXMLElement())

        return n;


class ParseState:
    def __init__(self):
        self._counters = {}

    def getNextIndex(self, name):
        if name in self._counters:
            self._counters[name] += 1
        else:
            self._counters[name] = 0
        return self._counters[name]

    def recordExplicitIndex(self, name, index):
        if name not in self._counters:
            self._counters[name] = index
        else:
            self._counters[name] = max(self._counters[name], index)


@dataclass(order=True, frozen=True)
class ReportedInclude:
    file: str                   # XML file in which the include="..." occurs
    line: int                   # line where it occurs
    includedPath: str           # normalized form of the included path


class PropsHandlerMode(enum.Enum):
    RESILIENT, RAISE_ON_FIRST_ERROR = range(2)

class _IncludePathCheck(enum.Enum):
    ALLOWED, DENIED = range(2)

class PropsHandler(handler.ContentHandler):
    def __init__(self, root=None, path=None, includePaths=None, *,
                 mode=PropsHandlerMode.RAISE_ON_FIRST_ERROR, baseDir=None,
                 errors=None, absoluteIncludes=None, parsedFiles=None):
        """Construct an xml.sax ContentHandler for parsing PropertyList files.

        'path' indicates the PropertyList file to parse. This is used
        when creating exceptions and reporting special things found
        during parsing, as xml.sax.xmlreader.XMLReader.parse() is what
        initiates the parsing and it already receives the input file
        path as an argument.

        'root' should be a Node instance upon which the parsed tree will
        be constructed, or None in which case a nameless root node will
        be created for this purpose.

        'includePaths' indicates the list of include paths to use for
        include="…" directives. It should typically start with the same
        path as specified by the 'baseDir' parameter.

        There are two modes: RESILIENT and RAISE_ON_FIRST_ERROR. In
        resilient mode, parsing continues after non-fatal errors; they
        are collected in self.errors, which will be a list of ParseError
        instances when parsing is finished. In “raise on first error”
        mode, the first error triggers an exception (non-fatal ones are
        normally subclasses of ParseError).

        The 'baseDir' argument is (still) optional but should be
        specified whenever it makes sense. It indicates a path that
        relative includes must not escape from and is also used to
        resolve absolute includes: see handleInclude(). If you are
        parsing PropertyList files that belong to an aircraft, 'baseDir'
        should be the aircraft directory. If you are parsing
        PropertyList files that belong to FGData, it should be a path to
        FGData.

        The 'errors', 'absoluteIncludes' and 'parsedFiles' arguments
        should be left to their default value None when initiating
        parsing. Parsing uses this class recursively in order to process
        include directives. These arguments are mutable objects in
        recursive calls; they collect errors, absolute includes and
        visited files during the whole recursive parsing process.

        """
        self._root = root
        self._path = path
        # Don't confuse _basePath and _baseDir: when processing for
        # instance Aircraft/R44/Systems/r44-base.xml, _basePath is
        # 'Aircraft/R44/Systems' and _baseDir should be a path to the
        # Aircraft/R44 directory. If this r44-base.xml file contains
        # includes, they will processed by recursively instantiating this
        # class with appropriate values of _basePath and _baseDir.
        self._basePath = os.path.dirname(path)

        self._baseDir = baseDir
        if baseDir is not None:
            self._baseDir_real = os.path.realpath(baseDir)

        self._includes = [] if includePaths is None else list(includePaths)
        self._locator = None
        # The argument is a tuple containing one element: ParseState()
        self._stateStack = collections.deque((ParseState(),))

        if root is None:
            # make a nameless root node
            self._root = Node("", 0)
        self._current = self._root

        self._mode = mode

        if errors is not None:
            # A list of ParseError instances
            self.errors = errors
        else:
            self.errors = []

        if absoluteIncludes is not None:
            # A set of ReportedInclude instances
            self.absoluteIncludes = absoluteIncludes
        else:
            self.absoluteIncludes = set()

        if parsedFiles is not None:
            self.parsedFiles = parsedFiles
        else:
            self.parsedFiles = set()
        self.parsedFiles.add(os.path.realpath(path))

    def setMode(self, mode):
        # 'mode' must be a member of the PropsHandlerMode enum
        self._mode = mode

    def setDocumentLocator(self, loc):
        self._locator = loc

    def _error(self, /, exceptionType, message, *, from_=None):
        """Raise or collect a ParseError exception, or a subclass thereof.

        In RAISE_ON_FIRST_ERROR mode, raise the exception. In RESILIENT
        mode, append it to self.errors. Automatically provide the
        offending file path and line number.

        """
        assert issubclass(exceptionType, ParseError), exceptionType
        exc = exceptionType(self._path, self._locator.getLineNumber(), message)

        if self._mode is PropsHandlerMode.RAISE_ON_FIRST_ERROR:
            if from_ is None:
                raise exc
            else:
                raise exc from from_
        else:
            assert self._mode is PropsHandlerMode.RESILIENT, self._mode
            exc.__cause__ = from_
            self.errors.append(exc)

    def startElement(self, name, attrs):
        self._content = None
        if (name == 'PropertyList'):
            # still need to handle includes on the root element
            if 'include' in attrs:
                self.handleInclude(attrs['include'])
            return

        index = self.nodeIndexFromAttributes(attrs) # None if no 'n' attribute
        # Get or create a child node with appropriate name and index. This
        # can't raise TooManyChildrenError because startElement_getChild()
        # uses Node.getChild(), not addChild().
        self._current = self.startElement_getChild(name, index)
        self._stateStack.append(ParseState())

        if 'include' in attrs:
            self.handleInclude(attrs['include'])

        self._currentTy = None
        if 'type' in attrs:
            self._currentTy = attrs['type']

    def startElement_getChild(self, name, index):
        """Return an appropriate child node when processing a start tag."""
        currentState = self._stateStack[-1]

        if index is not None:
            currentState.recordExplicitIndex(name, index)
            node = self._current.getChild(name, index, create=True)
        else:
            index = currentState.getNextIndex(name)
            # Important we use getChild() here, so that includes are resolved
            # correctly.
            node = self._current.getChild(name, index, create=True)

        return node

    def nodeIndexFromAttributes(self, attrs):
        """Decode and return attrs['n'], if it exists.

        Return an integer (node index), or None if 'attrs' has no 'n'
        key.

        """
        try:
            index = int(attrs['n'])
        except KeyError:
            index = None
        except ValueError as e:
            self._error(InvalidIndexStringError,
                        "invalid index {!r}".format(attrs['n']), from_=e)
            # We are in resilient mode and just need a value to continue.
            index = 0

        return index

    def handleInclude(self, includePath):
        """Process an include="…" directive.

        Let's assume we are processing an include directive in
        A/B/foo.xml where A is a subdirectory self._baseDir. There are
        two cases:

        - Relative includes are first resolved against baseDir/A/B, then
          against each element of 'includePaths' as passed to the
          constructor ('includePaths' should usually start with _baseDir
          to mimic FG's behavior where the CurrentAircraftDirProvider
          has PRIORITY_HIGH). The search using the 'includePaths' is
          skipped for relative includes containing .. components.

        - For absolute includes of the form Aircraft/⟨name⟩/…, if ⟨name⟩
          matches the last component of _baseDir, the trailing part … is
          first resolved against _baseDir; otherwise, the whole
          Aircraft/⟨name⟩/… is resolved against _baseDir, however this
          is only the current behaviour: no guarantee, better don't rely
          on it. In both cases, if the resolved path doesn't correspond
          to an existing file, the whole Aircraft/⟨name⟩/… is then
          resolved against each element of 'includePaths'.

        The above description assumes that self._baseDir is a path. It
        is also allowed to be None, but this is a degraded mode in which
        we can't check that relative paths don't “escape” and can't
        process absolute includes, so it should be avoided whenever
        possible.

        """
        skipSearch = False
        while includePath.startswith('/'):
            includePath = includePath[1:]

        # Check if we have an absolute include
        if includePath.startswith("Aircraft/"):
            status, p = self._checkAbsoluteInclude(includePath)
            if status is _IncludePathCheck.DENIED:
                return
        else:
            # Resolve against self._basePath, check it doesn't “escape”
            status, p = self._checkRelativeInclude(
                self._baseDir, self._basePath, includePath)
            if status is _IncludePathCheck.DENIED:
                return
            elif ".." in PurePosixPath(includePath).parts:
                # Using .. components only makes sense if the path is relative
                # to the directory containing the XML file we are parsing.
                # Not skipping the search would lead to confusing error
                # messages for relative paths with .. to non-existent files.
                skipSearch = True

        baseDir = self._baseDir
        found = False

        if os.path.exists(p):
            found = True
        elif not skipSearch:
            for i in self._includes:
                status, p = self._checkRelativeInclude(i, i, includePath)
                if status is _IncludePathCheck.DENIED:
                    return
                elif os.path.exists(p):
                    baseDir = i
                    found = True
                    break

        if found:
            _readProps(p, self._current, self._includes, self._mode, baseDir,
                       self.errors, self.absoluteIncludes, self.parsedFiles)
        else:
            self._error(IncludeFileNotFoundError,
                        "include file not found: {!r}".format(includePath))

    def _checkAbsoluteInclude(self, includePath):
        if ".." in PurePosixPath(includePath).parts:
            self._error(AbsoluteIncludeHasDotDotComponentError,
                        "include path starting with 'Aircraft/' has a '..' "
                        "component: {!r}".format(includePath))
            return _IncludePathCheck.DENIED, None
        elif self._baseDir is None:
            self._error(AbsoluteIncludeWithoutBaseDirError,
                        "include path starting with 'Aircraft/', however "
                        "readProps() was called with baseDir=None: {!r}"
                        .format(includePath))
            return _IncludePathCheck.DENIED, None

        self.absoluteIncludes.add(ReportedInclude(self._path,
                                                  self._locator.getLineNumber(),
                                                  includePath))
        trimmedInclude = includePath[9:] # trim the 'Aircraft/' prefix

        s = os.path.basename(self._baseDir_real) + "/"
        if trimmedInclude.startswith(s):
            # One can reasonably assume that self._baseDir is an aircraft dir
            # and that the absolute include refers to files from this aircraft.
            relInclude = trimmedInclude[len(s):]
            p = os.path.join(self._baseDir, relInclude)
            return _IncludePathCheck.ALLOWED, p
        else:
            # The absolute include doesn't refer to the same aircraft as
            # self._baseDir (which isn't necessarily an aircraft dir). Either
            # it is an illegal cross-aircraft include, or it refers to FGData
            # files in Aircraft/Generic, Aircraft/Instruments, etc. Due to the
            # latter possibility, we don't want to skip the search using the
            # include paths. The value assigned to 'p' can be, well, discussed.
            p = os.path.join(self._baseDir, includePath)
            return _IncludePathCheck.ALLOWED, p

    def _checkRelativeInclude(self, baseDir, startingPoint, relPath):
        """Check that startingPoint/relPath isn't above baseDir.

        baseDir is typically an aircraft directory or FGData; we don't
        want a relative include to reach above it. If baseDir is A and
        we are processing an include directive in A/B/foo.xml,
        startingPoint is normally A/B.

        """
        if baseDir is None: # bypass checks, maybe this should be BadAPIUsage
            resolved = os.path.join(startingPoint, relPath)
            return _IncludePathCheck.ALLOWED, resolved

        resolved = os.path.join(startingPoint, relPath)
        try:
            base = os.path.realpath(baseDir)
            resolved_real = os.path.realpath(resolved)
        except OSError as e:    # symlinks may cause this
            self._error(ProblemResolvingRelativeIncludeError,
                        "can't resolve relative include: {}".format(e), from_=e)
            return _IncludePathCheck.DENIED, None

        if os.path.commonpath((base, resolved_real)) == base:
            # The resolved path is under the expected base; that's fine.
            return _IncludePathCheck.ALLOWED, resolved
        else:
            self._error(ProblemResolvingRelativeIncludeError,
                        "relative include reaches above base dir: {!r}"
                        .format(relPath))
            return _IncludePathCheck.DENIED, None

    def endElement(self, name):
        if (name == 'PropertyList'):
            return

        if not (self._current.hasChildren() or self._currentTy == "alias"):
            self._parseElementContentsAsPropertyNodeValue()

        self._current = self._current.parent
        self._content = None
        self._currentTy = None
        self._stateStack.pop()

    def _parseElementContentsAsPropertyNodeValue(self):
        """Parse the contents of the current element as a prop node value.

        The value is read from self._content (string or None). It is
        converted to a particular type based on the declared type
        self._currentTy and stored in self._current.value.

        """
        self._current.value = self._content

        if self._currentTy == "string":
            pass
        elif self._currentTy in ("int", "long"):
            self._current.value = self.parseIntegerProperty(self._content)
        elif self._currentTy == "bool":
            self._current.value = self.parsePropsBool(self._content)
        elif self._currentTy in ("float", "double"):
            self._current.value = self.parseFloatingPointProperty(self._content)
        elif self._currentTy in ("unspecified", None):
            # TODO: SGPropertyNode::setUnspecifiedValue() interprets the
            # element body (string) in function of the existing node type to
            # set the node value.
            pass
        elif self._currentTy in ("vec3d", "vec4d"):
            pass                # TODO: actual handling?
        else:
            self._error(UnknownOrUnhandledPropertyTypeError,
                        "unknown or unhandled property type: {!r}".format(
                            self._currentTy))
            # resilient mode: self._current.value is already set, keep it as is

    _decimalDigits_cre = re.compile(r"^[0-9]+$")

    def parsePropsBool(self, content):
        if content is None:     # such is the body of empty elements...
            content = ""
        assert isinstance(content, str), repr(content)
        strippedContent = content.strip() # this will be good for messages
        content = strippedContent.lower()

        if content == "true":
            return True
        elif content == "false":
            return False
        elif self._decimalDigits_cre.match(content): # no sign, no underscores...
            icontent = int(content)
            return icontent != 0
        else:
            self._error(InvalidValueForBooleanPropertyError,
                        "invalid value for boolean property: {!r}"
                        .format(strippedContent))
            return False        # resilient mode

    @classmethod
    def _sanitizeElementBody(cls, content):
        if content is None:     # such is the body of empty elements...
            content = ""
        assert isinstance(content, str), repr(content)
        return content.strip()

    _integerProperty_cre = re.compile(r"^[+-]?[0-9]+$")

    def parseIntegerProperty(self, content):
        content = self._sanitizeElementBody(content)

        if self._integerProperty_cre.match(content):
            return int(content)
        else:
            self._error(InvalidValueForIntegerPropertyError,
                        "invalid value for 'int' or 'long' property: {!r}"
                        .format(content))
            return 0

    def parseFloatingPointProperty(self, content):
        content = self._sanitizeElementBody(content)
        if content.endswith('f'):
            content = content[:-1]

        try:
            return float(content)
        except ValueError as e:
            self._error(
                InvalidValueForFloatingPointPropertyError,
                "invalid value for 'float' or 'double' property: {!r}"
                .format(content), from_=e)
            return 0.0

    def characters(self, content):
        if self._content is None:
            self._content = ''
        self._content += content

    def endDocument(self):
        pass

    @property
    def root(self):
        return self._root


@dataclass
class ReadPropsResult:
    """Result type for readProps()."""
    node: Node                  # root node of the parsed PropertyList file
    errors: list[ParseError]    # list of collected errors
    absoluteIncludes: set[ReportedInclude] # those of the form 'Aircraft/...'
    # A record of all visited files. Each element is a return value from
    # os.path.realpath().
    parsedFiles: set[str]


def readProps(path, /, root=None, includePaths=None, *,
              mode=PropsHandlerMode.RAISE_ON_FIRST_ERROR, baseDir=None):
    """Parse a PropertyList file.

    Return a ReadPropsResult instance. See the PropsHandler class for
    the meaning of the parameters.

    """
    handler = _readProps(path, root, includePaths, mode, baseDir, None, None,
                         None)
    return ReadPropsResult(handler.root, handler.errors,
                           handler.absoluteIncludes, handler.parsedFiles)


# Internal function. It preserves the PropsHandlerMode and passes along
# the collected errors, absolute includes and visited files when
# PropsHandler.handleInclude() recursively calls back here.
def _readProps(path, root, includePaths, mode, baseDir, errors,
               absoluteIncludes, parsedFiles):
    parser = make_parser()
    locator = expatreader.ExpatLocator( parser )
    h = PropsHandler(root, path, includePaths, mode=mode, baseDir=baseDir,
                     errors=errors, absoluteIncludes=absoluteIncludes,
                     parsedFiles=parsedFiles)
    h.setDocumentLocator(locator)
    parser.setContentHandler(h)
    parser.parse(path)
    return h


def copy(src, dest):
    dest.value = src.value

    # recurse over children
    for c in src.getChildren() :
        dc = dest.getChild(c.name, i = c.index, create = True)
        copy(c, dc)
