#!/usr/local/sal/Python.framework/Versions/3.8/bin/python3


import datetime
import pathlib
import platform
import plistlib
import re
import subprocess
from distutils.version import StrictVersion

import sal


__version__ = '1.1.0'


def main():
    sus_submission = {}
    sus_submission['facts'] = get_sus_facts()

    # Process managed items and update histories.
    sus_submission['managed_items'] = get_sus_install_report()
    sus_submission['update_history'] = []

    pending = get_pending()
    sus_submission['managed_items'].update(pending)

    sal.set_checkin_results('Apple Software Update', sus_submission)


def get_sus_install_report():
    """Return installed apple updates from softwareupdate"""
    try:
        history = plistlib.loads(
            pathlib.Path('/Library/Receipts/InstallHistory.plist').read_bytes())
    except (IOError, plistlib.InvalidFileException):
        history = []
    return {
        i['displayName']: {
            'date_managed': i['date'],
            'status': 'PRESENT',
            'data': {
                'type': 'Apple SUS Install',
                'version': i['displayVersion'].strip()
            }
        } for i in history if i['processName'] == 'softwareupdated'}


def get_sus_facts():
    result = {'checkin_module_version': __version__}
    history_limit = (
        datetime.datetime.now().astimezone(datetime.timezone.utc) - datetime.timedelta(days=1))
    cmd = ['softwareupdate', '--dump-state']
    try:
        subprocess.check_call(cmd)
    except subprocess.CalledProcessError:
        return result

    with open('/var/log/install.log') as handle:
        install_log = handle.readlines()

    for line in reversed(install_log):
        # TODO: Stop if we go before the subprocess call datetime-wise
        if 'Catalog: http' in line and 'catalog' not in result:
            result['catalog'] = line.split()[-1]
        elif 'SUScan: Elapsed scan time = ' in line and 'last_check' not in result:
            result['last_check'] = _get_log_time(line).isoformat()

        if (log_time := _get_log_time(line)) and log_time < history_limit:
            # Let's not look earlier than when we started
            # softwareupdate.
            break

        elif 'catalog' in result and 'last_check' in result:
            # Once we have both facts, bail; no need to process the
            # entire file.
            break

    return result


def _get_log_time(line):
    # Example date 2019-02-08 10:49:56-05
    raw_datetime = ' '.join(line.split()[:2])
    # Add 0's to make TZ offset work with strptime (expects a 4
    # digit offset). This should hopefully cover even those off
    # by minutes locations. e.g. I would hope the above log time in
    # French Polynesia would look like this: 2019-02-08 10:49:56-0930
    raw_datetime += (24 - len(raw_datetime)) * '0'

    try:
        aware_datetime = datetime.datetime.strptime(raw_datetime, '%Y-%m-%d %H:%M:%S%z')
    except ValueError:
        aware_datetime = None
    # Convert to UTC time.
    return None if not aware_datetime else aware_datetime.astimezone(datetime.timezone.utc)


def get_pending():
    pending_items = {}
    cmd = ['softwareupdate', '-l', '--no-scan']
    try:
        # softwareupdate outputs "No new software available" to stderr,
        # so we pipe it off.
        output = subprocess.check_output(cmd, stderr=subprocess.PIPE, text=True)
    except subprocess.CalledProcessError:
        return pending_items

    # The following regex code is from Shea Craig's work on the Salt
    # mac_softwareupdate module. Reference that for future updates.
    if StrictVersion(platform.mac_ver()[0]) >= StrictVersion('10.15'):
        # Example output:
        # Software Update Tool
        #
        # Finding available software
        # Software Update found the following new or updated software:
        # * Label: Command Line Tools beta 5 for Xcode-11.0
        #     Title: Command Line Tools beta 5 for Xcode, Version: 11.0, Size: 224804K, Recommended: YES,
        # * Label: macOS Catalina Developer Beta-6
        #     Title: macOS Catalina Public Beta, Version: 5, Size: 3084292K, Recommended: YES, Action: restart,
        # * Label: BridgeOSUpdateCustomer
        #     Title: BridgeOSUpdateCustomer, Version: 10.15.0.1.1.1560926689, Size: 390674K, Recommended: YES, Action: shut down,
        # - Label: iCal-1.0.2
        #     Title: iCal, Version: 1.0.2, Size: 6520K,
        rexp = re.compile(
            r'(?m)'  # Turn on multiline matching
            r'^\s*[*-] Label: '  # Name lines start with * or - and "Label: "
            r'(?P<name>[^ ].*)[\r\n]'  # Capture the rest of that line; this is the update name.
            r'.*Version: (?P<version>[^,]*), '  # Grab the version number.
            r'Size: (?P<size>[^,]*),\s*'  # Grab the size; unused at this time.
            r'(?P<recommended>Recommended: YES,)?\s*'  # Optionally grab the recommended flag.
            r'(?P<action>Action: (?:restart|shut down),)?'  # Optionally grab an action.
        )
    else:
        # Example output:
        # Software Update Tool
        #
        # Finding available software
        # Software Update found the following new or updated software:
        #    * Command Line Tools (macOS Mojave version 10.14) for Xcode-10.3
        #        Command Line Tools (macOS Mojave version 10.14) for Xcode (10.3), 199140K [recommended]
        #    * macOS 10.14.1 Update
        #        macOS 10.14.1 Update (10.14.1), 199140K [recommended] [restart]
        #    * BridgeOSUpdateCustomer
        #        BridgeOSUpdateCustomer (10.14.4.1.1.1555388607), 328394K, [recommended] [shut down]
        #    - iCal-1.0.2
        #        iCal, (1.0.2), 6520K
        rexp = re.compile(
            r'(?m)'  # Turn on multiline matching
            r'^\s+[*-] '  # Name lines start with 3 spaces and either a * or a -.
            r'(?P<name>.*)[\r\n]'  # The rest of that line is the name.
            r'.*\((?P<version>[^ \)]*)'  # Capture the last parenthesized value on the next line.
            r'[^\r\n\[]*(?P<recommended>\[recommended\])?\s?'  # Capture [recommended] if there.
            r'(?P<action>\[(?:restart|shut down)\])?'  # Capture an action if present.
        )

    # Convert local time to UTC time represented as a ISO 8601 str.
    now = datetime.datetime.now().astimezone(datetime.timezone.utc).isoformat()
    return {
        m.group('name'): {
            'date_managed': now,
            'status': 'PENDING',
            'data': {
                'version': m.group('version'),
                'recommended': 'TRUE' if 'recommended' in m.group('recommended') else 'FALSE',
                'action': _bracket_cleanup(m, 'action')
            }
        } for m in rexp.finditer(output)
    }


def _bracket_cleanup(match, key):
    """Strip out [ and ] and uppercase SUS output"""
    return re.sub(r'[\[\]]', '', match.group(key) or '').upper()


if __name__ == "__main__":
    main()