#Requires -Version 7.0
<#
.SYNOPSIS
  End-to-end CLI test for the OPC UA Client (gateway) driver bridged through
  the OtOpcUa server.

.DESCRIPTION
  The OpcUaClient driver is unique in the fleet — it's a gateway that connects
  to ANOTHER OPC UA server and re-exposes its address space through the local
  OtOpcUa server. So there's no protocol-specific driver CLI; both directions
  of this test use `otopcua-cli` against two different endpoints:

    remote  = the upstream OPC UA server the driver connects to (opc-plc fixture
              by default, opc.tcp://localhost:50000)
    local   = the OtOpcUa server itself, which mirrors remote nodes through the
              OpcUaClient driver instance (opc.tcp://localhost:4840)

  Eight stages cover the driver's full capability surface:

    1. Remote probe         — otopcua-cli connect to the upstream. Confirms the
                              simulator / target server is reachable and
                              speaking UA Secure Channel.
    2. Remote read          — otopcua-cli read of -RemoteNodeId on the upstream.
                              Captures the current value + confirms the node
                              exists. Baseline for the forward-bridge stage.
    3. Forward bridge       — otopcua-cli read of -BridgeNodeId on the LOCAL
                              server. Proves the driver discovered + mirrored
                              the remote node into the local address space and
                              the read path is live (IReadable via session).
    4. Subscribe-sees-change — subscribe on local -BridgeNodeId in the
                              background. opc-plc's tickers (FastUInt1, StepUp)
                              mutate autonomously, so no driver poke is needed
                              — a data-change event should arrive within the
                              subscription window. Covers ISubscribable +
                              upstream subscription transfer.
    5. Reverse bridge       — otopcua-cli write to local -WritableBridgeNodeId,
                              then otopcua-cli read of -WritableRemoteNodeId
                              directly on the upstream. Confirms writes flow
                              through the driver to the remote (IWritable). Opt-
                              in — opc-plc default image has no writable nodes
                              without `--sn`; pass -WritableBridgeNodeId AND
                              -WritableRemoteNodeId to enable.
    6. Browse mirror        — otopcua-cli browse of the local -BridgeRootNodeId
                              at depth -BrowseDepth. Asserts at least
                              -BrowseMinNodes descendants appear. Covers
                              ITagDiscovery → local-namespace projection.
    7. Alarm fires          — otopcua-cli alarms subscription on local
                              -AlarmNodeId. opc-plc with `--alm` cycles a
                              TripAlarm autonomously; assert an Active alarm
                              event surfaces. Covers IAlarmSource → OPC UA A&E
                              projection. Opt-in via -AlarmNodeId.
    8. History read         — historyread on local -HistoryNodeId over a
                              lookback window. Covers IHistoryProvider →
                              upstream HistoryRead dispatch. Opt-in via
                              -HistoryNodeId. Note: opc-plc's default image
                              does not historize — a historizing upstream
                              (Prosys, UaExpert sample server) is required.

  Prereqs:

    1. Upstream OPC UA server reachable at -RemoteUrl. Default expects the
       opc-plc Docker fixture (`tests/.../Driver.OpcUaClient.IntegrationTests/
       Docker/docker-compose.yml`): `docker compose up -d` before running.
    2. OtOpcUa server running at -OpcUaUrl with an OpcUaClient DriverInstance
       in its Config DB whose EndpointUrl = -RemoteUrl. The server's
       DiscoverAsync populates the mirrored namespace at startup; the
       -BridgeNodeId / -BridgeRootNodeId you pass must correspond to whatever
       NodeIds that discovery produced on your local server.
    3. To exercise stages 5 / 7 / 8, the upstream must expose writable nodes /
       alarm conditions / history. opc-plc alone doesn't cover all three — see
       parameter docs below for the combinations that work with opc-plc.

.PARAMETER RemoteUrl
  Upstream OPC UA server endpoint (the server the driver connects to).
  Default matches the opc-plc Docker fixture — opc.tcp://localhost:50000.

.PARAMETER OpcUaUrl
  Local OtOpcUa server endpoint. Default opc.tcp://localhost:4840.

.PARAMETER RemoteNodeId
  NodeId on the upstream used for stages 1-2. Default ns=3;s=FastUInt1 — opc-plc
  ticker that increments every 100 ms.

.PARAMETER BridgeNodeId
  NodeId on the LOCAL server that mirrors -RemoteNodeId after the OpcUaClient
  driver discovers it. Dev-specific — whatever the local DiscoverAsync produced
  for the upstream node. No default; mandatory for stages 3-4.

.PARAMETER WritableRemoteNodeId
  Writable NodeId on the upstream for the reverse-bridge stage. opc-plc's
  default image has no writable nodes; add `--sn=1` to the compose command to
  expose `ns=3;s=SlowUInt1` as writable (or similar per opc-plc docs). Omit to
  skip stage 5.

.PARAMETER WritableBridgeNodeId
  Matching local mirror of -WritableRemoteNodeId. Omit to skip stage 5.

.PARAMETER BridgeRootNodeId
  Root NodeId on the local server under which the mirrored upstream sits. The
  browse stage walks from this node down to -BrowseDepth. Default i=85
  (ObjectsFolder) — works but produces a lot of output; pass a narrower root
  for faster / more targeted coverage.

.PARAMETER BrowseDepth
  Max depth for the browse stage. Default 3.

.PARAMETER BrowseMinNodes
  Minimum number of descendants expected under -BridgeRootNodeId. Default 5.

.PARAMETER AlarmNodeId
  NodeId of the ConditionType on the local server for the alarm-fires stage.
  opc-plc with `--alm` exposes e.g. TripAlarm conditions; the local mirror path
  of that condition goes here. Omit to skip stage 7.

.PARAMETER AlarmWaitSec
  Seconds to wait for the alarm to cycle. opc-plc's TripAlarm fires on its own
  cadence; 15 s usually covers one cycle. Default 15.

.PARAMETER HistoryNodeId
  NodeId on the local server whose history to query. Omit to skip stage 8.

.PARAMETER HistoryLookbackSec
  Seconds back from now to query history. Default 3600.

.PARAMETER ChangeWaitSec
  Seconds the subscribe-sees-change stage waits for a natural ticker update.
  opc-plc's FastUInt1 ticks every 100 ms so a short window suffices. Default 8.

.EXAMPLE
  # Bare-minimum: stages 1-4 + browse, against the opc-plc compose fixture.
  # Requires the local OtOpcUa server to have discovered opc-plc and placed
  # FastUInt1 under (for example) ns=2;s=OpcUaClient/FastUInt1.
  ./scripts/e2e/test-opcuaclient.ps1 -BridgeNodeId "ns=2;s=OpcUaClient/FastUInt1"

.EXAMPLE
  # Full matrix — all eight stages. Requires an opc-plc image with --sn (for
  # writable) + --alm (for alarms; default compose has this) + a historizing
  # upstream (opc-plc does not; Prosys does).
  ./scripts/e2e/test-opcuaclient.ps1 `
    -BridgeNodeId         "ns=2;s=OpcUaClient/FastUInt1" `
    -WritableRemoteNodeId "ns=3;s=SlowUInt1" `
    -WritableBridgeNodeId "ns=2;s=OpcUaClient/SlowUInt1" `
    -BridgeRootNodeId     "ns=2;s=OpcUaClient" `
    -AlarmNodeId          "ns=2;s=OpcUaClient/TripAlarm" `
    -HistoryNodeId        "ns=2;s=OpcUaClient/StepUp"
#>

param(
    [string]$RemoteUrl              = "opc.tcp://localhost:50000",
    [string]$OpcUaUrl               = "opc.tcp://localhost:4840",
    [string]$RemoteNodeId           = "ns=3;s=FastUInt1",
    [Parameter(Mandatory)] [string]$BridgeNodeId,
    [string]$WritableRemoteNodeId   = "",
    [string]$WritableBridgeNodeId   = "",
    [string]$BridgeRootNodeId       = "i=85",
    [int]$BrowseDepth               = 3,
    [int]$BrowseMinNodes            = 5,
    [string]$AlarmNodeId            = "",
    [int]$AlarmWaitSec              = 15,
    [string]$HistoryNodeId          = "",
    [int]$HistoryLookbackSec        = 3600,
    [int]$ChangeWaitSec             = 8
)

$ErrorActionPreference = "Stop"
. "$PSScriptRoot/_common.ps1"

$opcUaCli = Get-CliInvocation `
    -ProjectFolder "src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI" `
    -ExeName "otopcua-cli"

$results = @()

# ---------------------------------------------------------------------------
# Stage 1 — Remote probe. `otopcua-cli connect` exits 0 when the Secure Channel
# + Session handshake to the upstream complete cleanly. A failure here means
# opc-plc isn't running or the endpoint is unreachable — nothing downstream is
# worth trying.
# ---------------------------------------------------------------------------

Write-Header "Remote probe"
$probe = Invoke-Cli -Cli $opcUaCli -Args @("connect", "-u", $RemoteUrl)
if ($probe.ExitCode -eq 0 -and $probe.Output -match "Connection successful") {
    Write-Pass "upstream $RemoteUrl reachable + speaks UA"
    $results += @{ Passed = $true }
} else {
    Write-Fail "upstream connect failed (exit=$($probe.ExitCode))"
    Write-Host $probe.Output
    $results += @{ Passed = $false; Reason = "remote probe failed" }
    # Fail fast: if the upstream is down every other stage will cascade.
    Write-Summary -Title "OpcUaClient e2e" -Results $results
    exit 1
}

# ---------------------------------------------------------------------------
# Stage 2 — Remote read. Pulls the current value of -RemoteNodeId directly from
# the upstream. Recorded for later stages to compare against, and confirms the
# chosen NodeId actually exists on this upstream.
# ---------------------------------------------------------------------------

Write-Header "Remote read"
$remoteRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $RemoteUrl, "-n", $RemoteNodeId)
$remoteValue = $null
if ($remoteRead.ExitCode -eq 0 -and $remoteRead.Output -match "Value:\s+([^\r\n]+)") {
    $remoteValue = $Matches[1].Trim()
    Write-Pass "remote $RemoteNodeId = $remoteValue"
    $results += @{ Passed = $true }
} else {
    Write-Fail "remote read of $RemoteNodeId failed"
    Write-Host $remoteRead.Output
    $results += @{ Passed = $false; Reason = "remote read failed" }
}

# ---------------------------------------------------------------------------
# Stage 3 — Forward bridge. Read -BridgeNodeId on the LOCAL server. If the
# OpcUaClient driver is live + its discovery mapped -RemoteNodeId into the
# local namespace, this should return a Good value. For ticker nodes like
# FastUInt1 we don't require exact equality with stage 2 (the ticker has
# likely advanced between reads); a Good-status read is the real signal.
# ---------------------------------------------------------------------------

Write-Header "Forward bridge (remote → local)"
$localRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $BridgeNodeId)
if ($localRead.ExitCode -eq 0 -and $localRead.Output -match "Status:\s+0x00000000" -and $localRead.Output -match "Value:\s+([^\r\n]+)") {
    $localValue = $Matches[1].Trim()
    Write-Pass "local bridge $BridgeNodeId = $localValue (remote was $remoteValue)"
    $results += @{ Passed = $true }
} else {
    Write-Fail "local bridge read failed — driver instance may not be configured or discovery hasn't run"
    Write-Host $localRead.Output
    $results += @{ Passed = $false; Reason = "forward bridge failed" }
}

# ---------------------------------------------------------------------------
# Stage 4 — Subscribe sees change. opc-plc's FastUInt1 ticks autonomously so we
# don't need to drive a write. A properly wired OpcUaClient driver forwards
# remote MonitoredItem data-change callbacks to the local server, which then
# publishes them to our subscribe client. If nothing arrives within the
# window, either the remote node isn't a ticker OR the upstream subscription
# chain is broken (probe state, keep-alive, SDK publish queue).
# ---------------------------------------------------------------------------

Write-Header "Subscribe sees change"
$stdout = New-TemporaryFile
$stderr = New-TemporaryFile
$subArgs = @($opcUaCli.PrefixArgs) + @(
    "subscribe", "-u", $OpcUaUrl, "-n", $BridgeNodeId,
    "-i", "200", "--duration", "$ChangeWaitSec")
$subProc = Start-Process -FilePath $opcUaCli.File `
    -ArgumentList $subArgs -NoNewWindow -PassThru `
    -RedirectStandardOutput $stdout.FullName `
    -RedirectStandardError  $stderr.FullName
Write-Info "subscription started (pid $($subProc.Id)) for ${ChangeWaitSec}s"
$subProc.WaitForExit(($ChangeWaitSec + 5) * 1000) | Out-Null
if (-not $subProc.HasExited) { Stop-Process -Id $subProc.Id -Force }
$subOut = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -Raw)
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue

# SubscribeCommand prints `[timestamp] <NodeId> = <value> (0xNNNNNNNN)` per
# data-change event. 0x00000000 == Good; anything else is a non-Good status
# we intentionally don't count (a quality drop isn't a "saw the change").
$changeLines = @(($subOut -split "`n") | Where-Object { $_ -match "=\s+\S.*\(0x00000000\)" })
if ($changeLines.Count -gt 0) {
    Write-Pass "$($changeLines.Count) data-change events observed on bridge"
    $results += @{ Passed = $true }
} else {
    Write-Fail "no data-change events in ${ChangeWaitSec}s — upstream node may be static, or subscription chain broken"
    Write-Host $subOut
    $results += @{ Passed = $false; Reason = "no data-change" }
}

# ---------------------------------------------------------------------------
# Stage 5 — Reverse bridge. Only runs when both writable NodeIds are supplied.
# Writes on the local bridge side, reads directly on the upstream to verify
# the write crossed the driver. 2s settle accounts for the driver's next poll
# (non-idempotent writes on upstream side may take a tick to propagate).
# ---------------------------------------------------------------------------

if ([string]::IsNullOrEmpty($WritableBridgeNodeId) -or [string]::IsNullOrEmpty($WritableRemoteNodeId)) {
    Write-Header "Reverse bridge (local → remote)"
    Write-Skip "WritableBridgeNodeId / WritableRemoteNodeId not supplied — opc-plc default has no writable nodes. Add --sn=N to the compose and re-run with both params set."
} else {
    Write-Header "Reverse bridge (local → remote)"
    $writeValue = Get-Random -Minimum 1 -Maximum 9999
    $w = Invoke-Cli -Cli $opcUaCli -Args @(
        "write", "-u", $OpcUaUrl, "-n", $WritableBridgeNodeId, "-v", "$writeValue")
    if ($w.ExitCode -ne 0 -or $w.Output -notmatch "Write successful") {
        Write-Fail "local-side write failed"
        Write-Host $w.Output
        $results += @{ Passed = $false; Reason = "reverse-bridge write failed" }
    } else {
        Write-Info "local write ok, waiting 2s for driver propagate"
        Start-Sleep -Seconds 2
        $r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $RemoteUrl, "-n", $WritableRemoteNodeId)
        if ($r.ExitCode -eq 0 -and $r.Output -match "Value:\s+$([Regex]::Escape("$writeValue"))\b") {
            Write-Pass "remote reads back $writeValue"
            $results += @{ Passed = $true }
        } else {
            Write-Fail "remote value did not reflect $writeValue"
            Write-Host $r.Output
            $results += @{ Passed = $false; Reason = "reverse-bridge readback mismatch" }
        }
    }
}

# ---------------------------------------------------------------------------
# Stage 6 — Browse mirror. Walks -BridgeRootNodeId to -BrowseDepth levels. The
# BrowseCommand emits one line per encountered node; we count non-empty lines
# minus the root-summary line and compare against -BrowseMinNodes. A naked
# i=85 root always has something; a narrower dev-specific root is stricter.
# ---------------------------------------------------------------------------

Write-Header "Browse mirror"
$br = Invoke-Cli -Cli $opcUaCli -Args @(
    "browse", "-u", $OpcUaUrl, "-n", $BridgeRootNodeId,
    "-r", "-d", "$BrowseDepth")
if ($br.ExitCode -ne 0) {
    Write-Fail "browse failed (exit=$($br.ExitCode))"
    Write-Host $br.Output
    $results += @{ Passed = $false; Reason = "browse failed" }
} else {
    # BrowseCommand prints one line per node: `[Type] Name (NodeId: xxx)` with
    # indentation for depth. Count every line carrying a NodeId marker.
    $nodeLines = @(($br.Output -split "`n") | Where-Object { $_ -match "\(NodeId:" })
    $count = $nodeLines.Count
    if ($count -ge $BrowseMinNodes) {
        Write-Pass "$count descendants under $BridgeRootNodeId (>= $BrowseMinNodes)"
        $results += @{ Passed = $true }
    } else {
        Write-Fail "only $count descendants — expected >= $BrowseMinNodes"
        Write-Host $br.Output
        $results += @{ Passed = $false; Reason = "browse under-populated" }
    }
}

# ---------------------------------------------------------------------------
# Stage 7 — Alarm fires. opc-plc with --alm (set in the compose) cycles a
# TripAlarm Condition autonomously. The local alarm subscription should
# surface at least one Active transition within the wait window. Opt-in:
# requires the user to know the local mirror of the upstream alarm Condition.
# ---------------------------------------------------------------------------

if ([string]::IsNullOrEmpty($AlarmNodeId)) {
    Write-Header "Alarm fires"
    Write-Skip "AlarmNodeId not supplied — skipping alarm stage"
} else {
    Write-Header "Alarm fires"
    $stdout = New-TemporaryFile
    $stderr = New-TemporaryFile
    $allArgs = @($opcUaCli.PrefixArgs) + @(
        "alarms", "-u", $OpcUaUrl, "-n", $AlarmNodeId, "-i", "500", "--refresh")
    $proc = Start-Process -FilePath $opcUaCli.File `
        -ArgumentList $allArgs -NoNewWindow -PassThru `
        -RedirectStandardOutput $stdout.FullName `
        -RedirectStandardError  $stderr.FullName
    Write-Info "alarm subscription started (pid $($proc.Id)), waiting ${AlarmWaitSec}s for opc-plc alarm cycle"
    Start-Sleep -Seconds $AlarmWaitSec
    if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force }
    $out = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -Raw)
    Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue

    if ($out -match "ALARM\b" -and $out -match "Active\b") {
        Write-Pass "alarm condition fired with Active state"
        $results += @{ Passed = $true }
    } else {
        Write-Fail "no Active alarm event observed in ${AlarmWaitSec}s — check opc-plc compose has --alm + the AlarmNodeId is the local mirror of the upstream Condition"
        Write-Host $out
        $results += @{ Passed = $false; Reason = "no alarm event" }
    }
}

# ---------------------------------------------------------------------------
# Stage 8 — History read. IHistoryProvider dispatch to the upstream's
# HistoryRead service. opc-plc does NOT historize by default, so this stage
# SKIPs when -HistoryNodeId is empty. Against a historizing upstream (Prosys,
# UA Expert sample server, AVEVA Historian) point -HistoryNodeId at the local
# mirror of a historized node.
# ---------------------------------------------------------------------------

if ([string]::IsNullOrEmpty($HistoryNodeId)) {
    Write-Header "History read"
    Write-Skip "HistoryNodeId not supplied — opc-plc default does not historize; supply a historized-upstream mirror NodeId to enable."
} else {
    $results += Test-HistoryHasSamples `
        -OpcUaCli $opcUaCli `
        -OpcUaUrl $OpcUaUrl `
        -NodeId $HistoryNodeId `
        -LookbackSec $HistoryLookbackSec
}

Write-Summary -Title "OpcUaClient e2e" -Results $results
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
