"""Bounded native activity diagnostics; optional original eligible quit. No screen or flight input."""
import argparse
import hashlib
import json
from pathlib import Path
import time
import frida
import ctypes as c
import ctypes.wintypes as w

p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--pid',type=int,required=True)
p.add_argument('--output',type=Path,required=True)
p.add_argument('--quit',action='store_true',help='Invoke only the original eligible MainMenu QuitApplication action once')
p.add_argument('--enter-solo',action='store_true',help='Invoke the original eligible ContinueMenu and SetupPlaySolo actions')
p.add_argument('--seconds',type=int,default=5,choices=range(5,91))
p.add_argument('--thread',type=int,default=0)
p.add_argument('--keep-attached',action='store_true',help='Keep the diagnostic agent resident until this game process exits; avoid unloading live callback code')
a=p.parse_args()
profile=json.loads(Path('artifacts/navigation-research/native-profile-d.json').read_text(encoding='utf-8-sig'))
if hashlib.sha256(Path(profile['image']).read_bytes()).hexdigest()!=profile['sha256']:raise ValueError('Build mismatch')
source=r'''
const PROFILE=__PROFILE__;
const QUIT=__QUIT__,ENTER_SOLO=__SOLO__,EXPECTED_THREAD=__THREAD__;
const game=Process.getModuleByName('EliteDangerous64.exe'),base=game.base;
if(game.path.toLowerCase()!==PROFILE.image.toLowerCase())throw new Error('Unexpected game');
if(base.add(PROFILE.menuUpdate).readU8()===0xE9)throw new Error('Another hook already owns this diagnostic entry');
let calls=0,last='',latest=0,quitCalled=false,soloStage=0,selections=0;
const select=new NativeFunction(base.add(PROFILE.menuAction),'void',['pointer','pointer','int'],{scheduling:'exclusive'});
function name(p){let t='';for(let i=0;i<128;i++){const b=p.add(i).readU8();if(b===0)return /^[A-Za-z][A-Za-z0-9_ .-]{0,127}$/.test(t)?t:null;if(b<32||b>126)return null;t+=String.fromCharCode(b);}return null;}
Interceptor.attach(base.add(PROFILE.menuUpdate),{onEnter(args){this.activity=args[0];calls++;},onLeave(){
if(Date.now()-latest<200)return;latest=Date.now();
try{
const activity=this.activity;
if(!activity.add(0x80).readPointer().add(8).readPointer().equals(base.add(PROFILE.menuAction)))return;
const flow=activity.add(0x118).readPointer();if(!flow.readPointer().add(0x20).readPointer().equals(base.add(PROFILE.flowAction)))return;
const csm=flow.add(0x310).readPointer(),from=name(csm.add(0x100).readPointer().readPointer()),to=name(csm.add(0x108).readPointer().readPointer());
const definition=activity.add(0x10).readPointer(),selected=activity.add(0x208).readU32(),categories=definition.add(0x10).readU32();if(categories>128||selected>=categories)return;
const category=definition.add(8).readPointer().add(selected*0x58),count=category.add(0x48).readU32(),buttons=category.add(0x40).readPointer(),cache=activity.add(0x248).readPointer();
if(count>128||activity.add(0x240).readU64().toNumber()!==count)return;
let candidate=-1;
// The player may already have opened Continue before this bounded probe starts.
// Adopt that real menu state; select only the original eligible Solo action.
if(ENTER_SOLO&&soloStage===0&&from==='ContinueMenu'&&to===from)soloStage=1;
const wanted=QUIT&&from==='MainMenu'?'QuitApplication':ENTER_SOLO&&soloStage===0&&from==='MainMenu'?'ContinueMenu':ENTER_SOLO&&soloStage===1&&from==='ContinueMenu'?'SetupPlaySolo':null;
const rows=[];for(let i=0;i<count;i++){const button=buttons.add(i*0x78),cached=cache.add(i*0xC8);const action=name(button.readPointer().readPointer()),eligible=cached.readU16();rows.push({action,eligible});
if(QUIT||ENTER_SOLO){const configured=new Uint8Array(button.readByteArray(0x78)),actual=new Uint8Array(cached.add(0x30).readByteArray(0x78));if(configured.some((v,n)=>v!==actual[n]))throw new Error('Native definition/cache mismatch');if(action===wanted&&eligible===0x0101){if(candidate>=0)throw new Error('Ambiguous native action');candidate=i;}}}
const value={from,to,rows},key=JSON.stringify(value);if(key!==last){last=key;send({kind:'native-menu',...value});}
if(wanted!==null&&candidate>=0&&from===to&&Process.getCurrentThreadId()===EXPECTED_THREAD&&cache.add(candidate*0xC8).readU16()===0x0101&&(!QUIT||!quitCalled)){
if(QUIT)quitCalled=true;else soloStage++;selections++;send({kind:'original-native-menu-action',action:wanted,thread:Process.getCurrentThreadId()});select(activity.add(0x80),ptr(0),candidate);}
}catch(e){const key=String(e);if(last!==key){last=key;send({kind:'unavailable',error:key});}}
}});
rpc.exports={summary(){return {calls,screenUse:false,selectionCalls:selections,soloStage,flightInputs:0};}};
'''.replace('__PROFILE__',json.dumps(profile)).replace('__QUIT__',json.dumps(a.quit)).replace('__SOLO__',json.dumps(a.enter_solo)).replace('__THREAD__',str(a.thread))
if a.quit and a.enter_solo:raise ValueError('Choose only one original menu workflow')
if (a.quit or a.enter_solo) and a.thread<=0:raise ValueError('Native owning thread is required for menu selection')
with a.output.open('x',encoding='utf-8') as out:
    session=frida.attach(a.pid)
    try:
        script=session.create_script(source)
        def on_message(message,data):
            row=message.get('payload',message);out.write(json.dumps(row)+'\n');out.flush();print(json.dumps(row),flush=True)
        script.on('message',on_message);script.load()
        time.sleep(a.seconds)
        try:
            result=script.exports_sync.summary();out.write(json.dumps(result)+'\n');print(json.dumps(result),flush=True)
            if a.keep_attached:
                kernel=c.WinDLL('kernel32',use_last_error=True)
                kernel.OpenProcess.argtypes=[w.DWORD,w.BOOL,w.DWORD];kernel.OpenProcess.restype=w.HANDLE
                kernel.WaitForSingleObject.argtypes=[w.HANDLE,w.DWORD];kernel.CloseHandle.argtypes=[w.HANDLE]
                handle=kernel.OpenProcess(0x100000,False,a.pid)
                if not handle:raise c.WinError(c.get_last_error())
                try:
                    print(json.dumps({'kind':'diagnostic-resident-until-game-exit','pid':a.pid}),flush=True)
                    while kernel.WaitForSingleObject(handle,1000)==258:pass
                finally:kernel.CloseHandle(handle)
            else:script.unload()
        except (frida.InvalidOperationError,frida.ProcessNotFoundError):
            if not a.quit:raise
            out.write(json.dumps({'kind':'process-detached-after-quit'})+'\n')
    finally:
        try:session.detach()
        except frida.InvalidOperationError:pass
