#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env --allow-run
// deno-lint-ignore-file
// Copyright 2018-2026 the Deno authors. MIT license.

// This file is used to transform Node.js internal streams code to
// Deno polyfills.
//
// Run this script with `--upgrade` to upgrade the streams code. This will update
// the code to the Node.js version specified in `tests/node_compat/runner/suite/node_version.ts`.
//
// This script applies the following transformations:
//   a. Rewrite CJS-style internal Node.js modules to ESM for Deno.
//   b. Remap internal Node.js modules to Deno equivalents.

// @ts-types="npm:@types/jscodeshift"
import jscodeshift from "npm:jscodeshift@0.15.2";
import type {
  AssignmentExpression,
  ASTPath,
  ExportSpecifier,
  FileInfo,
  Identifier,
  ImportDeclaration,
  JSCodeshift,
  ObjectExpression,
  Property,
} from "npm:jscodeshift@0.15.2";
import $ from "jsr:@david/dax@0.42.0";

import path from "node:path";

import { version } from "../../tests/node_compat/runner/suite/node_version.ts";
import { expandGlobSync } from "jsr:@std/fs@1.0.14/expand-glob";

const globs = [
  "internal/streams/*.js",
  "stream/*.js",
];

// These have special handling for lazy loading
const ignore = ["duplexify.js"];

const moduleMap: Record<string, string> = {
  "events": "node:events",
  "buffer": "node:buffer",
  "stream": "node:stream",
  "string_decoder": "node:string_decoder",
  "internal/abort_controller": "ext:deno_web/03_abort_signal.js",
  "internal/events/abort_listener":
    "ext:deno_node/internal/events/abort_listener.mjs",
  "internal/assert": "ext:deno_node/internal/assert.mjs",
  "internal/webstreams/adapters":
    "ext:deno_node/internal/webstreams/adapters.js",
  "internal/webstreams/compression": "ext:deno_web/14_compression.js",
  "internal/webstreams/encoding": "ext:deno_web/08_text_encoding.js",
  "internal/errors": "ext:deno_node/internal/errors.ts",
  "internal/event_target": "ext:deno_node/internal/event_target.mjs",
  "internal/util": "ext:deno_node/internal/util.mjs",
  "internal/util/debuglog": "ext:deno_node/internal/util/debuglog.ts",
  "internal/validators": "ext:deno_node/internal/validators.mjs",
  "internal/encoding": "ext:deno_web/08_text_encoding.js",
  "internal/blob": "ext:deno_web/09_file.js",
};

// Use default export for these conditional require()
const defaultLazy = [
  "internal/streams/passthrough",
  "internal/streams/readable",
  "internal/streams/duplexify",
];

// Workaround a bug in our formatter: "export default from;" does not work
// correctly, so we rename it to something else and export.
//
// https://github.com/dprint/dprint-plugin-typescript/issues/705
const renameForDefaultExport = ["from"];

const mapping = (source: string): string => {
  if (source.startsWith("internal/webstreams")) {
    return `ext:deno_web/06_streams.js`;
  }
  if (source.startsWith("internal/")) {
    return `ext:deno_node/${source}.js`;
  }
  return source;
};

const getSource = (source: string): string =>
  moduleMap[source] || mapping(source);

function createDefaultAndNamedExport(
  j: JSCodeshift,
  expr: ObjectExpression,
  getUniqueImportId: (id?: string) => Identifier,
) {
  const props = expr.properties;

  const specifiers: ExportSpecifier[] = props
    .filter((prop) => j.Property.check(prop) && j.Identifier.check(prop.value))
    .map((p) => {
      const prop = p as Property;
      return j.exportSpecifier.from({
        exported: j.identifier((prop.value as Identifier).name),
        local: j.identifier((prop.key as Identifier).name),
      });
    });

  const tmpId = getUniqueImportId("_defaultExport");

  const tmpDecl = j.variableDeclaration("const", [
    j.variableDeclarator(tmpId, expr),
  ]);

  const defaultExport = j.exportDefaultDeclaration(tmpId);
  const namedExport = j.exportNamedDeclaration(null, specifiers);

  return { tmpDecl, defaultExport, namedExport };
}

const topLevel = (path: ASTPath) => path.parent.node.type === "Program";

const transform = (file: FileInfo, j: JSCodeshift) => {
  const root = j(file.source);

  let uidCounter = 1;
  function getUniqueImportId(base = "imported") {
    const used = new Set(Object.keys(root.getVariableDeclarators(() => true)));
    let name;
    do {
      name = `${base}${uidCounter++}`;
    } while (used.has(name));
    return j.identifier(name);
  }

  const requireDecls: ImportDeclaration[] = [];
  const destructurings: { index: number; node: any }[] = [];
  const toRemove: ASTPath[] = [];
  let insertedPrimordialsImport = false;
  let insertedProcessImport = false;
  let hasDefaultExport = false;

  // If "process" is used, add import
  root.find(j.Identifier)
    .filter((path) => path.node.name === "process")
    .forEach(() => {
      if (!insertedProcessImport) {
        const processImport = j.importDeclaration(
          [j.importDefaultSpecifier(j.identifier("process"))],
          j.literal("node:process"),
        );
        requireDecls.push(processImport);
        insertedProcessImport = true;
      }
    });

  root.find(j.VariableDeclaration)
    .forEach((path) => {
      path.node.declarations.forEach((decl) => {
        if (decl.type !== "VariableDeclarator") return;

        if (
          j.ObjectPattern.check(decl.id) &&
          j.Identifier.check(
            decl.init,
            (s) => "name" in s && s.name == "primordials",
          )
        ) {
          // Insert import if it hasn’t been added yet
          if (!insertedPrimordialsImport) {
            const primordialsImport = j.importDeclaration(
              [j.importSpecifier(j.identifier("primordials"))],
              j.literal("ext:core/mod.js"),
            );
            requireDecls.push(primordialsImport);
            insertedPrimordialsImport = true;
          }
        }

        if (
          j.CallExpression.check(decl.init) &&
          (decl.init.callee as Identifier)?.name === "require" &&
          decl.init.arguments.length === 1 &&
          j.Literal.check(decl.init.arguments[0])
        ) {
          const callee = decl.init.callee as Identifier;
          // Make sure that name is "require"
          if (callee.name !== "require") {
            throw new Error(
              'Expected "require" as the callee name. Found: ' +
                callee.name,
            );
          }

          const source = decl.init.arguments[0].value as string;
          const id = decl.id;

          if (j.Identifier.check(id)) {
            // const foo = require('bar')
            const importDecl = j.importDeclaration(
              [j.importDefaultSpecifier(j.identifier(id.name))],
              j.literal(getSource(source)),
            );
            requireDecls.push(importDecl);
            toRemove.push(path);
          } else if (j.ObjectPattern.check(id)) {
            const isFlat = id.properties.every(
              (p) => j.Property.check(p) && j.Identifier.check(p.value),
            );

            if (isFlat) {
              // const { x, y } = require('bar')
              const importDecl = j.importDeclaration(
                id.properties.map((p) => {
                  const prop = p as Property;
                  return j.importSpecifier(
                    j.identifier((prop.key as Identifier).name),
                    j.identifier((prop.value as Identifier).name),
                  );
                }),
                j.literal(getSource(source)),
              );
              requireDecls.push(importDecl);
              toRemove.push(path);
            } else {
              // const { o: { a } } = require('baz') → import tmp from 'baz'; const { o: { a } } = tmp;
              const importId = getUniqueImportId();
              const importDecl = j.importDeclaration(
                [j.importDefaultSpecifier(importId)],
                j.literal(getSource(source)),
              );
              requireDecls.push(importDecl);

              const replacementDecl = j.variableDeclaration(path.node.kind, [
                j.variableDeclarator(id, importId),
              ]);
              destructurings.push({ index: path.name, node: replacementDecl });

              toRemove.push(path);
            }
          }
        } else if (
          j.MemberExpression.check(decl.init) &&
          j.CallExpression.check(decl.init.object) &&
          (decl.init.object.callee as Identifier)?.name === "require" &&
          decl.init.object.arguments.length === 1 &&
          j.Literal.check(decl.init.object.arguments[0])
        ) {
          // Example: require('internal/errors').codes
          const source = decl.init.object.arguments[0].value as string;
          const accessedProp = (decl.init.property as Identifier).name;
          const importId = getUniqueImportId("_mod"); // e.g., _mod1

          const importDecl = j.importDeclaration(
            [j.importDefaultSpecifier(importId)],
            j.literal(getSource(source)),
          );
          requireDecls.push(importDecl);

          // Reassign: const { ... } = _mod.codes
          const newInit = j.memberExpression(
            importId,
            j.identifier(accessedProp),
          );
          const replacementDecl = j.variableDeclaration(path.node.kind, [
            j.variableDeclarator(decl.id, newInit),
          ]);

          destructurings.push({ index: path.name, node: replacementDecl });
          toRemove.push(path);
        }
      });
    });

  const inlineRequires = new Map(); // module name → imported identifier

  // Replace module.exports = { x, y } with export { x, y }
  const namedExportAssignments: string[] = [];

  const pushEnd = (n: any) => root.get().node.program.body.push(n);

  root.find(j.ExpressionStatement)
    .filter(topLevel)
    .filter((path) => {
      const expr = path.node.expression;
      return (
        j.AssignmentExpression.check(expr) &&
        j.MemberExpression.check(expr.left) &&
        (expr.left.object as Identifier).name === "module" &&
        (expr.left.property as Identifier).name === "exports"
      );
    })
    .forEach((path) => {
      const expr = path.node.expression as AssignmentExpression;

      if (j.ObjectExpression.check(expr.right)) {
        const { tmpDecl, defaultExport, namedExport } =
          createDefaultAndNamedExport(j, expr.right, getUniqueImportId);
        j(path).insertBefore(tmpDecl);
        j(path).insertAfter(namedExport);
        j(path).insertAfter(defaultExport);
        hasDefaultExport = true;

        j(path).remove();
      } else if (j.Identifier.check(expr.right)) {
        // module.exports = Foo;
        const id = expr.right;
        if (!hasDefaultExport) {
          let name = id.name;
          if (renameForDefaultExport.includes(name)) {
            name = "_defaultExport";
            // Assign to a new variable
            const decl = j.variableDeclaration("const", [
              j.variableDeclarator(j.identifier(name), id),
            ]);
            pushEnd(decl);
          }
          const exportDefault = j.exportDefaultDeclaration(
            j.identifier(name),
          );
          pushEnd(exportDefault);
          hasDefaultExport = true;
        }

        const exportNamed = j.exportNamedDeclaration(
          null,
          [j.exportSpecifier.from({
            exported: j.identifier(id.name),
            local: j.identifier(id.name),
          })],
        );
        pushEnd(exportNamed);
        j(path).remove();
      } else {
        // module.exports = () => ... or {}
        const exportDefault = j.exportDefaultDeclaration(expr.right);
        j(path).replaceWith(exportDefault);
      }
    });

  // Handle module.exports.X = ...
  root.find(j.ExpressionStatement)
    .filter(topLevel)
    .filter((path) => {
      const expr = path.node.expression;
      return (
        j.AssignmentExpression.check(expr) &&
        j.MemberExpression.check(expr.left) &&
        j.MemberExpression.check(expr.left.object) &&
        (expr.left.object.object as Identifier).name == "module" &&
        (expr.left.object.property as Identifier).name == "exports"
      );
    })
    .forEach((path) => {
      const expr = path.node.expression as AssignmentExpression;
      const exportName = "property" in expr.left && "name" in expr.left.property
        ? expr.left.property.name
        : null;
      if (typeof exportName !== "string") {
        return;
      }

      const right = expr.right;
      namedExportAssignments.push(exportName);

      if (
        j.Identifier.check(right) &&
        right.name === exportName
      ) {
        // Just export the existing binding
        const exportStmt = j.exportNamedDeclaration(null, [
          j.exportSpecifier.from({
            local: j.identifier(exportName),
            exported: j.identifier(exportName),
          }),
        ]);
        j(path).replaceWith(exportStmt);
      } else {
        // Define new const and export it
        const decl = j.variableDeclaration("const", [
          j.variableDeclarator(j.identifier(exportName), right),
        ]);

        const exportStmt = j.exportNamedDeclaration(null, [
          j.exportSpecifier.from({
            local: j.identifier(exportName),
            exported: j.identifier(exportName),
          }),
        ]);

        j(path).insertBefore(decl);
        j(path).insertAfter(exportStmt);
        j(path).remove();
      }
    });

  // Remove original require declarations
  toRemove.forEach((path) => j(path).remove());

  // Remove `module.exports` from `module.exports.call()`
  root.find(j.MemberExpression)
    .filter((path) => {
      const { node } = path;
      return (
        j.Identifier.check(node.object) &&
        node.object.name === "module" &&
        j.Identifier.check(node.property) &&
        node.property.name === "exports"
      );
    })
    .forEach((path) => {
      const nextProp = path.parentPath.node.property;
      if (j.Identifier.check(nextProp)) {
        j(path.parentPath).replaceWith(nextProp);
      }
    });

  root.find(j.CallExpression)
    .forEach((path) => {
      const { node } = path;

      // Remaining dynamic require('foo')
      if (
        j.Identifier.check(node.callee) &&
        node.callee.name === "require" &&
        node.arguments.length === 1 &&
        j.Literal.check(node.arguments[0])
      ) {
        const source = node.arguments[0].value as string;

        let importId;
        if (inlineRequires.has(source)) {
          importId = inlineRequires.get(source);
        } else {
          importId = getUniqueImportId("_mod");
          inlineRequires.set(source, importId);

          const importDecl = j.importDeclaration(
            [
              defaultLazy.includes(source)
                ? j.importDefaultSpecifier(importId)
                : j.importNamespaceSpecifier(importId),
            ],
            j.literal(getSource(source)),
          );
          requireDecls.push(importDecl);
        }

        j(path).replaceWith(importId);
      }
    });

  // Insert import declarations at the top
  if (requireDecls.length > 0) {
    const program = root.get().node.program;
    program.body = [...requireDecls, ...program.body];
  }

  // Insert destructuring replacements below imports
  if (destructurings.length > 0) {
    destructurings.forEach(({ node }) => {
      root.get().node.program.body.splice(requireDecls.length, 0, node);
    });
  }
  if (!hasDefaultExport && namedExportAssignments.length > 0) {
    const defaultExportObject = j.objectExpression(
      namedExportAssignments.map((name) =>
        j.objectProperty.from({
          key: j.identifier(name),
          value: j.identifier(name),
          shorthand: true,
        })
      ),
    );

    const exportDefault = j.exportDefaultDeclaration(defaultExportObject);
    root.get().node.program.body.push(exportDefault);
    hasDefaultExport = true;
  }

  const prelude =
    "// deno-lint-ignore-file\n// Copyright 2018-2026 the Deno authors. MIT license.\n\n";
  return prelude + root.toSource({ quote: "single" });
};

const upgrade = Deno.args.includes("--upgrade");

// Don't run if git status is dirty
const status = (await $`git status --porcelain`.text()).trim();
if (status) {
  console.error("Git status is dirty. Please commit or stash your changes.");
  Deno.exit(1);
}

const tag = "v" + version;
if (upgrade) {
  await $`rm -rf node`;
  await $`git clone --depth 1 --sparse --branch ${tag} --single-branch https://github.com/nodejs/node.git`;
  await $`git sparse-checkout add lib`.cwd("node");
}

const fromLib = new URL("./node/lib", import.meta.url).pathname;
const toLib = new URL("./polyfills", import.meta.url).pathname;
const root = new URL("../../", import.meta.url).pathname;

for (const glob of globs) {
  const sourcePath = path.join(fromLib, glob);
  const expand = expandGlobSync(sourcePath);

  for (const entry of expand) {
    if (ignore.includes(entry.name)) {
      console.log(`Ignoring ${entry.name}`);
      continue;
    }

    const sourcePath = entry.path;

    const code = await Deno.readTextFile(sourcePath);
    const output = transform({ path: sourcePath, source: code }, jscodeshift);

    const relativePath = path.relative(fromLib, sourcePath);
    const targetPath = path.join(toLib, relativePath);
    const targetDir = path.dirname(targetPath);
    await Deno.mkdir(targetDir, { recursive: true });
    await Deno.writeTextFile(targetPath, output);
    console.log(`${sourcePath} -> ${targetPath}`);
  }
}

await $`rm -rf node`;
await $`./tools/format.js`.cwd(root);
