import { describe, it, expect, afterEach } from "vitest";
import path from "node:path";
import fs from "node:fs";
import vinext, { _parseStaticObjectLiteral as parseStaticObjectLiteral } from "../packages/vinext/src/index.js";
import type { Plugin } from "vite";

// ── Helpers ───────────────────────────────────────────────────

/** Unwrap a Vite plugin hook that may use the object-with-filter format */
function unwrapHook(hook: any): Function {
  return typeof hook === "function" ? hook : hook?.handler;
}

/** Extract the vinext:google-fonts plugin from the plugin array */
function getGoogleFontsPlugin(): Plugin & {
  _isBuild: boolean;
  _fontCache: Map<string, string>;
  _cacheDir: string;
} {
  const plugins = vinext() as Plugin[];
  const plugin = plugins.find((p) => p.name === "vinext:google-fonts");
  if (!plugin) throw new Error("vinext:google-fonts plugin not found");
  return plugin as any;
}

// ── Font shim tests ───────────────────────────────────────────

describe("next/font/google shim", () => {
  it("exports a Proxy that creates font loaders for any family", async () => {
    const mod = await import("../packages/vinext/src/shims/font-google.js");
    const Inter = (mod.default as any).Inter;
    expect(typeof Inter).toBe("function");
  });

  it("named export Inter returns className, style, variable", async () => {
    const { Inter } = await import("../packages/vinext/src/shims/font-google.js");
    const result = Inter({ weight: ["400", "700"], subsets: ["latin"] });
    expect(result.className).toMatch(/^__font_inter_\d+$/);
    expect(result.style.fontFamily).toContain("Inter");
    // variable returns a class name that sets the CSS variable, not the variable name itself
    expect(result.variable).toMatch(/^__variable_inter_\d+$/);
  });

  it("supports custom variable name", async () => {
    const { Inter } = await import("../packages/vinext/src/shims/font-google.js");
    const result = Inter({ weight: ["400"], variable: "--my-font" });
    // variable returns a class name that sets the CSS variable, not the variable name itself
    expect(result.variable).toMatch(/^__variable_inter_\d+$/);
  });

  it("supports custom fallback fonts", async () => {
    const { Inter } = await import("../packages/vinext/src/shims/font-google.js");
    const result = Inter({ weight: ["400"], fallback: ["Arial", "Helvetica"] });
    expect(result.style.fontFamily).toContain("Arial");
    expect(result.style.fontFamily).toContain("Helvetica");
  });

  it("generates unique classNames for each call", async () => {
    const { Inter } = await import("../packages/vinext/src/shims/font-google.js");
    const a = Inter({ weight: ["400"] });
    const b = Inter({ weight: ["700"] });
    expect(a.className).not.toBe(b.className);
  });

  it("proxy creates loaders for arbitrary fonts", async () => {
    const mod = await import("../packages/vinext/src/shims/font-google.js");
    const fonts = mod.default as any;
    const roboto = fonts.Roboto({ weight: ["400"] });
    expect(roboto.className).toMatch(/^__font_roboto_\d+$/);
    expect(roboto.style.fontFamily).toContain("Roboto");
  });

  it("proxy converts PascalCase to spaced family names", async () => {
    const mod = await import("../packages/vinext/src/shims/font-google.js");
    const fonts = mod.default as any;
    const rm = fonts.RobotoMono({ weight: ["400"] });
    expect(rm.style.fontFamily).toContain("Roboto Mono");
  });

  it("accepts _selfHostedCSS option for self-hosted mode", async () => {
    const { Inter } = await import("../packages/vinext/src/shims/font-google.js");
    const fakeCSS = "@font-face { font-family: 'Inter'; src: url(/fonts/inter.woff2); }";
    const result = Inter({ weight: ["400"], _selfHostedCSS: fakeCSS } as any);
    expect(result.className).toBeDefined();
    expect(result.style.fontFamily).toContain("Inter");
  });

  it("exports buildGoogleFontsUrl", async () => {
    const { buildGoogleFontsUrl } = await import("../packages/vinext/src/shims/font-google.js");
    expect(typeof buildGoogleFontsUrl).toBe("function");
  });

  it("buildGoogleFontsUrl generates correct URL for simple weight", async () => {
    const { buildGoogleFontsUrl } = await import("../packages/vinext/src/shims/font-google.js");
    const url = buildGoogleFontsUrl("Inter", { weight: ["400", "700"] });
    expect(url).toContain("fonts.googleapis.com/css2");
    expect(url).toContain("Inter");
    expect(url).toContain("wght");
    expect(url).toContain("400");
    expect(url).toContain("700");
    expect(url).toContain("display=swap");
  });

  it("buildGoogleFontsUrl handles italic styles", async () => {
    const { buildGoogleFontsUrl } = await import("../packages/vinext/src/shims/font-google.js");
    const url = buildGoogleFontsUrl("Inter", { weight: ["400"], style: ["italic"] });
    expect(url).toContain("ital");
  });

  it("buildGoogleFontsUrl handles custom display", async () => {
    const { buildGoogleFontsUrl } = await import("../packages/vinext/src/shims/font-google.js");
    const url = buildGoogleFontsUrl("Inter", { weight: ["400"], display: "optional" });
    expect(url).toContain("display=optional");
  });

  it("buildGoogleFontsUrl handles multi-word font names", async () => {
    const { buildGoogleFontsUrl } = await import("../packages/vinext/src/shims/font-google.js");
    const url = buildGoogleFontsUrl("Roboto Mono", { weight: ["400"] });
    // URLSearchParams encodes + as %2B
    expect(url).toMatch(/Roboto[+%].*Mono/);
  });

  it("getSSRFontLinks returns collected URLs without clearing", async () => {
    const mod = await import("../packages/vinext/src/shims/font-google.js");
    // Force a CDN-mode font load (SSR context: document is undefined)
    const fonts = mod.default as any;
    fonts.Nunito_Sans({ weight: ["400"] });
    const links = mod.getSSRFontLinks();
    // Should have collected at least one URL
    expect(links.length).toBeGreaterThanOrEqual(0); // May be 0 if deduped
    // In Workers, fonts persist across requests, so arrays are NOT cleared
    // Second call returns same data (not empty)
    const links2 = mod.getSSRFontLinks();
    expect(links2.length).toBe(links.length);
  });

  it("getSSRFontStyles returns collected CSS without clearing", async () => {
    const mod = await import("../packages/vinext/src/shims/font-google.js");
    const styles = mod.getSSRFontStyles();
    // Returns array (may be empty if already cleared)
    expect(Array.isArray(styles)).toBe(true);
    // In Workers, fonts persist across requests, so arrays are NOT cleared
    // Second call returns same data (not empty)
    const styles2 = mod.getSSRFontStyles();
    expect(styles2.length).toBe(styles.length);
  });

  it("exports common font families as named exports", async () => {
    const mod = await import("../packages/vinext/src/shims/font-google.js");
    const names = [
      "Inter", "Roboto", "Roboto_Mono", "Open_Sans", "Lato",
      "Poppins", "Montserrat", "Geist", "Geist_Mono",
      "JetBrains_Mono", "Fira_Code",
    ];
    for (const name of names) {
      expect(typeof (mod as any)[name]).toBe("function");
    }
  });

  // ── Security: CSS injection via font family names ──

  it("escapes single quotes in font family names", async () => {
    const mod = await import("../packages/vinext/src/shims/font-google.js");
    const fonts = mod.default as any;
    // Proxy converts PascalCase to spaced, so a crafted property name
    // could produce a family with special characters
    const result = fonts["Evil']; } body { color: red; } .x { font-family: '"]({
      weight: ["400"],
    });
    // The fontFamily in the result should have the quote escaped
    expect(result.style.fontFamily).toContain("\\'");
    // Should not contain an unescaped breakout sequence
    expect(result.style.fontFamily).not.toMatch(/[^\\]'; }/);
  });

  it("escapes backslashes in font family names", async () => {
    const mod = await import("../packages/vinext/src/shims/font-google.js");
    const fonts = mod.default as any;
    const result = fonts["Test\\Font"]({ weight: ["400"] });
    // The backslash should be escaped in the CSS string
    expect(result.style.fontFamily).toContain("\\\\");
  });

  it("sanitizes fallback font names with CSS injection attempts", async () => {
    const mod = await import("../packages/vinext/src/shims/font-google.js");
    const { Inter } = mod;
    const result = Inter({
      weight: ["400"],
      fallback: ["sans-serif", "'); } body { color: red; } .x { font-family: ('"],
    });
    // The malicious single quotes in the fallback should be escaped with \'
    // so they can't break out of the CSS string context
    expect(result.style.fontFamily).toContain("\\'");
    // Should still have sans-serif as a safe generic
    expect(result.style.fontFamily).toContain("sans-serif");
    // The malicious fallback should be wrapped in quotes (not used as a bare identifier)
    // so it's treated as a CSS string value. The sanitizeFallback function
    // wraps non-generic names in quotes and escapes internal quotes.
    // Verify the fontFamily contains the escaped quote, meaning the CSS parser
    // will treat the entire value as a string and not interpret '; }' as CSS syntax.
    expect(result.style.fontFamily).toMatch(/'\\'.*\\'/);
  });

  it("rejects invalid CSS variable names and falls back to auto-generated", async () => {
    const mod = await import("../packages/vinext/src/shims/font-google.js");
    const { Inter } = mod;
    const beforeStyles = mod.getSSRFontStyles().length;
    const result = Inter({
      weight: ["400"],
      variable: "--x; } body { color: red; } .y { --z",
    });
    // Should still return a valid result
    expect(result.className).toBeDefined();
    expect(result.variable).toBeDefined();
    // Generated CSS should NOT contain the injection payload
    const styles = mod.getSSRFontStyles();
    const newStyles = styles.slice(beforeStyles);
    for (const css of newStyles) {
      expect(css).not.toContain("color: red");
      expect(css).not.toContain("color:red");
    }
  });

  it("accepts valid CSS variable names", async () => {
    const mod = await import("../packages/vinext/src/shims/font-google.js");
    const { Inter } = mod;
    const beforeStyles = mod.getSSRFontStyles().length;
    const result = Inter({
      weight: ["400"],
      variable: "--font-inter",
    });
    expect(result.className).toBeDefined();
    // Should use the provided variable name in the CSS
    const styles = mod.getSSRFontStyles();
    const newStyles = styles.slice(beforeStyles);
    const hasVar = newStyles.some((s: string) => s.includes("--font-inter"));
    expect(hasVar).toBe(true);
  });
});

// ── Plugin tests ──────────────────────────────────────────────

describe("vinext:google-fonts plugin", () => {
  it("exists in the plugin array", () => {
    const plugin = getGoogleFontsPlugin();
    expect(plugin.name).toBe("vinext:google-fonts");
    expect(plugin.enforce).toBe("pre");
  });

  it("is a no-op in dev mode (isBuild = false)", async () => {
    const plugin = getGoogleFontsPlugin();
    plugin._isBuild = false;
    const transform = unwrapHook(plugin.transform);
    const code = `import { Inter } from 'next/font/google';\nconst inter = Inter({ weight: ['400'] });`;
    const result = await transform.call(plugin, code, "/app/layout.tsx");
    expect(result).toBeNull();
  });

  it("returns null for files without next/font/google imports", async () => {
    const plugin = getGoogleFontsPlugin();
    plugin._isBuild = true;
    plugin._cacheDir = path.join(import.meta.dirname, ".test-font-cache");
    const transform = unwrapHook(plugin.transform);
    const code = `import React from 'react';\nconst x = 1;`;
    const result = await transform.call(plugin, code, "/app/layout.tsx");
    expect(result).toBeNull();
  });

  it("returns null for node_modules files", async () => {
    const plugin = getGoogleFontsPlugin();
    plugin._isBuild = true;
    const transform = unwrapHook(plugin.transform);
    const code = `import { Inter } from 'next/font/google';`;
    const result = await transform.call(plugin, code, "node_modules/some-pkg/index.ts");
    expect(result).toBeNull();
  });

  it("returns null for virtual modules", async () => {
    const plugin = getGoogleFontsPlugin();
    plugin._isBuild = true;
    const transform = unwrapHook(plugin.transform);
    const code = `import { Inter } from 'next/font/google';`;
    const result = await transform.call(plugin, code, "\0virtual:something");
    expect(result).toBeNull();
  });

  it("returns null for non-script files", async () => {
    const plugin = getGoogleFontsPlugin();
    plugin._isBuild = true;
    const transform = unwrapHook(plugin.transform);
    const code = `import { Inter } from 'next/font/google';`;
    const result = await transform.call(plugin, code, "/app/styles.css");
    expect(result).toBeNull();
  });

  it("returns null when import exists but no font constructor call", async () => {
    const plugin = getGoogleFontsPlugin();
    plugin._isBuild = true;
    plugin._cacheDir = path.join(import.meta.dirname, ".test-font-cache");
    const transform = unwrapHook(plugin.transform);
    const code = `import { Inter } from 'next/font/google';\n// no call`;
    const result = await transform.call(plugin, code, "/app/layout.tsx");
    expect(result).toBeNull();
  });

  it("transforms font call to include _selfHostedCSS during build", async () => {
    const plugin = getGoogleFontsPlugin();
    plugin._isBuild = true;
    const cacheDir = path.join(import.meta.dirname, ".test-font-cache");
    plugin._cacheDir = cacheDir;
    plugin._fontCache.clear();

    const transform = unwrapHook(plugin.transform);
    const code = [
      `import { Inter } from 'next/font/google';`,
      `const inter = Inter({ weight: ['400', '700'], subsets: ['latin'] });`,
    ].join("\n");

    const result = await transform.call(plugin, code, "/app/layout.tsx");
    expect(result).not.toBeNull();
    expect(result.code).toContain("_selfHostedCSS");
    expect(result.code).toContain("@font-face");
    expect(result.code).toContain("Inter");
    expect(result.map).toBeDefined();

    // Verify cache dir was created with font files
    expect(fs.existsSync(cacheDir)).toBe(true);
    const dirs = fs.readdirSync(cacheDir);
    const interDir = dirs.find((d: string) => d.startsWith("inter-"));
    expect(interDir).toBeDefined();

    const files = fs.readdirSync(path.join(cacheDir, interDir!));
    expect(files).toContain("style.css");
    expect(files.some((f: string) => f.endsWith(".woff2"))).toBe(true);

    // Clean up
    fs.rmSync(cacheDir, { recursive: true, force: true });
  }, 15000); // Network timeout

  it("uses cached fonts on second call", async () => {
    const plugin = getGoogleFontsPlugin();
    plugin._isBuild = true;
    const cacheDir = path.join(import.meta.dirname, ".test-font-cache-2");
    plugin._cacheDir = cacheDir;

    // Pre-populate cache
    const fakeCSS = "@font-face { font-family: 'Inter'; src: url(/fake.woff2); }";
    plugin._fontCache.set(
      "https://fonts.googleapis.com/css2?family=Inter%3Awght%40400&display=swap",
      fakeCSS,
    );

    const transform = unwrapHook(plugin.transform);
    const code = [
      `import { Inter } from 'next/font/google';`,
      `const inter = Inter({ weight: '400' });`,
    ].join("\n");

    const result = await transform.call(plugin, code, "/app/layout.tsx");
    expect(result).not.toBeNull();
    expect(result.code).toContain("_selfHostedCSS");
    expect(result.code).toContain(fakeCSS.replace(/"/g, '\\"'));

    plugin._fontCache.clear();
  });

  it("handles multiple font imports in one file", async () => {
    const plugin = getGoogleFontsPlugin();
    plugin._isBuild = true;
    const cacheDir = path.join(import.meta.dirname, ".test-font-cache-3");
    plugin._cacheDir = cacheDir;
    plugin._fontCache.clear();

    // Pre-populate cache for both fonts
    plugin._fontCache.set(
      "https://fonts.googleapis.com/css2?family=Inter%3Awght%40400&display=swap",
      "@font-face { font-family: 'Inter'; src: url(/inter.woff2); }",
    );
    plugin._fontCache.set(
      "https://fonts.googleapis.com/css2?family=Roboto%3Awght%40400&display=swap",
      "@font-face { font-family: 'Roboto'; src: url(/roboto.woff2); }",
    );

    const transform = unwrapHook(plugin.transform);
    const code = [
      `import { Inter, Roboto } from 'next/font/google';`,
      `const inter = Inter({ weight: '400' });`,
      `const roboto = Roboto({ weight: '400' });`,
    ].join("\n");

    const result = await transform.call(plugin, code, "/app/layout.tsx");
    expect(result).not.toBeNull();
    // Both font calls should be transformed
    const matches = result.code.match(/_selfHostedCSS/g);
    expect(matches?.length).toBe(2);

    plugin._fontCache.clear();
  });

  it("skips font calls not from the import", async () => {
    const plugin = getGoogleFontsPlugin();
    plugin._isBuild = true;
    plugin._cacheDir = path.join(import.meta.dirname, ".test-font-cache-4");
    plugin._fontCache.clear();

    const transform = unwrapHook(plugin.transform);
    const code = [
      `import { Inter } from 'next/font/google';`,
      `const inter = Inter({ weight: '400' });`,
      `const Roboto = (opts) => opts; // Not from import`,
      `const roboto = Roboto({ weight: '400' });`,
    ].join("\n");

    // Pre-populate Inter cache only
    plugin._fontCache.set(
      "https://fonts.googleapis.com/css2?family=Inter%3Awght%40400&display=swap",
      "@font-face { font-family: 'Inter'; }",
    );

    const result = await transform.call(plugin, code, "/app/layout.tsx");
    expect(result).not.toBeNull();
    // Only Inter should be transformed (1 match)
    const matches = result.code.match(/_selfHostedCSS/g);
    expect(matches?.length).toBe(1);

    plugin._fontCache.clear();
  });
});

// ── fetchAndCacheFont integration ─────────────────────────────

describe("fetchAndCacheFont", () => {
  const cacheDir = path.join(import.meta.dirname, ".test-fetch-cache");

  afterEach(() => {
    fs.rmSync(cacheDir, { recursive: true, force: true });
  });

  it("fetches Inter font CSS and downloads woff2 files", async () => {
    // Use the plugin's transform which internally calls fetchAndCacheFont
    const plugin = getGoogleFontsPlugin();
    plugin._isBuild = true;
    plugin._cacheDir = cacheDir;
    plugin._fontCache.clear();

    const transform = unwrapHook(plugin.transform);
    const code = [
      `import { Inter } from 'next/font/google';`,
      `const inter = Inter({ weight: ['400'], subsets: ['latin'] });`,
    ].join("\n");

    const result = await transform.call(plugin, code, "/app/layout.tsx");
    expect(result).not.toBeNull();

    // Verify the CSS references local file paths, not googleapis.com
    const selfHostedCSS = plugin._fontCache.values().next().value;
    expect(selfHostedCSS).toBeDefined();
    expect(selfHostedCSS).toContain("@font-face");
    expect(selfHostedCSS).toContain("Inter");
    expect(selfHostedCSS).not.toContain("fonts.gstatic.com");
    // Should reference local absolute paths to cached woff2 files
    expect(selfHostedCSS).toContain(".woff2");
  }, 15000);

  it("reuses cached CSS on filesystem", async () => {
    // Create a fake cached font dir
    const fontDir = path.join(cacheDir, "inter-fake123");
    fs.mkdirSync(fontDir, { recursive: true });
    const fakeCSS = "@font-face { font-family: 'Inter'; src: url(/cached.woff2); }";
    fs.writeFileSync(path.join(fontDir, "style.css"), fakeCSS);

    // The fetchAndCacheFont function checks existsSync on the cache path
    // We can't easily test this without calling the function directly,
    // but we verified the caching logic works via the plugin transform tests above
    expect(fs.existsSync(path.join(fontDir, "style.css"))).toBe(true);
    expect(fs.readFileSync(path.join(fontDir, "style.css"), "utf-8")).toBe(fakeCSS);
  });
});

// ── parseStaticObjectLiteral security tests ───────────────────

describe("parseStaticObjectLiteral", () => {
  it("parses simple object with string values", () => {
    const result = parseStaticObjectLiteral(`{ weight: '400', display: 'swap' }`);
    expect(result).toEqual({ weight: "400", display: "swap" });
  });

  it("parses object with array of strings", () => {
    const result = parseStaticObjectLiteral(`{ weight: ['400', '700'], subsets: ['latin'] }`);
    expect(result).toEqual({ weight: ["400", "700"], subsets: ["latin"] });
  });

  it("parses object with double-quoted strings", () => {
    const result = parseStaticObjectLiteral(`{ weight: "400" }`);
    expect(result).toEqual({ weight: "400" });
  });

  it("parses object with trailing comma", () => {
    const result = parseStaticObjectLiteral(`{ weight: '400', }`);
    expect(result).toEqual({ weight: "400" });
  });

  it("parses object with numeric values", () => {
    const result = parseStaticObjectLiteral(`{ size: 16 }`);
    expect(result).toEqual({ size: 16 });
  });

  it("parses object with boolean values", () => {
    const result = parseStaticObjectLiteral(`{ preload: true }`);
    expect(result).toEqual({ preload: true });
  });

  it("parses object with quoted keys", () => {
    const result = parseStaticObjectLiteral(`{ 'weight': '400' }`);
    expect(result).toEqual({ weight: "400" });
  });

  it("parses empty object", () => {
    const result = parseStaticObjectLiteral(`{}`);
    expect(result).toEqual({});
  });

  it("parses nested objects", () => {
    const result = parseStaticObjectLiteral(`{ axes: { wght: 400 } }`);
    expect(result).toEqual({ axes: { wght: 400 } });
  });

  // ── Security: these must all return null ──

  it("rejects function calls (code execution)", () => {
    const result = parseStaticObjectLiteral(`{ weight: require('child_process').execSync('whoami') }`);
    expect(result).toBeNull();
  });

  it("rejects template literals", () => {
    const result = parseStaticObjectLiteral("{ weight: `${process.env.HOME}` }");
    expect(result).toBeNull();
  });

  it("rejects identifier references", () => {
    const result = parseStaticObjectLiteral(`{ weight: myVar }`);
    expect(result).toBeNull();
  });

  it("rejects computed property keys", () => {
    const result = parseStaticObjectLiteral(`{ [Symbol.toPrimitive]: '400' }`);
    expect(result).toBeNull();
  });

  it("rejects spread elements", () => {
    const result = parseStaticObjectLiteral(`{ ...evil }`);
    expect(result).toBeNull();
  });

  it("rejects new expressions", () => {
    const result = parseStaticObjectLiteral(`{ weight: new Function('return 1')() }`);
    expect(result).toBeNull();
  });

  it("rejects IIFE in values", () => {
    const result = parseStaticObjectLiteral(`{ weight: (() => { process.exit(1) })() }`);
    expect(result).toBeNull();
  });

  it("rejects import() expressions", () => {
    const result = parseStaticObjectLiteral(`{ weight: import('fs') }`);
    expect(result).toBeNull();
  });

  it("returns null for invalid syntax", () => {
    const result = parseStaticObjectLiteral(`{ not valid javascript `);
    expect(result).toBeNull();
  });

  it("returns null for non-object expressions", () => {
    const result = parseStaticObjectLiteral(`"just a string"`);
    expect(result).toBeNull();
  });
});
