#!/usr/bin/env bash
#
# board-roll.sh — keep a LIVE orchestration board under its byte cap by rolling
#                 the oldest archival entries out to its append-only LEDGER.
#
# WHY: coordinator boards (MOS-ORCHESTRATION-BOARD-LIVE.md, MS-LEAD-BOARD-LIVE.md)
# enforce a "< 8 KB LIVE" discipline via a self-guard that ABORTs the board write
# when the file exceeds the cap. In practice the LIVE board keeps bumping the cap,
# so coordinators hand-trim + retry every time (observed: 38 ABORT-OVER-CAP cycles
# in a 24h window on one coordinator). This automates that trim, mechanically and
# reversibly, so the audit trail is preserved in the LEDGER instead of hand-deleted.
#
# CONTRACT (conservative by design — it NEVER guesses what is safe to move):
#   The LIVE board must declare an explicit ROLL ZONE with HTML-comment markers:
#
#       <!-- BOARD-ROLL:START -->
#       ### 2026-07-22 (newest tick — stays longest)
#       ...
#       ### 2026-07-19 (oldest tick — rolled first)
#       ...
#       <!-- BOARD-ROLL:END -->
#
#   Everything OUTSIDE the markers (title, protocol blockquote, curated always-current
#   `##` sections) is PINNED and never touched. Inside the zone, entries are delimited
#   by a heading marker (default `### `) and are assumed newest-first (top) → oldest-last
#   (bottom), matching board convention. board-roll moves whole oldest (bottom-most)
#   entry blocks out of the zone and APPENDS them verbatim to the LEDGER, one block at a
#   time, until the LIVE file is back under the cap or the zone is empty.
#
#   If no markers are present, it exits 3 without changing anything (safe default —
#   adding the markers is a deliberate opt-in by the board owner).
#
# USAGE:
#   board-roll.sh --live <LIVE.md> --ledger <LEDGER.md> [options]
#
# OPTIONS:
#   --live   <path>   LIVE board file (required)
#   --ledger <path>   append-only LEDGER file (required; created if absent)
#   --cap    <bytes>  size ceiling for LIVE (default 8192)
#   --marker <prefix> entry-heading prefix inside the roll zone (default "### ")
#   --dry-run         report what would move + resulting size; change nothing
#   -h, --help        print usage and exit 0
#
# EXIT CODES:
#   0  LIVE is under cap (already, or after rolling); on --dry-run, 0 = a plan exists
#      (or nothing to do)
#   2  usage / argument / IO error (bad flag, missing file, unwritable target)
#   3  cannot satisfy the cap: no roll markers present, MORE THAN ONE marker pair
#      (multiple zones are refused, not guessed), OR the zone was emptied and LIVE
#      is still over cap (curated pinned sections need a manual trim)
#
# NOTE: line endings are normalized to LF on rewrite (boards are LF markdown); a
# trailing newline is always ensured. Writes are atomic (temp file + mv) so a
# failure never leaves LIVE or LEDGER half-written.

set -euo pipefail

START_MARK='<!-- BOARD-ROLL:START -->'
END_MARK='<!-- BOARD-ROLL:END -->'

usage() {
    cat <<'EOF'
Usage: board-roll.sh --live <LIVE.md> --ledger <LEDGER.md> [options]

Roll the oldest entries out of a LIVE orchestration board into its LEDGER
until the LIVE file is under a byte cap. Conservative: only content inside
explicit <!-- BOARD-ROLL:START -->/<!-- BOARD-ROLL:END --> markers is moved.

Options:
  --live   <path>    LIVE board file (required)
  --ledger <path>    append-only LEDGER file (required; created if absent)
  --cap    <bytes>   size ceiling for LIVE (default 8192)
  --marker <prefix>  entry-heading prefix inside the roll zone (default "### ")
  --dry-run          report what would move; change nothing
  -h, --help         show this help and exit 0

Exit: 0 under cap (or dry-run plan) · 2 usage/IO error · 3 cannot meet cap
      (no markers, or pinned sections alone exceed the cap).
EOF
}

die() { echo "board-roll: $*" >&2; exit 2; }

LIVE=""; LEDGER=""; CAP=8192; MARKER='### '; DRYRUN=0
while [[ $# -gt 0 ]]; do
    case "$1" in
        --live)    LIVE="${2:-}"; shift 2 || die "--live needs a value" ;;
        --ledger)  LEDGER="${2:-}"; shift 2 || die "--ledger needs a value" ;;
        --cap)     CAP="${2:-}"; shift 2 || die "--cap needs a value" ;;
        --marker)  MARKER="${2:-}"; shift 2 || die "--marker needs a value" ;;
        --dry-run) DRYRUN=1; shift ;;
        -h|--help) usage; exit 0 ;;
        *) usage >&2; die "unknown option: $1" ;;
    esac
done

[[ -n "$LIVE"   ]] || { usage >&2; die "--live is required"; }
[[ -n "$LEDGER" ]] || { usage >&2; die "--ledger is required"; }
[[ -f "$LIVE"   ]] || die "LIVE file not found: $LIVE"
[[ "$CAP" =~ ^[0-9]+$ ]] || die "--cap must be a non-negative integer, got: $CAP"

# --- read LIVE into a line array (newlines stripped; re-added on write) ---------
mapfile -t LINES < "$LIVE"

# byte size of an array rendered as LF-terminated text
render_size() {
    if [[ $# -eq 0 ]]; then printf 0; return; fi
    printf '%s\n' "$@" | wc -c
}

orig_size=$(render_size "${LINES[@]}")

# --- already under cap → nothing to do -----------------------------------------
if (( orig_size < CAP )); then
    echo "board-roll: LIVE is ${orig_size}B (< cap ${CAP}B) — nothing to roll."
    exit 0
fi

# --- locate the roll-zone markers ----------------------------------------------
# Exactly ONE marker pair is supported. If a board carries more than one START or
# END marker we REFUSE (exit 3, zero changes) rather than guess: a naive
# first-START..last-END span would swallow the curated content and the intermediate
# markers sitting between two intended zones and silently relocate that pinned text
# to the LEDGER — the exact data-loss this tool exists to prevent. Refusing matches
# the "no markers = exit 3" conservative posture.
start_idx=-1; end_idx=-1; start_count=0; end_count=0
for i in "${!LINES[@]}"; do
    if [[ "${LINES[$i]}" == "$START_MARK" ]]; then
        if (( start_count == 0 )); then start_idx=$i; fi
        start_count=$(( start_count + 1 ))
    fi
    if [[ "${LINES[$i]}" == "$END_MARK" ]]; then
        end_idx=$i
        end_count=$(( end_count + 1 ))
    fi
done
if (( start_count > 1 || end_count > 1 )); then
    echo "board-roll: LIVE is ${orig_size}B (>= cap ${CAP}B) but has ${start_count} START / ${end_count} END" >&2
    echo "  markers — only a SINGLE '$START_MARK' … '$END_MARK' roll zone is supported." >&2
    echo "  Multiple zones are refused (not guessed) so content between zones is never relocated." >&2
    echo "  Consolidate the archival ticks into one zone, or trim manually." >&2
    exit 3
fi
if (( start_idx < 0 || end_idx < 0 || end_idx <= start_idx )); then
    echo "board-roll: LIVE is ${orig_size}B (>= cap ${CAP}B) but no usable roll zone" >&2
    echo "  (need '$START_MARK' then '$END_MARK'). Add the markers around the" >&2
    echo "  archival tick section to opt this board into automatic rolling." >&2
    exit 3
fi

# preamble = lines [0 .. start_idx]   (inclusive of START marker)
# zone     = lines (start_idx .. end_idx)  (exclusive of both markers)
# footer   = lines [end_idx .. end]   (inclusive of END marker)
preamble=(); zone=(); footer=()
for i in "${!LINES[@]}"; do
    if   (( i <= start_idx )); then preamble+=("${LINES[$i]}")
    elif (( i <  end_idx  )); then zone+=("${LINES[$i]}")
    else footer+=("${LINES[$i]}")
    fi
done

# --- split the zone into a fixed head + entry blocks ----------------------------
# zone_head = any zone lines before the first entry marker (kept, never rolled).
# blocks[k] = newline-joined text of entry k (marker line .. line before next marker).
zone_head=(); declare -a block_start=()
first_block=-1
for i in "${!zone[@]}"; do
    if [[ "${zone[$i]}" == "$MARKER"* ]]; then
        [[ $first_block -eq -1 ]] && first_block=$i
        block_start+=("$i")
    fi
done
if (( first_block == -1 )); then
    echo "board-roll: LIVE is ${orig_size}B (>= cap ${CAP}B) but the roll zone has no" >&2
    echo "  '${MARKER}' entries to move. Trim the pinned sections manually." >&2
    exit 3
fi
for (( i=0; i<first_block; i++ )); do zone_head+=("${zone[$i]}"); done

nblocks=${#block_start[@]}
# block k spans zone[ block_start[k] .. (block_start[k+1]-1 or end-of-zone) ]
block_text() {  # $1 = block index → prints the block's lines, LF-joined (no trailing)
    local k=$1 s e
    s=${block_start[$k]}
    if (( k+1 < nblocks )); then e=$(( block_start[$((k+1))] - 1 )); else e=$(( ${#zone[@]} - 1 )); fi
    local out=()
    for (( j=s; j<=e; j++ )); do out+=("${zone[$j]}"); done
    printf '%s\n' "${out[@]}"
}

# --- greedily roll oldest (bottom-most) blocks until under cap ------------------
# keep = number of newest blocks retained; start with all, drop from the bottom.
keep=$nblocks       # blocks [keep .. nblocks-1] are the oldest set that gets moved
current_size=$orig_size
build_live_size() {  # size of LIVE if we keep blocks [0 .. keep-1]
    local acc=("${preamble[@]}" "${zone_head[@]}")
    local k s e j
    for (( k=0; k<keep; k++ )); do
        s=${block_start[$k]}
        if (( k+1 < nblocks )); then e=$(( block_start[$((k+1))] - 1 )); else e=$(( ${#zone[@]} - 1 )); fi
        for (( j=s; j<=e; j++ )); do acc+=("${zone[$j]}"); done
    done
    acc+=("${footer[@]}")
    render_size "${acc[@]}"
}
while (( current_size >= CAP && keep > 0 )); do
    keep=$(( keep - 1 ))
    current_size=$(build_live_size)
done

moved_count=$(( nblocks - keep ))
if (( moved_count == 0 )); then
    # zone had entries but none movable brought us under (shouldn't happen: keep hits 0)
    echo "board-roll: could not reduce LIVE below cap (${current_size}B >= ${CAP}B)." >&2
    exit 3
fi

# --- dry-run report -------------------------------------------------------------
plan_headers() {
    local k
    for (( k=keep; k<nblocks; k++ )); do
        # first line of each moved block
        printf '    %s\n' "${zone[${block_start[$k]}]}"
    done
}
if (( DRYRUN )); then
    echo "board-roll: DRY RUN"
    echo "  LIVE now:   ${orig_size}B (cap ${CAP}B) — over by $(( orig_size - CAP ))B"
    echo "  would roll: ${moved_count} of ${nblocks} entr$([[ $moved_count -eq 1 ]] && echo y || echo ies) (oldest first):"
    plan_headers
    echo "  LIVE after: ${current_size}B"
    if (( current_size >= CAP )); then
        echo "  WARNING: still >= cap after emptying the zone; pinned sections need a manual trim." >&2
        exit 3
    fi
    exit 0
fi

# --- commit the roll atomically -------------------------------------------------
live_tmp="$(mktemp "${LIVE}.roll.XXXXXX")" || die "cannot create temp next to LIVE"
ledger_tmp=""
# shellcheck disable=SC2329  # invoked indirectly via `trap cleanup EXIT`
cleanup() { rm -f "$live_tmp" "$ledger_tmp" 2>/dev/null || true; }
trap cleanup EXIT

# new LIVE = preamble + zone_head + kept blocks + footer
{
    printf '%s\n' "${preamble[@]}" "${zone_head[@]}"
    for (( k=0; k<keep; k++ )); do block_text "$k"; done
    printf '%s\n' "${footer[@]}"
} > "$live_tmp"

# LEDGER gets the moved blocks appended verbatim, in original top→bottom order,
# under a provenance separator. LEDGER is append-only, so we only ever add at EOF.
ledger_tmp="$(mktemp "${LEDGER}.roll.XXXXXX")" || die "cannot create temp next to LEDGER"
if [[ -f "$LEDGER" ]]; then cat "$LEDGER" > "$ledger_tmp"; fi
# ensure a trailing newline on existing content before appending
if [[ -s "$ledger_tmp" && -n "$(tail -c1 "$ledger_tmp")" ]]; then printf '\n' >> "$ledger_tmp"; fi
{
    printf '\n<!-- board-roll: %d entr%s rolled from %s -->\n' \
        "$moved_count" "$([[ $moved_count -eq 1 ]] && echo y || echo ies)" "$(basename "$LIVE")"
    for (( k=keep; k<nblocks; k++ )); do block_text "$k"; done
} >> "$ledger_tmp"

# atomic swap (both, LEDGER first so a crash never drops content that left LIVE)
mv "$ledger_tmp" "$LEDGER"; ledger_tmp=""
mv "$live_tmp" "$LIVE"; live_tmp=""
trap - EXIT

# read the real on-disk size back (truthful, not the predicted value)
final_size=$(wc -c < "$LIVE")
echo "board-roll: rolled ${moved_count} entr$([[ $moved_count -eq 1 ]] && echo y || echo ies) to $(basename "$LEDGER"); LIVE ${orig_size}B → ${final_size}B (cap ${CAP}B)."
if (( final_size >= CAP )); then
    echo "board-roll: still >= cap after rolling all zone entries; pinned sections need a manual trim." >&2
    exit 3
fi
exit 0
