/**
 * The history hanging off the book: what was said, when, to whom — and the
 * `lastActivityAt` stamps the list views sort on.
 *
 * Both live here because they answer the same question — when was this last
 * touched — and a change to one that is not mirrored in the other produces an
 * account whose feed and whose "last activity" column disagree. That is not
 * hypothetical: the stamps used to be an index-based offset, so an account
 * could claim two days since contact while its newest entry was three days old.
 * Nothing here invents a date for a stamp any more; every stamp is
 * `max(occurredAt)` over the rows actually written.
 *
 * The copy matters more than the count. These strings are the most-read text in
 * the product — the Overview feed, the account timeline, the contact rows — so
 * they are written the way a capacity desk writes: the counterparty, the
 * number, and what it means for the block underneath. A subject that renders a
 * stage enum ("procurement — next step agreed") tells a reader nothing and
 * tells them the product is a fixture.
 *
 * Every figure quoted in a body reconciles with the block it names: the Finland
 * break-even is 171/249 of the block, the CoreWeave true-up remainder is what
 * is left after a 76% sale and an 8% research burn, the Crusoe outage is
 * measured against the 99.5% the SLA actually carries.
 */
import type { ActivityType } from '@pig/core';
import { asc, eq, like, sql } from 'drizzle-orm';
import {
  accounts,
  activities,
  capacityRequests,
  contacts,
  demandDeals,
  supplyDeals,
} from '../../schema/index';
import type { DemoContext } from './index';

/**
 * One entry in the feed.
 *
 * `days` is negative and often fractional. The fractions are the point: a
 * product opened on a Monday morning has to show something from the weekend,
 * and a whole-day grid cannot put anything inside the last 48 hours without
 * putting it at midnight.
 */
interface Beat {
  readonly type: ActivityType;
  /** Days before the instant the seed started. Negative. */
  readonly days: number;
  /** Written without the prefix; `insertBeats` adds it. */
  readonly subject: string;
  readonly body: string;
  /**
   * Who on the account was on the other end. `primary` is the deal's named
   * contact — the only one a body may name — and `other` rotates through
   * whoever else is on the account, so a second contact does not read as
   * having never been spoken to.
   */
  readonly who?: 'primary' | 'other';
  /** Written by Piggy rather than by a person; carries agent provenance. */
  readonly byAgent?: boolean;
}

// ---------------------------------------------------------------- demand side

/**
 * A script per demand account, written against that account's actual position:
 * Halcyon's notice period is the one its block underneath cannot absorb,
 * Verity's silence is the addendum that has not come back, Tessellate's hold
 * lapses on the day the seed says it lapses.
 *
 * Keyed by account name with the prefix stripped, and paired with the stage the
 * script was written for. A deal on the same account at a different stage falls
 * through to `genericBeats`, because a script that says "countersigned" against
 * a deal still in qualification is worse than no script at all.
 */
const DEMAND_SCRIPTS: Record<string, { readonly stage: string; readonly beats: readonly Beat[] }> = {
  'Halcyon Research': {
    stage: 'deployment',
    beats: [
      {
        type: 'email',
        days: -118,
        subject: 'Halcyon came in on the H200 waitlist',
        body: 'Dana Whitfield asked what we could put in front of a pre-training run starting inside the quarter. We have the New Jersey block; the question is the ramp, not the capacity.',
        who: 'primary',
      },
      {
        type: 'meeting',
        days: -104,
        subject: 'Scoping call with Halcyon — 256× H200, twelve months',
        body: 'Twelve months, 256 H200s, Infiniband end to end. They will not split a training run across sites, which rules out three of the four blocks on the book before price is discussed.',
        who: 'primary',
      },
      {
        type: 'stage_change',
        days: -96,
        subject: 'Halcyon moved to a written proposal',
        body: 'The requirement is firm enough to price. Their ceiling of $2.85/GPU-hr came from them, unprompted, which is a good deal more useful than a budget they refuse to name.',
      },
      {
        type: 'note',
        days: -88,
        subject: "Halcyon's reviewer wants the New Jersey site's SOC 2 report",
        body: 'The report itself, not the badge. Sent the supplier\'s Type II across with the carve-outs marked rather than paraphrasing it.',
        who: 'other',
      },
      {
        type: 'email',
        days: -74,
        subject: 'Halcyon pushing for 60-day notice on the H200 block',
        body: 'They want to hand the block back on 60 days. Our own commitment underneath runs 90 days with a full take-or-pay floor, so anything shorter leaves us paying for hours nobody is using.',
        who: 'primary',
      },
      {
        type: 'meeting',
        days: -61,
        subject: 'Terms session with Dana Whitfield and their counsel',
        body: 'Landed on 90 days to match the block underneath, in exchange for a credit against the first quarter\'s hours. Symmetry with the supply side is worth more than the credit costs.',
        who: 'primary',
      },
      {
        type: 'contract_event',
        days: -47,
        subject: 'Halcyon countersigned the agreement and the data addendum',
        body: 'Both executed the same afternoon. Priced at $2.71/GPU-hr against a $1.89 block cost — 82 cents an hour over what the capacity costs us.',
      },
      {
        type: 'note',
        days: -30,
        subject: 'First 128 H200s handed over to Halcyon',
        body: 'Half the allocation for the opening fortnight while their data pipeline catches up, then the balance. The block is paid for at full size throughout, ramp or no ramp.',
      },
      {
        type: 'call',
        days: -12,
        subject: 'Ramp check-in with Halcyon',
        body: 'Dana says the run is stable and throughput is within 4% of what they modelled. She asked, unprompted, what a second block would cost.',
        who: 'primary',
      },
      {
        type: 'task',
        days: -1.6,
        subject: 'Price a second H200 block for Halcyon before the quarter closes',
        body: 'They want a number, not a range. The Lambda 256× H200 line is the only thing that could cover it without taking hours off the New Jersey block that are already sold.',
      },
    ],
  },

  'Verity Health AI': {
    stage: 'procurement',
    beats: [
      {
        type: 'email',
        days: -96,
        subject: 'Inbound from Verity Health — EU-only fine-tuning',
        body: 'Lukas Brenner, VP Engineering. 64 H100s for six months, and the data cannot leave the EU under any circumstance. That rules out everything on the book except the Finland block.',
        who: 'primary',
      },
      {
        type: 'meeting',
        days: -83,
        subject: 'Residency walkthrough with Verity — the Finland site',
        body: 'Where the data sits, who can reach it, and under whose law. Their privacy office asked better questions than their engineers did.',
      },
      {
        type: 'note',
        days: -70,
        subject: "Verity's addendum wants 30 days' notice on sub-processors",
        body: 'Standard is fourteen. Thirty is workable for the Finland block, but not for anything we would burst onto the community pool — better said now than at the first burst.',
        who: 'other',
      },
      {
        type: 'email',
        days: -52,
        subject: 'Sent Verity the Finland site certifications',
        body: 'Their procurement will not open a file without the certificates attached, so both went over with the observation window highlighted.',
      },
      {
        type: 'stage_change',
        days: -38,
        subject: 'Verity budget signed off — into their vendor onboarding',
        body: 'Lukas has the money. What is left is the onboarding portal, the addendum, and an export authorisation that lapses inside the quarter.',
      },
      {
        type: 'call',
        days: -21,
        subject: "Verity's supplier onboarding — code issued",
        body: 'Registered on their portal and the supplier code came back the same day, which their own procurement team had predicted would take a fortnight.',
        who: 'other',
      },
      {
        type: 'email',
        days: -9,
        subject: 'Third ask for the signed data addendum',
        body: 'Polite, dated, copied to their privacy office. The Finland block is 55% sold and needs about 69% to cover its cost, so this deal is carrying more than its own weight.',
      },
      {
        type: 'call',
        days: -4,
        subject: 'Left a voicemail for Lukas about the addendum and the start date',
        body: 'No answer and no reply to the email either. Their office is out for a public holiday until Monday, which explains the silence without excusing the date.',
        who: 'primary',
      },
      {
        type: 'email',
        days: -1.4,
        subject: "Verity's privacy office: one open point left on the addendum",
        body: 'Everything agreed except audit rights over the supplier\'s own site, which are not ours to grant. Offered the supplier\'s audit report in place of a right of entry.',
        who: 'other',
      },
      {
        type: 'agent_action',
        days: -0.5,
        subject: "Piggy flagged Verity's export authorisation — 45 days left",
        body: 'The authorisation covers EU-resident training only and lapses in 45 days. Renewal is on the critical path for a deal that has not signed yet.',
        byAgent: true,
      },
    ],
  },

  'Northwind Robotics': {
    stage: 'deployment',
    beats: [
      {
        type: 'note',
        days: -110,
        subject: 'Northwind want to benchmark Blackwell against their H100 baseline',
        body: 'Priya Raghavan, CTO. They will not commit to nine months on a part they have not run their own workload on, which is the correct instinct and worth encouraging.',
      },
      {
        type: 'meeting',
        days: -101,
        subject: 'Benchmark plan agreed with Northwind — 32× B200, six weeks',
        body: 'Their training run, their harness, our block. Success defined before we start: 1.8× the H100 baseline per dollar, or they walk.',
      },
      {
        type: 'email',
        days: -86,
        subject: "Northwind's infrastructure lead wants Infiniband, not Ethernet",
        body: 'They were burned by an Ethernet fabric at a previous vendor. Checking the Texas site\'s fabric before promising them anything.',
        who: 'other',
      },
      {
        type: 'note',
        days: -64,
        subject: 'Texas fabric confirmed for Northwind',
        body: 'NCCL all-reduce within 8% of theoretical across the full fabric. Sent them the measurements rather than the assurance.',
      },
      {
        type: 'slack',
        days: -58,
        subject: 'Shared the Texas fabric numbers in the Northwind channel',
        body: 'Their engineers took the results apart for an afternoon in the shared channel. Fastest technical review we have had this year.',
      },
      {
        type: 'stage_change',
        days: -45,
        subject: 'Northwind pilot started on the Texas block',
        body: 'Six weeks on 32 B200s at the same rate the production term would carry, so the pilot proves the price as well as the part.',
      },
      {
        type: 'meeting',
        days: -26,
        subject: 'Week-four pilot review with Priya',
        body: '2.1× the H100 baseline per dollar on their own run, comfortably past the bar they set. The remaining question is storage throughput, not compute.',
        who: 'primary',
      },
      {
        type: 'email',
        days: -20,
        subject: 'Northwind asking what a nine-month term does to the rate',
        body: 'Quoted $4.41/GPU-hr on nine months against a $3.05 block cost. Priced twelve months as well, since they asked for it in the same breath.',
      },
      {
        type: 'contract_event',
        days: -13,
        subject: 'Northwind signed the nine-month B200 term',
        body: 'Board approved on the 20th and the order form followed the same week. $4.41/GPU-hr against a $3.05 cost, on a block that is committed either way.',
      },
      {
        type: 'call',
        days: -3,
        subject: 'Priya: the production run starts on Monday',
        body: 'Their pilot allocation rolls straight into the term, so there is no gap and nothing to re-provision.',
        who: 'primary',
      },
      {
        type: 'note',
        days: -1.1,
        subject: 'Northwind raised storage throughput on the Texas nodes',
        body: 'Checkpoint writes are slower than in the pilot\'s first fortnight. Worth taking to the site before the production term starts rather than after.',
      },
    ],
  },

  'Tessellate Labs': {
    stage: 'proposal',
    beats: [
      {
        type: 'email',
        days: -58,
        subject: 'Tessellate asking about burst inference for a launch',
        body: 'Owen Marsh, founding engineer. Sixteen A100s for three months around a product launch, and no appetite for committing beyond it.',
        who: 'primary',
      },
      {
        type: 'call',
        days: -44,
        subject: 'Owen Marsh: 16× A100, three months, and price-sensitive',
        body: 'They have a number in mind and it lands under $1.75/GPU-hr. The community pool is the only block on the book that clears it.',
        who: 'primary',
      },
      {
        type: 'note',
        days: -33,
        subject: 'Owen is not the signer at Tessellate',
        body: 'He can specify and he can recommend; their founder signs. Asked for an introduction rather than pushing a proposal at the wrong person.',
      },
      {
        type: 'email',
        days: -20,
        subject: 'Proposal out to Tessellate — 16× A100 at $1.68/GPU-hr',
        body: 'Three months, nothing beyond the term, on a pool that costs us $0.96. Thin, but the pool carries no take-or-pay floor, so hours we do not sell cost nothing.',
        who: 'other',
      },
      {
        type: 'note',
        days: -13,
        subject: 'Held 55% of the burst pool for Tessellate',
        body: 'A hold, not a sale. The hours are reserved and nothing has been earned against them; if they do not sign it lapses on its own.',
      },
      {
        type: 'email',
        days: -6,
        subject: 'Chasing Tessellate after the proposal',
        body: 'Quiet for a fortnight. Their launch date has not moved, so the silence is more likely to be their founder than a lost deal.',
        who: 'primary',
      },
      {
        type: 'call',
        days: -2.6,
        subject: 'Reached Owen — their founder wants one month first',
        body: 'One month, then extend. That is a third of the term, but the pool has no floor to protect and the hours are otherwise idle.',
        who: 'primary',
      },
      {
        type: 'note',
        days: -0.4,
        subject: "Tessellate's hold on the burst pool lapses in 12 days",
        body: 'Twelve days, then the hours go back to the pool. Worth deciding now whether we extend it or release it rather than discovering it on the day.',
      },
    ],
  },

  'Aurelian Systems': {
    stage: 'legal',
    beats: [
      {
        type: 'meeting',
        days: -120,
        subject: "Aurelian's platform team on a two-year H200 commitment",
        body: 'Meredith Cole runs the ML platform and has a two-year budget, which is rare enough to take seriously. 128 GPUs at steady state, no burst.',
      },
      {
        type: 'email',
        days: -99,
        subject: 'Aurelian want to paper this on their own agreement',
        body: 'Their standard form, not ours. Workable, but it assumes a hyperscaler underneath rather than a block we have committed to buy ourselves.',
        who: 'other',
      },
      {
        type: 'note',
        days: -84,
        subject: "Aurelian's security questionnaire: 214 items, due in a fortnight",
        body: 'Two-thirds of it is answerable from the supplier\'s own certifications and the rest is about us. The supplier\'s responses go in by reference rather than being rewritten.',
        who: 'other',
      },
      {
        type: 'task',
        days: -76,
        subject: "Return Aurelian's security questionnaire before Friday",
        body: 'Every day it sits is a day their legal review has not started, and their legal review is the long pole on a deal worth two years of committed capacity.',
      },
      {
        type: 'email',
        days: -70,
        subject: 'Questionnaire back to Aurelian, two items deferred',
        body: 'Both deferred items are the supplier\'s to answer — physical access logging and the spares held on site. Asked the supplier directly rather than guessing on their behalf.',
        who: 'other',
      },
      {
        type: 'meeting',
        days: -55,
        subject: 'Pricing workshop with Aurelian — 128× H200 over 24 months',
        body: 'Two years at a fixed rate means we carry the price risk on the second year. Priced accordingly, and said so in the room rather than burying it in the schedule.',
      },
      {
        type: 'contract_event',
        days: -34,
        subject: "First redlines back from Aurelian's counsel",
        body: 'Heavy but not unreasonable. The commercial substance sits in three clauses; the rest is house style and can be conceded cheaply.',
      },
      {
        type: 'note',
        days: -18,
        subject: 'Sticking point with Aurelian: termination on 60 days',
        body: 'Sixty days against a 24-month block we would still be paying for. This clause decides whether the deal is worth having at the price we quoted.',
      },
      {
        type: 'email',
        days: -8,
        subject: 'Countered Aurelian at 120 days with a prepayment credit',
        body: 'One hundred and twenty days, or sixty if they leave the prepayment behind. Either outcome covers the block underneath rather than stranding it.',
      },
      {
        type: 'call',
        days: -2.4,
        subject: 'Meredith: one more pass with their counsel',
        body: 'She expects signature early next quarter, which is where the close date now sits. Nothing is reserved for them, and nothing should be until it is signed.',
        who: 'primary',
      },
    ],
  },

  'Quillon AI': {
    stage: 'qualification',
    beats: [
      {
        type: 'email',
        days: -76,
        subject: 'Inbound from Quillon — an evaluation harness, small budget',
        body: 'Sofia Trentini, head of research. No capacity requirement at all, and a genuine question about whether they should simply build it themselves.',
        who: 'primary',
      },
      {
        type: 'call',
        days: -71,
        subject: 'Sofia: Quillon are weighing us against building it in-house',
        body: 'Two engineers and a quarter is their estimate of the build. Optimistic, but not so optimistic that arguing with it wins the deal.',
        who: 'primary',
      },
      {
        type: 'note',
        days: -63,
        subject: 'Quillon have no capacity requirement yet',
        body: 'Evaluations only. Worth keeping on the book because it is the cheapest way into an account that will need real capacity within the year.',
      },
      {
        type: 'email',
        days: -49,
        subject: 'Sent Quillon the evaluations overview',
        body: 'No reply.',
        who: 'other',
      },
      {
        type: 'buzz',
        days: -38,
        subject: 'Internal: is Quillon worth the desk time?',
        body: 'The view held: yes, at a nudge a quarter rather than a call a week. The deal does not pay for attention; the account behind it might.',
      },
      {
        type: 'note',
        days: -31,
        subject: 'Three weeks quiet at Quillon — moved to a quarterly nudge',
        body: 'Not lost and not moving. Chasing weekly would only teach them to ignore us.',
      },
      {
        type: 'email',
        days: -5,
        subject: 'Quarterly nudge to Sofia',
        body: 'Sent the evaluation harness pricing and left it there. If the in-house build has stalled, this is when it shows.',
        who: 'primary',
      },
    ],
  },
};

/**
 * The fallback, for a deal this file has no script for — a second deal on an
 * account, or one added after this module was written.
 *
 * Deliberately not evenly spaced either: an opening cluster while the
 * requirement is being pinned down, then a tail whose pace follows the stage.
 * The lost tail stops sixty days ago and stays stopped, because a lost deal
 * that keeps logging calls is the tell that a book is generated.
 */
function genericBeats(deal: {
  account: string;
  contact: string;
  stage: string;
  kit: string;
  /** The deal's own name, prefix stripped, for a second deal on an account. */
  label?: string;
  returning?: boolean;
}): Beat[] {
  const { account, contact, kit } = deal;

  /*
   * A second deal on an account does not open with a first contact.
   *
   * Without this, two deals against the same customer produced the same three
   * opening lines on the same three days, which on the account timeline reads
   * as a bug in the product rather than as two pieces of business.
   */
  const opening: Beat[] = deal.returning
    ? [
        {
          type: 'email',
          days: -74,
          subject: deal.label
            ? `${account} came back — ${deal.label}`
            : `${account} came back with a second workload`,
          body: `${kit}, alongside what they already run with us. A returning customer argues about capacity rather than about whether we can be trusted with it.`,
          who: 'primary',
        },
        {
          type: 'call',
          days: -63,
          subject: `Follow-up call with ${contact}`,
          body: 'What the new workload needs, whether it can share a fabric with the existing one, and when it has to start.',
          who: 'primary',
        },
        {
          type: 'note',
          days: -52,
          subject: `${account}: ${kit}, on top of what they already take`,
          body: 'Sizeable enough to need its own block rather than headroom on theirs, which makes it a deal rather than an amendment.',
        },
      ]
    : [
        {
          type: 'email',
          days: -112,
          subject: `First contact with ${account}`,
          body: `They asked what we could put in front of ${kit}. No date yet, and no sense of whether the budget exists.`,
          who: 'primary',
        },
        {
          type: 'call',
          days: -101,
          subject: `Discovery call with ${contact}`,
          body: `Went through the workload, the fabric it needs and the date it has to start. ${kit} is the shape of it.`,
          who: 'primary',
        },
        {
          type: 'note',
          days: -87,
          subject: `${account} will not move the start date`,
          body: `${kit}, and the date is the constraint rather than the price. Any block that cannot be handed over on time is out regardless of what it costs.`,
        },
      ];

  const tails: Record<string, Beat[]> = {
    qualification: [
      {
        type: 'email',
        days: -70,
        subject: `Sent ${account} the capacity overview`,
        body: 'Blocks, terms, what each one can and cannot carry. Nothing priced until the requirement is firm.',
        who: 'other',
      },
      {
        type: 'call',
        days: -52,
        subject: `${contact} still weighing us against doing it in-house`,
        body: 'Their build estimate is thin on the operational half, which is the half that costs money after the first month.',
        who: 'primary',
      },
      {
        type: 'note',
        days: -33,
        subject: `Nothing moves at ${account} until their next budget cycle`,
        body: 'Parked deliberately rather than chased. The requirement is real; the money is not there yet.',
      },
      {
        type: 'email',
        days: -6,
        subject: `Quarterly nudge to ${contact}`,
        body: 'Short, dated, and carrying the current rates rather than last quarter\'s.',
        who: 'primary',
      },
    ],
    scoping: [
      {
        type: 'meeting',
        days: -66,
        subject: `Scoping session with ${account}'s platform team`,
        body: `${kit}, and a fortnight of ramp before they can use all of it. The ramp is ours to absorb; the block is paid for at full size from day one.`,
      },
      {
        type: 'note',
        days: -48,
        subject: `${account} need one site, not two`,
        body: 'A single fabric, which narrows the blocks that can carry them and takes price off the table as the first question.',
      },
      {
        type: 'email',
        days: -25,
        subject: `Sent ${account} two shapes: committed and burst`,
        body: 'One priced on a term, one priced on the pool. The difference in rate is the difference in what we have to buy up front.',
        who: 'other',
      },
      {
        type: 'call',
        days: -7,
        subject: `${contact} choosing between the two shapes`,
        body: 'Leaning committed, which is the one that needs a block underneath it before we can promise a date.',
        who: 'primary',
      },
      {
        type: 'note',
        days: -2,
        subject: `${account} asked what a longer term does to the rate`,
        body: 'It lowers it, but only because the block underneath gets cheaper — worth being explicit that the discount is bought, not conjured.',
      },
    ],
    proposal: [
      {
        type: 'meeting',
        days: -60,
        subject: `Walked ${account} through the pricing`,
        body: 'Rate, term, what happens if they hand hours back, and what the notice period is on the block underneath.',
      },
      {
        type: 'stage_change',
        days: -41,
        subject: `Proposal out to ${account} — ${kit}`,
        body: 'Priced on the block we already hold, so the date is real rather than conditional on us buying something first.',
        who: 'other',
      },
      {
        type: 'note',
        days: -24,
        subject: `${account} pushing on price`,
        body: 'Held the rate. The margin over the block cost is not wide enough to give away and still cover the hours nobody buys.',
      },
      {
        type: 'email',
        days: -9,
        subject: `Chasing ${contact} on the proposal`,
        body: 'Second ask, with the hold expiry in the subject line so the date does the chasing.',
        who: 'primary',
      },
      {
        type: 'call',
        days: -3,
        subject: `${contact}: decision after their next board meeting`,
        body: 'A date rather than a maybe, which is as much as a proposal at this size usually gets.',
        who: 'primary',
      },
    ],
    procurement: [
      {
        type: 'email',
        days: -63,
        subject: `${account} raised a supplier onboarding form`,
        body: 'Bank details, insurance certificates and a modern slavery statement. None of it is negotiation, all of it is calendar.',
        who: 'other',
      },
      {
        type: 'note',
        days: -46,
        subject: `Security questionnaire back to ${account}`,
        body: 'Answered from the supplier\'s certifications where the question is about the site rather than about us.',
      },
      {
        type: 'call',
        days: -28,
        subject: `${contact}: budget approved, waiting on procurement`,
        body: 'The money exists. What is left is a queue, and queues respond to dates rather than to enthusiasm.',
        who: 'primary',
      },
      {
        type: 'email',
        days: -12,
        subject: `Chasing the supplier code from ${account}`,
        body: 'No purchase order can be raised without it, and the start date is holding hours we are already paying for.',
        who: 'other',
      },
      {
        type: 'note',
        days: -2.5,
        subject: `${account} expect to raise the order this week`,
        body: 'Their words, and the first date they have given that was not a quarter.',
      },
    ],
    poc: [
      {
        type: 'meeting',
        days: -58,
        subject: `Benchmark plan agreed with ${account}`,
        body: 'Their workload, their harness, our block, and a threshold written down before anyone runs anything.',
      },
      {
        type: 'stage_change',
        days: -44,
        subject: `Pilot started for ${account}`,
        body: `${kit} handed over at the rate the production term would carry, so the pilot tests the price alongside the hardware.`,
      },
      {
        type: 'slack',
        days: -36,
        subject: `Pilot numbers posted in the ${account} channel`,
        body: 'Their engineers picked the results apart the same afternoon, which is faster than any review a slide deck would have got.',
      },
      {
        type: 'meeting',
        days: -27,
        subject: `Mid-pilot review with ${contact}`,
        body: 'Ahead of the threshold on throughput, behind it on storage. Both are fixable; only one is ours to fix.',
        who: 'primary',
      },
      {
        type: 'email',
        days: -11,
        subject: `${account} asking what a committed term does to the rate`,
        body: 'The question that means a pilot has worked. Priced two terms rather than one so the comparison is theirs to make.',
        who: 'other',
      },
      {
        type: 'call',
        days: -2.5,
        subject: `${contact}: pilot results are good, moving to terms`,
        body: 'They want the paper ready before the pilot allocation runs out, which gives us a fortnight.',
        who: 'primary',
      },
    ],
    legal: [
      {
        type: 'email',
        days: -64,
        subject: `${account} want to paper this on their own agreement`,
        body: 'Their form assumes a hyperscaler underneath rather than a block we have bought and must pay for either way.',
      },
      {
        type: 'contract_event',
        days: -47,
        subject: `First redlines back from ${account}'s counsel`,
        body: 'Three clauses carry the commercial substance. The rest is house style.',
      },
      {
        type: 'note',
        days: -30,
        subject: `Open point with ${account}: termination for convenience`,
        body: 'Notice shorter than the notice on the block underneath means we keep paying for capacity they have handed back.',
      },
      {
        type: 'buzz',
        days: -22,
        subject: `Internal: what the ${account} termination clause costs us`,
        body: 'Worked through on the desk thread rather than in one person\'s head. Notice shorter than the block underneath is a number, and the number is the whole remaining term.',
      },
      {
        type: 'email',
        days: -14,
        subject: `Countered ${account} on notice and the prepayment credit`,
        body: 'Longer notice, or shorter notice against a prepayment that stays with us. Either shape covers the block.',
        who: 'other',
      },
      {
        type: 'call',
        days: -3,
        subject: `${contact}: one more pass with their counsel`,
        body: 'Signature expected next quarter. Nothing is reserved until it is signed.',
        who: 'primary',
      },
    ],
    deployment: [
      {
        type: 'contract_event',
        days: -55,
        subject: `Agreement with ${account} countersigned`,
        body: 'Executed on both sides, with the notice period matched to the block underneath.',
      },
      {
        type: 'meeting',
        days: -40,
        subject: `Kickoff with ${account}'s infrastructure team`,
        body: 'Handover plan, escalation path, and who is allowed to open a severity-one ticket at three in the morning.',
        who: 'other',
      },
      {
        type: 'slack',
        days: -33,
        subject: `Handover thread opened in the ${account} channel`,
        body: 'Both sides in one place, so a question at midnight reaches whoever is awake rather than whoever is on the contract.',
      },
      {
        type: 'note',
        days: -26,
        subject: `First tranche handed to ${account}`,
        body: 'Half now, half once their pipeline keeps up. We pay for all of it from the first day either way.',
      },
      {
        type: 'call',
        days: -12,
        subject: `Ramp check-in with ${contact}`,
        body: 'Steady, no incidents, and utilisation climbing as their queue fills.',
        who: 'primary',
      },
      {
        type: 'note',
        days: -2,
        subject: `${account} at their full allocation`,
        body: 'Everything they bought is in use, which is the point at which the expansion conversation is worth having.',
      },
    ],
    expansion: [
      {
        type: 'note',
        days: -58,
        subject: `${account} running above their committed hours`,
        body: 'Overage every week for a month. Cheaper for them on a second block than on the overage rate, and better for us than an argument about the invoice.',
      },
      {
        type: 'email',
        days: -40,
        subject: `Proposed a second block to ${contact}`,
        body: 'Sized to the overage rather than to the hope, so the number is defensible line by line.',
        who: 'primary',
      },
      {
        type: 'meeting',
        days: -25,
        subject: `Expansion review with ${account}`,
        body: 'Their forecast, our capacity, and the gap between the two in weeks rather than in quarters.',
        who: 'other',
      },
      {
        type: 'email',
        days: -10,
        subject: `Sent ${account} pricing for the additional capacity`,
        body: `${kit} again on the same terms, which keeps one renewal date rather than creating a second.`,
      },
      {
        type: 'call',
        days: -2,
        subject: `${contact}: they want it live before the quarter ends`,
        body: 'Achievable on the block we already hold. Anything larger needs a supplier conversation first.',
        who: 'primary',
      },
    ],
    closed_won: [
      {
        type: 'contract_event',
        days: -50,
        subject: `Agreement with ${account} executed`,
        body: 'Signed on both sides. The hours come off a block that is already bought and paid for.',
      },
      {
        type: 'meeting',
        days: -36,
        subject: `Kickoff with ${account}`,
        body: 'Access, quotas, escalation. Nothing commercial left to argue about, which makes for a short meeting.',
      },
      {
        type: 'note',
        days: -20,
        subject: `${account} onboarded — first hours drawn`,
        body: 'Utilisation from day three, which is faster than the ramp we assumed when the deal was priced.',
      },
      {
        type: 'email',
        days: -8,
        subject: `First invoice out to ${account}`,
        body: 'Against hours drawn, with the committed floor shown separately so there is no argument at the true-up.',
        who: 'other',
      },
      {
        type: 'call',
        days: -2,
        subject: `Check-in with ${contact} — everything steady`,
        body: 'No incidents, no complaints, and an early mention of a second workload.',
        who: 'primary',
      },
    ],
    closed_lost: [
      {
        type: 'email',
        days: -80,
        subject: `Proposal out to ${account}`,
        body: `${kit} on the block we hold, priced to cover the hours whether or not they take all of them.`,
        who: 'other',
      },
      {
        type: 'call',
        days: -72,
        subject: `${contact}: another supplier came in below us`,
        body: 'Under our block cost, on a longer term than we would buy. Either they know something we do not, or their supplier is buying the logo.',
        who: 'primary',
      },
      {
        type: 'stage_change',
        days: -66,
        subject: `Lost ${account} on price`,
        body: 'Not on fabric, not on residency, not on the date. Chasing it further would only have cost us the rate on the rest of the book.',
      },
      {
        type: 'email',
        days: -58,
        subject: `Asked ${contact} to keep us on the list for the next block`,
        body: 'Their term ends in a year and their supplier has to renew at whatever the market is then. Diarised, and otherwise left alone.',
        who: 'primary',
      },
    ],
  };

  const script = [...opening, ...(tails[deal.stage] ?? tails.proposal ?? [])];
  if (!deal.returning) return script;

  /*
   * Two deals on one account must not share an instant.
   *
   * Both would otherwise land on the same grid and the timeline would show two
   * identically-dated entries. The nudge is derived from the deal's own name so
   * it is stable across runs, and it only ever moves an entry further into the
   * past, which cannot push one out of the window the feed is showing.
   */
  const nudge = (hashOf(deal.label ?? deal.stage) % 7) * 0.21;
  return script.map((beat) => ({ ...beat, days: beat.days - nudge }));
}

/** What the demand slice knows about a deal, and all this one needs of it. */
export interface DealHistory {
  accountId: string;
  demandDealId: string;
  contactName: string;
  stage: string;
}

/**
 * The history behind one demand deal, and the stamps that follow from it.
 *
 * The account name is read back rather than passed in so that this module can
 * be given a bespoke script per account without the demand slice having to know
 * that scripts exist; the capacity request is read for the same reason, so that
 * a fallback script can name what the customer actually asked for.
 */
export async function seedDealActivities(
  context: DemoContext,
  deal: DealHistory,
): Promise<void> {
  const { db } = context;

  const [account] = await db
    .select({ name: accounts.name })
    .from(accounts)
    .where(eq(accounts.id, deal.accountId))
    .limit(1);
  if (!account) return;

  const shortName = account.name.replace(context.prefix, '');
  const script = DEMAND_SCRIPTS[shortName];

  const [named] = await db
    .select({ name: demandDeals.name })
    .from(demandDeals)
    .where(eq(demandDeals.id, deal.demandDealId))
    .limit(1);
  const label = named?.name.replace(context.prefix, '');

  let beats: readonly Beat[];
  if (script && script.stage === deal.stage) {
    beats = script.beats;
  } else {
    const [request] = await db
      .select({ gpuType: capacityRequests.gpuType, gpuCount: capacityRequests.gpuCount })
      .from(capacityRequests)
      .where(eq(capacityRequests.demandDealId, deal.demandDealId))
      .limit(1);
    // Whether this account has been spoken to before, on other business.
    const prior = await db
      .select({ demandDealId: activities.demandDealId })
      .from(activities)
      .where(eq(activities.accountId, deal.accountId))
      .limit(50);

    beats = genericBeats({
      account: shortName,
      contact: deal.contactName.split(' ')[0] ?? deal.contactName,
      stage: deal.stage,
      kit: describeKit(request?.gpuCount ?? null, request?.gpuType ?? null),
      label,
      returning: prior.some((row) => row.demandDealId !== deal.demandDealId),
    });
  }

  const people = await db
    .select({ id: contacts.id, fullName: contacts.fullName })
    .from(contacts)
    .where(eq(contacts.accountId, deal.accountId))
    .orderBy(asc(contacts.createdAt));

  const primary = people.find((p) => p.fullName === deal.contactName) ?? people[0];
  const others = people.filter((p) => p.id !== primary?.id);

  await insertBeats(context, beats, {
    accountId: deal.accountId,
    demandDealId: deal.demandDealId,
    // Keyed on the deal rather than on its stage: an account can carry two
    // deals, and a stage-keyed id would silently drop the second one's history.
    slug: `demand:${slugify(shortName)}:${slugify(label ?? deal.stage)}`,
    primaryContactId: primary?.id,
    otherContactIds: others.map((p) => p.id),
  });

  await stampFromActivities(context);
}

// ---------------------------------------------------------------- supply side

/**
 * The supply desk's own correspondence, which had none at all.
 *
 * A brokerage that shows six months of conversation with its customers and
 * nothing with the people it buys from is describing half a business. These are
 * the entries that make the other half legible: what the block cost and why the
 * floor was accepted, the ramp, the maintenance notice, the outage measured
 * against the service level it actually breached, and the renewal decision that
 * has to be taken before the notice date rather than at the end of the term.
 *
 * Keyed by supplier domain, and paired with the stage the script was written
 * for: a supplier can have more than one engagement on the book — a live block
 * and a churned one — and the live block's correspondence must not be hung on
 * the deal that lapsed. Anything without a bespoke script falls through to
 * `genericSupplyBeats`.
 *
 * No individual at a real company is named, and no subject asserts anything
 * about a real supplier that is not already on the demo block it refers to.
 */
const SUPPLY_SCRIPTS: Record<string, { readonly stage: string; readonly beats: readonly Beat[] }> = {
  'coreweave.com': {
    stage: 'live',
    beats: [
      {
        type: 'email',
        days: -158,
        subject: 'CoreWeave quoted $1.89/GPU-hr on 512× H200',
        body: 'Twelve months, New Jersey, Infiniband throughout. Well under the going rate on smaller blocks, and the whole of the difference is the size of the commitment.',
      },
      {
        type: 'meeting',
        days: -150,
        subject: 'Terms with CoreWeave: full take-or-pay, a quarter prepaid',
        body: 'A 100% floor means every unsold hour is still bought. Accepted for the price, which is the trade — and the reason break-even has to be measured against the whole block rather than the part that sold.',
      },
      {
        type: 'contract_event',
        days: -148,
        subject: 'Agreement with CoreWeave executed',
        body: 'Twelve months from handover, 90 days\' notice to stop the auto-renewal, prepayment reconciled against hours actually drawn.',
      },
      {
        type: 'note',
        days: -35,
        subject: 'New Jersey block hands over on the 1st, with a fortnight of ramp',
        body: 'A quarter of the fleet while the site finishes cabling, then all 512. Priced on the full block from the first day regardless.',
      },
      {
        type: 'stage_change',
        days: -30,
        subject: 'CoreWeave block live — 512× H200',
        body: 'On the book at $1.89/GPU-hr. Every hour is paid for whether or not it is sold, which is where the margin view has to start.',
      },
      {
        type: 'call',
        days: -22,
        subject: 'Monthly check-in with CoreWeave',
        body: 'Full fleet since the ramp ended and one node swapped inside the 24-hour replacement window, so no claim to make.',
      },
      {
        type: 'email',
        days: -7,
        subject: 'CoreWeave gave notice of a maintenance window on the fabric',
        body: 'Five business days\' notice as the agreement requires, and excluded from the uptime measurement. Warned the tenant before they noticed it themselves.',
      },
      {
        type: 'note',
        days: -0.9,
        subject: 'CoreWeave true-up is 130 days out — 84% of the block is spoken for',
        body: '76% sold and 8% burned by research. The remaining 16% has to find a buyer or it becomes an invoice on the true-up date.',
      },
    ],
  },

  'nebius.com': {
    stage: 'live',
    beats: [
      {
        type: 'email',
        days: -70,
        subject: 'Nebius quoted $1.71/GPU-hr on 128× H100 in Finland',
        body: 'Inside the EU, with ISO 27001 and a Type II on the site itself. For the customers this block was bought for, the residency is worth more than the rate.',
      },
      {
        type: 'meeting',
        days: -62,
        subject: 'Nebius: an 80% floor and nothing prepaid',
        body: 'Cheaper to walk away from than the New Jersey block, and priced to match. The 20% we are not obliged to buy is the only slack on the book.',
      },
      {
        type: 'contract_event',
        days: -60,
        subject: 'Agreement and service levels with Nebius executed',
        body: '99.5% at node level, measured monthly, with fee abatement rather than credits. Abatement returns money; credits return more of what already failed.',
      },
      {
        type: 'stage_change',
        days: -30,
        subject: 'Finland block live — 128× H100',
        body: 'At $1.71/GPU-hr with one tenant on it at $2.49.',
      },
      {
        type: 'note',
        days: -24,
        subject: 'Finland block is under water at 55% sold',
        body: 'At $2.49 against a $1.71 cost, about 69% of the block has to sell before it covers what it cost. It is at 55%.',
      },
      {
        type: 'agent_action',
        days: -12,
        subject: 'Piggy flagged idle capacity on the Finland block',
        body: '45% of the term\'s hours are unsold three weeks in, and the residency requirement that makes this block valuable also narrows the buyer list to a handful of accounts.',
        byAgent: true,
      },
      {
        type: 'note',
        days: -5,
        subject: 'Renewal notice on the Finland block is due in 26 days',
        body: 'Ninety days\' notice to stop it renewing for another six months, on a block that has not covered its cost in this one. The decision belongs before the notice date, not at the end of the term.',
      },
      {
        type: 'email',
        days: -1.4,
        subject: 'Nebius offered $1.66 on a twelve-month renewal',
        body: 'Five cents an hour off for twice the term, on a block that is 45% idle today. Cheaper hours do not fix an unsold block.',
      },
    ],
  },

  'crusoe.ai': {
    stage: 'live',
    beats: [
      {
        type: 'email',
        days: -95,
        subject: 'Crusoe quoted $3.05/GPU-hr on 64× B200 in Texas',
        body: 'Nine months, Infiniband, 15% prepaid against a full take-or-pay floor. Blackwell supply is tight enough that the floor was not up for discussion.',
      },
      {
        type: 'meeting',
        days: -80,
        subject: 'Crusoe technical diligence — fabric and storage',
        body: 'Fabric passed on the day. Storage throughput per GPU was the one reservation, and it was answered with a hardware change rather than an explanation.',
      },
      {
        type: 'contract_event',
        days: -60,
        subject: 'Agreement with Crusoe executed',
        body: 'Nine months, 90 days\' notice, 15% prepaid and reconciled against hours drawn.',
      },
      {
        type: 'note',
        days: -33,
        subject: 'Texas block accepted',
        body: 'NCCL all-reduce within 8% of theoretical across the fabric. Acceptance signed on the measurements, not on the tour.',
      },
      {
        type: 'call',
        days: -19,
        subject: 'Crusoe outage review — 4h 12m across two racks',
        body: 'A power event on two racks. At 99.5% measured monthly the allowance is 3h 36m, so this is fee abatement rather than an apology. Root cause arrived inside the 72 hours the agreement requires.',
      },
      {
        type: 'note',
        days: -12,
        subject: 'Crusoe abatement agreed for the rack outage',
        body: 'Claimed inside the 30-day deadline, on the affected nodes only. The postmortem is filed against the agreement rather than sitting in somebody\'s inbox.',
      },
      {
        type: 'email',
        days: -2.8,
        subject: 'Crusoe asked whether we will extend the Texas block',
        body: 'Eight months still to run, and one tenant on three-quarters of it who has not signed their production term. Nothing to decide until they do.',
      },
    ],
  },

  'runpod.io': {
    stage: 'live',
    beats: [
      {
        type: 'email',
        days: -50,
        subject: 'RunPod community pool at $0.96/GPU-hr, no floor',
        body: 'Cheap, cancellable, and unsuitable for anything with a residency or certification requirement. Bought for exactly that reason: it is where burst goes.',
      },
      {
        type: 'note',
        days: -41,
        subject: 'Community pool carries no certifications',
        body: 'No Type II and no ISO on the pool itself. Recorded against the site rather than in somebody\'s memory, so a regulated workload cannot land on it by accident.',
      },
      {
        type: 'contract_event',
        days: -34,
        subject: 'Agreement with RunPod executed',
        body: 'Three months, no take-or-pay floor and no prepayment. The point of the block is that walking away from it costs nothing.',
      },
      {
        type: 'stage_change',
        days: -30,
        subject: 'Burst pool live — 32× A100',
        body: 'At $0.96/GPU-hr. A zero floor is why this pool can carry a speculative hold that none of the other blocks could afford.',
      },
      {
        type: 'email',
        days: -12,
        subject: 'RunPod notified a price change on the community pool',
        body: 'Rates move on the 1st. Our block is priced for its term, but a renewal would be at the new rate — which changes what a burst quote can promise.',
      },
      {
        type: 'note',
        days: -3,
        subject: 'Nothing sold on the burst pool — one hold against it',
        body: 'The only claim on the pool is a hold that has not converted. On any other block that would be a loss; here it is simply an empty pool.',
      },
    ],
  },

  'lambda.ai': {
    stage: 'financial_diligence',
    beats: [
      {
        type: 'email',
        days: -24,
        subject: 'Lambda indicated $1.76/GPU-hr on 256× H200',
        body: 'Twelve months, indicative rather than firm. Thirteen cents under the New Jersey block on half the size, which is worth understanding before believing.',
      },
      {
        type: 'meeting',
        days: -15,
        subject: 'Lambda diligence — fabric passes, storage does not',
        body: 'Fabric verified end to end. Storage throughput per GPU is below the figure they publish, which matters for any tenant checkpointing at scale.',
      },
      {
        type: 'note',
        days: -9,
        subject: "Flagged Lambda's storage throughput to their solutions team",
        body: 'Their own numbers, not ours. A block that cannot feed checkpoints at rate is not a substitute for New Jersey whatever the hourly price says.',
      },
      {
        type: 'task',
        days: -4,
        subject: 'Get a firm quote from Lambda on the committed tranche',
        body: 'Indicative pricing cannot be committed against, and there is a tenant asking about a second H200 block this quarter.',
      },
      {
        type: 'email',
        days: -2.2,
        subject: 'Chased Lambda for the firm quote',
        body: 'Second ask. Nothing passes financial diligence without a number they can be held to.',
      },
      {
        type: 'note',
        days: -1.8,
        subject: "Lambda's storage answer is due alongside the quote",
        body: 'Both outstanding, both promised this week, neither arrived. The pricing is only interesting if the storage answer is good.',
      },
    ],
  },
};

/**
 * The fallback for a supply engagement this file has no script for — a second
 * block with the same supplier, a broker, an aggregator, anything added later.
 *
 * The two tails that end in silence are deliberate. A block we turned down and
 * a block we let lapse are the two supply outcomes a demo normally hides, and
 * they are the ones that show the pipeline is a judgement rather than a funnel.
 */
function genericSupplyBeats(supply: { supplier: string; stage: string; kit: string }): Beat[] {
  const { supplier, kit } = supply;

  const opening: Beat[] = [
    {
      type: 'email',
      days: -104,
      subject: `${supplier} came in with ${kit}`,
      body: 'A rate card and no term sheet. Worth an hour to establish whether the capacity is theirs or somebody else\'s.',
    },
    {
      type: 'call',
      days: -92,
      subject: `First call with ${supplier}`,
      body: 'Who owns the hardware, who operates it, and what happens when a node fails at three in the morning. The first two answers were clear; the third was not.',
    },
    {
      type: 'note',
      days: -80,
      subject: `${supplier}: the fabric is the question, not the price`,
      body: 'Everything else about the offer is ordinary. Whether the interconnect can carry a training job end to end is what decides it.',
    },
  ];

  const tails: Record<string, Beat[]> = {
    sourced: [
      {
        type: 'email',
        days: -62,
        subject: `Asked ${supplier} for a reference customer`,
        body: 'Anyone running a real workload on it for more than a month. A rate card is not evidence that a cluster works.',
      },
      {
        type: 'note',
        days: -40,
        subject: `${supplier} still has not named a reference`,
        body: 'Which is itself an answer, though not necessarily a final one.',
      },
      {
        type: 'note',
        days: -11,
        subject: `${supplier} parked until they can show a live cluster`,
        body: 'Kept on the book rather than dropped. Capacity is scarce enough that a maybe is worth a diary note.',
      },
    ],
    qualifying: [
      {
        type: 'meeting',
        days: -60,
        subject: `Capability review with ${supplier}`,
        body: 'Went through the fleet, the fabric and the operating model. The fleet is real; the operating model is three people and a rota.',
      },
      {
        type: 'email',
        days: -38,
        subject: `Asked ${supplier} for the site's certifications`,
        body: 'The certificates themselves rather than the claim. Half the book cannot be placed on a site that has neither.',
      },
      {
        type: 'note',
        days: -15,
        subject: `${supplier} answered on paper, not on evidence`,
        body: 'An alignment statement is not a certification, and a customer\'s auditor will know the difference even if we let it pass.',
      },
      {
        type: 'email',
        days: -4,
        subject: `Chasing ${supplier} for the audit report`,
        body: 'Second ask. Nothing moves to diligence without it.',
      },
    ],
    technical_diligence: [
      {
        type: 'meeting',
        days: -55,
        subject: `Fabric test scheduled on ${supplier}'s cluster`,
        body: 'Our harness, their hardware, a full-fleet all-reduce. Neither side gets to choose the numbers afterwards.',
      },
      {
        type: 'note',
        days: -34,
        subject: `${supplier} all-reduce is 18% off theoretical`,
        body: 'Tolerable for inference, not for a training tenant who will notice it in the first week and blame us.',
      },
      {
        type: 'email',
        days: -16,
        subject: `Asked ${supplier} to re-run with our harness`,
        body: 'Their own numbers were measured on a quarter of the fleet, which is not the thing anyone would be buying.',
      },
      {
        type: 'note',
        days: -3,
        subject: `${supplier} re-run is still short of the bar`,
        body: 'Better, and still not enough. The gap is the fabric, and the fabric is not something they can change this year.',
      },
    ],
    financial_diligence: [
      {
        type: 'email',
        days: -48,
        subject: `${supplier} quoted against a full take-or-pay floor`,
        body: 'Every hour bought whether or not it sells. That is the cost the whole block has to be measured against, not the part we resell.',
      },
      {
        type: 'note',
        days: -30,
        subject: `${supplier}'s economics only work above 70% sold`,
        body: 'Below that the block loses money at any resale price we could actually charge. Worth knowing before signing, not after.',
      },
      {
        type: 'meeting',
        days: -13,
        subject: `Finance review of the ${supplier} block`,
        body: 'Break-even, worst case, and what the shortfall costs if the demand behind it slips a quarter.',
      },
      {
        type: 'email',
        days: -2.5,
        subject: `Asked ${supplier} for a firm number`,
        body: 'Indicative pricing cannot be committed against, and there is demand waiting on the answer.',
      },
    ],
    pricing: [
      {
        type: 'email',
        days: -52,
        subject: `${supplier} opened at a rate we cannot resell`,
        body: 'Above what the same class of capacity is fetching on the open market, which leaves nothing between their price and ours.',
      },
      {
        type: 'meeting',
        days: -35,
        subject: `Rate and floor with ${supplier}`,
        body: 'Two levers, and they matter differently: the rate sets the margin, the floor sets what an empty block costs us.',
      },
      {
        type: 'note',
        days: -20,
        subject: `${supplier} will move on the floor, not on the rate`,
        body: 'Which is the better half of the trade. A lower floor is worth more than a few cents an hour on a block that may not fill.',
      },
      {
        type: 'email',
        days: -9,
        subject: `Countered ${supplier}: lower floor, shorter term`,
        body: 'Shorter term at their rate, or their term at a floor we can carry. Both shapes are ones we could actually sell out of.',
      },
      {
        type: 'call',
        days: -2.3,
        subject: `${supplier} taking the counter to their board`,
        body: 'An answer promised this week. Nothing is committed on our side until there is one.',
      },
    ],
    contracting: [
      {
        type: 'contract_event',
        days: -45,
        subject: `First draft from ${supplier}`,
        body: 'Their paper, and it reads as though the customer were a hobbyist rather than someone reselling the capacity.',
      },
      {
        type: 'note',
        days: -28,
        subject: `${supplier} draft offers credits, not abatement`,
        body: 'Credits return more of the thing that failed. On a block we have already sold hours out of, only money is a remedy.',
      },
      {
        type: 'email',
        days: -12,
        subject: `Redlines back to ${supplier}`,
        body: 'Abatement, a notice period that matches what we promise our own customers, and spares held on site.',
      },
      {
        type: 'call',
        days: -3,
        subject: `${supplier} expects to sign this week`,
        body: 'Nothing outstanding but their signature block and a bank detail we will verify by telephone rather than by email.',
      },
    ],
    onboarding: [
      {
        type: 'contract_event',
        days: -40,
        subject: `Agreement with ${supplier} executed`,
        body: 'Signed. The obligations that follow are dated in the system rather than remembered.',
      },
      {
        type: 'meeting',
        days: -25,
        subject: `Handover planning with ${supplier}`,
        body: 'Acceptance criteria written down before the cluster is handed over, so acceptance is a measurement rather than an argument.',
      },
      {
        type: 'note',
        days: -10,
        subject: `${supplier} cluster accepted on the second attempt`,
        body: 'First pass failed on two racks. Replaced inside the window the agreement allows, which is the window existing for exactly this.',
      },
      {
        type: 'note',
        days: -2,
        subject: `${supplier} block opens on the 1st`,
        body: 'Cost starts on the same day whether or not there is a tenant on it.',
      },
    ],
    live: [
      {
        type: 'contract_event',
        days: -40,
        subject: `Agreement with ${supplier} executed`,
        body: 'Signed, with the notice period matched to what we promise the tenants on top of it.',
      },
      {
        type: 'stage_change',
        days: -25,
        subject: `${supplier} block live — ${kit}`,
        body: 'On the book, and paid for in full from the first day regardless of how much of it sells.',
      },
      {
        type: 'call',
        days: -10,
        subject: `Monthly check-in with ${supplier}`,
        body: 'Fleet steady, one node swapped inside the replacement window, nothing to claim.',
      },
      {
        type: 'note',
        days: -2,
        subject: `${supplier} block steady`,
        body: 'No incidents this month. The only open question is how much of it is sold, which is ours rather than theirs.',
      },
    ],
    renewal: [
      {
        type: 'note',
        days: -50,
        subject: `${supplier} block comes up for renewal`,
        body: 'The decision belongs before the notice date, not at the end of the term. Missing the date renews it for us.',
      },
      {
        type: 'email',
        days: -30,
        subject: `Asked ${supplier} what a longer term buys`,
        body: 'A cheaper hour is only worth having if the hours sell. Asked for the floor to move as well as the rate.',
      },
      {
        type: 'meeting',
        days: -14,
        subject: `Renewal terms with ${supplier}`,
        body: 'They want two years, we want one with an option. The gap is the price of the option.',
      },
      {
        type: 'note',
        days: -3,
        subject: `Notice date on the ${supplier} block decides this`,
        body: 'Not the term end. Diarised, with the decision owner named rather than assumed.',
      },
    ],
    churned: [
      {
        type: 'note',
        days: -70,
        subject: `${supplier} block ran to term`,
        body: 'Served its purpose and never quite filled. The hours it did not sell were paid for all the same.',
      },
      {
        type: 'email',
        days: -55,
        subject: `${supplier} offered a renewal at the same rate`,
        body: 'The same rate on a block that did not cover itself is the same loss, twice.',
      },
      {
        type: 'stage_change',
        days: -44,
        subject: `Let the ${supplier} block lapse`,
        body: 'Notice given inside the window. No dispute, no shortfall invoice, and the relationship left intact for the next generation of hardware.',
      },
      {
        type: 'email',
        days: -38,
        subject: `Told ${supplier} we would look again next year`,
        body: 'Meant, rather than said politely. Their fabric was never the problem; the price against our demand was.',
      },
    ],
    rejected: [
      {
        type: 'meeting',
        days: -66,
        subject: `${supplier} technical review`,
        body: 'Fleet size is real and the price is remarkable. Everything after that is the question of what the fabric can actually carry.',
      },
      {
        type: 'note',
        days: -52,
        subject: `${supplier} cannot carry a distributed training job`,
        body: 'Aggregated consumer hardware across many sites: fine for embarrassingly parallel inference, useless for a job that needs one fabric.',
      },
      {
        type: 'stage_change',
        days: -44,
        subject: `Turned ${supplier} down`,
        body: 'Recorded with the reason attached. A rejection that does not say why has to be re-litigated the next time they call.',
      },
      {
        type: 'email',
        days: -36,
        subject: `Told ${supplier} why, in writing`,
        body: 'Specific and not unkind. If they solve the interconnect they should come back, and they will not if the refusal was vague.',
      },
    ],
  };

  return [...opening, ...(tails[supply.stage] ?? tails.qualifying ?? [])];
}

/**
 * The supply-side history, and the final pass over every stamp in the book.
 *
 * Called once, after the demand book exists: the restamp at the end has to see
 * every activity that will ever be written, including contacts added by the
 * demand slice after its deals were seeded.
 */
export async function seedSupplyActivities(context: DemoContext): Promise<void> {
  const { db, prefix } = context;

  // Driven from the deals that exist rather than from the scripts, so a
  // supplier added to the book after this file was written still gets a
  // history — an empty timeline on the buy side is what made the supply
  // pipeline read as a mock-up.
  const deals = await db
    .select({
      dealId: supplyDeals.id,
      accountId: supplyDeals.accountId,
      stage: supplyDeals.stage,
      gpuType: supplyDeals.gpuType,
      gpuCount: supplyDeals.gpuCount,
      domain: accounts.domain,
      accountName: accounts.name,
    })
    .from(supplyDeals)
    .innerJoin(accounts, eq(accounts.id, supplyDeals.accountId))
    .where(like(supplyDeals.name, `${prefix}%`));

  for (const deal of deals) {
    const script = deal.domain ? SUPPLY_SCRIPTS[deal.domain] : undefined;
    const supplier = deal.accountName.replace(prefix, '');
    const beats =
      script && script.stage === deal.stage
        ? script.beats
        : genericSupplyBeats({
            supplier,
            stage: deal.stage,
            kit: describeKit(deal.gpuCount, deal.gpuType),
          });

    await insertBeats(context, beats, {
      accountId: deal.accountId,
      supplyDealId: deal.dealId,
      slug: `supply:${slugify(deal.domain ?? supplier)}:${slugify(deal.stage)}`,
    });
  }

  await seedOrphanAccountActivity(context);
  await stampFromActivities(context);
}

/**
 * Anything that reached the end of the run with an empty timeline.
 *
 * The demand slice writes its history per deal as each deal is created, so
 * anything it adds by another route — a closed deal recorded after the fact, an
 * account created without a deal, an account that already existed and was
 * therefore skipped — would read "No activity" for ever. That is the single
 * worst thing a CRM demo can render on a screen someone is being shown, so this
 * pass sweeps for it: the deal's own script where there is a deal, and a short
 * qualification history where there is only an account.
 */
async function seedOrphanAccountActivity(context: DemoContext): Promise<void> {
  const { db, prefix } = context;

  /*
   * Deals first, because a deal with a single row against it is the commoner
   * gap: a closed deal recorded with one line saying it closed, and no sign of
   * the six months that got it there. Two entries or fewer is treated as no
   * history at all.
   */
  const deals = await db
    .select({
      id: demandDeals.id,
      accountId: demandDeals.accountId,
      stage: demandDeals.stage,
      primaryContactId: demandDeals.primaryContactId,
    })
    .from(demandDeals)
    .where(like(demandDeals.name, `${prefix}%`));

  for (const deal of deals) {
    if (!deal.accountId) continue;

    const seen = await db
      .select({ id: activities.id })
      .from(activities)
      .where(eq(activities.demandDealId, deal.id))
      .limit(3);
    if (seen.length >= 3) continue;

    const named = deal.primaryContactId
      ? await db
          .select({ fullName: contacts.fullName })
          .from(contacts)
          .where(eq(contacts.id, deal.primaryContactId))
          .limit(1)
      : [];
    const [fallback] = await db
      .select({ fullName: contacts.fullName })
      .from(contacts)
      .where(eq(contacts.accountId, deal.accountId))
      .orderBy(asc(contacts.createdAt))
      .limit(1);

    // Routed back through the function the demand slice calls, so a deal
    // rescued here gets the same script — and the same external ids — it would
    // have had if its history had been seeded in the usual order.
    await seedDealActivities(context, {
      accountId: deal.accountId,
      demandDealId: deal.id,
      contactName: named[0]?.fullName ?? fallback?.fullName ?? '',
      stage: deal.stage,
    });
  }

  const book = await db
    .select({ id: accounts.id, name: accounts.name, side: accounts.side })
    .from(accounts)
    .where(like(accounts.name, `${prefix}%`));

  for (const account of book) {
    if (account.side === 'supply') continue;

    const [seen] = await db
      .select({ id: activities.id })
      .from(activities)
      .where(eq(activities.accountId, account.id))
      .limit(1);
    if (seen) continue;

    const [person] = await db
      .select({ fullName: contacts.fullName })
      .from(contacts)
      .where(eq(contacts.accountId, account.id))
      .orderBy(asc(contacts.createdAt))
      .limit(1);

    const shortName = account.name.replace(prefix, '');
    const people = await db
      .select({ id: contacts.id })
      .from(contacts)
      .where(eq(contacts.accountId, account.id))
      .orderBy(asc(contacts.createdAt));

    await insertBeats(
      context,
      genericBeats({
        account: shortName,
        contact: person?.fullName.split(' ')[0] ?? 'their team',
        stage: 'qualification',
        kit: 'a block',
      }),
      {
        accountId: account.id,
        slug: `demand:${slugify(shortName)}:qualification`,
        primaryContactId: people[0]?.id,
        otherContactIds: people.slice(1).map((p) => p.id),
      },
    );
  }
}

// -------------------------------------------------------------------- writing

/**
 * How a person would say it out loud. "H100_80GB" is a catalogue key; a broker
 * writing to a customer says 64× H100, and the subject line is read by people.
 */
function describeKit(gpuCount: number | null, gpuType: string | null): string {
  if (!gpuCount || !gpuType) return 'a block';
  return `${gpuCount}× ${gpuType.replace(/_\d+GB$/, '')}`;
}

/** A small stable hash. Used to separate dates, never to identify anything. */
function hashOf(value: string): number {
  let hash = 2166136261;
  for (let i = 0; i < value.length; i += 1) {
    hash = Math.imul(hash ^ value.charCodeAt(i), 16777619);
  }
  return Math.abs(hash);
}

function slugify(value: string): string {
  return value
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '');
}

/**
 * Write a script, and be safe to run twice.
 *
 * Idempotency is carried by `externalId` — a deterministic slug per script plus
 * the beat's position — rather than by counting rows, because the seed is
 * expected to run against a database that already holds part of the book.
 */
async function insertBeats(
  context: DemoContext,
  beats: readonly Beat[],
  target: {
    accountId: string;
    demandDealId?: string;
    supplyDealId?: string;
    slug: string;
    primaryContactId?: string;
    otherContactIds?: string[];
  },
): Promise<void> {
  const { db, prefix, at } = context;
  if (beats.length === 0) return;

  const others = target.otherContactIds ?? [];
  // Started at a per-script offset rather than at zero, so two deals on the
  // same account do not both hand every non-primary entry to the same person
  // and leave the third contact on the account with an empty row.
  let rotation = others.length > 0 ? hashOf(target.slug) % others.length : 0;

  const rows = beats.map((beat, index) => {
    let contactId: string | null = null;
    if (beat.who === 'other' && others.length > 0) {
      contactId = others[rotation % others.length] ?? null;
      rotation += 1;
    } else if (beat.who) {
      contactId = target.primaryContactId ?? null;
    }

    return {
      type: beat.type,
      subject: `${prefix}${beat.subject}`,
      body: beat.body,
      accountId: target.accountId,
      contactId,
      demandDealId: target.demandDealId ?? null,
      supplyDealId: target.supplyDealId ?? null,
      occurredAt: at(beat.days),
      actorAgent: beat.byAgent ? 'piggy' : null,
      source: beat.byAgent ? ('agent' as const) : ('seed' as const),
      externalId: `demo:${target.slug}:${index}`,
    };
  });

  await db.insert(activities).values(rows).onConflictDoNothing({ target: activities.externalId });
}

// --------------------------------------------------------------------- stamps

/**
 * Set every `lastActivityAt` from the activities themselves.
 *
 * Scoped to rows that carry at least one demo entry, and computed over *all*
 * their activities rather than only the demo ones: on a database where someone
 * has been working alongside the demo book, taking the maximum of the demo rows
 * alone would move a real stamp backwards.
 */
async function stampFromActivities(context: DemoContext): Promise<void> {
  const { db, prefix } = context;
  const demo = `${prefix}%`;

  await db.execute(sql`
    with touched as (
      select distinct account_id as id from activities
      where account_id is not null and subject like ${demo}
    ), latest as (
      select a.account_id as id, max(a.occurred_at) as at
      from activities a join touched t on t.id = a.account_id
      group by a.account_id
    )
    update accounts set last_activity_at = latest.at
    from latest where accounts.id = latest.id
  `);

  await db.execute(sql`
    with touched as (
      select distinct contact_id as id from activities
      where contact_id is not null and subject like ${demo}
    ), latest as (
      select a.contact_id as id, max(a.occurred_at) as at
      from activities a join touched t on t.id = a.contact_id
      group by a.contact_id
    )
    update contacts set last_activity_at = latest.at
    from latest where contacts.id = latest.id
  `);

  await db.execute(sql`
    with touched as (
      select distinct demand_deal_id as id from activities
      where demand_deal_id is not null and subject like ${demo}
    ), latest as (
      select a.demand_deal_id as id, max(a.occurred_at) as at
      from activities a join touched t on t.id = a.demand_deal_id
      group by a.demand_deal_id
    )
    update demand_deals set last_activity_at = latest.at
    from latest where demand_deals.id = latest.id
  `);

  await db.execute(sql`
    with touched as (
      select distinct supply_deal_id as id from activities
      where supply_deal_id is not null and subject like ${demo}
    ), latest as (
      select a.supply_deal_id as id, max(a.occurred_at) as at
      from activities a join touched t on t.id = a.supply_deal_id
      group by a.supply_deal_id
    )
    update supply_deals set last_activity_at = latest.at
    from latest where supply_deals.id = latest.id
  `);
}

/**
 * Take the demo book back out of the stamps, for `--clear`.
 *
 * The supplier accounts are real and survive the teardown, but their
 * `lastActivityAt` was computed from demo correspondence that is about to be
 * deleted — so without this a cleared database shows CoreWeave as contacted
 * yesterday with an empty timeline behind it. Recomputed from whatever
 * non-demo activity remains, which is null on a database that only ever held
 * the demo book.
 *
 * Must run BEFORE the activities are deleted: afterwards there is no way to
 * tell which accounts the demo book had touched.
 */
export async function unstampDemoActivity(context: DemoContext): Promise<void> {
  const { db, prefix } = context;
  const demo = `${prefix}%`;

  await db.execute(sql`
    with touched as (
      select distinct account_id as id from activities
      where account_id is not null and subject like ${demo}
    )
    update accounts set last_activity_at = (
      select max(t.occurred_at) from activities t
      where t.account_id = accounts.id and (t.subject is null or t.subject not like ${demo})
    )
    from touched where accounts.id = touched.id
  `);
}

/**
 * A provisional stamp, written when the account row is created and overwritten
 * from `max(occurredAt)` by `stampFromActivities` a few statements later.
 *
 * It exists only so the column is never briefly null on a row a concurrent
 * reader might see; nothing in the finished book still carries this value, and
 * nothing should be derived from it. The truth is the activity feed.
 */
export function accountLastActivityAt(context: DemoContext, index: number): Date {
  return context.at(-2 - (index % 5));
}

/** As `accountLastActivityAt`, for a deal. Overwritten from its feed. */
export function dealLastActivityAt(context: DemoContext, index: number): Date {
  return context.at(-1 - (index % 6));
}
