<#
.SYNOPSIS
  Client-side helpers for sourcing GE-Enforce manifests from shopdb and reporting
  results back. Site-neutral reference kit - deploy alongside GE-Enforce; do NOT
  hardcode any site here.

  This module does NOT replace Install-FromManifest.ps1 (the engine). It only
  changes where the manifest JSON comes from (shopdb HTTP instead of a share
  file) and adds a result report. The engine, detection, self-heal, and SMB
  payload resolution stay exactly as they are.

  Resilience mirrors GE-Enforce: any failure is non-fatal (fail-safe). If shopdb
  is unreachable the client enforces from the last-known-good cached manifest and
  a PC is never left unmanaged because the web app is down.

  Config (params override registry): HKLM:\SOFTWARE\GE\ShopDB
    BaseUrl   e.g. https://shopdb.example.net
    ApiToken  a geenforce.fetch (+ geenforce.report) managed service token,
              provisioned the same way as SFLD creds (Azure DSC).
#>

Set-StrictMode -Version Latest

function Set-ShopdbTls {
    <#
      Force TLS 1.2 for the process-wide ServicePointManager. Windows PowerShell
      5.1 (what runs as SYSTEM on the display image) does not always negotiate
      TLS 1.2 by default, so every network helper calls this first. Mirrors the
      pattern in docs/COLLECTOR-INTEGRATION.md. Best-effort: never throws.
    #>
    try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {}
}

function Get-ShopdbProperty {
    <#
      Read a property/key from either a hashtable or a PSCustomObject, matching
      any of the given names case-insensitively. Returns $null when absent
      instead of throwing under Set-StrictMode. The engine's per-entry outcomes
      and summary may arrive as either shape, so all normalization goes through
      this.
    #>
    param($InputObject, [Parameter(Mandatory)][string[]]$Names)
    if ($null -eq $InputObject) { return $null }
    if ($InputObject -is [System.Collections.IDictionary]) {
        foreach ($wanted in $Names) {
            foreach ($key in @($InputObject.Keys)) {
                if ($key -is [string] -and $key -ieq $wanted) { return $InputObject[$key] }
            }
        }
        return $null
    }
    $properties = $InputObject.PSObject.Properties
    foreach ($wanted in $Names) {
        foreach ($property in $properties) {
            if ($property.Name -ieq $wanted) { return $property.Value }
        }
    }
    return $null
}

function Get-ShopdbConfig {
    param([string]$BaseUrl, [string]$ApiToken)
    $regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
    if ((-not $BaseUrl -or -not $ApiToken) -and (Test-Path $regPath)) {
        $props = Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue
        $regBase = Get-ShopdbProperty $props 'BaseUrl'
        $regTok  = Get-ShopdbProperty $props 'ApiToken'
        if (-not $BaseUrl -and $regBase) { $BaseUrl = [string]$regBase }
        if (-not $ApiToken -and $regTok) { $ApiToken = [string]$regTok }
    }
    # ApiToken is OPTIONAL: on a vaulted network the server may authorize by
    # source-IP allowlist, so a BaseUrl alone is a valid config. When a token
    # is present it is still sent (and honored) for token-authorized sites.
    if (-not $BaseUrl) { return $null }
    return @{ BaseUrl = $BaseUrl.TrimEnd('/'); ApiToken = $ApiToken }
}


function New-ShopdbAuthHeaders {
    # Only send X-API-Key when a token is configured; a token-less client
    # relies on the server's IP allowlist.
    param($Config)
    if ($Config.ApiToken) { return @{ 'X-API-Key' = $Config.ApiToken } }
    return @{}
}

function Sync-ShopdbManifest {
    <#
      Fetch the current published manifest for a scope into a local cache, using
      an ETag so an unchanged manifest is a cheap 304. On any network error, fall
      back to the last-known-good cached copy. Returns:
        @{ Path; Version; Source }  where Source is
        'shopdb' | 'cache-304' | 'cache-lastgood' | $null (nothing available)
    #>
    param(
        [Parameter(Mandatory)] [string]$Scope,
        [Parameter(Mandatory)] [hashtable]$Config,
        [string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
    )
    Set-ShopdbTls
    if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null }
    $manifestPath = Join-Path $CacheDir "$Scope.json"
    $etagPath     = Join-Path $CacheDir "$Scope.etag"
    $headers = New-ShopdbAuthHeaders $Config
    if (Test-Path $etagPath) { $headers['If-None-Match'] = (Get-Content -LiteralPath $etagPath -Raw).Trim() }

    $uri = "$($Config.BaseUrl)/api/geenforce/manifest?pctype=$([uri]::EscapeDataString($Scope))"
    try {
        $response = Invoke-WebRequest -Uri $uri -Headers $headers -UseBasicParsing `
            -TimeoutSec 30 -ErrorAction Stop
        if ($response.StatusCode -eq 200) {
            # Validate JSON before overwriting the last-known-good cache: a proxy
            # or IIS error page served as 200 must not clobber the fallback.
            try { $null = ($response.Content | ConvertFrom-Json) }
            catch { throw "manifest response for $Scope was not valid JSON" }
            [System.IO.File]::WriteAllText($manifestPath, $response.Content)
            # PowerShell 7 returns header values as string arrays; 5.1 as scalars.
            # @(...)[0] yields a clean scalar in both.
            $etag = @($response.Headers['ETag'])[0]
            if ($etag) {
                Set-Content -LiteralPath $etagPath -Value $etag -NoNewline
            }
            $version = @($response.Headers['X-Manifest-Version'])[0]
            if ($version) {
                Set-Content -LiteralPath (Join-Path $CacheDir "$Scope.version") -Value $version -NoNewline
            }
            return @{ Path = $manifestPath; Version = $version; Source = 'shopdb' }
        }
    } catch {
        $status = $null
        $exResponse = Get-ShopdbProperty $_.Exception 'Response'
        if ($exResponse) { $status = [int]$exResponse.StatusCode }
        if ($status -eq 304 -and (Test-Path $manifestPath)) {
            return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' }
        }
        # Network/other failure: fall back to last-known-good.
        if (Test-Path $manifestPath) {
            return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-lastgood' }
        }
        # Nothing cached (e.g. a fresh display): report why so the failure is not
        # silent. Include the HTTP status (401 auth, TLS-trust surfaces as a
        # non-status transport error) and the exception message.
        $errText = $_.Exception.Message
        if ($status) { $errText = "HTTP $status - $errText" }
        return @{ Path = $null; Version = $null; Source = $null; Error = $errText }
    }
    # 304 without exception (some PS versions) -> use cache.
    if (Test-Path $manifestPath) {
        return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' }
    }
    return @{ Path = $null; Version = $null; Source = $null }
}

function Read-CachedVersion {
    param([string]$CacheDir, [string]$Scope)
    $verPath = Join-Path $CacheDir "$Scope.version"
    if (Test-Path $verPath) { return (Get-Content -LiteralPath $verPath -Raw).Trim() }
    return $null
}

function Compare-ShopdbShadow {
    <#
      Shadow-mode comparison: do the shopdb manifest and the on-share manifest
      select the same ordered entry names? Returns @{ Same; ShopdbOnly; ShareOnly;
      OrderDiff }. Behavioral, not byte, comparison.
    #>
    param([Parameter(Mandatory)][string]$ShopdbManifestPath,
          [Parameter(Mandatory)][string]$ShareManifestPath)
    $shopdb = (Get-Content -LiteralPath $ShopdbManifestPath -Raw | ConvertFrom-Json)
    $share  = (Get-Content -LiteralPath $ShareManifestPath  -Raw | ConvertFrom-Json)
    $shopdbNames = @($shopdb.Applications | ForEach-Object { $_.Name })
    $shareNames  = @($share.Applications  | ForEach-Object { $_.Name })
    return @{
        Same       = (($shopdbNames -join '|') -eq ($shareNames -join '|'))
        ShopdbOnly = @($shopdbNames | Where-Object { $_ -notin $shareNames })
        ShareOnly  = @($shareNames  | Where-Object { $_ -notin $shopdbNames })
        OrderDiff  = (($shopdbNames -join '|') -ne ($shareNames -join '|'))
    }
}

function Send-ShopdbReport {
    <#
      POST an enforcement report to shopdb. Best-effort: never throws, returns
      $true on success. Report is a hashtable matching POST /api/geenforce/report.
    #>
    param([Parameter(Mandatory)][hashtable]$Config,
          [Parameter(Mandatory)][hashtable]$Report)
    Set-ShopdbTls
    try {
        $body = ($Report | ConvertTo-Json -Depth 6)
        Invoke-RestMethod -Uri "$($Config.BaseUrl)/api/geenforce/report" `
            -Method Post -Headers (New-ShopdbAuthHeaders $Config) `
            -ContentType 'application/json' -Body $body -TimeoutSec 30 -ErrorAction Stop | Out-Null
        return $true
    } catch {
        return $false
    }
}

function New-ShopdbReport {
    <#
      Build a report payload from an engine summary. `Summary` is expected to
      carry Installed/Skipped/Failed/Filtered counts and a Results list whose
      per-entry outcomes carry name/action and optionally selfhealed/exitcode/
      message. The engine emits these in PascalCase (Name/Action/SelfHealed/
      ExitCode/Message); the shopdb report contract is entirely lowercase, so
      this function maps every per-entry key down to lowercase. Case is matched
      case-insensitively, so a caller that already lowercased still works.
    #>
    param([string]$Hostname = $env:COMPUTERNAME,
          [Parameter(Mandatory)][string]$Scope,
          [int]$AppliedVersion,
          [Parameter(Mandatory)][hashtable]$Summary,
          [string]$SubType = (Get-ShopdbSubType))
    $results = @(foreach ($entry in @($Summary.Results)) {
        if ($null -eq $entry) { continue }
        $mapped = @{
            name   = [string](Get-ShopdbProperty -InputObject $entry -Names 'name')
            action = [string](Get-ShopdbProperty -InputObject $entry -Names 'action')
        }
        $selfHealed = Get-ShopdbProperty -InputObject $entry -Names 'selfhealed'
        if ($null -ne $selfHealed) { $mapped['selfhealed'] = [bool]$selfHealed }
        $exitCode = Get-ShopdbProperty -InputObject $entry -Names 'exitcode'
        if ($null -ne $exitCode) { $mapped['exitcode'] = [int]$exitCode }
        $message = Get-ShopdbProperty -InputObject $entry -Names 'message'
        if ($message) { $mapped['message'] = [string]$message }
        $mapped
    })
    return @{
        hostname        = $Hostname
        scopename       = $Scope
        appliedversion  = $AppliedVersion
        enforcerversion = $Summary.EnforcerVersion
        subtype         = $SubType
        counts = @{
            installed = [int]$Summary.Installed
            skipped   = [int]$Summary.Skipped
            failed    = [int]$Summary.Failed
            filtered  = [int]$Summary.Filtered
        }
        results = $results
    }
}

function Get-ShopdbSubType {
    <#
    .SYNOPSIS
      What this PC is WITHIN its scope, read off the machine itself.

    .DESCRIPTION
      A display knows whether it is a Dashboard, a Lobby screen or the 3D print
      room: the dispatcher reads C:\Enrollment\display-type.txt to choose which
      page to open. It just never told shopdb, so the fleet table had to guess
      from a DashboardDefault fqdn mapping that is empty unless somebody added a
      row per kiosk. Reported by the device beats inferred from a lookup table,
      the same way enforcerversion already works.

      Returns '' when the file is absent - every non-display PC type - so the
      report simply carries no subtype rather than a made-up one.
    #>
    param([string]$Path = 'C:\Enrollment\display-type.txt')
    try {
        if (-not (Test-Path $Path)) { return '' }
        $value = (Get-Content -Path $Path -TotalCount 1 -ErrorAction Stop)
        return ([string]$value).Trim()
    } catch {
        return ''
    }
}

function Get-ShopdbPayload {
    <#
      Fetch a payload blob by content hash over HTTPS, verify the sha256, and
      cache it locally (content-addressed, last-known-good). This is how a
      share-less PC pulls an installer the manifest references. Returns the local
      path, or $null on failure / hash mismatch.
    #>
    param(
        [Parameter(Mandatory)][string]$Sha256,
        [Parameter(Mandatory)][hashtable]$Config,
        [string]$Filename,
        [string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
    )
    Set-ShopdbTls
    $sha = $Sha256.Trim().ToLower()
    $payloadDir = Join-Path $CacheDir 'payloads'
    if (-not (Test-Path $payloadDir)) { New-Item -ItemType Directory -Path $payloadDir -Force | Out-Null }
    $ext = if ($Filename) { [System.IO.Path]::GetExtension($Filename) } else { '' }
    $dest = Join-Path $payloadDir "$sha$ext"

    # Cache hit only counts if the cached bytes still hash correctly.
    if (Test-Path $dest) {
        if ((Get-FileHash -LiteralPath $dest -Algorithm SHA256).Hash.ToLower() -eq $sha) { return $dest }
        Remove-Item -LiteralPath $dest -Force -ErrorAction SilentlyContinue
    }

    $uri = "$($Config.BaseUrl)/api/geenforce/payload/$sha"
    $tmp = "$dest.tmp"
    try {
        Invoke-WebRequest -Uri $uri -Headers (New-ShopdbAuthHeaders $Config) `
            -UseBasicParsing -TimeoutSec 120 -OutFile $tmp -ErrorAction Stop
    } catch {
        if (Test-Path $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue }
        return $null
    }
    $got = (Get-FileHash -LiteralPath $tmp -Algorithm SHA256).Hash.ToLower()
    if ($got -ne $sha) {
        Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
        return $null
    }
    Move-Item -LiteralPath $tmp -Destination $dest -Force
    return $dest
}

function Resolve-ShopdbPayloads {
    <#
      Rewrite a manifest so http/inline payload entries install from a locally
      fetched file instead of a share path - keeping the engine (and its SMB
      handling) untouched. For each entry with PayloadSha256 (PayloadSource
      http/inline), fetches + verifies the payload and points the entry's
      installer path at the local copy (Installer for MSI/EXE/CMD/BAT/INF, Script
      for PS1, Source for File). Returns a rewritten sibling manifest path, or the
      original path when there is nothing to resolve. Throws if a referenced
      payload cannot be fetched/verified (caller decides fail-safe behavior).
    #>
    param(
        [Parameter(Mandatory)][string]$ManifestPath,
        [Parameter(Mandatory)][hashtable]$Config,
        [string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
    )
    $json = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
    $pathField = @{ MSI='Installer'; EXE='Installer'; CMD='Installer'; BAT='Installer';
                    INF='Installer'; PS1='Script'; File='Source' }
    $changed = $false
    foreach ($entry in @($json.Applications)) {
        $src = [string](Get-ShopdbProperty $entry 'PayloadSource')
        $sha = [string](Get-ShopdbProperty $entry 'PayloadSha256')
        if (-not $sha -or ($src -ne 'http' -and $src -ne 'inline')) { continue }
        $field = $pathField[[string](Get-ShopdbProperty $entry 'Type')]
        if (-not $field) { continue }
        $ref = Get-ShopdbProperty $entry 'PayloadRef'
        $local = Get-ShopdbPayload -Sha256 $sha -Config $Config -Filename $ref -CacheDir $CacheDir
        if (-not $local) { throw "payload $sha for '$($entry.Name)' could not be fetched/verified" }
        # Write the LEAF filename, not the absolute path: the engine resolves the
        # entry field as Join-Path $InstallerRoot <field>, and the runner sets
        # InstallerRoot to this same payloads dir. Passing an absolute path made
        # the engine double it (InstallerRoot + C:\...\ -> C:\...\C:\...).
        $leaf = Split-Path -Leaf $local
        if ($entry.PSObject.Properties.Name -contains $field) { $entry.$field = $leaf }
        else { $entry | Add-Member -NotePropertyName $field -NotePropertyValue $leaf }
        $changed = $true
    }
    if (-not $changed) { return $ManifestPath }
    $out = [System.IO.Path]::ChangeExtension($ManifestPath, '.resolved.json')
    ($json | ConvertTo-Json -Depth 20) | Set-Content -LiteralPath $out -Encoding UTF8
    return $out
}

function Merge-ShopdbManifests {
    <#
      Merge the fleet-wide 'common' scope manifest into a pctype (display) scope
      manifest and write the merged result to a sibling file. This mirrors how
      the real GE-Enforce.ps1 applies scopes in order (common first, then the
      pctype), except we produce a single merged manifest for the unchanged
      engine to run once. Merge rules:

        - Entries are keyed by Name (case-insensitive).
        - common's unique entries come first, then all pctype entries, so
          common's own apps enforce ahead of the pctype's, matching the real
          script's common-then-pctype ordering.
        - On a Name conflict the PCTYPE entry wins (the pctype override replaces
          common's version, and keeps common's slot out of the list).

      Returns the merged manifest path. If there is no common manifest, returns
      the primary path unchanged. The merged manifest keeps the pctype
      manifest's top-level Version (that is the version the display reports as
      applied).
    #>
    param(
        [Parameter(Mandatory)][string]$PrimaryManifestPath,
        [string]$CommonManifestPath
    )
    if (-not $CommonManifestPath -or -not (Test-Path $CommonManifestPath)) { return $PrimaryManifestPath }

    $primary = Get-Content -LiteralPath $PrimaryManifestPath -Raw | ConvertFrom-Json
    $common  = Get-Content -LiteralPath $CommonManifestPath  -Raw | ConvertFrom-Json

    $primaryApps = @($primary.Applications)
    $commonApps  = @($common.Applications)

    $primaryNames = @{}
    foreach ($app in $primaryApps) {
        $name = [string]$app.Name
        if ($name) { $primaryNames[$name.ToLower()] = $true }
    }

    # common entries the pctype does not override, then all pctype entries.
    $merged = @()
    foreach ($app in $commonApps) {
        $name = [string]$app.Name
        if ($name -and $primaryNames.ContainsKey($name.ToLower())) { continue }
        $merged += $app
    }
    foreach ($app in $primaryApps) { $merged += $app }

    $primary.Applications = $merged
    $out = [System.IO.Path]::ChangeExtension($PrimaryManifestPath, '.merged.json')
    ($primary | ConvertTo-Json -Depth 20) | Set-Content -LiteralPath $out -Encoding UTF8
    return $out
}

function ConvertTo-ShopdbSummary {
    <#
      Normalize whatever the engine returns into a well-formed summary hashtable
      so New-ShopdbReport always has clean input, without assuming the engine was
      fixed.

      Expected engine contract (what a compliant engine returns):
        @{ Installed=<int>; Skipped=<int>; Failed=<int>; Filtered=<int>;
           EnforcerVersion=<string>;
           Results=@( @{ Name; Action; SelfHealed; ExitCode; Message } ... ) }

      This adapter tolerates any of:
        - $null / empty  -> a zeroed summary.
        - a hashtable or PSCustomObject with those keys (any casing).
        - an array / multiple emitted objects -> the last element that looks
          like a summary (has any count or a Results list) is used.
        - a bare return code (int) or unrecognized object -> a zeroed summary.

      Counts are coerced to int; a missing EnforcerVersion is left for the caller
      to default. Always returns a hashtable.
    #>
    param($EngineResult, [string]$DefaultEnforcerVersion = '2.6')

    $zero = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0;
               Results = @(); EnforcerVersion = $DefaultEnforcerVersion }

    $candidate = $EngineResult
    if ($candidate -is [System.Array]) {
        $picked = $null
        foreach ($item in $candidate) {
            if ($null -eq $item) { continue }
            $looksLikeSummary = $false
            foreach ($key in @('Installed', 'Skipped', 'Failed', 'Filtered', 'Results')) {
                if ($null -ne (Get-ShopdbProperty -InputObject $item -Names $key)) { $looksLikeSummary = $true; break }
            }
            if ($looksLikeSummary) { $picked = $item }
        }
        $candidate = $picked
    }

    if ($null -eq $candidate) { return $zero }
    # A bare return code (int) or any object without the expected members reads
    # as all-null through Get-ShopdbProperty below, which yields the zeroed
    # summary - exactly the fail-open behavior we want for a non-compliant engine.
    $results = Get-ShopdbProperty -InputObject $candidate -Names 'Results'
    $enforcerVersion = [string](Get-ShopdbProperty -InputObject $candidate -Names 'EnforcerVersion')
    if (-not $enforcerVersion) { $enforcerVersion = $DefaultEnforcerVersion }

    $toInt = {
        param($value)
        $parsed = 0
        if ($null -ne $value -and [int]::TryParse([string]$value, [ref]$parsed)) { return $parsed }
        return 0
    }

    return @{
        Installed       = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Installed'))
        Skipped         = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Skipped'))
        Failed          = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Failed'))
        Filtered        = (& $toInt (Get-ShopdbProperty -InputObject $candidate -Names 'Filtered', 'PCFiltered'))
        Results         = @($results)
        EnforcerVersion = $enforcerVersion
    }
}

Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, `
    Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion, `
    Get-ShopdbPayload, Resolve-ShopdbPayloads, Merge-ShopdbManifests, `
    ConvertTo-ShopdbSummary, Get-ShopdbSubType
