12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703
#!/usr/bin/env python3
"""Adom Fusion 360 Bridge Server — localhost HTTP server for Fusion 360 integration.

Receives commands from the Adom Desktop (Tauri app) and controls
Fusion 360 via Win32 API, os.startfile, and the AdomBridge add-in.

Usage:
    python server.py
    python server.py --port 8773
"""

import json
import os
import sys
import traceback
import urllib.request
import urllib.error
import urllib.parse
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from pathlib import Path

# Ensure the plugin root is on the path
sys.path.insert(0, str(Path(__file__).parent))

from fusion_detect import detect_fusion, ensure_fusion_running, wait_for_addin, _is_fusion_running, fusion_update_in_progress, find_licensing_dialog, family_windows, _incomplete_webdeploy_present
from handlers.open_design import handle_open_design
from handlers.close_fusion import handle_close_fusion, handle_fusion_stop, handle_fusion_kill
from handlers.dismiss_recovery import dismiss_recovery_dialog
from handlers.fusion_ui import (
    screenshot_fusion_window,
    screenshot_hwnd,
    click_fusion,
    send_key_to_fusion,
    close_window,
    get_fusion_window_info,
)
from handlers.dialog_classify import classify_blocking_dialogs, classify_launch_dialogs, close_dialog_bg, AUTO_DISMISS_CATEGORIES
from install_addin import install as install_addin
import aps
import describe
import ad_client

DEFAULT_PORT = 8773
ADDIN_PORT = 8774

# Read once at load so /status reports the same version we ship + bump.
try:
    BRIDGE_VERSION = (Path(__file__).parent / "BRIDGE_VERSION").read_text(encoding="utf-8").strip()
except Exception:
    BRIDGE_VERSION = "unknown"

# The add-in version this bridge BUNDLES (the one install_addin syncs into
# Fusion's Roaming AddIns dir). Read from our bundled manifest so it can't drift
# from a hardcode. Compared against the RUNNING add-in's reported version to
# catch a stale add-in (issue #55) - the running copy only re-syncs when Fusion
# is closed on bridge start, so a user who never restarted Fusion keeps an old
# add-in and commands like fusion_aps_open fail silently. We make it LOUD.
try:
    EXPECTED_ADDIN_VERSION = (json.loads(
        (Path(__file__).parent / "addin" / "AdomBridge" / "AdomBridge.manifest").read_text(encoding="utf-8")
    ) or {}).get("version", "unknown")
except Exception:
    EXPECTED_ADDIN_VERSION = "unknown"


def _addin_staleness(reported_version) -> dict:
    """Compare the running add-in's reported version to the bundled one.

    Returns {addinVersion, expectedAddinVersion, addinStale, _staleHint?}. A
    stale add-in is the #55 silent-failure: re-sync it by RESTARTING Fusion
    (fusion_stop then fusion_start) so the bridge's on-start install_addin can
    overwrite the Roaming copy (which is file-locked while Fusion runs)."""
    info = {
        "addinVersion": reported_version or "unknown",
        "expectedAddinVersion": EXPECTED_ADDIN_VERSION,
    }
    stale = (
        reported_version not in (None, "unknown")
        and EXPECTED_ADDIN_VERSION not in (None, "unknown")
        and str(reported_version) != str(EXPECTED_ADDIN_VERSION)
    )
    info["addinStale"] = bool(stale)
    if stale:
        info["_staleHint"] = (
            f"STALE add-in: Fusion is running add-in v{reported_version} but this bridge "
            f"bundles v{EXPECTED_ADDIN_VERSION}. Newer verbs (e.g. fusion_aps_open/open_by_urn) "
            f"can fail silently. Fix: fusion_stop then fusion_start - the bridge re-syncs the "
            f"add-in from its cache on start (only possible with Fusion CLOSED)."
        )
    return info

# Populated at startup
fusion_info = None


def _reclaim_seat_from_peers() -> int:
    """Free the Autodesk seat held by another machine by stopping Fusion on peer ADs.

    John (2026-07-06): "if the user asked you to do something with fusion you should
    just grab the license back. the user can always grab it back on the other machine,
    so there's no harm." So on a seat conflict we do NOT ask - we free the seat at the
    SOURCE via the cross-AD direct API (fully background + reliable, no CEF-dialog
    clicking), then relaunch locally with no conflict. Best-effort; returns the number
    of peers signalled (0 if the direct API/peers are unavailable)."""
    n = 0
    try:
        if not ad_client.available():
            return 0
        for peer in ad_client.peers():
            try:
                if ad_client.call("fusion_stop", {}, target=peer) is not None:
                    n += 1
            except Exception:
                pass
    except Exception:
        pass
    return n


def _resolve_seat_via_uia(dlg: dict) -> bool:
    """Resolve the 'Active Sessions Exceeded' seat dialog by UIA-invoking its native
    'Continue' button (Suspend is pre-selected) - entirely IN THE BACKGROUND.

    THE breakthrough (John 2026-07-06, after a long day of failing): UIA Invoke does
    NOT foreground the window, so this grabs the license without ever disturbing the
    user - unlike SendInput coordinate clicks (need foreground) or kill+relaunch (the
    server keeps the seat). Proven live: `desktop_ui_click {hwnd, name:"Continue"}`
    returned "Clicked in the BACKGROUND - the window did NOT come to the foreground"
    and the dialog cleared. The bridge calls the AD `desktop_ui_click` verb on ITSELF
    via the AD 1.9.84 direct API. Never raises."""
    try:
        hwnd = dlg.get("hwnd")
        if not hwnd or not ad_client.available():
            return False
        res = ad_client.call("desktop_ui_click", {"hwnd": hwnd, "name": "Continue"}, timeout=4)
        return bool(res and (res.get("success") or res.get("status") == "ok"))
    except Exception:
        return False


def _owned_popup_count(main_hwnd) -> int:
    """ownedPopupCount on a window via AD's desktop_screenshot_window - the SAME
    parent/child screenshot signal John kept pointing at (2026-07-06): a modal seat/
    error dialog shows up as an OWNED POPUP of the main Fusion window. This is the
    ground-truth 'is a dialog still up' check used to VERIFY a seat dialog actually
    cleared, instead of trusting a window-size heuristic (which false-'resolved' the
    823x262 'Suspend Remote Session' confirm). Returns the count, or -1 if unavailable."""
    try:
        if not main_hwnd or not ad_client.available():
            return -1
        res = ad_client.call("desktop_screenshot_window", {"hwnd": int(main_hwnd)}, timeout=4)
        if isinstance(res, dict):
            c = res.get("ownedPopupCount")
            if c is None and isinstance(res.get("data"), dict):
                c = res["data"].get("ownedPopupCount")
            if c is not None:
                return int(c)
    except Exception:
        pass
    return -1


def _resolve_seat_dialog(max_clicks: int = 3) -> dict:
    # max_clicks kept LOW (was 8): readiness calls this every tick and it SELF-HEALS across ticks, so
    # a single call must stay responsive - 8 clicks x (UIA click + screenshot-verify, each up to its
    # timeout) could blow readiness's whole budget and make the bridge look hung (John 2026-07-14).
    """Detect + resolve the seat/licensing modal deterministically, IN THE BACKGROUND,
    and VERIFY it truly cleared by SCREENSHOTTING the parent window's ownedPopupCount -
    not by re-running a size heuristic (that false-'resolved' bit John hard, 2026-07-06:
    the code clicked once, the size filter then failed to re-detect the short confirm
    variant, and readiness lied that the dialog was gone while it sat there blocking).

    Each pass: find the dialog -> UIA-invoke 'Continue' (background, Suspend pre-selected
    so it grabs the license) -> screenshot the parent's ownedPopupCount. Done only when
    BOTH find_licensing_dialog() sees nothing AND the parent shows 0 owned popups (or the
    screenshot signal is unavailable and the heuristic agrees). Returns
    {sawDialog, clicks, verified}. Never raises."""
    import time as _t
    clicks = 0
    saw = False
    verified = False
    for _ in range(max_clicks):
        try:
            dlg = find_licensing_dialog()
        except Exception:
            dlg = None
        if not dlg:
            verified = True
            break
        saw = True
        parent = dlg.get("owner")
        if _resolve_seat_via_uia(dlg):     # count only clicks that actually landed, so
            clicks += 1                    # seatDialogAutoResolved isn't a lie when ad_client is down
        _t.sleep(1.5)
        # Ground-truth confirm via parent/child screenshot (John's insisted-on check).
        cnt = _owned_popup_count(parent)
        if cnt == 0:
            verified = True
            break
        # cnt == -1 (screenshot unavailable): fall through, the next find_licensing_dialog
        # pass decides. cnt > 0: still up, loop and click again.
    return {"sawDialog": saw, "clicks": clicks, "verified": verified}


def _find_adom_desktop_cli() -> str | None:
    """Locate the adom-desktop CLI/exe so the bridge can drive AD's relay directly (used when the
    in-process ad_client is unavailable, e.g. on a headless VM). Checks the per-user Windows install
    dir, then PATH, then the common Linux/dev locations. Returns a path or None."""
    import shutil
    la = os.environ.get("LOCALAPPDATA", "")
    candidates = [
        os.path.join(la, "Adom Desktop", "adom-desktop.exe") if la else None,
        os.path.join(la, "Programs", "Adom Desktop", "adom-desktop.exe") if la else None,
    ]
    for c in candidates:
        if c and os.path.exists(c):
            return c
    return shutil.which("adom-desktop") or shutil.which("adom-desktop.exe")


def _cli_notify_all(title: str, body: str, level: str) -> dict:
    """Deliver a toast to EVERY connected desktop via `adom-desktop --target all notify_user`. This is
    the reliable cross-AD path when the bridge's in-process ad_client is down (headless VM): the CLI
    joins the relay itself and `--target all` reaches the user's real machine (not just this VM).
    Best-effort + never raises. Returns {delivered, targets, error}. (John 2026-07-14: the notify MUST
    actually leave the box - a returned-but-unsent payload is the bug, not a feature.)"""
    import subprocess as _sp
    exe = _find_adom_desktop_cli()
    if not exe:
        return {"delivered": False, "error": "adom-desktop CLI not found"}
    # STICKY by default: a human-wall alert must NOT vanish in a few seconds (John 2026-07-14 - the
    # first toasts disappeared before he noticed them). scenario:reminder keeps it on screen until the
    # user acts; it REQUIRES >=1 button, so include one. durationLong is a belt-and-suspenders ~25s.
    payload = json.dumps({"title": title, "body": body, "level": level,
                          "scenario": "reminder", "durationLong": True,
                          "buttons": [{"label": "Got it"}]})
    try:
        r = _sp.run([exe, "--target", "all", "notify_user", payload],
                    capture_output=True, text=True, timeout=30,
                    creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0))
        out = (r.stdout or "") + (r.stderr or "")
        # AD's CLI returns {status:'ok', action:'displayed'} per target; treat a zero exit or a
        # 'displayed'/'ok' in the output as delivered. The Windows exe may print nothing yet still
        # deliver, so a clean exit code is accepted too.
        delivered = (r.returncode == 0) or ("displayed" in out) or ('"status": "ok"' in out) or ("status':'ok" in out)
        return {"delivered": bool(delivered), "targets": "all", "error": None if delivered else out[:200]}
    except Exception as e:
        return {"delivered": False, "error": str(e)[:200]}


def _handle_notify_owner(args: dict) -> dict:
    """LAST-RESORT: toast the user's MAIN desktop (cross-AD) to ask for a human step.

    John's standing rule (2026-07-07): the bridge/AI does EVERYTHING itself; the only
    legitimate uses are true human walls - password/2FA entry, UAC elevation, a physical
    action. When this bridge runs on an unattended VM, the toast must reach the machine
    the user is actually AT - ad_client.notify(reach_user=True) fans out to all peer ADs
    on the relay, so it lands on their main computer too. Returns which targets were hit."""
    title = args.get("title") or "Fusion bridge needs you"
    body = args.get("body") or args.get("message") or "A human step is required to continue."
    level = args.get("level") or "warning"
    if not ad_client.available():
        # ad_client is the bridge's IN-PROCESS AD API - it is routinely UNAVAILABLE when this bridge
        # runs on an unattended Hyper-V VM (found live 2026-07-14: the toast silently never reached the
        # user, and the AI "forgot" to relay it - exactly the failure John demanded we engineer out).
        # SELF-DELIVER via the adom-desktop CLI, which connects to the relay independently: `--target
        # all` fans the toast out to EVERY connected desktop (the VM + the user's real machines), so it
        # reliably lands on the computer the user is actually AT. No AI relay step to forget.
        cli = _cli_notify_all(title, body, level)
        if cli.get("delivered"):
            return {"success": True, "via": "cli:--target all", "targets": cli.get("targets"),
                    "_hint": ("Toast fanned out to ALL connected desktops (the user's main machine "
                              "included) via the adom-desktop CLI, because the bridge's in-process AD "
                              "API was unavailable (this VM). WAIT and poll fusion_readiness; do not "
                              "re-toast within a few minutes. Only notify when the box is ACTUALLY "
                              "ready for the user to act - do not toast for a step you can do yourself.")}
        # CLI fallback also failed - return the payload so the AI can relay as the true last resort.
        return {
            "success": False,
            "error": "AD in-process API unavailable AND the adom-desktop CLI fallback failed: "
                     + str(cli.get("error"))[:200],
            "notifyUser": {"title": title, "body": body, "level": level},
            "_hint": ("Relay the notifyUser payload yourself: `adom-desktop --target all notify_user "
                      "{title, body, level}` (or `--target <the user's host>`)."),
        }
    res = ad_client.notify(title, body, level=level, reach_user=True) or {}
    # reach_user fans out via target="all", whose response is a BROADCAST envelope
    # {results:{host:{action}}, summary:{ok,failed,total}, targets:[...]}. Recognize that shape for
    # success (summary.ok>0) - not just the single-target {status:"ok"} - and report the ACTUAL
    # desktops reached, so a toast that landed on the user's laptop isn't mis-reported as failed.
    summary = res.get("summary") or {}
    ok = bool(res.get("success") or res.get("status") == "ok" or summary.get("ok", 0) > 0)
    reached = res.get("targets") or (["self"] if ok else [])
    return {
        "success": ok,
        "via": "ad_client:direct(all)",
        "targets": reached,
        "_hint": ("Toast fanned out to ALL connected desktops (the user's machine included) via the "
                  "in-process AD direct API. This is the LAST RESORT - only use after exhausting "
                  "programmatic options (UIA background clicks, seat auto-resolve, warm-SSO sign-in), "
                  "and only when the box is ACTUALLY ready for the user to act. Now WAIT and poll "
                  "fusion_readiness for the state to clear; do not re-toast within a few minutes."),
    }


_last_fg_notice = [0.0]


def _notify_before_foreground(reason: str) -> None:
    """Baked-in courtesy toast BEFORE the bridge foregrounds Fusion (John, 2026-07-08).

    Some Fusion interactions (SendInput key/click, CEF modal dialogs) can ONLY be driven
    with Fusion in the FOREGROUND, which steals the user's focus mid-work. The standing
    rule: ALWAYS drive in the background; foreground ONLY as a last resort. When we truly
    must, TELL the user why (via an AD notify, so every Adom user learns the principle) and
    rib Fusion for not being AI-native enough to allow it. Debounced so a burst of keystrokes
    fires ONE notice, not dozens. Best-effort; never raises, never blocks the operation."""
    try:
        now = _time.time()
        if now - _last_fg_notice[0] < 45:   # one notice per foreground burst
            return
        _last_fg_notice[0] = now
        body = (
            f"Adom is briefly bringing Fusion to the FOREGROUND: {reason}. I ALWAYS try to do "
            "everything in the background and only foreground when I have NO option left. Fusion "
            "just isn't AI-native/fast enough yet to let me drive this part in the background - "
            "hopefully Autodesk makes their app fully AI-drivable soon so I can skip these "
            "workarounds. Sorry for the interruption!"
        )
        ad_client.notify("Adom is foregrounding Fusion (last resort)", body,
                         level="info", reach_user=True)
    except Exception:
        pass


def _handle_new_electronics_from_eagle(args: dict) -> dict:
    """Import a legacy EAGLE .sch (+ paired .brd) into a NEW Fusion electronics design
    so the parts actually INSTANTIATE (schematic + populated board), then land in the PCB
    editor. Proven live 2026-07-07 on winvm.

    ⭐ PREFER THE BACKGROUND PATH FIRST (learned 2026-07-08, the hard way): if you have a
    `.brd` with the parts ALREADY PLACED (`<elements>` with x/y + an embedded `<library>`),
    do NOT use this verb - call **`fusion_open_board {filePath: <.brd>}`** instead. It opens
    the board via `Document.newDesignFromLocal`, which INSTANTIATES the placed elements with
    ZERO modal file-dialogs and ZERO foreground (this verb's ImportSCHAndBRDCmd pops TWO
    native Open dialogs that STEAL the user's focus - modal dialogs always foreground, there
    is no background way to drive them). A hand-authored EAGLE `.brd` (well-formed XML: layers
    + board outline on layer 20 + libraries/packages + elements) imports cleanly this way.
    ➜ FOR 3D BODIES ON THAT BOARD (not flat pads): embed a `<packages3d>` section (each
    `<package3d name=... wip_urn="urn:adsk.wipprod:fs.file:vf...."/>` from a prior
    `fusion_build_library_3d` bind) in the `.brd`'s `<library>`, AND give every `<element>` a
    `package3d_urn="<that pin's wip_urn>"`. Then `fusion_open_board` + `fusion_show_3d_board`
    renders the REAL component 3D (Fusion resolves the urns from the Hub). No urn on the
    element = a flat pad. (If a urn is stale/unresolved, re-run `fusion_build_library_3d` for
    that part to mint a fresh one.)

    THIS VERB is only for a legacy `.sch` whose parts must be instantiated by Fusion's own
    importer: `Document.newDesignFromLocal <file.sch>` opens the schematic EDITOR but does NOT
    instantiate parts (board_info == 0); Fusion's REAL EAGLE importer is `ImportSCHAndBRDCmd`,
    which pops the two Open dialogs. This verb drives them via the AD direct API when present -
    but that FOREGROUNDS Fusion, so use the `.brd`+`fusion_open_board` route whenever you can
    place the parts yourself. The native `.fsch/.fbrd` container is opaque BINARY, un-authorable.

    args: {schPath: Windows path to the .sch (required), brdPath: Windows path to the .brd
           (optional; defaults to the .sch's sibling .brd)}
    """
    import time as _t
    sch = (args.get("schPath") or args.get("filePath") or "").replace("\\", "/")
    if not sch or not sch.lower().endswith(".sch"):
        return {"success": False, "error": "schPath (a .sch file on the Windows host) is required.",
                "_hint": "Author the EAGLE .sch + paired .brd (same basename, same folder), stage both "
                         "to Windows (send_files), then call with {schPath, brdPath}."}
    brd = (args.get("brdPath") or (sch[:-4] + ".brd")).replace("\\", "/")
    if not ad_client.available():
        # Do NOT dead-end here (old bug: hard-failed even on latest AD when this bridge process
        # didn't get AD's injected direct-API env). The RIGHT move is the background route.
        brd_guess = (args.get("brdPath") or (sch[:-4] + ".brd")).replace("\\", "/")
        return {"success": False, "errorCode": "use_open_board_instead",
                "error": "AD direct API unavailable - and this dialog-driven import FOREGROUNDS Fusion anyway.",
                "_hint": ("Don't drive the Open dialogs. If the parts are placed in the .brd (elements + "
                          "embedded library), call `fusion_open_board {filePath:\"" + brd_guess + "\"}` - it "
                          "instantiates them via newDesignFromLocal with NO dialogs and NO foreground (the "
                          "background way). For 3D bodies, embed <packages3d> + per-element package3d_urn in the "
                          ".brd (see this verb's docstring), then fusion_show_3d_board. Only if you truly have a "
                          "bare .sch that Fusion must import: fire 'Commands.Start ImportSCHAndBRDCmd' via "
                          "fusion_execute_text_command and drive the two Open dialogs (foreground; last resort).")}

    def _find_open_dialog():
        res = ad_client.call("desktop_list_windows", {}) or {}
        out = res.get("output") if isinstance(res, dict) else None
        if isinstance(out, str):
            try: out = json.loads(out)
            except Exception: out = None
        wins = ((out or res).get("data", out or res) or {}).get("windows", []) if isinstance(out or res, dict) else []
        for w in wins:
            if str(w.get("title", "")).strip().lower() == "open":
                return w.get("hwnd")
        return None

    def _pick(hwnd, basename):
        # select the file by accessible name, then click Open - both background UIA
        ad_client.call("desktop_ui_click", {"hwnd": hwnd, "name": basename})
        _t.sleep(1.0)
        ad_client.call("desktop_ui_click", {"hwnd": hwnd, "name": "Open"})

    import os as _os
    sch_base, brd_base = _os.path.basename(sch), _os.path.basename(brd)

    # 1) Fire Fusion's EAGLE importer (opens the first Open dialog).
    _proxy_to_addin("execute_text_command", {"command": "Commands.Start ImportSCHAndBRDCmd"}, timeout=15)

    # 2) Drive the two Open dialogs (sch, then brd). Each appears after a beat; retry.
    picked = []
    for want, base in (("sch", sch_base), ("brd", brd_base)):
        dlg = None
        for _ in range(20):
            _t.sleep(1.5)
            dlg = _find_open_dialog()
            if dlg:
                break
        if not dlg:
            # brd dialog may not appear if Fusion inferred the sibling automatically
            if want == "brd" and picked:
                break
            return {"success": False, "error": f"The '{want}' Open dialog never appeared.",
                    "_hint": "Screenshot the Fusion hwnd + READ screenshots[] for a blocking modal; the importer "
                             "may have errored on the EAGLE source. Verify the .sch/.brd are valid EAGLE.",
                    "data": {"picked": picked}}
        _pick(dlg, base)
        picked.append(base)
        _t.sleep(2.0)

    # 3) Verify by STATE - poll until the imported design is an electronics design.
    ready = False
    for _ in range(40):  # up to ~2 min; import is slow, esp. software-rendered
        _t.sleep(3)
        try:
            st = _proxy_to_addin("get_app_state", {}, timeout=8)
            d = st.get("data") or {}
            if d.get("isElectronics") and str(d.get("activeWorkspace", "")).lower() in ("pcb editor", "schematic editor", "board layout", "3d pcb"):
                ready = True
                break
        except Exception:
            pass  # app_state is None while a modal import dialog blocks - keep polling

    return {
        "success": ready,
        "imported": picked,
        "statusVerb": "fusion_board_info",
        "_hint": (
            "EAGLE design imported via Fusion's own ImportSCHAndBRDCmd (both Open dialogs driven in the "
            "background). It is now an electronics design in the PCB editor. NEXT: fusion_show_2d_board, "
            "then RATSNEST + 'AUTO ;' (fusion_electron_run) to autoroute, fusion_show_3d_board for the 3D. "
            "NOTE: board_info can read 0 right after import (wrong-view query) even though the board is "
            "populated - verify by screenshotting the Fusion hwnd (read screenshots[]) or fusion_show_2d_board "
            "+ WINDOW FIT. If 3D bodies are missing, the placed parts' package3d urns must resolve in THIS "
            "project - build the library's 3D into this project with fusion_build_library_3d (do NOT reuse "
            "another library's urns; they don't resolve cross-project)."
            if ready else
            "Import fired + both Open dialogs driven, but the design did not confirm as electronics within the "
            "budget (a slow VM import can run longer). Poll fusion_get_app_state, and screenshot the Fusion hwnd "
            "reading screenshots[] for a blocking import dialog."
        ),
    }


def _handle_launch(fusion_info: dict, args: dict) -> dict:
    """Launch Fusion 360 and optionally wait for the AdomBridge add-in."""
    # LIVE detect (not the stale bridge-start fusion_info snapshot) so a launch right
    # after a fresh install is recognized (with a current exe_path) and a launch after a
    # removal fails cleanly. Reassign so the whole handler uses fresh paths.
    fusion_info = detect_fusion()
    if not fusion_info.get("installed"):
        return {
            "success": False,
            "error": "Fusion 360 is not installed on this machine.",
            "errorCode": "fusion_not_installed",
            "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in. (For a plain 'what EDA tools are installed' check, AD's bridge_readiness reports this cleanly without erroring.)",
        }

    if _is_fusion_running():
        addin_ok = wait_for_addin(timeout=5)
        return {
            "success": True,
            "output": "Fusion 360 is already running.",
            "addinConnected": addin_ok,
            "alreadyRunning": True,
            "resolvedPath": fusion_info.get("exe_path", ""),
        }

    # Relocate any crash recovery files BEFORE launching Fusion.
    # This prevents the "Recovered Documents" dialog from appearing at all.
    # Files are moved to ~/.adom/recovery/fusion/<timestamp>/ (not deleted).
    from handlers.dismiss_recovery import relocate_recovery_files
    reloc = {"moved": 0, "dest": None}
    try:
        reloc = relocate_recovery_files()
    except Exception:
        pass

    # Launch Fusion. AD's relay caps a single request at ~60s, but a FIRST launch
    # (fresh install: component downloads, updates, cloud sync) can take 2-4 min -
    # far past the cap. So we DON'T block on the add-in for the whole launch (that
    # dead-ended #63/Arav, and {"timeout":300} can't beat the relay cap). Instead:
    # launch + confirm the process, then wait for the add-in only within a budget
    # that keeps this request UNDER the relay cap; if it's still coming up, return a
    # clean stillLaunching response telling the caller to POLL fusion_readiness.
    import time as _t
    _start = _t.time()
    err = ensure_fusion_running(fusion_info, wait_addin=False)
    if err:
        return err

    # WATCH for launch/licensing dialogs the moment the process is up - IN CODE, so
    # a launch is never blind to them (the seat-conflict + streamed-app-error dialogs
    # are owned by AdskIdentityManager/FusionLauncher, invisible to the Fusion-scoped
    # classifier). Auto-dismiss BENIGN errors.
    launch_dialogs = classify_launch_dialogs()
    for _dlg in launch_dialogs:
        if _dlg.get("category") in AUTO_DISMISS_CATEGORIES:
            close_dialog_bg(_dlg.get("hwnd"))  # benign ack (WM_CLOSE == Cancel/OK)

    # ── DETERMINISTIC launch state loop (John 2026-07-06: track + handle state in
    #    CODE, in the BACKGROUND, never punt to the user, never foreground). Poll the
    #    add-in round-trip (the ground truth for "drivable") while, each tick:
    #      • AUTO-RESOLVING the seat/licensing dialog ("Active Sessions Exceeded") via
    #        UIA Invoke of its 'Continue' button - background, no foreground (THE fix;
    #        Suspend is pre-selected so Continue grabs the license). This is what
    #        un-sticks the launch: a blocked seat dialog made Fusion retry sign-in in a
    #        loop, foregrounding on every retry - the source of the endless fg-steal.
    #      • dismissing benign recovery/startup dialogs.
    #    Detection is deterministic (find_licensing_dialog by owning process + size,
    #    NOT the "Fusion360" title). No kill/relaunch (the server keeps the seat), no
    #    notify (we handle it ourselves). ──
    _RELAY_BUDGET = 50  # keep total handler time under AD's ~60s relay cap
    _deadline = _t.time() + max(6, int(_RELAY_BUDGET - (_t.time() - _start)))
    _seat_clicks = 0
    addin_ok = False
    while _t.time() < _deadline:
        # SEAT/LICENSING dialog takes PRIORITY over the add-in probe (fix, caught live
        # 2026-07-06): a STALE add-in port from a prior instance can answer while the
        # CURRENT Fusion sits blocked behind the seat dialog - so we must NOT break on
        # the add-in while the dialog is up. And the first UIA Invoke on a mid-render
        # dialog silently NO-OPs, so keep invoking 'Continue' EVERY tick until
        # find_licensing_dialog no longer sees it (verified gone), not just once.
        try:
            _lic = find_licensing_dialog()
        except Exception:
            _lic = None
        if _lic:
            # Resolve + SCREENSHOT-VERIFY it cleared (parent ownedPopupCount), not a
            # size heuristic that false-'resolved' the short confirm variant.
            if _seat_clicks < 10:
                _r = _resolve_seat_dialog(max_clicks=3)
                _seat_clicks += _r.get("clicks", 0)
            _t.sleep(1.0)
            continue
        # No seat dialog -> is the add-in actually serving? (ground truth for drivable)
        if _probe_addin(timeout=1.5):
            addin_ok = True
            break
        try:
            dismiss_recovery_dialog()
        except Exception:
            pass
        _t.sleep(2.5)

    if not addin_ok:
        # Add-in not up within our budget. On a first launch this is EXPECTED (still
        # initializing / signing in), not a failure and not a retry-able timeout.
        return {
            "success": True,
            "stillLaunching": True,
            "addinConnected": False,
            "statusVerb": "fusion_readiness",
            "recoveryFilesRelocated": reloc["moved"],
            "resolvedPath": fusion_info.get("exe_path", ""),
            "seatDialogResolved": _seat_clicks > 0,
            "backgroundLaunch": True,
            "_hint": (
                "Fusion was launched IN THE BACKGROUND (minimized, foreground-lock on) so it does not "
                "steal the user's focus - do NOT foreground it. "
                + ("A seat 'Active Sessions Exceeded' dialog appeared and was AUTO-RESOLVED in the "
                   "background via UIA (no foreground). " if _seat_clicks else "")
                + "The add-in isn't up yet - a first launch / fresh sign-in can take 2-4 min. This is "
                "NOT a failure and NOT a retry-able timeout: do NOT re-call fusion_start. WHAT HAPPENS "
                "NEXT: POLL fusion_readiness every ~10s; it returns needsSignin/licensingDialog/updating "
                "if something is mid-flight (the bridge handles the seat dialog itself), and ready:true "
                "when it is drivable - then run your fusion_* verbs. NEVER poll blind past ~2 min: "
                "desktop_screenshot_window the Fusion hwnd and READ the response's screenshots[] array "
                "- every OWNED POPUP rides back as its own child shot (this is how you SEE a blocking "
                "dialog; never desktop_screenshot_screen, the user is doing other things)."
            ),
        }

    # Auto-dismiss the "Recovered Documents" dialog if it appeared on launch.
    # Even though we relocated files above, Fusion may still show recovery
    # dialogs if files were locked or if Fusion cached them.
    recovery_dismissed = dismiss_recovery_dialog()

    # Probe the add-in to check if a modal dialog is blocking.
    # If Fusion has a "Recovered Documents" or other modal up, the add-in's
    # main thread will be blocked and commands will timeout. We detect this
    # PROACTIVELY so the AI knows immediately, rather than waiting for a
    # 30-second command timeout.
    dialog_blocking = False
    if addin_ok:
        dialog_blocking = _check_main_thread_blocked()

    output = "Fusion 360 launched successfully."
    if reloc["moved"] > 0:
        output += (
            f" Relocated {reloc['moved']} recovery file(s) to {reloc['dest']}"
            " (preserved for manual recovery if needed)."
        )
    if recovery_dismissed:
        output += " Dismissed 'Recovered Documents' dialog."
    if dialog_blocking:
        output = (
            "Fusion 360 launched but a MODAL DIALOG is blocking the UI. "
            "This is likely the 'Recovered Documents' dialog from a previous crash. "
            "Take a screenshot with desktop_screenshot_window to see what's showing, "
            "then dismiss it manually or use fusion_dismiss_recovery."
        )

    return {
        "success": True,
        "output": output,
        "addinConnected": addin_ok,
        "dialogBlocking": dialog_blocking,
        "recoveryDialogDismissed": recovery_dismissed,
        "recoveryFilesRelocated": reloc["moved"],
        "recoveryFilesPath": reloc["dest"],
        "resolvedPath": fusion_info.get("exe_path", ""),
    }


def _handle_dismiss_recovery(fusion_info: dict, args: dict) -> dict:
    """Dismiss the 'Recovered Documents' dialog if it's showing.

    Recovery files are relocated to ~/.adom/recovery/fusion/<timestamp>/
    instead of being deleted, preserving the user's safety net.
    """
    from handlers.dismiss_recovery import relocate_recovery_files

    # Relocate files first (dismiss_recovery_dialog also does this,
    # but we capture the result here for reporting)
    reloc = {"moved": 0, "dest": None}
    try:
        reloc = relocate_recovery_files()
    except Exception:
        pass

    dismissed = dismiss_recovery_dialog()

    parts = []
    if dismissed:
        parts.append("Dismissed recovery dialog(s).")
    else:
        parts.append("No recovery dialog found.")
    if reloc["moved"] > 0:
        parts.append(
            f"Relocated {reloc['moved']} recovery file(s) to {reloc['dest']}. "
            "The user can restore them from there if needed."
        )

    return {
        "success": True,
        "output": " ".join(parts),
        "dismissed": dismissed,
        "recoveryFilesRelocated": reloc["moved"],
        "recoveryFilesPath": reloc["dest"],
    }


def _post_open_screenshot(result: dict, settle_time: float = 2.0) -> dict:
    """Auto-screenshot Fusion after an open/switch command.

    Waits for Fusion to settle, then captures the full window (including
    any Qt dialogs or CEF overlays the API can't see). Attaches the
    screenshot path to the result so the AI can inspect it immediately
    without making a separate call.

    The screenshot is embedded into the result's 'data' field because the
    Tauri relay only forwards 'output', 'data', 'success', and 'error'.

    Also checks for blocking dialogs and warns in the response.
    """
    import re
    import time
    time.sleep(settle_time)

    # Check if main thread is blocked (dialog present)
    blocked = _check_main_thread_blocked()

    # Capture main Fusion window
    screenshot = screenshot_fusion_window()
    screenshot_info = {"screenshots": []}

    if screenshot.get("success"):
        screenshot_info["screenshots"].append({
            "type": "main_window",
            "savedTo": screenshot["savedTo"],
            "sizeKB": screenshot["sizeKB"],
        })
    else:
        screenshot_info["error"] = screenshot.get("error", "Screenshot failed")

    # Also capture ALL dialog windows — these are separate Qt windows
    # that the main window screenshot won't show
    window_info = get_fusion_window_info()
    if window_info.get("success"):
        dialogs = window_info.get("dialogs", [])
        for dialog in dialogs:
            try:
                title = dialog.get("title", "")
                hwnd = dialog.get("hwnd")
                # Skip tiny/hidden windows and banners (only screenshot real dialogs)
                rect = dialog.get("rect", {})
                w = rect.get("width", 0)
                h = rect.get("height", 0)
                if w < 50 or h < 50:
                    continue
                # Screenshot this dialog — sanitize label for filesystem
                label = re.sub(r'[<>:"/\\|?*]', '', title).replace(" ", "_")[:30]
                dialog_ss = screenshot_hwnd(hwnd, label=f"dialog-{label}")
                if dialog_ss.get("success"):
                    screenshot_info["screenshots"].append({
                        "type": "dialog",
                        "title": title,
                        "hwnd": hwnd,
                        "savedTo": dialog_ss["savedTo"],
                        "sizeKB": dialog_ss["sizeKB"],
                    })
            except Exception as e:
                screenshot_info.setdefault("errors", []).append(
                    f"Failed to screenshot dialog '{title}': {e}"
                )

    screenshot_info["message"] = (
        f"Auto-captured {len(screenshot_info['screenshots'])} Fusion window(s) after open. "
        "READ EACH screenshot file to check for blocking dialogs "
        "('What to design?', 'PCB out of date', 'Save changes?'). "
        "PCB/ELECTRONICS NAV RULE: a board is NOT a standalone file - the schematic, 2D board and 3D board are VIEWS inside ONE electronics DESIGN. Open the DESIGN document first (fusion_open_cloud_file or fusion_open_by_urn), THEN switch to its schematic and its 2D board (fusion_show_2d_board) and 3D board (fusion_show_3d_board). A Select-Electronics-Design-File picker IS that design list of its schematic + board. An EMPTY 2D board usually means you opened a derivative or the wrong file, or it is out of sync - reopen the parent electronics DESIGN and switch to its board view; do NOT open a .brd or .sch as a standalone file. "
        "Dismiss with fusion_send_key {\"key\": \"escape\"} if needed."
    )

    if blocked:
        screenshot_info["dialogBlocking"] = True
        screenshot_info["message"] = (
            "WARNING: A modal dialog is blocking Fusion. "
            "READ the screenshot to identify and dismiss it. " +
            screenshot_info["message"]
        )

    # Inject screenshot into the result's top-level 'data' dict.
    # The Tauri relay constructs its output as: json!({"message": output, "data": data})
    # so anything we put in result["data"] gets forwarded to the CLI.
    result.setdefault("data", {})
    if not isinstance(result["data"], dict):
        result["data"] = {}
    result["data"]["postOpenScreenshot"] = screenshot_info

    # UNIVERSAL: if an owned "Error" popup is up (e.g. malformed .lbr "has errors and
    # cannot be opened"), flip success -> False + surface it. An open that silently
    # errored must never be reported as ok.
    return _apply_failure_dialogs(result)


def _capture_dialog_array(settle: float = 0.6) -> dict | None:
    """After a mutating op, enumerate + screenshot any owned popups / modal dialogs so
    the AI can SEE and ANALYZE them. Titles alone are not enough: a dialog's body text
    lives in CEF/Qt child controls that are NOT Win32-readable, so we attach a per-dialog
    screenshot (hwnd-targeted PrintWindow, background - never foreground/fullscreen).

    Cheap when nothing is up: it enumerates windows (Win32 EnumWindows) and only
    screenshots when a real dialog is present. Returns None when no dialog is up;
    otherwise {dialogsDetected, dialogs:[{hwnd,title,category,resolution,screenshot}],
    _hint}. Never raises - best effort.
    """
    import re as _re
    import time as _t
    if settle:
        _t.sleep(settle)
    try:
        info = get_fusion_window_info()
    except Exception:
        return None
    if not info.get("success"):
        return None
    raw = info.get("dialogs") or []
    if not raw:
        return None
    main_enabled = info.get("mainEnabled", True)
    try:
        classified = {c.get("hwnd"): c for c in classify_blocking_dialogs()}
    except Exception:
        classified = {}
    # Categories that mean "we could not identify it by title" - i.e. a generic
    # "Fusion360"-titled window. When the main window is still ENABLED, such a
    # window is a docked panel (Browser/Timeline), NOT a blocking modal, so we
    # suppress it to avoid crying wolf on every op. A real modal DISABLES the main
    # window (main_enabled False) and is always surfaced; known dialogs (recovery,
    # update, save prompts) are surfaced regardless of the enabled state.
    _GENERIC = {None, "generic_modal_read_screenshot", "unknown"}
    out = []
    for d in raw:
        rect = d.get("rect", {})
        if rect.get("width", 0) < 80 or rect.get("height", 0) < 50:
            continue  # skip banners / tiny docked tool windows
        hwnd = d.get("hwnd")
        title = d.get("title", "")
        c = classified.get(hwnd) or {}
        cat = c.get("category")
        if main_enabled and cat in _GENERIC:
            continue  # docked panel, not a modal - nothing is blocking the main window
        entry = {"hwnd": hwnd, "title": title}
        if cat:
            entry["category"] = cat
            entry["resolution"] = c.get("resolution")
        try:
            label = _re.sub(r'[<>:"/\\|?*]', "", title).replace(" ", "_")[:30] or "dialog"
            ss = screenshot_hwnd(hwnd, label=f"dlg-{label}")
            if ss.get("success"):
                entry["screenshot"] = ss["savedTo"]
        except Exception:
            pass
        out.append(entry)
    if not out:
        return None
    return {
        "dialogsDetected": len(out),
        "dialogs": out,
        "mainWindowEnabled": main_enabled,
        "_hint": (
            f"⚠️ {len(out)} Fusion dialog(s)/owned popup(s) are up after this operation. "
            "STOP and ANALYZE before the next call: READ each dialogs[].screenshot and use its "
            "category/resolution. Do NOT blind-dismiss - some confirms LOSE work (clicking Yes on a "
            "Hub-upload 'are you sure you want to close?' destroys the uploaded packages). Take the "
            "work-preserving action (often: click No, then wait for the upload to drain). See the "
            "fusion-driving skill."
        ),
    }


def _merge_dialog_array(result: dict) -> dict:
    """Attach the post-op dialog array (if any) to a mutating verb's result, so the AI
    always sees pending dialogs WITHOUT having to remember a separate call. The hint is
    mirrored to top-level _hint AND into data.dialogArray (data is forwarded reliably by
    the relay). No-op when no dialog is up."""
    if not isinstance(result, dict):
        return result
    try:
        arr = _capture_dialog_array()
    except Exception:
        arr = None
    if not arr:
        return result
    result.setdefault("data", {})
    if isinstance(result.get("data"), dict):
        result["data"]["dialogArray"] = arr
    existing = result.get("_hint")
    result["_hint"] = arr["_hint"] + ((" | " + existing) if existing else "")
    return _apply_failure_dialogs(result)


def _apply_failure_dialogs(result: dict) -> dict:
    """UNIVERSAL error-dialog guard — baked into every mutating/open verb so the AI
    can never again report a failed op as success.

    Right after an operation, an owned Fusion popup titled "Error" (e.g. "<file>.lbr
    has errors and cannot be opened") means the op FAILED even when the underlying API
    call returned ok (Fusion opened an EMPTY design behind the error). This classifies
    any up dialog; if one is in FAILURE_CATEGORIES it:
      1. SCREENSHOTS the popup (hwnd-targeted, background) so the AI SEES it,
      2. flips result.success -> False + sets errorCode/error/_hint,
      3. dismisses the pure-ack popup (WM_CLOSE) so it can't block the next command.
    Best-effort + cheap when nothing is up (title enumeration, no screenshot). Never raises."""
    try:
        from handlers.dialog_classify import classify_blocking_dialogs, FAILURE_CATEGORIES
        dialogs = classify_blocking_dialogs()
    except Exception:
        return result
    failures = [d for d in (dialogs or []) if d.get("category") in FAILURE_CATEGORIES]
    if not failures or not isinstance(result, dict):
        return result
    f = failures[0]
    shots = []
    for d in failures:
        try:
            ss = screenshot_hwnd(d.get("hwnd"), label="error-dialog")
            if ss.get("success"):
                shots.append(ss["savedTo"])
        except Exception:
            pass
    prev_err = result.get("error") or ""
    result["success"] = False
    result["errorCode"] = "fusion_operation_error"
    result["error"] = (
        f"Operation FAILED - Fusion '{f.get('title') or 'Error'}' dialog is up"
        + (f" (prior: {prev_err})" if prev_err else "")
    )
    result["_hint"] = (f.get("resolution") or
                       "Fusion shows an Error dialog; the operation failed. Read the screenshot.")
    result.setdefault("data", {})
    if isinstance(result.get("data"), dict):
        result["data"]["errorDialog"] = {
            "title": f.get("title"), "category": f.get("category"),
            "resolution": f.get("resolution"), "screenshots": shots,
        }
    # Pure OK-acknowledgement — dismiss so it doesn't wedge the next command.
    for d in failures:
        try:
            close_window(d.get("hwnd"))
        except Exception:
            pass
    return result


def _handle_open_cloud_file(fusion_info: dict, args: dict) -> dict:
    """Open a cloud file with post-open dialog detection + auto-screenshot.

    Proxies to the add-in's open_cloud_file command, then:
    1. Waits for Fusion to settle
    2. Checks if a modal dialog blocked the main thread
    3. Auto-screenshots the Fusion window so the AI can see what happened
    """
    # Send the open command — cloud files can take 30+ seconds to download/open
    result = _proxy_to_addin("open_cloud_file", args, timeout=45)

    if not result.get("success"):
        # Failure is often a modal blocking the add-in's main thread (multi-design
        # "Select Electronics Design File" picker, update nag, recovery, etc.) — which
        # otherwise surfaces as a misleading "add-in not responding". Classify the
        # actual blocking dialog so the caller gets the real cause + resolution.
        dialogs = classify_blocking_dialogs()
        if dialogs:
            result["dialogBlocking"] = True
            result["blockingDialogs"] = dialogs
            result["hint"] = (
                "A modal dialog is blocking Fusion — see blockingDialogs for the "
                "classified cause + resolution. The multi-link 'Select Electronics "
                "Design File' picker is a CEF dialog that must be resolved on the "
                "desktop (its list isn't keyboard/Win32 navigable); recovery prompts "
                "use fusion_dismiss_recovery; an update nag must be cleared on the desktop."
            )
        # Screenshot even on failure — shows what went wrong
        return _post_open_screenshot(result, settle_time=1.0)

    return _post_open_screenshot(result)


def _handle_open_with_screenshot(fusion_info: dict, args: dict, command: str) -> dict:
    """Proxy an open/switch command to the add-in, then auto-screenshot.

    Used for open_schematic, open_board, show_3d_board, show_2d_board —
    any command that changes the Fusion UI state and might trigger a dialog.
    """
    result = _proxy_to_addin(command, args, timeout=30)
    if result.get("success"):
        return _post_open_screenshot(result)
    return result


def _handle_screenshot_fusion(fusion_info: dict, args: dict) -> dict:
    """Screenshot Fusion main window or a specific dialog by HWND."""
    hwnd = args.get("hwnd") or args.get("dialogHwnd")
    if hwnd:
        result = screenshot_hwnd(int(hwnd), label="dialog")
    else:
        result = screenshot_fusion_window()
    if result.get("success"):
        result["output"] = json.dumps({"savedTo": result["savedTo"], "sizeKB": result["sizeKB"]})
    return result


def _handle_click_fusion(fusion_info: dict, args: dict) -> dict:
    """Click at coordinates within Fusion window or a specific dialog HWND."""
    x = args.get("x", 0.5)
    y = args.get("y", 0.5)
    relative = args.get("relative", True)
    hwnd = args.get("hwnd") or args.get("dialogHwnd")
    # SendInput clicks REQUIRE foreground (steals the user's focus). Announce it first -
    # prefer background verbs (desktop_ui_click by name); this path is the last resort.
    _notify_before_foreground(args.get("reason") or "clicking a control Fusion only accepts via a foreground click")
    result = click_fusion(x, y, relative, hwnd=hwnd)
    if result.get("success"):
        result["output"] = json.dumps(result.get("clickedAt", {}))
    return result


def _handle_send_key(fusion_info: dict, args: dict) -> dict:
    """Send a key to Fusion or a specific dialog via SendInput."""
    key = args.get("key", "")
    if not key:
        return {"success": False, "error": "No key specified. Use 'enter', 'escape', 'tab', etc."}
    hwnd = args.get("hwnd") or args.get("dialogHwnd")
    # SendInput keystrokes REQUIRE foreground (steals the user's focus). Announce it first -
    # prefer background verbs (desktop_ui_set / WM_CLOSE); this path is the last resort.
    _notify_before_foreground(args.get("reason") or f"sending the '{key}' key, which Fusion only accepts in the foreground")
    result = send_key_to_fusion(key, hwnd=hwnd)
    if result.get("success"):
        result["output"] = f"Sent key '{key}' to Fusion."
    return result


def _handle_close_window(fusion_info: dict, args: dict) -> dict:
    """Close a dialog window by sending WM_CLOSE (equivalent to clicking X).

    Unlike Escape, this works on dialogs that don't have Cancel/Escape handling
    (e.g. the Recovered Documents dialog). Does NOT force-kill — the dialog can
    still intercept WM_CLOSE and prompt for confirmation.
    """
    hwnd = args.get("hwnd")
    if not hwnd:
        return {"success": False, "error": "Missing required arg: hwnd"}
    result = close_window(int(hwnd))
    if result.get("success"):
        result["output"] = f"Sent WM_CLOSE to hwnd {hwnd}."
    return result


def _handle_window_info(fusion_info: dict, args: dict) -> dict:
    """Get Fusion window info including any dialog windows."""
    result = get_fusion_window_info()
    if result.get("success"):
        dialogs = result.get("dialogs", [])
        dialog_info = [f"  hwnd={d['hwnd']}: {d['title']}" for d in dialogs]
        output_parts = [
            f"Main: hwnd={result['hwnd']}, title='{result['title']}'",
        ]
        if dialogs:
            output_parts.append(f"Dialogs ({len(dialogs)}):")
            output_parts.extend(dialog_info)
        else:
            output_parts.append("No dialog windows detected.")
        if dialogs:
            hint = (f"{len(dialogs)} dialog(s) detected. Screenshot each via "
                    "desktop_screenshot_window {{\"hwnd\":<hwnd>}}, then dismiss with "
                    "fusion_dismiss_blocking_dialogs or fusion_send_key/fusion_close_window.")
        else:
            hint = "No dialogs — Fusion is clear. Safe to run fusion_* commands."
        result["output"] = json.dumps({
            "hwnd": result["hwnd"],
            "title": result["title"],
            "rect": result["rect"],
            "dialogs": dialogs,
            "message": "\n".join(output_parts),
            "_hint": hint,
        })
    return result


def _handle_screenshot_all_fusion(fusion_info: dict, args: dict) -> dict:
    """Screenshot Fusion main window AND all dialog windows.

    Returns paths to all captured screenshots so the AI can see
    CEF overlay dialogs and separate Qt dialog windows.
    """
    screenshots = []

    # 1. Capture main Fusion window from screen (captures CEF overlays)
    main_result = screenshot_fusion_window()
    if main_result.get("success"):
        screenshots.append({
            "type": "main_window",
            "path": main_result["savedTo"],
            "sizeKB": main_result["sizeKB"],
        })

    # 2. List all Qt dialog windows (AI should use desktop_screenshot_window for each)
    info = get_fusion_window_info()
    if info.get("success") and info.get("dialogs"):
        for dialog in info["dialogs"]:
            screenshots.append({
                "type": "dialog",
                "title": dialog["title"],
                "hwnd": dialog["hwnd"],
                "rect": dialog["rect"],
                "hint": f"Use desktop_screenshot_window with hwnd={dialog['hwnd']} to capture this dialog.",
            })

    return {
        "success": True,
        "screenshots": screenshots,
        "dialogCount": len(info.get("dialogs", [])) if info.get("success") else 0,
        "_hint": f"Captured {len(screenshots)} screenshot(s). Screenshots saved on Windows — "
                 "use pull_file to get them to Docker, or Read tool to view each path directly. "
                 "If dialogs are present, dismiss with fusion_dismiss_blocking_dialogs.",
    }


def _handle_relocate_recovery(fusion_info: dict, args: dict) -> dict:
    """Relocate Fusion crash recovery files to ~/.adom/recovery/fusion/.

    Call this proactively before launching Fusion to prevent recovery
    dialogs from appearing. Files are preserved (not deleted) so the
    user can manually restore them if needed.
    """
    from handlers.dismiss_recovery import relocate_recovery_files
    try:
        reloc = relocate_recovery_files()
    except Exception as exc:
        return {
            "success": False,
            "error": f"Failed to relocate recovery files: {exc}",
        }

    if reloc["moved"] > 0:
        return {
            "success": True,
            "output": (
                f"Relocated {reloc['moved']} recovery file(s) to {reloc['dest']}. "
                "These files are preserved — the user can restore them from that "
                "directory if they need to recover unsaved work."
            ),
            "moved": reloc["moved"],
            "dest": reloc["dest"],
            "sources": reloc["sources"],
        }
    return {
        "success": True,
        "output": "No crash recovery files found.",
        "moved": 0,
    }


def _installer_running() -> bool:
    """Is the Fusion Client Downloader / streamer currently installing? Bridge-side
    check (subprocess from OUR process = no AD shell-approval gate)."""
    try:
        import subprocess as _sp
        out = _sp.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, text=True,
                      timeout=15, creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0)).stdout
        return ("streamer.exe" in out) or ("FusionDL.exe" in out) or ("Fusion Client Downloader" in out)
    except Exception:
        return False


def _handle_install_fusion(fusion_info: dict, args: dict) -> dict:
    """Install Fusion 360 FOR the user - no shell_execute, no AD approval gate.

    Declared as detect.installVerb (AD >=1.9.79) so bridge_readiness recommends
    THIS over the generic winget fallback. Downloads the official Fusion Client
    Downloader and runs it silently: --globalinstall when elevated, PER-USER when
    not (learned live: a non-admin shell makes --globalinstall die silently on
    UAC; the per-user install needs no elevation and lands in %LOCALAPPDATA%,
    which detect covers). Returns PROMPTLY - the stream takes 10-30 min; poll
    fusion_readiness until installed:true."""
    # LIVE detect only - never trust the bridge-start fusion_info snapshot, which can be
    # stale-True after Fusion was removed while the bridge kept running (found live on a
    # fresh VM 2026-07-05: this verb refused with "already installed" though disk was
    # NO_WEBDEPLOY, blocking the reinstall). detect_fusion() is the on-disk truth.
    if detect_fusion().get("installed"):
        return {"success": True, "alreadyInstalled": True,
                "_hint": "Fusion 360 is already installed - call fusion_start to launch it."}
    if _installer_running():
        return {"success": True, "installing": True, "statusVerb": "fusion_readiness",
                "_hint": "An install is ALREADY streaming (10-30 min). Poll fusion_readiness "
                         "until installed:true - do not start a second installer."}

    import tempfile, urllib.request as _ur, subprocess as _sp, ctypes as _ct
    stub = os.path.join(tempfile.gettempdir(), "FusionClientDownloader.exe")
    try:
        _ur.urlretrieve(
            "https://dl.appstreaming.autodesk.com/production/installers/Fusion%20Client%20Downloader.exe",
            stub)
    except Exception as e:
        return {"success": False, "error": f"Installer download failed: {e}",
                "errorCode": "installer_download_failed",
                "_hint": "Check the box is online; retry fusion_install_fusion. The stub URL is "
                         "Autodesk's official appstreaming installer."}
    try:
        elevated = bool(_ct.windll.shell32.IsUserAnAdmin()) if hasattr(_ct, "windll") else False
    except Exception:
        elevated = False
    cmd = [stub, "--quiet"] + (["--globalinstall"] if elevated else [])
    try:
        _sp.Popen(cmd, stdin=_sp.DEVNULL, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL,
                  creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0) | getattr(_sp, "DETACHED_PROCESS", 0))
    except Exception as e:
        return {"success": False, "error": f"Installer launch failed: {e}",
                "errorCode": "installer_launch_failed"}
    return {
        "success": True, "installing": True, "elevated": elevated,
        "mode": "globalinstall" if elevated else "per-user",
        "statusVerb": "fusion_readiness",
        "_hint": ("Fusion 360 install STARTED (" + ("system-wide" if elevated else
                  "per-user - no admin needed; a non-elevated --globalinstall dies silently on UAC") +
                  "). It streams several GB (10-30 min): poll fusion_readiness until installed:true "
                  "(it also reports installing:true while the streamer runs), then fusion_start. "
                  "NOTIFY the user it is underway (progress toast) and again at the Autodesk sign-in."),
    }


def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict:
    """FAST readiness check for the AI: is the Fusion host app present + running + the bridge ready?
    Does NOT launch Fusion (fast-fails when it's not running). AD DETECTS Fusion (never installs it)
    via bridge.json 'detect', and auto-installs Python if missing (AD >=1.9.47) - this verb just
    reports state. Pairs with AD's bridge_readiness."""
    # ALWAYS live-detect. fusion_info is a bridge-START snapshot; Fusion may have been
    # installed OR UNINSTALLED since. Trusting a cached installed:True made readiness
    # report installed/running/ready:true on a machine where Fusion had been removed
    # (found live on a fresh VM 2026-07-05: NO_WEBDEPLOY on disk, yet readiness said
    # ready). detect_fusion() is a fast filesystem scan, so pay it every call.
    raw_installed = bool(detect_fusion().get("installed"))
    # `streaming` must be RELIABLE. _installer_running() is a tasklist check, but the Autodesk streamer
    # spawns short-lived per-chunk workers, so it reads False most of a live multi-GB stream (flickered
    # True only 3 of 25 polls during a real install, John 2026-07-14). OR in the disk-state signal - a
    # webdeploy hash dir with FusionLauncher.exe but a missing/truncated .ini - which is present for the
    # WHOLE stream. Now `installing` is trustworthy, not racy.
    streaming = _installer_running() or _incomplete_webdeploy_present()
    running = _is_fusion_running() if raw_installed else False
    # FRESH-INSTALL COMPLETENESS GUARD (John caught this live 2026-07-14 on a fresh Hyper-V VM):
    # the streamer writes FusionLauncher.exe BEFORE it finishes writing FusionLauncher.exe.ini, so
    # detect_fusion() flips installed:true mid-stream. Starting into that half-written install throws
    # "Error Launching Streamed Application ... FusionLauncher.exe.ini is missing or incomplete" and
    # leaves TWO corrupt webdeploy production dirs. So: while the streamer is running and Fusion is
    # NOT already up (i.e. this is a first install, not a background auto-update of a running Fusion),
    # the install is STILL STREAMING - report installed:false + installing:true so nothing calls
    # fusion_start yet. Only treat it as installed once the streamer has exited.
    if streaming and not running:
        installed = False
        installing = True
    else:
        installed = raw_installed
        installing = (not installed) and streaming

    # The add-in only serves once Fusion is PAST sign-in (its HTTP server runs on
    # Fusion's main thread, which the sign-in modal blocks). So a responding add-in
    # is the definitive "signed in + drivable" signal.
    addin_status = _check_addin_status(timeout=1.0) if running else None
    addin_ok = addin_status is not None

    # Fusion can be RUNNING yet not drivable: the first-run Autodesk sign-in modal
    # ("Signing in - Autodesk Fusion" / "Welcome to Fusion") blocks the main thread,
    # so the add-in never serves and every modeling verb hangs. The old code reported
    # ready:True here purely on installed+running, which was a lie during sign-in.
    # Detect it from the FUSION-PROCESS window title - NOT classify_launch_dialogs,
    # which scans ALL top-level windows and false-matches a stray Edge "Sign in -
    # Autodesk" BROWSER tab (caught 2026-07-05).
    #
    # ⚠️ CHECK IT WHENEVER RUNNING, not only when the add-in is silent (fix 2026-07-06,
    # John caught it lying): a STALE add-in HTTP server from a just-killed instance can
    # still hold port 8774 and answer, so `addin_ok` goes True while the CURRENT Fusion
    # sits stuck on "Signing in". Gating the sign-in check on `not addin_ok` then let
    # readiness report ready:true for a dead, stuck-signing-in window. The Fusion-process
    # window title is authoritative - if it says "Signing in", we are NOT ready, period.
    needs_signin = False
    if running:
        try:
            finfo = get_fusion_window_info() or {}
            _t = (finfo.get("title") or "").lower()
            needs_signin = ("signing in" in _t or "welcome to fusion" in _t
                            or ("sign in - autodesk" in _t and "fusion" in _t))
        except Exception:
            needs_signin = False
    # "Ready to drive" requires the add-in to actually RESPOND (not just the process to
    # exist) AND no sign-in modal. A responding add-in proves Fusion's main thread is
    # free; requiring it stops a half-launched or stuck Fusion from reading as ready.
    ready = installed and running and addin_ok and not needs_signin

    # SEAT/LICENSING dialog blocking the launch - detected DETERMINISTICALLY by owning
    # process + dialog size (not the "Fusion360" title). Checked WHENEVER running, not
    # only when the add-in is silent (fix 2026-07-06): a STALE add-in port from a prior
    # instance answers while the CURRENT Fusion sits blocked behind the seat dialog, so
    # gating on `not addin_ok` false-negatived `licensingDialog` while the dialog sat
    # right there on the user's screen.
    #
    # ⚠️ GAP FIX (John caught it live 2026-07-06): the launch loop only auto-resolves the
    # seat dialog during its ~50s window. But this dialog also appears LATER - minutes
    # after sign-in, when the license server notices too many sessions - long after that
    # loop exited, so it just sat there blocking while readiness passively reported
    # licensingDialog:true. Now readiness SELF-HEALS: whenever it detects the dialog it
    # AUTO-RESOLVES it in the background (UIA 'Continue' + screenshot-verify), so the seat
    # is handled deterministically in CODE no matter when it shows up - the AI never has
    # to notice or act. (John: "do all this tracking from your code so it's deterministic
    # ... the ai never follows skills.")
    seat_resolved = False
    licensing = False
    if running:
        try:
            licensing = find_licensing_dialog() is not None
        except Exception:
            licensing = False
        if licensing:
            try:
                _r = _resolve_seat_dialog()
                seat_resolved = _r.get("clicks", 0) > 0
                # Re-check: did it actually clear? (screenshot-verified inside.)
                licensing = find_licensing_dialog() is not None
            except Exception:
                pass
            if not licensing:
                # Cleared - re-probe the add-in; Fusion may be drivable now.
                try:
                    addin_status = _check_addin_status(timeout=1.5)
                    addin_ok = addin_status is not None
                    ready = installed and running and addin_ok and not needs_signin
                except Exception:
                    pass
    if licensing:
        ready = False  # a seat/licensing dialog blocking the UI is never "ready"

    # Fusion applying an AUTO-UPDATE (two+ webdeploy builds) crash-restarts itself every
    # ~30-60s, so a verb intermittently sees running:false. That is NOT a crash or a
    # bridge failure - surface it as a distinct non-fatal `updating` flag so a driving AI
    # waits/retries instead of giving up. Only check when installed but not currently
    # ready (avoid noise when Fusion is happily drivable).
    updating = False
    if installed and not ready:
        try:
            updating = fusion_update_in_progress()
        except Exception:
            updating = False

    # When Fusion is up + signed in, also check the add-in isn't STALE (issue #55) so
    # a fresh readiness call surfaces the mismatch instead of a later verb failing
    # silently. Reuse the add-in status already fetched above (no second probe).
    stale_info = {}
    if addin_ok:
        stale_info = _addin_staleness(addin_status.get("version"))

    if updating and not ready:
        hint = ("Fusion is applying an AUTO-UPDATE (multiple webdeploy builds present) and "
                "RESTARTS ITSELF every ~30-60s - this is NOT a crash or a bridge failure. Poll "
                "fusion_readiness; it stabilizes to ready:true once the update finishes (can take "
                "10-30 min). Meanwhile, wrap modeling/export verbs in a short retry/catch-loop to "
                "ride the up-windows rather than treating an intermittent 'not running' as fatal.")
    elif needs_signin:
        hint = ("Fusion is RUNNING but stuck at the first-run Autodesk SIGN-IN (main UI blocked, add-in "
                "cannot serve) - NOT ready. FULL PLAYBOOK: the `fusion-autodesk-signin` skill. Proven "
                "sequence + the pitfalls that cost hours on a fresh Hyper-V VM (John 2026-07-14): "
                "(1) BLANK white webview on first launch? fusion_stop then fusion_start - the sign-in "
                "renders 'Welcome to Fusion' + a 'Sign In' button only after a clean relaunch. "
                "(2) ASK the user FIRST (AskUserQuestion): do they have a warm Google/Apple/Microsoft "
                "session for the account LINKED to their Autodesk login? Offer to drive it. A truly "
                "fresh box has NO warm browser session, so 'Continue with Google' still hits a password "
                "wall. (3) Click Fusion 'Sign In' -> it opens the DEFAULT browser (Edge) to the OAuth. "
                "Edge windows DO exist even if PowerShell EnumWindows looks empty - that is CLIXML "
                "progress noise; set $ProgressPreference='SilentlyContinue', and just "
                "desktop_screenshot_window the Edge hwnd (PrintWindow works backgrounded). "
                "(4) In the browser: 'Continue with Google' -> type the user's EMAIL (not secret) -> if "
                "it asks for a PASSWORD/2FA, STOP and fusion_notify_owner {title, body} to toast their "
                "MAIN computer so THEY type it; NEVER enter password/2FA yourself. (5) Handoff back to "
                "Fusion is via the 'Autodesk Identity Manager' protocol - Edge shows a 'This site is "
                "trying to open...' overlay: tick 'Always allow' + click 'Open'. (6) SPEED MATTERS: the "
                "sign-in code EXPIRES in ~2 min; on a high-latency VM slow screenshot/click round-trips "
                "let it expire ('Sign-in request expired') and you loop. Minimize steps; if it expires, "
                "click Fusion 'Sign In' again for a FRESH code (the browser session stays warm). "
                "Then poll until ready:true. FIRST-TIME DEMO? Run fusion_demo instead - it drives this "
                "sign-in through the user's NATIVE browser (already signed into Autodesk) and then "
                "gives them a real guided tour (project -> schematic -> 2D board -> 3D board).")
    elif ready and stale_info.get("addinStale"):
        hint = stale_info["_staleHint"]
    elif ready:
        hint = "READY - drive Fusion (fusion_open_lbr / build_library_3d / etc.)."
    elif installed and running and licensing:
        hint = ("Fusion is blocked by a SEAT/LICENSING dialog ('Active Sessions Exceeded' / 'Suspend "
                "Remote Session') - detected DETERMINISTICALLY as an OWNED POPUP of the main Fusion "
                "window (its title is just 'Fusion360' and it can be as short as 262px, so it is found "
                "by owner+parent-screenshot, NOT a size guess). This verb JUST TRIED to auto-resolve it "
                "in the BACKGROUND (UIA 'Continue', Suspend pre-selected so it reclaims the seat) but it "
                "is still up - it may be mid-render (the first UIA Invoke no-ops). Just POLL "
                "fusion_readiness again in a few seconds; it keeps auto-resolving. Never coordinate-click "
                "or ask the user, and never kill/relaunch (the server keeps the seat).")
    elif installed and running:
        hint = ("Fusion is RUNNING but its add-in is NOT responding yet - NOT ready to drive. Either it "
                "is still finishing launch / signing in (poll fusion_readiness a few more times), or the "
                "add-in did not load (fusion_stop then fusion_start to re-deploy + re-load it). Do NOT "
                "fire modeling/export verbs until ready:true.")
    elif installed:
        hint = "Fusion is INSTALLED but not running - call fusion_start (blocks until the add-in is ready), then retry."
    else:
        hint = ("Fusion install is STREAMING (10-30 min) - keep polling fusion_readiness until installed:true."
                if installing else
                "Fusion 360 is NOT installed. OFFER to install it for the user, then call "
                "fusion_install_fusion (no shell approval needed) - it streams the free trial and "
                "this verb reports installing:true until done.")

    result = {
        "success": True,
        "hostApp": "Fusion 360",
        "installed": installed,
        "installing": installing,
        "running": running,
        "ready": ready,
        "bridgeVersion": BRIDGE_VERSION,
        **stale_info,
        "_hint": hint,
    }
    if updating:
        result["updating"] = True
        result["statusVerb"] = "fusion_readiness"
    if needs_signin:
        result["needsSignin"] = True
        result["statusVerb"] = "fusion_readiness"
    if licensing:
        result["licensingDialog"] = True
        result["statusVerb"] = "fusion_readiness"
    if seat_resolved:
        # We acted on it this call (whether or not it fully cleared yet).
        result["seatDialogAutoResolved"] = True
    return result


# ── Fusion preferences (theme / navigation / units) ──────────────────────────
# Set via the adsk preferences API inside the live session (run_modeling_script),
# so this is a SERVER-only orchestrator - no add-in redeploy. Friendly keys map to
# adsk enums; unknown/failed keys are reported per-key (some themes, e.g. Dark Gray
# / Classic, aren't shipped in every Fusion build - proven live 2026-07-05, only
# LightGray/DarkBlue/Device were available). Theme changes apply LIVE (no restart).
_PREF_SCRIPT = r'''
import json
gp = app.preferences.generalPreferences
T  = adsk.core.UserInterfaceThemes
PZ = adsk.core.PanZoomOrbitShortcuts
MO = adsk.core.DefaultModelingOrientations
DU = adsk.fusion.DistanceUnits

THEME = {'light':[T.LightGrayUserInterfaceTheme],'lightgray':[T.LightGrayUserInterfaceTheme],
         'dark':[T.DarkGrayUserInterfaceTheme,T.DarkBlueUserInterfaceTheme],
         'darkgray':[T.DarkGrayUserInterfaceTheme],'darkblue':[T.DarkBlueUserInterfaceTheme],
         'classic':[T.ClassicUserInterfaceTheme],'device':[T.DeviceUserInterfaceTheme],
         'auto':[T.DeviceUserInterfaceTheme]}
THEME_NAME  = {0:'classic',1:'lightgray',2:'darkblue',3:'darkgray',4:'device'}
ORBIT = {'fusion360':PZ.Fusion360PanZoomOrbitShortcut,'fusion':PZ.Fusion360PanZoomOrbitShortcut,
         'alias':PZ.AliasPanZoomOrbitShortcut,'inventor':PZ.InventorPanZoomOrbitShortcut,
         'solidworks':PZ.SolidWorksPanZoomOrbitShortcut,'tinkercad':PZ.TinkercadPanZoomOrbitShortcut,
         'powermill':PZ.PowerMillPanZoomOrbitShortcut}
ORBIT_NAME  = {0:'fusion360',1:'alias',2:'inventor',3:'solidworks',4:'tinkercad',5:'powermill'}
ORIENT = {'yup':MO.YUpModelingOrientation,'zup':MO.ZUpModelingOrientation}
ORIENT_NAME = {0:'yup',1:'zup'}
UNITS = {'mm':DU.MillimeterDistanceUnits,'cm':DU.CentimeterDistanceUnits,'m':DU.MeterDistanceUnits,
         'in':DU.InchDistanceUnits,'inch':DU.InchDistanceUnits,'ft':DU.FootDistanceUnits}
UNITS_NAME  = {0:'mm',1:'cm',2:'m',3:'in',4:'ft'}

def read_current():
    cur = {
      'theme': THEME_NAME.get(gp.userInterfaceTheme, gp.userInterfaceTheme),
      'activeTheme': THEME_NAME.get(gp.activeUserInterfaceTheme, gp.activeUserInterfaceTheme),
      'invertScrollZoom': bool(gp.isZoomDirectionReversed),
      'orbitScheme': ORBIT_NAME.get(gp.panZoomOrbitShortcuts, gp.panZoomOrbitShortcuts),
      'modelingOrientation': ORIENT_NAME.get(gp.defaultModelingOrientation, gp.defaultModelingOrientation),
      'gestureNav': bool(gp.isGestureBasedViewNavigationUsed),
      'cameraPivot': bool(gp.isCameraPivotEnabled),
    }
    try:
        cur['lengthUnit'] = UNITS_NAME.get(design.fusionUnitsManager.distanceDisplayUnits,
                                           design.fusionUnitsManager.distanceDisplayUnits)
    except Exception:
        pass
    return cur

def try_set(obj, attr, cands, want):
    err = None
    for c in cands:
        if c is None: continue
        try:
            setattr(obj, attr, c)
            return {'requested': want, 'ok': True}
        except Exception as e:
            err = str(e)
    return {'requested': want, 'ok': False, 'error': err or 'no valid value'}

reqs = json.loads(__REQS__)
applied = {}
for k, v in reqs.items():
    kv = v if isinstance(v, bool) else str(v).strip().lower()
    if k == 'theme':
        c = THEME.get(kv);  applied[k] = try_set(gp,'userInterfaceTheme', c or [], v) if c else {'requested':v,'ok':False,'error':'unknown theme (use light/darkblue/darkgray/classic/device/dark)'}
    elif k in ('invertScrollZoom','reverseZoom'):
        applied['invertScrollZoom'] = try_set(gp,'isZoomDirectionReversed',[bool(v)],v)
    elif k == 'orbitScheme':
        c = ORBIT.get(kv);  applied[k] = try_set(gp,'panZoomOrbitShortcuts',[c],v) if c is not None else {'requested':v,'ok':False,'error':'unknown orbitScheme'}
    elif k == 'modelingOrientation':
        c = ORIENT.get(kv); applied[k] = try_set(gp,'defaultModelingOrientation',[c],v) if c is not None else {'requested':v,'ok':False,'error':'unknown modelingOrientation (yup/zup)'}
    elif k == 'gestureNav':
        applied[k] = try_set(gp,'isGestureBasedViewNavigationUsed',[bool(v)],v)
    elif k == 'cameraPivot':
        applied[k] = try_set(gp,'isCameraPivotEnabled',[bool(v)],v)
    elif k == 'lengthUnit':
        c = UNITS.get(kv)
        if c is None: applied[k] = {'requested':v,'ok':False,'error':'unknown unit (mm/cm/m/in/ft)'}
        else:
            try: applied[k] = try_set(design.fusionUnitsManager,'distanceDisplayUnits',[c],v)
            except Exception as e: applied[k] = {'requested':v,'ok':False,'error':str(e)}
    else:
        applied[k] = {'requested': v, 'ok': False, 'error': 'unknown preference key'}

result = {'applied': applied, 'current': read_current()}
print(json.dumps(result))
'''


def _shape_pref_result(res: dict, wrote: bool) -> dict:
    if not res or not res.get("success"):
        return {"success": False,
                "error": (res or {}).get("error", "preferences script did not run"),
                "_hint": "fusion_set_preference/get_preferences need Fusion running + signed in "
                         "(fusion_readiness -> ready:true). If needsSignin, drive the sign-in first."}
    data = (res.get("data") or {}).get("result") or {}
    applied = data.get("applied", {}) or {}
    current = data.get("current", {}) or {}
    out = {"success": True, "applied": applied, "current": current}
    if wrote:
        oks = [k for k, v in applied.items() if v.get("ok")]
        fails = {k: v.get("error") for k, v in applied.items() if not v.get("ok")}
        if fails:
            out["partial"] = True
            out["_hint"] = ("Applied live: %s. FAILED: %s. Note: some themes (Dark Gray/Classic) "
                            "aren't shipped in every Fusion build - use 'darkblue' or 'device'. See "
                            "`current` for the resulting state." %
                            (", ".join(oks) or "(none)",
                             "; ".join("%s=%s" % (k, e) for k, e in fails.items())))
        else:
            out["_hint"] = ("Applied LIVE (no restart needed): %s. See `current` for resulting state."
                            % ", ".join(oks))
    else:
        out["_hint"] = ("Current Fusion preferences. Change any with fusion_set_preference, keys: "
                        "theme (light/darkblue/darkgray/classic/device/dark), invertScrollZoom (bool), "
                        "orbitScheme (fusion360/alias/inventor/solidworks/tinkercad/powermill), "
                        "modelingOrientation (yup/zup), gestureNav (bool), cameraPivot (bool), "
                        "lengthUnit (mm/cm/m/in/ft - applies to the active design).")
    return out


def _run_pref_script(script: str, timeout: int) -> dict:
    """Proxy a preferences script to the add-in, retrying ONCE on a transient add-in
    connection error. The first pref call can hit the add-in mid doc-transition (e.g.
    right after opening a Library, when run_modeling_script must create a Design) and
    come back 'not running'/'not responding' even though Fusion is up - seen live on
    the fresh-VM battery, where a ~1.5s retry cleared it. A genuinely-down Fusion just
    costs one extra 1.5s try."""
    res = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=timeout)
    if res and res.get("success"):
        return res
    err = ((res or {}).get("error") or "").lower()
    if any(s in err for s in ("not running", "not responding", "add-in",
                              "connection", "timed out", "refused")):
        _time.sleep(1.5)
        res2 = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=timeout)
        if res2 is not None:
            return res2
    return res


def _handle_set_preference(fusion_info: dict, args: dict) -> dict:
    reqs = {k: v for k, v in (args or {}).items() if k not in ("settle",)}
    if not reqs:
        return {"success": False, "error": "No preferences given.",
                "_hint": "Pass e.g. {\"theme\":\"dark\"} or {\"invertScrollZoom\":true,"
                         "\"orbitScheme\":\"solidworks\"}. Call fusion_get_preferences to see current values + keys."}
    script = _PREF_SCRIPT.replace("__REQS__", json.dumps(json.dumps(reqs)))
    res = _run_pref_script(script, timeout=30)
    return _shape_pref_result(res, wrote=True)


def _handle_get_preferences(fusion_info: dict, args: dict) -> dict:
    script = _PREF_SCRIPT.replace("__REQS__", json.dumps(json.dumps({})))
    res = _run_pref_script(script, timeout=20)
    return _shape_pref_result(res, wrote=False)


# ── APS state + AI hint builder ───────────────────────────────────────────────
# Every cloud-file path routes through APS now (the old in-app search is disabled —
# see _handle_deprecated_cloud_search). This helper reads live APS state and returns
# a rich, self-teaching hint bundle so the AI ALWAYS knows: is APS installed/configured?
# signed in? token live/expired? what the verbs are, that it must sign in, and that the
# best sign-in path is the user's NATIVE Chrome/Edge via ABE (adom-browser-extension).
_APS_VERB_MAP = (
    "fusion_aps_status (state) · fusion_aps_signin (sign in) · fusion_aps_search {query} · "
    "fusion_aps_open {name} · fusion_aps_browse · fusion_aps_recent · fusion_aps_file_info · "
    "fusion_aps_versions · fusion_aps_download · fusion_aps_upload · fusion_aps_create_folder · "
    "fusion_aps_set_browser/get_browser/forget_browser (remember the native browser+profile)"
)
_APS_WHY = (
    "WHY APS: Autodesk offers NO fast file-search API for Fusion. The only in-app option "
    "(walk/search the folder tree from the add-in) takes 30+ min and CAN CRASH Fusion "
    "(WinError 10054). We searched far and wide; APS (Autodesk Platform Services, server-indexed) "
    "is the ONLY thing that works — seconds, no Fusion needed, no crash. It needs a one-time "
    "OAuth sign-in; once set up it is fast and amazing. The old crashing search is now DISABLED."
)
_APS_ABE = (
    "BEST sign-in path: drive the user's OWN Chrome/Edge (already logged into Autodesk) via ABE "
    "(adom-browser-extension). If ABE is not installed, tell the user to install it first "
    "(wiki.adom.inc/adom/adom-browser-extension), then walk them through it. With ABE the bridge "
    "opens the Autodesk auth URL in their real signed-in profile and captures the token — no "
    "password typing. Fallback: fusion_aps_signin {allowDefaultBrowser:true}."
)


def _aps_state_hint(extra: str = "") -> dict:
    """Live APS readiness + a self-teaching hint bundle for the AI. Never raises."""
    try:
        st = aps.handle_status({})
        d = st.get("data", {}) if isinstance(st, dict) else {}
    except Exception as e:  # pragma: no cover - defensive
        d = {"error": str(e)}
    configured = bool(d.get("configured"))
    signed_in = bool(d.get("signedIn"))
    live = bool(d.get("tokenLive"))
    if not configured:
        stage, todo = "not_configured", ("Register a PKCE app at https://aps.autodesk.com (Data "
                                         "Management API on), then fusion_aps_set_client_id + fusion_aps_signin.")
    elif not signed_in:
        stage, todo = "not_signed_in", "Sign in: fusion_aps_signin (prefer ABE / native browser)."
    elif not live:
        stage, todo = "token_expired", "Token expired/refresh failed — fusion_aps_signin again."
    else:
        stage, todo = "ready", "Ready — fusion_aps_search {\"query\":\"...\"} or fusion_aps_open {\"name\":\"...\"}."
    parts = [f"APS {stage}.", todo, "VERBS: " + _APS_VERB_MAP, _APS_ABE, _APS_WHY]
    if extra:
        parts.insert(0, extra)
    return {
        "apsStage": stage, "apsConfigured": configured, "apsSignedIn": signed_in,
        "apsTokenLive": live, "_hint": "  ".join(parts),
    }


def _aps_guarded(fn, args: dict):
    """Run an APS cloud handler, but FIRST ensure APS is signed-in + token-live. If not,
    short-circuit with the rich self-teaching hint bundle (state + verbs + sign-in + ABE)
    so the AI knows exactly what to do instead of getting an opaque auth failure. This is
    the 'check every time, in code' guard the user asked for."""
    state = _aps_state_hint()
    if state["apsStage"] != "ready":
        return {
            "success": False,
            "error": f"APS is {state['apsStage']} — sign in before cloud file operations.",
            "output": state["apsStage"],
            "data": {"apsNotReady": True, **state},
            "_hint": state["_hint"],
        }
    return fn(args)


def _handle_deprecated_cloud_search(command: str, args: dict) -> dict:
    """HARD-BLOCK the old in-app cloud search/walk (crashes Fusion). Redirect to APS,
    surfacing live APS state so the AI can immediately continue via the good path."""
    aps_state = _aps_state_hint()
    return {
        "success": False,
        "error": (f"fusion_{command} is DISABLED: the in-app Fusion cloud "
                  "search/walk takes 30+ min and CAN CRASH Fusion (WinError 10054). "
                  "Use fusion_aps_search {\"query\":\"...\"} (or fusion_aps_open to open by name) instead."),
        "output": "deprecated_cloud_search_disabled",
        "data": {"disabled": True, "use": "fusion_aps_search", **aps_state},
        "_hint": aps_state["_hint"],
    }


COMMAND_HANDLERS = {
    "describe": lambda fi, args: describe.handle_describe(args),
    "readiness": _handle_fusion_readiness,
    "set_preference": _handle_set_preference,
    "get_preferences": _handle_get_preferences,
    "install_fusion": _handle_install_fusion,
    "open_design": handle_open_design,
    "close": handle_close_fusion,  # deprecated alias - use stop (graceful) / kill (force)
    "stop": handle_fusion_stop,    # graceful: close docs + WM_CLOSE, NO force-kill
    "kill": handle_fusion_kill,    # force: taskkill /F (the desperate path)
    "launch": _handle_launch,
    "start": _handle_launch,  # alias — CLI's fusion_start delegates here on Docker
    "dismiss_recovery": _handle_dismiss_recovery,
    "relocate_recovery": _handle_relocate_recovery,
    "open_cloud_file": _handle_open_cloud_file,
    # Import a legacy EAGLE .sch (+ .brd) into a NEW populated Fusion electronics design via
    # Fusion's own ImportSCHAndBRDCmd (drives both Open dialogs in the background). This is the
    # ONLY way to author a board from EAGLE source - newDesignFromLocal opens the editor but does
    # NOT instantiate parts, and the .fsch/.fbrd binary container can't be built offline.
    "new_electronics_from_eagle": lambda fi, args: _handle_new_electronics_from_eagle(args),
    "screenshot_fusion": _handle_screenshot_fusion,
    "click_fusion": _handle_click_fusion,
    "send_key": _handle_send_key,
    "close_window": _handle_close_window,
    "window_info": _handle_window_info,
    "screenshot_all": _handle_screenshot_all_fusion,
    # On-demand dialog/owned-popup array: enumerate + screenshot every modal so the AI
    # can ANALYZE before acting. Use while polling a long op (e.g. a Hub upload) or any
    # time you suspect a dialog is up. Mutating verbs attach this automatically; this is
    # the manual entry point. See the fusion-driving skill.
    "check_dialogs": lambda fi, args: (
        _capture_dialog_array(settle=float(args.get("settle", 0.3)))
        or {"success": True, "dialogsDetected": 0, "dialogs": [],
            "_hint": "No Fusion dialogs/owned popups are currently up."}
    ),
    "addin_status": lambda fi, args: _handle_addin_status(),
    # LAST-RESORT human escalation (John 2026-07-07): when the bridge is truly blocked
    # on the user (password/2FA, UAC), send an AD toast that reaches their MAIN machine
    # (reach_user=True fans out to peer ADs, so a bridge running on an unattended VM
    # still lands the toast where the user actually is). ALWAYS exhaust programmatic
    # options first - this exists so the AI has ONE deterministic call when it must ask.
    "notify_owner": lambda fi, args: _handle_notify_owner(args),
    # APS cloud search (pure HTTPS, works with Fusion closed). Lives in
    # COMMAND_HANDLERS so it returns BEFORE any Fusion-running gate.
    "aps_status": lambda fi, args: aps.handle_status(args),
    "aps_set_client_id": lambda fi, args: aps.handle_set_client_id(args),
    "aps_signin": lambda fi, args: aps.handle_signin(args),
    "aps_set_browser": lambda fi, args: aps.handle_set_browser(args),
    "aps_get_browser": lambda fi, args: aps.handle_get_browser(args),
    "aps_forget_browser": lambda fi, args: aps.handle_forget_browser(args),
    # Cloud-data verbs go through _aps_guarded: it checks signed-in + token-live EVERY call
    # and returns the rich APS hint bundle (state/verbs/sign-in/ABE) if not ready.
    "aps_search": lambda fi, args: _aps_guarded(aps.handle_search, args),
    "aps_browse": lambda fi, args: _aps_guarded(aps.handle_browse, args),
    "aps_recent": lambda fi, args: _aps_guarded(aps.handle_recent, args),
    "aps_file_info": lambda fi, args: _aps_guarded(aps.handle_file_info, args),
    "aps_versions": lambda fi, args: _aps_guarded(aps.handle_versions, args),
    "aps_download": lambda fi, args: _aps_guarded(aps.handle_download, args),
    "aps_create_folder": lambda fi, args: _aps_guarded(aps.handle_create_folder, args),
    "aps_upload": lambda fi, args: _aps_guarded(aps.handle_upload, args),
    "aps_open": lambda fi, args: _aps_guarded(lambda a: _handle_aps_open(fi, a), args),
    "aps_get": lambda fi, args: aps.handle_get(args),
    # DEPRECATED cloud search — HARD-BLOCKED here (in COMMAND_HANDLERS) so they return
    # BEFORE ever reaching the crashing add-in path. Redirect to APS + surface APS state.
    "search_cloud_files": lambda fi, args: _handle_deprecated_cloud_search("search_cloud_files", args),
    "walk_cloud_tree": lambda fi, args: _handle_deprecated_cloud_search("walk_cloud_tree", args),
}


# Surfaced inline so the AI doesn't draw the WRONG conclusion (it has, repeatedly):
# a "Read Only" / expired / trial / personal-use Fusion CAN open + view + browse files
# (Basic Access ~365 days) — it only blocks save/export/modify. NEVER blame a failed/slow
# OPEN on the subscription; the cause is the open path (URN resolution, the Electronics
# design picker, a slow assembly download, a stuck main thread).
_READONLY_OPEN_NOTE = ("NOTE: a 'Read Only'/expired/trial Fusion still opens+views files — if "
                       "it never opens, debug the open path (URN/picker/slow assembly), NOT the "
                       "license. Only save/export are blocked in read-only.")


def _handle_aps_open(fusion_info: dict, args: dict) -> dict:
    """Search the cloud (APS) for a file, then OPEN the best match in Fusion.

    APS finds it instantly by name across the whole team hub; the add-in's
    open_cloud_file (projectName + fileName + fileExtension) opens it.
    """
    query = (args.get("query") or args.get("fileName") or "").strip()
    if not query:
        return {"success": False, "error": "Missing query/fileName."}
    match = aps.find_one(query)
    if not match:
        return {"success": False, "error": f"No cloud file matching '{query}'.",
                "_hint": "Run fusion_aps_search to see candidates, or refine the query. "
                         + _READONLY_OPEN_NOTE}
    # Fusion cloud displayNames are NOT filenames — do NOT splitext (it mangled
    # "...(1.6mm gasket)" into a bogus extension). Pass the full name.
    name = match.get("name") or ""
    open_args = {"projectName": match.get("projectName"), "fileName": name}
    if not fusion_info.get("installed") or not _is_fusion_running():
        return {"success": True, "output": f"Found '{name}' in project {match.get('projectName')}.",
                "data": {"match": match, "openArgs": open_args},
                "_hint": "Fusion isn't running — call fusion_start, then fusion_aps_open again to open it."}
    # Open by the EXACT file URN (works for any nesting). The FIRST cloud-open can
    # take 60-90s (download) — longer than AD's relay timeout — so FIRE it in the
    # background and return immediately. Caller polls fusion_get_app_state. Unless
    # {wait:true} is passed (then block and return the open result).
    urn = match.get("id")

    def _do_open():
        r = _proxy_to_addin("open_by_urn", {"urn": urn}, timeout=180)
        if not r.get("success"):
            _proxy_to_addin("open_cloud_file", open_args, timeout=120)  # by-name fallback
        return r

    if args.get("wait"):
        result = _do_open()
        result.setdefault("data", {})
        if isinstance(result.get("data"), dict):
            result["data"]["match"] = match
        return _post_open_screenshot(result) if result.get("success") else result

    import threading as _threading
    _threading.Thread(target=_do_open, daemon=True).start()
    return {
        "success": True,
        "output": f"Found '{name}' in project {match.get('projectName')} — opening in Fusion.",
        "data": {"match": match, "opening": True},
        "statusVerb": "fusion_get_app_state",  # AD 1.9.9 convention — poll this for completion
        "_hint": "The cloud file is opening in Fusion in the background (first open can take "
                 "~60-90s; large ASSEMBLIES download all referenced parts and take longer). "
                 "Poll fusion_get_app_state until activeDocument is the file. Pass "
                 "{\"wait\": true} to block instead. " + _READONLY_OPEN_NOTE,
    }

# Commands that are proxied to the Fusion add-in (port 8774).
# Keys are the CLI-facing names (after stripping "fusion_" prefix).
ADDIN_COMMANDS = {
    "get_app_state",
    "document_info",
    "activate_document",
    "import_step",  # add-in calls this "import_file" — mapped below
    "export_step", "export_stl", "export_3mf", "export_f3d", "export_fbx", "export_usdz",
    "export_dxf", "export_dwg", "export_iges", "export_obj", "export_sat", "export_skp",
    "get_design_info",
    "get_parameters", "set_parameter",
    "take_screenshot",
    # Electronics commands (EAGLE via Electron.run)
    "electron_run", "execute_text_command",
    "electron_zoom", "electron_pan", "electron_select",  # video-friendly view control
    "open_electronics", "list_text_commands",
    # Electronics source export (.fsch, .fbrd, .flbr via Document.CopyToDesktop)
    "export_source",
    # EAGLE-format export (.sch, .brd — extracted from .fsch/.fbrd ZIP container)
    "export_eagle_source",
    # Library file commands (EXPORT SCRIPT — open_lbr is orchestrated at bridge level)
    "export_lbr",
    # Document management
    "close_document",
    "close_all_documents",
    # Electronics file opening (open_schematic, open_board, show_3d_board, show_2d_board
    # are orchestrated at bridge level for auto-screenshot — not in this set)
    # Board data query
    "board_info",
    # Open any cloud file directly by its APS/Fusion URN (any folder depth).
    # fusion_aps_open also proxies this internally (fire-and-poll); registering it
    # here makes the direct fusion_open_by_urn verb work too instead of being
    # rejected as "Unknown command".
    "open_by_urn",
    # In-app parametric modeling — run an adsk.fusion script in the live session
    # (free path to programmatic CAD; the APS Fusion Automation API is the paid
    # cloud alternative).
    "run_modeling_script",
    # Cloud document management
    "save_to_cloud",
    "list_cloud_projects",
    "list_cloud_files",
    "delete_cloud_file",
    "create_cloud_folder",
    # open_cloud_file is orchestrated at bridge level (not direct proxy)
    # to detect blocking dialogs after open
    "check_recovery",
    "search_cloud_files",
    "walk_cloud_tree",
    "export_cloud_file",
    # Manufacturing exports
    "export_bom",
    "export_cpl",
    "export_gerbers",
    "set_design_rules",
    "export_board_image",
    "detect_layers",
}

# Map CLI command names → add-in command names (where they differ)
ADDIN_COMMAND_MAP = {
    "import_step": "import_file",
}

# Per-command timeout overrides for _proxy_to_addin. Heavy 3D exports on
# panelized boards (100+ placements) can take 120s+. The add-in-side
# timeout in http_server.py should be the source of truth; these are
# matched to that so urllib doesn't cut off before the add-in does.
ADDIN_COMMAND_TIMEOUTS = {
    "export_step": 300,
    "export_iges": 300,
    "export_sat": 300,
    "export_stl": 300,
    "export_3mf": 300,
    "export_usdz": 300,
    "export_obj": 300,
    "export_f3d": 300,
    "export_fbx": 300,
    "export_skp": 300,
    "export_dxf": 120,
    "export_dwg": 120,
    "export_gerbers": 180,
    "export_bom": 60,
    "export_cpl": 60,
    "export_board_image": 60,
    "close_all_documents": 60,
    # Cloud tree walker / search — can hit hundreds of folders on large projects.
    # Must match or exceed http_server.py PER_COMMAND_TIMEOUT so urllib doesn't
    # cut off before the add-in does.
    "walk_cloud_tree": 600,
    "search_cloud_files": 180,
    # Opening a cloud design downloads + loads it; large assemblies and
    # electronics/PCB designs (which spin up the Electronics editor) can take
    # minutes. fusion_aps_open's fire-and-poll path is the preferred way in for
    # these — but when open_by_urn is proxied synchronously, give it room.
    "open_by_urn": 240,
    # Modeling scripts can build many features; give them room without being unbounded.
    "run_modeling_script": 180,
}

# ── Busy gate: prevent command stacking during long-running add-in work ──
# When walk_cloud_tree or search_cloud_files is running, the Fusion main thread
# is blocked for 30-300+ seconds. Any add-in command sent during that time would
# pile up behind _main_thread_lock in http_server.py, eating HTTP threads and
# potentially crashing the host. The gate rejects those commands immediately at
# the bridge level with progress info, BEFORE they reach the add-in.
import threading as _threading
import time as _time

_long_command_lock = _threading.Lock()
_long_command = None  # None or {"command": str, "startedAt": float}

LONG_RUNNING_COMMANDS = {"walk_cloud_tree", "search_cloud_files"}

# Commands that change Fusion's state and can pop a modal dialog / owned popup the AI
# must see (a save confirm, the Hub "are you sure you want to close?" data-loss prompt,
# a recovery prompt, etc.). After these, the dispatcher auto-attaches the dialog array
# (_capture_dialog_array) + an analyze-this hint so the AI cannot fly blind. Read-only
# commands (get_app_state, document_info, board_info, exports) are intentionally excluded
# to avoid the per-call screenshot latency. See the fusion-driving skill.
MUTATING_COMMANDS = {
    "run_modeling_script", "execute_text_command", "electron_run",
    "close_document", "close_all_documents", "import_step",
    "set_parameter", "save_to_cloud", "delete_cloud_file",
}


def _set_long_command(command: str):
    with _long_command_lock:
        global _long_command
        _long_command = {"command": command, "startedAt": _time.time()}


def _clear_long_command():
    with _long_command_lock:
        global _long_command
        _long_command = None


def _get_long_command() -> dict | None:
    with _long_command_lock:
        if _long_command is None:
            return None
        return dict(_long_command)


def _get_busy_progress() -> dict | None:
    """Poll the add-in /status endpoint for walkProgress during a long command.

    Uses /status (not /health) because it's lighter and proven reliable under
    GIL contention during heavy walks. 3s timeout matches _check_addin_status.
    """
    status = _check_addin_status(timeout=3.0)
    if status and status.get("walkProgress"):
        return status["walkProgress"]
    return None


def _check_main_thread_blocked() -> bool:
    """Quick check: is Fusion's main thread blocked by a modal dialog?

    Sends a fast command (get_app_state) with a short timeout. If the add-in's
    HTTP server responds but the command times out, a modal dialog is blocking.
    Returns True if blocked, False if responsive.
    """
    try:
        body = json.dumps({"command": "get_app_state", "args": {}}).encode("utf-8")
        req = urllib.request.Request(
            f"http://127.0.0.1:{ADDIN_PORT}/command",
            data=body,
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=3) as resp:
            result = json.loads(resp.read())
        # If we got a response, main thread is fine
        if result.get("success"):
            return False
        # Add-in responded but with an error — check if it's a timeout
        if "timed out" in result.get("error", "").lower():
            return True
        return False
    except Exception as e:
        # HTTP timeout = main thread blocked (HTTP server is up but command didn't complete)
        if "timed out" in str(e).lower() or "timeout" in str(e).lower():
            return True
        # Connection refused = add-in not running (different problem)
        return False


def _probe_addin(timeout: float = 0.5) -> dict | None:
    """Check if the Fusion add-in HTTP server is running.

    Uses a short timeout (default 0.5s) to avoid blocking the /health endpoint.
    The add-in runs on localhost so if it's up, it responds in <50ms.
    """
    try:
        req = urllib.request.Request(f"http://127.0.0.1:{ADDIN_PORT}/health", method="GET")
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return json.loads(resp.read())
    except Exception:
        return None


def _check_addin_status(timeout: float = 3.0) -> dict | None:
    """GET /status from the add-in. Returns dict or None.

    This is the cross-bridge busy probe — reads add-in busy state without
    acquiring the main thread lock. Safe to call from any bridge/container.
    Usually ~50ms, but during heavy walks GIL contention can push to 1-2s.
    """
    try:
        req = urllib.request.Request(f"http://127.0.0.1:{ADDIN_PORT}/status", method="GET")
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return json.loads(resp.read())
    except Exception:
        return None


def _handle_addin_status() -> dict:
    """Bridge-level handler for addin_status — wraps /status in standard format."""
    status = _check_addin_status(timeout=3.0) or {"busy": False}
    stale = _addin_staleness(status.get("version"))
    status = {**status, **stale}
    result = {"success": True, "output": json.dumps(status), **status}
    if stale.get("addinStale"):
        result["_hint"] = stale["_staleHint"]
    return result


def _proxy_to_addin(command: str, args: dict, timeout: int = 30) -> dict:
    """Proxy a command to the Fusion add-in HTTP server.

    On timeout, probes /health to distinguish:
    - Add-in alive but main thread blocked (modal dialog) → distinct error
    - Add-in HTTP server crashed → connection error
    """
    body = json.dumps({"command": command, "args": args}).encode("utf-8")
    req = urllib.request.Request(
        f"http://127.0.0.1:{ADDIN_PORT}/command",
        data=body,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            result = json.loads(resp.read())

        # Check if the add-in itself returned a timeout (main thread didn't respond)
        if (not result.get("success")
                and "timed out" in result.get("error", "").lower()):
            return _diagnose_addin_timeout(command, result)

        # If the add-in rejected the command as unknown/unsupported, it may be a
        # STALE add-in that predates this verb (issue #55 - Drew's silent failure:
        # old add-in had no open_by_urn). Enrich with a version comparison + the
        # re-sync hint + a stable errorCode so the failure is LOUD, not silent.
        if not result.get("success"):
            err = (result.get("error") or "").lower()
            if any(s in err for s in ("unknown command", "no such command", "not supported", "unsupported command")):
                stale = _addin_staleness((_check_addin_status(timeout=1.0) or {}).get("version"))
                if stale.get("addinStale"):
                    result["errorCode"] = "addin_stale"
                    result["_hint"] = stale["_staleHint"]
                    result.update({k: stale[k] for k in ("addinVersion", "expectedAddinVersion", "addinStale")})

        return result

    except urllib.error.URLError as e:
        if "Connection refused" in str(e) or "No connection" in str(e):
            return {
                "success": False,
                "error": "Fusion 360 AdomBridge add-in not running. "
                         "Install it with: python plugins/fusion360/install_addin.py, "
                         "then restart Fusion 360.",
            }
        # Could be a socket timeout — the HTTP request itself took too long
        if "timed out" in str(e).lower() or "timeout" in str(e).lower():
            return _diagnose_addin_timeout(command, {"error": str(e)})
        return {"success": False, "error": f"Add-in request failed: {e}"}
    except Exception as e:
        if "timed out" in str(e).lower() or "timeout" in str(e).lower():
            return _diagnose_addin_timeout(command, {"error": str(e)})
        return {"success": False, "error": f"Add-in request failed: {e}"}


def _diagnose_addin_timeout(command: str, original_result: dict) -> dict:
    """After a command timeout, probe /health to determine the cause.

    Returns a distinct error if the add-in is alive but its main thread
    is blocked (e.g. by a modal dialog in Fusion).  Always captures
    auto-screenshots of Fusion windows on timeout so the AI can see
    what dialog is blocking.
    """
    # Auto-screenshot on timeout — the most common cause is a blocking dialog
    # that's invisible to the API.  Capture Fusion windows so the AI can
    # identify and dismiss the dialog.
    timeout_screenshots = {}
    try:
        timeout_screenshots = _post_open_screenshot(command)
    except Exception:
        pass  # Best effort — don't let screenshot failure mask the real error

    # Identify WHICH modal is blocking (titles enumerate over Win32 even while the
    # add-in's main thread is stuck). Turns the opaque "add-in not responding" into
    # an actionable cause + resolution — and stops the needless restart loop.
    blocking_dialogs = classify_blocking_dialogs()

    health = _probe_addin(timeout=2.0)
    if health and health.get("status") == "ok":
        main_thread = health.get("main_thread", "unknown")
        pending = health.get("pending_commands", 0)
        if main_thread == "blocked" or pending > 0:
            # Before assuming a modal dialog, check /status — if the add-in
            # is busy with a known command, it's working (not dialog-blocked).
            status = _check_addin_status(timeout=3.0)
            if status and status.get("busy"):
                busy_cmd = status.get("busyCommand", "unknown")
                elapsed = status.get("elapsedSeconds", 0)
                walk = status.get("walkProgress")
                resp = {
                    "success": False,
                    "error": f"Fusion main thread busy — {busy_cmd} running for {elapsed}s.",
                    "errorCode": "main_thread_busy",
                    "busyCommand": busy_cmd,
                    "elapsedSeconds": elapsed,
                    "_hint": (
                        "Add-in is busy with a long-running command. Do NOT retry add-in commands. "
                        "Do NOT press Escape — the add-in is working, not stuck on a dialog. "
                        "Commands that still work: fusion_window_info, fusion_screenshot_fusion, "
                        "fusion_click_fusion, fusion_send_key, fusion_close_window."
                    ),
                }
                if walk:
                    resp["progress"] = walk
                return resp

            # If we recognized the blocking modal, name it and give the precise fix
            # instead of the generic "read the screenshots" guidance.
            if blocking_dialogs:
                titles = ", ".join(f"'{d['title']}' ({d['category']})" for d in blocking_dialogs)
                resolutions = " ".join(dict.fromkeys(d["resolution"] for d in blocking_dialogs))
                message = (f"The AdomBridge add-in is alive but its main thread is blocked by a "
                           f"modal dialog: {titles}. This is NOT an add-in crash — do not restart "
                           f"Fusion. {resolutions}")
            else:
                message = (f"The AdomBridge add-in is alive but its main thread is not "
                           f"responding (status: {main_thread}, pending: {pending}). "
                           f"Fusion 360 likely has a modal dialog open (Document Recovery, "
                           f"error, or update prompt) that is blocking execution. "
                           f"READ the screenshots to identify the dialog, then dismiss "
                           f"with fusion_send_key {{\"key\": \"escape\"}} or "
                           f"fusion_send_key {{\"key\": \"tab\"}} + {{\"key\": \"enter\"}}.")
            return {
                "success": False,
                "error": "addin_main_thread_blocked",
                "message": message,
                "blockingDialogs": blocking_dialogs,
                "data": {
                    "command": command,
                    "health": health,
                    "blockingDialogs": blocking_dialogs,
                    "postOpenScreenshot": timeout_screenshots,
                },
            }
        # Health says responsive but command still timed out — unusual
        return {
            "success": False,
            "error": "addin_command_timeout",
            "message": f"Command '{command}' timed out but the add-in reports main thread "
                       f"is {main_thread}. The command may be long-running or stuck.",
            "data": {
                "command": command,
                "health": health,
                "postOpenScreenshot": timeout_screenshots,
            },
        }

    # Health probe failed — add-in HTTP server is down
    return {
        "success": False,
        "error": "AdomBridge add-in not responding (it may have crashed).",
        "errorCode": "fusion_addin_not_responding",
        "_hint": "Fix it YOURSELF - never ask the user: restart Fusion via fusion_stop + "
                 "fusion_start (the bridge installs the add-in to ALL Fusion add-in dirs incl. "
                 "%APPDATA%\\Autodesk\\FusionAddins, and runOnStartup reloads it).",
    }


def _orchestrate_open_lbr(args: dict) -> dict:
    """Open an EAGLE .lbr library file in Fusion 360 Electronics.

    Uses Document.newDesignFromLocal (via the add-in's execute_text_command)
    to open the .lbr file, which auto-switches to the Electronics Library
    editor. Then optionally navigates to a symbol and verifies via export.

    This is orchestrated at bridge level to avoid add-in module caching
    issues and to allow multi-step operations with waits.
    """
    import tempfile
    import time

    file_path = args.get("filePath", "")
    symbol_name = args.get("symbolName", "")
    verify = args.get("verify", False)

    if not file_path:
        return {"success": False, "error": "No filePath specified"}

    file_path = file_path.replace("\\", "/")
    results = []

    # Step 1: Open the .lbr via Document.newDesignFromLocal.
    # ⚠️ TIMEOUT + VERIFY-BY-STATE (fixed 2026-07-07, caught live on a GPU-less Azure
    # VM): the open can take 60s+ on slow/software-rendered machines, so a fixed 30s
    # read timeout expired mid-open and the canned proxy error claimed the ADD-IN was
    # "not running" while the document was actually opening fine (fusion_build_library_3d
    # then aborted all parts on a lie). Now: a generous timeout, AND on ANY failure we
    # poll get_app_state for the expected document name - the doc actually being open
    # outranks whatever the synchronous return claimed.
    open_result = _proxy_to_addin("execute_text_command", {
        "command": f"Document.newDesignFromLocal {file_path}",
    }, timeout=120)

    if not open_result.get("success"):
        # Verify by STATE before failing: did the doc open anyway?
        expected = file_path.replace("\\", "/").rsplit("/", 1)[-1]
        expected = expected.rsplit(".", 1)[0].lower()
        opened_anyway = False
        for _ in range(20):  # up to ~60s of settling
            time.sleep(3)
            try:
                st = _proxy_to_addin("get_app_state", {}, timeout=8)
                active = str((st.get("data") or {}).get("activeDocument", "")).lower()
                if expected and expected in active:
                    opened_anyway = True
                    break
            except Exception:
                pass
        if not opened_anyway:
            return {
                "success": False,
                "error": f"Document.newDesignFromLocal failed: {open_result.get('error', 'unknown')}",
                "_hint": ("The open did not complete AND the document never became active. On slow/"
                          "software-rendered machines opens can take 60s+; this call already waited + "
                          "verified by state. Check fusion_check_dialogs for a blocking modal, then retry."),
                "data": {"filePath": file_path},
            }
        results.append("Document.newDesignFromLocal: ok (verified by app state after slow open)")
    else:
        results.append("Document.newDesignFromLocal: ok")

    # Step 2: Navigate to symbol if requested
    if symbol_name:
        time.sleep(3)  # Let Fusion finish opening and switching workspace

        sym_ref = symbol_name if symbol_name.endswith(".sym") else f"{symbol_name}.sym"
        edit_result = _proxy_to_addin("electron_run", {
            "command": f"EDIT {sym_ref}",
        }, timeout=10)
        results.append(f"EDIT {sym_ref}: success={edit_result.get('success')}")

        # Zoom to fit
        _proxy_to_addin("electron_run", {"command": "WINDOW FIT"}, timeout=5)
        results.append("WINDOW FIT: ok")

    # Step 3: Verify via EXPORT SCRIPT
    verification = None
    if verify:
        import uuid
        time.sleep(2)  # Let Fusion settle before export
        # Use a unique path to avoid "overwrite?" dialogs blocking the UI thread
        export_path = str(Path(tempfile.gettempdir()) / f"_adom_verify_{uuid.uuid4().hex[:8]}.scr")
        export_result = _proxy_to_addin("export_lbr", {"outputPath": export_path}, timeout=30)
        if export_result.get("success"):
            preview = export_result.get("data", {}).get("preview", "")
            has_symbol = False
            if symbol_name:
                has_symbol = (
                    f"'{symbol_name.upper()}.sym'" in preview
                    or f"'{symbol_name}.sym'" in preview
                )
            verification = {
                "exported": True,
                "fileSize": export_result.get("data", {}).get("fileSize", 0),
                "hasSymbol": has_symbol,
                "preview": preview[:500],
            }
        else:
            verification = {"exported": False, "error": export_result.get("error", "unknown")}
        results.append(f"Verification: exported={verification.get('exported', False)}")

    import os
    response = {
        "success": True,
        "output": f"Opened library: {os.path.basename(file_path)}",
        "data": {"results": results, "filePath": file_path},
        "_hint": (
            "Library opened in the Electronics Library editor (Content Manager). "
            "ALWAYS VERIFY VIA SCREENSHOT: a .lbr can FAIL to open ('<file>.lbr has errors and cannot be "
            "opened') while this call still returns success - that error is an OWNED POPUP. Grab "
            "desktop_screenshot_window on the Fusion main hwnd and CHECK ownedPopupCount + read the "
            "_screenshots[] array (AD v1.8.177+ captures owned dialogs invisible to a plain capture); "
            "ownedPopupCount>0 means an error/confirm dialog is up. "
            "NOTE: an adom-lbr .lbr is 2D ONLY - symbol + footprint + a PLACEHOLDER 3D package. "
            "To attach the real 3D chip, use fusion_attach_3d_package (it runs the Package3D generator + "
            "FINISH, which binds the 3D onto the deviceset). See the 'fusion-libraries' skill / LIBRARY_FINDINGS.md."
        ),
    }
    if symbol_name:
        response["data"]["symbolName"] = symbol_name
    if verification:
        response["data"]["verification"] = verification

    # Auto-screenshot after opening — catches blocking dialogs, same as other open commands
    return _post_open_screenshot(response)


def _orchestrate_attach_3d_package(args: dict) -> dict:
    """Attach a real 3D model to a library package, end to end.

    Opens the .lbr (library active), runs Electron.Create3DPackage to enter the
    Package3DEnvironment showing the footprint, imports the STEP model and
    auto-orients it flat on the footprint, then executes Package3DStop (FINISH).
    Fusion then shows a modal Save dialog (an OWNED popup) — that single click is
    the only desktop-side step; this returns the exact instruction for it.

    args: {filePath: Windows path to the .lbr, modelPath: Windows path to the
           STEP, packageName: optional str}
    """
    import time
    file_path = (args.get("filePath") or "").replace("\\", "/")
    model_path = (args.get("modelPath") or "").replace("\\", "/")
    package = args.get("packageName", "")
    orient_flag = args.get("orient", True)  # 2026-07-07: expose orient (was hard-on)
    if not file_path or not model_path:
        return {"success": False,
                "error": "filePath (.lbr) and modelPath (.step) are required (Windows paths, e.g. C:/...).",
                "_hint": "Stage both onto Windows first (no container->Windows push verb). See the fusion-libraries skill."}
    steps = []
    open_res = _orchestrate_open_lbr({"filePath": file_path})
    if not open_res.get("success"):
        return {"success": False, "error": "open_lbr failed: " + str(open_res.get("error")), "data": {"steps": steps}}
    steps.append("open_lbr: ok (library active)")
    time.sleep(1)
    cp = _proxy_to_addin("execute_text_command", {"command": f"Electron.Create3DPackage {file_path}"}, timeout=40)
    if not cp.get("success"):
        return {"success": False, "error": "Create3DPackage failed: " + str(cp.get("error")),
                "_hint": "The library document must be ACTIVE, and the .lbr must be adom-lbr-generated "
                         "(a raw vendor EAGLE .lbr fails Fusion's XML parser).", "data": {"steps": steps}}
    steps.append("Create3DPackage: ok (Package3DEnvironment)")
    time.sleep(2)
    import_script = (
        "import adsk.core, adsk.fusion, math\n"
        "res={}\n"
        "d=adsk.fusion.Design.cast(app.activeProduct); root=d.rootComponent\n"
        "im=app.importManager\n"
        f"im.importToTarget(im.createSTEPImportOptions('{model_path}'), root)\n"
        "oc=root.occurrences.item(root.occurrences.count-1); bb=oc.boundingBox\n"
        "dx=bb.maxPoint.x-bb.minPoint.x; dy=bb.maxPoint.y-bb.minPoint.y; dz=bb.maxPoint.z-bb.minPoint.z\n"
        # Tall-part guard (2026-07-07): keep a part vertical if its largest dim is already Z; only
        # flatten a clearly-thin part whose thin axis isn't Z. Was: always tip smallest dim to Z,
        # which laid tall through-hole pins on their side. Honors the orient flag (default True).
        + ("axis=None\n" if not orient_flag else
           "thin=min(dx,dy,dz); big=max(dx,dy,dz)\n"
           "tall_z=(dz>=dx and dz>=dy)\n"
           "axis=None\n"
           "if (thin < 0.5*big) and not tall_z:\n"
           "    axis=(1,0,0) if (dy<=dx and dy<=dz) else ((0,1,0) if (dx<=dy and dx<=dz) else None)\n") +
        "if axis:\n"
        "    mat=oc.transform2.copy(); rot=adsk.core.Matrix3D.create()\n"
        "    rot.setToRotation(math.pi/2, adsk.core.Vector3D.create(*axis), adsk.core.Point3D.create(0,0,0))\n"
        "    mat.transformBy(rot); oc.transform2=mat\n"
        "    d.snapshots.add() if d.snapshots.hasPendingSnapshot else None\n"
        "res['dims_mm']=[round(dx*10,2),round(dy*10,2),round(dz*10,2)]\n"
        "result=res\n"
    )
    imp = _proxy_to_addin("run_modeling_script", {"script": import_script}, timeout=120)
    if not imp.get("success"):
        return {"success": False, "error": "import/orient failed: " + str(imp.get("error")), "data": {"steps": steps}}
    dims = (imp.get("data", {}) or {}).get("result", {})
    steps.append(f"import+orient: ok ({dims.get('dims_mm') if isinstance(dims, dict) else dims})")
    time.sleep(1)
    _proxy_to_addin("run_modeling_script",
                    {"script": "import adsk.core\ncd=ui.commandDefinitions.itemById('Package3DStop')\nresult={'finished': bool(cd) and cd.execute()}\n"},
                    timeout=30)
    steps.append("FINISH (Package3DStop): executed")
    return {
        "success": True,
        "output": f"3D model placed + oriented on the footprint and FINISH executed for '{package or file_path}'. A Save dialog is now up.",
        "data": {"steps": steps, "package": package},
        "savePending": True,
        "_hint": (
            "FINAL STEP (desktop-side, one click): a Fusion 'Save' dialog is now up to save the 3D package. "
            "Click it: desktop_find_window {titleContains:'Save'} -> desktop_ui_click "
            "{hwnd, automationId:'QTApplication.QTFrameWindow.standardActions.SaveButton'}. "
            "THEN VERIFY with desktop_screenshot_window on the Fusion hwnd: check ownedPopupCount (errors are owned "
            "popups), and the deviceset's Package column should flip Placeholder->part-name + the 3D preview becomes "
            "the real chip (the preview LAGS a beat - re-grab). Full flow: fusion-libraries skill / LIBRARY_FINDINGS.md sect 11."
        ),
    }


# ── Fusion cloud FOLDER HYGIENE (never write loose files to a shared project ROOT) ───────────
# Hard lesson (2026-06-29): defaulting uploads to a project's ROOT folder dumped 100+ loose f3d
# files into the shared Adom team root and other employees complained. RULE: the bridge NEVER
# writes a file to a project root. It writes into an AI-OWNED "Adom AI Workspace" folder, with a
# per-task SUBfolder, keeping the cloud tidy. See the fusion-cloud-hygiene skill.
_DEFAULT_UPLOAD_PROJECT = "a.YnVzaW5lc3M6YWRvbTMjMjAyMzExMjk3MDM5NjAzMzE"  # the Adom business project
# The shared team ROOT folder of that project - OFF LIMITS for loose files (only the workspace
# folder itself may live here). Known roots we must refuse as a write target.
_KNOWN_ROOT_FOLDERS = {"urn:adsk.wipprod:fs.folder:co.jyO4vxQXR6S9zFpTQWnZAg"}
_AI_WORKSPACE_NAME = "Adom AI Workspace"   # the AI-owned work area (BRAND: "Adom AI", not "Claude")
_ws_folder_cache = {}


def _safe_folder_name(name: str) -> str:
    import re as _re
    return _re.sub(r'[<>:"/\\|?*]', "", str(name or "")).strip()[:60] or "task"


def _find_child_folder(project_id: str, parent_id: str, name: str):
    """folderId of a subfolder named `name` directly under parent_id, or None."""
    try:
        r = aps.handle_browse({"projectId": project_id, "folderId": parent_id})
        items = (r.get("data") or {}).get("items", []) if isinstance(r, dict) else []
        for it in items:
            if it.get("type") == "folders" and (it.get("name") or "").strip() == name:
                return it.get("id")
    except Exception:
        pass
    return None


def _ensure_workspace_folder(project_id: str, task: str = None):
    """Return a folderId inside the AI-owned 'Adom AI Workspace' (NEVER a project root). Creates the
    workspace folder (one tidy folder under the project root) + an optional per-task subfolder if
    missing. Cached per (project, task). Returns None if it cannot be resolved (caller must NOT then
    fall back to root)."""
    task = _safe_folder_name(task) if task else None
    key = (project_id, task or "")
    if key in _ws_folder_cache:
        return _ws_folder_cache[key]
    root = next(iter(_KNOWN_ROOT_FOLDERS))  # parent for the single workspace folder
    ws = _find_child_folder(project_id, root, _AI_WORKSPACE_NAME)
    if not ws:
        cr = aps.handle_create_folder({"projectId": project_id, "parentFolderId": root, "name": _AI_WORKSPACE_NAME})
        ws = (cr.get("data") or {}).get("folderId") if isinstance(cr, dict) and cr.get("success") else None
    folder = ws
    if task and ws:
        sub = _find_child_folder(project_id, ws, task)
        if not sub:
            cr = aps.handle_create_folder({"projectId": project_id, "parentFolderId": ws, "name": task})
            sub = (cr.get("data") or {}).get("folderId") if isinstance(cr, dict) and cr.get("success") else None
        folder = sub or ws
    if folder:
        _ws_folder_cache[key] = folder
    return folder


def _discover_upload_target(args: dict) -> tuple:
    """(projectId, folderId) for f3d uploads - ALWAYS a non-root, AI-owned folder.

    Defaults to 'Adom AI Workspace/<task>' (auto-created). If the caller explicitly passes a
    folderId that is a known project ROOT, it is REFUSED (we steer to the workspace instead) - the
    bridge must never write loose files to a shared root. Returns (projectId, folderId|None);
    folderId is None only if the workspace folder could not be created (caller must error, NOT
    fall back to root)."""
    project_id = args.get("projectId") or _DEFAULT_UPLOAD_PROJECT
    fid = args.get("folderId")
    if fid and fid in _KNOWN_ROOT_FOLDERS:
        fid = None  # explicit root -> refuse, steer to workspace
    if not fid:
        fid = _ensure_workspace_folder(project_id, args.get("task"))
    return (project_id, fid)


def _capture_labeled(label) -> str | None:
    """Background-capture the main Fusion window to a labeled PNG on the box
    (C:/tmp/conduit-screenshots). Returns the saved path or None. Never fullscreen
    (hwnd-targeted PrintWindow, so it captures Fusion in the background per fusion-driving)."""
    if not label:
        return None
    try:
        info = get_fusion_window_info()
        hwnd = info.get("hwnd")
        if not hwnd:
            return None
        import re as _re
        safe = _re.sub(r'[<>:"/\\|?*]', "", str(label)).replace(" ", "_")[:40]
        r = screenshot_hwnd(hwnd, label=safe)
        return r.get("savedTo") if r.get("success") else None
    except Exception:
        return None


def _inject_package3d_bindings(lbr_text: str, bindings: list) -> str:
    """Inject EAGLE <packages3d> + per-device <package3dinstances> into an .lbr - MERGE-AWARE
    and IDEMPOTENT (safe to re-run with any subset of parts).

    bindings: [{package: <pkg name>, wip_urn: <urn>}]. The function reads any bindings ALREADY in
    the .lbr, merges the new ones on top (new wins on conflict), strips all prior <packages3d> +
    <package3dinstances>, then re-emits the full merged set: a library-level <package3d> per part
    (between </packages> and <symbols>) and a <package3dinstances> inside every <device> that uses
    that package (right after </connects>). So calling it again with just the parts that failed last
    time ACCUMULATES instead of wiping the parts that already succeeded - the fix for the
    'a re-run dropped the earlier bindings' trap. Returns the new .lbr text."""
    import re as _re
    # 1. read existing bindings already in the file; new bindings override
    merged = {}
    em = _re.search(r"<packages3d>(.*?)</packages3d>", lbr_text, _re.S)
    if em:
        for pm in _re.finditer(r'<package3d name="([^"]+)"[^>]*wip_urn="([^"]+)"', em.group(1)):
            merged[pm.group(1)] = pm.group(2)
    for b in bindings:
        merged[b["package"]] = b["wip_urn"]
    # 2. strip ALL prior package3d markup so re-injection is clean (idempotent)
    lbr_text = _re.sub(r"\s*<packages3d>.*?</packages3d>", "", lbr_text, flags=_re.S)
    lbr_text = _re.sub(r"\s*<package3dinstances>.*?</package3dinstances>", "", lbr_text, flags=_re.S)
    # 3. library-level <packages3d> block (all merged parts)
    blocks = []
    for pkg, urn in merged.items():
        blocks.append(
            f'<package3d name="{pkg}" urn="" wip_urn="{urn}" locally_modified="yes" type="model">'
            f'<description>{pkg}</description>'
            f'<packageinstances><packageinstance name="{pkg}"/></packageinstances>'
            f'</package3d>'
        )
    pkgs3d = "<packages3d>\n" + "\n".join(blocks) + "\n</packages3d>\n"
    lbr_text = lbr_text.replace("</packages>", "</packages>\n" + pkgs3d, 1)

    # 4. per-device <package3dinstances>
    def _dev_repl(m):
        dev = m.group(0)
        pm = _re.search(r'package="([^"]+)"', dev)
        if pm and pm.group(1) in merged and "</connects>" in dev:
            inst = (f'<package3dinstances><package3dinstance package3d_urn="{merged[pm.group(1)]}"/>'
                    f'</package3dinstances>')
            dev = dev.replace("</connects>", "</connects>\n" + inst, 1)
        return dev

    return _re.sub(r'<device\b[^>]*>.*?</device>', _dev_repl, lbr_text, flags=_re.S)


def _orchestrate_make_3d_package(args: dict) -> dict:
    """Create a RENDERING component 3D-PACKAGE urn (footprint + chip), fully programmatically - NO GUI
    dialogs. A proper component 3D model contains the FOOTPRINT (pads + courtyard) AND the chip,
    merged and aligned, so the 3D viewer can verify the chip's pads land on the footprint pads.

    So: open the library, run Electron.Create3DPackage to load the package's FOOTPRINT into a
    generator doc, import the STEP onto it, orient it flat, then saveAs an .f3d (footprint + chip) -
    which skips the FINISH Save dialog + the two unbeatable "Fusion360" CEF modals entirely -
    aps_upload the .f3d, and return the fs.file:vf wip_urn to hand-write into the library's
    <packages3d>.

    Two gotchas this avoids: (1) importing the STEP into an EMPTY design gives a chip with NO
    footprint (an incomplete package); (2) a raw STEP upload does not render at all ("Thumbnail
    download failed"). See the fusion-multipart-libraries skill.

    args: {lbrPath: Windows .lbr whose FIRST package is the footprint, modelPath: Windows .step,
           projectId, folderId (both from fusion_aps_browse), fileName?: str, orient?: bool}
    """
    import os as _os, time as _time
    lbr_path = (args.get("lbrPath") or args.get("filePath") or "").replace("\\", "/")
    model_path = (args.get("modelPath") or "").replace("\\", "/")
    project_id, folder_id = _discover_upload_target(args)  # an AI-owned non-root folder (never project root)
    if not lbr_path or not model_path:
        return {"success": False,
                "error": "lbrPath (.lbr with the footprint) and modelPath (.step) are required (Windows paths)."}
    if not folder_id:
        return {"success": False, "errorCode": "no_work_folder",
                "error": "Could not resolve a non-root 'Adom AI Workspace' upload folder.",
                "_hint": "The bridge refuses to write to a shared project ROOT. Sign in (fusion_aps_signin) "
                         "so it can create 'Adom AI Workspace', or pass a real (non-root) folderId. NEVER "
                         "pass a project root folderId. See the fusion-cloud-hygiene skill."}
    cap_label = args.get("captureLabel")  # when set, capture BEFORE (footprint) + AFTER (chip placed)
    task = _safe_folder_name(args.get("task") or "AI 3D packages")  # saveAs subfolder (never root)
    orient = args.get("orient", True)
    base = _os.path.basename(model_path).rsplit(".", 1)[0]
    name = (args.get("fileName") or (base + "_3d")).replace("'", "").replace(".f3d", "")
    f3d_name = name + ".f3d"
    # 1. open the library so the footprint exists; 2. Create3DPackage -> generator WITH the footprint loaded
    op = _orchestrate_open_lbr({"filePath": lbr_path})
    if not op.get("success"):
        return {"success": False, "error": "open_lbr failed: " + str(op.get("error"))}
    _time.sleep(1)
    cp = _proxy_to_addin("execute_text_command", {"command": f"Electron.Create3DPackage {lbr_path}"}, timeout=40)
    if not cp.get("success"):
        return {"success": False, "error": "Create3DPackage failed: " + str(cp.get("error")),
                "_hint": "The .lbr must be adom-lbr-generated; its FIRST package's footprint is loaded into the generator."}
    _time.sleep(2)
    # BEFORE shot: the footprint (pads + courtyard) loaded in the 3D viewer, no chip yet.
    before_shot = None
    if cap_label:
        try:
            _proxy_to_addin("run_modeling_script",
                            {"script": "app.activeViewport.fit()\nresult={}"}, timeout=15)
        except Exception:
            pass
        _time.sleep(0.4)
        before_shot = _capture_labeled(f"{cap_label}_before")
    # AUTO-ORIENT (fixed 2026-07-07): the old logic always rotated the SMALLEST bbox dim to Z,
    # which is right for a flat SMD chip lying down but TIPS A TALL THROUGH-HOLE PART (machine pin,
    # connector) onto its side - its long axis is already Z and must stay vertical. Now: keep the
    # part vertical if its largest dim is already Z (tall_z), and only flatten a clearly THIN part
    # whose thin axis isn't Z yet. A caller can still force orient:false to skip entirely.
    orient_block = (
        "thin=min(dx,dy,dz); big=max(dx,dy,dz)\n"
        "tall_z=(dz>=dx and dz>=dy)\n"
        "is_flat=(thin < 0.5*big)\n"
        "axis=None\n"
        "if is_flat and not tall_z:\n axis=(1,0,0) if (dy<=dx and dy<=dz) else ((0,1,0) if (dx<=dy and dx<=dz) else None)\n"
        "if axis:\n mat=oc.transform2.copy(); r=adsk.core.Matrix3D.create()\n"
        " r.setToRotation(math.pi/2, adsk.core.Vector3D.create(*axis), adsk.core.Point3D.create(0,0,0))\n"
        " mat.transformBy(r); oc.transform2=mat\n"
    ) if orient else ""
    # 3. import the chip ONTO the footprint in the generator doc, orient, saveAs as f3d (footprint+chip)
    script = (
        "import adsk.core, adsk.fusion, math\n"
        "app=adsk.core.Application.get(); doc=app.activeDocument\n"
        "d=adsk.fusion.Design.cast(app.activeProduct); root=d.rootComponent\n"
        "im=app.importManager\n"
        f"im.importToTarget(im.createSTEPImportOptions('{model_path}'), root)\n"
        "oc=root.occurrences.item(root.occurrences.count-1); bb=oc.boundingBox\n"
        "dx=bb.maxPoint.x-bb.minPoint.x; dy=bb.maxPoint.y-bb.minPoint.y; dz=bb.maxPoint.z-bb.minPoint.z\n"
        + orient_block +
        "try:\n app.activeViewport.fit()\nexcept: pass\n"
        # FOLDER HYGIENE: saveAs into an 'Adom AI Workspace'/<task> subfolder, NEVER the project root.
        "proj=app.data.activeProject; rf=proj.rootFolder\n"
        "def _sub(p,nm):\n"
        " for i in range(p.dataFolders.count):\n"
        "  if p.dataFolders.item(i).name==nm: return p.dataFolders.item(i)\n"
        " return p.dataFolders.add(nm)\n"
        f"wsf=_sub(_sub(rf,'Adom AI Workspace'),'{task}')\n"
        "res={}\n"
        "try:\n"
        f" doc.saveAs('{name}', wsf, '3d package (footprint+chip)', '')\n"
        " res['path']=doc.dataFile.id if doc.dataFile else None\n"
        " res['dims']=[round(dx*10,2),round(dy*10,2),round(dz*10,2)]\n"
        "except Exception as e: res['err']=str(e)[:80]\n"
        "result=res\n"
    )
    r = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=120)
    res = (r.get("data", {}) or {}).get("result", {}) if isinstance(r, dict) else {}
    f3d_path = res.get("path") if isinstance(res, dict) else None
    dims = res.get("dims") if isinstance(res, dict) else None
    # AFTER shot: the chip placed flat on its footprint (pads landing on pads) in the 3D viewer.
    after_shot = _capture_labeled(f"{cap_label}_after") if cap_label else None
    if not f3d_path or not str(f3d_path).endswith(".f3d"):
        return {"success": False, "error": "f3d saveAs did not produce a .f3d path: " + str(res),
                "before": before_shot, "after": after_shot,
                "_hint": "Confirm modelPath is a valid Windows .step path and Fusion is running."}
    up = aps.handle_upload({"projectId": project_id, "folderId": folder_id,
                            "localPath": f3d_path, "fileName": f3d_name})
    up_d = up.get("data", up) if isinstance(up, dict) else {}
    item_urn = (up_d or {}).get("itemUrn", "") if isinstance(up_d, dict) else ""
    try:
        _proxy_to_addin("run_modeling_script", {"script": "app.activeDocument.close(False)\nresult={}"}, timeout=20)
    except Exception:
        pass
    if "dm.lineage:" not in item_urn:
        return {"success": False, "error": "f3d upload did not return a lineage urn: " + str(up_d)}
    lid = item_urn.split("dm.lineage:")[-1]
    wip_urn = f"urn:adsk.wipprod:fs.file:vf.{lid}?version=1"
    return {
        "success": True,
        "wip_urn": wip_urn,
        "dims_mm": dims,
        "f3d": f3d_path,
        "before": before_shot,
        "after": after_shot,
        "_hint": (
            "DONE - a PROPER component 3D package (FOOTPRINT + chip, aligned) created with NO GUI dialogs. "
            "NEXT: hand-write this wip_urn into the library's EAGLE XML - a <package3d name=\"PKG\" urn=\"\" "
            "wip_urn=\"" + wip_urn + "\" locally_modified=\"yes\" type=\"model\"> (with <packageinstances>"
            "<packageinstance name=\"PKG\"/></packageinstances>) inside <packages3d> (between </packages> and "
            "<symbols>), PLUS <package3dinstances><package3dinstance package3d_urn=\"" + wip_urn + "\"/>"
            "</package3dinstances> inside the device after </connects>. Then fusion_open_lbr the merged .lbr. "
            "EXPECT: fusion_check_dialogs == 0 (no broken-ref), the device 3D preview shows footprint + chip "
            "(NOT 'Thumbnail download failed'), and opening the f3d shows the chip's pads landing on the "
            "footprint pads. "
            "SPEEDUP: for a many-part library, call this verb once per part, collect the urns, hand-merge ALL "
            "bindings in ONE pass, then fusion_open_lbr ONCE (don't open/save per part). "
            "PITFALLS: needs Fusion running (after a bridge_install respawn it can transiently report "
            "'not running' - fusion_start, then retry); a RAW STEP upload binds but renders NOTHING "
            "('Thumbnail download failed') so always go through this verb (it makes an f3d); NEVER FINISH the "
            "Package3D generator (Package3DStop) - its Save dialog + two opaque CEF modals are unbeatable, "
            "this verb saveAs-es instead. Full recipe: the fusion-multipart-libraries skill."
        ),
    }


def _orchestrate_build_library_3d(args: dict) -> dict:
    """Bind real 3D onto a multi-part library. Runs DETACHED by default (async=True) so it
    SURVIVES AD's 60s relay cap.

    ⚠️ LEARNED THE HARD WAY (2026-07-08): the cloud Package3D generate + Hub upload takes MINUTES
    on a real machine, but AD's relay hard-caps every request at ~60s and `timeoutSeconds` is NOT
    honored for this verb - so the old synchronous build got its thread KILLED at 60s, wrote no
    bound .lbr, and lost every wip_urn (3/4 pins on a demo board came back as flat pads because the
    4th never got a fresh urn). Fix: the build now runs in a BACKGROUND daemon thread and returns
    immediately; the caller POLLS the bound `outLbrPath` file (read_file) until it has one
    `<package3d ... wip_urn=urn:...>` per part. Pass `async:false` only on a fast machine where the
    whole build fits under 60s. Then embed those package3d + a per-`<element>` `package3d_urn` in a
    `.brd` and `fusion_open_board` + `fusion_show_3d_board` shows the REAL 3D bodies (background).

    args: {..., async?: bool (default TRUE - detached + pollable)}. See _build_library_3d_core.
    """
    combined = (args.get("lbrPath") or "").replace("\\", "/")
    out_path = (args.get("outLbrPath") or combined).replace("\\", "/")
    if args.get("async", True) and args.get("parts"):
        import threading as _th

        def _run():
            try:
                _build_library_3d_core(args)
            except Exception:
                pass
        _th.Thread(target=_run, daemon=True).start()
        return {
            "success": True, "status": "started", "async": True,
            "boundLbr": out_path, "partsTotal": len(args.get("parts") or []),
            "_hint": (
                "⏳ 3D bind runs DETACHED (survives AD's 60s relay cap, which used to kill it + lose "
                "every wip_urn). POLL the boundLbr via read_file every ~30s until it has one "
                "<package3d name=... wip_urn=urn:...> PER PART (count == partsTotal). Do NOT re-fire "
                "while running (check fusion_addin_status.busy). When done, embed those <packages3d> + "
                "a per-<element> package3d_urn in your .brd, then fusion_open_board + fusion_show_3d_board "
                "for real 3D bodies (all background). If a urn stays unresolved in the 3D view, re-run "
                "for just that part - its f3d upload failed."),
        }
    return _build_library_3d_core(args)


def _build_library_3d_core(args: dict) -> dict:
    """Build a RENDERING multi-part 3D library in ONE call - the whole programmatic pipeline.

    For each part: make its footprint+chip f3d package (no GUI dialogs, optional BEFORE/AFTER
    screenshots), collect the wip_urn. Then inject ALL bindings into the combined .lbr in one
    pass (no hand XML surgery) and open the finished library ONCE. This is the verb to call for
    a basic-parts sampler / any many-part library - it replaces the per-part make_3d_package loop
    + manual binding the AI used to do.

    args: {
      lbrPath:  combined .lbr to bind + open (Windows path),
      parts:    [{package, lbrPath (per-part .lbr whose FIRST package is this footprint),
                  modelPath (.step)}],
      outLbrPath?: where to write the bound .lbr (default: overwrite lbrPath),
      capture?:    bool - capture before/after per part (default True),
      projectId?, folderId?: APS upload target (default: the MAIN-project upload folder),
      openWhenDone?: bool (default True)
    }
    """
    combined = (args.get("lbrPath") or "").replace("\\", "/")
    parts = args.get("parts") or []
    if not combined or not parts:
        return {"success": False,
                "error": "lbrPath (combined .lbr) and parts[] are required.",
                "_hint": "parts: [{package, lbrPath (per-part footprint .lbr), modelPath (.step)}]. "
                         "projectId/folderId default to the MAIN-project upload folder."}
    out_path = (args.get("outLbrPath") or combined).replace("\\", "/")
    capture = args.get("capture", True)
    # Put this library's f3d in its OWN task subfolder under 'Adom AI Workspace' (never project root).
    import os as _os2
    task = args.get("task") or _os2.path.basename(combined).rsplit(".", 1)[0]
    project_id, folder_id = _discover_upload_target({**args, "task": task})
    if not folder_id:
        return {"success": False, "errorCode": "no_work_folder",
                "error": "Could not resolve a non-root 'Adom AI Workspace' upload folder.",
                "_hint": "The bridge refuses to write to a shared project ROOT. Sign in "
                         "(fusion_aps_signin) so it can create the workspace folder, or pass a real "
                         "(non-root) folderId. See the fusion-cloud-hygiene skill."}
    results = []; bindings = []; shots = []
    for p in parts:
        pkg = p.get("package")
        per_lbr = (p.get("lbrPath") or "").replace("\\", "/")
        step = (p.get("modelPath") or "").replace("\\", "/")
        if not pkg or not per_lbr or not step:
            results.append({"package": pkg, "success": False,
                            "error": "package, lbrPath (per-part .lbr) and modelPath (.step) all required"})
            continue
        mk_args = {
            "lbrPath": per_lbr, "modelPath": step,
            "projectId": project_id, "folderId": folder_id,
            "fileName": f"{pkg}_3d", "task": task,
            "captureLabel": (pkg if capture else None),
            # Per-part orient passthrough (2026-07-07): a caller can set orient:false on a part
            # whose STEP is already correctly oriented (e.g. a tall through-hole pin). Defaults
            # to True, and make_3d_package's tall-part guard now keeps tall parts vertical anyway.
            "orient": p.get("orient", True),
        }
        mk = _orchestrate_make_3d_package(mk_args)
        # Retry once: the FIRST part of a run often fails transiently while Fusion settles from a
        # prior view/library; a single retry recovers it (the dropped-first-part trap).
        if not mk.get("success"):
            import time as _t
            _t.sleep(2)
            mk = _orchestrate_make_3d_package(mk_args)
        entry = {"package": pkg, "success": bool(mk.get("success"))}
        if mk.get("success"):
            entry["wip_urn"] = mk.get("wip_urn"); entry["dims_mm"] = mk.get("dims_mm")
            bindings.append({"package": pkg, "wip_urn": mk["wip_urn"]})
        else:
            entry["error"] = mk.get("error")
        if mk.get("before"):
            shots.append({"package": pkg, "stage": "before", "path": mk["before"]})
        if mk.get("after"):
            shots.append({"package": pkg, "stage": "after", "path": mk["after"]})
        results.append(entry)
    # inject every binding in ONE pass, write the bound library. Read the ALREADY-BOUND output if it
    # exists so a re-run ACCUMULATES (merge-aware) onto prior successes instead of starting clean.
    bound = None
    if bindings:
        try:
            import os as _os
            src = out_path if _os.path.exists(out_path) else combined
            with open(src, "r", encoding="utf-8") as fh:
                txt = fh.read()
            txt = _inject_package3d_bindings(txt, bindings)
            with open(out_path, "w", encoding="utf-8") as fh:
                fh.write(txt)
            bound = out_path
        except Exception as e:
            return {"success": False, "error": f"binding injection failed: {e}",
                    "parts": results, "bindings": bindings, "screenshots": shots}
    # open the finished library ONCE (so check_dialogs reflects the merged result)
    opened = None
    if bound and args.get("openWhenDone", True):
        try:
            opened = bool(_orchestrate_open_lbr({"filePath": bound}).get("success"))
        except Exception:
            opened = False
    n_ok = sum(1 for r in results if r.get("success"))
    return {
        "success": n_ok > 0,
        "boundLbr": bound,
        "partsBound": n_ok,
        "partsTotal": len(parts),
        "parts": results,
        "screenshots": shots,
        "opened": opened,
        "_hint": (
            f"{n_ok}/{len(parts)} parts got a RENDERING footprint+chip 3D package; all bindings injected "
            f"into {bound} and the library opened once. NEXT: fusion_check_dialogs should be 0 (no "
            "broken-ref). For clean device previews + screenshots, save to the cloud and REOPEN from "
            "the cloud (fusion_aps_open) - the in-session view often gets an empty 'Untitled' shoved in "
            "front. Each device's lower-right 3D preview should show footprint + chip (NOT 'Thumbnail "
            "download failed'). The BEFORE (footprint) / AFTER (chip placed) PNGs are in screenshots[] "
            "on the Windows box (C:/tmp/conduit-screenshots) - pull them with desktop_pull_file (or "
            "re-capture via desktop_screenshot_window) for the demo video. "
            "AFTER (recommended): call fusion_capture_library_views per showcase part to grab the "
            "symbol / footprint / component (pin<->pad mapped) views - those are what make EEs trust "
            "the library, and the 3D-only shots miss them. "
            "PITFALLS: needs Fusion running. ⚠️ RELAY TIMEOUT: a many-part run takes minutes but the "
            "adom-desktop relay times out the REQUEST at ~60s - you may get 'Request timed out' even "
            "though the build KEEPS RUNNING server-side and finishes. Do NOT assume it failed: wait, "
            "then verify by reading the boundLbr (count <package3d name=) + the before/after PNGs in "
            "C:/tmp/conduit-screenshots. If a part is missing, just re-run build_library_3d for the "
            "missing parts pointing outLbrPath at the SAME file - binding injection is now MERGE-AWARE "
            "and idempotent, so it accumulates onto the existing bindings (it no longer wipes the "
            "parts that already succeeded). Each part also self-retries once on a transient first-part "
            "failure. Full recipe: the fusion-multipart-libraries skill."
        ),
    }


def _dismiss_dialogs_bg() -> list:
    """Dismiss any blocking Fusion dialog in the BACKGROUND, without stealing focus.

    Uses close_window (WM_CLOSE via PostMessage) = Cancel/No on the dialog - the background-safe
    dismiss. ⛔ NEVER use send_key/Escape for this: send_key calls SetForegroundWindow and YANKS
    Fusion to the foreground, disrupting the user (learned 2026-06-29). WM_CLOSE is also more reliable
    than Escape on Qt dialogs. Returns the titles dismissed."""
    try:
        info = get_fusion_window_info()
        dismissed = []
        for d in info.get("dialogs", []) or []:
            hwnd = d.get("hwnd")
            if hwnd:
                try:
                    close_window(hwnd)  # background WM_CLOSE = Cancel/No (never creates a stray)
                    dismissed.append(d.get("title") or "")
                except Exception:
                    pass
        return dismissed
    except Exception:
        return []


def _orchestrate_capture_library_views(args: dict) -> dict:
    """Capture the LIBRARY-EDITOR views that make EEs trust a part: the schematic SYMBOL, the
    FOOTPRINT (pads + layer stack), and the COMPONENT/device view (Content Manager: the symbol +
    the package table with the footprint<->package Mapped check + pin/pad counts). These are what
    developers want to see - the 3D before/after shots alone don't show them.

    Requires the .lbr OPEN in the Electronics Library workspace (fusion_open_lbr first). For each
    package it runs the EAGLE/Electron EDIT <pkg>.sym / .pac / .dev, WINDOW FIT to frame, and a
    background hwnd screenshot (never fullscreen). Returns the saved PNG paths on the Windows box
    (C:/tmp/conduit-screenshots - pull them with desktop_pull_file).

    args: {packages: [<deviceset name>, ...] (or a single 'package'),
           views?: subset of ['component','symbol','footprint'] (default all three),
           settle?: seconds to wait after each EDIT before framing/capturing (default 1.5 - raise it
                    if a shot shows the PREVIOUS part, lower it to go faster on a snappy machine)}
    NOTE: ~2s per view * parts * views can exceed the ~60s relay request timeout - the captures still
    complete server-side; verify the PNGs landed in C:/tmp/conduit-screenshots and pull them.
    """
    import time as _t
    pkgs = args.get("packages") or ([args["package"]] if args.get("package") else [])
    if not pkgs:
        return {"success": False,
                "error": "packages: [<deviceset name>] (or package: <name>) required.",
                "_hint": "Open the .lbr first (fusion_open_lbr). Names are the deviceset names, e.g. ESP32-S3FN8."}
    view_ext = {"component": "dev", "symbol": "sym", "footprint": "pac",
                "dev": "dev", "sym": "sym", "pac": "pac"}
    label_of = {"dev": "component", "sym": "symbol", "pac": "footprint"}
    views = args.get("views") or ["component", "symbol", "footprint"]
    # EDIT is async - the editor takes a beat to actually SWITCH the view. Capturing too soon grabs
    # the PREVIOUS view (a mislabeled shot). Settle AFTER the EDIT (before fit/capture); tunable.
    settle = float(args.get("settle", 1.5))
    out = []
    for pkg in pkgs:
        for v in views:
            ext = view_ext.get(v, v)
            er = _proxy_to_addin("electron_run", {"command": f"EDIT {pkg}.{ext}"}, timeout=30)
            _t.sleep(settle)  # let the editor LOAD the new view before framing/capturing it
            # ALWAYS catch + dismiss any dialog the EDIT raised - in the BACKGROUND (no focus steal).
            # Common one: "Create new symbol/footprint '<name>'?" when <name> is a DEVICESET name but
            # the symbol/package is named by the shared combo. Dismissing (Cancel/No) avoids a stray.
            dismissed = _dismiss_dialogs_bg()
            _proxy_to_addin("electron_run", {"command": "WINDOW FIT"}, timeout=20)
            _t.sleep(0.6)
            shot = _capture_labeled(f"{pkg}_{label_of.get(ext, ext)}")
            out.append({"package": pkg, "view": label_of.get(ext, ext), "path": shot,
                        "dialogDismissed": dismissed or None,
                        "ok": bool(er.get("success", True)) and bool(shot) and not dismissed})
    n_ok = sum(1 for o in out if o.get("ok"))
    return {
        "success": n_ok > 0,
        "captured": out,
        "_hint": (
            f"Captured {n_ok}/{len(out)} library-editor views to C:/tmp/conduit-screenshots (pull with "
            "desktop_pull_file). SYMBOL = full pinout; FOOTPRINT = pads + layer stack; COMPONENT = the "
            "Content Manager device view with the footprint<->package Mapped check + pin/pad counts. "
            "⚠️ NAMES: the COMPONENT (.dev) view opens by DEVICESET name (e.g. R-0603-10K). But SYMBOL "
            "(.sym) and FOOTPRINT (.pac) open by the SYMBOL / PACKAGE name - in a SHARED-FOOTPRINT "
            "library (one symbol/footprint per package, many value devicesets) that is the COMBO name "
            "(e.g. 'R-0603'), NOT the deviceset name ('R-0603-10K'). Passing a deviceset name to .sym/"
            ".pac makes Fusion pop 'Create new symbol/footprint?'; the bridge now auto-dismisses that in "
            "the BACKGROUND (Cancel/No, no focus steal - see entries with dialogDismissed) and marks the "
            "view not-ok, but you should pass the right name. For shared libraries the COMPONENT view "
            "alone shows symbol + footprint + 3D previews + Mapped, so it is usually enough. "
            "PITFALLS: the .lbr must be OPEN in the Electronics Library workspace (fusion_open_lbr first)."
        ),
    }


def _orchestrate_cleanup_cloud_files(args: dict) -> dict:
    """Precisely delete a LIST of cloud files by lineage urn - SAFE cleanup of AI-created clutter.

    Deletes ONLY the exact `fileIds` (lineage urns) given - never name-guessing, so it cannot touch a
    teammate's file in a shared folder. Loops server-side (one call deletes the whole list). Use this
    to clean up f3d files the bridge created. (Find the ids with fusion_aps_browse on the folder.)

    args: {fileIds: [<lineage urn>, ...], projectName?: str (default active), folderPath?: str
           (default root - where the file lives)}
    """
    file_ids = args.get("fileIds") or []
    if not file_ids:
        return {"success": False, "error": "fileIds [lineage urns] required.",
                "_hint": "Get them from fusion_aps_browse {projectId, folderId} (item .id)."}
    project_name = args.get("projectName", "")
    folder_path = args.get("folderPath", "")
    deleted = []; failed = []
    for fid in file_ids:
        try:
            r = _proxy_to_addin("delete_cloud_file",
                                {"fileId": fid, "projectName": project_name, "folderPath": folder_path},
                                timeout=40)
            if isinstance(r, dict) and r.get("success"):
                deleted.append(fid)
            else:
                failed.append({"fileId": fid, "error": (r.get("error") if isinstance(r, dict) else str(r))[:90]})
        except Exception as e:
            failed.append({"fileId": fid, "error": str(e)[:90]})
    return {
        "success": len(deleted) > 0 or not failed,
        "deletedCount": len(deleted),
        "failedCount": len(failed),
        "failed": failed[:25],
        "_hint": (
            f"Deleted {len(deleted)}/{len(file_ids)} cloud files by PRECISE lineage urn (no name-guessing, "
            "so teammates' files are untouched). NOTE: a large list takes minutes and the relay request "
            "may time out at ~60s while the deletes continue server-side - re-browse the folder to confirm "
            "the count dropped. Anything in failed[] usually means the file was already gone or you lack "
            "delete permission. See the fusion-cloud-hygiene skill."
        ),
    }


def _orchestrate_save_lbr(args: dict) -> dict:
    """Save the currently open Electronics library as a .flbr file.

    Uses Document.CopyToDesktop to export the library in Fusion's native
    .flbr format. The file can be re-opened later or uploaded to the cloud.
    """
    import os

    output_path = args.get("outputPath", "")
    if not output_path:
        return {"success": False, "error": "No outputPath specified"}

    output_path = output_path.replace("\\", "/")

    # Ensure path ends with .flbr
    if not output_path.lower().endswith(".flbr"):
        output_path += ".flbr"

    # Ensure parent directory exists
    parent = os.path.dirname(output_path)
    if parent and not os.path.exists(parent):
        try:
            os.makedirs(parent, exist_ok=True)
        except Exception as e:
            return {"success": False, "error": f"Cannot create directory {parent}: {e}"}

    # Execute Document.CopyToDesktop via the add-in
    result = _proxy_to_addin("execute_text_command", {
        "command": f"Document.CopyToDesktop {output_path}",
    }, timeout=30)

    if not result.get("success"):
        return {
            "success": False,
            "error": f"Document.CopyToDesktop failed: {result.get('error', 'unknown')}",
            "data": {"outputPath": output_path},
        }

    # Verify the file was created
    if os.path.exists(output_path):
        file_size = os.path.getsize(output_path)
        result = {
            "success": True,
            "output": f"Saved library to {os.path.basename(output_path)} ({file_size} bytes)",
            "data": {"outputPath": output_path, "fileSize": file_size},
        }
    else:
        result = {
            "success": True,
            "output": f"Document.CopyToDesktop executed (file may still be writing)",
            "data": {"outputPath": output_path},
        }

    # Auto-screenshot after save — catches blocking dialogs
    return _post_open_screenshot(result, settle_time=1.0)


# Package types = EPG's Scripts3d module names (each has runWithInput(params)).
# Kept in sync with Autodesk's ElectronicsPackageGenerator internal add-in.
_EPG_TYPES = [
    "axial_diode", "axial_fuse", "axial_polarized_capacitor", "axial_resistor",
    "bga", "chip", "chiparray2sideconvex", "chiparray2sideflat", "chiparray4sideflat",
    "chip_led", "cornerconcave", "crystal", "dfn2", "dfn3", "dfn4", "dip",
    "dip_socket", "dip_socket_dual_leaf", "dpak", "ecap", "female_standoff", "hc49",
    "header_right_angle", "header_right_angle_socket", "header_straight",
    "header_straight_socket", "male_female_standoff", "melf", "molded",
    "oscillator_j", "oscillator_l", "plcc", "qfn", "qfp", "radial_dipped_rect",
    "radial_ecap", "radial_inductor", "radial_round_led", "snap_lock", "sod",
    "sodfl", "soic", "soj", "son", "sot143", "sot223", "sot23", "sotfl",
    "surface_mount_header_female", "surface_mount_pin_header_right_angle",
    "surface_mount_pin_header_straight",
]

# EPG param keys that are NOT lengths (never mm->cm converted).
_EPG_NON_DIMENSION_KEYS = {"DPins", "EPins", "pins", "thermal", "color_r", "color_g", "color_b"}


def _orchestrate_generate_package(args: dict) -> dict:
    """Generate an IPC-compliant 3D package via Fusion's built-in
    ElectronicsPackageGenerator (EPG), optionally laser-etch a marking into the
    body top, and optionally export STEP - all in ONE headless add-in call.

    Proven live 2026-07-03 (0603 + '103' etch + 233KB STEP, 1.9s generation).
    """
    pkg_type = (args.get("type") or "").strip().lower()
    if pkg_type not in _EPG_TYPES:
        return {
            "success": False,
            "error": f"Unknown package type: {pkg_type!r}",
            "errorCode": "unknown_package_type",
            "supportedTypes": _EPG_TYPES,
            "_hint": "Pass type as one of supportedTypes (EPG Scripts3d module names). "
                     "Common: chip (0402/0603/0805 passives), soic, qfn, qfp, bga, sot23, "
                     "dfn2, melf, ecap, crystal, header_straight.",
        }

    raw_params = args.get("params") or {}
    units_cm = bool(args.get("unitsCm"))
    params = {}
    for k, v in raw_params.items():
        if (not units_cm) and isinstance(v, (int, float)) and k not in _EPG_NON_DIMENSION_KEYS:
            params[k] = v / 10.0  # mm (datasheet-native) -> cm (Fusion/EPG-native)
        else:
            params[k] = v

    etch = args.get("etch") or ""
    etch_style = (args.get("etchStyle") or "raised").strip().lower()  # raised (white, default) | engraved
    etch_depth_cm = float(args.get("etchDepthMm") or 0.03) / 10.0
    etch_height_mm = args.get("etchHeightMm")  # None = auto-fit
    output_step = args.get("outputStep") or ""
    bridge_version = BRIDGE_VERSION

    script = f"""
import sys, glob, os, importlib
import adsk.core, adsk.fusion

# Locate EPG across install conventions + versions (NEVER hardcode the webdeploy hash).
_bases = []
for env in ('ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432', 'LOCALAPPDATA'):
    root = os.environ.get(env)
    if root:
        _bases += glob.glob(os.path.join(root, 'Autodesk', 'webdeploy', 'production', '*',
                                         'Api', 'InternalAddins', 'ElectronicsPackageGenerator'))
if not _bases:
    raise RuntimeError('EPG_NOT_FOUND: ElectronicsPackageGenerator not present in this Fusion install')
epg_dir = max(_bases, key=os.path.getmtime)
parent = os.path.dirname(epg_dir)
if parent not in sys.path:
    sys.path.insert(0, parent)

mod = importlib.import_module('ElectronicsPackageGenerator.Scripts3d.{pkg_type}')

doc = app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType)
design = adsk.fusion.Design.cast(app.activeProduct)
mod.runWithInput({params!r}, design)
root = design.rootComponent

inv = [{{'name': b.name, 'vol': round(b.volume, 6)}} for b in root.bRepBodies]
etched = None
if {etch!r}:
    # FIND THE TOP SURFACE the robust way (John's algorithm, 2026-07-04):
    # 1. bounding box of the WHOLE chip (all bodies);
    # 2. only faces whose centroid sits in the TOP 5% band of that bbox count;
    # 3. among those upward planar faces take the LARGEST AREA (the molded body
    #    top beats terminal tops that tie it on height);
    # 4. lay the text out with a 10% margin inside that face.
    gmin, gmax = 1e9, -1e9
    for b in root.bRepBodies:
        bb = b.boundingBox
        gmin = min(gmin, bb.minPoint.z)
        gmax = max(gmax, bb.maxPoint.z)
    band_z = gmax - 0.05 * (gmax - gmin)
    top_face, top_area = None, -1.0
    for b in root.bRepBodies:
        for f in b.faces:
            if isinstance(f.geometry, adsk.core.Plane) and f.geometry.normal.z > 0.9 \
                    and f.centroid.z >= band_z and f.area > top_area:
                top_face, top_area = f, f.area
    if top_face is None:
        raise RuntimeError('ETCH_NO_TOP_FACE: no upward planar face in the top 5% of the chip bbox')
    body = top_face.body
    sk = root.sketches.add(top_face)
    center = sk.modelToSketchSpace(top_face.centroid)
    # Measure the face in SKETCH space (model-space bbox axes can be SWAPPED vs
    # the sketch axes the text lays out in). Project the model bbox corners.
    bb = top_face.boundingBox
    p_min = sk.modelToSketchSpace(bb.minPoint)
    p_max = sk.modelToSketchSpace(bb.maxPoint)
    face_w = abs(p_max.x - p_min.x)   # extent along sketch-x (the default text baseline)
    face_h = abs(p_max.y - p_min.y)   # extent along sketch-y
    # ALWAYS run the text along the LONGEST axis of the top face (most room for
    # the marking - John's rule, 2026-07-04). If the long axis is sketch-y,
    # rotate the text 90deg rather than squeezing it onto the short axis.
    import math as _math
    rot_deg = 90 if face_h > face_w else 0
    long_len = max(face_w, face_h)
    short_len = min(face_w, face_h)
    w = long_len * 0.80               # 10% margin each side along the long axis
    box_h_cap = short_len * 0.80      # 10% margin each side across it
    # multi-line support (e.g. MPN + variant on line 2): fit per-line
    lines = {etch!r}.split(chr(10))
    n_lines = max(1, len(lines))
    max_chars = max(1, max(len(l) for l in lines))
    # width-aware auto-fit: glyph ~0.75*h wide; line block ~1.35*h per line
    h = ({etch_height_mm!r} / 10.0) if {etch_height_mm!r} else \
        max(0.015, min(box_h_cap / (1.35 * n_lines), w / (0.75 * max_chars)))
    if rot_deg:
        c1 = adsk.core.Point3D.create(center.x - box_h_cap/2, center.y - w/2, 0)
        c2 = adsk.core.Point3D.create(center.x + box_h_cap/2, center.y + w/2, 0)
    else:
        c1 = adsk.core.Point3D.create(center.x - w/2, center.y - box_h_cap/2, 0)
        c2 = adsk.core.Point3D.create(center.x + w/2, center.y + box_h_cap/2, 0)
    ti = sk.sketchTexts.createInput2({etch!r}, h)
    ti.setAsMultiLine(c1, c2,
                      adsk.core.HorizontalAlignments.CenterHorizontalAlignment,
                      adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)
    if rot_deg:
        try:
            ti.angle = _math.pi / 2.0
        except Exception:
            rot_deg = 0  # older API without angle - fall back to unrotated
    txt = sk.sketchTexts.add(ti)
    # MEASURE-AND-CORRECT (the margin is a CONTRACT): font metrics vary, so the
    # 0.75h glyph estimate can under-shoot ('ADOM-A' rendered edge-to-edge). After
    # placing, measure the text's real bbox; if it busts the 10%-margin box,
    # recreate at the scaled-down height. Never trust an estimate you can measure.
    fit_iterations = 0
    measured_w = None
    for _pass in range(2):
        tb = txt.boundingBox
        t_ext_x = abs(tb.maxPoint.x - tb.minPoint.x)
        t_ext_y = abs(tb.maxPoint.y - tb.minPoint.y)
        measured_w = max(t_ext_x, t_ext_y)   # the baseline-axis extent
        measured_c = min(t_ext_x, t_ext_y)   # the cross-axis extent (line stack)
        if measured_w <= w and measured_c <= box_h_cap:
            break
        scale = min(w / max(measured_w, 1e-6), box_h_cap / max(measured_c, 1e-6)) * 0.95
        h = max(0.008, h * scale)
        txt.deleteMe()
        ti = sk.sketchTexts.createInput2({etch!r}, h)
        ti.setAsMultiLine(c1, c2,
                          adsk.core.HorizontalAlignments.CenterHorizontalAlignment,
                          adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)
        if rot_deg:
            try:
                ti.angle = _math.pi / 2.0
            except Exception:
                pass
        txt = sk.sketchTexts.add(ti)
        fit_iterations += 1
    marking_geom = {{
        'chipBBox': {{'min': [round(gmin,5), 0, 0], 'maxZ': round(gmax,5)}},
        'chipBBoxZ': {{'min': round(gmin,5), 'max': round(gmax,5)}},
        'topBandZ': round(band_z,5),
        'topFace': {{'area': round(top_area,6), 'centroidZ': round(top_face.centroid.z,5),
                    'sketchExtents': {{'x': round(face_w,5), 'y': round(face_h,5)}},
                    'body': body.name}},
        'longAxis': 'sketch-y' if face_h > face_w else 'sketch-x',
        'rotatedDeg': rot_deg,
        'textBox': {{'c1': [round(c1.x,5), round(c1.y,5)], 'c2': [round(c2.x,5), round(c2.y,5)],
                    'marginPct': 10}},
        'textHeightMm': round(h*10,4), 'lines': n_lines, 'maxLineChars': max_chars,
        'heightAuto': not bool({etch_height_mm!r}),
        'textWidthMeasuredMm': round(measured_w*10,4) if measured_w else None,
        'fitIterations': fit_iterations,
        'why': {{'band': 'top 5% of whole-chip bbox excludes terminal tops that tie the body',
                'largestArea': 'molded body top beats small terminal faces in the band',
                'longAxis': 'text runs along the longest face axis for maximum room',
                'fit': 'h = min(boxH/(1.35*lines), boxW/(0.75*maxChars)) - glyphs ~0.75h wide'}},
    }}
    if {etch_style!r} == 'engraved':
        # engraved (sunken) cut - the pre-2026-07-04 default
        ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.CutFeatureOperation)
        ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-{etch_depth_cm!r}))
        ext_in.participantBodies = [body]
        root.features.extrudeFeatures.add(ext_in)
    else:
        # RAISED WHITE marking (default): humans need CONTRAST - a thin positive
        # extrude painted white on the dark body reads like real silkscreen ink,
        # far better than a same-color emboss. Reuses EPG's own rgb-appearance
        # utility so the white survives Fusion rendering (+ colored STEP).
        ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)
        ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal({etch_depth_cm!r}))
        mark = root.features.extrudeFeatures.add(ext_in)
        from ElectronicsPackageGenerator.Utilities import addin_utility as _au
        for i in range(mark.bodies.count):
            mb = mark.bodies.item(i)
            mb.name = 'Marking' if i == 0 else 'Marking' + str(i + 1)
            _au.apply_rgb_appearance(app, design, mb, 255, 255, 255, 'AdomMarkingWhite')
    etched = {etch!r}

step_path = None
if {output_step!r}:
    em = design.exportManager
    opts = em.createSTEPExportOptions({output_step!r})
    em.execute(opts)
    step_path = {output_step!r}

# SIDECAR MANIFEST: every setting we picked + WHY + the bboxes we calculated,
# so a marking refresh (e.g. adding a variant name as a 2nd line) can rework the
# text without re-deriving anything. Written next to the STEP as <step>.manifest.json.
manifest = {{
    'schema': 'adom-fusion-generate-package/1',
    'bridgeVersion': {bridge_version!r},
    'type': {pkg_type!r},
    'paramsCm': {params!r},
    'paramsNote': 'paramsCm are EPG-native cm (mm inputs were /10)',
    'marking': ({{'text': {etch!r}, 'style': {etch_style!r},
                'depthMm': {etch_depth_cm!r} * 10, **marking_geom}} if etched else None),
    'bodies': inv,
    'outputs': {{'step': step_path}},
    'refresh': {{
        'howTo': 'To re-mark (e.g. add a variant on line 2): call fusion_generate_package again '
                 'with the SAME type+paramsCm (unitsCm:true) and the new multi-line etch text '
                 '(join lines with a newline). The generator is deterministic, so geometry and '
                 'placement reproduce exactly; this manifest carries the boxes/height the last '
                 'run chose for comparison.',
        'example': {{'type': {pkg_type!r}, 'unitsCm': True, 'params': 'paramsCm from this file',
                    'etch': ({etch!r} + chr(10) + 'VARIANT') if etched else 'MPN' + chr(10) + 'VARIANT'}},
    }},
}}
manifest_path = None
if step_path:
    manifest_path = step_path + '.manifest.json'
    import json as _json
    with open(manifest_path, 'w', encoding='utf-8') as _f:
        _json.dump(manifest, _f, indent=2)

vp = app.activeViewport
cam = vp.camera
cam.isSmoothTransition = False
cam.target = adsk.core.Point3D.create(0, 0, 0)
cam.eye = adsk.core.Point3D.create(0.25, -0.35, 0.45)
cam.upVector = adsk.core.Vector3D.create(0, 0, 1)
cam.isFitView = True
vp.camera = cam
vp.refresh()

result = {{'type': {pkg_type!r}, 'bodies': inv, 'etched': etched, 'stepPath': step_path,
           'manifest': manifest, 'manifestPath': manifest_path,
           'epgDir': epg_dir, 'doc': app.activeDocument.name}}
"""

    res = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=110)
    if not res.get("success"):
        err = (res.get("error") or "")
        if "EPG_NOT_FOUND" in err:
            res["errorCode"] = "epg_not_found"
            res["_hint"] = ("Fusion's built-in ElectronicsPackageGenerator add-in was not found in "
                            "this install (ships with 2024+ Fusion under webdeploy .../Api/"
                            "InternalAddins). Update Fusion, or build the part with "
                            "fusion_make_3d_package from a STEP instead.")
        return res

    data = res.get("data") or {}
    inner = data.get("result") or {}
    return {
        "success": True,
        "output": json.dumps(inner),
        **inner,
        "_hint": ("Generated an IPC-compliant parametric package via Fusion's built-in EPG"
                  + (f", marked '{etch}' on the chip top as {'an engraved cut' if etch_style == 'engraved' else 'RAISED WHITE text (silkscreen-style - high contrast on the dark body)'}" if etch else "")
                  + (f", exported STEP to {output_step}" if output_step else "")
                  + ". NEXT: fusion_screenshot_fusion for a visual check; pull_file the STEP for KiCad. "
                    "HOW THE MARKING WORKS: the top surface is found from the WHOLE-chip bounding box - "
                    "only upward planar faces in the top 5% height band count, largest area wins - and "
                    "the text is laid out with a 10% margin inside that face, auto-fit width-aware "
                    "(override with etchHeightMm). Default style is raised+white for CONTRAST (humans "
                    "can't read a dark-on-dark emboss); pass etchStyle:'engraved' for a sunken laser cut. "
                    "The text ALWAYS runs along the LONGEST axis of the top face (rotated 90deg when "
                    "needed) for maximum room; the 10% margin is ENFORCED by measuring the placed text bbox "
                    "and shrink-to-fitting (see fitIterations in the manifest). Multi-line markings work - join lines with a newline "
                    "(e.g. 'LM358' + newline + 'ADOM-A' for MPN + variant; per-line auto-fit). "
                    "SIDECAR MANIFEST: when outputStep is set, <step>.manifest.json records every "
                    "setting picked + WHY + the calculated bboxes (chip bbox, top band, face extents, "
                    "text box, height, rotation) - to refresh a marking (add a variant line), re-call "
                    "this verb with the manifest's type + paramsCm (unitsCm:true) + the new etch text; "
                    "generation is deterministic so placement reproduces exactly. The manifest is also "
                    "returned inline as 'manifest'. "
                    "PITFALLS: params are mm by default (unitsCm:true for EPG-native cm); dimension names "
                    "follow each EPG generator (chip: D/E/A/L/L1; soic: A/A1/b/D/E/E1/e/L/DPins); raised "
                    "markings are separate 'Marking' bodies (white in Fusion + colored STEP)."),
    }


# ── Optimized-GLB pipeline (service-step2glb "molecule mode") ──────────────────
# A raw STEP->GLB tessellation (fusion_export_step + a plain step2glb convert) leaves
# EVERY solid/pad/via as its own draw call - a real board comes out ~16MB / ~25,000
# primitives that HALTS the viewer's GPU on rotation (John hit this live 2026-07-14).
# service-step2glb's molecule mode is the OCCT-side replacement for Colby's Blender
# molecule-converter: tessellate -> anchor to the MP machine pins -> (optional) bake
# silkscreen -> gold pins -> dedup/flatten/JOIN/weld/prune -> Draco. Same board comes
# out ~465KB / ~31 draw calls, auto-oriented. We call it straight from the bridge so
# a Fusion board becomes a wiki-grade GLB in ONE verb. Reachable from the box (it is a
# public *.adom.cloud host, same as the wiki the bridge already streams from).
STEP2GLB_URL = (os.environ.get("ADOM_STEP2GLB_URL")
                or "https://step2glb-gmdoncpxdwx0.adom.cloud").rstrip("/")


def _multipart_body(fields: dict, files: list):
    """Build a multipart/form-data body. files = [(field, filename, bytes, content_type)]."""
    boundary = "----AdomBridgeGLB%d%d" % (int(_time.time() * 1000), os.getpid())
    out = []
    for k, v in (fields or {}).items():
        out.append(("--" + boundary).encode())
        out.append(('Content-Disposition: form-data; name="%s"' % k).encode())
        out.append(b"")
        out.append(str(v).encode())
    for field, filename, data, ctype in (files or []):
        out.append(("--" + boundary).encode())
        out.append(('Content-Disposition: form-data; name="%s"; filename="%s"'
                    % (field, filename)).encode())
        out.append(("Content-Type: %s" % (ctype or "application/octet-stream")).encode())
        out.append(b"")
        out.append(data)
    out.append(("--" + boundary + "--").encode())
    out.append(b"")
    return b"\r\n".join(out), boundary


# A User-Agent is REQUIRED on every service call. The *.adom.cloud edge WAF 403s the
# default "Python-urllib/x.y" UA (confirmed live 2026-07-14) while a browser/curl UA
# gets 200. Set it on the POST AND both polls or the call fails with an opaque 403.
def _service_ua():
    return "adom-fusion-bridge/%s" % BRIDGE_VERSION


def _service_glb_submit(step_path: str, silk_top: str = None, silk_bottom: str = None,
                        pin: str = "medium", job_name: str = "fusion-board") -> dict:
    """POST a STEP (+ optional silk PNGs) to service-step2glb molecule mode. Returns
    {ok, jobId} - does NOT wait. Pure urllib (no requests on the box)."""
    try:
        with open(step_path, "rb") as f:
            step_bytes = f.read()
    except Exception as e:
        return {"ok": False, "error": "could not read STEP: %s" % e}
    files = [("step", os.path.basename(step_path), step_bytes, "application/step")]
    for field, p in (("silk_top", silk_top), ("silk_bottom", silk_bottom)):
        if p and os.path.exists(p):
            try:
                with open(p, "rb") as f:
                    files.append((field, os.path.basename(p), f.read(), "image/png"))
            except Exception:
                pass
    body, boundary = _multipart_body({}, files)
    headers = {
        "Content-Type": "multipart/form-data; boundary=" + boundary,
        "X-Client": "fusion-bridge/adom",
        "X-Job-Name": job_name,
        "User-Agent": _service_ua(),
    }
    url = "%s/convert?molecule=true&pin=%s" % (STEP2GLB_URL, urllib.parse.quote(pin))
    try:
        req = urllib.request.Request(url, data=body, headers=headers, method="POST")
        with urllib.request.urlopen(req, timeout=90) as resp:
            queued = json.loads(resp.read().decode("utf-8", "replace"))
    except Exception as e:
        return {"ok": False, "error": "service POST failed: %s" % e,
                "_hint": "Is %s reachable from this box? Override with ADOM_STEP2GLB_URL." % STEP2GLB_URL}
    job_id = queued.get("job_id")
    if not job_id:
        return {"ok": False, "error": "service did not return a job_id", "raw": queued}
    return {"ok": True, "jobId": job_id}


def _service_glb_fetch(job_id: str, out_path: str = None, poll_s: int = 0) -> dict:
    """Poll a service job for up to poll_s seconds; if complete, optionally write the
    GLB to out_path. Returns {ok, status, stats, glbBytes?, wrote?}. poll_s=0 = one check."""
    ua = _service_ua()
    stats, deadline, first = {}, _time.time() + max(0, poll_s), True
    while first or _time.time() < deadline:
        first = False
        try:
            preq = urllib.request.Request("%s/jobs/%s" % (STEP2GLB_URL, job_id), headers={"User-Agent": ua})
            with urllib.request.urlopen(preq, timeout=20) as r:
                stats = json.loads(r.read().decode("utf-8", "replace"))
        except Exception:
            _time.sleep(4); continue
        st = stats.get("status")
        if st == "complete":
            break
        if st == "error":
            return {"ok": False, "status": "error", "error": stats.get("error") or stats, "stats": stats}
        if _time.time() >= deadline:
            return {"ok": True, "status": st or "processing", "stats": stats}
        _time.sleep(6)
    if stats.get("status") != "complete":
        return {"ok": True, "status": stats.get("status") or "processing", "stats": stats}
    try:
        rreq = urllib.request.Request("%s/jobs/%s/result" % (STEP2GLB_URL, job_id), headers={"User-Agent": ua})
        with urllib.request.urlopen(rreq, timeout=60) as r:
            glb = r.read()
    except Exception as e:
        return {"ok": False, "status": "complete", "error": "download failed: %s" % e, "stats": stats}
    wrote = None
    if out_path:
        try:
            with open(out_path, "wb") as f:
                f.write(glb)
            wrote = out_path
        except Exception as e:
            return {"ok": False, "status": "complete", "error": "could not write GLB: %s" % e, "stats": stats}
    return {"ok": True, "status": "complete", "stats": stats, "glbBytes": glb, "wrote": wrote}


def _orchestrate_export_optimized_glb(args: dict) -> dict:
    """Fusion board -> wiki-grade optimized GLB in one call. Exports the active design's
    STEP, (optionally) the top/bottom silkscreen, runs it through service-step2glb's
    molecule pipeline (anchor + optional silk bake + gold pins + join/weld/prune + Draco),
    and writes the small, fast, auto-anchored GLB to outputPath. See STEP2GLB_URL above."""
    output_path = args.get("outputPath") or args.get("output_path")
    if not output_path:
        return {"success": False, "error": "No outputPath specified.",
                "_hint": 'Usage: fusion_export_optimized_glb {"outputPath":"C:/tmp/board.glb", "silkscreen":true, "pin":"medium"}'}
    if not output_path.lower().endswith(".glb"):
        output_path = output_path + ".glb"
    pin = args.get("pin", "medium")
    want_silk = args.get("silkscreen", True)
    # 1) Need a 3D Design product for STEP. Switch to the 3D board (no-op if already 3D).
    _proxy_to_addin("show_3d_board", {}, timeout=60)
    # 2) Export STEP next to the target GLB.
    step_path = output_path[:-4] + ".step"
    step_res = _proxy_to_addin("export_step", {"outputPath": step_path}, timeout=300)
    if not step_res.get("success"):
        return {"success": False, "error": "STEP export failed: %s" % (step_res.get("error") or step_res.get("message")),
                "step": step_res, "_hint": "The active design must be a 3D Design product (fusion_show_3d_board first)."}
    # 3) Optional silkscreen (best-effort; the GLB is great without it).
    silk_top = silk_bottom = None
    silk_note = None
    if want_silk:
        st = output_path[:-4] + "_silk_top.png"
        sb = output_path[:-4] + "_silk_bottom.png"
        rt = _proxy_to_addin("take_silkscreen_screenshot", {"outputPath": st, "layer": "top"}, timeout=90)
        rb = _proxy_to_addin("take_silkscreen_screenshot", {"outputPath": sb, "layer": "bottom"}, timeout=90)
        if rt.get("success"):
            silk_top = st
        if rb.get("success"):
            silk_bottom = sb
        if not (silk_top or silk_bottom):
            silk_note = "silkscreen capture unavailable on this design; GLB built without a baked silk texture."
    # 4) Submit to the molecule optimizer (fire-and-return: a big board's tessellation
    #    can run minutes, longer than the AD relay's request timeout, so we do NOT block
    #    the whole time here). Then bounded-wait up to `wait` seconds (default 90) so
    #    small boards still come back complete in one call.
    job_name = os.path.splitext(os.path.basename(output_path))[0]
    sub = _service_glb_submit(step_path, silk_top, silk_bottom, pin=pin, job_name=job_name)
    if not sub.get("ok"):
        return {"success": False, "error": sub.get("error"), "_hint": sub.get("_hint"),
                "stepPath": step_path, "note": "STEP exported OK; optimize submit failed."}
    job_id = sub["jobId"]
    wait_s = int(args.get("wait", 15))  # keep total (STEP export + wait) under the ~60s relay timeout
    fetched = _service_glb_fetch(job_id, out_path=output_path, poll_s=wait_s) if wait_s > 0 else {"ok": True, "status": "processing"}
    status_url = "%s/jobs/%s" % (STEP2GLB_URL, job_id)
    result_url = "%s/jobs/%s/result" % (STEP2GLB_URL, job_id)
    if fetched.get("status") == "complete" and fetched.get("wrote"):
        stats = fetched.get("stats") or {}
        size = len(fetched.get("glbBytes") or b"")
        return {
            "success": True, "status": "complete",
            "glbPath": output_path, "stepPath": step_path, "jobId": job_id,
            "silkTop": silk_top, "silkBottom": silk_bottom,
            "meshesBefore": stats.get("meshes_before"), "meshesAfter": stats.get("meshes_after"),
            "sizeBytes": size, "moleculeAnchored": stats.get("molecule_anchored"),
            "silkscreenApplied": stats.get("silkscreen_applied"), "note": silk_note,
            "message": "Optimized GLB written (%d KB, meshes %s->%s, anchored=%s, silk=%s) to %s" % (
                size // 1024, stats.get("meshes_before"), stats.get("meshes_after"),
                stats.get("molecule_anchored"), stats.get("silkscreen_applied"), output_path),
            "_hint": "Pull it with pull_file, then set it as component.parts.model_3d on a wiki component page. "
                     "Same optimizer as the molecule GLBs (Colby's pipeline).",
        }
    if not fetched.get("ok"):
        return {"success": False, "error": fetched.get("error"), "jobId": job_id, "stepPath": step_path,
                "statusUrl": status_url, "resultUrl": result_url}
    # Still processing after the bounded wait - hand back the job so the caller finishes it.
    return {
        "success": True, "status": fetched.get("status") or "processing",
        "pending": True, "jobId": job_id, "stepPath": step_path,
        "glbPath": output_path, "silkTop": silk_top, "silkBottom": silk_bottom, "note": silk_note,
        "statusUrl": status_url, "resultUrl": result_url,
        "message": "Optimize job %s submitted; still processing after %ds. Finish it with "
                   "fusion_fetch_optimized_glb {\"jobId\":\"%s\",\"outputPath\":\"%s\"} (re-call until complete)." % (
                       job_id, wait_s, job_id, output_path),
        "_hint": "Big boards tessellate for a few minutes. Either re-call fusion_fetch_optimized_glb "
                 "with this jobId (writes the GLB on the box when ready), or GET the resultUrl directly.",
    }


def _orchestrate_fetch_optimized_glb(args: dict) -> dict:
    """Fetch a previously-submitted optimize job's GLB (from fusion_export_optimized_glb's
    jobId). Bounded poll (default 90s); writes to outputPath when complete."""
    job_id = args.get("jobId")
    if not job_id:
        return {"success": False, "error": "No jobId.",
                "_hint": 'Usage: fusion_fetch_optimized_glb {"jobId":"...","outputPath":"C:/tmp/board.glb"}'}
    out_path = args.get("outputPath") or args.get("output_path")
    if out_path and not out_path.lower().endswith(".glb"):
        out_path = out_path + ".glb"
    wait_s = int(args.get("wait", 15))  # keep total (STEP export + wait) under the ~60s relay timeout
    res = _service_glb_fetch(job_id, out_path=out_path, poll_s=wait_s)
    status_url = "%s/jobs/%s" % (STEP2GLB_URL, job_id)
    result_url = "%s/jobs/%s/result" % (STEP2GLB_URL, job_id)
    if res.get("status") == "complete" and res.get("wrote"):
        stats = res.get("stats") or {}
        size = len(res.get("glbBytes") or b"")
        return {"success": True, "status": "complete", "glbPath": out_path, "jobId": job_id,
                "sizeBytes": size, "meshesAfter": stats.get("meshes_after"),
                "moleculeAnchored": stats.get("molecule_anchored"), "silkscreenApplied": stats.get("silkscreen_applied"),
                "message": "Optimized GLB written (%d KB) to %s" % (size // 1024, out_path)}
    if not res.get("ok"):
        return {"success": False, "error": res.get("error"), "jobId": job_id, "statusUrl": status_url, "resultUrl": result_url}
    return {"success": True, "status": res.get("status") or "processing", "pending": True, "jobId": job_id,
            "statusUrl": status_url, "resultUrl": result_url,
            "message": "Job %s still %s; re-call fusion_fetch_optimized_glb to finish." % (job_id, res.get("status") or "processing")}


def _describe_profile(p: dict) -> str:
    """Human, UNAMBIGUOUS name for a browser profile - never just 'your browser'.

    A power user has several (personal / work / media), so a demo that says "it's in your
    Chrome" is useless (John, 2026-07-20). Produce e.g.
    "Chrome - John Personal ([email protected])" or "Edge - Default"."""
    browser = (p.get("browser") or "browser").strip()
    browser = {"chrome": "Chrome", "edge": "Edge", "brave": "Brave"}.get(browser.lower(), browser.title())
    name = (p.get("displayName") or "").strip()
    email = (p.get("email") or "").strip()
    bits = browser
    if name and email and name.lower() not in email.lower():
        bits += " - %s (%s)" % (name, email)
    elif email:
        bits += " - %s" % email
    elif name:
        bits += " - %s" % name
    elif p.get("profileDir"):
        bits += " - %s" % p.get("profileDir")
    return bits


def _ad_call(verb: str, args: dict, timeout: int = 60) -> dict:
    """Call another AD verb (nbrowser_*, desktop_*) from inside this bridge.

    Prefers the in-process ad_client; falls back to the adom-desktop CLI (which joins the
    relay itself) so this still works on an unattended VM where ad_client is down. Never
    raises - returns {} on failure so callers can degrade to instructing the AI instead."""
    why = []
    try:
        if ad_client.available():
            r = ad_client.call(verb, args, timeout=timeout)
            if isinstance(r, dict):
                inner = r.get("output")
                if isinstance(inner, str) and inner.strip().startswith("{"):
                    import json as _j0
                    try:
                        return _j0.loads(inner)
                    except Exception:
                        return r
                return r
            why.append("ad_client.call returned %r" % type(r).__name__)
        else:
            why.append("ad_client unavailable")
    except Exception as e:
        why.append("ad_client raised %s" % e)
    try:
        import subprocess as _sp, json as _j
        exe = _find_adom_desktop_cli()
        if not exe:
            return {"_adCallError": "; ".join(why + ["adom-desktop CLI not found"])}
        p = _sp.run([exe, verb, _j.dumps(args)], capture_output=True, text=True, timeout=timeout)
        out = (p.stdout or "").strip()
        if out.startswith("{"):
            d = _j.loads(out)
            inner = d.get("output")
            if isinstance(inner, str) and inner.strip().startswith("{"):
                try:
                    return _j.loads(inner)
                except Exception:
                    return d
            return d
        why.append("cli stdout not json: %s" % (out[:120] or (p.stderr or "")[:120]))
    except Exception as e:
        why.append("cli raised %s" % e)
    return {"_adCallError": "; ".join(why)}


def _fusion_signin_cfg_path():
    import pathlib
    d = pathlib.Path(os.path.expanduser("~")) / ".adom" / "fusion-signin"
    d.mkdir(parents=True, exist_ok=True)
    return d / "profile.json"


def _remembered_signin_profile():
    try:
        import json as _j
        p = _fusion_signin_cfg_path()
        if p.exists():
            return (_j.loads(p.read_text()) or {}).get("profile") or None
    except Exception:
        pass
    return None


def _remember_signin_profile(profile: str):
    try:
        import json as _j
        _fusion_signin_cfg_path().write_text(_j.dumps({"profile": profile}))
    except Exception:
        pass


def _chrome_authorize_urls(max_age_min: int = 15):
    """Scan Chrome + Edge profile History DBs for RECENT Autodesk-desktop OAuth authorize
    URLs (the Fusion Identity SDK ones - marked by `idsdk` / redirect to idmgr/callback).

    This is how we fix Fusion opening the WRONG browser profile: Fusion fires its OAuth at
    the OS-default browser, but the FULL authorize URL (client_id, PKCE challenge, state,
    request_id) lands in that browser's History. We read it back and re-open it in the
    profile the user actually authenticates Autodesk in. The URL is NOT tied to a browser
    (its redirect is accounts.autodesk.com/idmgr/callback + an autodesk:// protocol handoff
    matched by request_id), so completing it in ANY profile hands the token to the running
    Fusion. Verified live (John, 2026-07-21).

    Returns a list of {url, sourceProfileDir, browser, ageSec} newest first.
    """
    import glob, sqlite3, shutil, tempfile, time as _t
    la = os.environ.get("LOCALAPPDATA", "")
    roots = []
    if la:
        roots.append(("chrome", os.path.join(la, "Google", "Chrome", "User Data")))
        roots.append(("edge", os.path.join(la, "Microsoft", "Edge", "User Data")))
    now = _t.time()
    found = []
    for browser, root in roots:
        if not os.path.isdir(root):
            continue
        for hist in glob.glob(os.path.join(root, "*", "History")):
            prof_dir = os.path.basename(os.path.dirname(hist))
            tmp = os.path.join(tempfile.gettempdir(), "adom_hist_%s_%s.db" % (browser, prof_dir))
            try:
                shutil.copy2(hist, tmp)  # copy first - Chrome keeps History locked
                # Chrome buffers recent rows in the -wal; copy it too or a fresh authorize URL
                # is invisible (this was the bug: a just-opened sign-in did not appear).
                for ext in ("-wal", "-shm"):
                    if os.path.exists(hist + ext):
                        try: shutil.copy2(hist + ext, tmp + ext)
                        except Exception: pass
                con = sqlite3.connect(tmp)
                try: con.execute("PRAGMA journal_mode=WAL")
                except Exception: pass
                rows = con.execute(
                    "SELECT url, last_visit_time FROM urls "
                    "WHERE url LIKE '%developer.api.autodesk.com/authentication/v2/authorize%' "
                    "OR url LIKE '%idp.auth.autodesk.com/as/authorize%' "
                    "ORDER BY last_visit_time DESC LIMIT 6").fetchall()
                con.close()
            except Exception:
                continue
            for url, cts in rows:
                if "idsdk" not in url and "idmgr" not in url:
                    continue  # only the DESKTOP-app (Fusion) flow, not a web app
                epoch = (cts / 1_000_000) - 11644473600
                age = now - epoch
                if age <= max_age_min * 60:
                    found.append({"url": url, "sourceProfileDir": prof_dir,
                                  "browser": browser, "ageSec": int(age)})
    found.sort(key=lambda x: x["ageSec"])
    return found


def _fusion_window_hwnd():
    """Find the main Fusion window's hwnd by title (via desktop_list_windows). Returns int|None."""
    r = _ad_call("desktop_list_windows", {}, timeout=40)
    wins = (r.get("windows") if isinstance(r, dict) else None) or []
    if not wins and isinstance(r, dict):
        wins = ((r.get("data") or {}).get("windows")) or []
    cand = None
    for w in wins:
        t = str(w.get("title", ""))
        tl = t.lower()
        if "autodesk fusion" in tl or tl.startswith("signing in") or t.strip() == "Autodesk Fusion":
            # prefer the sign-in/welcome window
            if "signing in" in tl or "welcome" in tl:
                return w.get("hwnd")
            cand = cand or w.get("hwnd")
    return cand


def _fusion_click_signin() -> bool:
    """Click Fusion's 'Welcome to Fusion' -> Sign In button SERVER-SIDE (it is a webview with
    no UIA node, so image-space click). Returns True if a click was dispatched. Runs on-box so
    it adds no remote round-trip to the ~2-min OAuth window."""
    hwnd = _fusion_window_hwnd()
    if not hwnd:
        rd = _handle_fusion_readiness({}, {})
        hwnd = rd.get("hwnd") or rd.get("windowHandle")
    if not hwnd:
        return False
    shot = _ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45)
    sd = shot if isinstance(shot, dict) else {}
    cm = sd.get("coordMap") or {}
    sid = cm.get("shotId") or sd.get("shotId")
    w = sd.get("width") or (cm.get("imageWidth") if isinstance(cm, dict) else None) or 1400
    h = sd.get("height") or (cm.get("imageHeight") if isinstance(cm, dict) else None) or 860
    if not sid:
        return False
    # the Sign In button sits centered, ~57% down the welcome screen
    _ad_call("desktop_click", {"space": "image", "shotId": sid,
                               "x": int(w * 0.5), "y": int(h * 0.57)}, timeout=30)
    return True


def _capture_fresh_authorize(wait_sec: int = 30, max_age: int = 120):
    """Poll the browser History for a FRESH (< max_age s) Fusion authorize URL for up to
    wait_sec, ON-BOX. Collapsing capture into one server-side wait (instead of the caller
    re-polling over the flaky relay) is what lets the whole chain finish inside Fusion's
    ~2-min request window. Returns the auth dict or None."""
    import time as _t
    deadline = _t.time() + max(1, wait_sec)
    while True:
        urls = _chrome_authorize_urls()
        if urls and urls[0]["ageSec"] <= max_age:
            return urls[0]
        if _t.time() >= deadline:
            return urls[0] if urls else None
        _t.sleep(2)


def _orchestrate_signin(args: dict) -> dict:
    """Sign Fusion in through the CORRECT browser profile - the fix for Fusion firing its
    OAuth at the OS-default browser (often the wrong Autodesk account). See the
    fusion-multiprofile-signin skill.

    Staged: pick the target profile (remembered / arg / probed / ask) -> ensure Fusion has
    emitted its OAuth URL (click Sign In if not) -> read that URL from the wrong browser's
    History -> re-open it in the TARGET profile in the BACKGROUND -> report where it waits.

    auto=True runs the whole chain in ONE server-side call: click Fusion's Sign In, poll on-box
    for the fresh authorize URL, then re-open it in the target profile - so the capture->reopen
    round-trips happen on the box (fast) and fit inside Fusion's ~2-min request expiry even when
    the remote relay is slow. This is the reliable path; the staged/manual path is the fallback.
    """
    steps = []
    auto = bool(args.get("auto"))
    wait_sec = int(args.get("waitSec") or 32)
    target = args.get("profile")           # explicit override, e.g. "chrome:[email protected]"
    if target:
        _remember_signin_profile(target)
    if not target:
        target = _remembered_signin_profile()

    rd = _handle_fusion_readiness({}, {})
    if not rd.get("running"):
        return {"success": True, "stage": "not_running", "done": False, "steps": steps,
                "_hint": "Fusion is not running. fusion_start, then fusion_signin.",
                "statusVerb": "fusion_signin"}
    if not rd.get("needsSignin"):
        return {"success": True, "stage": "already", "done": True, "steps": steps,
                "narrate": "Fusion is already signed in.",
                "_hint": "Already signed in (ready:%s). Nothing to do." % rd.get("ready"),
                "statusVerb": "fusion_signin"}

    # profiles known to ABE (for target selection + naming)
    profs = _ad_call("nbrowser_profiles", {}, timeout=40)
    pdata = profs.get("data", profs) if isinstance(profs, dict) else {}
    choices = [p for p in (pdata.get("profiles") or []) if isinstance(p, dict) and p.get("profile")]

    # read Fusion's authorize URL from browser history (it fires the OS-default browser)
    urls = _chrome_authorize_urls()
    fresh = urls[0] if (urls and urls[0]["ageSec"] <= 150) else None
    if auto and not fresh:
        # ONE server-side pass: click Fusion's Sign In, then poll on-box for the fresh URL.
        clicked = _fusion_click_signin()
        steps.append("clicked Fusion 'Sign In'" if clicked else "could not click Fusion 'Sign In'")
        got = _capture_fresh_authorize(wait_sec=wait_sec, max_age=140)
        if got and got["ageSec"] <= 150:
            urls = [got]
        elif got:
            urls = [got]  # stale; fall through to the stale branch which tells us to reset
        else:
            urls = []
    if not urls:
        return {"success": True, "stage": "click_signin", "done": False, "steps": steps,
                "_hint": ("No recent Fusion OAuth URL in any browser's history yet - Fusion has not "
                          "opened the sign-in browser. CLICK Fusion's 'Sign In' button (it is a "
                          "webview with no UIA node, so image-space desktop_click on the button - "
                          "screenshot the Fusion window for a shotId first), wait ~4s, then call "
                          "fusion_signin again. This verb then grabs the URL Fusion opened in the "
                          "WRONG default browser and re-opens it in the RIGHT profile."),
                "statusVerb": "fusion_signin"}
    auth = urls[0]
    steps.append("captured Fusion OAuth URL from %s profile '%s' (%ss old)"
                 % (auth["browser"], auth["sourceProfileDir"], auth["ageSec"]))
    # Fusion's sign-in request EXPIRES in ~2 min. A URL older than that completes the LOGIN but
    # Fusion no longer waits on its request_id -> "Sign-in request expired", no handoff. Never
    # silently reuse it; force a fresh request. (Learned live 2026-07-21.)
    if auth["ageSec"] > 150:
        return {"success": True, "stage": "stale", "done": False, "steps": steps,
                "signinAuthUrl": auth["url"], "signinAgeSec": auth["ageSec"],
                "_hint": ("The newest Fusion sign-in URL is %ss old - Fusion expires the request in "
                          "~2 min, so completing it yields 'Sign-in request expired' and NO handoff. "
                          "Get a FRESH request: fusion_stop then fusion_start (resets Fusion to a "
                          "clean 'Welcome to Fusion'), click Fusion's Sign In, and call fusion_signin "
                          "again PROMPTLY. Completion is near-instant when the target profile is "
                          "already signed into Autodesk (auto-consents). Skill: "
                          "fusion-multiprofile-signin." % auth["ageSec"]),
                "statusVerb": "fusion_signin"}

    # choose the TARGET profile if not already fixed
    reason = ""
    if not target:
        # probe each live profile for a real Autodesk session; prefer signed-in, then work over consumer
        for c in choices:
            if not c.get("live"):
                continue
            ls = _ad_call("nbrowser_login_state",
                          {"profile": c["profile"], "url": "https://accounts.autodesk.com/"}, timeout=40)
            lsd = ls.get("data", ls) if isinstance(ls, dict) else {}
            c["_ad"] = "signed-in" if lsd.get("loggedIn") else ("signed-out" if lsd.get("ok") is not None else "unprobed")
            c["_conf"] = lsd.get("confidence"); c["_cookies"] = lsd.get("cookieCount")
        signed = [c for c in choices if c.get("_ad") == "signed-in"]
        if signed:
            signed.sort(key=lambda c: ({"high": 3, "medium": 2, "low": 1}.get(c.get("_conf"), 0), c.get("_cookies") or 0), reverse=True)
            target = signed[0]["profile"]; reason = "it holds a live Autodesk session"
        else:
            _CONSUMER = ("gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "live.com")
            work = [c for c in choices if c.get("live") and c.get("email") and not any((c["email"] or "").lower().endswith("@" + d) for d in _CONSUMER)]
            if work:
                target = work[0]["profile"]; reason = "it is a work/corporate profile (no live Autodesk session detected - CONFIRM)"
    if target:
        _remember_signin_profile(target)
        reason = reason or "you told me to use it"

    if not target:
        return {"success": True, "stage": "ask_profile", "done": False, "steps": steps,
                "signinAuthUrl": auth["url"], "profileChoices": choices,
                "_hint": ("Captured Fusion's OAuth URL but cannot tell which profile is your Autodesk "
                          "account. ASK the user which profile their Autodesk login belongs to, then "
                          "call fusion_signin {profile:'chrome:<their-account>'} - I will remember it. "
                          "profileChoices lists every profile."),
                "statusVerb": "fusion_signin"}

    tdesc = next((_describe_profile(c) for c in choices if c.get("profile") == target), target)
    import time as _tt
    sess = "fusion-signin-%d" % int(_tt.time())   # unique per attempt (a reused id can be refused)
    r = _ad_call("nbrowser_open_window",
                 {"sessionId": sess, "url": auth["url"], "background": True,
                  "profile": target, "thread": "fusion-signin",
                  "purpose": "Fusion Autodesk sign-in (correct profile)"}, timeout=60)
    rd_open = r.get("data", r) if isinstance(r, dict) else {}
    opened = bool(rd_open.get("sessionId") or r.get("ok") or r.get("success")
                  or rd_open.get("opened") or ("opened in the BACKGROUND" in str(r.get("_hint", ""))))
    steps.append(("re-opened it in %s" % tdesc) if opened
                 else ("could not open %s (raw: %s)" % (tdesc, str(r)[:160])))

    return {"success": True, "stage": "opened" if opened else "open_failed",
            "done": False, "steps": steps, "signinWhere": tdesc, "signinProfile": target,
            "signinProfileReason": reason, "signinSession": sess if opened else None,
            "signinWrongBrowser": "%s / %s" % (auth["browser"], auth["sourceProfileDir"]),
            "signinAuthUrl": auth["url"],
            "narrate": (("Fusion opened its sign-in in the WRONG browser (%s), so I moved the real "
                         "Autodesk sign-in URL into %s - I picked that because %s. Finish it there, "
                         "or I can drive it, and Fusion will pick up the login automatically."
                         % (auth["sourceProfileDir"], tdesc, reason)) if opened else
                        "I captured Fusion's sign-in URL but could not open the target profile."),
            "_hint": (("The REAL Fusion OAuth URL is now open in %s (session 'fusion-signin'). It "
                       "completes back to the running Fusion via the idmgr/callback + autodesk:// "
                       "protocol handoff (request_id match), so the browser profile no longer has to "
                       "be the OS default - THIS is the fix for Fusion signing into the wrong "
                       "account. TELL the user the exact profile (never 'your browser'), say WHY it "
                       "was chosen (signinProfileReason), and offer: they finish it, you foreground "
                       "it, or (with their OK) you drive 'Continue with Google/Apple/Microsoft'. "
                       "NEVER type their password/2FA. Then poll fusion_readiness until ready:true. "
                       "The wrong-browser tab (%s) can be closed. If the wrong ACCOUNT completes "
                       "anyway, sign Fusion out and re-run. Skill: fusion-multiprofile-signin."
                       % (tdesc, auth["sourceProfileDir"])) if opened else
                      "Open failed: %s. Retry, or open auth url yourself with nbrowser_open_window "
                      "{profile:'%s', url:<signinAuthUrl>}." % (r.get("_adCallError"), target)),
            "statusVerb": "fusion_signin"}


def _orchestrate_demo(args: dict) -> dict:
    """FIRST-TIME-USER DEMO: take a brand-new user from nothing to "wow" in one verb.

    Written because HD's installer ran a "demo" that just opened Fusion and SAT on the
    sign-in page doing nothing (John, 2026-07-20). A demo must actually finish the sign-in,
    set up APS, and SHOW the user real work: an electronics PROJECT -> SCHEMATIC -> 2D BOARD
    -> 3D BOARD, plus a live APS cloud search.

    STAGED + RESUMABLE. Each call advances as far as it safely can and returns:
      stage      - what it just did / is waiting on
      done       - whether the demo finished
      narrate    - a first-time-user-friendly line the CALLER should say + toast
      screenshots- images to show the user for this stage
      _hint      - exactly what the AI must do next (including nbrowser_* for the browser leg)

    The bridge owns Fusion; it does NOT own the browser. So when the sign-in needs a browser
    we hand the caller precise `nbrowser_*` (ABE) instructions - the user's NATIVE browser is
    already signed into Autodesk, whereas pup is anonymous and would force a fresh login.
    """
    stage = (args.get("stage") or "auto").strip()
    demo_query = args.get("query") or ""          # optional: which design to demo
    steps: list = []
    shots: list = []

    def _shot(label):
        try:
            r = _handle_screenshot_fusion({}, {})
            if r.get("success") and r.get("localSafePath"):
                shots.append({"label": label, "path": r.get("localSafePath")})
        except Exception:
            pass

    def _out(stage_name, narrate, hint, done=False, **extra):
        return {"success": True, "demo": True, "stage": stage_name, "done": done,
                "steps": steps, "narrate": narrate, "screenshots": shots,
                "statusVerb": "fusion_demo", "_hint": hint, **extra}

    # ── 1. Fusion present + running ─────────────────────────────────────────────
    rd = _handle_fusion_readiness({}, {})
    if not rd.get("installed"):
        return _out("not_installed",
            "Fusion 360 isn't installed yet - want me to install it? It's a free 30-day trial with "
            "everything switched on, so the whole tour works: real boards, schematics, 3D, Gerber and "
            "BOM exports, cloud search. After the trial, Fusion for Personal Use stays free for "
            "non-commercial work (hobby PCBs up to 2 layers), and even an expired install can still "
            "OPEN and VIEW your designs.",
            "OFFER the install - do not just report it is missing. Say what they GET: the free 30-day "
            "trial is FULL-FEATURED (every step of this demo works: electronics, schematic, 2D/3D "
            "board, Gerbers/BOM/CPL, APS cloud search). Be honest about after: Fusion for Personal Use "
            "is free for non-commercial use but LIMITS electronics (about 2 layers / 2 schematic "
            "sheets / small board area) and some exports; an expired or read-only install can still "
            "OPEN + VIEW + browse designs (it only blocks save/export/modify), so their work is never "
            "locked away. Then call fusion_install_fusion (no shell approval needed; streams the "
            "installer, 10-30 min), poll fusion_readiness until installed:true, and call fusion_demo "
            "again to continue the tour. Full flow: the fusion-onboarding skill.")
    if not rd.get("running"):
        steps.append("launched Fusion")
        _handle_launch({}, {})
        rd = _handle_fusion_readiness({}, {})

    # ── 2. SIGN-IN: finish it, do not sit on it ─────────────────────────────────
    if rd.get("needsSignin"):
        # Delegate to the multi-profile sign-in fix (reads Fusion's OAuth URL out of the WRONG
        # default browser and re-opens it in the RIGHT profile). Returns its own rich stage.
        si = _orchestrate_signin({"profile": args.get("signinProfile")})
        si["demo"] = True
        if si.get("stage") in ("opened", "click_signin", "ask_profile", "open_failed"):
            si.setdefault("done", False)
            si["_hint"] = (si.get("_hint", "") + "  (This is the fusion_demo sign-in stage - after "
                           "the user is signed in and fusion_readiness is ready:true, call fusion_demo "
                           "again to continue the tour: APS -> project -> schematic -> 2D -> 3D.)")
            return si
        # DO IT, don't just describe it: open the Autodesk sign-in in the user's OWN browser,
        # in the BACKGROUND so we never yank them out of what they're doing. Then tell the AI
        # exactly WHERE it is waiting and offer the three ways forward.
        nb = _ad_call("nbrowser_readiness", {}, timeout=40)
        nb_data = nb.get("data", nb) if isinstance(nb, dict) else {}
        nb_state = nb_data.get("state")
        opened, where, profile_used = False, "", ""
        pick_reason, ambiguous = "", False
        choices = []
        if nb_state == "ready":
            profs = _ad_call("nbrowser_profiles", {}, timeout=40)
            pdata = profs.get("data", profs) if isinstance(profs, dict) else {}
            # profiles are DICTS: {profile,label,email,browser,displayName,profileDir,active,live,...}
            for p in (pdata.get("profiles") or []):
                if not isinstance(p, dict):
                    continue
                if p.get("blocked") or p.get("unresolved") or not p.get("profile"):
                    continue
                choices.append({
                    "profile": p.get("profile"), "browser": (p.get("browser") or "").lower(),
                    "email": p.get("email") or "", "displayName": p.get("displayName") or "",
                    "profileDir": p.get("profileDir") or "",
                    "active": bool(p.get("active")), "live": bool(p.get("live")),
                    "asleep": bool(p.get("asleep")),
                    "extensionInstalled": p.get("extensionInstalled", True),
                    "describe": _describe_profile(p),
                })
            # PROBE each profile for a REAL Autodesk session - never guess by "active".
            # (John, 2026-07-20: picking the active profile grabbed his PERSONAL Chrome with
            # zero analysis. A power user's Autodesk login can live in any profile, so ASK THE
            # BROWSER, don't assume.) nbrowser_login_state reports auth cookies per profile.
            for c in choices:
                if not c["live"]:
                    c["autodesk"] = "unprobed(asleep)"
                    continue
                ls = _ad_call("nbrowser_login_state",
                              {"profile": c["profile"], "url": "https://accounts.autodesk.com/"},
                              timeout=45)
                lsd = ls.get("data", ls) if isinstance(ls, dict) else {}
                if lsd.get("ok") is None and lsd.get("loggedIn") is None:
                    c["autodesk"] = "unprobed"
                else:
                    c["autodesk"] = "signed-in" if lsd.get("loggedIn") else "signed-out"
                c["autodeskConfidence"] = lsd.get("confidence")
                c["autodeskCookies"] = lsd.get("cookieCount")
            _CONF = {"high": 3, "medium": 2, "low": 1}
            signed = [c for c in choices if c.get("autodesk") == "signed-in"]
            signed.sort(key=lambda c: (_CONF.get(c.get("autodeskConfidence"), 0),
                                       c.get("autodeskCookies") or 0), reverse=True)
            pick = signed[0] if signed else None
            if pick:
                pick_reason = ("it is the profile actually signed into Autodesk (%s auth cookies, %s "
                               "confidence)" % (pick.get("autodeskCookies"), pick.get("autodeskConfidence")))
            else:
                # NOTHING is signed in - do not silently guess. Prefer a work/corporate identity
                # over a consumer mailbox (Autodesk seats are usually work accounts), but SAY SO
                # and offer the alternatives.
                _CONSUMER = ("gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "live.com")
                def _work_first(c):
                    em = (c.get("email") or "").lower()
                    return (0 if (em and not any(em.endswith("@" + d) for d in _CONSUMER)) else 1,
                            0 if c["live"] else 1)
                ranked = sorted([c for c in choices if c["live"]] or choices, key=_work_first)
                pick = ranked[0] if ranked else None
                pick_reason = ("no profile has a live Autodesk session, so I picked the most likely "
                               "one (work/corporate identity first) - CONFIRM WITH THE USER")
            ambiguous = not signed or len(signed) > 1
            if pick:
                profile_used = pick["profile"]
                where = pick["describe"]
            oa = {"sessionId": "fusion-signin", "url": "https://accounts.autodesk.com/",
                  "background": True, "thread": "fusion-demo", "purpose": "Autodesk sign-in for Fusion"}
            if profile_used:
                oa["profile"] = profile_used
            r = _ad_call("nbrowser_open_window", oa, timeout=60)
            opened = bool((r.get("data", r) if isinstance(r, dict) else {}).get("sessionId") or r.get("ok") or r.get("success"))
            if not where:
                where = "your browser"
        if opened:
            steps.append("opened Autodesk sign-in in %s (background)" % (where or "browser"))
        else:
            steps.append("could not auto-open the browser sign-in: %s" % (nb.get("_adCallError") or nb_state or "unknown"))
        if opened:
            narrate = ("I've opened the Autodesk sign-in in %s - I chose that profile because %s. "
                       "It's sitting in the background so it won't interrupt what you're doing. "
                       "Finish it whenever you like, tell me to bring it up front, or I can complete "
                       "it for you. If that's the wrong account, say which profile to use."
                       % (where, pick_reason))
        else:
            narrate = ("Fusion needs your Autodesk sign-in. I'd like to use your own browser - you're "
                       "probably already signed in there, so it should take one click.")
        return _out("signin", narrate,
            ("FINISH THE SIGN-IN - never leave a first-time user parked here. " +
             ("A background sign-in tab is ALREADY OPEN in %s (session 'fusion-signin'). " % where
              if opened else "Auto-open failed - open it yourself: nbrowser_open_window "
              "{sessionId:'fusion-signin', url:'https://accounts.autodesk.com/', background:true, "
              "profile:'<chrome:their-account>'}. ") +
             "The profile was CHOSEN BY EVIDENCE, not guessed: each live profile was probed with "
             "nbrowser_login_state against accounts.autodesk.com, and the one holding a real Autodesk "
             "session wins (see signinProfileReason + per-profile autodesk/autodeskConfidence/"
             "autodeskCookies in signinProfileChoices). RELAY THAT REASON to the user. If "
             "signinProfileAmbiguous is true (nothing signed in, or several are), ASK them which "
             "account their Autodesk login belongs to instead of assuming. "
             "NAME THE EXACT PROFILE - never say just 'your browser' or 'your Chrome'. Power users "
             "run several profiles (personal / work / media), so say the browser AND the identity "
             "from `signinWhere` (e.g. 'Chrome - John Personal ([email protected])'). "
             "`signinProfileChoices` lists every profile with describe/email/displayName/active - if "
             "there is more than one, say which you used and OFFER TO SWITCH (re-run with a different "
             "profile). If the one you want shows extensionInstalled:false it needs the one-time "
             "extension install in THAT profile. "
             "TELL THE USER WHERE IT IS WAITING and OFFER ALL THREE: (a) they finish it themselves "
             "whenever they want, (b) you foreground that window for them "
             "(nbrowser_switch_window / browser window state), or (c) THEY LET YOU DRIVE IT - ask "
             "first (AskUserQuestion), then click through 'Continue with Google/Apple/Microsoft' "
             "using their warm session. Use the NATIVE browser (ABE), NEVER pup: pup is anonymous so "
             "Autodesk demands a full fresh login, while their real profile is usually already signed "
             "in. NEVER type their password or 2FA - if a secret is demanded, fusion_notify_owner "
             "toasts them to type it. Then click Fusion's own 'Sign In' (the webview exposes no UIA "
             "control, so use an image-space desktop_click on the button, or foreground + click) to "
             "fire the OAuth handoff; accept the 'Autodesk Identity Manager' overlay ('Always allow' "
             "+ 'Open'). Codes expire in ~2 min - if it expires, click Sign In again. Poll "
             "fusion_readiness until ready:true, then call fusion_demo again. Playbook: "
             "fusion-autodesk-signin."),
            needsSignin=True, signinOpened=opened, signinWhere=where,
            signinProfile=profile_used, signinSession="fusion-signin" if opened else None,
            signinProfileChoices=choices, signinProfileReason=pick_reason,
            signinProfileAmbiguous=ambiguous, nbState=nb_state)

    if not rd.get("ready"):
        return _out("waiting",
            "Fusion is starting up - one moment.",
            "Not ready yet (add-in still loading, or a licensing dialog). Poll fusion_readiness; it "
            "auto-resolves seat dialogs. When ready:true, call fusion_demo again. Reason: " +
            str(rd.get("_hint", ""))[:220])
    steps.append("Fusion ready")

    # ── 3. APS: set it up if we can, otherwise SELL it (never skip silently) ─────
    aps_ready = False
    try:
        ap = aps.handle_status({}).get("data", {})
        aps_ready = bool(ap.get("configured") and ap.get("signedIn") and ap.get("tokenLive"))
    except Exception:
        ap = {}
    if not aps_ready and stage != "skip_aps":
        return _out("aps_setup",
            "Next I'll turn on cloud search - it finds any design in your Autodesk cloud in about "
            "two seconds instead of a 30-minute folder crawl. It's a one-time sign-in.",
            "SET APS UP FOR THEM - do not merely mention it. (1) fusion_aps_status for exact state. "
            "(2) If not configured, an admin registers a PKCE app once, then fusion_aps_set_client_id. "
            "(3) fusion_aps_signin - drive it in their NATIVE browser (ABE), same reasoning as the "
            "Fusion sign-in: they're already logged into Autodesk there. (4) Poll fusion_aps_status "
            "until tokenLive:true, then call fusion_demo again - the demo then runs a LIVE sample "
            "search so they SEE the speed. If they decline setup, call fusion_demo {stage:'skip_aps'} "
            "and TELL them what they're missing (2s server-indexed search across the whole team hub; "
            "the old in-app search took 30+ min and crashed Fusion, so it is disabled). "
            "Skills: fusion-aps-search, fusion-aps-signin.",
            apsConfigured=bool(ap.get("configured")), apsSignedIn=bool(ap.get("signedIn")))

    # ── 4. APS sample search - let them SEE the speed ───────────────────────────
    aps_hits = []
    if aps_ready:
        try:
            import time as _t
            t0 = _t.time()
            sr = aps.handle_search({"query": demo_query or "board", "limit": 5}).get("data", {})
            aps_hits = [{"name": r.get("name"), "project": r.get("projectName")}
                        for r in (sr.get("results") or [])[:5]]
            steps.append("APS sample search: %d hits in %.1fs" % (len(aps_hits), _t.time() - t0))
        except Exception as e:
            steps.append("APS sample search failed: %s" % e)

    # ── 5. Open an ELECTRONICS design, then walk schematic -> 2D -> 3D ──────────
    st = _proxy_to_addin("get_app_state", {}, timeout=20) or {}
    doc = (st.get("data") or st).get("activeDocument")
    is_elec = bool((st.get("data") or st).get("isElectronics"))
    if not is_elec:
        target = demo_query or (aps_hits[0]["name"] if aps_hits else "")
        if not target:
            return _out("need_design",
                "I need an electronics design to show off. Which board should I open?",
                "No electronics design open and nothing to pick. If APS is live, "
                "fusion_aps_search {query:'<board>'} then fusion_aps_open {query:'<name>'}; else ask "
                "the user for a design name / open one via fusion_open_cloud_file. ALWAYS open the "
                "PROJECT (EcadDesignProductType), never a .brd/.sch/3D child. Then call fusion_demo again.")
        return _out("open_design",
            "Opening %s so you can see a real board end to end." % target,
            "OPEN THE PROJECT then re-call fusion_demo: fusion_aps_open {query:'%s'} (APS finds it at "
            "any folder depth in seconds). CRITICAL: open the electronics PROJECT file, not the "
            "schematic/.brd/3D child - a child opens an isolated, often empty view. Poll "
            "fusion_get_app_state until isElectronics:true, then call fusion_demo again." % target,
            target=target)

    steps.append("electronics project open: %s" % doc)
    _shot("project")

    # schematic -> 2D board -> 3D board, screenshotting each so the AI can SHOW them
    try:
        _proxy_to_addin("show_schematic", {}, timeout=90); steps.append("showed schematic"); _shot("schematic")
    except Exception as e:
        steps.append("schematic failed: %s" % e)
    try:
        _proxy_to_addin("show_2d_board", {}, timeout=90); steps.append("showed 2D board"); _shot("board_2d")
    except Exception as e:
        steps.append("2D board failed: %s" % e)
    try:
        _proxy_to_addin("show_3d_board", {}, timeout=180); steps.append("showed 3D board"); _shot("board_3d")
    except Exception as e:
        steps.append("3D board failed: %s" % e)

    aps_line = ("Cloud search is live - I searched your whole Autodesk hub in about two seconds and "
                "found: %s. " % ", ".join(h["name"] for h in aps_hits[:3])) if aps_hits else ""
    return _out("done",
        "That's the tour: your %s project, its schematic, the 2D board layout, and the real 3D board. "
        "%sFrom here just ask - export Gerbers/BOM/CPL for the fab, generate a laser-etched IPC "
        "package, load JLCPCB design rules, or open any design in your cloud." % (doc, aps_line),
        "DEMO COMPLETE. SHOW the user the screenshots in order (project, schematic, board_2d, "
        "board_3d) - they are in `screenshots` with labels. Narrate the `narrate` line. Then offer "
        "concrete next steps in THEIR words: 'export the Gerbers', 'make me a SOIC-8 with my part "
        "number etched on it' (fusion_generate_package), 'check this against JLCPCB rules' "
        "(fusion_load_design_rules), 'find my other boards' (fusion_aps_search). Full catalog: "
        "fusion_describe. Demo playbook: the fusion-demo skill.",
        done=True, document=doc, apsSampleSearch=aps_hits)


def _orchestrate_board_stackup(args: dict) -> dict:
    """Read a board's PHYSICAL fabrication stackup (see the pcb-stackup skill). A PCB is a
    stack of copper + dielectric: Cu(L1) / prepreg / Cu(L2) / core / ... / Cu(bottom). This
    reads the copper count + copper/dielectric thicknesses from the EAGLE design rules
    (layerSetup / mtCopper / mtIsolate in the .brd) and the measured FR4 extent from the 3D
    body, and returns the ordered layer list (name, thickness, z) so a caller can build the
    stackup table + the real-thickness exploded 3D view."""
    import re as _re
    import tempfile as _tf
    # 1) design rules from the EAGLE .brd (board editor active)
    _proxy_to_addin("show_2d_board", {}, timeout=60)
    brd = os.path.join(_tf.gettempdir(), "adom_stackup.brd")
    r = _proxy_to_addin("export_eagle_source", {"outputPath": brd}, timeout=120)
    if not r.get("success"):
        return {"success": False, "error": "could not export .brd for stackup: %s" % (r.get("error") or r.get("message")),
                "_hint": "Open the PROJECT and switch to the board (fusion_show_2d_board) first."}
    try:
        txt = open(brd, encoding="latin1", errors="replace").read()
    except Exception as e:
        return {"success": False, "error": "could not read .brd: %s" % e}
    def _p(name):
        m = _re.search(r'<param name="%s" value="([^"]*)"' % name, txt)
        return m.group(1) if m else None
    layer_setup = _p("layerSetup") or ""
    mt_copper = [x for x in (_p("mtCopper") or "").split()]
    mt_isolate = [x for x in (_p("mtIsolate") or "").split()]
    copper_layers = _re.findall(r"\d+", layer_setup)   # e.g. ['1','2','15','16']
    ncu = len(copper_layers)
    # bonds between copper layers, in order: '+' prepreg, '*' core
    bonds = [("core" if c == "*" else "prepreg") for c in layer_setup if c in "+*"]
    # 2) measure the FR4 Board body (3D)
    _proxy_to_addin("show_3d_board", {}, timeout=60)
    # Find the LARGEST-XY body named 'board' (the FR4 substrate) - there can be several
    # 'board'-named bodies (small ones), so pick the substrate by area, not the first.
    mscript = (
        "import adsk.core, adsk.fusion\n"
        "app=adsk.core.Application.get(); des=adsk.fusion.Design.cast(app.activeProduct)\n"
        "best=None; ba=-1.0\n"
        "for occ in des.rootComponent.allOccurrences:\n"
        " for b in occ.component.bRepBodies:\n"
        "  if b.name.lower()=='board':\n"
        "   bb=b.boundingBox; mn=bb.minPoint; mx=bb.maxPoint\n"
        "   a=(mx.x-mn.x)*(mx.y-mn.y)\n"
        "   if a>ba: ba=a; best=((mx.x-mn.x)*10,(mx.y-mn.y)*10,(mx.z-mn.z)*10)\n"
        "print('FR4 %.4f %.4f %.4f'%best if best else 'FR4 none')")
    mm = _proxy_to_addin("run_modeling_script", {"script": mscript}, timeout=60)
    fr4 = None
    try:
        msg = ""
        if isinstance(mm, dict):
            o = mm.get("output")
            msg = (json.loads(o).get("message") if isinstance(o, str) and o.startswith("{") else (mm.get("message") or o)) or ""
        m = _re.search(r"FR4\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)", str(msg))
        if m:
            fr4 = {"x_mm": float(m.group(1)), "y_mm": float(m.group(2)), "dielectric_mm": float(m.group(3))}
    except Exception:
        pass
    def _num(s):
        try: return float(str(s).replace("mm", ""))
        except Exception: return None
    return {
        "success": True,
        "layerSetup": layer_setup,
        "copperLayers": copper_layers,
        "copperCount": ncu,
        "copperThickness_mm": [_num(t) for t in mt_copper[:ncu]] if mt_copper else None,
        "dielectricBonds": bonds,                 # e.g. ['prepreg','core','prepreg']
        "dielectricThickness_raw": mt_isolate,    # design-rule list (may hold 2-layer defaults)
        "fr4": fr4,
        "brdPath": brd,
        "_hint": ("Physical stack (see pcb-stackup skill): the FR4 is NOT one slab - it is "
                  "prepreg/core/prepreg with copper between. Build the table + exploded view per that skill. "
                  "If mtIsolate looks like 2-layer defaults, fit a symmetric split to fr4.dielectric_mm."),
    }


def dispatch_command(command: str, args: dict) -> dict:
    """Dispatch a command to the appropriate handler."""
    # Direct handlers (don't need the add-in)
    handler = COMMAND_HANDLERS.get(command)
    if handler is not None:
        return handler(fusion_info, args)

    # Check if Fusion is installed and running before any add-in-dependent command.
    # We do NOT auto-launch — that causes 60s+ hangs when Fusion isn't running.
    # The user should launch Fusion themselves; we just report the status.
    # Commands that need Fusion running (add-in commands + orchestrated commands)
    _OPEN_WITH_SCREENSHOT = {"open_schematic", "open_board", "show_3d_board", "show_2d_board", "show_schematic", "import_electronics"}
    if command in ADDIN_COMMANDS or command in ("open_lbr", "save_lbr", "attach_3d_package", "make_3d_package", "build_library_3d", "capture_library_views", "cleanup_cloud_files", "generate_package", "export_optimized_glb", "board_stackup") or command in _OPEN_WITH_SCREENSHOT:
        if not fusion_info.get("installed"):
            return {
                "success": False,
                "error": "Fusion 360 is not installed on this machine.",
                "errorCode": "fusion_not_installed",
                "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in.",
            }
        if not _is_fusion_running():
            return {
                "success": False,
                "error": "Fusion 360 is installed but not running.",
                "errorCode": "fusion_not_running",
                "_hint": "Call fusion_start to start Fusion 360, then wait for the add-in to become ready (fusion_start blocks until ready). Then retry this command.",
            }
        # Fusion is running — check if add-in is responsive (short timeout).
        # But first, if the add-in is busy with a long command, skip the
        # responsiveness check and fall through to the busy gate below.
        addin_busy = _check_addin_status(timeout=3.0)
        if addin_busy and addin_busy.get("busy"):
            pass  # Fall through to busy gate
        elif not wait_for_addin(timeout=5):
            return {
                "success": False,
                "error": "Fusion 360 is running but AdomBridge add-in not responding.",
                "errorCode": "fusion_addin_not_responding",
                "_hint": "The add-in isn't responding. Fix it YOURSELF - never ask the user: the bridge auto-installs the add-in to ALL Fusion add-in dirs (2025+ Fusion scans %APPDATA%/Autodesk/FusionAddins; the legacy API/AddIns dirs are silently ignored - issue #63), so restart Fusion via fusion_stop + fusion_start and it loads (runOnStartup). Verify with fusion_addin_status.",
            }

    # ── Busy gate: reject add-in commands immediately if a long command is running ──
    # Bridge-level commands (COMMAND_HANDLERS) already returned above — they use
    # Win32 APIs and don't touch the add-in, so they always work during a walk.
    # But add-in commands (and orchestrated commands that proxy to the add-in)
    # would pile up behind _main_thread_lock for 300s and crash the host.
    busy = _get_long_command()
    if busy and command not in LONG_RUNNING_COMMANDS:
        # This command would block behind the long-running one — reject immediately
        progress = _get_busy_progress()
        elapsed = round(_time.time() - busy["startedAt"], 1)
        progress_pct = None
        if progress and progress.get("foldersVisited") and progress.get("queueSize") is not None:
            total = progress["foldersVisited"] + progress["queueSize"]
            if total > 0:
                progress_pct = round(100 * progress["foldersVisited"] / total)
        return {
            "success": False,
            "error": (
                f"Fusion main thread busy — {busy['command']} has been running for {elapsed}s. "
                f"Your command '{command}' cannot execute until it finishes."
            ),
            "errorCode": "main_thread_busy",
            "busyCommand": busy["command"],
            "elapsedSeconds": elapsed,
            "progress": progress,
            "_hint": (
                f"A cloud search ({busy['command']}) is in progress"
                + (f" (~{progress_pct}% done)" if progress_pct is not None else "")
                + f", running for {elapsed}s. "
                "Do NOT retry add-in commands (get_app_state, document_info, etc.) — they will "
                "all be rejected until the search finishes. Commands that still work right now: "
                "fusion_window_info, fusion_screenshot_fusion, fusion_click_fusion, "
                "fusion_send_key, fusion_close_window. Wait for the search to complete, then retry."
            ),
        }

    # Cross-bridge case: this bridge's _long_command is None but the add-in
    # might be busy from another bridge/session. Quick non-blocking check.
    if not busy and command not in LONG_RUNNING_COMMANDS:
        addin_status = _check_addin_status(timeout=3.0)
        if addin_status and addin_status.get("busy"):
            elapsed = addin_status.get("elapsedSeconds", 0)
            walk = addin_status.get("walkProgress")
            busy_cmd = addin_status.get("busyCommand", "unknown")
            resp = {
                "success": False,
                "error": (
                    f"Fusion main thread busy — {busy_cmd} running for {elapsed}s "
                    f"(from another bridge/session). Your command '{command}' cannot "
                    f"execute until it finishes."
                ),
                "errorCode": "main_thread_busy",
                "busyCommand": busy_cmd,
                "elapsedSeconds": elapsed,
                "_hint": (
                    f"A long-running command ({busy_cmd}) is in progress from another session. "
                    "Do NOT retry add-in commands — they will all be rejected until it finishes. "
                    "Do NOT press Escape — the add-in is working, not stuck on a dialog. "
                    "Commands that still work: fusion_window_info, fusion_screenshot_fusion, "
                    "fusion_click_fusion, fusion_send_key, fusion_close_window."
                ),
            }
            if walk:
                resp["progress"] = walk
            return resp

    # Orchestrated multi-step commands (handled at bridge level)
    if command in _OPEN_WITH_SCREENSHOT:
        return _handle_open_with_screenshot(fusion_info, args, command)
    if command == "open_lbr":
        return _orchestrate_open_lbr(args)
    if command == "save_lbr":
        return _merge_dialog_array(_orchestrate_save_lbr(args))
    if command == "attach_3d_package":
        return _merge_dialog_array(_orchestrate_attach_3d_package(args))
    if command == "make_3d_package":
        return _apply_failure_dialogs(_orchestrate_make_3d_package(args))
    if command == "build_library_3d":
        return _apply_failure_dialogs(_orchestrate_build_library_3d(args))
    if command == "capture_library_views":
        return _apply_failure_dialogs(_orchestrate_capture_library_views(args))
    if command == "cleanup_cloud_files":
        return _orchestrate_cleanup_cloud_files(args)
    if command == "generate_package":
        return _apply_failure_dialogs(_orchestrate_generate_package(args))
    if command == "export_optimized_glb":
        return _orchestrate_export_optimized_glb(args)
    if command == "fetch_optimized_glb":
        return _orchestrate_fetch_optimized_glb(args)
    if command == "demo":
        return _orchestrate_demo(args)
    if command == "signin":
        return _orchestrate_signin(args)
    if command == "board_stackup":
        return _orchestrate_board_stackup(args)

    # Add-in proxy commands
    if command in ADDIN_COMMANDS:
        addin_cmd = ADDIN_COMMAND_MAP.get(command, command)
        proxy_timeout = ADDIN_COMMAND_TIMEOUTS.get(command, 30)
        # Wrap long-running commands in set/clear so the gate knows they're active.
        # Pre-dismiss blocking dialogs: modal dialogs steal Fusion's event loop,
        # preventing fireCustomEvent from being processed. Without this, the
        # walk appears "busy" but never actually starts — _walk_progress stays
        # None indefinitely while the command sits in the event queue.
        if command in LONG_RUNNING_COMMANDS:
            try:
                info = get_fusion_window_info()
                if info.get("dialogs"):
                    for d in info["dialogs"]:
                        try:
                            # Use WM_CLOSE via PostMessage — doesn't steal foreground
                            # (unlike send_key which uses SendInput + SetForegroundWindow).
                            # WM_CLOSE is also more reliable than Escape for Qt dialogs.
                            close_window(d.get("hwnd"))
                        except Exception:
                            pass
                    import time as _time
                    _time.sleep(0.5)  # give Fusion a moment to process the dismiss
            except Exception:
                pass
            _set_long_command(command)
            try:
                return _proxy_to_addin(addin_cmd, args, timeout=proxy_timeout)
            finally:
                _clear_long_command()
        result = _proxy_to_addin(addin_cmd, args, timeout=proxy_timeout)
        # After a state-changing op, surface any dialog/owned-popup the AI must analyze
        # (the Hub upload-close confirm, a save prompt, recovery, etc.) so it can't fly
        # blind. Read-only verbs are excluded to avoid per-call screenshot latency.
        if command in MUTATING_COMMANDS:
            result = _merge_dialog_array(result)
        return result

    # Unknown command — check installation/running status for helpful errors
    if not fusion_info.get("installed"):
        return {
            "success": False,
            "error": f"Fusion 360 is not installed on this machine. (command: {command})",
            "errorCode": "fusion_not_installed",
            "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in.",
        }
    if not _is_fusion_running():
        return {
            "success": False,
            "error": f"Fusion 360 is installed but not running. (command: {command})",
            "errorCode": "fusion_not_running",
            "_hint": "Call fusion_start to start Fusion 360 and wait for the add-in to be ready, then retry this command.",
        }
    return {
        "success": False,
        "error": f"Unknown command: {command}",
        "_hint": "Run `adom-desktop help` or check cli/src/commands.rs to see the list of available fusion_* commands. This command name may be misspelled or not yet implemented.",
    }


def _build_status() -> dict:
    """Build the /status payload — the endpoint AD declares as healthEndpoint.

    MUST return HTTP 2xx whenever the server is up: AD's health check polls this
    path and a 404 (the classic manifest-vs-server mismatch) makes AD wait the
    full startup grace then report a generic "not reachable". We also self-report
    the GUI chip fields {led, summary, tooltip} — AD renders them verbatim (the
    bridge owns its color; AD owns only the unreachable→gray state).
    """
    import platform

    info = fusion_info or {}
    installed = bool(info.get("installed"))
    running = _is_fusion_running() if installed else False
    addin = _probe_addin() if running else None
    addin_ok = bool(addin)

    if platform.system() != "Windows":
        led, summary = "yellow", "Unsupported OS"
        tooltip = ("Bridge running, but this host is not Windows. Fusion 360 verbs "
                   "need a Windows host with Fusion 360 installed.")
    elif not installed:
        led, summary = "yellow", "Fusion not installed"
        tooltip = ("Bridge running, but Fusion 360 is not installed. The AI can install it "
                   "for the user (fusion-onboarding), then fusion_start.")
    elif not running:
        led, summary = "yellow", "Fusion not running"
        tooltip = "Fusion 360 is installed but not running. Call fusion_start to launch it."
    elif not addin_ok:
        led, summary = "yellow", "Add-in not connected"
        tooltip = ("Fusion 360 is running but the AdomBridge add-in isn't responding yet "
                   "(still loading; if it persists the AI restarts Fusion via fusion_stop/start).")
    else:
        led, summary = "green", "Fusion ready"
        tooltip = "Fusion 360 running, AdomBridge add-in connected. All fusion_* verbs available."

    return {
        "status": "ok",
        "led": led,
        "summary": summary,
        "tooltip": tooltip,
        "bridgeVersion": BRIDGE_VERSION,
        "fusion": {**info, "running": running},
        "addin": addin,
    }


class FusionBridgeHandler(BaseHTTPRequestHandler):
    """HTTP request handler for the Fusion 360 bridge server."""

    def do_GET(self):
        # /status is the manifest's healthEndpoint; /health is kept as a
        # back-compat alias (older callers + the add-in-probe code path). Both
        # return the same 2xx payload — new chip fields are purely additive.
        if self.path in ("/status", "/health"):
            self._respond(200, _build_status())
        else:
            self._respond(404, {"error": "Not found"})

    def do_POST(self):
        if self.path != "/command":
            self._respond(404, {"error": "Not found"})
            return

        content_length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(content_length)

        try:
            request = json.loads(body)
        except json.JSONDecodeError as e:
            self._respond(400, {"success": False, "error": f"Invalid JSON: {e}"})
            return

        command = request.get("command", "")
        args = request.get("args", {})

        print(f"[Fusion Bridge] Command: {command} | Args: {json.dumps(args)}")

        try:
            result = dispatch_command(command, args)
            self._respond(200, result)
        except Exception as e:
            print(f"[Fusion Bridge] ERROR: {e}")
            traceback.print_exc()
            self._respond(500, {
                "success": False,
                "error": f"Internal error: {e}",
            })

    def _respond(self, status: int, data: dict):
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(json.dumps(data).encode("utf-8"))

    def log_message(self, format, *args):
        print(f"[Fusion Bridge] {args[0]} {args[1]} {args[2]}")


def main():
    global fusion_info

    # Line-buffer stdout/stderr. AD spawns the bridge console-less and captures
    # our stdout+stderr into ~/.adom/bridge-logs/fusion360.log. Python BLOCK-buffers
    # stdout when it's not a TTY, so without this the buffer never flushes while
    # serve_forever() runs → the log stays 0 bytes (and a spawn-crash traceback
    # would be lost). Line buffering flushes every print()/traceback on newline.
    try:
        sys.stdout.reconfigure(line_buffering=True)
        sys.stderr.reconfigure(line_buffering=True)
    except Exception:
        pass

    port = DEFAULT_PORT
    if "--port" in sys.argv:
        idx = sys.argv.index("--port")
        if idx + 1 < len(sys.argv):
            port = int(sys.argv[idx + 1])

    # Early banner BEFORE detection — so the log proves we started even if
    # detect_fusion() is slow or wedges. (KiCad bridge prints an equivalent line.)
    print(f"[Fusion Bridge] starting - version {BRIDGE_VERSION}, port {port}, "
          f"healthEndpoint /status, pid {os.getpid()}", flush=True)

    fusion_info = detect_fusion()

    print(f"[Fusion Bridge] Fusion 360 detection result:")
    print(f"  Installed: {fusion_info.get('installed', False)}")
    if fusion_info.get("installed"):
        print(f"  Exe path:  {fusion_info.get('exe_path')}")
        print(f"  AddIns:    {fusion_info.get('addins_dir')}")
        print(f"  Add-in:    {'installed' if fusion_info.get('addin_installed') else 'not installed'}")
        print(f"  Running:   {fusion_info.get('running')}")

        # ALWAYS sync the add-in on startup - it is idempotent (_sync_directory copies
        # only CHANGED files). The old `if not addin_installed` guard meant a STALE
        # add-in was never UPDATED: an add-in fix (e.g. get_parameters, caught live
        # 2026-07-06) never reached users who already had ANY copy, because the bridge
        # only deployed when it was entirely MISSING. Always-sync fixes that. It is safe
        # while Fusion is up (locked add-in files simply skip via the per-target OSError
        # catch); the update lands on the next bridge respawn with Fusion closed
        # (fusion_stop -> bridge_install -> fusion_start).
        print(f"[Fusion Bridge] Syncing AdomBridge add-in (idempotent; updates a stale copy)...")
        try:
            install_addin()
            fusion_info = detect_fusion()  # Re-detect after sync
            print(f"  Add-in:    {'installed' if fusion_info.get('addin_installed') else 'FAILED'}")
        except Exception as e:
            print(f"  Add-in sync failed: {e}")
    else:
        print(f"  WARNING: Fusion 360 not found. Some commands will fail.")

    addin_health = _probe_addin()
    if addin_health:
        print(f"  Add-in server: running on port {ADDIN_PORT}")
    else:
        print(f"  Add-in server: not running (port {ADDIN_PORT})")

    all_commands = list(COMMAND_HANDLERS.keys()) + sorted(ADDIN_COMMANDS)
    print(f"[Fusion Bridge] Available commands: {', '.join(all_commands)}")

    # AD (>=1.9.63) passes ADOM_BIND_HOST (always 127.0.0.1) to every bridge it
    # spawns. Honor it and NEVER bind 0.0.0.0/'' by default - a public bind pops a
    # Windows Firewall "allow access?" dialog, and AD's guarantee to users is no
    # firewall prompts. Default to loopback if the var is absent (e.g. local dev).
    bind_host = os.environ.get("ADOM_BIND_HOST", "127.0.0.1")
    server = ThreadingHTTPServer((bind_host, port), FusionBridgeHandler)
    server.daemon_threads = True
    print(f"[Fusion Bridge] Listening on http://{bind_host}:{port}")
    print(f"[Fusion Bridge] Health check: http://{bind_host}:{port}/status (alias /health)")
    print(f"[Fusion Bridge] Press Ctrl+C to stop.")

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n[Fusion Bridge] Shutting down.")
        server.server_close()


if __name__ == "__main__":
    # Surface any fatal startup error to stderr (which AD captures into
    # ~/.adom/bridge-logs/fusion360.log) so a spawn-crash is debuggable, then
    # re-raise for a non-zero exit.
    try:
        main()
    except Exception:
        print("[Fusion Bridge] FATAL: bridge failed to start", flush=True)
        traceback.print_exc()
        sys.stderr.flush()
        raise