#!/usr/bin/python3
# encoding=utf-8
#
# Copyright © 2014-2016 Simon McVittie <smcv@debian.org>
# Copyright © 2015-2016 Alexandre Detiste <alexandre@detiste.be>
# SPDX-License-Identifier: GPL-2.0-or-later

from abc import (ABCMeta, abstractmethod)
import importlib
import os
import string
import typing


from ..data import (Package)


if typing.TYPE_CHECKING:
    from ..game import (GameData)


class RecursiveExpansionMap(dict):
    def __getitem__(self, k):
        v = super(RecursiveExpansionMap, self).__getitem__(k)
        return string.Template(v).substitute(self)


class PerPackageState(object):
    def __init__(
        self,
        package: Package,
        per_package_dir: str,
    ) -> None:
        self.package = package

        # Installed files that will end up in DEBIAN/md5sums for
        # .deb packaging, e.g.
        # { 'usr/share/games/quake3-data/baseq3/pak0.pk3': '1197ca...' }
        self.md5sums: typing.Dict[str, str] = {}

        # Component for package, possibly modified: if the license
        # for a freely redistributable game is missing, we demote it from
        # main or non-free to local (i.e. non-distributable).
        self.component: str = package.component

        self.per_package_dir = per_package_dir
        self.destdir = os.path.join(per_package_dir, 'DESTDIR')

        self.lintian_overrides = set(package.lintian_overrides)


class PackagingSystem(metaclass=ABCMeta):
    ASSETS = '$datadir'
    BINDIR = '$prefix/bin'
    DATADIR = '$prefix/share'
    DOCDIR = '$datadir/doc'
    LICENSEDIR = '$datadir/doc'
    PREFIX = '/usr'
    CHECK_CMD = None
    INSTALL_CMD = None

    # Generic tools needed to build packages
    BUILD_DEP = {}

    # Exceptions to our normal heuristic for mapping a tool to a package:
    # the executable tool 'unzip' is in the unzip package, etc.
    #
    # Only exceptions need to be listed.
    #
    # 'NotImplemented' means that this dependency is not packaged by
    # the distro.
    PACKAGE_MAP = {}

    # Exceptions to our normal heuristic for mapping an abstract package name
    # to a package:
    #
    # - the library 'libfoo.so.0' is in a package that Provides libfoo.so.0
    #   (suitable for RPM)
    # - anything else is in the obvious package name
    RENAME_PACKAGES = {}

    # Map from Debian architecture name to packaging system's architecture
    # name.
    # We use Debian architectures as our internal representation, as it
    # - has the most architectures supported
    # - differentiates 'any' from 'all'
    # - is the most tested
    # Architectures not found in this map are assumed to match 1:1.
    ARCH_DECODE = dict()

    # Map from packaging system's architecture name to Debian
    # architecture name.
    # Architectures not found in this map are assumed to match 1:1.
    ARCH_ENCODE = dict()

    def __init__(self, architecture=None):
        # Always a dpkg architecture
        self._architecture = self.ARCH_ENCODE.get(architecture, architecture)
        # Always a set of dpkg architectures
        self._foreign_architectures = set()
        # contexts to use when evaluating format- or distro-specific
        # dependencies, in order by preference
        self._contexts = ('generic',)

    def derives_from(self, context):
        return context in self._contexts

    def read_architecture(self):
        arch = os.uname()[4]
        self._architecture = { 'armv7l': 'armhf',
                               'armhfp': 'armhf',
                               'i586': 'i386',
                               'i686': 'i386',
                               'x86_64': 'amd64',
                             }.get(arch, arch)

    def get_architecture(self, archs=''):
        '''
        Return the dpkg architecture most suitable for archs, which is
        a whitespace-separated string containing dpkg architectures.
        '''

        if self._architecture is None:
            self.read_architecture()

        if archs:
            # In theory this should deal with wildcards like linux-any,
            # but it's unlikely to be relevant in practice.
            archs = archs.split()

            if self._architecture in archs or 'any' in archs:
                return self._architecture

            for arch in archs:
                if arch in self._foreign_architectures:
                    return arch

        return self._architecture

    def is_installed(self, package):
        """Return boolean: is a package with the given name installed?"""
        return (self.current_version(package) is not None)

    def is_available(self, package):
        """Return boolean: is a package with the given name available
        to apt or equivalent?
        """
        try:
            self.available_version(package)
        except:
            return False
        else:
            return True

    @abstractmethod
    def current_version(self, package):
        """Return the version number of the given package as a string,
        or None.
        """
        raise NotImplementedError

    @abstractmethod
    def available_version(self, package):
        """Return the version number of the given package available in
        apt or equivalent, or raise an exception if unavailable.
        """
        raise NotImplementedError

    @abstractmethod
    def install_packages(self, packages, method=None, gain_root='su'):
        """Install one or more packages (a list of filenames)."""
        raise NotImplementedError

    def substitute(self, template, package, **kwargs):
        if isinstance(template, dict):
            for c in self._contexts:
                if c in template:
                    template = template[c]
                    break
            else:
                return None

        if template is None:
            return template

        if '$' not in template:
            return template

        return string.Template(template).substitute(
                RecursiveExpansionMap(
                    assets=self.ASSETS,
                    bindir=self.BINDIR,
                    datadir=self.DATADIR,
                    docdir=self.DOCDIR,
                    licensedir=self.LICENSEDIR,
                    pkgdocdir=self._get_pkgdocdir(package),
                    pkglicensedir=self._get_pkglicensedir(package),
                    prefix=self.PREFIX,
                    **kwargs))

        return template

    def _get_pkgdocdir(self, package):
        return '/'.join((self.DOCDIR, package))

    def _get_pkglicensedir(self, package):
        return '/'.join((self.LICENSEDIR, package))

    def format_relations(self, relations):
        """Yield a native dependency representation for this packaging system
        for each gdp.data.PackagingRelation in relations.
        """
        for pr in relations:
            if pr.contextual:
                for c in self._contexts:
                    if c in pr.contextual:
                        for x in self.format_relations([pr.contextual[c]]):
                            yield x

                        break
            else:
                yield self.format_relation(pr)

    @abstractmethod
    def format_relation(self, pr):
        """Return a native dependency representation for this packaging system
        and the given gdp.data.PackagingRelation. It is guaranteed
        that pr.contextual is empty.
        """
        raise NotImplementedError

    def rename_package(self, dependency):
        """Given an abstract package name, return the corresponding
        package name in this packaging system.

        Abstract package names are mostly the same as for Debian,
        except that libraries are represented as libfoo.so.0.
        """
        return self.RENAME_PACKAGES.get(dependency, dependency)

    def package_for_tool(self, tool):
        """Given an executable name, return the corresponding
        package name in this packaging system.
        """
        return self.PACKAGE_MAP.get(tool, tool)

    def tool_for_package(self, package):
        """Given a package name, return the corresponding
        main/unique executable in this packaging system.
        """
        for k,v in self.PACKAGE_MAP.items():
            if v == package:
                return k
        return package

    def merge_relations(self, package, rel):
        return set(self.format_relations(package.relations[rel]))

    def generate_description(self, game, package, component=None):
        longname = package.longname or game.longname
        if component is None:
            component = package.component

        if package.short_description is not None:
            short_desc = package.short_description
        elif package.section == 'games':
            short_desc = 'game %s for %s' % (package.data_type, longname)
        else:
            short_desc = longname

        if package.long_description is not None:
            return (short_desc, package.long_description.splitlines())

        long_desc = []
        long_desc.append('This package was built using game-data-packager.')

        if component == 'local':
            long_desc.append(
                'It contains proprietary game data and must not be redistributed.')
        elif component == 'non-free':
            long_desc.append(
                'It contains proprietary game data that may be redistributed')
            long_desc.append('only under some conditions.')
        else:
            long_desc.append('It contains free game data and may be redistributed.')

        long_desc.append('')

        if package.description:
            for line in package.description.splitlines():
                long_desc.append(line.rstrip())
            long_desc.append('')

        if game.genre:
            long_desc.append(' Genre: ' + game.genre)

        if package.section == 'doc':
            long_desc.append(' Documentation: ' + longname)
        elif package.expansion_for and package.expansion_for in game.packages:
            game_name = (game.packages[package.expansion_for].longname
                         or game.longname)
            if game_name not in long_desc:
                long_desc.append(' Game: ' + game_name)
            if longname != game_name:
                long_desc.append(' Expansion: ' + longname)
        else:
            long_desc.append(' Game: ' + longname)

        copyright = package.copyright or game.copyright
        copyright = copyright.split(' ', 2)[2]
        if copyright not in long_desc:
            long_desc.append(' Published by: ' + copyright)

        engine = self.substitute(
                package.engine or game.engine,
                package.name)

        if engine and package.data_type not in ('music', 'documentation'):
            if long_desc[-1] != '':
                long_desc.append('')

            if '|' in engine:
                virtual = engine.split('|')[-1].strip()
                has_virtual = (virtual.split('-')[-1] == 'engine')
            else:
                has_virtual = False
            engine = engine.split('|')[0].split('(')[0].strip()
            if engine.startswith('gemrb'):
                engine = 'gemrb'
            if has_virtual:
                long_desc.append('Intended for use with some ' + virtual + ',')
                long_desc.append('such as for example: ' + engine)
            else:
                long_desc.append('Intended for use with: ' + engine)

        if package.used_sources:
            if long_desc[-1] != '':
                long_desc.append('')

            long_desc.append('Built from: ' + ', '.join(package.used_sources))

        return (short_desc, long_desc)

    def get_effective_architecture(self, package):
        '''
        Return an architecture in this packaging system's representation.
        '''
        arch = package.architecture
        if arch != 'all':
            arch = self.get_architecture(arch)
        return self.ARCH_DECODE.get(arch, arch)

    @abstractmethod
    def build_package(
        self,
        per_package_state: PerPackageState,
        game: 'GameData',
        destination,
        compress=True,
    ) -> str:
        """Build the .deb or equivalent in destination, and return its
        filename.

        per_package_state.per_package_dir may be used as scratch space.
        It already contains a subdirectory named DESTDIR which has been
        populated with the files to be packaged
        (so it contains DESTDIR/usr, etc.)
        """
        raise NotImplementedError

    def available_version_at_least(
        self,
        package: str,
        desired: str,
    ) -> bool:
        """Return true if the current or available version of the given
        package is at least a backport of the desired version.
        """
        # Stub implementation: we don't know how this packaging system
        # compares versions, so assume yes it is.
        return True


class NoPackaging(PackagingSystem):
    """
    A stub PackagingSystem used while checking consistency.
    """

    def __init__(self, architecture=None):
        super(NoPackaging, self).__init__(architecture=architecture)
        self._contexts = ('generic',)

    def current_version(self, package):
        return None

    def available_version(self, package):
        return None

    def install_packages(self, packages, method=None, gain_root='su'):
        pass

    def build_package(
        self,
        per_package_state,
        game,
        destination,
        compress=True,
    ):
        pass

    def format_relation(self, pr):
        assert not pr.contextual
        assert not pr.alternatives

        if pr.version is not None:
            return '%s (%s %s)' % (pr.package, pr.version_operator, pr.version)

        return pr.package

def get_packaging_system(format, distro=None, architecture=None):
    mod = 'game_data_packager.packaging.{}'.format(format)
    return importlib.import_module(mod).get_packaging_system(
        distro,
        architecture=architecture,
    )

def get_native_packaging_system():
    # lazy import when actually needed
    from ..version import (FORMAT, DISTRO)
    return get_packaging_system(FORMAT, DISTRO)
