import { readFileSync } from "node:fs";
import type { ControllerStore, HostRecord, QueueTicketRecord } from "./store";

export interface SchedulerTicketInput {
  key: string;
  repo: string;
  command: string;
  owner: QueueTicketRecord["owner"];
}

export interface SchedulerOptions {
  now?: () => number;
  ownerAlive?: (owner: QueueTicketRecord["owner"]) => boolean;
  spillLeaseTtlMs?: number;
  recoverOrphans?: boolean;
}

export interface DispatchPlacement {
  key: string;
  host: string;
  kind: "builder" | "spill";
}

const TERMINAL_STATES = new Set([
  "completed",
  "failed",
  "blocked",
  "discarded",
  "cancelled",
]);

export class ClusterScheduler {
  private readonly now: () => number;
  private readonly ownerAlive: (owner: QueueTicketRecord["owner"]) => boolean;
  private readonly spillLeaseTtlMs: number;

  constructor(
    private readonly store: ControllerStore,
    options: SchedulerOptions = {},
  ) {
    this.now = options.now ?? (() => Date.now());
    this.ownerAlive = options.ownerAlive ?? isProcessOwnerAlive;
    this.spillLeaseTtlMs = options.spillLeaseTtlMs ?? 5 * 60 * 1000;
    if (options.recoverOrphans) this.store.recoverOrphanHostSlotReservations();
  }

  enqueue(input: SchedulerTicketInput): QueueTicketRecord {
    const enqueuedAt = this.now();
    return this.store.appendQueueTicket({
      key: input.key,
      repo: input.repo,
      command: input.command,
      owner: input.owner,
      enqueuedAt,
      enqueueAgeSeconds: 0,
      state: "queued",
    });
  }

  reconcile(): DispatchPlacement[] {
    this.store.reconcileTerminalHostReservations();
    this.reclaimDeadTickets();

    const placements: DispatchPlacement[] = [];
    for (const ticket of this.queuedTickets()) {
      const host = this.reserveLeastLoaded(ticket);
      if (host) {
        placements.push({ key: ticket.key, host, kind: "builder" });
        continue;
      }

      const queuedCount = this.queuedTickets().length;
      const builders = this.builders();
      if (
        builders.length > 0 &&
        builders.every((builder) => this.isOverloaded(builder)) &&
        queuedCount > builders.length
      ) {
        const placedAt = this.now();
        const expiresAt = new Date(placedAt + this.spillLeaseTtlMs).toISOString();
        if (this.store.tryPlaceSpill(ticket.position, placedAt, expiresAt)) {
          placements.push({ key: ticket.key, host: "laptop", kind: "spill" });
        }
      }
      break;
    }
    return placements;
  }

  complete(jobId: string, terminalState: string): boolean {
    if (!TERMINAL_STATES.has(terminalState)) {
      throw new Error(`non-terminal scheduler state: ${terminalState}`);
    }
    return this.store.completeSchedulerPlacement(jobId, terminalState);
  }

  recallSpill(host?: string): number {
    return this.store.recallSpill(host);
  }

  setCriticalTemperature(host: string, critical: boolean): void {
    this.store.setHostDispatchPaused(host, critical);
  }

  private reclaimDeadTickets(): void {
    for (const ticket of this.store.listQueueTickets()) {
      if (ticket.state !== "queued") continue;
      if (!this.ownerAlive(ticket.owner)) {
        this.store.deleteQueueTicket(ticket.position);
      }
    }
  }

  private queuedTickets(): QueueTicketRecord[] {
    return this.store
      .listQueueTickets()
      .filter((ticket) => ticket.state === "queued")
      .sort((left, right) => left.position - right.position);
  }

  private builders(): HostRecord[] {
    return this.store.listHosts().filter((host) => host.role === "builder");
  }

  private reserveLeastLoaded(ticket: QueueTicketRecord): string | null {
    const candidates = this.builders()
      .filter((host) => this.isEligible(host, ticket.command ?? ""))
      .sort((left, right) => {
        const loadDelta = this.load(left) - this.load(right);
        return loadDelta !== 0 ? loadDelta : left.hostname.localeCompare(right.hostname);
      });
    for (const host of candidates) {
      if (this.store.tryReserveHostSlot(ticket.position, host.hostname, this.now())) {
        return host.hostname;
      }
    }
    return null;
  }

  private isEligible(host: HostRecord, command: string): boolean {
    return (
      host.state === "available" &&
      host.capabilityOk &&
      !host.dispatchPaused &&
      !host.quarantinedCommands.includes(command) &&
      !this.isOverloaded(host)
    );
  }

  private isOverloaded(host: HostRecord): boolean {
    return (
      host.slotsUsed + this.store.countHostSlotReservations(host.hostname) >=
      host.slotsTotal
    );
  }

  private load(host: HostRecord): number {
    if (host.slotsTotal <= 0) return Number.POSITIVE_INFINITY;
    return (
      (host.slotsUsed + this.store.countHostSlotReservations(host.hostname)) /
      host.slotsTotal
    );
  }
}

export function isProcessOwnerAlive(owner: QueueTicketRecord["owner"]): boolean {
  if (!Number.isInteger(owner.pid) || owner.pid <= 0) return false;
  try {
    const stat = readFileSync(`/proc/${owner.pid}/stat`, "utf8");
    const commandEnd = stat.lastIndexOf(")");
    if (commandEnd < 0) return false;
    const fieldsAfterCommand = stat.slice(commandEnd + 2).trim().split(/\s+/);
    const starttime = Number(fieldsAfterCommand[19]);
    return Number.isSafeInteger(starttime) && starttime === owner.starttime;
  } catch {
    return false;
  }
}
