#!/usr/bin/env bash
# bulk-read.sh  -  read a large file on the cheap rung and return a summary.
#
# The worker behind `check-read-size.sh`. The gate says a file is too big to be
# worth reading whole at this phase's rung; this is where that read goes instead.
# The full text reaches a haiku-rung worker, the worker returns a structured
# summary WITH LINE NUMBERS, and only the summary enters the caller's context.
#
# Line numbers are the whole design, not a nicety. A summary without them is a
# dead end: the caller cannot edit from it, cannot verify it, and ends up reading
# the file anyway - having now paid twice. With them, the intended next step is a
# bounded `Read(file, offset:, limit:)` around the region that matters, which is
# both cheap and exact, and which the gate lets through.
#
# Usage:
#   bulk-read.sh --file <path> [--question "<what you need>"] [--phase N]
#                [--model <rung>] [--timeout <seconds>] [--json]
#
# Output: a human-readable summary on stdout, plus the ref id that buys the full
# text back. `--json` prints the worker's raw object instead, for a caller that
# wants to parse it.
#
# Degradation is explicit and always safe. No CLI, no auth, a timeout, or a
# non-JSON answer -> this prints WHY and tells the caller to fall back to a
# bounded read. It never invents a summary, and it never silently succeeds: a
# fabricated summary of a file nobody read is the one outcome worse than paying
# full price for the file.
#
# Exit codes: 0 summary produced · 3 degraded (caller should read it directly)
#             1 usage error.

set -uo pipefail

FILE=""
QUESTION="Summarize this file so a reader can decide which regions to open."
PHASE="${MULTI_AGENT_PHASE:-0}"
MODEL=""
TIMEOUT=""
AS_JSON=0

while [ $# -gt 0 ]; do
  case "$1" in
    --file) FILE="${2:?--file needs a value}"; shift 2 ;;
    --question) QUESTION="${2:?--question needs a value}"; shift 2 ;;
    --phase) PHASE="${2:?--phase needs a value}"; shift 2 ;;
    --model) MODEL="${2:?--model needs a value}"; shift 2 ;;
    --timeout) TIMEOUT="${2:?--timeout needs a value}"; shift 2 ;;
    --json) AS_JSON=1; shift ;;
    -h|--help) sed -n '2,30p' "$0"; exit 0 ;;
    *) echo "bulk-read: unknown argument '$1'" >&2; exit 1 ;;
  esac
done

[ -n "$FILE" ] || { echo "bulk-read: --file is required" >&2; exit 1; }
[ -f "$FILE" ] || { echo "bulk-read: not a readable file: $FILE" >&2; exit 1; }

HERE="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"

degrade() {
  echo "bulk-read: DEGRADED  -  $1" >&2
  echo "Read the region you need directly instead: Read('$FILE', offset: <n>, limit: <n>)." >&2
  [ -x "$HERE/log-metric.sh" ] && \
    "$HERE/log-metric.sh" "${MULTI_AGENT_TASK_ID:-unknown}" "$PHASE" bulk_read.degraded \
      reason="$1" >/dev/null 2>&1
  exit 3
}

# Resolve the three prefs this script honours - `bulkRead.model` (which rung the
# delegated read runs on), `bulkRead.timeoutSeconds` (how long it may take) and
# `bulkRead.maxBytes` (the ceiling past which delegation stops being a saving).
# Same search order and the same positive-integer rule as offload-ref.sh: a zero
# or a string takes the default rather than silently disabling the control.
PREF_MODEL=""
PREF_TIMEOUT=""
PREF_MAX_BYTES=""
if command -v jq >/dev/null 2>&1; then
  for prefs in \
    "$HOME/.claude/multi-agent-preferences.json" \
    "$HOME/.config/multi-agent-pipeline/multi-agent-preferences.json" \
    "$HOME/.claude/preferences.json" \
    "$HOME/.config/multi-agent-pipeline/preferences.json"
  do
    [ -f "$prefs" ] || continue
    values=$(jq -r '.global.bulkRead // {} | [(.model // ""), (.timeoutSeconds // ""), (.maxBytes // "")] | @tsv' \
      "$prefs" 2>/dev/null) || true
    PREF_MODEL=$(printf '%s' "$values" | cut -f1)
    PREF_TIMEOUT=$(printf '%s' "$values" | cut -f2)
    PREF_MAX_BYTES=$(printf '%s' "$values" | cut -f3)
    break
  done
fi
[ -n "$MODEL" ] || MODEL="${PREF_MODEL:-haiku}"
case "$TIMEOUT" in "" ) TIMEOUT="$PREF_TIMEOUT" ;; esac
case "$TIMEOUT" in ""|*[!0-9]*|0) TIMEOUT=60 ;; esac

command -v claude >/dev/null 2>&1 || degrade "the claude CLI is not on PATH"

LINES=$(wc -l < "$FILE" | tr -d ' ')
BYTES=$(wc -c < "$FILE" | tr -d ' ')

# A ceiling, because delegation is not free either. Past some size the worker's
# own input bill approaches the read it replaced, and a file large enough to
# strain the worker's window would come back truncated - a partial summary
# presented as a whole one is exactly what this must never produce. Degrading
# here hands the caller a cheaper move (grep for the symbol, then read around it)
# instead of an expensive round trip to a worse answer.
case "$PREF_MAX_BYTES" in ""|*[!0-9]*|0) MAX_BYTES=1048576 ;; *) MAX_BYTES="$PREF_MAX_BYTES" ;; esac
if [ "$BYTES" -gt "$MAX_BYTES" ]; then
  echo "bulk-read: DEGRADED  -  ${FILE} is ${BYTES} bytes, past the ${MAX_BYTES}-byte ceiling" >&2
  echo "At this size the delegated read costs about what reading it would, and risks a truncated" >&2
  echo "summary presented as a whole one. Narrow it first: grep for the symbol you need, then" >&2
  echo "Read('${FILE}', offset: <n>, limit: <n>) around the hit." >&2
  [ -x "$HERE/log-metric.sh" ] && \
    "$HERE/log-metric.sh" "${MULTI_AGENT_TASK_ID:-unknown}" "$PHASE" bulk_read.degraded \
      reason=over-ceiling file_bytes="$BYTES" >/dev/null 2>&1
  exit 3
fi

# Measured and accepted, so park the full text before asking anything: the
# pointer in the summary is then a promise that is already kept. The caller buys the rest back by reading this file - the same
# contract offload-ref.sh makes for build logs, and the same directory.
ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
[ -n "$ROOT" ] || ROOT="$PWD"
REFS_DIR="$ROOT/.multi-agent/refs"
NODE_ID=""
if mkdir -p "$REFS_DIR" 2>/dev/null; then
  # Shared with offload-ref.sh via pipeline/lib/repo-hygiene.sh. `../lib` resolves in
  # both layouts: pipeline/scripts -> pipeline/lib in the repo, ~/.claude/scripts ->
  # ~/.claude/lib installed. Fall back to writing it inline if the lib is absent,
  # so this script stays usable standalone.
  _MA_HYG="$(dirname "${BASH_SOURCE[0]}")/../lib/repo-hygiene.sh"
  if [ -f "$_MA_HYG" ]; then
    . "$_MA_HYG"
    ma_hygiene_local_gitignore "$ROOT"
  else
    GITIGNORE="$ROOT/.multi-agent/.gitignore"
    if [ ! -f "$GITIGNORE" ] || ! grep -q '^\*$' "$GITIGNORE" 2>/dev/null; then
      printf '# Local run artefacts  -  never commit. Ignores this file too.\n*\n' > "$GITIGNORE"
    fi
  fi
  if command -v shasum >/dev/null 2>&1; then
    DIGEST=$(shasum -a 256 "$FILE" | awk '{print substr($1,1,8)}')
  elif command -v sha256sum >/dev/null 2>&1; then
    DIGEST=$(sha256sum "$FILE" | awk '{print substr($1,1,8)}')
  else
    DIGEST=$(cksum < "$FILE" | awk '{print $1}')
  fi
  SLUG=$(basename "$FILE" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '-' | sed 's/-\{1,\}/-/g; s/^-//; s/-$//')
  NODE_ID="p${PHASE}-read-${SLUG:-file}-${DIGEST}"
  cp "$FILE" "$REFS_DIR/${NODE_ID}.txt" 2>/dev/null || NODE_ID=""
fi

PROMPT_FILE="$(mktemp -t bulk-read.XXXXXX)"
OUT_FILE="$(mktemp -t bulk-read-out.XXXXXX)"
trap 'rm -f "$PROMPT_FILE" "$OUT_FILE"' EXIT

# The worker gets numbered lines because every claim it makes has to be
# addressable afterwards. `nl -ba` numbers blank lines too, so the numbers match
# the file's own and an offset computed from them is correct.
# The worker's contract is the bulk-reader PERSONA, loaded from disk rather than
# restated here. Two copies of the same prompt is the drift this repo keeps
# catching elsewhere: the copy that gets edited is never the copy that runs.
PERSONA=""
for candidate in \
  "$HOME/.claude/agents/bulk-reader.md" \
  "$HERE/../agents/bulk-reader.md"
do
  [ -f "$candidate" ] && { PERSONA="$candidate"; break; }
done
[ -n "$PERSONA" ] || degrade "the bulk-reader persona is not installed"

# The frontmatter is dispatch metadata for the Agent tool, not instruction text;
# strip it and send the body.
{
  awk 'BEGIN{fm=0} /^---$/{fm++; next} fm>=2{print}' "$PERSONA"
  printf '\n<question>\n%s\n</question>\n\n' "$QUESTION"
  printf '<file path="%s" lines="%s">\n' "$FILE" "$LINES"
  nl -ba "$FILE"
  printf '\n</file>\n'
} > "$PROMPT_FILE"

START=$(date +%s)
# `claude -p` reads the prompt from stdin; the model flag names the rung. A
# non-zero exit, an empty answer and a non-JSON answer are all degradations, and
# each one names itself rather than falling through to a generic failure.
# macOS ships no `timeout`; coreutils installs it as `gtimeout`. Naming only the
# GNU spelling would leave the budget silently unenforced on the platform most of
# these runs happen on - a pref that does nothing, which is the class
# smoke-prefs-consumed exists to catch.
TIMEOUT_BIN=""
for candidate in timeout gtimeout; do
  command -v "$candidate" >/dev/null 2>&1 && { TIMEOUT_BIN="$candidate"; break; }
done
if [ -n "$TIMEOUT_BIN" ]; then
  "$TIMEOUT_BIN" "$TIMEOUT" claude -p --model "$MODEL" < "$PROMPT_FILE" > "$OUT_FILE" 2>/dev/null
  STATUS=$?
else
  # No timeout binary: the budget cannot be enforced, so say so rather than
  # letting the caller believe timeoutSeconds is holding.
  echo "bulk-read: no timeout binary found; bulkRead.timeoutSeconds is not enforced on this host" >&2
  claude -p --model "$MODEL" < "$PROMPT_FILE" > "$OUT_FILE" 2>/dev/null
  STATUS=$?
fi
ELAPSED=$(( $(date +%s) - START ))

[ "$STATUS" -eq 124 ] && degrade "the worker did not answer within ${TIMEOUT}s"
[ "$STATUS" -ne 0 ] && degrade "the worker exited $STATUS"
[ -s "$OUT_FILE" ] || degrade "the worker returned nothing"

RENDER=$(FILE="$FILE" LINES="$LINES" BYTES="$BYTES" NODE_ID="$NODE_ID" \
         MODEL="$MODEL" ELAPSED="$ELAPSED" AS_JSON="$AS_JSON" \
         python3 - "$OUT_FILE" <<'PYEOF'
import json, os, re, sys

raw = open(sys.argv[1], encoding="utf-8", errors="replace").read()
try:
    data = json.loads(raw)
except Exception:
    # A worker that wrapped its object in prose or a fence is still usable; a
    # worker that answered in prose is not, and falls through to the degrade path.
    m = re.search(r"\{[\s\S]*\}", raw)
    if not m:
        sys.exit(7)
    try:
        data = json.loads(m.group(0))
    except Exception:
        sys.exit(7)

if not isinstance(data, dict) or "summary" not in data:
    sys.exit(7)

if os.environ.get("AS_JSON") == "1":
    print(json.dumps(data, indent=2))
    sys.exit(0)

out = []
out.append("bulk-read: %s (%s lines, %s bytes) via %s in %ss"
           % (os.environ["FILE"], os.environ["LINES"], os.environ["BYTES"],
              os.environ["MODEL"], os.environ["ELAPSED"]))
answer = (data.get("answer") or "").strip()
if answer:
    out.append("")
    out.append("ANSWER: " + answer)
out.append("")
out.append((data.get("summary") or "").strip())

symbols = [s for s in (data.get("symbols") or []) if isinstance(s, dict)]
if symbols:
    out.append("")
    out.append("Symbols:")
    for s in symbols[:40]:
        out.append("  %-6s %s  (line %s)" % (s.get("kind", "?"), s.get("name", "?"), s.get("line", "?")))

regions = [r for r in (data.get("regions") or []) if isinstance(r, dict)]
if regions:
    out.append("")
    out.append("Read next (bounded, and this gate allows it):")
    for r in regions[:8]:
        start, end = r.get("start"), r.get("end")
        span = ""
        try:
            span = "  Read(offset: %d, limit: %d)" % (int(start), int(end) - int(start) + 1)
        except Exception:
            pass
        out.append("  %s-%s  %s%s" % (start, end, r.get("why", ""), span))

node = os.environ.get("NODE_ID") or ""
if node:
    out.append("")
    out.append("Full text: .multi-agent/refs/%s.txt  [[ref:%s]]" % (node, node))
if data.get("truncated"):
    out.append("")
    out.append("NOTE: the worker reports it did not see the whole file.")
print("\n".join(out))
PYEOF
) || degrade "the worker's answer was not the requested JSON object"

printf '%s\n' "$RENDER"

[ -x "$HERE/log-metric.sh" ] && \
  "$HERE/log-metric.sh" "${MULTI_AGENT_TASK_ID:-unknown}" "$PHASE" bulk_read.delegated \
    file_lines="$LINES" file_bytes="$BYTES" model="$MODEL" duration_ms="$(( ELAPSED * 1000 ))" \
    >/dev/null 2>&1

exit 0
