import { describe, it, expect, vi, afterEach } from "vitest";
import { sign as cryptoSign, createPrivateKey, createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

// Generate a throwaway signing keypair BEFORE the config mock is hoisted, then
// pin its public key so verifySignature accepts signatures we produce here.
const h = vi.hoisted(() => {
  // eslint-disable-next-line @typescript-eslint/no-var-requires
  const crypto = require("node:crypto");
  const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
  const otherPub = crypto.generateKeyPairSync("ed25519").publicKey;
  const rawB64 = (pk: any) =>
    Buffer.from(pk.export({ format: "jwk" }).x, "base64url").toString("base64");
  return {
    pinnedPubB64: rawB64(publicKey),
    otherPubB64: rawB64(otherPub),
    privPem: privateKey.export({ type: "pkcs8", format: "pem" }) as string,
  };
});

vi.mock("../src/config.js", async (importActual) => {
  const actual = await importActual<typeof import("../src/config.js")>();
  return { ...actual, BINARY_SIGNING_PUBKEYS: [h.pinnedPubB64] };
});

import {
  BinaryVerificationError,
  downloadProBinary,
  fetchSignedManifest,
  parseChecksums,
  parseManifestVersion,
  verifyDownloadChecksum,
  verifyProDownload,
  verifySignature,
} from "../src/download.js";
import { DOWNLOAD_BASE_URL, getArchiveName, getChromiumVersion } from "../src/config.js";

/** Produce SHA256SUMS.sig content (base64 text bytes) for a manifest. */
function sign(manifest: Uint8Array): Uint8Array {
  const priv = createPrivateKey(h.privPem);
  const sig = cryptoSign(null, manifest, priv); // raw 64-byte Ed25519 signature
  return new TextEncoder().encode(sig.toString("base64"));
}

const enc = (s: string) => new TextEncoder().encode(s);

describe("verifySignature", () => {
  it("accepts a valid signature", () => {
    const manifest = enc("abc  cloakbrowser-linux-x64.tar.gz\n");
    expect(() => verifySignature(manifest, sign(manifest))).not.toThrow();
  });

  it("rejects a tampered manifest", () => {
    const manifest = enc("abc  cloakbrowser-linux-x64.tar.gz\n");
    const sig = sign(manifest);
    const tampered = enc("xyz  cloakbrowser-linux-x64.tar.gz\n");
    expect(() => verifySignature(tampered, sig)).toThrow(/signature verification failed/);
  });

  it("rejects malformed base64 in the .sig", () => {
    expect(() => verifySignature(enc("data\n"), enc("!!!not base64!!!")))
      .toThrow(/Malformed/);
  });

  it("rejects a signature from a non-pinned key", async () => {
    // Re-mock config so ONLY the other key is pinned, then the signature
    // (made with the real key) must fail.
    vi.resetModules();
    vi.doMock("../src/config.js", async (importActual) => {
      const actual = await importActual<typeof import("../src/config.js")>();
      return { ...actual, BINARY_SIGNING_PUBKEYS: [h.otherPubB64] };
    });
    const { verifySignature: vs } = await import("../src/download.js");
    const manifest = enc("data\n");
    expect(() => vs(manifest, sign(manifest))).toThrow(/signature verification failed/);
    vi.doUnmock("../src/config.js");
    vi.resetModules();
  });

  it("accepts a signature under the new key during rotation", async () => {
    // Pin BOTH keys (old + new) and sign with the real (new) key — must pass.
    vi.resetModules();
    vi.doMock("../src/config.js", async (importActual) => {
      const actual = await importActual<typeof import("../src/config.js")>();
      return { ...actual, BINARY_SIGNING_PUBKEYS: [h.otherPubB64, h.pinnedPubB64] };
    });
    const { verifySignature: vs } = await import("../src/download.js");
    const manifest = enc("rotated\n");
    expect(() => vs(manifest, sign(manifest))).not.toThrow();
    vi.doUnmock("../src/config.js");
    vi.resetModules();
  });
});

describe("verifyDownloadChecksum (official path, fail-closed)", () => {
  afterEach(() => {
    vi.restoreAllMocks();
    delete process.env.CLOAKBROWSER_DOWNLOAD_URL;
    delete process.env.CLOAKBROWSER_SKIP_CHECKSUM;
  });

  function tmpFile(bytes: Buffer): string {
    const p = path.join(os.tmpdir(), `cloak-sig-${process.pid}-${bytes.length}-${bytes[0]}`);
    fs.writeFileSync(p, bytes);
    return p;
  }

  /** Mock fetch to serve a signed manifest for the official URLs. */
  function mockManifest(manifestBytes: Uint8Array) {
    const sig = sign(manifestBytes);
    vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
      const url = typeof input === "string" ? input : (input as URL).toString();
      const body = url.endsWith(".sig") ? sig : manifestBytes;
      return { ok: true, arrayBuffer: async () => body.buffer } as Response;
    });
  }

  /** Manifest body with the bound version line prepended (defaults to current). */
  const body = (lines: string, version = getChromiumVersion()) =>
    enc(`version=${version}\n${lines}`);

  it("passes when signature is valid and hash matches", async () => {
    const data = Buffer.from("the real binary");
    const file = tmpFile(data);
    const hash = createHash("sha256").update(data).digest("hex");
    mockManifest(body(`${hash}  ${getArchiveName()}\n`));
    await expect(verifyDownloadChecksum(file)).resolves.toBeUndefined();
  });

  it("fails when the binary is tampered (hash mismatch)", async () => {
    const file = tmpFile(Buffer.from("a malicious binary"));
    const goodHash = createHash("sha256").update(Buffer.from("the real binary")).digest("hex");
    mockManifest(body(`${goodHash}  ${getArchiveName()}\n`));
    await expect(verifyDownloadChecksum(file)).rejects.toThrow(/Checksum verification failed/);
  });

  it("fails on a signed manifest for the wrong version (downgrade)", async () => {
    const data = Buffer.from("the real binary");
    const file = tmpFile(data);
    const hash = createHash("sha256").update(data).digest("hex");
    // Genuinely signed, but declares an old version we did not request.
    mockManifest(body(`${hash}  ${getArchiveName()}\n`, "1.0.0.0"));
    await expect(verifyDownloadChecksum(file)).rejects.toThrow(/Version mismatch/);
  });

  it("fails when the version line is missing (binding required)", async () => {
    const data = Buffer.from("the real binary");
    const file = tmpFile(data);
    const hash = createHash("sha256").update(data).digest("hex");
    mockManifest(enc(`${hash}  ${getArchiveName()}\n`)); // no version= line
    await expect(verifyDownloadChecksum(file)).rejects.toThrow(/Version mismatch/);
  });

  it("fails closed when no signed manifest can be fetched", async () => {
    const file = tmpFile(Buffer.from("x"));
    vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 404 } as Response);
    await expect(verifyDownloadChecksum(file)).rejects.toThrow(/signed SHA256SUMS/);
  });

  it("fails when the signed manifest has no entry for this platform", async () => {
    const file = tmpFile(Buffer.from("x"));
    const someHash = "0".repeat(64);
    mockManifest(body(`${someHash}  some-other-file.tar.gz\n`));
    await expect(verifyDownloadChecksum(file)).rejects.toThrow(/no entry for/);
  });

  it("custom download URL keeps the legacy skippable path (no signature fetch)", async () => {
    const file = tmpFile(Buffer.from("x"));
    process.env.CLOAKBROWSER_DOWNLOAD_URL = "https://my-mirror.test";
    process.env.CLOAKBROWSER_SKIP_CHECKSUM = "true";
    const spy = vi.spyOn(globalThis, "fetch");
    await expect(verifyDownloadChecksum(file)).resolves.toBeUndefined();
    expect(spy).not.toHaveBeenCalled();
  });
});

describe("downloadProBinary (version-pinned URL)", () => {
  afterEach(() => vi.restoreAllMocks());

  it("requests the explicit version, not /latest", async () => {
    let capturedUrl = "";
    // First fetch is the binary download; capture its URL then abort the flow
    // before verify/extract by returning a non-ok response.
    vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
      capturedUrl = typeof input === "string" ? input : (input as URL).toString();
      return { ok: false, status: 500, statusText: "stop" } as Response;
    });

    await downloadProBinary("147.0.1.0", "cb_key").catch(() => {});

    expect(capturedUrl).toBe(`${DOWNLOAD_BASE_URL}/api/download/147.0.1.0`);
    expect(capturedUrl.endsWith("/latest")).toBe(false);
  });
});

describe("verifyProDownload (Pro path, fail-closed parity)", () => {
  const PRO_VERSION = "147.0.1.0";

  afterEach(() => {
    vi.restoreAllMocks();
    delete process.env.CLOAKBROWSER_SKIP_CHECKSUM;
  });

  function tmpFile(bytes: Buffer): string {
    const p = path.join(os.tmpdir(), `cloak-pro-${process.pid}-${bytes.length}-${bytes[0]}`);
    fs.writeFileSync(p, bytes);
    return p;
  }

  /** Mock fetch: serve `manifestBytes` for SHA256SUMS, its signature for *.sig. */
  function mockManifest(manifestBytes: Uint8Array, sigBytes = sign(manifestBytes)) {
    vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
      const url = typeof input === "string" ? input : (input as URL).toString();
      const out = url.endsWith(".sig") ? sigBytes : manifestBytes;
      return { ok: true, arrayBuffer: async () => out.buffer } as Response;
    });
  }

  const body = (lines: string, version = PRO_VERSION) =>
    enc(`version=${version}\n${lines}`);

  it("passes when signature is valid and hash matches", async () => {
    const data = Buffer.from("the real pro binary");
    const file = tmpFile(data);
    const hash = createHash("sha256").update(data).digest("hex");
    mockManifest(body(`${hash}  ${getArchiveName()}\n`));
    await expect(verifyProDownload(file, PRO_VERSION)).resolves.toBeUndefined();
  });

  it("CLOAKBROWSER_SKIP_CHECKSUM does NOT bypass Pro verification", async () => {
    const file = tmpFile(Buffer.from("a malicious pro binary"));
    const goodHash = createHash("sha256").update(Buffer.from("the real pro binary")).digest("hex");
    process.env.CLOAKBROWSER_SKIP_CHECKSUM = "true";
    mockManifest(body(`${goodHash}  ${getArchiveName()}\n`));
    const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
    // The error TYPE is the contract the ensureBinary router branches on:
    // BinaryVerificationError => re-throw (never downgrade to free).
    expect(err).toBeInstanceOf(BinaryVerificationError);
    expect(err.message).toMatch(/Checksum verification failed/);
  });

  it("treats a failed manifest fetch as transient, not tampering", async () => {
    // A failed manifest FETCH must be a plain Error (router falls back to free),
    // NOT a BinaryVerificationError (which the router re-throws as a hard fail).
    const file = tmpFile(Buffer.from("x"));
    vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 404 } as Response);
    const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
    expect(err).toBeInstanceOf(Error);
    expect(err).not.toBeInstanceOf(BinaryVerificationError);
  });

  it("fails on a signed manifest for the wrong version (downgrade)", async () => {
    const data = Buffer.from("the real pro binary");
    const file = tmpFile(data);
    const hash = createHash("sha256").update(data).digest("hex");
    mockManifest(body(`${hash}  ${getArchiveName()}\n`, "1.0.0.0"));
    const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
    expect(err).toBeInstanceOf(BinaryVerificationError);
    expect(err.message).toMatch(/Version mismatch/);
  });

  it("rejects a manifest tampered after signing", async () => {
    const data = Buffer.from("the real pro binary");
    const file = tmpFile(data);
    const hash = createHash("sha256").update(data).digest("hex");
    const good = body(`${hash}  ${getArchiveName()}\n`);
    const sig = sign(good);
    const tampered = enc(new TextDecoder().decode(good).replace(getArchiveName(), "evil.tar.gz"));
    mockManifest(tampered, sig);
    const err = await verifyProDownload(file, PRO_VERSION).catch((e) => e);
    expect(err).toBeInstanceOf(BinaryVerificationError);
    expect(err.message).toMatch(/signature verification failed/);
  });
});

describe("version binding", () => {
  it("reads the version= line", () => {
    expect(
      parseManifestVersion("version=146.0.7680.177.5\nabc  file.tar.gz\n")
    ).toBe("146.0.7680.177.5");
  });

  it("returns null when absent", () => {
    expect(parseManifestVersion("abc  file.tar.gz\n")).toBeNull();
  });

  it("old parseChecksums ignores the version line", () => {
    const result = parseChecksums(
      `version=146.0.7680.177.5\n${"a".repeat(64)}  cloakbrowser-linux-x64.tar.gz\n`
    );
    expect(result.size).toBe(1);
    expect(result.has("cloakbrowser-linux-x64.tar.gz")).toBe(true);
  });
});

describe("fetchSignedManifest", () => {
  afterEach(() => {
    vi.restoreAllMocks();
  });

  const mockPair = (manifest: string, sig: string, failPrimarySig = false) =>
    vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
      const url = typeof input === "string" ? input : (input as URL).toString();
      const isSig = url.endsWith(".sig");
      if (url.includes("cloakbrowser.dev") && isSig && failPrimarySig) {
        return { ok: false, status: 404 } as Response;
      }
      return {
        ok: true,
        arrayBuffer: async () =>
          new TextEncoder().encode(isSig ? sig : manifest).buffer,
      } as Response;
    });

  it("returns manifest + sig from the primary origin", async () => {
    mockPair("MANIFEST", "U0lH");
    const result = await fetchSignedManifest("1.2.3.4");
    expect(new TextDecoder().decode(result!.manifestBytes)).toBe("MANIFEST");
    expect(new TextDecoder().decode(result!.sigBytes)).toBe("U0lH");
  });

  it("falls back to GitHub when the primary .sig is missing", async () => {
    const spy = mockPair("MANIFEST", "U0lH", true);
    const result = await fetchSignedManifest("1.2.3.4");
    expect(result).not.toBeNull();
    // primary SHA256SUMS + primary .sig (404) + github SHA256SUMS + github .sig
    expect(spy.mock.calls.length).toBeGreaterThanOrEqual(3);
  });

  it("returns null when everything fails", async () => {
    vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network"));
    expect(await fetchSignedManifest("1.2.3.4")).toBeNull();
  });
});
