# encoding: utf-8
#
# Copyright 2009-2017 Greg Neagle.
#
# Licensed under the Apache License, Version 2.0 (the 'License');
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an 'AS IS' BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
updatecheck.compare

Created by Greg Neagle on 2016-12-13.

Comparsion/checking functions used by updatecheck
"""

import os
from operator import itemgetter

from .. import display
from .. import munkihash
from .. import info
from .. import pkgutils
from .. import utils
from .. import FoundationPlist


ITEM_DOES_NOT_MATCH = VERSION_IS_LOWER = -1
ITEM_NOT_PRESENT = 0
ITEM_MATCHES = VERSION_IS_THE_SAME = 1
VERSION_IS_HIGHER = 2


def compare_versions(thisvers, thatvers):
    """Compares two version numbers to one another.

    Returns:
      -1 if thisvers is older than thatvers
      1 if thisvers is the same as thatvers
      2 if thisvers is newer than thatvers
    """
    if (pkgutils.MunkiLooseVersion(thisvers) <
            pkgutils.MunkiLooseVersion(thatvers)):
        return VERSION_IS_LOWER
    elif (pkgutils.MunkiLooseVersion(thisvers) ==
          pkgutils.MunkiLooseVersion(thatvers)):
        return VERSION_IS_THE_SAME
    else:
        return VERSION_IS_HIGHER


def compare_application_version(app):
    """Checks the given path if it's available,
    otherwise uses LaunchServices and/or Spotlight to look for the app

    Args:
      app: dict with application bundle info

    Returns:
         0 if the app isn't installed
            or doesn't have valid Info.plist
        -1 if it's older
         1 if the version is the same
         2 if the version is newer

    Raises utils.Error if there's an error in the input
    """
    if 'path' in app:
        filepath = os.path.join(app['path'], 'Contents', 'Info.plist')
        if os.path.exists(filepath):
            return compare_bundle_version(app)
        display.display_debug2('%s doesn\'t exist.', filepath)
        return ITEM_NOT_PRESENT

    # no 'path' in dict
    display.display_debug2('No path given for application item.')
    # let's search:
    name = app.get('CFBundleName', '')
    bundleid = app.get('CFBundleIdentifier', '')
    version_comparison_key = app.get(
        'version_comparison_key', 'CFBundleShortVersionString')
    versionstring = app.get(version_comparison_key)

    if name == '' and bundleid == '':
        # no path, no name, no bundleid. Error!
        raise utils.Error(
            'No path, application name or bundleid was specified!')

    display.display_debug1(
        'Looking for application %s with bundleid: %s, version %s...' %
        (name, bundleid, versionstring))

    # find installed apps that match this item by name or bundleid
    appdata = info.filtered_app_data()
    appinfo = [item for item in appdata
               if (item['path'] and
                   (item['bundleid'] == bundleid or
                    (name and item['name'] == name)))]

    if not appinfo:
        # No matching apps found
        display.display_debug1(
            '\tFound no matching applications on the startup disk.')
        return ITEM_NOT_PRESENT

    # sort highest version first
    try:
        appinfo.sort(key=itemgetter('version'), reverse=True)
    except KeyError:
        # some item did not have a version key
        pass

    # iterate through matching applications
    end_result = ITEM_NOT_PRESENT
    for item in appinfo:
        if 'name' in item:
            display.display_debug2('\tFound name: \t %s', item['name'])
        display.display_debug2('\tFound path: \t %s', item['path'])
        display.display_debug2(
            '\tFound CFBundleIdentifier: \t %s', item['bundleid'])
        # create a test_app item with our found path
        test_app = {}
        test_app.update(app)
        test_app['path'] = item['path']
        compare_result = compare_bundle_version(test_app)
        if compare_result in (VERSION_IS_THE_SAME, VERSION_IS_HIGHER):
            return compare_result
        elif compare_result == VERSION_IS_LOWER:
            end_result = VERSION_IS_LOWER

    # didn't find an app with the same or higher version
    if end_result == VERSION_IS_LOWER:
        display.display_debug1(
            'An older version of this application is present.')
    return end_result


def compare_bundle_version(item):
    """Compares a bundle version passed item dict.

    Returns  0 if the bundle isn't installed
                or doesn't have valid Info.plist
            -1 if it's older
             1 if the version is the same
             2 if the version is newer

    Raises utils.Error if there's an error in the input
    """
    # look for an Info.plist inside the bundle
    filepath = os.path.join(item['path'], 'Contents', 'Info.plist')
    if not os.path.exists(filepath):
        display.display_debug1('\tNo Info.plist found at %s', filepath)
        filepath = os.path.join(item['path'], 'Resources', 'Info.plist')
        if not os.path.exists(filepath):
            display.display_debug1('\tNo Info.plist found at %s', filepath)
            return ITEM_NOT_PRESENT

    display.display_debug1('\tFound Info.plist at %s', filepath)
    # just let comparePlistVersion do the comparison
    saved_path = item['path']
    item['path'] = filepath
    compare_result = compare_plist_version(item)
    item['path'] = saved_path
    return compare_result


def compare_plist_version(item):
    """Gets the version string from the plist at path and compares versions.

    Returns  0 if the plist isn't installed
            -1 if it's older
             1 if the version is the same
             2 if the version is newer

    Raises utils.Error if there's an error in the input
    """
    version_comparison_key = item.get(
        'version_comparison_key', 'CFBundleShortVersionString')
    if 'path' in item and version_comparison_key in item:
        versionstring = item[version_comparison_key]
        filepath = item['path']
        minupvers = item.get('minimum_update_version')
    else:
        raise utils.Error('Missing plist path or version!')

    display.display_debug1('\tChecking %s for %s %s...',
                           filepath, version_comparison_key, versionstring)
    if not os.path.exists(filepath):
        display.display_debug1('\tNo plist found at %s', filepath)
        return ITEM_NOT_PRESENT

    try:
        plist = FoundationPlist.readPlist(filepath)
    except FoundationPlist.NSPropertyListSerializationException:
        display.display_debug1('\t%s may not be a plist!', filepath)
        return ITEM_NOT_PRESENT
    if not hasattr(plist, 'get'):
        display.display_debug1(
            'plist not parsed as NSCFDictionary: %s', filepath)
        return ITEM_NOT_PRESENT

    if 'version_comparison_key' in item:
        # specific key has been supplied,
        # so use this to determine installed version
        display.display_debug1(
            '\tUsing version_comparison_key %s', version_comparison_key)
        installedvers = pkgutils.getVersionString(
            plist, version_comparison_key)
    else:
        # default behavior
        installedvers = pkgutils.getVersionString(plist)
    if installedvers:
        display.display_debug1(
            '\tInstalled item has version %s', installedvers)
        if minupvers:
            if compare_versions(installedvers, minupvers) < 1:
                display.display_debug1(
                    '\tVersion %s too old < %s', installedvers, minupvers)
                return ITEM_NOT_PRESENT
        compare_result = compare_versions(installedvers, versionstring)
        results = ['older', 'not installed?!', 'the same', 'newer']
        display.display_debug1(
            '\tInstalled item is %s.', results[compare_result + 1])
        return compare_result
    else:
        display.display_debug1('\tNo version info in %s.', filepath)
        return ITEM_NOT_PRESENT


def filesystem_item_exists(item):
    """Checks to see if a filesystem item exists.

    If item has md5checksum attribute, compares on disk file's checksum.

    Returns 0 if the filesystem item does not exist on disk,
    Returns 1 if the filesystem item exists and the checksum matches
                (or there is no checksum)
    Returns -1 if the filesystem item exists but the checksum does not match.

    Broken symlinks are OK; we're testing for the existence of the symlink,
    not the item it points to.

    Raises utils.Error is there's a problem with the input.
    """
    if 'path' in item:
        filepath = item['path']
        display.display_debug1('Checking existence of %s...', filepath)
        if os.path.lexists(filepath):
            display.display_debug2('\tExists.')
            if 'md5checksum' in item:
                storedchecksum = item['md5checksum']
                ondiskchecksum = munkihash.getmd5hash(filepath)
                display.display_debug2('Comparing checksums...')
                if storedchecksum == ondiskchecksum:
                    display.display_debug2('Checksums match.')
                    return ITEM_MATCHES
                else:
                    display.display_debug2(
                        'Checksums differ: expected %s, got %s',
                        storedchecksum, ondiskchecksum)
                    return ITEM_DOES_NOT_MATCH
            else:
                return ITEM_MATCHES
        else:
            display.display_debug2('\tDoes not exist.')
            return ITEM_NOT_PRESENT
    else:
        raise utils.Error('No path specified for filesystem item.')


def compare_item_version(item):
    '''Compares an installs_item with what's on the startup disk.
    Wraps other comparsion functions.

    For applications, bundles, and plists:
    Returns 0 if the item isn't installed
                or doesn't have valid Info.plist
            -1 if it's older
             1 if the version is the same
             2 if the version is newer

    For other filesystem items:
    Returns 0 if the filesystem item does not exist on disk,
            1 if the filesystem item exists and the checksum matches
                (or there is no checksum)
           -1 if the filesystem item exists but the checksum does not match.
    '''
    if not 'VersionString' in item and 'CFBundleShortVersionString' in item:
        # Ensure that 'VersionString', if not present, is populated
        # with the value of 'CFBundleShortVersionString' if present
        item['VersionString'] = item['CFBundleShortVersionString']
    itemtype = item.get('type')
    if itemtype == 'application':
        return compare_application_version(item)
    if itemtype == 'bundle':
        return compare_bundle_version(item)
    if itemtype == 'plist':
        return compare_plist_version(item)
    if itemtype == 'file':
        return filesystem_item_exists(item)
    raise utils.Error('Unknown installs item type: %s', itemtype)


def compare_receipt_version(item):
    """Determines if the given package is already installed.

    Args:
      item: dict with packageid; a 'com.apple.pkg.ServerAdminTools' style id

    Returns  0 if the receipt isn't present
            -1 if it's older
             1 if the version is the same
             2 if the version is newer

    Raises utils.Error if there's an error in the input
    """
    if item.get('optional'):
        # receipt has been marked as optional, so it doesn't matter
        # if it's installed or not. Return 1
        # only check receipts not marked as optional
        display.display_debug1(
            'Skipping %s because it is marked as optional',
            item.get('packageid', item.get('name')))
        return VERSION_IS_THE_SAME
    installedpkgs = pkgutils.getInstalledPackages()
    if 'packageid' in item and 'version' in item:
        pkgid = item['packageid']
        vers = item['version']
    else:
        raise utils.Error('Missing packageid or version info!')

    display.display_debug1('Looking for package %s, version %s', pkgid, vers)
    installedvers = installedpkgs.get(pkgid)
    if installedvers:
        return compare_versions(installedvers, vers)
    else:
        display.display_debug1(
            '\tThis package is not currently installed.')
        return ITEM_NOT_PRESENT


def get_installed_version(item_plist):
    """Attempts to determine the currently installed version of an item.

    Args:
      item_plist: pkginfo plist of an item to get the version for.

    Returns:
      String version of the item, or 'UNKNOWN' if unable to determine.

    """
    for receipt in item_plist.get('receipts', []):
        # look for a receipt whose version matches the pkginfo version
        if compare_versions(receipt.get('version', 0),
                            item_plist['version']) == 1:
            pkgid = receipt['packageid']
            display.display_debug2(
                'Using receipt %s to determine installed version of %s',
                pkgid, item_plist['name'])
            return pkgutils.getInstalledPackageVersion(pkgid)

    # try using items in the installs array to determine version
    install_items_with_versions = [item
                                   for item in item_plist.get('installs', [])
                                   if 'CFBundleShortVersionString' in item]
    for install_item in install_items_with_versions:
        # look for an installs item whose version matches the pkginfo version
        if compare_versions(install_item['CFBundleShortVersionString'],
                            item_plist['version']) == 1:
            if install_item['type'] == 'application':
                name = install_item.get('CFBundleName')
                bundleid = install_item.get('CFBundleIdentifier')
                display.display_debug2(
                    'Looking for application %s, bundleid %s',
                    name, install_item.get('CFBundleIdentifier'))
                try:
                    # check default location for app
                    filepath = os.path.join(install_item['path'],
                                            'Contents', 'Info.plist')
                    plist = FoundationPlist.readPlist(filepath)
                    return plist.get('CFBundleShortVersionString', 'UNKNOWN')
                except FoundationPlist.NSPropertyListSerializationException:
                    # that didn't work, fall through to the slow way
                    appinfo = []
                    appdata = info.app_data()
                    if appdata:
                        for ad_item in appdata:
                            if bundleid and ad_item['bundleid'] == bundleid:
                                appinfo.append(ad_item)
                            elif name and ad_item['name'] == name:
                                appinfo.append(ad_item)

                    maxversion = '0.0.0.0.0'
                    for ai_item in appinfo:
                        if ('version' in ai_item and
                                compare_versions(
                                    ai_item['version'], maxversion) == 2):
                            # version is higher
                            maxversion = ai_item['version']
                    return maxversion
            elif install_item['type'] == 'bundle':
                display.display_debug2(
                    'Using bundle %s to determine installed version of %s',
                    install_item['path'], item_plist['name'])
                filepath = os.path.join(install_item['path'],
                                        'Contents', 'Info.plist')
                try:
                    plist = FoundationPlist.readPlist(filepath)
                    return plist.get('CFBundleShortVersionString', 'UNKNOWN')
                except FoundationPlist.NSPropertyListSerializationException:
                    pass
            elif install_item['type'] == 'plist':
                display.display_debug2(
                    'Using plist %s to determine installed version of %s',
                    install_item['path'], item_plist['name'])
                try:
                    plist = FoundationPlist.readPlist(install_item['path'])
                    return plist.get('CFBundleShortVersionString', 'UNKNOWN')
                except FoundationPlist.NSPropertyListSerializationException:
                    pass
    # if we fall through to here we have no idea what version we have
    return 'UNKNOWN'


if __name__ == '__main__':
    print 'This is a library of support tools for the Munki Suite.'
