#!/usr/bin/env node
/**
 * notebook-load.mjs — scenario-based load test for the productBlocks notebook.
 *
 * Covers three scenarios per the scenario_testing rule:
 *
 *   1. CONCURRENT_INSERT  — 10 clients append to the same entity simultaneously,
 *      sustained for 60s. Verifies fractional-index tiebreaker prevents dupes
 *      and the paginated read stays deterministic under write pressure.
 *
 *   2. SUSTAINED_APPEND   — 1 client appends 500 blocks sequentially. Verifies
 *      memory stays bounded, pagination works past the hard-max page size,
 *      and query latency stays flat (not O(n) in block count).
 *
 *   3. MULTI_TAB_EDIT     — 5 clients each update a different existing block
 *      every 500ms for 30s. Verifies debounced save roundtrip + revision
 *      increments + no write-write collision on the same block.
 *
 * Honest reporting: prints p50, p95, p99, error rate per scenario.
 *
 * Usage (from repo root):
 *   node scripts/loadtest/notebook-load.mjs \
 *       --entity softbank \
 *       --scenario concurrent_insert \
 *       --clients 10 --duration 60
 *
 * Scenarios:
 *   concurrent_insert | sustained_append | multi_tab_edit |
 *   multi_tab_conflict | shared_session_insert |
 *   actor_rate_limit_guard | spike_insert | soak_mixed | all
 *
 * Requires:
 *   - CONVEX_URL (or VITE_CONVEX_URL) env var, or auto-loaded from .env.local
 *   - An existing entity slug to target (the e2e test seeds `softbank`)
 */

import { ConvexHttpClient } from "convex/browser";
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";

const { values: args } = parseArgs({
  options: {
    entity: { type: "string", default: "softbank" },
    scenario: { type: "string", default: "all" },
    clients: { type: "string", default: "10" },
    duration: { type: "string", default: "30" },
    url: { type: "string" },
    jsonOut: { type: "string" },
  },
});

const CLIENTS = parseInt(args.clients, 10);
const DURATION_SEC = parseInt(args.duration, 10);

// ── Load Convex URL ──────────────────────────────────────────────────────
function loadConvexUrl() {
  if (args.url) return args.url;
  if (process.env.CONVEX_URL) return process.env.CONVEX_URL;
  if (process.env.VITE_CONVEX_URL) return process.env.VITE_CONVEX_URL;
  try {
    const envPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../.env.local");
    const text = readFileSync(envPath, "utf8");
    const match = text.match(/VITE_CONVEX_URL="?([^"\n]+)"?/);
    if (match) return match[1];
  } catch {
    // fall through
  }
  throw new Error("CONVEX_URL not set. Pass --url or set VITE_CONVEX_URL in .env.local");
}

const CONVEX_URL = loadConvexUrl();
console.log(`[loadtest] target: ${CONVEX_URL}`);
console.log(`[loadtest] entity: ${args.entity}`);
console.log(`[loadtest] scenario: ${args.scenario}`);
console.log(`[loadtest] clients: ${CLIENTS}, duration: ${DURATION_SEC}s`);
console.log("");

// ── Per-client stats ─────────────────────────────────────────────────────
function makeStats(scenarioId, label, expectedErrorCodes = []) {
  return {
    scenarioId,
    label,
    latencies: [],
    errors: [],
    success: 0,
    errorCodes: {},
    expectedErrorCodes,
  };
}

// Extract the structured `code` from a ConvexError. The ConvexHttpClient
// preserves the payload on err.data (not in the message — message is just
// "[Request ID: ...] Server Error"). Constructor name is "ConvexError".
function extractErrorCode(err) {
  if (err?.constructor?.name === "ConvexError" && err.data && typeof err.data === "object") {
    const code = err.data.code;
    if (typeof code === "string") return code;
  }
  const msg = err?.message ?? String(err);
  if (
    typeof msg === "string" &&
    msg.includes("OptimisticConcurrencyControlFailure") &&
    msg.includes("productBlockWriteWindows")
  ) {
    return "RATE_LIMITED";
  }
  if (typeof msg === "string" && msg.trim().startsWith("{")) {
    try {
      const parsed = JSON.parse(msg);
      if (
        parsed &&
        parsed.code === "OptimisticConcurrencyControlFailure" &&
        typeof parsed.message === "string" &&
        parsed.message.includes("productBlockWriteWindows")
      ) {
        return "RATE_LIMITED";
      }
      if (parsed && typeof parsed.code === "string") {
        return parsed.code;
      }
    } catch {
      // ignore JSON parse errors and continue with the fallback mapping
    }
  }
  if (msg.includes("Server Error")) return "SERVER_ERROR";
  return "UNKNOWN";
}

// Read structured ConvexError payload, used by multi_tab_conflict to recover
// the current revision after a REVISION_MISMATCH rejection.
function extractErrorData(err) {
  if (err?.constructor?.name === "ConvexError" && err.data && typeof err.data === "object") {
    return err.data;
  }
  return null;
}

function report(stats) {
  const sorted = [...stats.latencies].sort((a, b) => a - b);
  const pct = (p) => (sorted.length ? sorted[Math.floor(sorted.length * p)] : null);
  const total = stats.success + stats.errors.length;
  const errRate = total > 0 ? (stats.errors.length / total) * 100 : 0;
  console.log(`┌─ ${stats.label}`);
  console.log(`│  requests:   ${total}`);
  console.log(`│  success:    ${stats.success}`);
  console.log(`│  errors:     ${stats.errors.length} (${errRate.toFixed(1)}%)`);
  console.log(`│  p50 (ms):   ${pct(0.5)?.toFixed(0) ?? "—"}`);
  console.log(`│  p95 (ms):   ${pct(0.95)?.toFixed(0) ?? "—"}`);
  console.log(`│  p99 (ms):   ${pct(0.99)?.toFixed(0) ?? "—"}`);
  if (stats.errors.length > 0) {
    const uniq = [...new Set(stats.errors.slice(0, 5))];
    console.log(`│  sample errs:`);
    for (const e of uniq) console.log(`│    · ${String(e).slice(0, 120)}`);
    const codeEntries = Object.entries(stats.errorCodes).sort((a, b) => b[1] - a[1]);
    if (codeEntries.length > 0) {
      console.log(`│  error codes:`);
      for (const [code, count] of codeEntries) {
        console.log(`│    · ${code}: ${count}`);
      }
    }
  }
  console.log("└──");
  console.log("");
}

function summarizeStats(stats) {
  const sorted = [...stats.latencies].sort((a, b) => a - b);
  const pct = (p) => (sorted.length ? sorted[Math.floor(sorted.length * p)] : null);
  const total = stats.success + stats.errors.length;
  const errRate = total > 0 ? (stats.errors.length / total) * 100 : 0;
  return {
    scenario: stats.scenarioId,
    label: stats.label,
    ok: stats.success,
    total,
    errors: stats.errors.length,
    errorRatePct: Number(errRate.toFixed(2)),
    p50: pct(0.5) ? Math.round(pct(0.5)) : null,
    p95: pct(0.95) ? Math.round(pct(0.95)) : null,
    p99: pct(0.99) ? Math.round(pct(0.99)) : null,
    errorCodes: stats.errorCodes,
    expectedErrorCodes: stats.expectedErrorCodes,
  };
}

async function timed(fn, stats) {
  const t0 = performance.now();
  try {
    const result = await fn();
    stats.latencies.push(performance.now() - t0);
    stats.success += 1;
    return result;
  } catch (err) {
    stats.errors.push(err?.message ?? String(err));
    const code = extractErrorCode(err);
    stats.errorCodes[code] = (stats.errorCodes[code] ?? 0) + 1;
    return null;
  }
}

// ── Scenario 1: concurrent insert ────────────────────────────────────────
async function concurrentInsert() {
  const stats = makeStats(
    "concurrent_insert",
    `SCENARIO 1 · concurrent_insert · ${CLIENTS} clients × ${DURATION_SEC}s`,
  );
  const deadline = Date.now() + DURATION_SEC * 1000;

  async function clientLoop(clientIdx) {
    const client = new ConvexHttpClient(CONVEX_URL);
    const anonId = `loadtest-client-${clientIdx}`;
    // Seed the target entity for this client's session (idempotent).
    try {
      await client.mutation("domains/product/entities:ensureEntity", {
        anonymousSessionId: anonId,
        slug: args.entity,
        name: args.entity,
      });
    } catch (err) {
      stats.errors.push(`seed:${err?.message ?? String(err)}`);
      return;
    }
    let counter = 0;
    while (Date.now() < deadline) {
      counter += 1;
      await timed(
        () =>
          client.mutation("domains/product/blocks:appendBlock", {
            anonymousSessionId: anonId,
            entitySlug: args.entity,
            kind: "text",
            content: [
              { type: "text", value: `client-${clientIdx} append #${counter}` },
            ],
            authorKind: "user",
            authorId: anonId,
          }),
        stats,
      );
      // Let the loop yield so all clients overlap rather than serializing
      await new Promise((r) => setTimeout(r, 20));
    }
  }

  const workers = Array.from({ length: CLIENTS }, (_, i) => clientLoop(i));
  await Promise.all(workers);
  report(stats);
  return stats;
}

// ── Scenario 2: sustained append ─────────────────────────────────────────
async function sustainedAppend() {
  const stats = makeStats("sustained_append", `SCENARIO 2 · sustained_append · 500 blocks from 1 client`);
  const client = new ConvexHttpClient(CONVEX_URL);
  const anonId = `loadtest-sustained`;
  await client.mutation("domains/product/entities:ensureEntity", {
    anonymousSessionId: anonId,
    slug: args.entity,
    name: args.entity,
  });
  for (let i = 0; i < 500; i++) {
    await timed(
      () =>
        client.mutation("domains/product/blocks:appendBlock", {
          anonymousSessionId: anonId,
          entitySlug: args.entity,
          kind: "bullet",
          content: [{ type: "text", value: `sustained append ${i}` }],
          authorKind: "user",
          authorId: anonId,
        }),
      stats,
    );
  }

  // After appending, read with pagination and check we can walk the whole list.
  const paginatedStats = makeStats("paginated_read", `SCENARIO 2b · paginated read across 500 blocks`);
  let cursor = null;
  let pages = 0;
  let totalRead = 0;
  while (true) {
    const result = await timed(
      () =>
        client.query("domains/product/blocks:listEntityBlocksPaginated", {
          anonymousSessionId: anonId,
          entitySlug: args.entity,
          paginationOpts: { numItems: 50, cursor },
        }),
      paginatedStats,
    );
    if (!result) break;
    pages += 1;
    totalRead += result.page?.length ?? 0;
    if (result.isDone || !result.continueCursor) break;
    cursor = result.continueCursor;
    if (pages > 20) break; // safety net against infinite loop
  }
  console.log(`[loadtest] paginated read: ${pages} pages, ${totalRead} blocks total`);
  report(stats);
  report(paginatedStats);
  return [stats, paginatedStats];
}

// ── Scenario 3: multi-tab edit ───────────────────────────────────────────
async function multiTabEdit() {
  const stats = makeStats(
    "multi_tab_edit",
    `SCENARIO 3 · multi_tab_edit · ${CLIENTS} clients × 500ms cadence × ${DURATION_SEC}s`,
  );

  // Seed: create one block per client so each has a target to edit.
  const seedClient = new ConvexHttpClient(CONVEX_URL);
  const blockIds = [];
  for (let i = 0; i < CLIENTS; i++) {
    const anonId = `loadtest-editor-${i}`;
    // Ensure the entity exists for this client's session first.
    await seedClient.mutation("domains/product/entities:ensureEntity", {
      anonymousSessionId: anonId,
      slug: args.entity,
      name: args.entity,
    });
    const id = await seedClient.mutation("domains/product/blocks:appendBlock", {
      anonymousSessionId: anonId,
      entitySlug: args.entity,
      kind: "text",
      content: [{ type: "text", value: `editor-${i} seed` }],
      authorKind: "user",
      authorId: anonId,
    });
    blockIds.push({ anonId, id });
  }

  const deadline = Date.now() + DURATION_SEC * 1000;

  async function editorLoop({ anonId, id }) {
    const client = new ConvexHttpClient(CONVEX_URL);
    let rev = 0;
    while (Date.now() < deadline) {
      rev += 1;
      await timed(
        () =>
          client.mutation("domains/product/blocks:updateBlock", {
            anonymousSessionId: anonId,
            blockId: id,
            content: [
              { type: "text", value: `${anonId} rev ${rev} @ ${Date.now()}` },
            ],
            editedByAuthorKind: "user",
            editedByAuthorId: anonId,
          }),
        stats,
      );
      await new Promise((r) => setTimeout(r, 500));
    }
  }

  await Promise.all(blockIds.map(editorLoop));
  report(stats);
  return stats;
}

// ── Scenario 4: multi-tab conflict (revision guard exercise) ─────────────
// Two tabs share an anonymous session and both edit THE SAME block at high
// cadence. Each passes its last-seen `revision` as `expectedRevision`. The
// server accepts the first and rejects the stale second with
// `REVISION_MISMATCH`. The losing tab re-fetches and retries. A healthy
// result is:
//   - non-zero REVISION_MISMATCH count (the guard actually fires),
//   - zero non-conflict errors,
//   - successful writes > rejected writes by a wide margin (retries work).
async function multiTabConflict() {
  const stats = makeStats(
    "multi_tab_conflict",
    `SCENARIO 4 · multi_tab_conflict · 2 tabs × 1 block × ${DURATION_SEC}s`,
    ["REVISION_MISMATCH"],
  );
  const anonId = `loadtest-conflict-shared`;
  const seed = new ConvexHttpClient(CONVEX_URL);
  await seed.mutation("domains/product/entities:ensureEntity", {
    anonymousSessionId: anonId,
    slug: args.entity,
    name: args.entity,
  });
  const blockId = await seed.mutation("domains/product/blocks:appendBlock", {
    anonymousSessionId: anonId,
    entitySlug: args.entity,
    kind: "text",
    content: [{ type: "text", value: "conflict-target seed" }],
    authorKind: "user",
    authorId: anonId,
  });

  const deadline = Date.now() + DURATION_SEC * 1000;
  let acceptedWrites = 0;
  let conflictRejects = 0;
  let retriesThatSucceeded = 0;

  async function tabLoop(tabIdx) {
    const client = new ConvexHttpClient(CONVEX_URL);
    // Each tab keeps a local guess of the current revision. It will drift
    // when the other tab wins a race; we resync on conflict.
    let knownRevision = 1;
    let localRev = 0;
    while (Date.now() < deadline) {
      localRev += 1;
      const content = [
        { type: "text", value: `tab-${tabIdx} local rev ${localRev} @ ${Date.now()}` },
      ];
      const t0 = performance.now();
      try {
        await client.mutation("domains/product/blocks:updateBlock", {
          anonymousSessionId: anonId,
          blockId,
          content,
          expectedRevision: knownRevision,
          editedByAuthorKind: "user",
          editedByAuthorId: `${anonId}-tab-${tabIdx}`,
        });
        stats.latencies.push(performance.now() - t0);
        stats.success += 1;
        acceptedWrites += 1;
        // The server just incremented revision; our next write assumes this.
        knownRevision += 1;
      } catch (err) {
        stats.errors.push(err?.message ?? String(err));
        const code = extractErrorCode(err);
        stats.errorCodes[code] = (stats.errorCodes[code] ?? 0) + 1;
        if (code === "REVISION_MISMATCH") {
          conflictRejects += 1;
          // Resync from the structured payload and retry once.
          const data = extractErrorData(err);
          if (data && typeof data.current === "number") {
            knownRevision = data.current;
          } else {
            knownRevision += 1;
          }
          try {
            await client.mutation("domains/product/blocks:updateBlock", {
              anonymousSessionId: anonId,
              blockId,
              content,
              expectedRevision: knownRevision,
              editedByAuthorKind: "user",
              editedByAuthorId: `${anonId}-tab-${tabIdx}-retry`,
            });
            knownRevision += 1;
            retriesThatSucceeded += 1;
          } catch {
            // A second mismatch is possible under extreme load; we just drop
            // this iteration. The invariant we care about is that the server
            // never loses data, not that every retry wins.
          }
        }
      }
      // Slight jitter so both tabs don't move in lockstep.
      await new Promise((r) => setTimeout(r, 50 + Math.random() * 50));
    }
  }

  await Promise.all([tabLoop(0), tabLoop(1)]);
  console.log(
    `[loadtest] multi_tab_conflict breakdown: accepted=${acceptedWrites}, ` +
      `rejected_revision=${conflictRejects}, retries_recovered=${retriesThatSucceeded}`,
  );
  report(stats);
  return stats;
}

// ── Orchestrator ─────────────────────────────────────────────────────────
async function sharedSessionInsert() {
  const stats = makeStats(
    "shared_session_insert",
    `SCENARIO 5 · shared_session_insert · ${CLIENTS} shared-session clients × ${Math.min(DURATION_SEC, 30)}s`,
  );
  const anonId = "loadtest-rate-limit-shared";
  const seed = new ConvexHttpClient(CONVEX_URL);
  await seed.mutation("domains/product/entities:ensureEntity", {
    anonymousSessionId: anonId,
    slug: args.entity,
    name: args.entity,
  });

  const scenarioDurationMs = Math.min(DURATION_SEC, 30) * 1000;
  const deadline = Date.now() + scenarioDurationMs;

  async function clientLoop(clientIdx) {
    const client = new ConvexHttpClient(CONVEX_URL);
    let counter = 0;
    while (Date.now() < deadline) {
      counter += 1;
      const t0 = performance.now();
      try {
        await client.mutation("domains/product/blocks:appendBlock", {
          anonymousSessionId: anonId,
          entitySlug: args.entity,
          kind: "text",
          content: [{ type: "text", value: `rate-limit client-${clientIdx} append #${counter}` }],
          authorKind: "user",
          authorId: `${anonId}-${clientIdx}`,
        });
        stats.latencies.push(performance.now() - t0);
        stats.success += 1;
      } catch (err) {
        stats.errors.push(err?.message ?? String(err));
        const code = extractErrorCode(err);
        stats.errorCodes[code] = (stats.errorCodes[code] ?? 0) + 1;
      }
      await new Promise((r) => setTimeout(r, 20));
    }
  }

  await Promise.all(Array.from({ length: CLIENTS }, (_, i) => clientLoop(i)));
  report(stats);
  return stats;
}

async function actorRateLimitGuard() {
  const stats = makeStats(
    "actor_rate_limit_guard",
    `SCENARIO 6 · actor_rate_limit_guard · ${Math.max(CLIENTS, 20)} shared-session clients × same actor`,
    ["RATE_LIMITED"],
  );
  const anonId = "loadtest-rate-limit-guard";
  const authorId = "loadtest-rate-limit-actor";
  const seed = new ConvexHttpClient(CONVEX_URL);
  await seed.mutation("domains/product/entities:ensureEntity", {
    anonymousSessionId: anonId,
    slug: args.entity,
    name: args.entity,
  });

  const guardClients = Math.max(CLIENTS, 20);
  const guardDurationMs = Math.min(Math.max(DURATION_SEC, 10), 20) * 1000;
  const deadline = Date.now() + guardDurationMs;

  async function clientLoop(clientIdx) {
    const client = new ConvexHttpClient(CONVEX_URL);
    let counter = 0;
    while (Date.now() < deadline) {
      counter += 1;
      const t0 = performance.now();
      try {
        await client.mutation("domains/product/blocks:appendBlock", {
          anonymousSessionId: anonId,
          entitySlug: args.entity,
          kind: "text",
          content: [{ type: "text", value: `guard client-${clientIdx} append #${counter}` }],
          authorKind: "user",
          authorId,
        });
        stats.latencies.push(performance.now() - t0);
        stats.success += 1;
      } catch (err) {
        stats.errors.push(err?.message ?? String(err));
        const code = extractErrorCode(err);
        stats.errorCodes[code] = (stats.errorCodes[code] ?? 0) + 1;
      }
    }
  }

  await Promise.all(Array.from({ length: guardClients }, (_, index) => clientLoop(index)));
  if ((stats.errorCodes.RATE_LIMITED ?? 0) === 0) {
    stats.errors.push("Expected RATE_LIMITED but none were observed.");
    stats.errorCodes.RATE_LIMIT_EXPECTED_MISSING =
      (stats.errorCodes.RATE_LIMIT_EXPECTED_MISSING ?? 0) + 1;
  }
  report(stats);
  return stats;
}

async function spikeInsert() {
  const stats = makeStats(
    "spike_insert",
    `SCENARIO 7 · spike_insert · ${CLIENTS} clients × ${Math.min(DURATION_SEC, 10)}s zero-ramp burst`,
  );
  const deadline = Date.now() + Math.min(DURATION_SEC, 10) * 1000;

  async function clientLoop(clientIdx) {
    const client = new ConvexHttpClient(CONVEX_URL);
    const anonId = `loadtest-spike-${clientIdx}`;
    await client.mutation("domains/product/entities:ensureEntity", {
      anonymousSessionId: anonId,
      slug: args.entity,
      name: args.entity,
    });
    let counter = 0;
    while (Date.now() < deadline) {
      counter += 1;
      await timed(
        () =>
          client.mutation("domains/product/blocks:appendBlock", {
            anonymousSessionId: anonId,
            entitySlug: args.entity,
            kind: "text",
            content: [{ type: "text", value: `spike client-${clientIdx} append #${counter}` }],
            authorKind: "user",
            authorId: anonId,
          }),
        stats,
      );
      if (counter % 10 === 0) {
        await new Promise((resolve) => setTimeout(resolve, 0));
      }
    }
  }

  await Promise.all(Array.from({ length: CLIENTS }, (_, index) => clientLoop(index)));
  report(stats);
  return stats;
}

async function soakMixed() {
  const stats = makeStats(
    "soak_mixed",
    `SCENARIO 8 · soak_mixed · ${CLIENTS} shared-session clients × ${DURATION_SEC}s mixed append/update/read`,
  );
  const anonId = "loadtest-soak-shared";
  const seed = new ConvexHttpClient(CONVEX_URL);
  await seed.mutation("domains/product/entities:ensureEntity", {
    anonymousSessionId: anonId,
    slug: args.entity,
    name: args.entity,
  });

  const seededBlocks = [];
  for (let index = 0; index < CLIENTS; index += 1) {
    const blockId = await seed.mutation("domains/product/blocks:appendBlock", {
      anonymousSessionId: anonId,
      entitySlug: args.entity,
      kind: "text",
      content: [{ type: "text", value: `soak seed block ${index}` }],
      authorKind: "user",
      authorId: `${anonId}-seed-${index}`,
    });
    seededBlocks.push(blockId);
  }

  const deadline = Date.now() + DURATION_SEC * 1000;

  async function clientLoop(clientIdx) {
    const client = new ConvexHttpClient(CONVEX_URL);
    const authorId = `${anonId}-client-${clientIdx}`;
    const ownedBlocks = seededBlocks[clientIdx] ? [{ id: seededBlocks[clientIdx], revision: 1 }] : [];
    let iteration = 0;
    while (Date.now() < deadline) {
      iteration += 1;
      if (iteration % 6 === 0) {
        await timed(
          () =>
            client.query("domains/product/blocks:listEntityBlocksPaginated", {
              anonymousSessionId: anonId,
              entitySlug: args.entity,
              paginationOpts: { numItems: 50, cursor: null },
            }),
          stats,
        );
      } else if (iteration % 2 === 0 && ownedBlocks.length > 0) {
        const target = ownedBlocks[iteration % ownedBlocks.length];
        const result = await timed(
          () =>
            client.mutation("domains/product/blocks:updateBlock", {
              anonymousSessionId: anonId,
              blockId: target.id,
              expectedRevision: target.revision,
              content: [{ type: "text", value: `soak ${authorId} rev ${iteration}` }],
              editedByAuthorKind: "user",
              editedByAuthorId: authorId,
            }),
          stats,
        );
        if (result) {
          target.revision += 1;
        }
      } else {
        const blockId = await timed(
          () =>
            client.mutation("domains/product/blocks:appendBlock", {
              anonymousSessionId: anonId,
              entitySlug: args.entity,
              kind: "text",
              content: [{ type: "text", value: `soak ${authorId} append ${iteration}` }],
              authorKind: "user",
              authorId,
            }),
          stats,
        );
        if (blockId) {
          ownedBlocks.push({ id: blockId, revision: 1 });
        }
      }
      await new Promise((resolve) => setTimeout(resolve, 150));
    }
  }

  await Promise.all(Array.from({ length: CLIENTS }, (_, index) => clientLoop(index)));
  report(stats);
  return stats;
}

async function main() {
  const scenarios = {
    concurrent_insert: concurrentInsert,
    sustained_append: sustainedAppend,
    multi_tab_edit: multiTabEdit,
    multi_tab_conflict: multiTabConflict,
    shared_session_insert: sharedSessionInsert,
    actor_rate_limit_guard: actorRateLimitGuard,
    spike_insert: spikeInsert,
    soak_mixed: soakMixed,
  };

  const selected =
    args.scenario === "all"
      ? [
          "concurrent_insert",
          "sustained_append",
          "multi_tab_edit",
          "multi_tab_conflict",
          "shared_session_insert",
        ]
      : [args.scenario];
  const results = [];
  for (const name of selected) {
    const fn = scenarios[name];
    if (!fn) {
      console.error(`Unknown scenario: ${name}`);
      process.exit(2);
    }
    const outcome = await fn();
    if (Array.isArray(outcome)) {
      results.push(...outcome);
    } else {
      results.push(outcome);
    }
  }

  console.log("=== SUMMARY ===");
  const summary = results.map(summarizeStats);
  console.log("scenario\tok/total\tp50\tp95\tp99\terror%");
  for (const row of summary) {
    console.log(
      `${row.scenario}\t${row.ok}/${row.total}\t${row.p50 ?? "-"}ms\t${row.p95 ?? "-"}ms\t${row.p99 ?? "-"}ms\t${row.errorRatePct.toFixed(2)}%`,
    );
  }

  if (args.jsonOut) {
    writeFileSync(
      args.jsonOut,
      JSON.stringify(
        {
          convexUrl: CONVEX_URL,
          entitySlug: args.entity,
          clients: CLIENTS,
          durationSec: DURATION_SEC,
          generatedAt: new Date().toISOString(),
          summary,
        },
        null,
        2,
      ),
      "utf8",
    );
    console.log(`[loadtest] wrote JSON summary to ${args.jsonOut}`);
  }

  // CI gate: >5% *unexpected* error rate fails the build. We do NOT count
  // REVISION_MISMATCH as a failure — the multi_tab_conflict scenario
  // deliberately causes them to prove the revision guard holds. A successful
  // retry is invisible to this ratio; a data-loss bug would show up as
  // SERVER_ERROR or UNKNOWN above the threshold.
  const failingScenarios = results.filter((s) => {
    const total = s.latencies.length + s.errors.length;
    if (total === 0) return false;
    const expectedCodes = new Set(["REVISION_MISMATCH", ...(s.expectedErrorCodes ?? [])]);
    const unexpected = Object.entries(s.errorCodes)
      .filter(([code]) => !expectedCodes.has(code))
      .reduce((sum, [, count]) => sum + count, 0);
    return unexpected / total > 0.05;
  });

  // Real-time alert on CI-gate failure. POST to ntfy so the on-call knows
  // before the next CI cycle's Slack notification. Set LOAD_TEST_NTFY_URL
  // in the CI environment (keep distinct from the client-side topic so
  // load-test noise doesn't pollute user-facing alerts).
  // Fail-open: ntfy down => exit code is still correct; we just skip paging.
  if (failingScenarios.length > 0 && process.env.LOAD_TEST_NTFY_URL) {
    try {
      const first = failingScenarios[0];
      const codeSummary = Object.entries(first.errorCodes)
        .filter(([code]) => !new Set(["REVISION_MISMATCH", ...(first.expectedErrorCodes ?? [])]).has(code))
        .map(([code, count]) => `${code}:${count}`)
        .join(", ");
      const body =
        `Scenarios failing: ${failingScenarios.length}\n` +
        `First: ${first.label}\n` +
        `Unexpected errors: ${codeSummary}\n` +
        `Convex: ${CONVEX_URL}\n` +
        `Entity: ${args.entity}`;
      await fetch(process.env.LOAD_TEST_NTFY_URL, {
        method: "POST",
        headers: {
          Title: `[P0] Notebook load test failed (${failingScenarios.length} scenarios)`,
          Priority: "5",
          Tags: "rotating_light,warning",
        },
        body,
      });
      console.log("[loadtest] ntfy alert sent");
    } catch (err) {
      console.warn("[loadtest] ntfy alert failed (fail-open):", err?.message ?? err);
    }
  }

  process.exit(failingScenarios.length > 0 ? 1 : 0);
}

main().catch((err) => {
  console.error("[loadtest] fatal:", err);
  process.exit(2);
});
