// The Cloudflare Worker that fronts the R2 traces bucket.
//
// SECURITY: unlike agents-share, there is NO public GET path here. Every route
// — PUT and GET — requires a Phoenix bearer. The caller must be the record's
// owner (path prefix == verified userId). A missing bearer → 401; a wrong
// owner → 403; public cache headers are NEVER emitted.
//
// Routes (all require Phoenix bearer + userId owner match):
//   PUT  /<userId>/<device>/index.json            — per-device stats shard
//   PUT  /<userId>/<device>/sessions/<id>.json    — per-session SessionDetail
//   GET  /<userId>/<device>/...                   — returns the stored object
//   GET  /<userId>/all/index.json                 — cross-device merge (read-side)
//   GET  /<userId>/all/sessions/<id>.json         — first device that holds <id>
//   GET  /                                        — 200 description (no data)
//
// The CLI writes per-device (`localDevice()` == hostname); the console asks for
// the "all agents" view (`/all/`). `all` is not a real device — the worker
// synthesizes it on read by listing this owner's device prefixes and merging.
// A single device is an exact passthrough; cross-device stats that need the raw
// sample (median, latency percentiles) are session-weighted approximations,
// documented at the merge site. This keeps the CLI unchanged and uploads nothing
// extra — the per-device shards already in R2 are the source (PHNX-3397).
//
// Emitted as a string so it compiles into dist/** and ships with no
// package.json#files change. `provisionTraces.ts` uploads this verbatim as an
// ES-module Worker with a BUCKET (R2) binding + a PHOENIX_ID_BASE secret.
/**
 * Render the traces Worker source. Pure — bindings are wired at deploy time.
 */
export function renderTracesWorkerScript() {
    return `// GENERATED by agents-cli agents traces — do not edit; edit
// src/lib/traces/worker-template.ts and re-provision.
export const hooks = {
  verifyPhoenixToken: defaultVerifyPhoenixToken,
};

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const path = decodeURIComponent(url.pathname.replace(/^\\/+/, ''));

    if (!path) {
      return new Response(
        'agents traces — PUT /<userId>/<device>/index.json or sessions/<id>.json to upload; GET the same path to read. All routes require a Phoenix bearer.',
        { status: 200, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'private, no-store' } },
      );
    }

    if (request.method === 'PUT') {
      return handlePut(request, env, path);
    }

    if (request.method === 'GET' || request.method === 'HEAD') {
      return handleGet(request, env, path);
    }

    return json({ error: 'method not allowed' }, 405);
  },
};

async function handlePut(request, env, path) {
  const auth = await authorizeWrite(request, env);
  if (auth.error) return auth.error;

  const segments = path.split('/').filter(Boolean);
  const expected = auth.owner;
  if (!expected || segments[0] !== expected) {
    return json({ error: 'namespace mismatch', owner: expected }, 403);
  }

  const contentType = request.headers.get('content-type') || 'application/json; charset=utf-8';
  await env.BUCKET.put(path, request.body, {
    httpMetadata: { contentType },
    customMetadata: {
      owner: auth.owner,
      uploadedAt: new Date().toISOString(),
    },
  });

  const headers = new Headers();
  headers.set('cache-control', 'private, no-store');
  return new Response(JSON.stringify({ ok: true, key: path }), {
    status: 200,
    headers,
  });
}

async function handleGet(request, env, path) {
  const auth = await authorizeRead(request, env, path);
  if (auth.error) return auth.error;

  const segments = path.split('/').filter(Boolean);
  // The "all agents" view is synthesized, not stored: merge this owner's device
  // shards on read. segments[0] === owner is already enforced by authorizeRead.
  if (segments[1] === 'all') {
    return handleAggregate(request, env, segments[0], segments.slice(2).join('/'));
  }

  const object = await env.BUCKET.get(path);
  if (!object) return json({ error: 'not found' }, 404);

  const headers = new Headers(object.httpMetadata ?? {});
  headers.set('cache-control', 'private, no-store');
  return new Response(object.body, { headers });
}

// Enumerate this owner's real device names (never 'all') using a delimited list
// so we read one page of prefixes, not every session object.
async function listDevices(env, owner) {
  const listed = await env.BUCKET.list({ prefix: owner + '/', delimiter: '/' });
  const prefixes = (listed && listed.delimitedPrefixes) || [];
  const base = owner + '/';
  return prefixes
    .map((p) => (p.startsWith(base) ? p.slice(base.length) : p).replace(/\\/+$/, ''))
    .filter((d) => d && d !== 'all');
}

async function handleAggregate(request, env, owner, rest) {
  const devices = await listDevices(env, owner);
  if (devices.length === 0) return json({ error: 'not found' }, 404);

  // A single session drill-down: the shard lives under whichever device owns it.
  if (rest.startsWith('sessions/')) {
    for (const device of devices) {
      const object = await env.BUCKET.get(owner + '/' + device + '/' + rest);
      if (object) {
        const headers = new Headers(object.httpMetadata ?? {});
        headers.set('cache-control', 'private, no-store');
        return new Response(object.body, { headers });
      }
    }
    return json({ error: 'not found' }, 404);
  }

  if (rest === 'index.json') {
    const shards = [];
    for (const device of devices) {
      const object = await env.BUCKET.get(owner + '/' + device + '/index.json');
      if (!object) continue;
      try {
        shards.push(JSON.parse(await object.text()));
      } catch {
        // A corrupt per-device shard must not sink the whole aggregate.
      }
    }
    if (shards.length === 0) return json({ error: 'not found' }, 404);
    return json(mergeIndexShards(shards, owner), 200);
  }

  return json({ error: 'not found' }, 404);
}

// Merge per-device index shards into one "all agents" shard. One device is an
// exact passthrough (relabelled). For many, counts sum and lists concat+resort;
// median/latency percentiles cannot be recomputed without the raw per-session
// sample, so they are session-count-weighted approximations — good enough for an
// at-a-glance fleet view, and exact the moment you filter to a single device.
function mergeIndexShards(shards, owner) {
  if (shards.length === 1) {
    return Object.assign({}, shards[0], { device: 'all', owner });
  }
  const sorted = shards.slice().sort((a, b) => (b.syncedAt || 0) - (a.syncedAt || 0));
  const totalSessions = sorted.reduce((n, s) => n + ((s.stats && s.stats.sessionsImported) || 0), 0) || 1;
  const wsum = (pick) => sorted.reduce((n, s) => n + (pick(s) || 0) * ((s.stats && s.stats.sessionsImported) || 0), 0) / totalSessions;

  const topicCounts = new Map();
  for (const s of sorted) for (const t of s.topics || []) {
    const cur = topicCounts.get(t.key) || Object.assign({}, t, { count: 0 });
    cur.count += t.count || 0;
    topicCounts.set(t.key, cur);
  }
  const byToolError = new Map();
  // Accumulate byCause over WHATEVER cause keys each shard carries (real / guard /
  // hook / behavioral / any future TraceFailureCause) rather than a hand-enumerated
  // set — a hardcoded {real,guard,hook} silently dropped a new member from the /all
  // view and made the cause sum NaN (RUSH-2988).
  const byCause = {};
  for (const s of sorted) {
    const f = s.failures || {};
    for (const e of f.byToolError || []) {
      const key = e.tool + '\\u0000' + e.desc + '\\u0000' + e.cause;
      const cur = byToolError.get(key) || Object.assign({}, e, { count: 0 });
      cur.count += e.count || 0;
      byToolError.set(key, cur);
    }
    for (const [cause, n] of Object.entries((f.byCause) || {})) {
      byCause[cause] = (byCause[cause] || 0) + (n || 0);
    }
  }
  // Fold failure patterns by their stable id, the same way topics/byToolError fold
  // by key above — a signature that fired on two devices (a rate limit on the laptop
  // AND on CI) is ONE ranked issue with combined counts, not two half-counted rows.
  const patternById = new Map();
  for (const s of sorted) for (const p of s.failurePatterns || []) {
    const cur = patternById.get(p.id);
    if (!cur) {
      patternById.set(p.id, Object.assign({}, p, { exampleSessionIds: (p.exampleSessionIds || []).slice(0, 5) }));
    } else {
      cur.sessions += p.sessions || 0;
      cur.occurrences += p.occurrences || 0;
      cur.wastedMs += p.wastedMs || 0;
      for (const id of p.exampleSessionIds || []) {
        if (cur.exampleSessionIds.length < 5 && !cur.exampleSessionIds.includes(id)) cur.exampleSessionIds.push(id);
      }
    }
  }
  const latencies = sorted.map((s) => s.latency).filter(Boolean);

  return {
    schema: 1,
    device: 'all',
    owner,
    syncedAt: sorted[0].syncedAt || 0,
    stats: {
      sessionsImported: sorted.reduce((n, s) => n + ((s.stats && s.stats.sessionsImported) || 0), 0),
      medianMs: Math.round(wsum((s) => s.stats && s.stats.medianMs)),
      p90Ms: Math.max.apply(null, sorted.map((s) => (s.stats && s.stats.p90Ms) || 0)),
      needAttention: sorted.reduce((n, s) => n + ((s.stats && s.stats.needAttention) || 0), 0),
      toolErrorRate: wsum((s) => s.stats && s.stats.toolErrorRate),
    },
    needsAttention: sorted.flatMap((s) => s.needsAttention || [])
      .sort((a, b) => (b.severity || 0) - (a.severity || 0) || String(a.id).localeCompare(String(b.id)))
      .slice(0, 100),
    topics: Array.from(topicCounts.values()).sort((a, b) => b.count - a.count),
    failures: {
      byToolError: Array.from(byToolError.values()).sort((a, b) => b.count - a.count).slice(0, 50),
      byCause,
    },
    failurePatterns: Array.from(patternById.values())
      .sort((a, b) => (b.wastedMs || 0) - (a.wastedMs || 0)).slice(0, 25),
    wastedMsTotal: sorted.reduce((n, s) => n + (s.wastedMsTotal || 0), 0),
    // Per-session roster (PHNX-3483): concat every device's rows so the "all"
    // view can filter + re-aggregate client-side exactly like a single device.
    // Devices still on a pre-roster CLI contribute none (|| []) and degrade to
    // the pre-rolled stats, so coverage grows as devices update — never crashes.
    sessions: sorted.flatMap((s) => s.sessions || []),
    latency: latencies.length ? {
      firstToolMs: {
        p50: Math.round(wsum((s) => s.latency && s.latency.firstToolMs && s.latency.firstToolMs.p50)),
        p90: Math.max.apply(null, latencies.map((l) => (l.firstToolMs && l.firstToolMs.p90) || 0)),
        p99: Math.max.apply(null, latencies.map((l) => (l.firstToolMs && l.firstToolMs.p99) || 0)),
        max: Math.max.apply(null, latencies.map((l) => (l.firstToolMs && l.firstToolMs.max) || 0)),
      },
    } : undefined,
    // Rolling history / drift are per-device time series; the freshest device's
    // are the most representative for the merged view without double-counting.
    bucketHistory: sorted[0].bucketHistory || [],
    driftSignals: sorted[0].driftSignals || [],
  };
}

// Require a Phoenix bearer that owns the path prefix.
async function authorizeRead(request, env, path) {
  const claims = await hooks.verifyPhoenixToken(request, env);
  if (!claims) return { error: json({ error: 'unauthorized' }, 401) };
  const ownerId = path.split('/').filter(Boolean)[0] || '';
  if (!ownerId || ownerId !== claims.userId) {
    return { error: json({ error: 'forbidden' }, 403) };
  }
  return { claims };
}

// Two legitimate principals for writes: static WRITE_TOKEN first (BYO),
// otherwise Phoenix bearer. 401 when neither authenticates.
async function authorizeWrite(request, env) {
  const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
  if (!presented) return { error: json({ error: 'unauthorized' }, 401) };
  if (env.WRITE_TOKEN && safeEqual(presented, env.WRITE_TOKEN)) {
    return { kind: 'byo', owner: env.TRACES_NAMESPACE || 'byo' };
  }
  const claims = await hooks.verifyPhoenixToken(request, env);
  if (claims && typeof claims.userId === 'string' && claims.userId) {
    return { kind: 'phoenix', owner: claims.userId };
  }
  return { error: json({ error: 'unauthorized' }, 401) };
}

async function defaultVerifyPhoenixToken(request, env) {
  const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
  if (!presented) return null;
  const base = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
  if (!base) return null;
  let res;
  try {
    res = await fetch(base + '/api/v1/auth/me', {
      method: 'GET',
      headers: { Authorization: 'Bearer ' + presented },
    });
  } catch {
    return null;
  }
  if (!res.ok) return null;
  let body;
  try {
    body = await res.json();
  } catch {
    return null;
  }
  if (!body || typeof body.userId !== 'string' || !body.userId) return null;
  return { userId: body.userId, email: typeof body.email === 'string' ? body.email : '' };
}

function json(body, status = 200) {
  return new Response(JSON.stringify(body), {
    status,
    headers: {
      'content-type': 'application/json; charset=utf-8',
      'cache-control': 'private, no-store',
    },
  });
}

// Constant-time comparison so timing attacks can't probe a static WRITE_TOKEN.
function safeEqual(a, b) {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}
`;
}
