# vim:set fileencoding=utf-8 et ts=4 sts=4 sw=4:
#
#   apt-listchanges - Show changelog entries between the installed versions
#                     of a set of packages and the versions contained in
#                     corresponding .deb files
#
#   Copyright (C) 2000-2006  Matt Zimmerman  <mdz@debian.org>
#   Copyright (C) 2006       Pierre Habouzit <madcoder@debian.org>
#   Copyright (C) 2016       Robert Luberda  <robert@debian.org>
#
#   This program is free software; you can redistribute it and/or modify
#   it under the terms of the GNU General Public License as published by
#   the Free Software Foundation; either version 2 of the License, or
#   (at your option) any later version.
#
#   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 St, Fifth Floor, Boston, MA 02110-1301, USA.
#

import copy
import datetime
import hashlib
import os
import pickle
import time

from apt_listchanges.ALChacks import _


class DbError(Exception):
    pass


class SeenDb():
    '''Tracker for seen per-package and per-source changelog entries
    Maintained in memory during program execution, potentially persisted to
    disk with pickle between invocations.
    Per binary package name per changelog path within that package, store
    checksums of changelog entries along with timestamps of when they were
    added to the database.
    Per source package name, store checksums of entry contents (sans headers
    and footers) along with timestamps of when they were added to the database.
    '''
    blank_db = {
        'db_version': 1,
        'sources': {},
        'packages': {},
    }

    def __init__(self, path=None):
        '''Initialize seen DB, possibly from disk
        If path is None or does not exist on disk then an empty DB is
        initialized. Otherwise content is loaded from disk.'''
        self.path = path
        if not path:
            self._d = copy.deepcopy(self.blank_db)
            return
        try:
            # pylint: disable=consider-using-with
            f = open(self.path, 'rb')
        except FileNotFoundError:
            self._d = copy.deepcopy(self.blank_db)
            return
        try:
            self._d = pickle.load(f)
        except Exception as ex:
            raise DbError(_("Database %(db)s failed to load: %(errmsg)s")
                          % {'db': path, 'errmsg': str(ex)}) from ex
        finally:
            f.close()

    def __contains__(self, entry):
        '''Return true if entry is present for source or binary package'''
        # We check both source and binary package because it's possible, albeit
        # rare, for a binary package to switch to a different source package.
        return self.is_in_source(entry) or self.is_in_binary(entry)

    def is_in_source(self, entry):
        '''Check if in source, return timestamp if so'''
        if entry.source not in self._d['sources']:
            return False
        digest = hashlib.md5((entry.content + entry.trailer).strip()
                             .encode()).digest()
        return self._d['sources'][entry.source].get(digest, False)

    def is_in_binary(self, entry):
        '''Check if in package, return timestamp if so'''
        if entry.package not in self._d['packages']:
            return False
        digest = hashlib.md5(str(entry).encode()).digest()
        return any(self._d['packages'][entry.package][path].get(digest, False)
                   for path in self._d['packages'][entry.package].keys())

    def __getitem__(self, entry):
        '''Return timestamp value for binary or source package for entry'''
        package_stamp = self.is_in_binary(entry)
        source_stamp = self.is_in_source(entry)
        if not (package_stamp or source_stamp):
            raise KeyError(entry)
        return max(package_stamp or 0, source_stamp or 0)

    def __setitem__(self, entry, value):
        '''Add item to DB. Value is ignored.'''
        self.add(entry)

    def has_package(self, package):
        return package in self._d['packages']

    def add(self, entry):
        '''Add the specified entry to the seen DB if not already there
        Returns True if the entry is new, meaning it is not already in the DB
        for either the binary or source package, or False otherwise.'''
        package_db = self._d['packages'].setdefault(entry.package, {})
        path_db = package_db.setdefault(entry.path, {})
        entry_checksum = \
            hashlib.md5(str(entry).strip().encode()).digest()
        if entry_checksum in path_db:
            return False
        path_db[entry_checksum] = int(time.time())
        source_db = self._d['sources'].setdefault(entry.source, {})
        content_checksum = \
            hashlib.md5((entry.content + entry.trailer).strip()
                        .encode()).digest()
        if content_checksum in source_db:
            return False
        source_db[content_checksum] = int(time.time())
        return True

    @staticmethod
    def _checksum_line(checksum, stamp):
        return (f'{"".join((f"{c:0{2}x}" for c in checksum))} '
                f' {stamp} '
                f'({datetime.datetime.fromtimestamp(stamp).strftime("%F")})')

    def expire_old(self):
        # We keep entries for three years because Debian releases come out
        # approximately annually and are supported for approximately two years,
        # so the odds are extremely good that a new package version is released
        # in the interim. But it's OK if that doesn't happen for all packages
        # since we fall back on re-parsing the changelog on disk when
        # necessary, which will "re-up" things for another three years.
        three_years_ago = int(time.time() - 365.25 * 3)
        for sources, checksums in self._d['sources'].items():
            # Convert to list so we can safely modify the dict in the loop
            for checksum in list(checksums.keys()):
                if checksums[checksum] < three_years_ago:
                    del checksums[checksum]
        for packages, paths in self._d['packages'].items():
            for path, checksums in paths.items():
                for checksum in list(checksums.keys()):
                    if checksums[checksum] < three_years_ago:
                        del checksums[checksum]

    def apply_changes(self):
        if not self.path:
            return
        self.expire_old()
        oldname = f'{self.path}-old'
        newname = f'{self.path}-new'
        with open(newname, 'wb') as f:
            pickle.dump(self._d, f)
        try:
            os.unlink(oldname)
        except FileNotFoundError:
            pass
        try:
            os.link(self.path, oldname)
        except FileNotFoundError:
            pass
        os.rename(newname, self.path)

    def dump(self):
        print('sources:')
        for source, checksums in self._d['sources'].items():
            print(f'  {source}:')
            for checksum, stamp in checksums.items():
                print(f'    {self._checksum_line(checksum, stamp)}')
        print('packages:')
        for package, files in self._d['packages'].items():
            print(f'  {package}:')
            for path, checksums in files.items():
                print(f'    {path}:')
                for checksum, stamp in checksums.items():
                    print(f'      {self._checksum_line(checksum, stamp)}')
