param(
    [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
    [string]$Hostname,
    [int]$PollSeconds = 0
)

# Force UTF-8 for console I/O
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}
try { [Console]::InputEncoding  = [System.Text.Encoding]::UTF8 } catch {}
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Continue'

function Decode-Bytes-Auto {
    param([byte[]]$Bytes)
    if (-not $Bytes) { return '' }

    $candidates = @()

    # Try strict UTF8 first (detect invalid sequences)
    try {
        $utf8Strict = New-Object System.Text.UTF8Encoding($false,$true)
        $txt = $utf8Strict.GetString($Bytes)
        $candidates += @{enc='utf8'; text=$txt}
    }
    catch {
        # invalid UTF8 sequences; ignore
    }

    # Try CP866 and CP1251
    try { $cp866 = [System.Text.Encoding]::GetEncoding(866); $txt866 = $cp866.GetString($Bytes); $candidates += @{enc='cp866'; text=$txt866} } catch {}
    try { $cp1251 = [System.Text.Encoding]::GetEncoding(1251); $txt1251 = $cp1251.GetString($Bytes); $candidates += @{enc='cp1251'; text=$txt1251} } catch {}

    # If nothing decoded yet, fallback to UTF8 permissive
    if ($candidates.Count -eq 0) {
        try { $txt = [System.Text.Encoding]::UTF8.GetString($Bytes); return $txt } catch { return '' }
    }

    # Score decodings by count of Cyrillic letters; prefer highest
    $best = $null; $bestScore = -1
    foreach ($c in $candidates) {
        $t = $c.text
        if (-not $t) { continue }
        $score = 0
        try { $score = ([regex]::Matches($t,'\p{IsCyrillic}')).Count } catch { $score = 0 }
        if ($score -gt $bestScore) { $best = $c; $bestScore = $score }
    }

    if ($best -ne $null) { return $best.text }

    # Final fallback: first candidate text
    return $candidates[0].text
}

function Get-Config {
    param([string]$Path)
    if (-not (Test-Path -LiteralPath $Path)) {
        throw "Config not found: $Path"
    }
    try {
        $bytes = [System.IO.File]::ReadAllBytes($Path)
        # Config is JSON. Prefer deterministic BOM-based decoding over heuristics.
        if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
            $text = [System.Text.Encoding]::UTF8.GetString($bytes)
        } elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
            $text = [System.Text.Encoding]::Unicode.GetString($bytes)
        } else {
            $text = [System.Text.Encoding]::UTF8.GetString($bytes)
        }
        $text = $text -replace '^\uFEFF', ''
        return $text | ConvertFrom-Json -ErrorAction Stop
    }
    catch {
        throw "Failed to read config: $Path - $($_.Exception.Message)"
    }
}

function Invoke-AwJsonPost {
    param(
        [Parameter(Mandatory = $true)][string]$Uri,
        [Parameter(Mandatory = $true)][string]$Json
    )
    try {
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($Json)
        Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -ErrorAction Stop | Out-Null
        return $true
    }
    catch {
        Write-Verbose "POST error: $($_.Exception.Message)"
        return $false
    }
}

function Ensure-Bucket {
    param(
        [Parameter(Mandatory = $true)][string]$ApiBase,
        [Parameter(Mandatory = $true)][string]$BucketId,
        [Parameter(Mandatory = $true)][string]$HostnameValue,
        [string]$ClientName = 'aw-worktime-session-collector',
        [string]$BucketType = 'aw.worktime.session'
    )
    try { Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" -ErrorAction Stop | Out-Null; return } catch { Write-Verbose "Bucket not found, creating: $BucketId" }

    $body = @{ client=$ClientName; type=$BucketType; hostname=$HostnameValue } | ConvertTo-Json -Compress
    $attempts = 0
    while ($attempts -lt 3) {
        $attempts++
        $ok = Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
        if ($ok) { return }
        Start-Sleep -Seconds (2 * $attempts)
    }
    try { Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" -ErrorAction Stop | Out-Null } catch { Write-Verbose "Ensure-Bucket final check failed: $BucketId" }
}

function Run-QueryUser {
    $tries = @(
        @{File='cmd.exe';Args='/c query user'},
        @{File='cmd.exe';Args='/c quser'},
        @{File='query.exe';Args='user'},
        @{File='quser.exe';Args=''}
    )
    foreach ($t in $tries) {
        try {
            $psi = New-Object System.Diagnostics.ProcessStartInfo
            $psi.FileName = $t.File
            if ($t.Args) { $psi.Arguments = $t.Args }
            $psi.RedirectStandardOutput = $true
            $psi.RedirectStandardError  = $true
            $psi.UseShellExecute = $false
            $psi.CreateNoWindow = $true

            $proc = [System.Diagnostics.Process]::Start($psi)
            $stream = $proc.StandardOutput.BaseStream
            $ms = New-Object System.IO.MemoryStream
            $buffer = New-Object byte[] 4096
            while (($read = $stream.Read($buffer,0,$buffer.Length)) -gt 0) { $ms.Write($buffer,0,$read) }
            if (-not $proc.WaitForExit(8000)) {
                try { $proc.Kill() } catch {}
                continue
            }
            $bytes = $ms.ToArray()

            $text = Decode-Bytes-Auto -Bytes $bytes
            if ($text -and $text.Trim()) { return ($text -split "\r?\n") | Where-Object { $_ -ne '' } }
        }
        catch {
            # try next
        }
    }
    return @()
}

function Parse-SessionLines {
    param([string[]]$Lines)
    $records = @()
    if (-not $Lines) { return $records }

    $startIndex = 0
    # NOTE: Keep this script ASCII-only to stay compatible with Windows PowerShell 5
    # when the file is UTF-8 without BOM. Avoid Cyrillic literals in regex patterns.
    if ($Lines.Count -gt 0 -and $Lines[0] -match '\b(USERNAME|UserName|USER)\b') { $startIndex = 1 }

    for ($i = $startIndex; $i -lt $Lines.Count; $i++) {
        $line = ($Lines[$i] -replace '^\s*>', '').Trim()
        if (-not $line) { continue }

        $parts = $line -split '\s+'
        if ($parts.Count -lt 3) { continue }
        $user = $parts[0]
        $sess = ''
        $id = -1
        $state = ''

        if ($parts.Count -ge 4 -and $parts[1] -match '^\d+$') {
            $sess = ''
            $id = [int]$parts[1]
            $state = [string]$parts[2]
        }
        elseif ($parts.Count -ge 4 -and $parts[2] -match '^\d+$') {
            $sess = [string]$parts[1]
            $id = [int]$parts[2]
            $state = [string]$parts[3]
        }
        else {
            continue
        }

        if ($id -lt 0) { continue }

        $records += [pscustomobject]@{ username=$user; sessionName=$sess; sessionId=$id; state=$state }
    }
    return $records
}

function Test-SessionIsActive {
    param([string]$State)
    if (-not $State) { return $false }
    $s = $State.Trim().ToLowerInvariant()
    # Match English "active" and Russian "актив*" without embedding Cyrillic.
    # "актив" = \u0430\u043A\u0442\u0438\u0432
    return ($s -match 'active') -or ($s -match '\u0430\u043a\u0442\u0438\u0432')
}

function Get-CanonicalUserId {
    param(
        [pscustomobject]$Config,
        [string]$HostnameValue,
        [string]$Username
    )

    $normalizedUser = [string]$Username
    if ([string]::IsNullOrWhiteSpace($normalizedUser)) {
        return ''
    }

    if ($Config -and $Config.PSObject.Properties.Name -contains 'userTasks' -and $Config.userTasks) {
        foreach ($task in @($Config.userTasks)) {
            try {
                $taskUserId = [string]$task.userId
                if ([string]::IsNullOrWhiteSpace($taskUserId)) {
                    continue
                }
                $parts = $taskUserId -split '\\', 2
                if ($parts.Count -eq 2 -and $parts[1].Equals($normalizedUser, [System.StringComparison]::OrdinalIgnoreCase)) {
                    return $taskUserId
                }
            }
            catch {
            }
        }
    }

    return "$HostnameValue\$normalizedUser"
}

function Get-SessionEventsBucketId {
    param(
        [pscustomobject]$Config,
        [string]$HostnameValue
    )
    $prefix = 'aw-session-events'
    if (
        $Config -and
        $Config.PSObject.Properties.Name -contains 'sessionEvents' -and
        $Config.sessionEvents -and
        $Config.sessionEvents.PSObject.Properties.Name -contains 'bucketPrefix' -and
        -not [string]::IsNullOrWhiteSpace([string]$Config.sessionEvents.bucketPrefix)
    ) {
        $prefix = [string]$Config.sessionEvents.bucketPrefix
    }
    return ('{0}_{1}' -f $prefix, $HostnameValue)
}

function Test-SessionProcessEventsEnabled {
    param([pscustomobject]$Config)
    if (
        $Config -and
        $Config.PSObject.Properties.Name -contains 'sessionEvents' -and
        $Config.sessionEvents -and
        $Config.sessionEvents.PSObject.Properties.Name -contains 'processEventsEnabled'
    ) {
        return [bool]$Config.sessionEvents.processEventsEnabled
    }
    return $true
}

function Get-ProcessStatePath {
    param([pscustomobject]$Config)
    $stateRoot = ''
    if ($Config -and $Config.PSObject.Properties.Name -contains 'paths' -and $Config.paths) {
        if ($Config.paths.PSObject.Properties.Name -contains 'stateRoot') {
            $stateRoot = [string]$Config.paths.stateRoot
        }
    }
    if ([string]::IsNullOrWhiteSpace($stateRoot)) {
        $stateRoot = 'C:\ProgramData\AWatch-rus'
    }
    return (Join-Path $stateRoot 'session-process-state.json')
}

function Load-ProcessState {
    param([string]$Path)
    $map = @{}
    try {
        if (Test-Path -LiteralPath $Path) {
            $raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
            if (-not [string]::IsNullOrWhiteSpace($raw)) {
                $obj = $raw | ConvertFrom-Json -ErrorAction Stop
                foreach ($item in @($obj.processes)) {
                    if (-not $item) { continue }
                    $key = [string]$item.key
                    if ([string]::IsNullOrWhiteSpace($key)) { continue }
                    $map[$key] = $item
                }
            }
        }
    }
    catch {
        Write-Verbose "Process state load error: $($_.Exception.Message)"
    }
    return $map
}

function Save-ProcessState {
    param(
        [string]$Path,
        [hashtable]$Map
    )
    try {
        $dir = Split-Path -Path $Path -Parent
        if ($dir -and -not (Test-Path -LiteralPath $dir)) {
            New-Item -Path $dir -ItemType Directory -Force | Out-Null
        }
        $items = @()
        foreach ($entry in $Map.GetEnumerator()) {
            $value = $entry.Value
            if ($null -eq $value) { continue }
            $items += [pscustomobject]@{
                key         = [string]$entry.Key
                processId   = [int]$value.processId
                sessionId   = [int]$value.sessionId
                username    = [string]$value.username
                userId      = [string]$value.userId
                state       = [string]$value.state
                processName = [string]$value.processName
                commandLine = [string]$value.commandLine
                createdAt   = [string]$value.createdAt
                hostname    = [string]$value.hostname
            }
        }
        $payload = [pscustomobject]@{ processes = $items }
        $payload | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $Path -Encoding UTF8
    }
    catch {
        Write-Verbose "Process state save error: $($_.Exception.Message)"
    }
}

function Test-ExcludedSessionProcess {
    param(
        [string]$Name,
        [string]$CommandLine
    )
    $n = [string]$Name
    if ([string]::IsNullOrWhiteSpace($n)) { return $true }
    if ($n -match '^(Idle|System|Registry|svchost|services|lsass|winlogon|csrss|fontdrvhost|dwm|taskhostw|sihost|explorer)\.exe$') { return $true }
    if ($n -match '^(aw-watcher-afk|aw-watcher-window|conhost)\.exe$') { return $true }
    return $false
}

function Get-SessionProcessSnapshot {
    param(
        [pscustomobject]$Config,
        [string]$HostnameValue,
        [object[]]$SessionRecords
    )
    $bySession = @{}
    foreach ($rec in @($SessionRecords)) {
        if ($null -eq $rec) { continue }
        $sid = [int]$rec.sessionId
        $bySession[$sid] = [pscustomobject]@{
            username = [string]$rec.username
            userId   = Get-CanonicalUserId -Config $Config -HostnameValue $HostnameValue -Username ([string]$rec.username)
            state    = [string]$rec.state
        }
    }

    $snapshot = @{}
    if ($bySession.Count -eq 0) {
        return $snapshot
    }

    try {
        $procs = Get-Process -ErrorAction Stop | Where-Object { $bySession.ContainsKey([int]$_.SessionId) }
    }
    catch {
        Write-Verbose "Process snapshot error: $($_.Exception.Message)"
        return $snapshot
    }

    foreach ($proc in @($procs)) {
        try {
            $sid = [int]$proc.SessionId
        }
        catch {
            continue
        }
        if (-not $bySession.ContainsKey($sid)) { continue }

        $name = [string]$proc.ProcessName
        if ($name -and $name -notmatch '\.exe$') {
            $name = "$name.exe"
        }
        $commandLine = ''
        if (Test-ExcludedSessionProcess -Name $name -CommandLine $commandLine) { continue }

        $createdAt = ''
        try {
            if ($proc.StartTime) {
                $createdAt = $proc.StartTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
            }
        }
        catch {
            $createdAt = ''
        }
        if ([string]::IsNullOrWhiteSpace($createdAt)) {
            $createdAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
        }

        $key = ('{0}|{1}|{2}' -f $sid, [int]$proc.Id, $createdAt)
        $sessionMeta = $bySession[$sid]
        $snapshot[$key] = [pscustomobject]@{
            processId   = [int]$proc.Id
            sessionId   = $sid
            username    = [string]$sessionMeta.username
            userId      = [string]$sessionMeta.userId
            state       = [string]$sessionMeta.state
            processName = $name
            commandLine = $commandLine
            createdAt   = $createdAt
            hostname    = $HostnameValue
        }
    }
    return $snapshot
}

function Publish-SessionProcessEvents {
    param(
        [string]$ApiBase,
        [string]$BucketId,
        [hashtable]$Previous,
        [hashtable]$Current
    )
    foreach ($entry in $Current.GetEnumerator()) {
        if ($Previous.ContainsKey($entry.Key)) { continue }
        $item = $entry.Value
        $payload = [pscustomobject]@{
            timestamp = [string]$item.createdAt
            duration  = 0
            data      = [pscustomobject]@{
                eventType   = 'process_start'
                username    = [string]$item.username
                userId      = [string]$item.userId
                sessionId   = [int]$item.sessionId
                state       = [string]$item.state
                processId   = [int]$item.processId
                processName = [string]$item.processName
                commandLine = [string]$item.commandLine
                createdAt   = [string]$item.createdAt
                hostname    = [string]$item.hostname
                source      = 'worktime-session-collector'
            }
        } | ConvertTo-Json -Depth 6 -Compress
        try {
            [void](Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId/heartbeat?pulsetime=1" -Json $payload)
        }
        catch {
            Write-Verbose "Process start publish error: $($_.Exception.Message)"
        }
    }

    $nowUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
    foreach ($entry in $Previous.GetEnumerator()) {
        if ($Current.ContainsKey($entry.Key)) { continue }
        $item = $entry.Value
        $payload = [pscustomobject]@{
            timestamp = $nowUtc
            duration  = 0
            data      = [pscustomobject]@{
                eventType   = 'process_stop'
                username    = [string]$item.username
                userId      = [string]$item.userId
                sessionId   = [int]$item.sessionId
                state       = [string]$item.state
                processId   = [int]$item.processId
                processName = [string]$item.processName
                commandLine = [string]$item.commandLine
                createdAt   = [string]$item.createdAt
                hostname    = [string]$item.hostname
                source      = 'worktime-session-collector'
            }
        } | ConvertTo-Json -Depth 6 -Compress
        try {
            [void](Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId/heartbeat?pulsetime=1" -Json $payload)
        }
        catch {
            Write-Verbose "Process stop publish error: $($_.Exception.Message)"
        }
    }
}

# Main
$cfg = Get-Config -Path $ConfigPath
$hostValue = if ($Hostname -and $Hostname.Trim()) { $Hostname.Trim() } elseif ($cfg -and $cfg.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$cfg.awHostname)) { [string]$cfg.awHostname } elseif ($cfg -and $cfg.awHostname) { [string]$cfg.awHostname } else { [string]$env:COMPUTERNAME }
try { $apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port } catch { throw 'Invalid server configuration in config file.' }

$bucketId = 'aw-worktime-sessions_' + $hostValue
$sessionEventsBucketId = Get-SessionEventsBucketId -Config $cfg -HostnameValue $hostValue
$processEventsEnabled = Test-SessionProcessEventsEnabled -Config $cfg
$processStatePath = Get-ProcessStatePath -Config $cfg
$sleepSec = if ($PollSeconds -gt 0) { $PollSeconds } elseif ($cfg.collector -and $cfg.collector.pollSeconds) { [int]$cfg.collector.pollSeconds } else { 30 }
$pulse = [Math]::Max($sleepSec * 3, 30)

Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue
if ($processEventsEnabled) {
    Ensure-Bucket -ApiBase $apiBase -BucketId $sessionEventsBucketId -HostnameValue $hostValue -ClientName 'aw-session-events' -BucketType 'aw.session.event'
    $previousProcessState = Load-ProcessState -Path $processStatePath
}
else {
    $previousProcessState = @{}
}

while ($true) {
    $now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
    try {
        $lines = Run-QueryUser
        $records = Parse-SessionLines -Lines $lines
    }
    catch {
        Write-Verbose "Session parse error: $($_.Exception.Message)"
        $records = @()
    }

    if (-not $records -or $records.Count -eq 0) {
        # Fallback sample: keep bucket alive even when query user output is unavailable
        # in non-interactive/session-0 contexts.
        $records = @(
            [pscustomobject]@{
                username    = [string]$env:USERNAME
                sessionName = ''
                sessionId   = [int](Get-Process -Id $PID).SessionId
                state       = 'Unknown'
            }
        )
    }

    if ($processEventsEnabled) {
        $currentProcessState = Get-SessionProcessSnapshot -Config $cfg -HostnameValue $hostValue -SessionRecords $records
        Publish-SessionProcessEvents -ApiBase $apiBase -BucketId $sessionEventsBucketId -Previous $previousProcessState -Current $currentProcessState
        Save-ProcessState -Path $processStatePath -Map $currentProcessState
        $previousProcessState = $currentProcessState
    }

    foreach ($rec in $records) {
        $canonicalUserId = Get-CanonicalUserId -Config $cfg -HostnameValue $hostValue -Username ([string]$rec.username)
        $payloadObj = [PSCustomObject]@{
            timestamp = $now
            duration  = $sleepSec
            data      = [PSCustomObject]@{
                username    = [string]$rec.username
                userId      = $canonicalUserId
                sessionId   = [int]$rec.sessionId
                sessionName = [string]$rec.sessionName
                state       = [string]$rec.state
                active      = Test-SessionIsActive -State ([string]$rec.state)
                sampleSeconds = $sleepSec
                pollSeconds   = $sleepSec
                hostname    = $hostValue
                source      = 'worktime-session-collector'
            }
        }

        $payload = $payloadObj | ConvertTo-Json -Depth 6 -Compress

        try {
            $ok = Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload
            if (-not $ok) { Write-Verbose "Heartbeat not confirmed for user $($rec.username)" }
        }
        catch {
            Write-Verbose "Heartbeat error: $($_.Exception.Message)"
        }
    }

    Start-Sleep -Seconds $sleepSec
}
