#!/usr/bin/env node
/*
 * scripts/bootstrap-hub.ts — production bootstrap for the priority-forge memory hub
 *
 * Single-shot automation that takes a Postgres connection string + team id +
 * list of agents and outputs a fully-provisioned hub ready for cross-forge use.
 *
 * What it does (each step idempotent — re-run is safe):
 *   1. Connects to the DB.
 *   2. Runs CREATE EXTENSION IF NOT EXISTS vector (RDS master has the privilege).
 *   3. Applies packages/priority-forge-hub/src/adapters/setup-pgvector.sql.
 *   4. Applies packages/priority-forge-hub/src/adapters/setup-shared-tasks.sql.
 *   5. Generates a 32-byte token per agent, hashes (SHA-256), INSERTs into
 *      agent_identities with ON CONFLICT DO UPDATE so re-running rotates.
 *   6. Writes team-tokens-<ts>.json containing per-agent rows for secure
 *      out-of-band distribution. NEVER commit this file.
 *   7. Runs a 6-step end-to-end smoke test against the live pgvector adapter,
 *      exercising create / claim / renew / release / get + claim history.
 *   8. Prints next steps for both the operator and each teammate.
 *
 * Usage:
 *   npx tsx scripts/bootstrap-hub.ts \
 *     --conn 'postgres://user:pass@host:5432/db' \
 *     --team-id team_xxx \
 *     --agents nick@team.com,teammate@team.com \
 *     [--skip-smoke] \
 *     [--output ./team-tokens-<ts>.json] \
 *     [--smoke-as-agent nick@team.com]   # which agent's token to use for smoke
 *
 * Exits 0 on success, non-zero on first failure with the failing step named.
 */

import { createHash, randomBytes } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { Pool } from 'pg';
import { PgvectorMemoryHub } from 'priority-forge-hub';

interface Args {
  conn: string;
  teamId: string;
  agents: string[];
  skipSmoke: boolean;
  output: string;
  smokeAsAgent?: string;
  /**
   * When true: agents that already exist in agent_identities for the
   * given teamId are skipped (token NOT rotated, no row in the output
   * file). Only NEW agents get registered and have tokens generated.
   * Use when onboarding an additional teammate without disrupting
   * already-distributed tokens for existing members.
   *
   * When false (default): every agent in --agents has their token
   * rotated via INSERT ... ON CONFLICT DO UPDATE.
   */
  addOnly: boolean;
  /**
   * Symmetric to --add-only. When true: agents in --agents that DO NOT
   * yet exist in agent_identities are SKIPPED (won't register a new
   * row). Only EXISTING agents have their tokens rotated. Use for
   * compromise-response rotation: re-issue tokens for known members
   * without accidentally bringing a typo'd name into existence.
   * Mutually exclusive with --add-only.
   */
  rotateOnly: boolean;
}

function parseArgs(argv: string[]): Args {
  const get = (flag: string): string | undefined => {
    const idx = argv.indexOf(flag);
    if (idx < 0 || idx + 1 >= argv.length) return undefined;
    return argv[idx + 1];
  };
  const has = (flag: string): boolean => argv.includes(flag);

  const conn = get('--conn');
  const teamId = get('--team-id');
  const agentsRaw = get('--agents');
  if (!conn) die('missing --conn <postgres URL>');
  if (!teamId) die('missing --team-id <team_slug>');
  if (!agentsRaw) die('missing --agents <comma-separated>');
  const agents = agentsRaw!
    .split(',')
    .map((s) => s.trim())
    .filter(Boolean);
  if (agents.length === 0) die('--agents resolved to empty list');

  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
  const output = get('--output') ?? `./team-tokens-${stamp}.json`;
  const smokeAsAgent = get('--smoke-as-agent') ?? agents[0];

  const addOnly = has('--add-only');
  const rotateOnly = has('--rotate-only');
  if (addOnly && rotateOnly) {
    die('--add-only and --rotate-only are mutually exclusive');
  }

  return {
    conn: conn!,
    teamId: teamId!,
    agents,
    skipSmoke: has('--skip-smoke'),
    output,
    smokeAsAgent,
    addOnly,
    rotateOnly,
  };
}

function die(msg: string): never {
  console.error(`[bootstrap-hub] ERROR: ${msg}`);
  process.exit(1);
}

function generateToken(): string {
  // 32 random bytes → 43 chars of URL-safe base64. ~256 bits of entropy.
  return randomBytes(32).toString('base64url');
}

function sha256(s: string): Buffer {
  return createHash('sha256').update(s).digest();
}

async function applySqlFile(pool: Pool, fileLabel: string, sqlPath: string): Promise<void> {
  const sql = fs.readFileSync(sqlPath, 'utf8');
  console.log(`[bootstrap-hub] applying ${fileLabel} from ${sqlPath} ...`);
  await pool.query(sql);
  console.log(`[bootstrap-hub]   ✓ ${fileLabel} applied`);
}

interface OutputAgentRow {
  agentId: string;
  agentToken: string;
  configSnippet: string;
}

interface OutputFile {
  teamId: string;
  connectionString: string;
  registeredAt: string;
  agents: OutputAgentRow[];
  _distribution_note: string;
}

function printNextSteps(
  args: Args,
  outputRows: OutputAgentRow[],
  skippedAgents: string[],
): void {
  const newCount = outputRows.length;
  const skippedCount = skippedAgents.length;
  console.log('');
  console.log('═══════════════════════════════════════════════════════════════');
  console.log(' BOOTSTRAP COMPLETE');
  console.log('═══════════════════════════════════════════════════════════════');
  console.log(`  Team:       ${args.teamId}`);
  if (args.addOnly) {
    console.log(`  Agents:     ${newCount} newly registered, ${skippedCount} preserved (--add-only)`);
    if (skippedCount > 0) {
      console.log(`  Skipped:    ${skippedAgents.join(', ')}`);
    }
  } else if (args.rotateOnly) {
    console.log(`  Agents:     ${newCount} rotated, ${skippedCount} skipped as unknown (--rotate-only)`);
    if (skippedCount > 0) {
      console.log(`  Skipped:    ${skippedAgents.join(', ')}`);
    }
  } else {
    console.log(`  Agents:     ${args.agents.length} registered (tokens rotated)`);
  }
  console.log(`  Output:     ${path.resolve(args.output)}`);
  console.log('');
  console.log(' NEXT STEPS');
  console.log('');
  console.log(' 1. Add the output filename to .gitignore if not already.');
  console.log('    (team-tokens-*.json is in the repo .gitignore by default.)');
  console.log('');
  console.log(' 2. Configure THIS forge:');
  console.log('    Open the output file, find your agent row, paste the');
  console.log('    configSnippet into the MCP xmem_enable_sync tool (or');
  console.log('    paste the same fields into ~/.priority-forge/xmem.json).');
  console.log('');
  console.log(' 3. Distribute teammate row(s) out-of-band:');
  console.log('    1Password Share / Signal / in-person. NOT email, NOT git.');
  console.log('');
  console.log(' 4. Smoke check across forges:');
  console.log('    - On forge A: shared_task_create({title: "hello"})');
  console.log('    - On forge B: shared_task_list({status: "claimable"})');
  console.log('    - On forge B: shared_task_claim({sharedTaskId: <id>})');
  console.log('    - On forge B: complete_task({id: <local-id>, outcome: "completed"})');
  console.log('    - On forge A: shared_task_get({sharedTaskId: <id>}) → status=completed');
  console.log('═══════════════════════════════════════════════════════════════');
}

async function main(): Promise<void> {
  const args = parseArgs(process.argv.slice(2));

  const repoRoot = path.resolve(__dirname, '..');
  const pgvectorSql = path.join(
    repoRoot,
    'packages/priority-forge-hub/src/adapters/setup-pgvector.sql',
  );
  const sharedTasksSql = path.join(
    repoRoot,
    'packages/priority-forge-hub/src/adapters/setup-shared-tasks.sql',
  );
  if (!fs.existsSync(pgvectorSql)) die(`schema file not found: ${pgvectorSql}`);
  if (!fs.existsSync(sharedTasksSql)) die(`schema file not found: ${sharedTasksSql}`);

  console.log('[bootstrap-hub] connecting ...');
  const pool = new Pool({ connectionString: args.conn });

  // Declared at function scope so printNextSteps (run after the finally
  // closes the pool) can see them, and so the --add-only early-return
  // path can populate them before bailing.
  const outputRows: OutputAgentRow[] = [];
  const skippedAgents: string[] = [];

  try {
    // 1. Probe connection
    const probe = await pool.query('SELECT version() AS v');
    console.log(`[bootstrap-hub]   ✓ connected: ${probe.rows[0].v.split(' ').slice(0, 2).join(' ')}`);

    // 2. Enable pgvector extension
    console.log('[bootstrap-hub] enabling vector extension ...');
    await pool.query('CREATE EXTENSION IF NOT EXISTS vector');
    console.log('[bootstrap-hub]   ✓ vector extension active');

    // 3. Apply schemas
    await applySqlFile(pool, 'team_memory_events schema', pgvectorSql);
    await applySqlFile(pool, 'shared_tasks + agent_identities schema', sharedTasksSql);

    // 4. Register agents
    //
    // Default behavior: rotate every agent's token via INSERT ... ON
    // CONFLICT DO UPDATE.
    //
    // --add-only: skip agents that already exist in agent_identities;
    // generate tokens only for NEW agents. Used when onboarding a
    // teammate without rotating tokens that other teammates are
    // already using. INSERT uses ON CONFLICT DO NOTHING as belt-and-
    // suspenders against a race between the SELECT check and the
    // INSERT — if a row appeared between the two, we just leave it
    // alone instead of overwriting.
    const modeNote = args.addOnly
      ? ' (--add-only: existing agents will be skipped)'
      : args.rotateOnly
        ? ' (--rotate-only: unknown agents will be skipped)'
        : '';
    console.log(
      `[bootstrap-hub] registering ${args.agents.length} agent(s)${modeNote} ...`,
    );
    for (const agentId of args.agents) {
      if (args.addOnly) {
        const existing = await pool.query(
          `SELECT 1 FROM agent_identities WHERE team_id = $1 AND agent_id = $2`,
          [args.teamId, agentId],
        );
        if (existing.rowCount && existing.rowCount > 0) {
          console.log(
            `[bootstrap-hub]   ⊘ skipped ${agentId} (already registered, --add-only)`,
          );
          skippedAgents.push(agentId);
          continue;
        }
      } else if (args.rotateOnly) {
        const existing = await pool.query(
          `SELECT 1 FROM agent_identities WHERE team_id = $1 AND agent_id = $2`,
          [args.teamId, agentId],
        );
        if (!existing.rowCount || existing.rowCount === 0) {
          console.log(
            `[bootstrap-hub]   ⊘ skipped ${agentId} (not yet registered, --rotate-only)`,
          );
          skippedAgents.push(agentId);
          continue;
        }
      }
      const token = generateToken();
      const hash = sha256(token);
      // --add-only:    DO NOTHING (we already confirmed the row didn't
      //                exist; race-protect against concurrent INSERT)
      // --rotate-only: DO UPDATE (we already confirmed the row DID
      //                exist; this is the rotation)
      // default:       DO UPDATE (idempotent upsert)
      await pool.query(
        args.addOnly
          ? `INSERT INTO agent_identities (team_id, agent_id, token_hash)
             VALUES ($1, $2, $3)
             ON CONFLICT (team_id, agent_id) DO NOTHING`
          : `INSERT INTO agent_identities (team_id, agent_id, token_hash)
             VALUES ($1, $2, $3)
             ON CONFLICT (team_id, agent_id)
             DO UPDATE SET token_hash = EXCLUDED.token_hash, registered_at = now()`,
        [args.teamId, agentId, hash],
      );
      const configSnippet =
        `xmem_enable_sync({\n` +
        `  backend: 'pgvector',\n` +
        `  teamId: '${args.teamId}',\n` +
        `  agentId: '${agentId}',\n` +
        `  connectionString: '<paste connection string here>',\n` +
        `  agentToken: '${token}'\n` +
        `})`;
      outputRows.push({ agentId, agentToken: token, configSnippet });
      console.log(`[bootstrap-hub]   ✓ registered ${agentId}`);
    }
    if (args.addOnly && outputRows.length === 0) {
      console.log(
        '[bootstrap-hub] --add-only: all requested agents already registered, no new tokens issued',
      );
    }
    if (args.rotateOnly && outputRows.length === 0) {
      console.log(
        '[bootstrap-hub] --rotate-only: none of the requested agents are registered yet, no tokens rotated',
      );
    }

    // 5. Write output file
    const outputData: OutputFile = {
      teamId: args.teamId,
      connectionString: args.conn,
      registeredAt: new Date().toISOString(),
      agents: outputRows,
      _distribution_note:
        'Share each agent row with that agent only, via 1Password Share / Signal / in-person. DO NOT commit, DO NOT email. Tokens cannot be recovered if lost — re-run bootstrap-hub to rotate.',
    };
    fs.writeFileSync(args.output, JSON.stringify(outputData, null, 2), {
      mode: 0o600, // owner-only read/write
    });
    console.log(`[bootstrap-hub]   ✓ tokens written to ${args.output} (mode 0600)`);

    // 6. Smoke test
    //
    // With --add-only, the smoke agent's token is only available in
    // outputRows if the agent was NEWLY registered this run. If the
    // smoke agent was skipped (already registered), we have no plaintext
    // token to authenticate with — auto-skip the smoke and tell the user.
    if (args.skipSmoke) {
      console.log('[bootstrap-hub] --skip-smoke set; skipping smoke test');
    } else if ((args.addOnly || args.rotateOnly) && outputRows.length === 0) {
      const flag = args.addOnly ? '--add-only' : '--rotate-only';
      console.log(
        `[bootstrap-hub] ${flag} produced no new tokens; auto-skipping smoke (no fresh token to authenticate with)`,
      );
    } else {
      const smokeAgent = args.smokeAsAgent!;
      const smokeRow = outputRows.find((r) => r.agentId === smokeAgent);
      if (!smokeRow) {
        if (args.addOnly || args.rotateOnly) {
          const flag = args.addOnly ? '--add-only' : '--rotate-only';
          console.log(
            `[bootstrap-hub] ${flag}: smoke agent '${smokeAgent}' was skipped (not in newly-issued tokens). Auto-skipping smoke. Re-run without ${flag} to issue+smoke as this agent, or pass --smoke-as-agent <agent in the newly-issued set>.`,
          );
          // Fall through to next-steps print without running smoke.
          await pool.end();
          printNextSteps(args, outputRows, skippedAgents);
          return;
        }
        die(`--smoke-as-agent '${smokeAgent}' not in --agents list`);
      }
      console.log(`[bootstrap-hub] smoke test (as ${smokeAgent}) ...`);
      const hub = new PgvectorMemoryHub({
        connectionString: args.conn,
        teamId: args.teamId,
        agentId: smokeAgent,
        agentToken: smokeRow!.agentToken,
      });

      // 6a. create
      const created = await hub.create({
        teamId: args.teamId,
        title: `[bootstrap smoke ${new Date().toISOString()}]`,
        description: 'auto-generated by scripts/bootstrap-hub.ts',
        originatorAgentId: smokeAgent,
        originatorTeamId: args.teamId,
      });
      if (created.status !== 'claimable') {
        die(`smoke create: expected status=claimable, got ${created.status}`);
      }
      console.log(`[bootstrap-hub]   ✓ create() → ${created.id} (claimable)`);

      // 6b. claim
      const claimResult = await hub.claim(created.id, smokeAgent, args.teamId, 60_000);
      if (!claimResult.ok) {
        die(`smoke claim: ok=false, reason=${claimResult.reason}`);
      }
      console.log(`[bootstrap-hub]   ✓ claim() → ok, leaseUntil=${claimResult.claim.leaseUntil}`);

      // 6c. renew
      await new Promise((r) => setTimeout(r, 50));
      const renew = await hub.renewLease(created.id, smokeAgent, 120_000);
      if (!renew.ok || !renew.claim) {
        die(`smoke renew: ok=false, reason=${renew.reason}`);
      }
      const renewedLeaseUntil = renew.claim.leaseUntil;
      console.log(`[bootstrap-hub]   ✓ renewLease() → leaseUntil=${renewedLeaseUntil}`);

      // 6d. release
      const releaseResult = await hub.release(created.id, smokeAgent, 'completed');
      if (!releaseResult.ok || releaseResult.sharedTask?.status !== 'completed') {
        die(`smoke release: ok=${releaseResult.ok}, status=${releaseResult.sharedTask?.status}`);
      }
      console.log('[bootstrap-hub]   ✓ release() → status=completed');

      // 6e. get + claimHistory
      const final = await hub.get(created.id);
      if (!final) die('smoke get: returned null');
      const events = final.claimHistory.map((e) => e.event);
      const expectedEvents = ['claimed', 'renewed', 'released'];
      for (const ev of expectedEvents) {
        if (!events.includes(ev as 'claimed' | 'renewed' | 'released')) {
          die(`smoke get: claimHistory missing ${ev} (got: ${events.join(', ')})`);
        }
      }
      console.log(`[bootstrap-hub]   ✓ get() + claimHistory → ${events.join(' → ')}`);

      // 6f. unauthorized agent check (verifyAgent works)
      const wrongHub = new PgvectorMemoryHub({
        connectionString: args.conn,
        teamId: args.teamId,
        agentId: smokeAgent,
        agentToken: 'wrong-token-' + Date.now(),
      });
      const wrongResult = await wrongHub.claim(created.id, smokeAgent, args.teamId, 60_000);
      if (wrongResult.ok || wrongResult.reason !== 'unauthorized_agent') {
        die(
          `smoke unauthorized: expected reason=unauthorized_agent, got ok=${wrongResult.ok} reason=${
            'reason' in wrongResult ? wrongResult.reason : 'n/a'
          }`,
        );
      }
      console.log('[bootstrap-hub]   ✓ verifyAgent() rejects bad token');

      console.log('[bootstrap-hub] SMOKE TEST PASSED');
    }
  } finally {
    await pool.end();
  }

  printNextSteps(args, outputRows, skippedAgents);
}

main().catch((err) => {
  console.error('[bootstrap-hub] FAILED:', err instanceof Error ? err.message : err);
  if (err instanceof Error && err.stack) console.error(err.stack);
  process.exit(1);
});
