/**
 * Golden snapshot tests for skill injection.
 *
 * Each scenario runs the PreToolUse hook with a specific vercel.json fixture
 * and compares the `skillInjection` metadata against a stored .snap file.
 *
 * To update baselines:  bun test tests/snapshot-runner.test.ts -- --update-snapshots
 */

import { describe, test, expect, beforeAll } from "bun:test";
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";

const ROOT = resolve(import.meta.dirname, "..");
const HOOK_SCRIPT = join(ROOT, "hooks", "pretooluse-skill-inject.mjs");
const SNAP_DIR = join(ROOT, "tests", "snapshots");
const FIXTURES_DIR = join(ROOT, "tests", "fixtures");

const UPDATE_SNAPSHOTS =
  process.argv.includes("--update-snapshots") ||
  process.env.UPDATE_SNAPSHOTS === "1";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Extract skillInjection metadata from the HTML comment in additionalContext. */
function parseSkillInjection(additionalContext: string): Record<string, unknown> | null {
  const match = additionalContext.match(/<!-- skillInjection: (\{.*?\}) -->/);
  if (!match) return null;
  try {
    return JSON.parse(match[1]);
  } catch {
    return null;
  }
}

/** Run the hook and return the parsed skillInjection metadata. */
async function runHook(input: object): Promise<{
  code: number;
  skillInjection: Record<string, unknown> | null;
}> {
  const session = `snap-${Date.now()}-${Math.random().toString(36).slice(2)}`;
  const payload = JSON.stringify({ ...input, session_id: session });
  const proc = Bun.spawn(["node", HOOK_SCRIPT], {
    stdin: "pipe",
    stdout: "pipe",
    stderr: "pipe",
    env: {
      ...process.env,
      VERCEL_PLUGIN_HOOK_DEDUP: "off", // disable dedup so each scenario is independent
      VERCEL_PLUGIN_INJECTION_BUDGET: "999999", // unlimited budget for snapshot tests
    },
  });
  proc.stdin.write(payload);
  proc.stdin.end();
  const code = await proc.exited;
  const stdout = await new Response(proc.stdout).text();

  let skillInjection: Record<string, unknown> | null = null;
  try {
    const parsed = JSON.parse(stdout);
    const ctx = parsed?.hookSpecificOutput?.additionalContext ?? "";
    skillInjection = parseSkillInjection(ctx);
  } catch {}

  return { code, skillInjection };
}

/**
 * Normalize the skillInjection object for deterministic comparison.
 * - Removes toolTarget (contains temp paths that change per run)
 * - Sorts array fields so ordering within sets is stable
 */
function normalize(injection: Record<string, unknown>): Record<string, unknown> {
  const clone = { ...injection };
  // toolTarget contains absolute paths that vary per machine/run
  delete clone.toolTarget;
  // Sort arrays for deterministic comparison
  for (const key of ["matchedSkills", "injectedSkills", "droppedByCap", "droppedByBudget"] as const) {
    if (Array.isArray(clone[key])) {
      // injectedSkills order matters (priority), so only sort matchedSkills
      if (key === "matchedSkills") {
        clone[key] = [...(clone[key] as string[])].sort();
      }
    }
  }
  return clone;
}

/** Read a .snap file. Returns null if it doesn't exist. */
function readSnap(name: string): string | null {
  const p = join(SNAP_DIR, name);
  if (!existsSync(p)) return null;
  return readFileSync(p, "utf-8");
}

/** Write (or overwrite) a .snap file. */
function writeSnap(name: string, content: string): void {
  if (!existsSync(SNAP_DIR)) mkdirSync(SNAP_DIR, { recursive: true });
  writeFileSync(join(SNAP_DIR, name), content, "utf-8");
}

/** Assert that current output matches the stored snapshot. */
function assertSnapshot(snapName: string, actual: Record<string, unknown>) {
  const serialized = JSON.stringify(actual, null, 2) + "\n";

  if (UPDATE_SNAPSHOTS) {
    writeSnap(snapName, serialized);
    // Still pass the test when updating
    return;
  }

  const stored = readSnap(snapName);
  if (stored === null) {
    throw new Error(
      `Snapshot "${snapName}" does not exist. Run with --update-snapshots to create it.`,
    );
  }

  expect(serialized).toBe(stored);
}

// ---------------------------------------------------------------------------
// Write temp vercel.json fixtures that the hook can actually read
// ---------------------------------------------------------------------------

interface Scenario {
  name: string;
  snapFile: string;
  vercelJson: object;
  toolName: string;
}

const scenarios: Scenario[] = [
  {
    name: "redirects-only",
    snapFile: "inject-redirects.snap",
    vercelJson: {
      redirects: [
        { source: "/blog/:path*", destination: "https://blog.example.com/:path*" },
      ],
    },
    toolName: "Read",
  },
  {
    name: "headers-only",
    snapFile: "inject-headers.snap",
    vercelJson: {
      headers: [
        {
          source: "/(.*)",
          headers: [
            { key: "X-Frame-Options", value: "DENY" },
            { key: "X-Content-Type-Options", value: "nosniff" },
          ],
        },
      ],
    },
    toolName: "Edit",
  },
  {
    name: "rewrites-only",
    snapFile: "inject-rewrites.snap",
    vercelJson: {
      rewrites: [
        { source: "/api/:path*", destination: "https://api.example.com/:path*" },
      ],
    },
    toolName: "Write",
  },
  {
    name: "mixed-all-keys",
    snapFile: "inject-mixed.snap",
    vercelJson: {
      redirects: [{ source: "/old", destination: "/new" }],
      headers: [
        {
          source: "/(.*)",
          headers: [{ key: "X-Frame-Options", value: "DENY" }],
        },
      ],
      rewrites: [
        { source: "/proxy/:path*", destination: "https://backend.example.com/:path*" },
      ],
      functions: { "api/**": { memory: 1024, maxDuration: 30 } },
      crons: [{ path: "/api/cron/daily", schedule: "0 8 * * *" }],
      buildCommand: "next build",
    },
    toolName: "Edit",
  },
  {
    name: "functions-only",
    snapFile: "inject-functions.snap",
    vercelJson: {
      functions: { "api/**": { memory: 1024 } },
      regions: ["iad1"],
    },
    toolName: "Read",
  },
  {
    name: "crons-only",
    snapFile: "inject-crons.snap",
    vercelJson: {
      crons: [
        { path: "/api/cron/hourly", schedule: "0 * * * *" },
        { path: "/api/cron/daily", schedule: "0 8 * * *" },
      ],
    },
    toolName: "Read",
  },
];

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

let tempDir: string;

beforeAll(() => {
  tempDir = join(tmpdir(), `vp-snap-${Date.now()}`);
  mkdirSync(tempDir, { recursive: true });
});

describe("golden snapshot tests", () => {
  for (const scenario of scenarios) {
    test(`snapshot: ${scenario.name}`, async () => {
      // Write a temp vercel.json the hook can read
      const filePath = join(tempDir, `${scenario.name}-vercel.json`);
      // The hook expects the file to be named vercel.json
      const projectDir = join(tempDir, scenario.name);
      mkdirSync(projectDir, { recursive: true });
      const vercelPath = join(projectDir, "vercel.json");
      writeFileSync(vercelPath, JSON.stringify(scenario.vercelJson, null, 2), "utf-8");

      const { code, skillInjection } = await runHook({
        tool_name: scenario.toolName,
        tool_input: { file_path: vercelPath },
      });

      expect(code).toBe(0);
      expect(skillInjection).not.toBeNull();

      const normalized = normalize(skillInjection!);
      assertSnapshot(scenario.snapFile, normalized);
    });
  }

  test("snapshot update mode is off by default", () => {
    // Ensure we aren't accidentally always updating
    if (!process.argv.includes("--update-snapshots") && process.env.UPDATE_SNAPSHOTS !== "1") {
      expect(UPDATE_SNAPSHOTS).toBe(false);
    }
  });
});

// ---------------------------------------------------------------------------
// Golden fixture tests — data-driven from tests/fixtures/golden-*.json
// ---------------------------------------------------------------------------

describe("golden fixture tests", () => {
  const goldenFiles = readdirSync(FIXTURES_DIR).filter(
    (f) => f.startsWith("golden-") && f.endsWith(".json"),
  );

  for (const fixtureName of goldenFiles) {
    test(`golden: ${fixtureName}`, async () => {
      const fixture = JSON.parse(
        readFileSync(join(FIXTURES_DIR, fixtureName), "utf-8"),
      );

      let input = fixture.input;

      // If the fixture includes vercelJson content, write a temp file so
      // vercel-config.mjs key-aware routing can read it.
      if (fixture.vercelJson) {
        const projectDir = join(tempDir, `golden-${fixtureName}`);
        mkdirSync(projectDir, { recursive: true });
        const vercelPath = join(projectDir, "vercel.json");
        writeFileSync(
          vercelPath,
          JSON.stringify(fixture.vercelJson, null, 2),
          "utf-8",
        );
        input = {
          ...input,
          tool_input: { ...input.tool_input, file_path: vercelPath },
        };
      }

      const { code, skillInjection } = await runHook(input);
      expect(code).toBe(0);
      expect(skillInjection).not.toBeNull();

      const actual = skillInjection!;
      const expected = fixture.expected.skillInjection;

      // Version and toolName must match exactly
      expect(actual.version).toBe(expected.version);
      expect(actual.toolName).toBe(expected.toolName);

      // toolTarget — only assert when the fixture specifies it
      // (vercelJson fixtures use temp paths so toolTarget varies)
      if (expected.toolTarget) {
        expect(actual.toolTarget).toBe(expected.toolTarget);
      }

      // matchedSkills — same set (order may vary)
      expect([...(actual.matchedSkills as string[])].sort()).toEqual(
        [...expected.matchedSkills].sort(),
      );

      // injectedSkills — exact ordered list (ranking matters)
      expect(actual.injectedSkills).toEqual(expected.injectedSkills);

      // droppedByCap — same set (order may vary)
      expect([...(actual.droppedByCap as string[])].sort()).toEqual(
        [...expected.droppedByCap].sort(),
      );

      // Cap collision: if droppedByCap is expected non-empty, verify it
      if (expected.droppedByCap.length > 0) {
        expect((actual.droppedByCap as string[]).length).toBeGreaterThan(0);
      }
    });
  }
});
