#!/usr/bin/env bash
# v1.0 M4b Playwright acceptance (ADR 033 D7) — the 5 browser-only interactive
# flows, hermetic (stub herdr), asserting DB SIDE EFFECTS not just UI text.
#
# This is the BROWSER half of the D7 acceptance proof. The HTML/structure half
# (every feed card class, ContextPanel sections, composer/reveal affordances)
# is covered by the in-process render tests in src/db_dashboard.rs; this driver
# proves the five flows that ONLY a real browser exercises: the chat<->audit
# toggle JS, live SSE feed/usage updates, the `__both__` client-side fan-out,
# and the inline reveal un-redaction. Every interactive flow asserts a DB side
# effect via `sqlite3` (anti-hollow): visible UI text alone is never the proof.
#
# Hermetic: no live herdr — a stub `$FAKE_HERDR` is passed via
# `db serve --herdr-bin`, so the audited Both fan-out records `sent` rows
# offline (the child `send herdr` shells out to the stub, which prints `sent`).
#
# Requires: a built `zynk` (default ./target/debug/zynk), `playwright-cli`,
# `sqlite3`. This is NOT a cargo test — run it directly:
#   ZYNK_BIN=./target/debug/zynk bash scripts/acceptance-playwright.sh
set -euo pipefail

ZYNK_BIN="${ZYNK_BIN:-./target/debug/zynk}"
WORK="$(mktemp -d)"
DB="$WORK/state/zynk.db"
ROOT="$WORK/outputs"
KEY="$WORK/custody.key"
PORT="${PORT:-8799}"
HOST="127.0.0.1:$PORT"
FAKE_HERDR="$WORK/fake-herdr"
printf '#!/bin/sh\necho sent\n' > "$FAKE_HERDR"; chmod +x "$FAKE_HERDR"

cleanup() {
  [ -n "${SERVE_PID:-}" ] && kill "$SERVE_PID" 2>/dev/null || true
  playwright-cli close 2>/dev/null || true
  rm -rf "$WORK"
}
trap cleanup EXIT

q() { sqlite3 "$DB" "$1"; }
# Evaluate a JS expression in the open page and print its raw result. `playwright-cli
# eval` frames the result as `### Result\n<value>\n### Ran Playwright code …` and
# JSON-encodes string values; extract the value between the markers and strip the
# surrounding JSON quotes so the shell comparisons (and sqlite3 id matches) see the
# bare result (bools/numbers pass through unquoted).
pe() {
  playwright-cli eval "$1" 2>/dev/null \
    | awk '/^### Result$/{f=1; next} /^### Ran Playwright code$/{f=0} f' \
    | sed '1s/^"//; 1s/"$//'
}
fail() { echo "ACCEPTANCE FAIL: $*" >&2; exit 1; }

# 1) Seed a real DB via the single-source-of-truth scenario.
ZYNK_BIN="$ZYNK_BIN" DB="$DB" ROOT="$ROOT" SESSION_ID=s1 WORKSPACE_ID=w-test \
  HERDR_BIN="$FAKE_HERDR" CUSTODY_KEY_FILE="$KEY" bash scripts/seed-scenario.sh >/dev/null

# 2) Serve with writes enabled + the stub herdr (zero product-code change).
#    `--db` belongs to the `db` subcommand (before `serve`). The reveal child
#    shells out to `zynk reveal`, which resolves the custody key via
#    $ZYNK_CUSTODY_KEY_FILE (else <db-dir>/custody.key); export it so the child
#    inherits the SAME key the seed wrote — otherwise reveal fails closed (502).
ZYNK_CUSTODY_KEY_FILE="$KEY" "$ZYNK_BIN" db --db "$DB" serve \
  --allow-writes --herdr-bin "$FAKE_HERDR" \
  --root "$ROOT" --host 127.0.0.1 --port "$PORT" &
SERVE_PID=$!
sleep 1

BASE="http://127.0.0.1:$PORT/?session=s1"
playwright-cli open "$BASE"
sleep 1  # let the initial SSE connect + first feed-reset settle

# ----------------------------------------------------------------------------
# FLOW 1 — VIEW TOGGLE (pure UI: no DB side effect; assert the swap + NO nav).
# Click `#view-toggle`; `#audit-view` (hidden by default) becomes visible AND
# `location.href` is unchanged (the toggle is pure client-side show/hide, never
# a navigation). This is the ONE flow with no DB assertion — it is structural.
# ----------------------------------------------------------------------------
HREF_BEFORE="$(pe "() => location.href")"
AUDIT_HIDDEN_BEFORE="$(pe "() => document.getElementById('audit-view').hidden")"
[ "$AUDIT_HIDDEN_BEFORE" = "true" ] || fail "flow1: #audit-view should start hidden (got $AUDIT_HIDDEN_BEFORE)"
pe "() => document.getElementById('view-toggle').click()" >/dev/null
AUDIT_HIDDEN_AFTER="$(pe "() => document.getElementById('audit-view').hidden")"
HREF_AFTER="$(pe "() => location.href")"
[ "$AUDIT_HIDDEN_AFTER" = "false" ] || fail "flow1: #audit-view should be visible after toggle (got $AUDIT_HIDDEN_AFTER)"
[ "$HREF_AFTER" = "$HREF_BEFORE" ] || fail "flow1: toggle must not navigate ($HREF_BEFORE -> $HREF_AFTER)"
# toggle back so the feed is visible for the SSE flows.
pe "() => document.getElementById('view-toggle').click()" >/dev/null
echo "FLOW 1 (view toggle) PASS"

# ----------------------------------------------------------------------------
# FLOW 2 — SSE CARD APPEARANCE (DB side effect: a new `think` work_event row).
# Capture the live `#feed .feed-item` count, emit a real `report think`, then
# assert the feed grew (live SSE feed-append) AND the source row exists in
# `work_events` (kind='think' now >= 2: the seed's one + this one).
# ----------------------------------------------------------------------------
FEED_BEFORE="$(pe "() => document.querySelectorAll('#feed .feed-item').length")"
"$ZYNK_BIN" report think --text "live-streamed thought" \
  --root "$ROOT" --db "$DB" --session-id s1 --actor claude >/dev/null
sleep 2  # SSE poll interval + feed-append
FEED_AFTER="$(pe "() => document.querySelectorAll('#feed .feed-item').length")"
[ "$FEED_AFTER" -gt "$FEED_BEFORE" ] || fail "flow2: feed did not grow over SSE ($FEED_BEFORE -> $FEED_AFTER)"
THINK_ROWS="$(q "SELECT COUNT(*) FROM work_events WHERE session_id='s1' AND kind='think'")"
[ "$THINK_ROWS" -ge 2 ] || fail "flow2: think work_event not recorded (got $THINK_ROWS, want >= 2)"
echo "FLOW 2 (SSE card appearance) PASS  (feed $FEED_BEFORE -> $FEED_AFTER, think rows=$THINK_ROWS)"

# ----------------------------------------------------------------------------
# FLOW 3 — BOTH FAN-OUT (DB side effect: one NEW `sent` audit row per sendable
# target, NO fabricated `__both__`/`both`/`all` target row).
# `__both__` is a CLIENT-side sentinel: the composer submit script fans it out
# into ONE `fetch('/send')` per REAL sendable target read from
# `data-sendable-targets`. The expected delta is therefore the NUMBER of real
# sendable targets the seed produced (read it from the form — do NOT hardcode,
# the seed currently yields claude + codex + operator = 3), each a distinct
# audited send. The server NEVER sees `__both__`, so no sentinel target row
# may appear in audit_records.
# ----------------------------------------------------------------------------
SENT_BEFORE="$(q "SELECT COUNT(*) FROM audit_records WHERE session_id='s1' AND delivery_status='sent'")"
EXPECTED_FANOUT="$(pe "() => JSON.parse(document.querySelector('.composer').getAttribute('data-sendable-targets')||'[]').length")"
[ "$EXPECTED_FANOUT" -ge 2 ] || fail "flow3: need >1 sendable target for a fan-out (got $EXPECTED_FANOUT)"
# Select the `__both__` sentinel, fill the body, and submit the real composer
# form — this triggers the page's own fan-out handler (the production code path,
# not a synthetic per-target POST). requestSubmit() dispatches the bound
# 'submit' listener that reads `data-sendable-targets` and fires N fetches.
pe "() => { var f=document.querySelector('.composer'); f.to.value='__both__'; f.body.value='m4b both fan-out'; f.requestSubmit(); return f.to.value; }" >/dev/null
sleep 3  # let all N async fetch('/send') round-trips land + project
SENT_AFTER="$(q "SELECT COUNT(*) FROM audit_records WHERE session_id='s1' AND delivery_status='sent'")"
DELTA=$((SENT_AFTER - SENT_BEFORE))
[ "$DELTA" -eq "$EXPECTED_FANOUT" ] || fail "flow3: fan-out delta=$DELTA, expected $EXPECTED_FANOUT new sent rows"
SENTINEL="$(q "SELECT COUNT(*) FROM audit_records WHERE target_agent_id IN ('__both__','both','all') OR target_address IN ('__both__','both','all')")"
[ "$SENTINEL" -eq 0 ] || fail "flow3: a fabricated sentinel target row leaked (count=$SENTINEL)"
echo "FLOW 3 (Both fan-out) PASS  ($EXPECTED_FANOUT new sent rows, 0 sentinel targets)"

# ----------------------------------------------------------------------------
# FLOW 4 — SSE USAGE UPDATE (DB side effect: a new `usage` work_event row).
# Capture the live `[data-usage]` textContent, emit a real `report usage`, then
# assert the mount's text changed (live SSE `usage` event) AND a second usage
# row exists. There are 3 `[data-usage]` mounts (topbar + 2 panels); read the
# topbar one (index 0) — the SSE handler updates all of them.
# ----------------------------------------------------------------------------
USAGE_BEFORE="$(pe "() => document.querySelectorAll('[data-usage]')[0].textContent.trim()")"
"$ZYNK_BIN" report usage --agent codex --tokens 1300 \
  --root "$ROOT" --db "$DB" --session-id s1 --actor codex >/dev/null
sleep 2
USAGE_AFTER="$(pe "() => document.querySelectorAll('[data-usage]')[0].textContent.trim()")"
[ "$USAGE_AFTER" != "$USAGE_BEFORE" ] || fail "flow4: usage mount text did not change over SSE ('$USAGE_BEFORE')"
USAGE_ROWS="$(q "SELECT COUNT(*) FROM work_events WHERE session_id='s1' AND kind='usage'")"
[ "$USAGE_ROWS" -ge 2 ] || fail "flow4: usage work_event not recorded (got $USAGE_ROWS, want >= 2)"
echo "FLOW 4 (SSE usage update) PASS  ('$USAGE_BEFORE' -> '$USAGE_AFTER', usage rows=$USAGE_ROWS)"

# ----------------------------------------------------------------------------
# FLOW 5 — REVEAL (DB side effect: a new `record_type='reveal'` proof row; the
# plaintext shows inline but NEVER leaks into the redacted read views).
# The seed itself reveals the custodied record once at seed time, so capture
# the proof count at RUNTIME (do not assume 0). Derive the revealable audit_id
# exactly as `validate_reveal` does (custody_vault join + non-`full` policy),
# confirm the rendered `.reveal-form` hidden field carries that id, then submit
# the form (the page's delegated `.reveal-form` handler lifts CSRF into
# X-Zynk-CSRF and POSTs /reveal, replacing the body with the plaintext result).
# ----------------------------------------------------------------------------
REVEAL_ID="$(q "SELECT a.audit_id FROM audit_records a JOIN custody_vault v ON v.audit_id=a.audit_id WHERE a.session_id='s1' AND a.payload_redaction_policy<>'full'")"
[ -n "$REVEAL_ID" ] || fail "flow5: no revealable custodied record found in the seed"
FORM_ID="$(pe "() => { var i=document.querySelector('.reveal-form input[name=\"audit_id\"]'); return i?i.value:''; }")"
[ "$FORM_ID" = "$REVEAL_ID" ] || fail "flow5: reveal-form audit_id ('$FORM_ID') != derived id ('$REVEAL_ID')"
REVEAL_BEFORE="$(q "SELECT COUNT(*) FROM audit_records WHERE record_type='reveal'")"
pe "() => document.querySelector('.reveal-form').requestSubmit()" >/dev/null
sleep 2  # the /reveal child shell-out + body replacement
# The handler replaces document.body with the escaped plaintext <pre> result.
BODY_TEXT="$(pe "() => document.body.textContent")"
case "$BODY_TEXT" in
  *"API_KEY=redacted-secret"*) : ;;
  *) fail "flow5: revealed plaintext not shown inline" ;;
esac
REVEAL_AFTER="$(q "SELECT COUNT(*) FROM audit_records WHERE record_type='reveal'")"
[ "$REVEAL_AFTER" -gt "$REVEAL_BEFORE" ] || fail "flow5: no new reveal proof row ($REVEAL_BEFORE -> $REVEAL_AFTER)"
# NON-LEAK: the redacted read views must STILL redact after the reveal returns.
# The reveal renders plaintext only in the transient /reveal response, never in
# the persisted feed/audit pages. Curl them fresh (out-of-band, not the mutated
# DOM) and assert the plaintext is absent.
LEAK_FEED="$(curl -s "http://$HOST/?session=s1" | grep -c "API_KEY=redacted-secret" || true)"
LEAK_AUDIT="$(curl -s "http://$HOST/audit?session=s1" | grep -c "API_KEY=redacted-secret" || true)"
[ "$LEAK_FEED" -eq 0 ] || fail "flow5: plaintext leaked into the feed read view"
[ "$LEAK_AUDIT" -eq 0 ] || fail "flow5: plaintext leaked into the /audit read view"
echo "FLOW 5 (reveal) PASS  (proof rows $REVEAL_BEFORE -> $REVEAL_AFTER, no read-view leak)"

echo "ALL 5 PLAYWRIGHT ACCEPTANCE FLOWS PASSED"
