Private/TestCaseManagement/Get-TcmStringHash.ps1

function Get-TcmStringHash {
    <#
        .SYNOPSIS
            Calculates SHA256 hash for test-case content with normalization.

        .DESCRIPTION
            Accepts either a JSON/string representation (-InputString) or a PowerShell
            object/hashtable (-InputObject). The input is normalized to a canonical
            JSON representation before hashing. Normalization includes:
              - ordering object properties alphabetically
              - sorting arrays of primitives
              - sorting steps by `stepNumber` where present
              - removing common non-deterministic properties (timestamps, ids,
                azure attachment ids, metadata)
              - normalizing attachments to deterministic fields (keeps path/name)

        .PARAMETER InputString
            JSON or plain string to be hashed. If JSON, it will be parsed and normalized.

        .PARAMETER InputObject
            A PowerShell object (hashtable / PSCustomObject) describing the test case.
    #>


    [CmdletBinding(DefaultParameterSetName = 'String')]
    param(
        [Parameter(ParameterSetName = 'String', Mandatory = $true, Position = 0)]
        [string] $InputString,

        [Parameter(ParameterSetName = 'Object', Mandatory = $true, Position = 0)]
        [object] $InputObject
    )

    # Helper: determine whether a value is a scalar (string/number/bool/null)
    function Is-Scalar($v) {
        if ($null -eq $v) { return $true }
        switch -regex ($v.GetType().Name) {
            'String' { return $true }
            'Int32|Int64|Double|Decimal|Boolean|Byte' { return $true }
            default { return $false }
        }
    }

    # Common non-deterministic property names (case-insensitive)
    $IgnoredPropertyRegexes = @(
        '^system\.(created|changed).*',
        '.*created(date)?$',
        '.*changed(date)?$',
        '.*last.*modified.*',
        '.*history.*',
        '^id$',
        '^system\.id$',
        'azureid',
        '^_.*' # internal/temporary fields prefixed with underscore
    )

    # Normalize attachments: keep only deterministic fields (path/name/url). Drop azure ids
    function Normalize-Attachment($att) {
        if ($null -eq $att) { return $null }
        if ($att -is [string]) { return $att.Trim() }
        # treat as hashtable/psobject
        $result = [ordered]@{}
        foreach ($k in ($att.Keys | Sort-Object -CaseSensitive)) {
            $kLower = $k.ToString().ToLower()
            if ($kLower -match 'azureid') { continue }
            if ($kLower -match 'id' -and $kLower -eq 'id') { continue }
            # Keep common deterministic properties
            if ($kLower -in @('path','filename','name','url','uri','relativepath')) {
                $result[$k] = ($att[$k] -as [string])?.Trim()
            }
        }
        return $result
    }

    # Recursively normalize objects/arrays/primitives
    function Normalize-Value($value) {
        if ($null -eq $value) { return $null }

        # Scalars: trim strings
        if ($value -is [string]) { return $value.Trim() }
        # Use TypeCode to detect primitive/value types. If the TypeCode is not Object,
        # treat as scalar and return as-is.
        try {
            $typeCode = [System.Type]::GetTypeCode($value.GetType())
            if ($typeCode -ne [System.TypeCode]::Object) { return $value }
        }
        catch {
            # If GetType or GetTypeCode fails, fall through to other handlers
        }

        # Hashtable or PSCustomObject: handle dictionaries/objects before generic IEnumerable
        if ($value -is [System.Collections.IDictionary] -or $value -is [PSCustomObject]) {
            $dict = @{}
            # Use .psobject.properties when PSCustomObject
            if ($value -is [PSCustomObject]) {
                foreach ($p in $value.PSObject.Properties) { $dict[$p.Name] = $p.Value }
            }
            else {
                foreach ($k in $value.Keys) { $dict[$k] = $value[$k] }
            }

            # Remove ignored keys
            $normalized = [ordered]@{}
            foreach ($k in ($dict.Keys | Where-Object { $_ } | Sort-Object -CaseSensitive)) {
                $shouldIgnore = $false
                foreach ($rx in $IgnoredPropertyRegexes) {
                    if ($k.ToString().ToLower() -match $rx) { $shouldIgnore = $true; break }
                }
                if ($shouldIgnore) { continue }

                $v = $dict[$k]

                # Special handling for well-known fields
                if ($k -ieq 'steps') {
                    # normalize steps array
                    $normalizedSteps = Normalize-Value $v
                    # For each step, ensure ordering of properties
                    $orderedSteps = @()
                    foreach ($s in $normalizedSteps) {
                        $stepObj = [ordered]@{}
                        # Prefer numeric stepNumber as first property if present
                        if ($s -ne $null -and ($s.PSObject.Properties.Name -contains 'stepNumber')) { $stepObj['stepNumber'] = [int]$s.stepNumber }
                        # Normalize attachments inside step
                        if ($s -ne $null -and ($s.PSObject.Properties.Name -contains 'attachments')) {
                            $atts = @()
                            foreach ($a in $s.attachments) { $atts += (Normalize-Attachment $a) }
                            $stepObj['attachments'] = $atts
                        }
                        # other properties: action, expectedResult, etc. Add sorted
                        $otherProps = ($s.PSObject.Properties.Name | Where-Object { $_ -ne 'stepNumber' -and $_ -ne 'attachments' } | Sort-Object -CaseSensitive)
                        foreach ($op in $otherProps) { $stepObj[$op] = Normalize-Value $s.$op }
                        $orderedSteps += $stepObj
                    }
                    $normalized[$k] = $orderedSteps
                    continue
                }

                if ($k -ieq 'tags' -or $k -ieq 'Tags') {
                    $normalized[$k] = (Normalize-Value $v)
                    continue
                }

                if ($k -ieq 'customFields' -or $k -ieq 'customfields') {
                    # normalize nested custom fields as ordered object
                    $cf = Normalize-Value $v
                    if ($cf -is [System.Collections.IDictionary]) {
                        $cfOrdered = [ordered]@{}
                        foreach ($ck in ($cf.Keys | Sort-Object -CaseSensitive)) { $cfOrdered[$ck] = Normalize-Value $cf[$ck] }
                        $normalized[$k] = $cfOrdered
                    }
                    else { $normalized[$k] = $cf }
                    continue
                }

                # attachments at top-level or other objects
                if ($k.ToString().ToLower() -match 'attachment') {
                    if ($v -is [System.Collections.IEnumerable] -and -not ($v -is [string])) {
                        $resAtt = @()
                        foreach ($a in $v) { $resAtt += (Normalize-Attachment $a) }
                        $normalized[$k] = $resAtt
                        continue
                    }
                }

                # Default: recursively normalize the value
                $normalized[$k] = Normalize-Value $v
            }

            return $normalized
        }

        # Arrays / generic IEnumerable (exclude IDictionary which was handled above)
        if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string]) -and -not ($value -is [System.Collections.IDictionary])) {
            $list = @()
            foreach ($item in $value) { $list += (Normalize-Value $item) }

            # If list of scalars, sort for deterministic order
            $allScalar = $true
            foreach ($i in $list) { if (-not (Is-Scalar $i)) { $allScalar = $false; break } }
            if ($allScalar) { return ($list | Sort-Object -Unique) }

            # If items are objects and have stepNumber, sort by it
            $hasStepNumber = $false
            foreach ($i in $list) { if ($i -ne $null -and ($i.PSObject.Properties.Name -contains 'stepNumber')) { $hasStepNumber = $true; break } }
            if ($hasStepNumber) {
                return ($list | Sort-Object {[int]($_.stepNumber)})
            }

            # Otherwise sort by JSON representation to get deterministic order
            $withJson = $list | ForEach-Object { @{ obj = $_; json = (ConvertTo-Json -InputObject $_ -Depth 50 -Compress) } }
            return ($withJson | Sort-Object -Property json | ForEach-Object { $_.obj })
        }

        # Hashtable or PSCustomObject
        if ($value -is [System.Collections.IDictionary] -or $value -is [PSCustomObject]) {
            $dict = @{}
            # Use .psobject.properties when PSCustomObject
            if ($value -is [PSCustomObject]) {
                foreach ($p in $value.PSObject.Properties) { $dict[$p.Name] = $p.Value }
            }
            else {
                foreach ($k in $value.Keys) { $dict[$k] = $value[$k] }
            }

            # Remove ignored keys
            $normalized = [ordered]@{}
            foreach ($k in ($dict.Keys | Where-Object { $_ } | Sort-Object -CaseSensitive)) {
                $shouldIgnore = $false
                foreach ($rx in $IgnoredPropertyRegexes) {
                    if ($k.ToString().ToLower() -match $rx) { $shouldIgnore = $true; break }
                }
                if ($shouldIgnore) { continue }

                $v = $dict[$k]

                # Special handling for well-known fields
                if ($k -ieq 'steps') {
                    # normalize steps array
                    $normalizedSteps = Normalize-Value $v
                    # For each step, ensure ordering of properties
                    $orderedSteps = @()
                    foreach ($s in $normalizedSteps) {
                        $stepObj = [ordered]@{}
                        # Prefer numeric stepNumber as first property if present
                        if ($s -ne $null -and ($s.PSObject.Properties.Name -contains 'stepNumber')) { $stepObj['stepNumber'] = [int]$s.stepNumber }
                        # Normalize attachments inside step
                        if ($s -ne $null -and ($s.PSObject.Properties.Name -contains 'attachments')) {
                            $atts = @()
                            foreach ($a in $s.attachments) { $atts += (Normalize-Attachment $a) }
                            $stepObj['attachments'] = $atts
                        }
                        # other properties: action, expectedResult, etc. Add sorted
                        $otherProps = ($s.PSObject.Properties.Name | Where-Object { $_ -ne 'stepNumber' -and $_ -ne 'attachments' } | Sort-Object -CaseSensitive)
                        foreach ($op in $otherProps) { $stepObj[$op] = Normalize-Value $s.$op }
                        $orderedSteps += $stepObj
                    }
                    $normalized[$k] = $orderedSteps
                    continue
                }

                if ($k -ieq 'tags' -or $k -ieq 'Tags') {
                    $normalized[$k] = (Normalize-Value $v)
                    continue
                }

                if ($k -ieq 'customFields' -or $k -ieq 'customfields') {
                    # normalize nested custom fields as ordered object
                    $cf = Normalize-Value $v
                    if ($cf -is [System.Collections.IDictionary]) {
                        $cfOrdered = [ordered]@{}
                        foreach ($ck in ($cf.Keys | Sort-Object -CaseSensitive)) { $cfOrdered[$ck] = Normalize-Value $cf[$ck] }
                        $normalized[$k] = $cfOrdered
                    }
                    else { $normalized[$k] = $cf }
                    continue
                }

                # attachments at top-level or other objects
                if ($k.ToString().ToLower() -match 'attachment') {
                    if ($v -is [System.Collections.IEnumerable] -and -not ($v -is [string])) {
                        $resAtt = @()
                        foreach ($a in $v) { $resAtt += (Normalize-Attachment $a) }
                        $normalized[$k] = $resAtt
                        continue
                    }
                }

                # Default: recursively normalize the value
                $normalized[$k] = Normalize-Value $v
            }

            return $normalized
        }

        # Fallback: return as-is
        return $value
    }

    # Resolve input into an object to normalize
    $objToNormalize = $null
    if ($PSCmdlet.ParameterSetName -eq 'String') {
        # Try parse JSON; if fails, treat as raw string and hash directly
        try {
            $objToNormalize = ConvertFrom-Json -InputObject $InputString -Depth 100 -ErrorAction Stop
        }
        catch {
            # Not JSON: hash raw string like prior behavior
            $hasher = [System.Security.Cryptography.SHA256]::Create()
            $bytes = [System.Text.Encoding]::UTF8.GetBytes($InputString)
            $hashBytes = $hasher.ComputeHash($bytes)
            $hasher.Dispose()
            return ([System.BitConverter]::ToString($hashBytes) -replace '-', '').ToLower()
        }
    }
    else {
        $objToNormalize = $InputObject
    }

    # Normalize the object
    $normalized = Normalize-Value $objToNormalize

    # Convert normalized object to canonical JSON (sorted keys preserved via ordered hashtables)
    $json = ConvertTo-Json -InputObject $normalized -Depth 100 -Compress

    # Finally compute SHA256
    $hasher = [System.Security.Cryptography.SHA256]::Create()
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
    $hashBytes = $hasher.ComputeHash($bytes)
    $hash = ([System.BitConverter]::ToString($hashBytes) -replace '-', '').ToLower()
    $hasher.Dispose()

    return $hash
}