# Copyright (C) 2007-2008 Michael Homer <=mwh>
#
# 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, see <http://www.gnu.org/licenses/>.

import pickle

from GetAvailable import GetAvailable
from CheckDependencies import *
from UseFlags import UseFlags, potentialFlags

from Freshen.decorators import *
from Freshen.update import Update
from Freshen.exceptions import InstallationError, CacheWarning
from Freshen.request import FreshenRequest as Request

version = {'major': 3, 'minor': 1, 'revision': 0, 'prerelease': 'SVN'}
requiredVersions = {
    'Scripts': '2.10.0',
    'Compile': '1.13.0',
}

_cacheDataFormatVersion = 5

goboPrograms = getGoboVariable('goboPrograms')

@memoised
def installedPrograms():
    """Returns a KeyInsensitiveDict of program=>[version-revisions].

    >>> sorted(installedPrograms()['Scripts']) # sorted for predictability
    ['2.8.4-r1', '2.9.0-r1', '2.9.0rc2-r1', 'SVN-r1']
    """
    installed = availables()['installed']['programs']
    # Generator: tuple (program, versionlist)
    # List comprehension: string "version-revision" installed
    # versions of program
    return KeyInsensitiveDict((prog,
        [v+'-'+r for v in installed[prog] for r in installed[prog][v]])
        for prog in installed)


@memoised
def latestInstalled(program):
    """Return the latest installed version-revision of program.

    >>> latestInstalled('Scripts')
    '2.9.0-r1'
    """
    if program in installedPrograms():
        return GuessLatest(installedPrograms()[program])
    return None


@memoised
def latestAvailable(program, types=('official_package', 'recipe')):
    """Return the latest available version of program (cached).

    >>> latestAvailable('Scripts')
    '2.9.5'
    >>> latestAvailable('Scripts', types=('recipe',))
    'svn-r1'
    """
    latest = None
    for type in types:
        typedata = availables()[type]['programs']
        if program in typedata:
            lver = GuessLatest(typedata[program])
            lrev = GuessLatest(typedata[program][lver])
            thisver = Join_Version_Revision(lver, lrev)
            if not latest:
                latest = thisver
            else:
                latest = GuessLatest((thisver, latest))
    return latest


@memoised
def updateAvailable(program, types=('official_package', 'recipe'),
                    noRevisions=False):
    """Whether there is a newer version of program available.

    >>> updateAvailable('Scripts')
    True
    >>> updateAvailable('Foo')
    True
    >>> updateAvailable('Foo', noRevisions=True)
    False
    """
    li = latestInstalled(program)
    la = latestAvailable(program, types=types)
    if not li:
        return True
    elif not la:
        return False
    if noRevisions:
        lai, _ = Split_Version_Revision(li)
        la, _ = Split_Version_Revision(la)
    return (GuessLatest((li, la)) != li)


def _ensure_recipe_local(upd):
    if upd.type == 'recipe' and not os.path.exists(upd.url):
        f = os.popen('GetRecipe %s %s 2> /dev/null' % (upd.program,
            Join_Version_Revision(upd.version, upd.revision)))
        upd.url = f.read().strip()
        f.close()


def _unordered_update(examinedProgram, req):
    """Helper method for unordered updates.

    Returns a single `Freshen.Update` corresponding to `examinedProgram`.
    """
    packages = FindPackage(examinedProgram, types=req.types,
                           availables=availables(),
                           accessWeb=False)
    newest = packages[0]
    upd = Update(newest)
    if 'recipe' == newest[3]:
        _ensure_recipe_local(upd)
        upd.initialiseFlagsEngine(UseFlags(upd.url, return_engine=True))
    return upd


def _add_update_flags(upd):
    """Extend `upd` with its corresponding useflags and return it.

    >>> upd = Update(('Foo', '1', 'r1', 'recipe', '/'))
    >>> upd = _add_update_flags(upd)
    >>> upd.enabledFlags
    frozenset(['awesomeness'])
    >>> upd.disabledFlags
    frozenset(['badness'])
    """
    if 'recipe' == upd.type:
        _ensure_recipe_local(upd)
        upd.initialiseFlagsEngine(UseFlags(upd.url, return_engine=True))
    return upd


def _program_updates_each(upd, examinedProgram, req):
    """Return `upd` when suitable for updating, or `None`.

    >>> req = Request('updating', (), (), False, False, (), \\
    ...                False, None, False, False)
    >>> upd = Update(('Foo', '1.0', 'r2', 'recipe', '/'))
    >>> _program_updates_each(upd, 'Foo', req) # doctest: +ELLIPSIS
    <Freshen.update.Update object at 0x...>

    When the noRevisions option is on and this is a revision-only
    update, returns None.
    >>> req.noRevisions = True
    >>> _program_updates_each(upd, 'Foo', req) is None
    True
    """
    # If we're including flag changes, check that at the beginning so
    # they're included regardless of version.
    if req.includeFlagChanges and upd.type == 'recipe':
        activeFlags = UseFlags(upd.url)
        current = set()
        uf = '%s/%s/Current/Resources/UseFlags' % (goboPrograms, upd.program)
        if (latestInstalled(upd.program) and os.path.exists(uf)):
            try:
                current = set(open(uf).readline().split())
            except:
                Log_Error('Resources/UseFlags data file for '
                          'installed %s exists but is not '
                          'readable.' % (upd.program), 'Freshen')
        else:
            current = set()
        if current != activeFlags and not _skip(upd, req):
            return _add_update_flags(upd)
    # Installed dependencies may be listed too in some modes,
    # but skip them.
    if upd.type != 'installed' and upd.type is not None:
        insVer = latestInstalled(upd.program)
        insVers = installedPrograms()[upd.program] if insVer else ()
        # If no revision updates were requested, trim the revisions
        # from all data for comparison.
        if req.noRevisions:
            ver = upd.version
            insVers = map(lambda v: Split_Version_Revision(v)[0], insVers)
        else:
            ver = Join_Version_Revision(upd.version, upd.revision)
        # Only updated or wholly new programs should
        # be included. However, note that in some cases
        # older versions of programs will be required.
        if not insVer or ver not in insVers:
            # Skip revision-only updates when asked
            if req.noRevisions and insVer:
                iv, _ = Split_Version_Revision(insVer)
                if upd.version == iv:
                    return None
            if not _skip(upd, req):
                return _add_update_flags(upd)
    return None


def _program_updates(examinedProgram, considered, programList, req):
    """Find and yield all updates for `examinedProgram` and dependencies.

    Extends `considered` and `programList` with the updates found.
    Programs in `considered` will not be examined further.

    Yielded updates have their `introducedBy` property set as
    `examinedProgram`.
    >>> req = Request('updating', (), (), False, False, (), \\
    ...                False, None, False, False)
    >>> considered = set(['LZMA-Utils'])
    >>> programList = ['<A Freshen.Update for LZMA-Utils>']
    >>> for upd in _program_updates('Scripts', considered, programList, req):
    ...     print (upd.program, upd.version, upd.revision, upd.introducedBy)
    ('UnionFS-Fuse', '0.18', 'r2', 'Scripts')
    ('Scripts', '2.9.1', '', 'Scripts')

    `considered` and `programList` are updated with the data generated
    from this set of updates
    >>> considered == set(['UnionFS-Fuse', 'LZMA-Utils', 'Scripts'])
    True
    >>> len(programList)
    3
    """
    orderedRules, orderedMatches = CheckDependencies(
        examinedProgram, acceptable_types=list(req.types),
        mode=req.mode, recursive=True, availables=availables())
    thisProgramUpdates = []
    frz = frozenset()
    # Loop through the update data for dependencies that have not
    # been considered already
    for upd in (Update(upd, activeFlags=rule.get('activeflags', frz))
                for upd, rule in zip(orderedMatches, orderedRules)
                if upd[0] not in considered):
        upd.introducedBy = examinedProgram
        considered.add(upd.program)
        update = _program_updates_each(upd, examinedProgram, req)
        # Maintain updates list to yield later for caching purposes
        if update:
            thisProgramUpdates.append(update)
    # Save updates into the cache now, then yield them: it's
    # possible the loop will terminate before they're done.
    programList.extend(thisProgramUpdates)
    for update in thisProgramUpdates:
        yield update


def _updates_from_cache(programList, listComplete, req):
    """Yield updates from cache as passed in.

    Sets `req.updateCount` when `listComplete` is true.

    >>> req = Request('updating', (), (), False, False, (), \\
    ...                False, None, False, False)
    >>> programList = ['<A Freshen.Update>', '<Another Freshen.Update>']
    >>> for upd in _updates_from_cache(programList, True, req):
    ...     print upd
    <A Freshen.Update>
    <Another Freshen.Update>
    >>> req.updateCount
    2
    """
    if listComplete:
        req.updateCount = len(programList)
    for update in programList:
        # Check if this should be included, and yield it if so.
        if not _skip(update, req):
            yield update


def _updates(req, progList, considered):
    """Yields `Freshen.Update`s for all programs with updates available.

    This test uses a tuple rather than a set for predictable ordering.
    >>> req = Request('updating', ('Scripts', 'Foo'), (), False,
    ...               False, (), False, None, False, False)
    >>> for upd in _updates(req, [], set()):
    ...     print (upd.program, upd.version, upd.revision, upd.introducedBy)
    ('LZMA-Utils', '4.32.5', 'r1', 'Scripts')
    ('UnionFS-Fuse', '0.18', 'r2', 'Scripts')
    ('Scripts', '2.9.1', '', 'Scripts')
    ('Foo', '1.0', 'r2', 'Foo')
    """
    onExamine = req.onExamine
    for program in req.examineSet:
        # Call the hook if given
        if onExamine:
            onExamine()
        # Don't consider programs twice.
        # They could have been covered here or from the cache.
        if program in considered or _skip(program, req):
            if not req.includeFlagChanges:
                continue
        if req.mode == 'unordered':
            newest = _unordered_update(program, req)
            progList.append(newest)
            yield newest
            continue
        # Process actual results from here
        for upd in _program_updates(program, considered, progList, req):
            yield upd


def updates(req=None, mode=None, examineSet=None, skipSet=None, noCache=None,
            forceCache=None, types=None, noRevisions=None, onExamine=None,
            last=None, includeFlagChanges=False):
    """Generator of `Freshen.Update`s, in dependency-resolved order.

    May be given either a `Freshen.Request` object as `req`, or the
    individual settings as keyword arguments. When given a request
    object, that object may be updated with progress information during
    the update process.
    """
    if not req:
        req = Freshen.Request(mode, examineSet, skipSet, noCache, forceCache,
                             types, noRevisions, onExamine,
                             includeFlagChanges)
    types = req.types
    noRevisions = req.noRevisions
    # Load from cache, and yield any programs from that
    if not req.examineSet:
        req.examineSet = frozenset(prog for prog in
                                installedPrograms() if
                                updateAvailable(prog, types, noRevisions))
    programList, considered, listComplete = _loadCache(req)
    updlist = []
    for upd in _updates_from_cache(programList, listComplete, req):
        updlist.append(upd)
        yield upd
    # No more updates are available, so don't bother trying
    if listComplete:
        if req:
            req.updates = updlist
        return
    try:
        # Now that the cache is exhausted, go through the programs we need
        # to examine.
        for u in _updates(req, programList, considered):
            updlist.append(u)
            yield u
        if req:
            req.updates = updlist
        listComplete = True
    finally:
        req.updateCount = len(programList)
        _saveCache(req, programList, considered, listComplete)


def installUpdate(upd, symlink='yes'):
    """InstallPackage/Compile `upd`, as applicable."""
    if upd.type == 'recipe':
        if os.system("Compile -l " + symlink + " -G -U -D -b %s" % (upd.url)):
            raise InstallationError(upd.program)
    elif upd.type == 'official_package' or upd.type == 'contrib_package':
        if os.system("InstallPackage -l " + symlink + " -s keep -k -U -s keep -b -D "
                     "--unmanaged=install %s" % (upd.url)):
            raise InstallationError(upd.program)
    elif upd.type == "alien":
        if os.system("Compile -l " + symlink + " -G -U -D -b %s" % (upd.program)):
            raise InstallationError(upd.program)
    else:
        raise InstallationError(None)


@memoised
def _skip(upd, req):
    """Boolean: whether the program in `upd` should be skipped.

    Wildcard entries are included, so skipping "KDE" will also
    skip anything named KDE-*.
                                       # skipSet
    >>> req = Request('updating', (), ('KDE', 'Glib'), False,
    ...               False, (), False, None, False, False)
    >>> _skip('KDE', req)
    True
    >>> _skip('KDE-Base', req)
    True
    >>> _skip('Glibc', req)
    False
    """
    if type(upd) == str:
        program = upd
    else:
        program = upd.program
    if program in req.skipSet:
        return True
    # Skip e.g. KDE-* with -x KDE
    spl = program.split('-')
    if len(spl) > 1 and spl[0] in req.skipSet:
        return True
    return False


def _cacheDir():
    cacheDir = os.environ.get('HOME') + '/.cache'
    if 'XDG_CACHE_HOME' in os.environ:
        cacheDir = os.environ['XDG_CACHE_HOME']
    return cacheDir + '/Freshen'


def _cacheFile(cacheDataID, file=None):
    """The filename corresponding to the passed ID and file.

    If `file` is provided, the return value will be the path to that file
    within the freshen_cache directory. Otherwise, it will be the file
    named `cacheDataID`.
    """
    cacheDir = _cacheDir()
    if not os.path.exists(cacheDir):
        os.makedirs(cacheDir)
    if file:
        return "%s/%s" % (cacheDir, file)
    return "%s/%s" % (cacheDir, cacheDataID)


def _saveCache(req, programList, considered, listComplete):
    """Save the cache of generated updates and delete expired files."""
    f = None
    try:
        f = open(_cacheFile(req.cacheDataID), 'w')
        pickle.dump(_cacheDataFormatVersion, f,
                    protocol = pickle.HIGHEST_PROTOCOL)
        pickle.dump(programList, f, protocol = pickle.HIGHEST_PROTOCOL)
        pickle.dump(considered, f, protocol = pickle.HIGHEST_PROTOCOL)
        pickle.dump(listComplete, f, protocol = pickle.HIGHEST_PROTOCOL)
    except (IOError, pickle.PicklingError):
        exc = sys.exc_info()
        raise CacheWarning("Unable to save update data cache. "
                            "~/.cache/Freshen or its\ncontents may"
                            "be non-writable (%s.%s: %s)" %
                            (exc[0].__module__, exc[0].__name__, exc[1]))
    finally:
        if f:
            f.close()
            lastCache = _cacheFile(req.cacheDataID, 'lastcache')
            if os.path.exists(lastCache):
                os.unlink(lastCache)
            os.link(_cacheFile(req.cacheDataID), lastCache)
    directory = _cacheDir()
    # Delete expired cache files
    for fn in os.listdir(directory):
        if time.time() - os.path.getmtime(directory + '/' + fn) > 1800:
            os.unlink(directory + '/' + fn)


def _loadCache(req):
    """Load data from the cache file, if available.

    If data is expired, does not exist, caching is disabled,
    the cache data format is outdated, or there is some other
    error, returns an empty dataset. Result is a triple
    (progList (list), considered (set), listComplete (boolean)).

    >>> req = Request('updating', ('Foo'), (), False,
    ...               False, (), False, None, False, False)
    >>> _loadCache(req)
    ([], set([]), False)
    >>> req.cacheDataID = 'TEST'
    >>> _loadCache(req)
    (['prog1'], frozenset(['prog1', 'prog2']), True)
    >>> req.cacheDataID = 'OUTDATED'
    >>> _loadCache(req)
    ([], set([]), False)
    """
    cacheFile = _cacheFile(req.cacheDataID)
    if req.last:
        cacheFile = _cacheFile(req.cacheDataID, 'lastcache')
    if not os.path.exists(cacheFile) or req.noCache:
        return ([], set(), False)
    if req.forceCache or (not req.noCache and
        (time.time() - os.path.getmtime(cacheFile) < 1800)):
        f = None
        try:
            f = open(cacheFile, 'r')
            format = pickle.load(f)
            if format != _cacheDataFormatVersion:
                return ([], set(), False)
            progList = pickle.load(f)
            considered = pickle.load(f)
            listComplete = pickle.load(f)
        except:
            return ([], set(), False)
        finally:
            if f:
                f.close()
        return (progList, considered, listComplete)
    return ([], set(), False)


@memoised
def availables(forceNoCache=False):
    """Returns the available programs data, from cache or GetAvailable.

    Loading from cache:
    >>> availables = availables_original
    >>> avs = availables()
    >>> sorted(avs.keys())
    ['installed', 'official_package', 'recipe']

    And generated from scratch (non-deterministic):
    >>> avs = availables(True) # doctest: +ELLIPSIS
    <<Test open method: saving to availables.cache>>
    <<Test open method: Writing>>
    ...
    >>> len(avs)
    6
    """
    cacheFile = _cacheFile(0, 'availables.cache')
    if (not forceNoCache and os.path.exists(cacheFile) and
          (time.time() - os.path.getmtime(cacheFile) < 1800)):
        try:
            f = open(cacheFile, 'r')
            return pickle.load(f)
        except:
            return availables(True)
        finally:
            f.close()
    else:
        consoleProgressHook.endString = '\015'
        avs = GetAvailable(
            types=['installed', 'local_package', 'official_package',
            'recipe', 'contrib_package', 'tracked'],
            hook=consoleProgressHook)
        try:
            f = open(cacheFile, 'w')
            pickle.dump(avs, f,
                        protocol = pickle.HIGHEST_PROTOCOL)
            return avs
        except Exception:
            exc = sys.exc_info()
            raise CacheWarning("Unable to save program data cache."
                                "~/.cache/Freshen or its\ncontents"
                                "may be non-writable (%s.%s: %s)" %
                                (exc[0].__module__, exc[0].__name__, exc[1]))
        finally:
            if f:
                f.close()


def clearCache():
    """Clear the current cache files."""
    cacheDir = _cacheDir()
    if not os.path.exists(cacheDir):
       return
    for cacheFile in os.listdir(cacheDir):
        os.unlink(os.path.join(cacheDir, cacheFile))

def _test_CheckDependencies(program, acceptable_types=None, mode=None,
                            recursive=None, availables=None):
    if program == 'Scripts':
        return ([{'hiver': '', 'activeflags': frozenset([]),
        'loincluded': True, 'program': 'LZMA-Utils', 'lover': None,
        'optional': True, 'hiincluded': False}, {'hiver': '',
        'activeflags': frozenset([]), 'loincluded': True, 
        'program': 'UnionFS-Fuse', 'lover': None, 'optional': True,
        'hiincluded': False}, {'hiver': '', 'loincluded': False, 
        'program': 'Scripts', 'lover': '', 'optional': True, 
        'hiincluded': False}], [('LZMA-Utils', '4.32.5', 'r1',
        'official_package',
        'http://gobo.calica.com/packages/official/LZMA-'
        'Utils--4.32.5-r1--i686.tar.bz2'),
        ('UnionFS-Fuse', '0.18', 'r2', 'official_package',
        'http://gobo.calica.com/packages/official/UnionFS-'
        'Fuse--0.18-r2--i686.tar.bz2'), ('Scripts', '2.9.1', '',
        'official_package',
        'http://gobo.calica.com/packages/official/Scripts'
        '--2.9.1--i686.tar.bz2')])
    elif program == 'Foo':
        return ([{'activeflags': frozenset()}],
                [('Foo', '1.0', 'r2', 'recipe', '/')])


if __name__ == '__main__':
    import doctest
    import StringIO
    # Build up a fake availables dict for predictabe testing.
    avs = {
        'installed': {
            'programs': {
                'Foo': {'1.0': {'r1': ['/']}},
                'Scripts': {
                    'SVN': {'r1': ['/Programs/Scripts/SVN']},
                    '2.9.0rc2': {'r1': ['/Programs/Scripts/2.9.0rc2']},
                    '2.9.0': {'r1': ['/Programs/Scripts/2.9.0']},
                    '2.8.4': {'r1': ['/Programs/Scripts/2.8.4']}}}},
        'recipe': {'programs': {
            'Scripts': {'svn': {'r1': ['recipe-url']}},
            'Foo': {'1.0': {'r2': ['/']}}}},
        'official_package': {'programs': {
            'Scripts': {'2.9.5': {'': ['package-url']}}}}}
    availables_original = availables
    availables = lambda: avs
    UseFlags = lambda x: frozenset(['awesomeness'])
    potentialFlags = lambda x: frozenset(['awesomeness', 'badness'])
    CheckDependencies = _test_CheckDependencies
    # These are used in cache loading tests
    os.path.getmtime = lambda x : time.time()
    os.path.exists = lambda x : 'TEST' in x or x.endswith(
                                                ('freshen_cache',
                                                 'availables.cache'))
    def test_open(filename, mode='r'):
        """Replacement for open, using StringIO for testing."""
        file = StringIO.StringIO()
        if 'w' in mode:
            print "<<Test open method: saving to %s>>" % (
                filename.rpartition('/')[2])
            file.write = lambda x: sys.stdout.write(
                '<<Test open method: Writing>>\n')
        elif 'TEST' in filename:
            pickle.dump(_cacheDataFormatVersion, file)
            pickle.dump(['prog1'], file)
            pickle.dump(frozenset(['prog1', 'prog2']), file)
            pickle.dump(True, file)
        elif 'OUTDATED' in filename:
            pickle.dump(_cacheDataFormatVersion-1, file)
            pickle.dump(['prog1'], file)
            pickle.dump(frozenset(['prog1', 'prog2']), file)
            pickle.dump(True, file)
        elif 'availables.cache' in filename:
            pickle.dump(avs, file)
        file.seek(0)
        return file
    open = test_open
    if doctest.testmod()[0]:
        exit(1)
