#!/usr/bin/env bash
# bollux: a bash gemini client
# Author: Case Duckworth
# License: MIT
# Version: 0.2.2

# Program information
PRGN="${0##*/}"
VRSN=0.2.2
# State
REDIRECTS=0

bollux_usage() {
	cat <<END
$PRGN (v. $VRSN): a bash gemini client
usage:
	$PRGN [-h]
	$PRGN [-q] [-v] [URL]
flags:
	-h	show this help and exit
	-q	be quiet: log no messages
	-v	verbose: log more messages
parameters:
	URL	the URL to start in
		If not provided, the user will be prompted.
END
}

run() {
	log debug "$@"
	"$@"
}

die() {
	ec="$1"
	shift
	log error "$*"
	exit "$ec"
}

# pure bash bible trim_string
trim() {
	: "${1#"${1%%[![:space:]]*}"}"
	: "${_%"${_##*[![:space:]]}"}"
	printf '%s\n' "$_"
}

log() {
	[[ "$BOLLUX_LOGLEVEL" == QUIET ]] && return
	case "$1" in
	d* | D*) # debug
		[[ "$BOLLUX_LOGLEVEL" == DEBUG ]] || return
		fmt=34
		;;
	e* | E*) # error
		fmt=31
		;;
	*) fmt=1 ;;
	esac
	shift
	printf >&2 '\e[%sm%s:\e[0m\t%s\n' "$fmt" "$PRGN" "$*"
}

# main entry point
bollux() {
	run bollux_args "$@"
	run bollux_config

	if [[ ! "${BOLLUX_URL:+isset}" ]]; then
		run prompt GO BOLLUX_URL
	fi

	run blastoff "$BOLLUX_URL"
}

bollux_args() {
	while getopts :hvq OPT; do
		case "$OPT" in
		h)
			bollux_usage
			exit
			;;
		v) BOLLUX_LOGLEVEL=DEBUG ;;
		q) BOLLUX_LOGLEVEL=QUIET ;;
		:) die 1 "Option -$OPTARG requires an argument" ;;
		*) die 1 "Unknown option: -$OPTARG" ;;
		esac
	done
	shift $((OPTIND - 1))
	if (($# == 1)); then
		BOLLUX_URL="$1"
	fi
}

bollux_config() {
	: "${BOLLUX_CONFIG:=${XDG_CONFIG_DIR:-$HOME/.config}/bollux/bollux.conf}"

	if [ -f "$BOLLUX_CONFIG" ]; then
		# shellcheck disable=1090
		. "$BOLLUX_CONFIG"
	else
		log debug "Can't load config file '$BOLLUX_CONFIG'."
	fi

	## behavior
	: "${BOLLUX_DOWNDIR:=.}"                   # where to save downloads
	: "${BOLLUX_LOGLEVEL:=3}"                  # log level
	: "${BOLLUX_MAXREDIR:=5}"                  # max redirects
	: "${BOLLUX_PORT:=1965}"                   # port number
	: "${BOLLUX_PROTO:=gemini}"                # default protocol
	: "${BOLLUX_LESSKEY:=/tmp/bollux-lesskey}" # where to store binds
	: "${BOLLUX_PAGESRC:=/tmp/bollux-src}"     # where to save the page source
	: "${BOLLUX_URL:=}"                        # start url
	## typesetting
	: "${T_MARGIN:=4}"      # left and right margin
	: "${T_WIDTH:=0}"       # width of the viewport -- 0 = get term width
	# colors -- these will be wrapped in \e[ __ m
	C_RESET='\e[0m'         # reset
	: "${C_SIGIL:=35}"      # sigil (=>, #, ##, ###, *, ```)
	: "${C_LINK_NUMBER:=1}" # link number
	: "${C_LINK_TITLE:=4}"  # link title
	: "${C_LINK_URL:=36}"   # link URL
	: "${C_HEADER1:=1;4}"   # header 1 formatting
	: "${C_HEADER2:=1}"     # header 2 formatting
	: "${C_HEADER3:=3}"     # header 3 formatting
	: "${C_LIST:=0}"        # list formatting
	: "${C_PRE:=0}"         # preformatted text formatting
}

prompt() {
	prompt="$1"
	shift
	read </dev/tty -e -r -p "$prompt> " "$@"
}

blastoff() { # load a url
	local well_formed=true
	if [[ "$1" == "-u" ]]; then
		well_formed=false
		shift
	fi
	URL="$1"

	if $well_formed && [[ "$1" != "$BOLLUX_URL" ]]; then
		URL="$(run transform_resource "$BOLLUX_URL" "$1")"
	fi
	[[ "$URL" != *://* ]] && URL="$BOLLUX_PROTO://$URL"
	URL="$(trim "$URL")"

	server="${URL#*://}"
	server="${server%%/*}"

	log d "URL='$URL' server='$server'"

	run request_url "$server" "$BOLLUX_PORT" "$URL" |
		run handle_response "$URL"
}

transform_resource() { # transform_resource BASE_URL REFERENCE_URL
	declare -A R B T # reference, base url, target
	eval "$(run parse_url B "$1")"
	eval "$(run parse_url R "$2")"
	# A non-strict parser may ignore a scheme in the reference
	# if it is identical to the base URI's scheme.
	if ! "${STRICT:-true}" && [[ "${R[scheme]}" == "${B[scheme]}" ]]; then
		unset "${R[scheme]}"
	fi

	# basically pseudo-code from spec ported to bash
	if isdefined "R[scheme]"; then
		T[scheme]="${R[scheme]}"
		isdefined "R[authority]" && T[authority]="${R[authority]}"
		isdefined R[path] &&
			T[path]="$(run remove_dot_segments "${R[path]}")"
		isdefined "R[query]" && T[query]="${R[query]}"
	else
		if isdefined "R[authority]"; then
			T[authority]="${R[authority]}"
			isdefined "R[authority]" &&
				T[path]="$(remove_dot_segments "${R[path]}")"
			isdefined R[query] && T[query]="${R[query]}"
		else
			if isempty "R[path]"; then
				T[path]="${B[path]}"
				if isdefined R[query]; then
					T[query]="${R[query]}"
				else
					T[query]="${B[query]}"
				fi
			else
				if [[ "${R[path]}" == /* ]]; then
					T[path]="$(remove_dot_segments "${R[path]}")"
				else
					T[path]="$(merge_paths "B[authority]" "${B[path]}" "${R[path]}")"
					T[path]="$(remove_dot_segments "${T[path]}")"
				fi
				isdefined R[query] && T[query]="${R[query]}"
			fi
			T[authority]="${B[authority]}"
		fi
		T[scheme]="${B[scheme]}"
	fi
	isdefined R[fragment] && T[fragment]="${R[fragment]}"
	# cf. 5.3 -- recomposition
	local r=""
	isdefined "T[scheme]" && r="$r${T[scheme]}:"
	# remove the port from the authority
	isdefined "T[authority]" && r="$r//${T[authority]%:*}"
	r="$r${T[path]}"
	isdefined T[query] && r="$r?${T[query]}"
	isdefined T[fragment] && r="$r#${T[fragment]}"
	printf '%s\n' "$r"
}

merge_paths() { # 5.2.3
	# shellcheck disable=2034
	B_authority="$1"
	B_path="$2"
	R_path="$3"
	# if R_path is empty, get rid of // in B_path
	if [[ -z "$R_path" ]]; then
		printf '%s\n' "${B_path//\/\//\//}"
		return
	fi

	if isdefined "B_authority" && isempty "B_path"; then
		printf '/%s\n' "${R_path//\/\//\//}"
	else
		if [[ "$B_path" == */* ]]; then
			B_path="${B_path%/*}/"
		else
			B_path=""
		fi
		printf '%s/%s\n' "${B_path%/}" "${R_path#/}"
	fi
}

remove_dot_segments() { # 5.2.4
	local input="$1"
	local output=
	# ^/\.(/|$) - BASH_REMATCH[0]
	while [[ "$input" ]]; do
		if [[ "$input" =~ ^\.\.?/ ]]; then
			input="${input#${BASH_REMATCH[0]}}"
		elif [[ "$input" =~ ^/\.(/|$) ]]; then
			input="/${input#${BASH_REMATCH[0]}}"
		elif [[ "$input" =~ ^/\.\.(/|$) ]]; then
			input="/${input#${BASH_REMATCH[0]}}"
			[[ "$output" =~ /?[^/]+$ ]]
			output="${output%${BASH_REMATCH[0]}}"
		elif [[ "$input" == . || "$input" == .. ]]; then
			input=
		else
			[[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || echo NOMATCH >&2
			output="$output${BASH_REMATCH[1]}"
			input="${BASH_REMATCH[2]}"
		fi
	done
	printf '%s\n' "${output//\/\//\//}"
}

parse_url() { # eval "$(split_url NAME STRING)" => NAME[...]
	local name="$1"
	local string="$2"
	# shopt -u extglob # TODO port re ^ to extglob syntax
	local re='^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?'
	[[ $string =~ $re ]] || return $?
	# shopt -s extglob

	local scheme="${BASH_REMATCH[2]}"
	local authority="${BASH_REMATCH[4]}"
	local path="${BASH_REMATCH[5]}"
	local query="${BASH_REMATCH[7]}"
	local fragment="${BASH_REMATCH[9]}"

	for c in scheme authority query fragment; do
		[[ "${!c}" ]] &&
			run printf '%s[%s]=%q\n' "$name" "$c" "${!c}"
	done
	# unclear if the path is always set even if empty but it looks that way
	run printf '%s[path]=%q\n' "$name" "$path"
}

# is a NAME defined ('set' in bash)?
isdefined() { [[ "${!1+x}" ]]; } # isdefined NAME
# is a NAME defined AND empty?
isempty() { [[ ! "${!1-x}" ]]; } # isempty NAME

request_url() {
	local server="$1"
	local port="$2"
	local url="$3"

	ssl_cmd=(openssl s_client -crlf -quiet -connect "$server:$port")
	ssl_cmd+=(-servername "$server") # SNI
	run "${ssl_cmd[@]}" <<<"$url" 2>/dev/null
}

handle_response() {
	local url="$1" code meta

	while read -r -d $'\r' hdr; do
		code="$(gawk '{print $1}' <<<"$hdr")"
		meta="$(
			gawk '{for(i=2;i<=NF;i++)printf "%s ",$i;printf "\n"}' <<<"$hdr"
		)"
		break
	done

	log x "[$code] $meta"

	case "$code" in
	1*)
		REDIRECTS=0
		BOLLUX_URL="$URL"
		run prompt "$meta" QUERY
		# shellcheck disable=2153
		run blastoff "?$QUERY"
		;;
	2*)
		REDIRECTS=0
		BOLLUX_URL="$URL"
		run display "$meta"
		;;
	3*)
		((REDIRECTS += 1))
		if ((REDIRECTS > BOLLUX_MAXREDIR)); then
			die $((100 + code)) "Too many redirects!"
		fi
		BOLLUX_URL="$URL"
		run blastoff "$meta"
		;;
	4*)
		REDIRECTS=0
		die "$((100 + code))" "$code"
		;;
	5*)
		REDIRECTS=0
		die "$((100 + code))" "$code"
		;;
	6*)
		REDIRECTS=0
		die "$((100 + code))" "$code"
		;;
	*)
		[[ -z "${code-}" ]] && die 100 "Empty response code."
		die "$((100 + code)) Unknown response code: $code."
		;;
	esac
}

display() {
	case "$1" in
	*\;*)
		mime="${1%;*}"
		charset="${1#*;}"
		trim "$mime"
		trim "$charset"
		log d "$mime $charset"
		;;
	*) mime="$(trim "$1")" ;;
	esac

	[[ -z "$mime" ]] && mime="text/gemini"
	if [[ -z "$charset" ]]; then
		charset="utf-8"
	else
		charset="${charset#charset=}"
	fi

	log debug "mime=$mime; charset=$charset"

	case "$mime" in
	text/*)
		less_cmd=(less -R)
		{
			[[ -r "$BOLLUX_LESSKEY" ]] || mklesskey "$BOLLUX_LESSKEY"
		} && less_cmd+=(-k "$BOLLUX_LESSKEY")
		less_cmd+=(
			-Pm'bollux$'
			-PM'o\:open, g\:goto, r\:refresh$'
			-M
		)

		submime="${mime#*/}"
		if declare -F | grep -q "$submime"; then
			log d "typeset_$submime"
			{
				normalize_crlf |
					tee "$BOLLUX_PAGESRC" |
					run "typeset_$submime" |
					run "${less_cmd[@]}"
			} || run handle_keypress "$?"
		else
			log "cat"
			{
				normalize_crlf |
					tee "$BOLLUX_PAGESRC" |
					run "${less_cmd[@]}"
			} || run handle_keypress "$?"
		fi
		;;
	*) run download "$BOLLUX_URL" ;;
	esac
}

mklesskey() {
	lesskey -o "$1" - <<-END
		#command
		o quit 0 # 48 open a link
		g quit 1 # 49 goto a url
		[ quit 2 # 50 back
		] quit 3 # 51 forward
		r quit 4 # 52 re-request / download
	END
}

normalize_crlf() {
	shopt -s extglob 
	while IFS= read -r; do
		printf '%s\n' "${REPLY//$'\r'?($'\n')/}"
	done
	shopt -u extglob
}

typeset_gemini() {
	local pre=false
	local ln=0 # link number

	if ((T_WIDTH == 0)); then
		shopt -s checkwinsize
		(
			:
			:
		) # XXX this doesn't work!?
		log d "LINES=$LINES; COLUMNS=$COLUMNS"
		T_WIDTH=$COLUMNS
	fi
	WIDTH=$((T_WIDTH - T_MARGIN))
	((WIDTH < 0)) && WIDTH=80  # default if dumb
	S_MARGIN=$((T_MARGIN - 1)) # spacing

	log d "T_WIDTH=$T_WIDTH"
	log d "WIDTH=$WIDTH"

	while IFS= read -r; do
		case "$REPLY" in
		'```')
			if $pre; then
				pre=false
			else
				pre=true
			fi
			continue
			;;
		=\>*)
			: $((ln += 1))
			gemini_link "$REPLY" $pre "$ln"
			;;
		\#*) gemini_header "$REPLY" $pre ;;
		\**)
			if [[ "$REPLY" =~ ^\*[[:space:]]+ ]]; then
				gemini_list "$REPLY" $pre
			else
				gemini_text "$REPLY" $pre
			fi
			;;
		*) gemini_text "$REPLY" $pre ;;
		esac
	done
}

gemini_link() {
	local re="^(=>)[[:blank:]]*([^[:blank:]]+)[[:blank:]]*(.*)"
	local s t a l # sigil, text, annotation(url), line
	if ! ${2-false} && [[ "$1" =~ $re ]]; then
		s="${BASH_REMATCH[1]}"
		a="${BASH_REMATCH[2]}"
		t="${BASH_REMATCH[3]}"
		if [[ -z "$t" ]]; then
			t="$a"
			a=
		fi

		printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
		printf -v l "\e[${C_LINK_NUMBER}m[%d]${C_RESET} \
			\e[${C_LINK_TITLE}m%s${C_RESET} \
			\e[${C_LINK_URL}m%s${C_RESET}\n" \
			"$3" "$t" "$a"
		fold_line "$WIDTH" "$l"
	else
		gemini_pre "$1"
	fi
}

gemini_header() {
	local re="^(#+)[[:blank:]]*(.*)"
	local s t a l # sigil, text, annotation(lvl), line
	if ! ${2-false} && [[ "$1" =~ $re ]]; then
		s="${BASH_REMATCH[1]}"
		a="${#BASH_REMATCH[1]}"
		t="${BASH_REMATCH[2]}"
		local hdrfmt
		hdrfmt="$(eval echo "\$C_HEADER$a")"

		printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
		printf -v l "\e[${hdrfmt}m%s${C_RESET}\n" "$t"
		fold_line "$WIDTH" "$l"
	else
		gemini_pre "$1"
	fi
}

gemini_list() {
	local re="^(\*)[[:blank:]]*(.*)"
	local s t a l # sigil, text, annotation(n/a), line
	if ! ${2-false} && [[ "$1" =~ $re ]]; then
		s="${BASH_REMATCH[1]}"
		t="${BASH_REMATCH[2]}"

		printf "\e[${C_SIGIL}m%${S_MARGIN}s " "$s"
		printf -v l "\e[${C_LIST}m%s${C_RESET}\n" "$t"
		fold_line "$WIDTH" "$l"
	else
		gemini_pre "$1"
	fi
}

gemini_text() {
	if ! ${2-false}; then
		printf "%${S_MARGIN}s " ' '
		fold_line "$WIDTH" "$1"
	else
		gemini_pre "$1"
	fi
}

gemini_pre() {
	printf "\e[${C_SIGIL}m%${S_MARGIN}s " '```'
	printf "\e[${C_PRE}m%s${C_RESET}\n" "$1"
}

fold_line() { # fold_line WIDTH TEXT
	local width="$1"
	local margin="${2%%[![:space:]]*}"
	if [[ "$margin" ]]; then
		margin="${#margin}"
	else
		margin="$T_MARGIN"
	fi
	local ll=0 wl plain
	# shellcheck disable=2086
	set -- $2 # TODO: is this the best way?

	for word; do
		plain="${word//$'\x1b'\[*([0-9;])m/}"
		wl=$((${#plain} + 1))
		if (((ll + wl) >= width)); then
			printf "\n%${margin}s" ' '
			ll=$wl
		else
			ll=$((ll + wl))
		fi
		printf '%s ' "$word"
	done
	printf '\n'
}

handle_keypress() {
	case "$1" in
	48) # o - open a link -- show a menu of links on the page
		run select_url "$BOLLUX_PAGESRC"
		;;
	49) # g - goto a url -- input a new url
		prompt GO URL
		run blastoff -u "$URL"
		;;
	50) # [ - back in the history
		run history_back
		;;
	51) # ] - forward in the history
		run history_forward
		;;
	52) # r - re-request the current resource
		run blastoff "$BOLLUX_URL"
		;;
	*) # 53-57 -- still available for binding
		;;
	esac
}

select_url() {
	run mapfile -t < <(extract_links <"$1")
	select u in "${MAPFILE[@]}"; do
		run blastoff "$(gawk '{print $1}' <<<"$u")" && break
	done </dev/tty
}

extract_links() {
	gawk '
	/^=>/ {
		sub(/=>[[:space:]]*/,"")
		if ($2) {
			rest=""
			for (i=2;i<=NF;i++) {
				rest=rest (rest?" ":"")$i
			}
			printf "%s (\033[34m%s\033[0m)\n", $1, rest
		} else {
			printf "%s\n", $1
		}
	}'
}

download() {
	tn="$(mktemp)"
	log x "Downloading: '$BOLLUX_URL' => '$tn'..."
	dd status=progress >"$tn"
	fn="$BOLLUX_DOWNDIR/${BOLLUX_URL##*/}"
	if [[ -f "$fn" ]]; then
		log x "Saved '$tn'."
	elif mv "$tn" "$fn"; then
		log x "Saved '$fn'."
	else
		log error "Error saving '$fn': downloaded to '$tn'."
	fi
}

history_back() { log error "Not implemented."; }
history_forward() { log error "Not implemented."; }

if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
	run bollux "$@"
else
	BOLLUX_LOGLEVEL=DEBUG
fi
