import os from "node:os";

import { afterEach, describe, expect, it, vi } from "vitest";

const createService = vi.fn();
const shutdown = vi.fn();

const logWarn = vi.fn();
const logDebug = vi.fn();
const getLoggerInfo = vi.fn();

const asString = (value: unknown, fallback: string) =>
  typeof value === "string" && value.trim() ? value : fallback;

vi.mock("../logger.js", () => {
  return {
    logWarn: (message: string) => logWarn(message),
    logDebug: (message: string) => logDebug(message),
    logInfo: vi.fn(),
    logError: vi.fn(),
    logSuccess: vi.fn(),
  };
});

vi.mock("../logging.js", () => {
  return {
    getLogger: () => ({ info: (...args: unknown[]) => getLoggerInfo(...args) }),
  };
});

vi.mock("@homebridge/ciao", () => {
  return {
    Protocol: { TCP: "tcp" },
    getResponder: () => ({
      createService,
      shutdown,
    }),
  };
});

const { startGatewayBonjourAdvertiser } = await import("./bonjour.js");

describe("gateway bonjour advertiser", () => {
  type ServiceCall = {
    name?: unknown;
    hostname?: unknown;
    domain?: unknown;
    txt?: unknown;
  };

  const prevEnv = { ...process.env };

  afterEach(() => {
    for (const key of Object.keys(process.env)) {
      if (!(key in prevEnv)) delete process.env[key];
    }
    for (const [key, value] of Object.entries(prevEnv)) {
      process.env[key] = value;
    }

    createService.mockReset();
    shutdown.mockReset();
    logWarn.mockReset();
    logDebug.mockReset();
    getLoggerInfo.mockReset();
    vi.useRealTimers();
    vi.restoreAllMocks();
  });

  it("does not block on advertise and publishes expected txt keys", async () => {
    // Allow advertiser to run in unit tests.
    delete process.env.VITEST;
    process.env.NODE_ENV = "development";

    vi.spyOn(os, "hostname").mockReturnValue("test-host");

    const destroy = vi.fn().mockResolvedValue(undefined);
    const advertise = vi.fn().mockImplementation(
      async () =>
        await new Promise<void>((resolve) => {
          setTimeout(resolve, 250);
        }),
    );

    createService.mockImplementation((options: Record<string, unknown>) => {
      return {
        advertise,
        destroy,
        serviceState: "announced",
        on: vi.fn(),
        getFQDN: () =>
          `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
        getHostname: () => asString(options.hostname, "unknown"),
        getPort: () => Number(options.port ?? -1),
      };
    });

    const started = await startGatewayBonjourAdvertiser({
      gatewayPort: 18789,
      sshPort: 2222,
      bridgePort: 18790,
      tailnetDns: "host.tailnet.ts.net",
      cliPath: "/opt/homebrew/bin/clawdis",
    });

    expect(createService).toHaveBeenCalledTimes(1);
    const [bridgeCall] = createService.mock.calls as Array<
      [Record<string, unknown>]
    >;
    expect(bridgeCall?.[0]?.type).toBe("clawdis-bridge");
    expect(bridgeCall?.[0]?.port).toBe(18790);
    expect(bridgeCall?.[0]?.domain).toBe("local");
    expect(bridgeCall?.[0]?.hostname).toBe("test-host");
    expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe(
      "test-host.local",
    );
    expect((bridgeCall?.[0]?.txt as Record<string, string>)?.bridgePort).toBe(
      "18790",
    );
    expect((bridgeCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe(
      "2222",
    );
    expect((bridgeCall?.[0]?.txt as Record<string, string>)?.cliPath).toBe(
      "/opt/homebrew/bin/clawdis",
    );
    expect((bridgeCall?.[0]?.txt as Record<string, string>)?.transport).toBe(
      "bridge",
    );

    // We don't await `advertise()`, but it should still be called for each service.
    expect(advertise).toHaveBeenCalledTimes(1);

    await started.stop();
    expect(destroy).toHaveBeenCalledTimes(1);
    expect(shutdown).toHaveBeenCalledTimes(1);
  });

  it("attaches conflict listeners for services", async () => {
    // Allow advertiser to run in unit tests.
    delete process.env.VITEST;
    process.env.NODE_ENV = "development";

    vi.spyOn(os, "hostname").mockReturnValue("test-host");

    const destroy = vi.fn().mockResolvedValue(undefined);
    const advertise = vi.fn().mockResolvedValue(undefined);
    const onCalls: Array<{ event: string }> = [];

    createService.mockImplementation((options: Record<string, unknown>) => {
      const on = vi.fn((event: string) => {
        onCalls.push({ event });
      });
      return {
        advertise,
        destroy,
        serviceState: "announced",
        on,
        getFQDN: () =>
          `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
        getHostname: () => asString(options.hostname, "unknown"),
        getPort: () => Number(options.port ?? -1),
      };
    });

    const started = await startGatewayBonjourAdvertiser({
      gatewayPort: 18789,
      sshPort: 2222,
      bridgePort: 18790,
    });

    // 1 service × 2 listeners
    expect(onCalls.map((c) => c.event)).toEqual([
      "name-change",
      "hostname-change",
    ]);

    await started.stop();
  });

  it("logs advertise failures and retries via watchdog", async () => {
    // Allow advertiser to run in unit tests.
    delete process.env.VITEST;
    process.env.NODE_ENV = "development";

    vi.useFakeTimers();
    vi.spyOn(os, "hostname").mockReturnValue("test-host");

    const destroy = vi.fn().mockResolvedValue(undefined);
    const advertise = vi
      .fn()
      .mockRejectedValueOnce(new Error("boom")) // initial advertise fails
      .mockResolvedValue(undefined); // watchdog retry succeeds

    createService.mockImplementation((options: Record<string, unknown>) => {
      return {
        advertise,
        destroy,
        serviceState: "unannounced",
        on: vi.fn(),
        getFQDN: () =>
          `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
        getHostname: () => asString(options.hostname, "unknown"),
        getPort: () => Number(options.port ?? -1),
      };
    });

    const started = await startGatewayBonjourAdvertiser({
      gatewayPort: 18789,
      sshPort: 2222,
      bridgePort: 18790,
    });

    // initial advertise attempt happens immediately
    expect(advertise).toHaveBeenCalledTimes(1);

    // allow promise rejection handler to run
    await Promise.resolve();
    expect(logWarn).toHaveBeenCalledWith(
      expect.stringContaining("advertise failed"),
    );

    // watchdog should attempt re-advertise at the 60s interval tick
    await vi.advanceTimersByTimeAsync(60_000);
    expect(advertise).toHaveBeenCalledTimes(2);

    await started.stop();

    await vi.advanceTimersByTimeAsync(120_000);
    expect(advertise).toHaveBeenCalledTimes(2);
  });

  it("handles advertise throwing synchronously", async () => {
    // Allow advertiser to run in unit tests.
    delete process.env.VITEST;
    process.env.NODE_ENV = "development";

    vi.spyOn(os, "hostname").mockReturnValue("test-host");

    const destroy = vi.fn().mockResolvedValue(undefined);
    const advertise = vi.fn(() => {
      throw new Error("sync-fail");
    });

    createService.mockImplementation((options: Record<string, unknown>) => {
      return {
        advertise,
        destroy,
        serviceState: "unannounced",
        on: vi.fn(),
        getFQDN: () =>
          `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
        getHostname: () => asString(options.hostname, "unknown"),
        getPort: () => Number(options.port ?? -1),
      };
    });

    const started = await startGatewayBonjourAdvertiser({
      gatewayPort: 18789,
      sshPort: 2222,
      bridgePort: 18790,
    });

    expect(advertise).toHaveBeenCalledTimes(1);
    expect(logWarn).toHaveBeenCalledWith(
      expect.stringContaining("advertise threw"),
    );

    await started.stop();
  });

  it("normalizes hostnames with domains for service names", async () => {
    // Allow advertiser to run in unit tests.
    delete process.env.VITEST;
    process.env.NODE_ENV = "development";

    vi.spyOn(os, "hostname").mockReturnValue("Mac.localdomain");

    const destroy = vi.fn().mockResolvedValue(undefined);
    const advertise = vi.fn().mockResolvedValue(undefined);
    createService.mockImplementation((options: Record<string, unknown>) => {
      return {
        advertise,
        destroy,
        serviceState: "announced",
        on: vi.fn(),
        getFQDN: () =>
          `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
        getHostname: () => asString(options.hostname, "unknown"),
        getPort: () => Number(options.port ?? -1),
      };
    });

    const started = await startGatewayBonjourAdvertiser({
      gatewayPort: 18789,
      sshPort: 2222,
      bridgePort: 18790,
    });

    const [bridgeCall] = createService.mock.calls as Array<[ServiceCall]>;
    expect(bridgeCall?.[0]?.name).toBe("Mac (Clawdis)");
    expect(bridgeCall?.[0]?.domain).toBe("local");
    expect(bridgeCall?.[0]?.hostname).toBe("Mac");
    expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe(
      "Mac.local",
    );

    await started.stop();
  });
});
