Private/Test-IdleWorkflowSchema.ps1

function Test-IdleWorkflowSchema {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable] $Workflow
    )

    # Strict validation: collect all schema violations and return them as a list.
    $errors = [System.Collections.Generic.List[string]]::new()

    # Helper: Validate step keys and detect disallowed keys.
    function Test-IdleWorkflowStepKeys {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [hashtable] $Step,

            [Parameter(Mandatory)]
            [string] $StepPath,

            [Parameter(Mandatory)]
            [AllowEmptyCollection()]
            [System.Collections.Generic.List[string]] $ErrorList
        )

        $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description', 'RetryProfile', 'Precondition', 'OnPreconditionFalse', 'PreconditionEvent')
        foreach ($k in $Step.Keys) {
            if ($allowedStepKeys -notcontains $k) {
                $ErrorList.Add("Unknown key '$k' in $StepPath. Allowed keys: $($allowedStepKeys -join ', ').")
            }
        }
    }

    # Helper: Validate RetryProfile property
    function Test-IdleWorkflowStepRetryProfile {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [hashtable] $Step,

            [Parameter(Mandatory)]
            [string] $StepPath,

            [Parameter(Mandatory)]
            [AllowEmptyCollection()]
            [System.Collections.Generic.List[string]] $ErrorList
        )

        if ($Step.ContainsKey('RetryProfile') -and $null -ne $Step.RetryProfile) {
            $retryProfile = [string]$Step.RetryProfile
            if ([string]::IsNullOrWhiteSpace($retryProfile)) {
                $ErrorList.Add("'$StepPath.RetryProfile' must not be an empty string.")
            }
            elseif ($retryProfile -notmatch '^[A-Za-z0-9_.-]{1,64}$') {
                $ErrorList.Add("'$StepPath.RetryProfile' value '$retryProfile' is invalid. Must match pattern: ^[A-Za-z0-9_.-]{1,64}$")
            }
        }
    }

    # Helper: Validate Precondition, OnPreconditionFalse, and PreconditionEvent fields on a step.
    function Test-IdleWorkflowStepPreconditionSettings {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [hashtable] $Step,

            [Parameter(Mandatory)]
            [string] $StepPath,

            [Parameter(Mandatory)]
            [AllowEmptyCollection()]
            [System.Collections.Generic.List[string]] $ErrorList
        )

        if ($Step.ContainsKey('Precondition') -and $null -ne $Step.Precondition) {
            if ($Step.Precondition -isnot [System.Collections.IDictionary]) {
                $ErrorList.Add("'$StepPath.Precondition' must be a hashtable (condition node).")
            }
            else {
                foreach ($schemaError in (Test-IdleConditionSchema -Condition ([hashtable]$Step.Precondition) -StepName $null)) {
                    $ErrorList.Add("'$StepPath.Precondition' has invalid condition schema: $schemaError")
                }
            }
        }

        if ($Step.ContainsKey('OnPreconditionFalse') -and $null -ne $Step.OnPreconditionFalse) {
            $opf = [string]$Step.OnPreconditionFalse
            if ($opf -notin @('Blocked', 'Fail', 'Continue')) {
                $ErrorList.Add("'$StepPath.OnPreconditionFalse' must be 'Blocked', 'Fail', or 'Continue'. Got: '$opf'.")
            }
        }

        if ($Step.ContainsKey('PreconditionEvent') -and $null -ne $Step.PreconditionEvent) {
            if ($Step.PreconditionEvent -isnot [hashtable]) {
                $ErrorList.Add("'$StepPath.PreconditionEvent' must be a hashtable.")
            }
            else {
                $pcEvt = $Step.PreconditionEvent
                if (-not $pcEvt.ContainsKey('Type') -or [string]::IsNullOrWhiteSpace([string]$pcEvt.Type)) {
                    $ErrorList.Add("'$StepPath.PreconditionEvent.Type' is required and must be a non-empty string.")
                }
                if (-not $pcEvt.ContainsKey('Message') -or [string]::IsNullOrWhiteSpace([string]$pcEvt.Message)) {
                    $ErrorList.Add("'$StepPath.PreconditionEvent.Message' is required and must be a non-empty string.")
                }
                if ($pcEvt.ContainsKey('Data') -and $null -ne $pcEvt.Data -and $pcEvt.Data -isnot [hashtable]) {
                    $ErrorList.Add("'$StepPath.PreconditionEvent.Data' must be a hashtable when provided.")
                }
            }
        }
    }

    function Test-IdleWorkflowStepCondition {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [hashtable] $Step,

            [Parameter(Mandatory)]
            [string] $StepPath,

            [Parameter(Mandatory)]
            [AllowEmptyCollection()]
            [System.Collections.Generic.List[string]] $ErrorList
        )

        # Conditions use the same declarative condition DSL as runtime preconditions.
        if ($Step.ContainsKey('Condition') -and $null -ne $Step.Condition) {
            if ($Step.Condition -isnot [System.Collections.IDictionary]) {
                $ErrorList.Add("'$StepPath.Condition' must be a hashtable (declarative condition object).")
            }
            else {
                foreach ($schemaError in (Test-IdleConditionSchema -Condition ([hashtable]$Step.Condition) -StepName $null)) {
                    $ErrorList.Add("'$StepPath.Condition' has invalid condition schema: $schemaError")
                }
            }
        }
    }

    function Test-IdleWorkflowStepCollection {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [object] $StepCollection,

            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $CollectionName,

            [Parameter(Mandatory)]
            [string] $DuplicateNameErrorTemplate,

            [Parameter(Mandatory)]
            [AllowEmptyCollection()]
            [System.Collections.Generic.List[string]] $ErrorList
        )

        if ($StepCollection -isnot [System.Collections.IEnumerable] -or
            $StepCollection -is [string] -or
            $StepCollection -is [System.Collections.IDictionary]) {
            $ErrorList.Add("'$CollectionName' must be an array/list of step hashtables.")
            return
        }

        $stepNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

        $i = 0
        foreach ($step in $StepCollection) {
            $stepPath = "{0}[{1}]" -f $CollectionName, $i

            if ($null -eq $step -or $step -isnot [hashtable]) {
                $ErrorList.Add("$stepPath must be a hashtable.")
                $i++
                continue
            }

            Test-IdleWorkflowStepKeys -Step $step -StepPath $stepPath -ErrorList $ErrorList

            if (-not $step.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$step.Name)) {
                $ErrorList.Add("Missing or empty required key '$stepPath.Name'.")
            }
            else {
                if (-not $stepNames.Add([string]$step.Name)) {
                    $ErrorList.Add(($DuplicateNameErrorTemplate -f [string]$step.Name))
                }
            }

            if (-not $step.ContainsKey('Type') -or [string]::IsNullOrWhiteSpace([string]$step.Type)) {
                $ErrorList.Add("Missing or empty required key '$stepPath.Type'.")
            }

            Test-IdleWorkflowStepCondition -Step $step -StepPath $stepPath -ErrorList $ErrorList

            # 'With' is step parameter bag (data-only). Detailed validation comes with step metadata later.
            if ($step.ContainsKey('With') -and $null -ne $step.With -and $step.With -isnot [hashtable]) {
                $ErrorList.Add("'$stepPath.With' must be a hashtable (step parameters).")
            }

            Test-IdleWorkflowStepRetryProfile -Step $step -StepPath $stepPath -ErrorList $ErrorList
            Test-IdleWorkflowStepPreconditionSettings -Step $step -StepPath $stepPath -ErrorList $ErrorList

            $i++
        }
    }

    $allowedRootKeys = @('Name', 'LifecycleEvent', 'Steps', 'OnFailureSteps', 'Description', 'ContextResolvers')
    foreach ($key in $Workflow.Keys) {
        if ($allowedRootKeys -notcontains $key) {
            $errors.Add("Unknown root key '$key'. Allowed keys: $($allowedRootKeys -join ', ').")
        }
    }

    if (-not $Workflow.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$Workflow.Name)) {
        $errors.Add("Missing or empty required root key 'Name'.")
    }

    if (-not $Workflow.ContainsKey('LifecycleEvent') -or [string]::IsNullOrWhiteSpace([string]$Workflow.LifecycleEvent)) {
        $errors.Add("Missing or empty required root key 'LifecycleEvent'.")
    }

    if (-not $Workflow.ContainsKey('Steps') -or $null -eq $Workflow.Steps) {
        $errors.Add("Missing required root key 'Steps'.")
    }
    else {
        Test-IdleWorkflowStepCollection -StepCollection $Workflow.Steps -CollectionName 'Steps' -DuplicateNameErrorTemplate "Duplicate step name '{0}' detected. Step names must be unique." -ErrorList $errors
    }

    # OnFailureSteps are optional. If present, validate them like regular Steps.
    if ($Workflow.ContainsKey('OnFailureSteps') -and $null -ne $Workflow.OnFailureSteps) {
        Test-IdleWorkflowStepCollection -StepCollection $Workflow.OnFailureSteps -CollectionName 'OnFailureSteps' -DuplicateNameErrorTemplate "Duplicate step name '{0}' detected in 'OnFailureSteps'. Step names must be unique within this collection." -ErrorList $errors
    }

    # ContextResolvers are optional. If present, validate each resolver entry.
    if ($Workflow.ContainsKey('ContextResolvers') -and $null -ne $Workflow.ContextResolvers) {
        if ($Workflow.ContextResolvers -isnot [System.Collections.IEnumerable] -or
            $Workflow.ContextResolvers -is [string] -or
            $Workflow.ContextResolvers -is [hashtable]) {
            $errors.Add("'ContextResolvers' must be an array/list of resolver hashtables, not a single hashtable.")
        }
        else {
            # 'To' is not user-configurable; each capability has a predefined output path.
            $allowedResolverKeys = @('Capability', 'With')

            $i = 0
            foreach ($resolver in $Workflow.ContextResolvers) {
                $resolverPath = "ContextResolvers[$i]"

                if ($null -eq $resolver -or $resolver -isnot [hashtable]) {
                    $errors.Add("$resolverPath must be a hashtable.")
                    $i++
                    continue
                }

                foreach ($k in $resolver.Keys) {
                    if ($allowedResolverKeys -notcontains $k) {
                        $errors.Add("Unknown key '$k' in $resolverPath. Allowed keys: $($allowedResolverKeys -join ', ').")
                    }
                }

                if (-not $resolver.ContainsKey('Capability') -or [string]::IsNullOrWhiteSpace([string]$resolver.Capability)) {
                    $errors.Add("Missing or empty required key '$resolverPath.Capability'.")
                }

                # 'With' is optional but must be a hashtable if present.
                if ($resolver.ContainsKey('With') -and $null -ne $resolver.With) {
                    if ($resolver.With -isnot [hashtable]) {
                        $errors.Add("'$resolverPath.With' must be a hashtable (resolver input parameters).")
                    }
                    else {
                        $with = $resolver.With

                        # 'With.Provider' is optional but must be a non-empty string if present.
                        if ($with.ContainsKey('Provider') -and $null -ne $with.Provider -and [string]::IsNullOrWhiteSpace([string]$with.Provider)) {
                            $errors.Add("'$resolverPath.With.Provider' must not be an empty string.")
                        }

                        # 'With.AuthSessionOptions' must be a hashtable if present.
                        if ($with.ContainsKey('AuthSessionOptions') -and $null -ne $with.AuthSessionOptions -and $with.AuthSessionOptions -isnot [hashtable]) {
                            $errors.Add("'$resolverPath.With.AuthSessionOptions' must be a hashtable.")
                        }
                    }
                }

                $i++
            }
        }
    }

    return $errors
}