#Requires -Version 5.1
<#
.SYNOPSIS
    H24 signal-write (M3, spec 2026-07-24-S149-hoi-tu-bookend + FIX-1). Writes the
    lead-self-audit tally to .claude/governance/.session-counter.json RIGHT AFTER the
    monitor pair (lead-stale-auditor + lead-gap-auditor) returns, so an audit that ran
    does not read "as if never run" (root-cause of the S146->S148 dropped-tally chain).

.DESCRIPTION
    Mechanises the tally step that was previously done by hand (hand-copy dropped half of
    the view-* classes at S148, losing 2 FLAGs). Given the classes of the FLAGs that just
    fired, it:
      (1) validates EVERY class against the CLOSED enum read LIVE from
          memory-budget.json -> lead_self_audit.flag_classes. An unknown class => exit != 0
          (a monitor may NOT invent a class; report verbatim + escalate to owner). NEVER
          hardcodes the enum.
      (2) PROCEDURAL max-1-decision/class/logic-session: reads the persisted per-session
          ledger (last_audit.signal_*); a class already decided this session is a NO-OP
          (one decision/class/session; the two bookend audits @start+@end collapse to one).
      (3) FIRE: counts[class] += 1 (deduped within a call). RESET (consecutive semantics):
          an enum class that did NOT fire this call AND has >= 1 in counts resets to 0 ONLY
          when (a) there is NO _frozen_until_owner key beside the map, and (b) no reset has
          happened yet this session. A present freeze key BLOCKS the reset and prints one
          line, leaving the owner-owned hung streak intact.
      (4) updates last_audit.{light|deep}_at_counter to the current counter.
      (5) writes ATOMICALLY (temp + Move-Item -Force) so a crash mid-write cannot wipe the
          owner-evidence file.
      (6) invariant: the counts map holds ONLY numbers (any note lives OUTSIDE the map;
          commit 2f39a7e removed a string that had been wedged into counts).

    Pure-ASCII source (gotcha #30: .ps1 stays ASCII-only).

.PARAMETER Session
    Logic-session label, e.g. S149. Scopes the per-session decision ledger.

.PARAMETER Flags
    Comma-separated list of the flag CLASSES that just fired (may repeat, may be empty).
    Example: 'gap-owner-specifics,gap-owner-specifics,view-stale-status'. Empty = a clean
    audit that fired nothing.

.PARAMETER AuditKind
    'light' or 'deep' - selects which last_audit.*_at_counter to stamp.

.PARAMETER RepoRoot
    Repo root; both config paths derive from it. A temp tree here isolates fault-injection
    (mirror of governance-detectors.ps1 -RepoRoot convention).

.OUTPUTS
    Exit 0 = written. Exit 2 = a class was outside the closed enum. Exit 3 = config/state
    error (missing enum, unreadable/unparseable counter, invariant violation).
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [ValidatePattern('^S\d+')]
    [string]$Session,

    [Parameter(Mandatory = $true)]
    [AllowEmptyString()]
    [string]$Flags,

    [Parameter(Mandatory = $true)]
    [ValidateSet('light', 'deep')]
    [string]$AuditKind,

    # SO THU HAI (S172, owner slot 70). Class CO CA trong vong nay nhung KHONG thanh FLAG
    # - dien hinh: lead TU KHAI truoc khi con-do soi, nen con-do xep vao §0-bis NGOAI TOTAL.
    # Chung KHONG tang tally (khong phai FLAG moi) nhung CHAN RESET (ca co that = chuoi chua dut).
    [AllowEmptyString()]
    [string]$CoCa,

    [string]$RepoRoot
)

$ErrorActionPreference = 'Stop'

# Default RepoRoot in the BODY, not the param block: $PSScriptRoot is EMPTY while param
# defaults evaluate under 'powershell.exe -File' (first-live failure S150; fault-inject K3
# passed only because every test passed -RepoRoot explicitly - happy-path did not cover the
# default path). Mirrors session-counter-tick.ps1, which computes its default in the body.
if (-not $RepoRoot) { $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path }

# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------

# UTF-8 byte read + BOM strip. Mirrors governance-detectors.ps1:1424 - dodges bug E-010
# (ANSI-decode of UTF-8) AND the leading-BOM "Invalid JSON primitive" that a plain
# UTF8.GetString would leave in front of the JSON.
function Read-JsonFile {
    param([string]$Path)
    $raw = [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($Path))
    $raw = $raw.TrimStart([char]0xFEFF)
    return ($raw | ConvertFrom-Json)
}

# Set a property whether or not it already exists (Add-Member only adds new props).
function Set-Prop {
    param($Obj, [string]$Name, $Value)
    if ($Obj.PSObject.Properties.Name -contains $Name) {
        $Obj.$Name = $Value
    }
    else {
        Add-Member -InputObject $Obj -MemberType NoteProperty -Name $Name -Value $Value
    }
}

# Read a counts entry as int; a missing key = 0.
function Get-Count {
    param($Counts, [string]$Key)
    if ($Counts.PSObject.Properties.Name -contains $Key) { return [int]$Counts.$Key }
    return 0
}

# Integer-like guard for the counts invariant. Rejects strings (the actual 2f39a7e bug),
# booleans, doubles and nested objects; accepts integral numeric types.
function Test-IntLike {
    param($Value)
    if ($Value -is [string]) { return $false }
    if ($Value -is [bool]) { return $false }
    if ($Value -is [int] -or $Value -is [long] -or $Value -is [int16] -or $Value -is [byte]) { return $true }
    return $false
}

# ---------------------------------------------------------------------------
# resolve paths
# ---------------------------------------------------------------------------
$budgetPath = Join-Path $RepoRoot '.claude\agent-memory\memory-budget.json'
$counterPath = Join-Path $RepoRoot '.claude\governance\.session-counter.json'

# ---------------------------------------------------------------------------
# (1) read the LIVE closed enum (never hardcode) + validate every fired class
# ---------------------------------------------------------------------------
if (-not (Test-Path -LiteralPath $budgetPath)) {
    Write-Host "FAIL: budget not found: $budgetPath (cannot read the closed enum)"
    exit 3
}
try { $budget = Read-JsonFile $budgetPath } catch { $budget = $null }
if ($null -eq $budget -or $null -eq $budget.lead_self_audit -or $null -eq $budget.lead_self_audit.flag_classes) {
    Write-Host "FAIL: memory-budget.json is missing lead_self_audit.flag_classes (the CLOSED enum)."
    Write-Host "      Refusing to invent a default - a hardcoded enum would silently re-create the drift the single-source exists to prevent."
    exit 3
}
$enum = @($budget.lead_self_audit.flag_classes | ForEach-Object { [string]$_ })

# parse -Flags (comma list; may repeat; may be empty)
$firedRaw = @()
if (-not [string]::IsNullOrWhiteSpace($Flags)) {
    $firedRaw = @($Flags -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
}

$bad = @($firedRaw | Where-Object { $enum -notcontains $_ } | Select-Object -Unique)
if ($bad.Count -gt 0) {
    foreach ($b in $bad) {
        Write-Host ("FAIL: class '{0}' is NOT in the closed enum lead_self_audit.flag_classes." -f $b)
    }
    Write-Host ("      A monitor may NOT invent a class - report it verbatim and escalate to the owner to extend the list.")
    Write-Host ("      Closed enum ({0}): {1}" -f $enum.Count, ($enum -join ', '))
    exit 2
}

$firedUnique = @($firedRaw | Select-Object -Unique)

# parse -CoCa (SO THU HAI, S172 slot 70). Cung enum DONG, cung luat cam tu-che class.
$cocaRaw = @()
if (-not [string]::IsNullOrWhiteSpace($CoCa)) {
    $cocaRaw = @($CoCa -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
}
$badCoCa = @($cocaRaw | Where-Object { $enum -notcontains $_ } | Select-Object -Unique)
if ($badCoCa.Count -gt 0) {
    foreach ($b in $badCoCa) {
        Write-Host ("FAIL: -CoCa class '{0}' is NOT in the closed enum lead_self_audit.flag_classes." -f $b)
    }
    Write-Host ("      Closed enum ({0}): {1}" -f $enum.Count, ($enum -join ', '))
    exit 2
}
$cocaUnique = @($cocaRaw | Select-Object -Unique)
# Giao voi -Flags: mot class vua FLAG vua tu-khai thi FLAG thang (no da tang tally roi).
$cocaOnly = @($cocaUnique | Where-Object { $firedUnique -notcontains $_ })

# ---------------------------------------------------------------------------
# read counter state
# ---------------------------------------------------------------------------
if (-not (Test-Path -LiteralPath $counterPath)) {
    Write-Host "FAIL: counter file not found: $counterPath"
    exit 3
}
try { $ctr = Read-JsonFile $counterPath } catch { $ctr = $null }
if ($null -eq $ctr) {
    Write-Host "FAIL: .session-counter.json is not valid JSON - refusing to write tally onto an unreadable state file."
    exit 3
}

$counter = [int]$ctr.counter

# ensure containers exist (defensive; the real file has them)
if ($null -eq $ctr.last_audit) { Set-Prop $ctr 'last_audit' ([pscustomobject]@{}) }
$la = $ctr.last_audit
if ($null -eq $ctr.class_repeat) { Set-Prop $ctr 'class_repeat' ([pscustomobject]@{ counts = [pscustomobject]@{} }) }
$cr = $ctr.class_repeat
if ($null -eq $cr.counts) { Set-Prop $cr 'counts' ([pscustomobject]@{}) }
$counts = $cr.counts

# ---------------------------------------------------------------------------
# freeze detection: _frozen_until_owner key present beside the map (class_repeat level)
# ---------------------------------------------------------------------------
$frozen = ($cr.PSObject.Properties.Name -contains '_frozen_until_owner')

# ---------------------------------------------------------------------------
# per-session decision ledger (persisted under last_audit; OUTSIDE the counts map).
# The two bookend audits of one logic-session are separate script invocations, so the
# ledger MUST persist in the file to collapse them into one decision/class/session.
# ---------------------------------------------------------------------------
$decided = @()
$resetDone = $false
if (([string]$la.signal_session) -eq $Session) {
    $sd = $la.signal_decided
    if ($null -ne $sd) {
        # robust to PS 5.1 array-serialization quirks (empty -> '' , single -> scalar)
        $decided = @($sd | Where-Object { ($_ -is [string]) -and ($_ -ne '') } | ForEach-Object { [string]$_ })
    }
    if ($la.PSObject.Properties.Name -contains 'signal_reset_done') {
        $resetDone = [bool]$la.signal_reset_done
    }
}
# else: a new logic-session -> fresh ledger (decided empty, resetDone false)

# ---------------------------------------------------------------------------
# (3a) FIRE pass - dedup within a call; NO-OP a class already decided this session
# ---------------------------------------------------------------------------
foreach ($cls in $firedUnique) {
    if ($decided -contains $cls) {
        Write-Host ("NO-OP fire: class '{0}' already has a decision this session ({1}) - max-1-decision/class/session (procedural)." -f $cls, $Session)
        continue
    }
    $cur = Get-Count $counts $cls
    $new = $cur + 1
    Set-Prop $counts $cls ([int]$new)
    $decided += $cls
    Write-Host ("FIRE: class '{0}' counts {1} -> {2}." -f $cls, $cur, $new)
}

# ---------------------------------------------------------------------------
# (3b) RESET pass - consecutive semantics; at most one reset pass per session (FIX-1 p2);
# a present freeze key blocks it entirely (owner-owned hung streak stays intact).
#
# 🔴 SO THU HAI chan reset (S172, owner slot 70 - vá incentive-inversion do ring2-audit A4 bắt).
# BENH: "consecutive semantics" doc tin-hieu tu DUY NHAT danh-sach -Flags. Nhung mot class CO CA
# THAT ma lead TU KHAI TRUOC khi con-do soi thi con-do xep no vao §0-bis NGOAI TOTAL => no vang
# mat khoi -Flags => may doc thanh "khong tai dien" => RESET. Tuc co-che dang THUONG CHO VIEC GIAU:
# im lang thi con-do bat va streak +1; tu thu thi streak ve 0.
# CA THAT @S172: view-stale-header 4 -> 0, trong khi 2 header stale (STATUS:6, HANDOFF:5) CO THAT
# va chinh lead da khai ra chung trong cung phien.
# VA: reset chi khi class VANG MAT o CA HAI so - khong FLAG *VA* khong co ca tu-khai.
#     -Flags = "so FLAG moi" (tang tally)  ⟂  -CoCa = "class nao co ca trong vong" (chi chan reset).
# ---------------------------------------------------------------------------
if (-not $resetDone) {
    $resetCandidates = @($enum | Where-Object {
            ($firedUnique -notcontains $_) -and ($cocaOnly -notcontains $_) -and ((Get-Count $counts $_) -ge 1) -and ($decided -notcontains $_)
        })
    if ($resetCandidates.Count -gt 0) {
        if ($frozen) {
            Write-Host ("(frozen - cho owner xu dai JUMP): _frozen_until_owner present; NOT resetting {0} hung class(es): {1}" -f $resetCandidates.Count, ($resetCandidates -join ', '))
            # nothing reset -> leave resetDone false and counts intact
        }
        else {
            foreach ($cls in $resetCandidates) {
                $cur = Get-Count $counts $cls
                Set-Prop $counts $cls ([int]0)
                Write-Host ("RESET: class '{0}' counts {1} -> 0 (khong FLAG VA khong co ca tu-khai - consecutive semantics)." -f $cls, $cur)
            }
            $resetDone = $true
        }
    }
}

# ---------------------------------------------------------------------------
# (4) stamp last_audit.{light|deep}_at_counter = current counter
# ---------------------------------------------------------------------------
$floorKey = "${AuditKind}_at_counter"
Set-Prop $la $floorKey ([int]$counter)

# persist the per-session ledger (all OUTSIDE the counts map)
Set-Prop $la 'signal_session' ([string]$Session)
Set-Prop $la 'signal_decided' (@($decided))
Set-Prop $la 'signal_reset_done' ([bool]$resetDone)
Set-Prop $la 'signal_last_kind' ([string]$AuditKind)
# SO THU HAI - vet de doc lai duoc "vong nay class nao co ca ma khong thanh FLAG"
Set-Prop $la 'signal_coca' (@($cocaOnly))

# ---------------------------------------------------------------------------
# (6a) B10 @S152 enum-parity: EVERY enum class must exist as a key (0 if absent) so a
# re-serialize can never silently drop an armed-at-0 class (ring2-deep S151: gap-underfill
# was present-but-toothless - one round-trip from vanishing). Keys OUTSIDE the enum =
# fail-loud, NOT silent delete (hidden data loss; owner extends enum or cleans by hand).
# ---------------------------------------------------------------------------
foreach ($cls in $enum) {
    if (-not ($counts.PSObject.Properties.Name -contains $cls)) { Set-Prop $counts $cls ([int]0) }
}
$extraKeys = @($counts.PSObject.Properties.Name | Where-Object { $enum -notcontains $_ })
if ($extraKeys.Count -gt 0) {
    Write-Host ("FAIL enum-parity: counts has {0} key(s) OUTSIDE the closed enum: {1} - refusing to write (silent delete hides data; extend lead_self_audit.flag_classes or clean by hand)." -f $extraKeys.Count, ($extraKeys -join ', '))
    exit 3
}
if (@($counts.PSObject.Properties).Count -ne $enum.Count) {
    Write-Host ("FAIL enum-parity: counts key-count {0} != enum count {1} after ensure - refusing to write." -f @($counts.PSObject.Properties).Count, $enum.Count)
    exit 3
}

# ---------------------------------------------------------------------------
# (6) invariant: counts map holds ONLY numbers (note lives OUTSIDE the map; 2f39a7e)
# ---------------------------------------------------------------------------
foreach ($p in $counts.PSObject.Properties) {
    if (-not (Test-IntLike $p.Value)) {
        $tn = if ($null -eq $p.Value) { 'null' } else { $p.Value.GetType().Name }
        Write-Host ("FAIL invariant: counts['{0}'] is not an integer (type {1}) - the counts map must hold ONLY numbers; any note goes OUTSIDE the map (commit 2f39a7e)." -f $p.Name, $tn)
        exit 3
    }
}

# ---------------------------------------------------------------------------
# (5) atomic write: temp + Move-Item -Force. PS 5.1 Out-File -Encoding utf8 emits a BOM;
# both readers strip it (governance-detectors.ps1 byte+TrimStart, nhip-no-probe.ps1
# Get-Content -Encoding UTF8).
# ---------------------------------------------------------------------------
$json = $ctr | ConvertTo-Json -Depth 20
$tmp = "$counterPath.tmp"
try {
    $json | Out-File -LiteralPath $tmp -Encoding utf8
    Move-Item -LiteralPath $tmp -Destination $counterPath -Force
}
catch {
    if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue }
    Write-Host ("FAIL: atomic write failed ({0}) - original file left untouched." -f $_.Exception.Message)
    exit 3
}

Write-Host ("OK: {0} | session={1} kind={2} counter={3} | fired=[{4}] decided-this-session=[{5}] reset-done={6} frozen={7}" -f `
    $counterPath, $Session, $AuditKind, $counter, ($firedUnique -join '|'), ($decided -join '|'), $resetDone, $frozen)
exit 0
