#!/bin/bash
# nvtable - compare upstream (nvchecker) vs installed vs shipped-in-repo versions.
#
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# Three version sources per tracked name:
#   upstream  - nvchecker new_ver.json
#   installed - /var/log/packages, then appimage-updater state, then flatpak
#   repo      - VERSION= in <repo>/<pkg>/<pkg>.info
#
# When installed == upstream the row is caught up, and nvtake records it so
# old_ver tracks the system. See take_caught_up() for the locking.

set -uo pipefail

CONF="${NVTABLE_CONF:-$HOME/.config/nvchecker/nvchecker.toml}"
NEWVER="${NVTABLE_NEWVER:-$HOME/.config/nvchecker/new_ver.json}"
AI_STATE="${XDG_STATE_HOME:-$HOME/.local/state}/appimage-updater/installed.json"
LOCK="${TMPDIR:-/tmp}/nvtable-$UID.lock"

REPOS=(
  "sbo:$HOME/Programming/GIT/sbo-slackbuilds"
  "my:$HOME/Programming/GIT/GITHUB/my-slackbuilds"
  "sps:$HOME/Programming/GIT/GITHUB/Slackware-Pentesting-Suite"
)

# Stanza name -> installed package name(s), space separated for one-to-many.
# Only for names that differ; everything else joins directly.
declare -A ALIAS=(
  [kvantum]="kvantum-qt5 kvantum-qt6"
  [metasploit-framework]="metasploit-framework-bin"
)

# Stanza name -> SlackBuild directory name, where the repo dir differs from the
# stanza (a -bin build tracked under its plain upstream name).
declare -A REPO_ALIAS=(
  [kitty]="kitty-bin"
  [claude-code]="claude-code-bin"
  [kvantum]="kvantum-qt6"
  [metasploit-framework]="metasploit-framework-bin"
)

SHOW_ALL=0; NO_TAKE=0; NO_COLOR=0; NO_EMOJI=0
for a in "$@"; do
  case "$a" in
    -a|--all)      SHOW_ALL=1 ;;
    -n|--no-take)  NO_TAKE=1 ;;
    -C|--no-color) NO_COLOR=1 ;;
    -E|--no-emoji) NO_EMOJI=1 ;;
    -h|--help)
      cat <<EOF
usage: nvtable [-a] [-n] [-C] [-E]
  -a, --all       show every tracked package, not just those needing action
  -n, --no-take   do not run nvtake on caught-up packages
  -C, --no-color  plain output
  -E, --no-emoji  ASCII status markers instead of emoji
EOF
      exit 0 ;;
    *) echo "nvtable: unknown option $a" >&2; exit 2 ;;
  esac
done

[[ -t 1 ]] || NO_COLOR=1
if (( NO_COLOR )); then
  R=""; B=""; DIM=""; RED=""; GRN=""; YEL=""; CYA=""; WHT=""
else
  R=$'\e[0m'; B=$'\e[1m'; DIM=$'\e[2m'; RED=$'\e[1;31m'
  GRN=$'\e[1;32m'; YEL=$'\e[1;33m'; CYA=$'\e[1;36m'; WHT=$'\e[1;37m'
fi

# Status markers. Emoji are double-width, so each ASCII fallback is padded to
# two columns to keep the name column aligned either way.
if (( NO_EMOJI )); then
  E_UP="!!" E_REPO="~ " E_OK="ok" E_NA="- "
else
  E_UP="🔴" E_REPO="🟡" E_OK="🟢" E_NA="⚪"
fi

command -v jq >/dev/null || { echo "nvtable: jq required" >&2; exit 1; }
[[ -r $NEWVER ]] || { echo "nvtable: no $NEWVER (run nvchecker first)" >&2; exit 1; }

# --- installed versions -----------------------------------------------------
# Package files are PRGNAM-VERSION-ARCH-BUILD, but VERSION may itself contain a
# dash (solvespace-3.1-659215d), so the PRGNAM/VERSION boundary cannot be found
# by counting dashes. Drop ARCH-BUILD from the right, then keep the whole
# remainder; lookup_installed() anchors on the name it is searching for.
declare -A PKGREST
while IFS= read -r f; do
  rest=${f%-*}        # drop BUILD
  rest=${rest%-*}     # drop ARCH  -> "PRGNAM-VERSION"
  [[ -n $rest ]] && PKGREST[$rest]=1
done < <(/bin/ls -1 /var/log/packages 2>/dev/null)

declare -A INST
for rest in "${!PKGREST[@]}"; do
  # Default split: last dash. Correct for the common case, fixed up below for
  # any name that a tracked stanza claims explicitly.
  INST[${rest%-*}]=${rest##*-}
done

# Given an exact package name, take everything after it as the version. The
# match is anchored on "<want>-" and strips exactly that many characters, so a
# VERSION containing dashes survives and a longer sibling package (kvantum-qt5
# when asked for kvantum) is never mistaken for a match.
pkg_version() {
  local want=$1 rest
  for rest in "${!PKGREST[@]}"; do
    if [[ ${rest,,} == "${want,,}-"* ]]; then
      rest=${rest:${#want}+1}
      # Reject when what follows is not a version (another name component).
      [[ $rest == [0-9]* ]] || continue
      echo "$rest"; return 0
    fi
  done
  return 1
}

# AppImages tracked by appimage-updater (its state is authoritative for those).
if [[ -r $AI_STATE ]]; then
  while IFS=$'\t' read -r k v; do
    [[ -n $k ]] && INST[$k]=$v
  done < <(jq -r 'to_entries[]|"\(.key)\t\(.value.version)"' "$AI_STATE" 2>/dev/null)
fi

# Flatpak apps, keyed on the last dot-component of the app id.
if command -v flatpak >/dev/null 2>&1; then
  while IFS=$'\t' read -r app v; do
    [[ -n $app ]] || continue
    INST[${app##*.}]=$v
  done < <(flatpak list --app --columns=application,version 2>/dev/null)
fi

installed_of() {  # echoes "version" or "" ; handles alias + case-insensitive
  local n=$1 cand v
  for cand in ${ALIAS[$n]:-$n}; do
    # Anchored lookup first: correct even when VERSION contains a dash.
    v=$(pkg_version "$cand") && { echo "$v"; return; }
    [[ -n ${INST[$cand]:-} ]] && { echo "${INST[$cand]}"; return; }
    for k in "${!INST[@]}"; do
      [[ ${k,,} == "${cand,,}" ]] && { echo "${INST[$k]}"; return; }
    done
  done
}

# --- repo .info versions ----------------------------------------------------
declare -A RVER RWHICH
for entry in "${REPOS[@]}"; do
  tag=${entry%%:*}; dir=${entry#*:}
  [[ -d $dir ]] || continue
  while IFS= read -r inf; do
    pkg=$(basename "$inf" .info)
    v=$(sed -n 's/^VERSION="\(.*\)"$/\1/p' "$inf" | head -1)
    [[ -n $v ]] || continue
    if [[ -n ${RVER[$pkg]:-} ]]; then
      RWHICH[$pkg]="${RWHICH[$pkg]},$tag"
    else
      RVER[$pkg]=$v; RWHICH[$pkg]=$tag
    fi
  done < <(find "$dir" -mindepth 2 -maxdepth 3 -name '*.info' 2>/dev/null)
done

# --- compare ----------------------------------------------------------------
# Versions come from unrelated sources, so normalise before comparing: strip a
# leading v, fold - and _ to dots, lowercase.
norm() { local s=${1,,}; s=${s#v}; s=${s//[-_]/.}; echo "$s"; }

# Some packages append build metadata the upstream version never carries:
# a kernel version (r8125 9.018.00_6.18.41) or a git hash (solvespace
# 3.1-659215d). Treat installed as equal when it merely extends upstream at a
# component boundary.
vers_equal() {
  local a b; a=$(norm "$1"); b=$(norm "$2")
  [[ $a == "$b" ]] && return 0
  [[ -n $a && -n $b && $b == "$a".* ]] && return 0
  return 1
}

rows=(); take=(); n_up=0; n_repo=0; n_ok=0; n_na=0
while IFS=$'\t' read -r name uver; do
  [[ -n $name ]] || continue
  iver=$(installed_of "$name")
  rkey=$name
  if [[ -z ${RVER[$rkey]:-} ]]; then
    for cand in ${REPO_ALIAS[$name]:-} ${ALIAS[$name]:-}; do
      [[ -n ${RVER[$cand]:-} ]] && { rkey=$cand; break; }
    done
  fi
  rver=${RVER[$rkey]:-}; rwhich=${RWHICH[$rkey]:-}

  same_inst=0; [[ -n $iver ]] && vers_equal "$uver" "$iver" && same_inst=1
  same_repo=1; [[ -n $rver ]] && { vers_equal "$uver" "$rver" || same_repo=0; }

  if (( same_inst )) && (( same_repo )); then
    (( n_ok++ )); take+=("$name")
    (( SHOW_ALL )) || continue
  else
    (( same_inst )) || { [[ -n $iver ]] && (( n_up++ )); }
    (( same_repo )) || (( n_repo++ ))
    # Nothing installed and the .info already matches upstream: there is no
    # action to take on this host, so keep it out of the default view.
    if [[ -z $iver ]] && (( same_repo )); then
      (( n_na++ ))
      (( SHOW_ALL )) || continue
    fi
  fi
  # Packed with | not tab: read collapses runs of whitespace IFS chars, which
  # would shift every field left whenever installed or repo is empty.
  rows+=("$name|$uver|$iver|$rver|$rwhich|$same_inst|$same_repo")
done < <(jq -r '.data|to_entries[]|"\(.key)\t\(.value.version // .value)"' "$NEWVER")

# --- output -----------------------------------------------------------------
if ((${#rows[@]})); then
  # Widen the name column to the longest actual name, so a long stanza does not
  # shunt every later column out of alignment.
  w=22
  for r in "${rows[@]}"; do n=${r%%|*}; (( ${#n} > w )) && w=${#n}; done
  printf '%s     %-*s %-14s %-14s %s%s\n' "$DIM" "$w" "package" "upstream" "installed" "repo" "$R"
  for r in "${rows[@]}"; do
    IFS='|' read -r name uver iver rver rwhich si sr <<<"$r"
    if   [[ -z $iver ]]; then ic="$DIM"; ishow="--"
    elif (( si ));      then ic="$GRN"; ishow=$iver
    else                     ic="$RED"; ishow=$iver
    fi
    # Installed-behind and repo-behind are independent, and a row can be both
    # (upstream moved, and neither the system nor the .info has caught up), so
    # the marker shows each dimension rather than collapsing to the worst one.
    if   [[ -z $iver ]];  then mark="$E_NA"
    elif (( si ));        then mark="$E_OK"
    else                       mark="$E_UP"
    fi
    (( sr )) || mark+="$E_REPO"
    if   [[ -z $rver ]]; then rc="$DIM"; rtxt="--"
    elif (( sr ));       then rc="$GRN"; rtxt="$rwhich $rver"
    else                      rc="$YEL"; rtxt="$rwhich $rver"
    fi
    # Pad the marker cell to a fixed width: rows carrying both markers must not
    # push the name column right relative to rows carrying one.
    if (( NO_EMOJI )); then mcell=$(printf '%-5s' "$mark")
    else                    mcell=$mark; (( ${#mark} > 1 )) || mcell="$mark  "
    fi
    printf '  %s %s%-*s%s %-14s %s%-14s%s %s%s%s\n' \
      "$mcell" "$WHT" "$w" "$name" "$R" "$uver" "$ic" "$ishow" "$R" "$rc" "$rtxt" "$R"
  done
  echo
fi
printf '  %s%d%s to upgrade · %s%d%s repo%s to bump · %s%d%s current' \
  "$RED" "$n_up" "$R" "$YEL" "$n_repo" "$R" "$( ((n_repo==1)) || echo s )" \
  "$GRN" "$n_ok" "$R"
(( n_na )) && printf ' · %s%d%s not installed' "$DIM" "$n_na" "$R"
echo

# --- nvtake -----------------------------------------------------------------
# Record caught-up packages so old_ver reflects the system. flock serialises
# concurrent logins: nvtake rewrites old_ver.json without locking of its own.
take_caught_up() {
  ((${#take[@]})) || return 0
  command -v nvtake >/dev/null || return 0
  exec 9>"$LOCK" || return 0
  flock -n 9 || return 0   # another shell is taking; skip, next run catches it
  nvtake -c "$CONF" --ignore-nonexistent "${take[@]}" >/dev/null 2>&1
  exec 9>&-
}
(( NO_TAKE )) || take_caught_up
