// Node 22+. Credentials come from private environment variables, never model chat.
// WAGMI_API_BASE=https://your-api/v1 WAGMI_BOT_KEY=... BOT_POLICY=./policy.json
// BOT_STATE must be a persistent local file. Run ONE runner per state file/wallet.
import { readFile, writeFile, rename, open, unlink } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
const base = process.env.WAGMI_API_BASE?.replace(/\/+$/, ''), key = process.env.WAGMI_BOT_KEY;
if (!base || !key)
    throw new Error('Set WAGMI_API_BASE and WAGMI_BOT_KEY in the runner secret store.');
const url = new URL(base);
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname)))
    throw new Error('Use HTTPS for the API.');
const file = process.env.BOT_STATE ?? './wagmi-runner-state.json';
const lock = await open(file + '.lock', 'wx', 0o600).catch(() => { throw new Error('Another runner owns this state file. After a crash, confirm it stopped before removing the .lock file.'); });
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
const policy = JSON.parse(await readFile(process.env.BOT_POLICY ?? './bot-policy.json', 'utf8'));
let state = { pending: null, executions: [], seen: [] };
try {
    state = JSON.parse(await readFile(file, 'utf8'));
}
catch (e) {
    if (e.code !== 'ENOENT')
        throw e;
}
async function save() { await writeFile(file + '.tmp', JSON.stringify(state, null, 2), { mode: 0o600 }); await rename(file + '.tmp', file); }
class ApiError extends Error {
    constructor(status, body, retry) { super(body.error?.message ?? 'API request failed'); this.code = body.error?.code ?? 'transport_error'; this.status = status; this.retry = Number(retry ?? 5) * 1000; }
}
async function request(path, { method = 'GET', body, token = key, idem } = {}) {
    const response = await fetch(base + path, { method, headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', ...(idem ? { 'idempotency-key': idem } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(50000), redirect: 'error' });
    const data = await response.json();
    if (!response.ok)
        throw new ApiError(response.status, data, response.headers.get('retry-after'));
    return data;
}
async function approve() {
    const auth = await request('/bot/authorizations', { method: 'POST', body: policy });
    console.log('Approve in WAGMI Settings:', auth.verification_uri, 'Pairing code:', auth.user_code);
    const end = Date.now() + auth.expires_in * 1000;
    while (Date.now() < end) {
        await pause(auth.interval * 1000);
        try {
            return await request('/bot/authorizations/token', { method: 'POST', body: { device_code: auth.device_code } });
        }
        catch (e) {
            if (e.code === 'authorization_pending')
                continue;
            if (e.status === 429) {
                await pause(e.retry);
                continue;
            }
            throw e;
        }
    }
    throw new Error('Approval expired. Restart the runner to request another session.');
}
async function reconcile() {
    for (const id of [...state.executions]) {
        const e = await request('/executions/' + encodeURIComponent(id));
        if (['filled', 'failed', 'cancelled', 'rejected', 'simulated'].includes(e.status)) {
            console.log('Execution result:', id, e.status, e.failure_code ?? '');
            state.executions = state.executions.filter(x => x !== id);
            await save();
        }
        else if (e.status === 'recovery_required')
            throw new Error('Execution needs owner review: ' + id);
    }
    return state.executions.length === 0;
}
try {
    const me = await request('/me');
    if (!me.bot.wallet_ids.includes(policy.wallet_id))
        throw new Error('Policy wallet is not authorized by this API key.');
    // Startup always resumes observations before requesting any new order.
    while (!(await reconcile()))
        await pause(10000);
    const standing = me.bot.scopes.includes('arbs:execute') ? await request('/bot/grants/current?' + new URLSearchParams({wallet_id:policy.wallet_id})) : null;
    const grant = standing?.grant?.standing || (standing?.grant && standing.grant.expires_at===null) ? {execution_token:key,expires_at:null} : await approve();
    while (grant.expires_at===null || Date.parse(grant.expires_at) > Date.now() + 10000) {
        if (!(await reconcile())) {
            await pause(10000);
            continue;
        }
        if (state.pending) {
            const lookup = await request('/executions?' + new URLSearchParams({ wallet_id: policy.wallet_id, client_order_id: state.pending.body.client_order_id }));
            if (lookup.executions.length) {
                state.executions.push(lookup.executions[0].execution_id);
                state.seen.push(state.pending.revision);
                state.pending = null;
                await save();
                continue;
            }
            try {
                const result = await request(`/arbs/${encodeURIComponent(state.pending.arb)}/execute`, { method: 'POST', body: state.pending.body, idem: state.pending.idem, token: grant.execution_token });
                state.executions.push(result.execution_id);
                state.seen.push(state.pending.revision);
                state.pending = null;
                await save();
            }
            catch (e) {
                if ([400, 403, 404, 409, 422].includes(e.status)) {
                    const check = await request('/executions?' + new URLSearchParams({ wallet_id: policy.wallet_id, client_order_id: state.pending.body.client_order_id }));
                    if (check.executions.length) {
                        state.executions.push(check.executions[0].execution_id);
                        state.pending = null;
                        await save();
                        continue;
                    }
                    console.log('Admission rejected:', e.code);
                    state.pending = null;
                    await save();
                    if (e.status === 403)
                        throw e;
                }
                else {
                    console.log('Admission response unknown; retaining the same request.');
                    await pause(e.retry ?? 10000);
                }
            }
            continue;
        }
        const list = await request('/arbs/active?' + new URLSearchParams({ wallet_id: policy.wallet_id }));
        const candidate = list.arbs.find(a => a.eligibility.state === 'eligible_for_quote' && !state.seen.includes(a.id + ':' + a.revision) && policy.allowed_assets.some(x => x.toLowerCase() === a.source_asset_id.toLowerCase()) && policy.allowed_assets.some(x => x.toLowerCase() === a.destination_asset_id.toLowerCase()));
        if (!candidate) {
            await pause(list.suggested_poll_ms ?? 10000);
            continue;
        }
        const revision = candidate.id + ':' + candidate.revision;
        try {
            // Fixed maximum candidate amount supplied by operator; server rechecks balances and USD limits.
            const amount = process.env.BOT_SELL_AMOUNT;
            if (!amount)
                throw new Error('Set BOT_SELL_AMOUNT for the operator-approved token amount. Use a per-asset sizing function before generalizing to other assets.');
            const quote = await request(`/arbs/${encodeURIComponent(candidate.id)}/quote`, { method: 'POST', body: { wallet_id: policy.wallet_id, opportunity_revision: candidate.revision, sell_amount: amount, slippage_bps: policy.max_slippage_bps } });
            const id = randomUUID();
            state.pending = { arb: candidate.id, revision, idem: id, body: { wallet_id: policy.wallet_id, quote_id: quote.quote_id, client_order_id: id } };
            await save();
        }
        catch (e) {
            if (e.status >= 400 && e.status < 500) {
                console.log('Candidate skipped:', candidate.id, e.code);
                state.seen.push(revision);
                await save();
                await pause(e.retry ?? 5000);
            }
            else
                throw e;
        }
    }
    // Observe accepted jobs after token expiry with the read key; no automatic grant renewal.
    while (!(await reconcile()))
        await pause(10000);
    console.log('Session finished. New trading requires a new owner approval.');
}
finally {
    await lock.close();
    await unlink(file + '.lock');
}
