#!/bin/bash
# This file is in the public domain.
# Transmit a Challenger TAN by SMS using the ClickSend v3 API.
#
# Usage:
#   echo "MESSAGE" | $0 +41791234567
#
# Note that unlike challenger-send-sms(1), the argument is the bare phone
# number: this script is invoked by the dispatcher, which has already extracted
# and syntax-checked it.
#
# Environment:
#   CLICKSEND_USERNAME
#   CLICKSEND_API_KEY
#
# Exit codes (see challenger-send-sms-clicksend(1)).  Success is 0 or the
# 200-210 band; every other value is a failure.  The success band is up at
# 200 and not at 1 because libgnunetutil reports a failed exec() of the
# helper as exit code 1, which must not be mistaken for a delivery.
# ClickSend collapses every bad number into INVALID_RECIPIENT, so
# this helper never reports 11, 12 or 13.
#
#    0 delivered    201 accepted         202 duplicate suppressed
#   14 invalid number 15 blocked
#   20 offline        21 expired           22 carrier
#   30 provider       31 rejected          32 rate limited     33 no verdict
#   40 config         41 authorization     42 balance
#   50 unclassified

set -eu

if [ -z "${CLICKSEND_USERNAME:-}" ]; then
  echo "CLICKSEND_USERNAME not set in environment" 1>&2
  exit 40
fi
if [ -z "${CLICKSEND_API_KEY:-}" ]; then
  echo "CLICKSEND_API_KEY not set in environment" 1>&2
  exit 40
fi

if [ $# -ne 1 ]; then
  echo "Usage: $0 <phone_number>" 1>&2
  exit 40
fi

PHONE_NUMBER="$1"
MESSAGE=$(cat -)

API="https://rest.clicksend.com/v3"

# Private files keep message text and credentials out of subprocess arguments.
umask 077
WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/clicksend-sms-XXXXXX")
trap 'rm -rf -- "$WORKDIR"' EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
printf '%s' "$MESSAGE" >"$WORKDIR/message"
BODY="$WORKDIR/response"
# curl config strings need escaping independently of shell quoting.
AUTH="$CLICKSEND_USERNAME:$CLICKSEND_API_KEY"
AUTH=${AUTH//\\/\\\\}
AUTH=${AUTH//\"/\\\"}
AUTH=${AUTH//$'\r'/\\r}
AUTH=${AUTH//$'\n'/\\n}
AUTH=${AUTH//$'\t'/\\t}
AUTH=${AUTH//$'\v'/\\v}
printf 'user = "%s"\n' "$AUTH" >"$WORKDIR/auth"

# Build the request with jq rather than by interpolation: the message is
# attacker-influenced text and any quote, backslash or newline in it would
# otherwise produce malformed JSON.
jq -n \
  --arg to "$PHONE_NUMBER" \
  --rawfile body "$WORKDIR/message" \
  '{messages: [{source: "challenger", to: $to, body: $body}]}' >"$WORKDIR/request"

# curl --fail is deliberately not used anywhere in this script: ClickSend puts
# all of its diagnostic value in the response body, which --fail discards.
HTTP=$(curl --silent --connect-timeout 5 --max-time 10 \
  --output "$BODY" \
  --write-out '%{http_code}' \
  --url "$API/sms/send" \
  --request POST \
  --header 'Content-Type: application/json' \
  --config "$WORKDIR/auth" \
  --data-binary "@$WORKDIR/request") || {
  echo "ClickSend: could not be reached" 1>&2
  exit 30
}

RESPONSE_CODE=$(jq -r '.response_code // empty' <"$BODY" 2>/dev/null || true)

if [ "SUCCESS" != "$RESPONSE_CODE" ]; then
  echo "ClickSend: request failed (HTTP $HTTP) for $PHONE_NUMBER" 1>&2
  case "$RESPONSE_CODE" in
  UNAUTHORIZED | FORBIDDEN) exit 41 ;;
  TOO_MANY_REQUESTS) exit 32 ;;
  BAD_REQUEST | NOT_FOUND | METHOD_NOT_ALLOWED) exit 31 ;;
  INTERNAL_SERVER_ERROR) exit 30 ;;
  esac
  case "$HTTP" in
  401 | 403) exit 41 ;;
  429) exit 32 ;;
  4??) exit 31 ;;
  *) exit 30 ;;
  esac
fi

# The envelope only says the request was understood.  ClickSend documents that
# the per-message status is the one that reflects what happened to the message,
# and that its value SUCCESS means "added to queue" -- not "delivered".
MESSAGE_STATUS=$(jq -r '.data.messages[0].status // empty' <"$BODY" 2>/dev/null || true)

case "$MESSAGE_STATUS" in
SUCCESS) ;;
INVALID_RECIPIENT)
  # Failed ClickSend's format or prefix check; never sent, never charged.
  echo "ClickSend: $PHONE_NUMBER rejected as an invalid recipient" 1>&2
  exit 14
  ;;
THROTTLED)
  # An identical message was recently accepted for this recipient, so the
  # same TAN is already on its way.  Suppressing the duplicate is not a
  # failure and must not cause a retry.
  echo "ClickSend: duplicate suppressed, an identical message to $PHONE_NUMBER is already in flight" 1>&2
  exit 202
  ;;
INSUFFICIENT_CREDIT)
  echo "ClickSend: account is out of message credits" 1>&2
  exit 42
  ;;
INVALID_CREDENTIALS | MISSING_CREDENTIALS | ACCOUNT_NOT_ACTIVATED)
  echo "ClickSend: submission rejected" 1>&2
  exit 41
  ;;
COUNTRY_NOT_ENABLED | REGISTRATION_NEEDED | INVALID_SENDER_ID)
  echo "ClickSend: submission blocked for $PHONE_NUMBER" 1>&2
  exit 15
  ;;
INTERNAL_ERROR | SOMETHING_IS_WRONG)
  echo "ClickSend: submission rejected" 1>&2
  exit 30
  ;;
EMPTY_MESSAGE | MISSING_REQUIRED_FIELDS | INVALID_SCHEDULE | TOO_MANY_RECIPIENTS | NOT_ENOUGH_PERMISSION_TO_LIST_ID)
  echo "ClickSend: submission rejected" 1>&2
  exit 31
  ;;
*)
  echo "ClickSend: unclassified submission status for $PHONE_NUMBER" 1>&2
  exit 50
  ;;
esac

MESSAGE_ID=$(jq -r '.data.messages[0].message_id | select(type == "string")' <"$BODY" 2>/dev/null || true)

if [ -z "$MESSAGE_ID" ]; then
  echo "ClickSend: queued message for $PHONE_NUMBER but returned no message_id" 1>&2
  exit 201
fi

# Every final delivery failure is reported as status_code 301, with the reason
# only in the free-text status_text, whose wording ClickSend does not promise to
# keep stable (it already differs between the receipts and history endpoints).
# Unrecognised text therefore degrades to 50 rather than being guessed at.
classify_failure() {
  case "$1" in
  *"Unknown subscriber"* | *"Unidentified subscriber"* | *"Illegal subscriber"*)
    echo 14
    ;;
  *"not supporting SMS"*)
    echo 13
    ;;
  *"Absent subscriber"* | *"Memory capacity exceeded"*)
    echo 20
    ;;
  *"receipt expired"*)
    echo 21
    ;;
  *"congestion"* | *"Rejected by the recipient network"* | *"Network timeout"* | *"Network routing error"*)
    echo 22
    ;;
  *"Anti-spam"*)
    echo 15
    ;;
  *)
    echo 50
    ;;
  esac
}

# Poll sms/history rather than sms/receipts/<id>: the receipts endpoint reads a
# pushed-DLR inbox that needs a POLL automation rule configured on the account
# and is consumed on read, so it 404s for an ordinary send.
# Acceptance starts a separate five-second window for delivery confirmation.
DEADLINE=$((SECONDS + 5))
while [ "$SECONDS" -lt "$DEADLINE" ]; do
  sleep 1
  REMAINING=$((DEADLINE - SECONDS))
  if [ "$REMAINING" -le 0 ]; then
    break
  fi
  curl --silent --connect-timeout 5 --max-time "$REMAINING" \
    --output "$BODY" \
    --url "$API/sms/history?q=message_id:$MESSAGE_ID" \
    --config "$WORKDIR/auth" || continue
  STATUS_CODE=$(jq -r '.data.data[0].status_code // empty' <"$BODY" 2>/dev/null || true)
  STATUS_TEXT=$(jq -r '.data.data[0].status_text // ""' <"$BODY" 2>/dev/null || true)
  STATUS=$(jq -r '.data.data[0].status // ""' <"$BODY" 2>/dev/null || true)
  case "$STATUS" in
  Cancelled | CancelledAfterReview)
    echo "ClickSend: message to $PHONE_NUMBER was stopped" 1>&2
    exit 15
    ;;
  esac
  case "$STATUS_CODE" in
  201)
    echo "ClickSend: delivered to $PHONE_NUMBER" 1>&2
    exit 0
    ;;
  301)
    RESULT=$(classify_failure "$STATUS_TEXT")
    echo "ClickSend: delivery to $PHONE_NUMBER failed with helper status $RESULT" 1>&2
    exit "$RESULT"
    ;;
  *)
    # 200 (queued/sent/scheduled) and 300 (temporary, ClickSend retries
    # for up to 72h) both mean "still in flight".
    continue
    ;;
  esac
done

echo "ClickSend: queued message for $PHONE_NUMBER, delivery not confirmed within the poll window" 1>&2
exit 201
