#!/usr/bin/env bash
# Tests for logs-rotate.sh (Task 1706). Fixture-driven: builds a throwaway logs
# dir per case, runs the real script against it, asserts on the resulting files.
# No stubs — gzip/find/wc/date are the real binaries, because the portability of
# exactly those calls across Linux and darwin is part of what this asserts.
set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROTATE="$SCRIPT_DIR/../logs-rotate.sh"

PASS=0
FAIL=0

run_case() {
  local name="$1"; shift
  echo ""
  echo "=== CASE: $name ==="
  if "$@"; then
    PASS=$((PASS + 1))
    echo "PASS: $name"
  else
    FAIL=$((FAIL + 1))
    echo "FAIL: $name"
  fi
}

# Build a throwaway logs dir. Echoes its path.
mk_logs_dir() {
  local d
  d="$(mktemp -d "${TMPDIR:-/tmp}/logs-rotate-test.XXXXXX")"
  echo "$d"
}

# Write a file of exactly $2 bytes at $1. $2 must be a whole number of MiB.
# bs=1048576 rather than bs=1m/1M: the suffix spellings differ between BSD and
# GNU dd, and this suite runs on both.
mk_file_of_size() {
  local path="$1" bytes="$2" mib
  mib=$(( bytes / 1048576 ))
  dd if=/dev/zero bs=1048576 count="$mib" 2>/dev/null | tr '\0' 'x' > "$path"
}

size_of() { wc -c < "$1" 2>/dev/null | awk '{print $1+0}'; }

# Backdate a path by $2 days. `date -r` is BSD, `date -d @` is GNU.
backdate() {
  local path="$1" days="$2" secs
  secs=$(( $(date +%s) - days * 86400 ))
  touch -t "$(date -u -r "$secs" +%Y%m%d%H%M 2>/dev/null || date -u -d "@$secs" +%Y%m%d%H%M)" "$path"
}

case_under_threshold_is_noop() {
  local d; d="$(mk_logs_dir)"
  # 63 MiB — just under the 64 MiB threshold, so this also pins the boundary.
  mk_file_of_size "$d/server.log" $((63 * 1024 * 1024))
  "$ROTATE" "$d" >/dev/null 2>&1
  local rc=$?
  [ "$rc" -eq 0 ] || { echo "  expected exit 0, got $rc"; rm -rf "$d"; return 1; }
  [ ! -e "$d/server.log.1.gz" ] || { echo "  rotated a 63 MiB file"; rm -rf "$d"; return 1; }
  [ "$(size_of "$d/server.log")" -eq $((63 * 1024 * 1024)) ] \
    || { echo "  file was modified"; rm -rf "$d"; return 1; }
  rm -rf "$d"; return 0
}

case_over_threshold_rotates_and_truncates() {
  local d; d="$(mk_logs_dir)"
  mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
  "$ROTATE" "$d" >/dev/null 2>&1
  [ -f "$d/server.log.1.gz" ] || { echo "  no .1.gz produced"; rm -rf "$d"; return 1; }
  # The live file must still exist (readers hardcode the path) and be small:
  # it holds only the marker line.
  [ -f "$d/server.log" ] || { echo "  live path missing after rotate"; rm -rf "$d"; return 1; }
  local live; live="$(size_of "$d/server.log")"
  [ "$live" -lt 4096 ] || { echo "  live file not truncated: $live bytes"; rm -rf "$d"; return 1; }
  rm -rf "$d"; return 0
}

case_marker_line_present() {
  local d; d="$(mk_logs_dir)"
  mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
  "$ROTATE" "$d" >/dev/null 2>&1
  grep -q '\[logs-rotate\] rotated file=server.log prev-bytes=68157440 prev-file=server.log.1.gz keep=5' "$d/server.log" \
    || { echo "  marker line absent or malformed:"; cat "$d/server.log"; rm -rf "$d"; return 1; }
  # The marker must be datable from itself.
  grep -qE '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z \[logs-rotate\]' "$d/server.log" \
    || { echo "  marker line not timestamped"; rm -rf "$d"; return 1; }
  rm -rf "$d"; return 0
}

case_generation_shift_drops_the_sixth() {
  local d; d="$(mk_logs_dir)"
  # Seed 5 existing generations, each identifiable by content.
  local i
  for i in 1 2 3 4 5; do echo "gen$i" | gzip -c > "$d/server.log.$i.gz"; done
  mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
  "$ROTATE" "$d" >/dev/null 2>&1
  # .5.gz must now hold what .4.gz held; the old .5.gz is gone.
  [ "$(gzip -cd "$d/server.log.5.gz")" = "gen4" ] || { echo "  .5.gz is not the old .4.gz"; rm -rf "$d"; return 1; }
  [ "$(gzip -cd "$d/server.log.2.gz")" = "gen1" ] || { echo "  .2.gz is not the old .1.gz"; rm -rf "$d"; return 1; }
  [ ! -e "$d/server.log.6.gz" ] || { echo "  a 6th generation was created"; rm -rf "$d"; return 1; }
  rm -rf "$d"; return 0
}

case_lock_held_is_noop_exit_zero() {
  local d; d="$(mk_logs_dir)"
  mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
  mkdir "$d/.rotate.lock"          # fresh lock, not stale
  "$ROTATE" "$d" >/dev/null 2>&1
  local rc=$?
  [ "$rc" -eq 0 ] || { echo "  expected exit 0 when lock held, got $rc"; rm -rf "$d"; return 1; }
  [ ! -e "$d/server.log.1.gz" ] || { echo "  rotated despite held lock"; rm -rf "$d"; return 1; }
  # A held lock must survive the tick that could not take it.
  [ -d "$d/.rotate.lock" ] || { echo "  another holder's lock was removed"; rm -rf "$d"; return 1; }
  rm -rf "$d"; return 0
}

case_stale_lock_is_reclaimed() {
  local d; d="$(mk_logs_dir)"
  mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
  mkdir "$d/.rotate.lock"
  backdate "$d/.rotate.lock" 1     # 1 day > STALE_LOCK_MINUTES=30
  "$ROTATE" "$d" >/dev/null 2>&1
  [ -f "$d/server.log.1.gz" ] || { echo "  stale lock was not reclaimed"; rm -rf "$d"; return 1; }
  rm -rf "$d"; return 0
}

case_missing_logs_dir_is_noop_exit_zero() {
  "$ROTATE" "/nonexistent/logs/dir/for/test" >/dev/null 2>&1
  local rc=$?
  [ "$rc" -eq 0 ] || { echo "  expected exit 0 on missing dir, got $rc"; return 1; }
  return 0
}

case_lock_released_on_exit() {
  local d; d="$(mk_logs_dir)"
  mk_file_of_size "$d/server.log" $((1 * 1024 * 1024))
  "$ROTATE" "$d" >/dev/null 2>&1
  [ ! -e "$d/.rotate.lock" ] || { echo "  lock not released"; rm -rf "$d"; return 1; }
  rm -rf "$d"; return 0
}

case_purges_rc_child_older_than_seven_days() {
  local d; d="$(mk_logs_dir)"
  echo old > "$d/rc-child-aaaa.log"; backdate "$d/rc-child-aaaa.log" 9
  echo new > "$d/rc-child-bbbb.log"   # today
  "$ROTATE" "$d" >/dev/null 2>&1
  [ ! -e "$d/rc-child-aaaa.log" ] || { echo "  9-day-old rc-child survived"; rm -rf "$d"; return 1; }
  [ -f "$d/rc-child-bbbb.log" ] || { echo "  today's rc-child was purged"; rm -rf "$d"; return 1; }
  rm -rf "$d"; return 0
}

case_purge_does_not_touch_server_log() {
  local d; d="$(mk_logs_dir)"
  echo old > "$d/server.log"; backdate "$d/server.log" 90
  echo x | gzip -c > "$d/server.log.1.gz"; backdate "$d/server.log.1.gz" 90
  "$ROTATE" "$d" >/dev/null 2>&1
  [ -f "$d/server.log" ] || { echo "  server.log was purged by age"; rm -rf "$d"; return 1; }
  [ -f "$d/server.log.1.gz" ] || { echo "  a kept generation was purged by age"; rm -rf "$d"; return 1; }
  rm -rf "$d"; return 0
}

case_mcp_sink_live_file_size_rotates() {
  local d; d="$(mk_logs_dir)"
  mk_file_of_size "$d/mcp-memory-sess-abc.log" $((65 * 1024 * 1024))
  "$ROTATE" "$d" >/dev/null 2>&1
  [ -f "$d/mcp-memory-sess-abc.log.1.gz" ] \
    || { echo "  live mcp sink was not size-rotated"; rm -rf "$d"; return 1; }
  rm -rf "$d"; return 0
}

# --- Code-review regressions (2026-07-17) -----------------------------------

# A failing gzip must not cost a generation. The shift used to run BEFORE the
# gzip, so every failure moved .4->.5 ... .1->.2 and wrote nothing to .1 —
# leaving a hole and dropping the oldest. The trigger is ENOSPC, i.e. exactly
# the condition rotation exists to prevent: 5 failing ticks destroyed 4 of the
# 5 retained generations while never truncating the live file.
case_gzip_failure_preserves_generations() {
  local d; d="$(mk_logs_dir)"
  local bin="$d/../bin-$$"; mkdir -p "$bin"
  printf '#!/bin/sh\nexit 1\n' > "$bin/gzip"; chmod +x "$bin/gzip"
  local i
  for i in 1 2 3 4 5; do echo "gen$i" | gzip -c > "$d/server.log.$i.gz"; done
  mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))

  PATH="$bin:$PATH" "$ROTATE" "$d" >/dev/null 2>&1
  local rc=$?

  local fail=0
  for i in 1 2 3 4 5; do
    if [ "$(gzip -cd "$d/server.log.$i.gz" 2>/dev/null)" != "gen$i" ]; then
      echo "  .$i.gz should still be gen$i, got: $(gzip -cd "$d/server.log.$i.gz" 2>/dev/null)"; fail=1
    fi
  done
  # The live file must be untouched — a failed rotate never truncates.
  [ "$(size_of "$d/server.log")" -eq $((65 * 1024 * 1024)) ] \
    || { echo "  live file was truncated despite gzip failure"; fail=1; }
  [ "$rc" -eq 0 ] || { echo "  expected exit 0, got $rc"; fail=1; }
  [ ! -e "$d/server.log.1.gz.tmp" ] || { echo "  temp file left behind"; fail=1; }
  rm -rf "$d" "$bin"; return $fail
}

# Task 1721 retired the per-date file and with it a since-removed tee's held
# write stream. Every remaining mcp sink is appendFileSync, so purging one is safe for
# the same reason purging rc-child is: no fd is held, and the next write simply
# recreates the file. The stale `mcp-*-stderr-<date>.log` orphans left by the old
# writers age out through the same glob.
case_purges_mcp_sink_older_than_seven_days() {
  local d; d="$(mk_logs_dir)"
  echo old > "$d/mcp-memory-sess-old.log";  backdate "$d/mcp-memory-sess-old.log" 9
  echo x | gzip -c > "$d/mcp-memory-sess-old.log.1.gz"; backdate "$d/mcp-memory-sess-old.log.1.gz" 9
  echo legacy > "$d/mcp-memory-stderr-2026-01-01.log"; backdate "$d/mcp-memory-stderr-2026-01-01.log" 90
  echo new > "$d/mcp-memory-sess-new.log"   # today
  "$ROTATE" "$d" >/dev/null 2>&1
  [ ! -e "$d/mcp-memory-sess-old.log" ] \
    || { echo "  9-day-old mcp sink survived"; rm -rf "$d"; return 1; }
  [ ! -e "$d/mcp-memory-sess-old.log.1.gz" ] \
    || { echo "  9-day-old mcp generation survived"; rm -rf "$d"; return 1; }
  [ ! -e "$d/mcp-memory-stderr-2026-01-01.log" ] \
    || { echo "  retired per-date orphan was not swept"; rm -rf "$d"; return 1; }
  [ -f "$d/mcp-memory-sess-new.log" ] \
    || { echo "  today's mcp sink was purged"; rm -rf "$d"; return 1; }
  rm -rf "$d"; return 0
}

# rc-child's 16 MiB cap does NOT hold: `written` is in-memory per manager
# process, so a manager restart resumes appending to the same path. Observed at
# 54 MB on sitedesk-code (3.25x the cap). A live long-lived session's file has a
# fresh mtime forever, so age-purge never reaches it — it needs size rotation.
case_rc_child_size_rotates() {
  local d; d="$(mk_logs_dir)"
  mk_file_of_size "$d/rc-child-live-session.log" $((65 * 1024 * 1024))
  "$ROTATE" "$d" >/dev/null 2>&1
  [ -f "$d/rc-child-live-session.log.1.gz" ] \
    || { echo "  an oversized rc-child log was not size-rotated"; rm -rf "$d"; return 1; }
  local live; live="$(size_of "$d/rc-child-live-session.log")"
  [ "$live" -lt 4096 ] || { echo "  rc-child live file not truncated: $live"; rm -rf "$d"; return 1; }
  rm -rf "$d"; return 0
}

run_case "gzip failure preserves generations"    case_gzip_failure_preserves_generations
run_case "purges mcp sink older than 7 days"     case_purges_mcp_sink_older_than_seven_days
run_case "rc-child size-rotates"                 case_rc_child_size_rotates

run_case "under threshold is a no-op"            case_under_threshold_is_noop
run_case "over threshold rotates and truncates"  case_over_threshold_rotates_and_truncates
run_case "marker line present and timestamped"   case_marker_line_present
run_case "generation shift drops the sixth"      case_generation_shift_drops_the_sixth
run_case "lock held is a no-op, exit 0"          case_lock_held_is_noop_exit_zero
run_case "stale lock is reclaimed"               case_stale_lock_is_reclaimed
run_case "missing logs dir is a no-op, exit 0"   case_missing_logs_dir_is_noop_exit_zero
run_case "lock released on exit"                 case_lock_released_on_exit
run_case "purges rc-child older than 7 days"     case_purges_rc_child_older_than_seven_days
run_case "purge does not touch server.log"       case_purge_does_not_touch_server_log
run_case "mcp sink live file size-rotates"       case_mcp_sink_live_file_size_rotates

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