#Requires -Version 5.1
<#
.SYNOPSIS
    Harness-24 session-counter TICK - mechanizes the `_contract` block of
    .claude/governance/.session-counter.json (M2, spec S149 hoi-tu-bookend).

.DESCRIPTION
    Turns the hand-run "tick + classify + append-history" ritual into a machine that
    touches disk deterministically (the S149 root-cause: SE was rules-rich / machine-poor,
    so ticks were done from memory and drifted: backfill S146, tally-drop S148, x27 != 33).

    Executes the `_contract` VERBATIM - it does NOT invent policy:
      tick(session, head):
        1. read HEAD via git (content-addressed: a tick is keyed on the repo HEAD sha).
        2. OR-guard idempotent: session == last_ticked_session  OR  head == last_ticked_head
           => NO-OP, exit 0, print one line. (The Stop-hook wal-flush.ps1 moves HEAD every
           turn-boundary, so the head-clause alone would let one real session tick twice; the
           session-label clause pins ONE label to exactly +1.)
        3. classify BEFORE tick (two triggers, two verdicts - contract v2):
           (a) counter REGRESSION (incoming session number < stored) => FAIL-LOUD, exit!=0, no write.
           (b) last_ticked_head object MISSING (git cat-file fails)   => FAIL-LOUD, exit!=0, no write.
           (c) last_ticked_head NOT reachable from HEAD but object EXISTS and counter did NOT
               regress => SQUASH-BENIGN: append ONE trace entry to history[] and CONTINUE (no owner
               alarm; a closeout squash routinely lifts the ticked wal:/session commit out of history).
           else (reachable + no regress) => CLEAN tick.
        4. tick: counter+1; update the 4 fields (counter, last_ticked_session, last_ticked_head,
           last_ticked_at); append ONE history entry classed clean | squash-benign.
        5. write ATOMICALLY: temp file then Move-Item -Force (khuon hub h17_cadence.py _save_atomic
           :174-184) - a crash mid-write leaves the original file intact.

    SCOPE GUARDS (per M2): does NOT touch `class_repeat` (that is M3) and does NOT reset anything.

    WRITE STRATEGY - why surgical string-replace, not ConvertTo-Json:
      Only Windows PowerShell 5.1 is present here (no pwsh 7 / no System.Text.Json). PS 5.1
      ConvertTo-Json re-serializes the whole file with CRLF + char-escaping (measured: 25900 ->
      28495 bytes) = a noisy diff on a committed governance file, and risks mangling the 8 emoji.
      So the file is PARSED with ConvertFrom-Json (for the guard logic) but WRITTEN by anchored
      line replacement + one array-insert on the raw UTF-8 text - preserving formatting byte-exact
      (measured surgical write: 25900 -> 25900 + one history entry, LF-only, emoji intact).
      Read/write both go through explicit UTF-8-no-BOM (E-010 trap: a missing -Encoding reads ANSI).

.PARAMETER Session
    Session label, e.g. "S150". Must match ^S\d+ (the leading number is the monotonic guard).

.PARAMETER RepoRoot
    Repo root. Default = the parent of this script's folder (the SE repo). Fault-injection (K2)
    points this at a throwaway git tree holding a copy of the counter json.

.PARAMETER FaultStopAfterTemp
    K2(e) FAULT-INJECTION ONLY (default off): perform the real write up to and including the temp
    file, then STOP before Move-Item - simulating a crash between write and rename so a test can
    assert the ORIGINAL file is still intact. Never used in production call-sites.

.EXAMPLE
    powershell -File scripts/session-counter-tick.ps1 -Session S150

.NOTES
    Contract source : .claude/governance/.session-counter.json  (_contract, read verbatim)
    Hub reference   : AI_INFRA/scripts/h17_cadence.py           (_save_atomic :174-184)
    Caller (M6)     : /session-start + /tiep call this instead of prose.
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string]$Session,
    [string]$RepoRoot,
    [switch]$FaultStopAfterTemp
)

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

# ------------------------------------------------------------------ output helpers
function Write-Line([string]$msg) { [Console]::Out.WriteLine($msg) }

function Fail-Loud([string]$msg, [int]$code) {
    # Single clear line to STDERR + non-zero exit (NOT a raw PowerShell exception dump).
    [Console]::Error.WriteLine("[h24-tick] FAIL-LOUD: $msg")
    exit $code
}

function Parse-SessionNum([string]$label) {
    if ([string]::IsNullOrWhiteSpace($label)) { return $null }
    $m = [regex]::Match($label, '^[Ss]?(\d+)')
    if ($m.Success) { return [int]$m.Groups[1].Value }
    return $null
}

# Anchored single-line replace on the raw text. Asserts EXACTLY one match (shape-drift = fail-loud),
# and uses a literal MatchEvaluator so no '$' in the replacement is ever re-interpreted.
function Replace-LineOnce([string]$text, [string]$pattern, [string]$newLine, [string]$what) {
    $rx = New-Object System.Text.RegularExpressions.Regex($pattern, [System.Text.RegularExpressions.RegexOptions]::Multiline)
    $n = $rx.Matches($text).Count
    if ($n -ne 1) {
        Fail-Loud "surgical replace '$what' expected exactly 1 match, found $n - counter-file shape drift; refusing to write." 7
    }
    $ev = [System.Text.RegularExpressions.MatchEvaluator]({ param($m) $newLine }.GetNewClosure())
    return $rx.Replace($text, $ev, 1)
}

# Style-preserving field replace (fix S151 format-fork): the S150 signal-write re-serialized
# this file via ConvertTo-Json (BOM + CRLF + two-space after colon), so the fixed-string
# replacer above stopped matching (0 match => exit 7 every session after every bookend).
# This variant's pattern carries TWO capture groups (prefix)(suffix); the matched prefix and
# suffix are kept verbatim, so whichever spacing/EOL shape the file has SURVIVES the write.
# Same exactly-one-match fail-loud as Replace-LineOnce (shape-drift still refuses to write).
function Replace-FieldOnce([string]$text, [string]$pattern, [string]$valueLiteral, [string]$what) {
    $rx = New-Object System.Text.RegularExpressions.Regex($pattern, [System.Text.RegularExpressions.RegexOptions]::Multiline)
    $n = $rx.Matches($text).Count
    if ($n -ne 1) {
        Fail-Loud "surgical replace '$what' expected exactly 1 match, found $n - counter-file shape drift; refusing to write." 7
    }
    $ev = [System.Text.RegularExpressions.MatchEvaluator]({ param($m) $m.Groups[1].Value + $valueLiteral + $m.Groups[2].Value }.GetNewClosure())
    return $rx.Replace($text, $ev, 1)
}

function Json-Escape([string]$s) {
    # Minimal JSON string escaping. Event text is authored quote/backslash-free, but stay safe.
    $s = $s -replace '\\', '\\'
    $s = $s -replace '"', '\"'
    return $s
}

# Run git and return its trimmed stdout + real exit code. Native git writes to stderr on a
# missing object / bad ref; under $ErrorActionPreference='Stop' PS 5.1 wraps that stderr as a
# TERMINATING NativeCommandError (even with 2>$null), so classification could never run. Drop to
# 'Continue' just around the call so a non-zero git exit is DATA we branch on, not a crash.
function Invoke-GitLines([string]$root, [string[]]$gitArgs) {
    $old = $ErrorActionPreference
    $ErrorActionPreference = 'Continue'
    try {
        $out = & git -C $root @gitArgs 2>$null
        $code = $LASTEXITCODE
    }
    finally { $ErrorActionPreference = $old }
    return [pscustomobject]@{ Out = ("$out").Trim(); Code = $code }
}

# ------------------------------------------------------------------ resolve paths
if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
    if ([string]::IsNullOrWhiteSpace($PSScriptRoot)) { $RepoRoot = (Get-Location).Path }
    else { $RepoRoot = Split-Path $PSScriptRoot -Parent }
}
try { $RepoRoot = (Resolve-Path -LiteralPath $RepoRoot -ErrorAction Stop).Path }
catch { Fail-Loud "RepoRoot does not exist: $RepoRoot" 8 }

$counterFile = Join-Path $RepoRoot ".claude/governance/.session-counter.json"

# ------------------------------------------------------------------ validate input
if ($Session -notmatch '^S\d+') {
    Fail-Loud "invalid -Session '$Session' - expected an S<nn> label (e.g. S150)." 8
}

# ------------------------------------------------------------------ read + parse counter file
if (-not (Test-Path -LiteralPath $counterFile)) {
    Fail-Loud "counter file missing: $counterFile" 5
}
$rawText = [System.IO.File]::ReadAllText($counterFile, [System.Text.Encoding]::UTF8)
try { $data = $rawText | ConvertFrom-Json }
catch { Fail-Loud "counter file is not valid JSON: $counterFile ($_)" 6 }

$storedCounter = [int]$data.counter
$storedSession = "$($data.last_ticked_session)"
$storedHead    = "$($data.last_ticked_head)"

# ------------------------------------------------------------------ (1) HEAD via git
$gh = Invoke-GitLines $RepoRoot @('rev-parse', 'HEAD')
if ($gh.Code -ne 0 -or [string]::IsNullOrWhiteSpace($gh.Out)) {
    Fail-Loud "cannot resolve git HEAD in $RepoRoot (not a git repo?)." 4
}
$headSha = $gh.Out

# ------------------------------------------------------------------ (2) OR-guard idempotent
if ($Session -eq $storedSession -or $headSha -eq $storedHead) {
    $reason = if ($Session -eq $storedSession) { "session-label '$Session' already ticked" }
              else { "HEAD $($headSha.Substring(0,7)) already ticked" }
    Write-Line "[h24-tick] NO-OP: $reason (stored $storedSession @ $($storedHead.Substring(0, [Math]::Min(7,$storedHead.Length)))); counter stays $storedCounter."
    exit 0
}

# ------------------------------------------------------------------ (3) classify BEFORE tick
# (3a) counter regression - incoming session number below the stored one = rollback / hand-edit.
$inNum     = Parse-SessionNum $Session
$storedNum = Parse-SessionNum $storedSession
if ($null -ne $storedNum -and $null -ne $inNum -and $inNum -lt $storedNum) {
    Fail-Loud "counter REGRESSION: incoming $Session (n=$inNum) < stored $storedSession (n=$storedNum) - the counter file was rolled back or hand-edited. Refusing to tick or write (contract fail_loud_on_regress trigger-1); report to owner." 2
}

# (3b/3c) reachability classification of the stored head (skip if there is no prior head = fresh seed).
$eventClass = 'clean'
$reachNote  = 'no prior head (fresh tick)'
if (-not [string]::IsNullOrWhiteSpace($storedHead) -and $storedHead -ne 'null') {
    $gc = Invoke-GitLines $RepoRoot @('cat-file', '-t', $storedHead)
    $objExists = ($gc.Code -eq 0 -and $gc.Out -eq 'commit')
    if (-not $objExists) {
        Fail-Loud "last_ticked_head $storedHead is a MISSING object (git cat-file -t failed) - not reachable AND not present = possible tamper/rollback. Refusing to tick (contract fail_loud_on_regress trigger-2, missing branch); report to owner." 3
    }
    $gm = Invoke-GitLines $RepoRoot @('merge-base', '--is-ancestor', $storedHead, $headSha)
    $reachable = ($gm.Code -eq 0)
    if ($reachable) {
        $reachNote  = "reachable (merge-base --is-ancestor exit 0)"
        $eventClass = 'clean'
    }
    else {
        # object EXISTS + counter did NOT regress (checked above) => squash-benign, continue.
        $reachNote  = "object EXISTS (cat-file=commit) but NOT reachable (merge-base --is-ancestor exit!=0), counter not regressed"
        $eventClass = 'squash-benign'
    }
}

# ------------------------------------------------------------------ (4) tick
$newCounter = $storedCounter + 1
$gd = Invoke-GitLines $RepoRoot @('log', '-1', '--format=%cd', '--date=short', $headSha)
if ($gd.Code -ne 0 -or [string]::IsNullOrWhiteSpace($gd.Out)) {
    $tickDate = (Get-Date -Format 'yyyy-MM-dd')
}
else { $tickDate = $gd.Out }
$oldShort  = $storedHead.Substring(0, [Math]::Min(7, $storedHead.Length))
$newShort  = $headSha.Substring(0, 7)

if ($eventClass -eq 'squash-benign') {
    $event = "squash-benign (session-counter-tick.ps1 M2, contract fail_loud_on_regress trigger-2 BENIGN branch): counter $storedCounter->$newCounter, session $storedSession->$Session, head $oldShort->$newShort. last_ticked_head $oldShort $reachNote => a closeout squash lifted the ticked wal:/session commit out of history (expected drift, not tamper). Trace appended, continue, no owner alarm. Written atomically (temp + Move-Item -Force)."
}
else {
    $event = "CLEAN tick (session-counter-tick.ps1 M2): counter $storedCounter->$newCounter, session $storedSession->$Session, head $oldShort->$newShort. Classify-before-tick: no regression (n=$inNum >= stored n=$storedNum); last_ticked_head $reachNote. 4 fields updated, 1 history entry appended, written atomically (temp + Move-Item -Force)."
}
$eventJson = Json-Escape $event

# ---- surgical edits on the raw text (style-preserving: tolerate BOTH the original
# ---- surgical shape (LF / one-space) AND the ConvertTo-Json shape (CRLF / two-space) ----
$new = $rawText
$new = Replace-FieldOnce $new '^(\s{4}"counter":\s+)\d+(,\r?)$'                 ("$newCounter") 'counter'
$new = Replace-FieldOnce $new '^(\s{4}"last_ticked_session":\s+")[^"]*(",\r?)$' $Session        'last_ticked_session'
$new = Replace-FieldOnce $new '^(\s{4}"last_ticked_head":\s+")[^"]*(",\r?)$'    $headSha        'last_ticked_head'
$new = Replace-FieldOnce $new '^(\s{4}"last_ticked_at":\s+")[^"]*(",\r?)$'      $tickDate       'last_ticked_at'

# ---- append ONE history entry: insert before the array close (anchored at EOF, exactly once).
# Shape-tolerant (fix S151 format-fork): match ANY indent + CRLF-or-LF at EOF; the inserted
# entry reuses the file's own EOL and the LAST entry's own indent, so both shapes stay intact.
$histRx = New-Object System.Text.RegularExpressions.Regex('(\r?\n)([ \t]+)\}([ \t]*)(\r?\n[ \t]*\][ \t]*\r?\n\}[ \t]*\r?\n?)$', [System.Text.RegularExpressions.RegexOptions]::Singleline)
$histN = $histRx.Matches($new).Count
if ($histN -ne 1) {
    Fail-Loud "history array-close anchor expected exactly 1 match at EOF, found $histN - counter-file shape drift; refusing to write." 7
}
$histEv = [System.Text.RegularExpressions.MatchEvaluator]({ param($m)
    $eol = $m.Groups[1].Value
    $ind = $m.Groups[2].Value          # indent of the LAST entry's closing brace
    $fld = $ind + '    '               # field indent = one level deeper
    $entry = $ind + '{' + $eol +
             $fld + '"at": "' + $tickDate + '",' + $eol +
             $fld + '"session": "' + $Session + '",' + $eol +
             $fld + '"event": "' + $eventJson + '"' + $eol +
             $ind + '}'
    $m.Groups[1].Value + $m.Groups[2].Value + '}' + $m.Groups[3].Value + ',' + $eol + $entry + $m.Groups[4].Value
}.GetNewClosure())
$new = $histRx.Replace($new, $histEv, 1)

# ---- validate the result is still parseable BEFORE we touch disk ----
try { $null = $new | ConvertFrom-Json }
catch { Fail-Loud "post-edit JSON failed to parse - aborting before write (no file touched): $_" 9 }

# ------------------------------------------------------------------ (5) atomic write
$tmp = $counterFile + '.tmp'
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($tmp, $new, $utf8NoBom)

if ($FaultStopAfterTemp) {
    [Console]::Error.WriteLine("[h24-tick] FAULT-INJECT: wrote temp then STOPPED before Move-Item (simulated crash). Original intact: $counterFile ; dangling temp: $tmp")
    exit 42
}

Move-Item -Force -LiteralPath $tmp -Destination $counterFile
Write-Line "[h24-tick] ${eventClass}: ticked $storedSession -> $Session, counter $storedCounter -> $newCounter (HEAD $newShort). Wrote $counterFile atomically."
exit 0
