#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# Copyright (C) 2016-2021 Xyne
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# (version 2) as published by the Free Software Foundation.
#
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.

import calendar
import collections
import functools
import time
import urllib.error

from pyalpm import vercmp

import XCPF.common


# ---------------------------------- PkgSet ---------------------------------- #

class PkgSet():
    '''
    Sets of packages with associated operations such as unions and intersections.

    The accessors should be a dictionary of functions that accept a package as
    the sole argument and return the property identified by the key in the
    dictionary associated with that function. The required keys are:

    * name
    * version
    * groups

    '''

    def __init__(self, accessors, pkgs=None):
        self.accessors = accessors
        self.pkgs = {}
        if pkgs:
            for pkg in pkgs:
                self.pkgs[self.accessors['name'](pkg)] = pkg

    def __repr__(self):
        return f'PkgSet({repr(self.pkgs)})'

    def copy(self):
        'Copy the set.'
        return self.__class__(self)

    def clear(self):
        'Clear the set.'
        self.pkgs.clear()

    def add(self, pkg):
        'Add a package.'
        self.pkgs[self.accessors['name'](pkg)] = pkg
        return self

    def addmany(self, pkgs):
        'Add several pacakges.'
        for pkg in pkgs:
            self.add(pkg)
        return self

    def __iadd__(self, other):
        return self.addmany(other)

    def __add__(self, other):
        copy = self.copy()
        return copy.__iadd__(other)

    def remove(self, pkg):
        'Remove a package.'
        if isinstance(pkg, str):
            name = pkg
        else:
            name = self.accessors['name'](pkg)
        try:
            del self.pkgs[name]
        except KeyError:
            pass
        return self

    def removemany(self, pkgs):
        'Remove several packages.'
        for pkg in pkgs:
            self.remove(pkg)
        return self

    def __isub__(self, other):
        return self.removemany(other)

    def __sub__(self, other):
        copy = self.copy()
        return copy.__isub__(other)

    def remove_version(self, name, version):
        'Remove a specific version.'
        try:
            if self.accessors['version'](self.pkgs[name]) == version:
                del self.pkgs[name]
        except KeyError:
            pass
        return self

    def names(self):
        'Get package names.'
        return self.pkgs.keys()

    def ignore(self, names, groups):
        'Ignore packages by name or group.'
        names = set(names)
        groups = set(groups)
        ignored = set()
        for name, pkg in self.pkgs.items():
            if names and name in names:
                ignored.add(name)
            else:
                try:
                    grps = self.accessors['groups'](pkg)
                except KeyError:
                    pass
                else:
                    if groups & set(grps):
                        ignored.add(name)
        for i in ignored:
            del self.pkgs[i]
        return self

    def __and__(self, other):
        copy = self.copy()
        return copy.__iand__(other)

    def __iand__(self, other):
        delenda = set(self.pkgs) - set(other.pkgs)
        for name in delenda:
            del self.pkgs[name]
        return self

    def __or__(self, other):
        copy = self.copy()
        return copy.__ior__(other)

    def __ior__(self, other):
        self.pkgs.update(other.pkgs)
        return self

    def __contains__(self, pkg):
        if isinstance(pkg, str):
            return pkg in self.pkgs
        return self.accessors['name'](pkg) in self.pkgs

    def __iter__(self):
        yield from self.pkgs.values()

    def __len__(self):
        return len(self.pkgs)

    def __bool__(self):
        return bool(self.pkgs)


class PyalpmPkgSet(PkgSet):
    '''
    Set of pyalpm packages.
    '''

    def __init__(self, pkgs=None):
        accessors = {
            'name': lambda x: x.name,
            'version': lambda x: x.version,
            'groups': lambda x: x.groups
        }
        super().__init__(accessors, pkgs=pkgs)


class PlaceholderPkgSet(PkgSet):
    '''
    PkgSet that can be used as a placeholder where an empty set is expected and
    another PkgSet type cannot be used.
    '''

    def __init__(self, pkgs=None):
        def func(_):
            raise ValueError(f'packages cannot be assigned to {self.__class__}')
        accessors = {
            'name': func,
            'version': func,
            'groups': func
        }
        super().__init__(accessors, pkgs=pkgs)


# ---------------------------- Buildable Packages ---------------------------- #

class BuildablePkgError(Exception):
    '''Exceptions raised by methods of the BuildablePkg class.'''


class BuildablePkgSet(PkgSet):
    '''
    Sets of buildable packages.
    '''

    def __init__(self, pkgs=None):
        accessors = {
            'name': lambda x: x.pkgname(),
            'version': lambda x: x.version()
        }
        super().__init__(accessors, pkgs=pkgs)


def collect_pkgbases(pkgs):
    '''
    Collect a map of pkgbakes to their packages.
    '''
    pkgbases = {}
    for pkg in pkgs:
        try:
            pkgbases[pkg.pkgbase()].append(pkg)
        except KeyError:
            pkgbases[pkg.pkgbase()] = [pkg]
    return pkgbases


class BuildablePkgMapping(collections.abc.Mapping):
    '''
    Wrapper class to provide a mapping of BuildablePkg. This can be used in string
    interpolations for example.
    '''

    def __init__(self, pkg):
        self.pkg = pkg
        super().__init__()
        # Use the AUR RPC names as they are the most likely to be encountered by
        # regular users.
        self.map = {
            'Name':         self.pkg.pkgname,
            'PackageBase':  self.pkg.pkgbase,
            'Repository':   self.pkg.repo,
            'Version':      self.pkg.version,
            'Maintainers':  self.pkg.maintainers,
            'Depends':      self.pkg.deps,
            'MakeDepends':  self.pkg.makedeps,
            'CheckDepends': self.pkg.checkdeps,
            'LastModified': self.pkg.last_modified
        }

    def __getitem__(self, key):
        value = self.map[key]()
        if not isinstance(value, str):
            try:
                value = ' '.join(iter(value))
            except TypeError:
                pass
        return value

    def __iter__(self):
        yield from self.map

    def items(self):
        for key, value in self.map.items():
            yield key, value()

    def __len__(self):
        return len(self.map)


# TODO
# Add a method to generate PKGBUILD-downloading Bash commands which can be used
# instead of the pbget commands.
@functools.total_ordering
class BuildablePkg():
    '''
    A wrapper object around different package objects to faciliate the generation
    of build scripts.
    '''

    def __init__(self, arch):
        self.arch = arch

    def _id(self):
        return f'{self.qualified_pkgname()}-{self.version()}'

    def __eq__(self, other):
        if isinstance(other, BuildablePkg):
            return self._id() == other._id()
        else:
            return self._id() == other

    def __lt__(self, other):
        if isinstance(other, BuildablePkg):
            this_name = self.qualified_pkgname()
            other_name = other.qualified_pkgname()
            if this_name < other_name:
                return True
            if this_name == other_name:
                return (vercmp(self.version(), other.version()) < 0)
            return False
        return self._id() < other

    @staticmethod
    def buildable():
        '''
        Return a boolean indicating if this package is buildable.
        '''
        return False

    @staticmethod
    def trusted():
        '''
        Return a boolean indicating if this package should be intrinsically trusted.
        '''
        return False

    @staticmethod
    def maintainers():
        '''
        Iterate over current maintainers.
        '''
        return NotImplemented

    @staticmethod
    def pkgname():
        '''
        Return the package name.
        '''
        return NotImplemented

    @staticmethod
    def version():
        '''
        Return the package version.
        '''
        return NotImplemented

    @staticmethod
    def pkgbase():
        '''
        Return the package base.
        '''
        return NotImplemented

    @staticmethod
    def repo():
        '''
        Return the package repository or a suitable origin identifier.
        '''
        return NotImplemented

    def qualified_pkgbase(self):
        '''
        Returned the pkgbase with the repo prefix.
        '''
        return f'{self.repo()}/{self.pkgbase()}'

    def qualified_pkgname(self):
        '''
        Returned the pkgname with the repo prefix.
        '''
        return f'{self.repo()}/{self.pkgname()}'

    @staticmethod
    def last_modified():
        '''
        Return the last modification time, in UNIX format.
        '''
        return NotImplemented

    @staticmethod
    def deps():
        '''
        Iterate over runtime dependencies.
        '''
        return NotImplemented

    @staticmethod
    def makedeps():
        '''
        Iterate over build dependencies.
        '''
        return NotImplemented

    @staticmethod
    def checkdeps():
        '''
        Iterate over check dependencies.
        '''
        return NotImplemented

    def alldeps(self):
        '''
        Iterate over all dependencies.
        '''
        # pylint: disable=not-an-iterable
        yield from self.deps()
        yield from self.makedeps()
        yield from self.checkdeps()


class BuildablePkgFactory():
    '''
    Generalized class to wrap packages in a BuildablePkg class.
    '''

    @staticmethod
    def pkg(_pkg):
        '''
        Convert a package to a buildable package if possible.
        '''
        return NotImplemented

    def pkgs(self, pkgs, *args, **kwargs):
        '''
        Generator around pkg method.
        '''
        for pkg in pkgs:
            yield self.pkg(pkg, *args, **kwargs)


class OfficialBuildablePkgFactory(BuildablePkgFactory):
    '''
    Wrapper class to convert pyalpm packages to OfficialBuildablePkgs.

    arch: Target architecture.
    config: Pacman configuration file (XCPF.PacmanConfig)
    opi: XCPF.common.OfficialPkgInfo instance
    '''

    def __init__(self, arch, *args, opi=None, **kwargs):
        self.arch = arch
        if opi is None:
            opi = XCPF.common.OfficialPkgInfo(
                *args,
                arch=arch,
                **kwargs
            )
        self.opi = opi

    def pkg(self, pkg, pkginfo=None):
        return OfficialBuildablePkg(
            self.arch,
            pkg,
            pkginfo=pkginfo,
            get_pkginfo=self.opi.retrieve_pkginfo
        )


class OfficialBuildablePkg(BuildablePkg):
    '''
    Buildable package from official repos.
    '''

    def __init__(self, arch, pkg, pkginfo=None, get_pkginfo=None):
        super().__init__(arch)
        self.pkg = pkg
        self.pkginfo = pkginfo
        for repo, _, _, _ in XCPF.common.ARCHLINUX_OFFICIAL_REPOS:
            if self.pkg.db.name == repo:
                self.official = True
                break
        else:
            self.official = False
        if get_pkginfo is None:
            get_pkginfo = XCPF.common.archlinux_org_pkg_info
        self.get_pkginfo_function = get_pkginfo

    # Lazy retrieval.
    def get_pkginfo(self):
        'Get package information.'
        if self.pkginfo is None:
            try:
                self.pkginfo = self.get_pkginfo_function(
                    self.pkg.db.name, self.pkg.arch, self.pkg.name
                )
            except urllib.error.HTTPError as err:
                raise BuildablePkgError(
                    f'failed to retrieve pkginfo for {self.pkg.name}',
                    error=err
                ) from err
        return self.pkginfo

    def buildable(self):
        return self.official

    def trusted(self):
        return True

    def maintainers(self):
        try:
            yield from self.get_pkginfo()['maintainers']
        except KeyError as err:
            raise BuildablePkgError(
                f'KeyError for {self.pkg.name}',
                error=err
            ) from err

    def pkgname(self):
        return self.pkg.name

    def version(self):
        try:
            info = self.get_pkginfo()
            return f'''{info['pkgver']}-{info['pkgrel']}'''
        except KeyError as err:
            raise BuildablePkgError(
                f'KeyError for {self.pkg.name}',
                error=err
            ) from err

    def pkgbase(self):
        try:
            return self.get_pkginfo()['pkgbase']
        except KeyError as err:
            raise BuildablePkgError(
                f'KeyError for {self.pkg.name}',
                error=err
            ) from err

    def repo(self):
        return self.pkg.db.name

    def last_modified(self):
        try:
            return calendar.timegm(time.strptime(
                self.get_pkginfo()['last_update'],
                XCPF.common.ARCHLINUX_ORG_JSON_TIME_FORMAT
            ))
        except KeyError as err:
            raise BuildablePkgError(
                f'KeyError for {self.pkg.name}',
                error=err
            ) from err
        except ValueError as err:
            raise BuildablePkgError(
                f'failed to parse mtime for {self.pkg.name}',
                error=err
            ) from err

    def deps(self):
        try:
            # The pkginfo will be used for building and may be newer than the database
            # information, so prefer it.
            yield from self.get_pkginfo()['depends']
        except KeyError as err:
            raise BuildablePkgError(
                f'KeyError for {self.pkg.name}',
                error=err
            ) from err

    def makedeps(self):
        # TODO
        # Determine a way to get the makedeps. This isn't a showstopper because the
        # repo makedeps are guaranteed to be available via makepkg -irs.
        return iter([])

    def checkdeps(self):
        # TODO
        # Determine a way to get the makedeps. This isn't a showstopper because the
        # repo makedeps are guaranteed to be available via makepkg -irs.
        return iter([])


# ------------------------------ Build Ordering ------------------------------ #

class DependencyGraphNode():
    '''
    Callable dependency graph node. Each call will propagate dependency levels to
    all nodes above this one in the graph.
    '''

    def __init__(self, graph, pkg):
        self.graph = graph
        self.pkg = pkg
        self.level = -1
        graph[pkg.pkgname()] = self
        self.deps = sorted(set(
            XCPF.common.split_version_requirement(d)[0] for d in pkg.alldeps()
        ))
        self.called = False

    def __call__(self, level):
        # Prevent circular recursion and redundant calls.
        if self.called or level <= self.level:
            return
        try:
            self.called = True
            self.level = level
            for dep in self.deps:
                try:
                    self.graph[dep](level+1)
                except KeyError:
                    pass
        finally:
            self.called = False

    def __str__(self):
        return f'{self.pkg.pkgname()}:{self.level:d}'

    def __repr__(self):
        return f'DependencyGraphNode({self.__str__()})'


def determine_dependency_graph(pkgs):
    '''
    Build the dependency graph.
    '''
    graph = {}
    for pkg in pkgs:
        DependencyGraphNode(graph, pkg)
    for value in graph.values():
        value(0)
    return graph


def determine_build_order(graph):
    '''
    Determine the build order from the dependency graph.
    '''
    # Include the name in the sort key for deterministic build order.
    for node in sorted(graph.values(), key=lambda x: (x.level, x.pkg.pkgname()), reverse=True):
        yield node.pkg
