/**
 * Demo dataset — a plausible book of business, for development and demos.
 *
 * Separate from `index.ts` (which seeds publicly-sourced, cited people) because
 * this is **invented**. It exists so the product is legible before anyone has
 * entered real data: margin that moves, blocks at different utilisation, deals
 * spread across both pipelines, contracts with real structure.
 *
 * Two integrity rules, and they are not fussiness:
 *
 *   **Every record is prefixed `DEMO —`.** A screenshot of this must never be
 *   mistakeable for real business.
 *
 *   **Demand-side customers are fictional.** Suppliers are real companies —
 *   they are public, and naming the actual market is the point — but their
 *   commitments are labelled and the prices are illustrative. Inventing
 *   *customers* with invented contract values against real named companies
 *   would be fabricating commercial records about real businesses, which is a
 *   different thing entirely and not worth the realism.
 *
 * Remove it all with `pnpm db:demo -- --clear`.
 *
 * The numbers are chosen to teach. The book as a whole clears a modest margin —
 * roughly what this industry actually earns once capacity cost is charged
 * honestly — while individual blocks tell different stories:
 *
 *   the large H200 block carries the book;
 *   the EU H100 block is UNDERWATER at 55% sold, because a 46% markup needs
 *     ~69% sold to break even at all;
 *   the community A100 pool has a large hold that has not converted, so it
 *     shows as reserved-but-unsold — the distinction between "sold" and "held"
 *     made visible rather than theoretical.
 *
 * A demo that opens on a healthy total and reveals the problems on drill-down
 * is more useful than one that opens on a loss, which reads as a broken
 * product rather than an under-utilised book.
 */
import {
  ALLOCATION_STATUSES,
  LEARN_MEDIA_PATH_PREFIX,
  isLearnMediaFilename,
  quarterBoundsFor,
  type AllocationStatus,
} from '@pig/core';
import { and, eq, like, or } from 'drizzle-orm';
import { readdir } from 'node:fs/promises';
import { resolve } from 'node:path';
import { createDatabase } from '../client';
import {
  accounts,
  activities,
  facts,
  allocations,
  calendarEntries,
  capacityCommitments,
  capacityRequests,
  complianceArtifacts,
  contacts,
  contracts,
  contractObligations,
  demandDeals,
  exportAuthorizations,
  learnResources,
  type NewAllocation,
  sites,
  slaTerms,
  supplyDeals,
  users,
} from '../schema/index';

const db = createDatabase();
const PREFIX = 'DEMO — ';

const day = 86_400_000;
const now = Date.now();
const at = (days: number) => new Date(now + days * day);

function isAllocationStatus(value: string): value is AllocationStatus {
  return (ALLOCATION_STATUSES as readonly string[]).includes(value);
}

/**
 * Dates are placed by QUARTER, and deterministically.
 *
 * This file used to scatter close dates with `at(20 + Math.random() * 60)`,
 * which put the whole book in one arbitrary bucket, differently on every run —
 * so the quarterly view could not be demonstrated and the CI seed-idempotency
 * gate was one unlucky reseed away from a false failure. Placement is now
 * deliberate: something in the quarter just gone, several in the one we are
 * in, and a couple in the next, so the calendar has all three states to show.
 */
const thisQuarter = quarterBoundsFor(new Date(now));

function quarterAt(offset: -1 | 0 | 1, fraction: number): Date {
  const bounds =
    offset === 0
      ? thisQuarter
      : quarterBoundsFor(
          new Date(
            offset < 0 ? thisQuarter.from.getTime() - 1 : thisQuarter.to.getTime(),
          ),
        );
  const span = bounds.to.getTime() - bounds.from.getTime();
  return new Date(bounds.from.getTime() + Math.round(span * fraction));
}

/** GPU-hours for a block, allowing for a maintenance/ramp haircut. */
const hours = (gpus: number, days: number, efficiency = 0.94) =>
  String(Math.round(gpus * 24 * days * efficiency));

// ---------------------------------------------------------------- suppliers

const SUPPLY = [
  {
    domain: 'coreweave.com',
    site: { name: 'US-East (NJ)', country: 'United States', countryCode: 'US', region: 'us-east' },
    commitment: {
      name: `${PREFIX}512× H200 reserved, 12 months`,
      gpuType: 'H200',
      socket: 'SXM5' as const,
      gpuCount: 512,
      interconnectType: 'Infiniband' as const,
      days: 365,
      costPerGpuHourCents: 189,
      takeOrPayFloorPct: '100',
      prepaidPct: '25',
    },
  },
  {
    domain: 'nebius.com',
    site: { name: 'EU-North (FI)', country: 'Finland', countryCode: 'FI', region: 'eu-north' },
    commitment: {
      name: `${PREFIX}128× H100 reserved, 6 months`,
      gpuType: 'H100_80GB',
      socket: 'SXM5' as const,
      gpuCount: 128,
      interconnectType: 'Infiniband' as const,
      days: 180,
      costPerGpuHourCents: 171,
      takeOrPayFloorPct: '80',
      prepaidPct: '0',
    },
  },
  {
    domain: 'crusoe.ai',
    site: { name: 'US-Central (TX)', country: 'United States', countryCode: 'US', region: 'us-central' },
    commitment: {
      name: `${PREFIX}64× B200 reserved, 9 months`,
      gpuType: 'B200',
      socket: 'SXM6' as const,
      gpuCount: 64,
      interconnectType: 'Infiniband' as const,
      days: 270,
      costPerGpuHourCents: 305,
      takeOrPayFloorPct: '100',
      prepaidPct: '15',
    },
  },
  {
    domain: 'runpod.io',
    site: { name: 'Community pool', country: 'United States', countryCode: 'US', region: 'us-west' },
    commitment: {
      name: `${PREFIX}32× A100 burst pool, 3 months`,
      gpuType: 'A100_80GB',
      socket: 'SXM4' as const,
      gpuCount: 32,
      interconnectType: 'Ethernet' as const,
      securityTier: 'community_cloud' as const,
      days: 90,
      costPerGpuHourCents: 96,
      takeOrPayFloorPct: '0',
      prepaidPct: '0',
    },
  },
];

/**
 * Dated obligations per supplier, spread deliberately across the year.
 *
 * `kind` is one of the five the schema allows. The near-term Nebius notice is
 * kept so the renewal alarm still has something to fire on today.
 */
const OBLIGATION_SCHEDULE: Record<
  string,
  { title: string; kind: 'renewal_notice' | 'payment' | 'true_up'; inDays: number; description: string }[]
> = {
  'nebius.com': [
    {
      title: 'Renewal notice',
      kind: 'renewal_notice',
      inDays: 21,
      description: '90 days notice required to prevent auto-renewal.',
    },
    {
      title: 'Quarterly instalment',
      kind: 'payment',
      inDays: 75,
      description: 'Committed spend invoiced quarterly in arrears.',
    },
  ],
  'coreweave.com': [
    {
      title: 'Renewal notice',
      kind: 'renewal_notice',
      inDays: 95,
      description: '90 days notice required to prevent auto-renewal.',
    },
    {
      title: 'Prepayment drawdown reconciliation',
      kind: 'payment',
      inDays: 40,
      description: 'Reconcile the 25% prepayment against hours actually drawn.',
    },
    {
      title: 'Take-or-pay true-up',
      kind: 'true_up',
      inDays: 130,
      // The obligation that turns idle capacity from a metric into an invoice.
      description: 'Shortfall against the 100% floor becomes payable at the true-up date.',
    },
  ],
  'crusoe.ai': [
    {
      title: 'Renewal notice',
      kind: 'renewal_notice',
      inDays: 160,
      description: '90 days notice required to prevent auto-renewal.',
    },
  ],
  'runpod.io': [
    {
      title: 'Renewal notice',
      kind: 'renewal_notice',
      inDays: 250,
      description: '90 days notice required to prevent auto-renewal.',
    },
  ],
};

/**
 * Fictional customers.
 *
 * Invented deliberately — see the note at the top of this file. Any resemblance
 * to a real company is unintended, and none of these figures describes anyone's
 * actual contract.
 */
const DEMAND = [
  {
    account: `${PREFIX}Halcyon Research`,
    segment: 'frontier_lab' as const,
    country: 'United States',
    contact: { name: 'Dana Whitfield', title: 'Head of Infrastructure', decisionMaker: true },
    deal: {
      name: `${PREFIX}Pre-training cluster, 12 months`,
      productLine: 'compute_reserved' as const,
      stage: 'deployment' as const,
      acvCents: 2_400_000_00,
      termMonths: 12,
      msaExecuted: true,
      dpaExecuted: true,
    },
    // Slipped: the close date is in the quarter just gone while the deal is
    // still open, so the calendar has a genuinely overdue item to render.
    close: { quarter: -1 as const, fraction: 0.62 },
    request: { gpuType: 'H200', gpuCount: 256, fastFabric: true, maxPriceCents: 285 },
    // Draws from the CoreWeave block.
    allocation: {
      supplier: 'coreweave.com',
      share: 0.76,
      priceCents: 271,
      status: 'active',
    },
  },
  {
    account: `${PREFIX}Verity Health AI`,
    segment: 'enterprise' as const,
    country: 'Germany',
    contact: { name: 'Lukas Brenner', title: 'VP Engineering', decisionMaker: true },
    deal: {
      name: `${PREFIX}EU-resident fine-tuning`,
      productLine: 'post_training' as const,
      stage: 'procurement' as const,
      acvCents: 640_000_00,
      termMonths: 6,
      msaExecuted: true,
      dpaExecuted: false,
    },
    close: { quarter: 0 as const, fraction: 0.55 },
    // Data residency: must land in the EU. Drives the Nebius block.
    request: {
      gpuType: 'H100_80GB',
      gpuCount: 64,
      fastFabric: true,
      maxPriceCents: 260,
      allowedRegions: ['eu-north', 'eu-west'],
      certifications: ['ISO 27001', 'SOC 2 Type II'],
    },
    allocation: {
      supplier: 'nebius.com',
      share: 0.55,
      priceCents: 249,
      status: 'committed',
    },
  },
  {
    account: `${PREFIX}Northwind Robotics`,
    segment: 'applied_ai_startup' as const,
    country: 'United States',
    contact: { name: 'Priya Raghavan', title: 'CTO', decisionMaker: true },
    deal: {
      name: `${PREFIX}Blackwell evaluation → production`,
      productLine: 'compute_reserved' as const,
      stage: 'poc' as const,
      acvCents: 890_000_00,
      termMonths: 9,
      msaExecuted: true,
      dpaExecuted: true,
    },
    close: { quarter: 0 as const, fraction: 0.82 },
    request: { gpuType: 'B200', gpuCount: 32, fastFabric: true, maxPriceCents: 460 },
    allocation: {
      supplier: 'crusoe.ai',
      share: 0.74,
      priceCents: 441,
      status: 'active',
    },
  },
  {
    account: `${PREFIX}Tessellate Labs`,
    segment: 'applied_ai_startup' as const,
    country: 'United Kingdom',
    contact: { name: 'Owen Marsh', title: 'Founding Engineer', decisionMaker: false },
    deal: {
      name: `${PREFIX}Inference burst capacity`,
      productLine: 'inference' as const,
      stage: 'proposal' as const,
      acvCents: 145_000_00,
      termMonths: 3,
      msaExecuted: false,
      dpaExecuted: false,
    },
    close: { quarter: 0 as const, fraction: 0.34 },
    request: { gpuType: 'A100_80GB', gpuCount: 16, fastFabric: false, maxPriceCents: 175 },
    // A HOLD, not a sale. The deal has not closed, so this reserves capacity
    // without counting as revenue — the distinction the capacity view exists
    // to make visible.
    allocation: {
      supplier: 'runpod.io',
      share: 0.55,
      priceCents: 168,
      status: 'planned',
      holdDays: 12,
    },
  },
  {
    account: `${PREFIX}Aurelian Systems`,
    segment: 'enterprise' as const,
    country: 'United States',
    contact: { name: 'Meredith Cole', title: 'Director, ML Platform', decisionMaker: true },
    deal: {
      name: `${PREFIX}Multi-year committed capacity`,
      productLine: 'compute_reserved' as const,
      stage: 'legal' as const,
      acvCents: 3_100_000_00,
      termMonths: 24,
      msaExecuted: false,
      dpaExecuted: false,
    },
    close: { quarter: 1 as const, fraction: 0.38 },
    request: { gpuType: 'H200', gpuCount: 128, fastFabric: true, maxPriceCents: 265 },
    allocation: null, // Still in legal. Nothing reserved yet — correctly.
  },
  {
    account: `${PREFIX}Quillon AI`,
    segment: 'applied_ai_startup' as const,
    country: 'Canada',
    contact: { name: 'Sofia Trentini', title: 'Head of Research', decisionMaker: true },
    deal: {
      name: `${PREFIX}Evaluation harness pilot`,
      productLine: 'evaluations' as const,
      stage: 'qualification' as const,
      acvCents: 48_000_00,
      termMonths: 3,
      msaExecuted: false,
      dpaExecuted: false,
    },
    close: { quarter: 1 as const, fraction: 0.74 },
    request: null,
    allocation: null,
  },
];

async function clear() {
  console.log('Removing demo data…');
  // Ordered so foreign keys never block a delete.
  const demoAccounts = await db
    .select({ id: accounts.id })
    .from(accounts)
    .where(like(accounts.name, `${PREFIX}%`));
  const ids = demoAccounts.map((a) => a.id);

  await db.delete(calendarEntries).where(like(calendarEntries.title, `${PREFIX}%`));
  await db.delete(learnResources).where(like(learnResources.title, `${PREFIX}%`));
  await db.delete(allocations).where(like(allocations.notes, `${PREFIX}%`));
  await db.delete(contractObligations);
  await db.delete(slaTerms);
  await db.delete(contracts).where(like(contracts.title, `${PREFIX}%`));
  await db.delete(capacityRequests);
  await db.delete(demandDeals).where(like(demandDeals.name, `${PREFIX}%`));
  await db.delete(supplyDeals).where(like(supplyDeals.name, `${PREFIX}%`));
  await db.delete(capacityCommitments).where(like(capacityCommitments.name, `${PREFIX}%`));
  await db.delete(activities).where(like(activities.subject, `${PREFIX}%`));
  for (const id of ids) {
    // Compliance rows cascade on the account anyway; deleted explicitly so the
    // order of removal stays readable rather than relying on the constraint.
    await db.delete(exportAuthorizations).where(eq(exportAuthorizations.accountId, id));
    await db.delete(complianceArtifacts).where(eq(complianceArtifacts.accountId, id));
    await db.delete(contacts).where(eq(contacts.accountId, id));
  }
  await db.delete(accounts).where(like(accounts.name, `${PREFIX}%`));
  console.log(`Removed ${ids.length} demo account(s) and everything hanging from them.`);
}

async function seedDemo() {
  console.log('Seeding the demo book…\n');

  // ------------------------------------------------------------ supply side
  const commitmentIds = new Map<string, string>();

  for (const supplier of SUPPLY) {
    const [account] = await db
      .select({ id: accounts.id })
      .from(accounts)
      .where(eq(accounts.domain, supplier.domain))
      .limit(1);
    if (!account) {
      console.log(`  skipped ${supplier.domain} — run the base seed first`);
      continue;
    }

    const [existing] = await db
      .select({ id: capacityCommitments.id })
      .from(capacityCommitments)
      .where(eq(capacityCommitments.name, supplier.commitment.name))
      .limit(1);
    if (existing) {
      commitmentIds.set(supplier.domain, existing.id);
      continue;
    }

    const [site] = await db
      .insert(sites)
      .values({
        accountId: account.id,
        name: `${PREFIX}${supplier.site.name}`,
        country: supplier.site.country,
        countryCode: supplier.site.countryCode,
        region: supplier.site.region,
        certifications:
          supplier.commitment.securityTier === 'community_cloud'
            ? []
            : ['SOC 2 Type II', 'ISO 27001'],
      })
      .returning();

    const c = supplier.commitment;
    const [commitment] = await db
      .insert(capacityCommitments)
      .values({
        accountId: account.id,
        siteId: site?.id,
        name: c.name,
        gpuType: c.gpuType,
        socket: c.socket,
        gpuCount: c.gpuCount,
        interconnectType: c.interconnectType,
        securityTier: c.securityTier ?? 'secure_cloud',
        startsAt: at(-30),
        endsAt: at(c.days - 30),
        totalGpuHours: hours(c.gpuCount, c.days),
        costPerGpuHourCents: c.costPerGpuHourCents,
        takeOrPayFloorPct: c.takeOrPayFloorPct,
        prepaidPct: c.prepaidPct,
        usefulLifeYears: '5',
        // A ramp: a quarter of the fleet for the first fortnight, then full.
        // Present so the shape column is exercised by something other than a
        // rectangle, which is the case it exists for.
        shape: {
          intervals: [at(-30).toISOString(), at(-16).toISOString(), at(c.days - 30).toISOString()],
          quantities: [Math.round(c.gpuCount * 0.25), c.gpuCount],
        },
        notes: 'Illustrative demo data. Not a real contract.',
      })
      .returning();

    if (commitment) commitmentIds.set(supplier.domain, commitment.id);

    // The paper behind it.
    const [msa] = await db
      .insert(contracts)
      .values({
        accountId: account.id,
        type: 'msa',
        status: 'executed',
        side: 'supply',
        title: `${PREFIX}MSA — ${supplier.domain}`,
        capacityCommitmentId: commitment?.id,
        // The anchor tenant's paper predates the block by months. Without one
        // contract genuinely in the past, every `contract_effective` event on
        // the calendar sits in the same fortnight and the view teaches nothing.
        effectiveAt: supplier.domain === 'coreweave.com' ? at(-150) : at(-60),
        expiresAt: at(c.days + 60),
        isAutoRenew: true,
        noticeDays: 90,
        takeOrPayFloorPct: c.takeOrPayFloorPct,
        prepaidPct: c.prepaidPct,
        terminationTier: c.prepaidPct !== '0' ? '1_prepaid' : '2_take_or_pay',
        governingLaw: 'New York',
      })
      .returning();

    if (msa) {
      // A negotiated SLA with fee abatement — the remedy that actually matters
      // on the supply side, and the one a credits-only model cannot express.
      const [sla] = await db
        .insert(contracts)
        .values({
          accountId: account.id,
          type: 'sla',
          status: 'executed',
          side: 'supply',
          title: `${PREFIX}SLA — ${supplier.domain}`,
          parentContractId: msa.id,
          effectiveAt: at(-60),
        })
        .returning();

      if (sla) {
        await db.insert(slaTerms).values({
          contractId: sla.id,
          kind: 'negotiated',
          uptimeTargetPct: '99.500',
          measurementUnit: 'node',
          measurementWindow: 'monthly',
          remedyType: 'fee_abatement',
          abatementTriggerValue: 2,
          abatementTriggerUnit: 'business_days',
          nodeReplacementHours: 24,
          claimDeadlineValue: 30,
          claimDeadlineUnit: 'days',
          creditCapPct: '50.000',
          sparePoolObligation: 'Spares held on site sufficient to replace failed nodes and switches.',
          sparePoolScope: ['compute_nodes', 'network_switches'],
          rcaDeliveryHours: 72,
          maintenanceClasses: [
            { class: 'planned', noticeValue: 5, noticeUnit: 'business_days', excludedFromUptime: true },
            { class: 'emergency', noticeValue: 24, noticeUnit: 'hours', excludedFromUptime: false },
          ],
        });
      }

      /*
       * Obligations spread across the year rather than bunched.
       *
       * Three of the four used to fall on the same day at +200, which made
       * every quarter after this one look empty and the current one look
       * uneventful. They are the dated things most likely to be missed, so a
       * demo that cannot show one falling due in each quarter is not showing
       * the feature at all. Payment and true-up dates are here for the same
       * reason: a renewal notice is not the only deadline that costs money.
       */
      const obligationsFor = OBLIGATION_SCHEDULE[supplier.domain] ?? [];
      for (const obligation of obligationsFor) {
        await db.insert(contractObligations).values({
          contractId: msa.id,
          title: `${PREFIX}${obligation.title} — ${supplier.domain}`,
          kind: obligation.kind,
          dueAt: at(obligation.inDays),
          description: obligation.description,
        });
      }
    }

    await db.insert(supplyDeals).values({
      accountId: account.id,
      siteId: site?.id,
      name: `${PREFIX}${c.gpuCount}× ${c.gpuType} — ${supplier.domain}`,
      stage: 'live',
      gpuType: c.gpuType,
      gpuCount: c.gpuCount,
      interconnectType: c.interconnectType,
      targetCostPerGpuHourCents: c.costPerGpuHourCents,
      termMonths: Math.round(c.days / 30),
      technicalVerdict: 'pass',
      technicalNotes: 'NCCL all-reduce within 8% of theoretical across the full fabric.',
      financialVerdict: 'pass',
      financialNotes: `Break-even at ~${Math.round((c.costPerGpuHourCents / 0.75 / 100) * 100) / 100}/GPU-hr at 75% utilisation.`,
    });
  }

  // A supply deal still in diligence, so that pipeline is not all `live`.
  const [lambda] = await db
    .select({ id: accounts.id })
    .from(accounts)
    .where(eq(accounts.domain, 'lambda.ai'))
    .limit(1);
  if (lambda) {
    const LAMBDA_DEAL = `${PREFIX}256× H200 — pricing`;
    const [existingLambdaDeal] = await db
      .select({ id: supplyDeals.id })
      .from(supplyDeals)
      .where(and(eq(supplyDeals.accountId, lambda.id), eq(supplyDeals.name, LAMBDA_DEAL)))
      .limit(1);
    if (!existingLambdaDeal) {
      await db.insert(supplyDeals).values({
        accountId: lambda.id,
        name: LAMBDA_DEAL,
        stage: 'financial_diligence',
        gpuType: 'H200',
        gpuCount: 256,
        interconnectType: 'Infiniband',
        targetCostPerGpuHourCents: 176,
        termMonths: 12,
        technicalVerdict: 'pass',
        technicalNotes: 'Fabric verified. Storage throughput per GPU is below spec — flagged.',
        financialNotes: 'Awaiting a firm quote on the committed tranche.',
      });
    }
  }

  // ------------------------------------------------------------ demand side
  for (const d of DEMAND) {
    const [existingAccount] = await db
      .select({ id: accounts.id })
      .from(accounts)
      .where(eq(accounts.name, d.account))
      .limit(1);
    if (existingAccount) continue;

    const [account] = await db
      .insert(accounts)
      .values({
        name: d.account,
        side: 'demand',
        customerSegment: d.segment,
        country: d.country,
        description: 'Fictional company, for demonstration only.',
        source: 'seed',
        confidence: 'confirmed',
        lastActivityAt: at(-2 - (DEMAND.indexOf(d) % 5)),
      })
      .returning();
    if (!account) continue;

    const [contact] = await db
      .insert(contacts)
      .values({
        accountId: account.id,
        fullName: d.contact.name,
        title: d.contact.title,
        affiliation: 'staff',
        isDecisionMaker: d.contact.decisionMaker,
        confidence: 'confirmed',
        source: 'seed',
        email: null,
      })
      .returning();

    const [deal] = await db
      .insert(demandDeals)
      .values({
        accountId: account.id,
        name: d.deal.name,
        productLine: d.deal.productLine,
        stage: d.deal.stage,
        acvCents: d.deal.acvCents,
        tcvCents: Math.round((d.deal.acvCents * d.deal.termMonths) / 12),
        termMonths: d.deal.termMonths,
        msaExecuted: d.deal.msaExecuted,
        dpaExecuted: d.deal.dpaExecuted,
        primaryContactId: contact?.id,
        expectedCloseDate: quarterAt(d.close.quarter, d.close.fraction),
        probability: String(
          { qualification: 0.1, legal: 0.35, proposal: 0.45, procurement: 0.6, poc: 0.7, deployment: 0.9 }[
            d.deal.stage
          ] ?? 0.5,
        ),
        lastActivityAt: at(-1 - (DEMAND.indexOf(d) % 6)),
      })
      .returning();
    if (!deal) continue;

    if (d.request) {
      await db.insert(capacityRequests).values({
        demandDealId: deal.id,
        gpuType: d.request.gpuType,
        gpuCount: d.request.gpuCount,
        requiresHighSpeedInterconnect: d.request.fastFabric,
        minInterconnectType: d.request.fastFabric ? 'Infiniband' : undefined,
        maxPricePerGpuHourCents: d.request.maxPriceCents,
        allowedRegions: d.request.allowedRegions ?? [],
        requiredCertifications: d.request.certifications ?? [],
        startsAt: at(15),
        endsAt: at(15 + d.deal.termMonths * 30),
        totalGpuHours: hours(d.request.gpuCount, d.deal.termMonths * 30, 1),
      });
    }

    if (d.allocation) {
      const commitmentId = commitmentIds.get(d.allocation.supplier);
      if (commitmentId) {
        const [commitment] = await db
          .select()
          .from(capacityCommitments)
          .where(eq(capacityCommitments.id, commitmentId))
          .limit(1);

        if (commitment) {
          if (!isAllocationStatus(d.allocation.status)) {
            throw new Error(`Invalid demo allocation status: ${d.allocation.status}`);
          }
          const allocationRow = {
            capacityCommitmentId: commitmentId,
            demandDealId: deal.id,
            gpuHours: String(
              Math.round(Number(commitment.totalGpuHours) * d.allocation.share),
            ),
            pricePerGpuHourCents: d.allocation.priceCents,
            startsAt: commitment.startsAt,
            endsAt: commitment.endsAt,
            status: d.allocation.status,
            guaranteeType: d.allocation.status === 'planned' ? 'committed' : 'guaranteed',
            priority: d.allocation.status === 'planned' ? 100 : 10,
            holdExpiresAt: d.allocation.holdDays ? at(d.allocation.holdDays) : null,
            notes: d.account,
          } satisfies NewAllocation;
          await db.insert(allocations).values(allocationRow);
        }
      }
    }

    // A little history, so the activity feed is not empty.
    await db.insert(activities).values([
      {
        type: 'meeting',
        subject: `${PREFIX}Technical scoping with ${d.contact.name}`,
        accountId: account.id,
        demandDealId: deal.id,
        occurredAt: at(-12),
      },
      {
        type: 'note',
        subject: `${PREFIX}${d.deal.stage} — next step agreed`,
        accountId: account.id,
        demandDealId: deal.id,
        occurredAt: at(-3),
      },
    ]);
  }

  // Internal research burn against the largest block — real cost, no revenue.
  const coreweave = commitmentIds.get('coreweave.com');
  if (coreweave) {
    const [commitment] = await db
      .select()
      .from(capacityCommitments)
      .where(eq(capacityCommitments.id, coreweave))
      .limit(1);
    const RESEARCH_NOTE = `${PREFIX}Internal research consumption`;
    const [existingResearch] = await db
      .select({ id: allocations.id })
      .from(allocations)
      .where(
        and(
          eq(allocations.capacityCommitmentId, coreweave),
          eq(allocations.notes, RESEARCH_NOTE),
        ),
      )
      .limit(1);

    if (commitment && !existingResearch) {
      const researchAllocationRow = {
        capacityCommitmentId: coreweave,
        internalTeam: 'research',
        gpuHours: String(Math.round(Number(commitment.totalGpuHours) * 0.08)),
        pricePerGpuHourCents: 0,
        startsAt: commitment.startsAt,
        endsAt: commitment.endsAt,
        status: 'active',
        guaranteeType: 'internal',
        priority: 200,
        notes: RESEARCH_NOTE,
      } satisfies NewAllocation;
      await db.insert(allocations).values(researchAllocationRow);
    }
  }

  // ------------------------------------------------ compliance deadlines
  //
  // Both of these columns are indexed, both carry a schema comment saying they
  // MUST be alerted on, and until the calendar existed neither was read by a
  // single endpoint or shown on a single screen. An export authorisation that
  // lapses unnoticed converts lawful business into unlawful business; a SOC 2
  // report that expires mid-procurement stalls the deal it was gating. Seeding
  // one of each means the quarterly view opens with both visible.
  const [verity] = await db
    .select({ id: accounts.id })
    .from(accounts)
    .where(eq(accounts.name, `${PREFIX}Verity Health AI`))
    .limit(1);

  if (verity) {
    const AUTHORIZATION_REFERENCE = `${PREFIX}DC-VEU-2026-0417`;
    const [existingAuthorization] = await db
      .select({ id: exportAuthorizations.id })
      .from(exportAuthorizations)
      .where(eq(exportAuthorizations.reference, AUTHORIZATION_REFERENCE))
      .limit(1);
    if (!existingAuthorization) {
      await db.insert(exportAuthorizations).values({
        accountId: verity.id,
        authorizationType: 'dc_veu',
        reference: AUTHORIZATION_REFERENCE,
        scopeNotes:
          'Illustrative demo record. Covers EU-resident training workloads only; ' +
          'inference in other regions is out of scope.',
        issuedAt: at(-320),
        // Inside the current quarter on almost any day of the year, and close
        // enough that it reads as urgent rather than as a diary note.
        expiresAt: at(45),
        evidenceUrl: 'https://example.invalid/demo-authorisation',
        // Rules in flux for this counterparty: re-verify, do not trust the date.
        volatile: true,
      });
    }

    const ARTIFACT_SCOPE = `${PREFIX}EU training platform`;
    const [existingArtifact] = await db
      .select({ id: complianceArtifacts.id })
      .from(complianceArtifacts)
      .where(eq(complianceArtifacts.scope, ARTIFACT_SCOPE))
      .limit(1);
    if (!existingArtifact) {
      await db.insert(complianceArtifacts).values({
        accountId: verity.id,
        claim: 'soc2',
        scope: ARTIFACT_SCOPE,
        // A true certification, not an alignment claim — the distinction the
        // column exists for, and the one procurement actually gates on.
        isCertified: true,
        soc2Type: 'type_ii',
        observationWindowStart: at(-365),
        observationWindowEnd: at(-10),
        auditFirm: 'Demo Assurance LLP',
        carveOutMethod: 'carve_out',
        productsInScope: ['training', 'managed inference'],
        evidenceUrl: 'https://example.invalid/demo-soc2',
        expiresAt: quarterAt(1, 0.5),
      });
    }
  }

  // ------------------------------------------------------- calendar entries
  //
  // The only rows the calendar owns. Everything else on it is projected from
  // a record that already carries the date; these are the human-owned items
  // that have nowhere else to live.
  const [owner] = await db.select({ id: users.id }).from(users).limit(1);
  const [halcyon] = await db
    .select({ id: accounts.id })
    .from(accounts)
    .where(eq(accounts.name, `${PREFIX}Halcyon Research`))
    .limit(1);

  const CALENDAR_ENTRIES = [
    {
      title: `${PREFIX}Q business review — Halcyon Research`,
      kind: 'qbr' as const,
      description: 'Utilisation against the reserved block, and the expansion case.',
      startsAt: quarterAt(0, 0.7),
      durationMinutes: 90,
      accountId: halcyon?.id ?? null,
    },
    {
      title: `${PREFIX}Renewal check-in — Nebius`,
      kind: 'meeting' as const,
      // A fortnight ahead of the +21 renewal notice obligation, which is the
      // point: the reminder has to land before the deadline, not on it.
      description: 'Decide whether to give notice before the 90-day window closes.',
      startsAt: at(7),
      durationMinutes: 45,
      accountId: null,
    },
    {
      title: `${PREFIX}Pipeline review — next quarter commit`,
      kind: 'internal' as const,
      description: 'Weighted pipeline against the number, before the quarter opens.',
      startsAt: quarterAt(1, 0.02),
      durationMinutes: 60,
      accountId: null,
    },
    {
      title: `${PREFIX}Blackwell availability campaign`,
      kind: 'campaign' as const,
      description: 'Outbound week against accounts waiting on B200 capacity.',
      startsAt: quarterAt(0, 0.45),
      // A span, not a point — the calendar must render both.
      durationMinutes: 5 * 24 * 60,
      accountId: null,
    },
  ];

  let entriesAdded = 0;
  for (const entry of CALENDAR_ENTRIES) {
    const [existingEntry] = await db
      .select({ id: calendarEntries.id })
      .from(calendarEntries)
      .where(eq(calendarEntries.title, entry.title))
      .limit(1);
    if (existingEntry) continue;
    await db.insert(calendarEntries).values({
      title: entry.title,
      description: entry.description,
      kind: entry.kind,
      startsAt: entry.startsAt,
      endsAt: new Date(entry.startsAt.getTime() + entry.durationMinutes * 60_000),
      allDay: entry.durationMinutes >= 24 * 60,
      accountId: entry.accountId,
      ownerUserId: owner?.id ?? null,
      createdByUserId: owner?.id ?? null,
    });
    entriesAdded += 1;
  }

  // ------------------------------------------------------------- learn
  //
  // Every id below is a REAL public recording on the Cap instance at
  // video.karti.ai, checked against its database rather than invented. A demo
  // row whose embed 404s teaches nothing and reads as a broken feature, which
  // is the opposite of what a demo seed is for — so the titles are illustrative
  // and prefixed, and the videos behind them are whatever is actually there.
  //
  // PLATFORM rows are no longer seeded here. The five real recordings in
  // HOSTED_LEARN_MANIFEST cover that track now, and leaving three illustrative
  // Cap rows beneath five genuine ones made the page read as half-placeholder
  // to the exact audience it is meant to convince.
  //
  // The concept rows stay: `supply` and `demand` have no real recordings yet,
  // and an empty track hides the shape of the page. They are `members`-visible,
  // which the CHECK constraint enforces anyway — only `platform` may be `code`.
  const LEARN_RESOURCES = [
    {
      track: 'supply' as const,
      title: `${PREFIX}How neocloud capacity is actually priced`,
      summary: 'Reserved versus on-demand, commitment length, and where the spread comes from.',
      externalId: '0n6n9p83efnxbs2',
      visibility: 'members' as const,
      durationSeconds: 14 * 60 + 30,
      sortOrder: 10,
    },
    {
      track: 'supply' as const,
      title: `${PREFIX}Qualifying a provider: fabric, tier and paperwork`,
      summary: 'Interconnect, security tier and the contract weight each supplier archetype brings.',
      externalId: '1rqq9rk4dpp71fd',
      visibility: 'members' as const,
      durationSeconds: 11 * 60,
      sortOrder: 20,
    },
    {
      track: 'demand' as const,
      title: `${PREFIX}Discovery for a training run`,
      summary: 'The five questions that decide whether a deal is servable before you quote it.',
      externalId: 'sjqqvthbfma27bm',
      visibility: 'members' as const,
      durationSeconds: 16 * 60 + 20,
      sortOrder: 10,
    },
    {
      track: 'demand' as const,
      title: `${PREFIX}Holds, and why one is not revenue`,
      summary: 'What a hold removes from everyone else, and when to let one expire.',
      externalId: '0n6n9p83efnxbs2',
      visibility: 'members' as const,
      durationSeconds: 7 * 60 + 45,
      sortOrder: 20,
    },
  ];

  let learnAdded = 0;
  for (const resource of LEARN_RESOURCES) {
    // Idempotent on the unique key rather than an existence check, which is
    // the whole reason that constraint exists: onConflictDoNothing without one
    // is a silent no-op and has duplicated seed data here twice before.
    const inserted = await db
      .insert(learnResources)
      .values({
        track: resource.track,
        title: resource.title,
        summary: resource.summary,
        url: `https://video.karti.ai/s/${resource.externalId}`,
        provider: 'cap',
        externalId: resource.externalId,
        visibility: resource.visibility,
        durationSeconds: resource.durationSeconds,
        sortOrder: resource.sortOrder,
        addedByUserId: owner?.id ?? null,
      })
      .onConflictDoNothing({
        target: [learnResources.track, learnResources.provider, learnResources.externalId],
      })
      .returning({ id: learnResources.id });
    if (inserted.length) learnAdded += 1;
  }

  // The self-hosted set, which is real rather than invented — see the long
  // note on `seedHostedLearn`. Folded into the demo seed so one command gives
  // a complete Learn page, but kept in its own function with its own flags
  // because it is not demo data and must not be removed with `--clear`.
  const hosted = await seedHostedLearn();

  // -------------------------------------------------- agent-derived facts
  //
  // Without these the fact-review queue and every provenance tooltip are
  // empty, which hides the thing that makes an agent-written CRM trustworthy:
  // that each claim carries a score, a band, evidence and a source, and that
  // only verified claims apply themselves.
  //
  // The mix is deliberate. Two `applied` facts show what a confident agent
  // writes unprompted; four `proposed` show what waits for a human; one is a
  // near-miss that a reviewer should reject, so the queue is not a row of
  // obvious approvals.
  const factSeeds: {
    accountDomain?: string;
    contactName?: string;
    field: string;
    value: string;
    score: string;
    band: 'verified' | 'probable' | 'possible';
    status: 'applied' | 'proposed';
    method: string;
    sourceUrl?: string;
    evidence: Record<string, unknown>;
  }[] = [
    {
      accountDomain: 'coreweave.com',
      field: 'supplierType',
      value: 'neocloud',
      score: '0.960',
      band: 'verified',
      status: 'applied',
      method: 'web_search',
      sourceUrl: 'https://www.coreweave.com/',
      evidence: {
        quote: 'Describes itself as an AI hyperscaler providing GPU cloud infrastructure.',
        corroboration: 2,
      },
    },
    {
      accountDomain: 'nebius.com',
      field: 'jurisdiction',
      value: 'European Union',
      score: '0.910',
      band: 'verified',
      status: 'applied',
      method: 'web_search',
      sourceUrl: 'https://nebius.com/',
      evidence: {
        quote: 'Operates a datacentre in Finland, inside the EU data-residency perimeter.',
        matters: 'Determines eligibility for customers with EU residency requirements.',
      },
    },
    {
      accountDomain: 'crusoe.ai',
      field: 'certifications',
      value: 'SOC 2 Type II',
      score: '0.720',
      band: 'probable',
      status: 'proposed',
      method: 'web_search',
      sourceUrl: 'https://crusoe.ai/',
      evidence: {
        quote: 'A trust page references SOC 2, but the report scope and observation window are not stated.',
        caution: 'Scope matters — a report can cover only some products.',
      },
    },
    {
      accountDomain: 'lambda.ai',
      field: 'supplierType',
      value: 'neocloud',
      score: '0.680',
      band: 'probable',
      status: 'proposed',
      method: 'web_search',
      sourceUrl: 'https://lambda.ai/',
      evidence: { quote: 'Markets GPU cloud and on-premises clusters.' },
    },
    {
      contactName: 'Dana Whitfield',
      field: 'title',
      value: 'VP Infrastructure',
      score: '0.540',
      band: 'possible',
      status: 'proposed',
      method: 'inference',
      evidence: {
        reasoning: 'A conference bio lists a VP title; the CRM records Head of Infrastructure.',
        conflict: 'Sources disagree, and neither is dated.',
      },
    },
    {
      accountDomain: 'runpod.io',
      field: 'customerSegment',
      value: 'frontier_lab',
      score: '0.310',
      band: 'possible',
      status: 'proposed',
      method: 'inference',
      evidence: {
        reasoning: 'Inferred from a blog post mentioning large training runs.',
        warning:
          'Weak. This is a supply-side provider, not a frontier lab — a reviewer should reject it.',
      },
    },
  ];

  let factsAdded = 0;
  for (const seed of factSeeds) {
    let accountId: string | undefined;
    let contactId: string | undefined;

    if (seed.accountDomain) {
      const [row] = await db
        .select({ id: accounts.id })
        .from(accounts)
        .where(eq(accounts.domain, seed.accountDomain))
        .limit(1);
      accountId = row?.id;
    }
    if (seed.contactName) {
      const [row] = await db
        .select({ id: contacts.id })
        .from(contacts)
        .where(eq(contacts.fullName, seed.contactName))
        .limit(1);
      contactId = row?.id;
    }
    if (!accountId && !contactId) continue;

    // Idempotent on the natural key: one claim per subject per field per value.
    const [existing] = await db
      .select({ id: facts.id })
      .from(facts)
      .where(
        and(
          accountId ? eq(facts.accountId, accountId) : eq(facts.contactId, contactId!),
          eq(facts.field, seed.field),
          eq(facts.value, seed.value),
        ),
      )
      .limit(1);
    if (existing) continue;

    await db.insert(facts).values({
      accountId,
      contactId,
      field: seed.field,
      value: seed.value,
      score: seed.score,
      band: seed.band,
      status: seed.status,
      method: seed.method,
      sourceUrl: seed.sourceUrl,
      evidence: seed.evidence,
      observedAt: at(-1 - (factSeeds.indexOf(seed) % 6)),
    });
    factsAdded += 1;
  }

  console.log('  4 capacity commitments, with sites, MSAs and negotiated SLAs');
  console.log(`  ${factSeeds.length} agent-derived facts (${factsAdded} new) — 2 applied, 4 awaiting review`);
  console.log('  6 demand deals across the pipeline, 5 supply deals');
  console.log('  Allocations including one unconverted hold and internal research burn');
  console.log(
    '  Close dates placed deliberately in the previous, current and next quarter',
  );
  console.log(
    `  1 export authorisation (45 days), 1 SOC 2 report (next quarter), ` +
      `${CALENDAR_ENTRIES.length} calendar entries (${entriesAdded} new)`,
  );
  console.log(
    `  ${LEARN_RESOURCES.length} learn resources (${learnAdded} new) — 3 platform walkthroughs behind the share code`,
  );
  console.log(
    `  ${hosted.present} PIG-hosted learn videos (${hosted.added} new)` +
      `${hosted.missing > 0 ? `, ${hosted.missing} manifest entries with no file yet` : ''}`,
  );
  console.log('\nEverything is prefixed "DEMO — ". Remove it with: pnpm db:demo -- --clear');
  console.log('The PIG-hosted rows are NOT prefixed and survive that. Remove them with: pnpm db:demo -- --clear-hosted');
}

// ------------------------------------------------------- PIG-hosted learn
//
// Real videos, served by PIG itself from PIG_MEDIA_DIR — not demo data. They
// carry no `DEMO — ` prefix precisely because they are genuine product
// walkthroughs, which also means `--clear` leaves them alone; `--clear-hosted`
// is their own switch.
//
// **A row is written only when its file is actually on disk.** A learn row
// whose media 404s is worse than a missing row: the card renders, the play
// button does nothing, and the feature reads as broken. So the manifest below
// declares the curriculum, and the seed inserts the entries it can find.
// Running it before the videos are generated is a no-op with a printed list,
// and running it again afterwards fills them in.
//
// **The filename is discovered, not written down.** Files are
// content-addressed — `<slug>.<hash>.mp4` — so the hash changes every time a
// video is re-rendered, and a manifest carrying the hash would be a file that
// has to be edited in lockstep with a render. Instead the slug is the stable
// identity and the directory supplies the rest. Idempotency then rests on the
// unique key (track, provider, external_id) exactly as the DEMO rows do.
//
// A re-render produces a NEW hash and therefore a new row; the old row keeps
// pointing at a file that is no longer there. That is reported rather than
// resolved automatically, because deleting rows on the strength of a missing
// file would empty the curriculum the first time someone ran this with the
// media directory unmounted.

interface HostedLearnEntry {
  /** Stable identity. Also the filename stem: `<slug>.<hash>.<ext>`. */
  slug: string;
  title: string;
  summary: string;
  track: 'supply' | 'demand' | 'platform';
  visibility: 'members' | 'code';
  durationSeconds: number;
  sortOrder: number;
}

export const HOSTED_LEARN_MANIFEST: readonly HostedLearnEntry[] = [
  {
    slug: 'overview-and-margin',
    title: 'Overview and the margin question',
    summary:
      'Which contracted capacity is sold, at what margin, and what is idle right now — and why cost is charged against the full commitment.',
    track: 'platform',
    visibility: 'code',
    durationSeconds: 29,
    sortOrder: 1,
  },
  {
    slug: 'quarterly-calendar',
    title: 'The quarterly calendar',
    summary:
      'What closes, what renews, what expires and when capacity lands — with export authorisations and compliance artefacts at the top.',
    track: 'platform',
    visibility: 'code',
    durationSeconds: 30,
    sortOrder: 2,
  },
  {
    slug: 'capacity-to-allocations',
    title: 'Capacity to allocations',
    summary:
      'Joining a commitment you bought to a deal you sold — the availability book, the matcher, and the allocation the ledger is built on.',
    track: 'platform',
    visibility: 'code',
    durationSeconds: 26,
    sortOrder: 3,
  },
  {
    slug: 'importing-your-book',
    title: 'Importing your book',
    summary:
      'Getting off the spreadsheet — CSV, Excel, Notion or a bounded Google Sheets range, with a dry run you review before anything is written.',
    track: 'platform',
    visibility: 'code',
    durationSeconds: 28,
    sortOrder: 4,
  },
  {
    slug: 'piggy-and-its-boundary',
    title: 'Piggy, and what it will not do',
    summary:
      'The docked agent reads through scoped, page-specific PIG tools — and has no shell, no filesystem, and no ability to write CRM records.',
    track: 'platform',
    visibility: 'code',
    durationSeconds: 28,
    sortOrder: 5,
  },
];

/**
 * Where the served files live. The same variable the API route reads.
 *
 * The default is resolved from this file's location, not from `process.cwd()`,
 * because pnpm runs the seed with the working directory at `packages/db` while
 * the server runs at the repository root — so a relative default would mean
 * two different directories, and the seed would write rows for files the API
 * cannot find.
 */
function mediaDirectory(): string {
  const configured = process.env.PIG_MEDIA_DIR?.trim();
  if (configured && configured.length > 0) return resolve(configured);
  return resolve(import.meta.dirname, '../../../../media');
}

/**
 * Find the one file belonging to a slug.
 *
 * Two files for one slug is an error rather than a choice: picking the newest
 * would silently publish whichever render happened to finish last, and the
 * operator has an old file to delete.
 */
function mediaFileFor(
  slug: string,
  filenames: readonly string[],
): { filename: string | null; ambiguous: readonly string[] } {
  const matches = filenames.filter(
    (name) => isLearnMediaFilename(name) && name.startsWith(`${slug}.`),
  );
  /*
   * Reported and skipped, not thrown.
   *
   * This runs from the middle of seedDemo(), so throwing on a duplicate took
   * out every later section — facts, activities, the lot — and left the
   * operator working out why `pnpm db:demo` died on two MP4s sharing a slug.
   * A stale render is a condition of one directory entry; its blast radius
   * should be that entry. Missing files are already handled this way.
   */
  if (matches.length > 1) return { filename: null, ambiguous: matches };
  return { filename: matches[0] ?? null, ambiguous: [] };
}

export async function seedHostedLearn(): Promise<{
  present: number;
  added: number;
  missing: number;
}> {
  const directory = mediaDirectory();
  let filenames: string[] = [];
  try {
    filenames = await readdir(directory);
  } catch {
    // No directory is the normal state of a fresh checkout, not a failure.
    console.log(`  (no media directory at ${directory} — skipping PIG-hosted learn videos)`);
    return { present: 0, added: 0, missing: HOSTED_LEARN_MANIFEST.length };
  }

  const [owner] = await db.select({ id: users.id }).from(users).limit(1);
  let present = 0;
  let added = 0;
  const absent: string[] = [];

  for (const entry of HOSTED_LEARN_MANIFEST) {
    const { filename, ambiguous } = mediaFileFor(entry.slug, filenames);
    if (ambiguous.length > 0) {
      console.warn(
        `  ! ${entry.slug}: ${ambiguous.length} files match (${ambiguous.join(', ')}) — ` +
          'content-addressed names mean one is a stale render. Delete it and re-run. Skipped.',
      );
      absent.push(entry.slug);
      continue;
    }
    if (!filename) {
      absent.push(entry.slug);
      continue;
    }
    present += 1;

    const inserted = await db
      .insert(learnResources)
      .values({
        track: entry.track,
        title: entry.title,
        summary: entry.summary,
        // The same path the resolver builds, from the same constant — so a
        // seeded row and a row created through the API are indistinguishable.
        url: `${LEARN_MEDIA_PATH_PREFIX}${filename}`,
        provider: 'pig',
        externalId: filename,
        visibility: entry.visibility,
        durationSeconds: entry.durationSeconds,
        sortOrder: entry.sortOrder,
        addedByUserId: owner?.id ?? null,
      })
      .onConflictDoNothing({
        target: [learnResources.track, learnResources.provider, learnResources.externalId],
      })
      .returning({ id: learnResources.id });
    if (inserted.length) added += 1;
  }

  if (absent.length) {
    console.log(
      `  (no file yet in ${directory} for: ${absent.join(', ')} — ` +
        'drop <slug>.<hash>.mp4 there and run this again)',
    );
  }

  // Rows whose file has gone: reported, never deleted. See the note above.
  const orphans = (
    await db
      .select({ externalId: learnResources.externalId, title: learnResources.title })
      .from(learnResources)
      .where(eq(learnResources.provider, 'pig'))
  ).filter((row) => !filenames.includes(row.externalId));
  for (const orphan of orphans) {
    console.log(`  ! "${orphan.title}" points at ${orphan.externalId}, which is not on disk`);
  }

  return { present, added, missing: absent.length };
}

async function clearHostedLearn() {
  const removed = await db
    .delete(learnResources)
    .where(eq(learnResources.provider, 'pig'))
    .returning({ id: learnResources.id });
  console.log(`Removed ${removed.length} PIG-hosted learn resource(s). Files on disk are untouched.`);
}

const argv = process.argv.slice(2);
const run = argv.includes('--clear')
  ? clear
  : argv.includes('--clear-hosted')
    ? clearHostedLearn
    // Insert only the self-hosted rows: the command to run after generating a
    // video, when reseeding the whole demo book would be beside the point.
    : argv.includes('--hosted')
      ? seedHostedLearn
      : seedDemo;

run()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error('Demo seed failed:', error);
    process.exit(1);
  });
