#!/usr/bin/env bash
# Brand log rotation — Task 1706.
#
# WHY THIS EXISTS AND NOT logrotate: server.log, edge.log and cloudflared.log
# are systemd stdout sinks (`StandardOutput=append:`, see
# packages/create-maxy-code/src/port-resolution.ts:233 and
# platform/scripts/resume-tunnel.sh:230). systemd opens the file O_APPEND ONCE
# at unit start and the process holds that fd for its whole life. logrotate's
# default rename-then-create would leave every service writing into the rotated
# inode forever — rotation would look like it worked while the live file stayed
# empty. So we gzip a COPY and truncate IN PLACE: because the fd is O_APPEND,
# each write seeks to end-of-file first, so writes resume at offset 0 with no
# service restart and no sparse hole. Verified against a real systemd unit on
# 2026-07-17 (laptop 192.168.88.10): NRestarts=0, file 71 -> 0 -> 80 bytes for
# 10 fresh lines. Never replace `: > "$F"` with a mv.
#
# Portability: this runs on Linux (cron) and darwin (launchd StartInterval), so
# no flock(1) (absent on darwin) and no `date +%N` (GNU-only). The lock is a
# mkdir, which is atomic on both. find's -mmin/-mtime/-delete and the timestamp
# format were confirmed identical on both platforms.
#
# Usage: logs-rotate.sh [LOGS_DIR]
set -uo pipefail

ROTATE_SIZE_BYTES=$((64 * 1024 * 1024))
KEEP=5
STALE_LOCK_MINUTES=30

# Resolve the logs dir the same way logs-read.sh:52-58 does: convention is
# configDir = ".${installDir}".
if [ "$#" -ge 1 ] && [ -n "${1:-}" ]; then
  LOGS_DIR="$1"
else
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  PLATFORM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
  INSTALL_DIR="$(dirname "$PLATFORM_ROOT")"
  LOGS_DIR="$HOME/.$(basename "$INSTALL_DIR")/logs"
fi

# Absent logs dir is not an error: the installer creates it, and a rotator tick
# that lands before the first install completes has nothing to do.
[ -d "$LOGS_DIR" ] || exit 0

LOCK="$LOGS_DIR/.rotate.lock"

stamp() { date -u +%Y-%m-%dT%H:%M:%SZ; }

# A mkdir lock is NOT released by the kernel when its holder dies, so a crashed
# rotator would wedge rotation permanently and silently — worse than the race
# the lock prevents. Reclaim on age: 30 min is two rotator ticks and 90x
# resume-tunnel.sh's 20s window, so no live holder is mistaken for a dead one.
#
# The reclaim moves the stale lock aside with `mv` before recreating it, rather
# than `rm -rf`-then-mkdir. `mv` is the atomic gate: if two ticks both see the
# same stale lock, only one can move that inode — the loser's `mv` fails because
# the source is already gone, and it backs off. rm-then-mkdir had no such gate:
# the loser would delete the winner's brand-new lock and both would rotate,
# shifting generations twice for one new .1.gz.
acquire_lock() {
  if mkdir "$LOCK" 2>/dev/null; then return 0; fi
  if [ -d "$LOCK" ] && [ -z "$(find "$LOCK" -maxdepth 0 -mmin -"$STALE_LOCK_MINUTES" 2>/dev/null)" ]; then
    local stale="$LOCK.stale.$$"
    mv "$LOCK" "$stale" 2>/dev/null || return 1   # lost the reclaim race
    rm -rf "$stale" 2>/dev/null
    echo "$(stamp) [logs-rotate] stale-lock-reclaimed lock=$LOCK age-min=>$STALE_LOCK_MINUTES"
    mkdir "$LOCK" 2>/dev/null && return 0          # a third tick may have won; then back off
  fi
  return 1
}

if ! acquire_lock; then
  # Another rotator tick, or a resume-tunnel.sh verification window, is live.
  # Exit 0: the next tick is 15 min away and the bound is not tight enough for
  # a skipped tick to matter.
  echo "$(stamp) [logs-rotate] skipped reason=lock-held lock=$LOCK"
  exit 0
fi
trap 'rmdir "$LOCK" 2>/dev/null || true' EXIT

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

# Size-rotate one file: gzip a copy to a temp, shift older generations, move the
# temp into .1.gz, truncate in place, then mark the truncation so the file is
# never mistaken for one that simply starts there.
#
# ORDER IS LOAD-BEARING: gzip FIRST, shift only once it succeeded. The shift used
# to run first, so a gzip failure moved .4->.5 ... .1->.2 and wrote nothing to
# .1 — dropping the oldest generation and leaving a hole, on every tick. The
# trigger is ENOSPC, i.e. precisely the condition rotation exists to prevent: 75
# minutes of a full disk destroyed 4 of the 5 retained generations while
# correctly refusing to truncate. Never hoist the shift above the gzip.
rotate_file() {
  local f="$1" base size i prev
  [ -f "$f" ] || return 0
  size="$(size_of "$f")"
  [ "$size" -gt "$ROTATE_SIZE_BYTES" ] || return 0
  base="$(basename "$f")"

  # gzip to a temp first, so a failure (or a tick killed mid-compress) costs
  # nothing: no generation has moved and the live file is untouched.
  #
  # gzip's stderr is CAPTURED, not discarded, and relayed verbatim on failure —
  # it is the only thing that distinguishes ENOSPC from EACCES from a missing
  # binary. `2>&1 >file` sends stderr to the command substitution and stdout to
  # the temp; the order matters.
  #
  # A live log grows while gzip reads it, so gzip emits "file size changed while
  # zipping" on nearly every real rotation. That is a WARNING and gzip still
  # exits 0 (verified on the laptop against a file being appended to during the
  # read), so it takes the success path and the noise is never logged. Only a
  # genuine non-zero exit reaches the branch below.
  local gzerr
  gzerr="$(gzip -c "$f" 2>&1 >"$f.1.gz.tmp")"
  if [ $? -ne 0 ]; then
    rm -f "$f.1.gz.tmp"
    echo "$(stamp) [logs-rotate] ERROR file=$base reason=gzip-failed bytes=$size err=$(printf '%s' "$gzerr" | tr '\n' ' ') — not truncated, generations intact"
    return 1
  fi

  # Shift down from the oldest so nothing is overwritten before it moves. The
  # KEEP-th generation is overwritten by the (KEEP-1)-th, which is how the
  # oldest falls off.
  i="$KEEP"
  while [ "$i" -gt 1 ]; do
    prev=$((i - 1))
    [ -f "$f.$prev.gz" ] && mv -f "$f.$prev.gz" "$f.$i.gz"
    i="$prev"
  done
  mv -f "$f.1.gz.tmp" "$f.1.gz"

  # THE load-bearing line. Truncate in place; do not mv. See the header.
  #
  # Lines written between gzip reaching EOF and this truncate are LOST. gzip
  # reads to the CURRENT end of file, so the window is the shift plus two mvs —
  # milliseconds, not the seconds the gzip itself takes. Inherent to
  # copy-truncate, which the O_APPEND fd leaves no way around.
  #
  # `size` was sampled BEFORE gzip ran, so on a live log the archive legitimately
  # holds slightly MORE than prev-bytes reports. prev-bytes locates the seam
  # approximately, not exactly. See .docs/brand-log-retention.md.
  : > "$f"
  echo "$(stamp) [logs-rotate] rotated file=$base prev-bytes=$size prev-file=$base.1.gz keep=$KEEP" >> "$f"
}

PURGE_DAYS=7

for f in server.log edge.log cloudflared.log check-due-events.log; do
  rotate_file "$LOGS_DIR/$f"
done

# Globbed size targets. nullglob so a brand with none of these yet does not try
# to rotate a literal '*' path.
#
# mcp-*.log: the raw sinks (mcp-<server>-<sessionId>.log, mcp-<server>-nosession.log)
# plus any stale mcp-<server>-stderr-<date>.log orphan from before Task 1721. All
# are appendFileSync writers holding no fd.
#
# rc-child-*.log: its 16 MiB writer cap does NOT hold. `written`
# (rc-child-log.ts:33) is in-memory per manager process, so a manager restart
# resumes appending to the same path with a fresh counter. Observed at 54 MB on
# sitedesk-code, 3.25x the cap. A long-lived session's file also has a fresh
# mtime forever, so the age purge below never reaches it — without size rotation
# it is genuinely unbounded.
shopt -s nullglob
for f in "$LOGS_DIR"/mcp-*.log "$LOGS_DIR"/rc-child-*.log; do
  rotate_file "$f"
done
shopt -u nullglob

# Age purge — rc-child and the mcp sinks.
#
# rc-child-*.log and mcp-<server>-<sessionId>.log are one file per session and
# grow without bound (100 rc-child on sitedesk-code). mcp-<server>-nosession.log
# is one fixed file per server instead, so the set never grows; like a live
# rc-child its mtime stays fresh, so the age purge reaps only its rotated
# generations and size rotation above is its real bound.
#
# Purging is SAFE because every one of them writes with appendFileSync
# (rc-child-log.ts:51; mcp-spawn-tee appendSafe): no fd is held, so a purged
# file is simply recreated on the next write.
#
# An earlier draft of 1706 could not purge mcp-*, because a since-retired stderr
# tee opened a createWriteStream at process start and held it for the process's
# whole life. A live file's mtime went stale, so unlinking it sent every later
# stderr write to an inode no one could read. That held stream is gone (Task 1721
# retired the per-date file; the tee module itself was later removed), which is
# what makes this purge safe — the hazard was the held fd, not the naming.
#
# The pattern is `.log*` so a purged session's rotated .gz generations go too.
# `find` matches each path by its own mtime — there is no sibling relationship —
# so a LIVE rc-child's generations also age out at 7 days. That is intended:
# retention for rc-child is min(KEEP, 7 days), and 7 days of a session's spawn
# output is the stated bound.
# One traversal, and the count is what was actually deleted rather than what a
# separate counting pass saw a moment earlier. `-print` before `-delete` prints
# each path as it goes.
purge_old() {
  local pattern="$1" n
  n="$(find "$LOGS_DIR" -maxdepth 1 -name "$pattern" -type f -mtime +"$PURGE_DAYS" -print -delete 2>/dev/null | wc -l | awk '{print $1+0}')"
  [ "$n" -gt 0 ] || return 0
  echo "$(stamp) [logs-rotate] purged pattern=$pattern files=$n older-than-days=$PURGE_DAYS"
}

purge_old 'rc-child-*.log*'
purge_old 'mcp-*.log*'

exit 0
