Public/Previews/New-JIMConfigurationChangePreview.ps1

# Copyright (c) Tetron Limited. All rights reserved.
# Licensed under the Tetron Commercial License. See LICENSE file in the project root.

function New-JIMConfigurationChangePreview {
    <#
    .SYNOPSIS
        Finds out what a proposed configuration change would do, without making it.
 
    .DESCRIPTION
        Starts a Configuration Change Preview: JIM evaluates a proposed change against the objects
        already in the metaverse and reports what would happen to them, changing nothing.
 
        Several surfaces can be previewed, selected by which identifier and proposal you pass:
 
        - -MetaverseObjectTypeId previews a change to that type's deletion settings. The field semantics
          match Set-JIMMetaverseObjectType exactly, so an omitted parameter previews the stored value.
          Pass the same parameters to this cmdlet and then to Set-JIMMetaverseObjectType and the preview
          describes precisely what the change will do.
        - -ConnectedSystemId previews a change to that system's partition and container selection. Pass
          the whole selection rather than one flag: what a deselection costs depends on the rest of the
          selection, because an object leaves import scope only when nothing else still covers it. Apply
          the previewed change with Set-JIMConnectedSystemPartition and Set-JIMConnectedSystemContainer.
        - -SyncRuleId previews a change to that Synchronisation Rule's two destructive toggles: the
          Outbound Deprovision Action (Disconnect or Delete on a scope exit) and the Inbound Out-of-Scope
          Action (RemainJoined or Disconnect). The field semantics match Set-JIMSyncRule exactly, so an
          omitted parameter previews the stored value. Apply the previewed change with Set-JIMSyncRule.
        - -SyncRuleId with -ScopingCriteriaGroup previews a change to that rule's Scoping Criteria: which
          objects it would manage at all, and what each movement in or out of scope would cost.
        - -SyncRuleId with -AttributeFlowMapping previews a change to that rule's Attribute Flow: what
          value every object it manages would end up with, per attribute.
        - -SyncRuleId with -RuleState previews a change to that rule's behaviour toggles: how many objects
          would stop having an identity or an account created for them, and how many would be left free to
          drift. -ProjectToMetaverse, -ProvisionToConnectedSystem and -EnforceState are optional and merge
          with the stored rule. Direction cannot be previewed: a saved rule's mappings are written for the
          direction it has, so a flip comes back with a blocking finding.
        - -ConnectedSystemId with -MatchingRule previews a change to that system's Object Matching Rules:
          which of its unjoined objects would join a different Metaverse Object, join instead of projecting
          a new identity, project instead of joining, or match ambiguously and fail. Add
          -ObjectMatchingRuleMode to preview the Simple/Advanced switch. Objects already joined are never
          re-matched, so no matching change can move them, and the preview says so.
        - -ConnectedSystemId with -SchemaObjectType previews a change to that system's schema selection:
          which Connected System Objects a deselected Object Type takes out of management (the next Full
          Import obsoletes them, so the joined ones disconnect and the rest leave import scope), which
          Metaverse Objects that would leave eligible for deletion, which attributes would stop being
          refreshed and on how many objects, and whose contributed Metaverse values would be withdrawn, or
          kept, when their obsolete objects are next synchronised. Deselecting an Object Type that an enabled
          Synchronisation Rule still manages is reported as Blocking, because saving it is refused. Selecting
          or deselecting an Object Type that overlaps another selected one (computer and user in Active
          Directory, or a structural class and an auxiliary class) is reported as a warning naming both: the
          objects carrying both classes would change Object Type, which the next import refuses, keeping
          them as they are, unless a Full Import runs with Allow Object Type Changes enabled.
        - -ConnectedSystemId with -Deletion previews deleting that Connected System by deprovisioning it
          through synchronisation, which is what Remove-JIMConnectedSystem does by default: which Metaverse
          attribute values another system would take over and which would be cleared, which Metaverse
          Objects would become eligible for deletion, and which corrective updates and deletions would be
          staged for the other Connected Systems. There is nothing to propose beyond the system itself.
          Deleting a system with -DeleteImmediately skips all of that work, so this preview does not
          describe it.
        - -ConnectedSystemId with -FullSynchronisation previews running a Full Synchronisation of that
          system: every change it would make to Metaverse Objects, every update, provisioning and
          deletion it would stage for the other Connected Systems, the corrections it would make where an
          export rule enforces state, and how many objects would not change. Ask for one after changing
          configuration and before running the Full Synchronisation that applies it. Every object is
          evaluated unless -MaxObjects caps it, so a large system takes a while; the start result's
          EstimatedDuration says roughly how long.
 
        Evaluation is asynchronous. Without -Wait this returns as soon as the proposal itself has been
        validated, carrying the ActivityId to poll with Get-JIMConfigurationChangePreview. With -Wait it
        polls until the preview reaches a terminal state and returns the finished preview.
 
        A proposal that cannot be applied comes back with a blocking validation finding and is never
        evaluated: check IsBlocked and ValidationFindings before reading anything else. Blocking and
        Warning findings are also written to the warning stream as the preview starts; Information findings
        go to the Verbose stream.
 
        Pass the returned ActivityId to the cmdlet that makes the change (Set-JIMMetaverseObjectType
        -PreviewActivityId, Remove-JIMConnectedSystem -PreviewActivityId for a deletion, or
        Start-JIMRunProfile -PreviewActivityId for a Full Synchronisation), so the audit records which
        preview informed it.
 
    .PARAMETER MetaverseObjectTypeId
        The Metaverse Object Type whose deletion settings are being proposed. Selects the deletion settings surface.
 
    .PARAMETER ConnectedSystemId
        The Connected System whose partition and container selection is being proposed. Selects the scope
        selection surface, unless -MatchingRule, -SchemaObjectType, -Deletion or -FullSynchronisation
        selects another.
 
    .PARAMETER Deletion
        Previews deleting the Connected System named by -ConnectedSystemId, rather than changing it.
 
    .PARAMETER FullSynchronisation
        Previews running a Full Synchronisation of the Connected System named by -ConnectedSystemId. The
        system needs a Full Synchronisation Run Profile; without one the proposal comes back blocked.
 
    .PARAMETER MaxObjects
        Evaluate only this many objects, in the order the synchronisation would meet them, rather than
        every object. Omitted, every object is evaluated, which is the only way the counts describe the
        whole system; a capped preview carries a warning saying its counts describe only the objects it
        evaluated. Useful for a quick look at a very large system.
 
    .PARAMETER SchemaObjectType
        The proposed schema selection, as one hashtable per Connected System Object Type being changed.
        Selects the schema selection surface.
 
        Each hashtable needs objectTypeId, and then only the keys being changed: selected (bool),
        removeContributedAttributesOnObsoletion (bool), and selectedAttributeIds (int array). Every
        omission means "leave this as it stands", at both levels: an Object Type not named here is left
        alone, and a key not set keeps that Type's stored value. Send the whole attribute set for a Type
        rather than the attributes that changed; External IDs are selected implicitly and need not be
        listed.
 
    .PARAMETER SelectedPartitionIds
        The partitions that would be managed. Omitted previews the partitions currently selected, so a
        proposal changing only containers need not restate them.
 
    .PARAMETER SelectedContainerIds
        The containers that would be managed. Omitted previews the containers currently selected.
 
        Selecting a container selects its whole subtree, so a descendant does not need listing to be in
        scope; pass the containers that would carry a tick. Read the current selection with
        Get-JIMConnectedSystemPartition.
 
    .PARAMETER ExcludedContainerIds
        The containers that would be carved out of the selection around them. Omitted previews the
        exclusions currently in force, so a proposal changing only the selection need not restate them;
        an empty array previews lifting every exclusion, which brings those branches back into scope.
 
        A container states one or the other, so an id passed here must not also appear in
        -SelectedContainerIds; a proposal naming both is refused rather than resolved.
 
    .PARAMETER DeletionRule
        The proposed deletion rule: Manual (objects are never deleted automatically), WhenLastConnectorDisconnected
        (deleted once every connector is removed) or WhenAuthoritativeSourceDisconnected (deleted when an
        authoritative source disconnects). Omitted previews the stored rule.
 
    .PARAMETER DeletionGracePeriod
        The proposed grace period, as a TimeSpan. Omitted previews the stored grace period;
        [TimeSpan]::Zero previews no grace period, matching how Set-JIMMetaverseObjectType stores it.
 
    .PARAMETER DeletionTriggerConnectedSystemIds
        The proposed authoritative sources. Omitted previews the stored list.
 
        JIM reads this list when a Connected System Object disconnects, not during housekeeping of objects
        already marked, so changing it alone moves no object's deletion date and the preview reports no
        impact from it. It can still make the proposal invalid, which comes back as a blocking finding.
 
    .PARAMETER DeletionTriggerMode
        The proposed trigger mode. Omitted previews the stored mode. Read at the same moment as the
        trigger list, and so with the same standing impact: none.
 
    .PARAMETER SyncRuleId
        The Synchronisation Rule whose destructive toggles are being proposed. Selects the destructive
        toggles surface.
 
    .PARAMETER OutboundDeprovisionAction
        The proposed action for a joined target object whose Metaverse Object leaves this export rule's
        scope: Disconnect leaves the object in the target Connected System; Delete stages a Delete export
        that removes it. Omitted previews the stored action. Read only by export Synchronisation Rules,
        and the preview says so when the rule is an import rule.
 
    .PARAMETER InboundOutOfScopeAction
        The proposed action for a joined Connected System Object that leaves this import rule's scope or
        is obsoleted: RemainJoined keeps the join ("once managed, always managed"); Disconnect breaks it,
        recalls what the object contributed and can trigger the Metaverse Object's deletion rules.
        Omitted previews the stored action. Read only by import Synchronisation Rules.
 
    .PARAMETER ScopingCriteriaGroup
        The proposed Scoping Criteria, as one hashtable per top-level criteria group. Groups are combined
        with OR, exactly as a synchronisation combines them. Each group takes a Type of 'All' or 'Any', a
        Criteria array, and an optional ChildGroups array of further groups nested inside it. Each
        criterion names one attribute by id (ConnectedSystemAttributeId on an import rule,
        MetaverseAttributeId on an export rule), a ComparisonType, and the value to compare against in the
        field matching the attribute's data type (StringValue, IntValue, LongValue, DecimalValue,
        DateTimeValue, BoolValue or GuidValue). To compare a computed value instead, a criterion gives an
        Expression and the type to compare it as in CompareAs, and no attribute id.
 
        Mandatory, and an empty array is a valid and deliberate value: it proposes removing every criterion,
        which hands the rule every object of its type. That is the widest change the Scope tab can make, so
        it has to be asked for rather than arrived at by omission.
 
    .PARAMETER AttributeFlowMapping
        The proposed Attribute Flow, as one hashtable per mapping. Each mapping names the attribute it
        writes (TargetMetaverseAttributeId on an import rule, TargetConnectedSystemAttributeId on an
        export rule) and a Sources array; a source takes an Order, and either an attribute id
        (ConnectedSystemAttributeId on an import rule, MetaverseAttributeId on an export rule) or an
        Expression with an optional MissingInputBehaviour. A mapping may also carry Priority,
        NullIsValue, InitialExportOnly, InboundValueProcessing and CaseNormalisation, which default to
        the same values the editor uses.
 
        Mandatory, and an empty array is a valid and deliberate value: it proposes removing every mapping,
        so the rule flows nothing.
 
        Priority is worth passing deliberately on an import mapping. It defaults to the lowest, so a
        mapping proposed for an attribute another rule already contributes to would be evaluated and then
        write nothing; the preview reports that as a validation finding rather than as values that would
        never be written.
 
    .PARAMETER RuleState
        Whether the rule would run at all, which selects the behaviour toggles surface. Mandatory because the toggle
        it sets is a boolean, so a caller who left it out could not be told apart from one proposing to switch the
        rule off.
 
    .PARAMETER ProjectToMetaverse
        Whether the rule would create Metaverse Objects for the in-scope objects that match none. Omitted previews
        the stored value.
 
    .PARAMETER ProvisionToConnectedSystem
        Whether the rule would create objects in the target Connected System for the in-scope Metaverse Objects that
        have none. Omitted previews the stored value.
 
    .PARAMETER EnforceState
        Whether the rule would go on correcting drift in the target Connected System. Omitted previews the stored
        value.
 
    .PARAMETER MatchingRule
        The proposed Object Matching Rules, as one hashtable per rule, across both matching modes. Each rule
        takes an Order (rules are tried in ascending order until one matches), its owner
        (ConnectedSystemObjectTypeId in Simple mode, SyncRuleId in Advanced mode), a MetaverseObjectTypeId
        (required in Simple mode), a TargetMetaverseAttributeId, and a Sources array whose entries take an
        Order and either a ConnectedSystemAttributeId or an Expression over the Connected System Object
        (cs["attributeName"]), judged as saving the rule would judge it. CaseSensitive is optional: omitted, it
        follows the sources exactly as a rule created without it does, respecting case only when a source
        attribute's values are declared case-exact.
 
        Mandatory, and an empty array is a valid and deliberate value: it proposes removing every rule, so
        nothing would join and every unjoined object would project a new identity instead.
 
    .PARAMETER ObjectMatchingRuleMode
        The matching mode the rules would use: ConnectedSystem (Simple) or SyncRule (Advanced). Omitted previews the
        stored mode; pass it to preview the switch between them.
 
    .PARAMETER FullDataSet
        Keep every object-level detail row rather than the per-group cap's worth. Summary counts are
        exact either way; this decides only how much of the detail behind them can be read back with
        Get-JIMConfigurationChangePreviewDelta. Use it when you intend to export the full list.
 
    .PARAMETER Wait
        Polls until the preview finishes, and returns the finished preview rather than the start result.
 
    .PARAMETER TimeoutSeconds
        How long -Wait polls before giving up and writing an error. The preview itself keeps running;
        read it later with Get-JIMConfigurationChangePreview. Defaults to 300 seconds, or to twice the
        preview's EstimatedDuration where that is longer, so a Full Synchronisation preview of a large
        system is not abandoned while it is working as expected. A value you pass is always honoured.
 
    .OUTPUTS
        Without -Wait, the result of starting the preview: its Activity id, and what validation found. With -Wait,
        the finished preview, as Get-JIMConfigurationChangePreview returns it; or the start result, at once, where
        the proposal was blocked or validation failed, since nothing further will run.
 
        JIM.ConfigurationChangePreviewStartResponse: Without -Wait, or where the proposal was blocked or validation failed
 
        JIM.ConfigurationChangePreviewResponse: With -Wait, once the preview finishes
 
    .EXAMPLE
        New-JIMConfigurationChangePreview -MetaverseObjectTypeId 1 -DeletionRule WhenLastConnectorDisconnected -Wait
 
        Reports what switching the User type to automatic deletion would do to the Metaverse Objects
        whose connectors have already gone, and waits for the answer.
 
    .EXAMPLE
        $preview = New-JIMConfigurationChangePreview -MetaverseObjectTypeId 1 -DeletionGracePeriod ([TimeSpan]::FromDays(7)) -Wait
        $preview.ImpactCounts
 
        Shortens the grace period to seven days and reads how many objects each transition affects.
 
    .EXAMPLE
        $preview = New-JIMConfigurationChangePreview -MetaverseObjectTypeId 1 -DeletionRule WhenLastConnectorDisconnected -Wait
        if ($preview.ImpactCounts.Count -eq 0) {
            Set-JIMMetaverseObjectType -Id 1 -DeletionRule WhenLastConnectorDisconnected -PreviewActivityId $preview.ActivityId
        }
 
        Applies the change only when the preview found nothing would happen to existing objects, and
        records the preview against the change.
 
    .EXAMPLE
        $current = Get-JIMConnectedSystemPartition -ConnectedSystemId 2
        $keep = $current.containers | Where-Object { $_.selected -and $_.name -ne 'Contractors' }
        New-JIMConfigurationChangePreview -ConnectedSystemId 2 -SelectedContainerIds $keep.id -Wait
 
        Reports what deselecting the Contractors container would do: how many Connected System Objects
        leave import scope, how many of those are joined and would disconnect from their Metaverse
        Object, and how many Metaverse Objects would then become eligible for automatic deletion.
 
    .EXAMPLE
        $current = Get-JIMConnectedSystemPartition -ConnectedSystemId 2
        $carveOut = $current.containers | Where-Object name -eq 'Service Accounts'
        New-JIMConfigurationChangePreview -ConnectedSystemId 2 -ExcludedContainerIds $carveOut.id -Wait
 
        Reports what excluding the Service Accounts container would do. Its parent stays selected, so
        nothing else in the branch moves; the objects inside the carve-out are the ones that leave import
        scope, and the ones already joined would disconnect from their Metaverse Objects.
 
    .EXAMPLE
        $preview = New-JIMConfigurationChangePreview -ConnectedSystemId 2 -SelectedPartitionIds 5 -Wait
        $preview.ImpactCounts | Where-Object transitionType -eq 'WouldBecomeDeletionEligible'
 
        Narrows the managed partitions to one and reads how many Metaverse Objects the resulting
        disconnections would put on course for deletion.
 
    .EXAMPLE
        $preview = New-JIMConfigurationChangePreview -SyncRuleId 42 -OutboundDeprovisionAction Delete -Wait
        $preview.ImpactCounts
 
        Reports what flipping an export Synchronisation Rule's Deprovisioning Action to Delete would do:
        how many joined objects already out of scope would be removed from the target Connected System at
        the next synchronisation, and how many managed objects' fate on a future scope exit changes.
 
    .EXAMPLE
        $group = @{
            Type = 'All'
            Criteria = @(
                @{ ConnectedSystemAttributeId = 101; ComparisonType = 'Equals'; StringValue = 'Sales' }
            )
        }
        $preview = New-JIMConfigurationChangePreview -SyncRuleId 42 -ScopingCriteriaGroup $group -Wait
        $preview.ImpactCounts
 
        Reports what narrowing an import Synchronisation Rule to the Sales department would do: how many
        joined objects would leave scope and disconnect from their Metaverse Objects, how many unjoined
        ones simply stop matching, and how many objects would newly enter scope and be projected.
 
    .EXAMPLE
        $preview = New-JIMConfigurationChangePreview -SyncRuleId 42 -ScopingCriteriaGroup @() -Wait
        $preview.ImpactCounts | Where-Object transitionType -eq 'Projected'
 
        Reports what removing every Scoping Criterion would do, which puts every object of the rule's type
        in scope. The Projected count is how many Metaverse Objects that would create.
 
    .EXAMPLE
        $mapping = @{
            TargetMetaverseAttributeId = 201
            Priority = 1
            Sources = @(
                @{ Order = 1; Expression = 'cs["givenName"] + "." + cs["sn"] + "@corp.local"'; MissingInputBehaviour = 'FailMapping' }
            )
        }
        $preview = New-JIMConfigurationChangePreview -SyncRuleId 42 -AttributeFlowMapping $mapping -Wait
        $preview.ImpactCounts
 
        Reports what an email cutover would write: how many identities' addresses change, and how many
        objects the Expression could not be evaluated for at all because a required input is missing.
 
    .EXAMPLE
        $preview = New-JIMConfigurationChangePreview -SyncRuleId 42 -AttributeFlowMapping $mapping -FullDataSet -Wait
        Get-JIMConfigurationChangePreviewDelta -ActivityId $preview.ActivityId |
            Where-Object transitionType -eq 'WouldFailAttributeFlow'
 
        Keeps every detail row and lists the objects the proposed Expression would not evaluate for, which
        is the handful the cutover would otherwise leave without an address.
 
    .EXAMPLE
        $preview = New-JIMConfigurationChangePreview -SyncRuleId 42 -InboundOutOfScopeAction Disconnect -Wait
        if (($preview.ImpactCounts | Measure-Object objectCount -Sum).Sum -eq 0) {
            Set-JIMSyncRule -Id 42 -InboundOutOfScopeAction Disconnect -PreviewActivityId $preview.ActivityId
        }
 
        Applies the tightened Out-of-Scope Action only when the preview found no object would be
        disconnected by it today, and records the preview against the change.
 
    .EXAMPLE
        $rules = @(
            @{
                order = 0
                connectedSystemObjectTypeId = 9
                metaverseObjectTypeId = 3
                targetMetaverseAttributeId = 201
                caseSensitive = $false
                sources = @(@{ order = 0; connectedSystemAttributeId = 102 })
            }
        )
        $preview = New-JIMConfigurationChangePreview -ConnectedSystemId 5 -MatchingRule $rules -Wait
        $preview.ImpactCounts | Where-Object transitionType -eq 'WouldJoinDifferentMetaverseObject'
 
        Previews matching on a different attribute and reports how many accounts would end up on a
        different identity, which is the count that decides whether the rule is safe to save.
 
    .EXAMPLE
        New-JIMConfigurationChangePreview -ConnectedSystemId 5 -MatchingRule @() -Wait
 
        Previews removing every Object Matching Rule: nothing would join, and every unjoined object would
        project a new identity instead.
 
    .EXAMPLE
        $types = @(@{ objectTypeId = 9; selected = $false })
        $preview = New-JIMConfigurationChangePreview -ConnectedSystemId 5 -SchemaObjectType $types -Wait
        $preview.ImpactCounts | Where-Object transitionType -in 'WouldDisconnectFromMetaverseObject', 'WouldBecomeDeletionEligible'
 
        Previews deselecting an Object Type and reports how many of its objects would be disconnected from
        their Metaverse Objects once the next Full Import obsoletes them, and how many of those Metaverse
        Objects would become eligible for deletion.
 
    .EXAMPLE
        $types = @(@{ objectTypeId = 9; removeContributedAttributesOnObsoletion = $false })
        New-JIMConfigurationChangePreview -ConnectedSystemId 5 -SchemaObjectType $types -Wait
 
        Previews turning obsoletion recall off, over the objects already obsolete and still joined, which
        are the only ones whose fate the setting changes now.
 
    .EXAMPLE
        $preview = New-JIMConfigurationChangePreview -ConnectedSystemId 3 -Deletion -Wait
        $preview.ImpactCounts
 
        Previews deleting a Connected System and reports, per consequence, how many objects it reaches:
        attribute values another system would take over, values that would be cleared, Metaverse Objects
        that would become eligible for deletion, and updates and deletions that would be staged for the
        other Connected Systems.
 
    .EXAMPLE
        $preview = New-JIMConfigurationChangePreview -ConnectedSystemId 3 -Deletion -Wait
        if ($preview.IsComplete) {
            Remove-JIMConnectedSystem -Id 3 -PreviewActivityId $preview.ActivityId -Confirm:$false
        }
 
        Deletes the Connected System once its deletion preview has finished, and records the preview on the
        deletion's Activity, so the audit shows the consequences were looked at first.
 
    .EXAMPLE
        $preview = New-JIMConfigurationChangePreview -ConnectedSystemId 3 -FullSynchronisation -Wait
        $preview.ImpactCounts | Format-Table TransitionType, ObjectCount
        $preview.Groups | Format-Table TransitionType, ConnectedSystemName, AttributeName, OldValue, NewValue, ObjectCount
 
        Previews a Full Synchronisation of Connected System 3. ImpactCounts says how many objects each
        consequence reaches; Groups breaks those down by Connected System, attribute and value change,
        such as the updates that would be staged for each other system.
 
    .EXAMPLE
        $preview = New-JIMConfigurationChangePreview -ConnectedSystemId 3 -FullSynchronisation -Wait
        $destructive = $preview.ImpactCounts |
            Where-Object TransitionType -in 'WouldBecomeDeletionEligible', 'WouldStageDeleteExport'
        if ($preview.IsComplete -and -not $destructive) {
            Start-JIMRunProfile -ConnectedSystemId 3 -RunProfileName 'Full Synchronisation' -PreviewActivityId $preview.ActivityId -Wait
        }
 
        Previews a Full Synchronisation and runs it only if nothing would become eligible for deletion
        and nothing would be deleted from another system. The run's Activity records the preview, so the
        audit shows what the run was expected to do.
 
    .EXAMPLE
        New-JIMConfigurationChangePreview -ConnectedSystemId 3 -FullSynchronisation -MaxObjects 1000 -Wait
 
        Takes a quick look at the first 1,000 objects of a large system. The result warns that its
        counts describe only those objects.
 
    .EXAMPLE
        $preview = New-JIMConfigurationChangePreview -SyncRuleId 42 -RuleState Disabled -Wait
        $preview.ImpactCounts
 
        Previews disabling a Synchronisation Rule and reports what stops happening: the identities and accounts
        that would no longer be created, which is the count that says whether "just pausing it" is what it does.
 
    .EXAMPLE
        New-JIMConfigurationChangePreview -SyncRuleId 42 -RuleState Enabled -ProvisionToConnectedSystem $true -Wait
 
        Previews enabling a rule and turning provisioning on together, which is account creation at scale.
 
    .LINK
        Get-JIMConfigurationChangePreview
        Get-JIMConfigurationChangePreviewDelta
        Stop-JIMConfigurationChangePreview
        Set-JIMMetaverseObjectType
        Set-JIMConnectedSystemPartition
        Set-JIMConnectedSystemContainer
        Set-JIMSyncRule
        Set-JIMMatchingRule
        Set-JIMConnectedSystemObjectType
        Remove-JIMConnectedSystem
        Start-JIMRunProfile
    #>

    [CmdletBinding(DefaultParameterSetName = 'MetaverseObjectTypeDeletionSettings')]
    [OutputType('JIM.ConfigurationChangePreviewStartResponse')]
    [OutputType('JIM.ConfigurationChangePreviewResponse')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'MetaverseObjectTypeDeletionSettings', ValueFromPipelineByPropertyName)]
        [int]$MetaverseObjectTypeId,

        [Parameter(ParameterSetName = 'MetaverseObjectTypeDeletionSettings')]
        [ValidateSet('Manual', 'WhenLastConnectorDisconnected', 'WhenAuthoritativeSourceDisconnected')]
        [string]$DeletionRule,

        [Parameter(ParameterSetName = 'MetaverseObjectTypeDeletionSettings')]
        [TimeSpan]$DeletionGracePeriod,

        [Parameter(ParameterSetName = 'MetaverseObjectTypeDeletionSettings')]
        [int[]]$DeletionTriggerConnectedSystemIds,

        [Parameter(ParameterSetName = 'MetaverseObjectTypeDeletionSettings')]
        [ValidateSet('AllSourcesDisconnect', 'SpecificSourcesDisconnect')]
        [string]$DeletionTriggerMode,

        [Parameter(Mandatory, ParameterSetName = 'ConnectedSystemScopeSelection', ValueFromPipelineByPropertyName)]
        [Parameter(Mandatory, ParameterSetName = 'ObjectMatching', ValueFromPipelineByPropertyName)]
        [Parameter(Mandatory, ParameterSetName = 'ConnectedSystemSchema', ValueFromPipelineByPropertyName)]
        [Parameter(Mandatory, ParameterSetName = 'ConnectedSystemDeletion', ValueFromPipelineByPropertyName)]
        [Parameter(Mandatory, ParameterSetName = 'ConnectedSystemFullSynchronisation', ValueFromPipelineByPropertyName)]
        [int]$ConnectedSystemId,

        # Mandatory so that it, and only it, selects the deletion set: -ConnectedSystemId on its own keeps meaning
        # the scope selection, and a deletion is never previewed because a caller left something out.
        [Parameter(Mandatory, ParameterSetName = 'ConnectedSystemDeletion')]
        [switch]$Deletion,

        # Mandatory for the same reason as -Deletion: it alone selects the Full Synchronisation set.
        [Parameter(Mandatory, ParameterSetName = 'ConnectedSystemFullSynchronisation')]
        [switch]$FullSynchronisation,

        [Parameter(ParameterSetName = 'ConnectedSystemFullSynchronisation')]
        [ValidateRange(1, [int]::MaxValue)]
        [int]$MaxObjects,

        [Parameter(ParameterSetName = 'ConnectedSystemScopeSelection')]
        [int[]]$SelectedPartitionIds,

        [Parameter(ParameterSetName = 'ConnectedSystemScopeSelection')]
        [int[]]$SelectedContainerIds,

        [Parameter(ParameterSetName = 'ConnectedSystemScopeSelection')]
        [AllowEmptyCollection()]
        [int[]]$ExcludedContainerIds,

        [Parameter(Mandatory, ParameterSetName = 'SyncRuleDestructiveToggles', ValueFromPipelineByPropertyName)]
        [Parameter(Mandatory, ParameterSetName = 'SyncRuleScopingCriteria', ValueFromPipelineByPropertyName)]
        [Parameter(Mandatory, ParameterSetName = 'SyncRuleAttributeFlow', ValueFromPipelineByPropertyName)]
        [Parameter(Mandatory, ParameterSetName = 'SyncRuleBehaviour', ValueFromPipelineByPropertyName)]
        [int]$SyncRuleId,

        [Parameter(ParameterSetName = 'SyncRuleDestructiveToggles')]
        [ValidateSet('Disconnect', 'Delete')]
        [string]$OutboundDeprovisionAction,

        [Parameter(ParameterSetName = 'SyncRuleDestructiveToggles')]
        [ValidateSet('RemainJoined', 'Disconnect')]
        [string]$InboundOutOfScopeAction,

        [Parameter(Mandatory, ParameterSetName = 'SyncRuleScopingCriteria')]
        [AllowEmptyCollection()]
        [hashtable[]]$ScopingCriteriaGroup,

        [Parameter(Mandatory, ParameterSetName = 'SyncRuleAttributeFlow')]
        [AllowEmptyCollection()]
        [hashtable[]]$AttributeFlowMapping,

        [Parameter(Mandatory, ParameterSetName = 'ObjectMatching')]
        [AllowEmptyCollection()]
        [hashtable[]]$MatchingRule,

        [Parameter(ParameterSetName = 'ObjectMatching')]
        [ValidateSet('ConnectedSystem', 'SyncRule')]
        [string]$ObjectMatchingRuleMode,

        [Parameter(Mandatory, ParameterSetName = 'SyncRuleBehaviour')]
        [ValidateSet('Enabled', 'Disabled')]
        [string]$RuleState,

        [Parameter(ParameterSetName = 'SyncRuleBehaviour')]
        [bool]$ProjectToMetaverse,

        [Parameter(ParameterSetName = 'SyncRuleBehaviour')]
        [bool]$ProvisionToConnectedSystem,

        [Parameter(ParameterSetName = 'SyncRuleBehaviour')]
        [bool]$EnforceState,

        [Parameter(Mandatory, ParameterSetName = 'ConnectedSystemSchema')]
        [hashtable[]]$SchemaObjectType,

        [Parameter()]
        [switch]$FullDataSet,

        [Parameter()]
        [switch]$Wait,

        [Parameter()]
        [ValidateRange(1, 86400)]
        [int]$TimeoutSeconds = 300
    )

    process {
        # Check connection first
        if (-not $script:JIMConnection) {
            Write-Error "You are not connected to JIM. Run Connect-JIM -Url <your JIM URL> to authenticate, then try again."
            return
        }

        $body = @{}

        if ($PSCmdlet.ParameterSetName -eq 'ConnectedSystemScopeSelection') {
            if ($PSBoundParameters.ContainsKey('SelectedPartitionIds')) {
                # Wrapped in @() so a single id serialises as a JSON array rather than a bare number.
                $body.selectedPartitionIds = @($SelectedPartitionIds)
            }

            if ($PSBoundParameters.ContainsKey('SelectedContainerIds')) {
                $body.selectedContainerIds = @($SelectedContainerIds)
            }

            if ($PSBoundParameters.ContainsKey('ExcludedContainerIds')) {
                $body.excludedContainerIds = @($ExcludedContainerIds)
            }
        }

        if ($DeletionRule) {
            # Enum sent as its string name; the API rejects numeric ordinals
            # (JsonStringEnumConverter allowIntegerValues:false).
            $body.deletionRule = $DeletionRule
        }

        if ($PSBoundParameters.ContainsKey('DeletionGracePeriod')) {
            $body.deletionGracePeriod = $DeletionGracePeriod.ToString()
        }

        if ($PSBoundParameters.ContainsKey('DeletionTriggerConnectedSystemIds')) {
            $body.deletionTriggerConnectedSystemIds = $DeletionTriggerConnectedSystemIds
        }

        if ($PSBoundParameters.ContainsKey('DeletionTriggerMode')) {
            $body.deletionTriggerMode = $DeletionTriggerMode
        }

        if ($PSCmdlet.ParameterSetName -eq 'SyncRuleDestructiveToggles') {
            if ($OutboundDeprovisionAction) {
                # Enum sent as its string name; the API rejects numeric ordinals
                # (JsonStringEnumConverter allowIntegerValues:false).
                $body.outboundDeprovisionAction = $OutboundDeprovisionAction
            }

            if ($InboundOutOfScopeAction) {
                $body.inboundOutOfScopeAction = $InboundOutOfScopeAction
            }
        }

        if ($PSCmdlet.ParameterSetName -eq 'SyncRuleScopingCriteria') {
            # Always sent, including as an empty array: omitting the field previews the rule's stored criteria,
            # while an empty array proposes removing every one of them. The two are different questions, and the
            # parameter is mandatory so the caller has to say which they are asking.
            # Wrapped in @() so a single group serialises as a JSON array rather than a bare object.
            $body.criteriaGroups = @($ScopingCriteriaGroup)
        }

        if ($PSCmdlet.ParameterSetName -eq 'SyncRuleAttributeFlow') {
            # Always sent, including as an empty array, for the same reason as the criteria groups above: omitting
            # the field previews the rule's stored mappings, while an empty array proposes removing every one of
            # them. Wrapped in @() so a single mapping serialises as a JSON array rather than a bare object.
            $body.mappings = @($AttributeFlowMapping)
        }

        if ($PSCmdlet.ParameterSetName -eq 'SyncRuleBehaviour') {
            # -RuleState is mandatory because the toggle it sets is a boolean: were it optional, a caller who left
            # it out could not be told apart from one proposing to switch the rule off, and those are opposite
            # questions. The other three are optional and merge with the stored rule, so silence never proposes a
            # change nobody asked for.
            $body.enabled = ($RuleState -eq 'Enabled')

            foreach ($toggle in 'ProjectToMetaverse', 'ProvisionToConnectedSystem', 'EnforceState') {
                if ($PSBoundParameters.ContainsKey($toggle)) {
                    $body[[System.Char]::ToLowerInvariant($toggle[0]) + $toggle.Substring(1)] = $PSBoundParameters[$toggle]
                }
            }
        }

        if ($PSCmdlet.ParameterSetName -eq 'ConnectedSystemSchema') {
            # Only the Object Types the caller names are sent, and within each only the keys they set. Every
            # omission means "leave this as it stands", because every type default here is the destructive answer:
            # an absent 'selected' read as false would propose taking a whole Object Type out of management on a
            # request that meant to change one attribute. Wrapped in @() so a single Object Type serialises as a
            # JSON array rather than a bare object.
            $body.objectTypes = @($SchemaObjectType)
        }

        if ($PSCmdlet.ParameterSetName -eq 'ObjectMatching') {
            # Always sent, including as an empty array, for the same reason as the mappings above: omitting the
            # field previews the Connected System's stored rules, while an empty array proposes removing every one
            # of them, leaving nothing able to join. Wrapped in @() so a single rule serialises as a JSON array
            # rather than a bare object.
            $body.rules = @($MatchingRule)

            # The mode is only sent when the caller is changing it; omitted, the preview keeps the stored mode, so
            # a caller editing rules alone does not have to restate which mode they are in.
            if ($ObjectMatchingRuleMode) {
                $body.mode = $ObjectMatchingRuleMode
            }
        }

        if ($PSCmdlet.ParameterSetName -eq 'ConnectedSystemFullSynchronisation' -and $PSBoundParameters.ContainsKey('MaxObjects')) {
            # Sent only when asked for: without a cap every object is evaluated, which is the only way the counts
            # describe the whole system.
            $body.maxObjects = $MaxObjects
        }

        if ($FullDataSet) {
            $body.deltaPersistence = 'Full'
        }

        if ($PSCmdlet.ParameterSetName -eq 'ConnectedSystemScopeSelection') {
            $endpoint = "/api/v1/synchronisation/connected-systems/$ConnectedSystemId/scope-selection/preview"
            $subject = "Connected System $ConnectedSystemId"
        }
        elseif ($PSCmdlet.ParameterSetName -eq 'SyncRuleDestructiveToggles') {
            $endpoint = "/api/v1/synchronisation/sync-rules/$SyncRuleId/destructive-toggles/preview"
            $subject = "Synchronisation Rule $SyncRuleId"
        }
        elseif ($PSCmdlet.ParameterSetName -eq 'SyncRuleScopingCriteria') {
            $endpoint = "/api/v1/synchronisation/sync-rules/$SyncRuleId/scoping-criteria/preview"
            $subject = "Synchronisation Rule $SyncRuleId"
        }
        elseif ($PSCmdlet.ParameterSetName -eq 'SyncRuleAttributeFlow') {
            $endpoint = "/api/v1/synchronisation/sync-rules/$SyncRuleId/mappings/preview"
            $subject = "Synchronisation Rule $SyncRuleId"
        }
        elseif ($PSCmdlet.ParameterSetName -eq 'ObjectMatching') {
            $endpoint = "/api/v1/synchronisation/connected-systems/$ConnectedSystemId/matching-rules/preview"
            $subject = "Connected System $ConnectedSystemId"
        }
        elseif ($PSCmdlet.ParameterSetName -eq 'SyncRuleBehaviour') {
            $endpoint = "/api/v1/synchronisation/sync-rules/$SyncRuleId/behaviour/preview"
            $subject = "Synchronisation Rule $SyncRuleId"
        }
        elseif ($PSCmdlet.ParameterSetName -eq 'ConnectedSystemSchema') {
            $endpoint = "/api/v1/synchronisation/connected-systems/$ConnectedSystemId/schema-selection/preview"
            $subject = "Connected System $ConnectedSystemId"
        }
        elseif ($PSCmdlet.ParameterSetName -eq 'ConnectedSystemDeletion') {
            # The deletion has no settings to propose, so the body carries only -FullDataSet's choice, if any.
            $endpoint = "/api/v1/synchronisation/connected-systems/$ConnectedSystemId/deletion/preview"
            $subject = "the deletion of Connected System $ConnectedSystemId"
        }
        elseif ($PSCmdlet.ParameterSetName -eq 'ConnectedSystemFullSynchronisation') {
            $endpoint = "/api/v1/synchronisation/connected-systems/$ConnectedSystemId/full-synchronisation/preview"
            $subject = "a Full Synchronisation of Connected System $ConnectedSystemId"
        }
        else {
            $endpoint = "/api/v1/metaverse/object-types/$MetaverseObjectTypeId/deletion-settings/preview"
            $subject = "Metaverse Object Type $MetaverseObjectTypeId"
        }

        try {
            $start = Invoke-JIMApi -Endpoint $endpoint -Method 'POST' -Body $body
        }
        catch {
            Write-Error "Failed to start a preview for ${subject}: $_"
            return
        }

        if (-not $start) {
            return
        }

        foreach ($finding in $start.ValidationFindings) {
            # Surfaced as they are produced rather than left for the caller to find in the object: a
            # blocking finding means nothing was evaluated, and a script that read straight past it
            # would treat "this change is impossible" as "this change affects nothing". A warning is a
            # consequence the caller did not ask for, such as objects changing Object Type and being
            # refused at the next import (#2049), and on the Verbose stream nobody would see it.
            # Information stays there, so it does not bury the findings that matter.
            $message = "Preview validation ($($finding.Severity)): $($finding.Message)"
            if ($finding.Severity -in 'Blocking', 'Warning') { Write-Warning $message } else { Write-Verbose $message }
        }

        if (-not $Wait -or $start.IsBlocked -or $start.Failed) {
            return $start
        }

        # Five minutes suits every surface but one: a Full Synchronisation preview evaluates every object in the
        # system, and its start response says how long that should take. Unless the caller chose a timeout, allow
        # twice that estimate, so a large preview working exactly as expected is not abandoned part-way.
        [long]$timeout = $TimeoutSeconds
        if (-not $PSBoundParameters.ContainsKey('TimeoutSeconds') -and $start.EstimatedDuration) {
            $estimate = [TimeSpan]$start.EstimatedDuration
            $timeout = [Math]::Max($timeout, [long][Math]::Ceiling($estimate.TotalSeconds * 2))
        }

        $deadline = (Get-Date).AddSeconds($timeout)
        while ($true) {
            $preview = Get-JIMConfigurationChangePreview -ActivityId $start.ActivityId
            if ($preview.IsComplete -or $preview.HasFailed -or $preview.ActivityStatus -eq 'Cancelled') {
                return $preview
            }

            if ((Get-Date) -ge $deadline) {
                Write-Error "The preview did not finish within $timeout seconds. It is still running; read it with Get-JIMConfigurationChangePreview -ActivityId $($start.ActivityId), or abandon it with Stop-JIMConfigurationChangePreview."
                return $preview
            }

            Start-Sleep -Seconds 2
        }
    }
}