import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
  createWatchdogFromConfig,
  loadWatchdogConfig,
  parseWatchdogConfig,
  readWatchdogToken,
  validateWatchdogConfig,
  WatchdogFatalError,
} from "./watchdog-main";
import { createWatchdogWebhookAdapter } from "./notify";
import { OffLaptopWatchdog } from "./watchdog";

const validConfig = {
  controllerHeartbeatUrl: "http://127.0.0.1:18787/heartbeat",
  collectorHealthUrl: "http://127.0.0.1:18138/health",
  tokenFile: "/tmp/token",
  webhookUrl: "https://ntfy.example/topic",
};

describe("watchdog config", () => {
  test("accepts absent restart argv fields as observe-only", () => {
    const config = parseWatchdogConfig(validConfig);
    expect(config.controllerRestartArgv).toBeUndefined();
    expect(config.collectorRestartArgv).toBeUndefined();
    expect(config.intervalMs).toBe(30_000);
  });

  test("accepts empty restart argv arrays as observe-only", () => {
    const config = parseWatchdogConfig({
      ...validConfig,
      controllerRestartArgv: [],
      collectorRestartArgv: [],
    });
    expect(config.controllerRestartArgv).toEqual([]);
    expect(config.collectorRestartArgv).toEqual([]);
  });

  test("rejects intervalMs at or below probe timeout", () => {
    expect(() => parseWatchdogConfig({ ...validConfig, intervalMs: 10_000 }))
      .toThrow("intervalMs must be an integer greater than 10000");
    expect(() => parseWatchdogConfig({ ...validConfig, intervalMs: 5_000 }))
      .toThrow("intervalMs must be an integer greater than 10000");
  });

  test("rejects non-loopback target URLs", () => {
    expect(() => parseWatchdogConfig({
      ...validConfig,
      controllerHeartbeatUrl: "http://localhost:18787/heartbeat",
    })).toThrow("controllerHeartbeatUrl must use loopback host 127.0.0.1");
  });

  test("rejects non-HTTPS webhook URLs", () => {
    expect(() => parseWatchdogConfig({
      ...validConfig,
      webhookUrl: "http://ntfy.example/topic",
    })).toThrow("webhookUrl must use HTTPS");
  });

  test("rejects unknown config keys", () => {
    expect(() => parseWatchdogConfig({ ...validConfig, extra: true }))
      .toThrow();
  });

  test("rejects restart argv with empty strings", () => {
    expect(() => parseWatchdogConfig({
      ...validConfig,
      controllerRestartArgv: ["systemctl", ""],
    })).toThrow("controllerRestartArgv must contain only non-empty strings");
  });
});

describe("watchdog token file", () => {
  let dir = "";

  afterEach(() => {
    if (dir) {
      Bun.spawnSync({ cmd: ["rm", "-rf", dir] });
      dir = "";
    }
  });

  test("TOKEN_MISSING when token file is absent", async () => {
    dir = mkdtempSync(join(tmpdir(), "watchdog-token-"));
    const tokenPath = join(dir, "missing-token");
    await expect(readWatchdogToken(tokenPath)).rejects.toMatchObject({
      code: "TOKEN_MISSING",
    });
  });

  test("TOKEN_EMPTY when token file is blank", async () => {
    dir = mkdtempSync(join(tmpdir(), "watchdog-token-"));
    const tokenPath = join(dir, "token");
    writeFileSync(tokenPath, "   \n", { mode: 0o600 });
    await expect(readWatchdogToken(tokenPath)).rejects.toMatchObject({
      code: "TOKEN_EMPTY",
    });
  });
});

describe("config-validate", () => {
  let dir = "";

  afterEach(() => {
    if (dir) {
      Bun.spawnSync({ cmd: ["rm", "-rf", dir] });
      dir = "";
    }
  });

  test("returns TOKEN_MISSING before probes start", async () => {
    dir = mkdtempSync(join(tmpdir(), "watchdog-config-"));
    const configPath = join(dir, "watchdog.json");
    const tokenPath = join(dir, "token");
    writeFileSync(configPath, JSON.stringify({ ...validConfig, tokenFile: tokenPath }), { mode: 0o600 });
    await expect(validateWatchdogConfig(configPath)).rejects.toMatchObject({
      code: "TOKEN_MISSING",
    });
  });

  test("loads config from disk", async () => {
    dir = mkdtempSync(join(tmpdir(), "watchdog-config-"));
    const configPath = join(dir, "watchdog.json");
    const tokenPath = join(dir, "token");
    writeFileSync(configPath, JSON.stringify({ ...validConfig, tokenFile: tokenPath }), { mode: 0o600 });
    writeFileSync(tokenPath, "secret-token", { mode: 0o600 });
    const loaded = await loadWatchdogConfig(configPath);
    expect(loaded.controllerHeartbeatUrl).toBe("http://127.0.0.1:18787/heartbeat");
    expect(loaded.collectorHealthUrl).toBe("http://127.0.0.1:18138/health");
    await expect(validateWatchdogConfig(configPath)).resolves.toBeUndefined();
  });
});

describe("watchdog runtime wiring", () => {
  test("tunnel URLs resolve as observe-only targets", () => {
    const config = parseWatchdogConfig(validConfig);
    const polls: string[] = [];
    const watchdog = createWatchdogFromConfig(config, "token", {
      notify: () => {},
    });
    const originalPollOnce = watchdog.pollOnce.bind(watchdog);
    watchdog.pollOnce = async () => {
      polls.push("probe");
      await originalPollOnce();
    };

    expect(config.controllerHeartbeatUrl).toBe("http://127.0.0.1:18787/heartbeat");
    expect(config.collectorHealthUrl).toBe("http://127.0.0.1:18138/health");
    expect(config.controllerRestartArgv).toBeUndefined();
    expect(config.collectorRestartArgv).toBeUndefined();
  });

  test("off-laptop watchdog pages through webhook adapter on probe failure", async () => {
    const config = parseWatchdogConfig(validConfig);
    const requests: Array<{ url: string; init: RequestInit }> = [];
    const notifier = createWatchdogWebhookAdapter({
      webhookUrl: config.webhookUrl,
      now: () => Date.parse("2026-07-20T12:00:00.000Z"),
      fetcher: async (url, init) => {
        requests.push({ url, init });
        return new Response("ok", { status: 200 });
      },
    });
    const watchdog = new OffLaptopWatchdog({
      targets: [{ name: "controller", url: config.controllerHeartbeatUrl }],
      token: "secret",
      fetcher: async () => new Response(JSON.stringify({ ok: false }), { status: 503 }),
      clock: { sleep: async () => {} },
      restarter: { restart: async () => {} },
      notifier,
      intervalMs: 30_000,
    });

    await watchdog.pollOnce();

    expect(requests).toHaveLength(1);
    expect(requests[0]?.url).toBe("https://ntfy.example/topic");
    const headers = requests[0]?.init.headers as Record<string, string>;
    expect(headers.Title).toBe("watchdog:controller");
    expect(headers.Priority).toBe("urgent");
    expect(headers.Tags).toBe("warning");
  });

  test("WatchdogFatalError preserves exact codes", () => {
    const missing = new WatchdogFatalError("TOKEN_MISSING", "missing");
    const empty = new WatchdogFatalError("TOKEN_EMPTY", "empty");
    expect(missing.code).toBe("TOKEN_MISSING");
    expect(empty.code).toBe("TOKEN_EMPTY");
  });
});
