#!/usr/bin/env bash
#
# Description: Simple AUR browser: check for package updates, search for packages,
#              clone packages, show info for packages, or simply download to
#              show any PKGBUILD from a package. Does not build or manage packages,
#              and is not a pacman/paru/yay clone.
#
# Homepage: https://codeberg.org/krathalan/miscellaneous-scripts
#
# Copyright (C) 2020-2024 Hunter Peavey
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# 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.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
#
# This file incorporates work from https://github.com/dylanaraps/pash,
# covered by the following copyright and permission notice:
#
#     Copyright (c) 2016-2019, Dylan Araps
#
#     Permission is hereby granted, free of charge, to any person obtaining a copy
#     of this software and associated documentation files (the "Software"), to deal
#     in the Software without restriction, including without limitation the rights
#     to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
#     copies of the Software, and to permit persons to whom the Software is
#     furnished to do so, subject to the following conditions:
#
#     The above copyright notice and this permission notice shall be included in all
#     copies or substantial portions of the Software.
#
#     THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
#     IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
#     FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
#     AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
#     LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
#     OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
#     SOFTWARE.

# -----------------------------------------
# -------------- Guidelines ---------------
# -----------------------------------------

# This script follows the Google Shell Style Guide:
# https://google.github.io/styleguide/shell.xml

# This script uses shellcheck: https://www.shellcheck.net/

# See https://vaneyckt.io/posts/safer_bash_scripts_with_set_euxo_pipefail/
set -Eeuo pipefail

# Clean up if script is interrupted early
trap "kill 0" SIGINT
trap "clean_up" EXIT

# -----------------------------------------
# ----------- Program variables -----------
# -----------------------------------------

# Colors
BLUE=$(tput sgr0 && tput setaf 4)
GREEN=$(tput setaf 10)
PURPLE=$(tput bold && tput setaf 5)
RED=$(tput bold && tput setaf 1)
YELLOW=$(tput sgr0 && tput setaf 3)
NC=$(tput sgr0) # No color/turn off all tput attributes
readonly BLUE GREEN PURPLE RED YELLOW NC

# Other
readonly AUR_BASE_URL="https://aur.archlinux.org"
readonly AUR_RPC_URL="${AUR_BASE_URL}/rpc/?v=5&type="
readonly AUR_PKGBUILD_URL="${AUR_BASE_URL}/cgit/aur.git/plain/PKGBUILD?h="
readonly SCRIPT_NAME="${0##*/}"
readonly DANGEROUS_COMMANDS=("rm" "mount" "mkfs")
DANGEROUS_COMMAND_FOUND="false"

# -----------------------------------------
# ------------- User variables ------------
# -----------------------------------------

QUIET="false"

# -----------------------------------------
# --------------- "Library" ---------------
# -----------------------------------------

#######################################
# Cleans up temporary files if they exist.
# Globals:
#   TMP_DIR
# Arguments:
#   none
# Returns:
#   none
#######################################
clean_up()
{
  if [[ -d "${TMP_DIR:-}" ]]; then
    rm -rf "${TMP_DIR}"
  fi
}

#######################################
# Downloads json from the Aurweb RPC interface.
# Docs: https://wiki.archlinux.org/index.php/Aurweb_RPC_interface
# Globals:
#   AUR_JSON
# Arguments:
#   $1: URL to download
# Returns:
#   none
#######################################
download_file()
{
  readonly AUR_JSON="${TMP_DIR}/aur.json"

  curl --silent -o "${AUR_JSON}" "$1"

  if grep -q -i "service unavailable" "${AUR_JSON}"; then
    printf "%sWarning%s: %s\n" "${YELLOW}" "${NC}" "AUR service unavailable. Probably down for maintenance." >&2
    exit 0
  fi

  if [[ "$(jaq -r .type "${AUR_JSON}")" == "error" ]]; then
    exit_script_on_failure "$(jaq -r .error "${AUR_JSON}")"
  fi
}

#######################################
# Prints passed error message before premature exit.
# Prints everything to >&2 (STDERR).
# Globals:
#   RED, NC
#   SCRIPT_NAME
# Arguments:
#   $1: error message to print
# Returns:
#   none
#######################################
exit_script_on_failure()
{
  printf "%sError%s: %s\n" "${RED}" "${NC}" "$1" >&2
  exit 1
}

#######################################
# This is a simple wrapper around a case statement to allow
# for simple string comparisons against globs.
# Copyright (C) 2016-2019 Dylan Araps
# Globals:
#   none
# Arguments:
#   $1: string to check against glob
#   $2: glob
# Returns:
#   true or false
#######################################
glob() {
  # Disable this warning as it is the intended behavior.
  # shellcheck disable=2254
  case $1 in $2) return 0; esac; return 1
}

#######################################
# Makes a temporary directory for the script to use.
# Globals:
#   SCRIPT_NAME
# Arguments:
#   none
# Returns:
#   none
#######################################
make_tmp()
{
  TMP_DIR="$(mktemp -d -t "${SCRIPT_NAME}_XXXXXXXX")"
  readonly TMP_DIR
}

# -----------------------------------------
# --------------- Functions ---------------
# -----------------------------------------

#######################################
# Checks all foreign packages for updates on the AUR.
# Globals:
#   AUR_RPC_URL
#   AUR_JSON
#   OUTPUTFILEBAD
#   OUTPUTFILEGOOD
#   QUIET
# Arguments:
#   none
# Returns:
#   none
#######################################
check()
{
  printf "Constructing download parameters..."
  
  make_tmp
  readonly OUTPUTFILEBAD="${TMP_DIR}/${SCRIPT_NAME}-bad.txt"
  readonly OUTPUTFILEGOOD="${TMP_DIR}/${SCRIPT_NAME}-good.txt"

  # Ensure both output files get written to
  touch "${OUTPUTFILEBAD}" "${OUTPUTFILEGOOD}" &

  mapfile -t packages <<< "$(pacman -Qmq)"
  mapfile -t packageVersions <<< "$(pacman -Qm | cut -d' ' -f2-)"

  # Add krathalan repo packages if krathalan repo is present
  # First check if we have the repo enabled
  if pacman-conf -l | grep -q krathalan; then
    # Then map all packages from [krathalan] into an array
    mapfile -t krathalanPackages <<< "$(comm -12 <(pacman -Qq | sort) <(pacman -Slq "krathalan" | sort))"

    # Add them and their versions to the foreign package arrays
    for package in "${krathalanPackages[@]}"; do
      packages+=("${package}")
    done

    for package in "${krathalanPackages[@]}"; do
      packageVersions+=("$(pacman -Qi "${package}" | grep Version | awk '{printf $3}')")
    done
  fi

  requestURL="${AUR_RPC_URL}info"

  for package in "${packages[@]}"; do
    if [[ "${package}" == *"git" ]]; then
      continue
    fi

    requestURL+="&arg[]=${package}"
  done

  printf "Downloading data from AUR..."

  download_file "${requestURL}"

  printf "Parsing downloaded json..."
  
  whileCounter=0

  while [[ "${whileCounter}" -lt "${#packages[@]}" ]]; do
    if [[ "${packages[${whileCounter}]}" == *"git" ]]; then
      printf "%s%s is a -git package; skipping%s\n" "${YELLOW}" "${packages[${whileCounter}]}" "${NC}" >> "${OUTPUTFILEGOOD}"
      whileCounter="$(( whileCounter + 1 ))"
      continue
    fi

    check_print_entry "${packages[${whileCounter}]}" "${packageVersions[${whileCounter}]}" &
    whileCounter="$(( whileCounter + 1 ))"
  done

  wait

  printf "\r\033[K"

  if [[ "${QUIET}" == "true" ]]; then
    sort "${OUTPUTFILEBAD}"
  else
    sort "${OUTPUTFILEBAD}" "${OUTPUTFILEGOOD}"
  fi
}

#######################################
# Used to facilitate multiprocess comparison of installed
# foreign package version to AUR package version.
# Globals:
#   GREEN, PURPLE, RED, NC
#   AUR_JSON
#   OUTPUTFILEBAD
#   OUTPUTFILEGOOD
# Arguments:
#   $1: package name
#   $2: installed package version
# Returns:
#   none
#######################################
check_print_entry()
{
  # Extract version from AUR json

  local -r aurVersion="$(jaq -r ".results[] | select(.Name == \"$1\").Version" "${AUR_JSON}")"

  if [[ "${aurVersion}" == "" ]]; then
    printf "%s%s not on AUR%s\n" "${PURPLE}" "$1" "${NC}" >> "${OUTPUTFILEGOOD}"
  elif [[ "${aurVersion}" != "$2" ]]; then
    printf "%s%s %s installed; %s available%s\n" "${RED}" "$1" "$2" "${aurVersion}" "${NC}" >> "${OUTPUTFILEBAD}"
  else
    printf "%s%s up to date%s\n" "${GREEN}" "$1" "${NC}" >> "${OUTPUTFILEGOOD}"
  fi
}

#######################################
# Fetches package(s) from the AUR.
# Globals:
#   AUR_BASE_URL
# Arguments:
#   $@: package name(s) to fetch
# Returns:
#   none
#######################################
fetch()
{
  while [[ $# -gt 0 ]]; do
    git clone "${AUR_BASE_URL}/$1.git"
    shift
  done
}

#######################################
# Prints information about a package on the AUR in the
# style of `pacman -Qi`.
# Globals:
#   AUR_RPC_URL
# Arguments:
#   $1: package name to print info of
# Returns:
#   none
#######################################
info()
{
  make_tmp

  printf "Downloading data from AUR..."

  download_file "${AUR_RPC_URL}info&arg[]=$1"

  printf "Parsing downloaded json..."

  [[ -z "$(jaq -r .results[] "${AUR_JSON}")" ]] &&
    printf "\n\"%s\" not found on AUR\n" "$1" && exit 0

  # Map simple json values to an array
  mapfile -t packageData <<< "$(jaq -r ".results[].Name, .results[].Version, .results[].Description, .results[].URL, .results[].NumVotes, .results[].Maintainer, .results[].FirstSubmitted, .results[].LastModified" "${AUR_JSON}")"

  # Use a bunch of subprocesses to process each array in the json
  # that can't be mapped easily
  local -r LICENSES_FILE="${TMP_DIR}/licenses"
  local -r DEPENDS_FILE="${TMP_DIR}/depends"
  local -r MAKE_DEPENDS_FILE="${TMP_DIR}/make_depends"
  local -r OPT_DEPENDS_FILE="${TMP_DIR}/opt_depends"
  local -r CONFLICTS_FILE="${TMP_DIR}/conflicts"
  local -r PROVIDES_FILE="${TMP_DIR}/provides"
  local -r REPLACES_FILE="${TMP_DIR}/replaces"

  info_helper "License" "${LICENSES_FILE}" &
  info_helper "Depends" "${DEPENDS_FILE}" &
  info_helper "MakeDepends" "${MAKE_DEPENDS_FILE}" &
  info_helper "OptDepends" "${OPT_DEPENDS_FILE}" &
  info_helper "Conflicts" "${CONFLICTS_FILE}" &
  info_helper "Provides" "${PROVIDES_FILE}" &
  info_helper "Replaces" "${REPLACES_FILE}" &

  wait

  printf "\r\033[K"

  printf "\n%s" "\
Repository      : aur
Name            : ${packageData[0]}
Version         : ${packageData[1]}
Description     : ${packageData[2]}
URL             : ${packageData[3]}
Licenses        :$(<"${LICENSES_FILE}")
Depends On      :$(<"${DEPENDS_FILE}")
Make Depends    :$(<"${MAKE_DEPENDS_FILE}")
Optional Deps   :$(<"${OPT_DEPENDS_FILE}")
Conflicts With  :$(<"${CONFLICTS_FILE}")
Provides        :$(<"${PROVIDES_FILE}")
Replaces        :$(<"${REPLACES_FILE}")
Votes           : ${packageData[4]}
Maintainer      : ${packageData[5]}
First Submitted : $(date -d "@${packageData[6]}" "+%a %d %B %Y %r %Z")
Last Modified   : $(date -d "@${packageData[7]}" "+%a %d %B %Y %r %Z")
"
}

#######################################
# Used to facilitate multiprocess parsing of json arrays
# containing information about a package.
# Globals:
#   AUR_JSON
# Arguments:
#   $1: jaq value to parse
#   $2: file to output to
# Returns:
#   none
#######################################
info_helper()
{
  # Always ensure file is written
  touch "$2"

  local whileCounter=0
  local output=""
  local parser

  # Limitation of JAQ vs. JQ:
  # If the root element in the json isn't present, attempting to access indexed elements
  # owned by the root element will result in an error.
  if [[ "$(jaq -r ".results[].$1" "${AUR_JSON}")" == "null" ]]; then
    return
  fi
  parser="$(jaq -r ".results[].$1[${whileCounter}]" "${AUR_JSON}")"

  while [[ "${parser}" != "null" ]]; do
    output+=" ${parser}"

    # Refresh data
    whileCounter="$(( whileCounter + 1 ))"
    parser="$(jaq -r ".results[].$1[${whileCounter}]" "${AUR_JSON}")"
  done

  printf "%s" "${output:- None}" > "$2"
}

#######################################
# Searches on the AUR for a search term.
# Globals:
#   AUR_RPC_URL
# Arguments:
#   $1: search term
# Returns:
#   none
#######################################
search()
{
  make_tmp

  printf "Downloading data from AUR..."

  download_file "${AUR_RPC_URL}search&arg=$1"

  printf "Parsing downloaded json..."

  readonly TERMINAL_WIDTH="$(( $(tput cols) - 9 ))"
  readonly OUTPUT_FILE="${TMP_DIR}/search_output"

  # Sort json by votes to only display the top 150 results
  local -r totalNumberResults="$(jaq -r '.resultcount' "${AUR_JSON}")"
  local -r sortedJson="$(jaq -r '[.results[]] | sort_by(.NumVotes) | reverse' "${AUR_JSON}")"
  printf "%s" "${sortedJson}" > "${AUR_JSON}"

  local whileCounter=0
  local output=""
  local parser
  parser="$(jaq -r ".[${whileCounter}]" "${AUR_JSON}")"

  while [[ "${parser}" != "null" ]] && [[ "${whileCounter}" != 150 ]]; do
    search_print_entry "${parser}" &

    # Refresh data
    whileCounter="$(( whileCounter + 1 ))"
    parser="$(jaq -r ".[${whileCounter}]" "${AUR_JSON}")"
  done

  wait

  [[ ! -f "${OUTPUT_FILE}" ]] &&
    printf "\nNo results\n" && exit 0

  local -r numberOfResults="$(wc -l "${OUTPUT_FILE}" | cut -d' ' -f1)"
  local pluralSuffix=""
  local overflowSuffix=""

  if [[ "${numberOfResults}" -gt 1 ]]; then
    pluralSuffix="s, sorted by votes"
  fi

  if [[ "${totalNumberResults}" -gt "${numberOfResults}" ]]; then
    overflowSuffix=" (${totalNumberResults} total, truncated to ${numberOfResults})"
  fi

  printf "\r\033[K"

  sort -g "${OUTPUT_FILE}"

  printf "\n%s result%s%s\n" "${numberOfResults}" "${pluralSuffix}" "${overflowSuffix}"
}

#######################################
# Prints information for package from a search query.
# Globals:
#   BLUE, PURPLE, GREEN, NC
#   TERMINAL_WIDTH
#   OUTPUT_FILE
# Arguments:
#   $1: json information for a package
# Returns:
#   none
#######################################
search_print_entry()
{
  mapfile -t packageData <<< "$(jaq -r ".Name, .Version, .NumVotes, .Description" <<< "$1")"

  local packageVotes="${packageData[2]}"

  if [[ "${packageVotes}" -lt 10 ]]; then
    packageVotes="000${packageVotes}"
  elif [[ "${packageVotes}" -lt 100 ]]; then
    packageVotes="00${packageVotes}"
  elif [[ "${packageVotes}" -lt 1000 ]]; then
    packageVotes="0${packageVotes}"
  fi

  local maxDescriptionLength="$(( TERMINAL_WIDTH - ${#packageData[0]} - ${#packageData[1]} ))"

  local packageDescription="${packageData[3]:0:${maxDescriptionLength}}"

  if [[ "${#packageDescription}" == "${maxDescriptionLength}" ]]; then
    packageDescription+="…"
  fi

  printf "%s%s %s%s %s%s%s: %s\n" "${BLUE}" "${packageVotes}" "${PURPLE}" "${packageData[0]}" "${GREEN}" "${packageData[1]}" "${NC}" "${packageDescription}" >> "${OUTPUT_FILE}"
}

#######################################
# Prints the PKGBUILD for a specified AUR package.
# Globals:
#   AUR_PKGBUILD_URL
#   TMP_DIR
#   RED
#   NC
# Arguments:
#   $1: package to print PKGBUILD of
# Returns:
#   none
#######################################
print_pkgbuild()
{
  make_tmp
  local -r pkgbuild_file="${TMP_DIR}/PKGBUILD"

  printf "Downloading PKGBUILD..."
  curl --silent -o "${pkgbuild_file}" "${AUR_PKGBUILD_URL}$1"

  # Use bat if present for line numbering and syntax highlighting
  if [[ -n "$(command -v bat)" ]]; then
    local -r print_file_cmd="bat --paging=never"
  else
    local -r print_file_cmd="cat"
  fi

  printf "\r\033[K"

  ${print_file_cmd} "${pkgbuild_file}"

  analyze "${pkgbuild_file}"
}

#######################################
# Prints information if the specified command is potentially
# in the PKGBUILD.
# Globals:
#   RED
#   NC
# Arguments:
#   $1: command to grep for in pkgbuild
#   $2: name of file to grep in
# Returns:
#   none
#######################################
grep_command()
{
  if grep -Pq "(^| +)$1 +" "$2"; then
    printf "\n%s===> Warning! %s potentially contains \`%s\` command:\n%s" "${YELLOW}" "${2##*/}" "$1" "${NC}"
    grep -Pn --color=always "(^| +)$1 +" "$2"

    DANGEROUS_COMMAND_FOUND="true"
  fi
}

#######################################
# Analyzes a (presumably Bash script) file for potentially
# dangerous commands. You should still read the whole thing anyways!
# Globals:
#   DANGEROUS_COMMANDS
# Arguments:
#   $1: file name to analyze; defaults to "${PWD}/PKGBUILD" 
#       if none specified
# Returns:
#   none
#######################################
analyze()
{
  if [[ $# -gt 0 ]]; then
    [[ ! -f "$1" ]] &&
      exit_script_on_failure "File $1 not found."
    
    for cmd in "${DANGEROUS_COMMANDS[@]}"; do
      grep_command "${cmd}" "$1"
    done
  else
    [[ ! -f PKGBUILD ]] &&
      exit_script_on_failure "PKGBUILD file not found."
    
    for cmd in "${DANGEROUS_COMMANDS[@]}"; do
      grep_command "${cmd}" "${PWD}/PKGBUILD"
    done
  fi

  if [[ "${DANGEROUS_COMMAND_FOUND}" == "true" ]]; then
    printf "\nPlease make sure to read through the whole PKGBUILD anyways.\nThis is just a supplementary tool to help you identify areas\nto look at more closely.\n"
  fi
}

#######################################
# Prints usage information about the script.
# Copyright (C) 2016-2019 Dylan Araps
# Globals:
#   SCRIPT_NAME
# Arguments:
#   $1: none
# Returns:
#   none
#######################################
usage() { printf "%s" "\
${SCRIPT_NAME} - helps you manage AUR packages

=> [c]heck            - Check local package versions against those on the AUR.
                        Pass --quiet/-q flag to print only non-matching versions.
=> [f]etch [name(s)]  - Clone git repository of [name] package(s) on the AUR.
=> [i]nfo [name]      - Show full information for [name] package on the AUR.
=> [p]kgbuild [name]  - Download and print the PKGBUILD for [name] package
=> [a]nalyze ([name]) - Analyze [name] file for dangerous commands, or if no
                        [name] is given, analyze 'PKGBUILD' in the current dir.
=> [s]earch [name]    - Search for [name] on the AUR.
"
exit 0
}

# -----------------------------------------
# ---------------- Script -----------------
# -----------------------------------------

[[ "$(whoami)" = "root" ]] &&
  exit_script_on_failure "${SCRIPT_NAME} should NOT be run as root (or sudo)!"

[[ -z "${1:-}" ]] && usage

if [[ $# -gt 0 ]]; then
  if [[ "$*" == *"--quiet"* ]] || [[ "$*" == *"-q"* ]]; then
    QUIET="true"
  fi

  if [[ "$1" == "--quiet" ]] || [[ "$1" == "-q" ]]; then
    shift
  fi
fi

glob "$1" '[fips]*' && [[ -z "${2:-}" ]] &&
  exit_script_on_failure "Missing [name] argument"

case $1 in
  a*) shift; analyze "$@" ;;
  c*) check ;;
  f*) shift; fetch "$@" ;;
  i*) info "$2" ;;
  p*) print_pkgbuild "$2" ;;
  s*) search "$2" ;;
  *)  usage
esac
