#!/bin/bash
# ql-demos — Quake Live demo browser and player
#
# Usage: ql-demos [command] [args...]
#   (no args)       — interactive TUI (fzf)
#   list [filter]   — list demos, optionally filtered
#   recent [N]      — show N most recent (default: 20)
#   maps            — maps by play count
#   opponents       — opponents by play count
#   modes           — game modes by play count
#   watch <file>    — play demo in wolfcam-ql
#   play <file>     — launch demo in Quake Live via Steam
#   info <file>     — show demo metadata (UDT_json)
#   record <file>   — record demo to video (ffmpeg)
#   backup [dest]   — rsync demos to dest (default: ~/ql-demos-backup)
#   aim [N]         — accuracy-over-time graphs, per weapon, batches of
#                     N games (default N=10; --refresh rebuilds cache)
#
# Aim stats:
#   Terminal shows only the trend graphs (sparklines), both metrics:
#     mean  — simple average of each game's accuracy%
#     wavg  — shot-weighted: total hits / total shots (true hit-rate)
#   The full per-batch table (both metrics) is written to files, not
#   printed: aim-table.txt (human-readable) and aim-stats.csv.
#   Games are date-sorted then cut into batches of N (gaps don't matter).
#   Parse cache: .aim-cache.<player>.tsv in the script dir, one per
#     player, keyed by demo filename. Auto-incremental — only new demos
#     are parsed on each run; --refresh re-parses everything from scratch
#     (also auto-rebuilds when the cache format changes). Gitignored.
#   Env: QL_PLAYER (auto-detected from QL's qzconfig.cfg if unset),
#        QL_AIM_BATCH (default 10).
#
# TUI keys (single-mode, no command prefix):
#   nav      j/k move  g/G top/end  J/K preview scroll  ctrl-d/u half-page
#   filter   s/S cycle sort field: date|map|opponent (S reverse cycle)
#            R   toggle direction of active sort (asc/desc)
#            m/M cycle mode filter: all|Duel|FFA|iFFA|CA|TDM|RACE|FT (M reverse)
#            b   toggle bot games
#   action   i info  p play(QL Steam)  P play(wolfcam)  d del  r rec  q quit
#   search   / enter search  esc exit search

set -euo pipefail

# Resolve project root (follow symlinks)
SCRIPTDIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"

QLAPPID=282440
UDT_JSON="$SCRIPTDIR/lib/udt/UDT_json"
WOLFCAM="$SCRIPTDIR/lib/wolfcam/wolfcamql"
WCDIR="$SCRIPTDIR/lib/wolfcam"

# aim stats: which in-game player is "you", and where to cache/export.
# Resolution order: $QL_PLAYER env → QL's name cvar from qzconfig.cfg → empty.
_detect_player() {
    local cfg
    cfg=$(find "$HOME/.local/share/Steam/steamapps/common/Quake Live"/*/baseq3/qzconfig.cfg 2>/dev/null | head -1) || true
    [[ -f "$cfg" ]] || return 0
    grep '^seta name "' "$cfg" 2>/dev/null | head -1 | sed 's/^seta name "\(.*\)"$/\1/' || true
}
AIM_PLAYER="${QL_PLAYER:-$(_detect_player)}"
# Cache is per-player (filename keyed by demo only — sharing it across
# players would replay the wrong player's stats), slug sanitized for FS.
_aim_slug="${AIM_PLAYER//[^A-Za-z0-9._-]/_}"
AIM_CACHE="$SCRIPTDIR/.aim-cache.$_aim_slug.tsv"
AIM_CSV="$SCRIPTDIR/aim-stats.csv"      # machine-readable, both metrics
AIM_TXT="$SCRIPTDIR/aim-table.txt"      # human-readable table, both metrics
AIM_BATCH="${QL_AIM_BATCH:-10}"

die() { echo "error: $*" >&2; exit 1; }

# Auto-detect demo directory from Steam
_find_demodir() {
    local steamroot="$HOME/.local/share/Steam"
    local qldir="$steamroot/steamapps/common/Quake Live"
    [[ -d "$qldir" ]] || die "Quake Live not found in $qldir"
    # Find the Steam ID directory (only one expected)
    local iddir
    iddir=$(find "$qldir" -maxdepth 1 -type d -name '[0-9]*' -print -quit 2>/dev/null) \
        || die "no Steam ID directory found in $qldir"
    echo "$iddir/baseq3/demos"
}

DEMODIR="${DEMODIR:-$(_find_demodir)}"

# Known Quake Live bot names (used by bot filter toggle)
BOTS="Anarki Angel Biker Bitterman Bones Cadavre Crash Daemia Demona Doom \
Gorre Grunt Hossman Hunter Keel Klesk Lucy Major Mynx Orbb Patriot Phobos \
Ranger Razor Sarge Slash Sorlag Stripe TankJr Uriel Visor Wrack Xaero"

# --- Filename parser ---

# Parse demo filename into tab-separated fields (no subshells — pure bash)
parse_demo() {
    local name="${1%.dm_91}"

    # Extract date: YYYY_MM_DD-HH_MM_SS at end
    [[ "$name" =~ ([0-9]{4}_[0-9]{2}_[0-9]{2}-[0-9]{2}_[0-9]{2}_[0-9]{2})$ ]] || return 1
    local date="${BASH_REMATCH[1]}"
    local rest="${name%-"$date"}"
    rest="${rest%-}"  # strip trailing hyphen

    local mode="" player="" opponent="" map=""

    if [[ "$rest" =~ ^(FFA|iFFA|CA|TDM|RACE|FT)- ]]; then
        mode="${BASH_REMATCH[1]}"
        rest="${rest#"${mode}"-}"
        local last_seg="${rest##*-}"
        local before_last="${rest%-*}"
        if [[ "$before_last" == "$rest" ]]; then
            map="$rest"
        else
            map="$last_seg"
            player="$before_last"
        fi
    elif [[ "$rest" == *-vs-* ]]; then
        mode="Duel"
        player="${rest%%-vs-*}"
        player="${player%(POV)}"
        local after_vs="${rest#*-vs-}"
        map="${after_vs##*-}"
        opponent="${after_vs%-*}"
    else
        mode="?"
        map="$rest"
    fi

    # Format date: 2026_01_06-22_10_43 → 2026-01-06 22:10:43
    local fdate="${date//_/-}"
    fdate="${fdate:0:10} ${fdate:11:2}:${fdate:14:2}:${fdate:17:2}"

    printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$fdate" "$mode" "${player:--}" "${opponent:--}" "$map" "$1"
}

# --- Data generator (used by CLI commands and fzf reload) ---

# Raw tab-separated data: date\tmode\tplayer\topponent\tmap\tfilename
_raw_data() {
    local filter=""
    for arg in "$@"; do
        case "$arg" in --filter=*) filter="${arg#--filter=}" ;; esac
    done
    local filter_lower="${filter,,}"
    for f in "$DEMODIR"/*.dm_91; do
        local base="${f##*/}"
        if [[ -n "$filter" && "${base,,}" != *"$filter_lower"* ]]; then
            continue
        fi
        parse_demo "$base"
    done
}

# Sort, filter, and format tab-separated demo data.
# Reads raw data from stdin. Output: fixed-width display columns \t filename
_generate() {
    local sort_key="1" sort_order="r" no_bots="" mode="" reverse=""

    for arg in "$@"; do
        case "$arg" in
            --sort=date)     sort_key="1"; sort_order="r" ;;
            --sort=map)      sort_key="5"; sort_order="" ;;
            --sort=opponent) sort_key="4"; sort_order="" ;;
            --no-bots)       no_bots=1 ;;
            --mode=*)        mode="${arg#--mode=}" ;;
            --reverse)       reverse=1 ;;
        esac
    done

    # Flip the per-field default direction when reverse is requested
    if [[ -n "$reverse" ]]; then
        if [[ "$sort_order" == "r" ]]; then sort_order=""; else sort_order="r"; fi
    fi

    sort -t$'\t' -k"${sort_key},${sort_key}${sort_order}" \
        | awk -F'\t' -v no_bots="$no_bots" -v bots="$BOTS" -v mode="$mode" '
            BEGIN { if (no_bots) { n = split(bots, a, " "); for (i=1; i<=n; i++) bot[a[i]] = 1 } }
            no_bots && bot[$4] { next }
            mode != "" && $2 != mode { next }
            { rows[++nr][1]=$1; rows[nr][2]=$2; rows[nr][3]=$3; rows[nr][4]=$4; rows[nr][5]=$5; rows[nr][6]=$6
              for (c=2; c<=4; c++) if (length(rows[nr][c]) > mw[c]) mw[c]=length(rows[nr][c]) }
            END { for (i=1; i<=nr; i++) {
              line = sprintf("%s  %-*s  %-*s  %-*s  %s", rows[i][1], mw[2], rows[i][2], mw[3], rows[i][3], mw[4], rows[i][4], rows[i][5])
              sub(/ +$/, "", line)
              printf "%3d  %s\t%s\n", i, line, rows[i][6] }
            }'
}

# --- CLI commands ---

list_demos() {
    _raw_data ${1:+--filter="$1"} | _generate --sort=date | cut -f1 | sed 's/^[[:space:]]*[0-9]*  //'
}

show_recent() {
    _raw_data | _generate --sort=date | head -n "${1:-20}" | cut -f1 | sed 's/^[[:space:]]*[0-9]*  //'
}

show_maps() {
    _raw_data | cut -f5 | sort | uniq -c | sort -rn
}

show_opponents() {
    _raw_data | cut -f4 | { grep -v '^-$' || true; } | sort | uniq -c | sort -rn
}

show_modes() {
    _raw_data | cut -f2 | sort | uniq -c | sort -rn
}

# --- Demo lookup ---

find_demo() {
    local input="$*"

    # Exact filename match
    if [[ -f "$DEMODIR/$input" ]]; then
        echo "$DEMODIR/$input"
        return
    fi

    # Substring search (case-insensitive, no glob interpretation)
    local matches=()
    local input_lower="${input,,}"
    for f in "$DEMODIR"/*.dm_91; do
        local base="${f##*/}"
        [[ "${base,,}" == *"$input_lower"* ]] && matches+=("$f")
    done

    if [[ ${#matches[@]} -eq 0 ]]; then
        die "no demos matching '$input'"
    elif [[ ${#matches[@]} -gt 1 ]]; then
        echo "Multiple matches:" >&2
        for m in "${matches[@]}"; do
            echo "  ${m##*/}" >&2
        done
        die "narrow your search (${#matches[@]} matches)"
    fi

    echo "${matches[0]}"
}

watch_demo() {
    local match
    match=$(find_demo "$@")
    local demo
    demo=$(basename "$match" .dm_91)
    echo "Watching: $demo"
    # Clean up any leftover recording configs (trap may have missed on crash)
    rm -f "$WCDIR/wolfcam-ql/record_auto.cfg" "$WCDIR/wolfcam-ql/cgamepostinit.cfg"
    # Fix audio: cl_aviNoAudioHWOutput is latched — must be 0 in q3config.cfg
    # before wolfcam starts, since sound init reads it before autoexec.cfg runs
    sed -i 's/cl_aviNoAudioHWOutput "1"/cl_aviNoAudioHWOutput "0"/' \
        "$WCDIR/wolfcam-ql/q3config.cfg" 2>/dev/null
    "$WOLFCAM" +set fs_basepath "$WCDIR" +set fs_homepath "$WCDIR" \
        +set r_mode -1 +set r_customwidth 1920 +set r_customheight 1080 \
        +set r_fullscreen 0 \
        +set cl_renderer opengl2 \
        +demo "$demo"
}

play_demo() {
    local match
    match=$(find_demo "$@")
    local demo
    demo=$(basename "$match" .dm_91)
    echo "Playing: $demo"
    steam "steam://run/$QLAPPID//+demo%20\"$demo\""
}

show_info() {
    local match
    match=$(find_demo "$@")
    echo "=== $(basename "$match") ==="
    "$UDT_JSON" -c -a=gsd "$match" 2>/dev/null
}

record_demo() {
    local match
    match=$(find_demo "$@")
    local demo
    demo=$(basename "$match" .dm_91)

    local outdir="$HOME/Videos"
    mkdir -p "$outdir"
    local outfile="$outdir/${demo}.mp4"

    if [[ -f "$outfile" ]]; then
        die "output already exists: $outfile"
    fi

    local wcvideos="$WCDIR/wolfcam-ql/videos"
    mkdir -p "$wcvideos"

    # Write temp cfg to auto-start recording and quit on demo end
    # NOTE: cl_aviPipeCommand contains only ffmpeg options — wolfcam appends output path
    local record_cfg="$WCDIR/wolfcam-ql/record_auto.cfg"
    local postinit="$WCDIR/wolfcam-ql/cgamepostinit.cfg"
    local had_postinit=0
    [[ -f "$postinit" ]] && had_postinit=1
    local postinit_backup=""

    # Cleanup function — runs on exit, interrupt, or error
    _record_cleanup() {
        rm -f "$record_cfg"
        if [[ -n "$postinit_backup" ]]; then
            mv "$postinit_backup" "$postinit"
        elif [[ $had_postinit -eq 0 ]]; then
            rm -f "$postinit"
        fi
        if [[ -n "${shutdown_backup:-}" ]]; then
            mv "$shutdown_backup" "$shutdown_cfg"
        elif [[ ${had_shutdown:-0} -eq 0 ]]; then
            rm -f "${shutdown_cfg:-}"
        fi
    }
    trap _record_cleanup EXIT

    cat > "$record_cfg" <<'EOF'
// Force real-time pacing — com_timescale multiplies game-time-per-frame
// in wolfcam's video capture loop (cl_main.c:5650), so stray timescale
// from numpad binds would speed up the recording.
set com_timescale 1
set cl_aviFrameRateDivider 1
set cl_aviFrameRate 60
set cl_aviPipeCommand "-threads 0 -c:a aac -c:v libx264 -preset medium -y -pix_fmt yuv420p -crf 18"
set cl_aviPipeExtension "mp4"
set cl_aviNoAudioHWOutput 1
video pipe
EOF

    # Reset audio on shutdown so q3config.cfg doesn't persist the mute
    local shutdown_cfg="$WCDIR/wolfcam-ql/shutdown.cfg"
    local had_shutdown=0
    [[ -f "$shutdown_cfg" ]] && had_shutdown=1
    local shutdown_backup=""
    if [[ $had_shutdown -eq 1 ]]; then
        shutdown_backup="$shutdown_cfg.bak.$$"
        cp "$shutdown_cfg" "$shutdown_backup"
    fi
    echo 'set cl_aviNoAudioHWOutput 0' > "$shutdown_cfg"

    # cgamepostinit.cfg runs after cgame loads — exec our record config
    if [[ $had_postinit -eq 1 ]]; then
        postinit_backup="$postinit.bak.$$"
        cp "$postinit" "$postinit_backup"
    fi
    echo "exec record_auto" > "$postinit"

    echo "Recording: $demo"
    echo "(wolfcam will open — let the demo play through, then it auto-quits)"

    # Mark time so we can find wolfcam's output afterward
    local marker="$wcvideos/.record_marker"
    touch "$marker"

    "$WOLFCAM" +set fs_basepath "$WCDIR" +set fs_homepath "$WCDIR" \
        +set r_mode -1 +set r_customwidth 1920 +set r_customheight 1080 \
        +set r_fullscreen 0 \
        +set cl_renderer opengl2 \
        +set nextdemo "quit" \
        +demo "$demo"

    # Find wolfcam's output (newer than our marker)
    local wcout
    wcout=$(find "$wcvideos" -maxdepth 1 -name '*.mp4' -newer "$marker" -print -quit 2>/dev/null || true)
    rm -f "$marker"

    if [[ -n "$wcout" && -f "$wcout" ]]; then
        mv "$wcout" "$outfile"
        local size
        size=$(du -h "$outfile" | cut -f1)
        echo "Done: $outfile ($size)"
    else
        echo "Warning: no video output found in $wcvideos"
    fi
}

# Detached wrapper around record_demo. Marks a marker file for dwmblocks
# (screen-record-status block shows 🎬), runs the recording, then sends
# a desktop notification with the saved path and copies the path to the
# X clipboard. Designed to be spawned by `setsid -f` from the fzf binding.
RECORD_MARKER="/tmp/ql-demos-recording"
RECORD_LOG="/tmp/ql-demos-record.log"

record_bg() {
    [[ -n "${1:-}" ]] || die "usage: ql-demos record-bg <file>"

    if [[ -f "$RECORD_MARKER" ]]; then
        local active
        active=$(cat "$RECORD_MARKER" 2>/dev/null)
        command -v notify-send >/dev/null && \
            notify-send -u critical -t 5000 "ql-demos record" \
                "Another recording is already in progress ($active)."
        echo "error: another recording is in progress ($active)" >&2
        exit 1
    fi

    local match demo outfile
    match=$(find_demo "$@") || exit 1
    demo=$(basename "$match" .dm_91)
    outfile="$HOME/Videos/${demo}.mp4"

    echo "$demo" > "$RECORD_MARKER"
    pkill -RTMIN+5 "${STATUSBAR:-dwmblocks}" 2>/dev/null || true

    # Clean marker + refresh statusbar even on crash/interrupt
    trap 'rm -f "$RECORD_MARKER"; pkill -RTMIN+5 "${STATUSBAR:-dwmblocks}" 2>/dev/null || true' EXIT

    if record_demo "$@" >"$RECORD_LOG" 2>&1; then
        command -v xclip >/dev/null && \
            printf '%s' "$outfile" | xclip -selection clipboard 2>/dev/null
        command -v notify-send >/dev/null && \
            notify-send -i video-x-generic -t 10000 "ql-demos: recording done" \
                "Saved: $outfile

Path copied to clipboard."
    else
        command -v notify-send >/dev/null && \
            notify-send -u critical -t 10000 "ql-demos: recording failed" \
                "demo: $demo
see: $RECORD_LOG"
    fi
}

# --- fzf TUI ---

fzf_browse() {
    command -v fzf >/dev/null || die "fzf not installed (try: ql-demos list)"

    local self
    self=$(readlink -f "$0")

    export DEMODIR UDT_JSON AIM_PLAYER AIM_CACHE

    # Cache raw data for fast sort/filter reloads (avoids re-parsing 718 files)
    local cache
    cache=$(mktemp)
    trap "rm -f '$cache'" EXIT
    _raw_data > "$cache"

    # All bindable single-key actions — unbound on / (search), rebound on esc
    # (`enter` is bound globally to ignore — kept out of this list so it stays
    # neutral in search mode too, instead of triggering fzf's default accept)
    local keys="s,S,R,b,m,M,d,q,p,P,r,i,j,k,J,K,g,G,ctrl-d,ctrl-u"

    # Header — single mode, every key direct
    local header
    header="j/k:move  g/G:top/end  J/K:preview  ctrl-d/u:½pg  /:search  esc:exit
s/S:sort  R:reverse  m/M:mode  b:bots  i:info  p:QL  P:wolfcam  d:del  r:rec  q:quit"

    # Parse state from FZF_PROMPT into $s sort  $nb no-bots  $mo mode  $rev reverse
    local state='p="$FZF_PROMPT"; s=date; nb=""; mo=""; rev="";
        case "$p" in *sort:date*) s=date;; *sort:map*) s=map;; *sort:opponent*) s=opponent;; esac;
        [[ "$p" == *\|rev* ]] && rev=" --reverse";
        [[ "$p" == *no-bots* ]] && nb=" --no-bots";
        if [[ "$p" =~ mode=([A-Za-z?]+) ]]; then mo=" --mode=${BASH_REMATCH[1]}"; fi'

    # Mode cycle ("all" = no filter). Order matches parse_demo prefixes.
    local modes='all Duel FFA iFFA CA TDM RACE FT'

    # Build prompt tag from current $s $rev $nb $mo
    local mktag='tag="sort:$s"; [[ -n "$rev" ]] && tag="$tag|rev";
        [[ -n "$nb" ]] && tag="$tag|no-bots";
        cur_mo="${mo#*=}"; [[ -n "$cur_mo" ]] && tag="$tag|mode=$cur_mo"'

    # Reload shorthand
    local rl="cat $cache | $self _generate"

    _generate --sort=date < "$cache" | \
    fzf \
        --delimiter='\t' \
        --with-nth=1 \
        --header="$header" \
        --header-first \
        --prompt="sort:date> " \
        --preview="$SCRIPTDIR/lib/preview.sh \"$DEMODIR/\"{-1}" \
        --preview-window=right:64:wrap \
        --bind="enter:ignore" \
        --bind="P:execute($self watch {-1})" \
        --bind="p:execute($self play {-1})" \
        --bind="r:execute-silent(setsid -f $self record-bg {-1} >/dev/null 2>&1)" \
        --bind='i:execute($UDT_JSON -c -a=gsd "$DEMODIR/"{-1} 2>/dev/null | less)' \
        --bind='s:transform:'"$state"'
            case "$s" in date) s=map;; map) s=opponent;; *) s=date;; esac;
            rev="";
            '"$mktag"';
            echo "reload('"$rl"' --sort=$s$nb$mo$rev)+change-prompt($tag> )"' \
        --bind='S:transform:'"$state"'
            case "$s" in date) s=opponent;; opponent) s=map;; *) s=date;; esac;
            rev="";
            '"$mktag"';
            echo "reload('"$rl"' --sort=$s$nb$mo$rev)+change-prompt($tag> )"' \
        --bind='R:transform:'"$state"'
            if [[ -n "$rev" ]]; then rev=""; else rev=" --reverse"; fi;
            '"$mktag"';
            echo "reload('"$rl"' --sort=$s$nb$mo$rev)+change-prompt($tag> )"' \
        --bind='b:transform:'"$state"'
            if [[ -n "$nb" ]]; then nb=""; else nb=" --no-bots"; fi;
            '"$mktag"';
            echo "reload('"$rl"' --sort=$s$nb$mo$rev)+change-prompt($tag> )"' \
        --bind='m:transform:'"$state"'
            ms=('"$modes"'); cur="${mo#*=}"; cur="${cur:-all}";
            idx=0; for i in "${!ms[@]}"; do [[ "${ms[i]}" == "$cur" ]] && { idx=$i; break; }; done;
            idx=$(( (idx + 1) % ${#ms[@]} ));
            new="${ms[idx]}";
            if [[ "$new" == all ]]; then mo=""; else mo=" --mode=$new"; fi;
            '"$mktag"';
            echo "reload('"$rl"' --sort=$s$nb$mo$rev)+change-prompt($tag> )"' \
        --bind='M:transform:'"$state"'
            ms=('"$modes"'); cur="${mo#*=}"; cur="${cur:-all}";
            idx=0; for i in "${!ms[@]}"; do [[ "${ms[i]}" == "$cur" ]] && { idx=$i; break; }; done;
            idx=$(( (idx - 1 + ${#ms[@]}) % ${#ms[@]} ));
            new="${ms[idx]}";
            if [[ "$new" == all ]]; then mo=""; else mo=" --mode=$new"; fi;
            '"$mktag"';
            echo "reload('"$rl"' --sort=$s$nb$mo$rev)+change-prompt($tag> )"' \
        --bind="j:down,k:up" \
        --bind="ctrl-d:half-page-down,ctrl-u:half-page-up" \
        --bind="g:first,G:last" \
        --bind="J:preview-down,K:preview-up" \
        --bind='d:execute(read -p "Delete {-1}? [y/N] " ans < /dev/tty; [ "$ans" = y ] && rm "$DEMODIR/"{-1} && grep -vF "{-1}" '"$cache"' > '"$cache"'.tmp && mv '"$cache"'.tmp '"$cache"' && echo "Deleted." || echo "Cancelled.")+transform:'"$state"'
            echo "reload('"$rl"' --sort=$s$nb$mo$rev)"' \
        --bind="q:abort" \
        --bind="/:enable-search+unbind($keys)+change-border-label( -- SEARCH -- )" \
        --bind="esc:disable-search+rebind($keys)+change-border-label( -- NORMAL -- )" \
        --bind="resize:refresh-preview" \
        --reverse \
        --no-mouse \
        --disabled \
        --info=inline \
        --scrollbar='▌▐' \
        --border=bottom \
        --border-label=' -- NORMAL -- ' \
        --color='header:italic:dim,border:dim,label:bold'
}

# --- Aim stats (accuracy trend, per weapon, in game batches) ---

# Cache format marker — bump when column layout changes (forces rebuild)
_AIM_VER='#AIMv2'

# jq program: one TSV line per match where AIM_PLAYER participated.
# Columns: 1 file 2 unix 3 isoDate 4 type 5 map 6 overallAcc
#          then per weapon acc/shots/hits, in order LG RG RL SG MG GL PG:
#          7-9 LG  10-12 RG  13-15 RL  16-18 SG  19-21 MG  22-24 GL  25-27 PG
_AIM_JQ='
  .matchStats[]? as $m
  | ( $m.playerStats[]? | select(.cleanName==$P and .team!="spectators") )
  | [ $F,
      ($m.startDateUnix // 0),
      ( ($m.startDateUnix // 0) | if . > 0 then todate else "" end ),
      ($m.gameType // "?"), ($m.map // "?"),
      (.accuracy // 0),
      (.lightningGunAccuracy // 0),   (.lightningGunShots // 0),   (.lightningGunHits // 0),
      (.railgunAccuracy // 0),        (.railgunShots // 0),        (.railgunHits // 0),
      (.rocketLauncherAccuracy // 0), (.rocketLauncherShots // 0), (.rocketLauncherHits // 0),
      (.shotgunAccuracy // 0),        (.shotgunShots // 0),        (.shotgunHits // 0),
      (.machinegunAccuracy // 0),     (.machinegunShots // 0),     (.machinegunHits // 0),
      (.grenadeLauncherAccuracy // 0),(.grenadeLauncherShots // 0),(.grenadeLauncherHits // 0),
      (.plasmaGunAccuracy // 0),      (.plasmaGunShots // 0),      (.plasmaGunHits // 0)
    ] | @tsv'

# Parse one demo → cache lines (one per match, or a SKIP marker)
_aim_extract() {
    local f="$1" base="${1##*/}" out
    out=$("$UDT_JSON" -c -a=gsd "$f" 2>/dev/null \
          | jq -r --arg P "$AIM_PLAYER" --arg F "$base" "$_AIM_JQ" 2>/dev/null) || true
    if [[ -n "$out" ]]; then
        printf '%s\n' "$out"
    else
        printf '%s\tSKIP\n' "$base"
    fi
}

# Incrementally build the parse cache (skips demos already cached)
_aim_build_cache() {
    [[ "${1:-}" == "--refresh" ]] && rm -f "$AIM_CACHE"
    # Stale or missing format marker → wipe and rebuild from scratch
    if [[ ! -f "$AIM_CACHE" || "$(head -n1 "$AIM_CACHE" 2>/dev/null)" != "$_AIM_VER" ]]; then
        [[ -f "$AIM_CACHE" ]] && printf '  cache format changed — rebuilding\n' >&2
        printf '%s\n' "$_AIM_VER" > "$AIM_CACHE"
    fi

    declare -A seen=()
    local bn
    while IFS=$'\t' read -r bn _; do seen["$bn"]=1; done < "$AIM_CACHE"

    local files=( "$DEMODIR"/*.dm_91 )
    local total=${#files[@]} done=0 new=0 f
    for f in "${files[@]}"; do
        done=$((done+1))
        bn="${f##*/}"
        [[ -n "${seen[$bn]:-}" ]] && continue
        _aim_extract "$f" >> "$AIM_CACHE"
        new=$((new+1))
        if (( done % 50 == 0 )); then
            printf '\r  scanning %d/%d (new: %d)…' "$done" "$total" "$new" >&2
        fi
    done
    if (( new > 0 )); then
        printf '\r  scanned %d demos, %d newly parsed%-20s\n' "$total" "$new" '' >&2
    else
        printf '  cache up to date (%d demos)\n' "$total" >&2
    fi
    return 0
}

# Group date-sorted games into batches of N → per-batch aggregate TSV.
# Out cols: 1 batch 2 games 3 from 4 to 5 OVR_mean 6 OVR_wavg
#   then per weapon (LG RG RL SG MG GL PG): mean, wavg, shots, n
_aim_aggregate() {
    { grep -vF $'\tSKIP' "$AIM_CACHE" || true; } \
        | sort -t$'\t' -k2,2n \
        | awk -F'\t' -v N="$1" '
        function flush(   i,w,oh,os) {
            if (c==0) return
            b++
            oh=0; os=0
            for (i=1;i<=nw;i++){ oh+=wh[wn[i]]; os+=wsh[wn[i]] }
            printf "%d\t%d\t%s\t%s\t%.1f", b, c, dfrom, dto, osum/c
            printf "\t%s", (os>0 ? sprintf("%.1f", oh/os*100) : "NA")
            for (i=1;i<=nw;i++){ w=wn[i]
                printf "\t%s", (wc[w]>0  ? sprintf("%.1f", ws[w]/wc[w])      : "NA")
                printf "\t%s", (wsh[w]>0 ? sprintf("%.1f", wh[w]/wsh[w]*100) : "NA")
                printf "\t%d\t%d", wsh[w], wc[w] }
            printf "\n"
            c=0; osum=0; dfrom=""
            for (i=1;i<=nw;i++){ ws[wn[i]]=0; wc[wn[i]]=0; wsh[wn[i]]=0; wh[wn[i]]=0 }
        }
        BEGIN{ nw=7
            # per weapon: acc col, shots col, hits col
            wn[1]="LG"; aa[1]=7;  ss[1]=8;  hh[1]=9
            wn[2]="RG"; aa[2]=10; ss[2]=11; hh[2]=12
            wn[3]="RL"; aa[3]=13; ss[3]=14; hh[3]=15
            wn[4]="SG"; aa[4]=16; ss[4]=17; hh[4]=18
            wn[5]="MG"; aa[5]=19; ss[5]=20; hh[5]=21
            wn[6]="GL"; aa[6]=22; ss[6]=23; hh[6]=24
            wn[7]="PG"; aa[7]=25; ss[7]=26; hh[7]=27 }
        $2 ~ /^[0-9]+$/ && $2>0 {
            c++; osum+=$6
            d=substr($3,1,10)
            if (dfrom=="") dfrom=d
            dto=d
            for (i=1;i<=nw;i++){ sc=$(ss[i])+0
                if (sc>0){ w=wn[i]
                    ws[w]+=$(aa[i]); wc[w]++          # for simple mean
                    wsh[w]+=sc; wh[w]+=$(hh[i])+0 } } # for shot-weighted
            if (c>=N) flush()
        }
        END{ flush() }'
}

aim_stats() {
    command -v jq >/dev/null || die "jq not installed"
    local refresh="" n="$AIM_BATCH"
    for a in "$@"; do
        case "$a" in
            --refresh) refresh="--refresh" ;;
            [0-9]*)    n="$a" ;;
        esac
    done

    echo "Building parse cache for '$AIM_PLAYER' (cached across runs)…" >&2
    _aim_build_cache $refresh

    local agg
    agg=$(mktemp)
    trap "rm -f '$agg'" RETURN
    _aim_aggregate "$n" > "$agg"
    [[ -s "$agg" ]] || die "no parseable games for '$AIM_PLAYER' (try: ql-demos aim --refresh)"

    local nb games
    nb=$(wc -l < "$agg")
    games=$(awk -F'\t' '{s+=$2} END{print s}' "$agg")

    # --- Full per-batch table → file (both metrics, plain text, no ANSI) ---
    {
        printf 'Aim table — %s   (%d games, %d batches of %s)\n' \
            "$AIM_PLAYER" "$games" "$nb" "$n"
        printf 'Generated %s   Columns: OVR LG RG RL SG MG GL PG   "·" = weapon unused\n' \
            "$(date '+%Y-%m-%d %H:%M')"
        local w lbl
        for w in 0 1; do
            [[ $w -eq 0 ]] && lbl="simple per-game mean" || lbl="shot-weighted (hits/shots)"
            printf '\n== %s ==\n' "$lbl"
            printf '  %-5s %3s  %-23s  %4s %4s %4s %4s %4s %4s %4s %4s\n' \
                Batch '#' Period OVR LG RG RL SG MG GL PG
            awk -F'\t' -v W="$w" '{
                o = (W ? $6 : $5)
                printf "  %-5s %3s  %s→%s  %4s", "#"$1, $2, $3, $4, \
                       (o=="NA" ? " ·" : sprintf("%.0f",o))
                for (base=7; base<=31; base+=4){ v=$(base + (W?1:0))
                    printf " %4s", (v=="NA" ? " ·" : sprintf("%.0f",v)) }
                printf "\n" }' "$agg"
        done
    } > "$AIM_TXT"

    # --- Terminal: header + only the graphs ---
    printf '\n\033[1;36m  Aim trend — %s\033[0m  (%d games, %d batches of %s)\n' \
        "$AIM_PLAYER" "$games" "$nb" "$n"
    printf '\033[2m  per-batch table saved to files below · accuracy %% over time\033[0m\n'

    # --- Evolution: BOTH metrics per weapon, each scaled to its own range ---
    printf '\n\033[2m  Evolution — mean (per-game avg) vs wavg (hits/shots), each scaled to its own min→max:\033[0m\n'
    awk -F'\t' '
        function spark(g,s,   k,v,idx,line){
            line=""
            for(k=1;k<=cn[g,s];k++){ v=V[g,s,k]
              if(mx[g,s]==mn[g,s]) idx=4
              else idx=int((v-mn[g,s])/(mx[g,s]-mn[g,s])*7+0.5)+1
              if(idx<1)idx=1; if(idx>8)idx=8; line=line bl[idx] }
            return line }
        function arrow(g,s,   d){ d=last[g,s]-first[g,s]
            if(d>=2)  return sprintf("\033[32m+%.0f ↑\033[0m",d)
            if(d<=-2) return sprintf("\033[31m%.0f ↓\033[0m",d)
            return sprintf("\033[2m%+.0f →\033[0m",d) }
        BEGIN{ split("▁▂▃▄▅▆▇█",bl,"")
            split("OVR LG RG RL SG MG GL PG",lbl," ")
            split("5 7 11 15 19 23 27 31",mc," ")
            split("6 8 12 16 20 24 28 32",wcc," ")
            tag[1]="mean"; tag[2]="wavg" }
        { for (s=1;s<=8;s++) for (g=1;g<=2;g++){
            v=$( g==1 ? mc[s] : wcc[s] )
            if(v!="NA"){ cn[g,s]++; V[g,s,cn[g,s]]=v
              if(!sn[g,s]){ mn[g,s]=v; mx[g,s]=v; sn[g,s]=1 }
              else { if(v<mn[g,s])mn[g,s]=v; if(v>mx[g,s])mx[g,s]=v }
              last[g,s]=v; if(cn[g,s]==1)first[g,s]=v } } }
        END{ for (s=1;s<=8;s++){
            if(cn[1,s]==0 && cn[2,s]==0){
              printf "  \033[1m%-4s\033[0m (no data)\n",lbl[s]; continue }
            for(g=1;g<=2;g++){
              lab=(g==1 ? sprintf("\033[1m%-4s\033[0m",lbl[s]) : "    ")
              if(cn[g,s]==0){ printf "  %s %s  (never fired)\n",lab,tag[g]; continue }
              printf "  %s \033[2m%s\033[0m \033[36m%s\033[0m  %2.0f→%2.0f%%  (%s)\n",
                lab, tag[g], spark(g,s), first[g,s], last[g,s], arrow(g,s) } } }' "$agg"

    # --- CSV (always BOTH metrics + shot volume, NA → empty) ---
    { echo "batch,games,from,to,OVR_mean,OVR_wavg,LG_mean,LG_wavg,LG_shots,LG_n,RG_mean,RG_wavg,RG_shots,RG_n,RL_mean,RL_wavg,RL_shots,RL_n,SG_mean,SG_wavg,SG_shots,SG_n,MG_mean,MG_wavg,MG_shots,MG_n,GL_mean,GL_wavg,GL_shots,GL_n,PG_mean,PG_wavg,PG_shots,PG_n"
      sed 's/\t/,/g; s/NA//g' "$agg"
    } > "$AIM_CSV"

    printf '\n\033[2m  Table (mean + wavg) : %s\033[0m\n' "$AIM_TXT"
    printf '\033[2m  CSV   (both metrics): %s\033[0m\n\n' "$AIM_CSV"
}

# --- Backup ---

backup_demos() {
    local dest="${1:-$HOME/ql-demos-backup}"
    mkdir -p "$dest"

    local count
    count=$(find "$DEMODIR" -maxdepth 1 -name '*.dm_91' -type f | wc -l)
    local size
    size=$(du -sh "$DEMODIR" | cut -f1)

    echo "Backing up $count demos ($size) to $dest"
    rsync -a --info=progress2 "$DEMODIR/" "$dest/"
    echo "Done. Backup at: $dest"
}

# --- Main ---

case "${1:-}" in
    "")        fzf_browse ;;
    list)      list_demos "${2:-}" ;;
    recent)    show_recent "${2:-20}" ;;
    maps)      show_maps ;;
    opponents) show_opponents ;;
    modes)     show_modes ;;
    watch)     [[ -n "${2:-}" ]] || die "usage: ql-demos watch <file>"; shift; watch_demo "$@" ;;
    play)      [[ -n "${2:-}" ]] || die "usage: ql-demos play <file>"; shift; play_demo "$@" ;;
    info)      [[ -n "${2:-}" ]] || die "usage: ql-demos info <file>"; shift; show_info "$@" ;;
    record)    [[ -n "${2:-}" ]] || die "usage: ql-demos record <file>"; shift; record_demo "$@" ;;
    record-bg) shift; record_bg "$@" ;;
    backup)    backup_demos "${2:-}" ;;
    aim)       shift; aim_stats "$@" ;;
    _generate)  shift; if [[ ! -t 0 ]]; then _generate "$@"; else _raw_data | _generate "$@"; fi ;;
    -h|--help|help)
        sed -n '2,/^$/{ s/^# //; s/^#$//; p; }' "$0"
        ;;
    *)         die "unknown command: $1 (try: ql-demos help)" ;;
esac
