/**
 * Capacity: availability, matching and margin.
 *
 * This is PIG's business logic. Everything here answers one of three questions
 * a compute GTM team asks constantly and cannot ask a generic CRM at all:
 *
 *   "What can I actually sell?"      → availability
 *   "What fits this customer?"       → matching
 *   "What is it worth?"              → margin
 */
import { and, eq, gte, inArray, isNull, lte, or, sql } from 'drizzle-orm';
import type { Database } from '@pig/db';
import {
  allocations,
  capacityCommitments,
  complianceDecisionAllowsMatch,
  inventoryListings,
} from '@pig/db';
import {
  computeMargin,
  breakEvenPricePerGpuHourCents,
  CONSUMING_ALLOCATION_STATUSES,
  RESERVING_ALLOCATION_STATUSES,
  securityTierSatisfies,
} from '@pig/core';
import type { InterconnectType, SecurityTier } from '@pig/core';
import type { ComplianceMatchDecision } from '@pig/db';

export interface CommitmentShape {
  intervals: string[];
  quantities: number[];
}

/**
 * GPUs held at an instant, according to a commitment's shape.
 *
 * A commitment is not a rectangle: it ramps across tranches and steps down at
 * checkpoints. Where no shape is recorded the flat `gpuCount` applies for the
 * whole window, which is the common simple case.
 */
export function quantityAt(
  shape: CommitmentShape | null | undefined,
  flatCount: number,
  at: Date,
): number {
  if (!shape || shape.intervals.length < 2) return flatCount;
  const t = at.getTime();
  for (let i = 0; i < shape.quantities.length; i++) {
    const start = Date.parse(shape.intervals[i]!);
    const end = Date.parse(shape.intervals[i + 1]!);
    if (Number.isFinite(start) && Number.isFinite(end) && t >= start && t < end) {
      return shape.quantities[i] ?? 0;
    }
  }
  // Outside every declared interval the commitment holds nothing. Falling back
  // to the flat count here would invent capacity beyond the contract.
  return 0;
}

/**
 * Total GPU-hours a commitment provides, integrating over its shape.
 *
 * Used to reconcile a recorded `totalGpuHours` against the shape actually
 * entered — a mismatch usually means someone typed a headline number from a
 * term sheet and then entered a ramp that does not add up to it.
 */
export function gpuHoursFromShape(shape: CommitmentShape): number {
  let hours = 0;
  for (let i = 0; i < shape.quantities.length; i++) {
    const start = Date.parse(shape.intervals[i]!);
    const end = Date.parse(shape.intervals[i + 1]!);
    if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) continue;
    hours += ((end - start) / 3_600_000) * (shape.quantities[i] ?? 0);
  }
  return hours;
}

export interface AvailabilityRow {
  commitmentId: string;
  accountId: string;
  name: string;
  gpuType: string;
  gpuCount: number;
  interconnectType: InterconnectType;
  securityTier: SecurityTier;
  startsAt: Date;
  endsAt: Date;
  totalGpuHours: number;
  /** Hours consumed by allocations that count as sold. */
  soldGpuHours: number;
  /** Hours held by unexpired holds. Reserved, but not yet sold. */
  heldGpuHours: number;
  /** Hours neither sold nor held. What a seller may actually offer. */
  availableGpuHours: number;
  costPerGpuHourCents: number;
  utilisation: number;
  /** Price at which the remaining hours break even on this block. */
  breakEvenPriceCents: number | null;
}

export interface CapacityMatchRequirement {
  gpuType?: string;
  gpuTypeAlternatives?: string[];
  gpuCount: number;
  totalGpuHours?: number;
  requiresHighSpeedInterconnect?: boolean;
  minSecurityTier?: SecurityTier;
  startsAt?: Date;
  endsAt?: Date;
  maxPricePerGpuHourCents?: number;
  /** `null` means evaluated but missing, and therefore blocks the match. */
  complianceDecision?: ComplianceMatchDecision | null;
}

export function capacityMeetsRequirement(
  row: AvailabilityRow,
  requirement: CapacityMatchRequirement,
): boolean {
  const acceptableTypes = [
    ...(requirement.gpuType ? [requirement.gpuType] : []),
    ...(requirement.gpuTypeAlternatives ?? []),
  ];
  if (acceptableTypes.length > 0 && !acceptableTypes.includes(row.gpuType)) return false;
  if (row.gpuCount < requirement.gpuCount) return false;

  if (requirement.requiresHighSpeedInterconnect) {
    const fast: InterconnectType[] = ['Infiniband', 'RoCE', 'NVLink'];
    if (!fast.includes(row.interconnectType)) return false;
  }
  if (
    requirement.minSecurityTier &&
    !securityTierSatisfies(row.securityTier, requirement.minSecurityTier)
  ) {
    return false;
  }
  if (!complianceDecisionAllowsMatch(requirement.complianceDecision)) return false;
  if (requirement.startsAt && row.startsAt > requirement.startsAt) return false;
  if (requirement.endsAt && row.endsAt < requirement.endsAt) return false;
  if (requirement.totalGpuHours && row.availableGpuHours < requirement.totalGpuHours) return false;
  return true;
}

export class CapacityService {
  constructor(private readonly db: Database) {}

  /**
   * What is genuinely sellable, per commitment.
   *
   * The key subtlety is that sold and held are counted separately. A live hold
   * must remove capacity from availability — otherwise two sellers promise the
   * same GPUs — but it is not revenue and must not inflate utilisation. A
   * pipeline of optimistic holds should never be able to make the book look
   * full.
   *
   * Expired holds are ignored here rather than requiring a sweep to have run,
   * so availability is correct even if the cleanup job is behind.
   */
  async availability(options: { at?: Date; gpuType?: string } = {}): Promise<AvailabilityRow[]> {
    const now = options.at ?? new Date();

    const commitments = await this.db
      .select()
      .from(capacityCommitments)
      .where(
        and(
          isNull(capacityCommitments.terminatedAt),
          gte(capacityCommitments.endsAt, now),
          options.gpuType ? eq(capacityCommitments.gpuType, options.gpuType) : undefined,
        ),
      );

    if (commitments.length === 0) return [];

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

    return commitments.map((commitment) => {
      const mine = allocRows.filter((a) => a.capacityCommitmentId === commitment.id);

      let soldGpuHours = 0;
      let heldGpuHours = 0;
      for (const a of mine) {
        const hours = Number(a.gpuHours);
        if ((CONSUMING_ALLOCATION_STATUSES as readonly string[]).includes(a.status)) {
          soldGpuHours += hours;
        } else if (!a.holdExpiresAt || a.holdExpiresAt > now) {
          // A lapsed hold reserves nothing, whether or not it has been swept.
          heldGpuHours += hours;
        }
      }

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

      return {
        commitmentId: commitment.id,
        accountId: commitment.accountId,
        name: commitment.name,
        gpuType: commitment.gpuType,
        gpuCount: commitment.gpuCount,
        interconnectType: commitment.interconnectType as InterconnectType,
        securityTier: commitment.securityTier as SecurityTier,
        startsAt: commitment.startsAt,
        endsAt: commitment.endsAt,
        totalGpuHours,
        soldGpuHours,
        heldGpuHours,
        availableGpuHours: Math.max(0, totalGpuHours - soldGpuHours - heldGpuHours),
        costPerGpuHourCents: commitment.costPerGpuHourCents,
        utilisation: margin.utilisation,
        breakEvenPriceCents: breakEvenPricePerGpuHourCents(
          { gpuHours: totalGpuHours, costPerGpuHourCents: commitment.costPerGpuHourCents },
          mine
            .filter((a) =>
              (CONSUMING_ALLOCATION_STATUSES as readonly string[]).includes(a.status),
            )
            .map((a) => ({
              gpuHours: Number(a.gpuHours),
              pricePerGpuHourCents: a.pricePerGpuHourCents,
            })),
        ),
      };
    });
  }

  /**
   * Match a customer requirement against capacity we hold.
   *
   * Interconnect is a hard filter rather than a preference. Selling
   * Ethernet-only capacity to a distributed-training customer produces a
   * cluster that cannot do the job, which is worse than losing the deal — so a
   * requirement for high-speed fabric excludes `Ethernet` and `Unknown` alike.
   * `Unknown` is excluded deliberately: unverified is not the same as adequate.
   */
  async match(requirement: CapacityMatchRequirement): Promise<
    (AvailabilityRow & {
      /** 0–1. Higher is a better fit. */
      score: number;
      /** Why this matched, in plain words, for the UI and for agents. */
      rationale: string[];
    })[]
  > {
    const acceptableTypes = [
      ...(requirement.gpuType ? [requirement.gpuType] : []),
      ...(requirement.gpuTypeAlternatives ?? []),
    ];

    const rows = await this.availability({ at: requirement.startsAt ?? new Date() });

    const matches = rows
      .filter((row) => capacityMeetsRequirement(row, requirement))
      .map((row) => {
        const rationale: string[] = [];
        let score = 0.5;

        // Prefer filling blocks that are sitting idle: the marginal hour on an
        // under-utilised commitment is already paid for, so selling it is worth
        // more than selling one from a block that is nearly full.
        const idleBonus = 1 - row.utilisation;
        score += idleBonus * 0.3;
        if (row.utilisation < 0.5) {
          rationale.push(
            `Block is ${Math.round(row.utilisation * 100)}% utilised — selling here reduces idle spend.`,
          );
        }

        if (requirement.gpuType && row.gpuType === requirement.gpuType) {
          score += 0.1;
          rationale.push(`Exact GPU match (${row.gpuType}).`);
        } else if (acceptableTypes.includes(row.gpuType)) {
          rationale.push(`Acceptable alternative (${row.gpuType}).`);
        }

        if (requirement.requiresHighSpeedInterconnect) {
          rationale.push(`${row.interconnectType} fabric meets the training requirement.`);
        }

        if (requirement.minSecurityTier) {
          rationale.push(
            `${row.securityTier} capacity meets the ${requirement.minSecurityTier} security requirement.`,
          );
        }

        // Margin headroom: can this be sold above break-even, within the
        // customer's ceiling?
        if (requirement.maxPricePerGpuHourCents && row.breakEvenPriceCents != null) {
          if (row.breakEvenPriceCents <= requirement.maxPricePerGpuHourCents) {
            score += 0.1;
            rationale.push(
              `Break-even is below the customer's ceiling — there is margin available.`,
            );
          } else {
            // Kept in the results but scored down, because a seller may still
            // want to know the option exists and price it as a loss leader.
            score -= 0.3;
            rationale.push(
              `⚠ Break-even exceeds the customer's stated ceiling. This would sell at a loss.`,
            );
          }
        }

        return { ...row, score: Math.max(0, Math.min(1, score)), rationale };
      })
      .sort((a, b) => b.score - a.score);

    return matches;
  }

  /**
   * Blocks with meaningful unsold capacity — the alert that pays for PIG.
   *
   * Committed hours are already paid for, so idle capacity is money leaving the
   * business every hour it stays unsold. Filtered to blocks that are live or
   * starting soon, since idle capacity in eighteen months is a forecasting
   * matter rather than an alarm.
   */
  async idleCapacity(options: { thresholdPct?: number; withinDays?: number } = {}) {
    const threshold = options.thresholdPct ?? 0.25;
    const horizon = new Date(Date.now() + (options.withinDays ?? 30) * 86_400_000);

    const rows = await this.availability();
    return rows
      .filter((row) => row.startsAt <= horizon && 1 - row.utilisation >= threshold)
      .map((row) => {
        const idleHours = row.totalGpuHours - row.soldGpuHours;
        return {
          ...row,
          idleGpuHours: idleHours,
          // The number that makes the case: what the unsold hours cost us.
          idleCostCents: Math.round(idleHours * row.costPerGpuHourCents),
        };
      })
      .sort((a, b) => b.idleCostCents - a.idleCostCents);
  }

  /** Book-level margin across every live commitment. */
  async marginReport(): Promise<{
    totals: ReturnType<typeof computeMargin>;
    blocks: AvailabilityRow[];
  }> {
    const blocks = await this.availability();
    const allocRows = blocks.length
      ? await this.db
          .select()
          .from(allocations)
          .where(
            and(
              inArray(
                allocations.capacityCommitmentId,
                blocks.map((b) => b.commitmentId),
              ),
              inArray(allocations.status, [...CONSUMING_ALLOCATION_STATUSES]),
            ),
          )
      : [];

    const books = blocks.map((block) => ({
      commitment: {
        gpuHours: block.totalGpuHours,
        costPerGpuHourCents: block.costPerGpuHourCents,
      },
      allocations: allocRows
        .filter((a) => a.capacityCommitmentId === block.commitmentId)
        .map((a) => ({
          gpuHours: Number(a.gpuHours),
          pricePerGpuHourCents: a.pricePerGpuHourCents,
        })),
    }));

    // Aggregate by summing cents, never by averaging per-block percentages —
    // an average of ratios weights a tiny block equally with a huge one.
    const { aggregateMargin } = await import('@pig/core');
    return { totals: aggregateMargin(books), blocks };
  }

  /**
   * Release holds whose timer has lapsed.
   *
   * Availability already ignores expired holds, so this is bookkeeping rather
   * than correctness — it keeps the stored state honest and makes the release
   * visible in the UI.
   */
  async sweepExpiredHolds(now = new Date()): Promise<number> {
    const result = await this.db
      .update(allocations)
      .set({ status: 'released', releasedAt: now, updatedAt: now })
      .where(
        and(
          eq(allocations.status, 'planned'),
          sql`${allocations.holdExpiresAt} IS NOT NULL`,
          lte(allocations.holdExpiresAt, now),
        ),
      )
      .returning({ id: allocations.id });
    return result.length;
  }

  /** Live inventory from providers, for capacity we do not yet hold. */
  async searchInventory(query: {
    gpuType?: string;
    minGpuCount?: number;
    maxPriceCents?: number;
    requiresHighSpeedInterconnect?: boolean;
    limit?: number;
  }) {
    const fast: InterconnectType[] = ['Infiniband', 'RoCE', 'NVLink'];
    return this.db
      .select()
      .from(inventoryListings)
      .where(
        and(
          query.gpuType ? eq(inventoryListings.gpuType, query.gpuType) : undefined,
          query.minGpuCount ? gte(inventoryListings.gpuCount, query.minGpuCount) : undefined,
          query.maxPriceCents
            ? lte(inventoryListings.onDemandPriceCents, query.maxPriceCents)
            : undefined,
          query.requiresHighSpeedInterconnect
            ? inArray(inventoryListings.interconnectType, fast)
            : undefined,
          // Unavailable stock is never a useful search result.
          or(
            eq(inventoryListings.stockStatus, 'Available'),
            eq(inventoryListings.stockStatus, 'Low'),
            eq(inventoryListings.stockStatus, 'Medium'),
            eq(inventoryListings.stockStatus, 'High'),
          ),
        ),
      )
      .limit(Math.min(query.limit ?? 50, 200));
  }
}
