import * as action from "@actions/core";
import { spawn, spawnSync } from "child_process";
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "fs";
import { readdirSync } from "node:fs";
import { basename, resolve } from "node:path";
import { cpus, hostname, tmpdir, totalmem, userInfo } from "os";
import PQueue from "p-queue";
import { join, normalize, posix, relative } from "path";
import { fileURLToPath } from "url";

const run_start = new Date();
const TIMEOUT_DURATION = 1000 * 60 * 5;
const SHORT_TIMEOUT_DURATION = Math.ceil(TIMEOUT_DURATION / 5);

function defaultConcurrency() {
  // This causes instability due to the number of open file descriptors / sockets in some tests
  // Windows has higher limits
  if (process.platform !== "win32") {
    return 1;
  }

  return Math.min(Math.floor((cpus().length - 2) / 2), 2);
}
const windows = process.platform === "win32";
const nativeMemory = totalmem();
const force_ram_size_input = parseInt(process.env["BUN_JSC_forceRAMSize"] || "0", 10);
let force_ram_size = Number(BigInt(nativeMemory) >> BigInt(2)) + "";
if (!(Number.isSafeInteger(force_ram_size_input) && force_ram_size_input > 0)) {
  force_ram_size = force_ram_size_input + "";
}
function uncygwinTempDir() {
  if (process.platform === "win32") {
    for (let key of ["TMPDIR", "TEMP", "TEMPDIR", "TMP"]) {
      let TMPDIR = process.env[key] || "";
      if (!/^\/[a-zA-Z]\//.test(TMPDIR)) {
        continue;
      }

      const driveLetter = TMPDIR[1];
      TMPDIR = path.win32.normalize(`${driveLetter.toUpperCase()}:` + TMPDIR.substring(2));
      process.env[key] = TMPDIR;
    }
  }
}

uncygwinTempDir();

const cwd = resolve(fileURLToPath(import.meta.url), "../../../../");
process.chdir(cwd);

const ci = !!process.env["GITHUB_ACTIONS"];
const enableProgressBar = false;

const dirPrefix = "bun-test-tmp-" + ((Math.random() * 100_000_0) | 0).toString(36) + "_";
const run_concurrency = Math.max(Number(process.env["BUN_TEST_CONCURRENCY"] || defaultConcurrency(), 10), 1);
const queue = new PQueue({ concurrency: run_concurrency });

var prevTmpdir = "";
function maketemp() {
  prevTmpdir = join(
    tmpdir(),
    dirPrefix + (Date.now() | 0).toString() + "_" + ((Math.random() * 100_000_0) | 0).toString(36),
  );
  mkdirSync(prevTmpdir, { recursive: true });
  return prevTmpdir;
}

const extensions = [".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", ".mts", ".cts", ".mjsx", ".cjsx", ".mtsx", ".ctsx"];

const git_sha =
  process.env["GITHUB_SHA"] ?? spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf-8" }).stdout.trim();

const TEST_FILTER = process.env.BUN_TEST_FILTER;

function isTest(path) {
  if (!basename(path).includes(".test.") || !extensions.some(ext => path.endsWith(ext))) {
    return false;
  }

  if (TEST_FILTER) {
    if (!path.includes(TEST_FILTER)) {
      return false;
    }
  }

  return true;
}

function* findTests(dir, query) {
  for (const entry of readdirSync(resolve(dir), { encoding: "utf-8", withFileTypes: true })) {
    const path = resolve(dir, entry.name);
    if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".git") {
      yield* findTests(path, query);
    } else if (isTest(path)) {
      yield path;
    }
  }
}

let bunExe = "bun";

if (process.argv.length > 2) {
  bunExe = resolve(process.argv.at(-1));
} else if (process.env.BUN_PATH) {
  const { BUN_PATH_BASE, BUN_PATH } = process.env;
  bunExe = resolve(normalize(BUN_PATH_BASE), normalize(BUN_PATH));
}

const { error, stdout: revision_stdout } = spawnSync(bunExe, ["--revision"], {
  env: { ...process.env, BUN_DEBUG_QUIET_LOGS: 1 },
});
if (error) {
  if (error.code !== "ENOENT") throw error;
  console.error(`\x1b[31merror\x1b[0;2m:\x1b[0m Could not find Bun executable at '${bunExe}'`);
  process.exit(1);
}
const revision = revision_stdout.toString().trim();

const { error: error2, stdout: argv0_stdout } = spawnSync(bunExe, ["-e", "console.log(process.argv[0])"], {
  env: { ...process.env, BUN_DEBUG_QUIET_LOGS: 1 },
});
if (error2) throw error2;
const argv0 = argv0_stdout.toString().trim();

console.log(`Testing ${argv0} v${revision}`);

const ntStatusPath = "C:\\Program Files (x86)\\Windows Kits\\10\\Include\\10.0.22621.0\\shared\\ntstatus.h";
let ntstatus_header_cache = null;
function lookupWindowsError(code) {
  if (ntstatus_header_cache === null) {
    try {
      ntstatus_header_cache = readFileSync(ntStatusPath, "utf-8");
    } catch {
      console.error(`could not find ntstatus.h to lookup error code: ${ntStatusPath}`);
      ntstatus_header_cache = "";
    }
  }
  const match = ntstatus_header_cache.match(new RegExp(`(STATUS_\\w+).*0x${code.toString(16)}`, "i"));
  if (match) {
    return match[1];
  }
  return null;
}

const failing_tests = [];
const passing_tests = [];
let maxFd = -1;
function getMaxFileDescriptor(path) {
  if (process.platform === "win32") {
    return -1;
  }

  hasInitialMaxFD = true;

  if (process.platform === "linux" || process.platform === "darwin") {
    try {
      readdirSync(process.platform === "darwin" ? "/dev/fd" : "/proc/self/fd").forEach(name => {
        const fd = parseInt(name.trim(), 10);
        if (Number.isSafeInteger(fd) && fd >= 0) {
          maxFd = Math.max(maxFd, fd);
        }
      });

      return maxFd;
    } catch {}
  }

  const devnullfd = openSync("/dev/null", "r");
  closeSync(devnullfd);
  maxFd = devnullfd + 1;
  return maxFd;
}
let hasInitialMaxFD = false;

const activeTests = new Map();

let slowTestCount = 0;
function checkSlowTests() {
  const now = Date.now();
  const prevSlowTestCount = slowTestCount;
  slowTestCount = 0;
  for (const [path, { start, proc }] of activeTests) {
    if (proc && now - start >= TIMEOUT_DURATION) {
      console.error(
        `\x1b[31merror\x1b[0;2m:\x1b[0m Killing test ${JSON.stringify(path)} after ${Math.ceil((now - start) / 1000)}s`,
      );
      proc?.stdout?.destroy?.();
      proc?.stderr?.destroy?.();
      proc?.kill?.(9);
    } else if (now - start > SHORT_TIMEOUT_DURATION) {
      console.error(
        `\x1b[33mwarning\x1b[0;2m:\x1b[0m Test ${JSON.stringify(path)} has been running for ${Math.ceil(
          (now - start) / 1000,
        )}s`,
      );
      slowTestCount++;
    }
  }

  if (slowTestCount > prevSlowTestCount && queue.concurrency > 1) {
    queue.concurrency += 1;
  }
}

setInterval(checkSlowTests, SHORT_TIMEOUT_DURATION).unref();
var currentTestNumber = 0;
async function runTest(path) {
  const pathOnDisk = resolve(path);
  const thisTestNumber = currentTestNumber++;
  const testFileName = posix.normalize(relative(cwd, path).replaceAll("\\", "/"));
  let exitCode, signal, err, output;

  const start = Date.now();

  const activeTestObject = { start, proc: undefined };
  activeTests.set(testFileName, activeTestObject);

  try {
    await new Promise((finish, reject) => {
      const chunks = [];
      process.stderr.write(
        `
at ${((start - run_start.getTime()) / 1000).toFixed(2)}s, file ${thisTestNumber
          .toString()
          .padStart(total.toString().length, "0")}/${total}, ${failing_tests.length} failing files
Starting "${testFileName}"

`,
      );
      const TMPDIR = maketemp();
      const proc = spawn(bunExe, ["test", pathOnDisk], {
        stdio: ["ignore", "pipe", "pipe"],
        env: {
          ...process.env,
          FORCE_COLOR: "1",
          BUN_GARBAGE_COLLECTOR_LEVEL: "1",
          BUN_JSC_forceRAMSize: force_ram_size,
          BUN_RUNTIME_TRANSPILER_CACHE_PATH: "0",
          GITHUB_ACTIONS: process.env.GITHUB_ACTIONS ?? "true",
          BUN_DEBUG_QUIET_LOGS: "1",
          BUN_INSTALL_CACHE_DIR: join(TMPDIR, ".bun-install-cache"),
          BUN_ENABLE_CRASH_REPORTING: "0",
          [windows ? "TEMP" : "TMPDIR"]: TMPDIR,
        },
      });
      activeTestObject.proc = proc;
      proc.stdout.once("end", () => {
        done();
      });

      let doneCalls = 0;
      var done = () => {
        // TODO: wait for stderr as well
        // spawn.test currently causes it to hang
        if (doneCalls++ === 1) {
          actuallyDone();
        }
      };
      var actuallyDone = function () {
        actuallyDone = done = () => {};
        proc?.stderr?.unref?.();
        proc?.stdout?.unref?.();
        proc?.unref?.();
        output = Buffer.concat(chunks).toString();
        finish();
      };

      // if (!KEEP_TMPDIR)
      //   proc.once("close", () => {
      //     rm(TMPDIR, { recursive: true, force: true }).catch(() => {});
      //   });

      proc.stdout.on("data", chunk => {
        chunks.push(chunk);
        if (run_concurrency === 1) process.stdout.write(chunk);
      });
      proc.stderr.on("data", chunk => {
        chunks.push(chunk);
        if (run_concurrency === 1) process.stderr.write(chunk);
      });

      proc.once("close", () => {
        activeTestObject.proc = undefined;
      });

      proc.once("exit", (code_, signal_) => {
        activeTestObject.proc = undefined;
        exitCode = code_;
        signal = signal_;
        if (signal || exitCode !== 0) {
          actuallyDone();
        } else {
          done();
        }
      });
      proc.once("error", err_ => {
        activeTestObject.proc = undefined;
        err = err_;
        actuallyDone();
      });
    });
  } finally {
    activeTests.delete(testFileName);
  }

  if (!hasInitialMaxFD) {
    getMaxFileDescriptor();
  } else if (maxFd > 0) {
    const prevMaxFd = maxFd;
    maxFd = getMaxFileDescriptor();
    if (maxFd > prevMaxFd + queue.concurrency * 2) {
      process.stderr.write(
        `\n\x1b[31mewarn\x1b[0;2m:\x1b[0m file descriptor leak in ${testFileName}, delta: ${
          maxFd - prevMaxFd
        }, current: ${maxFd}, previous: ${prevMaxFd}\n`,
      );
    }
  }

  const passed = exitCode === 0 && !err && !signal;

  let reason = "";
  if (!passed) {
    let match;
    if (err && err.message.includes("timed")) {
      reason = "hang";
    } else if ((match = output && output.match(/thread \d+ panic: (.*)\n/))) {
      reason = 'panic "' + match[1] + '"';
    } else if (err) {
      reason = (err.name || "Error") + ": " + err.message;
    } else if (signal) {
      reason = signal;
    } else if (exitCode === 1) {
      const failMatch = output.match(/\x1b\[31m\s(\d+) fail/);
      if (failMatch) {
        reason = failMatch[1] + " failing";
      } else {
        reason = "code 1";
      }
    } else {
      const x = windows && lookupWindowsError(exitCode);
      if (x) {
        if (x === "STATUS_BREAKPOINT") {
          if (output.includes("Segmentation fault at address")) {
            reason = "STATUS_ACCESS_VIOLATION";
          }
        }
        reason = x;
      } else {
        reason = "code " + exitCode;
      }
    }
  }

  const duration = (Date.now() - start) / 1000;

  if (run_concurrency !== 1 && enableProgressBar) {
    // clear line
    process.stdout.write("\x1b[2K\r");
  }

  console.log(
    `\x1b[2m${formatTime(duration).padStart(6, " ")}\x1b[0m ${
      passed ? "\x1b[32m✔" : "\x1b[31m✖"
    } ${testFileName}\x1b[0m${reason ? ` (${reason})` : ""}`,
  );

  finished++;

  if (run_concurrency !== 1 && enableProgressBar) {
    writeProgressBar();
  }

  if (run_concurrency > 1 && ci) {
    process.stderr.write(output);
  }

  if (!passed) {
    failing_tests.push({ path: testFileName, reason, output });
    process.exitCode = 1;
    if (err) console.error(err);
  } else {
    passing_tests.push(testFileName);
  }

  return passed;
}

var finished = 0;

function writeProgressBar() {
  const barWidth = Math.min(process.stdout.columns || 40, 80) - 2;
  const percent = (finished / total) * 100;
  const bar = "=".repeat(Math.floor(percent / 2));
  const str1 = `[${finished}/${total}] [${bar}`;
  process.stdout.write(`\r${str1}${" ".repeat(barWidth - str1.length)}]`);
}

const allTests = [...findTests(resolve(cwd, "test"))];
console.log(`Starting ${allTests.length} tests with ${run_concurrency} concurrency...`);
let total = allTests.length;
for (const path of allTests) {
  queue.add(
    async () =>
      await runTest(path).catch(e => {
        console.error("Bug in bun-internal-test");
        console.error(e);
        process.exit(1);
      }),
  );
}
await queue.onIdle();
console.log(`
Completed ${total} tests with ${failing_tests.length} failing tests
`);
console.log("\n");

function linkToGH(linkTo) {
  return `https://github.com/oven-sh/bun/blob/${git_sha}/${linkTo}`;
}

failing_tests.sort((a, b) => a.path.localeCompare(b.path));
passing_tests.sort((a, b) => a.localeCompare(b));

const failingTestDisplay = failing_tests.map(({ path, reason }) => `- \`${path}\` ${reason}`).join("\n");

// const passingTestDisplay = passing_tests.map(path => `- \`${path}\``).join("\n");

rmSync("report.md", { force: true });

const uptime = process.uptime();

function formatTime(seconds) {
  if (seconds < 60) {
    return seconds.toFixed(1) + "s";
  } else if (seconds < 60 * 60) {
    return (seconds / 60).toFixed(0) + "m " + formatTime(seconds % 60);
  } else {
    return (seconds / 60 / 60).toFixed(0) + "h " + formatTime(seconds % (60 * 60));
  }
}

const header = `
host:     ${process.env["GITHUB_RUN_ID"] ? "GitHub Actions: " : ""}${userInfo().username}@${hostname()}
platform: ${process.platform} ${process.arch}
bun:      ${argv0}
version:  v${revision}

date:     ${run_start.toISOString()}
duration: ${formatTime(uptime)}

total:    ${total} files
failing:  ${failing_tests.length} files
passing:  ${passing_tests.length} files

percent:  ${((passing_tests.length / total) * 100).toFixed(2)}%
`.trim();

console.log("\n" + "-".repeat(Math.min(process.stdout.columns || 40, 80)) + "\n");
console.log(header);
console.log("\n" + "-".repeat(Math.min(process.stdout.columns || 40, 80)) + "\n");

let report = `# bun test on ${
  process.env["GITHUB_REF"] ??
  spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf-8" }).stdout.trim()
}

\`\`\`
${header}
\`\`\`

`;

if (failingTestDisplay.length > 0) {
  report += `## Failing tests\n\n`;
  report += failingTestDisplay;
  report += "\n\n";
}

// if(passingTestDisplay.length > 0) {
//   report += `## Passing tests\n\n`;
//   report += passingTestDisplay;
//   report += "\n\n";
// }

if (failing_tests.length) {
  report += `## Failing tests log output\n\n`;
  for (const { path, output, reason } of failing_tests) {
    report += `### ${path}\n\n`;
    report += "[Link to file](" + linkToGH(path) + ")\n\n";
    report += `${reason}\n\n`;
    report += "```\n";

    let failing_output = output
      .replace(/\x1b\[[0-9;]*m/g, "")
      .replace(/^::(group|endgroup|error|warning|set-output|add-matcher|remove-matcher).*$/gm, "");

    if (failing_output.length > 1024 * 64) {
      failing_output = failing_output.slice(0, 1024 * 64) + `\n\n[truncated output (length: ${failing_output.length})]`;
    }

    report += failing_output;

    report += "```\n\n";
  }
}

writeFileSync("test-report.md", report);
writeFileSync(
  "test-report.json",
  JSON.stringify({
    failing_tests,
    passing_tests,
  }),
);

function mabeCapitalize(str) {
  str = str.toLowerCase();
  if (str.includes("arm64") || str.includes("aarch64")) {
    return str.toUpperCase();
  }

  if (str.includes("x64")) {
    return "x64";
  }

  if (str.includes("baseline")) {
    return str;
  }

  return str[0].toUpperCase() + str.slice(1);
}

console.log("-> test-report.md, test-report.json");
function linkify(text, url) {
  if (url?.startsWith?.("https://")) {
    return `[${text}](${url})`;
  }

  return text;
}

if (ci) {
  if (failing_tests.length > 0) {
    action.setFailed(`${failing_tests.length} files with failing tests`);
  }
  action.setOutput("failing_tests", failingTestDisplay);
  action.setOutput("failing_tests_count", failing_tests.length);
  if (failing_tests.length) {
    const { env } = process;
    const tag = process.env.BUN_TAG || "unknown";
    const url = `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`;

    let comment = `## ${linkify(`${emojiTag(tag)}${failing_tests.length} failing tests`, url)} ${tag
      .split("-")
      .map(mabeCapitalize)
      .join(" ")}

${failingTestDisplay}

`;
    writeFileSync("comment.md", comment);
  }
  let truncated_report = report;
  if (truncated_report.length > 512 * 1000) {
    truncated_report = truncated_report.slice(0, 512 * 1000) + "\n\n...truncated...";
  }
  action.summary.addRaw(truncated_report);
  await action.summary.write();
}

function emojiTag(tag) {
  let emojiText = "";
  tag = tag.toLowerCase();
  if (tag.includes("win32") || tag.includes("windows")) {
    emojiText += "🪟";
  }

  if (tag.includes("linux")) {
    emojiText += "🐧";
  }

  if (tag.includes("macos") || tag.includes("darwin")) {
    emojiText += "";
  }

  if (tag.includes("x86") || tag.includes("x64") || tag.includes("_64") || tag.includes("amd64")) {
    if (!tag.includes("linux")) {
      emojiText += "💻";
    } else {
      emojiText += "🖥";
    }
  }

  if (tag.includes("arm64") || tag.includes("aarch64")) {
    emojiText += "💪";
  }

  if (emojiText) {
    emojiText += " ";
  }

  return emojiText;
}

process.exit(failing_tests.length ? 1 : process.exitCode);
