# 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.
"""
appleupdates.au

Created by Greg Neagle on 2017-01-06.

AppleUpdates object defined here
"""

import glob
import hashlib
import os
import subprocess
import time
import urllib2

# PyLint cannot properly find names inside Cocoa libraries, so issues bogus
# No name 'Foo' in module 'Bar' warnings. Disable them.
# pylint: disable=E0611
from Foundation import NSDate
# pylint: enable=E0611

from . import dist
from . import su_prefs
from . import sync

from ..updatecheck import catalogs

from .. import display
from .. import fetch
from .. import launchd
from .. import munkistatus
from .. import munkihash
from .. import munkilog
from .. import osutils
from .. import prefs
from .. import processes
from .. import reports
from .. import updatecheck
from .. import FoundationPlist


# Apple's index of downloaded updates
INDEX_PLIST = '/Library/Updates/index.plist'


class AppleUpdates(object):

    """Class for installation of Apple Software Updates within Munki.

    This class handles update detection, as well as downloading and installation
    of those updates.
    """

    RESTART_ACTIONS = ['RequireRestart', 'RecommendRestart']

    def __init__(self):
        self._managed_install_dir = prefs.pref('ManagedInstallDir')
        self.apple_updates_plist = os.path.join(
            self._managed_install_dir, 'AppleUpdates.plist')

        # fix things if somehow we died last time before resetting the
        # original CatalogURL
        os_version_tuple = osutils.getOsVersion(as_tuple=True)
        if os_version_tuple in [(10, 9), (10, 10)]:
            su_prefs.reset_original_catalogurl()

        self.applesync = sync.AppleUpdateSync()

        self._update_list_cache = None

        # apple_update_metadata support
        self.client_id = ''
        self.force_catalog_refresh = False

    def _display_status_major(self, message):
        """Resets MunkiStatus detail/percent, logs and msgs GUI.

        Args:
          message: str message to display to the user and log.
        """
        # pylint: disable=no-self-use
        display.display_status_major(message)

    def restart_needed(self):
        """Returns True if any update requires an restart."""
        try:
            apple_updates = FoundationPlist.readPlist(self.apple_updates_plist)
        except FoundationPlist.NSPropertyListSerializationException:
            return True
        for item in apple_updates.get('AppleUpdates', []):
            if item.get('RestartAction') in self.RESTART_ACTIONS:
                return True
        # if we get this far, there must be no items that require restart
        return False

    def clear_apple_update_info(self):
        """Clears Apple update info.

        This is called after performing munki updates because the Apple updates
        may no longer be relevant.
        """
        try:
            os.unlink(self.apple_updates_plist)
        except (OSError, IOError):
            pass

    def download_available_updates(self):
        """Downloads available Apple updates.

        Returns:
          Boolean. True if successful, False otherwise.
        """
        msg = 'Checking for available Apple Software Updates...'
        self._display_status_major(msg)

        if os.path.exists(INDEX_PLIST):
            # try to remove old/stale /Library/Updates/index.plist --
            # in some older versions of OS X this can hang around and is not
            # always cleaned up when /usr/sbin/softwareupdate finds no updates
            try:
                os.unlink(INDEX_PLIST)
            except OSError:
                pass

        os_version_tuple = osutils.getOsVersion(as_tuple=True)
        if os_version_tuple >= (10, 11):
            catalog_url = None
        else:
            catalog_url = self.applesync.get_apple_catalogurl()

        retcode = self._run_softwareupdate(
            ['-d', '-a'], catalog_url=catalog_url, stop_allowed=True)
        if retcode:  # there was an error
            display.display_error('softwareupdate error: %s', retcode)
            return False
        # not sure all older OS X versions set LastSessionSuccessful, so
        # react only if it's explicitly set to False
        last_session_successful = su_prefs.pref(
            'LastSessionSuccessful')
        if last_session_successful is False:
            display.display_error(
                'softwareupdate reported an unsuccessful download session.')
            return False
        return True

    def available_update_product_ids(self):
        """Returns a list of product IDs of available Apple updates.

        Returns:
          A list of string Apple update products ids.
        """
        # pylint: disable=no-self-use
        # first, try to get the list from com.apple.SoftwareUpdate preferences
        recommended_updates = su_prefs.pref(
            'RecommendedUpdates')
        if recommended_updates:
            return [item['Product Key'] for item in recommended_updates
                    if 'Product Key' in item]

        # not in com.apple.SoftwareUpdate preferences, try index.plist
        if not os.path.exists(INDEX_PLIST):
            display.display_debug1('%s does not exist.' % INDEX_PLIST)
            return []

        try:
            product_index = FoundationPlist.readPlist(INDEX_PLIST)
            products = product_index.get('ProductPaths', {})
            return products.keys()
        except (FoundationPlist.FoundationPlistException,
                KeyError, AttributeError), err:
            display.display_error(
                "Error processing %s: %s", INDEX_PLIST, err)
            return []


    def installed_apple_pkgs_changed(self):
        """Generates a SHA-256 checksum of the info for all packages in the
        receipts database whose id matches com.apple.* and compares it to a
        stored version of this checksum.

        Returns:
          Boolean. False if the checksums match, True if they differ."""
        # pylint: disable=no-self-use
        cmd = ['/usr/sbin/pkgutil', '--regexp', '--pkg-info-plist',
               r'com\.apple\.*']
        proc = subprocess.Popen(cmd, shell=False, bufsize=1,
                                stdin=subprocess.PIPE,
                                stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        output, dummy_err = proc.communicate()

        current_apple_packages_checksum = hashlib.sha256(output).hexdigest()
        old_apple_packages_checksum = prefs.pref(
            'InstalledApplePackagesChecksum')

        if current_apple_packages_checksum == old_apple_packages_checksum:
            return False
        else:
            prefs.set_pref('InstalledApplePackagesChecksum',
                           current_apple_packages_checksum)
            return True

    def _force_check_necessary(self, original_hash):
        """Returns True if a force check is needed, False otherwise.

        Args:
          original_hash: the SHA-256 hash of the Apple catalog before being
              redownloaded.
        Returns:
          Boolean. True if a force check is needed, False otherwise.
        """
        new_hash = munkihash.getsha256hash(
            self.applesync.apple_download_catalog_path)
        if original_hash != new_hash:
            munkilog.log('Apple update catalog has changed.')
            return True

        if self.installed_apple_pkgs_changed():
            munkilog.log('Installed Apple packages have changed.')
            return True

        if not self.available_updates_downloaded():
            munkilog.log('Downloaded updates do not match our list of '
                         'available updates.')
            return True

        return False

    def check_for_software_updates(self, force_check=True):
        """Check if Apple Software Updates are available, if needed or forced.

        Args:
          force_check: Boolean. If True, forces a check, otherwise only checks
              if the last check is deemed outdated.
        Returns:
          Boolean. True if there are updates, False otherwise.
        """
        before_hash = munkihash.getsha256hash(
            self.applesync.apple_download_catalog_path)

        msg = 'Checking Apple Software Update catalog...'
        self._display_status_major(msg)
        try:
            self.applesync.cache_apple_catalog()
        except sync.CatalogNotFoundError:
            return False
        except (sync.ReplicationError, fetch.Error) as err:
            display.display_warning(
                'Could not download Apple SUS catalog:')
            display.display_warning('\t%s', unicode(err))
            return False

        if not force_check and not self._force_check_necessary(before_hash):
            display.display_info(
                'Skipping Apple Software Update check '
                'because sucatalog is unchanged, installed Apple packages are '
                'unchanged and we recently did a full check.')
            # return True if we have cached updates; False otherwise
            return bool(self.software_update_info())

        if self.download_available_updates():  # Success; ready to install.
            prefs.set_pref('LastAppleSoftwareUpdateCheck', NSDate.date())
            product_ids = self.available_update_product_ids()
            if not product_ids:
                # No updates found
                self.applesync.clean_up_cache()
                return False
            try:
                self.applesync.cache_update_metadata(product_ids)
            except sync.ReplicationError as err:
                display.display_warning(
                    'Could not replicate software update metadata:')
                display.display_warning('\t%s', unicode(err))
                return False
            return True
        else:
            # Download error, allow check again soon.
            display.display_error(
                'Could not download all available Apple updates.')
            prefs.set_pref('LastAppleSoftwareUpdateCheck', None)
            return False

    def update_downloaded(self, product_key):
        """Verifies that a given update appears to be downloaded.
        Returns a boolean."""
        # pylint: disable=no-self-use
        product_dir = os.path.join('/Library/Updates', product_key)
        if not os.path.isdir(product_dir):
            munkilog.log(
                'Apple Update product directory %s is missing'
                % product_key)
            return False
        else:
            pkgs = glob.glob(os.path.join(product_dir, '*.pkg'))
            if not pkgs:
                munkilog.log(
                    'Apple Update product directory %s contains no pkgs'
                    % product_key)
                return False
        return True

    def available_updates_downloaded(self):
        """Verifies that applicable/available updates have been downloaded.

        Returns:
          Boolean. False if a product directory are missing,
                   True otherwise (including when there are no available
                                   updates).
        """
        apple_updates = self.software_update_info()
        if not apple_updates:
            return True

        for update in apple_updates:
            if not self.update_downloaded(update.get('productKey')):
                return False
        return True

    def software_update_info(self):
        """Uses /Library/Preferences/com.apple.SoftwareUpdate.plist or
        /Library/Updates/index.plist to generate the AppleUpdates.plist,
        which records available updates in the format that
        Managed Software Update.app expects.

        Returns:
          List of dictionary update data.
        """
        update_display_names = {}
        update_versions = {}
        product_keys = []
        english_su_info = {}
        apple_updates = []

        # first, try to get the list from com.apple.SoftwareUpdate preferences
        recommended_updates = su_prefs.pref('RecommendedUpdates')
        if recommended_updates:
            for item in recommended_updates:
                try:
                    update_display_names[item['Product Key']] = (
                        item['Display Name'])
                except (TypeError, AttributeError, KeyError):
                    pass
                try:
                    update_versions[item['Product Key']] = (
                        item['Display Version'])
                except (TypeError, AttributeError, KeyError):
                    pass
            try:
                product_keys = [item['Product Key']
                                for item in recommended_updates]
            except (TypeError, AttributeError, KeyError):
                pass

        if not product_keys:
            # next, try to get the applicable/recommended updates from
            # /Library/Updates/index.plist
            if os.path.exists(INDEX_PLIST):
                try:
                    product_index = FoundationPlist.readPlist(INDEX_PLIST)
                    products = product_index.get('ProductPaths', {})
                    product_keys = products.keys()
                except (FoundationPlist.FoundationPlistException,
                        AttributeError, TypeError), err:
                    display.display_error(
                        "Error parsing %s: %s", INDEX_PLIST, err)

        for product_key in product_keys:
            if not self.update_downloaded(product_key):
                display.display_warning(
                    'Product %s does not appear to be downloaded',
                    product_key)
                continue
            localized_dist = self.applesync.distribution_for_product_key(
                product_key)
            if not localized_dist:
                display.display_warning(
                    'No dist file for product %s', product_key)
                continue
            if (not recommended_updates and
                    not localized_dist.endswith('English.dist')):
                # we need the English versions of some of the data
                # see (https://groups.google.com/d/msg/munki-dev/
                # _5HdMyy3kKU/YFxqslayDQAJ)
                english_dist = self.applesync.distribution_for_product_key(
                    product_key, 'English')
                if english_dist:
                    english_su_info = dist.parse_su_dist(english_dist)
            su_info = dist.parse_su_dist(localized_dist)
            su_info['productKey'] = product_key
            if su_info['name'] == '':
                su_info['name'] = product_key
            if product_key in update_display_names:
                su_info['apple_product_name'] = (
                    update_display_names[product_key])
            elif english_su_info:
                su_info['apple_product_name'] = (
                    english_su_info['apple_product_name'])
            if product_key in update_versions:
                su_info['version_to_install'] = update_versions[product_key]
            elif english_su_info:
                su_info['version_to_install'] = (
                    english_su_info['version_to_install'])
            apple_updates.append(su_info)

        return apple_updates

    def write_appleupdates_file(self):
        """Writes a file used by the MSU GUI to display available updates.

        Returns:
          Integer. Count of available Apple updates.
        """
        apple_updates = self.software_update_info()
        if apple_updates:
            if not prefs.pref('AppleSoftwareUpdatesOnly'):
                cataloglist = updatecheck.get_primary_manifest_catalogs(
                    self.client_id, force_refresh=self.force_catalog_refresh)
                if cataloglist:
                    # Check for apple_update_metadata
                    display.display_detail(
                        '**Checking for Apple Update Metadata**')
                    for item in apple_updates:
                        # Find matching metadata item
                        metadata_item = catalogs.get_item_detail(
                            item['productKey'], cataloglist,
                            vers='apple_update_metadata')
                        if metadata_item:
                            display.display_debug1(
                                'Processing metadata for %s, %s...',
                                item['productKey'], item['display_name'])
                            self.copy_update_metadata(item, metadata_item)
            plist = {'AppleUpdates': apple_updates}
            FoundationPlist.writePlist(plist, self.apple_updates_plist)
            return len(apple_updates)
        else:
            try:
                os.unlink(self.apple_updates_plist)
            except (OSError, IOError):
                pass
            return 0

    def display_apple_update_info(self):
        """Prints Apple update information and updates ManagedInstallReport."""
        try:
            pl_dict = FoundationPlist.readPlist(self.apple_updates_plist)
        except FoundationPlist.FoundationPlistException:
            display.display_error(
                'Error reading: %s', self.apple_updates_plist)
            return
        apple_updates = pl_dict.get('AppleUpdates', [])
        if not apple_updates:
            display.display_info('No available Apple Software Updates.')
            return
        reports.report['AppleUpdates'] = apple_updates
        display.display_info(
            'The following Apple Software Updates are available to '
            'install:')
        for item in apple_updates:
            display.display_info(
                '    + %s-%s' % (
                    item.get('display_name', ''),
                    item.get('version_to_install', '')))
            if item.get('RestartAction') in self.RESTART_ACTIONS:
                display.display_info('       *Restart required')
                reports.report['RestartRequired'] = True
            elif item.get('RestartAction') == 'RequireLogout':
                display.display_info('       *Logout required')
                reports.report['LogoutRequired'] = True

    def _run_softwareupdate(
            self, options_list, catalog_url=None, stop_allowed=False,
            mode=None, results=None):
        """Runs /usr/sbin/softwareupdate with options.

        Provides user feedback via command line or MunkiStatus.

        Args:
          options_list: sequence of options to send to softwareupdate.
          stopped_allowed:
          mode:
          results:
        Returns:
          Integer softwareupdate exit code.
        """
        if results is None:
            # we're not interested in the results,
            # but need to create a temporary dict anyway
            results = {}

        # we need to wrap our call to /usr/sbin/softwareupdate with a utility
        # that makes softwareupdate think it is connected to a tty-like
        # device so its output is unbuffered so we can get progress info
        #
        # Try to find our ptyexec tool
        # first look in the parent directory of the parent directory of this
        # file's directory
        # (../)
        parent_dir = (
            os.path.dirname(
                os.path.dirname(
                    os.path.dirname(
                        os.path.abspath(__file__)))))
        ptyexec_path = os.path.join(parent_dir, 'ptyexec')
        if not os.path.exists(ptyexec_path):
            # try absolute path in munki's normal install dir
            ptyexec_path = '/usr/local/munki/ptyexec'
        if os.path.exists(ptyexec_path):
            cmd = [ptyexec_path]
        else:
            # fall back to /usr/bin/script
            # this is not preferred because it uses way too much CPU
            # checking stdin for input that will never come...
            cmd = ['/usr/bin/script', '-q', '-t', '1', '/dev/null']
        cmd.extend(['/usr/sbin/softwareupdate', '--verbose'])

        os_version_tuple = osutils.getOsVersion(as_tuple=True)
        if catalog_url:
            # OS version-specific stuff to use a specific CatalogURL
            if os_version_tuple < (10, 9):
                cmd.extend(['--CatalogURL', catalog_url])
            elif os_version_tuple in [(10, 9), (10, 10)]:
                su_prefs.set_custom_catalogurl(catalog_url)

        cmd.extend(options_list)

        display.display_debug1('softwareupdate cmd: %s', cmd)

        try:
            job = launchd.Job(cmd)
            job.start()
        except launchd.LaunchdJobException as err:
            display.display_warning(
                'Error with launchd job (%s): %s', cmd, err)
            display.display_warning('Skipping softwareupdate run.')
            return -3

        results['installed'] = []
        results['download'] = []
        results['failures'] = []

        last_output = None
        while True:
            if stop_allowed and processes.stop_requested():
                job.stop()
                break

            output = job.stdout.readline()
            if not output:
                if job.returncode() is not None:
                    break
                else:
                    # no data, but we're still running
                    # sleep a bit before checking for more output
                    time.sleep(1)
                    continue

            # Don't bother parsing the stdout output if it hasn't changed since
            # the last loop iteration.
            if last_output == output:
                continue
            last_output = output

            output = output.decode('UTF-8').strip()
            # send the output to STDOUT or MunkiStatus as applicable
            if output.startswith('Progress: '):
                # Snow Leopard/Lion progress info with '-v' flag
                try:
                    percent = int(output[10:].rstrip('%'))
                except ValueError:
                    percent = -1
                display.display_percent_done(percent, 100)
            elif output.startswith('Software Update Tool'):
                # don't display this
                pass
            elif output.startswith('Copyright 2'):
                # don't display this
                pass
            elif output.startswith('Installing ') and mode == 'install':
                item = output[11:]
                if item:
                    self._display_status_major(output)
            elif output.startswith('Downloaded ') and mode == 'install':
                # don't display this
                pass
            elif output.startswith('Installed '):
                # 10.6 / 10.7 / 10.8. Successful install of package name.
                if mode == 'install':
                    display.display_status_minor(output)
                    results['installed'].append(output[10:])
                else:
                    pass
                    # don't display.
                    # softwareupdate logging "Installed" at the end of a
                    # successful download-only session is odd.
            elif output.startswith('Done with ') and mode == 'install':
                # 10.9 successful install
                display.display_status_minor(output)
                results['installed'].append(output[10:])
            elif output.startswith('Done '):
                # 10.5. Successful install of package name.
                display.display_status_minor(output)
                results['installed'].append(output[5:])
            elif output.startswith('Downloading ') and mode == 'install':
                # This is 10.5 & 10.7 behavior for a missing subpackage.
                display.display_warning(
                    'A necessary subpackage is not available on disk '
                    'during an Apple Software Update installation '
                    'run: %s' % output)
                results['download'].append(output[12:])
            elif output.startswith('Package failed:'):
                # Doesn't tell us which package.
                display.display_error(
                    'Apple update failed to install: %s' % output)
                results['failures'].append(output)
            elif output.startswith('x '):
                # don't display this, it's just confusing
                pass
            elif 'Missing bundle identifier' in output:
                # don't display this, it's noise
                pass
            elif output == '':
                pass
            else:
                display.display_status_minor(output)

        if catalog_url:
            # reset CatalogURL if needed
            if os_version_tuple in [(10, 9), (10, 10)]:
                su_prefs.reset_original_catalogurl()

        retcode = job.returncode()
        if retcode == 0:
            # get SoftwareUpdate's LastResultCode
            last_result_code = su_prefs.pref(
                'LastResultCode') or 0
            if last_result_code > 2:
                retcode = last_result_code

            if results['failures']:
                return 1

        return retcode

    def install_apple_updates(self, only_unattended=False):
        """Uses softwareupdate to install previously downloaded updates.

        Returns:
          Boolean. True if a restart is needed after install, False otherwise.
        """
        # disable Stop button if we are presenting GUI status
        if display.munkistatusoutput:
            munkistatus.hideStopButton()

        # Get list of unattended_installs
        if only_unattended:
            msg = 'Installing unattended Apple Software Updates...'
            unattended_install_items, unattended_install_product_ids = \
                self.get_unattended_installs()
            # ensure that we don't restart for unattended installations
            restartneeded = False
            if not unattended_install_items:
                return False  # didn't find any unattended installs
        else:
            msg = 'Installing available Apple Software Updates...'
            restartneeded = self.restart_needed()

        self._display_status_major(msg)

        installlist = self.software_update_info()
        installresults = {'installed': [], 'download': []}

        su_options = ['-i']

        if only_unattended:
            # Append list of unattended_install items
            su_options.extend(unattended_install_items)
            # Filter installist to only include items
            # which we're attempting to install
            installlist = [item for item in installlist
                           if item.get('productKey') in
                           unattended_install_product_ids]
        else:
            # We're installing all available updates; add all their names
            for item in installlist:
                su_options.append(
                    item['name'] + '-' + item['version_to_install'])

        # new in 10.11: '--no-scan' flag to tell softwareupdate to just install
        # and not rescan for available updates.
        os_version_tuple = osutils.getOsVersion(as_tuple=True)
        if os_version_tuple >= (10, 11):
            su_options.append('--no-scan')
            # 10.11 seems not to like file:// URLs, and we don't really need
            # to switch to a local file URL anyway since we now have the
            # --no-scan option
            catalog_url = None
        else:
            # use our local catalog
            if not os.path.exists(self.applesync.local_catalog_path):
                display.display_error(
                    'Missing local Software Update catalog at %s',
                    self.applesync.local_catalog_path)
                return False  # didn't do anything, so no restart needed
            catalog_url = 'file://localhost' + urllib2.quote(
                self.applesync.local_catalog_path)

        retcode = self._run_softwareupdate(
            su_options, mode='install', catalog_url=catalog_url,
            results=installresults)
        if not 'InstallResults' in reports.report:
            reports.report['InstallResults'] = []

        display.display_debug1(
            'Raw Apple Update install results: %s', installresults)
        for item in installlist:
            rep = {}
            rep['name'] = item.get('apple_product_name')
            rep['version'] = item.get('version_to_install', '')
            rep['applesus'] = True
            rep['time'] = NSDate.new()
            rep['productKey'] = item.get('productKey', '')
            message = 'Apple Software Update install of %s-%s: %s'
            if rep['name'] in installresults['installed']:
                rep['status'] = 0
                install_status = 'SUCCESSFUL'
            elif ('display_name' in item and
                  item['display_name'] in installresults['installed']):
                rep['status'] = 0
                install_status = 'SUCCESSFUL'
            elif rep['name'] in installresults['download']:
                rep['status'] = -1
                install_status = 'FAILED due to missing package.'
                display.display_warning(
                    'Apple update %s, %s failed. A sub-package was missing '
                    'on disk at time of install.'
                    % (rep['name'], rep['productKey']))
            else:
                rep['status'] = -2
                install_status = 'FAILED for unknown reason'
                display.display_warning(
                    'Apple update %s, %s may have failed to install. No record '
                    'of success or failure.', rep['name'], rep['productKey'])
                if installresults['installed']:
                    display.display_warning(
                        'softwareupdate recorded these installations: %s',
                        installresults['installed'])

            reports.report['InstallResults'].append(rep)
            log_msg = message % (rep['name'], rep['version'], install_status)
            munkilog.log(log_msg, 'Install.log')

        if retcode:  # there was an error
            display.display_error('softwareupdate error: %s' % retcode)

        # clean up our now stale local cache
        if not only_unattended:
            self.applesync.clean_up_cache()
        # remove the now invalid AppleUpdates.plist and AvailableUpdates.plist
        self.clear_apple_update_info()
        # Also clear our pref value for last check date. We may have
        # just installed an update which is a pre-req for some other update.
        # Let's check again soon.
        prefs.set_pref('LastAppleSoftwareUpdateCheck', None)

        # show stop button again
        if display.munkistatusoutput:
            munkistatus.showStopButton()

        return restartneeded

    def software_updates_available(
            self, force_check=False, suppress_check=False):
        """Checks for available Apple Software Updates, trying not to hit the
        SUS more than needed.

        Args:
          force_check: Boolean. If True, forces a softwareupdate run.
          suppress_check: Boolean. If True, skips a softwareupdate run.
        Returns:
          Integer. Count of available Apple updates.
        """
        success = True
        if suppress_check:
            # don't check at all --
            # typically because we are doing a logout install
            # just return any AppleUpdates info we already have
            if not os.path.exists(self.apple_updates_plist):
                return 0
            try:
                plist = FoundationPlist.readPlist(self.apple_updates_plist)
            except FoundationPlist.FoundationPlistException:
                plist = {}
            return len(plist.get('AppleUpdates', []))
        if force_check:
            # typically because user initiated the check from
            # Managed Software Update.app
            success = self.check_for_software_updates(force_check=True)
        else:
            # have we checked recently?  Don't want to check with
            # Apple Software Update server too frequently
            now = NSDate.new()
            next_su_check = now
            last_su_check_string = prefs.pref(
                'LastAppleSoftwareUpdateCheck')
            if last_su_check_string:
                try:
                    last_su_check = NSDate.dateWithString_(
                        last_su_check_string)
                    # dateWithString_ returns None if invalid date string.
                    if not last_su_check:
                        raise ValueError
                    interval = 24 * 60 * 60
                    # only force check every 24 hours.
                    next_su_check = last_su_check.dateByAddingTimeInterval_(
                        interval)
                except (ValueError, TypeError):
                    pass
            if now.timeIntervalSinceDate_(next_su_check) >= 0:
                success = self.check_for_software_updates(force_check=True)
            else:
                success = self.check_for_software_updates(force_check=False)
        display.display_debug1(
            'CheckForSoftwareUpdates result: %s' % success)
        if success:
            count = self.write_appleupdates_file()
        else:
            self.clear_apple_update_info()
            return 0
        return count

    def copy_update_metadata(self, item, metadata):
        """Applies metadata to Apple update item restricted
        to keys contained in 'metadata_to_copy'.
        """
        # pylint: disable=no-self-use
        metadata_to_copy = ['blocking_applications',
                            'description',
                            'display_name',
                            'force_install_after_date',
                            'unattended_install',
                            'RestartAction']

        # Mapping of supported restart_actions to
        # equal or greater auxiliary actions
        restart_actions = {
            'RequireRestart': ['RequireRestart', 'RecommendRestart'],
            'RecommendRestart': ['RequireRestart', 'RecommendRestart'],
            'RequireLogout': ['RequireRestart', 'RecommendRestart',
                              'RequireLogout'],
            'None': ['RequireRestart', 'RecommendRestart',
                     'RequireLogout']
        }

        for key in metadata:
            # Apply 'white-listed', non-empty metadata keys
            if key in metadata_to_copy and metadata[key]:
                if key == 'RestartAction':
                    # Ensure that a heavier weighted 'RestartAction' is not
                    # overridden by one supplied in metadata
                    if metadata[key] not in restart_actions.get(
                            item.get(key, 'None')):
                        display.display_debug2(
                            '\tSkipping metadata RestartAction\'%s\' '
                            'for item %s (ProductKey %s), '
                            'item\'s original \'%s\' is preferred.',
                            metadata[key], item.get('name'),
                            item.get('productKey'), item[key])
                        continue
                elif key == 'unattended_install':
                    # Don't apply unattended_install if a RestartAction exists
                    # in either the original item or metadata
                    if metadata.get('RestartAction', 'None') != 'None':
                        display.display_warning(
                            '\tIgnoring unattended_install key for Apple '
                            'update %s (ProductKey %s) '
                            'because metadata RestartAction is %s.',
                            item.get('name'), item.get('productKey'),
                            metadata.get('RestartAction'))
                        continue
                    if item.get('RestartAction', 'None') != 'None':
                        display.display_warning(
                            '\tIgnoring unattended_install key for Apple '
                            'update %s (ProductKey %s) '
                            'because item RestartAction is %s.'
                            % (item.get('name'), item.get('productKey'),
                               item.get('RestartAction')))
                        continue
                display.display_debug2('\tApplying %s...' % key)
                item[key] = metadata[key]
        return item

    def get_unattended_installs(self):
        """Processes AppleUpdates.plist to return a list
        of NAME-VERSION formatted items and a list of product_ids
        which are elgible for unattended installation.
        """
        item_list = []
        product_ids = []
        try:
            pl_dict = FoundationPlist.readPlist(self.apple_updates_plist)
        except FoundationPlist.FoundationPlistException:
            display.display_error(
                'Error reading: %s', self.apple_updates_plist)
            return item_list, product_ids
        apple_updates = pl_dict.get('AppleUpdates', [])
        os_version_tuple = osutils.getOsVersion(as_tuple=True)
        for item in apple_updates:
            if (item.get('unattended_install') or
                    (prefs.pref('UnattendedAppleUpdates') and
                     item.get('RestartAction', 'None') is 'None' and
                     os_version_tuple >= (10, 10))):
                if processes.blocking_applications_running(item):
                    display.display_detail(
                        'Skipping unattended install of %s because '
                        'blocking application(s) running.'
                        % item['display_name'])
                    continue
                install_item = item['name'] + '-' + item['version_to_install']
                item_list.append(install_item)
                product_ids.append(item['productKey'])
            else:
                display.display_detail(
                    'Skipping install of %s because it\'s not unattended.'
                    % item['display_name'])
        return item_list, product_ids


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