[CmdletBinding()]
param(
    [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
    [string]$ServerHost,
    [int]$ServerPort,
    [ValidateSet('http', 'https')]
    [string]$ServerScheme,
    [string]$PolicyEngineHost,
    [int]$PolicyEnginePort,
    [ValidateSet('http', 'https')]
    [string]$PolicyEngineScheme,
    [string]$PolicyPath,
    [ValidateSet('local', 'server')]
    [string]$PolicyMode,
    [int]$PolicyRefreshSeconds,
    [string]$PolicyCachePath,
    [string]$LogPath,
    [int]$PollSeconds,
    [switch]$SelfTestSuppressedBlock
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# Ensure HttpClient is available (Windows PowerShell 5 may not auto-load it)
try {
    Add-Type -AssemblyName System.Net.Http
}
catch {
}

$script:TransportQueuePath = $null
$script:TransportQueueLockPath = $null
$script:SessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId
$script:TransportMetrics = @{
    eventsEnqueued = 0
    eventsFlushed  = 0
    sendFailures   = 0
    queueDepth     = 0
}

$policyClientModulePath = Join-Path $PSScriptRoot 'dlp-policy-client.ps1'
if (Test-Path -LiteralPath $policyClientModulePath) {
    try {
        Import-Module $policyClientModulePath -Force -DisableNameChecking
        $script:PolicyClientAvailable = $true
    }
    catch {
        $script:PolicyClientAvailable = $false
    }
}
else {
    $script:PolicyClientAvailable = $false
}

function Get-DeploymentConfig {
    param([string]$Path)
    if ($Path -and (Test-Path -LiteralPath $Path)) {
        return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
    }
    return $null
}

function Write-EndpointLog {
    param([string]$Message)
    if (-not $script:LocalAgentLogsEnabled) {
        return
    }
    try {
        Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message)
    }
    catch {
    }
}

function Get-QueueNameToken {
    param(
        [string]$UserName,
        [int]$SessionId
    )
    $token = ('{0}-s{1}' -f $UserName, $SessionId)
    foreach ($ch in [System.IO.Path]::GetInvalidFileNameChars()) {
        $token = $token.Replace([string]$ch, '_')
    }
    if ([string]::IsNullOrWhiteSpace($token)) {
        return ('session-{0}' -f $SessionId)
    }
    return $token
}

function Invoke-AwJsonPost {
    param(
        [Parameter(Mandatory = $true)][string]$Uri,
        [Parameter(Mandatory = $true)][string]$Json
    )

    try {
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($Json)
        $req = [System.Net.HttpWebRequest]::Create($Uri)
        $req.Method = 'POST'
        $req.ContentType = 'application/json'
        $req.Accept = 'application/json'
        $req.KeepAlive = $false
        $req.Timeout = 15000
        $req.ReadWriteTimeout = 15000
        $req.ContentLength = $bytes.Length

        $stream = $req.GetRequestStream()
        try { $stream.Write($bytes, 0, $bytes.Length) } finally { $stream.Close() }

        $resp = $req.GetResponse()
        try {
            # read body for debugging, but discard on success
            $rs = $resp.GetResponseStream()
            if ($rs) { $sr = New-Object System.IO.StreamReader($rs); $null = $sr.ReadToEnd(); $sr.Close() }
        } finally {
            $resp.Close()
        }
        return
    }
    catch [System.Net.WebException] {
        $status = $null
        $body = ''
        try {
            if ($_.Exception.Response) {
                try { $status = [int]$_.Exception.Response.StatusCode } catch {}
                $rs = $_.Exception.Response.GetResponseStream()
                if ($rs) { $sr = New-Object System.IO.StreamReader($rs); $body = $sr.ReadToEnd(); $sr.Close() }
            }
        } catch {}

        # aw-server-rust may return 304 for idempotent bucket create. Treat it as OK.
        if ($status -eq 304) {
            Write-EndpointLog ("POST bucket exists (304): uri={0}" -f $Uri)
            return
        }

        Write-EndpointLog ("POST failed: uri={0} status={1} err={2} body={3}" -f $Uri, $status, $_.Exception.Message, $body)
        throw
    }
    catch {
        Write-EndpointLog ("POST error: uri={0} err={1}" -f $Uri, $_.Exception.Message)
        throw
    }
}

function Initialize-TransportQueue {
    param([Parameter(Mandatory = $true)][string]$StateRoot)
    $queueToken = Get-QueueNameToken -UserName $env:USERNAME -SessionId $script:SessionId
    $script:TransportQueuePath = Join-Path $StateRoot ("dlp-endpoint-signals-queue-{0}.jsonl" -f $queueToken)
    $script:TransportQueueLockPath = Join-Path $StateRoot ("dlp-endpoint-signals-queue-{0}.lock" -f $queueToken)
    if (-not (Test-Path -LiteralPath $script:TransportQueuePath)) {
        New-Item -Path $script:TransportQueuePath -ItemType File -Force | Out-Null
    }
    $legacyQueuePath = Join-Path $StateRoot 'dlp-endpoint-signals-queue.jsonl'
    if (Test-Path -LiteralPath $legacyQueuePath) {
        $legacyItems = @(Get-Content -LiteralPath $legacyQueuePath -ErrorAction SilentlyContinue | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
        if ($legacyItems.Count -gt 0) {
            Add-Content -LiteralPath $script:TransportQueuePath -Value $legacyItems -Encoding UTF8
            Clear-Content -LiteralPath $legacyQueuePath -ErrorAction SilentlyContinue
        }
    }
}

function Get-TransportQueueLock {
    $tries = 0
    while ($tries -lt 50) {
        try {
            return [System.IO.File]::Open($script:TransportQueueLockPath, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
        }
        catch {
            Start-Sleep -Milliseconds 50
            $tries++
        }
    }
    throw "Failed to acquire transport queue lock: $script:TransportQueueLockPath"
}

function Add-TransportQueueItem {
    param(
        [Parameter(Mandatory = $true)][string]$Uri,
        [Parameter(Mandatory = $true)][string]$Payload,
        [string]$Kind = 'endpoint'
    )
    $lock = Get-TransportQueueLock
    try {
        $line = @{
            ts      = (Get-Date).ToUniversalTime().ToString('o')
            uri     = $Uri
            payload = $Payload
            kind    = $Kind
        } | ConvertTo-Json -Compress
        Add-Content -LiteralPath $script:TransportQueuePath -Value $line -Encoding UTF8
        $script:TransportMetrics.eventsEnqueued++
    }
    finally {
        $lock.Dispose()
    }
}

function Read-TransportQueueItems {
    if (-not (Test-Path -LiteralPath $script:TransportQueuePath)) { return @() }
    $items = @()
    foreach ($line in @(Get-Content -LiteralPath $script:TransportQueuePath -ErrorAction SilentlyContinue)) {
        if ([string]::IsNullOrWhiteSpace($line)) { continue }
        try { $items += ($line | ConvertFrom-Json) } catch {}
    }
    return $items
}

function Write-TransportQueueItems {
    param([object[]]$Items = @())

    $lines = @()
    foreach ($item in @($Items)) {
        if ($null -eq $item) { continue }
        $lines += ($item | ConvertTo-Json -Compress)
    }

    Set-Content -LiteralPath $script:TransportQueuePath -Value $lines -Encoding UTF8
    $script:TransportMetrics.queueDepth = @($Items).Count
}

function Flush-TransportQueue {
    param([int]$MaxItems = 200)
    if (-not (Test-Path -LiteralPath $script:TransportQueuePath)) { return }

    $items = @()
    $lock = Get-TransportQueueLock
    try {
        $items = @(Read-TransportQueueItems)
        $itemCount = @($items).Count
        $script:TransportMetrics.queueDepth = $itemCount
        if ($itemCount -eq 0) { return }

        # Drain the on-disk queue under lock, then release the lock before network I/O.
        # This prevents one stalled POST from blocking every concurrent enqueue/flush attempt.
        Write-TransportQueueItems -Items @()
    }
    finally {
        $lock.Dispose()
    }

    $retryItems = @()
    $sent = 0
    foreach ($item in @($items)) {
        if ($null -eq $item) { continue }
        if ($sent -ge $MaxItems) {
            $retryItems += $item
            continue
        }
        try {
            Invoke-AwJsonPost -Uri ([string]$item.uri) -Json ([string]$item.payload)
            $sent++
            $script:TransportMetrics.eventsFlushed++
        }
        catch {
            $script:TransportMetrics.sendFailures++
            $retryItems += $item
        }
    }

    $lock = Get-TransportQueueLock
    try {
        $concurrentItems = @(Read-TransportQueueItems)
        Write-TransportQueueItems -Items (@($retryItems) + @($concurrentItems))
    }
    finally {
        $lock.Dispose()
    }
}

function Ensure-Bucket {
    param(
        [string]$BucketId,
        [string]$ClientName,
        [string]$BucketType
    )

    if ($script:KnownBuckets.ContainsKey($BucketId)) {
        return
    }

    if ($script:KnownBuckets.ContainsKey($BucketId)) {
        return
    }

    # Fast-path: if bucket already exists, don't POST.
    try {
        Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" -TimeoutSec 10 -DisableKeepAlive -ErrorAction Stop | Out-Null
        Write-EndpointLog ("bucket ok (GET): {0}" -f $BucketId)
        $script:KnownBuckets[$BucketId] = $true
        return
    }
    catch {
        Write-EndpointLog ("bucket GET failed: {0} err={1}" -f $BucketId, $_.Exception.Message)
    }

    $body = @{
        client   = $ClientName
        type     = $BucketType
        hostname = $script:Hostname
    } | ConvertTo-Json -Compress

    try {
        Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body
    }
    catch {
        # If create failed (race), verify it exists now.
        try {
            Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" -TimeoutSec 10 -DisableKeepAlive | Out-Null
        }
        catch {
            throw
        }
    }
    $script:KnownBuckets[$BucketId] = $true
}

function Send-EndpointSignalHeartbeat {
    param(
        [string]$SignalType,
        [hashtable]$Data
    )

    $bucketId = 'aw-dlp-endpoint-signals_' + $script:Hostname
    Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-endpoint-signals' -BucketType 'aw.dlp.endpoint.signal'

    $payload = @{
        timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
        duration  = 0
        data      = @{
            signalType = $SignalType
            username   = $env:USERNAME
            sessionId  = $script:SessionId
            hostname   = $script:Hostname
            source     = 'endpoint-signals-phase2'
        } + $Data
    } | ConvertTo-Json -Depth 6 -Compress

    Add-TransportQueueItem -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Payload $payload -Kind 'endpoint_signal'
    Flush-TransportQueue -MaxItems 50
}

function Send-DlpIncidentHeartbeat {
    param(
        [string]$RuleId,
        [string]$Action,
        [string]$Severity,
        [string]$Message,
        [string]$SignalType,
        [hashtable]$Data
    )

    $bucketId = 'aw-dlp-incidents_' + $script:Hostname
    Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident'

    $captureData = @{}
    if ($script:IncidentScreenshotEnabled) {
        try {
            $captureData = Capture-IncidentScreenshot -RuleId $RuleId -SignalType $SignalType
        }
        catch {
        }
    }

    $payload = @{
        timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
        duration  = 0
        data      = @{
            ruleId    = $RuleId
            action    = $Action
            severity  = $Severity
            message   = $Message
            signalType = $SignalType
            username  = $env:USERNAME
            sessionId = $script:SessionId
            hostname  = $script:Hostname
            source    = 'endpoint-signals-phase2'
        } + $Data + $captureData
    } | ConvertTo-Json -Depth 7 -Compress

    Add-TransportQueueItem -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Payload $payload -Kind 'dlp_incident'
    Flush-TransportQueue -MaxItems 100
}

function Get-FileSha256Hex {
    param([Parameter(Mandatory = $true)][string]$Path)
    try {
        $sha = [Security.Cryptography.SHA256]::Create()
        $stream = [IO.File]::OpenRead($Path)
        try {
            ($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join ''
        }
        finally {
            $stream.Dispose()
            $sha.Dispose()
        }
    }
    catch {
        return $null
    }
}

function Ensure-Directory {
    param([Parameter(Mandatory = $true)][string]$Path)
    if (-not (Test-Path -LiteralPath $Path)) {
        New-Item -Path $Path -ItemType Directory -Force | Out-Null
    }
}

function Get-IncidentScreenshotPath {
    param(
        [Parameter(Mandatory = $true)][string]$RuleId,
        [Parameter(Mandatory = $true)][string]$SignalType
    )

    $safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_')
    $safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_')
    $safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_')
    $stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff')
    $file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule
    $file = '{0}_{1}' -f $stamp, $file
    return (Join-Path $script:IncidentArtifactsRoot $file)
}

function Ensure-ScreenshotTypesLoaded {
    if ($script:ScreenshotTypesLoaded) {
        return
    }
    Add-Type -AssemblyName System.Windows.Forms | Out-Null
    Add-Type -AssemblyName System.Drawing | Out-Null
    $script:ScreenshotTypesLoaded = $true
}

function Capture-IncidentScreenshot {
    param(
        [Parameter(Mandatory = $true)][string]$RuleId,
        [Parameter(Mandatory = $true)][string]$SignalType
    )

    try {
        Ensure-Directory -Path $script:IncidentArtifactsRoot
        Ensure-ScreenshotTypesLoaded

        $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
        $bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height)
        $gfx = [System.Drawing.Graphics]::FromImage($bmp)
        try {
            $gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size)
            $path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType
            $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)
        }
        finally {
            $gfx.Dispose()
            $bmp.Dispose()
        }

        return @{
            screenshotPath   = $path
            screenshotFormat = 'png'
            screenshotWidth  = [int]$vs.Width
            screenshotHeight = [int]$vs.Height
            screenshotSha256 = (Get-FileSha256Hex -Path $path)
        }
    }
    catch {
        Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message)
        return @{}
    }
}

# ---------------------------------------------------------------------------
# Enforcement functions (action = "block")
# ---------------------------------------------------------------------------

function Show-EnforcementNotification {
    param(
        [Parameter(Mandatory = $true)][string]$Title,
        [Parameter(Mandatory = $true)][string]$Body
    )
    try {
        Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
        $icon = New-Object System.Windows.Forms.NotifyIcon
        $icon.Icon = [System.Drawing.SystemIcons]::Warning
        $icon.BalloonTipTitle = $Title
        $icon.BalloonTipText = $Body
        $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning
        $icon.Visible = $true
        $icon.ShowBalloonTip(5000)
        Start-Sleep -Milliseconds 200
        $icon.Dispose()
    }
    catch {
        Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message)
    }
}

function Invoke-ClipboardEnforcement {
    [OutputType([bool])]
    param()
    try {
        Set-Clipboard -Value $null -ErrorAction Stop
        Write-EndpointLog "enforcement: clipboard cleared"
        return $true
    }
    catch {
        Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message)
        return $false
    }
}

function Invoke-UsbWriteBlockEnforcement {
    [OutputType([bool])]
    param(
        [Parameter(Mandatory = $true)][string]$DriveLetter
    )
    try {
        $partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop
        $disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop
        if ($disk.BusType -ne 'USB') {
            Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType)
            return $false
        }
        if (-not $disk.IsReadOnly) {
            Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop
            Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter)
        }
        return $true
    }
    catch {
        Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message)
        return $false
    }
}

function Invoke-PrintJobEnforcement {
    [OutputType([bool])]
    param(
        [Parameter(Mandatory = $true)][string]$PrinterName,
        [string]$DocumentName,
        [string]$Owner
    )
    $cancelled = $false
    try {
        $jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue
        foreach ($job in @($jobs)) {
            $jobPrinter = [string]$job.Name
            $jobOwner = [string]$job.Owner
            $jobDoc = [string]$job.Document
            $matchPrinter = ($jobPrinter -like "*$PrinterName*")
            $matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*")
            if ($matchPrinter -and $matchOwner) {
                Remove-CimInstance -InputObject $job -ErrorAction Stop
                Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc)
                $cancelled = $true
            }
        }
    }
    catch {
        Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message)
    }
    return $cancelled
}

function Get-StringHash {
    param([AllowNull()][string]$Value)
    if ($null -eq $Value) { return $null }
    $bytes = [Text.Encoding]::UTF8.GetBytes($Value)
    $sha = [Security.Cryptography.SHA256]::Create()
    try {
        ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join ''
    }
    finally {
        $sha.Dispose()
    }
}

function Get-ClipboardTextSafe {
    [OutputType([string])]
    param()

    try {
        $v = Get-Clipboard -Raw -ErrorAction Stop
        if ($null -ne $v) { return [string]$v }
    }
    catch {
        Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message)
    }

    # Clipboard is not reliably accessible from Session 0 (SYSTEM). Avoid noisy thread hacks there.
    if ($script:SessionId -eq 0) {
        return $null
    }

    # Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases.
    try {
        Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null
        $result = [string]::Empty
        $script:__aw_clip = $null
        $threadStart = [System.Threading.ThreadStart]{
            try {
                $script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText()
            }
            catch {
                $script:__aw_clip = $null
            }
        }
        $thread = New-Object System.Threading.Thread($threadStart)
        $thread.SetApartmentState([System.Threading.ApartmentState]::STA)
        $thread.Start()
        $thread.Join(3000) | Out-Null
        if ($thread.IsAlive) {
            try { $thread.Abort() } catch {}
        }
        $result = [string]$script:__aw_clip
        Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue
        return $result
    }
    catch {
        Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message)
        return $null
    }
}

function Load-DlpPolicy {
    param([string]$Path)

    $script:Policy = [ordered]@{
        defaults = [ordered]@{
            enabled = $true
            cooldownSeconds = 300
            action = 'alert'
            severity = 'medium'
        }
        endpoint = [ordered]@{
            clipboard = @()
            usb = @()
            print = @()
        }
        contentAnalysis = [ordered]@{
            dictionaryPack = $null
            regexPack = $null
            ocrEnabled = $false
        }
        nativeControls = [ordered]@{
            mode = 'monitor'
            rollout = [ordered]@{
                allowGlobalBlock = $false
            }
            channels = [ordered]@{
                clipboard = [ordered]@{ action = 'audit' }
                usb = [ordered]@{ action = 'audit' }
                print = [ordered]@{ action = 'audit' }
            }
        }
    }

    $script:PolicySource = 'defaults'
    $script:PolicyVersion = $null
    $script:PolicyChecksum = $null

    if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
        Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path)
        return
    }

    try {
        $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
        if ($raw.defaults) {
            if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled }
            if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds }
            if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action }
            if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity }
        }

        if ($raw.endpoint) {
            $props = @()
            try { $props = @($raw.endpoint.PSObject.Properties.Name) } catch { $props = @() }
            if ($props -contains 'clipboard' -and $raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) }
            if ($props -contains 'usb' -and $raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) }
            if ($props -contains 'print' -and $raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) }
        }

        if ($raw.contentAnalysis) {
            if ($raw.contentAnalysis.PSObject.Properties.Name -contains 'dictionaryPack' -and $raw.contentAnalysis.dictionaryPack) {
                $script:Policy.contentAnalysis.dictionaryPack = [string]$raw.contentAnalysis.dictionaryPack
            }
            if ($raw.contentAnalysis.PSObject.Properties.Name -contains 'regexPack' -and $raw.contentAnalysis.regexPack) {
                $script:Policy.contentAnalysis.regexPack = [string]$raw.contentAnalysis.regexPack
            }
            if ($raw.contentAnalysis.PSObject.Properties.Name -contains 'ocrEnabled') {
                $script:Policy.contentAnalysis.ocrEnabled = [bool]$raw.contentAnalysis.ocrEnabled
            }
        }
        if ($raw.nativeControls) {
            $nativeProps = @($raw.nativeControls.PSObject.Properties.Name)
            if ($nativeProps -contains 'mode' -and $raw.nativeControls.mode) {
                $script:Policy.nativeControls.mode = ([string]$raw.nativeControls.mode).ToLowerInvariant()
            }
            if ($nativeProps -contains 'rollout' -and $raw.nativeControls.rollout) {
                $rolloutProps = @($raw.nativeControls.rollout.PSObject.Properties.Name)
                if ($rolloutProps -contains 'allowGlobalBlock') {
                    $script:Policy.nativeControls.rollout.allowGlobalBlock = [bool]$raw.nativeControls.rollout.allowGlobalBlock
                }
            }
            if ($nativeProps -contains 'channels' -and $raw.nativeControls.channels) {
                foreach ($channelName in @('clipboard', 'usb', 'print')) {
                    if (@($raw.nativeControls.channels.PSObject.Properties.Name) -contains $channelName) {
                        $channel = $raw.nativeControls.channels.$channelName
                        if ($channel -and (@($channel.PSObject.Properties.Name) -contains 'action') -and $channel.action) {
                            $script:Policy.nativeControls.channels[$channelName].action = ([string]$channel.action).ToLowerInvariant()
                        }
                    }
                }
            }
        }
        $script:PolicySource = 'local'
    }
    catch {
        Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message)
    }
}

function Resolve-DlpEffectiveAction {
    param(
        [Parameter(Mandatory = $true)][string]$RequestedAction,
        [Parameter(Mandatory = $true)][ValidateSet('clipboard', 'usb', 'print')][string]$Channel
    )

    $requested = $RequestedAction.ToLowerInvariant()
    $mode = ([string]$script:Policy.nativeControls.mode).ToLowerInvariant()
    $allowGlobalBlock = [bool]$script:Policy.nativeControls.rollout.allowGlobalBlock
    $channelAction = 'audit'
    try {
        $channelAction = ([string]$script:Policy.nativeControls.channels[$Channel].action).ToLowerInvariant()
    }
    catch {
        $channelAction = 'audit'
    }

    $suppressed = $false
    $effective = $requested
    if ($requested -eq 'block') {
        $channelAllowsBlock = $channelAction -in @('block', 'blockwithoverride')
        if ($mode -ne 'enforce' -or -not $allowGlobalBlock -or -not $channelAllowsBlock) {
            $effective = 'alert'
            $suppressed = $true
        }
    }

    return [pscustomobject]@{
        requestedAction = $requested
        action = $effective
        enforcementMode = $mode
        nativeChannelAction = $channelAction
        enforcementSuppressed = $suppressed
    }
}

function Invoke-SuppressedBlockSelfTest {
    $decisions = @()
    foreach ($channel in @('clipboard', 'usb', 'print')) {
        $decisions += (Resolve-DlpEffectiveAction -RequestedAction 'block' -Channel $channel)
    }

    $failed = @(
        $decisions |
            Where-Object { $_.action -eq 'block' -or -not [bool]$_.enforcementSuppressed }
    )

    $result = [ordered]@{
        ok = (@($failed).Count -eq 0)
        test = 'suppressed-block-in-monitor'
        policySource = $script:PolicySource
        policyMode = $script:PolicyMode
        decisions = @($decisions)
    }

    $result | ConvertTo-Json -Depth 6
    if (-not $result.ok) {
        exit 2
    }
    exit 0
}

function Test-ValidInn {
    param([string]$Value)
    $digits = ($Value -replace '\D', '')
    if ($digits.Length -eq 10) {
        $coef = @(2, 4, 10, 3, 5, 9, 4, 6, 8)
        $sum = 0
        for ($i = 0; $i -lt 9; $i++) { $sum += ([int][string]$digits[$i]) * $coef[$i] }
        $chk = ($sum % 11) % 10
        return $chk -eq ([int][string]$digits[9])
    }
    if ($digits.Length -eq 12) {
        $c11 = @(7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
        $c12 = @(3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
        $sum11 = 0
        for ($i = 0; $i -lt 10; $i++) { $sum11 += ([int][string]$digits[$i]) * $c11[$i] }
        $sum12 = 0
        for ($i = 0; $i -lt 11; $i++) { $sum12 += ([int][string]$digits[$i]) * $c12[$i] }
        return ((($sum11 % 11) % 10) -eq ([int][string]$digits[10])) -and ((($sum12 % 11) % 10) -eq ([int][string]$digits[11]))
    }
    return $false
}

function Test-ValidSnils {
    param([string]$Value)
    $digits = ($Value -replace '\D', '')
    if ($digits.Length -ne 11) { return $false }
    $num = $digits.Substring(0, 9)
    $checksum = [int]$digits.Substring(9, 2)
    $sum = 0
    for ($i = 0; $i -lt 9; $i++) { $sum += ([int][string]$num[$i]) * (9 - $i) }
    if ($sum -lt 100) { $expected = $sum }
    elseif ($sum -eq 100 -or $sum -eq 101) { $expected = 0 }
    else {
        $expected = $sum % 101
        if ($expected -eq 100) { $expected = 0 }
    }
    return $checksum -eq $expected
}

function Test-ValidPassport {
    param([string]$Value)
    $digits = ($Value -replace '\D', '')
    if ($digits.Length -ne 10) { return $false }
    if ($digits -eq '0000000000') { return $false }
    return ($digits.ToCharArray() | Select-Object -Unique).Count -gt 1
}

function Get-AdvancedContentMatches {
    param(
        [string]$Text,
        [string]$DictionaryPack,
        [string]$RegexPack
    )

    $result = @{
        dictionaryMatches = @()
        regexMatches = @()
    }
    if ([string]::IsNullOrWhiteSpace($Text)) { return $result }

    if ($DictionaryPack -eq '152-fz-pdn') {
        $m = [regex]::Matches($Text, '\b\d{10}\b|\b\d{12}\b')
        foreach ($item in $m) {
            if (Test-ValidInn -Value $item.Value) {
                $result.dictionaryMatches += @{ name = 'inn'; value = $item.Value; severity = 'high' }
            }
        }
        $m = [regex]::Matches($Text, '\b\d{3}-\d{3}-\d{3}\s?\d{2}\b')
        foreach ($item in $m) {
            if (Test-ValidSnils -Value $item.Value) {
                $result.dictionaryMatches += @{ name = 'snils'; value = $item.Value; severity = 'high' }
            }
        }
        $m = [regex]::Matches($Text, '\b\d{4}\s?\d{6}\b')
        foreach ($item in $m) {
            if (Test-ValidPassport -Value $item.Value) {
                $result.dictionaryMatches += @{ name = 'passport'; value = $item.Value; severity = 'high' }
            }
        }
    }

    $regexRules = @()
    switch ($RegexPack) {
        'financial' {
            $regexRules = @(
                @{ id = 'card-pan'; regex = '\b(?:\d[ -]*?){13,19}\b'; severity = 'high' },
                @{ id = 'iban'; regex = '\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b'; severity = 'medium' }
            )
        }
        'contacts' {
            $regexRules = @(
                @{ id = 'email'; regex = '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'; severity = 'low' },
                @{ id = 'phone-ru'; regex = '(?:\+7|8)\s*\(?\d{3}\)?\s*\d{3}[- ]?\d{2}[- ]?\d{2}'; severity = 'low' }
            )
        }
        'secrets' {
            $regexRules = @(
                @{ id = 'aws-access-key'; regex = 'AKIA[0-9A-Z]{16}'; severity = 'high' },
                @{ id = 'generic-password'; regex = '(?i)(password|пароль)\s*[:=]\s*\S{6,}'; severity = 'medium' }
            )
        }
    }
    foreach ($rule in $regexRules) {
        $m = [regex]::Matches($Text, [string]$rule.regex)
        foreach ($item in $m) {
            $result.regexMatches += @{ name = [string]$rule.id; value = $item.Value; severity = [string]$rule.severity }
        }
    }

    return $result
}

function Apply-PolicyFromBundle {
    param(
        [Parameter(Mandatory = $true)]$Bundle,
        [Parameter(Mandatory = $true)][string]$Source
    )

    if (-not $Bundle.policy) {
        throw 'Policy bundle has no policy payload.'
    }

    $tempPath = [System.IO.Path]::GetTempFileName()
    try {
        $Bundle.policy | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $tempPath -Encoding UTF8
        Load-DlpPolicy -Path $tempPath
        $script:PolicySource = $Source
        $script:PolicyVersion = if ($Bundle.PSObject.Properties.Name -contains 'version') { [string]$Bundle.version } else { $null }
        $script:PolicyChecksum = if ($Bundle.PSObject.Properties.Name -contains 'checksum') { [string]$Bundle.checksum } else { $null }
    }
    finally {
        Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
    }
}

function Refresh-DlpPolicyFromServer {
    if (-not $script:PolicyEngineEnabled) {
        return $false
    }
    if (-not $script:PolicyClientAvailable) {
        Write-EndpointLog 'policy client module unavailable, cannot use server mode'
        return $false
    }

    try {
        $bundle = Get-RemoteDlpPolicyBundle -ApiBase $script:PolicyApiBase -TimeoutSec 10
        Save-CachedDlpPolicyBundle -Bundle $bundle -CachePath $script:PolicyCachePath
        Apply-PolicyFromBundle -Bundle $bundle -Source 'server'
        $script:LastPolicyRefreshAt = (Get-Date).ToUniversalTime()
        Write-EndpointLog ("policy refreshed from server version={0} checksum={1}" -f $script:PolicyVersion, $script:PolicyChecksum)
        return $true
    }
    catch {
        Write-EndpointLog ("policy refresh failed: {0}" -f $_.Exception.Message)
        return $false
    }
}

function Sync-DlpPolicyDesiredState {
    if (-not $script:PolicyEngineEnabled -or -not $script:PolicyClientAvailable) {
        return $false
    }
    if (-not $script:PolicyAgentId) {
        return $false
    }

    try {
        [void](Send-DlpPolicyAgentHeartbeat -ApiBase $script:PolicyApiBase -AgentId $script:PolicyAgentId -Hostname $script:Hostname -Version $script:PolicyVersion -Checksum $script:PolicyChecksum -TimeoutSec 10)
        $desired = Get-RemoteDlpPolicyDesired -ApiBase $script:PolicyApiBase -AgentId $script:PolicyAgentId -TimeoutSec 10
        if ($desired -and $desired.refreshNow -eq $true) {
            Write-EndpointLog ("policy desired refresh requested: reason={0}" -f $desired.reason)
            return (Refresh-DlpPolicyFromServer)
        }
        return $true
    }
    catch {
        Write-EndpointLog ("policy desired sync failed: {0}" -f $_.Exception.Message)
        return $false
    }
}

function Initialize-DlpPolicy {
    if ($script:PolicyMode -eq 'server') {
        if (Refresh-DlpPolicyFromServer) {
            return
        }

        if ($script:PolicyClientAvailable) {
            $cached = Read-CachedDlpPolicyBundle -CachePath $script:PolicyCachePath
            if ($cached) {
                try {
                    Apply-PolicyFromBundle -Bundle $cached -Source 'cache'
                    Write-EndpointLog ("policy loaded from cache version={0} checksum={1}" -f $script:PolicyVersion, $script:PolicyChecksum)
                    return
                }
                catch {
                    Write-EndpointLog ("cached policy load failed: {0}" -f $_.Exception.Message)
                }
            }
        }

        Load-DlpPolicy -Path $script:LocalPolicyPath
        $script:PolicySource = 'local-fallback'
        return
    }

    Load-DlpPolicy -Path $script:LocalPolicyPath
}

function Should-EmitByCooldown {
    param(
        [string]$Fingerprint,
        [int]$CooldownSeconds
    )

    $now = (Get-Date).ToUniversalTime()
    if ($script:Cooldown.ContainsKey($Fingerprint)) {
        $last = [datetime]$script:Cooldown[$Fingerprint]
        if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) {
            return $false
        }
    }

    $script:Cooldown[$Fingerprint] = $now
    return $true
}

function Evaluate-ClipboardRules {
    param(
        [string]$ClipboardText,
        [string]$ClipboardHash
    )

    foreach ($rule in @($script:Policy.endpoint.clipboard)) {
        if (-not $rule) { continue }
        if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue }
        $ruleId = [string]$rule.id
        if (-not $ruleId) { continue }
        $minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 }
        $regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() }
        $dictionaryPack = if ($rule.dictionaryPack) { [string]$rule.dictionaryPack } elseif ($script:Policy.contentAnalysis.dictionaryPack) { [string]$script:Policy.contentAnalysis.dictionaryPack } else { $null }
        $regexPack = if ($rule.regexPack) { [string]$rule.regexPack } elseif ($script:Policy.contentAnalysis.regexPack) { [string]$script:Policy.contentAnalysis.regexPack } else { $null }
        $ocrEnabled = if ($rule.PSObject.Properties.Name -contains 'ocrEnabled') { [bool]$rule.ocrEnabled } else { [bool]$script:Policy.contentAnalysis.ocrEnabled }
        if ($ClipboardText.Length -lt $minLength) { continue }

        $matched = $false
        foreach ($pattern in $regexPatterns) {
            if ($ClipboardText -match [string]$pattern) {
                $matched = $true
                break
            }
        }
        $advanced = Get-AdvancedContentMatches -Text $ClipboardText -DictionaryPack $dictionaryPack -RegexPack $regexPack
        $advancedMatched = (@($advanced.dictionaryMatches).Count -gt 0) -or (@($advanced.regexMatches).Count -gt 0)
        if ($advancedMatched) { $matched = $true }

        if (-not $matched) { continue }

        $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds }
        $fingerprint = "clipboard|$ruleId|$ClipboardHash|$env:USERNAME"
        if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue }

        $requestedAction = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
        $actionDecision = Resolve-DlpEffectiveAction -RequestedAction $requestedAction -Channel 'clipboard'
        $action = [string]$actionDecision.action
        $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
        $message = if ($rule.message) { [string]$rule.message } else { "Clipboard rule matched: $ruleId" }

        $enforced = $false
        if ($action -eq 'block') {
            $enforced = Invoke-ClipboardEnforcement
            Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message
        }

        Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{
            clipboardHash = $ClipboardHash
            clipboardLength = $ClipboardText.Length
            enforced = $enforced
            requestedAction = [string]$actionDecision.requestedAction
            enforcementMode = [string]$actionDecision.enforcementMode
            nativeChannelAction = [string]$actionDecision.nativeChannelAction
            enforcementSuppressed = [bool]$actionDecision.enforcementSuppressed
            dictionaryPack = $dictionaryPack
            regexPack = $regexPack
            dictionaryMatches = @($advanced.dictionaryMatches)
            regexMatches = @($advanced.regexMatches)
            ocrRequested = $ocrEnabled
        }
        Write-EndpointLog ("incident clipboard rule={0} requested={1} action={2} severity={3} enforced={4} suppressed={5}" -f $ruleId, $requestedAction, $action, $severity, $enforced, [bool]$actionDecision.enforcementSuppressed)
    }
}

function Evaluate-UsbRules {
    param(
        [string]$DriveLetter,
        [string]$VolumeName
    )

    foreach ($rule in @($script:Policy.endpoint.usb)) {
        if (-not $rule) { continue }
        if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue }
        $ruleId = [string]$rule.id
        if (-not $ruleId) { continue }

        $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds }
        $fingerprint = "usb|$ruleId|$DriveLetter|$env:USERNAME"
        if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue }

        $requestedAction = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
        $actionDecision = Resolve-DlpEffectiveAction -RequestedAction $requestedAction -Channel 'usb'
        $action = [string]$actionDecision.action
        $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
        $message = if ($rule.message) { [string]$rule.message } else { "USB rule matched: $ruleId" }

        $enforced = $false
        if ($action -eq 'block') {
            $enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter
            Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message
        }

        Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{
            driveLetter = $DriveLetter
            volumeName  = $VolumeName
            enforced = $enforced
            requestedAction = [string]$actionDecision.requestedAction
            enforcementMode = [string]$actionDecision.enforcementMode
            nativeChannelAction = [string]$actionDecision.nativeChannelAction
            enforcementSuppressed = [bool]$actionDecision.enforcementSuppressed
        }
        Write-EndpointLog ("incident usb rule={0} requested={1} action={2} severity={3} drive={4} enforced={5} suppressed={6}" -f $ruleId, $requestedAction, $action, $severity, $DriveLetter, $enforced, [bool]$actionDecision.enforcementSuppressed)
    }
}

function Evaluate-PrintRules {
    param(
        [string]$PrinterName,
        [string]$DocumentName,
        [string]$Owner
    )

    foreach ($rule in @($script:Policy.endpoint.print)) {
        if (-not $rule) { continue }
        if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue }
        $ruleId = [string]$rule.id
        if (-not $ruleId) { continue }

        $match = $true
        if ($rule.printerRegex) {
            $match = $match -and ($PrinterName -match [string]$rule.printerRegex)
        }
        if ($rule.documentRegex) {
            $match = $match -and ($DocumentName -match [string]$rule.documentRegex)
        }
        $dictionaryPack = if ($rule.dictionaryPack) { [string]$rule.dictionaryPack } elseif ($script:Policy.contentAnalysis.dictionaryPack) { [string]$script:Policy.contentAnalysis.dictionaryPack } else { $null }
        $regexPack = if ($rule.regexPack) { [string]$rule.regexPack } elseif ($script:Policy.contentAnalysis.regexPack) { [string]$script:Policy.contentAnalysis.regexPack } else { $null }
        $ocrEnabled = if ($rule.PSObject.Properties.Name -contains 'ocrEnabled') { [bool]$rule.ocrEnabled } else { [bool]$script:Policy.contentAnalysis.ocrEnabled }
        $advanced = Get-AdvancedContentMatches -Text $DocumentName -DictionaryPack $dictionaryPack -RegexPack $regexPack
        $advancedMatched = (@($advanced.dictionaryMatches).Count -gt 0) -or (@($advanced.regexMatches).Count -gt 0)
        if ($advancedMatched) { $match = $true }
        if (-not $match) { continue }

        $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds }
        $fingerprint = "print|$ruleId|$PrinterName|$Owner|$env:USERNAME"
        if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue }

        $requestedAction = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
        $actionDecision = Resolve-DlpEffectiveAction -RequestedAction $requestedAction -Channel 'print'
        $action = [string]$actionDecision.action
        $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
        $message = if ($rule.message) { [string]$rule.message } else { "Print rule matched: $ruleId" }

        $enforced = $false
        if ($action -eq 'block') {
            $enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner
            Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message
        }

        Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{
            printerName  = $PrinterName
            documentName = $DocumentName
            owner        = $Owner
            enforced = $enforced
            requestedAction = [string]$actionDecision.requestedAction
            enforcementMode = [string]$actionDecision.enforcementMode
            nativeChannelAction = [string]$actionDecision.nativeChannelAction
            enforcementSuppressed = [bool]$actionDecision.enforcementSuppressed
            dictionaryPack = $dictionaryPack
            regexPack = $regexPack
            dictionaryMatches = @($advanced.dictionaryMatches)
            regexMatches = @($advanced.regexMatches)
            ocrRequested = $ocrEnabled
        }
        Write-EndpointLog ("incident print rule={0} requested={1} action={2} severity={3} printer={4} enforced={5} suppressed={6}" -f $ruleId, $requestedAction, $action, $severity, $PrinterName, $enforced, [bool]$actionDecision.enforcementSuppressed)
    }
}

function Test-LooksLikeMojibakeQuestionMarks {
    param([AllowNull()][string]$Value)
    if ([string]::IsNullOrWhiteSpace($Value)) { return $true }
    return $Value -match '\?{2,}'
}

function Normalize-OwnerForMatch {
    param([AllowNull()][string]$Value)
    if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
    $normalized = $Value.Trim().ToLowerInvariant()
    if ($normalized -match '[\\/]') {
        $parts = $normalized -split '[\\/]'
        if ($parts.Count -gt 0) {
            $normalized = [string]$parts[$parts.Count - 1]
        }
    }
    if ($normalized -match '@') {
        $parts = $normalized -split '@'
        if ($parts.Count -gt 0) {
            $normalized = [string]$parts[0]
        }
    }
    return $normalized
}

function Test-OwnerLooseMatch {
    param(
        [string]$Expected,
        [string]$Actual
    )
    $expectedNorm = Normalize-OwnerForMatch -Value $Expected
    $actualNorm = Normalize-OwnerForMatch -Value $Actual
    if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) {
        return $false
    }
    return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm)
}

function Normalize-PrinterForMatch {
    param([AllowNull()][string]$Value)
    if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
    $normalized = $Value.Trim().ToLowerInvariant()
    if ($normalized.Contains(',')) {
        $normalized = ($normalized -split ',', 2)[0].Trim()
    }
    if ($normalized -match '\son\s') {
        $normalized = ($normalized -split '\son\s', 2)[0].Trim()
    }
    return $normalized
}

function Test-PrinterLooseMatch {
    param(
        [string]$Expected,
        [string]$Actual
    )
    $expectedNorm = Normalize-PrinterForMatch -Value $Expected
    $actualNorm = Normalize-PrinterForMatch -Value $Actual
    if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) {
        return $false
    }
    return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm)
}

function Get-PrintServiceEventSummary {
    param([Parameter(Mandatory = $true)]$Event)

    $props = @($Event.Properties)
    $propertyValues = @()
    foreach ($prop in $props) {
        $propertyValues += [string]$prop.Value
    }

    [pscustomobject]@{
        RecordId      = [string]$Event.RecordId
        TimeCreated   = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' }
        PropertyCount  = $props.Count
        DocumentName   = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' }
        Owner         = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' }
        PrinterName   = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' }
        PropertyValues = $propertyValues
    }
}

function Get-PrintServiceDocumentFallback {
    param(
        [Parameter(Mandatory = $true)]$EventSummary,
        [string]$Owner,
        [string]$PrinterName
    )

    $preferred = [string]$EventSummary.DocumentName
    if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') {
        return $preferred
    }

    $pathCandidates = New-Object System.Collections.Generic.List[string]
    $textCandidates = New-Object System.Collections.Generic.List[string]

    foreach ($value in @($EventSummary.PropertyValues)) {
        $candidate = [string]$value
        if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
        if ($candidate -eq $preferred) { continue }
        if ($Owner -and $candidate -like "*$Owner*") { continue }
        if ($PrinterName -and $candidate -like "*$PrinterName*") { continue }
        if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue }

        if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') {
            $pathCandidates.Add($candidate)
            continue
        }

        if ($candidate -match '^[0-9]+$') {
            continue
        }

        $textCandidates.Add($candidate)
    }

    foreach ($candidate in @($pathCandidates)) {
        $leaf = Split-Path -Path $candidate -Leaf
        if (-not [string]::IsNullOrWhiteSpace($leaf)) {
            return $leaf
        }
        return $candidate
    }

    foreach ($candidate in @($textCandidates)) {
        return $candidate
    }

    return $null
}

function Write-PrintServiceEventTrace {
    param(
        [Parameter(Mandatory = $true)]$EventSummary,
        [string]$Phase,
        [string]$MatchReason,
        [string]$ResolvedDocument
    )

    $properties = if ($EventSummary.PropertyValues) {
        ($EventSummary.PropertyValues -join ' | ')
    }
    else {
        ''
    }

    Write-EndpointLog (
        'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f
        $Phase,
        $EventSummary.RecordId,
        $EventSummary.TimeCreated,
        $EventSummary.Owner,
        $EventSummary.PrinterName,
        $EventSummary.DocumentName,
        $ResolvedDocument,
        $properties,
        $MatchReason
    )
}

function Get-BetterDocumentNameFromPrintServiceEvents {
    param(
        [string]$Owner,
        [string]$PrinterName
    )

    try {
        $startTime = (Get-Date).AddMinutes(-15)
        $events = Get-WinEvent -FilterHashtable @{
            LogName   = 'Microsoft-Windows-PrintService/Operational'
            Id        = 307
            StartTime = $startTime
        } -MaxEvents 200 -ErrorAction Stop

        foreach ($pass in @('strict', 'relaxed')) {
            foreach ($event in @($events)) {
                $summary = Get-PrintServiceEventSummary -Event $event
                $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName

                $ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true }
                $printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true }

                if ($pass -eq 'strict') {
                    if ($Owner -and -not $ownerMatches) {
                        Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument
                        continue
                    }
                    if ($PrinterName -and -not $printerMatches) {
                        Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument
                        continue
                    }
                }
                else {
                    if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
                        Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument
                        continue
                    }
                }

                if ([string]::IsNullOrWhiteSpace($resolvedDocument)) {
                    Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument ''
                    continue
                }

                $matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' }
                Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument
                return $resolvedDocument
            }
        }
    }
    catch {
    }

    return $null
}

$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' }
$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 }
$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' }
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' }
$resolvedStateRoot = if ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'stateRoot') { [string]$deploymentConfig.paths.stateRoot } else { Split-Path -Path $resolvedPolicyPath -Parent }
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' }
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) }
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' }
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
$resolvedHostname = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$deploymentConfig.awHostname)) { [string]$deploymentConfig.awHostname } else { [string]$env:COMPUTERNAME }
$resolvedPolicyMode = if ($PolicyMode) { [string]$PolicyMode } elseif ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'mode') { [string]$deploymentConfig.policyEngine.mode } else { 'local' }
$resolvedPolicyEngineEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'enabled') { [bool]$deploymentConfig.policyEngine.enabled } else { $false }
$resolvedPolicyEngineHost = if ($PolicyEngineHost) { [string]$PolicyEngineHost } elseif ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'host') { [string]$deploymentConfig.policyEngine.host } else { $resolvedServerHost }
$resolvedPolicyEnginePort = if ($PSBoundParameters.ContainsKey('PolicyEnginePort')) { $PolicyEnginePort } elseif ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'port') { [int]$deploymentConfig.policyEngine.port } else { $resolvedServerPort }
$resolvedPolicyEngineScheme = if ($PolicyEngineScheme) { [string]$PolicyEngineScheme } elseif ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'scheme') { [string]$deploymentConfig.policyEngine.scheme } else { $resolvedServerScheme }
$resolvedPolicyRefreshSeconds = if ($PSBoundParameters.ContainsKey('PolicyRefreshSeconds')) { $PolicyRefreshSeconds } elseif ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'refreshSeconds') { [int]$deploymentConfig.policyEngine.refreshSeconds } else { 300 }
$resolvedPolicyCachePath = if ($PolicyCachePath) { [string]$PolicyCachePath } elseif ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'cachePath') { [string]$deploymentConfig.policyEngine.cachePath } else { Join-Path $resolvedStateRoot 'dlp-policy-cache.json' }

if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
    New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null
}

$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort
$script:PolicyApiBase = '{0}://{1}:{2}/api/0' -f $resolvedPolicyEngineScheme, $resolvedPolicyEngineHost, $resolvedPolicyEnginePort
$script:Hostname = $resolvedHostname
$script:SessionId = (Get-Process -Id $PID).SessionId
$script:KnownBuckets = @{}
$script:Cooldown = @{}
$script:SeenUsb = @{}
$script:SeenPrintJob = @{}
$script:SeenPrintEvent = @{}
$script:LastClipboardHash = $null
$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30)
$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60)
$script:LastSelfTestAt = [datetime]::MinValue
$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled
$script:LogPath = $resolvedLogPath
$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot
$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled
$script:ScreenshotTypesLoaded = $false
$script:PolicyMode = $resolvedPolicyMode
$script:PolicyEngineEnabled = $resolvedPolicyEngineEnabled
$script:PolicyRefreshSeconds = [Math]::Max($resolvedPolicyRefreshSeconds, 60)
$script:PolicyCachePath = $resolvedPolicyCachePath
$script:LocalPolicyPath = $resolvedPolicyPath
$script:LastPolicyRefreshAt = [datetime]::MinValue
$script:PolicyAgentId = $resolvedHostname
$script:TransportBackoffSeconds = 1
# Integration test flag (backward compatible - defaults to false)
$script:IntegrationTestEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'integrationTestEnabled') { [bool]$deploymentConfig.integrationTestEnabled } else { $false }

# Integration metadata tracking (backward compatible)
$script:TotalEventsProcessed = 0
$script:LastEventTime = $null

Initialize-TransportQueue -StateRoot $resolvedStateRoot
Initialize-DlpPolicy
if ($SelfTestSuppressedBlock) {
    Invoke-SuppressedBlockSelfTest
}
Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase)

while ($true) {
    try {
        try {
            Flush-TransportQueue -MaxItems 200
            $script:TransportBackoffSeconds = 1
        }
        catch {
            $script:TransportBackoffSeconds = [Math]::Min($script:TransportBackoffSeconds * 2, 60)
            Write-EndpointLog ("transport flush failed, backoff={0}s err={1}" -f $script:TransportBackoffSeconds, $_.Exception.Message)
        }

        if ($script:PolicyMode -eq 'server') {
            $policyAge = ((Get-Date).ToUniversalTime() - $script:LastPolicyRefreshAt).TotalSeconds
            if ($policyAge -ge $script:PolicyRefreshSeconds) {
                [void](Refresh-DlpPolicyFromServer)
            }
            else {
                [void](Sync-DlpPolicyDesiredState)
            }
        }

        $nowUtc = (Get-Date).ToUniversalTime()
        if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) {
            Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{
                collector = 'dlp-endpoint-signals'
                policyEnabled = [bool]$script:Policy.defaults.enabled
                policyMode = $script:PolicyMode
                policySource = $script:PolicySource
                policyVersion = $script:PolicyVersion
                policyChecksum = $script:PolicyChecksum
                queueDepth = [int]$script:TransportMetrics.queueDepth
                eventsEnqueued = [int]$script:TransportMetrics.eventsEnqueued
                eventsFlushed = [int]$script:TransportMetrics.eventsFlushed
                sendFailures = [int]$script:TransportMetrics.sendFailures
            }
            $script:LastSelfTestAt = $nowUtc
        }

        if (-not $script:Policy.defaults.enabled) {
            Start-Sleep -Seconds $resolvedPollSeconds
            continue
        }

        try {
            $clipboardText = Get-ClipboardTextSafe
            if ($clipboardText) {
                $clipboardHash = Get-StringHash -Value $clipboardText
                if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) {
                    $script:LastClipboardHash = $clipboardHash
                    Send-EndpointSignalHeartbeat -SignalType 'clipboard_change' -Data @{
                        clipboardHash = $clipboardHash
                        clipboardLength = $clipboardText.Length
                    }
                    $script:TotalEventsProcessed++
                    $script:LastEventTime = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
                    Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash
                }
            }
        }
        catch {
        }

        try {
            $usbDrives = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=2" -ErrorAction SilentlyContinue
            $currentUsb = @{}
            foreach ($drive in @($usbDrives)) {
                $deviceId = [string]$drive.DeviceID
                if (-not $deviceId) { continue }
                $currentUsb[$deviceId] = $true
                if (-not $script:SeenUsb.ContainsKey($deviceId)) {
                    $script:SeenUsb[$deviceId] = (Get-Date).ToUniversalTime()
                    $volumeName = [string]$drive.VolumeName
                    Send-EndpointSignalHeartbeat -SignalType 'usb_insert' -Data @{
                        driveLetter = $deviceId
                        volumeName  = $volumeName
                    }
                    $script:TotalEventsProcessed++
                    $script:LastEventTime = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
                    Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName
                }
            }

            foreach ($known in @($script:SeenUsb.Keys)) {
                if (-not $currentUsb.ContainsKey($known)) {
                    $script:SeenUsb.Remove($known)
                }
            }
        }
        catch {
        }

        try {
            $printJobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue
            foreach ($job in @($printJobs)) {
                $jobId = [string]$job.JobId
                if (-not $jobId) { continue }
                if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
                $script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()

                $printerName = [string]$job.Name
                $documentName = [string]$job.Document
                $owner = [string]$job.Owner
                $documentNameOriginal = $documentName

                if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) {
                    $eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
                    if ($eventDocumentName) {
                        $documentName = $eventDocumentName
                    }
                }

                Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{
                    printerName  = $printerName
                    documentName = $documentName
                    documentNameOriginal = $documentNameOriginal
                    owner        = $owner
                }
                $script:TotalEventsProcessed++
                $script:LastEventTime = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
                Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner
            }

            $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8)
            foreach ($k in @($script:SeenPrintJob.Keys)) {
                $ts = [datetime]$script:SeenPrintJob[$k]
                if ($ts -lt $cleanupBefore) {
                    $script:SeenPrintJob.Remove($k)
                }
            }
        }
        catch {
        }

        try {
            $printEvents = Get-WinEvent -FilterHashtable @{
                LogName = 'Microsoft-Windows-PrintService/Operational'
                Id = 307
                StartTime = (Get-Date).AddMinutes(-20)
            } -MaxEvents 200 -ErrorAction SilentlyContinue

            foreach ($event in @($printEvents)) {
                $recordId = [string]$event.RecordId
                if (-not $recordId) { continue }
                if ($script:SeenPrintEvent.ContainsKey($recordId)) { continue }
                $script:SeenPrintEvent[$recordId] = (Get-Date).ToUniversalTime()

                $summary = Get-PrintServiceEventSummary -Event $event
                $documentName = [string]$summary.DocumentName
                $owner = [string]$summary.Owner
                $printerName = [string]$summary.PrinterName
                $resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $owner -PrinterName $printerName

                Write-PrintServiceEventTrace -EventSummary $summary -Phase 'emit' -MatchReason 'raw-scan' -ResolvedDocument $resolvedDocument

                if (-not [string]::IsNullOrWhiteSpace($owner) -and $owner -notlike "*$env:USERNAME*") {
                    continue
                }

                Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{
                    printerName  = $printerName
                    documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName }
                    documentNameOriginal = $documentName
                    owner        = $owner
                    eventRecordId = $recordId
                    eventSource = 'printservice-307'
                }
                $script:TotalEventsProcessed++
                $script:LastEventTime = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
                Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner
            }

            $cleanupBeforeEvent = (Get-Date).ToUniversalTime().AddHours(-8)
            foreach ($k in @($script:SeenPrintEvent.Keys)) {
                $ts = [datetime]$script:SeenPrintEvent[$k]
                if ($ts -lt $cleanupBeforeEvent) {
                    $script:SeenPrintEvent.Remove($k)
                }
            }
        }
        catch {
        }
    }
    catch {
        Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message)
    }

    # Integration metadata self-test (backward compatible)
    if ($script:IntegrationTestEnabled -and (Get-Date).Minute -eq 0) {
        try {
            $testMetadata = @{
                timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
                collector = 'dlp-endpoint-signals'
                version = '1.0.0'
                hostname = $env:COMPUTERNAME
                username = $env:USERNAME
                status = 'healthy'
                checks = @{
                    eventsProcessed = $script:TotalEventsProcessed
                    lastEventTime = $script:LastEventTime
                    iocRulesLoaded = if ($script:IocRules) { @($script:IocRules).Count } else { 0 }
                    policyRulesLoaded = if ($script:Policy -and $script:Policy.endpoint) { (@($script:Policy.endpoint.clipboard).Count + @($script:Policy.endpoint.usb).Count + @($script:Policy.endpoint.print).Count) } else { 0 }
                }
            }
            Send-EndpointSignalHeartbeat -SignalType 'integration_test' -Data $testMetadata
            Write-EndpointLog "Integration metadata test sent"
        }
        catch {
            Write-EndpointLog "Integration test failed: $($_.Exception.Message)"
        }
    }

    if ($script:TransportBackoffSeconds -gt $resolvedPollSeconds) {
        Start-Sleep -Seconds $script:TransportBackoffSeconds
    }
    else {
        Start-Sleep -Seconds $resolvedPollSeconds
    }
}
