#!/usr/bin/env bash
# freshenup — pick outdated Homebrew nodes (formulae + casks) and, optionally, outdated Mac
# App Store apps, then act on them. A formula node is a leaf that is outdated itself or has
# outdated deps beneath it; a cask or App Store app is a standalone node. Everything starts
# selected. Keys are shown in the fzf header (single source of truth). Updating names the
# outdated deps directly — `brew upgrade mpv` is a no-op when mpv is current, so it wouldn't
# pull an outdated dep along. App Store rows show a slugified name; the numeric id drives the
# action (upgrade/uninstall via `mas`, which needs root — a sudo prompt appears only then).
# Flag: -u/--update force `brew update` now (brew otherwise auto-refreshes at most once/24h).
# Deps: fzf, brew, coreutils(comm), awk. Optional: mas (Mac App Store). No config, no state.
set -euo pipefail

# Preview mode (re-invoked by fzf per row): the node's outdated members + version bumps.
# Payload columns: type <TAB> node <TAB> member <TAB> "current → latest".
if [ "${1:-}" = "--preview" ]; then
  awk -F'\t' -v n="${2:-}" '
    $2 == n { typ = $1
      if ($3 == n || $1 == "mas") self = $4; else deps = deps sprintf("  %-22s %s\n", $3, $4) }
    END {
      lab["cask"] = "  (cask)"; lab["mas"] = "  (App Store)"; lab["formula"] = "  (formula)"
      print (self == "") ? n lab[typ] : n "  " self lab[typ]; printf "%s", deps }' \
    "${BREWTOP_PAYLOAD:-/dev/null}"
  exit 0
fi

SELF="$(command -v -- "$0" 2>/dev/null || true)"; [ -x "$SELF" ] || SELF="$0"

# No NO_AUTO_UPDATE override: brew's built-in throttle auto-refreshes at most once every
# ~24h before outdated/upgrade, so within a day this uses the cache and stays fast.
if [ "${1:-}" = "-u" ] || [ "${1:-}" = "--update" ]; then
  echo "Refreshing Homebrew…" >&2; brew update >/dev/null
fi

of="$(mktemp)"; oc="$(mktemp)"; lf="$(mktemp)"; vmap="$(mktemp)"; payload="$(mktemp)"; om="$(mktemp)"
trap 'rm -f "$of" "$oc" "$lf" "$vmap" "$payload" "$om"' EXIT
has_mas=""; command -v mas >/dev/null 2>&1 && has_mas=1
msg="Scanning for outdated formulae and casks"
[ -n "$has_mas" ] && msg="Scanning for outdated formulae, casks, and App Store apps"
echo "${msg}…" >&2
# brew's per-call startup is ~2.5s, so run the independent lookups concurrently.
brew outdated --formula --verbose >"$of" 2>/dev/null &
brew outdated --cask --verbose >"$oc" 2>/dev/null &
brew leaves --installed-on-request | sort >"$lf" 2>/dev/null &
[ -n "$has_mas" ] && mas outdated --json >"$om" 2>/dev/null &
wait

# Version map "name<TAB>current → latest" from "name (…, current) < latest" (formulae)
# and "name (version) != version" (casks). A cask version's comma-suffix is part of the
# version — keep it (e.g. dotnet 8.0.422,8.0.28) and only strip a trailing build hash.
# ponytail: "16+ hex chars" is the hash heuristic; tighten to require an a–f digit if a real
# version ever gets truncated.
awk '{
  i = index($0, " ("); if (i == 0) next
  name = substr($0, 1, i-1); rest = substr($0, i+2)
  j = index(rest, ")"); inside = substr(rest, 1, j-1); after = substr(rest, j+1)
  sub(/^ *(<|!=) */, "", after)
  m = split(inside, a, ", "); cur = a[m]
  sub(/,[0-9a-fA-F]{16,}$/, "", cur); sub(/,[0-9a-fA-F]{16,}$/, "", after)
  printf "%s\t%s → %s\n", name, cur, after
}' "$of" "$oc" > "$vmap"

names_in() { awk '{ i=index($0," ("); if (i>0) print substr($0,1,i-1) }' "$1"; }
outdated="$(names_in "$of")"        # outdated formula names
casks="$(names_in "$oc")"           # outdated cask names
leaves="$(cat "$lf")"; export leaves

# Map each outdated formula to its top node:
#   - outdated leaf         → the node itself (set intersection)
#   - outdated non-leaf dep → its leaf ancestors (brew uses, one per dep, in parallel)
build() {
  comm -12 <(echo "$leaves") <(echo "$outdated" | sort) | while read -r l; do
    [ -n "$l" ] && printf '%s\t●\t\n' "$l"
  done
  comm -13 <(echo "$leaves") <(echo "$outdated" | sort) | xargs -P8 -I{} bash -c '
    d="$1"; [ -z "$d" ] && exit 0
    comm -12 <(brew uses --installed --recursive "$d" | sort) <(echo "$leaves") \
      | while read -r a; do [ -n "$a" ] && printf "%s\t\t%s\n" "$a" "$d"; done
  ' _ {} || true
}

# One row per formula node: node <TAB> ●-or-empty <TAB> "dep1 dep2 ".
pending="$(build | awk -F'\t' '
  { seen[$1]=1; if ($2=="●") self[$1]=1; if ($3!="") deps[$1]=deps[$1] $3 " " }
  END { for (n in seen) printf "%s\t%s\t%s\n", n, (n in self ? "●" : ""), deps[n] }
')"

# Payload: type <TAB> node <TAB> member <TAB> "current → latest".
# Formula nodes expand to their outdated members; casks collapse under a depends_on parent.
awk -F'\t' '
  NR==FNR { ver[$1]=$2; next }
  { if ($2=="●") print "formula\t" $1 "\t" $1 "\t" ver[$1]
    c=split($3,a," "); for (i=1;i<=c;i++) if (a[i]!="") print "formula\t" $1 "\t" a[i] "\t" ver[a[i]] }
' "$vmap" <(printf '%s\n' "$pending") > "$payload"
# Fold an outdated cask under an outdated cask that depends on it (e.g. dotnet-sdk8 →
# dotnet-sdk8-0-400) so a wrapper and its version band show as one node, like formula deps
# under their leaf. Deps come from each cask's install receipt (runtime_dependencies) — a
# ~1KB local read, no brew calls. ponytail: one dep level, covers the wrapper pattern.
caskroom="${HOMEBREW_PREFIX:-$(dirname "$(dirname "$(command -v brew)")")}/Caskroom"
cver() { awk -F'\t' -v n="$1" '$1==n{print $2}' "$vmap"; }
cask_apps() {  # installed .app bundle name(s) for a cask, from its receipt's uninstall_artifacts
  awk -F'"' '
    $0 ~ /"app"[[:space:]]*:/ { inapp=1; next }
    inapp && /\]/ { inapp=0; next }
    inapp && NF>=2 { print $2 }' "$caskroom/$1/.metadata/INSTALL_RECEIPT.json" 2>/dev/null
}
cdeps="$(while read -r c; do
  [ -z "$c" ] && continue
  awk -F'"' '/"full_name":/ { d = $4; sub(/.*\//, "", d); print c "\t" d }' \
    c="$c" "$caskroom/$c/.metadata/INSTALL_RECEIPT.json" 2>/dev/null
done <<<"$casks")"                                          # parent<TAB>dep
folded="$(printf '%s\n' "$cdeps" | awk -F'\t' 'NF==2{print $2}' | sort -u \
         | comm -12 - <(printf '%s\n' "$casks" | sort -u))" # outdated casks shown under a parent
while read -r c; do
  [ -z "$c" ] && continue
  printf '%s\n' "$folded" | grep -qxF "$c" && continue      # this cask folds under a parent
  printf 'cask\t%s\t%s\t%s\n' "$c" "$c" "$(cver "$c")"      # top-level node (self row)
  printf '%s\n' "$cdeps" | awk -F'\t' -v c="$c" '$1==c{print $2}' | while read -r d; do
    printf '%s\n' "$folded" | grep -qxF "$d" && printf 'cask\t%s\t%s\t%s\n' "$c" "$d" "$(cver "$d")"
  done
done <<<"$casks" >> "$payload"

# Each outdated Mac App Store app is a standalone self node: display a slugified name + the
# last 3 id digits (keeps rows visually brew-like and near-unique); the numeric id in the
# member slot is what `mas` acts on. JSON fields are order-independent, so match each.
if [ -s "$om" ]; then
  awk '
    { id=""; name=""; cur=""; new=""
      if (match($0, /"adamID":[0-9]+/))       id   = substr($0, RSTART+9,  RLENGTH-9)
      if (match($0, /"name":"[^"]*"/))        name = substr($0, RSTART+8,  RLENGTH-9)
      if (match($0, /"version":"[^"]*"/))     cur  = substr($0, RSTART+11, RLENGTH-12)
      if (match($0, /"newVersion":"[^"]*"/))  new  = substr($0, RSTART+14, RLENGTH-15)
      if (id == "" || name == "") next
      s = tolower(name); gsub(/[^a-z0-9]+/, "-", s); gsub(/^-+|-+$/, "", s)
      printf "mas\t%s-%s\t%s\t%s\n", s, substr(id, length(id)-2), id, (cur == "" ? new : cur " → " new)
    }' "$om" >> "$payload"
fi

[ ! -s "$payload" ] && { echo "Nothing outdated."; exit 0; }
export BREWTOP_PAYLOAD="$payload"

# ctrl-x prints a marker then the highlighted line only (deselect-all makes accept emit the
# current item, not the selection — verified in fzf src output()/{} handling). No --expect.
sel="$(cut -f2 "$payload" | sort -u | fzf -m \
  --bind 'load:select-all' \
  --bind 'ctrl-t:toggle-all' \
  --bind 'ctrl-x:print(%%UNINSTALL%%)+deselect-all+accept' \
  --header=$'enter=update  ctrl-x=uninstall highlighted  tab=toggle  ctrl-t=invert' \
  --preview="$SELF --preview {1}" --preview-window=right,55%)" || exit 0

[ -z "$sel" ] && exit 0

if [ "$(head -1 <<<"$sel")" = "%%UNINSTALL%%" ]; then
  # Uninstall the single highlighted node (line 2), confirmed; runs here so output persists.
  n="$(sed -n 2p <<<"$sel")"
  [ -z "$n" ] && exit 0
  t="$(awk -F'\t' -v n="$n" '$2==n{print $1; exit}' "$payload")"
  printf 'Uninstall %s (%s)? [y/N] ' "$n" "$t" > /dev/tty
  read -r ans < /dev/tty || ans=""
  case "$ans" in
    y|Y) case "$t" in
           cask) brew uninstall --cask "$n" || true ;;
           mas)  mid="$(awk -F'\t' -v n="$n" '$1=="mas" && $2==n {print $3; exit}' "$payload")"
                 { [ -n "$mid" ] && sudo mas uninstall "$mid"; } || true ;;
           *)    { brew uninstall --formula "$n" && brew autoremove; } || true ;;
         esac ;;
    *) echo "  skipped $n" > /dev/tty ;;
  esac
else
  # Update: gather selected nodes' outdated members, split by type, upgrade in one call each.
  nodes="$sel"
  fpkgs=""; cpkgs=""; mpkgs=""
  while read -r n; do
    [ -z "$n" ] && continue
    while IFS=$'\t' read -r t m; do
      [ -z "$m" ] && continue
      case "$t" in
        cask) cpkgs="$cpkgs$m"$'\n' ;;
        mas)  mpkgs="$mpkgs$m"$'\n' ;;
        *)    fpkgs="$fpkgs$m"$'\n' ;;
      esac
    done < <(awk -F'\t' -v n="$n" '$2==n { print $1 "\t" $3 }' "$payload")
  done <<<"$nodes"
  fpkgs="$(printf '%s' "$fpkgs" | sed '/^$/d' | sort -u)"
  cpkgs="$(printf '%s' "$cpkgs" | sed '/^$/d' | sort -u)"
  mpkgs="$(printf '%s' "$mpkgs" | sed '/^$/d' | sort -u)"
  # shellcheck disable=SC2086
  [ -n "$fpkgs" ] && brew upgrade --formula $fpkgs
  # A cask whose installed .app is owned by another user (typically root, after a vendor
  # self-updater reinstalled it) can't be upgraded by brew without sudo — brew would `sudo cp`
  # the bundle, which fails non-interactively (e.g. under topgrade) and which freshenup never
  # does. Detect those up front (ownership, not `-w`: App Management protection makes `-w`
  # report false even for your own apps) and per cask offer to chown it back to you or skip it.
  if [ -n "$cpkgs" ]; then
    me="$(id -un)"; ok=""
    while read -r c; do
      [ -z "$c" ] && continue
      root_app=""
      while read -r app; do
        p="/Applications/$app"; [ -e "$p" ] || continue
        # ponytail: owner-based (updaters root the whole bundle); group-writable edge unhandled.
        [ "$(stat -f '%Su' "$p")" != "$me" ] && root_app="$p"
      done < <(cask_apps "$c")
      if [ -z "$root_app" ]; then ok="$ok$c"$'\n'; continue; fi
      o="$(stat -f '%Su' "$root_app")"
      printf '%s is owned by %s — brew needs sudo to upgrade it. chown to you first? [y/N] ' \
        "$c" "$o" > /dev/tty
      read -r ans < /dev/tty || ans=""
      case "$ans" in
        y|Y) sudo chown -R "$me:admin" "$root_app" && ok="$ok$c"$'\n' \
               || echo "  chown failed; skipped $c" > /dev/tty ;;
        *)   echo "  skipped $c (owned by $o)" > /dev/tty ;;
      esac
    done <<<"$cpkgs"
    cpkgs="$(printf '%s' "$ok" | sed '/^$/d' | sort -u)"
  fi
  # shellcheck disable=SC2086
  [ -n "$cpkgs" ] && brew upgrade --cask $cpkgs
  # shellcheck disable=SC2086
  [ -n "$mpkgs" ] && sudo mas upgrade $mpkgs
  true
fi
