// Offline checks for the parts that have to match the shipped build byte-for-byte:
// the mrs-sig-v1 canonical string, the device-id derivation, and the header rewriting.
// Plus live loopback checks that a client hanging up cannot take the proxy down.
// Run: ./vendor/node test.mjs

import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import assert from 'node:assert/strict';

// Point the config at a scratch dir so a real login is never touched.
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'mirasim-test-'));
process.env.MIRASIM_PROXY_HOME = home;

// A stand-in relay, bound before the import so RELAY_BASE picks it up. It streams SSE one
// chunk at a time and never returns on its own, which is what lets the client hang up
// mid-response. `held` lets a test see that the upstream read was actually torn down.
const upstream = http.createServer();
const held = { aborted: 0, requests: 0, last: null };
upstream.on('request', (req, res) => {
  held.requests++;
  held.last = { path: req.url, headers: req.headers };
  req.resume();
  if (req.url.startsWith('/v1/device/session')) { res.writeHead(404).end('{}'); return; }
  if (req.url.startsWith('/slow-headers')) return; // never responds at all
  res.writeHead(200, { 'content-type': 'text/event-stream' });
  let n = 0;
  const timer = setInterval(() => { if (!res.write(`data: {"n":${n++}}\n\n`)) clearInterval(timer); }, 5);
  res.on('close', () => { clearInterval(timer); if (!res.writableEnded) held.aborted++; });
});
await new Promise((r) => upstream.listen(0, '127.0.0.1', r));
process.env.MIRASIM_RELAY_BASE_URL = `http://127.0.0.1:${upstream.address().port}`;
process.env.MIRASIM_LOGIN_URL = process.env.MIRASIM_RELAY_BASE_URL;

const m = await import('./mirasim-relay.mjs');

let passed = 0;
function check(name, fn) {
  try { fn(); process.stdout.write(`  ok   ${name}\n`); passed++; }
  catch (err) { process.stdout.write(`  FAIL ${name}\n       ${err.message}\n`); process.exitCode = 1; }
}

async function checkAsync(name, fn) {
  try { await fn(); process.stdout.write(`  ok   ${name}\n`); passed++; }
  catch (err) { process.stdout.write(`  FAIL ${name}\n       ${err.message}\n`); process.exitCode = 1; }
}

check('device identity is stable across calls and persisted', () => {
  const a = m.deviceIdentity();
  const b = m.deviceIdentity();
  assert.equal(a.deviceId, b.deviceId);
  assert.equal(a.deviceId.length, 22);
  const stored = JSON.parse(fs.readFileSync(path.join(home, 'config.json'), 'utf8'));
  assert.ok(stored.device.privateKey.includes('BEGIN PRIVATE KEY'));
});

check('deviceId is sha256(spki-b64) base64url, first 22 chars', () => {
  const id = m.deviceIdentity();
  const expected = crypto.createHash('sha256').update(id.publicKeyB64).digest('base64url').slice(0, 22);
  assert.equal(id.deviceId, expected);
});

check('config file is not world-readable', () => {
  const mode = fs.statSync(path.join(home, 'config.json')).mode & 0o777;
  assert.equal(mode, 0o600, `mode was ${mode.toString(8)}`);
});

check('canonical string is the 6-field newline join', () => {
  const s = m.canonicalString({ method: 'post', path: '/v1/messages', ts: '123', nonce: 'n', bodySha256: 'abc' });
  assert.equal(s, `${m.SIG_SCHEME}\nPOST\n/v1/messages\n123\nn\nabc`);
  assert.equal(s.split('\n').length, 6);
});

check('signature verifies with the device public key', () => {
  const id = m.deviceIdentity();
  const body = Buffer.from(JSON.stringify({ hello: 'world' }));
  const h = m.signatureHeaders(id, { method: 'POST', path: m.DEVICE_SESSION_PATH, body });

  const canonical = m.canonicalString({
    method: 'POST',
    path: m.DEVICE_SESSION_PATH,
    ts: h['x-mirasim-ts'],
    nonce: h['x-mirasim-nonce'],
    bodySha256: crypto.createHash('sha256').update(body).digest('hex'),
  });

  const pub = crypto.createPublicKey({
    key: Buffer.from(id.publicKeyB64, 'base64'),
    format: 'der',
    type: 'spki',
  });
  assert.ok(crypto.verify(null, Buffer.from(canonical, 'utf8'), pub,
    Buffer.from(h['x-mirasim-sig'], 'base64url')), 'signature did not verify');
  assert.equal(h['x-mirasim-device'], id.deviceId);
  assert.equal(h['x-mirasim-client'], m.CLIENT_VERSION);
});

check('nonces do not repeat', () => {
  const id = m.deviceIdentity();
  const seen = new Set();
  for (let i = 0; i < 200; i++) {
    seen.add(m.signatureHeaders(id, { method: 'POST', path: '/x', body: Buffer.alloc(0) })['x-mirasim-nonce']);
  }
  assert.equal(seen.size, 200);
});

check('empty body still hashes (no undefined in canonical)', () => {
  const id = m.deviceIdentity();
  const h = m.signatureHeaders(id, { method: 'GET', path: '/v1/models', body: undefined });
  assert.match(h['x-mirasim-sig'], /^[A-Za-z0-9_-]+$/);
});

check('hop-by-hop headers are dropped', () => {
  const out = m.forwardHeaders({
    'content-length': '10', 'transfer-encoding': 'chunked', connection: 'keep-alive',
    host: 'localhost:8787', 'anthropic-version': '2023-06-01',
  });
  assert.deepEqual(Object.keys(out).sort(), ['anthropic-version']);
});

check('anthropic-beta keeps only the relay-honoured value', () => {
  const kept = m.KEPT_BETAS[0];
  assert.equal(m.forwardHeaders({ 'anthropic-beta': `foo,${kept}, bar` })['anthropic-beta'], kept);
  assert.equal('anthropic-beta' in m.forwardHeaders({ 'anthropic-beta': 'foo,bar' }), false);
});

check('array header values are joined', () => {
  assert.equal(m.forwardHeaders({ 'x-thing': ['a', 'b'] })['x-thing'], 'a, b');
});

check('non-ascii header values are percent-encoded', () => {
  assert.equal(m.sanitizeHeader('claude'), 'claude');
  assert.equal(m.sanitizeHeader('中'), '%E4%B8%AD');
});

check('jwt claims are decoded from the payload segment', () => {
  const payload = Buffer.from(JSON.stringify({ sub: 'u1', exp: 42 })).toString('base64url');
  const claims = m.decodeJwt(`h.${payload}.s`);
  assert.equal(claims.sub, 'u1');
  assert.equal(claims.exp, 42);
  assert.equal(m.decodeJwt('garbage'), null);
});

check('ticket manager reports no credential until a mint succeeds', () => {
  const t = new m.TicketManager();
  assert.equal(t.credential(), null);
  assert.equal(t.signingIdentity(), null); // unsigned fallback, matching the desktop build
});

check('path classifier matches the shipped router', () => {
  assert.equal(m.classifyPath('/v1/messages'), 'anthropic');
  assert.equal(m.classifyPath('/v1/messages/'), 'anthropic');
  assert.equal(m.classifyPath('/v1/responses'), 'openai-responses');
  assert.equal(m.classifyPath('/backend-api/codex/responses'), 'openai-responses');
  assert.equal(m.classifyPath('/v1/chat/completions'), 'openai-chat');
  assert.equal(m.classifyPath('/openai/v1/chat/completions'), 'openai-chat');
  assert.equal(m.classifyPath('/v1/messages/count_tokens'), null); // forwarded, just unclassified
  assert.equal(m.classifyPath('/v1/models'), null);
});

check('codex provider block matches the desktop overrides', () => {
  const o = m.codexOverrides('http://127.0.0.1:8787');
  assert.deepEqual(o, [
    'model_providers.apodex.name=apodex',
    'model_providers.apodex.base_url=http://127.0.0.1:8787/v1',
    'model_providers.apodex.wire_api=responses',
    'model_providers.apodex.env_key=OPENAI_API_KEY',
    'model_provider=apodex',
  ]);
  // wire_api=responses means codex POSTs base_url + /responses — the route the relay serves.
  assert.equal(o[1].split('=')[1] + '/responses', 'http://127.0.0.1:8787/v1/responses');
});

// ---------------------------------------------------------------------------
// Disconnect resilience — an agent hanging up must cost one request, not the server.
// ---------------------------------------------------------------------------

// Fabricate a long-lived credential so serve() gets past the boot check. It is only ever
// presented to the loopback stand-in above.
{
  const file = path.join(home, 'config.json');
  const cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
  const claims = Buffer.from(JSON.stringify({ sub: 'test', exp: Math.floor(Date.now() / 1000) + 86400 })).toString('base64url');
  cfg.auth = { token: `h.${claims}.s`, exp: Math.floor(Date.now() / 1000) + 86400 };
  fs.writeFileSync(file, JSON.stringify(cfg), { mode: 0o600 });
}

const proxy = await m.serve({ port: 0, host: '127.0.0.1', verbose: false });
const PROXY = `http://127.0.0.1:${proxy.address().port}`;

const alive = async () => {
  const res = await fetch(`${PROXY}/__health`);
  assert.equal(res.status, 200, `health check returned ${res.status}`);
  assert.equal((await res.json()).ok, true);
};

await checkAsync('survives a client aborting mid-stream', async () => {
  const abort = new AbortController();
  const res = await fetch(`${PROXY}/v1/messages`, {
    method: 'POST', body: '{"stream":true}', signal: abort.signal,
  });
  assert.equal(res.status, 200);
  const reader = res.body.getReader();
  await reader.read();            // one SSE chunk really arrived
  const before = held.aborted;
  abort.abort();                  // this is what used to kill the process
  await new Promise((r) => setTimeout(r, 120));
  assert.ok(held.aborted > before, 'upstream read was not torn down');
  await alive();
});

await checkAsync('survives an abort while the upstream is still silent', async () => {
  const abort = new AbortController();
  const pending = fetch(`${PROXY}/slow-headers`, { method: 'POST', body: '{}', signal: abort.signal })
    .catch(() => {});
  await new Promise((r) => setTimeout(r, 60));
  abort.abort();
  await pending;
  await new Promise((r) => setTimeout(r, 60));
  await alive();
});

await checkAsync('survives a client vanishing mid-upload', async () => {
  const net = await import('node:net');
  const sock = net.connect(proxy.address().port, '127.0.0.1');
  await new Promise((r) => sock.once('connect', r));
  // Announce more body than we intend to send, then disappear.
  sock.write('POST /v1/messages HTTP/1.1\r\nHost: x\r\ncontent-length: 4096\r\n\r\n{"a":1}');
  await new Promise((r) => setTimeout(r, 40));
  sock.destroy();
  await new Promise((r) => setTimeout(r, 60));
  await alive();
});

await checkAsync('still serves normally after all of that', async () => {
  const res = await fetch(`${PROXY}/v1/messages`, { method: 'POST', body: '{}' });
  assert.equal(res.status, 200);
  const reader = res.body.getReader();
  const first = await reader.read();
  assert.match(Buffer.from(first.value).toString(), /^data: /);
  await reader.cancel();
});

await checkAsync('responses traffic is forwarded and reported as codex', async () => {
  const res = await fetch(`${PROXY}/v1/responses`, {
    method: 'POST',
    headers: { authorization: 'Bearer whatever-codex-sent', 'openai-beta': 'responses=experimental' },
    body: JSON.stringify({ model: m.CODEX_DEFAULT_MODEL, input: 'hi' }),
  });
  assert.equal(res.status, 200);
  await res.body.cancel();
  assert.equal(held.last.path, '/v1/responses');
  assert.equal(held.last.headers['x-mirasim-agent'], 'codex');
  // codex's own key is replaced, never forwarded.
  assert.match(held.last.headers.authorization, /^Bearer h\./);
  assert.equal(held.last.headers['openai-beta'], 'responses=experimental');
});

await checkAsync('anthropic traffic is still reported as claude', async () => {
  const res = await fetch(`${PROXY}/v1/messages`, { method: 'POST', body: '{}' });
  await res.body.cancel();
  assert.equal(held.last.headers['x-mirasim-agent'], 'claude');
});

proxy.close();
upstream.close();
fs.rmSync(home, { recursive: true, force: true });
process.stdout.write(`\n${passed} passed${process.exitCode ? ', with failures' : ''}\n`);
