#!/usr/bin/env bash
# shellcheck disable=SC1091
. gettext.sh

usage() {
  cat <<EOF
$(gettext "Usage: downgrade [option...] <pkg> [pkg...] [-- pacman_option...]")

$(gettext "Options"):
  --pacman        <$(gettext "command")>
                  $(gettext "pacman command to use, defaults to") "pacman"
  --pacman-conf   <$(gettext "path")>
                  $(gettext "pacman configuration file, defaults to") "/etc/pacman.conf"
  --pacman-cache  <$(gettext "path")>
                  $(gettext "pacman cache directory,")
                  $(gettext "default value(s) taken from pacman configuration file,")
                  $(gettext "or otherwise defaults to") "/var/cache/pacman/pkg"
  --pacman-log    <$(gettext "path")>
                  $(gettext "pacman log file,")
                  $(gettext "default value taken from pacman configuration file,")
                  $(gettext "or otherwise defaults to") "/var/log/pacman.log"
  --git           $(gettext "uses git for choosing version, viable only for aur packages")
  --maxdepth      <$(gettext "integer")>
                  $(gettext "maximum depth to search for cached packages, defaults to") 1
  --ala-url       <url>
                  $(gettext "location of ALA server, defaults to") "https://archive.archlinux.org"
  --ala-only      $(gettext "only use ALA server")
  --cached-only   $(gettext "only use cached packages")
  --ignore        <prompt|always|never>
                  $(gettext "whether to add packages to IgnorePkg")
  --unignore      $(gettext "remove packages from IgnorePkg")
  --latest        $(gettext "pick latest matching version")
  --oldest        $(gettext "pick oldest matching version")
  --prefer-cache  $(gettext "do not query ala if a matching package was found in cache")
  --version       $(gettext "show downgrade version")
  -h, --help      $(gettext "show help script")

$(gettext "Note"):
  $(gettext "Options after the -- characters will be treated as pacman options.")
  $(gettext "See downgrade(8) for details.")
EOF
}

sed_msg() {
  local msg="$1"
  shift

  if ! sed "$@" 2>/dev/null; then
    printf "%s\n" "Failed $msg" >&2
    exit 1
  fi
}

read_pacman_conf() {
  pacman-conf --config "$PACMAN_CONF" "$1"
}

read_downgrade_conf() {
  local var=$1

  eval "$var=($(grep -E -v '^ *(#.*)?$' "$DOWNGRADE_CONF" 2>/dev/null | xargs printf '%q '))"
}

read_unique() {
  local var=$1
  shift

  if [[ -n "$*" ]]; then
    # shellcheck disable=SC2229
    mapfile -t "$var" < <(printf "%s\n" "$@" | sort -u)
  fi
}

previously_installed() {
  # Delay this defaulting so #read_pacman_conf behavior is tested
  : "${PACMAN_LOG:=$(read_pacman_conf LogFile)}"
  : "${PACMAN_LOG:=/var/log/pacman.log}"

  sed_msg "to parse previously installed packages" '
    /.*\(installed\|upgraded\) \('"$1"'\) (\(.* -> \)\?\([^)]*\))/!d
    s//\2-\4/
  ' "$PACMAN_LOG"
}

currently_installed() {
  LC_ALL=C.UTF8 "$PACMAN" -Qi "$1" 2>/dev/null | awk -F " : " '
    /^Name / { name=$2 };
    /^Version / { version=$2 };
    END { if (name != "") printf("%s-%s\n", name, version) }
  '
}

# <package name> [package path] …
present_packages() {
  local i=1
  local pkgname="$1"
  shift

  (($#)) || return 1

  repo="$(pacman -Ss "^${pkgname}$" | awk -F'/' 'NR==1 {print $1}')"
  repo=${repo:-foreign/aur}

  gettext 'Available packages'
  printf " (%s):\n" "$repo"

  for entry; do
    output_package "$((i++))" "$entry" "$pkgname"
  done | column -s '	' -t -R 2,4,6
  # Expand tabs in the output of output_package to spaces as needed to create a
  # table. Right align numerical columns 2 (index), 4 (epoch) and 6 (release).
}

read_selection() {
  local ans

  read -r ans

  ((ans > 0 && ans <= $#)) && printf "%s" "${!ans}"
}

is_yes() {
  local ans=${1,,} y

  y=$(gettext 'y')

  # Accept localized yes or english yes
  # See https://github.com/archlinux-downgrade/downgrade/issues/219
  [[ "${1,,}" == ${y}* ]] || [[ "${1,,}" == y* ]]
}

prompt_to_ignore() {
  local pkg ans

  if [[ $DOWNGRADE_IGNORE == never ]]; then
    return
  fi

  for pkg; do
    if ! pacignore check -c "$PACMAN_CONF" "$pkg"; then
      if [[ $DOWNGRADE_IGNORE == always ]]; then
        pacignore add -c "$PACMAN_CONF" "$pkg"
      else
        eval_gettext "add \$pkg to IgnorePkg? [y/N] "
        read -r ans
        if is_yes "$ans"; then
          pacignore add -c "$PACMAN_CONF" "$pkg"
        fi
      fi
    fi
  done
}

filter_packages() {
  local name=$1 operator=$2 version=$3 pkg

  while read -r pkg; do
    if matches_name_version_filter "$pkg" "$name" "$operator" "$version"; then
      echo "$pkg"
    fi
  done
}

matches_name_version_filter() {
  local pkg=$1 name=$2 operator=$3 search_version=$4 pkg_version version_regex

  if [[ -z "$operator" ]] || [[ -z "$search_version" ]]; then
    return 0
  fi

  # version: 2025.11.12.r15.g0eed3fe
  version_regex="[a-zA-Z0-9._]+"
  hash_or_minor="([0-9]+|[a-z0-9]{6,})"
  pkg_version=$(sed -r "s/.*$name-($version_regex)(-$hash_or_minor)?-(any|$DOWNGRADE_ARCH)\\.(git)?pkg\\.tar\\.(gz|xz|zst)/\1\2/g" <<<"$pkg")
  cmp=$(vercmp "$pkg_version" "$search_version")

  case "$operator" in
    '>=')
      ((cmp >= 0))
      ;;
    '<=')
      ((cmp <= 0))
      ;;
    '>')
      ((cmp == 1))
      ;;
    '<')
      ((cmp == -1))
      ;;
    '=~')
      [[ $pkg_version =~ $search_version ]]
      ;;
    '=')
      ((cmp == 0))
      ;;
    '==')
      ((cmp == 0))
      ;;
  esac
}

search_git() {
  local name=$1 pairs path

  # Delay this defaulting so #read_pacman_conf behavior is tested
  if ((!${#PACMAN_CACHE[@]})); then
    mapfile -t PACMAN_CACHE < <(read_pacman_conf CacheDir)
  fi

  for cache in "${PACMAN_CACHE[@]}"; do
    path="$cache/$name" # directory name
    if ! [[ -d "$path/.git" ]]; then
      {
        gettext "\`$path\" not a git directory, skipping..."
        echo
      } >&2
      continue
    fi

    # TODO: in future could be enhanced by counting every commit
    # that DOES NOT change version as minor like 0.0.1-2 instead of any
    # pair = abcd12 1.0
    pairs=$(git -C "$path" log -p --oneline --format="GIT: %h" PKGBUILD | grep -e 'GIT:.*' -e '+pkgver' | grep -Pzo 'GIT: .*\n\+pkgver=.*\n' | sed 's/GIT: \|+pkgver=//' | tr -d '\0')

    # shellcheck disable=SC2086
    echo "$path/$name" $pairs | awk '{for(i=2;i<NF;i+=2) printf "%s-%s-%s-any.gitpkg.tar.gz\n", $1, $(i+1), $i}' | sort -V
  done
}

search_ala() {
  local name=$1 uriname pkgfile_re index

  # This is a *very* naive URI encoding. Packages typically don't use characters
  # that need encoding, so we'll just add them as we get bug reports. So far +
  # seems to be the only one, which makes sense. If we ever move to a "real
  # language", we can use a library for this bit.
  # shellcheck disable=SC2001
  uriname=$(sed 's/+/%2B/g' <<<"$name")

  pkgfile_re="$uriname-[^-]+-[0-9.]+-(any|$DOWNGRADE_ARCH)\\.pkg\\.tar\\.(gz|xz|zst)"
  index="$DOWNGRADE_ALA_URL/packages/${uriname:0:1}/$uriname/"

  curl --fail --silent "$index" | sed_msg "to parse A.L.A." -E '
    /.* href="('"$pkgfile_re"')".*/!d;
    s||'"$index"'\1|g; s|\+| |g; s|%|\\x|g' | xargs -0 printf "%b"
}

search_cache() {
  local name=$1 pkgfile_re index

  # version: 2025.11.12.r15.g0eed3fe
  version_regex="[a-zA-Z0-9._]+"
  hash_or_minor="([0-9]+|[a-z0-9]{6,})"
  pkgfile_re="$name-($version_regex)(-$hash_or_minor)?-(any|$DOWNGRADE_ARCH)\\.pkg\\.tar\\.(gz|xz|zst)"

  # Delay this defaulting so #read_pacman_conf behavior is tested
  if ((!${#PACMAN_CACHE[@]})); then
    mapfile -t PACMAN_CACHE < <(read_pacman_conf CacheDir)
  fi

  if ((!${#PACMAN_CACHE[@]})); then
    PACMAN_CACHE=(/var/cache/pacman/pkg/)
  fi

  # shellcheck disable=SC2086
  find -L "${PACMAN_CACHE[@]}" -maxdepth "$DOWNGRADE_MAXDEPTH" -regextype posix-extended -regex ".*/$pkgfile_re"
}

sort_packages() {
  grep -Fv 'testing/' |
    awk 'BEGIN { FS="/"; OFS="|" } { print $NF, $0 }' |
    pacsort -f -t '|' -k 1 | cut -d '|' -f 2-
}

# <number> <path> <package name>
output_package() {
  local number="$1" path="$2" pkgname="$3"
  local pkg indicator=" " location timestamp version epoch release

  if [[ -n "$current" ]] && [[ "$path" == *"$current"* ]]; then
    # Currently installed
    indicator="+"
  else
    for pkg in "${installed[@]}"; do
      case "$path" in
        *$pkg*)
          indicator="-"
          break
          ;;
      esac
    done
  fi

  # Remote or local file
  if [[ $path =~ ^/ && ! ($path =~ gitpkg) ]]; then
    location="$(dirname "$path")"
    timestamp=$(stat -c '%y' "$path" | cut -d' ' -f1)
  else
    location="$(gettext 'remote')"
    timestamp=
  fi

  IFS=, read -r epoch version release _ < <(
    extract_version_parts "$pkgname" "$path"
  )

  printf "%s\t%s)\t%s\t%s\t%s\t%s\t%s\t%s\n" \
    "$indicator" \
    "$number" \
    "$pkgname" \
    "$epoch" \
    "$version" \
    "$release" \
    "$location" \
    "$timestamp"
}

# <package name> <package path>
extract_version_parts() {
  local pkgname=$1 path=$2

  sed '
    # Strip first path component
    s|^.*/||;

    # Strip package name
    s|^.\{'${#pkgname}'\}-\?||;

    # Strip package extension
    s|\.\(git\)\?pkg\(\.tar\)\?\(\.[a-z0-9A-Z]\+\)\?$||;

    # (epoch:)?version(-release)?(-arch)? -> epoch,version,release,arch
    s|\(\([^:]*\):\)\?\([^-]*\)\(-\([^-]*\)\)\?\(-\(.*\)\)\?|\2,\3,\5,\7|;
  ' <<<"$path"
}

# shellcheck disable=SC2207
process_term() {
  local term=$1 name operator version candidates choice

  read -r name operator version < \
    <(sed -r "s/(.*[^<>=~])(<=|>=|<|>|=|=~|==)([^<>=~].*)/\1 \2 \3/g" <<<"$term")

  installed=($(previously_installed "$name"))
  current=$(currently_installed "$name")

  candidates=()
  if ((DOWNGRADE_FROM_CACHE)); then
    candidates+=($(search_cache "$name" | filter_packages "$name" "$operator" "$version"))
  fi
  if ((DOWNGRADE_FROM_ALA)) && { ((!DOWNGRADE_PREFER_CACHE)) || ((${#candidates[@]} == 0)); }; then
    candidates=($(search_ala "$name" | filter_packages "$name" "$operator" "$version") "${candidates[@]}")
  fi

  candidates=($(printf '%s\n' "${candidates[@]}" | sort_packages))

  # this is already sorted
  if ((DOWNGRADE_FROM_GIT)); then
    candidates=($(search_git "$name" | filter_packages "$name" "$operator" "$version") "${candidates[@]}")
  fi

  if (("${#candidates[@]}" == 0)); then
    {
      gettext "No results found"
      echo
    } >&2
  elif (("${#candidates[@]}" == 1)); then
    choice=${candidates[0]}
  elif ((DOWNGRADE_TO_LATEST)); then
    # Select the most up to date package
    choice=${candidates[-1]}
  elif ((DOWNGRADE_TO_OLDEST)); then
    # Select the most out of date
    choice=${candidates[0]}
  else
    choice=$(present_packages "$name" "${candidates[@]}" |
      fzf --tac --border --header-lines 1 --tiebreak=begin |
      sed 's|[^0-9]\+\([0-9]\+\)).*|\1|' | read_selection "${candidates[@]}")
    if [[ -z "$choice" ]]; then
      {
        gettext "Invalid choice"
        echo
      } >&2
    fi
  fi

  if [[ -n "$choice" ]]; then
    to_ignore+=("$name")
    to_install+=("$choice")
    return 0
  fi

  {
    eval_gettext "Unable to downgrade \$name"
    echo
  } >&2
  return 1
}

main() {
  local term candidates choice pkg exit_code=0

  (($#)) || return 1

  for term; do
    if ! process_term "$term"; then
      exit_code=1
    fi
  done

  return $exit_code
}

parse_options() {
  while [[ -n "$1" ]]; do
    case "$1" in
      --pacman)
        if [[ -n "$2" ]]; then
          shift
          PACMAN="$1"
        else
          {
            gettext "Missing --pacman argument"
            echo
            usage
          } >&2
          exit 1
        fi
        ;;
      --pacman-conf)
        if [[ -n "$2" ]]; then
          shift
          PACMAN_CONF="$1"
        else
          {
            gettext "Missing --pacman-conf argument"
            echo
            usage
          } >&2
          exit 1
        fi
        ;;
      --ala-url)
        if [[ -n "$2" ]]; then
          shift
          DOWNGRADE_ALA_URL="$1"
        else
          {
            gettext "Missing --ala-url argument"
            echo
            usage
          } >&2
          exit 1
        fi
        ;;
      --pacman-cache)
        if [[ -n "$2" ]]; then
          shift
          PACMAN_CACHE+=("$1")
        else
          {
            gettext "Missing --pacman-cache argument"
            echo
            usage
          } >&2
          exit 1
        fi
        ;;
      --pacman-log)
        if [[ -n "$2" ]]; then
          shift
          PACMAN_LOG="$1"
        else
          {
            gettext "Missing --pacman-log argument"
            echo
            usage
          } >&2
          exit 1
        fi
        ;;
      --git)
        DOWNGRADE_FROM_GIT=1
        ;;
      --maxdepth)
        if [[ -n "$2" ]]; then
          shift
          DOWNGRADE_MAXDEPTH="$1"
        else
          {
            gettext "Missing --maxdepth argument"
            echo
            usage
          } >&2
          exit 1
        fi
        ;;
      --ala-only)
        DOWNGRADE_FROM_ALA=1
        DOWNGRADE_FROM_CACHE=0
        ;;
      --cached-only)
        DOWNGRADE_FROM_ALA=0
        DOWNGRADE_FROM_CACHE=1
        ;;
      --ignore)
        if [[ -n "$2" && "$2" =~ ^(prompt|always|never)$ ]]; then
          shift
          DOWNGRADE_IGNORE="$1"
        else
          {
            gettext "Missing or wrong --ignore argument"
            echo
            usage
          } >&2
          exit 1
        fi
        ;;
      --unignore)
        shift
        pacignore rm -c "$PACMAN_CONF" "$@"
        exit $?
        ;;
      --latest)
        DOWNGRADE_TO_LATEST=1
        DOWNGRADE_TO_OLDEST=0
        ;;
      --oldest)
        DOWNGRADE_TO_LATEST=0
        DOWNGRADE_TO_OLDEST=1
        ;;
      --prefer-cache)
        DOWNGRADE_PREFER_CACHE=1
        ;;
      --)
        shift
        pacman_args=("$@")
        break
        ;;
      -*)
        local current_option
        # shellcheck disable=SC2034
        current_option="$1"
        {
          eval_gettext "Unrecognized option \$current_option"
          echo
          usage
        } >&2
        exit 1
        ;;
      *)
        terms+=("$1")
        ;;
    esac
    shift
  done

  if ((!"${#terms[@]}")); then
    {
      gettext "No packages provided for downgrading"
      echo
      usage
    } >&2
    exit 1
  fi
}

build_pkg() {
  local item="$1" path commit_hash build_failed versionreg hashreg gitpkgreg
  if [[ "$item" =~ .*gitpkg.tar.gz ]]; then
    # version: 2025.11.12.r15.g0eed3fe
    versionreg="([a-zA-Z0-9_]+\.?)*"
    hashreg='[a-z0-9]{6,}'
    #hash_or_minor="-([0-9]+|[a-z0-9]{6,})" # leaving this as a note, only hash should appear tho
    gitpkgreg="(.*)-$versionreg-($hashreg)-(any|$DOWNGRADE_ARCH).gitpkg.tar.gz"
    path=$(dirname "$item")
    commit_hash=$(sed -r "s/$gitpkgreg/\3/" <<<"$item")

    pushd "$path" >/dev/null
    su -P "$(stat -c"%U" PKGBUILD)" -c "git checkout $commit_hash &>/dev/null && makepkg -fs"
    build_failed=$?

    if ((!build_failed)); then
      item=$(su "$(stat -c"%U" PKGBUILD)" -c "makepkg --packagelist" | head -n 1)
    fi

    # NOTE: when force-canceling, users might need to manually do checkout master
    # otherwise they won't be able to update that pkg using yay or whatever I guess...
    su "$(stat -c"%U" PKGBUILD)" -c "git switch - &>/dev/null"
    if ((build_failed)); then
      exit 1
    fi

    popd >/dev/null
  fi
  printf "%s\0" "$item" >>new_to_install.downgrade
}

cli() {
  local conf_args=()

  # Get configuration and command-line arguments and parse everything
  read_downgrade_conf conf_args
  parse_options "${conf_args[@]}" "$@"

  # Make arrays unique
  read_unique terms "${terms[@]}"
  read_unique PACMAN_CACHE "${PACMAN_CACHE[@]}"

  # Proceed with rest of workflow
  main "${terms[@]}"
  if ((DOWNGRADE_FROM_GIT)); then
    for item in "${to_install[@]}"; do
      build_pkg "$item"
    done
    mapfile -d '' to_install <new_to_install.downgrade
    rm new_to_install.downgrade
  fi

  pacman -U "${pacman_args[@]}" "${to_install[@]}"
  prompt_to_ignore "${to_ignore[@]}"
}

# Set script defaults
PACMAN="pacman"
PACMAN_CONF="/etc/pacman.conf"
DOWNGRADE_ARCH="$(pacman-conf Architecture | head -n 1)"
DOWNGRADE_ALA_URL="https://archive.archlinux.org"
DOWNGRADE_FROM_GIT=0
DOWNGRADE_FROM_ALA=1
DOWNGRADE_FROM_CACHE=1
DOWNGRADE_MAXDEPTH=1
DOWNGRADE_CONF="/etc/xdg/downgrade/downgrade.conf"
DOWNGRADE_VERSION="11.8.2"
DOWNGRADE_IGNORE="prompt"
DOWNGRADE_TO_LATEST=0
DOWNGRADE_TO_OLDEST=0
DOWNGRADE_PREFER_CACHE=0

# Main code execution
if ((!DOWNGRADE_LIB)); then
  set -e

  locale="$(dirname "$0")"/../share/locale

  if [[ -d "$locale" ]]; then
    # Packaged installation
    TEXTDOMAINDIR=$(cd "$locale" && pwd)
  else
    # Probably testing ./downgrade
    TEXTDOMAINDIR=/usr/share/locale
  fi

  export TEXTDOMAIN=downgrade TEXTDOMAINDIR

  # Check for help CLI option
  for arg; do
    if [[ "$arg" =~ ^-h$|^--help$ ]]; then
      usage
      exit 0
    elif [[ "$arg" == '--version' ]]; then
      printf "%s\n" "$DOWNGRADE_VERSION"
      exit 0
    fi
  done

  # Check to ensure downgrade running as root
  if ((EUID)); then
    {
      gettext "downgrade must be run as root"
      echo
    } >&2
    exit 1
  fi

  cli "$@"
fi
