// RunnerPool Durable Object (docs/ANTIMATTER_RUNNER.md §7–8): one per runner pool. Owns the
// container-instance registry, the placement decision (the load balancer), the max-containers
// cap the user sets in Settings ▸ Antimatter ▸ Runner, epoch fencing, and idle reaping.
//
// Shape notes:
//   • The DO is a plain class over structural interfaces (storage/alarm), so the whole thing is
//     unit-testable under node:test with fakes — the same discipline as the relay's Room DO.
//   • Placement is a pure function (`placeSandbox`), tested exhaustively on its own.
//   • Containers are driven through the RUNNER_CONTAINERS namespace via `ContainerDriver`, an
//     injected seam so tests never need the Containers runtime — and so a self-hosted supervisor
//     (docker/podman) can implement the same five calls (docs/ANTIMATTER_RUNNER.md §8).

export type BalancerPolicy = "least-sessions" | "round-robin";

export interface PoolSettings {
  /** Total containers the pool may run at once, host-of-record included. 1 ⇒ tier-0 only:
   * every session runs in place inside the host container. Mirrors `nucleic.macvm.maxConcurrent`. */
  maxContainers: number;
  balancerPolicy: BalancerPolicy;
  /** Session sandboxes idle (zero sessions) longer than this are reaped by the alarm. */
  idleTimeoutSeconds: number;
}

export const DEFAULT_SETTINGS: PoolSettings = {
  maxContainers: 4,
  balancerPolicy: "least-sessions",
  idleTimeoutSeconds: 600,
};

export const MAX_CONTAINERS_RANGE = { min: 1, max: 16 } as const;
export const IDLE_TIMEOUT_RANGE = { min: 60, max: 7200 } as const;

/** Server-side clamp: the Settings UI clamps too, but the DO never trusts a client. Unknown
 * fields are dropped; missing fields keep their current value. */
export function clampSettings(current: PoolSettings, patch: Partial<PoolSettings> | null | undefined): PoolSettings {
  const next = { ...current };
  if (patch && typeof patch.maxContainers === "number" && Number.isFinite(patch.maxContainers)) {
    next.maxContainers = Math.min(MAX_CONTAINERS_RANGE.max, Math.max(MAX_CONTAINERS_RANGE.min, Math.floor(patch.maxContainers)));
  }
  if (patch && (patch.balancerPolicy === "least-sessions" || patch.balancerPolicy === "round-robin")) {
    next.balancerPolicy = patch.balancerPolicy;
  }
  if (patch && typeof patch.idleTimeoutSeconds === "number" && Number.isFinite(patch.idleTimeoutSeconds)) {
    next.idleTimeoutSeconds = Math.min(IDLE_TIMEOUT_RANGE.max, Math.max(IDLE_TIMEOUT_RANGE.min, Math.floor(patch.idleTimeoutSeconds)));
  }
  return next;
}

export type InstanceState = "starting" | "ready" | "stopped";

export interface InstanceRecord {
  instanceId: string; // "host" or "sb-<n>"
  state: InstanceState;
  /** Live agent sessions the instance reported on its last heartbeat. */
  sessions: number;
  /** Pool-wide monotonic fencing epoch stamped at boot; a heartbeat carrying an older epoch is
   * a zombie (the pool has since re-booted that slot) and is rejected. */
  epoch: number;
  /** Last accepted heartbeat (ms since epoch). */
  lastSeenAt: number;
  /** Last moment the instance had ≥1 session (ms) — drives idle reaping. */
  lastActiveAt: number;
  /** Sandbox tier (docs/ANTIMATTER_RUNNER.md §4): 0 = in-place, 1 = lightweight session
   * container. Tier 2 (desktop VM) is future; carrying the field now keeps the API stable. */
  tier: 0 | 1;
}

/** A heartbeat this stale means the container is gone or wedged; it no longer counts as
 * placeable capacity (but still counts against maxContainers until reaped). */
export const HEARTBEAT_STALE_MS = 90_000;

export type Placement =
  | { decision: "place"; instanceId: string }
  | { decision: "boot"; instanceId: string }
  | { decision: "at_capacity" };

/** The load balancer. Considers only live sandboxes (ready + fresh heartbeat); boots a new one
 * when under the cap; otherwise reports at_capacity so nucleicd falls back to running the
 * session in place (tier 0) — a full pool degrades, it never fails a dispatch. */
export function placeSandbox(opts: {
  instances: InstanceRecord[];
  settings: PoolSettings;
  rrCursor: number;
  now: number;
}): Placement {
  const { instances, settings, rrCursor, now } = opts;
  const sandboxes = instances.filter((i) => i.instanceId !== "host" && i.state !== "stopped");
  const placeable = sandboxes.filter((i) => i.state === "ready" && now - i.lastSeenAt <= HEARTBEAT_STALE_MS);

  if (placeable.length > 0) {
    if (settings.balancerPolicy === "round-robin") {
      const ordered = [...placeable].sort((a, b) => a.instanceId.localeCompare(b.instanceId));
      const pick = ordered[((rrCursor % ordered.length) + ordered.length) % ordered.length];
      return { decision: "place", instanceId: pick.instanceId };
    }
    // least-sessions: fewest sessions wins; ties break to the lexicographically first id so the
    // decision is deterministic and testable.
    const pick = [...placeable].sort(
      (a, b) => a.sessions - b.sessions || a.instanceId.localeCompare(b.instanceId),
    )[0];
    return { decision: "place", instanceId: pick.instanceId };
  }

  // Nothing placeable: boot a fresh sandbox if the cap allows. The host container counts
  // against the cap — "max containers" means max containers, not max-plus-one.
  const total = instances.filter((i) => i.state !== "stopped").length;
  if (total < settings.maxContainers) {
    const used = new Set(sandboxes.map((i) => i.instanceId));
    let n = 1;
    while (used.has(`sb-${n}`)) n += 1;
    return { decision: "boot", instanceId: `sb-${n}` };
  }
  return { decision: "at_capacity" };
}

// --- Durable Object ----------------------------------------------------------------------------

/** The five lifecycle calls the pool makes against a container slot. In production this wraps
 * the RUNNER_CONTAINERS namespace (container.ts); in tests, a fake; on a self-hosted runner, a
 * local docker/podman supervisor. */
export interface ContainerDriver {
  start(poolId: string, instanceId: string, env: Record<string, string>): Promise<void>;
  stop(poolId: string, instanceId: string): Promise<void>;
}

interface StorageLike {
  get<T>(key: string): Promise<T | undefined>;
  put(key: string, value: unknown): Promise<void>;
  delete(key: string): Promise<boolean>;
  setAlarm(time: number): Promise<void>;
}

export interface PoolStateLike {
  storage: StorageLike;
}

function json(obj: unknown, status = 200): Response {
  return new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
}

const K_SETTINGS = "settings";
const K_INSTANCES = "instances";
const K_EPOCH = "epoch";
const K_RR = "rrCursor";

export interface PoolDeps {
  driver: ContainerDriver;
  mintInstanceToken: (poolId: string, instanceId: string) => Promise<string>;
  now?: () => number;
}

/** The RunnerPool DO logic, constructed over injectable state + deps so node:test drives it
 * directly. `RunnerPool` (below) is the thin Workers-runtime binding. */
export class RunnerPoolCore {
  private readonly state: PoolStateLike;
  private readonly poolId: string;
  private readonly deps: PoolDeps;

  // Node's strip-types runner rejects constructor parameter properties (non-erasable syntax),
  // so fields are declared explicitly — same reason the edge Worker's DO classes do this.
  constructor(state: PoolStateLike, poolId: string, deps: PoolDeps) {
    this.state = state;
    this.poolId = poolId;
    this.deps = deps;
  }

  private now(): number {
    return this.deps.now ? this.deps.now() : Date.now();
  }

  private async settings(): Promise<PoolSettings> {
    return (await this.state.storage.get<PoolSettings>(K_SETTINGS)) ?? { ...DEFAULT_SETTINGS };
  }

  private async instances(): Promise<InstanceRecord[]> {
    return (await this.state.storage.get<InstanceRecord[]>(K_INSTANCES)) ?? [];
  }

  private async saveInstances(list: InstanceRecord[]): Promise<void> {
    await this.state.storage.put(K_INSTANCES, list);
  }

  private async bumpEpoch(): Promise<number> {
    const epoch = ((await this.state.storage.get<number>(K_EPOCH)) ?? 0) + 1;
    await this.state.storage.put(K_EPOCH, epoch);
    return epoch;
  }

  /** Routes an internal request (the Worker has already authenticated the caller and stamped
   * `x-nucleic-auth: pool | instance:<instanceId>`). */
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    const auth = request.headers.get("x-nucleic-auth") ?? "";
    const isPool = auth === "pool";
    const instanceAuth = auth.startsWith("instance:") ? auth.slice("instance:".length) : null;

    switch (`${request.method} ${url.pathname}`) {
      case "GET /status":
        if (!isPool) return new Response("forbidden", { status: 403 });
        return this.status();
      case "PATCH /settings":
        if (!isPool) return new Response("forbidden", { status: 403 });
        return this.patchSettings(await readJson(request));
      case "POST /provision":
        if (!isPool) return new Response("forbidden", { status: 403 });
        return this.provision();
      case "POST /wake":
        if (!isPool) return new Response("forbidden", { status: 403 });
        return this.wake();
      case "POST /stop":
        if (!isPool) return new Response("forbidden", { status: 403 });
        return this.stopAll();
      case "POST /sandbox/acquire":
        if (!instanceAuth && !isPool) return new Response("forbidden", { status: 403 });
        return this.acquire();
      case "POST /sandbox/release":
        if (!instanceAuth && !isPool) return new Response("forbidden", { status: 403 });
        return this.release(await readJson(request));
      case "POST /heartbeat":
        if (!instanceAuth) return new Response("forbidden", { status: 403 });
        return this.heartbeat(instanceAuth, await readJson(request));
      default:
        return new Response("not found", { status: 404 });
    }
  }

  private async status(): Promise<Response> {
    const [settings, instances, epoch] = await Promise.all([
      this.settings(),
      this.instances(),
      this.state.storage.get<number>(K_EPOCH),
    ]);
    const now = this.now();
    return json({
      poolId: this.poolId,
      settings,
      epoch: epoch ?? 0,
      instances: instances.map((i) => ({
        ...i,
        stale: i.state !== "stopped" && now - i.lastSeenAt > HEARTBEAT_STALE_MS,
      })),
    });
  }

  private async patchSettings(patch: Partial<PoolSettings> | null): Promise<Response> {
    const next = clampSettings(await this.settings(), patch);
    await this.state.storage.put(K_SETTINGS, next);
    // A lowered cap doesn't kill running work; the alarm reaps idle sandboxes down to it.
    await this.scheduleReap();
    return json({ settings: next });
  }

  /** Boot (or confirm) the host-of-record container. Idempotent. */
  private async provision(): Promise<Response> {
    const instances = await this.instances();
    const existing = instances.find((i) => i.instanceId === "host");
    if (existing && existing.state !== "stopped") {
      return json({ instanceId: "host", epoch: existing.epoch, alreadyRunning: true });
    }
    const epoch = await this.bumpEpoch();
    const token = await this.deps.mintInstanceToken(this.poolId, "host");
    await this.deps.driver.start(this.poolId, "host", {
      NUCLEIC_RUNNER_POOL_ID: this.poolId,
      NUCLEIC_RUNNER_INSTANCE_ID: "host",
      NUCLEIC_RUNNER_EPOCH: String(epoch),
      NUCLEIC_RUNNER_INSTANCE_TOKEN: token,
    });
    const now = this.now();
    const record: InstanceRecord = {
      instanceId: "host",
      state: "starting",
      sessions: 0,
      epoch,
      lastSeenAt: now,
      lastActiveAt: now,
      tier: 0,
    };
    await this.saveInstances([record, ...instances.filter((i) => i.instanceId !== "host")]);
    await this.scheduleReap();
    return json({ instanceId: "host", epoch, alreadyRunning: false });
  }

  private async wake(): Promise<Response> {
    // Wake is provision-by-another-name; kept as its own verb so relay presence hooks read well.
    return this.provision();
  }

  private async stopAll(): Promise<Response> {
    const instances = await this.instances();
    for (const i of instances) {
      if (i.state !== "stopped") await this.deps.driver.stop(this.poolId, i.instanceId);
    }
    await this.saveInstances(instances.map((i) => ({ ...i, state: "stopped" as const, sessions: 0 })));
    return json({ stopped: instances.length });
  }

  /** nucleicd asks for a session sandbox; the balancer answers. `at_capacity` is a normal,
   * non-error outcome — the caller runs the session in place (tier 0). */
  private async acquire(): Promise<Response> {
    const [settings, instances] = await Promise.all([this.settings(), this.instances()]);
    const rrCursor = (await this.state.storage.get<number>(K_RR)) ?? 0;
    const placement = placeSandbox({ instances, settings, rrCursor, now: this.now() });

    switch (placement.decision) {
      case "place": {
        await this.state.storage.put(K_RR, rrCursor + 1);
        const next = instances.map((i) =>
          i.instanceId === placement.instanceId
            ? { ...i, sessions: i.sessions + 1, lastActiveAt: this.now() }
            : i,
        );
        await this.saveInstances(next);
        const epoch = next.find((i) => i.instanceId === placement.instanceId)?.epoch ?? 0;
        return json({ decision: "place", instanceId: placement.instanceId, epoch });
      }
      case "boot": {
        const epoch = await this.bumpEpoch();
        const token = await this.deps.mintInstanceToken(this.poolId, placement.instanceId);
        await this.deps.driver.start(this.poolId, placement.instanceId, {
          NUCLEIC_RUNNER_POOL_ID: this.poolId,
          NUCLEIC_RUNNER_INSTANCE_ID: placement.instanceId,
          NUCLEIC_RUNNER_EPOCH: String(epoch),
          NUCLEIC_RUNNER_INSTANCE_TOKEN: token,
        });
        const now = this.now();
        const record: InstanceRecord = {
          instanceId: placement.instanceId,
          state: "starting",
          sessions: 1,
          epoch,
          lastSeenAt: now,
          lastActiveAt: now,
          tier: 1,
        };
        await this.saveInstances([...instances.filter((i) => i.instanceId !== placement.instanceId), record]);
        await this.scheduleReap();
        return json({ decision: "boot", instanceId: placement.instanceId, epoch });
      }
      case "at_capacity":
        return json({ decision: "at_capacity" }, 200);
    }
  }

  private async release(body: { instanceId?: unknown } | null): Promise<Response> {
    if (typeof body?.instanceId !== "string") return new Response("instanceId required", { status: 422 });
    const instances = await this.instances();
    const next = instances.map((i) =>
      i.instanceId === body.instanceId ? { ...i, sessions: Math.max(0, i.sessions - 1) } : i,
    );
    await this.saveInstances(next);
    await this.scheduleReap();
    return json({ ok: true });
  }

  /** Instance liveness + fencing. A heartbeat whose epoch doesn't match the registry's record
   * for that slot is a zombie (the slot was re-booted since) and gets 409 — the container must
   * exit, never write. An accepted heartbeat re-mints the instance token. */
  private async heartbeat(
    instanceId: string,
    body: { epoch?: unknown; sessions?: unknown; state?: unknown } | null,
  ): Promise<Response> {
    const instances = await this.instances();
    const record = instances.find((i) => i.instanceId === instanceId);
    if (!record || record.state === "stopped") return new Response("unknown instance", { status: 404 });
    if (typeof body?.epoch !== "number" || body.epoch !== record.epoch) {
      return new Response("stale epoch", { status: 409 });
    }
    const sessions =
      typeof body.sessions === "number" && Number.isFinite(body.sessions)
        ? Math.max(0, Math.floor(body.sessions))
        : record.sessions;
    const now = this.now();
    const next = instances.map((i) =>
      i.instanceId === instanceId
        ? {
            ...i,
            state: "ready" as const,
            sessions,
            lastSeenAt: now,
            lastActiveAt: sessions > 0 ? now : i.lastActiveAt,
          }
        : i,
    );
    await this.saveInstances(next);
    await this.scheduleReap();
    const token = await this.deps.mintInstanceToken(this.poolId, instanceId);
    return json({ ok: true, token });
  }

  /** Alarm: reap idle session sandboxes (zero sessions past the idle timeout) and any sandbox
   * over a lowered cap. The host container is never reaped here — its sleep policy is the
   * relay-presence lifecycle (docs/ANTIMATTER_RUNNER.md §7). */
  async alarm(): Promise<void> {
    const [settings, instances] = await Promise.all([this.settings(), this.instances()]);
    const now = this.now();
    const live = instances.filter((i) => i.state !== "stopped");
    const sandboxes = live
      .filter((i) => i.instanceId !== "host")
      .sort((a, b) => a.lastActiveAt - b.lastActiveAt); // oldest-idle first

    const overCap = Math.max(0, live.length - settings.maxContainers);
    const toStop = new Set<string>();
    for (const sb of sandboxes) {
      const idle = sb.sessions === 0 && now - sb.lastActiveAt >= settings.idleTimeoutSeconds * 1000;
      if (idle || toStop.size < overCap) {
        if (sb.sessions === 0) toStop.add(sb.instanceId);
      }
    }
    for (const id of toStop) await this.deps.driver.stop(this.poolId, id);
    if (toStop.size > 0) {
      await this.saveInstances(
        instances.map((i) => (toStop.has(i.instanceId) ? { ...i, state: "stopped" as const } : i)),
      );
    }
    const remaining = instances.some(
      (i) => i.instanceId !== "host" && i.state !== "stopped" && !toStop.has(i.instanceId),
    );
    if (remaining) await this.scheduleReap();
  }

  private async scheduleReap(): Promise<void> {
    const settings = await this.settings();
    await this.state.storage.setAlarm(this.now() + Math.min(settings.idleTimeoutSeconds * 1000, 60_000));
  }
}

async function readJson(request: Request): Promise<Record<string, unknown> | null> {
  try {
    return (await request.json()) as Record<string, unknown>;
  } catch {
    return null;
  }
}
