#!/usr/bin/python3

'''A module to handle Debian Pure Blends tasks, modelled after apt.package.

The examples use the following sample tasks file:

>>> sample_task = """Format: https://blends.debian.org/blends/1.1
... Task: Education
... Install: true
... Description: Educational astronomy applications
...  Various applications that can be used to teach astronomy.
...  .
...  This is however incomplete.
...
... Recommends: celestia-gnome | celestia-glut, starplot
...
... Recommends: gravit
... WNPP: 743379
... Homepage: http://gravit.slowchop.com/
... Pkg-Description: Visually stunning gravity simulator
...  Gravit is a free, visually stunning gravity simulator.
...  .
...  You can spend endless time experimenting with various
...  configurations of simulated universes.
... Why: Useful package
... Remark: Entered Debian in 2014
...
... Suggests: sunclock, xtide
... """
>>> with open('education', 'w') as fp:
...     nbytes = fp.write(sample_task)
'''
import io
import os
import itertools
import re
import shutil
from debian.deb822 import Deb822

class Blend:
    '''Representation of a Debian Pure Blend.
    '''
    def __init__(self, basedir = '.', suppress_empty = True):
        with open(os.path.join(basedir, 'debian', 'control.stub'), encoding="UTF-8") as fp:
            self.control_stub = Deb822List(Deb822.iter_paragraphs(fp))
        self.short_name = self.control_stub[0]['Source'].split('-', 1)[-1]
        self.name = self.control_stub[0]['Source']
        self.title = 'Debian ' + self.short_name.capitalize()
        if len(self.control_stub) > 1:
            self.prefix = self.control_stub[1]['Package'].split('-', 1)[0]
        else:
            self.prefix = self.short_name

        base_deps = [
            "${misc:Depends}",
            "{}-tasks (= ${{source:Version}})".format(self.prefix),
        ]
        try:
            with open(os.path.join(basedir, 'config', 'control'), encoding="UTF-8") as fp:
                self.control_stub.append(Deb822(fp))
                base_deps.append("{}-config (= ${{source:Version}})"
                                 .format(self.prefix))
        except IOError:
            pass

        self.tasks = []
        self.suppress_empty = suppress_empty
        for name in sorted(filter(lambda n: n[-1] != '~',
                                  os.listdir(os.path.join(basedir, 'tasks')))):
            with open(os.path.join(basedir, 'tasks', name), encoding="UTF-8") as fp:
                task = Task(self.name, self.prefix,  name, fp,
                            base_deps = base_deps)
            self.tasks.append(task)

    def update(self, cache):
        '''Update from cache

        :param cache: ``apt.Cache`` like object

        This adds the available versions to all dependencies. It
        updates descriptions, summaries etc. available to all
        BaseDependencies in all tasks.

        Instead of using ``update()``, also the ``+=`` operator can be used.

        '''
        for task in self.tasks:
            task += cache

    def __iadd__(self, cache):
        self.update(cache)
        return self

    def fix_dependencies(self):
        '''Fix the dependencies according to available packages

        This lowers all unavailable ``recommended`` dependencies to
        ``suggested``.
        '''
        missing = []
        for task in self.tasks:
            missing += task.fix_dependencies()
        return missing

    @property
    def control(self):
        '''Return the task as list of ``Deb822`` objects suitable for
        ``debian/control``
        '''
        tasks = list(filter(lambda task: task.is_metapackage
                            and (not self.suppress_empty
                                 or len(task.recommends) > 0
                                 or len(task.dependencies) > 0),
                            self.tasks))

        # Create the special 'all' task recommending all tasks that
        # shall be installed by default
        all_task = Task(
            self.name, self.prefix, "all",
            '''Description: Default selection of tasks for {task.title}
            This package is part of the {task.title} Pure Blend and installs all
            tasks for a default installation of this blend.'''.format(task=self),
            base_deps = ['${misc:Depends}'])
        for task in tasks:
            if task.install:
                all_task.recommends.append(Dependency("Recommends",
                                                      task.package_name))
            else:
                all_task.suggests.append(Dependency("Suggests",
                                                    task.package_name))
        if len(all_task.recommends) > 0:
            tasks.insert(0, all_task)

        return Deb822List(self.control_stub + [ task.control for task in tasks ])

    @property
    def task_desc(self):
        '''Return the task as list of ``Deb822`` objects suitable for
        ``blends-task.desc``
        '''
        tasks = list(filter(lambda task:
                            task.is_metapackage
                            and task.is_leaf
                            and (not self.suppress_empty
                                 or len(task.recommends) > 0
                                 or len(task.dependencies) > 0),
                            self.tasks))

        header = Deb822({
            'Task': self.name,
            'Relevance':  '7',
            'Section': self.name,
            'Description': '{} Pure Blend\n .'.format(self.title),
        })
        return Deb822List([ header ] + [ task.task_desc for task in tasks ])

    @property
    def udeb_task_desc(self):
        '''Return the task as list of ``Deb822`` objects suitable for
        ``blends-task.desc`` in case the blend uses udebs
        '''
        tasks = list(filter(lambda task:
                            task.is_metapackage
                            and task.is_leaf
                            and (not self.suppress_empty
                                 or len(task.recommends) > 0
                                 or len(task.dependencies) > 0),
                            self.tasks))
        return Deb822List([ task.udeb_task_desc for task in tasks ])


class Task:
    '''Representation of a Blends task. Modelled after apt.package.Version.

    The Version class contains all information related to a
    specific package version of a blends task.

    :param blend: Name of the blend

    :param prefix: Prefix for the package name

    :param name: Name of the task

    :param sequence: ``str`` or ``file`` containing the ``Deb822``
                     description of the task

    :param base_deps: List of dependencies to add to the task (``str``)

    When the header does not contain a line

    ``Format: https://blends.debian.org/blends/1.1``

    then the priorities will be lowered when read:

    * ``Recommends`` --> ``Suggests``
    * ``Depends`` --> ``Recommends``

    Example:

    >>> with open('education') as fp:
    ...     task = Task('debian-astro', 'astro', 'education', fp)
    >>> print(task.name)
    education
    >>> print(task.package_name)
    astro-education
    >>> print(task.description)
    Various applications that can be used to teach astronomy.
    <BLANKLINE>
    This is however incomplete.
    >>> print(task.summary)
    Educational astronomy applications
    >>> print(task.section)
    metapackages
    >>> print(task.architecture)
    all
    >>> for p in task.all:
    ...     print(p.name)
    celestia-gnome
    celestia-glut
    starplot
    gravit
    sunclock
    xtide

    '''
    def __init__(self, blend, prefix, name, sequence, base_deps = None):
        self.blend = blend
        self.prefix = prefix
        self.name = name
        self.content = Deb822List(Deb822.iter_paragraphs(sequence))
        self.header = self.content[0]
        self.base_deps = base_deps or []

        # Check for the format version, and upgrade if not actual
        self.format_upgraded = False
        if 'Format' in self.header:
            self.format_version = self.header['Format'].strip().rsplit('/', 1)[-1]
        else:
            self.format_version = '1'
        if self.format_version.split('.') < ['1', '1']:
            self.content = Task.upgrade_from_1_0(self.content)
            self.format_upgraded = True

        dep_types = ["Depends", "Recommends", "Suggests"]
        dep_attrs = [ "dependencies", "recommends", "suggests" ]
        for dep_type, dep_attr in zip(dep_types, dep_attrs):
            setattr(self, dep_attr, list(itertools.chain(
                *(list(Dependency(dep_type, s.strip(), par)
                       for s in par.get(dep_type, '').split(",") if s)
                  for par in self.content[1:]))))

    @property
    def install(self):
        '''``True`` if the task is installed as a default package
        '''
        return self.header.get("Install") == "true"

    @property
    def index(self):
        '''``True`` if the task shall appear in the tasks index in the
        web senitel
        '''
        return self.header.get("index", "true") == "true"

    @property
    def is_leaf(self):
        return self.header.get("leaf", "true") == "true"

    @property
    def is_metapackage(self):
        '''``True`` if the tasks has a Debian metapackage
        '''
        return self.header.get("metapackage", "true") == "true"

    @property
    def package_name(self):
        return '{task.prefix}-{task.name}'.format(task=self)

    @property
    def description(self):
        '''Return the formatted long description.
        '''
        desc = self.header.get("Pkg-Description",
                                self.header.get("Description"))
        if not desc:
            return None
        else:
            return "\n".join(l[1:] if l != ' .' else ''
                             for l in desc.split("\n")[1:])

    @property
    def summary(self):
        '''Return the short description (one line summary).
        '''
        desc = self.header.get("Pkg-Description",
                                self.header.get("Description"))
        return desc.split('\n')[0] if desc else None

    @property
    def section(self):
        '''Return the section of the package.
        '''
        return 'metapackages'

    @property
    def architecture(self):
        '''Return the architecture of the package version.
        '''
        return self.header.get('Architecture', 'all')

    @property
    def enhances(self):
        '''Return the task name this task is an enhancement of,
        or `None`.
        '''
        return self.header.get('Enhances')

    @property
    def tests(self):
        '''Return all tests for this task when included in tasksel
        '''
        tests = dict((key.split('-', 1)[1], value)
                     for key, value in self.header.items()
                     if key.startswith('Test-'))
        if self.install:
            tests['new-install'] = 'mark show'
        return tests

    @property
    def all(self):
        '''All Base Dependencies of this task
        '''
        return list(itertools.chain(
            *itertools.chain(self.dependencies,
                             self.recommends,
                             self.suggests)))

    @property
    def control(self):
        '''Return the task as ``Deb822`` object suitable for ``debian/control``

        >>> with open('education') as fp:
        ...     task = Task('debian-astro', 'astro', 'education', fp)
        >>> print(task.control.dump())
        Package: astro-education
        Section: metapackages
        Architecture: all
        Recommends: celestia-gnome | celestia-glut,
                    gravit,
                    starplot
        Suggests: sunclock,
                  xtide
        Description: Educational astronomy applications
         Various applications that can be used to teach astronomy.
         .
         This is however incomplete.
        <BLANKLINE>
        '''
        d = Deb822()
        d['Package'] = self.package_name
        d['Section'] = self.section
        d['Architecture'] = self.architecture
        if self.dependencies or self.base_deps:
            d['Depends'] = ",\n         ".join(sorted(
                self.base_deps
                + list(set(d.rawstr for d in self.dependencies))
            ))
        if self.recommends:
            d['Recommends'] = ",\n            ".join(sorted(
                set(d.rawstr for d in self.recommends)
            ))
        if self.suggests:
            d['Suggests'] = ",\n          ".join(sorted(
                set(d.rawstr for d in self.suggests)
            ))
        d['Description'] = self.summary + '\n ' + \
            "\n ".join(self.description.replace("\n\n", "\n.\n").split("\n"))
        return d

    @property
    def task_desc(self):
        '''Return the task as ``Deb822`` object suitable for ``blends-task.desc``.

        >>> with open('education') as fp:
        ...     task = Task('debian-astro', 'astro', 'education', fp)
        >>> print(task.task_desc.dump())
        Task: astro-education
        Parent: debian-astro
        Section: debian-astro
        Description: Educational astronomy applications
         Various applications that can be used to teach astronomy.
         .
         This is however incomplete.
        Test-new-install: mark show
        Key:
         astro-education
        <BLANKLINE>
        '''
        return self._get_task_desc(udeb = False)

    @property
    def udeb_task_desc(self):
        '''Return the task as ``Deb822`` object suitable for ``blends-task.desc``
        in case the blend uses udebs

        >>> with open('education') as fp:
        ...     task = Task('debian-astro', 'astro', 'education', fp)
        >>> print(task.udeb_task_desc.dump())
        Task: astro-education
        Section: debian-astro
        Description: Educational astronomy applications
         Various applications that can be used to teach astronomy.
         .
         This is however incomplete.
        Relevance: 10
        Test-new-install: mark show
        Key:
         astro-education
        Packages: list
         celestia-glut
         celestia-gnome
         gravit
         starplot
        <BLANKLINE>
        '''
        return self._get_task_desc(udeb = True)

    def _get_task_desc(self, udeb = False):
        d = Deb822()
        d['Task'] = self.package_name
        if not udeb:
            d['Parent'] = self.blend
        d['Section'] = self.blend
        d['Description'] = self.summary + '\n ' + \
          "\n ".join(self.description.replace("\n\n", "\n.\n").split("\n"))
        if udeb:
            d['Relevance'] = '10'
        if self.enhances:
            d['Enhances'] = self.enhances
        for key, value in self.tests.items():
            d['Test-' + key] = value
        d['Key'] = '\n {}'.format(self.package_name)
        if udeb:
            d['Packages'] = 'list\n ' + \
                          '\n '.join(sorted(d.name for d in itertools.chain(
                              *(self.recommends + self.dependencies))))
        return d

    def update(self, cache):
        '''Update from cache

        This adds the available versions to all dependencies. It updates
        descriptions, summaries etc. available to all BaseDependencies.

        :param cache: ``apt.Cache`` like object

        Instead of using ``update()``, also the ``+=`` operator can be used:

        >>> import apt
        >>> with open('education') as fp:
        ...     task = Task('debian-astro', 'astro', 'education', fp)
        >>> dep = task.recommends[1][0]
        >>> print(dep.name + ": ", dep.summary)
        starplot:  None
        >>> task += apt.Cache()
        >>> print(dep.name + ": ", dep.summary)
        starplot:  3-dimensional perspective star map viewer
        '''
        for dep in self.all:
            pkg = cache.get(dep.name)
            if pkg is not None:
                dep.target_versions += pkg.versions
            for pkg in cache.get_providing_packages(dep.name):
                dep.target_versions += pkg.versions

    def __iadd__(self, cache):
        self.update(cache)
        return self
    
    def fix_dependencies(self):
        '''Fix the dependencies according to available packages

        This lowers all unavailable ``recommended`` dependencies to
        ``suggested``.

        >>> import apt
        >>> with open('education') as fp:
        ...     task = Task('debian-astro', 'astro', 'education', fp)
        >>> for dep in task.recommends:
        ...     print(dep.rawstr)
        celestia-gnome | celestia-glut
        starplot
        gravit
        >>> for dep in task.suggests:
        ...     print(dep.rawstr)
        sunclock
        xtide
        >>> task += apt.Cache()
        >>> missing = task.fix_dependencies()
        >>> for dep in task.recommends:
        ...     print(dep.rawstr)
        starplot
        gravit
        >>> for dep in task.suggests:
        ...     print(dep.rawstr)
        sunclock
        xtide
        celestia-gnome | celestia-glut
        '''
        missing = list()
        for dep in self.recommends[:]:
            sdep = Dependency("Suggests")
            for odep in dep[:]:
                if len(odep.target_versions) == 0:
                    dep.remove(odep)
                    sdep.append(odep)
                    missing.append(odep)
            if len(dep) == 0:
                self.recommends.remove(dep)
            if len(sdep) > 0:
                self.suggests.append(sdep)
        return missing

    @staticmethod
    def upgrade_from_1_0(content):
        header = [ ("Format", "https://blends.debian.org/blends/1.1") ]
        header += list(filter(lambda x: x[0] != "Format", content[0].items()))
        res = [ dict(header) ]
        for p in content[1:]:
            q = []
            for key, value in p.items():
                if key == 'Recommends':
                    key = 'Suggests'
                elif key == 'Depends':
                    key = 'Recommends'
                # Remove backslashes, which are not DEB822 compliant
                value = re.sub(r'\s*\\', '', value)
                q.append((key, value))
            res.append(dict(q))
        return Deb822List(res)
            

class Dependency(list):
    '''Represent an Or-group of dependencies. 

    Example:

    >>> with open('education') as fp:
    ...     task = Task('debian-astro', 'astro', 'education', fp)
    >>> dep = task.recommends[0]
    >>> print(dep.rawstr)
    celestia-gnome | celestia-glut
    '''

    def __init__(self, rawtype, s = None, content = None):
        super(Dependency, self).__init__(BaseDependency(bs.strip(), content)
                                         for bs in (s.split("|") if s else []))
        self.rawtype = rawtype
        '''The type of the dependencies in the Or-group'''
        
    @property
    def or_dependencies(self):
        return self

    @property
    def rawstr(self):
        '''String represenation of the Or-group of dependencies.

        Returns the string representation of the Or-group of
        dependencies as it would be written in the ``debian/control``
        file.  The string representation does not include the type of
        the Or-group of dependencies.
        '''
        return ' | '.join(bd.rawstr for bd in self)

    @property
    def target_versions(self):
        '''A list of all Version objects which satisfy this Or-group of deps.
        '''
        return list(itertools.chain(bd.target_versions for bd in self))


class BaseDependency:
    '''A single dependency.

    Example:

    >>> with open('education') as fp:
    ...     task = Task('debian-astro', 'astro', 'education', fp)
    >>> dep = task.recommends[2][0]
    >>> print(dep.rawstr)
    gravit
    >>> print(dep.wnpp)
    743379
    >>> print(dep.homepage)
    http://gravit.slowchop.com/
    >>> print(dep.description)
    Gravit is a free, visually stunning gravity simulator.
    <BLANKLINE>
    You can spend endless time experimenting with various
    configurations of simulated universes.
    >>> print(dep.summary)
    Visually stunning gravity simulator
    '''

    def __init__(self, s, content = None):
        self.name = s
        self.content = content or dict()
        self.target_versions = []

    def _get_from_target_versions(self, key):
        for v in self.target_versions:
            if v.package.name == self.name:
                return getattr(v, key)

    @property
    def rawstr(self):
        '''String represenation of the dependency.

        Returns the string representation of the dependency as it
        would be written in the ``debian/control`` file.  The string
        representation does not include the type of the dependency.
        '''
        return self.name

    @property
    def wnpp(self):
        '''The WNPP bug number, if available, or None
        '''
        return self.content.get("WNPP")

    @property
    def homepage(self):
        '''Return the homepage for the package.
        '''
        return self._get_from_target_versions("homepage") or \
            self.content.get("Homepage")

    @property
    def description(self):
        '''Return the formatted long description.
        '''
        desc = self._get_from_target_versions("description")
        if desc is not None:
            return desc
        desc = self.content.get("Pkg-Description",
                                self.content.get("Description"))
        if desc:
            return "\n".join(l[1:] if l != ' .' else ''
                             for l in desc.split("\n")[1:])

    @property
    def summary(self):
        '''Return the short description (one line summary).
        '''
        summary = self._get_from_target_versions("summary")
        if summary:
            return summary

        desc = self.content.get("Pkg-Description",
                                self.content.get("Description"))
        if desc:
            return desc.split('\n')[0]

    @property
    def why(self):
        return self.content.get("Why")

    @property
    def remark(self):
        return self.content.get("Remark")


class Deb822List(list):
    '''A list of ``Deb822`` paragraphs
    '''
    def __init__(self, paragraphs):
        list.__init__(self, (p if isinstance(p, Deb822) else Deb822(p)
                             for p in paragraphs))

    def dump(self, fd=None, encoding=None, text_mode=False):
        '''Dump the the contents in the original format

        If ``fd`` is ``None``, returns a ``unicode`` object.
        Otherwise, ``fd`` is assumed to be a ``file``-like object, and
        this method will write the data to it instead of returning a
        ``unicode`` object.

        If ``fd`` is not ``None`` and ``text_mode`` is ``False``, the
        data will be encoded to a byte string before writing to the
        file.  The encoding used is chosen via the encoding parameter;
        None means to use the encoding the object was initialized with
        (utf-8 by default).  This will raise ``UnicodeEncodeError`` if
        the encoding can't support all the characters in the
        ``Deb822Dict`` values.

        '''
        if fd is None:
            fd = io.StringIO()
            return_string = True
        else:
            return_string = False

        for p in self:
            p.dump(fd, encoding, text_mode)
            fd.write("\n")

        if return_string:
            return fd.getvalue()


def aptcache(distribution = None):
    '''Open and update a (temporary) apt cache for the specified distribution.

    If the distribution is not given, use the system's cache without update.
    '''
    import tempfile
    import apt

    if distribution is None:
        return apt.Cache()
    d = tempfile.mkdtemp()
    os.makedirs(os.path.join(d, 'etc', 'apt'))
    shutil.copytree('/etc/apt/trusted.gpg.d',
                    os.path.join(d, 'etc', 'apt', 'trusted.gpg.d'))
    shutil.copy('/etc/blends/sources.list.{}'.format(distribution),
                    os.path.join(d, 'etc', 'apt', 'sources.list'))
    c = apt.Cache(rootdir=d, memonly=True)
    c.update()
    c.open()
    shutil.rmtree(d)
    return c


if __name__ == '__main__':
    import sys
    import argparse

    blend_dev_dir = "/usr/share/blends-dev/"
    default_release = "testing"

    parser = argparse.ArgumentParser()

    parser.add_argument("-d", "--dir", dest="dir", type=str,
                        default=".",
                        help="Base directory of the tasks source package"
                        + " (default: current directory)")

    parser.add_argument("-r", "--release", dest="release", type=str,
                        default=default_release,
                        help="Target release, eg: stable, testing etc."
                        + " (default: {})".format(default_release))

    parser.add_argument("-S", '--supress-empty', dest="suppressempty",
                        action="store_true", default=False,
                        help="suppress tasks without any recommended package")

    parser.add_argument("-a", '--wanted', dest="wanted",
                        action="store_true", default=False,
                        help="print all wanted packages")

    parser.add_argument("-m", '--missing', dest="missing",
                        action="store_true", default=False,
                        help="print missing packages")

    parser.add_argument("-c", '--control', dest="gencontrol",
                        action="store_true", default=False,
                        help="Create new debian/control file.")

    parser.add_argument("-t", '--taskdesc', dest="taskdesc",
                        action="store_true", default=False,
                        help="Print task descriptions and package list for task")

    parser.add_argument("-u", '--udebs', dest="udebs",
                        action="store_true", default=False,
                        help="modify tasks desc file in case the blend uses udebs")

    parser.add_argument("-U", '--upgrade-tasks', dest="upgrade_tasks",
                        action="store_true", default=False,
                        help="Upgrade tasks files to the latest format version")

    args = parser.parse_args()

    if args.release == "current":
        args.release = None

    blend = Blend(basedir=args.dir, suppress_empty = args.suppressempty)

    # For better performance, remove all tasks that will not create metapackages
    for task in blend.tasks[:]:
        if not task.is_metapackage:
            blend.tasks.remove(task)

    blend += aptcache(args.release)
    missing = blend.fix_dependencies()

    if missing and args.missing:
        missing = sorted(set(d.name for d in missing))
        print('Missing {} packages downgraded to `suggests`:\n  '
              .format(len(missing))
              + '\n  '.join(missing))

    if args.wanted:
        wanted = sorted(set(d.name for d in itertools.chain(*(
            itertools.chain(*(t.recommends + t.dependencies))
            for t in blend.tasks))))
        print("Total {} packages in Depends or Recommend:\n  "
              .format(len(wanted))
              + '\n  '.join(wanted))

    if args.gencontrol:
        with open(os.path.join(args.dir,'debian', 'control'), 'w') as fp:
            fp.write('# This file is autogenerated. Do not edit!\n')
            blend.control.dump(fp, text_mode=True)

    if args.taskdesc and not args.udebs:
        with open(os.path.join(args.dir,'{}-tasks.desc'.format(blend.name)), 'w') as fp:
            blend.task_desc.dump(fp, text_mode=True)

    if args.taskdesc and args.udebs:
        with open(os.path.join(args.dir,'{}-tasks.desc'.format(blend.name)), 'w') as fp:
            blend.udeb_task_desc.dump(fp, text_mode=True)

    upgraded_tasks = []
    for task in blend.tasks:
        if task.format_upgraded:
            upgraded_tasks.append(task)

    if upgraded_tasks:
        if args.upgrade_tasks:
            print('Upgrading {} tasks from format version {}'
                  .format(len(upgraded_tasks), upgraded_tasks[0].format_version))
            for task in upgraded_tasks:
                with open(os.path.join(args.dir, 'tasks', task.name), 'w') as fp:
                    task.content.dump(fp, text_mode=True)
        else:
            print('Warning: {} tasks use the old format {}'
                  .format(len(upgraded_tasks), upgraded_tasks[0].format_version))
            print('Please consider upgrading the task files with the `-U` flag.')

