/**
 * The page-scoped read tools Piggy gets while docked.
 *
 * The equivalent answers already exist in the MCP server, but every one of
 * those tools is an authenticated HTTP call carrying a `pig_…` API key. Piggy
 * has no way to mint one and calling the API back through the network to read
 * a database it already holds a handle to would be a round trip for nothing —
 * so the queries are ported here as direct Drizzle reads.
 *
 * The calendar is the exception, and deliberately so. Its projection spans
 * thirteen kinds across nine tables and it is the answer a user is looking at
 * on /calendar; a second implementation here would not merely duplicate it,
 * it would disagree with it, and Piggy contradicting the page it has just been
 * told it is reading is worse than Piggy having no calendar tool. So @pig/piggy
 * depends on @pig/api and calls `CalendarService` in-process — the service
 * layer takes a `Database`, not a request, precisely so it can be called this
 * way. Lifting it into @pig/core instead would drag nine table imports into a
 * package the browser bundles.
 *
 * The hard constraint is size, not capability. Interactive chat runs at
 * `max_tokens` 1024 across at most four turns, so a tool that returns rows
 * spends the whole budget on transcription and truncates mid-answer. Every
 * result here is aggregated first and capped at a handful of exemplar rows:
 * the model is given the conclusion and enough evidence to quote, never the
 * ledger. The bounded reads that feed them are wide, but that width never
 * leaves this process.
 */
import {
  CONSUMING_ALLOCATION_STATUSES,
  DEMAND_OPEN_STAGES,
  RESERVING_ALLOCATION_STATUSES,
  SUPPLY_OPEN_STAGES,
  aggregateMargin,
  breakEvenPricePerGpuHourCents,
  computeMargin,
  formatCents,
  type AllocationInput,
  type CalendarEvent,
  type CalendarEventKind,
  type MarginResult,
  type PiggyPageRoute,
} from '@pig/core';
import {
  allocations,
  capacityCommitments,
  demandDeals,
  supplyDeals,
  type Database,
} from '@pig/db';
import { CalendarService } from '@pig/api/src/services/calendar';
import { and, gte, inArray, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { piggyPageGuide, type PiggyPageToolName } from './page-routes';
import { defineTool, type AgentTool } from './provider';

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

/** How many exemplar rows a result may carry. Everything else is a total. */
const EXEMPLARS = 8;

/** Bound on the internal read. Wide enough for a real book, still finite. */
const SCAN_LIMIT = 500;

/**
 * A bounded read that knows whether it was bounded.
 *
 * Every list here is capped, and a cap the caller cannot see is how a
 * book-level figure ends up asserted over an arbitrary slice: the model is
 * told these results are already aggregated and quotes them verbatim. So each
 * read asks for one row more than its budget — the same trick the calendar
 * service uses — and every result that could have been cut carries the flag.
 */
function bounded<Row>(rows: Row[], limit = SCAN_LIMIT): { rows: Row[]; truncated: boolean } {
  const truncated = rows.length > limit;
  return { rows: truncated ? rows.slice(0, limit) : rows, truncated };
}

/**
 * Written into the headline because that is the field the model quotes. A
 * `truncated: true` sitting further down the payload is routinely ignored.
 */
const TRUNCATION_NOTE =
  'One or more reads hit their row cap, so these figures cover part of a larger ' +
  'book — present them as a lower bound, not as the whole.';

/** Prefixes a count the model must not read as exact. */
function atLeast(count: number, truncated: boolean): string {
  return truncated ? `at least ${count}` : `${count}`;
}

/**
 * One tool per page. The dock is present everywhere, so the model sees this
 * list on every message — a second tool would be a second thing to choose
 * wrongly, and choosing wrongly costs one of four turns.
 */
export function createPagePigTools(db: Database, route: PiggyPageRoute): AgentTool[] {
  return [pageTool(db, piggyPageGuide(route).tool)];
}

function pageTool(db: Database, name: PiggyPageToolName): AgentTool {
  switch (name) {
    case 'pig_get_margin_summary':
      return defineTool({
        name,
        description:
          'Read book-level margin across every live capacity commitment: revenue, cost, ' +
          'gross margin, utilisation and idle hours, plus the largest blocks. Cost is charged ' +
          'against the full commitment, not only the hours that sold.',
        inputSchema: noInput,
        execute: async () => readMarginSummary(db),
      });
    case 'pig_get_idle_capacity':
      return defineTool({
        name,
        description:
          'Read committed capacity that is bought and unsold, ranked by what the idle hours ' +
          'cost, with the break-even price for the remainder of each block.',
        inputSchema: noInput,
        execute: async () => readIdleCapacity(db),
      });
    case 'pig_get_pipeline':
      return defineTool({
        name,
        description:
          'Read the open demand and supply pipelines: how many deals sit at each stage, what ' +
          'they are worth, and the largest few on each side.',
        inputSchema: noInput,
        execute: async () => readPipeline(db),
      });
    case 'pig_get_calendar_ahead':
      return defineTool({
        name,
        description:
          'Read the same calendar projection the /calendar page renders: everything dated in ' +
          'the near future — deals expected to close, contract effective, expiry and execution ' +
          'dates, renewal notices, obligations due, capacity and allocation windows, hold ' +
          'expiries, supply availability, export authorisation and compliance artefact ' +
          'expiries, and calendar entries — plus what is already overdue.',
        inputSchema: z
          .object({
            withinDays: z
              .number()
              .int()
              .min(1)
              .max(365)
              .optional()
              .describe('Horizon in days. Default 30.'),
          })
          .strict(),
        execute: async ({ withinDays }) => readCalendarAhead(db, withinDays ?? 30),
      });
    case 'pig_get_workspace_summary':
      return defineTool({
        name,
        description:
          'Read a bounded overview of the PIG workspace: book margin and utilisation, open ' +
          'deal counts on both sides, and the worst idle capacity. This cannot inspect the ' +
          'filesystem or external systems.',
        inputSchema: noInput,
        execute: async () => readWorkspaceSummary(db),
      });
  }
}

// ---------------------------------------------------------------------------
// The book
// ---------------------------------------------------------------------------

interface LiveBlock {
  name: string;
  gpuType: string;
  gpuCount: number;
  startsAt: Date;
  endsAt: Date;
  totalGpuHours: number;
  soldGpuHours: number;
  /** Held by a live hold: removed from availability, but not revenue. */
  heldGpuHours: number;
  costPerGpuHourCents: number;
  /** The sold slices, kept so book totals can sum cents rather than ratios. */
  sold: readonly AllocationInput[];
  margin: MarginResult;
  breakEvenPriceCents: number | null;
}

interface LiveBook {
  blocks: LiveBlock[];
  /** True when the book is wider than SCAN_LIMIT, so the totals are partial. */
  truncated: boolean;
}

/**
 * Live commitments with sold and held hours counted separately.
 *
 * A port of `CapacityService.availability`, minus the shape integration and
 * matching the API does not need here. Sold and held stay distinct because a
 * pipeline of optimistic holds must never be able to make the book look full.
 * Expired holds are ignored rather than swept, so the figures are right even
 * when the cleanup job is behind.
 *
 * The cap is reported rather than hidden: revenue, cost and gross margin here
 * are sums over whatever came back, and past 500 live commitments that is an
 * arbitrary slice of the book being stated as the book.
 */
async function readLiveBlocks(db: Database, now = new Date()): Promise<LiveBook> {
  const { rows: commitments, truncated } = bounded(
    await db
      .select()
      .from(capacityCommitments)
      .where(and(isNull(capacityCommitments.terminatedAt), gte(capacityCommitments.endsAt, now)))
      .limit(SCAN_LIMIT + 1),
  );
  if (commitments.length === 0) return { blocks: [], truncated };

  const reservations = await db
    .select()
    .from(allocations)
    .where(
      and(
        inArray(
          allocations.capacityCommitmentId,
          commitments.map((commitment) => commitment.id),
        ),
        inArray(allocations.status, [...RESERVING_ALLOCATION_STATUSES]),
      ),
    );

  const blocks = commitments.map((commitment) => {
    const mine = reservations.filter((row) => row.capacityCommitmentId === commitment.id);
    let soldGpuHours = 0;
    let heldGpuHours = 0;
    for (const row of mine) {
      // numeric columns arrive as strings; adding them unconverted concatenates.
      const hours = Number(row.gpuHours);
      if ((CONSUMING_ALLOCATION_STATUSES as readonly string[]).includes(row.status)) {
        soldGpuHours += hours;
      } else if (!row.holdExpiresAt || row.holdExpiresAt > now) {
        heldGpuHours += hours;
      }
    }

    const book = {
      gpuHours: Number(commitment.totalGpuHours),
      costPerGpuHourCents: commitment.costPerGpuHourCents,
    };
    const sold = mine
      .filter((row) => (CONSUMING_ALLOCATION_STATUSES as readonly string[]).includes(row.status))
      .map((row) => ({
        gpuHours: Number(row.gpuHours),
        pricePerGpuHourCents: row.pricePerGpuHourCents,
      }));

    return {
      name: commitment.name,
      gpuType: commitment.gpuType,
      gpuCount: commitment.gpuCount,
      startsAt: commitment.startsAt,
      endsAt: commitment.endsAt,
      totalGpuHours: book.gpuHours,
      soldGpuHours,
      heldGpuHours,
      costPerGpuHourCents: commitment.costPerGpuHourCents,
      sold,
      margin: computeMargin(book, sold),
      breakEvenPriceCents: breakEvenPricePerGpuHourCents(book, sold),
    };
  });

  return { blocks, truncated };
}

function bookTotals(blocks: readonly LiveBlock[]): MarginResult {
  // Sum cents, never average per-block percentages: an average of ratios
  // weights a tiny block equally with a huge one.
  return aggregateMargin(
    blocks.map((block) => ({
      commitment: {
        gpuHours: block.totalGpuHours,
        costPerGpuHourCents: block.costPerGpuHourCents,
      },
      allocations: block.sold,
    })),
  );
}

async function readMarginSummary(db: Database): Promise<unknown> {
  const { blocks, truncated } = await readLiveBlocks(db);
  const totals = bookTotals(blocks);
  const largest = [...blocks]
    .sort((a, b) => b.margin.costCents - a.margin.costCents)
    .slice(0, EXEMPLARS);

  return {
    headline:
      `Revenue ${formatCents(totals.revenueCents)} against cost ${formatCents(totals.costCents)}; ` +
      `gross margin ${formatCents(totals.grossMarginCents)} (${percent(totals.grossMarginPct)}) ` +
      `at ${percent(totals.utilisation)} utilisation across ` +
      `${atLeast(blocks.length, truncated)} live commitment(s).` +
      (truncated ? ` ${TRUNCATION_NOTE}` : ''),
    truncated,
    totals: {
      revenueCents: totals.revenueCents,
      costCents: totals.costCents,
      grossMarginCents: totals.grossMarginCents,
      grossMarginPct: totals.grossMarginPct,
      utilisation: totals.utilisation,
      idleGpuHours: Math.round(totals.idleGpuHours),
      marginPerAllocatedGpuHourCents: totals.marginPerAllocatedGpuHourCents,
    },
    liveCommitments: blocks.length,
    largestBlocks: largest.map((block) => ({
      name: block.name,
      gpuType: block.gpuType,
      utilisation: block.margin.utilisation,
      soldGpuHours: Math.round(block.soldGpuHours),
      totalGpuHours: Math.round(block.totalGpuHours),
      costPerGpuHourCents: block.costPerGpuHourCents,
      grossMarginCents: block.margin.grossMarginCents,
    })),
  };
}

/** Idle blocks, on the same defaults the API and MCP use: 25% within 30 days. */
async function readIdleCapacity(db: Database): Promise<unknown> {
  const now = new Date();
  const horizon = new Date(now.getTime() + 30 * 86_400_000);
  const { blocks, truncated } = await readLiveBlocks(db, now);
  const idle = blocks
    .filter((block) => block.startsAt <= horizon && 1 - block.margin.utilisation >= 0.25)
    .map((block) => ({
      block,
      idleGpuHours: block.margin.idleGpuHours,
      // The number that makes the case: what the unsold hours already cost us.
      idleCostCents: Math.round(block.margin.idleGpuHours * block.costPerGpuHourCents),
    }))
    .sort((a, b) => b.idleCostCents - a.idleCostCents);

  const totalIdleCostCents = idle.reduce((sum, row) => sum + row.idleCostCents, 0);

  return {
    headline:
      (idle.length === 0
        ? 'No live block is more than 25% unsold within the next 30 days.'
        : `${atLeast(idle.length, truncated)} block(s) at least 25% unsold within 30 days, ` +
          `${formatCents(totalIdleCostCents)} of capacity bought and not yet earning.`) +
      (truncated ? ` ${TRUNCATION_NOTE}` : ''),
    truncated,
    thresholdPct: 0.25,
    withinDays: 30,
    totalIdleCostCents,
    blocks: idle.slice(0, EXEMPLARS).map((row) => ({
      name: row.block.name,
      gpuType: row.block.gpuType,
      gpuCount: row.block.gpuCount,
      utilisation: row.block.margin.utilisation,
      idleGpuHours: Math.round(row.idleGpuHours),
      idleCostCents: row.idleCostCents,
      // What the rest of the block must fetch to come out even.
      breakEvenPricePerGpuHourCents: row.block.breakEvenPriceCents,
      endsAt: row.block.endsAt.toISOString(),
    })),
  };
}

// ---------------------------------------------------------------------------
// The two pipelines
// ---------------------------------------------------------------------------

async function readPipeline(db: Database): Promise<unknown> {
  const [demandRead, supplyRead] = await Promise.all([
    db
      .select()
      .from(demandDeals)
      .where(inArray(demandDeals.stage, [...DEMAND_OPEN_STAGES]))
      .limit(SCAN_LIMIT + 1),
    db
      .select()
      .from(supplyDeals)
      .where(inArray(supplyDeals.stage, [...SUPPLY_OPEN_STAGES]))
      .limit(SCAN_LIMIT + 1),
  ]);
  const { rows: demand, truncated: demandTruncated } = bounded(demandRead);
  const { rows: supply, truncated: supplyTruncated } = bounded(supplyRead);
  const truncated = demandTruncated || supplyTruncated;

  // Total contract value where it is known, annual value otherwise: a deal
  // valued only by ACV is still worth counting, and treating it as zero would
  // understate the pipeline rather than admit the gap.
  const valueOf = (deal: (typeof demand)[number]) => deal.tcvCents ?? deal.acvCents ?? 0;
  const demandValueCents = demand.reduce((sum, deal) => sum + valueOf(deal), 0);

  return {
    headline:
      `${atLeast(demand.length, demandTruncated)} open demand deal(s) worth ` +
      `${formatCents(demandValueCents)} and ${atLeast(supply.length, supplyTruncated)} ` +
      'open supply deal(s).' +
      (truncated ? ` ${TRUNCATION_NOTE}` : ''),
    truncated: { demandDeals: demandTruncated, supplyDeals: supplyTruncated },
    demand: {
      openDeals: demand.length,
      valueCents: demandValueCents,
      byStage: countByStage(demand.map((deal) => deal.stage)),
      largest: [...demand]
        .sort((a, b) => valueOf(b) - valueOf(a))
        .slice(0, EXEMPLARS)
        .map((deal) => ({
          name: deal.name,
          stage: deal.stage,
          valueCents: valueOf(deal),
          expectedCloseDate: deal.expectedCloseDate?.toISOString() ?? null,
        })),
    },
    supply: {
      openDeals: supply.length,
      byStage: countByStage(supply.map((deal) => deal.stage)),
      largest: [...supply]
        .sort((a, b) => (b.gpuCount ?? 0) - (a.gpuCount ?? 0))
        .slice(0, EXEMPLARS)
        .map((deal) => ({
          name: deal.name,
          stage: deal.stage,
          gpuType: deal.gpuType,
          gpuCount: deal.gpuCount,
          targetCostPerGpuHourCents: deal.targetCostPerGpuHourCents,
        })),
    },
  };
}

function countByStage(stages: readonly string[]): Record<string, number> {
  const counts: Record<string, number> = {};
  for (const stage of stages) counts[stage] = (counts[stage] ?? 0) + 1;
  return counts;
}

// ---------------------------------------------------------------------------
// Dates
// ---------------------------------------------------------------------------

/**
 * How far back a lapsed item still counts as this week's problem.
 *
 * Unbounded, the overdue arm surfaced whatever was oldest — a stale obligation
 * from two years ago crowding out a renewal notice that lapsed on Friday. Past
 * a quarter it is a data-hygiene job, not an operational one, so the window
 * stops there and the exemplars run most-recent-first within it.
 */
const OVERDUE_LOOKBACK_DAYS = 90;

/**
 * The kinds that can honestly be late.
 *
 * Lateness needs a completion column: an obligation, a renewal notice and a
 * deal's expected close all have somewhere to record that the thing happened.
 * A capacity window that has ended is finished, not overdue, and an expiry
 * that has passed is a state of the world rather than an errand — listing
 * either as overdue work invents a backlog.
 */
const OVERDUE_KINDS = [
  'obligation_due',
  'renewal_notice',
  'expected_close',
] as const satisfies readonly CalendarEventKind[];

/**
 * What is dated in the near future — the same projection /calendar renders.
 *
 * This used to reimplement the projection over two tables. The page shows
 * thirteen kinds, so Piggy asserted a total that was missing contract
 * expiries, renewal notices, hold expiries, capacity and allocation windows,
 * authorisation and artefact expiries, and every human-owned calendar entry.
 * Being confidently wrong about the screen in front of the reader is the one
 * failure that costs the tool its credibility, so it calls the service.
 *
 * Two projections, not one: overdue work sits BEFORE `now` and the horizon
 * starts at it, and a single wide window would let a quarter of stale rows
 * consume the per-source budget that the coming month needs.
 *
 * Counts are taken over the full projected set and only then sliced for
 * exemplars — the previous version interpolated the capped list lengths, so a
 * book with two hundred overdue obligations reported eight, and the system
 * prompt tells the model to quote these figures rather than recompute them.
 */
async function readCalendarAhead(db: Database, withinDays: number): Promise<unknown> {
  const now = new Date();
  const horizon = new Date(now.getTime() + withinDays * 86_400_000);
  const lookback = new Date(now.getTime() - OVERDUE_LOOKBACK_DAYS * 86_400_000);
  const calendar = new CalendarService(db, () => now);

  const [ahead, behind] = await Promise.all([
    calendar.project({ from: now, to: horizon }),
    calendar.project({ from: lookback, to: now, kinds: OVERDUE_KINDS }),
  ]);

  // A done event is a dated fact, not something anyone must act on; the page
  // shows it greyed out and a count that includes it reads as a workload.
  const upcoming = ahead.events.filter((event) => event.state !== 'done');
  const overdue = behind.events.filter((event) => event.state === 'overdue');
  const truncated = ahead.truncated || behind.truncated;
  const upcomingByKind = countByKind(upcoming);

  return {
    headline:
      `Next ${withinDays} day(s): ${atLeast(upcoming.length, ahead.truncated)} dated item(s) ` +
      `across ${Object.keys(upcomingByKind).length} kind(s), of which ` +
      `${ahead.totals.obligationCount} obligation(s) due, ${ahead.totals.closingCount} demand ` +
      `deal(s) expected to close worth ${formatCents(ahead.totals.weightedPipelineCents)} ` +
      `weighted, ${ahead.totals.renewalCount} renewal notice(s) and ` +
      `${ahead.totals.expiringAuthorizationCount} export authorisation(s) expiring; ` +
      `${atLeast(overdue.length, behind.truncated)} item(s) overdue in the last ` +
      `${OVERDUE_LOOKBACK_DAYS} day(s).` +
      (truncated ? ` ${TRUNCATION_NOTE}` : ''),
    withinDays,
    truncated,
    /**
     * Counted in SQL by the service, so these five stay exact even when a
     * source truncates. Everything else on this payload is counted off the
     * event list and moves with `truncated`.
     */
    exactTotals: {
      obligationsDue: ahead.totals.obligationCount,
      dealsExpectedToClose: ahead.totals.closingCount,
      weightedPipelineCents: ahead.totals.weightedPipelineCents,
      renewalNotices: ahead.totals.renewalCount,
      expiringExportAuthorizations: ahead.totals.expiringAuthorizationCount,
    },
    upcoming: {
      count: upcoming.length,
      truncated: ahead.truncated,
      byKind: upcomingByKind,
      byState: countByState(upcoming),
      // Soonest first: the near edge of the horizon is what gets acted on.
      events: upcoming.slice(0, EXEMPLARS * 2).map(exemplar),
    },
    overdue: {
      count: overdue.length,
      truncated: behind.truncated,
      lookbackDays: OVERDUE_LOOKBACK_DAYS,
      byKind: countByKind(overdue),
      events: [...overdue]
        .sort((a, b) => b.startsAt.localeCompare(a.startsAt))
        .slice(0, EXEMPLARS)
        .map(exemplar),
    },
  };
}

/**
 * One event, small enough to quote. `meta`, `id` and the ids are dropped: the
 * model cannot navigate and a uuid in a 1024-token answer is pure cost.
 */
function exemplar(event: CalendarEvent): Record<string, unknown> {
  return {
    kind: event.kind,
    title: event.title,
    startsAt: event.startsAt,
    endsAt: event.endsAt,
    state: event.state,
    accountName: event.accountName,
    amountCents: event.amountCents,
  };
}

function countByKind(events: readonly CalendarEvent[]): Record<string, number> {
  const counts: Record<string, number> = {};
  for (const event of events) counts[event.kind] = (counts[event.kind] ?? 0) + 1;
  return counts;
}

function countByState(events: readonly CalendarEvent[]): Record<string, number> {
  const counts: Record<string, number> = {};
  for (const event of events) counts[event.state] = (counts[event.state] ?? 0) + 1;
  return counts;
}

// ---------------------------------------------------------------------------
// The fallback
// ---------------------------------------------------------------------------

/**
 * The default when no page names a better tool.
 *
 * This replaced a dump of up to 600 rows. That version could not survive one
 * turn of a 1024-token budget, so the model saw a truncated ledger and
 * answered from the fragment it happened to receive.
 */
async function readWorkspaceSummary(db: Database): Promise<unknown> {
  const [book, demandRead, supplyRead] = await Promise.all([
    readLiveBlocks(db),
    db
      .select({ id: demandDeals.id })
      .from(demandDeals)
      .where(inArray(demandDeals.stage, [...DEMAND_OPEN_STAGES]))
      .limit(SCAN_LIMIT + 1),
    db
      .select({ id: supplyDeals.id })
      .from(supplyDeals)
      .where(inArray(supplyDeals.stage, [...SUPPLY_OPEN_STAGES]))
      .limit(SCAN_LIMIT + 1),
  ]);
  const { blocks } = book;
  const { rows: demand, truncated: demandTruncated } = bounded(demandRead);
  const { rows: supply, truncated: supplyTruncated } = bounded(supplyRead);
  const truncated = {
    commitments: book.truncated,
    demandDeals: demandTruncated,
    supplyDeals: supplyTruncated,
  };
  const anyTruncated = Object.values(truncated).some(Boolean);
  const totals = bookTotals(blocks);
  const worstIdle = [...blocks]
    .filter((block) => block.margin.idleGpuHours > 0)
    .sort(
      (a, b) =>
        b.margin.idleGpuHours * b.costPerGpuHourCents -
        a.margin.idleGpuHours * a.costPerGpuHourCents,
    )
    .slice(0, 3);

  return {
    headline:
      `${atLeast(blocks.length, truncated.commitments)} live commitment(s) at ` +
      `${percent(totals.utilisation)} utilisation; ` +
      `gross margin ${formatCents(totals.grossMarginCents)}; ` +
      `${atLeast(demand.length, demandTruncated)} open demand and ` +
      `${atLeast(supply.length, supplyTruncated)} open supply deal(s).` +
      (anyTruncated ? ` ${TRUNCATION_NOTE}` : ''),
    truncated,
    book: {
      liveCommitments: blocks.length,
      revenueCents: totals.revenueCents,
      costCents: totals.costCents,
      grossMarginCents: totals.grossMarginCents,
      utilisation: totals.utilisation,
      idleGpuHours: Math.round(totals.idleGpuHours),
    },
    openDemandDeals: demand.length,
    openSupplyDeals: supply.length,
    worstIdleBlocks: worstIdle.map((block) => ({
      name: block.name,
      gpuType: block.gpuType,
      idleGpuHours: Math.round(block.margin.idleGpuHours),
      idleCostCents: Math.round(block.margin.idleGpuHours * block.costPerGpuHourCents),
    })),
  };
}

function percent(value: number | null): string {
  return value == null ? 'n/a' : `${Math.round(value * 100)}%`;
}
