import fs from "node:fs";
import fsp from "node:fs/promises";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { WebSocketServer } from "ws";
import {
  decorateOpenClawProfile,
  ensureProfileCleanExit,
  findChromeExecutableMac,
  findChromeExecutableWindows,
  isChromeCdpReady,
  isChromeReachable,
  resolveBrowserExecutableForPlatform,
  stopOpenClawChrome,
} from "./chrome.js";
import {
  DEFAULT_OPENCLAW_BROWSER_COLOR,
  DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./constants.js";

type StopChromeTarget = Parameters<typeof stopOpenClawChrome>[0];

async function readJson(filePath: string): Promise<Record<string, unknown>> {
  const raw = await fsp.readFile(filePath, "utf-8");
  return JSON.parse(raw) as Record<string, unknown>;
}

async function readDefaultProfileFromLocalState(
  userDataDir: string,
): Promise<Record<string, unknown>> {
  const localState = await readJson(path.join(userDataDir, "Local State"));
  const profile = localState.profile as Record<string, unknown>;
  const infoCache = profile.info_cache as Record<string, unknown>;
  return infoCache.Default as Record<string, unknown>;
}

async function withMockChromeCdpServer(params: {
  wsPath: string;
  onConnection?: (wss: WebSocketServer) => void;
  run: (baseUrl: string) => Promise<void>;
}) {
  const server = createServer((req, res) => {
    if (req.url === "/json/version") {
      const addr = server.address() as AddressInfo;
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(
        JSON.stringify({
          webSocketDebuggerUrl: `ws://127.0.0.1:${addr.port}${params.wsPath}`,
        }),
      );
      return;
    }
    res.writeHead(404);
    res.end();
  });
  const wss = new WebSocketServer({ noServer: true });
  server.on("upgrade", (req, socket, head) => {
    if (req.url !== params.wsPath) {
      socket.destroy();
      return;
    }
    wss.handleUpgrade(req, socket, head, (ws) => {
      wss.emit("connection", ws, req);
    });
  });
  params.onConnection?.(wss);
  await new Promise<void>((resolve, reject) => {
    server.listen(0, "127.0.0.1", () => resolve());
    server.once("error", reject);
  });
  try {
    const addr = server.address() as AddressInfo;
    await params.run(`http://127.0.0.1:${addr.port}`);
  } finally {
    await new Promise<void>((resolve) => wss.close(() => resolve()));
    await new Promise<void>((resolve) => server.close(() => resolve()));
  }
}

async function stopChromeWithProc(proc: ReturnType<typeof makeChromeTestProc>, timeoutMs: number) {
  await stopOpenClawChrome(
    {
      proc,
      cdpPort: 12345,
    } as unknown as StopChromeTarget,
    timeoutMs,
  );
}

function makeChromeTestProc(overrides?: Partial<{ killed: boolean; exitCode: number | null }>) {
  return {
    killed: overrides?.killed ?? false,
    exitCode: overrides?.exitCode ?? null,
    kill: vi.fn(),
  };
}

describe("browser chrome profile decoration", () => {
  let fixtureRoot = "";
  let fixtureCount = 0;

  const createUserDataDir = async () => {
    const dir = path.join(fixtureRoot, `profile-${fixtureCount++}`);
    await fsp.mkdir(dir, { recursive: true });
    return dir;
  };

  beforeAll(async () => {
    fixtureRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-suite-"));
  });

  afterAll(async () => {
    if (fixtureRoot) {
      await fsp.rm(fixtureRoot, { recursive: true, force: true });
    }
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.restoreAllMocks();
  });

  it("writes expected name + signed ARGB seed to Chrome prefs", async () => {
    const userDataDir = await createUserDataDir();
    decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });

    const expectedSignedArgb = ((0xff << 24) | 0xff4500) >> 0;

    const def = await readDefaultProfileFromLocalState(userDataDir);

    expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
    expect(def.shortcut_name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
    expect(def.profile_color_seed).toBe(expectedSignedArgb);
    expect(def.profile_highlight_color).toBe(expectedSignedArgb);
    expect(def.default_avatar_fill_color).toBe(expectedSignedArgb);
    expect(def.default_avatar_stroke_color).toBe(expectedSignedArgb);

    const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
    const browser = prefs.browser as Record<string, unknown>;
    const theme = browser.theme as Record<string, unknown>;
    const autogenerated = prefs.autogenerated as Record<string, unknown>;
    const autogeneratedTheme = autogenerated.theme as Record<string, unknown>;

    expect(theme.user_color2).toBe(expectedSignedArgb);
    expect(autogeneratedTheme.color).toBe(expectedSignedArgb);

    const marker = await fsp.readFile(
      path.join(userDataDir, ".openclaw-profile-decorated"),
      "utf-8",
    );
    expect(marker.trim()).toMatch(/^\d+$/);
  });

  it("best-effort writes name when color is invalid", async () => {
    const userDataDir = await createUserDataDir();
    decorateOpenClawProfile(userDataDir, { color: "lobster-orange" });
    const def = await readDefaultProfileFromLocalState(userDataDir);

    expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
    expect(def.profile_color_seed).toBeUndefined();
  });

  it("recovers from missing/invalid preference files", async () => {
    const userDataDir = await createUserDataDir();
    await fsp.mkdir(path.join(userDataDir, "Default"), { recursive: true });
    await fsp.writeFile(path.join(userDataDir, "Local State"), "{", "utf-8"); // invalid JSON
    await fsp.writeFile(
      path.join(userDataDir, "Default", "Preferences"),
      "[]", // valid JSON but wrong shape
      "utf-8",
    );

    decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });

    const localState = await readJson(path.join(userDataDir, "Local State"));
    expect(typeof localState.profile).toBe("object");

    const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
    expect(typeof prefs.profile).toBe("object");
  });

  it("writes clean exit prefs to avoid restore prompts", async () => {
    const userDataDir = await createUserDataDir();
    ensureProfileCleanExit(userDataDir);
    const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
    expect(prefs.exit_type).toBe("Normal");
    expect(prefs.exited_cleanly).toBe(true);
  });

  it("is idempotent when rerun on an existing profile", async () => {
    const userDataDir = await createUserDataDir();
    decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });
    decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });

    const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
    const profile = prefs.profile as Record<string, unknown>;
    expect(profile.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
  });
});

describe("browser chrome helpers", () => {
  function mockExistsSync(match: (pathValue: string) => boolean) {
    return vi.spyOn(fs, "existsSync").mockImplementation((p) => match(String(p)));
  }

  afterEach(() => {
    vi.unstubAllEnvs();
    vi.unstubAllGlobals();
    vi.restoreAllMocks();
  });

  it("picks the first existing Chrome candidate on macOS", () => {
    const exists = mockExistsSync((pathValue) =>
      pathValue.includes("Google Chrome.app/Contents/MacOS/Google Chrome"),
    );
    const exe = findChromeExecutableMac();
    expect(exe?.kind).toBe("chrome");
    expect(exe?.path).toMatch(/Google Chrome\.app/);
    exists.mockRestore();
  });

  it("returns null when no Chrome candidate exists", () => {
    const exists = vi.spyOn(fs, "existsSync").mockReturnValue(false);
    expect(findChromeExecutableMac()).toBeNull();
    exists.mockRestore();
  });

  it("picks the first existing Chrome candidate on Windows", () => {
    vi.stubEnv("LOCALAPPDATA", "C:\\Users\\Test\\AppData\\Local");
    const exists = mockExistsSync((pathStr) => {
      return (
        pathStr.includes("Google\\Chrome\\Application\\chrome.exe") ||
        pathStr.includes("BraveSoftware\\Brave-Browser\\Application\\brave.exe") ||
        pathStr.includes("Microsoft\\Edge\\Application\\msedge.exe")
      );
    });
    const exe = findChromeExecutableWindows();
    expect(exe?.kind).toBe("chrome");
    expect(exe?.path).toMatch(/chrome\.exe$/);
    exists.mockRestore();
  });

  it("finds Chrome in Program Files on Windows", () => {
    const marker = path.win32.join("Program Files", "Google", "Chrome");
    const exists = mockExistsSync((pathValue) => pathValue.includes(marker));
    const exe = findChromeExecutableWindows();
    expect(exe?.kind).toBe("chrome");
    expect(exe?.path).toMatch(/chrome\.exe$/);
    exists.mockRestore();
  });

  it("returns null when no Chrome candidate exists on Windows", () => {
    const exists = vi.spyOn(fs, "existsSync").mockReturnValue(false);
    expect(findChromeExecutableWindows()).toBeNull();
    exists.mockRestore();
  });

  it("resolves Windows executables without LOCALAPPDATA", () => {
    vi.stubEnv("LOCALAPPDATA", "");
    vi.stubEnv("ProgramFiles", "C:\\Program Files");
    vi.stubEnv("ProgramFiles(x86)", "C:\\Program Files (x86)");
    const marker = path.win32.join(
      "Program Files",
      "Google",
      "Chrome",
      "Application",
      "chrome.exe",
    );
    const exists = mockExistsSync((pathValue) => pathValue.includes(marker));
    const exe = resolveBrowserExecutableForPlatform(
      {} as Parameters<typeof resolveBrowserExecutableForPlatform>[0],
      "win32",
    );
    expect(exe?.kind).toBe("chrome");
    expect(exe?.path).toMatch(/chrome\.exe$/);
    exists.mockRestore();
  });

  it("reports reachability based on /json/version", async () => {
    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
      } as unknown as Response),
    );
    await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(true);

    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue({
        ok: false,
        json: async () => ({}),
      } as unknown as Response),
    );
    await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false);

    vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom")));
    await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false);
  });

  it("blocks private CDP probes when strict SSRF policy is enabled", async () => {
    const fetchSpy = vi.fn().mockRejectedValue(new Error("should not be called"));
    vi.stubGlobal("fetch", fetchSpy);

    await expect(
      isChromeReachable("http://127.0.0.1:12345", 50, {
        dangerouslyAllowPrivateNetwork: false,
      }),
    ).resolves.toBe(false);
    await expect(
      isChromeReachable("ws://127.0.0.1:19999", 50, {
        dangerouslyAllowPrivateNetwork: false,
      }),
    ).resolves.toBe(false);

    expect(fetchSpy).not.toHaveBeenCalled();
  });

  it("reports cdpReady only when Browser.getVersion command succeeds", async () => {
    await withMockChromeCdpServer({
      wsPath: "/devtools/browser/health",
      onConnection: (wss) => {
        wss.on("connection", (ws) => {
          ws.on("message", (raw) => {
            let message: { id?: unknown; method?: unknown } | null = null;
            try {
              const text =
                typeof raw === "string"
                  ? raw
                  : Buffer.isBuffer(raw)
                    ? raw.toString("utf8")
                    : Array.isArray(raw)
                      ? Buffer.concat(raw).toString("utf8")
                      : Buffer.from(raw).toString("utf8");
              message = JSON.parse(text) as { id?: unknown; method?: unknown };
            } catch {
              return;
            }
            if (message?.method === "Browser.getVersion" && message.id === 1) {
              ws.send(
                JSON.stringify({
                  id: 1,
                  result: { product: "Chrome/Mock" },
                }),
              );
            }
          });
        });
      },
      run: async (baseUrl) => {
        await expect(isChromeCdpReady(baseUrl, 300, 400)).resolves.toBe(true);
      },
    });
  });

  it("reports cdpReady false when websocket opens but command channel is stale", async () => {
    await withMockChromeCdpServer({
      wsPath: "/devtools/browser/stale",
      // Simulate a stale command channel: WS opens but never responds to commands.
      onConnection: (wss) => wss.on("connection", (_ws) => {}),
      run: async (baseUrl) => {
        await expect(isChromeCdpReady(baseUrl, 300, 150)).resolves.toBe(false);
      },
    });
  });

  it("probes WebSocket URLs via handshake instead of HTTP", async () => {
    // For ws:// URLs, isChromeReachable should NOT call fetch at all —
    // it should attempt a WebSocket handshake instead.
    const fetchSpy = vi.fn().mockRejectedValue(new Error("should not be called"));
    vi.stubGlobal("fetch", fetchSpy);
    // No WS server listening → handshake fails → not reachable
    await expect(isChromeReachable("ws://127.0.0.1:19999", 50)).resolves.toBe(false);
    expect(fetchSpy).not.toHaveBeenCalled();
  });

  it("stopOpenClawChrome no-ops when process is already killed", async () => {
    const proc = makeChromeTestProc({ killed: true });
    await stopChromeWithProc(proc, 10);
    expect(proc.kill).not.toHaveBeenCalled();
  });

  it("stopOpenClawChrome sends SIGTERM and returns once CDP is down", async () => {
    vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("down")));
    const proc = makeChromeTestProc();
    await stopChromeWithProc(proc, 10);
    expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
  });

  it("stopOpenClawChrome escalates to SIGKILL when CDP stays reachable", async () => {
    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
      } as unknown as Response),
    );
    const proc = makeChromeTestProc();
    await stopChromeWithProc(proc, 1);
    expect(proc.kill).toHaveBeenNthCalledWith(1, "SIGTERM");
    expect(proc.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
  });
});
