Datum.psm1

Class FileProvider {
    hidden $Path
    hidden [hashtable] $DataOptions
    FileProvider ($Path,$DataOptions)
    {
        $this.DataOptions = $DataOptions
        $this.Path = Get-Item $Path -ErrorAction SilentlyContinue

        $Result = Get-ChildItem $path | ForEach-Object {
            if($_.PSisContainer) {
                $val = [scriptblock]::Create("New-DatumFileProvider -Path `"$($_.FullName)`" -DataOptions `$this.DataOptions")
                $this | Add-Member -MemberType ScriptProperty -Name $_.BaseName -Value $val
            }
            else {
                $val = [scriptblock]::Create("Get-FileProviderData -Path `"$($_.FullName)`" -DataOptions `$this.DataOptions")
                $this | Add-Member -MemberType ScriptProperty -Name $_.BaseName -Value $val
            }
        }
    }
}

#$ConfigurationData = [fileProvider]::new($PWD.Path,@{})
#($ConfigurationData.AllNodes.psobject.Properties | % { $ConfigurationData.AllNodes.($_.Name) })[1]

Class Node : hashtable {
    Node([hashtable]$NodeData)
    {
        $NodeData.keys | % {
            $This[$_] = $NodeData[$_]
        }

        $this | Add-member -MemberType ScriptProperty -Name Roles -Value {
            $PathArray = $ExecutionContext.InvokeCommand.InvokeScript('Get-PSCallStack')[2].Position.text -split '\.'
            $PropertyPath =  $PathArray[2..($PathArray.count-1)] -join '\'
            Write-warning "Resolve $PropertyPath"

            $obj = [PSCustomObject]@{}
            $currentNode = $obj
            if($PathArray.Count -gt 3) {
                foreach ($property in $PathArray[2..($PathArray.count-2)]) {
                    Write-Debug "Adding $Property property"
                    $currentNode | Add-member -MemberType NoteProperty -Name $property -Value ([PSCustomObject]@{})
                    $currentNode = $currentNode.$property
                }
            }
            Write-Debug "Adding Resolved property to last object's property $($PathArray[-1])"
            $currentNode | Add-member -MemberType NoteProperty -Name $PathArray[-1] -Value ($PropertyPath)

            return $obj
        }
    }
    static ResolveDscProperty($Path)
    {
        "Resolve-DscProperty $Path"
    }
}

Class SecureDatum {
    [hashtable] hidden  $UnprotectParams
    SecureDatum($Object,[hashtable]$UnprotectParams) {
        $this.UnprotectParams = $UnprotectParams
        if($Object -is [hashtable]) {
            $Object = [PSCustomObject]$Object
        }

        if ($Object -is [PSCustomObject]) {
            foreach ($Property in $Object.PSObject.Properties.name) {
                $MemberTypeParams = @{
                    MemberType = 'NoteProperty'
                    Name = $Property
                    Value = ([SecureDatum]::GetObject($Object.$Property,$UnprotectParams))
                }
                if ($MemberTypeParams.Value -is [scriptblock]) {
                    $MemberTypeParams.MemberType = 'ScriptProperty'
                }
                $This | Add-Member @MemberTypeParams
            }
        }
    }
    [string] ToString()
    {
        return "{$($this.PSObject.Properties.Name -join ', ')}"
    }
    static [object] GetObject($object,$UnprotectParams)
    {
        if($null -eq $object) {
            return $null
        }
        elseif($object -is [PSCustomObject] -or
            $object -is [hashtable]) {
            return ([SecureDatum]::new($object,$UnprotectParams))
        }
        elseif ($object -is [System.Collections.IEnumerable] -and $object -isnot [string]) {
            $collection = @()
            $collection = foreach ($item in $object) {
                [SecureDatum]::GetObject($item,$UnprotectParams)
            }
            return $collection
        }
        elseif($object -is [string] -and $object -match "^\[ENC=[\w\W]*\]$") {
            $UnprotectScriptBlock = "
                `$Base64Data = `"$object`"
                `[SecureDatum]::Unprotect(`$Base64Data.Trim(),`$this.UnprotectParams)
                "

            return ([scriptblock]::Create($UnprotectScriptBlock))
        }
        else {
            return $object
        }
    }
    static [object] Unprotect($object,$UnprotectParams)
    {
        return (Unprotect-Datum -Base64Data $object @UnprotectParams)
    }
}

function ConvertTo-Hashtable
{
    param (
        [Parameter(ValueFromPipeline)]
        $InputObject
    )

    process
    {
        if ($null -eq $InputObject) { return $null }

        if ($InputObject -is [System.Collections.Hashtable]) {
            return $InputObject
        }
        elseif ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string])
        {
            $collection = @(
                foreach ($object in $InputObject) { ConvertTo-Hashtable $object }
            )

            Write-Output -NoEnumerate $collection
        }
        elseif ($InputObject -is [psobject])
        {
            $hash = @{}

            foreach ($property in $InputObject.PSObject.Properties)
            {
                $hash[$property.Name] = ConvertTo-Hashtable $property.Value
            }

            $hash
        }
        else
        {
            $InputObject
        }
    }
}

function ConvertTo-ProtectedDatum
{###########ConvertTo-DatumSecureObjectReader
    param (
        [Parameter(ValueFromPipeline)]
        $InputObject,

        $UnprotectOptions
    )

    process
    {
        if ($UnprotectOptions.ContainsKey('ClearTextPassword')) {
            $UnprotectOptions['password'] = $UnprotectOptions.ClearTextPassword |
                ConvertTo-SecureString -AsPlainText -force
            $null = $UnprotectOptions.remove('ClearTextPassword')
        }
        elseif ($UnprotectOptions.ContainsKey('SecureStringPassword')) {
            $UnprotectOptions['password'] = $UnprotectOptions.SecureStringPassword |
                ConvertTo-SecureString
            $null = $UnprotectOptions.remove('SecureStringPassword')
        }
       [SecureDatum]::GetObject($InputObject,$UnprotectOptions)
    }
}

#Requires -module powershell-yaml
#Using Module Datum

function Get-FileProviderData {
    [CmdletBinding()]
    Param(
        $Path,

        [AllowNull()]
        $DataOptions
    )
    Write-Verbose "Getting File Provider Data for Path: $Path"
    $File = Get-Item -Path $Path
    switch ($File.Extension) {
        '.psd1' { Import-PowerShellDataFile $File }
        '.json' { Get-Content -Raw $Path | ConvertFrom-Json | ConvertTo-Hashtable }
        '.yml'  { convertfrom-yaml (Get-Content -raw $Path) | ConvertTo-Hashtable }
        '.ejson'{ Get-Content -Raw $Path | ConvertFrom-Json | ConvertTo-ProtectedDatum -UnprotectOptions $DataOptions}
        '.eyaml'{ ConvertFrom-Yaml (Get-Content -Raw $Path) | ConvertTo-ProtectedDatum -UnprotectOptions $DataOptions}
        '.epsd1'{ Import-PowerShellDatafile $File | ConvertTo-ProtectedDatum -UnprotectOptions $DataOptions}
        Default { Get-Content -Raw $Path }
    }
}

function New-DatumFileProvider {
    Param(
        [alias('DataDir')]
        $Path,

        [AllowNull()]
        $DataOptions
    )

    [FileProvider]::new($Path,$DataOptions)
}

<#
    Datum Structure is a PSCustomObject
     To that object we add DatumStores as Script Properties/Class instances
      Those Properties embed the mechanism to call the container hierarchy and the RAW value of the items
       The format of the item defines its method of conversion from raw to Object
#>


function New-DatumStructure {
    [CmdletBinding()]
    Param (
        $Structure
    )

    $root = @{}
    foreach ($store in $Structure.DatumStructure){
        $StoreParams = Convertto-hashtable $Store.StoreOptions
        $cmd = Get-Command ("{0}\New-Datum{1}Provider" -f ($store.StoreProvider -split '::'))
        $storeObject = &$cmd @StoreParams
        $root.Add($store.StoreName,$storeObject)
    }
    [PSCustomObject]$root
}

#Requires -Modules ProtectedData

function Protect-Datum {
    [CmdletBinding()]
    [OutputType([PSObject])]
    Param (
        # Serialized Protected Data represented on Base64 encoding
        [Parameter(
             Mandatory
            ,Position=0
            ,ValueFromPipeline
            ,ValueFromPipelineByPropertyName
        )]
        [ValidateNotNullOrEmpty()]
        [PSObject]
        $InputObject,

        # By Password only for development / Test purposes
        [Parameter(
            ParameterSetName='ByPassword'
            ,Mandatory
            ,Position=1
            ,ValueFromPipelineByPropertyName
        )]
        [System.Security.SecureString]
        $Password,

        # Specify the Certificate to be used by ProtectedData
        [Parameter(
            ParameterSetName='ByCertificate'
            ,Mandatory
            ,Position=1
            ,ValueFromPipelineByPropertyName
        )]
        [String]
        $Certificate,

        # Number of columns before inserting newline in chunk
        [Parameter(
            ValueFromPipelineByPropertyName
        )]
        [Int]
        $MaxLineLength = 100,

        # Number of columns before inserting newline in chunk
        [Parameter(
            ValueFromPipelineByPropertyName
        )]
        [String]
        $Header = '[ENC=',

        # Number of columns before inserting newline in chunk
        [Parameter(
            ValueFromPipelineByPropertyName
        )]
        [String]
        $Footer = ']',

        # Number of columns before inserting newline in chunk
        [Parameter(
            ValueFromPipelineByPropertyName
        )]
        [Switch]
        $NoEncapsulation

    )

    begin {
    }

    process {
        Write-Verbose "Deserializing the Object from Base64"

        $ProtectDataParams = @{
            InputObject = $InputObject
        }
        Write-verbose "Calling Protect-Data $($PSCmdlet.ParameterSetName)"
        Switch ($PSCmdlet.ParameterSetName) {
            'ByCertificae' { $ProtectDataParams.Add('Certificate',$Certificate)}
            'ByPassword'   { $ProtectDataParams.Add('Password',$Password)      }
        }

        $securedData =  Protect-Data @ProtectDataParams
        $xml = [System.Management.Automation.PSSerializer]::Serialize($securedData, 5)
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($xml)
        $Base64String = [System.Convert]::ToBase64String($bytes)

        if($MaxLineLength -gt 0) {
            $Base64DataBlock = [regex]::Replace($Base64String,"(.{$MaxLineLength})","`$1`r`n")
        }
        else {
            $Base64DataBlock = $Base64String
        }
        if(!$NoEncapsulation) {
            $Header,$Base64DataBlock,$Footer -join ''
        }
        else {
            $Base64DataBlock
        }
    }

}

Function Resolve-Datum {
    [cmdletBinding()]
    Param(
        [Parameter(
            Mandatory
        )]
        [string]
        $PropertyPath,

        $Node,

        $Default,

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

        [Parameter(
            Mandatory
        )]
        $DatumStructure = $DatumStructure,

        $MaxDepth,

        [ValidateSet('MostSpecific','AllValues')]
        $SearchBehavior = 'MostSpecific'
    )

    $Pattern = '(?<opening><%=)(?<sb>.*?)(?<closure>%>)'
    $splitPattern = '\' + [IO.Path]::DirectorySeparatorChar
    $Depth = 0
    $MergeResult = $null
    # Walk every search path in listed order, and return datum when found at end of path
    foreach ($SearchPath in $searchPaths) {
        $ArraySb = [System.Collections.ArrayList]@()
        $CurrentSearch = Join-Path $searchPath $PropertyPath
        #extract script block for execution
        $newSearch = [regex]::Replace($CurrentSearch, $Pattern, {
                    param($match)
                    $expr = $match.groups['sb'].value
                    $index = $ArraySb.Add($expr)
                    "`$({$index})"
                },  @('IgnoreCase', 'SingleLine', 'MultiLine'))

        $PathStack = $newSearch -split $splitPattern
        $DatumFound = Resolve-DatumPath -Node $Node -DatumStructure $DatumStructure -PathStack $PathStack -PathVariables $ArraySb
        #Stop processing further path when the Max depth is reached
        # or when you found the first value
        if (($DatumFound -and ($SearchBehavior -eq 'MostSpecific')) -or ($Depth -eq $MaxDepth)) {
            Write-Debug "Depth: $depth; Search Behavior: $SearchBehavior"
            $DatumFound
            return
        }
        elseif($DatumFound -and ($SearchBehavior -eq 'AllValues')) {
            $DatumFound
        }
        #Add Those merge Behaviour:
        # Unique
        # Hash
        # Deep merge

        # https://docs.puppet.com/puppet/5.0/hiera_merging.html

        # Configure Merge Behaviour in the Datum structure (as per Puppet hiera)

    }
}

function Resolve-DatumPath {
    [CmdletBinding()]
    param(
        $Node,

        $DatumStructure,

        [string[]]
        $PathStack,

        [System.Collections.ArrayList]
        $PathVariables
    )

    $currentNode = $DatumStructure

    foreach ($StackItem in $PathStack) {
        $RelativePath = $PathStack[0..$PathStack.IndexOf($StackItem)]
        Write-Verbose "Current relative Path: $($RelativePath -join '\')"
        $LeftOfStack = $PathStack[$PathStack.IndexOf($StackItem)..($PathStack.Count-1)]
        Write-Verbose "Left Path to search: $($LeftOfStack -join '\')"
        if ( $StackItem -match '\{\d+\}') {
            Write-Debug -Message "Replacing expression $StackItem"
            $StackItem = [scriptblock]::Create( ($StackItem -f ([string[]]$PathVariables)) ).Invoke()
            Write-Debug -Message ($StackItem | Format-List * | Out-String)
            $PathItem = $stackItem
        }
        else {
            $PathItem = $CurrentNode.($ExecutionContext.InvokeCommand.ExpandString($StackItem))
        }

        switch ($PathItem) {
            $null {
                Write-Verbose -Message "NULL FOUND AT PATH: $(($RelativePath -join '\') -f [string[]]$PathVariables) before reaching $($LeftOfStack -join '\')"
                Return $null
            }
            {$_.GetType() -eq [hashtable]} { $CurrentNode = $PathItem; Break }
            default                        { $CurrentNode = $PathItem; break }
        }

        if ($LeftOfStack.Count -eq 1) {
            Write-Output $CurrentNode
        }

    }
}

#Requires -Modules ProtectedData

function Unprotect-Datum {
    [CmdletBinding()]
    [OutputType([PSObject])]
    Param (
        # Serialized Protected Data represented on Base64 encoding
        [Parameter(
             Mandatory
            ,Position=0
            ,ValueFromPipeline
            ,ValueFromPipelineByPropertyName
        )]
        [ValidateNotNullOrEmpty()]
        [string]
        $Base64Data,

        # By Password only for development / Test purposes
        [Parameter(
            ParameterSetName='ByPassword'
            ,Mandatory
            ,Position=1
            ,ValueFromPipelineByPropertyName
        )]
        [System.Security.SecureString]
        $Password,

        # Specify the Certificate to be used by ProtectedData
        [Parameter(
            ParameterSetName='ByCertificate'
            ,Mandatory
            ,Position=1
            ,ValueFromPipelineByPropertyName
        )]
        [String]
        $Certificate,

        # Number of columns before inserting newline in chunk
        [Parameter(
            ValueFromPipelineByPropertyName
        )]
        [String]
        $Header = '[ENC=',

        # Number of columns before inserting newline in chunk
        [Parameter(
            ValueFromPipelineByPropertyName
        )]
        [String]
        $Footer = ']',

        # Number of columns before inserting newline in chunk
        [Parameter(
            ValueFromPipelineByPropertyName
        )]
        [Switch]
        $NoEncapsulation
    )

    begin {
    }

    process {
        if (!$NoEncapsulation) {
            Write-Verbose "Removing $header DATA $footer "
            $Base64Data = $Base64Data -replace "^$([regex]::Escape($Header))" -replace "$([regex]::Escape($Footer))$"
        }

        Write-Verbose "Deserializing the Object from Base64"
        $bytes = [System.Convert]::FromBase64String($Base64Data)
        $xml = [System.Text.Encoding]::UTF8.GetString($bytes)
        $obj = [System.Management.Automation.PSSerializer]::Deserialize($xml)
        $UnprotectDataParams = @{
            InputObject = $obj
        }
        Write-verbose "Calling Unprotect-Data $($PSCmdlet.ParameterSetName)"
        Switch ($PSCmdlet.ParameterSetName) {
            'ByCertificae' { $UnprotectDataParams.Add('Certificate',$Certificate)}
            'ByPassword'   { $UnprotectDataParams.Add('Password',$Password)      }
        }
        Unprotect-Data @UnprotectDataParams
    }

}