#!/bin/sh
set -eu

VERSION="0.4.1"
API="${IPBIND_API:-https://api.ipbind.sh}"

CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/ipbind"
HOSTS_DIR="$CONFIG_DIR/hosts"
MODES_DIR="$CONFIG_DIR/modes"
LEGACY_CONFIG="$CONFIG_DIR/config"

die() {
    printf 'ipbind: %s\n' "$*" >&2
    exit 1
}

need_curl() {
    command -v curl >/dev/null 2>&1 ||
        die "curl is required"
}

init_dirs() {
    mkdir -p "$CONFIG_DIR" "$HOSTS_DIR" "$MODES_DIR"
    chmod 700 "$CONFIG_DIR" "$HOSTS_DIR" "$MODES_DIR"
}

normalize_name() {
    name=$1

    case "$name" in
        *.ipbind.sh)
            name=${name%.ipbind.sh}
            ;;
    esac

    case "$name" in
        ''|*[!a-zA-Z0-9-]*)
            die "invalid hostname"
            ;;
    esac

    printf '%s' "$name" | tr 'A-Z' 'a-z'
}

host_file() {
    name=$(normalize_name "$1")
    printf '%s/%s.ipbind.sh\n' "$HOSTS_DIR" "$name"
}

api() {
    method=$1
    path=$2
    shift 2

    curl -fsS \
        --connect-timeout 10 \
        --max-time 20 \
        -X "$method" \
        "$API$path" \
        "$@"
}

api_capture() {
    method=$1
    path=$2
    shift 2

    tmp=$(mktemp)
    trap 'rm -f "$tmp"' EXIT HUP INT TERM

    status=$(
        curl -sS \
            --connect-timeout 10 \
            --max-time 20 \
            -o "$tmp" \
            -w '%{http_code}' \
            -X "$method" \
            "$API$path" \
            "$@"
    ) || {
        rc=$?
        rm -f "$tmp"
        trap - EXIT HUP INT TERM
        return "$rc"
    }

    API_BODY=$(cat "$tmp")
    API_STATUS=$status

    rm -f "$tmp"
    trap - EXIT HUP INT TERM
}

api_error_detail() {
    printf '%s' "${API_BODY:-}" |
        extract_json_string detail
}

extract_json_string() {
    key=$1

    sed -n \
        's/.*"'$key'":"\([^"]*\)".*/\1/p'
}

save_host() {
    name=$(normalize_name "$1")
    token=$2
    file="$HOSTS_DIR/$name.ipbind.sh"

    init_dirs
    umask 077

    printf '%s\n' "$token" > "$file"
    chmod 600 "$file"
}

load_token() {
    name=$(normalize_name "$1")
    file="$HOSTS_DIR/$name.ipbind.sh"

    [ -f "$file" ] ||
        die "$name.ipbind.sh is not configured locally"

    TOKEN=$(cat "$file")

    [ -n "$TOKEN" ] ||
        die "empty token for $name.ipbind.sh"
}

migrate_legacy() {
    [ -f "$LEGACY_CONFIG" ] || return 0

    # The legacy file was generated by ipbind v0.1.x.
    HOSTNAME=
    TOKEN=

    # shellcheck disable=SC1090
    . "$LEGACY_CONFIG"

    [ -n "${HOSTNAME:-}" ] || return 0
    [ -n "${TOKEN:-}" ] || return 0

    name=${HOSTNAME%.ipbind.sh}
    target="$HOSTS_DIR/$name.ipbind.sh"

    init_dirs

    if [ ! -f "$target" ]; then
        umask 077
        printf '%s\n' "$TOKEN" > "$target"
        chmod 600 "$target"

        printf 'Migrated: %s\n' "$HOSTNAME" >&2
    fi

    mv "$LEGACY_CONFIG" "$CONFIG_DIR/config.v0.1.migrated"
    chmod 600 "$CONFIG_DIR/config.v0.1.migrated"
}

get_mode() {
    name=$(normalize_name "$1")
    file="$MODES_DIR/$name.ipbind.sh"

    if [ -f "$file" ]; then
        mode=$(cat "$file")
        case "$mode" in
            auto|manual)
                printf '%s\n' "$mode"
                return
                ;;
        esac
    fi

    # Existing hosts default to automatic DDNS.
    printf 'auto\n'
}

set_mode() {
    name=$(normalize_name "$1")
    mode=$2

    case "$mode" in
        auto|manual)
            ;;
        *)
            die "mode must be auto or manual"
            ;;
    esac

    init_dirs
    umask 077
    printf '%s\n' "$mode" > "$MODES_DIR/$name.ipbind.sh"
    chmod 600 "$MODES_DIR/$name.ipbind.sh"
}

configured_count() {
    count=0

    for file in "$HOSTS_DIR"/*.ipbind.sh; do
        [ -f "$file" ] || continue
        count=$((count + 1))
    done

    printf '%s\n' "$count"
}

only_host() {
    found=

    for file in "$HOSTS_DIR"/*.ipbind.sh; do
        [ -f "$file" ] || continue

        name=${file##*/}
        name=${name%.ipbind.sh}

        if [ -n "$found" ]; then
            die "multiple hosts configured; specify a hostname"
        fi

        found=$name
    done

    [ -n "$found" ] ||
        die "no hostnames configured; run: ipbind claim <name>"

    printf '%s\n' "$found"
}

resolve_name() {
    if [ "$#" -eq 1 ]; then
        normalize_name "$1"
        return
    fi

    [ "$#" -eq 0 ] ||
        die "too many arguments"

    only_host
}

update_one() {
    name=$(normalize_name "$1")
    explicit_ip=${2:-}

    load_token "$name"

    if [ -n "$explicit_ip" ]; then
        response=$(
            api PUT "/v1/hosts/$name" \
                -H "Authorization: Bearer $TOKEN" \
                -H 'Content-Type: application/json' \
                -d "{\"ip\":\"$explicit_ip\"}"
        ) || die "update failed for $name.ipbind.sh"

        set_mode "$name" manual
    else
        response=$(
            api POST "/v1/update/$name" \
                -H "Authorization: Bearer $TOKEN"
        ) || die "update failed for $name.ipbind.sh"
    fi

    ip=$(
        printf '%s' "$response" |
            extract_json_string ip
    )

    [ -n "$ip" ] ||
        die "invalid response for $name.ipbind.sh"

    if printf '%s' "$response" |
        grep -q '"changed":false'
    then
        dns_status="unchanged"
    else
        dns_status="updated"
    fi

    mode=$(get_mode "$name")

    printf 'Hostname: %s.ipbind.sh\n' "$name"
    printf 'IP:       %s\n' "$ip"
    printf 'Mode:     %s\n' "$mode"
    printf 'DNS:      %s\n' "$dns_status"
}

cmd_claim() {
    [ "$#" -eq 1 ] ||
        die "usage: ipbind claim <name>"

    name=$(normalize_name "$1")

    init_dirs

    file="$HOSTS_DIR/$name.ipbind.sh"

    [ ! -f "$file" ] ||
        die "$name.ipbind.sh is already configured locally"

    printf 'Claiming %s.ipbind.sh...\n' "$name"

    if ! api_capture POST /v1/hosts \
        -H 'Content-Type: application/json' \
        -d "{\"name\":\"$name\"}"
    then
        die "could not reach API"
    fi

    case "$API_STATUS" in
        201)
            response=$API_BODY
            ;;
        400)
            detail=$(api_error_detail)
            case "$detail" in
                *reserved*)
                    die "hostname '$name' is invalid or reserved"
                    ;;
                *)
                    die "${detail:-invalid hostname}"
                    ;;
            esac
            ;;
        409)
            die "hostname '$name' is already claimed"
            ;;
        *)
            detail=$(api_error_detail)
            if [ -n "$detail" ]; then
                die "claim failed (HTTP $API_STATUS): $detail"
            fi
            die "claim failed (HTTP $API_STATUS)"
            ;;
    esac

    hostname=$(
        printf '%s' "$response" |
            extract_json_string hostname
    )

    token=$(
        printf '%s' "$response" |
            extract_json_string token
    )

    [ -n "$hostname" ] ||
        die "invalid response from server"

    [ -n "$token" ] ||
        die "server did not return a token"

    save_host "$name" "$token"

    printf 'Claimed: %s\n' "$hostname"
    printf 'Token:   saved locally\n'

    update_one "$name"
}

cmd_list() {
    [ "$#" -eq 0 ] ||
        die "usage: ipbind list"

    found=0

    for file in "$HOSTS_DIR"/*.ipbind.sh; do
        [ -f "$file" ] || continue

        hostname=${file##*/}
        printf '%s\n' "$hostname"
        found=1
    done

    [ "$found" -eq 1 ] ||
        printf 'No hostnames configured.\n'
}

cmd_update() {
    if [ "$#" -eq 1 ] && [ "$1" = "--all" ]; then
        found=0

        for file in "$HOSTS_DIR"/*.ipbind.sh; do
            [ -f "$file" ] || continue

            name=${file##*/}
            name=${name%.ipbind.sh}

            mode=$(get_mode "$name")

            [ "$mode" = "auto" ] || continue

            if [ "$found" -eq 1 ]; then
                printf '\n'
            fi

            update_one "$name"
            found=1
        done

        if [ "$found" -eq 0 ]; then
            printf 'No automatic hostnames configured.\n'
        fi

        return
    fi

    [ "$#" -ge 1 ] ||
        die "usage: ipbind update <name> [--ip ADDRESS]"

    name=$(normalize_name "$1")
    shift

    if [ "$#" -eq 0 ]; then
        update_one "$name"
        return
    fi

    if [ "$#" -eq 2 ] && [ "$1" = "--ip" ]; then
        [ -n "$2" ] ||
            die "IP address is required"

        update_one "$name" "$2"
        return
    fi

    die "usage: ipbind update <name> [--ip ADDRESS]"
}

cmd_status() {
    name=$(resolve_name "$@")

    load_token "$name"

    response=$(
        api GET "/v1/hosts/$name" \
            -H "Authorization: Bearer $TOKEN"
    ) || die "status request failed for $name.ipbind.sh"

    ip=$(
        printf '%s' "$response" |
            extract_json_string ip
    )

    created=$(
        printf '%s' "$response" |
            extract_json_string created_at
    )

    updated=$(
        printf '%s' "$response" |
            extract_json_string updated_at
    )

    printf 'Hostname: %s.ipbind.sh\n' "$name"
    printf 'IP:       %s\n' "${ip:-not set}"
    printf 'Created:  %s\n' "${created:-unknown}"
    printf 'Updated:  %s\n' "${updated:-unknown}"
}

cmd_rotate() {
    name=$(resolve_name "$@")

    load_token "$name"

    response=$(
        api POST "/v1/hosts/$name/token" \
            -H "Authorization: Bearer $TOKEN"
    ) || die "token rotation failed for $name.ipbind.sh"

    new_token=$(
        printf '%s' "$response" |
            extract_json_string token
    )

    [ -n "$new_token" ] ||
        die "server did not return a new token"

    save_host "$name" "$new_token"

    printf 'Hostname: %s.ipbind.sh\n' "$name"
    printf 'Token:    rotated and saved locally\n'
}

cmd_delete() {
    name=$(resolve_name "$@")

    load_token "$name"

    printf 'Deleting %s.ipbind.sh...\n' "$name"

    api DELETE "/v1/hosts/$name" \
        -H "Authorization: Bearer $TOKEN" \
        >/dev/null ||
        die "delete failed for $name.ipbind.sh"

    rm -f "$HOSTS_DIR/$name.ipbind.sh"
    rm -f "$MODES_DIR/$name.ipbind.sh"

    printf 'Deleted: %s.ipbind.sh\n' "$name"
    printf 'Local credentials removed.\n'
}

cmd_mode() {
    [ "$#" -ge 1 ] && [ "$#" -le 2 ] ||
        die "usage: ipbind mode <name> [auto|manual]"

    name=$(normalize_name "$1")
    load_token "$name"

    if [ "$#" -eq 2 ]; then
        set_mode "$name" "$2"
    fi

    mode=$(get_mode "$name")

    printf 'Hostname: %s.ipbind.sh\n' "$name"
    printf 'Mode:     %s\n' "$mode"
}

cmd_enable() {
    [ "$#" -eq 0 ] ||
        die "usage: ipbind enable"

    [ "$(id -u)" -ne 0 ] ||
        die "run 'ipbind enable' as the user who owns the hostnames, not root"

    count=$(configured_count)
    [ "$count" -gt 0 ] ||
        die "no hostnames configured; run: ipbind claim <name>"

    command -v systemctl >/dev/null 2>&1 ||
        die "systemd is required for automatic updates"

    if command -v sudo >/dev/null 2>&1; then
        PRIV="sudo"
    elif command -v su >/dev/null 2>&1; then
        PRIV="su"
    else
        die "root access via sudo or su is required"
    fi

    user=$(id -un)
    uid=$(id -u)
    home=$HOME
    ipbind_path=$(command -v ipbind)

    printf 'Installing automatic updates for user %s...\n' "$user"

    root_script=$(mktemp)
    chmod 600 "$root_script"

    cat > "$root_script" <<'ROOT'
set -eu

user=$1
uid=$2
home=$3
ipbind_path=$4

case "$user" in
    ''|*[!A-Za-z0-9_.-]*)
        echo "ipbind: unsafe user name" >&2
        exit 1
        ;;
esac

case "$uid" in
    ''|*[!0-9]*)
        echo "ipbind: unsafe uid" >&2
        exit 1
        ;;
esac

unit="ipbind-${uid}"

cat > "/etc/systemd/system/${unit}.service" <<EOF
[Unit]
Description=ipbind.sh Dynamic DNS update for ${user}
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=${user}
Environment=HOME=${home}
ExecStart=${ipbind_path} update --all
EOF

cat > "/etc/systemd/system/${unit}.timer" <<EOF
[Unit]
Description=Update ipbind.sh Dynamic DNS for ${user}

[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
RandomizedDelaySec=30s
Persistent=true
Unit=${unit}.service

[Install]
WantedBy=timers.target
EOF

systemctl daemon-reload
systemctl enable --now "${unit}.timer"
systemctl start "${unit}.service"
ROOT

    if [ "$PRIV" = "sudo" ]; then
        sudo sh "$root_script" "$user" "$uid" "$home" "$ipbind_path"
    else
        su -c "sh '$root_script' '$user' '$uid' '$home' '$ipbind_path'"
    fi

    rm -f "$root_script"

    printf '\nAutomatic updates enabled.\n'
    printf 'User:     %s\n' "$user"
    printf 'Interval: every 5 minutes\n'
}

cmd_disable() {
    [ "$#" -eq 0 ] ||
        die "usage: ipbind disable"

    [ "$(id -u)" -ne 0 ] ||
        die "run 'ipbind disable' as the user who owns the automation"

    command -v systemctl >/dev/null 2>&1 ||
        die "systemd is required"

    if command -v sudo >/dev/null 2>&1; then
        PRIV="sudo"
    elif command -v su >/dev/null 2>&1; then
        PRIV="su"
    else
        die "root access via sudo or su is required"
    fi

    uid=$(id -u)
    unit="ipbind-${uid}"

    root_script=$(mktemp)
    chmod 600 "$root_script"

    cat > "$root_script" <<'ROOT'
set -eu

unit=$1

systemctl disable --now "${unit}.timer" 2>/dev/null || true

rm -f \
    "/etc/systemd/system/${unit}.timer" \
    "/etc/systemd/system/${unit}.service"

systemctl daemon-reload
systemctl reset-failed >/dev/null 2>&1 || true
ROOT

    if [ "$PRIV" = "sudo" ]; then
        sudo sh "$root_script" "$unit"
    else
        su -c "sh '$root_script' '$unit'"
    fi

    rm -f "$root_script"

    printf 'Automatic updates disabled.\n'
}

cmd_timer_status() {
    [ "$#" -eq 0 ] ||
        die "usage: ipbind timer-status"

    [ "$(id -u)" -ne 0 ] ||
        die "run 'ipbind timer-status' as the user who owns the automation"

    command -v systemctl >/dev/null 2>&1 ||
        die "systemd is required"

    uid=$(id -u)
    unit="ipbind-${uid}.timer"

    if systemctl is-enabled "$unit" >/dev/null 2>&1; then
        printf 'Automatic updates: enabled\n'
    else
        printf 'Automatic updates: disabled\n'
        return
    fi

    systemctl status "$unit" --no-pager
}

cmd_config() {
    [ "$#" -le 1 ] ||
        die "usage: ipbind config [name]"

    name=$(resolve_name "$@")
    file="$HOSTS_DIR/$name.ipbind.sh"

    printf 'Hostname: %s.ipbind.sh\n' "$name"
    printf 'Config:   %s\n' "$file"
    printf 'Token:    stored (hidden)\n'
}

usage() {
    cat <<EOF
ipbind.sh CLI v$VERSION

Usage:
  ipbind claim <name>
  ipbind list
  ipbind update <name>
  ipbind update <name> --ip <address>
  ipbind update --all
  ipbind status [name]
  ipbind rotate [name]
  ipbind delete [name]
  ipbind config [name]
  ipbind mode <name> [auto|manual]
  ipbind enable
  ipbind disable
  ipbind timer-status
  ipbind version
  ipbind help

Examples:
  ipbind claim home
  ipbind claim nas
  ipbind list
  ipbind update home
  ipbind update remote --ip 8.8.8.8
  ipbind update --all
  ipbind status home

Storage:
  ~/.config/ipbind/hosts/<hostname>.ipbind.sh

Each hostname has its own authentication token.

Environment:
  IPBIND_API   Override API endpoint
EOF
}

need_curl
init_dirs
migrate_legacy

command=${1:-help}

if [ "$#" -gt 0 ]; then
    shift
fi

case "$command" in
    claim)
        cmd_claim "$@"
        ;;
    list)
        cmd_list "$@"
        ;;
    update)
        cmd_update "$@"
        ;;
    status)
        cmd_status "$@"
        ;;
    rotate)
        cmd_rotate "$@"
        ;;
    delete)
        cmd_delete "$@"
        ;;
    config)
        cmd_config "$@"
        ;;
    mode)
        cmd_mode "$@"
        ;;
    enable)
        cmd_enable "$@"
        ;;
    disable)
        cmd_disable "$@"
        ;;
    timer-status)
        cmd_timer_status "$@"
        ;;
    version|--version|-V)
        printf 'ipbind %s\n' "$VERSION"
        ;;
    help|-h|--help)
        usage
        ;;
    *)
        die "unknown command: $command (try: ipbind help)"
        ;;
esac
