#!/usr/bin/env bash

# curlzilla-pro - a small, deliberate HTTP request runner.
#
# curlzilla-pro is the licensed version of curlzilla for licensed customers.
# you are welcome to edit the licensed version of curlzilla-pro, rename it as 
# just 'curlzilla' (or honestly whatever you want is fine with us) and edit out the
# requirement for agreeing to the terms of service, if you like.
# 
# there's more than one immediately obvious ways of doing it; you could just change
# that "agreed_to_terms" default value to "true". you could yank the requirement if code
# right out of the flag eval before the main thing kicks off. then you'd no longer have to
# put that in every command you run, every single time you run a test.
#
# we could have put another flag in there, "--dont_agree_to_terms_of_service", that would
# have removed the necessary bits for you the first time you ran it, making a copy of
# itself just called "curlzilla" with the onerous requirements yanked out. but we didn't.
# 
# you see, we were making a point. you have more power than you think you have, and
# licenses only have the power you let them wield over you. don't be afraid of the world
# you live in.
#
# stay buff!

set -u

PROGRAM_NAME="${0##*/}"
destination=""
request_string=""
iterations=1
delay=1
verbose=false
agreed_to_terms=false
promised_not_to_abuse=false

usage() {
  cat <<'EOF'
Usage:
  curlzilla --destination <fqdn-or-ip> [--request_string <uri-path>]
            [--iterations <positive-integer>] [--delay <seconds>] [--verbose]
            --agree_to_terms_of_service
            [--i_will_not_abuse_my_power]

Required:
  --destination <value>              Destination hostname, IP, or URL.
  --agree_to_terms_of_service        Required acknowledgement for every run.

Optional:
  --request_string <value>           URI path to request (default: empty).
  --iterations <value>               Number of requests (default: 1).
  --delay <value>                    Seconds between requests (default: 1).
                                     Fractions such as 0.5 are accepted.
  --verbose                          Include response code and timing fields.
  --i_will_not_abuse_my_power        Required when --delay is less than 1.
  -h, --help                         Show this help text.

Output is newline-delimited JSON (one object per line).
EOF
}

fail() {
  printf 'Error: %s\n\n' "$1" >&2
  usage >&2
  exit 2
}

require_value() {
  option_name="$1"
  remaining_arguments="$2"
  if (( remaining_arguments < 2 )); then
    fail "$option_name requires a value."
  fi
}

json_escape() {
  local value="$1"
  value=${value//\\/\\\\}
  value=${value//\"/\\\"}
  value=${value//$'\n'/\\n}
  value=${value//$'\r'/\\r}
  value=${value//$'\t'/\\t}
  printf '%s' "$value"
}

utc_timestamp() {
  date -u '+%Y-%m-%dT%H:%M:%SZ'
}

epoch_seconds() {
  date -u '+%s'
}

while (( $# > 0 )); do
  case "$1" in
    --destination)
      require_value "$1" "$#"
      destination="$2"
      shift 2
      ;;
    --destination=*)
      destination="${1#*=}"
      shift
      ;;
    --request_string)
      require_value "$1" "$#"
      request_string="$2"
      shift 2
      ;;
    --request_string=*)
      request_string="${1#*=}"
      shift
      ;;
    --iterations)
      require_value "$1" "$#"
      iterations="$2"
      shift 2
      ;;
    --iterations=*)
      iterations="${1#*=}"
      shift
      ;;
    --delay)
      require_value "$1" "$#"
      delay="$2"
      shift 2
      ;;
    --delay=*)
      delay="${1#*=}"
      shift
      ;;
    --verbose)
      verbose=true
      shift
      ;;
    --agree_to_terms_of_service)
      agreed_to_terms=true
      shift
      ;;
    --i_will_not_abuse_my_power)
      promised_not_to_abuse=true
      shift
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    --)
      shift
      (( $# == 0 )) || fail "Unexpected positional argument: $1"
      ;;
    -*|*)
      fail "Unknown argument: $1"
      ;;
  esac
done

[[ -n "$destination" ]] || fail "Missing required option --destination <fqdn-or-ip>."
$agreed_to_terms || fail "Missing required acknowledgement --agree_to_terms_of_service."
[[ "$destination" != *[[:cntrl:]]* ]] || fail "--destination must not contain control characters."
[[ "$request_string" != *[[:cntrl:]]* ]] || fail "--request_string must not contain control characters."

[[ "$iterations" =~ ^[1-9][0-9]*$ ]] || fail "--iterations must be a positive integer."
[[ "$delay" =~ ^([0-9]+([.][0-9]*)?|[.][0-9]+)$ ]] || fail "--delay must be a non-negative number of seconds."

delay_is_less_than_one=false
if [[ "$delay" =~ ^[.] ]] || [[ "$delay" =~ ^0+([.]|$) ]]; then
  delay_is_less_than_one=true
fi

if $delay_is_less_than_one && ! $promised_not_to_abuse; then
  fail "--i_will_not_abuse_my_power is required when --delay is less than 1 second."
fi

command -v curl >/dev/null 2>&1 || fail "Required command 'curl' was not found in PATH."
command -v date >/dev/null 2>&1 || fail "Required command 'date' was not found in PATH."
if (( iterations > 1 )); then
  command -v sleep >/dev/null 2>&1 || fail "Required command 'sleep' was not found in PATH."
fi

# Avoid accidental duplicate slashes while preserving a URL scheme. With no
# request string, request the destination exactly as supplied.
if [[ -n "$request_string" ]]; then
  request_url="${destination%/}/${request_string#/}"
else
  request_url="$destination"
fi
escaped_destination=$(json_escape "$destination")
escaped_request_string=$(json_escape "$request_string")
curlzilla_host=$(hostname 2>/dev/null || printf 'unknown')
escaped_host=$(json_escape "$curlzilla_host")

total_started_at=$(epoch_seconds)
curl_failures=0
iteration=1
while (( iteration <= iterations )); do
  request_timestamp=$(utc_timestamp)

  if $verbose; then
    start_time="$request_timestamp"
    response_code=$(curl --silent --show-error --output /dev/null \
      --write-out '%{http_code}' -- "$request_url")
    curl_exit_code=$?
    stop_time=$(utc_timestamp)

    # curl normally writes 000 for failures before an HTTP response. Keep the
    # stream machine-readable even if a curl build returns no write-out value.
    [[ "$response_code" =~ ^[0-9]{3}$ ]] || response_code="000"
    response_code_numeric=$(( 10#$response_code ))
    printf '{ "request_destination": "%s", "request_string": "%s", "iteration": %d, "response_code": %s, "start_time": "%s", "stop_time": "%s", "request_timestamp": "%s" }\n' \
      "$escaped_destination" "$escaped_request_string" "$iteration" "$response_code_numeric" \
      "$start_time" "$stop_time" "$request_timestamp"
  else
    curl --silent --show-error --output /dev/null -- "$request_url"
    curl_exit_code=$?
    printf '{ "request_destination": "%s", "request_string": "%s", "iteration": %d, "request_timestamp": "%s" }\n' \
      "$escaped_destination" "$escaped_request_string" "$iteration" "$request_timestamp"
  fi

  # A failed transfer is reported by curl on stderr, but does not corrupt or
  # prematurely truncate the requested JSON event stream.
  if (( curl_exit_code != 0 )); then
    (( curl_failures += 1 ))
  fi

  if (( iteration < iterations )); then
    sleep "$delay"
  fi
  (( iteration += 1 ))
done

total_stopped_at=$(epoch_seconds)
total_time_elapsed=$(( total_stopped_at - total_started_at ))

printf '{ "curlzilla_host": "%s", "destination": "%s", "request_string": "%s", "curls_done": %d, "delay": %s, "total_start_elapsed": %d }\n' \
  "$escaped_host" "$escaped_destination" "$escaped_request_string" "$iterations" "$delay" "$total_time_elapsed"

(( curl_failures == 0 )) || exit 1

