#!/bin/bash
# cf-ul-fast.sh — fast two-phase UL fetch with AI-vision handoff
#
# Phase 1 (pre-captcha):  navigate → preset form → trusted-click I'm-not-a-robot
#                         → screenshot iframe + crop if image-grid escalates
#                         → output JSON: silent_pass OR image_grid info
#
# AI does vision step (only LLM round-trip).
#
# Phase 2 (post-captcha): click tiles → click Verify → atomic-eval Submit
#                         → poll Downloads → pull → distribute to family MPNs
#                         → output JSON: done with file paths
#
# This collapses the chained-LLM-call problem (~30s of AI latency between
# tile-pick and Submit-fire) into TWO calls with one vision step in between.
# All non-vision work runs in tight bash with sub-second internal latency.
#
# Usage:
#   ./cf-ul-fast.sh --phase pre-captcha --url '<UL-URL>'
#   ./cf-ul-fast.sh --phase post-captcha --tiles 4,9 \
#       --mpns VLMS1500,VLMTG1500,VLMB1500,VLMO1500,VLMY1500
#
# State between phases lives at /tmp/cf-ul-fast-state.json.

set -u
SESSION="${CF_SESSION:-chip-fetcher}"
ACTIVITY="http://127.0.0.1:8786/api/activity"
STATE_FILE="/tmp/cf-ul-fast-state.json"

PHASE=""
URL=""
TILES=""
MPNS=""
GRID="3x3"  # 3x3 (9 tiles) or 4x4 (16 tiles) — UL rotates these to throw off AI; AI sees grid in screenshot and passes to script
ROUND=1

while [[ $# -gt 0 ]]; do
  case "$1" in
    --phase) PHASE="$2"; shift 2 ;;
    --url) URL="$2"; shift 2 ;;
    --tiles) TILES="$2"; shift 2 ;;
    --mpns) MPNS="$2"; shift 2 ;;
    --grid) GRID="$2"; shift 2 ;;
    --round) ROUND="$2"; shift 2 ;;
    *) echo "{\"ok\":false,\"error\":\"unknown arg: $1\"}"; exit 1 ;;
  esac
done

# Grid geometry (derived from iframe rect at click time, not hardcoded).
# bframe internal layout (% of iframe inner dims):
#   - header (blue prompt bar): ~14% of height (top)
#   - tile grid: middle, full width minus ~3% side margins
#   - footer (refresh/audio/info + VERIFY): ~17% of height (bottom)
HEADER_PCT=0.14
FOOTER_PCT=0.17
SIDE_PCT=0.03
if [[ "$GRID" == "4x4" ]]; then
  TILE_COLS=4; TILE_ROWS=4
else
  TILE_COLS=3; TILE_ROWS=3
fi

post_act()  { curl -s -X POST "$ACTIVITY" -H 'Content-Type: application/json' -d "$1" >/dev/null 2>&1 || true; }
clear_act() { curl -s -X POST "$ACTIVITY" -H 'Content-Type: application/json' -d '{}' >/dev/null 2>&1 || true; }

# ===== PHASE 1: pre-captcha =====
if [[ "$PHASE" == "pre-captcha" ]]; then
  [[ -z "$URL" ]] && { echo '{"ok":false,"error":"--url required for pre-captcha phase"}'; exit 1; }

  post_act '{"mpn":"_ul","stage":"navigating UL"}'
  adom-desktop browser_navigate "{\"sessionId\":\"$SESSION\",\"url\":\"$URL\"}" >/dev/null 2>&1
  sleep 5

  # Optionally click "Choose CAD Formats & Download" — exists on vendor.ultralibrarian.com
  # embedded URLs but NOT on app.ultralibrarian.com part-detail pages where the format
  # selectors are inline. Skip the click if the button isn't on the page.
  post_act '{"mpn":"_ul","stage":"opening Choose CAD Formats (if present)"}'
  RECT=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const a=Array.from(document.querySelectorAll('a, button')).find(e=>/choose cad/i.test((e.textContent||'').trim())); if(!a)return null; const r=a.getBoundingClientRect(); return JSON.stringify({x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)});})()\"}" 2>/dev/null \
    | python3 -c "import sys,json; d=json.load(sys.stdin); r=json.loads(d.get('result','{}') or '{}'); print(r.get('x',0), r.get('y',0))" 2>/dev/null)
  CX=$(echo "$RECT" | awk '{print $1}')
  CY=$(echo "$RECT" | awk '{print $2}')
  if [[ -n "$CX" && "$CX" != "0" ]]; then
    adom-desktop browser_input_dispatch "{\"sessionId\":\"$SESSION\",\"type\":\"click\",\"x\":$CX,\"y\":$CY}" >/dev/null 2>&1
    sleep 3
  fi
  # On UL Pro pages, format selectors may be in collapsible category panels — expand them
  # so the format-checkbox preset finds them.
  adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const cats=Array.from(document.querySelectorAll('a, button, [role=button], div')).filter(e=>{const t=(e.textContent||'').trim(); return /^(3D CAD Model|KiCAD|Altium|Autodesk|Fusion)\\s*[▶▼►▾]?\$/i.test(t);}); for(const c of cats){c.click();} return cats.length+' cats expanded';})()\"}" >/dev/null 2>&1
  sleep 1

  # Preset form (canonical four + T&C + mm)
  post_act '{"mpn":"_ul","stage":"presetting form (STEP + KiCad v6+ + Altium + Fusion 360 + mm + T&C)"}'
  adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const cbs=Array.from(document.querySelectorAll('input[type=checkbox]')); const want=[/^STEP\$/i,/kicad v6/i,/^altium designer/i,/^fusion.?360/i,/agree|terms/i]; let n=0; for(const re of want){const c=cbs.find(c=>re.test((c.parentElement?.textContent||c.closest('label')?.textContent||'').trim())); if(c&&!c.checked){c.click();n++;}} const sels=Array.from(document.querySelectorAll('select')); const u=sels.find(s=>Array.from(s.options).some(o=>/metric|mm/i.test(o.textContent))); if(u){const m=Array.from(u.options).find(o=>/metric|mm/i.test(o.textContent)); u.value=m.value; u.dispatchEvent(new Event('change',{bubbles:true}));} return n+' set';})()\"}" >/dev/null 2>&1
  sleep 1

  # Install JS hooks: MutationObserver auto-submit + DOM cache. Once token populates,
  # Submit fires within one event-loop tick (no 200ms polling latency).
  post_act '{"mpn":"_ul","stage":"installing auto-submit hook"}'
  adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{if(window.__cf)return 'already';window.__cf={submitted:false,submittedAt:0,tokenLen:0};const ta=document.querySelector('[name=g-recaptcha-response]');if(!ta)return 'no_token_textarea';const findSubmit=()=>Array.from(document.querySelectorAll('a, button, input[type=submit]')).find(e=>/^submit\$/i.test((e.textContent||e.value||'').trim()));const fire=()=>{const t=ta.value||'';if(t.length<=20||window.__cf.submitted)return;window.__cf.tokenLen=t.length;const btn=findSubmit();if(btn){btn.click();window.__cf.submitted=true;window.__cf.submittedAt=Date.now();}};const obs=new MutationObserver(fire);obs.observe(ta,{attributes:true,attributeFilter:['value'],characterData:true,subtree:true,childList:true});window.__cf.obs=obs;window.__cf.poll=setInterval(fire,100);return 'installed';})()\"}" >/dev/null 2>&1

  # Mark a unix-second checkpoint BEFORE the captcha. desktop_watch_files
  # (adom-desktop v1.5+) accepts unix seconds for `since` so we only see
  # files written AFTER this checkpoint — no shell, no PowerShell.
  post_act '{"mpn":"_ul","stage":"marking pre-captcha checkpoint"}'
  BEFORE_TS=$(date +%s)
  echo "$BEFORE_TS" > /tmp/cf-ul-fast-before.txt

  # Trigger captcha — two paths:
  #   (a) vendor.ultralibrarian.com/<mfr>/embedded — anchor iframe "I'm not a robot"
  #   (b) app.ultralibrarian.com/details/...     — "Download Now" button (invisible reCAPTCHA)
  # Try (a) first; fall back to (b).
  post_act '{"mpn":"_ul","stage":"triggering captcha (anchor or Download Now)"}'
  IFR=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const f=document.querySelector('iframe[src*=anchor]') || document.querySelector('iframe[src*=recaptcha]'); if(!f)return null; const r=f.getBoundingClientRect(); if(r.width<10||r.height<10)return null; return JSON.stringify({x:r.x|0,y:r.y|0});})()\"}" 2>/dev/null \
    | python3 -c "import sys,json; d=json.load(sys.stdin); r=json.loads(d.get('result','{}') or '{}'); print(r.get('x',0), r.get('y',0))" 2>/dev/null)
  IX=$(echo $IFR | awk '{print $1}')
  IY=$(echo $IFR | awk '{print $2}')
  if [[ -n "$IX" && "$IX" != "0" ]]; then
    # Path A — visible anchor checkbox (vendor.ultralibrarian.com embedded)
    IX=$((IX + 30))
    IY=$((IY + 40))
    adom-desktop browser_input_dispatch "{\"sessionId\":\"$SESSION\",\"type\":\"move\",\"x\":$((IX-200)),\"y\":$((IY-100)),\"steps\":5}" >/dev/null 2>&1
    sleep 0.4
    adom-desktop browser_input_dispatch "{\"sessionId\":\"$SESSION\",\"type\":\"move\",\"x\":$IX,\"y\":$IY,\"steps\":15}" >/dev/null 2>&1
    sleep 0.3
    adom-desktop browser_input_dispatch "{\"sessionId\":\"$SESSION\",\"type\":\"click\",\"x\":$IX,\"y\":$IY}" >/dev/null 2>&1
    sleep 4
  else
    # Path B — UL Pro page; click "Download Now" button which triggers invisible reCAPTCHA
    post_act '{"mpn":"_ul","stage":"clicking Download Now (UL Pro flow)"}'
    adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const b=Array.from(document.querySelectorAll('button, a')).find(e=>/^Download\\s*Now\$/i.test((e.textContent||e.value||'').trim())); if(!b) return 'no btn'; b.click(); return 'clicked';})()\"}" >/dev/null 2>&1
    sleep 5
  fi

  # Read state
  STATE=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const t=(document.querySelector('[name=g-recaptcha-response]')?.value||'').length; const bf=document.querySelector('iframe[src*=bframe]'); const r=bf?bf.getBoundingClientRect():null; const visible=r&&r.y>0&&r.y<2000; return JSON.stringify({tokenLen:t,bframe:r?{x:r.x|0,y:r.y|0,w:r.width|0,h:r.height|0}:null,visible:visible,dpr:window.devicePixelRatio});})()\"}" 2>/dev/null \
    | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))")

  TOKLEN=$(echo "$STATE" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('tokenLen',0))")

  if [[ "$TOKLEN" -gt 20 ]]; then
    # Silent pass — Submit immediately, but defer to phase 2 (so AI sees the structured output)
    cat > "$STATE_FILE" <<EOF
{"phase1_completed_at":"$(date -u +%FT%TZ)","silent_pass":true,"url":"$URL"}
EOF
    cat <<EOF
{"ok":true,"stage":"silent_pass","_hint":"reCAPTCHA passed silently. Run: cf-ul-fast.sh --phase post-captcha --mpns <comma-list>"}
EOF
    exit 0
  fi

  # Image grid escalated — screenshot + crop
  post_act '{"mpn":"_ul","stage":"image grid escalated — cropping for AI vision"}'
  adom-desktop browser_screenshot "{\"sessionId\":\"$SESSION\",\"path\":\"/tmp/cf-ul-fullpage.png\",\"maxWidth\":2000}" >/dev/null 2>&1
  # browser_screenshot writes to its own conduit-screenshots dir; find latest
  LATEST=$(ls -1t /tmp/conduit-screenshots/screenshot-*.png 2>/dev/null | head -1)
  cp "$LATEST" /tmp/cf-ul-fullpage.png

  # Crop bframe region using state from above
  python3 << PYEOF > /tmp/cf-ul-bframe-meta.json
import json
from PIL import Image
state = json.loads("""$STATE""")
bf = state.get('bframe') or {}
dpr = state.get('dpr', 1.5)
img = Image.open('/tmp/cf-ul-fullpage.png')
sw, sh = img.size
x = int(bf.get('x',0) * dpr)
y = int(bf.get('y',0) * dpr)
w = int(bf.get('w',0) * dpr)
h = int(bf.get('h',0) * dpr)
crop = img.crop((x, y, min(x+w, sw), min(y+h, sh)))
crop.save('/tmp/cf-ul-bframe-crop.png')
crop_2x = crop.resize((crop.size[0]*2, crop.size[1]*2))
crop_2x.save('/tmp/cf-ul-bframe-crop-2x.png')
print(json.dumps({
  "iframe_png": "/tmp/cf-ul-bframe-crop.png",
  "iframe_png_2x": "/tmp/cf-ul-bframe-crop-2x.png",
  "size": list(crop.size),
  "size_2x": list(crop_2x.size),
  "iframe_css_rect": bf,
  "dpr": dpr
}))
PYEOF
  META=$(cat /tmp/cf-ul-bframe-meta.json)

  # Try to extract the prompt + grid size from the iframe DOM (best-effort — bframe is cross-origin)
  # Most reCAPTCHA prompts surface as "Select all images with X" — for now we leave grid detection to AI vision.

  # Save state for phase 2
  cat > "$STATE_FILE" <<EOF
{"phase1_completed_at":"$(date -u +%FT%TZ)","silent_pass":false,"url":"$URL","iframe_meta":$META}
EOF

  cat <<EOF
{"ok":true,"stage":"image_grid","round":1,"iframe_png":"/tmp/cf-ul-bframe-crop.png","iframe_png_2x":"/tmp/cf-ul-bframe-crop-2x.png","_hint":"Read iframe_png_2x. UL rotates captcha types — note (a) grid size (3x3 = 9 tiles, 4x4 = 16 tiles), (b) prompt text in the blue header, (c) whether it says 'Select all images with X' (one-shot) vs 'Click verify once there are none left' (iterative; may need multiple rounds). Then call: cf-ul-fast.sh --phase post-captcha --tiles <list> --mpns <list> --grid <3x3-or-4x4>. Tiles numbered left-to-right, top-to-bottom (1-9 for 3x3, 1-16 for 4x4)."}
EOF
  exit 0

# ===== PHASE 2: post-captcha =====
elif [[ "$PHASE" == "post-captcha" ]]; then
  [[ -z "$MPNS" ]] && { echo '{"ok":false,"error":"--mpns required for post-captcha phase"}'; exit 1; }

  if [[ ! -f "$STATE_FILE" ]]; then
    echo "{\"ok\":false,\"error\":\"no $STATE_FILE — run --phase pre-captcha first\"}"
    exit 1
  fi

  SILENT=$(python3 -c "import json; print(json.load(open('$STATE_FILE')).get('silent_pass',False))")
  IFRAME_RECT=$(python3 -c "import json; m=json.load(open('$STATE_FILE')).get('iframe_meta',{}).get('iframe_css_rect',{}); print(m.get('x',0),m.get('y',0),m.get('w',0),m.get('h',0))")
  read IX IY IW IH <<< "$IFRAME_RECT"

  if [[ "$SILENT" == "False" || "$SILENT" == "false" ]]; then
    if [[ -z "$TILES" ]]; then
      echo '{"ok":false,"error":"image_grid was active in phase 1 — --tiles is required"}'
      exit 1
    fi

    # Click each tile — coords derived from CURRENT iframe rect (not hardcoded)
    post_act "{\"mpn\":\"_ul\",\"stage\":\"AI vision round $ROUND: clicking tiles $TILES ($GRID)\"}"
    # Compute grid geometry from iframe rect (CSS pixels)
    GRID_X0=$(python3 -c "print(int($IX + $IW * $SIDE_PCT))")
    GRID_Y0=$(python3 -c "print(int($IY + $IH * $HEADER_PCT))")
    GRID_W=$(python3 -c "print(int($IW * (1 - 2 * $SIDE_PCT)))")
    GRID_H=$(python3 -c "print(int($IH * (1 - $HEADER_PCT - $FOOTER_PCT)))")
    TW=$(python3 -c "print($GRID_W / $TILE_COLS)")
    TH=$(python3 -c "print($GRID_H / $TILE_ROWS)")
    IFS=',' read -ra TILE_ARR <<< "$TILES"
    for T in "${TILE_ARR[@]}"; do
      ROW=$(( (T - 1) / TILE_COLS ))
      COL=$(( (T - 1) % TILE_COLS ))
      X=$(python3 -c "print(int($GRID_X0 + ($COL + 0.5) * $TW))")
      Y=$(python3 -c "print(int($GRID_Y0 + ($ROW + 0.5) * $TH))")
      adom-desktop browser_input_dispatch "{\"sessionId\":\"$SESSION\",\"type\":\"click\",\"x\":$X,\"y\":$Y}" >/dev/null 2>&1 &
    done
    wait
    sleep 0.2

    # Click Verify — bottom-right of iframe footer.
    # Footer is the last $FOOTER_PCT of iframe height; VERIFY button sits ~75px in from right edge, ~40px above bottom (CSS).
    VX=$((IX + IW - 75))
    VY=$((IY + IH - 40))
    post_act "{\"mpn\":\"_ul\",\"stage\":\"clicking Verify\"}"
    adom-desktop browser_input_dispatch "{\"sessionId\":\"$SESSION\",\"type\":\"click\",\"x\":$VX,\"y\":$VY}" >/dev/null 2>&1
    sleep 0.5

    # Detect iterative-captcha escalation: bframe still visible + no token = new round needed
    ROUND2=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const t=(document.querySelector('[name=g-recaptcha-response]')?.value||'').length; const bf=document.querySelector('iframe[src*=bframe]'); const r=bf?bf.getBoundingClientRect():null; const visible=r&&r.y>0&&r.y<2000; return JSON.stringify({tokenLen:t,bframe:r?{x:r.x|0,y:r.y|0,w:r.width|0,h:r.height|0}:null,visible:visible});})()\"}" 2>/dev/null \
      | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))")
    R2_TOK=$(echo "$ROUND2" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('tokenLen',0))")
    R2_VIS=$(echo "$ROUND2" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('visible',False))")
    if [[ "$R2_TOK" == "0" && ( "$R2_VIS" == "True" || "$R2_VIS" == "true" ) ]]; then
      # Iterative captcha — new tiles appeared. Re-screenshot + output for AI vision again.
      post_act '{"mpn":"_ul","stage":"iterative captcha — round 2 escalation, re-screenshotting"}'
      adom-desktop browser_screenshot "{\"sessionId\":\"$SESSION\",\"path\":\"/tmp/cf-ul-fullpage.png\",\"maxWidth\":2000}" >/dev/null 2>&1
      LATEST=$(ls -1t /tmp/conduit-screenshots/screenshot-*.png 2>/dev/null | head -1)
      cp "$LATEST" /tmp/cf-ul-fullpage.png
      python3 << PYEOF >/dev/null
import json
from PIL import Image
state = json.loads("""$ROUND2""")
bf = state.get('bframe') or {}
dpr = 1.5  # pup default
img = Image.open('/tmp/cf-ul-fullpage.png')
sw, sh = img.size
x = int(bf.get('x',0) * dpr); y = int(bf.get('y',0) * dpr)
w = int(bf.get('w',0) * dpr); h = int(bf.get('h',0) * dpr)
crop = img.crop((x, y, min(x+w, sw), min(y+h, sh)))
crop.save('/tmp/cf-ul-bframe-crop.png')
crop.resize((crop.size[0]*2, crop.size[1]*2)).save('/tmp/cf-ul-bframe-crop-2x.png')
PYEOF
      NEXT_ROUND=$((ROUND + 1))
      cat <<EOF
{"ok":true,"stage":"image_grid_round_${NEXT_ROUND}","round":${NEXT_ROUND},"iframe_png":"/tmp/cf-ul-bframe-crop.png","iframe_png_2x":"/tmp/cf-ul-bframe-crop-2x.png","_hint":"Iterative captcha round ${NEXT_ROUND}. UL rotates approaches — re-read iframe_png_2x carefully (grid may be 3x3 or 4x4, prompt may differ from round 1, type may switch between 'select all' and 'click verify once there are none left'). Call --phase post-captcha --tiles <new-list> --mpns <same-list> --grid <3x3-or-4x4> --round ${NEXT_ROUND}. State preserved."}
EOF
      exit 0
    fi
  fi

  # Atomic-eval poll for token + auto-Submit (90s budget, 200ms cadence — TIGHT)
  post_act '{"mpn":"_ul","stage":"atomic-eval token watcher (Submit fires on token populate)"}'
  SUBMITTED=""
  for i in $(seq 1 450); do
    sleep 0.2
    RES=$(adom-desktop browser_eval "{\"sessionId\":\"$SESSION\",\"expr\":\"(()=>{const t=(document.querySelector('[name=g-recaptcha-response]')?.value||''); if(t.length>20){const a=Array.from(document.querySelectorAll('a, button, input[type=submit]')).find(e=>/^submit\$/i.test((e.textContent||e.value||'').trim())); if(a){a.click();return 'SUBMITTED_'+a.tagName;} return 'NO_BTN';} return 'WAIT';})()\"}" 2>/dev/null \
      | python3 -c "import sys,json; print(json.load(sys.stdin).get('result',''))")
    case "$RES" in
      SUBMITTED*) SUBMITTED="$RES"; break ;;
      NO_BTN) echo "{\"ok\":false,\"error\":\"token populated but Submit btn not found\",\"attempt\":$i}"; exit 1 ;;
    esac
  done

  if [[ -z "$SUBMITTED" ]]; then
    echo '{"ok":false,"error":"timeout waiting for token — captcha may have failed or token expired"}'
    exit 1
  fi

  # Watch Downloads for new ul_*.zip and pull it in one shot — no shell,
  # no PowerShell, no user-approval dialog.
  post_act '{"mpn":"_ul","stage":"watching Downloads for ul_*.zip"}'
  BEFORE_TS=$(cat /tmp/cf-ul-fast-before.txt)
  PULL=$(adom-desktop desktop_pull_glob "{\"path\":\"%USERPROFILE%\\\\Downloads\",\"glob\":\"ul_*.zip\",\"since\":$BEFORE_TS,\"wait\":true,\"timeoutMs\":60000,\"saveTo\":\"/home/adom/project/chip-fetcher/incoming/\"}" 2>/dev/null)
  ZIP_PATH=$(echo "$PULL" | python3 -c "import sys,json; d=json.load(sys.stdin); f=(d.get('files') or [{}])[0]; print(f.get('path',''))")
  if [[ -z "$ZIP_PATH" || ! -f "$ZIP_PATH" ]]; then
    echo "{\"ok\":false,\"error\":\"timeout — no new ul_*.zip after Submit\",\"detail\":$(echo "$PULL" | python3 -c 'import sys,json; print(json.dumps(json.load(sys.stdin)))')}"
    exit 1
  fi
  ZIP_NAME=$(basename "$ZIP_PATH")

  # Extract once to a tmp dir
  EXTRACT_DIR=$(mktemp -d)
  unzip -q -o "$ZIP_PATH" -d "$EXTRACT_DIR"

  # Distribute KiCad + Altium + Fusion + STEP to each MPN's library dir (family-distribution)
  post_act '{"mpn":"_ul","stage":"distributing KiCad+Altium+Fusion+STEP to family MPNs"}'
  IFS=',' read -ra MPN_ARR <<< "$MPNS"
  DISTRIBUTED=""
  for MPN in "${MPN_ARR[@]}"; do
    LIB="library/$MPN"
    mkdir -p "$LIB"
    # KiCad: .kicad_sym + .kicad_mod (under KiCADv6/ in UL zip)
    SYM=$(find "$EXTRACT_DIR" -name "*.kicad_sym" | head -1)
    MOD=$(find "$EXTRACT_DIR" -name "*.kicad_mod" | head -1)
    # Altium: .SchLib + .PcbLib (under Altium/<MPN>/ in UL zip)
    ALT_SCH=$(find "$EXTRACT_DIR" -name "*.SchLib" | head -1)
    ALT_PCB=$(find "$EXTRACT_DIR" -name "*.PcbLib" | head -1)
    # Fusion 360: .lbr (under Fusion360/ in UL zip)
    FUS_LBR=$(find "$EXTRACT_DIR" -name "*.lbr" | head -1)
    # STEP (top-level OR inside Fusion360/ / Altium/)
    STEP_F=$(find "$EXTRACT_DIR" \( -name "*.step" -o -name "*.stp" \) | head -1)
    [[ -n "$SYM"     ]] && cp "$SYM"     "$LIB/$MPN.kicad_sym"
    [[ -n "$MOD"     ]] && cp "$MOD"     "$LIB/$MPN.kicad_mod"
    [[ -n "$ALT_SCH" ]] && cp "$ALT_SCH" "$LIB/$MPN.SchLib"
    [[ -n "$ALT_PCB" ]] && cp "$ALT_PCB" "$LIB/$MPN.PcbLib"
    [[ -n "$FUS_LBR" ]] && cp "$FUS_LBR" "$LIB/$MPN.lbr"
    # Don't overwrite mfr-direct STEP if already imported
    if [[ -n "$STEP_F" && ! -f "$LIB/$MPN.step" ]]; then
      cp "$STEP_F" "$LIB/$MPN.step"
    fi
    DISTRIBUTED="$DISTRIBUTED \"$MPN\","
    post_act "{\"mpn\":\"$MPN\",\"stage\":\"KiCad+Altium+Fusion imported (mfr-via-UL)\"}"
  done
  DISTRIBUTED="[${DISTRIBUTED%,}]"

  # Update info.json per MPN with mfr-via-UL provenance for sym/mod
  for MPN in "${MPN_ARR[@]}"; do
    INFO="library/$MPN/info.json"
    if [[ -f "$INFO" ]]; then
      python3 -c "
import json
p = '$INFO'
d = json.load(open(p))
d['sym_source'] = 'mfr-via-UL'
d['sym_url'] = '$URL'
d['mod_source'] = 'mfr-via-UL'
d['mod_url'] = '$URL'
d['ul_zip'] = '$ZIP_NAME'
json.dump(d, open(p,'w'), indent=2)
"
    fi
  done

  rm -rf "$EXTRACT_DIR"
  clear_act

  cat <<EOF
{"ok":true,"stage":"done","ul_zip":"$NEW","distributed_to":$DISTRIBUTED,"_hint":"sym+mod imported to library/<MPN>/. Refresh chip-fetcher dashboard to see the chips promoted from stub to bundled."}
EOF
  exit 0

else
  cat <<EOF
{"ok":false,"error":"--phase must be 'pre-captcha' or 'post-captcha'","_hint":"Phase 1: cf-ul-fast.sh --phase pre-captcha --url <UL-URL>. Phase 2: cf-ul-fast.sh --phase post-captcha --tiles 4,9 --mpns A,B,C"}
EOF
  exit 1
fi