#!/usr/bin/env bash

#
# Common constants and functions used by the blackbox_* utilities.
#

# Usage:
#
#   set -e
#   source "${0%/*}/_blackbox_common.sh"

# Load additional useful functions
source "${0%/*}"/_stack_lib.sh

# Where are we?
: "${BLACKBOX_HOME:="$(cd "${0%/*}" ; pwd)"}" ;

# What are the candidates for the blackbox data directory?
#
# The order of candidates matter. The first entry of the array
# sets the default Blackbox directory for all new repositories.
declare -a BLACKBOXDATA_CANDIDATES
BLACKBOXDATA_CANDIDATES=(
  '.blackbox'
  'keyrings/live'
)

# If $EDITOR is not set, set it to "vi":
: "${EDITOR:=vi}" ;

# Allow overriding gpg command
: "${GPG:=gpg}" ;

function physical_directory_of() {
  local d=$(dirname "$1")
  local f=$(basename "$1")
  (cd "$d" && echo "$(pwd -P | sed 's/\/$//')/$f" )
}

# Set REPOBASE to the top of the repository
# Set VCS_TYPE to 'git', 'hg', 'svn' or 'unknown'
if which >/dev/null 2>/dev/null git && git rev-parse --show-toplevel >/dev/null 2>&1 ; then
  VCS_TYPE=git
  REPOBASE=$(git rev-parse --show-toplevel)
elif [ -d ".svn" ] ; then
  # Find topmost dir with .svn sub-dir
  parent=""
  grandparent="."
  while [ -d "$grandparent/.svn" ]; do
    parent=$grandparent
    grandparent="$parent/.."
  done

  REPOBASE=$(cd "$parent" ; pwd)
  VCS_TYPE=svn
elif which >/dev/null 2>/dev/null hg && hg root >/dev/null 2>&1 ; then
  # NOTE: hg has to be tested last because it always "succeeds".
  VCS_TYPE=hg
  REPOBASE=$(hg root 2>/dev/null)
else
  # We aren't in a repo at all.  Assume the cwd is the root
  # of the tree.
  VCS_TYPE=unknown
  REPOBASE="$(pwd)"
fi
export VCS_TYPE
export REPOBASE=$(physical_directory_of "$REPOBASE")
# FIXME: Verify this function by checking for .hg or .git
# after determining what we believe to be the answer.

if [[ -n "$BLACKBOX_REPOBASE" ]]; then
	echo "Using custom repobase: $BLACKBOX_REPOBASE" >&2
	export REPOBASE="$BLACKBOX_REPOBASE"
fi

if [ -z "$BLACKBOXDATA" ] ; then
  BLACKBOXDATA="${BLACKBOXDATA_CANDIDATES[0]}"
  for candidate in ${BLACKBOXDATA_CANDIDATES[@]} ; do
    if [ -d "$REPOBASE/$candidate" ] ; then
      BLACKBOXDATA="$candidate"
      break
    fi
  done
fi

KEYRINGDIR="$REPOBASE/$BLACKBOXDATA"
BB_ADMINS_FILE="blackbox-admins.txt"
BB_ADMINS="${KEYRINGDIR}/${BB_ADMINS_FILE}"
BB_FILES_FILE="blackbox-files.txt"
BB_FILES="${KEYRINGDIR}/${BB_FILES_FILE}"
SECRING="${KEYRINGDIR}/secring.gpg"
: "${DECRYPT_UMASK:=0022}" ;
# : ${DECRYPT_UMASK:=o=} ;

# Checks if $1 is 0 bytes, and if $1/keyrings
# is a directory
function is_blackbox_repo() {
  if [[ -n "$1" ]] && [[ -d "$1/keyrings" ]]; then
    return 0 # Yep, its a repo
  else
    return 1
  fi
}

# Return error if not on cryptlist.
function is_on_cryptlist() {
  # Assumes $1 does NOT have the .gpg extension
  file_contains_line "$BB_FILES" "$(vcs_relative_path "$1")"
}

# Exit with error if a file exists.
function fail_if_exists() {
  if [[ -f "$1" ]]; then
    echo ERROR: "$1" exists.  "$2" >&2
    echo Exiting... >&2
    exit 1
  fi
}

# Exit with error if a file is missing.
function fail_if_not_exists() {
  if [[ ! -f "$1" ]]; then
    echo ERROR: "$1" not found.  "$2" >&2
    echo Exiting... >&2
    exit 1
  fi
}

# Exit we we aren't in a VCS repo.
function fail_if_not_in_repo() {
  if [[ $VCS_TYPE = "unknown" ]]; then
    echo "ERROR: This must be run in a VCS repo: git, hg, or svn." >&2
    echo Exiting... >&2
    exit 1
  fi
}

# Exit with error if filename is not registered on blackbox list.
function fail_if_not_on_cryptlist() {
  # Assumes $1 does NOT have the .gpg extension

  local name="$1"

  if ! is_on_cryptlist "$name" ; then
    echo "ERROR: $name not found in $BB_FILES" >&2
    echo "PWD=$(/usr/bin/env pwd)" >&2
    echo 'Exiting...' >&2
    exit 1
  fi
}

# Exit with error if keychain contains secret keys.
function fail_if_keychain_has_secrets() {
  if [[ -s ${SECRING} ]]; then
    echo 'ERROR: The file' "$SECRING" 'should be empty.' >&2
    echo 'Did someone accidentally add this private key to the ring?' >&2
    echo 'Exiting...' >&2
    exit 1
  fi
}

function get_pubring_path() {
  if [[ -f "${KEYRINGDIR}/pubring.gpg" ]]; then
    echo "${KEYRINGDIR}/pubring.gpg"
  else
    echo "${KEYRINGDIR}/pubring.kbx"
  fi
}

# Output the unencrypted filename.
function get_unencrypted_filename() {
  echo "$(dirname "$1")/$(basename "$1" .gpg)" | sed -e 's#^\./##'
}

# Output the encrypted filename.
function get_encrypted_filename() {
  echo "$(dirname "$1")/$(basename "$1" .gpg).gpg" | sed -e 's#^\./##'
}

# Prepare keychain for use.
function prepare_keychain() {
  local keyringasc
  echo '========== Importing keychain: START' >&2
  # Works with gpg 2.0
  #$GPG --import "$(get_pubring_path)" 2>&1 | egrep -v 'not changed$' >&2
  # Works with gpg 2.0 and 2.1
  # NB: We must export the keys to a format that can be imported.
  make_self_deleting_tempfile keyringasc
  export LANG="C.UTF-8"

  #if gpg2 is installed next to gpg like on ubuntu 16
  if [[ "$GPG" != "gpg2" ]]; then
    $GPG --export --no-default-keyring --keyring "$(get_pubring_path)" >"$keyringasc"
    $GPG --import "$keyringasc" 2>&1 | egrep -v 'not changed$' >&2
  else
    $GPG --keyring "$(get_pubring_path)" --export | $GPG --import
  fi

  echo '========== Importing keychain: DONE' >&2
}

# Add file to list of encrypted files.
function add_filename_to_cryptlist() {
  # If the name is already on the list, this is a no-op.
  # However no matter what the datestamp is updated.
  
  # https://github.com/koalaman/shellcheck/wiki/SC2155
  local name
  name=$(vcs_relative_path "$1")

  if file_contains_line "$BB_FILES" "$name" ; then
    echo "========== File is registered. No need to add to list."
  else
    echo "========== Adding file to list."
    touch "$BB_FILES"
    echo "$name" >> "$BB_FILES"
    sort -u -o "$BB_FILES" "$BB_FILES"
  fi
}

# Removes a file from the list of encrypted files
function remove_filename_from_cryptlist() {
  # If the name is not already on the list, this is a no-op.

  # https://github.com/koalaman/shellcheck/wiki/SC2155
  local name
  name=$(vcs_relative_path "$1")

  if ! file_contains_line "$BB_FILES" "$name" ; then
    echo "========== File is not registered. No need to remove from list."
  else
    echo "========== Removing file from list."
    remove_line "$BB_FILES" "$name"
  fi
}

# Print out who the current BB ADMINS are:
function disclose_admins() {
  echo "========== blackbox administrators are:"
  cat "$BB_ADMINS"
}

# Encrypt file, overwriting .gpg if it exists.
function encrypt_file() {
  local unencrypted
  local encrypted
  unencrypted="$1"
  encrypted="$2"

  echo "========== Encrypting: $unencrypted" >&2
  $GPG --use-agent --yes --trust-model=always --encrypt -o "$encrypted"  $(awk '{ print "-r" $1 }' < "$BB_ADMINS") "$unencrypted" >&2
  echo '========== Encrypting: DONE' >&2
}

# Decrypt .gpg file, asking "yes/no" before overwriting unencrypted file.
function decrypt_file() {
  local encrypted
  local unencrypted
  local old_umask
  encrypted="$1"
  unencrypted="$2"

  echo "========== EXTRACTING $unencrypted" >&2

  old_umask=$(umask)
  umask "$DECRYPT_UMASK"
  $GPG --use-agent -q --decrypt -o "$unencrypted" "$encrypted" >&2
  umask "$old_umask"
}

# Decrypt .gpg file, overwriting unencrypted file if it exists.
function decrypt_file_overwrite() {
  local encrypted
  local unencrypted
  local old_hash
  local new_hash
  local old_umask
  encrypted="$1"
  unencrypted="$2"

  if [[ -f "$unencrypted" ]]; then
    old_hash=$(md5sum_file "$unencrypted")
  else
    old_hash=unmatchable
  fi

  old_umask=$(umask)
  umask "$DECRYPT_UMASK"
  $GPG --use-agent --yes -q --decrypt -o "$unencrypted" "$encrypted" >&2
  umask "$old_umask"

  new_hash=$(md5sum_file "$unencrypted")
  if [[ "$old_hash" != "$new_hash" ]]; then
    echo "========== EXTRACTED $unencrypted" >&2
  fi
}

# Shred a file.  If shred binary does not exist, delete it.
function shred_file() {
  local name
  local CMD
  local OPT
  name="$1"

  if which shred >/dev/null 2>/dev/null ; then
    CMD=shred
    OPT=-u
  elif which srm >/dev/null 2>/dev/null ; then
    #NOTE: srm by default uses 35-pass Gutmann algorithm
    CMD=srm
    OPT=-f
  elif _F=$(mktemp); rm -P "${_F}" >/dev/null 2>/dev/null ; then
    CMD=rm
    OPT=-Pf
  else
    echo "shred_file: WARNING: No secure deletion utility (shred or srm) present; using insecure rm" >&2
    CMD=rm
    OPT=-f
  fi

  $CMD $OPT -- "$name"
}

# $1 is the name of a file that contains a list of files.
# For each filename, output the individual subdirectories
# leading up to that file. i.e. one one/two one/two/three
function enumerate_subdirs() {
  local listfile
  local dir
  local filename
  listfile="$1"

  while read filename; do
    dir=$(dirname "$filename")
    while [[ $dir != '.' && $dir != '/' ]]; do
      echo "$dir"
      dir=$(dirname "$dir")
    done
  done <"$listfile" | sort -u
}
 

# chdir to the base of the repo.
function change_to_vcs_root() {
  # if vcs_root not explicitly defined, use $REPOBASE

  local rbase=${1:-$REPOBASE} # use $1 but if unset use $REPOBASE

  cd "$rbase"

}

# $1 is a string pointing to a directory.  Outputs a
# list of  valid blackbox repos,relative to $1
function enumerate_blackbox_repos() {
  if [[ -z "$1" ]]; then
    echo "enumerate_blackbox_repos: ERROR: No Repo provided to Enumerate"
    exit 1
  fi

  # https://github.com/koalaman/shellcheck/wiki/Sc2045
  for dir in $1*/; do
    if is_blackbox_repo "$dir"; then
      echo "$dir"
    fi
  done
}

# Output the path of a file relative to the repo base
function vcs_relative_path() {
  # Usage: vcs_relative_path file
  local name="$1"
  #python -c 'import os ; print(os.path.relpath("'"$(pwd -P)"'/'"$name"'", "'"$REPOBASE"'"))'
  local p=$( printf "%s" "$( pwd -P )/${1}" | sed 's#//*#/#g' )
  local name="${p#$REPOBASE}"
  name=$( printf "%s" "$name" | sed 's#^/##g' | sed 's#/$##g' )
  printf "%s" "$name"
}

# Removes a line from a text file
function remove_line() {
  local tempfile

  make_self_deleting_tempfile tempfile

  # Ensure source file exists
  touch "$1"
  grep -Fsxv "$2" "$1" > "$tempfile" || true

  # Using cat+rm instead of cp will preserve permissions/ownership
  cat "$tempfile" > "$1"
}

# Determine if a file contains a given line
function file_contains_line() {
  # $1: the file
  # $2: the line
  grep -xsqF "$2" "$1"
}

#
# Portability Section:
#

#
# Abstract the difference between Linux and Mac OS X:
#

function md5sum_file() {
  # Portably generate the MD5 hash of file $1.
  case $(uname -s) in
    Darwin | FreeBSD )
      md5 -r "$1" | awk '{ print $1 }'
      ;;
    NetBSD )
      md5 -q "$1"
      ;;
    SunOS )
      digest -a md5 "$1"
      ;;
    Linux | CYGWIN* | MINGW* )
      md5sum "$1" | awk '{ print $1 }'
      ;;
    * )
      echo 'ERROR: Unknown OS. Exiting. (md5sum_file)'
      exit 1
      ;;
  esac
}

function cp_permissions() {
  # Copy the perms of $1 onto $2 .. end.
  case $(uname -s) in
    Darwin )
      chmod $( stat -f '%Lp' "$1" ) "${@:2}"
      ;;
    FreeBSD | NetBSD )
      chmod $( stat -f '%p' "$1" | sed -e "s/^100//" ) "${@:2}"
      ;;
    SunOS )
      chmod $( stat -c '%a' "$1" ) "${@:2}"
      ;;
    Linux | CYGWIN* | MINGW* | SunOS )
      if [[ -e /etc/alpine-release ]]; then
        chmod $( stat -c '%a' "$1" ) "${@:2}"
      else
        chmod --reference "$1" "${@:2}"
      fi
      ;; 
    * )
      echo 'ERROR: Unknown OS. Exiting. (cp_permissions)'
      exit 1
      ;;
  esac
}


#
# Abstract the difference between git and hg:
#

# Is this file in the current repo?
function is_in_vcs() {
  is_in_$VCS_TYPE "$@"
}
# Mercurial
function is_in_hg() {
  local filename
  filename="$1"

  if hg locate "$filename" ; then
    echo true
  else
    echo false
  fi
}
# Git:
function is_in_git() {
  local filename
  filename="$1"

  if git ls-files --error-unmatch >/dev/null 2>&1 -- "$filename" ; then
    echo true
  else
    echo false
  fi
}
# Subversion
function is_in_svn() {
  local filename
  filename="$1"

  if svn list "$filename" ; then
    echo true
  else
    echo false
  fi
}
# Perforce
function is_in_p4() {
  local filename
  filename="$1"

  if p4 list "$filename" ; then
    echo true
  else
    echo false
  fi
}
# No repo
function is_in_unknown() {
  echo true
}


# Add a file to the repo (but don't commit it).
function vcs_add() {
  vcs_add_$VCS_TYPE "$@"
}
# Mercurial
function vcs_add_hg() {
  hg add "$@"
}
# Git
function vcs_add_git() {
  git add "$@"
}
# Subversion
function vcs_add_svn() {
  svn add --parents "$@"
}
# Perfoce
function vcs_add_p4() {
  p4 add "$@"
}
# No repo
function vcs_add_unknown() {
  :
}


# Commit a file to the repo
function vcs_commit() {
  vcs_commit_$VCS_TYPE "$@"
}
# Mercurial
function vcs_commit_hg() {
  hg commit -m "$@"
}
# Git
function vcs_commit_git() {
  git commit -m "$@"
}
# Subversion
function vcs_commit_svn() {
  svn commit -m "$@"
}
# Perforce
function vcs_commit_p4() {
  p4 submit -d "$@"
}
# No repo
function vcs_commit_unknown() {
  :
}


# Remove file from repo, even if it was deleted locally already.
# If it doesn't exist yet in the repo, it should be a no-op.
function vcs_remove() {
  vcs_remove_$VCS_TYPE "$@"
}
# Mercurial
function vcs_remove_hg() {
  hg rm -A -- "$@"
}
# Git
function vcs_remove_git() {
  git rm --ignore-unmatch -f -- "$@"
}
# Subversion
function vcs_remove_svn() {
  svn delete "$@"
}
# Perforce
function vcs_remove_p4() {
  p4 delete "$@"
}
# No repo
function vcs_remove_unknown() {
  :
}

# Get a path for the ignore file if possible in current vcs
function vcs_ignore_file_path() {
  vcs_ignore_file_path_$VCS_TYPE
}
# Mercurial
function vcs_ignore_file_path_hg() {
  echo "$REPOBASE/.hgignore"
}
# Git
function vcs_ignore_file_path_git() {
  echo "$REPOBASE/.gitignore"
}


# Ignore a file in a repo.  If it was already ignored, this is a no-op.
function vcs_ignore() {
  local file
  for file in "$@"; do
    vcs_ignore_$VCS_TYPE "$file"
  done
}
# Mercurial
function vcs_ignore_hg() {
  vcs_ignore_generic_file "$(vcs_ignore_file_path)" "$file"
}
# Git
function vcs_ignore_git() {
  vcs_ignore_generic_file "$(vcs_ignore_file_path)" "$file"
  git add "$REPOBASE/.gitignore"
}
# Subversion
function vcs_ignore_svn() {
  svn propset svn:ignore "$file" "$(vcs_relative_path)"
}
# Perforce
function vcs_ignore_p4() {
  :
}
# No repo
function vcs_ignore_unknown() {
  :
}
# Generic - add line to file
function vcs_ignore_generic_file() {
  local file
  file="$(vcs_relative_path "$2")"
  file="${file/\$\//}"
  file="$(echo "/$file" | sed 's/\([\*\?]\)/\\\1/g')"
  if ! file_contains_line "$1" "$file" ; then
    echo "$file" >> "$1"
    vcs_add "$1"
  fi
}


# Notice (un-ignore) a file in a repo.  If it was not ignored, this is
# a no-op
function vcs_notice() {
  local file
  for file in "$@"; do
    vcs_notice_$VCS_TYPE "$file"
  done
}
# Mercurial
function vcs_notice_hg() {
  vcs_notice_generic_file "$REPOBASE/.hgignore" "$file"
}
# Git
function vcs_notice_git() {
  vcs_notice_generic_file "$REPOBASE/.gitignore" "$file"
  git add "$REPOBASE/.gitignore"
}
# Subversion
function vcs_notice_svn() {
  svn propdel svn:ignore "$(vcs_relative_path "$file")"
}
# Perforce
function vcs_notice_p4() {
  :
}
# No repo
function vcs_notice_unknown() {
  :
}
# Generic - remove line to file
function vcs_notice_generic_file() {
  local file
  file="$(vcs_relative_path "$2")"
  file="${file/\$\//}"
  file="$(echo "/$file" | sed 's/\([\*\?]\)/\\\1/g')"
  if file_contains_line "$1" "$file" ; then
    remove_line "$1" "$file"
    vcs_add "$1"
  fi
  if file_contains_line "$1" "${file:1}" ; then
    echo "WARNING:  Found a non-absolute ignore match in $1"
    echo "WARNING:  Confirm the pattern is intended to only exclude $file"
    echo "WARNING:  If so, manually update the ignore file"
  fi
}

function gpg_agent_version_check() {
  if ! hash 'gpg-agent' &> /dev/null; then
    return 1
  fi
  local gpg_agent_version=$(gpg-agent --version | head -1 | awk '{ print $3 }' | tr -d '\n')
  semverLT $gpg_agent_version "2.1.0"
}

function gpg_agent_notice() {
  if [[ $(gpg_agent_version_check) == '0' && -z $GPG_AGENT_INFO ]];then
    echo 'WARNING: You probably want to run gpg-agent as'
    echo 'you will be asked for your passphrase many times.'
    echo 'Example: $ eval $(gpg-agent --daemon)'
    read -r -p 'Press CTRL-C now to stop. ENTER to continue: '
  fi
}
