/**
 * The tools interactive chat gets.
 *
 * Two layers, and the distinction between them is the whole design.
 *
 * FOCUSED tools answer where the user already is: the record the panel was
 * opened from, or the page it is docked on. They take no id, because the id is
 * the context, and a tool that could pivot would let the model wander off the
 * thing the user is looking at.
 *
 * LOOKUP tools are the opposite, and exist because the focused layer capped
 * every conversation at one question. "Compare Halcyon and Northwind", "which
 * customer has the nearest renewal", "what can we buy H200 for" are all
 * questions about rows nobody handed Piggy, and until it could find one by name
 * the only honest answer was that it could not look.
 *
 * Lookup tools are offered on every message, so each is a permanent tax on the
 * prompt and one more thing a 30B model can choose wrongly. Four earned that —
 * see the note above `createLookupPigTools` for what was declined and why.
 */
import {
  PIGGY_RECORD_TYPES,
  formatCents,
  isPageContext,
  type PiggyRecordType,
} from '@pig/core';
import {
  accounts,
  allocations,
  capacityCommitments,
  contacts,
  contractObligations,
  contracts,
  demandDeals,
  slaMetricTargets,
  slaTerms,
  supplyDeals,
  type Contract,
  type Database,
  type InventoryListing,
} from '@pig/db';
import { CapacityService } from '@pig/api/src/services/capacity';
import { renewalAlarm } from '@pig/api/src/services/contracts';
import { and, asc, count, eq, gt, ilike, inArray, isNotNull, isNull } from 'drizzle-orm';
import { z } from 'zod';
import type { PiggyChatContext } from './chat';
import { createAccountLifecycleTool } from './lifecycle-tools';
import { atLeast, createPagePigTools, resultScope, type ResultScope } from './page-tools';
import { defineTool, type AgentTool } from './provider';

const noInput = z.object({}).strict();

/**
 * The per-collection cap on a record read.
 *
 * Named rather than repeated as a literal because the scope below reports it:
 * a related list that came back exactly full is a list that was probably cut,
 * and a cut list the reader cannot see is how "this account has 100 deals"
 * gets said about an account with three hundred.
 */
const RELATED_LIMIT = 100;

/**
 * The scope of a record read.
 *
 * Unlike the page tools, a record read filters nothing — it enumerates what
 * belongs to one row — so `matched` equals `total` and the sentence is not
 * hedged. What it must still say is the boundary, because the failure here is
 * the same shape as the /capacity one measured in production: asked how many
 * deals are on the book while an account is in focus, a model with only this
 * payload counts the four in front of it. `totalLabel` therefore names the
 * record and says the figures stop there.
 */
function recordScope(subject: string, collections: Record<string, readonly unknown[]>): ResultScope {
  const entries = Object.entries(collections);
  const rows = entries.reduce((sum, [, list]) => sum + list.length, 0);
  const capped = entries.some(([, list]) => list.length >= RELATED_LIMIT);
  const breakdown = entries.map(([label, list]) => `${list.length} ${label}`).join(', ');
  return resultScope({
    covers: `belong to ${subject}`,
    matched: rows,
    total: rows,
    totalLabel: `record(s) belonging to ${subject} and to no other — ${breakdown}; these are that record's own figures, never book-wide totals`,
    listed: rows,
    filters: capped ? { rowCapPerCollection: RELATED_LIMIT } : {},
    truncated: capped,
  });
}

/**
 * Interactive chat gets one scoped read tool for where it is, plus the lookup
 * layer, and no ambient access.
 *
 * A record context gets `pig_get_record`, which takes no id and so always reads
 * the row the user opened. A page context gets the single tool that answers
 * that page — and never `pig_get_record`, because there is no record to read
 * and a tool that would throw is a wasted turn out of four.
 */
export function createInteractivePigTools(
  db: Database,
  context: PiggyChatContext | undefined,
): AgentTool[] {
  return [...focusedPigTools(db, context), ...createLookupPigTools(db)];
}

function focusedPigTools(db: Database, context: PiggyChatContext | undefined): AgentTool[] {
  // No context is the dashboard case by another name: the same bounded
  // workspace overview, rather than a second definition that could drift.
  if (!context) return createPagePigTools(db, '/');
  if (isPageContext(context)) return createPagePigTools(db, context.route);
  const focused = defineTool({
    name: 'pig_get_record',
    description:
      'Read the PIG record currently in focus and its directly related commercial data. ' +
      'This tool accepts no id; use pig_get_record_by_id to read a different record.',
    inputSchema: noInput,
    execute: async () => readFocusedRecord(db, context),
  });
  if (context.type === 'account') return [focused, createAccountLifecycleTool(db, context.id)];
  return [focused];
}

type PiggyRecordContext = Exclude<PiggyChatContext, { type: 'page' }>;

/**
 * One not-found message for both entry points.
 *
 * `readFocusedRecord` is now reached from a context the panel supplied AND from
 * an id the model chose, so "the account in focus no longer exists" was wrong
 * half the time — and wrong in the direction that makes a model retry rather
 * than correct the id it invented.
 */
function missingRecord(type: PiggyRecordType, id: string): Error {
  return new Error(`No ${type} record exists with id ${id}.`);
}

async function readFocusedRecord(db: Database, context: PiggyRecordContext): Promise<unknown> {
  if (context.type === 'account') {
    const [account] = await db.select().from(accounts).where(eq(accounts.id, context.id)).limit(1);
    if (!account) throw missingRecord(context.type, context.id);
    const [people, demand, supply, paperwork] = await Promise.all([
      db.select().from(contacts).where(eq(contacts.accountId, context.id)).limit(RELATED_LIMIT),
      db.select().from(demandDeals).where(eq(demandDeals.accountId, context.id)).limit(RELATED_LIMIT),
      db.select().from(supplyDeals).where(eq(supplyDeals.accountId, context.id)).limit(RELATED_LIMIT),
      db.select().from(contracts).where(eq(contracts.accountId, context.id)).limit(RELATED_LIMIT),
    ]);
    return {
      scope: recordScope(`the account ${account.name}`, {
        'contact(s)': people,
        'demand deal(s)': demand,
        'supply deal(s)': supply,
        'contract(s)': paperwork,
      }),
      account,
      contacts: people,
      demandDeals: demand,
      supplyDeals: supply,
      contracts: paperwork,
    };
  }

  if (context.type === 'contact') {
    const [contact] = await db.select().from(contacts).where(eq(contacts.id, context.id)).limit(1);
    if (!contact) throw missingRecord(context.type, context.id);
    const [account] = contact.accountId
      ? await db.select().from(accounts).where(eq(accounts.id, contact.accountId)).limit(1)
      : [];
    return {
      scope: recordScope(`the contact ${contact.fullName}`, {
        'account(s)': account ? [account] : [],
      }),
      contact,
      account: account ?? null,
    };
  }

  if (context.type === 'demand_deal') {
    const [deal] = await db.select().from(demandDeals).where(eq(demandDeals.id, context.id)).limit(1);
    if (!deal) throw missingRecord(context.type, context.id);
    const [account] = await db.select().from(accounts).where(eq(accounts.id, deal.accountId)).limit(1);
    const reservations = await db
      .select()
      .from(allocations)
      .where(eq(allocations.demandDealId, deal.id))
      .limit(RELATED_LIMIT);
    return {
      scope: recordScope(`the demand deal ${deal.name}`, {
        'allocation(s)': reservations,
        'account(s)': account ? [account] : [],
      }),
      deal,
      account: account ?? null,
      allocations: reservations,
    };
  }

  if (context.type === 'supply_deal') {
    const [deal] = await db.select().from(supplyDeals).where(eq(supplyDeals.id, context.id)).limit(1);
    if (!deal) throw missingRecord(context.type, context.id);
    const [account] = await db.select().from(accounts).where(eq(accounts.id, deal.accountId)).limit(1);
    const commitments = await db
      .select()
      .from(capacityCommitments)
      .where(eq(capacityCommitments.supplyDealId, deal.id))
      .limit(RELATED_LIMIT);
    return {
      scope: recordScope(`the supply deal ${deal.name}`, {
        'capacity commitment(s)': commitments,
        'account(s)': account ? [account] : [],
      }),
      deal,
      account: account ?? null,
      commitments,
    };
  }

  if (context.type === 'commitment') {
    const [commitment] = await db
      .select()
      .from(capacityCommitments)
      .where(eq(capacityCommitments.id, context.id))
      .limit(1);
    if (!commitment) throw missingRecord(context.type, context.id);
    const reservations = await db
      .select()
      .from(allocations)
      .where(eq(allocations.capacityCommitmentId, commitment.id))
      .limit(RELATED_LIMIT);
    return {
      scope: recordScope(`the capacity commitment ${commitment.name}`, {
        'allocation(s)': reservations,
      }),
      commitment,
      allocations: reservations,
    };
  }

  const [contract] = await db.select().from(contracts).where(eq(contracts.id, context.id)).limit(1);
  if (!contract) throw missingRecord(context.type, context.id);
  const [serviceLevels, obligations] = await Promise.all([
    db.select().from(slaTerms).where(eq(slaTerms.contractId, contract.id)).limit(10),
    db
      .select()
      .from(contractObligations)
      .where(eq(contractObligations.contractId, contract.id))
      .limit(RELATED_LIMIT),
  ]);
  const metrics = serviceLevels[0]
    ? await db
        .select()
        .from(slaMetricTargets)
        .where(eq(slaMetricTargets.slaTermId, serviceLevels[0].id))
        .limit(RELATED_LIMIT)
    : [];
  return {
    scope: recordScope(`the contract ${contract.title}`, {
      'SLA term(s)': serviceLevels,
      'SLA metric target(s)': metrics,
      'obligation(s)': obligations,
    }),
    contract,
    slaTerms: serviceLevels,
    slaMetricTargets: metrics,
    obligations,
  };
}

// ---------------------------------------------------------------------------
// The lookup layer
// ---------------------------------------------------------------------------

/** Rows any one search may carry back per table, before ranking. */
const SEARCH_PER_TYPE = 5;

/** Rows a search result may carry in total, after ranking. */
const SEARCH_RESULTS = 12;

/** The longest name fragment a model may send. Long enough for any real name. */
const SEARCH_QUERY_MAX = 64;

/** Rows a ranked list may carry. Everything above it is reported as a count. */
const EXEMPLARS = 8;

/** Bound on an internal scan. Wide enough for a real book, still finite. */
const SCAN_LIMIT = 200;

/**
 * The tools that are not about where the user is standing.
 *
 * Four, chosen against a fixed budget: every entry here is in the prompt of
 * every message and is another candidate for a small model to pick wrongly.
 *
 * `pig_search_records` and `pig_get_record_by_id` are the pair that lifts the
 * one-question ceiling — find a row by name, then open it. `readFocusedRecord`
 * does the opening, so a record fetched by id is shaped exactly like the record
 * the panel was opened from and the model has one shape to learn, not two.
 *
 * `pig_list_renewals` exists because the renewal deadline is expiry minus
 * notice days, which nothing in a record payload states outright: given the raw
 * contract a model has to do date arithmetic it is bad at, and a notice window
 * that quietly opened last week is the most expensive thing in this book to
 * miss. The calendar tool does surface renewal notices, but only on /calendar,
 * mixed into thirteen other kinds, and without the contract ids.
 *
 * `pig_list_inventory` reads what providers are currently offering. Nothing
 * else can see that table at all, and "what would this cost us to buy today"
 * is the supply half of every pricing conversation.
 *
 * Declined, deliberately:
 *
 * - A commitment-comparison tool. `pig_search_records` plus two
 *   `pig_get_record_by_id` calls already answer it inside the four-turn budget,
 *   and `pig_get_margin_summary` already ranks live blocks by margin. A fifth
 *   tool would be a fifth wrong choice for a question two calls cover.
 * - Anything over `activities`. There are 185 of them, they are prose, and a
 *   bounded slice of somebody's notes is the fastest way to spend a 1024-token
 *   answer on transcription. The lifecycle tool already carries the one
 *   activity fact that changes a decision — when the account last moved.
 * - Contacts in the search index. A person is not a commercial record, the
 *   account read already returns its contacts, and every extra searched table
 *   dilutes the twelve result slots the model actually reads.
 */
export function createLookupPigTools(db: Database): AgentTool[] {
  // Two things about the optional parameters below are load-bearing and
  // invisible in TypeScript, both found by printing what the model is actually
  // sent (`zodToJsonSchema(..., { target: 'openAi' })`).
  //
  // They are `.nullish()`, not `.optional()`. The OpenAI target emits an
  // optional field as required-and-nullable, so a model that follows the schema
  // it was given sends `{"side": null}` — which `.optional()` rejects, turning a
  // correct call into a failed tool result.
  //
  // And `.describe()` comes BEFORE `.nullish()`. Applied after, the description
  // is attached to the wrapper and dropped from the emitted schema, so the
  // sentence explaining the parameter never reaches the model at all.
  return [
    defineTool({
      name: 'pig_search_records',
      description:
        'Find PIG records by name when they are not already in focus. Matches the name or title ' +
        'of accounts, demand deals, supply deals, contracts and capacity commitments, ' +
        'case-insensitively, on a fragment. Returns each match with its type and id, for ' +
        'pig_get_record_by_id. Names only: this does not search notes, activities or people.',
      inputSchema: z
        .object({
          query: z
            .string()
            .trim()
            .min(2)
            .max(SEARCH_QUERY_MAX)
            .describe(
              `Name fragment, 2 to ${SEARCH_QUERY_MAX} characters. Use the distinctive word, not a whole sentence: "Halcyon", not "the Halcyon Research account".`,
            ),
        })
        .strict(),
      execute: async ({ query }) => searchRecords(db, query),
    }),
    defineTool({
      name: 'pig_get_record_by_id',
      description:
        'Read one PIG record by type and id, with its directly related commercial data. Use it ' +
        'to open a result from pig_search_records. Ids must come from a tool result; never ' +
        'invent one.',
      inputSchema: z
        .object({
          type: z
            .enum(PIGGY_RECORD_TYPES)
            .describe('Record type, exactly as pig_search_records reported it.'),
          id: z.string().uuid().describe('Record id from a previous tool result.'),
        })
        .strict(),
      execute: async ({ type, id }) => readFocusedRecord(db, { type, id }),
    }),
    defineTool({
      name: 'pig_list_renewals',
      description:
        'List executed contracts that have not yet expired, ordered by the nearest deadline: ' +
        'the renewal-notice date where the contract auto-renews, otherwise the expiry date. ' +
        'renewalState is "due" when the notice window is already open, "scheduled" when it is ' +
        'still ahead, "not_applicable" when the contract does not auto-renew.',
      inputSchema: z
        .object({
          side: z
            .enum(['demand', 'supply'])
            .describe('demand for customer paper, supply for provider paper. null for both.')
            .nullish(),
        })
        .strict(),
      execute: async ({ side }) => listRenewals(db, side ?? undefined),
    }),
    defineTool({
      name: 'pig_list_inventory',
      description:
        'List the GPU capacity third-party providers currently offer for purchase, cheapest ' +
        'first: provider, region, interconnect, stock level and on-demand price per GPU-hour. ' +
        'This is capacity on offer, not capacity PIG already owns — what PIG owns is a capacity ' +
        'commitment.',
      inputSchema: z
        .object({
          gpuType: z
            .string()
            .trim()
            .min(1)
            .max(24)
            .describe('GPU model fragment, matched loosely: "H100" finds H100_80GB. null for any.')
            .nullish(),
          minGpuCount: z
            .number()
            .int()
            .min(1)
            .max(100_000)
            .describe('Smallest acceptable GPU count per listing. null for any.')
            .nullish(),
          requiresFastInterconnect: z
            .boolean()
            .describe('True to keep only Infiniband, RoCE or NVLink — the training-grade fabrics.')
            .nullish(),
        })
        .strict(),
      execute: async (input) =>
        listInventory(db, {
          gpuType: input.gpuType ?? undefined,
          minGpuCount: input.minGpuCount ?? undefined,
          requiresFastInterconnect: input.requiresFastInterconnect ?? undefined,
        }),
    }),
  ];
}

// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------

/** A hit plus the fields ranking needs and the payload does not. */
interface RankedHit {
  rank: number;
  name: string;
  hit: Record<string, unknown>;
}

/**
 * `%` and `_` are LIKE wildcards and this string arrives from a model. Left
 * unescaped, a query of `%` matches every row in every table and the model is
 * handed the first five rows of each as though they were answers.
 */
export function likeFragment(query: string): string {
  return `%${query.replace(/[\\%_]/g, (character) => `\\${character}`)}%`;
}

/**
 * Exact name first, then prefix, then anything containing the fragment.
 *
 * "Meridian" matches two accounts in the demo book — a sovereign customer and a
 * supply partner — so the tie-break is not academic: an unranked list buries the
 * exact match the user named behind whichever row the planner returned first.
 */
function matchRank(name: string, query: string): number {
  const lowered = name.toLowerCase();
  const needle = query.toLowerCase();
  if (lowered === needle) return 0;
  if (lowered.startsWith(needle)) return 1;
  return 2;
}

/**
 * The tie-break when the match quality is identical.
 *
 * Searching "Halcyon" in the demo book matches one account and three contracts,
 * none of them a prefix match, so without this the list opens with a data
 * processing addendum and the account — the record that reaches the other three
 * through `pig_get_record_by_id` — is third. The hub record goes first.
 */
const TYPE_PRIORITY: Record<string, number> = {
  account: 0,
  demand_deal: 1,
  supply_deal: 2,
  commitment: 3,
  contract: 4,
};

async function accountNames(
  db: Database,
  ids: readonly string[],
): Promise<Map<string, string>> {
  const unique = [...new Set(ids)];
  if (unique.length === 0) return new Map();
  const rows = await db
    .select({ id: accounts.id, name: accounts.name })
    .from(accounts)
    .where(inArray(accounts.id, unique));
  return new Map(rows.map((row) => [row.id, row.name]));
}

/**
 * Five bounded name searches, ranked into one list.
 *
 * Each table is read one row past its budget so the result can say it was cut
 * rather than let the model report "two contracts match" over a capped five.
 * The counts are per type because "which Meridian?" is answered by the shape of
 * the result set, not by the first row of it.
 */
async function searchRecords(db: Database, query: string): Promise<unknown> {
  const fragment = likeFragment(query);
  const take = SEARCH_PER_TYPE + 1;

  const [
    accountRows,
    demandRows,
    supplyRows,
    contractRows,
    commitmentRows,
    accountsAll,
    demandAll,
    supplyAll,
    contractsAll,
    commitmentsAll,
  ] = await Promise.all([
    db
      .select({
        id: accounts.id,
        name: accounts.name,
        side: accounts.side,
        customerSegment: accounts.customerSegment,
        country: accounts.country,
      })
      .from(accounts)
      .where(and(isNull(accounts.archivedAt), ilike(accounts.name, fragment)))
      .limit(take),
    db
      .select({
        id: demandDeals.id,
        name: demandDeals.name,
        accountId: demandDeals.accountId,
        stage: demandDeals.stage,
        acvCents: demandDeals.acvCents,
        tcvCents: demandDeals.tcvCents,
        expectedCloseDate: demandDeals.expectedCloseDate,
      })
      .from(demandDeals)
      .where(ilike(demandDeals.name, fragment))
      .limit(take),
    db
      .select({
        id: supplyDeals.id,
        name: supplyDeals.name,
        accountId: supplyDeals.accountId,
        stage: supplyDeals.stage,
        gpuType: supplyDeals.gpuType,
        gpuCount: supplyDeals.gpuCount,
        targetCostPerGpuHourCents: supplyDeals.targetCostPerGpuHourCents,
      })
      .from(supplyDeals)
      .where(ilike(supplyDeals.name, fragment))
      .limit(take),
    db
      .select({
        id: contracts.id,
        title: contracts.title,
        accountId: contracts.accountId,
        contractType: contracts.type,
        status: contracts.status,
        side: contracts.side,
        expiresAt: contracts.expiresAt,
        valueCents: contracts.valueCents,
      })
      .from(contracts)
      .where(ilike(contracts.title, fragment))
      .limit(take),
    db
      .select({
        id: capacityCommitments.id,
        name: capacityCommitments.name,
        accountId: capacityCommitments.accountId,
        gpuType: capacityCommitments.gpuType,
        gpuCount: capacityCommitments.gpuCount,
        startsAt: capacityCommitments.startsAt,
        endsAt: capacityCommitments.endsAt,
        costPerGpuHourCents: capacityCommitments.costPerGpuHourCents,
      })
      .from(capacityCommitments)
      .where(ilike(capacityCommitments.name, fragment))
      .limit(take),
    // The five denominators. A search that reports only its hits invites
    // "there are 3 accounts" from a book of twenty-three, and these counts also
    // make this tool able to answer how many of a thing exist at all.
    db.select({ value: count() }).from(accounts).where(isNull(accounts.archivedAt)),
    db.select({ value: count() }).from(demandDeals),
    db.select({ value: count() }).from(supplyDeals),
    db.select({ value: count() }).from(contracts),
    db.select({ value: count() }).from(capacityCommitments),
  ]);

  const names = await accountNames(db, [
    ...demandRows.map((row) => row.accountId),
    ...supplyRows.map((row) => row.accountId),
    ...contractRows.map((row) => row.accountId),
    ...commitmentRows.map((row) => row.accountId),
  ]);

  const rows = (result: readonly { value: number }[]): number => result[0]?.value ?? 0;

  return assembleSearchResult(query, {
    accounts: accountRows,
    demandDeals: demandRows,
    supplyDeals: supplyRows,
    contracts: contractRows,
    commitments: commitmentRows,
    accountNames: names,
    totals: {
      account: rows(accountsAll),
      demand_deal: rows(demandAll),
      supply_deal: rows(supplyAll),
      contract: rows(contractsAll),
      commitment: rows(commitmentsAll),
    },
  });
}

/** The five row sets a search reads, one row past each budget. */
export interface SearchRowSets {
  accounts: readonly {
    id: string;
    name: string;
    side: string;
    customerSegment: string | null;
    country: string | null;
  }[];
  demandDeals: readonly {
    id: string;
    name: string;
    accountId: string;
    stage: string;
    acvCents: number | null;
    tcvCents: number | null;
    expectedCloseDate: Date | null;
  }[];
  supplyDeals: readonly {
    id: string;
    name: string;
    accountId: string;
    stage: string;
    gpuType: string | null;
    gpuCount: number | null;
    targetCostPerGpuHourCents: number | null;
  }[];
  contracts: readonly {
    id: string;
    title: string;
    accountId: string;
    contractType: string;
    status: string;
    side: string;
    expiresAt: Date | null;
    valueCents: number | null;
  }[];
  commitments: readonly {
    id: string;
    name: string;
    accountId: string;
    gpuType: string;
    gpuCount: number;
    startsAt: Date;
    endsAt: Date;
    costPerGpuHourCents: number;
  }[];
  accountNames: ReadonlyMap<string, string>;
  /**
   * How many rows each searched table holds in total — the denominators the
   * per-type match counts are drawn from. Keyed by the same names the results
   * carry, so a model reading `counts.account: 1` beside `totals.account: 23`
   * cannot mistake a name match for a census.
   */
  totals: Record<SearchedRecordType, number>;
}

/** The five types a name search covers. People are deliberately not indexed. */
export type SearchedRecordType = Exclude<PiggyRecordType, 'contact'>;

/**
 * Ranking, capping and counting, with no database in sight.
 *
 * Split out from the reads so the two things that can silently go wrong here —
 * a count taken off a capped list, and an exact match sorted below a
 * coincidental substring — are pinned by the unit suite. That suite runs in CI
 * before the migration step, so anything it can reach must not need tables.
 */
export function assembleSearchResult(query: string, sets: SearchRowSets): unknown {
  const names = sets.accountNames;
  const cut = <Row>(rows: readonly Row[]): { rows: readonly Row[]; truncated: boolean } => ({
    rows: rows.slice(0, SEARCH_PER_TYPE),
    truncated: rows.length > SEARCH_PER_TYPE,
  });
  const accountsCut = cut(sets.accounts);
  const demandCut = cut(sets.demandDeals);
  const supplyCut = cut(sets.supplyDeals);
  const contractsCut = cut(sets.contracts);
  const commitmentsCut = cut(sets.commitments);

  const ranked: RankedHit[] = [
    ...accountsCut.rows.map((row) => ({
      rank: matchRank(row.name, query),
      name: row.name,
      hit: {
        type: 'account',
        id: row.id,
        name: row.name,
        side: row.side,
        customerSegment: row.customerSegment,
        country: row.country,
      },
    })),
    ...demandCut.rows.map((row) => ({
      rank: matchRank(row.name, query),
      name: row.name,
      hit: {
        type: 'demand_deal',
        id: row.id,
        name: row.name,
        accountName: names.get(row.accountId) ?? null,
        stage: row.stage,
        acvCents: row.acvCents,
        tcvCents: row.tcvCents,
        expectedCloseDate: row.expectedCloseDate?.toISOString() ?? null,
      },
    })),
    ...supplyCut.rows.map((row) => ({
      rank: matchRank(row.name, query),
      name: row.name,
      hit: {
        type: 'supply_deal',
        id: row.id,
        name: row.name,
        accountName: names.get(row.accountId) ?? null,
        stage: row.stage,
        gpuType: row.gpuType,
        gpuCount: row.gpuCount,
        targetCostPerGpuHourCents: row.targetCostPerGpuHourCents,
      },
    })),
    ...contractsCut.rows.map((row) => ({
      rank: matchRank(row.title, query),
      name: row.title,
      hit: {
        type: 'contract',
        id: row.id,
        name: row.title,
        accountName: names.get(row.accountId) ?? null,
        contractType: row.contractType,
        status: row.status,
        side: row.side,
        expiresAt: row.expiresAt?.toISOString() ?? null,
        valueCents: row.valueCents,
      },
    })),
    ...commitmentsCut.rows.map((row) => ({
      rank: matchRank(row.name, query),
      name: row.name,
      hit: {
        type: 'commitment',
        id: row.id,
        name: row.name,
        accountName: names.get(row.accountId) ?? null,
        gpuType: row.gpuType,
        gpuCount: row.gpuCount,
        startsAt: row.startsAt.toISOString(),
        endsAt: row.endsAt.toISOString(),
        costPerGpuHourCents: row.costPerGpuHourCents,
      },
    })),
  ].sort(
    (a, b) =>
      a.rank - b.rank ||
      (TYPE_PRIORITY[String(a.hit.type)] ?? 9) - (TYPE_PRIORITY[String(b.hit.type)] ?? 9) ||
      a.name.localeCompare(b.name),
  );

  const counts = {
    account: accountsCut.rows.length,
    demand_deal: demandCut.rows.length,
    supply_deal: supplyCut.rows.length,
    contract: contractsCut.rows.length,
    commitment: commitmentsCut.rows.length,
  };
  const perTypeTruncated =
    accountsCut.truncated ||
    demandCut.truncated ||
    supplyCut.truncated ||
    contractsCut.truncated ||
    commitmentsCut.truncated;
  const results = ranked.slice(0, SEARCH_RESULTS);
  const truncated = perTypeTruncated || ranked.length > results.length;
  const searched = Object.values(sets.totals).reduce((sum, rows) => sum + rows, 0);
  const breakdown = Object.entries(counts)
    .filter(([, matches]) => matches > 0)
    .map(([type, matches]) => `${matches} of ${sets.totals[type as SearchedRecordType]} ${type}(s)`)
    .join(', ');

  return {
    headline:
      results.length === 0
        ? `None of the ${searched} account(s), deal(s), contract(s) and capacity commitment(s) ` +
          `on the book has a name containing "${query}".`
        : `${truncated ? 'At least ' : ''}${ranked.length} of ${searched} searchable record(s) ` +
          `match "${query}": ${breakdown}. Those are name matches, not totals; the counts they ` +
          'were drawn from are beside them.',
    scope: resultScope({
      covers: `have a name containing "${query}"`,
      matched: ranked.length,
      total: searched,
      totalLabel:
        'record(s) searchable by name: accounts, demand deals, supply deals, contracts and capacity commitments',
      listed: results.length,
      filters: { query },
      truncated,
    }),
    query,
    truncated,
    counts,
    /** The denominator for each entry in `counts`, keyed identically. */
    totals: sets.totals,
    results: results.map((entry) => entry.hit),
  };
}

// ---------------------------------------------------------------------------
// Renewals
// ---------------------------------------------------------------------------

/**
 * Executed paper that has not yet expired, nearest deadline first.
 *
 * The deadline is the notice date where one exists, because that — not the
 * expiry — is the date after which the decision is no longer available. A
 * contract whose notice window opened last week therefore sorts to the top with
 * a negative `daysUntilDeadline` and `renewalState: "due"`, which is exactly the
 * row somebody is looking for when they ask what they have missed.
 *
 * `renewalAlarm` is the API's own definition of that arithmetic and is called
 * rather than repeated: two implementations of expiry-minus-notice would
 * eventually disagree, and Piggy contradicting the contracts page is worse than
 * Piggy having no renewals tool.
 */
async function listRenewals(db: Database, side: 'demand' | 'supply' | undefined): Promise<unknown> {
  const now = new Date();
  // The denominator is counted on the same side filter the list uses, so
  // "4 of 20" and "4 of 11 on the demand side" are both answers to the
  // question that was actually asked.
  const [rows, all] = await Promise.all([
    db
      .select({ contract: contracts, accountName: accounts.name })
      .from(contracts)
      .leftJoin(accounts, eq(accounts.id, contracts.accountId))
      .where(
        and(
          eq(contracts.status, 'executed'),
          isNull(contracts.terminatedAt),
          isNotNull(contracts.expiresAt),
          gt(contracts.expiresAt, now),
          side ? eq(contracts.side, side) : undefined,
        ),
      )
      .orderBy(asc(contracts.expiresAt))
      .limit(SCAN_LIMIT + 1),
    db
      .select({ value: count() })
      .from(contracts)
      .where(side ? eq(contracts.side, side) : undefined),
  ]);

  return assembleRenewals(rows.slice(0, SCAN_LIMIT), {
    now,
    side,
    truncated: rows.length > SCAN_LIMIT,
    totalContracts: all[0]?.value ?? 0,
  });
}

/** Exactly the contract columns the renewal projection reads. */
export type RenewalContract = Pick<
  Contract,
  'id' | 'title' | 'side' | 'type' | 'isAutoRenew' | 'noticeDays' | 'expiresAt' | 'valueCents'
>;

export interface RenewalRow {
  contract: RenewalContract;
  accountName: string | null;
}

/**
 * The deadline projection, with no database in sight.
 *
 * Separated from the read because the two things worth pinning here are the
 * ordering — a lapsed notice must outrank a distant expiry — and the fact that
 * `count` is the whole set while `renewals` is a capped slice of it.
 */
export function assembleRenewals(
  rows: readonly RenewalRow[],
  options: {
    now: Date;
    side?: 'demand' | 'supply';
    truncated: boolean;
    /** Every contract on this side, whatever its status. The denominator. */
    totalContracts: number;
  },
): unknown {
  const { now, side, truncated, totalContracts } = options;
  const renewals = rows
    .flatMap(({ contract, accountName }) => {
      // The query already requires an expiry; narrowing here rather than
      // asserting keeps the sort key a date the compiler agrees exists.
      const expiresAt = contract.expiresAt;
      if (!expiresAt) return [];
      const alarm = renewalAlarm(contract, now);
      const deadlineAt = alarm.renewalNoticeAt ?? expiresAt;
      return [{
        id: contract.id,
        title: contract.title,
        accountName,
        side: contract.side,
        contractType: contract.type,
        isAutoRenew: contract.isAutoRenew,
        noticeDays: contract.noticeDays,
        expiresAt: expiresAt.toISOString(),
        renewalNoticeAt: alarm.renewalNoticeAt?.toISOString() ?? null,
        renewalState: alarm.renewalState,
        deadlineAt: deadlineAt.toISOString(),
        deadlineKind: alarm.renewalNoticeAt ? ('renewal_notice' as const) : ('expiry' as const),
        // Negative once the notice window has opened. Read alongside
        // renewalState rather than on its own.
        daysUntilDeadline: Math.ceil((deadlineAt.getTime() - now.getTime()) / 86_400_000),
        valueCents: contract.valueCents,
      }];
    })
    .sort((a, b) => a.deadlineAt.localeCompare(b.deadlineAt));

  const noticeOpen = renewals.filter((row) => row.renewalState === 'due');
  const nearest = renewals[0];
  /**
   * Master agreements routinely carry no `valueCents` — the money sits on the
   * order forms beneath them. Summing nulls to zero and printing "$0.00 of
   * stated contract value" reads as a worthless renewal rather than an
   * unpriced one, so the clause is only stated where a figure exists.
   */
  const statedValueCents = noticeOpen.reduce((sum, row) => sum + (row.valueCents ?? 0), 0);
  const anyStatedValue = noticeOpen.some((row) => row.valueCents != null);

  const sideLabel = side ? `${side}-side contract(s) on the book` : 'contract(s) on the book';
  const listed = Math.min(renewals.length, EXEMPLARS);

  return {
    headline:
      (nearest
        ? `${truncated ? 'At least ' : ''}${renewals.length} of ${totalContracts} ${sideLabel} ` +
          'are executed and not yet expired' +
          `${renewals.length > listed ? `; the nearest ${listed} are listed` : ''}. ` +
          'Nearest deadline: the ' +
          `${nearest.deadlineKind === 'renewal_notice' ? 'renewal notice' : 'expiry'} for ` +
          `${nearest.title}${nearest.accountName ? ` (${nearest.accountName})` : ''} on ` +
          `${nearest.deadlineAt.slice(0, 10)}` +
          `${nearest.daysUntilDeadline < 0 ? ', which has already passed' : ''}.`
        : `None of the ${totalContracts} ${sideLabel} is executed with an expiry date ahead of it.`) +
      (noticeOpen.length > 0
        ? ` ${noticeOpen.length} of those ${renewals.length} have a notice window already open` +
          (anyStatedValue
            ? `, covering ${formatCents(statedValueCents)} of stated contract value.`
            : '; none of those contracts states a value of its own.')
        : ''),
    scope: resultScope({
      covers: 'are executed, not terminated and not yet expired',
      matched: renewals.length,
      total: totalContracts,
      totalLabel: sideLabel,
      listed,
      filters: { side: side ?? 'both', status: 'executed', expired: 'excluded' },
      truncated,
    }),
    side: side ?? 'both',
    truncated,
    count: renewals.length,
    totalContracts,
    noticeWindowOpenCount: noticeOpen.length,
    /** A filter over a filter, so it states its own denominator too. */
    noticeWindowOpenScope: resultScope({
      covers: 'have a renewal-notice window that is already open',
      matched: noticeOpen.length,
      total: renewals.length,
      totalLabel: `executed, unexpired ${sideLabel}`,
      listed: 0,
      filters: { renewalState: 'due' },
      truncated,
    }),
    renewals: renewals.slice(0, EXEMPLARS),
  };
}

// ---------------------------------------------------------------------------
// Provider inventory
// ---------------------------------------------------------------------------

interface InventoryQuery {
  gpuType?: string;
  minGpuCount?: number;
  requiresFastInterconnect?: boolean;
}

/**
 * What providers are offering right now, cheapest first.
 *
 * `CapacityService.searchInventory` decides what counts as purchasable — it
 * drops `Unavailable` stock — and is called rather than re-queried so that
 * Piggy and the capacity page never disagree about what is on the market.
 *
 * Its `gpuType` filter is an exact match, which is wrong for this caller: a
 * model asked about "H100" sends "H100", and the seeded SKU is `H100_80GB`, so
 * an exact filter answers "nothing" to a question with eleven answers. The
 * filter is therefore applied here as a case-insensitive fragment over a wide
 * bounded read. The width never leaves this process; only EXEMPLARS rows do.
 */
async function listInventory(db: Database, query: InventoryQuery): Promise<unknown> {
  const capacity = new CapacityService(db);
  // The unfiltered read is the denominator, and it is taken through the same
  // service rather than counted here: the service decides what "purchasable"
  // means (it drops Unavailable stock), and a denominator computed from a
  // second definition of that word would disagree with its own numerator.
  const [listings, market] = await Promise.all([
    capacity.searchInventory({
      minGpuCount: query.minGpuCount,
      requiresHighSpeedInterconnect: query.requiresFastInterconnect,
      limit: SCAN_LIMIT,
    }),
    capacity.searchInventory({ limit: SCAN_LIMIT }),
  ]);
  const providerNames = await accountNames(
    db,
    listings.flatMap((listing) => (listing.accountId ? [listing.accountId] : [])),
  );
  return assembleInventoryResult(query, listings, {
    // The service caps at its own ceiling, so a full page is the only signal
    // available that there was more behind it.
    truncated: listings.length >= SCAN_LIMIT,
    providerNames,
    totalListings: market.length,
    totalTruncated: market.length >= SCAN_LIMIT,
  });
}

/** Exactly the listing columns the offer projection reads. */
export type InventoryOffer = Pick<
  InventoryListing,
  | 'accountId'
  | 'providerSlug'
  | 'gpuType'
  | 'gpuCount'
  | 'interconnectType'
  | 'region'
  | 'country'
  | 'securityTier'
  | 'stockStatus'
  | 'isSpot'
  | 'onDemandPriceCents'
  | 'priceIsVariable'
  | 'observedAt'
>;

/**
 * Fragment matching, price ranking and the exemplar cap, with no database in
 * sight — so the unit suite can pin the ordering that decides which offer a
 * seller is shown first.
 */
export function assembleInventoryResult(
  query: InventoryQuery,
  listings: readonly InventoryOffer[],
  options: {
    truncated: boolean;
    providerNames: ReadonlyMap<string, string>;
    /** Purchasable listings on the market with no filter applied at all. */
    totalListings: number;
    totalTruncated: boolean;
  },
): unknown {
  const { truncated, providerNames: providers, totalListings, totalTruncated } = options;
  const needle = query.gpuType?.toLowerCase();
  const matched = needle
    ? listings.filter((listing) => listing.gpuType.toLowerCase().includes(needle))
    : listings;

  // Unpriced listings are real — some providers quote on request — but they
  // cannot be ranked on price, so they sort last rather than as free capacity.
  const ranked = [...matched].sort(
    (a, b) => (a.onDemandPriceCents ?? Infinity) - (b.onDemandPriceCents ?? Infinity),
  );
  const cheapest = ranked.find((listing) => listing.onDemandPriceCents != null);

  const filters = {
    gpuType: query.gpuType ?? null,
    minGpuCount: query.minGpuCount ?? null,
    requiresFastInterconnect: query.requiresFastInterconnect ?? false,
  };
  const anyFilter = Object.values(filters).some((value) => value !== null && value !== false);
  const listed = Math.min(ranked.length, EXEMPLARS);
  const market = 'purchasable listing(s) on the market';

  return {
    headline:
      ranked.length === 0
        ? `None of the ${atLeast(totalListings, totalTruncated)} ${market} matches that ` +
          `request${query.gpuType ? ` for ${query.gpuType}` : ''}.`
        : `${ranked.length} of ${atLeast(totalListings, totalTruncated)} ${market} match` +
          `${anyFilter ? ' the filters given' : ' (no filter was applied)'}` +
          `${query.gpuType ? `, including ${query.gpuType}` : ''}` +
          (cheapest
            ? `; cheapest on-demand is ${formatCents(cheapest.onDemandPriceCents ?? 0)} per ` +
              `GPU-hour for ${cheapest.gpuType}.`
            : '; none of them carry a published on-demand price.') +
          ` ${listed} listed here.`,
    scope: resultScope({
      covers: anyFilter ? 'match the filters given' : 'are purchasable',
      matched: ranked.length,
      total: totalListings,
      totalLabel: market,
      listed,
      // An unasked-for filter is not a filter: passing the three nulls through
      // would have an unfiltered result describe itself as a slice.
      filters: anyFilter ? filters : {},
      truncated: truncated || totalTruncated,
    }),
    truncated,
    count: ranked.length,
    totalListings,
    filters,
    listings: ranked.slice(0, EXEMPLARS).map((listing) => shapeListing(listing, providers)),
  };
}

/**
 * One listing, small enough to quote.
 *
 * The price column is stored as `onDemandPriceCents` but is normalised per GPU
 * on the way in (`packages/prime/src/map.ts`), so it is renamed on the way out.
 * A model that reads a bare "price" for an eight-GPU node as the node price
 * quotes a rate eight times too low, and the suffix is what the units rule in
 * the system prompt keys on.
 */
function shapeListing(
  listing: InventoryOffer,
  providers: ReadonlyMap<string, string>,
): Record<string, unknown> {
  return {
    providerName: listing.accountId ? providers.get(listing.accountId) ?? null : null,
    providerSlug: listing.providerSlug,
    gpuType: listing.gpuType,
    gpuCount: listing.gpuCount,
    interconnectType: listing.interconnectType,
    region: listing.region,
    country: listing.country,
    securityTier: listing.securityTier,
    stockStatus: listing.stockStatus,
    isSpot: listing.isSpot,
    onDemandPricePerGpuHourCents: listing.onDemandPriceCents,
    priceIsVariable: listing.priceIsVariable,
    // A listing nobody has confirmed for a week is a quote, not a price.
    observedAt: listing.observedAt.toISOString(),
  };
}
