import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import type { Server } from "bun";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
  MetricSeriesSchema,
  MetricsRegistry,
  type HostMetricsSampler,
} from "./metrics";
import { startServer } from "./server";
import { ControllerStore } from "./store";

const NOW = Date.parse("2026-07-19T12:00:00.000Z");
const TOKEN = "metrics-token";

const sampler: HostMetricsSampler = {
  sample: () => ({ gaps: [], metrics: [
    {
      host: "debian1",
      sessions: 3,
      remoteWork: { agentSeats: 2, remoteBuildJobs: 1, offloadShells: 0 },
      load: 0.5,
      cores: 2,
      memUsedBytes: 8,
      memTotalBytes: 16,
      swapUsedBytes: 1,
      swapTotalBytes: 4,
      netRxBytesPerSecond: 10,
      netTxBytesPerSecond: 20,
      disks: [
        { mountpoint: "/", freeBytes: 100, sizeBytes: 200 },
        { mountpoint: "/build", freeBytes: 300, sizeBytes: 400 },
      ],
      coreLoadPercent: [25, 75],
      temperatures: { pkg: 55, max: 61, crit: 95 },
      guard: {
        memoryStallSome60: 3.5,
        memoryStallFull60: 1.25,
        memoryStallFull300: 0,
        tmpUsedBytes: 500,
        tmpSizeBytes: 1_000,
        workSlices: [
          { slice: "agent.slice", memoryBytes: 7, oomKillTotal: 2, pidsCurrent: 9 },
        ],
      },
    },
    {
      host: "not-in-controller-status",
      sessions: null,
      remoteWork: null,
      load: 99,
      cores: 1,
      memUsedBytes: 1,
      memTotalBytes: 1,
      swapUsedBytes: 0,
      swapTotalBytes: 0,
      netRxBytesPerSecond: 0,
      netTxBytesPerSecond: 0,
      disks: [],
      coreLoadPercent: [100],
      temperatures: { pkg: 100, max: 100, crit: 100 },
      guard: {
        memoryStallSome60: 0,
        memoryStallFull60: 0,
        memoryStallFull300: 0,
        tmpUsedBytes: 0,
        tmpSizeBytes: 0,
        workSlices: [],
      },
    },
  ] }),
};

describe("Prometheus metrics", () => {
  let dir: string;
  let store: ControllerStore;
  let registry: MetricsRegistry;
  let server: Server<undefined> | undefined;

  beforeEach(() => {
    dir = mkdtempSync(join(tmpdir(), "controller-metrics-"));
    store = new ControllerStore(join(dir, "state.sqlite"), { now: () => NOW });
    store.upsertHost({
      hostname: "debian1",
      state: "available",
      slotsTotal: 4,
      slotsUsed: 0,
      runningJobs: 0,
    });
    store.upsertQueueTicket({
      position: 1,
      key: "queue-1",
      repo: "owner/repo",
      owner: { pid: 42, starttime: 100 },
      enqueueAgeSeconds: 601,
      state: "queued",
    });
    registry = new MetricsRegistry(store, sampler, () => NOW);
    registry.recordExit("debian1", "playwright", 127, 3);
    registry.setHostBuilds24h("debian1", 7);
  });

  afterEach(() => {
    server?.stop(true);
    store.close();
    rmSync(dir, { recursive: true, force: true });
  });

  test("omits the sessions series rather than publishing zero when a host cannot report it", () => {
    const unknown = new MetricsRegistry(
      store,
      {
        sample: (hosts) => ({
          gaps: [],
          metrics: sampler.sample(hosts).metrics.map((host) => ({ ...host, sessions: null })),
        }),
      },
      () => NOW,
    );
    const names = unknown.collect().map(({ name }) => name);
    expect(names).not.toContain("build_offload_host_sessions");
    expect(names).toContain("build_offload_host_load");
  });

  test("omits the builds-24h series rather than publishing zero when no count was recorded", () => {
    const unrecorded = new MetricsRegistry(store, sampler, () => NOW);
    const names = unrecorded.collect().map(({ name }) => name);
    expect(names).not.toContain("build_offload_host_builds_24h");
    expect(names).toContain("build_offload_host_running_jobs");
  });

  test("publishes every spec series with exact labels and multi-mount disks", () => {
    const series = registry.collect().map((sample) => MetricSeriesSchema.parse(sample));
    const names = new Set(series.map(({ name }) => name));
    expect(names).toEqual(new Set([
      "build_offload_queue_depth",
      "build_offload_queue_oldest_age_seconds",
      "build_offload_queue_age_p95_seconds",
      "build_offload_fallback_lease_expired",
      "build_offload_artifact_cas_mismatch_total",
      "build_offload_exit_total",
      "build_offload_host_running_jobs",
      "build_offload_host_slots_free",
      "build_offload_host_slots_total",
      "build_offload_host_dispatch_accept",
      "build_offload_host_load",
      "build_offload_host_cores",
      "build_offload_host_builds_24h",
      "build_offload_host_sessions",
      "build_offload_host_remote_work",
      "build_offload_host_mem_used_bytes",
      "build_offload_host_mem_total_bytes",
      "build_offload_host_swap_used_bytes",
      "build_offload_host_swap_total_bytes",
      "build_offload_host_net_rx_bytes_per_second",
      "build_offload_host_net_tx_bytes_per_second",
      "build_offload_host_disk_free_bytes",
      "build_offload_host_disk_size_bytes",
      "build_offload_host_core_load_percent",
      "build_offload_host_cpu_temp_celsius",
      "build_offload_host_memory_stall_percent",
      "build_offload_host_tmp_used_bytes",
      "build_offload_host_tmp_size_bytes",
      "build_offload_host_work_slice_memory_bytes",
      "build_offload_host_work_slice_oom_kills_total",
      "build_offload_host_work_slice_pids",
    ]));

    const byName = (name: string) => series.filter((sample) => sample.name === name);
    expect(byName("build_offload_host_disk_free_bytes").map(({ labels }) => labels)).toEqual([
      { host: "debian1", mountpoint: "/" },
      { host: "debian1", mountpoint: "/build" },
    ]);
    expect(byName("build_offload_host_core_load_percent").map(({ labels }) => labels)).toEqual([
      { host: "debian1", core: "0" },
      { host: "debian1", core: "1" },
    ]);
    expect(byName("build_offload_host_cpu_temp_celsius").map(({ labels }) => labels)).toEqual([
      { host: "debian1", sensor: "pkg" },
      { host: "debian1", sensor: "max" },
      { host: "debian1", sensor: "crit" },
    ]);
    expect(series.some(({ labels }) => labels.host === "not-in-controller-status")).toBe(false);

    expect(byName("build_offload_queue_oldest_age_seconds")[0]?.value).toBeGreaterThan(300);
    expect(byName("build_offload_host_slots_free")[0]?.value).toBeGreaterThan(0);
    expect(byName("build_offload_host_sessions")[0]?.value).toBe(3);
    expect(byName("build_offload_host_remote_work").map(({ labels, value }) => [labels.kind, value])).toEqual([
      ["agent_seat", 2],
      ["remote_build_job", 1],
      ["offload_shell", 0],
    ]);
    expect(byName("build_offload_exit_total")[0]?.value).toBe(3);
    expect(MetricSeriesSchema.safeParse({
      name: "build_offload_exit_total",
      labels: { host: "", command: "playwright", code: "127" },
      value: 1,
    }).success).toBe(false);
    expect(MetricSeriesSchema.safeParse({
      name: "build_offload_host_core_load_percent",
      labels: { host: "debian1", core: "cpu0" },
      value: 1,
    }).success).toBe(false);

    expect(byName("build_offload_host_memory_stall_percent").map(({ labels, value }) => [labels.window, value]))
      .toEqual([["some60", 3.5], ["full60", 1.25], ["full300", 0]]);
    expect(byName("build_offload_host_tmp_used_bytes")[0]?.value).toBe(500);
    expect(byName("build_offload_host_tmp_size_bytes")[0]?.value).toBe(1_000);
    expect(byName("build_offload_host_work_slice_oom_kills_total").map(({ labels, value }) => [labels.slice, value]))
      .toEqual([["agent.slice", 2]]);
    expect(byName("build_offload_host_work_slice_pids")[0]?.value).toBe(9);
    expect(byName("build_offload_host_work_slice_memory_bytes")[0]?.value).toBe(7);
    expect(MetricSeriesSchema.safeParse({
      name: "build_offload_host_memory_stall_percent",
      labels: { host: "debian1", window: "avg42" },
      value: 1,
    }).success).toBe(false);
  });

  test("serves Prometheus text and selector-aware vector JSON behind Bearer auth", async () => {
    server = startServer({ host: "127.0.0.1", port: 0, token: TOKEN, store, metrics: registry });
    const origin = `http://127.0.0.1:${server.port}`;

    expect((await fetch(`${origin}/metrics`)).status).toBe(401);
    expect((await fetch(`${origin}/api/v1/query?query=build_offload_queue_depth`)).status).toBe(401);

    const headers = { authorization: `Bearer ${TOKEN}` };
    const text = await fetch(`${origin}/metrics`, { headers });
    expect(text.status).toBe(200);
    expect(text.headers.get("content-type")).toContain("text/plain");
    expect(await text.text()).toContain('build_offload_host_disk_free_bytes{host="debian1",mountpoint="/build"} 300');

    const query = encodeURIComponent('build_offload_host_cpu_temp_celsius{host!="",sensor="crit"}');
    const response = await fetch(`${origin}/api/v1/query?query=${query}`, { headers });
    expect(response.status).toBe(200);
    expect(await response.json()).toEqual({
      status: "success",
      data: {
        resultType: "vector",
        result: [{
          metric: {
            __name__: "build_offload_host_cpu_temp_celsius",
            host: "debian1",
            sensor: "crit",
          },
          value: [NOW / 1000, "95"],
        }],
      },
    });
  });

  test("reuses a short-lived collect cache across repeated queries", () => {
    let sampleCalls = 0;
    const cachingSampler: HostMetricsSampler = {
      sample(hosts) {
        sampleCalls += 1;
        return sampler.sample(hosts);
      },
    };
    const cached = new MetricsRegistry(store, cachingSampler, () => NOW, undefined, 5_000);
    cached.query("build_offload_host_cores{host!=\"\"}");
    cached.query("build_offload_host_mem_used_bytes{host!=\"\"}");
    expect(sampleCalls).toBe(1);
  });
});
