#!/bin/sh
# shellcheck shell=sh
# ipinfo — ipinfo.app CLI (POSIX sh)
#
# Portable (Linux / macOS / BSD / Alpine / BusyBox sh) shell client for the
# ipinfo.app service suite: my.ipinfo.app, asn.ipinfo.app, atlas.ipinfo.app,
# and blackbox.ipinfo.app. Distributed as a single static file from
# https://www.ipinfo.app/cli/ipinfo; installs into /usr/local/bin/ipinfo.
#
# Design notes:
#
#   • Pure POSIX sh — NOT bash. No `[[`, no arrays, no `<<<`, no `local`.
#     Tested against dash, BusyBox sh, and bash in POSIX mode. Adding a
#     bash-ism breaks Alpine and Debian /bin/dash users; reviewers should
#     reject it in PR unless there's a platform test case to back it up.
#
#   • `jq` is a hard dependency. Our upstream response shapes differ from
#     ipinfo.io's; the CLI translates them so migrated scripts keep working.
#     Doing that translation in pure sh (sed/awk/grep on JSON) is fragile;
#     `jq` is on every mainstream package manager. install.sh probes for it
#     and prints an install hint if missing — do not bypass that guard.
#
#   • `curl` is the HTTP client. `wget` is detected by the installer only
#     (for the install/self-update bootstrap); the CLI itself uses curl
#     exclusively because its flag set for timeouts/retries is more uniform
#     across versions than wget's.
#
#   • No state on disk by default. The optional RapidAPI key file at
#     $XDG_CONFIG_HOME/ipinfo-app/rapidapi-key is only read if it exists,
#     never created by the CLI itself — users must place it manually.
#
# Exit codes:
#   0  success
#   1  generic error (user-facing message printed to stderr)
#   2  usage error (bad flag, unknown subcommand, missing required arg)
#   3  integrity error (SHA256 mismatch on self-update)
#   7  network error (curl failed; matches curl's "failed to connect")

set -eu

# ── Version + build metadata ──────────────────────────────────────────────────
# IPINFO_APP_CLI_VERSION is the single source of truth for this script's
# version number. self-update compares it against version.json on the server
# to decide whether to prompt for an upgrade. When releasing a new version:
#   1. bump this constant
#   2. regenerate SHA256 via `sha256sum ipinfo` and update version.json
#   3. commit both in the same PR
IPINFO_APP_CLI_VERSION="1.2.1"
IPINFO_APP_CLI_USER_AGENT="ipinfo.app-cli/${IPINFO_APP_CLI_VERSION} (+https://www.ipinfo.app/cli/)"

# ── Upstream endpoints ────────────────────────────────────────────────────────
# All endpoints are overridable via env for local development / staging:
#   IPINFO_APP_MY_URL       — my.ipinfo.app base (default: https://my.ipinfo.app)
#   IPINFO_APP_ASN_URL      — asn.ipinfo.app base
#   IPINFO_APP_ATLAS_URL    — atlas.ipinfo.app base
#   IPINFO_APP_BLACKBOX_URL — blackbox.ipinfo.app base
#   IPINFO_APP_CLI_DIST_URL — distribution root for self-update
IPINFO_APP_MY_URL="${IPINFO_APP_MY_URL:-https://my.ipinfo.app}"
IPINFO_APP_ASN_URL="${IPINFO_APP_ASN_URL:-https://asn.ipinfo.app}"
IPINFO_APP_ATLAS_URL="${IPINFO_APP_ATLAS_URL:-https://atlas.ipinfo.app}"
IPINFO_APP_BLACKBOX_URL="${IPINFO_APP_BLACKBOX_URL:-https://blackbox.ipinfo.app}"
IPINFO_APP_CLI_DIST_URL="${IPINFO_APP_CLI_DIST_URL:-https://www.ipinfo.app/cli}"

# ── Colour handling ───────────────────────────────────────────────────────────
# Colour is on only when stdout is a TTY AND the user hasn't asked to opt out.
# Respects the NO_COLOR spec (https://no-color.org/) in addition to --nocolor.
# _c() writes a colour escape; _rc() writes a reset. When colour is disabled
# both expand to empty strings so the same call sites stay readable.
_colour_enabled=1
if [ ! -t 1 ] || [ -n "${NO_COLOR:-}" ]; then
    _colour_enabled=0
fi

_c() {
    # $1 = name (red, green, cyan, amber, dim, bold)
    [ "$_colour_enabled" = 0 ] && return 0
    case "$1" in
        red)    printf '\033[31m' ;;
        green)  printf '\033[32m' ;;
        cyan)   printf '\033[36m' ;;
        amber)  printf '\033[33m' ;;
        dim)    printf '\033[2m' ;;
        bold)   printf '\033[1m' ;;
    esac
}

_rc() {
    [ "$_colour_enabled" = 0 ] && return 0
    printf '\033[0m'
}

# ── Diagnostic helpers ────────────────────────────────────────────────────────
# _info and _warn go to stderr so they don't pollute output when the caller
# pipes JSON into jq. _die does the same plus exits with the supplied code
# (or 1 if none given).
_info()  { printf '%s\n' "$*" >&2; }
_warn()  { printf '%s%s%s %s\n' "$(_c amber)" "warning:" "$(_rc)" "$*" >&2; }
_error() { printf '%s%s%s %s\n' "$(_c red)"   "error:"   "$(_rc)" "$*" >&2; }
_die()   { code=${2:-1}; _error "$1"; exit "$code"; }

# ── Dependency probes ─────────────────────────────────────────────────────────
# Every command that touches the network or JSON needs both curl and jq.
# We check once, lazily, and cache the result in a shell variable so we
# don't shell-out repeatedly in hot paths like bulk lookups.
_have_cmd() { command -v "$1" >/dev/null 2>&1; }

_require_curl() {
    if ! _have_cmd curl; then
        _die "curl is required but not installed. install it from your package manager (apt/brew/apk/dnf)." 1
    fi
}

_require_jq() {
    if ! _have_cmd jq; then
        cat >&2 <<EOF
$(_c red)error:$(_rc) jq is required but not installed.

install it with your package manager:
  $(_c cyan)brew install jq$(_rc)            (macOS)
  $(_c cyan)apt-get install jq$(_rc)         (Debian / Ubuntu)
  $(_c cyan)apk add jq$(_rc)                  (Alpine)
  $(_c cyan)dnf install jq$(_rc)             (Fedora / RHEL)
  $(_c cyan)pacman -S jq$(_rc)                (Arch)
EOF
        exit 1
    fi
}

# ── HTTP wrapper ──────────────────────────────────────────────────────────────
# _curl wraps curl with sane defaults and a consistent User-Agent so our
# traffic is identifiable in upstream logs. -sS silences progress but keeps
# errors; -L follows redirects; --max-time puts a hard ceiling on hanging
# connections. We pass through any extra args the caller provides (e.g.
# -H for headers) so RapidAPI auth can be added without modifying this
# wrapper.
_curl() {
    _require_curl
    curl -sSL --max-time 30 \
        --user-agent "$IPINFO_APP_CLI_USER_AGENT" \
        "$@"
}

# Variant that surfaces HTTP status via the exit code so JSON pipelines can
# fail loudly on 4xx/5xx instead of piping an error string into jq and
# getting a "parse error" one directory down.
_curl_or_die() {
    url=$1
    shift
    out=$(_curl -w '\n__HTTP_STATUS__:%{http_code}' "$url" "$@") \
        || _die "network error contacting $url (curl exit $?)" 7
    status=$(printf '%s\n' "$out" | sed -n 's/^__HTTP_STATUS__://p')
    body=$(printf '%s\n' "$out" | sed '$d')
    case "$status" in
        2*) printf '%s\n' "$body" ;;
        *)  _die "upstream $url returned HTTP $status" 1 ;;
    esac
}

# ── Output formatters ─────────────────────────────────────────────────────────
# CLI output mode is one of:
#   json (default)  — pretty-printed JSON to stdout
#   csv             — comma-separated, one row per object (headers inferred)
#   field           — extract a single field by jq path, one value per line
#
# Mode is chosen by the caller via -j/-c/-f flags. _format_output consumes
# JSON on stdin and writes formatted output on stdout.
_output_mode="json"
_output_field=""

_format_output() {
    # $1 = JSON string (one object or an array of objects)
    _require_jq
    case "$_output_mode" in
        json)
            # Pretty by default; jq's . filter handles both objects and arrays
            printf '%s\n' "$1" | jq .
            ;;
        field)
            # -r strips quotes from strings so `ipinfo 8.8.8.8 -f country`
            # prints `US` not `"US"`. Null fields print the literal "null"
            # rather than being filtered — script authors may want to
            # detect the absence explicitly.
            printf '%s\n' "$1" | jq -r --arg f "$_output_field" \
                'if type=="array" then .[] | (getpath($f | split(".")) // null) else getpath($f | split(".")) // null end'
            ;;
        csv)
            # For CSV we need consistent columns. We take the keys of the
            # first object as the header row and emit each object's values
            # in that order. Nested objects are flattened to top-level keys
            # only — scripts needing deep CSV should use --field.
            printf '%s\n' "$1" | jq -r '
                if type == "array" then
                    (.[0] | keys_unsorted) as $hdr
                    | ($hdr | @csv),
                      (.[] | [$hdr[] as $k | .[$k] // ""] | @csv)
                else
                    (keys_unsorted | @csv),
                    ([.[]] | @csv)
                end'
            ;;
    esac
}

# ── RapidAPI key discovery ────────────────────────────────────────────────────
# Precedence: env var > config file (0600) > none. When absent and the caller
# asked for --api v2 or v3, we error out with a pointer to docs rather than
# falling back silently to v1 — silent downgrades surprise users.
_find_rapidapi_key() {
    if [ -n "${IPINFO_APP_RAPIDAPI_KEY:-}" ]; then
        printf '%s' "$IPINFO_APP_RAPIDAPI_KEY"
        return 0
    fi
    cfg_dir="${XDG_CONFIG_HOME:-$HOME/.config}/ipinfo-app"
    key_file="$cfg_dir/rapidapi-key"
    if [ -r "$key_file" ]; then
        # Warn on permissive mode — a key file readable by group/other is a
        # common footgun on shared boxes. We still return the key (don't
        # break a working setup) but nudge the user to tighten it.
        mode=$(stat -c '%a' "$key_file" 2>/dev/null || stat -f '%A' "$key_file" 2>/dev/null)
        case "$mode" in
            600|400|""|*) [ -n "$mode" ] && [ "$mode" != "600" ] && [ "$mode" != "400" ] \
                            && _warn "key file $key_file has mode $mode; recommend 0600" ;;
        esac
        # Trim trailing newline — users often save keys with `echo foo > file`.
        tr -d '\n\r' < "$key_file"
        return 0
    fi
    return 1
}

# ── Subcommand: myip ─────────────────────────────────────────────────────────
# Fetches the caller's own IP details for BOTH IPv4 and IPv6 in parallel,
# then merges into a single response that's drop-in compatible with
# ipinfo.io's single-IP shape at the top level plus `ipv4` and `ipv6`
# sub-objects for explicit per-family drill-in.
#
# How it works:
#   • my.ipinfo.app publishes three hostnames:
#       ipv4.my.ipinfo.app  — A-only  (forces IPv4 for the request)
#       ipv6.my.ipinfo.app  — AAAA-only (forces IPv6)
#       my.ipinfo.app       — dual-stack (whatever the caller connected with)
#   • We fire /api/ip + /api/rdns against each of the v4-only and v6-only
#     hostnames in parallel using background subshells and a tempdir for
#     state (POSIX sh has no simple shared associative state across `&`
#     processes — tempfiles are the portable answer). Short --max-time
#     keeps the call from hanging when one address family isn't routable.
#   • Top-level fields mirror ipinfo.io's shape and are populated from
#     the IPv4 response. If IPv4 failed (v6-only network), we promote
#     IPv6 into the top-level slot so legacy scripts still see a value.
#   • Sub-objects `ipv4` and `ipv6` are always present; either or both
#     may be null when that family wasn't reachable. That's the explicit
#     signal for "not available" — a flag scripts can check without
#     having to guess.
#
# Output shape:
#   {
#     ip, hostname, city, region, country, loc, org, postal, timezone, asn,  ← legacy mirror
#     ipv4: { ip, hostname, country, org, asn:{…} } | null,
#     ipv6: { ip, hostname, country, org, asn:{…} } | null
#   }
cmd_myip() {
    tmp_dir=$(mktemp -d -t ipinfo-myip.XXXXXX) || _die "failed to create tempdir" 1
    trap 'rm -rf "$tmp_dir"' EXIT INT TERM
    _require_jq

    # Parallel fan-out. Each call gets a 5-second ceiling — on a v4-only
    # network the AAAA connect() would otherwise hang for ~30s, and vice
    # versa. On failure we drop an empty object so jq -s gets a stable
    # array of 4 elements regardless of which family resolved.
    (_curl --max-time 5 "https://ipv4.my.ipinfo.app/api/ip"   > "$tmp_dir/v4_ip"   2>/dev/null || printf '{}' > "$tmp_dir/v4_ip")   &
    (_curl --max-time 5 "https://ipv4.my.ipinfo.app/api/rdns" > "$tmp_dir/v4_rdns" 2>/dev/null || printf '{}' > "$tmp_dir/v4_rdns") &
    (_curl --max-time 5 "https://ipv6.my.ipinfo.app/api/ip"   > "$tmp_dir/v6_ip"   2>/dev/null || printf '{}' > "$tmp_dir/v6_ip")   &
    (_curl --max-time 5 "https://ipv6.my.ipinfo.app/api/rdns" > "$tmp_dir/v6_rdns" 2>/dev/null || printf '{}' > "$tmp_dir/v6_rdns") &
    wait

    # jq does the heavy lifting: define a helper that builds one
    # ipinfo.io-shape block from an (ip, rdns) pair, then apply it to
    # both families and choose which one becomes the top-level mirror.
    merged=$(jq -n \
        --slurpfile v4ip   "$tmp_dir/v4_ip" \
        --slurpfile v4rdns "$tmp_dir/v4_rdns" \
        --slurpfile v6ip   "$tmp_dir/v6_ip" \
        --slurpfile v6rdns "$tmp_dir/v6_rdns" '
        def build(ip; rdns):
            if (ip.ip // null) == null then null
            else {
                ip:       ip.ip,
                hostname: (rdns.hostname // rdns.rdns // null),
                city:     null,
                region:   null,
                country:  (ip.countryCode // (if (ip.country // "") == "Not Available" then null else ip.country end)),
                loc:      null,
                org:      (if (ip.asn // "") == "Not Available" then null else ip.asn end),
                postal:   null,
                timezone: null,
                asn: {
                    asn:    (if ip.asnNumber then "AS\(ip.asnNumber)" else null end),
                    name:   (if (ip.asn // "") == "Not Available" then null else (ip.asn | sub("^AS[0-9]+ - "; "")) end),
                    domain: null,
                    route:  (ip.cidr // null),
                    type:   null
                }
            } end;
        (build($v4ip[0]; $v4rdns[0]))   as $v4 |
        (build($v6ip[0]; $v6rdns[0]))   as $v6 |
        # Prefer IPv4 as the legacy top-level mirror — matches ipinfo.io
        # conventions. On v6-only networks where v4 is null, promote v6
        # so the top-level fields still carry real data rather than nulls.
        ($v4 // $v6 // {ip: null, hostname: null, city: null, region: null, country: null, loc: null, org: null, postal: null, timezone: null, asn: {asn: null, name: null, domain: null, route: null, type: null}})
        | . + { ipv4: $v4, ipv6: $v6 }
        ')
    _format_output "$merged"
}

# ── Subcommand: ip (arbitrary) ────────────────────────────────────────────────
# Looks up a specific IP address. Hits atlas.ipinfo.app directly; it's the
# public arbitrary-IP endpoint (asn.ipinfo.app/api/json/ip/ is same-origin
# guarded and rejects cross-origin clients like this CLI).
#
# Note: we do NOT attempt to resolve rDNS for arbitrary IPs. Our /api/rdns
# endpoint is caller-scoped only, and running `host`/`dig` locally adds an
# unportable dep. The `hostname` field is always null here; documented.
cmd_ip() {
    ip=$1
    atlas_json=$(_curl_or_die "$IPINFO_APP_ATLAS_URL/api/v2/ip/$ip")
    # atlas returns both `country_code` (ISO alpha-2, e.g. "US") and
    # `country` (full name, e.g. "United States of America"). ipinfo.io's
    # CLI convention — which migrated scripts expect — is ISO alpha-2, so
    # we map atlas.country_code into our output's `country` field. The
    # full name is dropped rather than being exposed as an extra field,
    # because adding non-ipinfo.io fields would break `jq -f` in scripts
    # that iterate keys.
    merged=$(printf '%s\n' "$atlas_json" | jq --arg ip "$ip" '
        {
            ip:       $ip,
            hostname: null,
            city:     null,
            region:   null,
            country:  (.country_code // .country // null),
            loc:      null,
            org:      (if .as_number and .as_description then "AS\(.as_number) \(.as_description)" else null end),
            postal:   null,
            timezone: null,
            asn: {
                asn:    (if .as_number then "AS\(.as_number)" else null end),
                name:   (.as_description // null),
                domain: null,
                route:  (.cidr // null),
                type:   null
            }
        }')
    _format_output "$merged"
}

# ── Subcommand: asn ───────────────────────────────────────────────────────────
# ASN details. Accepts either "AS13335" or "13335"; the asn-node service is
# flexible on either form via its /api/json/details/:as route. We pass the
# user's input through after a minimal check that it's digits (possibly
# prefixed with AS).
cmd_asn() {
    asn=$1
    case "$asn" in
        [Aa][Ss][0-9]*) asn_num=${asn#[Aa][Ss]} ;;
        [0-9]*)         asn_num=$asn ;;
        *)              _die "invalid ASN: $asn (expected NNNN or ASNNNN)" 2 ;;
    esac
    # Use /detailsRaw/ — it returns the full CIDR list (`list`) that
    # ipinfo.io's `asn.prefixes` field corresponds to. The shorter
    # /details/ endpoint only returns prefix-count stats, not the actual
    # prefixes. asn-node doesn't expose country-code on either endpoint
    # (it lives in the registry endpoint, which is same-origin-guarded),
    # so `country` stays null for now — documented in the migration
    # matrix.
    details=$(_curl_or_die "$IPINFO_APP_ASN_URL/api/json/detailsRaw/$asn_num")
    mapped=$(printf '%s\n' "$details" | jq '
        {
            asn:      ("AS" + (.asn // "" | tostring)),
            name:     (.name // .as_description // null),
            country:  null,
            prefixes: (.list // [])
        }')
    _format_output "$mapped"
}

# ── Subcommand: classify ──────────────────────────────────────────────────────
# IP reputation / classification. Three tiers:
#   v1 (default) — free, returns single char Y/N/E from blackbox.ipinfo.app
#   v2           — per-signal flags, requires RapidAPI key
#   v3           — full classification, RapidAPI (currently open during beta)
#
# We always return JSON even for v1 so pipelines can consume it uniformly;
# the single-char response is wrapped as {"ip": "...", "reputation": "Y"|"N"|"E"}.
_classify_api="v1"
cmd_classify() {
    ip=$1
    case "$_classify_api" in
        v1)
            body=$(_curl_or_die "$IPINFO_APP_BLACKBOX_URL/api/v1/$ip")
            # Trim whitespace; single-char responses sometimes arrive with a
            # trailing newline depending on upstream framing.
            body=$(printf '%s' "$body" | tr -d ' \n\r\t')
            json=$(printf '%s' "$body" | jq -R --arg ip "$ip" '{ip: $ip, reputation: ., tier: "v1"}')
            _format_output "$json"
            # Gentle upsell to stderr when we find something interesting, TTY
            # only so JSON pipelines aren't polluted. Suppressed by -q/--quiet.
            if [ "$body" = "Y" ] && [ -t 1 ] && [ "${_quiet:-0}" = "0" ]; then
                _info ""
                _info "  ${_c_cyan:-}→${_rc_:-} for per-signal breakdown, try: $(_c cyan)ipinfo classify $ip --api v2$(_rc)"
                _info "    (requires RapidAPI key — https://rapidapi.com/ipinfo-app/api/blackbox)"
            fi
            ;;
        v2)
            key=$(_find_rapidapi_key) || _die "--api v2 requires a RapidAPI key. Set IPINFO_APP_RAPIDAPI_KEY or see https://www.ipinfo.app/cli/#rapidapi" 2
            json=$(_curl_or_die "$IPINFO_APP_BLACKBOX_URL/api/v2/$ip" -H "x-rapidapi-key: $key")
            _format_output "$json"
            ;;
        v3)
            key=$(_find_rapidapi_key) || _die "--api v3 requires a RapidAPI key. Set IPINFO_APP_RAPIDAPI_KEY or see https://www.ipinfo.app/cli/#rapidapi" 2
            # v3beta auth is currently commented-out upstream but we send the
            # header anyway so the CLI works unchanged once auth is re-enabled.
            json=$(_curl_or_die "$IPINFO_APP_BLACKBOX_URL/api/v3beta/$ip" -H "x-rapidapi-key: $key")
            _format_output "$json"
            ;;
        *)
            _die "unknown --api value: $_classify_api (expected v1, v2, or v3)" 2
            ;;
    esac
}

# ── Subcommand: bulk ──────────────────────────────────────────────────────────
# Takes IPs from argv or stdin, emits one JSON object per IP (NDJSON by
# default, or a JSON array with -j). Concurrency is controlled via --workers
# (default 4); we use xargs -P for POSIX-portable parallelism.
_bulk_workers=4
cmd_bulk() {
    # Collect IPs from argv (if any) and stdin (if piped). Dedupe not done —
    # users may have legitimate reasons to look up the same IP twice in a
    # stream (e.g., before/after timestamps) and surprising dedupe is worse
    # than a couple of duplicate results.
    tmp=$(mktemp -t ipinfo-bulk.XXXXXX) || _die "failed to create tempfile" 1
    trap 'rm -f "$tmp"' EXIT INT TERM

    if [ $# -gt 0 ]; then
        for ip in "$@"; do printf '%s\n' "$ip" >> "$tmp"; done
    fi
    if [ ! -t 0 ]; then
        # stdin is piped — append. grep strips blank lines and obvious
        # comments (anything starting with #) so users can paste host files
        # directly.
        grep -v '^[[:space:]]*\(#\|$\)' >> "$tmp" || true
    fi

    if [ ! -s "$tmp" ]; then
        _die "bulk: no IPs provided on argv or stdin" 2
    fi

    # xargs -P runs lookups in parallel; -n 1 ensures one IP per invocation
    # so our inner loop is simple. Each worker invokes `$0 _bulk_one <ip>`
    # (an internal-only subcommand) and prints a JSON line to stdout.
    # Output ordering is not guaranteed — add --ordered later if needed.
    # Mode handling: bulk returns an NDJSON stream by default. With -j we
    # wrap it in a JSON array at the end; with -c we emit CSV with a header
    # from the first row.
    case "$_output_mode" in
        json)
            # Default: NDJSON (one line per IP). Scripts `jq -s` to collect
            # if they want an array. This streams, so it's memory-safe for
            # million-IP inputs.
            # shellcheck disable=SC2016
            xargs -P "$_bulk_workers" -n 1 -I {} sh -c 'sh "$0" _bulk_one "{}"' "$0" < "$tmp"
            ;;
        field)
            # Field extraction across bulk: stream JSON through jq once,
            # one value per input IP.
            # shellcheck disable=SC2016
            xargs -P "$_bulk_workers" -n 1 -I {} sh -c 'sh "$0" _bulk_one "{}"' "$0" < "$tmp" \
                | jq -r --arg f "$_output_field" 'getpath($f | split(".")) // null'
            ;;
        csv)
            # Collect all results, then emit as CSV with the nested `asn`
            # object flattened to top-level columns so every cell is a
            # primitive. jq's @csv errors if a cell is an object/array,
            # which would otherwise trip anyone using the default bulk
            # output (asn is always a nested object). Users wanting the
            # nested structure should use `-j` (JSON) instead of `-c`.
            # shellcheck disable=SC2016
            results=$(xargs -P "$_bulk_workers" -n 1 -I {} sh -c 'sh "$0" _bulk_one "{}"' "$0" < "$tmp")
            printf '%s\n' "$results" | jq -s -r '
                map({
                    ip, hostname, city, region, country, loc, org,
                    asn_number:   (.asn.asn // null),
                    asn_name:     (.asn.name // null),
                    asn_route:    (.asn.route // null)
                })
                | (.[0] | keys_unsorted) as $hdr
                | ($hdr | @csv),
                  (.[] | [$hdr[] as $k | .[$k] // ""] | @csv)'
            ;;
    esac
}

# Internal one-shot bulk worker — invoked by xargs from cmd_bulk. Kept as a
# separate subcommand (rather than an inline sh -c blob) so error messages
# and output formatting go through the same _format_output path as the
# interactive single-IP case.
_cmd_bulk_one() {
    ip=$1
    # Use a lighter-weight path than full cmd_ip: we don't need to shell out
    # for rDNS because bulk is already slow enough without PTR probes, and
    # bulk callers care more about throughput than field completeness.
    atlas_json=$(_curl "$IPINFO_APP_ATLAS_URL/api/v2/ip/$ip" 2>/dev/null) || {
        # On per-IP error, emit a minimal object with error set, so pipelines
        # don't break mid-stream. Exit code stays 0 — bulk's contract is
        # "one object per IP, errors inline", not "fail fast".
        printf '{"ip":"%s","error":"lookup failed"}\n' "$ip"
        return 0
    }
    # country_code preferred over country for ipinfo.io compat — see cmd_ip
    # for the full rationale.
    printf '%s' "$atlas_json" | jq -c --arg ip "$ip" '
        {
            ip:       $ip,
            hostname: null,
            city:     null,
            region:   null,
            country:  (.country_code // .country // null),
            loc:      null,
            org:      (if .as_number and .as_description then "AS\(.as_number) \(.as_description)" else null end),
            asn: {
                asn:    (if .as_number then "AS\(.as_number)" else null end),
                name:   (.as_description // null),
                route:  (.cidr // null)
            }
        }'
}

# ── Subcommand: summarize ─────────────────────────────────────────────────────
# Reads IPs from stdin, runs bulk, then aggregates: counts per country,
# counts per ASN, counts per continent. Output is a single JSON object with
# three arrays (countries, asns, continents), each sorted by count desc.
# Matches the spirit of ipinfo.io's `ipinfo summarize` without being
# bug-for-bug compatible — we don't have all the fields they aggregate on.
cmd_summarize() {
    # Use a distinct variable name (not `tmp`) because cmd_bulk also uses
    # `tmp` internally — POSIX sh has no function-local scope, so a bare
    # `tmp=…` in cmd_bulk would clobber ours and we'd end up jq-ing the
    # wrong file. Learned the hard way during the v1.0.0 bring-up.
    sum_tmp=$(mktemp -t ipinfo-sum.XXXXXX) || _die "failed to create tempfile" 1
    trap 'rm -f "$sum_tmp"' EXIT INT TERM

    # Run bulk in JSON mode regardless of the user's -c/-f — summarize needs
    # structured data to aggregate, and we format the final summary to their
    # chosen mode at the end.
    saved_mode=$_output_mode
    _output_mode=json
    cmd_bulk > "$sum_tmp"
    _output_mode=$saved_mode

    summary=$(jq -s '
        {
            total: length,
            countries:  ([.[] | .country // "unknown"] | group_by(.) | map({country:  .[0], count: length}) | sort_by(-.count)),
            asns:       ([.[] | (.asn.asn // "unknown") + " " + (.asn.name // "")] | group_by(.) | map({asn: .[0], count: length}) | sort_by(-.count))
        }' "$sum_tmp")
    _format_output "$summary"
}

# ── Subcommand: speedtest ─────────────────────────────────────────────────────
# Hits my.ipinfo.app/api/speedtest/* endpoints. Latency is computed server-side
# (TCP RTT to three well-known resolvers, min/avg/max per host); download is
# timed locally via curl's --write-out.
#
# Single-stream throughput undersamples fast links — a single TCP connection
# is bounded by congestion-window ramp-up (slow start), per-flow CDN limits,
# and single-CPU encryption throughput on the client. Real-world residential
# Gbps links commonly cap a single stream at 100–300 Mbps even when the line
# is happily delivering 1+ Gbps in aggregate.
#
# Two-phase design:
#   1. Calibration — single 25 MB stream to estimate Mbps. Cheap and gives us
#      enough signal to pick a reasonable parallelism for phase 2. Defaulting
#      to a 25 MB calibration (rather than 5 MB) keeps the estimate stable
#      across slow-start ramp.
#   2. Parallel test — N concurrent streams, each S MB. N and S are picked
#      from the calibration. Aggregate speed = sum(bytes) / max(seconds), so
#      a single straggler stream can't artificially deflate the result.
#
# Tier table (calibration Mbps → streams × per-stream MB):
#   <25      : 1 × 25  (skip phase 2 — calibration is the answer)
#   25–50    : 2 × 25
#   50–100   : 4 × 25
#   100–500  : 6 × 25
#   500–1000 : 8 × 50
#   1000–2500: 12 × 100
#   ≥2500    : 16 × 100
#
# Server cap: my.ipinfo.app/api/speedtest/download enforces MAX_MB=100, hence
# the 100 MB ceiling. To go faster we add streams, not size.
#
# Flags:
#   --size <MB>     override per-stream size (also used for calibration)
#   --streams <N>   force parallelism, skip the tier picker
#   --no-calibrate  skip phase 1, run phase 2 immediately with --streams
#                   (default 4) × --size (default 25)
_speedtest_size=25
_speedtest_streams=""
_speedtest_no_calibrate=0
_speedtest_latency_only=0

# _speedtest_run_one runs a single curl download, prints the timing JSON
# to stdout. Used by the calibration phase. 180s timeout accommodates
# slow links (25 MB at ~1 Mbps).
_speedtest_run_one() {
    sz=$1
    curl -sS --max-time 180 \
        --user-agent "$IPINFO_APP_CLI_USER_AGENT" \
        -o /dev/null -w '{"bytes":%{size_download},"seconds":%{time_total}}\n' \
        "$IPINFO_APP_MY_URL/api/speedtest/download?size=$sz" 2>/dev/null \
        || printf '{"bytes":0,"seconds":0}\n'
}

# _speedtest_pick_streams maps a calibrated Mbps reading to a stream count.
# awk is used (not jq) because we need pure arithmetic on a float string
# without dragging in a JSON parse. Tiers favour smaller jumps below 100
# Mbps so common residential links land in the right bucket.
_speedtest_pick_streams() {
    awk -v m="$1" 'BEGIN {
        if (m < 25)        print 1
        else if (m < 50)   print 2
        else if (m < 100)  print 4
        else if (m < 500)  print 6
        else if (m < 1000) print 8
        else if (m < 2500) print 12
        else               print 16
    }'
}

# _speedtest_pick_size scales per-stream size with calibrated speed —
# bigger payloads make slow start a smaller share of the measurement
# on fast links, but waste time on slow ones. Capped at 100 MB by the
# server (MAX_MB).
_speedtest_pick_size() {
    awk -v m="$1" 'BEGIN {
        if (m < 500)        print 25
        else if (m < 1000)  print 50
        else                print 100
    }'
}

# _speedtest_run_parallel launches $1 background curls, each downloading
# $2 MB, and aggregates bytes + max(seconds). Per-stream timing comes from
# curl --write-out so we don't need a high-resolution clock on the shell
# side (some BSDs lack `date +%s%N`). Aggregate seconds = max so that one
# slow stream caps the rate rather than averaging it down.
_speedtest_run_parallel() {
    streams=$1
    size=$2
    p_tmp=$(mktemp -d -t ipinfo-stp.XXXXXX) || return 1

    pids=""
    i=1
    while [ "$i" -le "$streams" ]; do
        ( _speedtest_run_one "$size" > "$p_tmp/r$i" ) &
        pids="$pids $!"
        i=$((i + 1))
    done
    for pid in $pids; do
        wait "$pid" 2>/dev/null
    done

    # jq -s slurps each result file into an array, then we aggregate.
    # max(.seconds) reflects the wall clock of the slowest stream — i.e.,
    # the wall clock of the test as a whole, since all started together.
    jq -s --argjson sz "$size" --argjson n "$streams" '
        ((map(.bytes) | add) // 0) as $bytes |
        ((map(.seconds) | max) // 0) as $seconds |
        {
            bytes:               $bytes,
            seconds:             $seconds,
            megabits_per_second: (if $seconds > 0 then (($bytes * 8 / $seconds / 1000000) * 100 | round / 100) else 0 end),
            streams:             $n,
            per_stream_mb:       $sz
        }' "$p_tmp"/r*

    rm -rf "$p_tmp"
}

# _speedtest_progress overwrites the previous stderr progress line with
# a new one. Same TTY/quiet guards as the legacy warm-up hint.
_speedtest_progress() {
    [ -t 2 ] || return 0
    [ "${_quiet:-0}" = "0" ] || return 0
    printf '\r\033[K%s%s%s ' "$(_c dim)" "→ $1" "$(_rc)" >&2
}

cmd_speedtest() {
    # Tempdir holds latency + download JSON streams so the parallel probes
    # write their results independently. Tempfiles are the portable answer
    # for passing structured values back from background subshells in POSIX
    # sh — same pattern as cmd_myip.
    st_tmp=$(mktemp -d -t ipinfo-st.XXXXXX) || _die "failed to create tempdir" 1
    trap 'rm -rf "$st_tmp"' EXIT INT TERM
    _require_curl
    _require_jq

    _speedtest_progress "warming up edge…"

    # Latency runs in parallel with the entire download phase. Server-side
    # TCP probe to three resolvers is independent of the download pipe.
    (_curl_or_die "$IPINFO_APP_MY_URL/api/speedtest/latency" > "$st_tmp/lat" 2>/dev/null \
        || printf '{}' > "$st_tmp/lat") &
    lat_pid=$!

    cal_data='null'
    dl_data='null'

    if [ "$_speedtest_latency_only" = "0" ]; then
        # ── Phase 1: calibration ─────────────────────────────────────────
        # Skipped when --no-calibrate is set. When --streams is given we
        # honour it regardless; otherwise calibration picks N from the tier
        # table.
        if [ "$_speedtest_no_calibrate" = "0" ]; then
            _speedtest_progress "calibrating (${_speedtest_size} MB)…"
            cal_raw=$(_speedtest_run_one "$_speedtest_size")
            cal_mbps=$(printf '%s' "$cal_raw" | jq -r '
                if .seconds and .seconds > 0
                then (.bytes * 8 / .seconds / 1000000)
                else 0 end')
            # Round mbps for human display; keep raw for tier picker.
            cal_data=$(printf '%s' "$cal_raw" | jq '
                . + (if .seconds and .seconds > 0 then {
                    megabits_per_second: (((.bytes * 8 / .seconds / 1000000) * 100 | round) / 100)
                } else {megabits_per_second: 0} end)')

            if [ -n "$_speedtest_streams" ]; then
                streams=$_speedtest_streams
                size=$_speedtest_size
            else
                streams=$(_speedtest_pick_streams "$cal_mbps")
                size=$(_speedtest_pick_size "$cal_mbps")
            fi
        else
            # No calibration: trust user flags, default to 4 streams.
            streams=${_speedtest_streams:-4}
            size=$_speedtest_size
            cal_data='null'
        fi

        # ── Phase 2: parallel test ───────────────────────────────────────
        # Skip when calibration tier == 1 stream AND user didn't override —
        # at that point a second pass would just duplicate the first. Reuse
        # the calibration sample as the final result and mark streams=1.
        if [ "$streams" = "1" ] && [ "$_speedtest_no_calibrate" = "0" ] && [ -z "$_speedtest_streams" ]; then
            dl_data=$(printf '%s' "$cal_data" | jq --argjson sz "$size" '
                . + {
                    megabits_per_second: (if .seconds and .seconds > 0
                        then (((.bytes * 8 / .seconds / 1000000) * 100 | round) / 100)
                        else 0 end),
                    streams: 1,
                    per_stream_mb: $sz
                }')
        else
            _speedtest_progress "running ${streams}× ${size} MB…"
            dl_data=$(_speedtest_run_parallel "$streams" "$size")
        fi
    fi

    wait "$lat_pid" 2>/dev/null

    # Clear the progress line before printing JSON.
    if [ -t 2 ] && [ "${_quiet:-0}" = "0" ]; then
        printf '\r\033[K' >&2
    fi

    # ── Merge + emit ─────────────────────────────────────────────────────
    # Using --slurpfile to bind the latency tempfile avoids the brace-
    # expansion landmine in POSIX-sh parameter expansion that bit us in
    # 1.2.0. The download data is already a JSON object (no slurp needed)
    # so we pass it via --argjson.
    merged=$(jq -n \
        --slurpfile lat "$st_tmp/lat" \
        --argjson   dl  "$dl_data" \
        --argjson   cal "$cal_data" '
        ($lat[0] // {}) as $l |
        {
            download: (if $dl == null then null else $dl + {calibration: $cal} end),
            latency:  $l
        }')
    _format_output "$merged"
}

# ── Subcommand: traceroute ────────────────────────────────────────────────────
# Shells out to the local traceroute binary with -n (no DNS — the hop
# endpoint does PTR lookups for us, better to rely on its cached results
# than have traceroute block per-hop on the system resolver). For each
# hop line, extracts the IP and the first RTT sample, then queries
# atlas.ipinfo.app/api/v2/hop/:ip for per-hop annotation (PTR + ASN + PoP
# carrier-PoP match). Emits NDJSON by default so progress is visible as
# hops come in; with -j collects into a JSON array at the end.
#
# Design notes:
#   • We pass -n to traceroute so it doesn't do its own PTR lookups. The
#     hop endpoint does PTR as part of its response and it's edge-cached
#     for 24h — much faster than the local resolver on most networks.
#   • Timed-out hops (marked `* * *` in traceroute output) are emitted
#     with ip=null and rtt_ms=null so callers see the gap in the chain
#     without a special sentinel type.
#   • Parsing is deliberately loose — both GNU traceroute (Linux), the
#     BSD traceroute (macOS), and busybox traceroute all share the
#     "HOP  IP  TIME ms" core format. The regex pulls the first IPv4/v6
#     address and the first time in ms and works across all three.
#   • If /hop/ returns 404 on the configured atlas URL, we print a one-
#     time hint pointing at atlas-staging.ipinfo.app and fall through to
#     raw-traceroute output so the command isn't useless during the
#     production promotion window.
_traceroute_max_hops=30
_traceroute_no_annotate=0
_traceroute_hop_url_warned=0
cmd_traceroute() {
    target=$1
    [ -n "$target" ] || _die "traceroute: missing target (host or IP)" 2

    if ! _have_cmd traceroute; then
        _die "traceroute binary not installed. $(_pm_hint traceroute)" 1
    fi
    _require_jq

    # Deliberately NOT capturing stderr — traceroute writes its "traceroute
    # to X (Y), N hops max" header to stderr on Linux but stdout on macOS.
    # We read stdout and let stderr flow through to the user's terminal so
    # the header is still visible. Disable line buffering via stdbuf when
    # available so hops stream as they arrive instead of after the full
    # traceroute completes (macOS-style output buffering).
    if _have_cmd stdbuf; then
        _bufcmd="stdbuf -oL"
    else
        _bufcmd=""
    fi

    # Accumulator file for JSON/CSV modes. Can't use a plain shell variable
    # because the `cmd | while read` loop runs in a subshell in POSIX sh —
    # any state it mutates is lost when the pipeline ends. The tempfile
    # survives the subshell boundary, and NDJSON/field modes don't even
    # need it (they print per-hop directly to stdout).
    tr_tmp=$(mktemp -t ipinfo-tr.XXXXXX) || _die "failed to create tempfile" 1
    trap 'rm -f "$tr_tmp"' EXIT INT TERM

    # shellcheck disable=SC2086  # intentional word splitting on $_bufcmd
    $_bufcmd traceroute -n -m "$_traceroute_max_hops" "$target" 2>/dev/null \
        | while IFS= read -r line; do
        # Skip header and empty lines. The header on most traceroute
        # implementations starts with "traceroute to " (lowercase).
        case "$line" in
            ''|'traceroute to '*|'traceroute: '*|'Tracing route '*) continue ;;
        esac

        # Pull hop number (first field). If it isn't a number, skip — this
        # filters any stray junk the subprocess might have emitted.
        hop_num=$(printf '%s\n' "$line" | awk '{print $1}')
        case "$hop_num" in
            ''|*[!0-9]*) continue ;;
        esac

        # Extract first IPv4 or IPv6 address on the line. awk over a
        # regex is more portable than grep -oE across busybox/GNU/BSD.
        ip=$(printf '%s\n' "$line" \
            | awk '{
                for (i=1;i<=NF;i++) {
                    if ($i ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) { print $i; exit }
                    if ($i ~ /^[0-9a-fA-F:]+:[0-9a-fA-F:]+$/) { print $i; exit }
                }
            }')

        # Extract first "NNN.NNN ms" RTT. If the hop timed out on all
        # probes there's only * markers on the line and rtt stays empty.
        rtt=$(printf '%s\n' "$line" \
            | awk 'match($0, /[0-9]+\.[0-9]+ ms/) { print substr($0, RSTART, RLENGTH-3) }')

        # ── Emit per hop ──────────────────────────────────────────────
        if [ -z "$ip" ]; then
            # Timed-out hop — no IP means no annotation possible.
            hop_json=$(jq -n -c --argjson hop "$hop_num" '
                { hop: $hop, ip: null, rtt_ms: null, ptr: null, asn: null, pop: null, timeout: true }')
        elif [ "$_traceroute_no_annotate" = 1 ]; then
            # User opted out of annotation — emit raw hop.
            hop_json=$(jq -n -c \
                --argjson hop "$hop_num" \
                --arg     ip  "$ip" \
                --arg     rtt "$rtt" '
                { hop: $hop, ip: $ip, rtt_ms: ($rtt | tonumber? // null), ptr: null, asn: null, pop: null, timeout: false }')
        else
            # Annotate via /api/v2/hop/:ip. We use a short timeout so a
            # dead endpoint doesn't stall the whole traceroute — fall
            # through to raw hop on failure.
            ann=$(curl -sSL --max-time 10 \
                --user-agent "$IPINFO_APP_CLI_USER_AGENT" \
                "$IPINFO_APP_ATLAS_URL/api/v2/hop/$ip" 2>/dev/null || printf '')
            if [ -z "$ann" ] || ! printf '%s' "$ann" | jq -e . >/dev/null 2>&1; then
                # Endpoint unavailable or returned non-JSON. Warn once
                # and emit raw hop so traceroute still produces output.
                if [ "$_traceroute_hop_url_warned" = 0 ]; then
                    _warn "hop annotation unavailable at $IPINFO_APP_ATLAS_URL/api/v2/hop/ — emitting raw hops."
                    _warn "  if running early-access, try: IPINFO_APP_ATLAS_URL=https://atlas-staging.ipinfo.app ipinfo traceroute …"
                    _traceroute_hop_url_warned=1
                fi
                hop_json=$(jq -n -c \
                    --argjson hop "$hop_num" \
                    --arg     ip  "$ip" \
                    --arg     rtt "$rtt" '
                    { hop: $hop, ip: $ip, rtt_ms: ($rtt | tonumber? // null), ptr: null, asn: null, pop: null, timeout: false }')
            else
                hop_json=$(printf '%s' "$ann" | jq -c \
                    --argjson hop "$hop_num" \
                    --arg     rtt "$rtt" '
                    { hop: $hop, ip: .ip, rtt_ms: ($rtt | tonumber? // null),
                      ptr: .ptr, asn: .asn, pop: .pop, ptr_style: .ptr_style, timeout: false }')
            fi
        fi

        # Dispatch per-hop output according to mode. NDJSON is streamed
        # immediately so the user sees progress. JSON/CSV modes append
        # to tr_tmp (one NDJSON line per hop) and get slurped at EOF.
        case "$_output_mode" in
            json|csv)
                printf '%s\n' "$hop_json" >> "$tr_tmp"
                ;;
            field)
                printf '%s\n' "$hop_json" | jq -r --arg f "$_output_field" 'getpath($f | split(".")) // null'
                ;;
            *)
                # Default stream: NDJSON. One object per line.
                printf '%s\n' "$hop_json"
                ;;
        esac
    done

    # Post-processing for buffered modes — slurp the NDJSON tempfile into
    # an array with `jq -s` and shape it according to the requested mode.
    case "$_output_mode" in
        json)
            jq -s '.' "$tr_tmp"
            ;;
        csv)
            jq -s -r '
                map({
                    hop, ip,
                    rtt_ms,
                    ptr,
                    asn_number: (.asn.number // null),
                    asn_name:   (.asn.name // null),
                    pop_iata:   (.pop.iata // null),
                    pop_city:   (.pop.city // null),
                    confidence: (.pop.confidence // null),
                    timeout
                })
                | (.[0] | keys_unsorted) as $hdr
                | ($hdr | @csv),
                  (.[] | [$hdr[] as $k | .[$k] // ""] | @csv)' "$tr_tmp"
            ;;
    esac
}

# _pm_hint prints a packaging-manager-appropriate install one-liner for
# a missing binary. Mirrors install.sh's _pm_install_hint but kept here
# so the CLI has no dependency on the installer being resident.
_pm_hint() {
    pkg=$1
    if   _have_cmd brew;    then printf 'try: brew install %s' "$pkg"
    elif _have_cmd apt-get; then printf 'try: sudo apt-get install %s' "$pkg"
    elif _have_cmd dnf;     then printf 'try: sudo dnf install %s' "$pkg"
    elif _have_cmd pacman;  then printf 'try: sudo pacman -S %s' "$pkg"
    elif _have_cmd apk;     then printf 'try: sudo apk add %s' "$pkg"
    else                         printf 'install %s via your package manager' "$pkg"
    fi
}

# ── Subcommand: self-update ───────────────────────────────────────────────────
# Fetches version.json, compares version, verifies SHA256, prompts, swaps.
# The prompt is a guardrail — users can skip it with --yes. If stdin is not
# a TTY and --yes isn't given, we abort rather than prompting into the void.
_self_update_yes=0
cmd_self_update() {
    _require_curl
    manifest=$(_curl_or_die "$IPINFO_APP_CLI_DIST_URL/version.json")
    remote_version=$(printf '%s' "$manifest" | jq -r '.version // ""')
    remote_sha=$(printf '%s' "$manifest" | jq -r '.sha256.sh // ""')
    remote_released=$(printf '%s' "$manifest" | jq -r '.released // ""')

    [ -z "$remote_version" ] && _die "version.json is missing 'version' field" 1
    [ -z "$remote_sha" ]     && _die "version.json is missing 'sha256.sh' field" 1

    if [ "$remote_version" = "$IPINFO_APP_CLI_VERSION" ]; then
        _info "already up to date (v$IPINFO_APP_CLI_VERSION)"
        return 0
    fi

    # Semver-ish compare — treat remote as "newer" if it sorts after. We
    # don't support full semver precedence (prereleases, etc.) because the
    # project doesn't ship them.
    newer=$(printf '%s\n%s\n' "$IPINFO_APP_CLI_VERSION" "$remote_version" | sort -V | tail -n1)
    if [ "$newer" = "$IPINFO_APP_CLI_VERSION" ]; then
        _info "local version ($IPINFO_APP_CLI_VERSION) is newer than remote ($remote_version) — skipping"
        return 0
    fi

    _info ""
    _info "$(_c bold)update available$(_rc): $(_c dim)v$IPINFO_APP_CLI_VERSION$(_rc) → $(_c cyan)v$remote_version$(_rc)"
    [ -n "$remote_released" ] && _info "$(_c dim)released:$(_rc) $remote_released"
    _info "$(_c dim)sha256:$(_rc)   $remote_sha"
    _info ""

    if [ "$_self_update_yes" != "1" ]; then
        if [ ! -t 0 ]; then
            _die "stdin is not a TTY; pass --yes to self-update non-interactively" 2
        fi
        printf 'proceed with update? [y/N] '
        read -r answer
        case "$answer" in
            [Yy]|[Yy][Ee][Ss]) : ;;
            *) _info "aborted."; exit 0 ;;
        esac
    fi

    tempfile=$(mktemp -t ipinfo-new.XXXXXX) || _die "failed to create tempfile" 1
    trap 'rm -f "$tempfile"' EXIT INT TERM

    _info "downloading…"
    _curl -o "$tempfile" "$IPINFO_APP_CLI_DIST_URL/ipinfo" \
        || _die "download failed" 7

    _info "verifying sha256…"
    if _have_cmd sha256sum; then
        actual=$(sha256sum "$tempfile" | awk '{print $1}')
    elif _have_cmd shasum; then
        actual=$(shasum -a 256 "$tempfile" | awk '{print $1}')
    else
        _die "neither sha256sum nor shasum available — cannot verify integrity" 1
    fi

    if [ "$actual" != "$remote_sha" ]; then
        _die "sha256 mismatch! expected $remote_sha, got $actual. aborting." 3
    fi

    _info "installing to $0…"
    chmod +x "$tempfile" || _die "chmod failed on tempfile" 1
    # mv is atomic on the same filesystem. If $0 is on a different FS from
    # /tmp (rare but possible in container setups), we fall back to cp+rm.
    if ! mv "$tempfile" "$0" 2>/dev/null; then
        cp "$tempfile" "$0" && rm -f "$tempfile" \
            || _die "failed to write $0 — check permissions" 1
    fi
    trap - EXIT INT TERM

    _info "$(_c green)✓$(_rc) updated to v$remote_version"
}

# ── Subcommand: completion ────────────────────────────────────────────────────
# Emits a shell completion script for the requested shell. Simple hand-
# written completions; kept small because the command surface is small.
cmd_completion() {
    shell=$1
    case "$shell" in
        bash)
            cat <<'BASH_EOF'
# ipinfo bash completion — source this or add to /etc/bash_completion.d/
_ipinfo_complete() {
    local cur prev words cword
    cur="${COMP_WORDS[COMP_CWORD]}"
    prev="${COMP_WORDS[COMP_CWORD-1]}"
    local subcommands="myip asn bulk summarize classify speedtest traceroute completion version self-update help"
    if [ "$COMP_CWORD" = 1 ]; then
        COMPREPLY=( $(compgen -W "$subcommands" -- "$cur") )
        return 0
    fi
    case "$prev" in
        --api)        COMPREPLY=( $(compgen -W "v1 v2 v3" -- "$cur") ) ;;
        completion)   COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ) ;;
        -f|--field)   COMPREPLY=( $(compgen -W "ip hostname country org asn.asn asn.name asn.route" -- "$cur") ) ;;
        *)            COMPREPLY=( $(compgen -W "-j -c -f --api --workers --size --streams --no-calibrate --latency-only --max-hops --no-annotate --yes --nocolor" -- "$cur") ) ;;
    esac
}
complete -F _ipinfo_complete ipinfo
BASH_EOF
            ;;
        zsh)
            cat <<'ZSH_EOF'
# ipinfo zsh completion — add to ~/.zsh/completions/_ipinfo or fpath
#compdef ipinfo
_ipinfo() {
    local -a subcommands
    subcommands=(
        'myip:look up your own IP'
        'asn:look up an ASN'
        'bulk:look up multiple IPs from args or stdin'
        'summarize:aggregate bulk results by country/ASN'
        'classify:IP reputation (v1 free, v2/v3 RapidAPI)'
        'speedtest:connection speed + latency'
        'traceroute:traceroute with per-hop ASN/PoP annotation'
        'completion:emit shell completion (bash/zsh/fish)'
        'version:print version'
        'self-update:check for and install updates'
        'help:show help'
    )
    if (( CURRENT == 2 )); then
        _describe 'subcommand' subcommands
    else
        _arguments \
            '-j[JSON output]' \
            '-c[CSV output]' \
            '-f+[extract field]:field:(ip hostname country org asn.asn asn.name asn.route)' \
            '--api+[blackbox API tier]:tier:(v1 v2 v3)' \
            '--workers+[parallel workers]:count:' \
            '--size+[speedtest per-stream size in MB]:mb:' \
            '--streams+[speedtest parallel streams]:count:' \
            '--no-calibrate[skip speedtest calibration phase]' \
            '--latency-only[speedtest: skip download]' \
            '--yes[non-interactive self-update]' \
            '--nocolor[disable colour output]'
    fi
}
_ipinfo
ZSH_EOF
            ;;
        fish)
            cat <<'FISH_EOF'
# ipinfo fish completion — add to ~/.config/fish/completions/ipinfo.fish
set -l subcommands myip asn bulk summarize classify speedtest traceroute completion version self-update help

complete -c ipinfo -n "not __fish_seen_subcommand_from $subcommands" -a "myip"         -d "look up your own IP"
complete -c ipinfo -n "not __fish_seen_subcommand_from $subcommands" -a "asn"          -d "look up an ASN"
complete -c ipinfo -n "not __fish_seen_subcommand_from $subcommands" -a "bulk"         -d "look up multiple IPs"
complete -c ipinfo -n "not __fish_seen_subcommand_from $subcommands" -a "summarize"    -d "aggregate bulk results"
complete -c ipinfo -n "not __fish_seen_subcommand_from $subcommands" -a "classify"     -d "IP reputation"
complete -c ipinfo -n "not __fish_seen_subcommand_from $subcommands" -a "speedtest"    -d "connection speed + latency"
complete -c ipinfo -n "not __fish_seen_subcommand_from $subcommands" -a "traceroute"   -d "traceroute + per-hop annotation"
complete -c ipinfo -n "not __fish_seen_subcommand_from $subcommands" -a "completion"   -d "emit shell completion"
complete -c ipinfo -n "not __fish_seen_subcommand_from $subcommands" -a "version"      -d "print version"
complete -c ipinfo -n "not __fish_seen_subcommand_from $subcommands" -a "self-update"  -d "check for updates"
complete -c ipinfo -n "not __fish_seen_subcommand_from $subcommands" -a "help"         -d "show help"

complete -c ipinfo -s j                      -d "JSON output"
complete -c ipinfo -s c                      -d "CSV output"
complete -c ipinfo -s f -l field -x          -d "extract field"
complete -c ipinfo      -l api  -x -a "v1 v2 v3" -d "blackbox API tier"
complete -c ipinfo      -l workers -x        -d "parallel workers"
complete -c ipinfo      -l size -x           -d "speedtest per-stream size (MB)"
complete -c ipinfo      -l streams -x        -d "speedtest parallel streams"
complete -c ipinfo      -l no-calibrate      -d "skip speedtest calibration"
complete -c ipinfo      -l latency-only      -d "speedtest: skip download"
complete -c ipinfo      -l yes               -d "non-interactive self-update"
complete -c ipinfo      -l nocolor           -d "disable colour output"
FISH_EOF
            ;;
        *)
            _die "unknown shell: $shell (expected bash, zsh, or fish)" 2
            ;;
    esac
}

# ── Help text ────────────────────────────────────────────────────────────────
_print_help() {
    cat <<EOF
$(_c bold)ipinfo$(_rc) — ipinfo.app CLI  $(_c dim)v$IPINFO_APP_CLI_VERSION$(_rc)

$(_c cyan)USAGE$(_rc)
    ipinfo [<command>] [<args>] [<flags>]

$(_c cyan)COMMANDS$(_rc)
    $(_c bold)myip$(_rc)                        look up your own IP (rDNS + ASN + country)
    $(_c bold)<ip>$(_rc)                        look up any IP (default subcommand)
    $(_c bold)asn$(_rc) <num>                   look up an ASN (13335 or AS13335)
    $(_c bold)bulk$(_rc) <ip>...                multiple IPs; also reads IPs from stdin
    $(_c bold)summarize$(_rc)                   aggregate bulk results by country / ASN
    $(_c bold)classify$(_rc) <ip> [--api v1|v2|v3]   IP reputation — v1 free, v2/v3 need RapidAPI key
    $(_c bold)speedtest$(_rc)                   download + latency test against my.ipinfo.app
    $(_c bold)traceroute$(_rc) <host>          traceroute + per-hop PTR/ASN/PoP annotation via atlas
    $(_c bold)completion$(_rc) <bash|zsh|fish>  emit shell completion script
    $(_c bold)version$(_rc)                     print version info
    $(_c bold)self-update$(_rc) [--yes]         check for + install updates (prompts unless --yes)

$(_c cyan)FLAGS$(_rc)
    $(_c bold)-j$(_rc), $(_c bold)--json$(_rc)                force JSON output (default for most commands)
    $(_c bold)-c$(_rc)                           CSV output
    $(_c bold)-f$(_rc), $(_c bold)--field$(_rc) <path>        extract a single field by jq path (e.g. country, asn.name)
    $(_c bold)--api$(_rc) <v1|v2|v3>             blackbox classification tier (default v1)
    $(_c bold)--workers$(_rc) <N>                bulk concurrency (default 4, cap 10)
    $(_c bold)--size$(_rc) <MB>                  speedtest per-stream size (default 25, max 100)
    $(_c bold)--streams$(_rc) <N>                speedtest parallel streams (default: auto from calibration)
    $(_c bold)--no-calibrate$(_rc)              speedtest: skip calibration, use --streams x --size directly
    $(_c bold)--latency-only$(_rc)              skip download, show latency only
    $(_c bold)--max-hops$(_rc) <N>               traceroute max hops (default 30)
    $(_c bold)--no-annotate$(_rc)               traceroute: skip /hop/ annotation, emit raw
    $(_c bold)--yes$(_rc)                        skip confirmation prompts (self-update)
    $(_c bold)--nocolor$(_rc)                   disable colour output (also: NO_COLOR=1)
    $(_c bold)-q$(_rc), $(_c bold)--quiet$(_rc)               suppress upsell hints on stderr

$(_c cyan)ENVIRONMENT$(_rc)
    IPINFO_APP_RAPIDAPI_KEY    RapidAPI key for blackbox v2/v3 (takes precedence over key file)
    NO_COLOR                   disable colour output
    XDG_CONFIG_HOME            config dir; key file at \$XDG_CONFIG_HOME/ipinfo-app/rapidapi-key

$(_c cyan)EXAMPLES$(_rc)
    ipinfo myip
    ipinfo 8.8.8.8 -f country
    ipinfo asn 13335 -f name
    ipinfo classify 8.8.8.8 --api v2
    cat ips.txt | ipinfo -c > results.csv
    cat ips.txt | ipinfo summarize

$(_c cyan)DOCS$(_rc)
    https://www.ipinfo.app/cli/
EOF
}

# ── Argument parsing ──────────────────────────────────────────────────────────
# POSIX sh has no getopt_long; we hand-roll a small parser that:
#   • extracts the first non-flag token as the subcommand (or defaults to ip
#     when the first arg looks like an IP, to match ipinfo.io's convention
#     of `ipinfo 8.8.8.8`);
#   • consumes short and long flags, swallowing their values where needed;
#   • leaves remaining positional args for the subcommand.
_subcommand=""
_positionals=""
_quiet=0

# _is_ip checks for a rough IPv4 or IPv6 pattern. Not bullet-proof (doesn't
# validate ranges) — just enough to decide between treating argv[0] as an
# IP or as a subcommand name.
_is_ip() {
    case "$1" in
        *:*) return 0 ;;  # colon implies IPv6
        [0-9]*.[0-9]*.[0-9]*.[0-9]*) return 0 ;;
        *) return 1 ;;
    esac
}

while [ $# -gt 0 ]; do
    case "$1" in
        -j|--json)       _output_mode=json ;;
        -c|--csv)        _output_mode=csv ;;
        -f|--field)      _output_mode=field; _output_field=$2; shift ;;
        --api)           _classify_api=$2; shift ;;
        --workers)       _bulk_workers=$2; shift ;;
        --size)          _speedtest_size=$2; shift ;;
        --streams)       _speedtest_streams=$2; shift ;;
        --no-calibrate)  _speedtest_no_calibrate=1 ;;
        --latency-only)  _speedtest_latency_only=1 ;;
        --max-hops)      _traceroute_max_hops=$2; shift ;;
        --no-annotate)   _traceroute_no_annotate=1 ;;
        --yes)           _self_update_yes=1 ;;
        --nocolor)       _colour_enabled=0 ;;
        -q|--quiet)      _quiet=1 ;;
        -h|--help|help)  _print_help; exit 0 ;;
        --version)       printf 'ipinfo %s\n' "$IPINFO_APP_CLI_VERSION"; exit 0 ;;
        --)              shift; while [ $# -gt 0 ]; do _positionals="$_positionals $1"; shift; done; break ;;
        -*)              _die "unknown flag: $1 (see 'ipinfo --help')" 2 ;;
        *)
            if [ -z "$_subcommand" ]; then
                # First non-flag arg. If it looks like an IP, the implicit
                # subcommand is `ip`. Otherwise treat it as the subcommand.
                if _is_ip "$1"; then
                    _subcommand="ip"
                    _positionals="$_positionals $1"
                else
                    _subcommand=$1
                fi
            else
                _positionals="$_positionals $1"
            fi
            ;;
    esac
    shift
done

# ── Subcommand dispatch ───────────────────────────────────────────────────────
# Internal `_bulk_one` is an implementation detail of bulk() — keep it here
# rather than in the visible subcommand list because users aren't meant to
# invoke it directly.
# shellcheck disable=SC2086  # intentional word-splitting on $_positionals
set -- $_positionals

# If no subcommand was given but stdin is a pipe, treat as bulk (matches
# ipinfo.io's behaviour of "cat ips.txt | ipinfo" running an implicit bulk).
# This has to happen after arg parsing so that flags like -c/-f are already
# applied to the output mode when we dispatch.
if [ -z "${_subcommand:-}" ] && [ ! -t 0 ]; then
    _subcommand="bulk"
fi

case "${_subcommand:-}" in
    ""|help)       _print_help ;;
    myip)          cmd_myip ;;
    ip)            [ $# -lt 1 ] && _die "ip: missing IP address" 2; cmd_ip "$1" ;;
    asn)           [ $# -lt 1 ] && _die "asn: missing ASN number" 2; cmd_asn "$1" ;;
    classify)      [ $# -lt 1 ] && _die "classify: missing IP address" 2; cmd_classify "$1" ;;
    bulk)          cmd_bulk "$@" ;;
    _bulk_one)     [ $# -lt 1 ] && _die "_bulk_one: missing IP" 2; _cmd_bulk_one "$1" ;;
    summarize)     cmd_summarize ;;
    speedtest)     cmd_speedtest ;;
    traceroute)    [ $# -lt 1 ] && _die "traceroute: missing target host/IP" 2; cmd_traceroute "$1" ;;
    completion)    [ $# -lt 1 ] && _die "completion: missing shell name (bash|zsh|fish)" 2; cmd_completion "$1" ;;
    version)       printf 'ipinfo %s\n' "$IPINFO_APP_CLI_VERSION" ;;
    self-update)   cmd_self_update ;;
    *)
        # If the subcommand looks like an IP (e.g. the user typed
        # `ipinfo -c 8.8.8.8` and the IP ended up as subcommand), treat it
        # as cmd_ip — mirrors ipinfo.io's default behaviour.
        if _is_ip "$_subcommand"; then
            cmd_ip "$_subcommand"
        else
            _die "unknown subcommand: $_subcommand (see 'ipinfo --help')" 2
        fi
        ;;
esac
