// Node 22+. One runner per wallet; keep BOT_STATE on durable disk.
// WAGMI_API_BASE=https://wagmi-api.vercel.app/v1 WAGMI_BOT_KEY=<secret-store value>
// BOT_STATE=./order-001.json node wagmi-trading-client.mjs swap ./swap-request.json
// Action: swap | deposit | withdraw | arbitrage (requires arb_id in JSON). Request JSON is the corresponding quote body.
// Restart with the same input and state file to reconcile, never to place a replacement.
import { readFile, writeFile, rename, open, unlink } from 'node:fs/promises';
import { randomUUID, createHash } from 'node:crypto';
const [action,inputFile]=process.argv.slice(2),base=process.env.WAGMI_API_BASE?.replace(/\/+$/,''),key=process.env.WAGMI_BOT_KEY;
if(!['swap','deposit','withdraw','arbitrage'].includes(action)||!inputFile||!base||!key||!process.env.BOT_STATE)throw new Error('Set API base, private bot key and BOT_STATE; provide action and quote-request JSON path.');
const origin=new URL(base);if(origin.protocol!=='https:'||origin.username||origin.password)throw new Error('Use an HTTPS API origin without embedded credentials.');
const input=JSON.parse(await readFile(inputFile,'utf8')),file=process.env.BOT_STATE;
const fingerprint=createHash('sha256').update(JSON.stringify([base,action,input])).digest('hex');
const lock=await open(file+'.lock','wx',0o600).catch(()=>{throw new Error('Another runner holds this state file. After a crash, confirm it stopped before removing the lock.');});
const pause=ms=>new Promise(r=>setTimeout(r,ms));
async function request(path,method='GET',body,idem){
    const r=await fetch(base+path,{method,headers:{authorization:`Bearer ${key}`,'content-type':'application/json',...(idem?{'Idempotency-Key':idem}:{})},...(body?{body:JSON.stringify(body)}:{}),redirect:'error',signal:AbortSignal.timeout(50000)});
    const data=await r.json();if(!r.ok){const e=new Error(data.error?.message??'API request failed');Object.assign(e,{status:r.status,code:data.error?.code,details:data.error?.details,retry:Math.max(5,Number(r.headers.get('retry-after'))||5)});throw e;}return data;
}
let state={fingerprint};
async function save(){await writeFile(file+'.tmp',JSON.stringify(state,null,2),{mode:0o600});await rename(file+'.tmp',file);}
try{
    try{state=JSON.parse(await readFile(file,'utf8'));}catch(e){if(e.code!=='ENOENT')throw e;}
    if(state.fingerprint!==fingerprint)throw new Error('State file belongs to a different request. Reconcile it before starting another.');
    if(state.result){console.log(JSON.stringify(state.result));}
    else {
        const me=await request('/me');
        if(!me.bot.wallet_ids.includes(input.wallet_id))throw new Error('This key cannot access the requested wallet.');
        if(state.keyId&&state.keyId!==me.bot.key_id)throw new Error('Resume with the original key; this state belongs to another bot.');
        state.keyId=me.bot.key_id;
        if(action==='arbitrage'&&!/^[a-zA-Z0-9_-]+$/.test(input.arb_id??''))throw new Error('Provide arb_id from /arbs/active.');
        const prefix=action==='arbitrage'?'/arbs/'+encodeURIComponent(input.arb_id):action==='swap'?'/swaps':'/earn/'+action,execute=['swap','arbitrage'].includes(action)?prefix+'/execute':prefix;
        const quoteInput={...input};delete quoteInput.arb_id;
        if(!state.pending){
            const scope=action==='arbitrage'?'arbs:execute':action==='swap'?'swaps:execute':'earn:'+action;
            if(!me.bot.scopes.includes(scope))throw new Error('Enable this action once in WAGMI Settings → Bot trading access.');
            const quote=await request(prefix+'/quote','POST',quoteInput),id=randomUUID();
            state.pending={path:execute,idem:id,body:{wallet_id:input.wallet_id,quote_id:quote.quote_id,client_order_id:id}};
            await save();
        }
        while(!state.executionId){
            const pending=state.pending;
            const lookup=await request('/executions?'+new URLSearchParams({wallet_id:input.wallet_id,client_order_id:pending.body.client_order_id}));
            if(lookup.executions.length){state.executionId=lookup.executions[0].execution_id;await save();break;}
            try {const r=await request(pending.path,'POST',pending.body,pending.idem);state.executionId=r.execution_id;await save();}
            catch(e){
                // Keep the saved request after every failure, even a 4xx. A delayed original
                // response may still correspond to an admitted order. No automatic new quote.
                if(e.status&&e.status<500&&e.status!==429)throw e;
                await pause((e.retry??10)*1000);
            }
        }
        for(;;){
            let execution;
            try{execution=await request('/executions/'+encodeURIComponent(state.executionId));}
            catch(e){if(!e.status||e.status>=500||e.status===429){await pause((e.retry??10)*1000);continue;}throw e;}
            if(['filled','failed','cancelled','rejected','simulated'].includes(execution.status)){state.result=execution;await save();console.log(JSON.stringify(execution));break;}
            if(execution.status==='recovery_required')throw new Error('Recovery required for '+state.executionId+'. Do not submit a replacement.');
            await pause(Math.max(5000,execution.suggested_poll_ms??5000));
        }
    }
}finally{await lock.close();await unlink(file+'.lock');}
