123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303
#!/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 threading
import time as _t2
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

# Caller identity: WHO we are currently acting for (issue #348). AD stamps X-Adom-Caller-* on
# every request it dispatches to us; we echo those back on our own AD callbacks so the user sees
# "chip-fetcher tab 3 (via fusion)" instead of a nameless bridge.
#
# THREAD-LOCAL, deliberately. This is a ThreadingHTTPServer, so a module global would let
# concurrent requests clobber each other and attribute tab 3's work to tab 11 - precisely the
# failure this feature exists to prevent. Thread-local also fixes the stale-identity trap the
# contract calls out: a background thread (health poll, timer, crash cleanup) has no value bound,
# so it can never inherit some earlier request's name.
_caller_tls = threading.local()


def _set_caller_identity(ident: dict | None) -> None:
    """Bind the calling AI thread's identity to THIS request thread. Empty values are dropped."""
    _caller_tls.value = {k: v for k, v in (ident or {}).items() if v}


def _get_caller_identity() -> dict:
    """Identity bound to this thread, or {} when we are acting on our own behalf."""
    return getattr(_caller_tls, "value", None) or {}


def _stamp_caller(args: dict) -> dict:
    """Attribute an OUTGOING AD call (issue #348).

    Forwards the identity of the AI thread we are carrying out work for. When we act on our OWN
    behalf (health poll, timer, cleanup sweep) nothing is bound to this thread, so we name
    ourselves rather than forwarding a stale identity. An explicit caller in args always wins.

    Returns a COPY; the caller's dict is never mutated."""
    out = dict(args or {})
    if out.get("caller") or out.get("aiThread"):
        return out
    ident = _get_caller_identity()
    if ident.get("thread"):
        caller = {"aiThread": ident["thread"]}
        if ident.get("container"):
            caller["containerName"] = ident["container"]
    else:
        caller = {"aiThread": "fusion bridge (self)", "containerName": "local"}
    out["caller"] = caller
    return out


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 host app's CLI so the bridge can drive it when the in-process ad_client is down.

    Accepts BOTH names. The host app was renamed Adom Desktop -> Adom Bridge in v2.0.0 (issue #537),
    so a box may carry either layout: a renamed install has "Adom Bridge"/adom-bridge-cli.exe, an
    older one still has "Adom Desktop"/adom-desktop-cli.exe. Looking for only one silently kills the
    CLI fallback on the other, so we try new-first then old and let whichever exists win.

    The hard rule survives the rename: shell the **-cli** exe only. Never the GUI exe (adom-bridge.exe
    / adom-desktop.exe) - launching that with verb args foregrounds the app on the user's screen every
    single call (issue #295). Prefer the loopback direct API over shelling at all (_ad_direct_api_call).
    """
    import shutil
    la = os.environ.get("LOCALAPPDATA", "")
    apps = (("Adom Bridge", "adom-bridge-cli.exe"), ("Adom Desktop", "adom-desktop-cli.exe"))
    if la:
        for folder, exe in apps:
            for parent in (la, os.path.join(la, "Programs")):
                c = os.path.join(parent, folder, exe)
                if os.path.exists(c):
                    return c
    # PATH: the CLI shim only. On Windows NEVER resolve the bare name - that is the GUI exe.
    for shim in ("adom-bridge-cli", "adom-desktop-cli"):
        hit = shutil.which(shim)
        if hit:
            return hit
    if os.name != "nt":
        # On Linux/dev the bare name IS the CLI.
        return shutil.which("adom-bridge") or shutil.which("adom-desktop")
    return None


def _cli_notify_all(title: str, body: str, level: str) -> dict:
    """Deliver a toast to EVERY connected desktop via `adom-bridge-cli --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-bridge-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 adom-bridge-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 adom-bridge-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-bridge-cli fallback failed: "
                     + str(cli.get("error"))[:200],
            "notifyUser": {"title": title, "body": body, "level": level},
            "_hint": ("Relay the notifyUser payload yourself: `adom-bridge-cli --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 _aps_quick_state() -> dict:
    """Cheap APS state for readiness: {configured, signedIn}. Never raises, never blocks."""
    try:
        import aps as _aps
        st = _aps.handle_status({}) or {}
        d = st.get("data", st)
        return {"configured": bool(d.get("configured")), "signedIn": bool(d.get("signedIn"))}
    except Exception:
        return {"configured": None, "signedIn": None}


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:
        # AUTO-UPDATE, checked on EVERY launch and applied SILENTLY (John, 2026-07-22: "i just
        # always want the latest fusion... make this generally invisible to me"). Fusion nags
        # constantly; we click it in the BACKGROUND so the user never sees it. Sticky opt-out.
        _upd = _apply_fusion_update_silently()
        # APS is checked on EVERY launch now (John, 2026-07-22: "why aren't those just driven from
        # 1 login?"). Fusion sign-in and APS are both Autodesk logins; the user should authenticate
        # ONCE. They cannot share a token (Autodesk DPAPI-encrypts Fusion's store), but they CAN
        # share the warm browser SSO session - so APS consent is silent right after a Fusion login.
        _aps = _aps_quick_state()
        # MCP state, reported beside APS on every launch (both are search/drive surfaces the
        # AI should know about without probing). Cheap: a 0.4s loopback socket.
        _mcpmsg = (" Autodesk's MCP server is LIVE (fusion_mcp_* verbs work)."
                   if _probe_mcp_port() else
                   " Autodesk's MCP server is OFF - fusion_mcp_enable can turn it on for the "
                   "user (~10s, drives the Preferences dialog).")
        _updmsg = _mcpmsg
        if _upd.get("applied"):
            _updmsg += (" AUTO-UPDATE: Fusion was offering an update and I clicked %r for the user "
                       "IN THE BACKGROUND - it downloads now and the new build comes up on the next "
                       "launch. TELL THE USER it is handled; they never need to click the nag. "
                       "They can opt out with fusion_set_auto_update {enabled:false} (sticky)."
                       % _upd.get("button"))
        elif not _auto_update_enabled():
            _updmsg += (" AUTO-UPDATE is OFF (the user opted out, sticky) - do NOT click Fusion's "
                       "update prompts for them.")
        if _aps.get("signedIn"):
            hint = ("READY - drive Fusion (fusion_open_lbr / build_library_3d / etc.). APS cloud "
                    "search is signed in too, so use fusion_aps_search for ~2s cloud lookups."
                    + _updmsg)
        elif _aps.get("configured"):
            hint = ("READY to drive Fusion, but APS CLOUD SEARCH IS NOT SIGNED IN - cloud file "
                    "search will be slow/blocked. DO IT NOW, do not make the user log in a second "
                    "time: call fusion_aps_signin RIGHT AFTER a Fusion sign-in, while the browser "
                    "profile still has a warm Autodesk SSO session - it consents silently (no "
                    "password, no 2FA). Then fusion_aps_search works." + _updmsg)
        else:
            hint = ("READY to drive Fusion. APS cloud search is NOT SET UP (no client ID) - cloud "
                    "search is the slow path without it. Offer to set it up: fusion_aps_set_client_id "
                    "then fusion_aps_signin (silent while the browser SSO session is warm). APS is "
                    "how cloud search gets to ~2 seconds." + _updmsg)
    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}."
)


# ── SEARCH ROUTER: one live-state answer to "which file-search should the AI use?" ───────────
# John, 2026-07-24: "you now have a lot of ways to search for files, so even the ai will get
# confused by which verb to use... are your hints smart, i.e. they know if aps is configured or
# not so the hint is dynamic and does a bunch of lookup work... and give hints where you can
# tell it ideas on things you could enable and the complexity of enabling it?"
#
# There are three ways to find a cloud file, and the right one DEPENDS ON LIVE STATE:
#   1. fusion_aps_search        - APS Data Management. Needs APS signed in. Works with Fusion
#                                 CLOSED, headless, no subscription. ~2s.
#   2. fusion_mcp_call read/document/search - Autodesk's MCP server. Needs Fusion RUNNING +
#                                 subscription + the MCP toggle on. ~2s.
#   3. fusion_search_cloud_files - DISABLED (30+ min walk, crashed Fusion). Never.
#
# _search_router() probes all of it live (cheap: APS token file read + a 0.4s loopback socket)
# and every relevant hint renders the SAME ranked answer, including what could be ENABLED and
# what enabling costs (APS signin = silent on a warm SSO session, else a browser consent;
# MCP = fusion_mcp_enable drives the Preferences dialog, ~10s, brief announced foreground).
def _probe_mcp_port(timeout: float = 0.4) -> bool:
    """Is Autodesk's MCP server listening on loopback right now? The bridge runs ON the box, so
    this is a direct, cheap socket probe - no relay round-trip."""
    import socket
    try:
        s = socket.socket()
        s.settimeout(timeout)
        s.connect(("127.0.0.1", 27182))
        s.close()
        return True
    except Exception:
        return False


def _search_router() -> dict:
    """Live state of every file-search path + a ranked recommendation. Never raises."""
    aps_st = _aps_quick_state()                      # {configured, signedIn} - token-aware
    mcp_on = _probe_mcp_port()
    fusion_up = mcp_on  # the MCP server lives inside Fusion; port open implies running
    if not mcp_on:
        try:
            fusion_up = _is_fusion_running()
        except Exception:
            fusion_up = False

    aps_ready = bool(aps_st.get("signedIn"))
    options = []
    if aps_ready:
        options.append(("fusion_aps_search", "ready NOW (~2s, works even with Fusion closed)"))
    if mcp_on:
        options.append(("fusion_mcp_call {tool:'fusion_mcp_read', arguments:{queryType:'document',"
                        "operation:'search', name:'...'}}", "ready NOW (~2s, MCP server is live)"))

    enable = []
    if not aps_ready:
        if aps_st.get("configured"):
            enable.append("APS: one fusion_aps_signin - SILENT if run right after a Fusion "
                          "sign-in (warm browser SSO), else a one-click browser consent")
        else:
            enable.append("APS: needs one-time setup - fusion_aps_set_client_id (once per "
                          "company) then fusion_aps_signin (once per user)")
    if not mcp_on:
        if fusion_up:
            enable.append("MCP: fusion_mcp_enable turns it on FOR the user (drives the "
                          "Preferences dialog, ~10s, brief announced foreground); needs a "
                          "Fusion subscription")
        else:
            enable.append("MCP: needs Fusion running first (fusion_start), then "
                          "fusion_mcp_enable; needs a Fusion subscription")

    if options:
        best = options[0][0].split(" ")[0].split("{")[0]
        rec = "BEST SEARCH RIGHT NOW: " + "; also ".join("%s - %s" % o for o in options) + "."
    else:
        best = None
        # nothing ready -> show ALL enable paths + costs, not just the quickest, so the AI can
        # pick (e.g. it must know MCP needs fusion_start when Fusion is down).
        rec = ("NO file search is ready right now. To enable one: "
               + " | ".join(enable) + "." if enable else
               "NO file search is ready and none can be enabled in the current state.")
    if enable and options:
        rec += " Could also enable: " + " | ".join(enable) + "."

    return {"apsReady": aps_ready, "apsConfigured": bool(aps_st.get("configured")),
            "mcpLive": mcp_on, "fusionRunning": fusion_up,
            "bestSearch": best, "searchHint": rec}


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\":\"...\"}."
    # SMART ROUTING (John 2026-07-24): an APS problem must not dead-end the AI when another
    # search path is live right now. The router probes MCP + Fusion state and says what is
    # usable NOW vs what could be enabled and at what cost.
    router = _search_router()
    parts = [f"APS {stage}.", todo, router["searchHint"], "VERBS: " + _APS_VERB_MAP, _APS_ABE, _APS_WHY]
    if extra:
        parts.insert(0, extra)
    return {
        "apsStage": stage, "apsConfigured": configured, "apsSignedIn": signed_in,
        "apsTokenLive": live, "searchRouter": router, "_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 = {
    # Mechanical BOM + physical properties (PR #22, Oliver / BOM Forge). These live in the
    # ADD-IN, so they must be listed here or the bridge never proxies them and the verb
    # 404s no matter how well the handler works.
    "assembly_bom", "physical_properties",
    "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 = {
    # getPhysicalProperties runs on Fusion's MAIN thread per component, so a big assembly
    # (or assembly_bom with includePhysicalProperties) is slow. Give both real headroom.
    "assembly_bom": 300,
    "physical_properties": 300,
    "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


# Raw Fusion API failures (issue #289 item 3, Oliver). Fusion raises RuntimeError with text like
# "3 : Part Design documents can only contain one component" - no code, no remedy - so an AI has to
# infer the fix, and the first of these invalidates a whole modelling approach. Same idea as the
# dialog classifier, applied one layer down: match the text, attach a STABLE code and the real fix.
_API_ERROR_PATTERNS = (
    ("part design documents can only contain one component", "part_template_single_component",
     "This document came from Fusion's PART template, which permits exactly ONE component, so "
     "occurrences cannot be added. Documents created via documents.add(FusionDesignDocumentType) "
     "ARE assembly-capable. Either model with BODIES inside the single component, or create a new "
     "document through the API and build there. Fusion's API and UI defaults differ here, which is "
     "why this is surprising."),
    ("root component name cannot be changed", "root_rename_unsupported",
     "The root component always takes the DOCUMENT's name and cannot be renamed directly. Rename "
     "the document on save instead, or rename a sub-occurrence."),
    ("refers to a deleted object", "stale_api_handle",
     "A handle you are holding points at an object that was closed or deleted. Re-fetch it from "
     "the CURRENT app.activeDocument / activeProduct instead of reusing one from an earlier call."),
)


def _classify_api_error(result: dict) -> dict:
    """Give a raw Fusion API exception a stable errorCode + an actionable remedy (issue #289)."""
    if not isinstance(result, dict) or result.get("success") or result.get("errorCode"):
        return result
    data = result.get("data") if isinstance(result.get("data"), dict) else {}
    blob = " ".join(str(x) for x in (result.get("error", ""), result.get("output", ""),
                                     data.get("traceback", ""))).lower()
    for needle, code, hint in _API_ERROR_PATTERNS:
        if needle in blob:
            result["errorCode"] = code
            result["_hint"] = hint
            break
    return result


# Purchased-subassembly defaults for assembly_bom (issue #289 item 4, Oliver). Fusion's own
# Manage -> BOM renders EVERY subassembly as one collapsible row; the add-in shipped with only
# "with fasteners", so a purchased bearing or pulley unit was descended into and emitted as its
# modeled internals (240 Bearing Ball rows on Wirebening desk, which Fusion never shows at that
# level). Widened HERE at the bridge so no add-in redeploy is needed. An explicit caller-supplied
# treatAsUnit always wins. Note partNumber is NOT a usable signal: Fusion auto-fills it from the
# component name, so the bearing balls carry one too.
_ASSEMBLY_BOM_UNIT_DEFAULTS = ["with fasteners", "with fastener", "with bearings",
                               "bearing", "idler pulley", "pulley", "idler"]


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
    """
    if command == "assembly_bom" and not args.get("treatAsUnit"):
        args = dict(args)
        args["treatAsUnit"] = list(_ASSEMBLY_BOM_UNIT_DEFAULTS)

    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")})

        # Raw Fusion API exceptions arrive as bare "3 : <text>" RuntimeError strings with no
        # errorCode and no remedy (issue #289 item 3). Classify them here, one layer below the
        # dialog classifier and in the same shape, so a caller can branch on a stable code.
        result = _classify_api_error(result)

        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 Bridge 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_direct_api_call(verb: str, args: dict, timeout: float = 60) -> dict | None:
    """Call an AD verb over the loopback DIRECT API - the correct, non-foregrounding way for a
    bridge to reach AD when the in-process ad_client is down (issue #295). AD spawns the bridge
    with ADOM_DIRECT_API_URL in its env (fallback: ~/.adom/direct-api-port); POST
    {"command", "args"} to <base>/command. Returns the parsed result dict, or None if the direct
    API is unavailable or errors (so _ad_call can fall through). Never shells the GUI exe."""
    base = (os.environ.get("ADOM_DIRECT_API_URL") or "").strip()
    if not base:
        try:
            with open(os.path.join(os.path.expanduser("~"), ".adom", "direct-api-port")) as f:
                port = f.read().strip()
            if port:
                base = "http://127.0.0.1:%s" % port
        except Exception:
            return None
    if not base:
        return None
    try:
        body = json.dumps({"command": verb, "args": args}).encode("utf-8")
        headers = {"Content-Type": "application/json"}
        # The bridge token is what keeps a bridge's own callbacks exempt from AD's identity gate
        # (ad_client already sends it; this path used to omit it).
        tok = os.environ.get("ADOM_BRIDGE_TOKEN") or ""
        if tok:
            headers["X-Adom-Bridge-Token"] = tok
        # Echo the identity AD handed us, then name OURSELVES as the delegate (issue #348). The
        # thread alone would hide who ran it; the bridge alone would hide who asked. AD needs both
        # to render "chip-fetcher tab 3 (via fusion)". Delegate is ADDED, never forwarded.
        ident = _get_caller_identity()
        for key, hdr in (("thread", "X-Adom-Caller-Thread"),
                         ("container", "X-Adom-Caller-Container"),
                         ("reason", "X-Adom-Caller-Reason")):
            if ident.get(key):
                headers[hdr] = ident[key]
        headers["X-Adom-Caller-Delegate"] = "fusion"
        req = urllib.request.Request(base.rstrip("/") + "/command", data=body,
                                     headers=headers, method="POST")
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            r = json.loads(resp.read())
        if isinstance(r, dict):
            inner = r.get("output")
            if isinstance(inner, str) and inner.strip().startswith("{"):
                try:
                    return json.loads(inner)
                except Exception:
                    return r
            return r
    except Exception:
        return None
    return None


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; then the loopback DIRECT API (issue #295); only as a last
    resort shells the host app's **-cli** exe (adom-bridge-cli.exe, NEVER the GUI adom-bridge.exe,
    which would foreground AD). Works on an unattended VM where ad_client is down. Never raises -
    returns {} on failure so callers can degrade to instructing the AI instead.

    Attribution (issue #348) is stamped into args HERE, once, so it survives on ALL THREE paths -
    the in-process client and the CLI subprocess cannot carry HTTP headers, and an explicit caller
    block in args is the highest-precedence form AD accepts. The direct-API path additionally sends
    the X-Adom-Caller-* headers so AD can record the delegation chain."""
    args = _stamp_caller(args)
    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)
    # #295: prefer the loopback DIRECT API - it reaches AD without foregrounding anything.
    direct = _ad_direct_api_call(verb, args, timeout=timeout)
    if isinstance(direct, dict):
        return direct
    why.append("direct-api unavailable")
    try:
        import subprocess as _sp, json as _j
        exe = _find_adom_desktop_cli()  # the -cli exe only; never the GUI exe (#295)
        if not exe:
            return {"_adCallError": "; ".join(why + ["adom-bridge-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)}


# ── FUSION AUTO-UPDATE (John, 2026-07-22) ────────────────────────────────────────────────────
# "fusion ships updates non-stop and its annoying to click this. i just always want the latest
# fusion... just make this generally invisible to me and do that by default for all other adom
# users. but let them tell you they don't want you doing that and you make that a sticky setting."
#
# So: ON BY DEFAULT for everyone, applied silently in the BACKGROUND, opt-out is sticky per user.
# Fusion's own updater downloads in the background and swaps the build on the next launch, so all
# we have to do is stop making the human click "Update Now" on a nag panel.
def _bridge_prefs_path():
    import pathlib
    d = pathlib.Path(os.path.expanduser("~")) / ".adom" / "fusion-bridge"
    d.mkdir(parents=True, exist_ok=True)
    return d / "prefs.json"


def _get_bridge_pref(key: str, default=None):
    try:
        import json as _j
        p = _bridge_prefs_path()
        if p.exists():
            v = (_j.loads(p.read_text()) or {})
            return v.get(key, default)
    except Exception:
        pass
    return default


def _set_bridge_pref(key: str, value) -> bool:
    try:
        import json as _j
        p = _bridge_prefs_path()
        cur = {}
        if p.exists():
            try: cur = _j.loads(p.read_text()) or {}
            except Exception: cur = {}
        cur[key] = value
        p.write_text(_j.dumps(cur, indent=2))
        return True
    except Exception:
        return False


def _auto_update_enabled() -> bool:
    """Default TRUE. Only a user who explicitly opted out gets False, and that sticks."""
    return bool(_get_bridge_pref("autoUpdateFusion", True))


# Buttons Fusion puts on its update nag / Job Status panel, best-first.
_UPDATE_BUTTONS = ("Update Now", "Update now", "Install Now", "Restart Now")


def _find_update_offer(hwnd=None) -> dict:
    """Is Fusion offering an update right now? Detected via UIA (background, no foreground).
    Returns {offered, button, hwnd, detail} - never raises."""
    try:
        hw = hwnd or _fusion_window_hwnd()
        if not hw:
            return {"offered": False}
        # Scan the main window AND Fusion's owned popups - the update nag lives in the
        # "View Job Status" panel, which is a CHILD window and is only in the UIA tree while it
        # is open. Checking both is why this runs on a schedule rather than once.
        candidates = [int(hw)]
        try:
            for w in (family_windows(int(hw)) or []):
                h2 = w.get("hwnd") if isinstance(w, dict) else w
                if h2 and int(h2) != int(hw):
                    candidates.append(int(h2))
        except Exception:
            pass
        for h in candidates:
            for label in _UPDATE_BUTTONS:
                r = _unwrap(_ad_call("desktop_find_control", {"hwnd": h, "name": label}, timeout=20))
                best = r.get("best") or {}
                if best.get("name") and best.get("invokable"):
                    return {"offered": True, "button": best.get("name"), "hwnd": h,
                            "detail": "Fusion is offering an update ('%s' is on screen)" % best.get("name")}
        return {"offered": False, "hwnd": hw}
    except Exception:
        return {"offered": False}


def _apply_fusion_update_silently(hwnd=None) -> dict:
    """Click Fusion's update button IN THE BACKGROUND so the download starts and the new build
    is applied on the next launch. No foreground, no caption - the user asked for this to be
    invisible. Returns {applied, button, why}."""
    if not _auto_update_enabled():
        return {"applied": False, "why": "user opted out (sticky pref autoUpdateFusion=false)"}
    offer = _find_update_offer(hwnd)
    if not offer.get("offered"):
        return {"applied": False, "why": "no update offered"}
    r = _unwrap(_ad_call("desktop_ui_click",
                         {"hwnd": int(offer["hwnd"]), "name": offer["button"]}, timeout=30))
    ok = bool(r.get("invoked") or r.get("success"))
    if ok:
        print("[Fusion Bridge] auto-update: clicked %r in the background" % offer["button"])
    return {"applied": ok, "button": offer["button"],
            "why": ("clicked %r in the background; Fusion downloads now and swaps on next launch"
                    % offer["button"]) if ok else "found the button but the UIA invoke did not take"}


def _handle_set_auto_update(fusion_info: dict, args: dict) -> dict:
    """Turn Fusion auto-updating on/off. STICKY - remembered for this user forever."""
    if "enabled" not in (args or {}):
        cur = _auto_update_enabled()
        return {"success": True, "autoUpdateFusion": cur,
                "_hint": ("Fusion auto-update is currently %s. It is ON BY DEFAULT: the bridge "
                          "clicks Fusion's 'Update Now' for the user in the BACKGROUND so they "
                          "always run the latest build and never see the nag. To opt out: "
                          "fusion_set_auto_update {\"enabled\": false} - that choice is STICKY."
                          % ("ON" if cur else "OFF")),
                "statusVerb": "fusion_readiness"}
    enabled = bool(args.get("enabled"))
    _set_bridge_pref("autoUpdateFusion", enabled)
    return {"success": True, "autoUpdateFusion": enabled,
            "narrate": ("I'll keep Fusion updated automatically in the background."
                        if enabled else
                        "I'll stop auto-updating Fusion. You'll see Autodesk's own update prompts."),
            "_hint": ("Saved and sticky (~/.adom/fusion-bridge/prefs.json). %s"
                      % ("Auto-update ON: the bridge silently clicks 'Update Now' whenever Fusion "
                         "offers one, so the user always runs the newest build. Tell them it is "
                         "handled and they never need to click it."
                         if enabled else
                         "Auto-update OFF: the bridge will NOT touch update prompts; the user "
                         "handles Autodesk's nag themselves. Do not override this.")),
            "statusVerb": "fusion_readiness"}


# Registered here, NOT in the COMMAND_HANDLERS literal: that dict is built at line ~1699,
# long before this function exists, so naming it there raised NameError at import and
# crash-looped the whole bridge (done exactly that, 2026-07-22).
COMMAND_HANDLERS["set_auto_update"] = _handle_set_auto_update


# ── AUTODESK FUSION MCP SERVER PROXY (John, 2026-07-23) ──────────────────────────────────────
# Autodesk + Anthropic shipped a Fusion MCP server: a LOCAL HTTP/JSON-RPC endpoint at
# http://127.0.0.1:27182/mcp that exposes Fusion's own text-to-CAD surface (read geometry,
# execute/update features, read Electronics design data).
#
# WHY THE BRIDGE PROXIES IT: that server binds LOOPBACK on the user's machine. It was designed
# for Claude Desktop running on the same box. An Adom AI runs in a CLOUD container and cannot
# reach the user's 127.0.0.1 at all. This bridge already runs on that machine, so it is exactly
# the right proxy: these verbs give the cloud AI Autodesk's MCP tools without reimplementing them.
#
# The server speaks MCP streamable-HTTP: you must `initialize`, capture the MCP-Session-Id
# response header, send notifications/initialized, and pass that header on every later call.
# Skipping it returns 400 "Missing MCP-Session-Id".
_MCP_URL = "http://127.0.0.1:27182/mcp"
_mcp_session_id = None
_mcp_lock = threading.Lock()


def _mcp_post(body: dict, sid: str = None, timeout: int = 30) -> dict:
    import urllib.request, urllib.error
    h = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
    if sid:
        h["MCP-Session-Id"] = sid
    req = urllib.request.Request(_MCP_URL, method="POST",
                                 data=json.dumps(body).encode(), headers=h)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            return {"status": r.status, "headers": dict(r.headers),
                    "body": r.read().decode("utf-8", "replace")}
    except urllib.error.HTTPError as e:
        return {"httpError": e.code, "headers": dict(e.headers),
                "body": e.read().decode("utf-8", "replace")[:1000]}
    except Exception as e:
        return {"error": "%s: %s" % (type(e).__name__, str(e)[:200])}


def _mcp_new_session() -> dict:
    """initialize + notifications/initialized. Returns {sessionId, serverInfo} or {error}."""
    global _mcp_session_id
    r = _mcp_post({"jsonrpc": "2.0", "id": 1, "method": "initialize",
                   "params": {"protocolVersion": "2024-11-05", "capabilities": {},
                              "clientInfo": {"name": "fusion-bridge",
                                             "version": BRIDGE_VERSION}}})
    if r.get("error") or r.get("httpError"):
        return {"error": r.get("error") or ("HTTP %s" % r.get("httpError")), "raw": r.get("body")}
    sid = None
    for k, v in (r.get("headers") or {}).items():
        if k.lower() == "mcp-session-id":
            sid = v
    info = {}
    try:
        info = (json.loads(r.get("body") or "{}").get("result") or {}).get("serverInfo") or {}
    except Exception:
        pass
    if sid:
        _mcp_post({"jsonrpc": "2.0", "method": "notifications/initialized"}, sid)
        _mcp_session_id = sid
    return {"sessionId": sid, "serverInfo": info}


def _mcp_rpc(method: str, params: dict = None, timeout: int = 60) -> dict:
    """One JSON-RPC call with automatic session (re)establishment."""
    global _mcp_session_id
    with _mcp_lock:
        if not _mcp_session_id:
            s = _mcp_new_session()
            if s.get("error"):
                return {"_mcpError": s["error"]}
        sid = _mcp_session_id
    body = {"jsonrpc": "2.0", "id": 7, "method": method}
    if params is not None:
        body["params"] = params
    r = _mcp_post(body, sid, timeout=timeout)
    # a dropped/rotated session shows up as 400 Missing/Invalid session; re-init once
    if r.get("httpError") in (400, 404) and "ession" in str(r.get("body", "")):
        with _mcp_lock:
            _mcp_session_id = None
            s = _mcp_new_session()
            if s.get("error"):
                return {"_mcpError": s["error"]}
            sid = _mcp_session_id
        r = _mcp_post(body, sid, timeout=timeout)
    if r.get("error") or r.get("httpError"):
        return {"_mcpError": r.get("error") or ("HTTP %s: %s" % (r.get("httpError"), r.get("body", "")[:200]))}
    try:
        return json.loads(r.get("body") or "{}")
    except Exception:
        return {"_mcpError": "unparseable response", "raw": (r.get("body") or "")[:400]}


_MCP_OFF_STATIC = (
    "Fusion's MCP server is not reachable on 127.0.0.1:27182. It is OFF by default. The bridge "
    "can turn it on FOR the user: call fusion_mcp_enable, which opens Preferences, expands "
    "General, selects API, ticks 'Fusion MCP Server (runs locally on this device)' and clicks "
    "Apply/OK. It needs Fusion running and briefly uses the foreground (announced with an "
    "on-screen caption). Manual path if you prefer: profile icon (top right) -> Preferences -> "
    "General -> API -> tick the box. NOTE the setting does NOT survive if Fusion is killed before "
    "Apply is clicked."
)


def _mcp_off_hint() -> str:
    """The MCP-unreachable hint, made state-aware: never tell the AI to go enable MCP when APS
    is signed in and could serve the request RIGHT NOW with zero setup."""
    r = _search_router()
    lead = ""
    if r.get("apsReady"):
        lead = ("NOTE FIRST: APS is signed in, so for FILE SEARCH you do not need MCP at all - "
                "fusion_aps_search works right now. Only enable MCP if you need its other surface "
                "(script text-to-CAD, screenshots, electronics object-model reads). ")
    elif r.get("apsConfigured"):
        lead = ("NOTE: for FILE SEARCH, APS is configured and only needs fusion_aps_signin "
                "(silent on a warm SSO session) - that may be less disruptive than enabling MCP. ")
    return lead + _MCP_OFF_STATIC


def _handle_mcp_status(fusion_info: dict, args: dict) -> dict:
    """Is Autodesk's Fusion MCP server up, and what does it expose?"""
    s = _mcp_new_session()
    if s.get("error"):
        return {"success": True, "enabled": False, "url": _MCP_URL, "error": s.get("error"),
                "_hint": _mcp_off_hint(), "statusVerb": "fusion_mcp_status"}
    tools = []
    tl = _mcp_rpc("tools/list")
    if not tl.get("_mcpError"):
        tools = [{"name": t.get("name"),
                  "description": (t.get("description") or "").split("\n")[0][:160]}
                 for t in ((tl.get("result") or {}).get("tools") or [])]
    return {"success": True, "enabled": True, "url": _MCP_URL,
            "sessionId": s.get("sessionId"), "serverInfo": s.get("serverInfo"),
            "toolCount": len(tools), "tools": tools,
            "_hint": ("Autodesk's Fusion MCP server is LIVE. Its tools are Autodesk's own, not this "
                      "bridge's: call them with fusion_mcp_call {tool, arguments}. Use "
                      "fusion_mcp_tools for full input schemas and fusion_mcp_resources for the "
                      "Electronics entity schemas. These COMPLEMENT the fusion_* verbs: prefer a "
                      "native verb when one exists (it is tested and returns richer hints), and "
                      "reach for MCP for Autodesk's text-to-CAD surface."),
            "statusVerb": "fusion_mcp_status"}


def _handle_mcp_tools(fusion_info: dict, args: dict) -> dict:
    """Full tool list WITH input schemas."""
    tl = _mcp_rpc("tools/list")
    if tl.get("_mcpError"):
        return {"success": False, "enabled": False, "error": tl["_mcpError"], "_hint": _mcp_off_hint()}
    tools = (tl.get("result") or {}).get("tools") or []
    return {"success": True, "count": len(tools), "tools": tools,
            "_hint": "Call one with fusion_mcp_call {\"tool\":\"<name>\",\"arguments\":{...}}.",
            "statusVerb": "fusion_mcp_status"}


def _handle_mcp_call(fusion_info: dict, args: dict) -> dict:
    """Call ANY tool on Autodesk's Fusion MCP server."""
    tool = (args or {}).get("tool")
    if not tool:
        return {"success": False, "error": "Pass {tool, arguments}.",
                "_hint": "fusion_mcp_tools lists the available tools and their input schemas."}
    r = _mcp_rpc("tools/call", {"name": tool, "arguments": (args or {}).get("arguments") or {}},
                 timeout=int((args or {}).get("timeout") or 120))
    if r.get("_mcpError"):
        return {"success": False, "error": r["_mcpError"], "_hint": _mcp_off_hint()}
    res = r.get("result") or {}
    return {"success": not res.get("isError", False), "tool": tool, "result": res,
            "_hint": ("Autodesk MCP tool result. Content is usually a list of {type,text} blocks. "
                      "If it complains about no active document, open one first (fusion_aps_open) "
                      "- MCP acts on the ACTIVE Fusion document."),
            "statusVerb": "fusion_mcp_status"}


def _handle_mcp_resources(fusion_info: dict, args: dict) -> dict:
    """List, or read, Autodesk's MCP resources (the Electronics entity schemas)."""
    uri = (args or {}).get("uri")
    if uri:
        r = _mcp_rpc("resources/read", {"uri": uri})
        if r.get("_mcpError"):
            return {"success": False, "error": r["_mcpError"], "_hint": _mcp_off_hint()}
        return {"success": True, "uri": uri, "result": r.get("result"),
                "statusVerb": "fusion_mcp_status"}
    r = _mcp_rpc("resources/list")
    if r.get("_mcpError"):
        return {"success": False, "error": r["_mcpError"], "_hint": _mcp_off_hint()}
    res = (r.get("result") or {}).get("resources") or []
    return {"success": True, "count": len(res),
            "resources": [x.get("uri") for x in res],
            "_hint": "Read one with fusion_mcp_resources {\"uri\":\"resource://...\"}.",
            "statusVerb": "fusion_mcp_status"}


def _handle_mcp_enable(fusion_info: dict, args: dict) -> dict:
    """Turn Autodesk's MCP server ON by driving Fusion's Preferences dialog.

    Autodesk exposes NO API for this toggle (apiPreferences has debuggingPort and
    isDeveloperToolsEnabled but nothing for MCP), so the UI is the only route. Proven live
    2026-07-23. The tree children under General are rendered lazily by Qt and are NOT in the
    UIA tree, so the section click is image-space and DOES take the foreground - announced.
    """
    import time as _t
    if not _unwrap(_handle_fusion_readiness({}, {})).get("running"):
        return {"success": False, "error": "Fusion is not running.",
                "_hint": "fusion_start first, then fusion_mcp_enable."}
    already = _mcp_new_session()
    if not already.get("error"):
        return {"success": True, "enabled": True, "alreadyOn": True,
                "_hint": "Already enabled; nothing to do. fusion_mcp_status shows the tools."}

    _announce_foreground("turning on Fusion's MCP server in preferences")
    steps = []
    r = _unwrap(_ad_call("fusion_execute_text_command", {"command": "Commands.Start PreferencesCommand"}, timeout=45))
    _t.sleep(3)
    dlg = None
    for w in (_unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []):
        if str(w.get("title", "")).strip() == "Preferences":
            dlg = w.get("hwnd")
    if not dlg:
        return {"success": False, "error": "Preferences dialog did not open.",
                "steps": steps, "_hint": _mcp_off_hint()}
    steps.append("opened Preferences")

    def shot():
        s = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(dlg)}, timeout=45))
        cm = s.get("coordMap") or {}
        img = cm.get("image") or {}
        return cm.get("shotId"), (img.get("w") or 1400), (img.get("h") or 836)

    sid_, W, H = shot()
    if not sid_:
        return {"success": False, "error": "could not capture Preferences", "steps": steps}
    # fractional coords, measured on the real dialog (1400x836): expand arrow, then API row
    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),
                               "x": int(W * 0.029), "y": int(H * 0.092)}, timeout=30)   # expand General
    steps.append("expanded General")
    _t.sleep(2)
    sid_, W, H = shot()
    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),
                               "x": int(W * 0.071), "y": int(H * 0.135)}, timeout=30)   # API row
    steps.append("selected API")
    _t.sleep(2)
    sid_, W, H = shot()
    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),
                               "x": int(W * 0.493), "y": int(H * 0.569)}, timeout=30)   # the checkbox
    steps.append("ticked 'Fusion MCP Server'")
    _t.sleep(1)
    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),
                               "x": int(W * 0.839), "y": int(H * 0.948)}, timeout=30)   # Apply
    _t.sleep(2)
    _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),
                               "x": int(W * 0.894), "y": int(H * 0.948)}, timeout=30)   # OK
    steps.append("clicked Apply then OK")
    _t.sleep(4)

    s = _mcp_new_session()
    ok = not s.get("error")
    return {"success": ok, "enabled": ok, "steps": steps,
            "sessionId": s.get("sessionId"), "serverInfo": s.get("serverInfo"),
            "narrate": ("Fusion's MCP server is on. I enabled it for you in Preferences."
                        if ok else "I drove the Preferences dialog but the MCP port is still closed."),
            "_hint": ("MCP server ENABLED - fusion_mcp_status lists its tools. Tell the user it is "
                      "handled; they do not need to touch Preferences."
                      if ok else
                      "The toggle did not take. Ask the user to set it manually: profile icon (top "
                      "right) -> Preferences -> General -> API -> tick 'Fusion MCP Server'. Do NOT "
                      "restart or kill Fusion before they click Apply, that discards the setting."),
            "statusVerb": "fusion_mcp_status"}


COMMAND_HANDLERS["mcp_status"] = _handle_mcp_status
COMMAND_HANDLERS["mcp_tools"] = _handle_mcp_tools
COMMAND_HANDLERS["mcp_call"] = _handle_mcp_call
COMMAND_HANDLERS["mcp_resources"] = _handle_mcp_resources
COMMAND_HANDLERS["mcp_enable"] = _handle_mcp_enable


# ── FUSION PREFERENCES, DRIVEN THROUGH THE UI (John, 2026-07-23) ─────────────────────────────
# Fusion's Python API exposes only a THIN slice of preferences (generalPreferences: theme, orbit,
# units; apiPreferences: debuggingPort, isDeveloperToolsEnabled). Everything else - including the
# Fusion MCP Server toggle - is UI-only.
#
# But the Preferences dialog IS reachable programmatically:
#   Commands.Start PreferencesCommand   opens it (no menu hunting, no profile-icon click)
# and its LEFT-HAND SECTION TREE is exposed to UIA, so sections can be found by name.
#
# The catch, learned live: the CHILD sections under General (API, Design, Manufacture,
# Electronics, Render, Drawing, Simulation) are rendered LAZILY by Qt and never appear in the
# UIA tree, even after desktop_ui_expand reports success. So navigating into a child section
# needs an image-space click, which takes the foreground and is therefore announced with a
# caption. Everything up to that point is background.
#
# ⛔ The setting is DISCARDED unless Apply/OK is clicked. Killing or restarting Fusion with the
# dialog still open loses it (that is exactly why an earlier MCP enable silently reverted).
_PREF_SECTIONS = {
    # section -> (parent-to-expand or None, fractional x, fractional y) on the 1400x836 dialog
    "general":     (None,      0.071, 0.092),
    "api":         ("general", 0.071, 0.135),
    "design":      ("general", 0.071, 0.179),
    "manufacture": ("general", 0.071, 0.222),
    "electronics": ("general", 0.071, 0.265),
    "render":      ("general", 0.071, 0.310),
    "drawing":     ("general", 0.071, 0.353),
    "material":    (None,      0.071, 0.483),
    "graphics":    (None,      0.071, 0.527),
    "network":     (None,      0.071, 0.570),
    "preview features": (None, 0.071, 0.744),
}


def _prefs_dialog_hwnd():
    for w in (_unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []):
        if str(w.get("title", "")).strip() == "Preferences":
            return w.get("hwnd")
    return None


def _prefs_shot(hwnd):
    s = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45))
    cm = s.get("coordMap") or {}
    img = cm.get("image") or {}
    return (cm.get("shotId"), img.get("w") or 1400, img.get("h") or 836,
            s.get("localSafePath"))


def _handle_prefs_open(fusion_info: dict, args: dict) -> dict:
    """Open Fusion Preferences, optionally navigate to a section, and hand back a screenshot the
    AI can click in. This is how you reach ANY preference, not just the API-exposed few."""
    import time as _t
    if not _unwrap(_handle_fusion_readiness({}, {})).get("running"):
        return {"success": False, "error": "Fusion is not running.",
                "_hint": "fusion_start first."}
    section = str((args or {}).get("section") or "").strip().lower()
    steps = []
    hwnd = _prefs_dialog_hwnd()
    if not hwnd:
        _announce_foreground("opening Fusion preferences")
        _ad_call("fusion_execute_text_command",
                 {"command": "Commands.Start PreferencesCommand"}, timeout=45)
        _t.sleep(3)
        hwnd = _prefs_dialog_hwnd()
        steps.append("opened Preferences")
    if not hwnd:
        return {"success": False, "error": "Preferences dialog did not open.", "steps": steps}

    if section:
        spec = _PREF_SECTIONS.get(section)
        if not spec:
            return {"success": False, "error": "Unknown section %r." % section,
                    "knownSections": sorted(_PREF_SECTIONS),
                    "_hint": ("Pass one of knownSections, or omit `section` and click the returned "
                              "shotId yourself with desktop_click {space:'image'}.")}
        parent, fx, fy = spec
        sid_, W, H, _p = _prefs_shot(hwnd)
        if parent:
            pspec = _PREF_SECTIONS[parent]
            # click the expand arrow, left of the parent label
            _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),
                                       "x": int(W * 0.029), "y": int(H * pspec[2])}, timeout=30)
            steps.append("expanded %s" % parent)
            _t.sleep(2)
            sid_, W, H, _p = _prefs_shot(hwnd)
        _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),
                                   "x": int(W * fx), "y": int(H * fy)}, timeout=30)
        steps.append("selected %s" % section)
        _t.sleep(2)

    sid_, W, H, path = _prefs_shot(hwnd)
    return {"success": True, "hwnd": hwnd, "section": section or "general",
            "shotId": sid_, "imageWidth": W, "imageHeight": H, "screenshot": path,
            "steps": steps, "knownSections": sorted(_PREF_SECTIONS),
            "_hint": ("Preferences is OPEN and showing this section. LOOK at the screenshot, then "
                      "click any control with desktop_click {space:'image', shotId, x, y, hwnd}. "
                      "⛔ Nothing is saved until you call fusion_prefs_close {save:true} (Apply+OK) "
                      "- killing or restarting Fusion first DISCARDS the change. Child sections "
                      "under General are lazily rendered and absent from the UIA tree, which is why "
                      "this verb hands you an image to click rather than control names."),
            "statusVerb": "fusion_get_preferences"}


def _handle_prefs_close(fusion_info: dict, args: dict) -> dict:
    """Apply+OK (save:true, default) or Cancel the Preferences dialog."""
    import time as _t
    save = (args or {}).get("save", True)
    hwnd = _prefs_dialog_hwnd()
    if not hwnd:
        return {"success": True, "closed": False,
                "_hint": "Preferences was not open; nothing to do."}
    sid_, W, H, _p = _prefs_shot(hwnd)
    if save:
        _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),
                                   "x": int(W * 0.839), "y": int(H * 0.948)}, timeout=30)  # Apply
        _t.sleep(2)
        _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),
                                   "x": int(W * 0.894), "y": int(H * 0.948)}, timeout=30)  # OK
    else:
        _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),
                                   "x": int(W * 0.951), "y": int(H * 0.948)}, timeout=30)  # Cancel
    _t.sleep(2)
    return {"success": True, "closed": _prefs_dialog_hwnd() is None, "saved": bool(save),
            "narrate": ("Saved your Fusion preferences." if save else "Closed preferences without saving."),
            "_hint": ("Applied and closed. Some settings (the MCP server is one) only take effect "
                      "once saved this way." if save else "Cancelled; nothing was changed."),
            "statusVerb": "fusion_get_preferences"}


COMMAND_HANDLERS["prefs_open"] = _handle_prefs_open
COMMAND_HANDLERS["prefs_close"] = _handle_prefs_close


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():
    """Find the main Fusion window via desktop_list_windows. Returns the whole entry (it already
    carries `rect`, so we get geometry in the SAME call - there is no desktop_window_info verb).
    Returns {} when not found."""
    wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []
    cand = {}
    for w in wins:
        t = str(w.get("title", ""))
        tl = t.lower()
        if "autodesk fusion" in tl or tl.startswith("signing in"):
            if "signing in" in tl or "welcome" in tl:   # prefer the sign-in/welcome window
                return w
            cand = cand or w
    return cand


def _fusion_window_hwnd():
    """Just the hwnd of the main Fusion window (int|None)."""
    return (_fusion_window() or {}).get("hwnd")


# ── NEVER STEAL THE USER'S FOREGROUND (John, 2026-07-22) ─────────────────────────────────────
# "try to NEVER bring windows to the foreground cuz its disruptive."
# desktop_click is SendInput and FOREGROUNDS the window. desktop_ui_click/desktop_ui_set are UIA
# Invoke/SetValue: programmatic, NO focus steal, NO cursor move, and Chromium exposes its a11y
# tree so page buttons/fields ARE reachable by accessible name. So: ALWAYS try background UIA
# first and only fall back to a foreground click for things with no UIA node (Fusion's Sign In
# button is a webview with none - that is the one documented exception).
# ── SIGN-IN WINDOW JANITOR (John, 2026-07-22) ────────────────────────────────────────────────
# "if you open a browser window for sign in, you must run a loop on a schedule to know when to
# close it so you never leave the user's desktop messy."
# We open browser windows to complete OAuth. Those windows are OURS and must not be left as
# litter once they have served their purpose. Every window we open is tracked here, and a
# background loop closes it as soon as the sign-in it belongs to is DONE (or it is clearly dead
# or too old). Never touches a window the user opened.
_signin_windows = []          # [{sessionId, profile, kind, openedAt}]
_signin_windows_lock = threading.Lock()
_JANITOR_MAX_AGE = 900
_last_update_sweep = 0.0        # 15 min: an OAuth window older than this is dead weight either way


def _track_signin_window(session_id, profile, kind="fusion"):
    if not session_id:
        return
    with _signin_windows_lock:
        _signin_windows.append({"sessionId": session_id, "profile": profile,
                                "kind": kind, "openedAt": _t2.time()})


def _close_tracked_window(w) -> bool:
    """Close one window WE opened. nbrowser_close_window only closes our own sessions."""
    r = _ad_call("nbrowser_close_window",
                 {"sessionId": w["sessionId"], "profile": w.get("profile")}, timeout=30)
    return isinstance(r, dict) and not r.get("_adCallError")


def _signin_goal_met(kind) -> bool:
    """Has the sign-in this window exists for actually completed?"""
    try:
        if kind == "aps":
            return bool(_aps_quick_state().get("signedIn"))
        return bool(_unwrap(_handle_fusion_readiness({}, {})).get("ready"))
    except Exception:
        return False


def _signin_janitor_loop():
    """Poll until each tracked sign-in window can be closed, then close it."""
    while True:
        try:
            _t2.sleep(20)
            # Periodic AUTO-UPDATE sweep. Fusion's update nag only enters the UIA tree while its
            # Job Status panel is open, so a single check at readiness time misses most of them.
            # Sweeping on a schedule catches the nag whenever Fusion actually shows it, and the
            # click is background so the user never sees any of it.
            global _last_update_sweep
            if _auto_update_enabled() and (_t2.time() - _last_update_sweep) > 300:
                _last_update_sweep = _t2.time()
                try:
                    if _unwrap(_handle_fusion_readiness({}, {})).get("running"):
                        _apply_fusion_update_silently()
                except Exception:
                    pass
            with _signin_windows_lock:
                pending = list(_signin_windows)
            if not pending:
                continue
            done_goals = {}
            for w in pending:
                kind = w.get("kind", "fusion")
                if kind not in done_goals:
                    done_goals[kind] = _signin_goal_met(kind)
                aged = (_t2.time() - w["openedAt"]) > _JANITOR_MAX_AGE
                if done_goals[kind] or aged:
                    if _close_tracked_window(w):
                        print("[Fusion Bridge] janitor: closed %s sign-in window %s (%s)"
                              % (kind, w["sessionId"], "signed in" if done_goals[kind] else "expired"))
                    with _signin_windows_lock:
                        if w in _signin_windows:
                            _signin_windows.remove(w)
        except Exception:
            continue


def _start_signin_janitor():
    t = threading.Thread(target=_signin_janitor_loop, daemon=True, name="signin-janitor")
    t.start()
    return t


def _announce_foreground(reason: str) -> bool:
    """ALWAYS tell the user WHY, right before we steal their foreground.

    John, 2026-07-22: "anytime you bring something to the foreground, you should show an ad notify
    for 2 seconds telling the user why... or an ad caption." A caption is the gentler of the two
    (no toast stack, auto-clears), so that is what we use. Best-effort; never blocks the action.
    """
    try:
        _ad_call("desktop_caption",
                 {"text": "Adom: %s" % reason, "id": "fusion-bridge-foreground",
                  "expiresInMs": 2500}, timeout=15)
        return True
    except Exception:
        return False


def _click_background_first(hwnd, name=None, shot_id=None, x=None, y=None, reason=None) -> dict:
    """Click preferring the BACKGROUND UIA path (no focus steal, no cursor move). Only falls back
    to SendInput - which DOES foreground - when there is no UIA node, and in that case announces
    WHY to the user first. Returns {clicked, via, why, announced}."""
    if name:
        r = _unwrap(_ad_call("desktop_ui_click", {"hwnd": int(hwnd), "name": name}, timeout=30))
        if r.get("ok") or r.get("clicked") or r.get("invoked"):
            return {"clicked": True, "via": "uia_background", "announced": False,
                    "why": "UIA Invoke on %r (background, no focus steal)" % name}
    if shot_id is not None and x is not None and y is not None:
        announced = _announce_foreground(reason or "clicking %s for you" % (name or "a button"))
        _ad_call("desktop_click", {"space": "image", "shotId": shot_id,
                                   "x": int(x), "y": int(y), "hwnd": int(hwnd)}, timeout=30)
        return {"clicked": True, "via": "foreground_click", "announced": announced,
                "why": "no UIA node; used SendInput (DOES foreground - user was told why)"}
    return {"clicked": False, "via": None, "announced": False,
            "why": "no UIA node and no shot coords given"}


# ── HUMAN WALLS: notify the user, and tell the AI it can drive it ────────────────────────────
# John, 2026-07-22: "you should be sending me ad notifies when you need me to do something" AND
# "suggest to the ai via hints that you can drive the thing on your own cuz the adom user wants
# you to do everything automatically if you can". So on every wall we do BOTH: toast the human,
# and hand the AI an exact, mechanical way to clear it without them.
def _autodesk_window_hwnds() -> set:
    """hwnds of every Autodesk-ish BROWSER window right now (used to tell NEW from STALE)."""
    wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []
    out = set()
    for w in wins:
        tl = str(w.get("title", "")).lower()
        if "autodesk" in tl and "fusion" not in tl:
            out.add(w.get("hwnd"))
    return out


def _detect_signin_wall(ignore_hwnds=None) -> dict:
    """Classify what the Autodesk sign-in is waiting on, from BROWSER WINDOW TITLES.
    Title-based on purpose: cheap, needs no OCR, and never touches/foregrounds the window.

    ignore_hwnds = windows that already existed BEFORE we opened this sign-in. Without it a dead
    'Sign-in request expired' / stale '2-step verification' tab from an earlier attempt gets
    reported as the live wall and we toast the user about nothing (seen live 2026-07-22).
    """
    ignore = set(ignore_hwnds or ())
    wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []
    best = {}
    for w in wins:
        if w.get("hwnd") in ignore:
            continue
        t = str(w.get("title", "")); tl = t.lower()
        if "2-step verification" in tl or "confirm sign-in" in tl:
            return {"wall": "twofactor_email_code", "hwnd": w.get("hwnd"), "title": t}
        if "identity manager" in tl:
            best = {"wall": "protocol_handoff", "hwnd": w.get("hwnd"), "title": t}
        elif "sign in - autodesk" in tl and not best:
            best = {"wall": "credentials", "hwnd": w.get("hwnd"), "title": t}
    return best


_WALL_COPY = {
    "twofactor_email_code": ("Autodesk needs your 6-digit code",
                             "Autodesk emailed a verification code to finish signing Fusion in. "
                             "Enter it in the Chrome window, or tell your AI to fetch it for you."),
    "credentials": ("Autodesk sign-in needs you",
                    "The Autodesk sign-in is asking for credentials. I never type your password "
                    "or 2FA - please complete it in the Chrome window."),
    "protocol_handoff": ("Allow Fusion to finish signing in",
                         "Chrome is asking permission to open Autodesk Identity Manager. Click "
                         "Open to hand the login back to Fusion."),
}


def _notify_signin_wall(wall: str) -> dict:
    """Toast the user's real desktop about a human wall. Best-effort, never raises."""
    title, body = _WALL_COPY.get(wall, ("Fusion sign-in needs you", "Please check the sign-in window."))
    return _cli_notify_all(title, body, "warning")


def _wall_hint(wall: str, notified: bool) -> str:
    """The AI-facing instruction for a wall. Per John: do as much as possible IN CODE, and use
    HINTS (not skills) to make the calling AI act correctly."""
    common = ("I ALREADY TOASTED THE USER about this (notifyDelivered=%s) - you do not need to ask "
              "me to, and do not nag them again. " % notified)
    if wall == "twofactor_email_code":
        return (common +
                "WALL: Autodesk emailed a 6-DIGIT CODE. THE USER WANTS YOU TO DO THIS FOR THEM if "
                "you can, so TRY THIS FIRST, do not just hand it back to them: if `adom-google` is "
                "available in YOUR container, read the code out of their Gmail and submit it - "
                "  adom-google gmail search 'from:autodesk verification code' --limit 1 \n"
                "then pull the 6 digits and call  fusion_signin_2fa {\"code\":\"123456\"}  which "
                "types it in the BACKGROUND (no foreground steal) and submits. If `adom-google` is "
                "NOT installed, TELL THE USER that installing adom-google would let you finish "
                "logins like this automatically next time, then let them type the code. NEVER ask "
                "them for the code if you can read it yourself.")
    if wall == "protocol_handoff":
        return (common +
                "WALL: Chrome's native 'Open Autodesk Identity Manager?' dialog. Try to clear it "
                "YOURSELF in the background: desktop_find_control on that dialog then "
                "desktop_ui_click {name:'Open'} (UIA, no focus steal). Only if UIA finds no node, "
                "fall back to a foregrounded desktop_click. Then poll fusion_readiness.")
    return (common +
            "WALL: the sign-in wants credentials. NEVER type their password or 2FA. If the browser "
            "autofills, you may submit it. Otherwise let the user finish in the window I opened.")


def _unwrap(r):
    """AD verb responses come back either flat or nested under `data` (the CLI puts the real
    payload in output.data). Always look through both - forgetting this silently broke the
    server-side Sign In click, because coordMap/shotId live one level down."""
    if not isinstance(r, dict):
        return {}
    inner = r.get("data")
    if isinstance(inner, dict) and inner:
        return inner
    return r


def _fusion_click_signin() -> dict:
    """Click Fusion's 'Welcome to Fusion' -> Sign In button SERVER-SIDE, ALWAYS, on the user's
    behalf (John, 2026-07-22: "you should ALWAYS just click sign in on behalf of the adom
    users"). The button is a webview with no UIA node, so this is an image-space click.

    Runs on-box, so it costs no remote round-trip against Fusion's ~2-min OAuth expiry.
    Returns {clicked: bool, why: str} - never raises.
    """
    import time as _t
    win = _fusion_window()
    hwnd = win.get("hwnd") or _unwrap(_handle_fusion_readiness({}, {})).get("hwnd")
    if not hwnd:
        return {"clicked": False, "why": "could not find the Fusion window"}

    # A MINIMIZED Fusion captures as a ~237x39 title-bar sliver, so the click lands on nothing.
    # Restore it first (seen live: rect was at -32000,-32000, the Windows minimized position).
    rect = win.get("rect") or {}
    if (rect.get("left") is not None and rect["left"] <= -30000) or \
       (0 < (rect.get("width") or 0) < 600) or (0 < (rect.get("height") or 0) < 400):
        _ad_call("desktop_set_window_state", {"hwnd": int(hwnd), "state": "restore"}, timeout=30)
        _t.sleep(1.5)

    shot = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45))
    cm = shot.get("coordMap") or {}
    sid = cm.get("shotId") or shot.get("shotId")
    if not sid:
        return {"clicked": False, "why": "screenshot returned no shotId"}
    img = cm.get("image") or {}
    w = shot.get("width") or img.get("w") or img.get("width") or cm.get("imageWidth") or 1400
    h = shot.get("height") or img.get("h") or img.get("height") or cm.get("imageHeight") or 860
    # Background UIA first (no focus steal). Fusion's Sign In is a WEBVIEW button with no UIA
    # node, so this almost always falls through to the image-space click - that is the documented
    # exception to the never-foreground rule, not a licence to foreground elsewhere.
    r = _click_background_first(hwnd, name="Sign In", shot_id=sid,
                                x=int(w * 0.5), y=int(h * 0.57),
                                reason="signing Fusion in for you (clicking Sign In)")
    return {"clicked": r.get("clicked"),
            "why": "%s (image %dx%d)" % (r.get("why"), w, h),
            "via": r.get("via")}


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)


_SSO_BUTTONS = {"google": "Continue with Google", "apple": "Continue with Apple",
                "microsoft": "Continue with Microsoft", "facebook": "Continue with Facebook"}


def _drive_sso_signin(profile: str, tab_id, email: str, provider: str = "google") -> dict:
    """Finish the Autodesk sign-in over CDP using SSO - NO password, NO 2FA, NO foreground.

    PROVEN LIVE (John, 2026-07-22) after password/2FA attempts kept stalling. If the browser
    PROFILE is already signed into the identity provider (a work Google account is the common
    case), "Continue with Google" -> pick the account -> "Open Product" completes the whole login
    silently and hands the token back to Fusion via the autodesk:// callback.

    TWO THINGS MATTER:
      1. It must be a FRESH flow. Resuming a flowId that already advanced past the provider
         choice lands you on the PASSWORD screen with no way back - that dead end cost us an hour.
      2. Click the provider BEFORE typing any email; entering an email commits to the password path.
    """
    steps = []

    def ev(expr):
        r = _unwrap(_ad_call("nbrowser_eval", {"profile": profile, "tabId": tab_id,
                                               "expression": expr}, timeout=45))
        return r.get("result") or r.get("value")

    def click(text=None, selector=None):
        a = {"profile": profile, "tabId": tab_id}
        if text: a["text"] = text
        if selector: a["selector"] = selector
        r = _unwrap(_ad_call("nbrowser_click", a, timeout=45))
        return bool(r.get("ok") or r.get("success") or r.get("changed"))

    btn = _SSO_BUTTONS.get((provider or "google").lower(), _SSO_BUTTONS["google"])
    if not click(text=btn):
        return {"done": False, "steps": steps,
                "why": "%r not on the page - this is probably NOT a fresh flow (a resumed flowId "
                       "goes straight to the password screen)." % btn}
    steps.append("clicked %r" % btn)
    _t2.sleep(7)

    # provider account chooser - pick by the exact email
    if email and click(text=email):
        steps.append("picked the %s account" % email)
        _t2.sleep(8)

    state = str(ev("JSON.stringify({u:location.href.slice(0,120),b:document.body.innerText.slice(0,200)})") or "")
    if "You're signed in" in state or "signed in" in state.lower():
        steps.append("provider returned 'You're signed in'")
        # hand the token back to Fusion (autodesk:// protocol callback)
        if click(text="Open Product"):
            steps.append("clicked 'Open Product' to hand the token to Fusion")
        return {"done": True, "steps": steps, "state": state[:200]}
    return {"done": False, "steps": steps, "state": state[:200],
            "why": "did not reach the signed-in page; a provider password/2FA may be required"}


def _orchestrate_signin_2fa(args: dict) -> dict:
    """Submit Autodesk's emailed 6-digit verification code, IN THE BACKGROUND.

    Exists so the AI can finish a login end-to-end for the user (read the code from Gmail with
    adom-google, then call this) instead of parking them on a 2FA screen. Uses UIA SetValue +
    Invoke, so it never steals focus or moves the cursor.
    """
    code = str(args.get("code") or "").strip()
    digits = "".join(ch for ch in code if ch.isdigit())
    if len(digits) < 4:
        return {"success": False, "errorCode": "bad_code",
                "error": "Pass the emailed code, e.g. fusion_signin_2fa {\"code\":\"123456\"}.",
                "_hint": ("Read it from the user's Gmail if you can: "
                          "adom-google gmail search 'from:autodesk verification code' --limit 1 "
                          "-> take the 6 digits -> call this verb again. If adom-google is not "
                          "installed, tell the user it would let you automate this."),
                "statusVerb": "fusion_signin_2fa"}

    wall = _detect_signin_wall()
    hwnd = args.get("hwnd") or wall.get("hwnd")
    if not hwnd:
        return {"success": False, "errorCode": "no_2fa_window",
                "error": "No Autodesk 2-step-verification window found.",
                "_hint": ("Nothing is waiting on a code right now. Check fusion_readiness - if "
                          "needsSignin is still true, run fusion_signin again to restart the flow."),
                "statusVerb": "fusion_readiness"}

    # Find the code field WITHOUT touching the foreground. Autodesk renders either one input or a
    # segmented set, so try the obvious accessible names, then fall back to the first edit control.
    found = _unwrap(_ad_call("desktop_find_control",
                             {"hwnd": int(hwnd), "role": "edit"}, timeout=40))
    ctrls = found.get("controls") or found.get("matches") or []
    steps = []
    filled = False
    if len(ctrls) >= len(digits) and len(ctrls) >= 4:
        # segmented: one box per digit
        for i, ch in enumerate(digits[:len(ctrls)]):
            _ad_call("desktop_ui_set", {"hwnd": int(hwnd),
                                        "automationId": ctrls[i].get("automationId"),
                                        "name": ctrls[i].get("name"), "value": ch}, timeout=20)
        filled = True
        steps.append("typed %d digits into %d segmented boxes (background UIA)" % (len(digits), len(ctrls)))
    elif ctrls:
        c = ctrls[0]
        r = _unwrap(_ad_call("desktop_ui_set", {"hwnd": int(hwnd),
                                                "automationId": c.get("automationId"),
                                                "name": c.get("name"), "value": digits}, timeout=20))
        filled = bool(r.get("ok") or r.get("set") or r is not None)
        steps.append("typed the code into %r (background UIA)" % (c.get("name") or c.get("automationId")))

    if not filled:
        return {"success": False, "errorCode": "code_field_not_found",
                "error": "Could not find the code field via UIA.",
                "controlsSeen": ctrls[:8],
                "_hint": ("The code input was not in the UIA tree (shadow DOM). Run "
                          "desktop_find_control {hwnd:%s} to see what IS exposed. Last resort is a "
                          "FOREGROUNDED desktop_click on the field then desktop_type - avoid that "
                          "if you can, foregrounding is disruptive to the user." % hwnd),
                "statusVerb": "fusion_signin_2fa"}

    sub = _click_background_first(hwnd, name="Next")
    if not sub.get("clicked"):
        sub = _click_background_first(hwnd, name="Verify")
    steps.append("submitted via %s" % (sub.get("via") or "no submit control found"))

    return {"success": True, "submitted": True, "steps": steps,
            "narrate": "I read the code in and submitted it for you, without touching your screen.",
            "_hint": ("Code submitted in the BACKGROUND. Now poll fusion_readiness until ready:true. "
                      "If a Chrome 'Open Autodesk Identity Manager?' dialog appears, clear it with "
                      "desktop_ui_click {name:'Open'} (background) - fusion_signin reports that wall "
                      "too. If it says the code was wrong, the email may have a NEWER code."),
            "statusVerb": "fusion_readiness"}


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 = []
    # ALWAYS click Sign In for the user unless they explicitly opt out (auto:false). Adom users
    # should never be told "now go click Sign In yourself" - we do it for them and SAY so.
    auto = args.get("auto")
    auto = True if auto is None else bool(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 FOR THE USER, then poll on-box for the URL.
        ck = _fusion_click_signin()
        clicked = ck.get("clicked")
        steps.append("clicked Fusion 'Sign In' for the user" if clicked
                     else "could not click Fusion 'Sign In' (%s)" % ck.get("why"))
        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,
                "narrate": ("I clicked Fusion's Sign In for you, but Fusion has not written its "
                            "sign-in URL to a browser yet. Give it a few seconds."),
                "_hint": ("THIS VERB CLICKS FUSION'S 'Sign In' FOR THE USER - never tell them to go "
                          "click it themselves, and do not click it yourself. It just did (see "
                          "steps[]), but no Fusion OAuth URL has landed in any browser's history "
                          "yet. Simply CALL fusion_signin AGAIN in a few seconds; it will click if "
                          "needed, wait on-box for the URL, and re-open it in the right profile. "
                          "If steps[] says the click FAILED, that is a bridge bug worth reporting - "
                          "the usual causes are a minimized Fusion window (this verb restores it) "
                          "or the sign-in wall not being up yet (check fusion_readiness "
                          "needsSignin:true)."),
                "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)
    # ── Is Fusion ALREADY using the right profile? (John, 2026-07-22: "they both opened in my
    # work profile, so you're being lazy") ──────────────────────────────────────────────────
    # Chrome profile DIRECTORIES are named Default / Profile 1 / Profile 2, and the user's work
    # account is very often just "Default". We capture the authorize URL from a profile DIR, but
    # the target is an EMAIL (chrome:[email protected]). Without mapping email -> dir we cannot tell
    # they are the SAME profile, so we "fixed" a non-problem by re-opening the identical URL in
    # the identical profile - a redundant second sign-in window on the user's screen.
    target_dir = None
    for c in choices:
        if c.get("profile") == target:
            target_dir = c.get("profileDir") or c.get("dir")
            break
    if target_dir and auth.get("sourceProfileDir") and \
       str(target_dir).strip().lower() == str(auth["sourceProfileDir"]).strip().lower():
        steps.append("Fusion already opened the sign-in in %s (profile dir %r) - no re-open needed"
                     % (target, target_dir))
        wall0 = _detect_signin_wall()
        notified0 = bool(_notify_signin_wall(wall0["wall"]).get("delivered")) if wall0.get("wall") else False
        return {"success": True, "stage": "already_right_profile", "done": False, "steps": steps,
                "signinProfile": target, "signinProfileDir": target_dir,
                "signinWhere": tdesc if False else target,
                "signinWall": wall0.get("wall"), "signinWallHwnd": wall0.get("hwnd"),
                "notifyDelivered": notified0,
                "signinWallHint": _wall_hint(wall0["wall"], notified0) if wall0.get("wall") else None,
                "signinAuthUrl": auth["url"],
                "narrate": ("Fusion opened its sign-in in the RIGHT profile already (%s), so I did "
                            "not open a second window." % target),
                "_hint": ("NO re-open was needed - Fusion's OS-default browser IS the user's Autodesk "
                          "profile here (dir %r). Do NOT open another window; that just litters their "
                          "screen with a duplicate sign-in. The existing window is the one to finish. "
                          "%s" % (target_dir,
                                  _wall_hint(wall0["wall"], notified0) if wall0.get("wall")
                                  else "Poll fusion_readiness until ready:true.")),
                "statusVerb": "fusion_readiness"}

    # snapshot the Autodesk windows that already exist, so a STALE expired tab from an earlier
    # attempt is never mistaken for this attempt's wall
    pre_hwnds = _autodesk_window_hwnds()
    _open_args = {"sessionId": sess, "url": auth["url"], "background": True,
                  "profile": target, "thread": "fusion-signin",
                  "purpose": "Fusion Autodesk sign-in (correct profile)"}
    r = _ad_call("nbrowser_open_window", _open_args, 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", ""))))
    open_via = "extension" if opened else None
    if not opened:
        # EXTENSION-FREE fallback: nbrowser_open_window needs the Adom extension installed in
        # that profile. nbrowser_open_os_window does not - it opens the URL in the real profile
        # at the OS level, which is all an OAuth consent needs. This keeps the multi-profile fix
        # working on machines with no extension (same gap as APS issue #12).
        r2 = _ad_call("nbrowser_open_os_window", _open_args, timeout=60)
        if isinstance(r2, dict) and not r2.get("_adCallError"):
            # ok:false just means the hwnd was not resolved in ~8s; the launch usually still fired
            opened = bool(r2.get("ok") or r2.get("hwnd") or "Launch fired" in str(r2.get("_hint", "")))
            if opened:
                open_via = "extension_free"
                r = r2
    steps.append((("re-opened it in %s%s" % (tdesc, " (extension-free)" if open_via == "extension_free" else ""))
                  if opened else ("could not open %s (raw: %s)" % (tdesc, str(r)[:160]))))
    if opened:
        # hand it to the janitor so it is never left as litter on the user's desktop
        _track_signin_window(sess, target, kind="fusion")

    # ── FINISH IT: drive the SSO path ourselves (no password, no 2FA, no foreground) ──────────
    # John, 2026-07-22: the goal is a SIGNED-IN Fusion, not a handed-off checklist. When we own
    # the window (nbrowser_open_window => agent-opened => CDP-drivable) and the profile is already
    # signed into the identity provider, this completes the whole login silently.
    sso = None
    if opened and open_via == "extension" and args.get("driveSso", True):
        tabs = _unwrap(_ad_call("nbrowser_list_tabs", {"profile": target}, timeout=45)).get("tabs") or []
        my_tab = None
        for t in tabs:
            if "autodesk" in str(t.get("url", "")).lower() and t.get("owner") == "agent-opened":
                my_tab = t.get("tabId") or t.get("id")
        if my_tab:
            email = None
            for c in choices:
                if c.get("profile") == target:
                    email = c.get("email")
            sso = _drive_sso_signin(target, my_tab, email, args.get("ssoProvider", "google"))
            steps.extend(sso.get("steps") or [])
            if sso.get("done"):
                for _ in range(6):
                    _t2.sleep(5)
                    if _unwrap(_handle_fusion_readiness({}, {})).get("ready"):
                        steps.append("Fusion reports ready:true - SIGNED IN")
                        # ── ONE LOGIN, BOTH SYSTEMS (John, 2026-07-22) ───────────────────────
                        # "why should the user have to login twice to autodesk to sign in to
                        # fusion and setup aps? why aren't those just driven from 1 login?"
                        # Right now the browser profile has a WARM Autodesk SSO session (we just
                        # used it). APS consent rides that session silently, so do it HERE while
                        # it is warm instead of making the user authenticate a second time.
                        aps_state = _aps_quick_state()
                        if aps_state.get("configured") and not aps_state.get("signedIn"):
                            try:
                                import aps as _apsmod
                                _apsmod.start_signin({"profile": target})
                                steps.append("APS not signed in - started its consent on the SAME "
                                             "warm SSO session (no second login for the user)")
                                for _ in range(5):
                                    _t2.sleep(4)
                                    if _aps_quick_state().get("signedIn"):
                                        steps.append("APS signed in too - one login covered both")
                                        break
                            except Exception as e:
                                steps.append("APS auto-setup skipped (%s)" % str(e)[:80])
                        aps_state = _aps_quick_state()
                        return {"success": True, "stage": "signed_in", "done": True, "steps": steps,
                                "signinProfile": target, "signinWhere": tdesc,
                                "signinOpenedVia": open_via,
                                "aps": aps_state,
                                "narrate": ("Fusion is signed in as %s. No password or 2FA was needed - "
                                            "I used the browser profile's existing SSO session.%s"
                                            % (email or target,
                                               " APS cloud search is set up too, from the same login."
                                               if aps_state.get("signedIn") else "")),
                                "_hint": ("DONE - Fusion is signed in (ready:true). Nothing else to do; "
                                          "do not tell the user to sign in. %s Drive Fusion normally now."
                                          % ("APS is signed in as well, so fusion_aps_search is live."
                                             if aps_state.get("signedIn") else
                                             "APS is NOT signed in - run fusion_aps_signin NOW while the "
                                             "browser SSO session is still warm so it consents silently.")),
                                "statusVerb": "fusion_readiness"}

    # Did the reopened sign-in immediately hit a HUMAN WALL (2FA, credentials, protocol dialog)?
    # If so, toast the user AND tell the AI how to clear it for them.
    wall = _detect_signin_wall(ignore_hwnds=pre_hwnds) if opened else {}
    wall_kind = wall.get("wall")
    notified = False
    if wall_kind:
        notified = bool(_notify_signin_wall(wall_kind).get("delivered"))
        steps.append("hit %s wall; toasted the user (delivered=%s)" % (wall_kind, notified))

    return {"success": True, "stage": "opened" if opened else "open_failed",
            "done": False, "steps": steps, "signinWhere": tdesc, "signinProfile": target,
            "signinOpenedVia": open_via,
            "signinWall": wall_kind, "signinWallHwnd": wall.get("hwnd"),
            "notifyDelivered": notified,
            "signinWallHint": _wall_hint(wall_kind, notified) if wall_kind else None,
            "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 _assert_active_document(expect: str) -> dict | None:
    """Document guard (issue #289): return a `wrong_document` error dict if Fusion's active document
    is not `expect`, else None. A Fusion tab-switch silently retargets `app.activeDocument`, so a
    mutating verb can hit the wrong file and destroy work. Callers pass `expectDocument` to assert
    intent BEFORE any write. Fails OPEN (returns None) when the active doc cannot be read, so the
    guard never wedges a legitimate call - it only blocks a CONFIRMED mismatch."""
    actual = None
    try:
        st = _proxy_to_addin("get_app_state", {}, timeout=8)
        data = st.get("data") if isinstance(st, dict) else None
        if not isinstance(data, dict):
            out = st.get("output") if isinstance(st, dict) else None
            if isinstance(out, str) and out.strip().startswith("{"):
                data = json.loads(out).get("data", {})
        actual = (data or {}).get("activeDocument") or (st.get("activeDocument") if isinstance(st, dict) else None)
    except Exception:
        actual = None
    if actual and actual != expect:
        return {
            "success": False,
            "errorCode": "wrong_document",
            "expected": expect,
            "actual": actual,
            "error": "Active document is '%s', expected '%s'." % (actual, expect),
            "_hint": ("The active document changed - a Fusion tab-switch retargets the active "
                      "document. Re-activate the intended document, or re-issue with expectDocument "
                      "set to the current name (a rename mid-session also changes it)."),
        }
    return None


def dispatch_command(command: str, args: dict, caller_identity: dict = None) -> dict:
    """Dispatch a command to the appropriate handler.

    caller_identity: {'thread','container','reason'} from the X-Adom-Caller-* headers AD stamped on
    this request. Bound to THIS thread (issue #348) so any AD callback we make while carrying out
    the verb is attributed to the AI thread that asked for it. Pass None to leave the binding alone.
    """
    if caller_identity is not None:
        _set_caller_identity(caller_identity)
    # Strip the "fusion_" prefix if present — handlers are registered without it (e.g. "aps_signin"
    # not "fusion_aps_signin"), but the relay passes them with the prefix for CLI clarity. Issue #327.
    if command.startswith("fusion_"):
        command = command[7:]

    # Document guard (issue #289 items 1 AND 5, Oliver): a verb carrying `expectDocument` must not
    # run against a document the caller didn't intend. Originally only mutating verbs were guarded;
    # Oliver's BOM session proved READS need it too - a passive tab switch silently returned a
    # different assembly (68/268 vs 94/424 instances) with nothing signalling the retarget. So the
    # assertion now fires for ANY verb that passes the arg: a caller who states an expectation wants
    # it checked. Asserted BEFORE dispatch, then stripped so handlers never see the key.
    if isinstance(args, dict) and args.get("expectDocument"):
        _guard = _assert_active_document(str(args["expectDocument"]))
        if _guard is not None:
            return _guard
        args = {k: v for k, v in args.items() if k != "expectDocument"}
    # 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:
        # Issue #326: Fusion may be installed under a different Windows user profile than the current
        # one, so file-path detection fails even though Fusion is running. Check if it's actually
        # running (via tasklist) as a reliable fallback. If running, it's installed somewhere.
        fusion_running = _is_fusion_running()
        if not fusion_info.get("installed") and not fusion_running:
            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 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 == "signin_2fa":
        return _orchestrate_signin_2fa(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-bridge-cli 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", {})

        # Who is asking (issue #348). AD stamps these on every request it dispatches to a bridge.
        caller_identity = {
            "thread": self.headers.get("X-Adom-Caller-Thread", ""),
            "container": self.headers.get("X-Adom-Caller-Container", ""),
            "reason": self.headers.get("X-Adom-Caller-Reason", ""),
        }

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

        try:
            result = dispatch_command(command, args, caller_identity=caller_identity)
            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}",
            })
        finally:
            # Unbind before this thread is reused or retired. Without this, a later call made on
            # our OWN behalf could inherit this AI thread's name and misattribute it.
            _set_caller_identity(None)

    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 _prune_bridge_logs(max_mb: int = 8, keep_tail_mb: int = 2) -> dict:
    """LOG JANITOR (John, 2026-07-22: "do you have a janitor to clean up your log?").

    AD captures this bridge's stdout/stderr into ~/.adom/bridge-logs/fusion360.log. Nothing
    rotated it, so it grew unbounded - fine today (it is small), a slow leak on a long-lived box.
    On every bridge start we truncate our OWN logs to the most recent keep_tail_mb once they pass
    max_mb, keeping the tail because that is where a crash traceback lives.

    Only touches files this bridge owns (fusion360*). Never raises.
    """
    import glob
    out = {"checked": 0, "pruned": []}
    try:
        d = os.path.join(os.path.expanduser("~"), ".adom", "bridge-logs")
        for path in glob.glob(os.path.join(d, "fusion360*.log")):
            out["checked"] += 1
            try:
                sz = os.path.getsize(path)
                if sz <= max_mb * 1024 * 1024:
                    continue
                with open(path, "rb") as f:
                    f.seek(-keep_tail_mb * 1024 * 1024, os.SEEK_END)
                    tail = f.read()
                with open(path, "wb") as f:
                    f.write(b"[adom-bridge log janitor] truncated %d MB -> tail %d MB\n"
                            % (sz // (1024 * 1024), keep_tail_mb))
                    f.write(tail)
                out["pruned"].append({"file": os.path.basename(path), "wasMB": sz // (1024 * 1024)})
            except Exception:
                continue
        if out["pruned"]:
            print("[Fusion Bridge] log janitor: %s" % out["pruned"])
    except Exception:
        pass
    return out


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

    _prune_bridge_logs()
    _start_signin_janitor()

    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()


# ── Geometry read-back (issue #289 item 2, Oliver) ───────────────────────────────────────────
# There was no way to ask what actually got BUILT, so every caller hand-wrote body enumeration
# inside a modeling script and parsed it back out. That read-back caught both real modelling bugs
# in Oliver's session and NEITHER was visible in a screenshot (a body at Z -167.6 instead of -83.8
# from a double-applied offset; a handle buried inside the plate it should hang below). This work
# is often matte-black parts on a dark canvas, so pixels are a weak verification channel and an AI
# driving the bridge has no other way to check its own output.
#
# Implemented BRIDGE-side on the existing run_modeling_script surface (the add-in already captures
# a script's `result`), so it needs no add-in redeploy and no Fusion restart.
#
# Units are MILLIMETRES, deliberately. Fusion's internals are centimetres, which is a standing
# source of off-by-ten errors when an AI reads these numbers back.
_INSPECT_BODIES_SCRIPT = r'''
import adsk.core, adsk.fusion

_des = adsk.fusion.Design.cast(app.activeProduct)
_WORLD = __WORLD__
_OCC = __OCC__

def _mm(v):      return round(v * 10.0, 4)      # cm  -> mm
def _mm3(v):     return round(v * 1000.0, 4)    # cm3 -> mm3
def _mm2(v):     return round(v * 100.0, 4)     # cm2 -> mm2

def _describe(body, owner):
    bb = body.boundingBox
    mn, mx = bb.minPoint, bb.maxPoint
    cyl = 0
    try:
        for f in body.faces:
            g = f.geometry
            if g and g.surfaceType == adsk.core.SurfaceTypes.CylinderSurfaceType:
                cyl += 1
    except Exception:
        cyl = -1
    def _nm(o):
        try:    return o.name
        except Exception: return None
    return {
        "name": body.name,
        "owner": owner,
        "bbox": {"x": [_mm(mn.x), _mm(mx.x)],
                 "y": [_mm(mn.y), _mm(mx.y)],
                 "z": [_mm(mn.z), _mm(mx.z)]},
        "sizeMm": [_mm(mx.x - mn.x), _mm(mx.y - mn.y), _mm(mx.z - mn.z)],
        "volumeMm3": _mm3(body.volume),
        "areaMm2": _mm2(body.area),
        "faceCount": body.faces.count,
        "cylindricalFaceCount": cyl,
        "appearance": _nm(body.appearance),
        "material": _nm(body.material),
        "isVisible": body.isVisible,
    }

rows = []
root = _des.rootComponent
for b in root.bRepBodies:
    rows.append(_describe(b, root.name))

if _WORLD:
    # Bodies fetched THROUGH an occurrence are proxies whose boundingBox is already resolved
    # into assembly space, so occurrence placement is verified without any matrix maths here.
    for occ in root.allOccurrences:
        if _OCC and _OCC.lower() not in occ.name.lower():
            continue
        try:
            for b in occ.bRepBodies:
                rows.append(_describe(b, occ.name))
        except Exception:
            pass

result = {"units": "mm", "worldSpace": bool(_WORLD),
          "documentName": app.activeDocument.name if app.activeDocument else None,
          "bodyCount": len(rows), "bodies": rows}
'''


def _handle_inspect_bodies(fusion_info: dict, args: dict) -> dict:
    """fusion_inspect_bodies - what actually got built, in millimetres."""
    world = bool(args.get("worldSpace"))
    occ = args.get("occurrence") or ""
    script = (_INSPECT_BODIES_SCRIPT
              .replace("__WORLD__", "True" if world else "False")
              .replace("__OCC__", repr(str(occ)) if occ else "None"))
    res = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=120)
    if not res.get("success"):
        return res
    data = res.get("data") or {}
    payload = data.get("result")
    if not isinstance(payload, dict):
        return {"success": False, "error": "inspect_bodies returned no structured result",
                "errorCode": "inspect_failed", "data": data,
                "_hint": "The read-back script ran but produced no result dict. Usually means no "
                         "active Design; open a document and retry."}
    n = payload.get("bodyCount", 0)
    payload["success"] = True
    payload["_hint"] = (
        "Bounding boxes, volumes and areas are in MILLIMETRES (Fusion's API is centimetres; "
        "converted here so you do not have to). Check bbox Z against where the part SHOULD sit - "
        "a wrong offset renders perfectly and is invisible in a screenshot. cylindricalFaceCount "
        "is the cheap way to confirm holes were actually cut: a symmetric cut that reaches nothing "
        "fails SILENTLY with no exception, and the count is what surfaces it. "
        + ("Pass worldSpace:true to resolve bodies through their occurrence transforms and verify "
           "assembly placement." if not world else
           "worldSpace:true - occurrence bodies are proxies already resolved into assembly space.")
    ) if n else (
        "No bodies found. If you expected some, the active document may not be the one you built "
        "into (pass expectDocument on mutating verbs to catch that), or the design is empty."
    )
    return payload


COMMAND_HANDLERS["inspect_bodies"] = _handle_inspect_bodies


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