#!/usr/bin/env bash
# Outside-in public HTTPS checks (no SSH).
set -euo pipefail
ROOT=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=lib.sh
source "$ROOT/lib.sh"

# Area www-### — public HTTPS (outside-in)
set_area www
section "www · public URLs · ${TALER_DOMAIN:-?} (outside-in, no SSH)"

tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

check_url() {
  local label="$1" expect="$2" url="$3"
  local code
  code=$(http_code "$url")
  case ",$expect," in
    *",$code,"*) ok "$label $url" ;;
    *) fail "$label $url" "got $code want $expect" ;;
  esac
}

# Soft check: OK on expect, WARN on foreign stack if down, ERROR on local stack
check_url_soft() {
  local label="$1" expect="$2" url="$3"
  local code
  code=$(http_code "$url")
  case ",$expect," in
    *",$code,"*) ok "$label $url" ;;
    *)
      if [ "${LOCAL_STACK:-1}" = "0" ]; then
        warn "$label $url" "got $code (optional on remote domain)"
      else
        fail "$label $url" "got $code want $expect"
      fi
      ;;
  esac
}

expect_currency() {
  local label="$1" file="$2" want="${EXPECT_CURRENCY:-}"
  local cur
  cur=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("currency",""))' "$file" 2>/dev/null || true)
  if [ -z "$want" ]; then
    info "$label currency" "${cur:-?}"
    return
  fi
  if [ "$cur" = "$want" ]; then
    ok "$label currency=$want"
  else
    fail "$label currency" "got ${cur:-?} want $want"
  fi
}

# Legal docs: /terms and /privacy must be HTTP 200 with a real document body
# (not empty, not "not configured", not JSON API error).
# $1=label $2=url $3=optional needle regex (case-insensitive) for local stack
check_legal_doc() {
  local label="$1" url="$2" needle="${3:-}"
  local f code soft
  soft=0
  [ "${LOCAL_STACK:-1}" = "0" ] && soft=1
  f=$(mktemp)
  code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" \
    -H "Accept: text/html,text/markdown,text/plain,*/*" \
    -o "$f" -w '%{http_code}' "$url" 2>/dev/null || echo 000)
  if [ "$code" != "200" ]; then
    rm -f "$f"
    if [ "$soft" = "1" ]; then
      warn "$label" "HTTP $code — $url"
    else
      fail "$label" "HTTP $code — $url"
    fi
    return
  fi
  if [ ! -s "$f" ]; then
    rm -f "$f"
    fail "$label" "empty body — $url"
    return
  fi
  # merchant returns plain "not configured" when PRIVACY_ETAG missing
  if grep -qiE '^(not configured)\s*$' "$f" 2>/dev/null \
    || grep -qiE '"code"\s*:\s*21' "$f" 2>/dev/null; then
    rm -f "$f"
    fail "$label" "not configured / API error — $url"
    return
  fi
  if [ -n "$needle" ] && [ "${LOCAL_STACK:-1}" = "1" ]; then
    if ! grep -qiE "$needle" "$f" 2>/dev/null; then
      warn "$label content" "missing /$needle/ — $url"
      rm -f "$f"
      return
    fi
  fi
  ok "$label" "HTTP 200 · $(wc -c <"$f" | tr -d ' ') bytes"
  rm -f "$f"
}

# --- exchange (core; always required) ---  www-001 …
check_url "exchange /config" 200 "$EXCHANGE_PUBLIC/config"
code=$(http_body "$EXCHANGE_PUBLIC/config" "$tmp/ec.json")
if [ "$code" = "200" ]; then
  expect_currency "exchange" "$tmp/ec.json"
  # currency_specification.alt_unit_names (wallet codec)
  if json_has_alt_unit_names "$tmp/ec.json" "${EXPECT_CURRENCY:-}" >/tmp/alt-ex.$$ 2>&1; then
    ok "exchange /config alt_unit_names" "$(tr '\n' '; ' </tmp/alt-ex.$$ | sed 's/; $//')"
  else
    fail "exchange /config alt_unit_names" "$(tr '\n' '; ' </tmp/alt-ex.$$ | sed 's/; $//')"
  fi
  rm -f /tmp/alt-ex.$$
fi
check_url "exchange /keys" 200 "$EXCHANGE_PUBLIC/keys"
# /keys should also expose currency_specification.alt_unit_names when present
code=$(http_body "$EXCHANGE_PUBLIC/keys" "$tmp/ek.json")
if [ "$code" = "200" ]; then
  if python3 - "$tmp/ek.json" <<'PY'
import json,sys
d=json.load(open(sys.argv[1]))
cs=d.get("currency_specification") or {}
au=cs.get("alt_unit_names") if isinstance(cs,dict) else None
if not isinstance(au, dict) or "0" not in au:
    # older keys without embedded spec: not a hard fail if /config is good
    sys.exit(2)
sys.exit(0)
PY
  then
    ok "exchange /keys alt_unit_names"
  else
    ec=$?
    if [ "$ec" = "2" ]; then
      warn "exchange /keys alt_unit_names" "no currency_specification in /keys (ok if /config has it)"
    else
      fail "exchange /keys alt_unit_names"
    fi
  fi
fi
check_url_soft "exchange /intro/" 200 "$EXCHANGE_PUBLIC/intro/"
# Root should land on intro (302/301 then 200 on follow is checked separately)
check_url_soft "exchange /" 302,301,200 "$EXCHANGE_PUBLIC/"

# ---------------------------------------------------------------------------
# Performance — outside-in public HTTPS latency (this runner, not loopback)
# Same spirit as bank landing-stats public probes; measured here from outside.
# ---------------------------------------------------------------------------
section "www · performance · public HTTPS latency (outside-in)"

# Latency thresholds (ms), outside-in from this runner.
# ≥ PERF_WARN_MS → WARN. ≥ PERF_FAIL_MS → ERROR (suite fails).
PERF_WARN_MS="${PERF_WARN_MS:-8000}"
PERF_FAIL_MS="${PERF_FAIL_MS:-20000}"

# Measure one URL: require HTTP expect (default 200), report time_total in ms.
# $1=label $2=url $3=optional expected codes (default 200)
check_perf() {
  local label="$1" url="$2" expect="${3:-200}"
  local out code t_s ms
  out=$(curl -skS --max-redirs 3 -L -m "${PERF_CURL_TIMEOUT:-25}" \
    -o /dev/null -w '%{http_code} %{time_total}' "$url" 2>/dev/null || echo "000 0")
  code=$(printf '%s' "$out" | awk '{print $1}')
  t_s=$(printf '%s' "$out" | awk '{print $2}')
  ms=$(awk -v t="${t_s:-0}" 'BEGIN{
    ms=(t+0)*1000
    if (ms>0 && ms<1) ms=1
    printf "%d", int(ms+0.5)
  }')
  case ",$expect," in
    *",$code,"*)
      if [ "$ms" -ge "${PERF_FAIL_MS}" ] 2>/dev/null; then
        fail "$label" "HTTP $code · ${ms} ms ≥ fail ${PERF_FAIL_MS} ms · $url"
      elif [ "$ms" -ge "${PERF_WARN_MS}" ] 2>/dev/null; then
        warn "$label" "HTTP $code · ${ms} ms ≥ warn ${PERF_WARN_MS} ms · $url"
      else
        ok "$label" "HTTP $code · ${ms} ms · $url"
      fi
      ;;
    *)
      if [ "${LOCAL_STACK:-1}" = "1" ]; then
        fail "$label" "HTTP $code want $expect · ${ms} ms · $url"
      else
        warn "$label" "HTTP $code want $expect · ${ms} ms · $url"
      fi
      ;;
  esac
}

# Bank first (wallet-critical paths before UI chrome)
check_perf "perf bank /taler-integration/config" "$BANK_PUBLIC/taler-integration/config"
check_perf "perf bank /config" "$BANK_PUBLIC/config"
check_perf "perf bank /intro/" "$BANK_PUBLIC/intro/"
check_perf "perf bank /intro/stats.json" "$BANK_PUBLIC/intro/stats.json" 200
check_perf "perf bank /webui/" "$BANK_PUBLIC/webui/" 200,301,302

# Exchange
check_perf "perf exchange /config" "$EXCHANGE_PUBLIC/config"
check_perf "perf exchange /keys" "$EXCHANGE_PUBLIC/keys"
check_perf "perf exchange /intro/" "$EXCHANGE_PUBLIC/intro/"

# Merchant
check_perf "perf merchant /config" "$MERCHANT_PUBLIC/config"
check_perf "perf merchant /webui/" "$MERCHANT_PUBLIC/webui/" 200,301,302
check_perf "perf merchant /intro/" "$MERCHANT_PUBLIC/intro/"

info "perf note" "measured from this host (outside-in); not container loopback"


# Terms + privacy (legal docs)
check_legal_doc "exchange /terms" "$EXCHANGE_PUBLIC/terms" "terms|GOA|exploration|FADP|revDSG|privacy"
check_legal_doc "exchange /privacy" "$EXCHANGE_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|exploration"
# trailing slash: 200 or redirect to bare path
code=$(http_code "$EXCHANGE_PUBLIC/terms/")
case "$code" in
  200|301|302) ok "exchange /terms/" "HTTP $code" ;;
  *)
    if [ "${LOCAL_STACK:-1}" = "1" ]; then warn "exchange /terms/" "HTTP $code"
    else warn "exchange /terms/" "HTTP $code"
    fi
    ;;
esac

# --- bank ---
if [ "${LOCAL_STACK:-1}" = "0" ]; then
  check_url_soft "bank /config" 200 "$BANK_PUBLIC/config"
else
  check_url "bank /config" 200 "$BANK_PUBLIC/config"
fi
code=$(http_body "$BANK_PUBLIC/config" "$tmp/bc.json")
if [ "$code" = "200" ]; then
  expect_currency "bank" "$tmp/bc.json"
  if json_has_alt_unit_names "$tmp/bc.json" >/tmp/alt-bank.$$ 2>&1; then
    ok "bank /config alt_unit_names" "$(tr '\n' '; ' </tmp/alt-bank.$$ | sed 's/; $//')"
  else
    fail "bank /config alt_unit_names" "$(tr '\n' '; ' </tmp/alt-bank.$$ | sed 's/; $//')"
  fi
  rm -f /tmp/alt-bank.$$
  # Wallet probes this; GET must be 200 (POST→405 is normal)
  if [ "${LOCAL_STACK:-1}" = "1" ]; then
    check_url "bank /taler-integration/config" 200 "$BANK_PUBLIC/taler-integration/config"
  else
    check_url_soft "bank /taler-integration/config" 200 "$BANK_PUBLIC/taler-integration/config"
  fi
  check_url_soft "bank /webui/" 200 "$BANK_PUBLIC/webui/"
  check_url_soft "bank /intro/" 200 "$BANK_PUBLIC/intro/"
  check_url_soft "bank /" 302,301,200 "$BANK_PUBLIC/"
  # Auto-account: credentials + shared-pool taler://withdraw (like step 2)
  aa_code=$(http_body "$BANK_PUBLIC/intro/auto-account.json" "$tmp/aa.json")
  case "$aa_code" in
    200)
      if python3 - "$tmp/aa.json" <<'PY'
import json, re, sys
from urllib.parse import urlparse
d = json.load(open(sys.argv[1]))
if not d.get("ok"):
    print("ok!=true"); sys.exit(1)
if "payto_uri" in d and d.get("payto_uri"):
    print("payto_uri must not be present"); sys.exit(1)
wuri = d.get("taler_withdraw_uri") or d.get("qr_payload") or ""
wm = re.match(r"^taler://withdraw/([^/]+)/taler-integration/([0-9a-fA-F-]+)$", wuri)
if not wm:
    print("need taler://withdraw/HOST:PORT/taler-integration/ID:", wuri[:120]); sys.exit(1)
if ":" not in wm.group(1):
    print("withdraw missing port:", wm.group(1)); sys.exit(1)
webui = d.get("login_url") or d.get("webui") or d.get("account_url") or ""
u = urlparse(webui)
if u.scheme not in ("http", "https") or "webui" not in (u.path or ""):
    print("login webui missing:", webui[:80]); sys.exit(1)
print("user=%s withdraw=%s login=%s" % (d.get("username"), wm.group(1), webui))
sys.exit(0)
PY
      then
        ok "bank /intro/auto-account.json" "$(python3 -c 'import json;d=json.load(open("'"$tmp/aa.json"'"));print(d.get("username",""),"·",(d.get("taler_withdraw_uri") or "")[:72])' 2>/dev/null || true)"
      else
        fail "bank /intro/auto-account.json" "invalid withdraw/login (HTTP body bad)"
      fi
      ;;
    405|501|404|502|503|000)
      fail "bank /intro/auto-account.json" "HTTP $aa_code (want 200; 405/501 = broken)"
      ;;
    *)
      fail "bank /intro/auto-account.json" "HTTP $aa_code want 200"
      ;;
  esac
fi

# Bank legal docs (landing nginx via Caddy /terms* /privacy* or /intro/*)
check_legal_doc "bank /terms" "$BANK_PUBLIC/terms" "terms|GOA|exploration|bank|FADP|revDSG"
# Prefer /privacy; fall back to /intro/privacy.html for older deploys
code=$(http_code "$BANK_PUBLIC/privacy")
if [ "$code" = "200" ]; then
  check_legal_doc "bank /privacy" "$BANK_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|bank"
else
  if [ "${LOCAL_STACK:-1}" = "1" ]; then
    check_legal_doc "bank /privacy (or /intro/privacy.html)" \
      "$BANK_PUBLIC/intro/privacy.html" "privacy|FADP|revDSG|data|GOA|bank"
    # still report bare /privacy failure for local
    warn "bank /privacy" "HTTP $code — prefer Caddy handle /privacy* → landing"
  else
    check_url_soft "bank /privacy" 200 "$BANK_PUBLIC/privacy"
  fi
fi

# --- merchant ---
if [ "${LOCAL_STACK:-1}" = "0" ]; then
  check_url_soft "merchant /config" 200 "$MERCHANT_PUBLIC/config"
else
  check_url "merchant /config" 200 "$MERCHANT_PUBLIC/config"
fi
code=$(http_body "$MERCHANT_PUBLIC/config" "$tmp/mc.json")
if [ "$code" = "200" ]; then
  want="${EXPECT_CURRENCY:-}"
  if python3 - "$tmp/mc.json" "$want" <<'PY'
import json,sys
try:
    d=json.load(open(sys.argv[1]))
except Exception:
    sys.exit(2)
want=sys.argv[2]
if not want:
    sys.exit(0)
curs=list((d.get("currencies") or {}).keys())
ex=d.get("exchanges") or []
ok = want in curs or any((e.get("currency") if isinstance(e,dict) else None)==want for e in ex)
if not ok and isinstance(d.get("currency"), str):
    ok = d["currency"]==want
sys.exit(0 if ok else 1)
PY
  then
    ok "merchant /config (${want:-currency} ok)"
  else
    ec=$?
    if [ "$ec" = "2" ]; then
      warn "merchant /config" "non-JSON body"
    elif [ -n "$want" ]; then
      fail "merchant /config currency" "want $want"
    else
      info "merchant /config" "ok"
    fi
  fi
  # merchant-local currency maps (GOA + CHF, …)
  if json_has_alt_unit_names "$tmp/mc.json" >/tmp/alt-mer.$$ 2>&1; then
    ok "merchant /config currencies alt_unit_names" "$(tr '\n' '; ' </tmp/alt-mer.$$ | sed 's/; $//')"
  else
    fail "merchant /config currencies alt_unit_names" "$(tr '\n' '; ' </tmp/alt-mer.$$ | sed 's/; $//')"
  fi
  rm -f /tmp/alt-mer.$$
  # Follow each exchange listed in merchant /config and require its /config alt_unit_names
  check_merchant_listed_exchanges_alt_units "$tmp/mc.json"
  check_url_soft "merchant /intro/" 200 "$MERCHANT_PUBLIC/intro/"
  check_url_soft "merchant /webui/" 200 "$MERCHANT_PUBLIC/webui/"
  check_url_soft "merchant /" 302,301,200 "$MERCHANT_PUBLIC/"
fi

# Merchant legal docs
check_legal_doc "merchant /terms" "$MERCHANT_PUBLIC/terms" "terms|dual|GOA|CHF|explorational|merchant"
check_legal_doc "merchant /privacy" "$MERCHANT_PUBLIC/privacy" "privacy|FADP|revDSG|data|GOA|CHF|merchant"
code=$(http_code "$MERCHANT_PUBLIC/terms/")
case "$code" in
  200|301|302) ok "merchant /terms/" "HTTP $code" ;;
  *) warn "merchant /terms/" "HTTP $code (expect 302 → /terms)" ;;
esac

# ---------------------------------------------------------------------------
# Landing pages: every HTTPS link exposed on bank / merchant / exchange intros
# + required static assets + bank withdraw mint (taler://withdraw only)
# ---------------------------------------------------------------------------
section "www · landing exposed links · bank / merchant / exchange"

# Known-good landing static paths (relative to each host base)
# Checked even if HTML parse misses them.
check_landing_asset() {
  local label="$1" url="$2" soft="${3:-0}"
  local code
  code=$(http_code "$url")
  case "$code" in
    200) ok "$label" "HTTP 200 · $url" ;;
    301|302|303|307|308)
      # follow once for assets that redirect
      code=$(curl -skS --max-redirs 3 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo 000)
      if [ "$code" = "200" ]; then
        ok "$label" "HTTP redirect→200 · $url"
      elif [ "$soft" = "1" ]; then
        warn "$label" "HTTP $code — $url"
      else
        fail "$label" "HTTP $code after redirect — $url"
      fi
      ;;
    *)
      if [ "$soft" = "1" ]; then
        warn "$label" "HTTP $code — $url"
      else
        fail "$label" "HTTP $code — $url"
      fi
      ;;
  esac
}

# Soft external (app stores / upstream docs): WARN if down, never ERROR
check_external_soft() {
  local label="$1" url="$2"
  local code
  code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo 000)
  case "$code" in
    200|204|301|302|303|307|308) ok "$label" "HTTP $code · $url" ;;
    *) warn "$label" "HTTP $code (external soft) · $url" ;;
  esac
}

# Parse one landing HTML: collect absolute https + root-relative href/src;
# resolve against base; classify own-stack vs external.
# Writes lists: $1.own  $1.ext  (one URL per line)
extract_landing_urls() {
  local base="$1" html="$2" out_prefix="$3"
  python3 - "$base" "$html" "$out_prefix" <<'PY'
import re, sys
from urllib.parse import urljoin, urlparse

base, html_path, out = sys.argv[1], sys.argv[2], sys.argv[3]
html = open(html_path, encoding="utf-8", errors="replace").read()
base = base.rstrip("/") + "/"
parsed_base = urlparse(base)
# Hard-check only the three public Taler hosts for this stack (not git.* etc.)
own_hosts = {
    (parsed_base.hostname or "").lower(),
    "bank.hacktivism.ch",
    "exchange.hacktivism.ch",
    "taler.hacktivism.ch",
}
# include configured public hosts when domain differs (demo / ops)
for envu in (
    __import__("os").environ.get("BANK_PUBLIC", ""),
    __import__("os").environ.get("EXCHANGE_PUBLIC", ""),
    __import__("os").environ.get("MERCHANT_PUBLIC", ""),
):
    h = urlparse(envu).hostname if envu else None
    if h:
        own_hosts.add(h.lower())

raw = set()
for m in re.finditer(
    r'''(?:href|src|content)=["']([^"'#]+)["']''', html, re.I
):
    raw.add(m.group(1).strip())
# bare absolute URLs in scripts (fetch, template strings)
for m in re.finditer(r'''https://[^\s"'<>\\]+''', html):
    u = m.group(0).rstrip("\\).,;'\"")
    # strip trailing punctuation leftovers
    while u and u[-1] in ".,);]}\"'":
        u = u[:-1]
    if u.startswith("https://"):
        raw.add(u)

own, ext = set(), set()
skip_prefix = ("data:", "javascript:", "mailto:", "taler://", "blob:")
skip_exact = {"website", "summary_large_image", "image/png", "en_US"}
for r in raw:
    if not r or r in skip_exact:
        continue
    if r.startswith(skip_prefix):
        continue
    # meta content noise
    if re.fullmatch(r"\d+", r) or r.startswith("width="):
        continue
    if " " in r and not r.startswith("http"):
        continue
    if r.startswith("//"):
        absu = "https:" + r
    elif r.startswith("http://") or r.startswith("https://"):
        absu = r
    elif r.startswith("/"):
        absu = urljoin(base, r)
    else:
        # relative asset
        if "/" in r or r.endswith((".js", ".css", ".png", ".svg", ".html", ".json", ".uri")):
            absu = urljoin(base + "intro/", r)
        else:
            continue
    # drop query-only noise / anchors already stripped
    p = urlparse(absu)
    if p.scheme not in ("http", "https"):
        continue
    # normalize: drop fragment
    absu = absu.split("#", 1)[0]
    host = (p.hostname or "").lower()
    # og image query ok
    if host in own_hosts:
        own.add(absu)
    else:
        ext.add(absu)

open(out + ".own", "w").write("\n".join(sorted(own)) + ("\n" if own else ""))
open(out + ".ext", "w").write("\n".join(sorted(ext)) + ("\n" if ext else ""))
print(f"own={len(own)} ext={len(ext)}")
PY
}

check_one_landing() {
  local name="$1" base="$2"
  local html="$tmp/landing-${name}.html"
  local pref="$tmp/urls-${name}"
  local code n own_n ext_n
  code=$(http_body "${base}/intro/" "$html")
  if [ "$code" != "200" ]; then
    if [ "${LOCAL_STACK:-1}" = "1" ]; then
      fail "landing ${name} /intro/" "HTTP $code"
    else
      warn "landing ${name} /intro/" "HTTP $code"
    fi
    return
  fi
  ok "landing ${name} /intro/" "HTTP 200 · $(wc -c <"$html" | tr -d ' ') bytes"

  # Required static assets (hard on local)
  check_landing_asset "landing ${name} qrcode.min.js" "${base}/intro/qrcode.min.js"
  check_landing_asset "landing ${name} og-goa-shop.png" "${base}/intro/og-goa-shop.png"
  check_landing_asset "landing ${name} qr-logo.png" "${base}/intro/qr-logo.png" 1

  n=$(extract_landing_urls "$base" "$html" "$pref" 2>/dev/null || echo "own=0 ext=0")
  info "landing ${name} link extract" "$n"
  own_n=0
  ext_n=0
  [ -f "${pref}.own" ] && own_n=$(grep -c . "${pref}.own" 2>/dev/null || echo 0)
  [ -f "${pref}.ext" ] && ext_n=$(grep -c . "${pref}.ext" 2>/dev/null || echo 0)
  if [ "${own_n:-0}" -lt 1 ]; then
    fail "landing ${name} own-stack links" "none extracted from HTML"
  else
    ok "landing ${name} own-stack links" "${own_n} URLs to probe"
  fi

  # Probe every own-stack URL from the page
  if [ -f "${pref}.own" ]; then
    while IFS= read -r u; do
      [ -n "$u" ] || continue
      # skip mint endpoints that create resources on GET if any (auto-account creates accounts)
      case "$u" in
        */intro/auto-account.json)
          # shape checked separately; still require 200 GET
          ;;
      esac
      code=$(http_code "$u")
      case "$code" in
        200) ok "landing ${name} link" "HTTP 200 · $u" ;;
        301|302|303|307|308)
          code=$(curl -skS --max-redirs 5 -L -m "${TIMEOUT}" -o /dev/null -w '%{http_code}' "$u" 2>/dev/null || echo 000)
          if [ "$code" = "200" ]; then
            ok "landing ${name} link" "redirect→200 · $u"
          else
            fail "landing ${name} link" "HTTP $code after redirect · $u"
          fi
          ;;
        405|501)
          # some APIs reject wrong method — try GET already failed; soft note
          fail "landing ${name} link" "HTTP $code · $u"
          ;;
        *)
          if [ "${LOCAL_STACK:-1}" = "1" ]; then
            fail "landing ${name} link" "HTTP $code · $u"
          else
            warn "landing ${name} link" "HTTP $code · $u"
          fi
          ;;
      esac
    done < "${pref}.own"
  fi

  # External store / docs: soft
  if [ -f "${pref}.ext" ]; then
    while IFS= read -r u; do
      [ -n "$u" ] || continue
      check_external_soft "landing ${name} external" "$u"
    done < "${pref}.ext"
  fi
}

check_one_landing "bank" "$BANK_PUBLIC"
check_one_landing "merchant" "$MERCHANT_PUBLIC"
check_one_landing "exchange" "$EXCHANGE_PUBLIC"

# Cross-links between the three landings (always on local stack)
if [ "${LOCAL_STACK:-1}" = "1" ]; then
  check_landing_asset "cross bank→merchant intro" "$MERCHANT_PUBLIC/intro/"
  check_landing_asset "cross bank→exchange intro" "$EXCHANGE_PUBLIC/intro/"
  check_landing_asset "cross merchant→bank intro" "$BANK_PUBLIC/intro/"
  check_landing_asset "cross exchange→bank intro" "$BANK_PUBLIC/intro/"
fi

# Bank-only: shared-pool withdraw mint + static withdraw files + shop assets
if [ "${LOCAL_STACK:-1}" = "1" ] || [ -n "${BANK_PUBLIC:-}" ]; then
  check_landing_asset "bank shop-pay.js" "$BANK_PUBLIC/intro/shop-pay.js" 1
  check_landing_asset "bank shop-pay.css" "$BANK_PUBLIC/intro/shop-pay.css" 1
  dw_code=$(http_body "$BANK_PUBLIC/intro/demo-withdraw.json" "$tmp/dw.json")
  case "$dw_code" in
    200)
      if python3 - "$tmp/dw.json" <<'PY'
import json, re, sys
d = json.load(open(sys.argv[1]))
if not d.get("ok", True) and "taler_withdraw_uri" not in d:
    print("not ok"); sys.exit(1)
u = d.get("taler_withdraw_uri") or ""
m = re.match(r"^taler://withdraw/([^/]+)/taler-integration/([0-9a-fA-F-]+)$", u)
if not m:
    print("bad uri:", u[:120]); sys.exit(1)
if ":" not in m.group(1):
    print("missing port:", m.group(1)); sys.exit(1)
print(u[:88])
sys.exit(0)
PY
      then
        ok "bank /intro/demo-withdraw.json" "$(python3 -c 'import json;print(json.load(open("'"$tmp/dw.json"'")).get("taler_withdraw_uri","")[:80])' 2>/dev/null || true)"
        wid=$(python3 -c 'import json;print(json.load(open("'"$tmp/dw.json"'")).get("withdrawal_id",""))' 2>/dev/null || true)
        if [ -n "$wid" ]; then
          check_landing_asset "bank taler-integration withdraw op" \
            "$BANK_PUBLIC/taler-integration/withdrawal-operation/${wid}"
        fi
      else
        fail "bank /intro/demo-withdraw.json" "invalid taler://withdraw shape"
      fi
      ;;
    405|501|404|502|503|000)
      fail "bank /intro/demo-withdraw.json" "HTTP $dw_code (want 200)"
      ;;
    *)
      if [ "${LOCAL_STACK:-1}" = "1" ]; then
        fail "bank /intro/demo-withdraw.json" "HTTP $dw_code want 200"
      else
        warn "bank /intro/demo-withdraw.json" "HTTP $dw_code"
      fi
      ;;
  esac
fi

# Merchant landing shop assets
check_landing_asset "merchant shop-pay.js" "$MERCHANT_PUBLIC/intro/shop-pay.js" 1
check_landing_asset "merchant shop-pay.css" "$MERCHANT_PUBLIC/intro/shop-pay.css" 1

summary
