Private/PresentationSemantics.ps1

$script:TerminalSlidesMaximumValueDepth = 32

function Get-TerminalSlideMaximumRevealStep {
    param([Parameter(Mandatory)][TerminalSlides.Schema.V1.Slide]$Slide)

    $maximum = 0
    foreach ($element in $Slide.Elements) {
        if ($element.RevealStep -lt 0) {
            throw "Slide element reveal step '$($element.RevealStep)' must be non-negative."
        }
        if ($element.RevealStep -gt $maximum) { $maximum = $element.RevealStep }
    }
    return $maximum
}

function New-TerminalSemanticTraversalContext {
    return [pscustomobject]@{
        MaximumDepth = $script:TerminalSlidesMaximumValueDepth
        ActiveReferences = [Collections.Generic.HashSet[object]]::new(
            [Collections.Generic.ReferenceEqualityComparer]::Instance
        )
    }
}

function Assert-TerminalSemanticTraversalDepth {
    param(
        [int]$Depth,
        [int]$MaximumDepth = $script:TerminalSlidesMaximumValueDepth
    )

    if ($Depth -gt $MaximumDepth) {
        throw "Metadata exceeds the supported depth of $MaximumDepth."
    }
}

function Enter-TerminalSemanticReference {
    param(
        [Parameter(Mandatory)][object]$Value,
        [Parameter(Mandatory)][psobject]$TraversalContext
    )

    if (-not $TraversalContext.ActiveReferences.Add($Value)) {
        throw "Metadata contains a reference cycle at type '$($Value.GetType().FullName)'."
    }
}

function Exit-TerminalSemanticReference {
    param(
        [Parameter(Mandatory)][object]$Value,
        [Parameter(Mandatory)][psobject]$TraversalContext
    )

    [void]$TraversalContext.ActiveReferences.Remove($Value)
}

function Set-TerminalMediaOrigin {
    param(
        [Parameter(Mandatory)][TerminalSlides.Schema.V1.SlideElement]$Element,
        [Parameter(Mandatory)][string]$Directory
    )

    [TerminalSlides.Schema.V1.MediaOriginRegistry]::Set($Element, [IO.Path]::GetFullPath($Directory))
}

function Get-TerminalMediaOrigin {
    param([Parameter(Mandatory)][TerminalSlides.Schema.V1.SlideElement]$Element)

    return [TerminalSlides.Schema.V1.MediaOriginRegistry]::Get($Element)
}

function Get-TerminalSemanticProperty {
    param(
        [AllowNull()][object]$InputObject,
        [Parameter(Mandatory)][string]$Name
    )

    if ($null -eq $InputObject) { return $null }
    if ($InputObject -is [System.Collections.IDictionary]) { return $InputObject[$Name] }
    $property = $InputObject.PSObject.Properties[$Name]
    if ($property) { return $property.Value }
    return $null
}

function Assert-TerminalSupportedArrayShape {
    param([Parameter(Mandatory)][Type]$ArrayType)

    if (-not $ArrayType.IsSZArray -or $ArrayType.GetElementType().IsArray) {
        throw "Metadata arrays must be zero-based, one-dimensional, and non-jagged. Type '$($ArrayType.FullName)' is not supported."
    }
}

function ConvertFrom-TerminalDateTimeText {
    param([Parameter(Mandatory)][string]$Text)

    $culture = [Globalization.CultureInfo]::InvariantCulture
    if ($Text -match '[+-][0-9]{2}:[0-9]{2}\z') {
        $offsetValue = [datetimeoffset]::Parse($Text, $culture, [Globalization.DateTimeStyles]::RoundtripKind)
        return $offsetValue.LocalDateTime
    }
    return [datetime]::Parse($Text, $culture, [Globalization.DateTimeStyles]::RoundtripKind)
}

function Assert-TerminalUnambiguousDictionaryKeys {
    param([Parameter(Mandatory)][System.Collections.IDictionary]$Dictionary)

    Assert-TerminalUnambiguousNames -Names @($Dictionary.Keys) -Subject 'Metadata dictionary keys' -Item 'metadata key'
}

function Get-TerminalCanonicalDictionaryKeys {
    param([Parameter(Mandatory)][System.Collections.IDictionary]$Dictionary)

    $keys = [string[]]@($Dictionary.Keys)
    if ($Dictionary -isnot [System.Collections.Specialized.OrderedDictionary]) {
        [Array]::Sort($keys, [StringComparer]::Ordinal)
    }
    return $keys
}

function Assert-TerminalUnambiguousNames {
    param(
        [AllowEmptyCollection()][object[]]$Names,
        [Parameter(Mandatory)][string]$Subject,
        [Parameter(Mandatory)][string]$Item
    )

    $keys = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    foreach ($name in $Names) {
        if ($null -eq $name -or $name -isnot [string] -or $name.Length -eq 0) {
            throw "$Subject must be non-empty strings."
        }
        if (-not $keys.Add($name)) {
            throw "$Subject must be unique ignoring case. Duplicate $Item '$name'."
        }
    }
}

function Copy-TerminalSemanticValue {
    param(
        [AllowNull()][object]$Value,
        [int]$Depth = 0,
        [AllowNull()][psobject]$TraversalContext
    )

    if ($null -eq $TraversalContext) { $TraversalContext = New-TerminalSemanticTraversalContext }
    Assert-TerminalSemanticTraversalDepth -Depth $Depth -MaximumDepth $TraversalContext.MaximumDepth

    if ($null -eq $Value -or $Value -is [string] -or $Value.GetType().IsValueType -or
        $Value -is [uri] -or $Value -is [version] -or $Value -is [regex] -or
        $Value -is [scriptblock]) {
        return $Value
    }

    Enter-TerminalSemanticReference -Value $Value -TraversalContext $TraversalContext
    try {
        if ($Value -is [System.Collections.Specialized.OrderedDictionary]) {
            Assert-TerminalUnambiguousDictionaryKeys -Dictionary $Value
            $copy = [ordered]@{}
            foreach ($key in $Value.Keys) {
                $copy[$key] = Copy-TerminalSemanticValue -Value $Value[$key] -Depth ($Depth + 1) -TraversalContext $TraversalContext
            }
            return $copy
        }
        if ($Value -is [hashtable]) {
            Assert-TerminalUnambiguousDictionaryKeys -Dictionary $Value
            $copy = @{}
            foreach ($key in $Value.Keys) {
                $copy[$key] = Copy-TerminalSemanticValue -Value $Value[$key] -Depth ($Depth + 1) -TraversalContext $TraversalContext
            }
            return $copy
        }
        if ($Value.GetType().IsArray) {
            Assert-TerminalSupportedArrayShape -ArrayType $Value.GetType()
            $elementType = $Value.GetType().GetElementType()
            $copy = [Array]::CreateInstance($elementType, $Value.Length)
            for ($index = 0; $index -lt $Value.Length; $index++) {
                $item = Copy-TerminalSemanticValue -Value $Value.GetValue($index) -Depth ($Depth + 1) -TraversalContext $TraversalContext
                $copy.SetValue($item, $index)
            }
            return ,$copy
        }
        if ($Value -is [System.Collections.ArrayList]) {
            $copy = [System.Collections.ArrayList]::new($Value.Count)
            foreach ($item in $Value) {
                [void]$copy.Add((Copy-TerminalSemanticValue -Value $item -Depth ($Depth + 1) -TraversalContext $TraversalContext))
            }
            return ,$copy
        }
        if ($Value -is [System.Management.Automation.PSCustomObject]) {
            $copy = [ordered]@{}
            foreach ($property in $Value.PSObject.Properties) {
                $copy[$property.Name] = Copy-TerminalSemanticValue -Value $property.Value -Depth ($Depth + 1) -TraversalContext $TraversalContext
            }
            return [pscustomobject]$copy
        }

        throw "Unsupported mutable value type '$($Value.GetType().FullName)'. Use primitives, arrays, hashtables, ordered dictionaries, or PSCustomObject values."
    }
    finally {
        Exit-TerminalSemanticReference -Value $Value -TraversalContext $TraversalContext
    }
}

function ConvertTo-TerminalScalarValue {
    param([AllowNull()][object]$Value)

    $culture = [Globalization.CultureInfo]::InvariantCulture
    if ($null -eq $Value) {
        return [TerminalSlides.Schema.V1.ScalarValue]::new('Null', [Management.Automation.Language.NullString]::Value)
    }
    if ($Value -is [string]) { return [TerminalSlides.Schema.V1.ScalarValue]::new('String', [string]$Value) }
    if ($Value -is [char]) { return [TerminalSlides.Schema.V1.ScalarValue]::new('Char', [string]$Value) }
    if ($Value -is [bool]) { return [TerminalSlides.Schema.V1.ScalarValue]::new('Boolean', $Value.ToString($culture)) }
    if ($Value -is [sbyte]) { return [TerminalSlides.Schema.V1.ScalarValue]::new('SByte', $Value.ToString($culture)) }
    if ($Value -is [byte]) { return [TerminalSlides.Schema.V1.ScalarValue]::new('Byte', $Value.ToString($culture)) }
    if ($Value -is [int16]) { return [TerminalSlides.Schema.V1.ScalarValue]::new('Int16', $Value.ToString($culture)) }
    if ($Value -is [uint16]) { return [TerminalSlides.Schema.V1.ScalarValue]::new('UInt16', $Value.ToString($culture)) }
    if ($Value -is [int32]) { return [TerminalSlides.Schema.V1.ScalarValue]::new('Int32', $Value.ToString($culture)) }
    if ($Value -is [uint32]) { return [TerminalSlides.Schema.V1.ScalarValue]::new('UInt32', $Value.ToString($culture)) }
    if ($Value -is [int64]) { return [TerminalSlides.Schema.V1.ScalarValue]::new('Int64', $Value.ToString($culture)) }
    if ($Value -is [uint64]) { return [TerminalSlides.Schema.V1.ScalarValue]::new('UInt64', $Value.ToString($culture)) }
    if ($Value -is [single]) { return [TerminalSlides.Schema.V1.ScalarValue]::new('Single', $Value.ToString('R', $culture)) }
    if ($Value -is [double]) { return [TerminalSlides.Schema.V1.ScalarValue]::new('Double', $Value.ToString('R', $culture)) }
    if ($Value -is [decimal]) { return [TerminalSlides.Schema.V1.ScalarValue]::new('Decimal', $Value.ToString($culture)) }
    if ($Value -is [datetime]) { return [TerminalSlides.Schema.V1.ScalarValue]::new('DateTime', $Value.ToString('o', $culture)) }
    if ($Value -is [datetimeoffset]) { return [TerminalSlides.Schema.V1.ScalarValue]::new('DateTimeOffset', $Value.ToString('o', $culture)) }
    if ($Value -is [timespan]) { return [TerminalSlides.Schema.V1.ScalarValue]::new('TimeSpan', $Value.ToString('c', $culture)) }
    if ($Value -is [guid]) { return [TerminalSlides.Schema.V1.ScalarValue]::new('Guid', $Value.ToString('D')) }

    throw "Table values must be scalar. '$($Value.GetType().FullName)' is not supported."
}

function ConvertFrom-TerminalScalarValue {
    param([Parameter(Mandatory)][TerminalSlides.Schema.V1.ScalarValue]$Value)

    $culture = [Globalization.CultureInfo]::InvariantCulture
    switch ($Value.Kind.ToString()) {
        'Null' { return $null }
        'String' { return $Value.Value }
        'Char' { return [char]$Value.Value }
        'Boolean' { return [bool]::Parse($Value.Value) }
        'SByte' { return [sbyte]::Parse($Value.Value, $culture) }
        'Byte' { return [byte]::Parse($Value.Value, $culture) }
        'Int16' { return [int16]::Parse($Value.Value, $culture) }
        'UInt16' { return [uint16]::Parse($Value.Value, $culture) }
        'Int32' { return [int32]::Parse($Value.Value, $culture) }
        'UInt32' { return [uint32]::Parse($Value.Value, $culture) }
        'Int64' { return [int64]::Parse($Value.Value, $culture) }
        'UInt64' { return [uint64]::Parse($Value.Value, $culture) }
        'Single' { return [single]::Parse($Value.Value, $culture) }
        'Double' { return [double]::Parse($Value.Value, $culture) }
        'Decimal' { return [decimal]::Parse($Value.Value, $culture) }
        'DateTime' { return ConvertFrom-TerminalDateTimeText -Text $Value.Value }
        'DateTimeOffset' { return [datetimeoffset]::ParseExact($Value.Value, 'o', $culture, [Globalization.DateTimeStyles]::RoundtripKind) }
        'TimeSpan' { return [timespan]::ParseExact($Value.Value, 'c', $culture) }
        'Guid' { return [guid]::ParseExact($Value.Value, 'D') }
        default { throw "Unsupported scalar kind '$($Value.Kind)'." }
    }
}

function Assert-TerminalTableColumnIdentity {
    param([Parameter(Mandatory)][AllowEmptyCollection()][TerminalSlides.Schema.V1.DataRow[]]$Rows)

    $names = [Collections.Generic.Dictionary[string,string]]::new([StringComparer]::OrdinalIgnoreCase)
    foreach ($row in $Rows) {
        Assert-TerminalRequiredModelValue -Value $row -Subject 'Table row'
        $rowNames = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
        foreach ($cell in $row.Cells) {
            Assert-TerminalRequiredModelValue -Value $cell -Subject 'Table cell'
            if (-not $rowNames.Add($cell.Name)) {
                throw "Table column names must be unique ignoring case. Duplicate table column '$($cell.Name)'."
            }
            if ($names.ContainsKey($cell.Name) -and $names[$cell.Name] -cne $cell.Name) {
                throw 'Table column names must be unique ignoring case.'
            }
            if (-not $names.ContainsKey($cell.Name)) { $names.Add($cell.Name, $cell.Name) }
        }
    }
}

function Assert-TerminalDiagramNodeIdentity {
    param([Parameter(Mandatory)][TerminalSlides.Schema.V1.DiagramNode[]]$Nodes)

    $identifiers = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    foreach ($node in $Nodes) {
        Assert-TerminalRequiredModelValue -Value $node -Subject 'Diagram node'
        if (-not $identifiers.Add($node.Id)) {
            throw "Diagram node IDs must be unique ignoring case. Duplicate node ID '$($node.Id)'."
        }
    }
}

function Assert-TerminalRequiredModelValue {
    param(
        [Parameter(Mandatory)][AllowNull()][object]$Value,
        [Parameter(Mandatory)][string]$Subject
    )

    if ($null -eq $Value) { throw "$Subject must not be null." }
}

function Assert-TerminalWireReadyPresentationModel {
    param([Parameter(Mandatory)][TerminalSlides.Schema.V1.TerminalPresentation]$Presentation)

    if ($Presentation.Width -lt 0 -or $Presentation.Height -lt 0 -or
        ($Presentation.Width -gt 0 -and $Presentation.Width -lt 20) -or
        ($Presentation.Height -gt 0 -and $Presentation.Height -lt 10)) {
        throw 'Presentation dimensions must be automatic (0) or at least 20x10.'
    }
    Assert-TerminalRequiredModelValue -Value $Presentation.Metadata -Subject 'Presentation Metadata'
    Assert-TerminalRequiredModelValue -Value $Presentation.Metadata.Custom -Subject 'Presentation Metadata Custom map'
    Assert-TerminalRequiredModelValue -Value $Presentation.Configuration -Subject 'Presentation Configuration map'
    Assert-TerminalRequiredModelValue -Value $Presentation.Slides -Subject 'Presentation Slides collection'
    $theme = Resolve-TerminalPresentationTheme -Presentation $Presentation
    Assert-TerminalRequiredModelValue -Value $theme.Metadata -Subject 'Presentation theme Metadata map'
    foreach ($field in 'Name','CodeTheme','BulletSymbol') {
        if ([string]::IsNullOrWhiteSpace($theme.$field)) { throw "Presentation theme $field must be nonblank." }
    }
    foreach ($field in 'Background','Foreground','Primary','Accent','Muted','Heading','Border','ErrorColor','WarningColor','SuccessColor') {
        [void](Read-TerminalWireHexColor $theme.$field "Presentation theme $field")
    }
    foreach ($field in 'CodeBackground','CodeForeground') {
        [void](Read-TerminalWireHexColor $theme.$field "Presentation theme $field" -AllowDefault)
    }
    [void](Read-TerminalWireDomainName $theme.BoxDrawingStyle @('unicode','ascii','double','rounded','single') 'Presentation theme BoxDrawingStyle')
    [void](Read-TerminalWireDomainName $theme.HeadingStyle @('plain','bold','banner') 'Presentation theme HeadingStyle')
    if (-not $theme.ChartPalette -or $theme.ChartPalette.Count -eq 0) { throw 'Presentation theme ChartPalette must not be empty.' }
    foreach ($color in $theme.ChartPalette) { [void](Read-TerminalWireHexColor $color 'Presentation theme ChartPalette') }

    [void](Read-TerminalWireDomainName $Presentation.DefaultLayout $script:TerminalSlideLayouts 'Presentation DefaultLayout')
    foreach ($slide in $Presentation.Slides) {
        Assert-TerminalRequiredModelValue -Value $slide -Subject 'Presentation slide'
        if ($slide.Index -lt 0) { throw "Slide Index '$($slide.Index)' must be non-negative." }
        Assert-TerminalRequiredModelValue -Value $slide.Metadata -Subject 'Slide Metadata'
        Assert-TerminalRequiredModelValue -Value $slide.Metadata.Custom -Subject 'Slide Metadata Custom map'
        Assert-TerminalRequiredModelValue -Value $slide.Elements -Subject 'Slide Elements collection'
        [void](Get-TerminalSlideMaximumRevealStep -Slide $slide)
        $layout = Read-TerminalWireDomainName $slide.Layout $script:TerminalSlideLayouts 'Slide Layout'
        [void](Read-TerminalWireHexColor $slide.Background 'Slide Background' -AllowDefault)
        $regions = Get-LayoutRegions -Layout $layout -Width 20 -Height 10
        $supportedRegions = @(Get-TerminalElementRegionNames -Regions $regions)
        $usedRegions = [Collections.Generic.List[string]]::new()
        foreach ($element in $slide.Elements) {
            Assert-TerminalRequiredModelValue -Value $element -Subject 'Slide element'
            foreach ($field in 'X','Y','Width','Height','Padding') {
                if ($element.$field -lt 0) { throw "Slide element $field '$($element.$field)' must be non-negative." }
            }
            if ($element.Kind -eq [TerminalSlides.Schema.V1.ElementKind]::Diagram) {
                Assert-TerminalDiagramNodeIdentity -Nodes ([TerminalSlides.Schema.V1.DiagramNode[]]@($element.Payload.Nodes))
                foreach ($edge in $element.Payload.Edges) { Assert-TerminalRequiredModelValue -Value $edge -Subject 'Diagram edge' }
            }
            if ($element.Kind -eq [TerminalSlides.Schema.V1.ElementKind]::Table) {
                Assert-TerminalTableColumnIdentity -Rows ([TerminalSlides.Schema.V1.DataRow[]]@($element.Payload.Rows))
                foreach ($row in $element.Payload.Rows) {
                    foreach ($cell in $row.Cells) {
                        Assert-TerminalRequiredModelValue -Value $cell.Value -Subject 'Table cell value'
                        if ($cell.Value.Kind -eq [TerminalSlides.Schema.V1.ScalarKind]::Null) {
                            if ($null -ne $cell.Value.Value) { throw 'Null table cell values must contain null text.' }
                            continue
                        }
                        try { $decoded = ConvertFrom-TerminalScalarValue $cell.Value }
                        catch { throw "Table cell '$($cell.Name)' contains an invalid $($cell.Value.Kind) value: $($_.Exception.Message)" }
                        if ($cell.Value.Kind -eq [TerminalSlides.Schema.V1.ScalarKind]::DateTime) {
                            Assert-TerminalCanonicalTaggedScalarText -Type DateTime -Value $cell.Value.Value
                        }
                        else {
                            $canonical = ConvertTo-TerminalScalarValue $decoded
                            if ($canonical.Kind -ne $cell.Value.Kind -or $canonical.Value -cne $cell.Value.Value) {
                                throw "Table cell '$($cell.Name)' contains a noncanonical $($cell.Value.Kind) value."
                            }
                        }
                    }
                }
            }
            if ($element.Kind -eq [TerminalSlides.Schema.V1.ElementKind]::Chart) {
                if (-not [Enum]::IsDefined([TerminalSlides.Schema.V1.ChartKind], $element.Payload.ChartKind)) {
                    throw "Chart payload ChartKind '$($element.Payload.ChartKind)' is unsupported."
                }
                foreach ($point in $element.Payload.Points) { Assert-TerminalRequiredModelValue -Value $point -Subject 'Chart point' }
            }
            $region = Read-TerminalWireDomainName $element.Region $script:TerminalElementRegionOrder 'Slide element Region'
            if ($region -notin $supportedRegions) { throw "Region '$region' is not available in layout '$layout'." }
            $usedRegions.Add($region)
            [void](Read-TerminalWireDomainName $element.Alignment @('Left','Center','Right') 'Slide element Alignment')
            [void](Read-TerminalWireDomainName $element.VerticalAlignment @('Top','Middle','Bottom') 'Slide element VerticalAlignment')
            [void](Read-TerminalWireDomainName $element.BorderStyle @('unicode','ascii','double','rounded','single') 'Slide element BorderStyle')
            [void](Read-TerminalWireDomainName $element.OverflowBehavior @('Wrap','Truncate','Scroll') 'Slide element OverflowBehavior')
            [void](Read-TerminalWireHexColor $element.ForegroundColor 'Slide element ForegroundColor' -AllowDefault)
            [void](Read-TerminalWireHexColor $element.BackgroundColor 'Slide element BackgroundColor' -AllowDefault)
        }
        Assert-TerminalElementRegionCombination -Layout $layout -Regions $regions -RegionNames $usedRegions.ToArray()
    }
}

function ConvertTo-TerminalDataRows {
    param([AllowNull()][object]$Data)

    if ($null -eq $Data) { return ,([TerminalSlides.Schema.V1.DataRow[]]@()) }
    $rows = if ($Data -is [System.Collections.IDictionary] -or $Data -is [string]) { @($Data) }
        elseif ($Data -is [System.Collections.IEnumerable]) { @($Data) }
        else { @($Data) }

    $typedRows = foreach ($row in $rows) {
        $cells = [Collections.Generic.List[TerminalSlides.Schema.V1.DataCell]]::new()
        if ($row -is [System.Collections.IDictionary]) {
            Assert-TerminalUnambiguousNames -Names @($row.Keys) -Subject 'Table column names' -Item 'table column'
            foreach ($name in (Get-TerminalCanonicalDictionaryKeys -Dictionary $row)) {
                $cells.Add([TerminalSlides.Schema.V1.DataCell]::new([string]$name, (ConvertTo-TerminalScalarValue $row[$name])))
            }
        }
        elseif ($null -eq $row -or $row -is [string] -or $row.GetType().IsPrimitive -or
            $row -is [decimal] -or $row -is [datetime] -or $row -is [datetimeoffset] -or
            $row -is [timespan] -or $row -is [guid]) {
            $cells.Add([TerminalSlides.Schema.V1.DataCell]::new('Value', (ConvertTo-TerminalScalarValue $row)))
        }
        else {
            $properties = @($row.PSObject.Properties | Where-Object MemberType -in NoteProperty, Property)
            Assert-TerminalUnambiguousNames -Names @($properties.Name) -Subject 'Table column names' -Item 'table column'
            foreach ($property in $properties) {
                $cells.Add([TerminalSlides.Schema.V1.DataCell]::new($property.Name, (ConvertTo-TerminalScalarValue $property.Value)))
            }
        }
        [TerminalSlides.Schema.V1.DataRow]::new($cells.ToArray())
    }
    $result = [TerminalSlides.Schema.V1.DataRow[]]@($typedRows)
    Assert-TerminalTableColumnIdentity -Rows $result
    return ,$result
}

function ConvertFrom-TerminalDataRows {
    param([Parameter(Mandatory)][Collections.Generic.IReadOnlyList[TerminalSlides.Schema.V1.DataRow]]$Rows)

    return @(
        foreach ($row in $Rows) {
            $record = [ordered]@{}
            foreach ($cell in $row.Cells) { $record[$cell.Name] = ConvertFrom-TerminalScalarValue $cell.Value }
            [pscustomobject]$record
        }
    )
}

function Copy-TerminalElementPayload {
    param([Parameter(Mandatory)][TerminalSlides.Schema.V1.ElementPayload]$Payload)

    switch ($Payload.GetType().Name) {
        'TextPayload' { return [TerminalSlides.Schema.V1.TextPayload]::new($Payload.Text) }
        'CodePayload' { return [TerminalSlides.Schema.V1.CodePayload]::new($Payload.Code, $Payload.Language) }
        'ImagePayload' { return [TerminalSlides.Schema.V1.ImagePayload]::new($Payload.Path, $Payload.AltText) }
        'QuotePayload' { return [TerminalSlides.Schema.V1.QuotePayload]::new($Payload.Text, $Payload.Attribution) }
        'ChartPayload' {
            $points = @($Payload.Points | ForEach-Object { [TerminalSlides.Schema.V1.ChartPoint]::new($_.Label, $_.Value) })
            return [TerminalSlides.Schema.V1.ChartPayload]::new([TerminalSlides.Schema.V1.ChartPoint[]]$points, $Payload.ChartKind, $Payload.Title)
        }
        'DiagramPayload' {
            $nodes = @($Payload.Nodes | ForEach-Object { [TerminalSlides.Schema.V1.DiagramNode]::new($_.Id, $_.Label) })
            $edges = @($Payload.Edges | ForEach-Object { [TerminalSlides.Schema.V1.DiagramEdge]::new($_.From, $_.To, $_.Label) })
            return [TerminalSlides.Schema.V1.DiagramPayload]::new([TerminalSlides.Schema.V1.DiagramNode[]]$nodes, [TerminalSlides.Schema.V1.DiagramEdge[]]$edges)
        }
        'TablePayload' {
            $rows = foreach ($row in $Payload.Rows) {
                $cells = @($row.Cells | ForEach-Object {
                    $scalarText = if ($null -eq $_.Value.Value) {
                        [Management.Automation.Language.NullString]::Value
                    }
                    else { $_.Value.Value }
                    [TerminalSlides.Schema.V1.DataCell]::new(
                        $_.Name,
                        [TerminalSlides.Schema.V1.ScalarValue]::new($_.Value.Kind, $scalarText)
                    )
                })
                [TerminalSlides.Schema.V1.DataRow]::new([TerminalSlides.Schema.V1.DataCell[]]$cells)
            }
            return [TerminalSlides.Schema.V1.TablePayload]::new([TerminalSlides.Schema.V1.DataRow[]]@($rows))
        }
    }
}

function Copy-TerminalSlideElement {
    param([Parameter(Mandatory)][TerminalSlides.Schema.V1.SlideElement]$Element)

    $copy = [TerminalSlides.Schema.V1.SlideElement]::new($Element.Kind, (Copy-TerminalElementPayload $Element.Payload))
    foreach ($property in 'Id','Region','X','Y','Width','Height','Alignment','VerticalAlignment','Padding','ForegroundColor','BackgroundColor','Border','BorderStyle','RevealStep','OverflowBehavior') {
        $copy.$property = $Element.$property
    }
    $origin = Get-TerminalMediaOrigin $Element
    if ($origin) { Set-TerminalMediaOrigin -Element $copy -Directory $origin }
    return $copy
}

function Copy-TerminalSlideModel {
    param([Parameter(Mandatory)][TerminalSlides.Schema.V1.Slide]$Slide)

    $copy = [TerminalSlides.Schema.V1.Slide]::new()
    foreach ($property in 'Id','Index','Title','Layout','Notes','Background','Transition','Hidden') { $copy.$property = $Slide.$property }
    $copy.Metadata.Author = $Slide.Metadata.Author
    $copy.Metadata.Custom = Copy-TerminalSemanticValue $Slide.Metadata.Custom
    foreach ($element in $Slide.Elements) { $copy.Elements.Add((Copy-TerminalSlideElement $element)) }
    $copy.MaxRevealStep = Get-TerminalSlideMaximumRevealStep -Slide $copy
    return $copy
}

function Copy-TerminalThemeDefinition {
    param([Parameter(Mandatory)][TerminalSlides.Schema.V1.ThemeDefinition]$Theme)

    $copy = [TerminalSlides.Schema.V1.ThemeDefinition]::new()
    foreach ($property in 'Name','Background','Foreground','Primary','Accent','Muted','Heading','Border','CodeTheme',
        'CodeBackground','CodeForeground','BulletSymbol','BoxDrawingStyle','HeadingStyle','ErrorColor','WarningColor','SuccessColor') {
        $copy.$property = $Theme.$property
    }
    $copy.ChartPalette = [string[]]@($Theme.ChartPalette)
    $copy.Metadata = if ($Theme.Metadata) { Copy-TerminalSemanticValue $Theme.Metadata } else { @{} }
    return $copy
}

function Resolve-TerminalPresentationTheme {
    param([Parameter(Mandatory)][TerminalSlides.Schema.V1.TerminalPresentation]$Presentation)

    if ($Presentation.EmbeddedTheme -and [string]::Equals(
        $Presentation.Theme, $Presentation.EmbeddedTheme.Name, [StringComparison]::OrdinalIgnoreCase
    )) { return $Presentation.EmbeddedTheme }
    return Get-ResolvedTheme -Name $Presentation.Theme
}

function New-TerminalPresentationView {
    param([Parameter(Mandatory)][TerminalSlides.Schema.V1.TerminalPresentation]$Presentation)

    $view = [TerminalSlides.Schema.V1.TerminalPresentation]::new()
    foreach ($property in 'Title','Subtitle','Author','Description','Theme','Width','Height','DefaultTransition','DefaultLayout','CreatedDate','ModifiedDate') { $view.$property = $Presentation.$property }
    if ($Presentation.EmbeddedTheme) { $view.EmbeddedTheme = Copy-TerminalThemeDefinition $Presentation.EmbeddedTheme }
    foreach ($property in 'Title','Subtitle','Author','Description','Version') { $view.Metadata.$property = $Presentation.Metadata.$property }
    $view.Metadata.Custom = Copy-TerminalSemanticValue $Presentation.Metadata.Custom
    $view.Configuration = Copy-TerminalSemanticValue $Presentation.Configuration
    foreach ($slide in $Presentation.Slides) {
        if ($slide.Hidden) { continue }
        $copy = Copy-TerminalSlideModel $slide
        $copy.Hidden = $false
        $copy.Index = $view.Slides.Count + 1
        $view.Slides.Add($copy)
    }
    return $view
}

function Get-TerminalElementPayload {
    param([Parameter(Mandatory)][TerminalSlides.Schema.V1.SlideElement]$Element)

    $payload = $Element.Payload
    $result = [ordered]@{
        Kind = $Element.Kind.ToString(); Text = ''; Code = ''; Language = ''; Rows = @(); Title = ''
        ChartType = ''; Nodes = @(); Edges = @(); Path = ''; AltText = ''; Attribution = ''
        SourceDirectory = (Get-TerminalMediaOrigin $Element); Raw = $null
    }
    switch ($Element.Kind.ToString()) {
        { $_ -in 'Title','Subtitle','Text','Bullet','Box' } { $result.Text = $payload.Text; $result.Raw = $payload.Text }
        'Code' { $result.Code = $payload.Code; $result.Language = $payload.Language; $result.Raw = $payload.Code }
        'Table' { $result.Rows = @(ConvertFrom-TerminalDataRows $payload.Rows); $result.Raw = $result.Rows }
        'Chart' {
            $result.Rows = @($payload.Points | ForEach-Object { [pscustomobject]@{ Label = $_.Label; Value = $_.Value } })
            $result.ChartType = $payload.ChartKind.ToString(); $result.Title = $payload.Title; $result.Raw = $result.Rows
        }
        'Diagram' { $result.Nodes = @($payload.Nodes); $result.Edges = @($payload.Edges); $result.Raw = $payload }
        'Image' { $result.Path = $payload.Path; $result.AltText = $payload.AltText; $result.Raw = $payload.Path }
        'Quote' { $result.Text = $payload.Text; $result.Attribution = $payload.Attribution; $result.Raw = $payload.Text }
    }
    return [pscustomobject]$result
}