#
# Copyright (c) nexB Inc. and others.
# http://nexb.com and https://github.com/nexB/debut/

# Copyright (c) 2018 Peter Odding
# Author: Peter Odding <peter@peterodding.com>
# URL: https://github.com/xolox/python-deb-pkg-tools

# SPDX-License-Identifier: Apache-2.0 AND MIT


from __future__ import absolute_import
from __future__ import print_function
from __future__ import unicode_literals

import re

try:
    # Python 2
    unicode = unicode  # NOQA
except NameError:  # pragma: nocover
    # Python 3
    unicode = str  # NOQA

from attr import asdict
from attr import attrs
from attr import attrib

from debut import version as dversion



"""
Parse and evaluate Debian package relationship aka. dependencies.

This module provides functions to parse and evaluate Debian package relationship
declarations as defined in `chapter 7` of the Debian policy manual.
http://www.debian.org/doc/debian-policy/ch-relationships.html#s-depsyntax
"""


# Define a compiled regular expression pattern that we will use to match
# package relationship expressions consisting of a package name followed by
# optional version and architecture restrictions.
parse_package_relationship_expression = re.compile(r'''
    # Capture all leading characters up to (but not including)
    # the first parenthesis, bracket or space.
    (?P<name> [^\(\[ ]+ )
    # Ignore any whitespace.
    \s*
    # Optionally capture version restriction inside parentheses.
    ( \( (?P<version> [^)]+ ) \) )?
    # Ignore any whitespace.
    \s*
    # Optionally capture architecture restriction inside brackets.
    ( \[ (?P<architectures> [^\]]+ ) \] )?
''', re.VERBOSE).match


ARCHITECTURE_RESTRICTIONS_MESSAGE = "Architecture constraint is not implemented."


def parse_depends(relationships):
    """
    Return an AndRelationships from a Debian package relationship declaration
    line. Raise ValueError when parsing fails.

    `relationships` is a string containing one or more comma separated package
    relationships or a list of strings with package relationships.

    The input has a form such as ``python (>= 2.6), python (<< 3)``, i.e. a
    comma-separated list of expressions.

    Each expression is a package name/versions/arch constraint or a pipe-
    separated list of alternative.
    """

    if isinstance(relationships, (str, unicode)):
        relationships = (r.strip() for r in relationships.split(',') if r.strip())

    return AndRelationships.from_relationships(*(map(parse_alternatives, relationships)))


def parse_alternatives(expression):
    """
    Return a Relationship for an expression string that contains one or more
    alternative relationships. Raise ValueError when parsing fails.

    An expression with alternative is something such as as ``python2.6 |
    python2.7.``, i.e. a list of relationship expressions separated by ``|``
    tokens.

    Each pipe-separated sub-expression is parsed with `parse_relationship()`
    """
    if '|' in expression:
        alternatives = (a.strip() for a in expression.split('|') if a.strip())
        alternatives = (parse_relationship(a) for a in alternatives)
        return OrRelationships.from_relationships(*alternatives)
    else:
        return parse_relationship(expression)


# split on operators
split_on_ops = re.compile('([<>=]+)').split


def parse_relationship(expression):
    """
    Parse an expression containing a package name and optional version and
    architecture constraints.

    :param expression: A relationship expression (a string).
    :returns: A :class:`Relationship` object.
    :raises: :exc:`~exceptions.ValueError` when parsing fails.

    This function parses relationship expressions containing a package name and
    (optionally) a version relation of the form ``python (>= 2.6)`` and/or an
    architecture restriction (refer to the Debian policy manual's documentation
    on the syntax of relationship fields for details).
    https://www.debian.org/doc/debian-policy/ch-relationships.html
    """
    try:
        pre = parse_package_relationship_expression(expression)
        name = pre.group('name')
        version = pre.group('version')
    except AttributeError:
        print('gd:', pre.groupdict())
        print('this:', repr(expression))
        raise

    # Split the architecture restrictions into a tuple of strings.
    architectures = tuple((pre.group('architectures') or '').split())

    if name and not version:
        # A package name (and optional architecture restrictions) without
        # version relation.
        return Relationship(name=name, architectures=architectures)

    else:
        # A package name (and optional architecture restrictions) followed by a
        # relationship to specific version(s) of the package.
        tokens = [t.strip() for t in split_on_ops(version) if t and t.strip()]
        if len(tokens) != 2:
            # Encountered something unexpected!
            raise ValueError(
                'Corrupt package relationship expression: Splitting operator '
                'from version resulted in more than two tokens! '
                '(expression: {e}, tokens: {t})'.format(e=expression, t=tokens)
            )
        return VersionedRelationship(
            name=name,
            architectures=architectures,
            operator=tokens[0],
            version=tokens[1])


@attrs
class AbstractRelationship(object):

    @property
    def names(self):
        """
        A set of package names (strings) in the relationship.
        """
        raise NotImplementedError

    def matches(self, name, version=None, architecture=None):
        """
        Check if the relationship matches a given package name and version.

        Return True, False or None:
          - True if the name and version match,
          - False if the name matches but not the version
          - None if the name does not match
        """
        raise NotImplementedError


@attrs
class Relationship(AbstractRelationship):
    """
    A simple package relationship referring only to the name of a package.
    """

    name = attrib()
    architectures = attrib(default=tuple())

    @property
    def names(self):
        return set([self.name])

    def matches(self, name, version=None, architecture=None):
        """
        Return True if the relationship matches a given package name ignoring
        version or None.
        """
        if self.name == name:
            # TODO: support architectures
            if self.architectures:
                raise NotImplementedError(ARCHITECTURE_RESTRICTIONS_MESSAGE)
            return True
        else:
            return None

    def __str__(self, *args, **kwargs):
        if not self.architectures:
            return self.name

        return '{name} {arches}'.format(
            name=self.name,
            arches='[{}]'.format(' '.join(self.architectures))
        )
    def to_dict(self):
        return asdict(self)


@attrs
class VersionedRelationship(Relationship):
    """
    A conditional package relationship that refers to a package and certain
    versions of that package.
    """

    name = attrib()
    operator = attrib()
    version = attrib()
    architectures = attrib(default=tuple())

    def matches(self, name, version=None, architecture=None):
        """
        Check if the relationship matches a given package name and version.
        """
        if self.name == name:
            if version:
                if self.architectures:
                    raise NotImplementedError(ARCHITECTURE_RESTRICTIONS_MESSAGE)
                # TODO: check if this is the correct order
                return dversion.eval_constraint(version, self.operator, self.version)
            else:
                return False
        else:
            return None

    def __str__(self, *args, **kwargs):

        s = '{name} ({operator} {version})'.format(
            name=self.name, operator=self.operator, version=self.version)
        if self.architectures:
            s += ' [{}]'.format(' '.join(self.architectures))

        return s


@attrs
class MultipleRelationship(AbstractRelationship):

    relationships = attrib(default=tuple())

    @classmethod
    def from_relationships(cls, *relationships):
        return cls(relationships=tuple(relationships))

    @property
    def names(self):
        """
        Get the name(s) of the packages in the relationship set.

        :returns: A set of package names (strings).
        """
        names = set()
        for relationship in self.relationships:
            names |= relationship.names
        return names

    def __iter__(self):
        return iter(self.relationships)


class OrRelationships(MultipleRelationship):
    """
    A multi relationship where one of the relationships must be satisfied.
    """

    def matches(self, name, version=None, architecture=None):
        """
        Return True if at least one of the relationships match a given package
        name and version strings.

        Return False if at least one of the relationships name are matched
        without the version or None otherwise.
        """
        matches = None
        for alternative in self.relationships:
            alternative_matches = alternative.matches(name, version, architecture)
            if alternative_matches is True:
                return True
            elif alternative_matches is False:
                # Keep looking for a match but return False if we don't find one.
                matches = False
        return matches

    def __str__(self, *args, **kwargs):
        return ' | '.join(str(r) for r in self.relationships)


@attrs
class AndRelationships(MultipleRelationship):
    """
    A multi relationship where all relationships must be satisfied.
    """

    def matches(self, name, version=None, architecture=None):
        """
        Return True if all the the relationships match a given package name and
        version strings.

        Return False if at least one relationship matches to False or None
        otherwise.
        """
        results = (r.matches(name, version) for r in self.relationships)
        matches = [r for r in results if r is not None]
        if matches:
            return all(matches)
        else:
            return None

    def __str__(self, *args, **kwargs):
        return ', '.join(str(r) for r in self.relationships)

