#!powershell

# Copyright: (c) 2015, Peter Mounce <public@neverrunwithscissors.com>
# Copyright: (c) 2015, Michael Perzel <michaelperzel@gmail.com>
# Copyright: (c) 2017, Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

#Requires -Module Ansible.ModuleUtils.Legacy
#Requires -Module Ansible.ModuleUtils.SID

$ErrorActionPreference = "Stop"

$params = Parse-Args -arguments $args -supports_check_mode $true
$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false
$diff_mode = Get-AnsibleParam -obj $params -name "_ansible_diff" -type "bool" -default $false
$_remote_tmp = Get-AnsibleParam $params "_ansible_remote_tmp" -type "path" -default $env:TMP

$name = Get-AnsibleParam -obj $params -name "name" -type "str" -failifempty $true
$path = Get-AnsibleParam -obj $params -name "path" -type "str" -default "\"
$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "absent", "present"

# task actions, list of dicts [{path, arguments, working_directory}]
$actions = Get-AnsibleParam -obj $params -name "actions" -type "list"

# task triggers, list of dicts [{ type, ... }]
$triggers = Get-AnsibleParam -obj $params -name "triggers" -type "list"

# task Principal properties
$display_name = Get-AnsibleParam -obj $params -name "display_name" -type "str"
$group = Get-AnsibleParam -obj $params -name "group" -type "str"
$logon_type = Get-AnsibleParam -obj $params -name "logon_type" -type "str" -validateset "none","password","s4u","interactive_token","group","service_account","interactive_token_or_password"
$run_level = Get-AnsibleParam -obj $params -name "run_level" -type "str" -validateset "limited", "highest" -aliases "runlevel"
$username = Get-AnsibleParam -obj $params -name "username" -type "str" -aliases "user"
$password = Get-AnsibleParam -obj $params -name "password" -type "str"
$update_password = Get-AnsibleParam -obj $params -name "update_password" -type "bool" -default $true

# task RegistrationInfo properties
$author = Get-AnsibleParam -obj $params -name "author" -type "str"
$date = Get-AnsibleParam -obj $params -name "date" -type "str"
$description = Get-AnsibleParam -obj $params -name "description" -type "str"
$source = Get-AnsibleParam -obj $params -name "source" -type "str"
$version = Get-AnsibleParam -obj $params -name "version" -type "str"

# task Settings properties
$allow_demand_start = Get-AnsibleParam -obj $params -name "allow_demand_start" -type "bool"
$allow_hard_terminate = Get-AnsibleParam -obj $params -name "allow_hard_terminate" -type "bool"
$compatibility =  Get-AnsibleParam -obj $params -name "compatibility" -type "int" # https://msdn.microsoft.com/en-us/library/windows/desktop/aa383486(v=vs.85).aspx
$delete_expired_task_after = Get-AnsibleParam -obj $params -name "delete_expired_task_after" -type "str" # time string PT...
$disallow_start_if_on_batteries = Get-AnsibleParam -obj $params -name "disallow_start_if_on_batteries" -type "bool"
$enabled = Get-AnsibleParam -obj $params -name "enabled" -type "bool"
$execution_time_limit = Get-AnsibleParam -obj $params -name "execution_time_limit" -type "str" # PT72H
$hidden = Get-AnsibleParam -obj $params -name "hidden" -type "bool"
# TODO: support for $idle_settings, needs to be created as a COM object
$multiple_instances = Get-AnsibleParam -obj $params -name "multiple_instances" -type "int" # https://msdn.microsoft.com/en-us/library/windows/desktop/aa383507(v=vs.85).aspx
# TODO: support for  $network_settings, needs to be created as a COM object
$priority = Get-AnsibleParam -obj $params -name "priority" -type "int" # https://msdn.microsoft.com/en-us/library/windows/desktop/aa383512(v=vs.85).aspx
$restart_count = Get-AnsibleParam -obj $params -name "restart_count" -type "int"
$restart_interval = Get-AnsibleParam -obj $params -name "restart_interval" -type "str" # time string PT..
$run_only_if_idle = Get-AnsibleParam -obj $params -name "run_only_if_idle" -type "bool"
$run_only_if_network_available = Get-AnsibleParam -obj $params -name "run_only_if_network_available" -type "bool"
$start_when_available = Get-AnsibleParam -obj $params -name "start_when_available" -type "bool"
$stop_if_going_on_batteries = Get-AnsibleParam -obj $params -name "stop_if_going_on_batteries" -type "bool"
$wake_to_run = Get-AnsibleParam -obj $params -name "wake_to_run" -type "bool"

$result = @{
    changed = $false
}

if ($diff_mode) {
    $result.diff = @{}
}

$task_enums = @"
public enum TASK_ACTION_TYPE // https://msdn.microsoft.com/en-us/library/windows/desktop/aa383553(v=vs.85).aspx
{
    TASK_ACTION_EXEC          = 0,
    // The below are not supported and are only kept for documentation purposes
    TASK_ACTION_COM_HANDLER   = 5,
    TASK_ACTION_SEND_EMAIL    = 6,
    TASK_ACTION_SHOW_MESSAGE  = 7
}

public enum TASK_CREATION // https://msdn.microsoft.com/en-us/library/windows/desktop/aa382538(v=vs.85).aspx
{
    TASK_VALIDATE_ONLY                 = 0x1,
    TASK_CREATE                        = 0x2,
    TASK_UPDATE                        = 0x4,
    TASK_CREATE_OR_UPDATE              = 0x6,
    TASK_DISABLE                       = 0x8,
    TASK_DONT_ADD_PRINCIPAL_ACE        = 0x10,
    TASK_IGNORE_REGISTRATION_TRIGGERS  = 0x20
}

public enum TASK_LOGON_TYPE // https://msdn.microsoft.com/en-us/library/windows/desktop/aa383566(v=vs.85).aspx
{
    TASK_LOGON_NONE                           = 0,
    TASK_LOGON_PASSWORD                       = 1,
    TASK_LOGON_S4U                            = 2,
    TASK_LOGON_INTERACTIVE_TOKEN              = 3,
    TASK_LOGON_GROUP                          = 4,
    TASK_LOGON_SERVICE_ACCOUNT                = 5,
    TASK_LOGON_INTERACTIVE_TOKEN_OR_PASSWORD  = 6
}

public enum TASK_RUN_LEVEL // https://msdn.microsoft.com/en-us/library/windows/desktop/aa380747(v=vs.85).aspx
{
    TASK_RUNLEVEL_LUA      = 0,
    TASK_RUNLEVEL_HIGHEST  = 1
}

public enum TASK_TRIGGER_TYPE2 // https://msdn.microsoft.com/en-us/library/windows/desktop/aa383915(v=vs.85).aspx
{
    TASK_TRIGGER_EVENT                 = 0,
    TASK_TRIGGER_TIME                  = 1,
    TASK_TRIGGER_DAILY                 = 2,
    TASK_TRIGGER_WEEKLY                = 3,
    TASK_TRIGGER_MONTHLY               = 4,
    TASK_TRIGGER_MONTHLYDOW            = 5,
    TASK_TRIGGER_IDLE                  = 6,
    TASK_TRIGGER_REGISTRATION          = 7,
    TASK_TRIGGER_BOOT                  = 8,
    TASK_TRIGGER_LOGON                 = 9,
    TASK_TRIGGER_SESSION_STATE_CHANGE  = 11
}
"@

$original_tmp = $env:TMP
$env:TMP = $_remote_tmp
Add-Type -TypeDefinition $task_enums
$env:TMP = $original_tmp

########################
### HELPER FUNCTIONS ###
########################
Function Convert-SnakeToPascalCase($snake) {
    # very basic function to convert snake_case to PascalCase for use in COM
    # objects
    [regex]$regex = "_(\w)"
    $pascal_case = $regex.Replace($snake, { $args[0].Value.Substring(1).ToUpper() })
    $capitalised = $pascal_case.Substring(0, 1).ToUpper() + $pascal_case.Substring(1)

    return $capitalised
}

Function Compare-Properties($property_name, $parent_property, $map, $enum_map=$null) {
    $changes = [System.Collections.ArrayList]@()

    # loop through the passed in map and compare values
    # Name = The name of property in the COM object
    # Value = The new value to compare the existing value with
    foreach ($entry in $map.GetEnumerator()) {
        $new_value = $entry.Value

        if ($null -ne $new_value) {
            $property_name = $entry.Name
            $existing_value = $parent_property.$property_name
            if ($existing_value -cne $new_value) {
                try {
                    $parent_property.$property_name = $new_value
                } catch {
                    Fail-Json -obj $result -message "failed to set $property_name property '$property_name' to '$new_value': $($_.Exception.Message)"
                }

                if ($null -ne $enum_map -and $enum_map.ContainsKey($property_name)) {
                    $enum = [type]$enum_map.$property_name
                    $existing_value = [Enum]::ToObject($enum, $existing_value)
                    $new_value = [Enum]::ToObject($enum, $new_value)
                }
                [void]$changes.Add("-$property_name=$existing_value`n+$property_name=$new_value")
            }
        }
    }

    return ,$changes
}

Function Set-PropertyForComObject($com_object, $name, $arg, $value) {
    $com_name = Convert-SnakeToPascalCase -snake $arg
    try {
        $com_object.$com_name = $value
    } catch {
        Fail-Json -obj $result -message "failed to set $name property '$com_name' to '$value': $($_.Exception.Message)"
    }
}

Function Compare-PropertyList {
    Param(
        $collection, # the collection COM object to manipulate, this must contains the Create method
        [string]$property_name, # human friendly name of the property object, e.g. action/trigger
        [Array]$new, # a list of new properties, passed in by Ansible
        [Array]$existing, # a list of existing properties from the COM object collection
        [Hashtable]$map, # metadata for the collection, see below for the structure
        [string]$enum # the parent enum name for type value
    )
    <## map metadata structure
    {
        collection type [TASK_ACTION_TYPE] for Actions or [TASK_TRIGGER_TYPE2] for Triggers {
            mandatory = list of mandatory properties for this type, ansible input name not the COM name
            optional = list of optional properties that could be set for this type
            # maps the ansible input object name to the COM name, e.g. working_directory = WorkingDirectory
            map = {
                ansible input name = COM name
            }
        }
    }##>
    # used by both Actions and Triggers to compare the collections of that property

    $enum = [type]$enum
    $changes = [System.Collections.ArrayList]@()
    $new_count = $new.Count
    $existing_count = $existing.Count

    for ($i = 0; $i -lt $new_count; $i++) {
        if ($i -lt $existing_count) {
            $existing_property = $existing[$i]
        } else {
            $existing_property = $null
        }
        $new_property = $new[$i]

        # get the type of the property, for action this is set automatically
        if (-not $new_property.ContainsKey("type")) {
            Fail-Json -obj $result -message "entry for $property_name must contain a type key"
        }
        $type = $new_property.type
        $valid_types = $map.Keys
        $property_map = $map.$type

        # now let's validate the args for the property
        $mandatory_args = $property_map.mandatory
        $optional_args = $property_map.optional
        $total_args = $mandatory_args + $optional_args

        # validate the mandatory arguments
        foreach ($mandatory_arg in $mandatory_args) {
            if (-not $new_property.ContainsKey($mandatory_arg)) {
                Fail-Json -obj $result -message "mandatory key '$mandatory_arg' for $($property_name) is not set, mandatory keys are '$($mandatory_args -join "', '")'"
            }
        }
        # throw a warning if in invalid key was set
        foreach ($entry in $new_property.GetEnumerator()) {
            $key = $entry.Name
            if ($key -notin $total_args -and $key -ne "type") {
                Add-Warning -obj $result -message "key '$key' for $($property_name) entry is not valid and will be ignored, valid keys are '$($total_args -join "', '")'"
            }
        }

        # now we have validated the input and have gotten the metadata, let's
        # get the diff string
        if ($null -eq $existing_property) {
            # we have more properties than before,just add to the new
            # properties list
            $diff_list = [System.Collections.ArrayList]@()

            foreach ($property_arg in $total_args) {
                if ($new_property.ContainsKey($property_arg)) {
                    $com_name = Convert-SnakeToPascalCase -snake $property_arg
                    $property_value = $new_property.$property_arg

                    if ($property_value -is [Hashtable]) {
                        foreach ($kv in $property_value.GetEnumerator()) {
                            $sub_com_name = Convert-SnakeToPascalCase -snake $kv.Key
                            $sub_property_value = $kv.Value
                            [void]$diff_list.Add("+$com_name.$sub_com_name=$sub_property_value")
                        }
                    } else {
                        [void]$diff_list.Add("+$com_name=$property_value")
                    }
                }
            }

            [void]$changes.Add("+$property_name[$i] = {`n  +Type=$type`n  $($diff_list -join ",`n  ")`n+}")
        } elseif ([Enum]::ToObject($enum, $existing_property.Type) -ne $type) {
            # the types are different so we need to change
            $diff_list = [System.Collections.ArrayList]@()

            if ($existing_property.Type -notin $valid_types) {
                [void]$diff_list.Add("-UNKNOWN TYPE $($existing_property.Type)")
                foreach ($property_args in $total_args) {
                    if ($new_property.ContainsKey($property_arg)) {
                        $com_name = Convert-SnakeToPascalCase -snake $property_arg
                        $property_value = $new_property.$property_arg

                        if ($property_value -is [Hashtable]) {
                            foreach ($kv in $property_value.GetEnumerator()) {
                                $sub_com_name = Convert-SnakeToPascalCase -snake $kv.Key
                                $sub_property_value = $kv.Value
                                [void]$diff_list.Add("+$com_name.$sub_com_name=$sub_property_value")
                            }
                        } else {
                            [void]$diff_list.Add("+$com_name=$property_value")
                        }
                    }
                }
            } else {
                # we know the types of the existing property
                $existing_type = [Enum]::ToObject([TASK_TRIGGER_TYPE2], $existing_property.Type)
                [void]$diff_list.Add("-Type=$existing_type")
                [void]$diff_list.Add("+Type=$type")
                foreach ($property_arg in $total_args) {
                    $com_name = Convert-SnakeToPascalCase -snake $property_arg
                    $property_value = $new_property.$property_arg
                    $existing_value = $existing_property.$com_name

                    if ($property_value -is [Hashtable]) {
                        foreach ($kv in $property_value.GetEnumerator()) {
                            $sub_property_value = $kv.Value
                            $sub_com_name = Convert-SnakeToPascalCase -snake $kv.Key
                            $sub_existing_value = $existing_property.$com_name.$sub_com_name

                            if ($null -ne $sub_property_value) {
                                [void]$diff_list.Add("+$com_name.$sub_com_name=$sub_property_value")
                            }

                            if ($null -ne $sub_existing_value) {
                                [void]$diff_list.Add("-$com_name.$sub_com_name=$sub_existing_value")
                            }
                        }
                    } else {
                        if ($null -ne $property_value) {
                            [void]$diff_list.Add("+$com_name=$property_value")
                        }

                        if ($null -ne $existing_value) {
                            [void]$diff_list.Add("-$com_name=$existing_value")
                        }
                    }
                }
            }

            [void]$changes.Add("$property_name[$i] = {`n  $($diff_list -join ",`n  ")`n}")
        } else {
            # compare the properties of existing and new
            $diff_list = [System.Collections.ArrayList]@()

            foreach ($property_arg in $total_args) {
                $com_name = Convert-SnakeToPascalCase -snake $property_arg
                $property_value = $new_property.$property_arg
                $existing_value = $existing_property.$com_name

                if ($property_value -is [Hashtable]) {
                    foreach ($kv in $property_value.GetEnumerator()) {
                        $sub_property_value = $kv.Value

                        if ($null -ne $sub_property_value) {
                            $sub_com_name = Convert-SnakeToPascalCase -snake $kv.Key
                            $sub_existing_value = $existing_property.$com_name.$sub_com_name

                            if ($sub_property_value -cne $sub_existing_value) {
                                [void]$diff_list.Add("-$com_name.$sub_com_name=$sub_existing_value")
                                [void]$diff_list.Add("+$com_name.$sub_com_name=$sub_property_value")
                            }
                        }
                    }
                } elseif ($null -ne $property_value -and $property_value -cne $existing_value) {
                    [void]$diff_list.Add("-$com_name=$existing_value")
                    [void]$diff_list.Add("+$com_name=$property_value")
                }
            }

            if ($diff_list.Count -gt 0) {
                [void]$changes.Add("$property_name[$i] = {`n  $($diff_list -join ",`n  ")`n}")
            }
        }

        # finally rebuild the new property collection
        $new_object = $collection.Create($type)
        foreach ($property_arg in $total_args) {
            $new_value = $new_property.$property_arg
            if ($new_value -is [Hashtable]) {
                $com_name = Convert-SnakeToPascalCase -snake $property_arg
                $new_object_property = $new_object.$com_name

                foreach ($kv in $new_value.GetEnumerator()) {
                    $value = $kv.Value
                    if ($null -ne $value) {
                        Set-PropertyForComObject -com_object $new_object_property -name $property_name -arg $kv.Key -value $value
                    }
                }
            } elseif ($null -ne $new_value) {
                Set-PropertyForComObject -com_object $new_object -name $property_name -arg $property_arg -value $new_value
            }
        }
    }

    # if there were any extra properties not in the new list, create diff str
    if ($existing_count -gt $new_count) {
        for ($i = $new_count; $i -lt $existing_count; $i++) {
            $diff_list = [System.Collections.ArrayList]@()
            $existing_property = $existing[$i]
            $existing_type = [Enum]::ToObject($enum, $existing_property.Type)

            if ($map.ContainsKey($existing_type)) {
                $property_map = $map.$existing_type
                $property_args = $property_map.mandatory + $property_map.optional

                foreach ($property_arg in $property_args) {
                    $com_name = Convert-SnakeToPascalCase -snake $property_arg
                    $existing_value = $existing_property.$com_name
                    if ($null -ne $existing_value) {
                        [void]$diff_list.Add("-$com_name=$existing_value")
                    }
                }
            } else {
                [void]$diff_list.Add("-UNKNOWN TYPE $existing_type")
            }

            [void]$changes.Add("-$property_name[$i] = {`n  $($diff_list -join ",`n  ")`n-}")
        }
    }

    return ,$changes
}

Function Compare-Actions($task_definition) {
    # compares the Actions property and returns a list of list of changed
    # actions for use in a diff string
    # ActionCollection - https://msdn.microsoft.com/en-us/library/windows/desktop/aa446804(v=vs.85).aspx
    # Action - https://msdn.microsoft.com/en-us/library/windows/desktop/aa446803(v=vs.85).aspx
    if ($null -eq $actions) {
        return ,[System.Collections.ArrayList]@()
    }

    $task_actions = $task_definition.Actions
    $existing_count = $task_actions.Count

    # because we clear the actions and re-add them to keep the order, we need
    # to convert the existing actions to a new list.
    # The Item property in actions starts at 1
    $existing_actions = [System.Collections.ArrayList]@()
    for ($i = 1; $i -le $existing_count; $i++) {
        [void]$existing_actions.Add($task_actions.Item($i))
    }
    if ($existing_count -gt 0) {
        $task_actions.Clear()
    }

    $map = @{
        [TASK_ACTION_TYPE]::TASK_ACTION_EXEC = @{
            mandatory = @('path')
            optional = @('arguments', 'working_directory')
        }
    }
    $changes = Compare-PropertyList -collection $task_actions -property_name "action" -new $actions -existing $existing_actions -map $map -enum TASK_ACTION_TYPE

    return ,$changes
}

Function Compare-Principal($task_definition, $task_definition_xml) {
    # compares the Principal property and returns a list of changed objects for
    # use in a diff string
    # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382071(v=vs.85).aspx
    $principal_map = @{
        DisplayName = $display_name
        LogonType = $logon_type
        RunLevel = $run_level
    }
    $enum_map = @{
        LogonType = "TASK_LOGON_TYPE"
        RunLevel = "TASK_RUN_LEVEL"
    }
    $task_principal = $task_definition.Principal
    $changes = Compare-Properties -property_name "Principal" -parent_property $task_principal -map $principal_map -enum_map $enum_map

    # Principal.UserId and GroupId only returns the username portion of the
    # username, skipping the domain or server name. This makes the
    # comparison process useless so we need to parse the task XML to get
    # the actual sid/username. Depending on OS version this could be the SID
    # or it could be the username, we need to handle that accordingly
    $principal_username_sid = $task_definition_xml.Task.Principals.Principal.UserId
    if ($null -ne $principal_username_sid -and $principal_username_sid -notmatch "^S-\d-\d+(-\d+){1,14}(-\d+){0,1}$") {
        $principal_username_sid = Convert-ToSID -account_name $principal_username_sid
    }
    $principal_group_sid = $task_definition_xml.Task.Principals.Principal.GroupId
    if ($null -ne $principal_group_sid -and $principal_group_sid -notmatch "^S-\d-\d+(-\d+){1,14}(-\d+){0,1}$") {
        $principal_group_sid = Convert-ToSID -account_name $principal_group_sid
    }

    if ($null -ne $username_sid) {
        $new_user_name = Convert-FromSid -sid $username_sid
        if ($null -ne $principal_group_sid) {
            $existing_account_name = Convert-FromSid -sid $principal_group_sid
            [void]$changes.Add("-GroupId=$existing_account_name`n+UserId=$new_user_name")
            $task_principal.UserId = $new_user_name
            $task_principal.GroupId = $null
        } elseif ($null -eq $principal_username_sid) {
            [void]$changes.Add("+UserId=$new_user_name")
            $task_principal.UserId = $new_user_name
        } elseif ($principal_username_sid -ne $username_sid) {
            $existing_account_name = Convert-FromSid -sid $principal_username_sid
            [void]$changes.Add("-UserId=$existing_account_name`n+UserId=$new_user_name")
            $task_principal.UserId = $new_user_name
        }
    }
    if ($null -ne $group_sid) {
        $new_group_name = Convert-FromSid -sid $group_sid
        if ($null -ne $principal_username_sid) {
            $existing_account_name = Convert-FromSid -sid $principal_username_sid
            [void]$changes.Add("-UserId=$existing_account_name`n+GroupId=$new_group_name")
            $task_principal.UserId = $null
            $task_principal.GroupId = $new_group_name
        } elseif ($null -eq $principal_group_sid) {
            [void]$changes.Add("+GroupId=$new_group_name")
            $task_principal.GroupId = $new_group_name
        } elseif ($principal_group_sid -ne $group_sid) {
            $existing_account_name = Convert-FromSid -sid $principal_group_sid
            [void]$changes.Add("-GroupId=$existing_account_name`n+GroupId=$new_group_name")
            $task_principal.GroupId = $new_group_name
        }
    }

    return ,$changes
}

Function Compare-RegistrationInfo($task_definition) {
    # compares the RegistrationInfo property and returns a list of changed
    # objects for use in a diff string
    # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382100(v=vs.85).aspx
    $reg_info_map = @{
        Author = $author
        Date = $date
        Description = $description
        Source = $source
        Version = $version
    }
    $changes = Compare-Properties -property_name "RegistrationInfo" -parent_property $task_definition.RegistrationInfo -map $reg_info_map

    return ,$changes
}

Function Compare-Settings($task_definition) {
    # compares the task Settings property and returns a list of changed objects
    # for use in a diff string
    # https://msdn.microsoft.com/en-us/library/windows/desktop/aa383480(v=vs.85).aspx
    $settings_map = @{
        AllowDemandStart = $allow_demand_start
        AllowHardTerminate = $allow_hard_terminate
        Compatibility = $compatibility
        DeleteExpiredTaskAfter = $delete_expired_task_after
        DisallowStartIfOnBatteries = $disallow_start_if_on_batteries
        ExecutionTimeLimit = $execution_time_limit
        Enabled = $enabled
        Hidden = $hidden
        # IdleSettings = $idle_settings # TODO: this takes in a COM object
        MultipleInstances = $multiple_instances
        # NetworkSettings = $network_settings # TODO: this takes in a COM object
        Priority = $priority
        RestartCount = $restart_count
        RestartInterval = $restart_interval
        RunOnlyIfIdle = $run_only_if_idle
        RunOnlyIfNetworkAvailable = $run_only_if_network_available
        StartWhenAvailable = $start_when_available
        StopIfGoingOnBatteries = $stop_if_going_on_batteries
        WakeToRun = $wake_to_run
    }
    $changes = Compare-Properties -property_name "Settings" -parent_property $task_definition.Settings -map $settings_map

    return ,$changes
}

Function Compare-Triggers($task_definition) {
    # compares the task Triggers property and returns a list of changed objects
    # for use in a diff string
    # TriggerCollection - https://msdn.microsoft.com/en-us/library/windows/desktop/aa383875(v=vs.85).aspx
    # Trigger - https://msdn.microsoft.com/en-us/library/windows/desktop/aa383868(v=vs.85).aspx
    if ($null -eq $triggers) {
        return ,[System.Collections.ArrayList]@()
    }

    $task_triggers = $task_definition.Triggers
    $existing_count = $task_triggers.Count

    # because we clear the actions and re-add them to keep the order, we need
    # to convert the existing actions to a new list.
    # The Item property in actions starts at 1
    $existing_triggers = [System.Collections.ArrayList]@()
    for ($i = 1; $i -le $existing_count; $i++) {
        [void]$existing_triggers.Add($task_triggers.Item($i))
    }
    if ($existing_count -gt 0) {
        $task_triggers.Clear()
    }

    $map = @{
        [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_BOOT = @{
            mandatory = @()
            optional = @('delay', 'enabled', 'end_boundary', 'execution_time_limit', 'start_boundary', 'repetition')
        }
        [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_DAILY = @{
            mandatory = @('start_boundary')
            optional = @('days_interval', 'enabled', 'end_boundary', 'execution_time_limit', 'random_delay', 'repetition')
        }
        [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_EVENT = @{
            mandatory = @('subscription')
            # TODO: ValueQueries is a COM object
            optional = @('delay', 'enabled', 'end_boundary', 'execution_time_limit', 'start_boundary', 'repetition')
        }
        [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_IDLE = @{
            mandatory = @()
            optional = @('enabled', 'end_boundary', 'execution_time_limit', 'start_boundary', 'repetition')
        }
        [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_LOGON = @{
            mandatory = @()
            optional = @('delay', 'enabled', 'end_boundary', 'execution_time_limit', 'start_boundary', 'user_id', 'repetition')
        }
        [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_MONTHLYDOW = @{
            mandatory = @('start_boundary')
            optional = @('days_of_week', 'enabled', 'end_boundary', 'execution_time_limit', 'months_of_year', 'random_delay', 'run_on_last_week_of_month', 'weeks_of_month', 'repetition')
        }
        [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_MONTHLY = @{
            mandatory = @('days_of_month', 'start_boundary')
            optional = @('enabled', 'end_boundary', 'execution_time_limit', 'months_of_year', 'random_delay', 'run_on_last_day_of_month', 'start_boundary', 'repetition')
        }
        [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_REGISTRATION = @{
            mandatory = @()
            optional = @('delay', 'enabled', 'end_boundary', 'execution_time_limit', 'start_boundary', 'repetition')
        }
        [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_TIME = @{
            mandatory = @('start_boundary')
            optional = @('enabled', 'end_boundary', 'execution_time_limit', 'random_delay', 'repetition')
        }
        [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_WEEKLY = @{
            mandatory = @('days_of_week', 'start_boundary')
            optional = @('enabled', 'end_boundary', 'execution_time_limit', 'random_delay', 'weeks_interval', 'repetition')
        }
        [TASK_TRIGGER_TYPE2]::TASK_TRIGGER_SESSION_STATE_CHANGE = @{
            mandatory = @('days_of_week', 'start_boundary')
            optional = @('delay', 'enabled', 'end_boundary', 'execution_time_limit', 'state_change', 'user_id', 'repetition')
        }
    }
    $changes = Compare-PropertyList -collection $task_triggers -property_name "trigger" -new $triggers -existing $existing_triggers -map $map -enum TASK_TRIGGER_TYPE2

    return ,$changes
}

Function Test-TaskExists($task_folder, $name) {
    # checks if a task exists in the TaskFolder COM object, returns null if the
    # task does not exist, otherwise returns the RegisteredTask object
    $task = $null
    if ($task_folder) {
        $raw_tasks = $task_folder.GetTasks(1) # 1 = TASK_ENUM_HIDDEN

        for ($i = 1; $i -le $raw_tasks.Count; $i++) {
            if ($raw_tasks.Item($i).Name -eq $name) {
                $task = $raw_tasks.Item($i)
                break
            }
        }
    }

    return $task
}

Function Test-XmlDurationFormat($key, $value) {
    # validate value is in the Duration Data Type format
    # PnYnMnDTnHnMnS
    try {
        $time_span = [System.Xml.XmlConvert]::ToTimeSpan($value)
        return $time_span
    } catch [System.FormatException] {
        Fail-Json -obj $result -message "trigger option '$key' must be in the XML duration format but was '$value'"
    }
}

######################################
### VALIDATION/BUILDING OF OPTIONS ###
######################################
# convert username and group to SID if set
$username_sid = $null
if ($username) {
    $username_sid = Convert-ToSID -account_name $username
}
$group_sid = $null
if ($group) {
    $group_sid = Convert-ToSID -account_name $group
}

# validate store_password and logon_type
if ($null -ne $logon_type) {
    $full_enum_name = "TASK_LOGON_$($logon_type.ToUpper())"
    $logon_type = [TASK_LOGON_TYPE]::$full_enum_name
}

# now validate the logon_type option with the other parameters
if ($null -ne $username -and $null -ne $group) {
    Fail-Json -obj $result -message "username and group can not be set at the same time"
}
if ($null -ne $logon_type) {
    if ($logon_type -eq [TASK_LOGON_TYPE]::TASK_LOGON_PASSWORD -and $null -eq $password) {
        Fail-Json -obj $result -message "password must be set when logon_type=password"
    }
    if ($logon_type -eq [TASK_LOGON_TYPE]::TASK_LOGON_S4U -and $null -eq $password) {
        Fail-Json -obj $result -message "password must be set when logon_type=s4u"
    }

    if ($logon_type -eq [TASK_LOGON_TYPE]::TASK_LOGON_GROUP -and $null -eq $group) {
        Fail-Json -obj $result -message "group must be set when logon_type=group"
    }

    # SIDs == Local System, Local Service and Network Service
    if ($logon_type -eq [TASK_LOGON_TYPE]::TASK_LOGON_SERVICE_ACCOUNT -and $username_sid -notin @("S-1-5-18", "S-1-5-19", "S-1-5-20")) {
        Fail-Json -obj $result -message "username must be SYSTEM, LOCAL SERVICE or NETWORK SERVICE when logon_type=service_account"
    }
}

# convert the run_level to enum value
if ($null -ne $run_level) {
    if ($run_level -eq "limited") {
        $run_level = [TASK_RUN_LEVEL]::TASK_RUNLEVEL_LUA
    } else {
        $run_level = [TASK_RUN_LEVEL]::TASK_RUNLEVEL_HIGHEST
    }
}

# manually add the only support action type for each action - also convert PSCustomObject to Hashtable
for ($i = 0; $i -lt $actions.Count; $i++) {
    $action = $actions[$i]
    $action.type = [TASK_ACTION_TYPE]::TASK_ACTION_EXEC
    if (-not $action.ContainsKey("path")) {
        Fail-Json -obj $result -message "action entry must contain the key 'path'"
    }
    $actions[$i] = $action
}

# convert and validate the triggers - and convert PSCustomObject to Hashtable
for ($i = 0; $i -lt $triggers.Count; $i++) {
    $trigger = $triggers[$i]
    $valid_trigger_types = @('event', 'time', 'daily', 'weekly', 'monthly', 'monthlydow', 'idle', 'registration', 'boot', 'logon', 'session_state_change')
    if (-not $trigger.ContainsKey("type")) {
        Fail-Json -obj $result -message "a trigger entry must contain a key 'type' with a value of '$($valid_trigger_types -join "', '")'"
    }

    $trigger_type = $trigger.type
    if ($trigger_type -notin $valid_trigger_types) {
        Fail-Json -obj $result -message "the specified trigger type '$trigger_type' is not valid, type must be a value of '$($valid_trigger_types -join "', '")'"
    }

    $full_enum_name = "TASK_TRIGGER_$($trigger_type.ToUpper())"
    $trigger_type = [TASK_TRIGGER_TYPE2]::$full_enum_name
    $trigger.type = $trigger_type

    $date_properties = @('start_boundary', 'end_boundary')
    foreach ($property_name in $date_properties) {
        # validate the date is in the DateTime format
        # yyyy-mm-ddThh:mm:ss
        if ($trigger.ContainsKey($property_name)) {
            $date_value = $trigger.$property_name
            try {
                $date = Get-Date -Date $date_value -Format s
                # make sure we convert it to the full string format
                $trigger.$property_name = $date.ToString()
            } catch [System.Management.Automation.ParameterBindingException] {
                Fail-Json -obj $result -message "trigger option '$property_name' must be in the format 'YYYY-MM-DDThh:mm:ss' format but was '$date_value'"
            }
        }
    }

    $time_properties = @('execution_time_limit', 'delay', 'random_delay')
    foreach ($property_name in $time_properties) {
        if ($trigger.ContainsKey($property_name)) {
            $time_span = $trigger.$property_name
            Test-XmlDurationFormat -key $property_name -value $time_span
        }
    }

    if ($trigger.ContainsKey("repetition")) {
        if ($trigger.repetition -is [Array]) {
            Add-DeprecationWarning -obj $result -message "repetition is a list, should be defined as a dict" -version "2.12"
            $trigger.repetition = $trigger.repetition[0]
        }

        $interval_timespan = $null
        if ($trigger.repetition.ContainsKey("interval") -and $null -ne $trigger.repetition.interval) {
            $interval_timespan = Test-XmlDurationFormat -key "interval" -value $trigger.repetition.interval
        }

        $duration_timespan = $null
        if ($trigger.repetition.ContainsKey("duration") -and $null -ne $trigger.repetition.duration) {
            $duration_timespan = Test-XmlDurationFormat -key "duration" -value $trigger.repetition.duration
        }

        if ($null -ne $interval_timespan -and $null -ne $duration_timespan -and $interval_timespan -gt $duration_timespan) {
            Fail-Json -obj $result -message "trigger repetition option 'interval' value '$($trigger.repetition.interval)' must be less than or equal to 'duration' value '$($trigger.repetition.duration)'"
        }
    }

    # convert out human readble text to the hex values for these properties
    if ($trigger.ContainsKey("days_of_week")) {
        $days = $trigger.days_of_week
        if ($days -is [String]) {
            $days = $days.Split(",").Trim()
        } elseif ($days -isnot [Array]) {
            $days = @($days)
        }

        $day_value = 0
        foreach ($day in $days) {
            # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382057(v=vs.85).aspx
            switch ($day) {
                sunday { $day_value = $day_value -bor 0x01 }
                monday { $day_value = $day_value -bor 0x02 }
                tuesday { $day_value = $day_value -bor 0x04 }
                wednesday { $day_value = $day_value -bor 0x08 }
                thursday { $day_value = $day_value -bor 0x10 }
                friday { $day_value = $day_value -bor 0x20 }
                saturday { $day_value = $day_value -bor 0x40 }
                default { Fail-Json -obj $result -message "invalid day of week '$day', check the spelling matches the full day name" }
            }
        }
        if ($day_value -eq 0) {
            $day_value = $null
        }

        $trigger.days_of_week = $day_value
    }
    if ($trigger.ContainsKey("days_of_month")) {
        $days = $trigger.days_of_month
        if ($days -is [String]) {
            $days = $days.Split(",").Trim()
        } elseif ($days -isnot [Array]) {
            $days = @($days)
        }

        $day_value = 0
        foreach ($day in $days) {
            # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382063(v=vs.85).aspx
            switch ($day) {
                1 { $day_value = $day_value -bor 0x01 }
                2 { $day_value = $day_value -bor 0x02 }
                3 { $day_value = $day_value -bor 0x04 }
                4 { $day_value = $day_value -bor 0x08 }
                5 { $day_value = $day_value -bor 0x10 }
                6 { $day_value = $day_value -bor 0x20 }
                7 { $day_value = $day_value -bor 0x40 }
                8 { $day_value = $day_value -bor 0x80 }
                9 { $day_value = $day_value -bor 0x100 }
                10 { $day_value = $day_value -bor 0x200 }
                11 { $day_value = $day_value -bor 0x400 }
                12 { $day_value = $day_value -bor 0x800 }
                13 { $day_value = $day_value -bor 0x1000 }
                14 { $day_value = $day_value -bor 0x2000 }
                15 { $day_value = $day_value -bor 0x4000 }
                16 { $day_value = $day_value -bor 0x8000 }
                17 { $day_value = $day_value -bor 0x10000 }
                18 { $day_value = $day_value -bor 0x20000 }
                19 { $day_value = $day_value -bor 0x40000 }
                20 { $day_value = $day_value -bor 0x80000 }
                21 { $day_value = $day_value -bor 0x100000 }
                22 { $day_value = $day_value -bor 0x200000 }
                23 { $day_value = $day_value -bor 0x400000 }
                24 { $day_value = $day_value -bor 0x800000 }
                25 { $day_value = $day_value -bor 0x1000000 }
                26 { $day_value = $day_value -bor 0x2000000 }
                27 { $day_value = $day_value -bor 0x4000000 }
                28 { $day_value = $day_value -bor 0x8000000 }
                29 { $day_value = $day_value -bor 0x10000000 }
                30 { $day_value = $day_value -bor 0x20000000 }
                31 { $day_value = $day_value -bor 0x40000000 }
                default { Fail-Json -obj $result -message "invalid day of month '$day', please specify numbers from 1-31" }
            }
        }
        if ($day_value -eq 0) {
            $day_value = $null
        }
        $trigger.days_of_month = $day_value
    }
    if ($trigger.ContainsKey("weeks_of_month")) {
        $weeks = $trigger.weeks_of_month
        if ($weeks -is [String]) {
            $weeks = $weeks.Split(",").Trim()
        } elseif ($weeks -isnot [Array]) {
            $weeks = @($weeks)
        }

        $week_value = 0
        foreach ($week in $weeks) {
            # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382061(v=vs.85).aspx
            switch ($week) {
                1 { $week_value = $week_value -bor 0x01 }
                2 { $week_value = $week_value -bor 0x02 }
                3 { $week_value = $week_value -bor 0x04 }
                4 { $week_value = $week_value -bor 0x08 }
                default { Fail-Json -obj $result -message "invalid week of month '$week', please specify weeks from 1-4" }
            }

        }
        if ($week_value -eq 0) {
            $week_value = $null
        }
        $trigger.weeks_of_month = $week_value
    }
    if ($trigger.ContainsKey("months_of_year")) {
        $months = $trigger.months_of_year
        if ($months -is [String]) {
            $months = $months.Split(",").Trim()
        } elseif ($months -isnot [Array]) {
            $months = @($months)
        }

        $month_value = 0
        foreach ($month in $months) {
            # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382064(v=vs.85).aspx
            switch ($month) {
                january { $month_value = $month_value -bor 0x01 }
                february { $month_value = $month_value -bor 0x02 }
                march { $month_value = $month_value -bor 0x04 }
                april { $month_value = $month_value -bor 0x08 }
                may { $month_value = $month_value -bor 0x10 }
                june { $month_value = $month_value -bor 0x20 }
                july { $month_value = $month_value -bor 0x40 }
                august { $month_value = $month_value -bor 0x80 }
                september { $month_value = $month_value -bor 0x100 }
                october { $month_value = $month_value -bor 0x200 }
                november { $month_value = $month_value -bor 0x400 }
                december { $month_value = $month_value -bor 0x800 }
                default { Fail-Json -obj $result -message "invalid month name '$month', please specify full month name" }
            }
        }
        if ($month_value -eq 0) {
            $month_value = $null
        }
        $trigger.months_of_year = $month_value
    }
    $triggers[$i] = $trigger
}

# add \ to start of path if it is not already there
if (-not $path.StartsWith("\")) {
    $path = "\$path"
}
# ensure path does not end with \ if more than 1 char
if ($path.EndsWith("\") -and $path.Length -ne 1) {
    $path = $path.Substring(0, $path.Length - 1)
}

########################
### START CODE BLOCK ###
########################
$service = New-Object -ComObject Schedule.Service
try {
    $service.Connect()
} catch {
    Fail-Json -obj $result -message "failed to connect to the task scheduler service: $($_.Exception.Message)"
}

# check that the path for the task set exists, create if need be
try {
    $task_folder = $service.GetFolder($path)
} catch {
    $task_folder = $null
}

# try and get the task at the path
$task = Test-TaskExists -task_folder $task_folder -name $name
$task_path = Join-Path -Path $path -ChildPath $name

if ($state -eq "absent") {
    if ($null -ne $task) {
        if (-not $check_mode) {
            try {
                $task_folder.DeleteTask($name, 0)
            } catch {
                Fail-Json -obj $result -message "failed to delete task '$name' at path '$path': $($_.Exception.Message)"
            }
        }
        if ($diff_mode) {
            $result.diff.prepared = "-[Task]`n-$task_path`n"
        }
        $result.changed = $true

        # check if current folder has any more tasks
        $other_tasks = $task_folder.GetTasks(1) # 1 = TASK_ENUM_HIDDEN
        if ($other_tasks.Count -eq 0 -and $task_folder.Name -ne "\") {
            try {
                $task_folder.DeleteFolder($null, $null)
            } catch {
                Fail-Json -obj $result -message "failed to delete empty task folder '$path' after task deletion: $($_.Exception.Message)"
            }
        }
    }
} else {
    if ($null -eq $task) {
        $create_diff_string = "+[Task]`n+$task_path`n`n"
        # to create a bare minimum task we need 1 action
        if ($null -eq $actions -or $actions.Count -eq 0) {
            Fail-Json -obj $result -message "cannot create a task with no actions, set at least one action with a path to an executable"
        }

        # Create a bare minimum task here, further properties will be set later on
        $task_definition = $service.NewTask(0)

        # Set Actions info
        # https://msdn.microsoft.com/en-us/library/windows/desktop/aa446803(v=vs.85).aspx
        $create_diff_string += "[Actions]`n"
        $task_actions = $task_definition.Actions
        foreach ($action in $actions) {
            $create_diff_string += "+action[0] = {`n  +Type=$([TASK_ACTION_TYPE]::TASK_ACTION_EXEC),`n  +Path=$($action.path)`n"
            $task_action = $task_actions.Create([TASK_ACTION_TYPE]::TASK_ACTION_EXEC)
            $task_action.Path = $action.path
            if ($null -ne $action.arguments) {
                $create_diff_string += "  +Arguments=$($action.arguments)`n"
                $task_action.Arguments = $action.arguments
            }
            if ($null -ne $action.working_directory) {
                $create_diff_string += "  +WorkingDirectory=$($action.working_directory)`n"
                $task_action.WorkingDirectory = $action.working_directory
            }
            $create_diff_string += "+}`n"
        }

        # Register the new task
        # https://msdn.microsoft.com/en-us/library/windows/desktop/aa382577(v=vs.85).aspx
        if ($check_mode) {
            # Only validate the task in check mode
            $task_creation_flags = [TASK_CREATION]::TASK_VALIDATE_ONLY
        } else {
            # Create the task but do not fire it as we still need to configure it further below
            $task_creation_flags = [TASK_CREATION]::TASK_CREATE -bor [TASK_CREATION]::TASK_IGNORE_REGISTRATION_TRIGGERS
        }

        # folder doesn't exist, need to create
        if ($null -eq $task_folder) {
            $task_folder = $service.GetFolder("\")
            try {
                if (-not $check_mode) {
                    $task_folder = $task_folder.CreateFolder($path)
                }
            } catch {
                Fail-Json -obj $result -message "failed to create new folder at path '$path': $($_.Exception.Message)"
            }
        }

        try {
            $task = $task_folder.RegisterTaskDefinition($name, $task_definition, $task_creation_flags, $null, $null, $null)
        } catch {
            Fail-Json -obj $result -message "failed to register new task definition: $($_.Exception.Message)"
        }
        if ($diff_mode) {
            $result.diff.prepared = $create_diff_string
        }

        $result.changed = $true
    }

    # we cannot configure a task that was created above in check mode as it
    # won't actually exist
    if ($task) {
        $task_definition = $task.Definition
        $task_definition_xml = [xml]$task_definition.XmlText

        $action_changes = Compare-Actions -task_definition $task_definition
        $principal_changed = Compare-Principal -task_definition $task_definition -task_definition_xml $task_definition_xml
        $reg_info_changed = Compare-RegistrationInfo -task_definition $task_definition
        $settings_changed = Compare-Settings -task_definition $task_definition
        $trigger_changes = Compare-Triggers -task_definition $task_definition

        # compile the diffs into one list with headers
        $task_diff = [System.Collections.ArrayList]@()
        if ($action_changes.Count -gt 0) {
            [void]$task_diff.Add("[Actions]")
            foreach ($action_change in $action_changes) {
                [void]$task_diff.Add($action_change)
            }
            [void]$task_diff.Add("`n")
        }
        if ($principal_changed.Count -gt 0) {
            [void]$task_diff.Add("[Principal]")
            foreach ($principal_change in $principal_changed) {
                [void]$task_diff.Add($principal_change)
            }
            [void]$task_diff.Add("`n")
        }
        if ($reg_info_changed.Count -gt 0) {
            [void]$task_diff.Add("[Registration Info]")
            foreach ($reg_info_change in $reg_info_changed) {
                [void]$task_diff.Add($reg_info_change)
            }
            [void]$task_diff.Add("`n")
        }
        if ($settings_changed.Count -gt 0) {
            [void]$task_diff.Add("[Settings]")
            foreach ($settings_change in $settings_changed) {
                [void]$task_diff.add($settings_change)
            }
            [void]$task_diff.Add("`n")
        }
        if ($trigger_changes.Count -gt 0) {
            [void]$task_diff.Add("[Triggers]")
            foreach ($trigger_change in $trigger_changes) {
                [void]$task_diff.Add("$trigger_change")
            }
            [void]$task_diff.Add("`n")
        }

        if ($null -ne $password -and (($update_password -eq $true) -or ($task_diff.Count -gt 0))) {
            # because we can't compare the passwords we just need to reset it
            $register_username = $username
            $register_password = $password
            $register_logon_type = $task_principal.LogonType
        } else {
            # will inherit from the Principal property values
            $register_username = $null
            $register_password = $null
            $register_logon_type = $null
        }

        if ($task_diff.Count -gt 0 -or $null -ne $register_password) {
            if ($check_mode) {
                # Only validate the task in check mode
                $task_creation_flags = [TASK_CREATION]::TASK_VALIDATE_ONLY
            } else {
                # Create the task
                $task_creation_flags = [TASK_CREATION]::TASK_CREATE_OR_UPDATE
            }
            try {
                $task_folder.RegisterTaskDefinition($name, $task_definition, $task_creation_flags, $register_username, $register_password, $register_logon_type) | Out-Null
            } catch {
                Fail-Json -obj $result -message "failed to modify scheduled task: $($_.Exception.Message)"
            }

            $result.changed = $true

            if ($diff_mode) {
                $changed_diff_text = $task_diff -join "`n"
                if ($null -ne $result.diff.prepared) {
                    $diff_text = "$($result.diff.prepared)`n$changed_diff_text"
                } else {
                    $diff_text = $changed_diff_text
                }
                $result.diff.prepared = $diff_text.Trim()
            }
        }
    }
}

Exit-Json -obj $result
