#!/bin/bash
#
# Purpose : Send an install or update log to the CoreDesktop team, so they can
#           work out what went wrong and fix it
#
# report                 Send the newest log, after showing what will be sent
# report FILE            Send FILE
# report --view [FILE]   Show what would be sent, and send nothing
# report --offer FILE [STEP...]
#                        What update and install.sh run when they fail: ask on
#                        the terminal whether to send FILE. STEP names what failed.
#
# Nothing is sent unless the user says yes. Before sending, the log is cleaned
# of terminal codes and personal details are replaced: the home folder, user
# name, host name, email addresses, IPv4 addresses and Tailscale names.
#
# What's sent (the receiver on federated.sh implements the other side): an
# HTTPS POST of multipart/form-data to $COREDESKTOP_REPORT_URL, with fields
#   kind      install or update, from the log's name
#   version   CoreDesktop's version, e.g. 2026.11.08 (a82bbfc)
#   failed    what failed, e.g. "71-siblings.sh, 46-neovim.sh" (may be empty)
#   kernel    the running kernel, from uname -r
#   siblings  the installed sibling projects, one "name version" per line
#   log       the cleaned, redacted log, gzipped (log.gz, application/gzip)
# A 2xx response's body is the report's ID, on one line: up to 40 letters,
# digits and dashes. Any other response's
# body is a message for the user.

COREDESKTOP="$HOME/.local/share/coredesktop"
LOGS="$HOME/.local/state/coredesktop/logs"
REPORT_URL="${COREDESKTOP_REPORT_URL:-https://federated.sh/report}"
TTY="${COREDESKTOP_TTY:-/dev/tty}"
# The receiver refuses bigger reports
MAX_BYTES=$(( 10 * 1024 * 1024 ))

usage() {
  cat << EOF
Usage: report [FILE]          Send a log (the newest, if none is named)
       report --view [FILE]   Show what would be sent, and send nothing
EOF
}

newest_log() {
  local logs
  mapfile -t logs < <(ls -t "$LOGS"/*.log 2> /dev/null)
  [ "${#logs[@]}" -gt 0 ] && echo "${logs[0]}"
}

# Escape $1 for use in a sed regular expression
regex_escape() {
  printf '%s' "$1" | sed 's/[][\.*^$/]/\\&/g'
}

# Print log $1 as it would be sent: without script(1)'s header and footer,
# terminal codes or progress redraws, and with personal details replaced.
# LC_ALL=C so the ranges mean bytes, whatever the locale. IPv4 addresses are
# replaced twice because a match takes the character after an address, which
# can be the one before the next; dotted versions such as 1.0.4.1.3 are kept.
redact() {
  local home user host
  home="$(regex_escape "$HOME")"
  user="$(regex_escape "${USER:-$(id -un)}")"
  host="$(regex_escape "$(uname -n)")"
  LC_ALL=C sed -E \
    -e '1{/^Script started on /d}' -e '/^Script done on .*COMMAND_EXIT_CODE/d' \
    -e 's/\x1b\][^\x07\x1b]*(\x07|\x1b\\)//g' -e 's#\x1b\[[0-9;?]*[ -/]*[@-~]##g' \
    -e 's/\x1b[()][0-9A-Za-z]//g' -e 's/\x1b[=>]//g' -e 's/\r+$//' -e 's/.*\r//' \
    -e "s#${home}([/[:space:]'\":]|\$)#~\\1#g" \
    -e 's/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/<email>/g' \
    -e 's/[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.ts\.net/<tailscale-name>/g' \
    -e 's/(^|[^0-9.])([0-9]{1,3}\.){3}[0-9]{1,3}([^0-9.]|$)/\1<ip>\3/g' \
    -e 's/(^|[^0-9.])([0-9]{1,3}\.){3}[0-9]{1,3}([^0-9.]|$)/\1<ip>\3/g' \
    -e "s/(^|[^A-Za-z0-9_-])${user}([^A-Za-z0-9_-]|\$)/\\1<user>\\2/g" \
    -e "s/(^|[^A-Za-z0-9_-])${host}([^A-Za-z0-9_-]|\$)/\\1<host>\\2/g" \
    "$1"
}

coredesktop_version() {
  git -C "$COREDESKTOP" log -1 --format='%cd (%h)' --date=format:%Y.%m.%d 2> /dev/null || echo unknown
}

# "name version" for each installed sibling, without its URL, which can name a person
sibling_versions() {
  [ -f "$COREDESKTOP/sibling" ] || return 0
  bash "$COREDESKTOP/sibling" list 2> /dev/null | grep -v '^No sibling' \
    | awk '{ $NF = ""; sub(/ +$/, ""); print }'
}

# show_redacted FILE: page through what would be sent
show_redacted() {
  if command -v less > /dev/null && [ -t 1 ]; then
    redact "$1" | less
  else
    redact "$1"
  fi
}

# send FILE [STEP...]: send FILE's report and print its ID
send() {
  local file="$1" kind tmp response code body
  shift
  kind="$(basename "$file")"
  kind="${kind%%-*}"
  [[ "$kind" == install || "$kind" == update ]] || kind=other

  tmp="$(mktemp -d)" || return 1
  redact "$file" | gzip -9 > "$tmp/log.gz"
  if (( $(stat -c %s "$tmp/log.gz") > MAX_BYTES )); then
    echo "This log is too big to send ($(( $(stat -c %s "$tmp/log.gz") / 1024 / 1024 )) MB compressed)."
    rm -rf "$tmp"
    return 1
  fi

  local failed
  failed="$(printf '%s, ' "$@")"
  failed="${failed%, }"

  echo "Sending..."
  # Values go in with -F name=<file, which sends them as they are: with
  # name=value, curl would treat a value starting with @ or < as a file
  printf '%s' "$kind" > "$tmp/kind"
  coredesktop_version | tr -d '\n' > "$tmp/version"
  printf '%s' "$failed" > "$tmp/failed"
  uname -r | tr -d '\n' > "$tmp/kernel"
  sibling_versions > "$tmp/siblings"
  response="$(curl -sS --max-time 120 -A CoreDesktop-report \
    -F "kind=<$tmp/kind" -F "version=<$tmp/version" -F "failed=<$tmp/failed" \
    -F "kernel=<$tmp/kernel" -F "siblings=<$tmp/siblings" \
    -F "log=@$tmp/log.gz;type=application/gzip;filename=log.gz" \
    -w '\n%{http_code}' "$REPORT_URL" 2>&1)"
  local status=$?
  rm -rf "$tmp"

  code="${response##*$'\n'}"
  body="${response%$'\n'*}"
  [[ "$response" == *$'\n'* ]] || body="$response"
  # A 2xx that isn't a report ID is some other page, not the receiver
  if (( status == 0 )) && [[ "$code" == 2?? && "$body" =~ ^[A-Za-z0-9-]{1,40}$ ]]; then
    echo "Sent. Thank you! Your report's ID is $body; mention it if you contact us about this."
    return 0
  fi
  echo "Couldn't send the report: ${body:-no response}"
  echo "To try again later, run: report $file"
  return 1
}

# Open the terminal once, on file descriptor 3, for ask to read answers from.
# Fails if there's no terminal to ask on.
open_tty() {
  { exec 3< "$TTY"; } 2> /dev/null
}

# ask QUESTION: read an answer from the terminal into $answer
ask() {
  read -rp "$1" answer <&3
}

# offer FILE [STEP...]: ask whether to send FILE, show it first if asked
offer() {
  local file="$1"
  [ -f "$file" ] || return 0
  open_tty || return 0
  echo
  echo "Send this log to the CoreDesktop team, so we can look into what went wrong?"
  echo "Personal details such as your user name, host name and email addresses are"
  echo "taken out first."
  while ask "[v] view what would be sent, [y] send, [N] don't send: "; do
    case "$answer" in
      [vV]*) show_redacted "$file" ;;
      [yY]*) send "$@"; return 0 ;;
      *) break ;;
    esac
  done
  echo "Not sent. To send it later, run: report $file"
}

case "$1" in
  -h|--help) usage ;;
  --offer) shift; offer "$@" ;;
  --view)
    file="${2:-$(newest_log)}"
    [ -f "$file" ] || { echo "No log to show."; exit 1; }
    show_redacted "$file"
    ;;
  -*) usage; exit 1 ;;
  *)
    file="${1:-$(newest_log)}"
    [ -f "$file" ] || { echo "No log found in $LOGS."; exit 1; }
    echo "Log: $file"
    if ! open_tty; then
      # No terminal to ask on: running report is itself the request to send
      send "$file"
      exit
    fi
    while true; do
      ask "[v] view what would be sent, [y] send, [N] don't send: " || answer=n
      case "$answer" in
        [vV]*) show_redacted "$file" ;;
        [yY]*) send "$file"; exit ;;
        *) echo "Not sent."; exit 0 ;;
      esac
    done
    ;;
esac
