#!/usr/bin/env bash

set -eou pipefail

################################################
# Variables that can be configured by the user #
################################################

stateDir="${NIX_DESKTOP_STATE_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/nix-desktop/state}"

###################
# Basic utilities #
###################

onAbort() { echo "Aborted." >&2; }
trap onAbort 1 2 6 15

# This path should be replaced using substituteInPlace
. 'ansi/ansi'

declare -A logLevels=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3)
logLevel=1

declare -A ansi=()
ansi[ERROR]="--red"
ansi[WARN]="--yellow"
ansi[success]="--green"
ansi[directory]="--bold --magenta"

log() {
  local priority="$1"
  shift

  if [[ ${logLevels[$priority]} -lt $logLevel ]]; then
    return
  fi

  case $priority in
    ERROR|WARN)
      ansi --no-newline ${ansi[$priority]} "$priority: " >&2
      ;;
  esac
  ansi $* >&2
}

########################
# Parsing command line #
########################

usage() {
  cat <<HELP
Usage: $(basename $0) [install|uninstall|build] DIR

Usage: $(basename $0) list [--verify]

Usage: $(basename $0) update

HELP
}

showVersion() {
  echo "$(basename $0) VERSION"
}

if [[ $# -eq 0 ]]; then
  usage
  exit 2
fi

cmd=install

case "$1" in
  -h|--help)
    usage
    exit
    ;;
  --version)
    showVersion
    exit
    ;;
  build)
    cmd=build
    shift
    ;;
  install)
    cmd=install
    shift
    ;;
  uninstall)
    cmd=uninstall
    shift
    ;;
  list)
    cmd=list
    shift
    ;;
  update)
    cmd=update
    shift
    ;;
esac

case "$cmd" in
  # Commands with one positional argument
  build|install|uninstall)
    if [[ $# -eq 1 ]]; then
      dir="$1"
    else
      usage
      exit 2
    fi

    if ! [[ -d "$dir" ]]; then
      log ERROR "$dir is not an existing directory."
      exit 1
    fi
    ;;
  # Commands with no positional argument
  *)
    unset verify
    for arg; do
      case "$arg" in
        --verify)
          verify=1
          ;;
      esac
    done
    ;;
esac

#############
# Functions #
#############

# Set `projectMap` variable to an assoc array of projects and source
# directories.
setProjectMap() {
  for name in $(ls "$stateDir"); do
    if [[ -L "$stateDir/$name/desktop.nix" ]]; then
      file="$(readlink "$stateDir/$name/desktop.nix")"
      projectMap+=([$name]="${file%/*}")
   fi
  done
}

compareAndInstall() {

  local destDir="$1"
  local relativeDir="$2"
  local updateHook="$3"

  local oldDir="$oldRoot/$relativeDir"
  local newDir="$newRoot/$relativeDir"

  local added=()
  local changed=()
  local deleted=()
  local notChanged=()

  declare -A newFiles=()
  declare -A oldFiles=()

  local updated=1

  # Return early if no file exists at all in the group
  if [[ ! -d "$newDir" && ! -d "$oldDir" ]]; then
    return
  fi

  if [[ -d "$newDir" ]]; then
    for filename in $(ls "$newDir"); do
      newFiles["$filename"]="$(readlink "$newDir/$filename")"
    done
  fi

  if [[ -d "$oldDir" ]]; then
    for filename in $(ls "$oldDir"); do
      oldFiles["$filename"]="$(readlink "$oldDir/$filename")"
    done
  fi

  for newFile in "${!newFiles[@]}"; do
    if [[ ! -d "$oldDir" ]] \
         || [[ ${#oldFiles[*]} -eq 0 ]] \
         || [[ ! ${oldFiles["$newFile"]+_} ]]; then
      added+=("$newFile")
    elif [[ ${oldFiles[$newFile]} = ${newFiles[$newFile]} ]]; then
      notChanged+=("$newFile")
    else
      changed+=("$newFile")
    fi
  done

  for oldFile in "${!oldFiles[@]}"; do
    if [[ ! ${newFiles["$oldFile"]+_} ]]; then
      deleted+=("$oldFile")
    fi
  done

  log INFO ${ansi[directory]} "$destDir"

  log INFO "The following changes will be made:"

  for filename in ${added[*]}; do
    log INFO "Add: $filename"
  done

  for filename in ${changed[*]}; do
    log INFO "Modify: $filename"
  done

  for filename in ${deleted[*]}; do
    log INFO "Delete: $filename"
  done

  for filename in ${notChanged[*]}; do
    log INFO "Not changed: $filename"
  done

  # Check conflicts before actual operation
  for filename in ${added[*]}; do
    if [[ -e "$destDir/$filename" ]]; then
      log ERROR "$destDir/$filename already exists."
      return 1
    fi
  done
  for filename in ${changed[*]} ${notChanged[*]}; do
    if [[ -e "$destDir/$filename" ]]; then
      if [[ ! ${oldFiles["$filename"]+_} ]] \
           || [[ "$(readlink "$destDir/$filename")" != ${oldFiles["$filename"]} ]]; then
        log ERROR "$destDir/$filename does not point to an expected location."
        return 1
      fi
    fi
  done

  if [[ -x "$oldRoot/.pre-remove-hook" ]]; then
    for filename in ${deleted[*]}; do
      "$oldRoot/.pre-remove-hook" "$relativeDir" "$filename"
    done
  fi

  for filename in ${added[*]}; do
    mkdir -p "$destDir"
    ln -t "$destDir" -f -s "${newFiles[$filename]}"
    updated=0
    if [[ -x "$newRoot/.activate-on-add-hook" ]]; then
      activateHooks+=("$newRoot/.activate-on-add-hook $relativeDir $filename")
    fi
  done

  for filename in ${changed[*]}; do
    mkdir -p "$destDir"
    ln -t "$destDir" -fv -s "${newFiles[$filename]}"
    updated=0
    if [[ -x "$newRoot/.activate-on-change-hook" ]]; then
      activateHooks+=("$newRoot/.activate-on-change-hook $relativeDir $filename")
    fi
  done

  for filename in ${deleted[*]}; do
    log INFO "Unlinking $destDir/$filename"
    unlink "$destDir/$filename"
    updated=0
  done

  # It would be better to always clear these non-local variables
  # declared inside a function.
  #
  # I've tried to make this unsetting as early as possible, but some
  # of the hooks above can fail, which can make the variables left
  # uncleared.
  unset newFiles
  unset oldFiles

  if [[ $updated -eq 0 ]]; then
    log INFO ${ansi[success]} "Finished updating."
    if [[ -n "$updateHook" ]]; then
      log INFO "Running hooks..."
      $updateHook
    fi
  else
    log INFO ${ansi[success]} "No change was made."
  fi

  # These hooks should be run after updating,
  # e.g. enable systemd services after reloading unit files

  if [[ -x "$newRoot/.post-add-hook" ]]; then
    for filename in ${added[*]}; do
      "$newRoot/.post-add-hook" "$relativeDir" "$filename"
    done
  fi

  # If necessary, implement post-change hook.
  # The implementation should be similar to post-add hook.
  #
  # if [[ -x "$oldRoot/.post-change-hook" ]]; then
  #   for filename in ${added[*]}; do
  #     "$oldRoot/.post-change-hook" "$relativeDir" "$filename"
  #   done
  # fi

}

uninstall() {
  local destDir="$1"
  local relativeDir="$2"
  local hook="$3"

  local oldDir="$oldRoot/$relativeDir"
  local dest
  local origin

  if ! [[ -d "$oldDir" ]]; then
    return
  fi

  for filename in $(ls "$oldDir"); do
    dest="$(readlink "$oldDir/$filename")"
    origin="$destDir/$filename"
    if [[ -L "$origin" ]] && [[ "$dest" = "$(readlink "$origin")" ]]; then
      log INFO "Removing $origin"
      unlink "$origin"
    fi
  done
}

group() {
  case "$cmd" in
    install)
      compareAndInstall "$@"
      ;;
    uninstall)
      uninstall "$@"
      ;;
  esac
}

########
# Main #
########

# These commands early exit
case "$cmd" in
  list)
    r=0
    declare -A projectMap
    setProjectMap
    for name in ${!projectMap[*]}; do
      dir="${projectMap[$name]}"
      if [[ -v verify && ! -e "$dir" ]]; then
        suffix=" (missing)"
        r=1
      else
        suffix=
      fi
      echo $name ${file%/*}$suffix
    done
    exit $r
    ;;
  update)
    r=0
    declare -A projectMap
    setProjectMap
    for dir in ${projectMap[*]}; do
      if ! "$0" "$dir"; then
        r=1
      fi
    done
    exit $r
    ;;
esac

cd "$dir"

# Retrieve name from the expression
name="$(nix-instantiate --eval -A name --strict desktop.nix | tr -d \")"

oldRoot="$stateDir/$name/result"

# Verify if the desktop.nix point to the same file
if [[ -L "$stateDir/$name/desktop.nix" ]]; then
  lastSource="$(readlink "$stateDir/$name/desktop.nix")"
  if [[ "$lastSource" = "$PWD/desktop.nix" ]]; then
    log DEBUG Same directory
  else
    log ERROR "$name is already installed from a different location: ${lastSource%/*}"
    exit 1
  fi
fi

case "$cmd" in
  install|build)
    newRoot="$(nix-build --quiet --arg configFile ./desktop.nix --no-out-link 'main.nix')"
    ;;
esac

if [[ "$cmd" = build ]]; then
  echo "$newRoot"
  exit
fi

case "$cmd" in
  install)
    log INFO --blue "Installing $name ($PWD)..."
    ;;
  uninstall)
    log INFO --blue "Uninstalling $name ($PWD)..."
    ;;
esac

# Run deactivate hooks.
# This actually calls pre-remove hook on each file.
if [[ "$cmd" = uninstall ]]; then
  if [[ -x "$oldRoot/.pre-remove-hook" ]]; then
    for dir in "share/systemd/user"; do
      for file in $(ls "$oldRoot/$dir"); do
        "$oldRoot/.pre-remove-hook" "$dir" "$file"
      done
    done
  fi
fi

declare -a activateHooks

group "${XDG_DATA_HOME:-$HOME/.local/share}/applications" \
      "share/applications" \
      "xdg-desktop-menu forceupdate"

group "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" \
      "share/systemd/user" \
      "systemctl --user daemon-reload"

# Update the symlink
case "$cmd" in
  install)
    mkdir -p "$stateDir/$name"
    ln -sTf "$newRoot" "$oldRoot"
    ln -sf "$PWD/desktop.nix" "$stateDir/$name/desktop.nix"
    ;;
  uninstall)
    if ! [[ -e "$oldRoot" ]]; then
      log WARN "Directory $oldRoot does not exist."
      log INFO "Nothing to do."
    else
      unlink "$oldRoot"
      unlink "$stateDir/$name/desktop.nix"
    fi
    ;;
esac

# Add activate hook Activation may fail, so it should be run
# after updating the state, i.e. the symbolic link.

# Some activate hooks may fail, while others may succeed. Thus the
# process should continue even an error occurs from one of the hook
# scripts. Use 'set +e' to permit error and use a variable to track
# the final exit code.
set +e
r=0
# TODO: Run activate hooks concurrently
if [[ -v activateHooks ]] && [[ ${#activateHooks[*]} -gt 0 ]]; then
  log INFO "Running activation hooks..."
  for i in $(seq ${#activateHooks[*]}); do
    if ! ${activateHooks[$((i - 1))]}; then
      r=1
    fi
  done
fi
exit $r

# Local Variables:
# sh-basic-offset: 2
# End:
