TypeFormat.psm1

using namespace System.Management.Automation
using namespace System.Management.Automation.Runspaces
using namespace System.Collections
using namespace System.Collections.Generic
using namespace System.Text
using namespace System.Xml
using namespace System.IO

$ErrorActionPreference = 'Stop'
$SCRIPT:RunspaceFormats = [Runspace]::DefaultRunspace.InitialSessionState.Formats

function ConvertFrom-Format {
    <#
    .SYNOPSIS
        Converts Format output to a FormatViewDefinition that can be persisted to the session state or saved as an XML file with ConvertTo-FormatXml
    #>

    param(
        #The name of the format view definition assigned to the format data. Default is 'CustomTypeTableView
        [ValidateNotNullOrEmpty()][string]$Name = 'CustomTypeTableView',
        #The format data to convert. It's best to pipe to this command from Format-Table, etc. for best results
        [Parameter(ValueFromPipeline)][object]$InputObject
    )

    begin {
        $ErrorActionPreference = 'Stop'
        New-Variable -Name 'FormatStartData'
        New-Variable -Name 'GroupData'
        New-Variable -Name 'FormatEntryData'
    }

    process {

        #FIXME: Redo this processing loop to capture the first formatdata/groupdata/etc. so less has to be attached via Add-Member

        #HACK: This is a PowerShell internal type so we cannot use -is or reference the type directly
        $InputObjectType = $InputObject.GetType().Name

        #We are capturing the first of each type of header we encounter, for there is metadata we need stored there.
        #This is a hacky form a type pattern matching
        switch ($InputObjectType) {
            'FormatStartData' {
                if ($FormatStartData) {
                    Write-Warning 'Multiple formats detected, please pass only one format type to this function. It will only process the first format detected'
                    return
                }
                $formatStartData = $InputObject
                break
            }
            'GroupStartData' {
                if ($GroupData) { return }
                $GroupData = $InputObject
                return
            }
            'FormatEntryData' {
                if ($FormatEntryData) { return }
                $FormatEntryData = $InputObject
                return
            }
        }
    }

    end {
        if (-not $formatStartData) {
            Write-Error 'You must provide formatting info to this command. Try | Format-Table | ConvertFrom-Format'
        }

        $shapeInfo = $formatStartData.shapeInfo
        if ($shapeInfo.HeaderInfo.count -gt 1) { throw 'Multiple Header Infos detected. This is a bug and should never happen.' }


        [PSObject]$control = switch ($shapeInfo.GetType().Name) {
            'TableHeaderInfo' {
                $tcProps = @{
                    CalculatedProperties = $formatStartData.CalculatedProperties #Custom added property
                    AutoSize             = $null -ne $FormatStartData.autoSizeInfo
                    Wrap                 = $formatStartData.Wrap #Custom added property
                    GroupBy              = $formatStartData.GroupBy #Custom added property
                }
                #FIXME: Nulls are emitting here, not sure why
                New-TableControl @tcProps $shapeInfo
                | Where-Object { $PSItem }
                | Select-Object -First 1
            }
            default { throw [NotImplementedException]'This type of format is not supported yet' }
        }

        return [FormatViewDefinition]::new($Name, $control)
    }
}

filter Add-Format {
    <#
    .SYNOPSIS
    Adds a new format view definition to the current session state
    #>

    [CmdletBinding()]
    param(
        #Supply the type to associate to the format definition
        [Parameter(Mandatory)][string]$Type,
        [Parameter(Mandatory, ValueFromPipeline)]
        [Management.Automation.FormatViewDefinition]$FormatViewDefinition,

        #Pass through the new Extended Type Definition
        [switch]$PassThru,

        #Don't save the format data to the session state as the default view for the object
        [switch]$NoPersist,

        #Update the definitions but do not process them. You should rarely need to do this, maybe if adding a lot of type definitions at runtime amd then update them separately
        [switch]$NoUpdate
    )

    $ErrorActionPreference = 'Stop'

    $formatName = $FormatViewDefinition.Name

    #TODO: More Advanced Conflict/Update Checking
    $existingTypeData = Get-FormatData -TypeName $Type
    | Where-Object TypeNames -Contains $Type

    $existingFormatData = $existingTypeData
    | ForEach-Object FormatViewDefinition
    | Where-Object Name -EQ $formatName
    | Where-Object Control -Is [TableControl]

    $formatData = [ExtendedTypeDefinition]::new(
        $Type,
        [List[FormatViewDefinition]]@($FormatViewDefinition)
    )

    if (-not $NoPersist) {
        if ($existingFormatData) {
            #Update the formatting information for the type
            throw [NotImplementedException]"#TODO: An existing table view $formatName for $Type was found. Updating existing format data is not yet implemented."
            # Write-Verbose "Found existing table format data $Name for type $Type. Updating..."
            # $existingFormatData.Control = $FormatViewDefinition.Control
        }

        Write-Verbose "Adding new format definition $formatName for type $Type."

        if ($existingTypeData) {
            #HACK: We need to "prepend" our new type data to have it take precedence and be the default view, but unfortunately
            #the collection has no prepend method, so we need to create a new list, prepend, flush the formats, and add the
            #new list with the format prepended
            Write-Verbose "Existing type data found for $Type. Prepending new format data."
            [List[SessionStateFormatEntry]]$newFormats = $SCRIPT:RunspaceFormats
            $newFormats.insert(0, $formatData)
            $SCRIPT:RunspaceFormats.Clear()
            $SCRIPT:RunspaceFormats.Add($newFormats)
        } else {
            #If it is a net-new type format, then prepending isn't needed
            $SCRIPT:RunspaceFormats.Add($formatData)

            #TODO: -Append parameter to add to an existing type definition
        }
    }

    if (-not $NoUpdate) {
        Update-FormatData
    }

    if ($PassThru) {
        return $formatData
    }

}
function New-TableControl {
    [OutputType([TableControl])]
    param($TableHeaderInfo, $CalculatedProperties, [bool]$AutoSize, [bool]$Wrap, $GroupBy) #Should be TableHeaderInfo

    # Helpful class found here: https://github.com/PowerShell/PowerShell/blob/f69a4b542bac0082544f3a38b5e7a54274c26872/src/System.Management.Automation/FormatAndOutput/common/DisplayDatabase/displayDescriptionData_Table.cs#L546
    [TableControlBuilder]$tcBuilder = [TableControl]::Create($true, $AutoSize, $TableHeaderInfo.hideHeader)
    #FIXME: Continue using the tablecontrolbuilder to build the table control
    [TableRowDefinitionBuilder]$row = $tcBuilder.StartRowDefinition($Wrap)

    # #FIXME: This is set in format data but doesn't take effect in the view. Need to find out why.
    # if ($table.HideTableHeaders) {
    # Write-Warning 'BUG: HideTableHeaders will only work if you save this format definition to XML. More Info: https://github.com/PowerShell/PowerShell/issues/23841'
    # }
    if ($GroupBy) {
        if ($GroupBy -is [string]) {
            [void]$tcBuilder.GroupByProperty($GroupBy)
        } else {
            throw [System.NotImplementedException]'TODO:GroupBy with anything except a single string value is not yet implemented'
        }
    }

    if ($TableHeaderInfo.tableColumnInfoList.count -le 0) { throw 'No table column info found' }
    foreach ($columnInfo in $TableHeaderInfo.tableColumnInfoList) {
        #This is a little confusing, but the row columns and the headers should almost always align.
        #The display label is defined on the header and the actual property it references is defined in the column.
        #If the property and label are the same, the label can be omitted
        #So for each property in the HeaderInfo, we create a new TableControlColumnHeader and TableControlColumn

        #If the columnInfo has a calculated property with a scriptblock, the propertyname needs to be set to the header label
        #to display correctly. Otherwise, label can be omitted unless it is different from the propertyname.

        [string]$scriptBlock = $null -ne $CalculatedProperties ?
        $CalculatedProperties[$columnInfo.propertyName] :
        $null

        [string]$label = $scriptBlock ? $columnInfo.propertyName : $columnInfo.Label

        [void]$tcBuilder.AddHeader($columnInfo.alignment, $columnInfo.Width, $label)

        if ($columnInfo.propertyName) {
            if ($scriptBlock) {
                [void]$row.AddScriptBlockColumn($scriptBlock, $columnInfo.alignment)
            } else {
                [void]$row.AddPropertyColumn($columnInfo.propertyName, $columnInfo.alignment)
            }
        }
    }

    return $row.EndRowDefinition().EndTable()
}

function ConvertTo-FormatXml {
    <#
    .SYNOPSIS
    Converts ExtendedTypeDefinition objects to XML format data
    #>

    [CmdletBinding(DefaultParameterSetName = 'FormatView')]
    param(
        [Parameter(ParameterSetName = 'TypeDefinition', Mandatory, ValueFromPipeline)]
        [Management.Automation.ExtendedTypeDefinition[]]$TypeDefinition,

        [Parameter(ParameterSetName = 'FormatView', Mandatory, ValueFromPipeline)]
        [Management.Automation.FormatViewDefinition]$FormatDefinition,

        [Parameter(ParameterSetName = 'FormatView', Mandatory, ValueFromPipeline)]
        [String]$TypeName,

        [switch]$Compress,
        [string]$OutFile
    )

    begin {
        Write-Host -ForegroundColor Magenta "ParameterSetName: $($PSCmdlet.ParameterSetName)"
        [List[ExtendedTypeDefinition]]$TypeDefinitions = @()
    }

    process {
        if ($FormatDefinition) {
            [List[FormatViewDefinition]]$FormatDefinitionList = @($FormatDefinition)
            $TypeDefinitions.Add(([ExtendedTypeDefinition]::new($TypeName, $FormatDefinitionList)))
        }

        foreach ($tdItem in $TypeDefinition) {
            $TypeDefinitions.Add($tdItem)
        }
    }

    end {
        #HACK: Fast way to get SMA rather than enumerating. There's no guarantee FormatXML will remain here but it is reasonably safe.
        $smaAssembly = [psobject].assembly

        $writeToXml = $smaAssembly.
        GetType('Microsoft.PowerShell.Commands.FormatXmlWriter').
        GetMethod('WriteToXml', @('Static', 'NonPublic'))

        $xmlSettings = [XmlWriterSettings]::new()

        if (-not $Compress) {
            $xmlSettings.Indent = $true
            $xmlSettings.NewLineOnAttributes = $true
        }

        $sb = [StringBuilder]::new()
        $xmlWriter = [XmlWriter]::Create($sb, $xmlSettings)
        $writeToXmlParams = @(
            $xmlWriter,
            $TypeDefinitions,
            $true #Export Script blocks
        )
        $writeToXml.Invoke($null, $writeToXmlParams)
        $xmlWriter.Flush()
        $xmlOutput = $sb.ToString()

        if ($OutFile) {
            Out-File -InputObject $xmlOutput -FilePath $OutFile
            return
        }

        return $xmlOutput
    }
}

function Format-TypeTable {
    <#
    .SYNOPSIS
        Uses Format-Table syntax to persist formatting data to this session, or save it as a formatting file.
    .DESCRIPTION
        Format-TypeTable is an alternative to hand-editing formatting files, using familiar Format-Table syntax to create custom views for objects.
    .EXAMPLE
    $obj = [PSCustomObject]@{
        Name = 'Test'
        Items = 4
        NotShown = 'thisproperty'
        PSTypeName = 'MyTestType'
    }
    PS> $obj | Format-TypeTable Name,Length
 
    RESULT:
    PS> $obj
 
    Name Items
    ---- ------
    Test 4
 
    This will create a new format view for the type MyTestType that will display the Name and Length properties only. The NotShown property will not be displayed but it is still present.
 
    .EXAMPLE
    class Test {
        [string]$Name
        [int]$Items
        [string]$NotShown
    }
    #>

    [CmdletBinding(DefaultParameterSetName = 'Property')]
    param(
        [Parameter(ParameterSetName = 'Property', Position = 0)]
        [System.Object[]]
        ${Property},

        [Object]
        ${GroupBy},

        [Parameter(ParameterSetName = 'View')]
        [string]
        ${View},

        # [switch]
        # ${ShowError},

        # [switch]
        # ${DisplayError},

        # [switch]
        # ${Force},

        # [ValidateSet('CoreOnly', 'EnumOnly', 'Both')]
        # [string]
        # ${Expand},

        [switch]
        ${AutoSize},

        # [switch]
        # ${RepeatHeader},

        [switch]
        ${HideTableHeaders},

        [switch]
        ${Wrap},

        #region Custom Properties

        #If specified, will not save the format data to the session state as the default view for the object
        [Parameter(ParameterSetName = 'Property')]
        [switch]$NoPersist,

        #If specified, will save the view with a custom name that can be referenced later with Format-Table -View
        [Parameter(ParameterSetName = 'Property')]
        [string]$ViewName,

        #endregion Custom Properties

        #InputObject goes as the last property as this is usually provided via the pipeline and we don't want it to be positional.
        [Parameter(Mandatory, ValueFromPipeline = $true)]
        [psobject]
        ${InputObject},

        #If specified, outputs the format data CLIXML instead of saving it to the session state
        [string]$OutPath
    )

    begin {
        [List[Object]]$InputObjects = @()

        #A deduplicated list of the types provided to format-table, for purposes of collecting
        [HashSet[String]]$TypeNames = @()

        #TODO: Support full groupby syntax
        if ($GroupBy -and $GroupBy -isnot [string]) {
            Write-Warning 'GroupBy currently can only take a single property, multiple properties or expressions are not currently supported.'
            $GroupBy = $null
        }
    }

    process {
        #Collect the input objects on the pipeline, if any
        $InputObjects.Add($PSItem)
    }

    end {
        #Replace the boundparameters for splatting to Format-Table
        if ($InputObjects.count -gt 0) {
            $PSBoundParameters.InputObject = $InputObjects
        }

        #Collect the types of the input objects for attaching to the formatStartData.
        foreach ($inputObjectItem in $PSBoundParameters.InputObject) {
            if (
                $inputObjectItem.psobject.TypeNames -contains 'System.Management.Automation.PSCustomObject'
            ) {
                if ($inputObjectItem.PSTypeNames.count -le 2) {
                    throw [NotSupportedException]'A PSCustomObject with more than one PSTypeName was detected. This is not supported and will likely cause formatting issues.'
                }
                if ($inputObjectItem.PSTypeNames.count -gt 3) {
                    throw [NotImplementedException]'A PSCustomObject with more than one PSTypeName was detected. This is not supported and will likely cause formatting issues.'
                }
                [void]$TypeNames.Add($inputObjectItem.PSTypeNames[0])
                continue
            }

            [void]$TypeNames.Add($inputObjectItem.GetType())
        }

        #TODO: Gather calculated property information to attach as metadata

        #Remove Extension Properties
        foreach ($customParam in 'NoPersist', 'ViewName') {
            [void]$PSBoundParameters.Remove($customParam)
        }

        $ftResult = Microsoft.PowerShell.Utility\Format-Table @PSBoundParameters -ErrorAction Stop

        #If -View was used, we don't save the output and return immediately.
        if ($View) {
            return $ftResult
        }

        if ($ftResult.count -eq 0) {
            Write-Warning 'No formatting output found. Did you pass any objects?'
            return
        }

        if ($ftResult[0].GetType().Name -ne 'FormatStartData' ) {
            throw [InvalidDataException]'Unexpected output type from Format-Table.'
        }

        if ($GroupBy -and $ftResult[1].GetType().Name -ne 'GroupStartData') {
            throw [InvalidDataException]'GroupBy was specified but no GroupStartData was found. This is probably a bug.'
        }

        #Add the types as an ETS property to FormatStartData
        Add-Member -InputObject $ftResult[0] -NotePropertyName 'TypeNames' -NotePropertyValue $TypeNames -Force

        Add-Member -InputObject $ftResult[0] -NotePropertyName 'Wrap' -NotePropertyValue $Wrap -Force

        #This gets lost in translation from Format-Table to FormatStartData
        Add-Member -InputObject $ftResult[0] -NotePropertyName 'GroupBy' -NotePropertyValue $GroupBy -Force

        $calcPropertyMetadata = Get-CalculatedPropertiesMetadata $PSBoundParameters.Property
        if ($calcPropertyMetadata.count -gt 0) {
            Add-Member -InputObject $ftResult[0] -NotePropertyName 'CalculatedProperties' -NotePropertyValue $calcPropertyMetadata -Force
        }

        $convertFromFormatParams = @{}
        if ($ViewName) {
            $convertFromFormatParams.Name = $ViewName
        }

        foreach ($Type in $ftResult[0].TypeNames) {
            $formatView = $ftResult
            | ConvertFrom-Format @convertFromFormatParams

            if ($OutPath) {
                ConvertTo-FormatXml -FormatDefinition $formatView -OutFile $OutPath
            } else {
                Add-Format -FormatViewDefinition $formatView -Type $Type -NoPersist:$NoPersist
            }

        }

        return $ftResult
    }
}


#region Private
function Get-CalculatedPropertiesMetadata ([object]$Properties) {

    #TODO: In order to do this we have to update the typedata.

    $calcProps = @{}
    foreach ($Property in $Properties) {
        if ($Property -is [scriptblock]) {
            throw [NotSupportedException]'Raw Scriptblocks in -Properties are not supported, use hashtables instead'
        }
        if ($Property -is [string]) {
            #This has already been handled by Format-Table, there's nothing to include
            continue
        }

        #Only hashtables from this point on
        if ($Property -isnot [hashtable]) {
            Write-Warning "$($Property.GetType()) is not currently implemented for parsing in -Property and will be ignored"
            continue
        }

        if ($Property.ContainsKey('Name') -and $Property.ContainsKey('N')) {
            throw [InvalidDataException]'Cannot specify both Name and N for a calculated Property'
        }

        if ($Property.ContainsKey('Expression') -and $Property.ContainsKey('E')) {
            throw [InvalidDataException]'Cannot specify both Expression and E for a calculated Property'
        }

        [string]$Name = $null

        foreach ($key in $Property.keys) {
            #TODO: Add FormatString, Width, Alignment if these properties not present in FT
            switch -Wildcard ($key) {
                'Name' {
                    $Name = $Property[$key]
                    break
                }
                'N' {
                    $Name = $Property[$key]
                    break
                }
                'default' {
                    Write-Warning "$key is not a currently implemented key for a calculated property in -Properties and will be ignored"
                    continue
                }
            }
        }

        if (-not $Name) {
            Write-Warning 'The Name key was not included in the calculated property hashtable. That property will be ignored.'
            continue
        }

        if (-not ($Property.Expression -or $Property.E)) {
            Write-Warning "Calculated Property $Name does not have an Expression key. This property will be ignored."
            continue
        }
        $calcProps[$Name] = $Property.Expression ?? $Property.E
    }
    return $calcProps
}

#endregion Private