#!/usr/bin/env bash
# Regression test for prompt-optimiser-compliance.sh (Task 719 Stop hook).
#
# A turn is flagged "directive-fired no-route-taken" only when the directive
# marker is in the turn slice AND the prompt is non-trivial AND no Agent/Skill/
# ToolSearch/mcp__* tool_use occurred. Covers both transcript record shapes,
# every route surface, the trivial filters, and fail-open.

set -u

HOOK="$(cd "$(dirname "$0")/.." && pwd)/prompt-optimiser-compliance.sh"
if [[ ! -x "$HOOK" ]]; then
  echo "FAIL: $HOOK not executable" >&2
  exit 1
fi

PASS=0
FAIL=0

# Build a transcript file from JSONL lines passed as args; echo its path.
mk_transcript() {
  local f; f=$(mktemp)
  local line
  for line in "$@"; do printf '%s\n' "$line" >> "$f"; done
  printf '%s' "$f"
}

# Run the hook against a transcript; capture combined stderr (where the flag is
# echoed). $1=transcript path.
run_hook() {
  printf '{"transcript_path":"%s","session_id":"sess12345678"}' "$1" | bash "$HOOK" 2>&1
}

USER_PROMPT='{"type":"user","message":{"role":"user","content":"how do I set up a Google Workspace email"}}'
# UserPromptSubmit additionalContext, CC 2.1.x attachment/hook_success shape.
DIRECTIVE_ATTACH='{"type":"attachment","attachment":{"type":"hook_success","hookEvent":"UserPromptSubmit","stdout":"{\"hookSpecificOutput\":{\"additionalContext\":\"PROMPT-OPTIMISER DIRECTIVE (standing) ...\"}}"}}'
# Pi shape.
DIRECTIVE_PI='{"type":"hook_additional_context","additionalContext":"PROMPT-OPTIMISER DIRECTIVE (standing) ..."}'
ASSISTANT_TEXT='{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"To set up Gmail, enable IMAP in settings."}]}}'
ASSISTANT_AGENT='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Agent","input":{}}]}}'
ASSISTANT_SKILL='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Skill","input":{}}]}}'
ASSISTANT_MCP='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__plugin_email__email-provider-info","input":{}}]}}'
ASSISTANT_TOOLSEARCH='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"ToolSearch","input":{}}]}}'
ASSISTANT_REPLY='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__whatsapp-channel__reply","input":{}}]}}'
ASSISTANT_REPLYDOC='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__whatsapp-channel__reply-document","input":{}}]}}'
ASSISTANT_SKILLLOAD='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__admin__skill-load","input":{}}]}}'
ASSISTANT_TASK='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Task","input":{}}]}}'
ASSISTANT_BASH='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{}}]}}'
ASSISTANT_WRITE='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Write","input":{}}]}}'
# Task 805 — a production-shape reframe turn: channel-wrapped, isMeta, carrying
# the composeAdminContent ## Context / ## Instruction sections. No directive
# marker row accompanies it (the directive hook suppresses itself on channel
# turns, Task 753), so the reframe itself must count as directive-equivalent.
CHANNEL_REFRAME='{"type":"user","isMeta":true,"message":{"role":"user","content":"<channel source=\"whatsapp-channel\">## Context\nplease draft the works order PDF for order 926\n\n## Instruction\nChoose the installed specialist that owns this request and dispatch it with the Agent tool.</channel>"}}'
# Same shape, one-word Context payload — the trivial filter must read the
# payload, not the wrapper (the always-present instruction text would otherwise
# make every reframed turn look non-trivial).
CHANNEL_REFRAME_TRIVIAL='{"type":"user","isMeta":true,"message":{"role":"user","content":"<channel source=\"whatsapp-channel\">## Context\nyes\n\n## Instruction\nChoose the installed specialist that owns this request and dispatch it with the Agent tool.</channel>"}}'
# Task 853 — multi-target (unified server) shape: the harness wraps with the
# registration key `channel`, and the server embeds an inner source tag, so the
# reframe arrives DOUBLE-wrapped. The strip must iterate to reach ## Context.
CHANNEL_REFRAME_MULTI='{"type":"user","isMeta":true,"message":{"role":"user","content":"<channel source=\"channel\">\n<channel source=\"whatsapp\">\n## Context\nplease draft the works order PDF for order 926\n\n## Instruction\nChoose the installed specialist that owns this request and dispatch it with the Agent tool.\n</channel>\n</channel>"}}'
# Task 1505 — the same reframe carrying the account's active standing-rules block
# (Task 1486 prepends `## Standing rules\n- …\n\n` above ## Context on admin
# channel turns). The reframe detection must strip that optional prefix, mirroring
# the reader anchor, so a rules-present turn is evaluated identically to a
# rules-absent one; without the strip the turn no longer starts with ## Context
# and the hook silently stops flagging on exactly the rule-carrying turns.
CHANNEL_REFRAME_RULES='{"type":"user","isMeta":true,"message":{"role":"user","content":"<channel source=\"whatsapp-channel\">## Standing rules\n- Always greet the client by name\n- Use British spelling\n\n## Context\nplease draft the works order PDF for order 926\n\n## Instruction\nChoose the installed specialist that owns this request and dispatch it with the Agent tool.</channel>"}}'
# Same rules-prefixed shape, one-word Context payload — after the prefix strip the
# trivial filter must still read just the payload (`yes`), not the rules block.
CHANNEL_REFRAME_RULES_TRIVIAL='{"type":"user","isMeta":true,"message":{"role":"user","content":"<channel source=\"whatsapp-channel\">## Standing rules\n- Always greet the client by name\n- Use British spelling\n\n## Context\nyes\n\n## Instruction\nChoose the installed specialist that owns this request and dispatch it with the Agent tool.</channel>"}}'

assert_flagged() {
  local name="$1" t="$2" out
  out=$(run_hook "$t")
  if printf '%s' "$out" | grep -q "no-route-taken"; then
    echo "PASS: $name"; PASS=$((PASS+1))
  else
    echo "FAIL: $name (expected flag, got: $out)" >&2; FAIL=$((FAIL+1))
  fi
  rm -f "$t"
}

assert_not_flagged() {
  local name="$1" t="$2" out
  out=$(run_hook "$t")
  if printf '%s' "$out" | grep -q "no-route-taken"; then
    echo "FAIL: $name (expected NO flag, got: $out)" >&2; FAIL=$((FAIL+1))
  else
    echo "PASS: $name"; PASS=$((PASS+1))
  fi
  rm -f "$t"
}

# 1. Directive fired (attachment shape) + prose only -> flagged.
assert_flagged "attachment-shape directive + no route -> flagged" \
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_TEXT")"

# 2. Directive fired (Pi shape) + prose only -> flagged.
assert_flagged "pi-shape directive + no route -> flagged" \
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_PI" "$ASSISTANT_TEXT")"

# 3–4. Genuine routes suppress the flag.
assert_not_flagged "Agent dispatch -> not flagged" \
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_AGENT")"
assert_not_flagged "Skill load -> not flagged" \
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_SKILL")"

# 5. A non-owning direct mcp__ call is NOT a route -> flagged (kills the
#    "any direct admin MCP call satisfies it" defect; Task 747).
assert_flagged "non-route mcp__ tool only -> flagged" \
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_MCP")"
# 6. ToolSearch is a schema load, not a route -> flagged (Task 747).
assert_flagged "ToolSearch only -> flagged" \
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_TOOLSEARCH")"
# 6a. Channel transport reply alone is mandatory transport, not a route -> flagged.
assert_flagged "channel reply transport only -> flagged" \
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_REPLY")"
# 6b. reply-document transport alone -> flagged.
assert_flagged "channel reply-document transport only -> flagged" \
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_REPLYDOC")"
# 6c. The e48c2a5e shape: reply + ToolSearch only -> flagged.
assert_flagged "reply + ToolSearch only (e48c2a5e shape) -> flagged" \
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_REPLY" "$ASSISTANT_TOOLSEARCH")"
# 6d. Task dispatch is a route -> not flagged.
assert_not_flagged "Task dispatch -> not flagged" \
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_TASK")"
# 6e. The mcp__admin__skill-load tool is a route -> not flagged.
assert_not_flagged "skill-load MCP tool -> not flagged" \
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_SKILLLOAD")"
# 6f. reply transport then a genuine Agent dispatch -> not flagged (route present).
assert_not_flagged "reply then Agent dispatch -> not flagged" \
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_REPLY" "$ASSISTANT_AGENT")"

# 7. No directive in the slice -> not flagged.
assert_not_flagged "no directive -> not flagged" \
  "$(mk_transcript "$USER_PROMPT" "$ASSISTANT_TEXT")"

# 8. Slash-command prompt -> not flagged.
assert_not_flagged "slash-command -> not flagged" \
  "$(mk_transcript '{"type":"user","message":{"role":"user","content":"/insight"}}' "$DIRECTIVE_ATTACH" "$ASSISTANT_TEXT")"

# 9. One-word confirmation -> not flagged.
assert_not_flagged "one-word confirmation -> not flagged" \
  "$(mk_transcript '{"type":"user","message":{"role":"user","content":"yes"}}' "$DIRECTIVE_ATTACH" "$ASSISTANT_TEXT")"

# Synthetic (isMeta) user rows that Claude Code injects AFTER the real prompt:
# a skill body and a system-reminder. The boundary must NOT land on these.
META_SKILL='{"type":"user","isMeta":true,"message":{"role":"user","content":[{"type":"text","text":"Base directory for this skill: /x/y. Follow the instructions."}]}}'
META_REMINDER='{"type":"user","isMeta":true,"message":{"role":"user","content":[{"type":"text","text":"<system-reminder>The task tools were not used recently.</system-reminder>"}]}}'
# WhatsApp/native channel inbound: isMeta:true AND channel-wrapped — this IS the prompt.
CHANNEL_PROMPT='{"type":"user","isMeta":true,"message":{"role":"user","content":"<channel source=\"whatsapp-channel\"> how do I connect a domain </channel>"}}'

# 10. Channel-wrapped non-trivial prompt (isMeta, as on the Pi) -> flagged.
assert_flagged "isMeta channel-wrapped prompt + no route -> flagged" \
  "$(mk_transcript "$CHANNEL_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_TEXT")"

# 10b. Real prompt followed by isMeta skill/reminder injections -> still flagged
# (boundary stays on the real prompt; the slice is not truncated). Regression for
# the boundary bug where the last text-bearing user row was a meta injection.
assert_flagged "prompt then isMeta injections + no route -> flagged (boundary holds)" \
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_TEXT" "$META_SKILL" "$META_REMINDER")"

# 10c. A route taken before the trailing isMeta injections -> NOT flagged. Proves
# the slice still reaches the tool_use rather than stopping at the meta boundary.
assert_not_flagged "route then isMeta injections -> not flagged (slice not truncated)" \
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_AGENT" "$META_SKILL")"

# 10d. Slash-command prompt followed by an isMeta skill body -> not flagged. The
# boundary must read the slash prompt, not the non-slash injected body.
assert_not_flagged "slash prompt then isMeta skill body -> not flagged" \
  "$(mk_transcript '{"type":"user","message":{"role":"user","content":"/insight"}}' "$DIRECTIVE_ATTACH" "$ASSISTANT_TEXT" "$META_SKILL")"

# 16. Task 805 — reframed channel turn, no marker row, inline authoring tools
# (the e48c2a5e freestyle shape) -> flagged. The reframe IS the directive.
assert_flagged "reframe turn + inline authoring, no marker -> flagged" \
  "$(mk_transcript "$CHANNEL_REFRAME" "$ASSISTANT_REPLY" "$ASSISTANT_BASH" "$ASSISTANT_WRITE")"
# 17. Same reframed turn with an Agent dispatch -> not flagged.
assert_not_flagged "reframe turn + Agent dispatch -> not flagged" \
  "$(mk_transcript "$CHANNEL_REFRAME" "$ASSISTANT_REPLY" "$ASSISTANT_AGENT")"
# 18. Reframed turn whose Context payload is one word -> trivial, not flagged,
# even though the wrapper text is long.
assert_not_flagged "reframe turn with one-word Context payload -> not flagged (trivial)" \
  "$(mk_transcript "$CHANNEL_REFRAME_TRIVIAL" "$ASSISTANT_TEXT")"
# 18b. Task 853 — double-wrapped multi-target reframe + inline authoring, no
# marker -> flagged. A single-strip would leave the inner tag and never see
# ## Context (the Task 805 re-blinding class).
assert_flagged "multi-target double-wrapped reframe + inline authoring -> flagged" \
  "$(mk_transcript "$CHANNEL_REFRAME_MULTI" "$ASSISTANT_REPLY" "$ASSISTANT_BASH" "$ASSISTANT_WRITE")"
# 18c. Same double-wrapped reframe with an Agent dispatch -> not flagged.
assert_not_flagged "multi-target double-wrapped reframe + Agent dispatch -> not flagged" \
  "$(mk_transcript "$CHANNEL_REFRAME_MULTI" "$ASSISTANT_REPLY" "$ASSISTANT_AGENT")"
# 18d. Task 1505 — rules-prefixed reframe, inline authoring, no marker -> flagged.
# The regression guard: with the standing-rules block above ## Context the pre-fix
# hook read is_reframe=False and silently stopped flagging this class.
assert_flagged "rules-prefixed reframe + inline authoring, no marker -> flagged" \
  "$(mk_transcript "$CHANNEL_REFRAME_RULES" "$ASSISTANT_REPLY" "$ASSISTANT_BASH" "$ASSISTANT_WRITE")"
# 18e. Same rules-prefixed reframe with an Agent dispatch -> not flagged.
assert_not_flagged "rules-prefixed reframe + Agent dispatch -> not flagged" \
  "$(mk_transcript "$CHANNEL_REFRAME_RULES" "$ASSISTANT_REPLY" "$ASSISTANT_AGENT")"
# 18f. Rules-prefixed reframe whose Context payload is one word -> trivial, not
# flagged (the prefix strip must leave just the payload for the trivial filter).
assert_not_flagged "rules-prefixed reframe with one-word Context payload -> not flagged (trivial)" \
  "$(mk_transcript "$CHANNEL_REFRAME_RULES_TRIVIAL" "$ASSISTANT_TEXT")"
# 19. Channel-wrapped NON-reframe turn (no ## Instruction section — webchat
# today, Task 776) with no marker -> not directive-equivalent, not flagged.
assert_not_flagged "channel-wrapped non-reframe turn, no marker -> not flagged" \
  "$(mk_transcript "$CHANNEL_PROMPT" "$ASSISTANT_TEXT")"

# 11. Fail-open: missing transcript_path -> exit 0, no output.
fo_out=$(printf '{"session_id":"x"}' | bash "$HOOK" 2>&1); fo_exit=$?
if [[ "$fo_exit" -eq 0 && -z "$fo_out" ]]; then
  echo "PASS: missing transcript_path -> exit 0, no output (fail-open)"; PASS=$((PASS+1))
else
  echo "FAIL: fail-open wrong (exit=$fo_exit out=$fo_out)" >&2; FAIL=$((FAIL+1))
fi

# 12. Fail-open: nonexistent transcript path -> exit 0, no output.
ne_out=$(printf '{"transcript_path":"/nonexistent/x.jsonl","session_id":"x"}' | bash "$HOOK" 2>&1); ne_exit=$?
if [[ "$ne_exit" -eq 0 && -z "$ne_out" ]]; then
  echo "PASS: nonexistent transcript -> exit 0, no output (fail-open)"; PASS=$((PASS+1))
else
  echo "FAIL: nonexistent-transcript fail-open wrong (exit=$ne_exit out=$ne_out)" >&2; FAIL=$((FAIL+1))
fi

# 13. Durable log: flag lands in $LOG_DIR/prompt-optimiser-directive.log.
LD=$(mktemp -d)
t13=$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_TEXT")
printf '{"transcript_path":"%s","session_id":"sess12345678"}' "$t13" | LOG_DIR="$LD" bash "$HOOK" >/dev/null 2>&1
if grep -q "no-route-taken" "$LD/prompt-optimiser-directive.log" 2>/dev/null \
   && grep -q "session=sess1234" "$LD/prompt-optimiser-directive.log" 2>/dev/null; then
  echo "PASS: flag appended to durable log with session id"; PASS=$((PASS+1))
else
  echo "FAIL: durable flag missing (log=$(cat "$LD/prompt-optimiser-directive.log" 2>/dev/null))" >&2; FAIL=$((FAIL+1))
fi
rm -rf "$LD" "$t13"

# 14. The emitted flag carries the tools= CSV of tool names seen in the slice,
# in order (Task 747 observability). A future false-negative shows which tool was
# (mis)counted.
t14=$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_REPLY" "$ASSISTANT_TOOLSEARCH")
out14=$(run_hook "$t14")
if printf '%s' "$out14" | grep -q 'tools=mcp__whatsapp-channel__reply,ToolSearch'; then
  echo "PASS: tools= CSV lists the slice's tool names in order"; PASS=$((PASS+1))
else
  echo "FAIL: tools= CSV wrong (got: $out14)" >&2; FAIL=$((FAIL+1))
fi
rm -f "$t14"

# 15. A no-mcp freestyle turn (the 45617005 shape) still flags, with an empty
# tools= field (no tool calls in the slice).
t15=$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_TEXT")
out15=$(run_hook "$t15")
if printf '%s' "$out15" | grep -q "no-route-taken" && printf '%s' "$out15" | grep -q 'tools= prompt='; then
  echo "PASS: no-tool freestyle turn flags with empty tools="; PASS=$((PASS+1))
else
  echo "FAIL: no-tool freestyle tools= wrong (got: $out15)" >&2; FAIL=$((FAIL+1))
fi
rm -f "$t15"

echo "----"
echo "PASS=$PASS FAIL=$FAIL"
[[ "$FAIL" -eq 0 ]]
