#!/bin/bash

### CurBash - Curby's Bash Script Environment
### by Michael Lee <curby@cur.by>
### v1.0.0 2013-05-20
#
# Provides common support and convenience functions for bash scripts
#

###   Integration
#
#  To use curbash, put it somewhere in your executable path and add the
#  following lines to your bash script:
#
#  . curbash || {
#    echo "This script requires curbash: http://cur.by/filelib/curbash"
#    exit 1
#  }
#


###   License
#
#  Copyright (C) 2013  Michael Lee <curby@cur.by>
#
#  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 2
#  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, write to the Free Software
#  Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
#


# emoji
# --- failure/error
# skull f09f9280
# fire f09f94a5
# police light f09f9aa8
# red x e29d8c

# --- alert/warning
# warning sign e29aa0
# do not enter sign e29b94
# generic "no"  sign f09f9aab
# bell f09f9494

# --- notice / info
# speech balloon f09f92ac
# pencil paper f09f939d
# book f09f9396
# info box e284b9
# printed page f09f9384

# --- access
# lock f09f9492
# open lock f09f9493
# key f09f9491

# --- debug
# thought balloon f09f92ad

# --- success
# green checkbox e29c85
# checkered flag f09f8f81
# tada f09f8e89

# ------------------------------------------------------------------------------
# Common variables
# ------------------------------------------------------------------------------

MYNAME=$(basename $0)
MYPID=$$
MYUSER=$(whoami)
HOSTNAME=$(hostname -f)
HOSTNM=$(hostname -s)
MYHOST="$HOSTNAME"
ADMIN_EMAIL="admin@curby.net"
[[ "$(uname)" == "Darwin" ]] && ON_OSX=true
[[ "$(uname)" == "Linux" ]] && ON_LINUX=true
args=("$@")


# ------------------------------------------------------------------------------
# Constants for our use
# ------------------------------------------------------------------------------

CURBASH_VERSION="20140706"
DEFAULT_LOGLEVEL="user.notice"
TEMPDIR="/tmp"

[[ -t 2 ]] && {
  T_STR=$( tput bold || tput md ) # Start bold
  T_INV=$( tput smso || tput so ) # Start stand-out
  T_INVE=$(tput rmso || tput se ) # End stand-out
  T_UL=$( tput smul || tput us ) # Start underline
  T_ULE=$(tput rmul || tput ue ) # End   underline
  T_ITA=$( tput sitm || tput ZH ) # Start italic
  T_ITAE=$(tput ritm || tput ZR ) # End   italic
  T_RST=$( tput sgr0 || tput me ) # Reset cursor
  [[ $TERM != *-m ]] && {
    T_BLA=$(tput setaf 0 || tput AF 0)
    T_RED=$(tput setaf 1 || tput AF 1)
    T_GRE=$(tput setaf 2 || tput AF 2)
    T_YEL=$(tput setaf 3 || tput AF 3)
    T_BLU=$(tput setaf 4 || tput AF 4)
    T_MAG=$(tput setaf 5 || tput AF 5)
    T_CYA=$(tput setaf 6 || tput AF 6)
    T_WHI=$(tput setaf 7 || tput AF 7)
    T_B_BLA=$(tput setab 0|| tput AB 0)
    T_B_RED=$(tput setab 1|| tput AB 1)
    T_B_GRE=$(tput setab 2|| tput AB 2)
    T_B_YEL=$(tput setab 3|| tput AB 3)
    T_B_BLU=$(tput setab 4|| tput AB 4)
    T_B_MAG=$(tput setab 5|| tput AB 5)
    T_B_CYA=$(tput setab 6|| tput AB 6)
    T_B_WHI=$(tput setab 7|| tput AB 7)
  }
} 2>/dev/null ||: # ignore errors



# ------------------------------------------------------------------------------
# Logging functions
# ------------------------------------------------------------------------------

function log() {
  local loglevel=$DEFAULT_LOGLEVEL also_mail=false also_print=false
  while [[ $# -gt 0 ]]; do
    IFS='=' read -r opt arg <<< "$1"
    case "$opt" in
      "--mail")
        shift
        also_mail=true ;;
      "--print")
        shift
        also_print=true ;;
      "--level")
        shift
        [[ ${#arg} -eq 0 ]] && pterror "Option ${opt} requires an argument." 1
        loglevel="$arg" ;;
      "--")
        shift
        break ;;
      *)
        break ;;
    esac
  done

  # Can't put the regex in quotes or else it fails
  if [[ $# -gt 2 || $# -eq 2 && ! "$2" =~ ^-?[0-9]+$ ]]; then
    pterror "$FUNCNAME() expects a quoted log message and optional exit code" 1
  fi
  # logger on OS X has a bug: it always adds pid of `logger' even when -i is not specified
  # If we add the MYPID on OS X, it will mess up log parsing
  # (still broken in Mavericks)
  if [[ $ON_OSX ]]; then
    logger -t "$MYNAME" -p $loglevel "$1"
  else
    logger -t "$MYNAME[$MYPID]" -p $loglevel $1
  fi

  $also_mail && echo "$1" | mail -s "Problem in $0" $ADMIN_EMAIL
  $also_print && pretty "$1"
  if [[ $# -eq 2 ]]; then
    exit "$2"
  fi
}



# ------------------------------------------------------------------------------
# I/O functions
# ------------------------------------------------------------------------------

function die() {
  perror "$1"
  exit ${2:-1}
}

# Display a horizontal rule along with optional message
# The line itself will be entirely hidden if message is longer than terminal width
function hrule() {
  if [[ $# -gt 1 ]]; then
    pterror "$FUNCNAME() takes an optional message as a single quoted parameter" 1
  fi
  local cols=$(tput cols)
  echo -en "${T_WHI}"
  for count in $(seq 1 $cols); do
    echo -n '-'
  done
  if [[ $# -eq 1 ]]; then
    tput cub $((cols - (cols-${#1}-1)/2))
    echo -en "${T_CYA} $1 "
  fi
  echo -e "${T_RST}"
}

# Display verbose output if OUTPUT_VERBOSE is set to true
function pverb()  { ${OUTPUT_VERBOSE:-false} && output_message $FUNCNAME "$@"; }
function pverb2()  { ${OUTPUT_VERBOSE:-false} && output_message $FUNCNAME "$@"; }

# Display normal feedback if OUTPUT_QUIET is false (or is unset)
function pretty() { ! ${OUTPUT_QUIET:-false} && output_message $FUNCNAME "$@"; }
function pretty2() { ! ${OUTPUT_QUIET:-false} && output_message $FUNCNAME "$@"; }

# Display a notice message
function pnote()  { output_message $FUNCNAME "$@"; }
function pnote2()  { output_message $FUNCNAME "$@"; }

# Display a warning message
function pwarn()  { output_message $FUNCNAME "$@"; }

# Prompt for input
# Add a linebreak '\n' to the query if you want one before the user's response
function pask()   { output_message $FUNCNAME "$@" && read response; }

# Displate a message with timestamp
function ptime() { output_message $FUNCNAME "$@"; }

# Display an error message.  Also, exit if a return value is given.
function perror() { output_message_exit $FUNCNAME "$@"; }

# Display an error message.  Also, exit if a return value is given.
function perror2() { output_message_exit $FUNCNAME "$@"; }

# Display a stack trace with error message.  Also, exit if a return value is given.
function pterror() { stacktrace 2; output_message_exit $FUNCNAME "$@"; }

# Display a successful termination message.  Also, exit if a return value is given.
function pdone() { output_message_exit $FUNCNAME "$@"; }

function output_message() {
  local caller=$1;shift
  if [[ $# -gt 1 ]]; then
    pterror "$caller() expects a message as a single quoted parameter" 1
  fi
  case $caller in
    "pverb")
      echo -e "${T_BLU}==>${T_WHI} $1${T_RST}" ;;
    "pretty")
      echo -e "${T_CYA}==>${T_WHI} $1${T_RST}" ;;
    "pnote")
      echo -e "${T_YEL}${T_STR}==>${T_RST}${T_WHI} $1${T_RST}" ;;
    "pverb2")
      echo -e "   ${T_WHI} $1${T_RST}" ;;
    "pretty2")
      echo -e "   ${T_WHI} $1${T_RST}" ;;
    "pnote2")
      echo -e "   ${T_WHI} $1${T_RST}" ;;
    "pwarn")
      echo -e "${T_YEL}${T_STR}Warning:${T_RST}${T_WHI} $1${T_RST}" ;;
    "pask")
      echo -en "${T_CYA}???${T_YEL} $1${T_RST}" ;;
    "ptime")
      echo -e "${T_BLU}$(date +'%b %d %H:%M:%S')${T_WHI} $1${T_RST}" ;;
    *)
      pterror "$FUNCNAME() called by unknown function $caller" 1
  esac
}

function output_message_exit() {
  local caller=$1;shift
  # can't put the regex in quotes or else it fails
  if [[ $# -gt 2 || $# -eq 2 && ! "$2" =~ ^-?[0-9]+$ ]]; then
    pterror "$caller() expects a message as a single quoted parameter" 1
  fi
  case $caller in
    "perror"|"pterror")
      echo -e "${T_RED}${T_STR}Error:${T_RST}${T_WHI} $1${T_RST}" ;;
    "perror2")
      echo -e "${T_RED}${T_STR}      ${T_RST}${T_WHI} $1${T_RST}" ;;
    "pdone")
      echo -e "${T_GRE}${T_STR}Success:${T_RST}${T_WHI} $1${T_RST}" ;;
    "pdone2")
      echo -e "${T_GRE}${T_STR}        ${T_RST}${T_WHI} $1${T_RST}" ;;
    *)
      pterror "$FUNCNAME() called by unknown function $caller" 1
  esac
  if [[ $# -eq 2 ]]; then
    exit "$2"
  fi
}



# ------------------------------------------------------------------------------
# General functions
# ------------------------------------------------------------------------------

function min() {
  local candidate smallest=$1;shift
  for candidate in "$@"; do
    if [[ $candidate -lt $smallest ]]; then
      smallest=$candidate
    fi
  done
  echo $smallest
}

function max() {
  local candidate largest=$1;shift
  for candidate in "$@"; do
    if [[ $candidate -gt $largest ]]; then
      largest=$candidate
    fi
  done
  echo $largest
}


# ------------------------------------------------------------------------------
# Testing functions
# ------------------------------------------------------------------------------

function require_osx() {
  if [[ ! $ON_OSX ]]; then
    perror "$MYNAME requires an OS X machine ($(uname) detected)." 1
  fi
  pverb "Identified OS X operating system."
}

function require_linux() {
  if [[ ! $ON_LINUX ]]; then
    perror "$MYNAME requires a Linux machine ($(uname) detected)." 1
  fi
  pverb "Identified Linux operating system."
}

function require_root() {
  if [[ "$UID" != "0" ]]; then
    pwarn "No root privileges detected.  Attempting \`sudo $0 ${args[*]}\`..."
    sudo "$0" "${args[@]}"
    exit
  fi
}

function require_file() {
  if [[ ! -f "$1" ]]; then
    log "Error: File $1 doesn't exist!"
    exit 1
  fi
}

function require_directory() {
  if [[ ! -d "$1" ]]; then
    log "Error: Directory $1 doesn't exist!"
    exit 1
  fi
}

function require_any() {
  if [[ ! -e "$1" ]]; then
    log --print "Error: Object $1 doesn't exist!"
    exit 1
  fi
}

# trim functions from http://stackoverflow.com/questions/369758/how-to-trim-whitespace-from-bash-variable
# input MUST be quoted or else consecutive interior whitespace characters will be collapsed

# Remove leading whitespace characters
function ltrim() {
  local string=$@
  echo -n "${string#"${string%%[![:space:]]*}"}"
}

# Remove trailing whitespace characters
function rtrim() {
  local string=$@
  echo -n "${string%"${string##*[![:space:]]}"}"
}

# Remove surrounding whitespace characters
function trim() {
  local string=$@
  echo -n "$(rtrim "$(ltrim "$string")")"
}

# Adapted from Wicked Cool Shell Scripts #2
function isAlphaNumeric() {
  local cleaned=$(echo $1 | sed -e 's/[^[:alnum:]]//g')
  if [[ "$cleaned" == "$1" ]]; then
    return 0
  else
    return 1
  fi
}

# Adapted from Wicked Cool Shell Scripts #5
#   default allows:   -2 0 -0 47
#   loose allows dot:  -83. 3.
#   natural forces non-neg: 98 721
#   loose natural allows: 45. 2853
function isInteger() {
  local loose=false negativeOK=true cleaned raw option
  while [[ ${1:0:2} == "--" ]]; do
    option=$1; shift
    case $option in 
      "--loose")    loose=true ;;
      "--natural")  negativeOK=false ;;
      "--")         break ;;
      *)            perror "Unsupported option '$option'" 1 ;;
    esac
  done
  [[ $# -eq 0 ]] && perror "No input received" 1
  raw="$1"
  # Allow (trim) leading dash if negative numbers are ok
  $negativeOK && [[ "${raw:0:1}" == "-" ]] && raw=${raw:1}
  # Allow (trim) trailing period if loose matching is enabled
  $loose && [[ "${raw:(-1)}" == "." ]] && raw=${raw:0:$((${#raw}-1))}
  # Forbid empty strings (or '-' or '-.' or '.')
  [[ ${#raw} -eq 0 ]] && return 1
  # Strip any remaining non-digit characters
  cleaned=$(echo $raw | sed -e 's/[^[:digit:]]//g')
  if [[ "$cleaned" == "$raw" ]]; then
    return 0
  else
    return 1
  fi
}


# Adapted from Wicked Cool Shell Scripts #6
#   default allows 3.65 -6.0 0.258
#   loose allows -.36 26. 1.7
function isFloat() {
  # XXX add a strict mode, which requires a dot surrounded by digits (i.e. -7.0, 0.16, 634.9, or 0.0)
  # If we have at least one decimal point, break it apart
  if [[ -n $(echo $1 | sed 's/[^.]//g') ]]; then
    local wholePart="$(echo $1 | cut -d. -f1)"
    local fractPart="$(echo $1 | cut -d. -f2-)"

    # Anything after decimal needs to be a non-negative integer
    if [[ -n $fractPart ]]; then
      ! isInteger --natural -- $fractPart && return 1
    fi

    # Nothing in front of the decimal, we need something after it
    if [[ "$wholePart" == "-" || -z "$wholePart" ]]; then
      [[ -z $fractPart ]] && return 1
    # Something's in front of decimal, it needs to be a valid integer
    else
      ! isInteger -- $wholePart && return 1
    fi
  # No decimal point, entire string needs to be a valid integer
  else
    ! isInteger -- $1 && return 1
  fi
  return 0
}

# test array membership
function isMemberOf() {
  local e
  for e in "${@:2}"; do [[ "$e" == "$1" ]] && return 0; done
  return 1
}



# ------------------------------------------------------------------------------
# Alert limiters
# ------------------------------------------------------------------------------
#   Certain error conditions such as service outages should be checked often and
#   administrators should be notified of failures.  However, notifying on every
#   failed check could create dozens of notices.  These alert-limiting functions
#   allow for a notice to be sent when an error condition is first detected, and
#   then at regular intervals thereafter.
# ------------------------------------------------------------------------------

# alert_write()
#   Manages alert condition timestamp files and directs caller to send
#   notifications.
#
# Arguments:
#   name          Name of the alert condition
#   delay         Optional: don't send first alert until after delay seconds
#   throttle      Squelch subsequent alerts during throttle duration, in seconds
#
# Return value:
#   0             Caller should not send notification
#   1             Caller should send first notification
#   2             Caller should send another notification
#   -1            Error condition found
#
# Examples
#   alert_write name 600
#   alert_write name 0 600
#                 When this alert condition is encountered, caller should send
#                 first alert immediately, then every 10 minutes afterwards.
#                 (Both forms above are equivalent, as delay defaults to 0.)
#   alert_write name 300 600
#                 When this alert condition is encountered, caller should send
#                 first alert after 5 minutes, then every 10 minutes afterwards.

function alert_write() {
  [[ ! -d "$TEMPDIR" ]] && perror "Directory for alert files, $TEMPDIR, does not exist" -1
  [[ $# -eq 0 || ${#1} -eq 0 ]] && pterror "$FUNCNAME() expects a non-empty alert name as the first parameter" -1
  delayfile="$TEMPDIR/alert.$(basename $0).$(whoami).$1.delay"
  throttlefile="$TEMPDIR/alert.$(basename $0).$(whoami).$1.throttle"
  alertname="$1"
  shift
  if [[ $# == 2 ]]; then
    delay=$1
    shift
  else
    delay=0
  fi
  throttle=$1
  alert_read "$alertname" "$delay" "$throttle"
  case $? in
    # Caller does not need to alert, but we may need to create a delay file
    0)
      if [[ $delay -gt 0 && ! -f $delayfile && ! -f $throttlefile ]]; then
        date +%s > "$delayfile" || perror "Error $? while creating alert delay file $alertfile" -1
      fi
      return 0 ;;
    # Switch to throttle mode, caller should send first alert
    1)
      alert_clear "$alertname"
      date +%s > "$throttlefile" && return 1 || perror "Error $? while creating alert throttle file $alertfile" -1 ;;
    # Refresh existing throttle file, caller should send another alert
    2)
      date +%s > "$throttlefile" && return 2 || perror "Error $? while refreshing alert throttle file $alertfile" -1 ;;
    # Error
    *)
      return -1 ;;
  esac
}

# alert_read()
#   Returns an alert condition's state.
#   This isn't really intended for external use
#
# Arguments:
#   name          Name of the alert condition
#   delay         Optional: don't send first alert until after delay seconds
#   throttle      Squelch subsequent alerts during throttle duration, in seconds
#
# Return value:
#   0             In timeout period (delay/throttle files may or may not exist)
#   1             Send first alert (delay time exceeded (it may be 0))
#   2             Send subsequent alert (throttle time exceeded (it may be 0))
#   -1            Error condition found

function alert_read() {
  # Sanity checks and state setup
  [[ ! -d "$TEMPDIR" ]] && perror "Directory for alert files, $TEMPDIR, does not exist" -1
  [[ $# -eq 0 || ${#1} -eq 0 ]] && pterror "$FUNCNAME() expects a non-empty alert name as the first parameter" -1
  delayfile="$TEMPDIR/alert.$(basename $0).$(whoami).$1.delay"
  throttlefile="$TEMPDIR/alert.$(basename $0).$(whoami).$1.throttle"
  alertname="$1"
  shift
  if [[ $# == 2 ]]; then
    delay=$1
    shift
  else
    delay=0
  fi
  throttle=$1

  if [[ -f "$throttlefile" ]]; then
    oldstamp=$(cat "$throttlefile")
    checktime=$((oldstamp + throttle))
    now=$(date +%s)
    # Beyond throttle threshold, need to send and update file
    if [[ $checktime -le $now ]]; then
      return 2
    # Don't send/update
    else
      return 0
    fi
  elif [[ -f "$delayfile" ]]; then
    oldstamp=$(cat "$delayfile")
    checktime=$((oldstamp + delay))
    now=$(date +%s)
    # Beyond delay threshold, need to send and switch files
    if [[ $checktime -le $now ]]; then
      return 1
    # Don't send/update
    else
      return 0
    fi
  else
    # Delay is 0, we should be sending the first alert (and going to throttle mode)
    if [[ $delay -eq 0 ]]; then
      return 1
    # Make a delay file but don't send alert
    else
      return 0
    fi
  fi
}

# alert_laststamp()
#   Echo an alert condition's last written Unix epoch timestamp.  If alert was
#   cleared or was never written, echo 0 instead.
#
# Arguments:
#   name          Name of the alert condition
#
# Return value:
#   0             No errors encountered
#   nonzero       Error!

function alert_laststamp() {
  [[ ! -d "$TEMPDIR" ]] && perror "Directory for alert files, $TEMPDIR, does not exist" -1
  [[ $# -eq 0 || ${#1} -eq 0 ]] && pterror "$FUNCNAME() expects a non-empty alert name as the first parameter" -1
  delayfile="$TEMPDIR/alert.$(basename $0).$(whoami).$1.delay"
  throttlefile="$TEMPDIR/alert.$(basename $0).$(whoami).$1.throttle"
  if [[ -f $throttlefile ]]; then
    echo $(cat $throttlefile)
  elif [[ -f $delayfile ]]; then
    echo $(cat $delayfile)
  else
    echo 0
  fi
  return 0

}

# alert_clear()
#   Removes an alert condition's timestamp file(s).  Call this when the alert
#   condition is cleared, as leftover files may disrupt future alert scenarios.
#
# Arguments:
#   name          Name of the alert condition
#
# Return value:
#   0             Any files found were successfully removed
#   -1            Error condition found

function alert_clear() {
  [[ ! -d "$TEMPDIR" ]] && perror "Directory for alert files, $TEMPDIR, does not exist" -1
  [[ $# -eq 0 || ${#1} -eq 0 ]] && pterror "$FUNCNAME() expects a non-empty alert name as the first parameter" -1
  delayfile="$TEMPDIR/alert.$(basename $0).$(whoami).$1.delay"
  throttlefile="$TEMPDIR/alert.$(basename $0).$(whoami).$1.throttle"
  if [[ -f $delayfile ]]; then
    rm -f "$delayfile"
    [[ $? -ne 0 ]] && perror "Error $? while clearing alert file $delayfile" $?
  fi
  if [[ -f $throttlefile ]]; then
    rm -f "$throttlefile"
    [[ $? -ne 0 ]] && perror "Error $? while clearing alert file $throttlefile" $?
  fi
  return 0
}



# ------------------------------------------------------------------------------
# Debugging functions
# ------------------------------------------------------------------------------

# Based loosely on http://www.runscripts.com/support/guides/scripting/bash/debugging-bash/stack-trace
function stacktrace () {
  # Include the call to us (stacktrace) if called with param of "full"
  if [[ $# -eq 1 ]]; then
    if [[ $1 == "full" ]]; then
      lastframe=0
    else
      lastframe="$1"
    fi
  else
    lastframe=1
  fi
  declare -i frame=0
  declare -a trace_info
  declare -a lines

  # Grab stack trace
  while frame_info=$(caller $frame); do
    trace_info[$frame]=$frame_info
    frame=$((frame+1))
  done

  # Re-order and pretty-print (Python style)
  echo "Traceback (most recent call last):"
  for (( n=${#trace_info[@]}-1 ; n>=$lastframe ; n-- )) ; do
    frame_info=(${trace_info[n]}) # Parentheses make an array
    echo -en "  File "${frame_info[2]}", line ${frame_info[0]}, in ${frame_info[1]}\n"
    lineno=${frame_info[0]}
    lines[0]=$(sed -n "$lineno{s/^[[:space:]]*//;p;q;}" ${frame_info[2]})
    while true; do
      if [[ $lineno -eq 1 ]]; then
        break
      fi
      lineno=$((lineno-1))
      line=$(sed -n "$lineno{s/^[[:space:]]*//;p;q;}" ${frame_info[2]})
      if [[ "${line:(-1)}" == "\\" ]]; then
        lines[${#lines[*]}]=$line
      else
        break
      fi
    done
    for (( m=${#lines[@]}-1 ; m>=0 ; m-- )) ; do
      echo -e "    ${T_CYA}${lines[m]}${T_RST}"
    done
  done
}



# ------------------------------------------------------------------------------
# Invocation sanity checks
# ------------------------------------------------------------------------------

if [[ "$0" == "${BASH_SOURCE[0]}" ]]; then
  perror "Source $MYNAME from other scripts instead of calling it directly." 1
fi

# this fails if curbash is sourced without version (it inherits $* from parent)
#if [[ $1 -gt $CURBASH_VERSION ]]; then
#  perror "$MYNAME needs curbash version $1 or higher, but found version $CURBASH_VERSION instead"
#  exit 1
#fi
