#!/bin/bash
# Integration tests for `tcli card upload`.
#
# This test assumes tcli was built with the `experimental` Cargo
# feature:
#
#     cargo build --features experimental
#
# On a default build the experimental subcommands do not exist on the
# binary, and this script exits early with an explanatory error
# instead of producing misleading failures.
#
# Stage A (always runs): CLI argument validation. No smart card
# needed. Covers --signing-from value validation and keystore-resolution
# errors.
#
# Stage B (runs only when a writable OpenPGP card is plugged in and
# TCLI_TEST_CARD_UPLOAD=1 is exported by the caller): end-to-end flow.
# Generates a key, imports to the keystore, uploads to the signing slot,
# and verifies a git commit signature via tclig. Skipped in CI unless
# the environment explicitly opts in.
#
# Usage:
#   cargo build --features experimental && ./tests/test_upload_card.sh
#   TCLI_TEST_CARD_UPLOAD=1 ADMIN_PIN=12345678 TUMPA_PASSPHRASE=testpass \
#       ./tests/test_upload_card.sh
#
# Multi-card host (e.g. Nitrokey 3 + jcecard virtual reader): set
# TCLI_CARD_IDENT to the IDENT printed by `tcli card list` (e.g.
# "000F:CB9A5355", i.e. MFG_ID:SERIAL where MFG_ID is the 4-hex
# manufacturer code, NOT the human-readable manufacturer name) to pin
# every card-touching tcli call to that reader. Stage A doesn't need
# it; Stage B does, otherwise libtumpa refuses with the multi-card
# guard.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
TCLI="$PROJECT_DIR/target/debug/tcli"
TCLIG="$PROJECT_DIR/target/debug/tclig"

[[ -x "$TCLI"  ]] || { echo "ERROR: build tcli first (cargo build)";  exit 1; }
[[ -x "$TCLIG" ]] || { echo "ERROR: build tclig first (cargo build)"; exit 1; }

TEST_DIR=$(mktemp -d -t tumpa-upload-card-XXXXXX)
export TUMPA_KEYSTORE="$TEST_DIR/keys.db"

cleanup() { rm -rf "$TEST_DIR"; }
trap cleanup EXIT

PASS=0; FAIL=0
ok()   { echo "  OK   $1"; PASS=$((PASS+1)); }
fail() { echo "  FAIL $1${2:+ — $2}"; FAIL=$((FAIL+1)); }

# Assert that the given command exits non-zero AND the combined output
# contains the expected substring.
expect_error_containing() {
    local name="$1"; shift
    local needle="$1"; shift
    local out rc
    out=$("$@" 2>&1) && rc=0 || rc=$?
    if [[ $rc -eq 0 ]]; then
        fail "$name" "expected non-zero exit, got 0; output: $out"
        return
    fi
    if ! grep -qF -- "$needle" <<<"$out"; then
        fail "$name" "missing expected text '$needle'; got: $out"
        return
    fi
    ok "$name"
}

# ---------------------------------------------------------------------
# Preflight: require a feature-experimental build
# ---------------------------------------------------------------------
#
# A default build does not include the `card upload` subcommand at all
# (clap reports an unrecognized subcommand). A feature build gets past
# argv parsing and falls through to keystore resolution ("key not
# found"). Anything else means something unrelated went wrong and we
# should not run Stage A at all.

preflight_out=$("$TCLI" card upload 0000000000000000000000000000000000000000 2>&1 || true)
if [[ "$preflight_out" == *"unrecognized subcommand"* ]] \
   || [[ "$preflight_out" == *"unexpected argument"* ]]; then
    echo "ERROR: tcli was built without the 'experimental' Cargo feature." >&2
    echo "       Rebuild with:  cargo build --features experimental" >&2
    exit 2
fi
if [[ "$preflight_out" != *"key not found"* ]]; then
    echo "ERROR: unexpected 'card upload' preflight output:" >&2
    echo "$preflight_out" >&2
    exit 2
fi

# ---------------------------------------------------------------------
# Stage A — CLI argument validation (no card required)
# ---------------------------------------------------------------------

echo ""
echo "=== Stage A: CLI argument validation ==="

# A1: --signing-from is only present on `card upload`. clap rejects
#     it on any other subcommand with an "unexpected argument" error.
expect_error_containing \
    "rejects --signing-from outside 'card upload'" \
    "unexpected argument" \
    "$TCLI" list --signing-from primary

# A2: --signing-from with an unknown value is rejected at parse time
#     (clap value_enum produces "invalid value").
expect_error_containing \
    "rejects invalid --signing-from value" \
    "invalid value" \
    "$TCLI" card upload AAAA --signing-from bogus

# A3: unknown fingerprint is surfaced as "key not found".
expect_error_containing \
    "surfaces unknown fingerprint" \
    "key not found" \
    "$TCLI" card upload \
    0000000000000000000000000000000000000000

# A4: --signing-from primary / --signing-from sub both parse (without
#     any keystore key, they must still reach keystore resolution and
#     fail there with a lookup error — this proves the flag value is
#     accepted).
expect_error_containing \
    "accepts --signing-from primary" \
    "key not found" \
    "$TCLI" card upload \
    0000000000000000000000000000000000000000 \
    --signing-from primary

expect_error_containing \
    "accepts --signing-from sub" \
    "key not found" \
    "$TCLI" card upload \
    0000000000000000000000000000000000000000 \
    --signing-from sub

# A5: --with parses comma-separated slot lists (without a keystore key,
#     reaches "key not found" — flag value accepted).
expect_error_containing \
    "accepts --with encryption,authentication" \
    "key not found" \
    "$TCLI" card upload \
    0000000000000000000000000000000000000000 \
    --with encryption,authentication

# ---------------------------------------------------------------------
# Stage B — End-to-end upload + card sign (requires opt-in + real card)
# ---------------------------------------------------------------------

if [[ "${TCLI_TEST_CARD_UPLOAD:-}" != "1" ]]; then
    echo ""
    echo "=== Stage B: skipped (set TCLI_TEST_CARD_UPLOAD=1 to enable) ==="
    echo ""
    echo "================================="
    echo "Results: $PASS passed, $FAIL failed"
    echo "================================="
    exit $((FAIL > 0 ? 1 : 0))
fi

: "${TUMPA_PASSPHRASE:?TUMPA_PASSPHRASE must be set for Stage B}"
: "${ADMIN_PIN:?ADMIN_PIN must be set for Stage B}"

# tcli's pinentry helper reads TUMPA_ADMIN_PIN for admin-PIN prompts
# (to keep the key passphrase and the admin PIN separable in CI).
# Fresh jcecard after a factory reset defaults to 12345678.
export TUMPA_ADMIN_PIN="$ADMIN_PIN"

# Optional multi-card disambiguation. If TCLI_CARD_IDENT is set,
# `tcli card upload` and `tcli card reset` are pinned to that reader
# via `--card-ident`. The diagnostic `tcli card status` / `tcli card
# list` calls below are intentionally NOT filtered: `card status`
# does not take `--card-ident`, and `card list` enumerates every
# attached reader. The Stage-B verification logic below assumes the
# test always operates on a freshly-reset card whose only populated
# slots are the ones the test just wrote, so an unfiltered fingerprint
# grep stays unambiguous.
CARD_IDENT_ARGS=()
if [[ -n "${TCLI_CARD_IDENT:-}" ]]; then
    CARD_IDENT_ARGS=(--card-ident "$TCLI_CARD_IDENT")
fi

command -v gpg >/dev/null || { echo "ERROR: gpg not installed"; exit 1; }
command -v git >/dev/null || { echo "ERROR: git not installed"; exit 1; }

# A card must actually be reachable. `card list` is the right probe
# here: it does only ATR-level enumeration (one PCSC connect per
# reader, no get_card_details / no APDU traffic that can transiently
# fail on a freshly-attached jcecard slot), so a positive result is a
# reliable yes/no on card presence. `card status` is reserved for
# the diagnostic dump on failure where richer info is helpful.
if ! "$TCLI" card list 2>&1 | grep -qE '^[0-9A-Fa-f]{4}:'; then
    echo "ERROR: Stage B requested but no OpenPGP card detected." >&2
    "$TCLI" card list >&2 || true
    "$TCLI" card status >&2 || true
    exit 1
fi

# Start from a known-clean card: block the admin PIN, factory-reset.
# This makes Stage B idempotent across local reruns — critical for the
# virtual jcecard, whose state file persists across pcscd restarts.
# After reset the admin PIN is back to its default of 12345678.
if ! "$TCLI" card reset "${CARD_IDENT_ARGS[@]}" >"$TEST_DIR/reset.out" 2>&1; then
    fail "card reset (Stage B setup)" "$(cat "$TEST_DIR/reset.out")"
    exit 1
fi
ok "card reset to factory defaults"

echo ""
echo "=== Stage B: end-to-end upload + card sign ==="

# B1: generate a dual-sign key (primary sign+cert, subkey sign) in gpg,
# import the secret into the tumpa keystore.
GNUPGHOME_B="$TEST_DIR/gpghome"
mkdir -p "$GNUPGHOME_B"
chmod 700 "$GNUPGHOME_B"
export GNUPGHOME="$GNUPGHOME_B"
echo "allow-loopback-pinentry" > "$GNUPGHOME/gpg-agent.conf"
echo "pinentry-mode loopback"  > "$GNUPGHOME/gpg.conf"

cat > "$TEST_DIR/keygen.batch" <<EOF
Key-Type: RSA
Key-Length: 4096
Key-Usage: sign,cert
Subkey-Type: RSA
Subkey-Length: 4096
Subkey-Usage: sign
Name-Real: Card Uploader
Name-Email: upload@example.local
Expire-Date: 1y
Passphrase: $TUMPA_PASSPHRASE
%commit
EOF
gpg --batch --gen-key "$TEST_DIR/keygen.batch" 2>/dev/null
KEY_FP=$(gpg --list-keys --with-colons upload@example.local \
    | awk -F: '/^fpr:/ {print $10; exit}')
echo "$KEY_FP:6:" | gpg --import-ownertrust 2>/dev/null

gpg --pinentry-mode loopback --passphrase "$TUMPA_PASSPHRASE" \
    --batch --yes --armor --export-secret-keys "$KEY_FP" \
    > "$TEST_DIR/secret.asc"
"$TCLI" import "$TEST_DIR/secret.asc" >/dev/null
ok "imported sign+cert+sign-subkey cert $KEY_FP into keystore"

# B2: ambiguous upload (primary AND signing subkey) without
# --signing-from must refuse. select_sign_target's error message
# still references the legacy `--which primary` / `--which sub`
# flag names; this test greps for that exact text. When LOW-2
# (refresh select_sign_target error texts to mention --signing-from)
# lands, update the grep accordingly.
expect_error_containing \
    "refuses ambiguous upload without --signing-from" \
    "--which primary" \
    "$TCLI" card upload "$KEY_FP"

# B3: upload the PRIMARY with --signing-from primary.
if "$TCLI" card upload "$KEY_FP" --signing-from primary "${CARD_IDENT_ARGS[@]}" \
        >"$TEST_DIR/upload.out" 2>&1; then
    ok "upload primary → signing slot"
else
    fail "upload primary → signing slot" "$(cat "$TEST_DIR/upload.out")"
    cat "$TEST_DIR/upload.out"
    exit 1
fi

# B4: tclig signs a commit (it should use the card now).
REPO_DIR="$TEST_DIR/repo"
mkdir -p "$REPO_DIR"
cd "$REPO_DIR"
git init --quiet
git config user.name "Card Uploader"
git config user.email "upload@example.local"
git config user.signingkey "$KEY_FP"
git config commit.gpgsign true
git config gpg.program "$TCLIG"
# git 2.45+ regression workaround: without an explicit commitEncoding,
# git hands raw (Latin-1) bytes to `gpg.program` at sign time but
# transcodes them to UTF-8 before writing the commit object, which
# breaks every non-UTF-8 commit's signature. See docs/git_sign_issue.md.
git config i18n.commitEncoding iso-8859-1

printf 'commit with non-ASCII byte \xa7\n' > "$TEST_DIR/msg.txt"
echo "hello" > file1.txt
git add file1.txt
if git commit -F "$TEST_DIR/msg.txt" --quiet 2>"$TEST_DIR/commit.err"; then
    C1=$(git rev-parse HEAD)
    ok "tclig signs via card: $C1"
else
    fail "tclig signs via card" "$(cat "$TEST_DIR/commit.err")"
    exit 1
fi

if git verify-commit "$C1" 2>"$TEST_DIR/v1.err"; then
    ok "tclig verifies the card-signed commit"
else
    fail "tclig verify" "$(cat "$TEST_DIR/v1.err")"
fi

# B5: gpg verifies too.
REAL_GPG=$(command -v gpg)
WRAPPER="$TEST_DIR/gpg-noprompt"
cat > "$WRAPPER" <<EOF
#!/bin/bash
exec "$REAL_GPG" --pinentry-mode loopback --passphrase "$TUMPA_PASSPHRASE" --batch --yes "\$@"
EOF
chmod +x "$WRAPPER"
git config gpg.program "$WRAPPER"
if git verify-commit "$C1" 2>"$TEST_DIR/v2.err"; then
    ok "gpg verifies the card-signed commit"
else
    fail "gpg verify" "$(cat "$TEST_DIR/v2.err")"
fi

# ---------------------------------------------------------------------
# Stage B2 — Cv25519Modern primary upload (Nitrokey regression case)
# ---------------------------------------------------------------------
#
# Stage B above exercised an RSA primary; this section uploads a V4
# CipherSuite::Cv25519Modern primary (Ed25519 Certify+Sign primary,
# X25519 encryption subkey, Ed25519 authentication subkey) which is
# the exact shape that triggered SW 6A80 on Nitrokey 3 (opcard-rs >=
# 1.5) before wecanencrypt 0.14.1 added the `0x40` Curve25519 public-
# key prefix. If wecanencrypt ever regresses on that fix, this case
# fails first.
#
# Fixture is shared with the wecanencrypt repo (tests/files/v4_x25519_cs_primary.asc).
# Passphrase is fixed at "redhat" — overrides TUMPA_PASSPHRASE just for
# this upload.

cd "$PROJECT_DIR"

V4_FIXTURE="$PROJECT_DIR/tests/keys/v4_x25519_cs_primary.asc"
V4_FP="605D5E93B30357B5F15378D3102F0E908B001D4F"
V4_PASS="redhat"

[[ -f "$V4_FIXTURE" ]] || {
    fail "v4 Cv25519Modern fixture present" "missing $V4_FIXTURE"
    exit 1
}

# Reset card so this case starts from a clean slate (the RSA upload
# above left a key in the signing slot).
if ! "$TCLI" card reset "${CARD_IDENT_ARGS[@]}" >"$TEST_DIR/reset2.out" 2>&1; then
    fail "card reset before Cv25519Modern upload" "$(cat "$TEST_DIR/reset2.out")"
    exit 1
fi
ok "card reset before Cv25519Modern upload"

if ! "$TCLI" import "$V4_FIXTURE" >"$TEST_DIR/v4_import.out" 2>&1; then
    fail "import Cv25519Modern fixture" "$(cat "$TEST_DIR/v4_import.out")"
    exit 1
fi
ok "imported Cv25519Modern fixture $V4_FP"

if TUMPA_PASSPHRASE="$V4_PASS" "$TCLI" \
        card upload "$V4_FP" \
        --signing-from primary \
        --with encryption,authentication \
        "${CARD_IDENT_ARGS[@]}" \
        >"$TEST_DIR/v4_upload.out" 2>&1; then
    ok "upload Cv25519Modern primary + E + A subkeys (Nitrokey path)"
else
    fail "upload Cv25519Modern primary + E + A subkeys" \
        "$(cat "$TEST_DIR/v4_upload.out")"
    cat "$TEST_DIR/v4_upload.out"
    exit 1
fi

# Confirm all three slots actually hold the expected subkey fingerprints.
# X25519 encryption subkey of v4_x25519_cs_primary.asc:
V4_ENC_FP="D0C8D771D446D6170531DB90068C54B217F6384F"
# Ed25519 authentication subkey of v4_x25519_cs_primary.asc:
V4_AUTH_FP="626936B80CF7A4E793B893236134A4EB28428F1C"

# tcli card status prints fingerprints with spaces every 4 chars; strip
# them out and compare against the bare hex to keep the assertion robust.
status_out=$("$TCLI" card status 2>&1)
status_compact=$(printf '%s' "$status_out" | tr -d '[:space:]' | tr 'a-f' 'A-F')

for label in "$V4_FP:signing" "$V4_ENC_FP:encryption" "$V4_AUTH_FP:authentication"; do
    fp=${label%:*}; slot=${label##*:}
    if grep -qF "$fp" <<<"$status_compact"; then
        ok "Nitrokey reports $slot slot fp $fp"
    else
        fail "Nitrokey reports $slot slot fp $fp" \
            "card-status output: $status_out"
    fi
done

echo ""
echo "================================="
echo "Results: $PASS passed, $FAIL failed"
echo "================================="
exit $((FAIL > 0 ? 1 : 0))
