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

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>;
}

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)));
  }

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

  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("stopOpenClawChrome no-ops when process is already killed", async () => {
    const proc = makeProc({ killed: true });
    await stopOpenClawChrome(
      {
        proc,
        cdpPort: 12345,
      } as unknown as Parameters<typeof stopOpenClawChrome>[0],
      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 = makeProc();
    await stopOpenClawChrome(
      {
        proc,
        cdpPort: 12345,
      } as unknown as Parameters<typeof stopOpenClawChrome>[0],
      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 = makeProc();
    await stopOpenClawChrome(
      {
        proc,
        cdpPort: 12345,
      } as unknown as Parameters<typeof stopOpenClawChrome>[0],
      1,
    );
    expect(proc.kill).toHaveBeenNthCalledWith(1, "SIGTERM");
    expect(proc.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
  });
});
