// Copyright 2018-2026 the Deno authors. MIT license.

import {
  assert,
  assertEquals,
  assertFalse,
  assertMatch,
  assertRejects,
  assertStringIncludes,
  unindent,
} from "./test_util.ts";

import { basename, join, toFileUrl } from "@std/path";

class TempDir implements AsyncDisposable, Disposable {
  #path: string;
  constructor(options?: Deno.MakeTempOptions) {
    // Resolve symlinks (e.g. macOS `/var` -> `/private/var`) up front. The
    // bundler canonicalizes module paths, and Deno matches read/write grants
    // by literal path, so tests that scope permissions to this directory must
    // grant the canonical path the bundler will actually check against.
    this.#path = Deno.realPathSync(Deno.makeTempDirSync(options));
  }

  async [Symbol.asyncDispose]() {
    await Deno.remove(this.#path, { recursive: true });
  }

  [Symbol.dispose]() {
    Deno.removeSync(this.#path, { recursive: true });
  }

  get path() {
    return this.#path;
  }

  join(path: string) {
    return join(this.#path, path);
  }
}

class TempFile implements AsyncDisposable, Disposable {
  #path: string;
  constructor(options?: Deno.MakeTempOptions) {
    this.#path = Deno.makeTempFileSync(options);
  }

  async [Symbol.asyncDispose]() {
    await Deno.remove(this.#path);
  }

  [Symbol.dispose]() {
    Deno.removeSync(this.#path);
  }

  get path() {
    return this.#path;
  }
}

async function assertPathNotFound(path: string) {
  await assertRejects(() => Deno.lstat(path), Deno.errors.NotFound);
}

Deno.test("bundle: basic in-memory bundle succeeds and returns content", async () => {
  using dir = new TempDir();
  const entry = dir.join("index.ts");
  const dep = dir.join("mod.ts");

  await Deno.writeTextFile(
    dep,
    [
      "export function greet(name: string) {",
      "  return `hello ${name}`;",
      "}",
    ].join("\n"),
  );
  await Deno.writeTextFile(
    entry,
    [
      "import { greet } from './mod.ts';",
      "console.log(greet('world'));",
    ].join("\n"),
  );

  const result = await Deno.bundle({
    entrypoints: [entry],
    // keep readable to assert on content
    minify: false,
    write: false,
  });

  assertEquals(result.success, true);
  assertEquals(result.errors.length, 0);
  assert(Array.isArray(result.warnings));
  assert(result.outputFiles !== undefined);
  const withContent = result.outputFiles!.filter((f) => !!f.contents);
  assert(withContent.length >= 1);
  const content = withContent[0].text();
  // should contain the string literal from the source
  assertStringIncludes(content, "hello ");
  // stripped of TS types
  assertFalse(content.includes(": string"));
});

Deno.test("bundle: write to outputDir omits outputFiles and writes files", async () => {
  using dir = new TempDir();
  const srcDir = dir.join("src");
  const outDir = dir.join("dist");
  await Deno.mkdir(srcDir, { recursive: true });
  const entry = join(srcDir, "main.ts");
  const dep = join(srcDir, "util.ts");

  await Deno.writeTextFile(
    dep,
    unindent`
      export const msg: string = 'Hello bundle write';
      export function upper(s: string) { return s.toUpperCase(); }
    `,
  );
  await Deno.writeTextFile(
    entry,
    unindent`
      import { msg, upper } from './util.ts';
      console.log(upper(msg));
    `,
  );

  const result = await Deno.bundle({
    entrypoints: [entry],
    outputDir: outDir,
    // default write is true when outputDir/outputPath is set
    // but be explicit here
    write: true,
    minify: false,
  });

  assertEquals(result.success, true);
  assertEquals(result.errors.length, 0);
  // when writing, the provider returns `null` for outputFiles currently
  assert(result.outputFiles == null);

  // verify a JS file was written to the output directory
  const files = [] as string[];
  for await (const e of Deno.readDir(outDir)) {
    if (e.isFile && e.name.endsWith(".js")) files.push(e.name);
  }
  assert(files.length >= 1);
  // read first file and check expected content present
  const outJsPath = join(outDir, files[0]);
  const outContent = await Deno.readTextFile(outJsPath);
  assertStringIncludes(outContent, "Hello bundle write");
});

Deno.test("bundle: write requires caller permission", async () => {
  using dir = new TempDir();
  const entry = dir.join("main.ts");
  const runner = dir.join("runner.ts");
  const blockedDir = dir.join("blocked");
  const output = join(blockedDir, "dist", "main.js");

  await Deno.writeTextFile(entry, "export const value = 42;\n");
  await Deno.writeTextFile(
    runner,
    unindent`
      await Deno.bundle({
        entrypoints: [${JSON.stringify(entry)}],
        outputPath: ${JSON.stringify(output)},
        write: true,
      });
    `,
  );

  const { success, stderr } = await new Deno.Command(Deno.execPath(), {
    args: [
      "run",
      "--no-config",
      "--no-lock",
      "--no-prompt",
      "--quiet",
      "--unstable-bundle",
      `--allow-read=${dir.path}`,
      runner,
    ],
    stderr: "piped",
  }).output();

  assertFalse(success);
  const stderrText = new TextDecoder().decode(stderr);
  assertStringIncludes(stderrText, "Requires write access");
  assertStringIncludes(stderrText, "--allow-write");
  await assertPathNotFound(blockedDir);
  await assertPathNotFound(output);
});

Deno.test("bundle: module read requires caller permission", async () => {
  using dir = new TempDir();
  const secret = dir.join("secret.ts");
  const runner = dir.join("runner.ts");

  await Deno.writeTextFile(
    secret,
    'export const TOKEN = "super-secret-value";\n',
  );
  await Deno.writeTextFile(
    runner,
    unindent`
      const result = await Deno.bundle({
        entrypoints: [${JSON.stringify(secret)}],
        write: false,
      });
      console.log(JSON.stringify({
        success: result.success,
        outputs: result.outputFiles?.length ?? 0,
        errors: result.errors.map((error) => error.text),
      }));
    `,
  );

  // Read access to the runner only, so reading the bundled module is denied
  // before graph preparation reads its source.
  const { success, stdout } = await new Deno.Command(Deno.execPath(), {
    args: [
      "run",
      "--no-config",
      "--no-lock",
      "--no-prompt",
      "--quiet",
      "--unstable-bundle",
      `--allow-read=${runner}`,
      runner,
    ],
    stdout: "piped",
  }).output();

  assert(success);
  const result = JSON.parse(new TextDecoder().decode(stdout)) as {
    success: boolean;
    outputs: number;
    errors: string[];
  };
  assertFalse(result.success);
  assertEquals(result.outputs, 0);
  assert(
    result.errors.some((error) => error.includes("Requires read access")),
  );
  assert(
    result.errors.some((error) => error.includes("--allow-read")),
    JSON.stringify(result.errors),
  );
  assertFalse(
    result.errors.some((error) => error.includes("import()")),
    JSON.stringify(result.errors),
  );
});

Deno.test("bundle: permitted path alias preserves module identity", async () => {
  using dir = new TempDir();
  const realDir = dir.join("real");
  const aliasDir = dir.join("alias");
  const entry = join(realDir, "entry.ts");
  const aliasEntry = join(aliasDir, "entry.ts");
  const runner = dir.join("runner.ts");

  await Deno.mkdir(realDir);
  await Deno.writeTextFile(entry, "export const value = 42;\n");
  await Deno.symlink(realDir, aliasDir, {
    type: Deno.build.os === "windows" ? "junction" : "dir",
  });
  await Deno.writeTextFile(
    runner,
    unindent`
      const result = await Deno.bundle({
        entrypoints: [${JSON.stringify(aliasEntry)}],
        write: false,
      });
      console.log(JSON.stringify({
        success: result.success,
        errors: result.errors.map((error) => error.text),
      }));
    `,
  );

  const { success, stdout, stderr } = await new Deno.Command(
    Deno.execPath(),
    {
      args: [
        "run",
        "--no-config",
        "--no-lock",
        "--no-prompt",
        "--quiet",
        "--unstable-bundle",
        `--allow-read=${runner},${aliasEntry}`,
        runner,
      ],
      stdout: "piped",
      stderr: "piped",
    },
  ).output();

  const stderrText = new TextDecoder().decode(stderr);
  assert(success, stderrText);
  const result = JSON.parse(new TextDecoder().decode(stdout)) as {
    success: boolean;
    errors: string[];
  };
  assert(result.success, JSON.stringify(result.errors));
});

Deno.test("bundle: html entrypoint read requires caller permission", async () => {
  using dir = new TempDir();
  const secretDir = dir.join("secret");
  const secretHtml = join(secretDir, "index.html");
  const outDir = dir.join("dist");
  const runner = dir.join("runner.ts");

  await Deno.mkdir(secretDir, { recursive: true });
  await Deno.writeTextFile(
    secretHtml,
    unindent`
      <html>
        <body><script type="module" src="./main.ts"></script></body>
      </html>
    `,
  );
  await Deno.writeTextFile(
    join(secretDir, "main.ts"),
    "console.log('secret');\n",
  );
  await Deno.writeTextFile(
    runner,
    unindent`
      await Deno.bundle({
        entrypoints: [${JSON.stringify(toFileUrl(secretHtml).href)}],
        outputDir: ${JSON.stringify(outDir)},
        write: false,
      });
    `,
  );

  // Read access to the runner only, so loading the HTML entrypoint is denied.
  const { success, stderr } = await new Deno.Command(Deno.execPath(), {
    args: [
      "run",
      "--no-config",
      "--no-lock",
      "--no-prompt",
      "--quiet",
      "--unstable-bundle",
      `--allow-read=${runner}`,
      runner,
    ],
    stderr: "piped",
  }).output();

  assertFalse(success);
  const stderrText = new TextDecoder().decode(stderr);
  assertStringIncludes(stderrText, "Requires read access");
  assertStringIncludes(stderrText, "--allow-read");
  await assertPathNotFound(outDir);
});

Deno.test("bundle: worker permissions override parent permissions", async () => {
  using dir = new TempDir();
  const sourceDir = dir.join("source");
  const secretDir = dir.join("secret");
  const blockedDir = dir.join("blocked");
  const entry = join(sourceDir, "main.ts");
  const secretHtml = join(secretDir, "secret.html");
  const output = join(blockedDir, "main.js");
  const runner = dir.join("worker_runner.ts");

  await Deno.mkdir(sourceDir, { recursive: true });
  await Deno.mkdir(secretDir, { recursive: true });
  await Deno.writeTextFile(entry, "export const value = 42;\n");
  await Deno.writeTextFile(secretHtml, "<p>blocked</p>\n");

  await Deno.writeTextFile(
    runner,
    unindent`
      const workerSource = ${
      JSON.stringify(unindent`
        self.onmessage = async ({ data }) => {
          const result = {};

          try {
            await Deno.bundle({
              entrypoints: [data.secretHtml],
              outputDir: data.outputDir,
              write: false,
            });
            result.htmlRead = "allowed";
          } catch (err) {
            result.htmlRead = String(err);
          }

          try {
            await Deno.bundle({
              entrypoints: [data.entry],
              outputPath: data.output,
              write: true,
            });
            result.write = "allowed";
          } catch (err) {
            result.write = String(err);
          }

          self.postMessage(result);
        };
      `)
    };
      const workerUrl = URL.createObjectURL(
        new Blob([workerSource], { type: "text/javascript" }),
      );
      const worker = new Worker(workerUrl, {
        type: "module",
        deno: {
          permissions: {
            read: [${JSON.stringify(sourceDir)}],
            write: false,
          },
        },
      });
      const result = await new Promise((resolve, reject) => {
        worker.onmessage = (event) => resolve(event.data);
        worker.onerror = (event) => reject(event.error ?? event.message);
        worker.postMessage({
          entry: ${JSON.stringify(entry)},
          secretHtml: ${JSON.stringify(toFileUrl(secretHtml).href)},
          outputDir: ${JSON.stringify(blockedDir)},
          output: ${JSON.stringify(output)},
        });
      });
      worker.terminate();
      console.log(JSON.stringify(result));
    `,
  );

  const { success, stdout, stderr } = await new Deno.Command(
    Deno.execPath(),
    {
      args: [
        "run",
        "--no-config",
        "--no-lock",
        "--no-prompt",
        "--quiet",
        "--unstable-bundle",
        "--unstable-worker-options",
        `--allow-read=${dir.path}`,
        `--allow-write=${dir.path}`,
        runner,
      ],
      stdout: "piped",
      stderr: "piped",
    },
  ).output();

  const stdoutText = new TextDecoder().decode(stdout);
  const stderrText = new TextDecoder().decode(stderr);
  assert(success, stderrText);
  const result = JSON.parse(stdoutText) as {
    htmlRead: string;
    write: string;
  };
  assertStringIncludes(result.htmlRead, "Requires read access");
  assertStringIncludes(result.write, "Requires write access");
  await assertPathNotFound(blockedDir);
  await assertPathNotFound(output);
});

Deno.test("bundle: minify produces smaller output", async () => {
  using dir = new TempDir();
  const entry = dir.join("index.ts");
  const dep = dir.join("calc.ts");

  await Deno.writeTextFile(
    dep,
    unindent`
      export function add(a: number, b: number) {
        /* lots of spacing and comments to be minified */
        const sum = a + b;  // trailing comment
        return sum;
      }
    `,
  );
  await Deno.writeTextFile(
    entry,
    unindent`
      import { add } from './calc.ts';
      console.log(add(  1,   2));
    `,
  );

  const normal = await Deno.bundle({
    entrypoints: [entry],
    minify: false,
    write: false,
  });
  assertEquals(normal.success, true);
  const normalJs = normal.outputFiles!.find((f) => !!f.contents)!;

  const minified = await Deno.bundle({
    entrypoints: [entry],
    minify: true,
    write: false,
  });
  assertEquals(minified.success, true);
  const minJs = minified.outputFiles!.find((f) => !!f.contents)!;

  assert(minJs.text().length < normalJs.text().length);
});

Deno.test("bundle: keep-names preserves names", async () => {
  using dir = new TempDir();
  const entry = dir.join("index.ts");

  await Deno.writeTextFile(
    entry,
    unindent`
      export function add(a: number, b: number) {
        /* lots of spacing and comments to be minified */
        const sum = a + b;  // trailing comment
        return sum;
      }
    `,
  );

  const minifiedWithNames = await Deno.bundle({
    entrypoints: [entry],
    minify: true,
    keepNames: true,
    write: false,
  });
  assertEquals(minifiedWithNames.success, true);
  const minJs = minifiedWithNames.outputFiles!.find((f) => !!f.contents)!;

  assertMatch(minJs.text(), /export\s*\{[^}]*\badd\b[^}]*\}/);
});

Deno.test("bundle: code splitting with multiple entrypoints", async () => {
  using dir = new TempDir();
  const shared = dir.join("shared.ts");
  const a = dir.join("a.ts");
  const b = dir.join("b.ts");

  await Deno.writeTextFile(
    shared,
    unindent`
    export const shared = 'shared chunk';
  `,
  );
  await Deno.writeTextFile(
    a,
    unindent`
      import { shared } from './shared.ts';
      console.log('a', shared);
    `,
  );
  await Deno.writeTextFile(
    b,
    unindent`
      import { shared } from './shared.ts';
      console.log('b', shared);
    `,
  );

  const outDir = dir.join("dist");
  const result = await Deno.bundle({
    entrypoints: [a, b],
    codeSplitting: true,
    // esbuild requires an output directory when splitting
    outputDir: outDir,
    write: false,
    minify: false,
  });

  assertEquals(result.success, true);
  assert(result.outputFiles !== undefined);
  const jsFiles = result.outputFiles!.filter((f) => !!f.contents);
  // 2 entries + at least 1 shared chunk
  assert(jsFiles.length >= 3);
});

Deno.test("bundle: inline sourcemap is present", async () => {
  using dir = new TempDir();
  const entry = dir.join("index.ts");
  await Deno.writeTextFile(entry, "export const x = 1;\n");

  const result = await Deno.bundle({
    entrypoints: [entry],
    sourcemap: "inline",
    write: false,
    minify: false,
  });

  assertEquals(result.success, true);
  const js = result.outputFiles!.find((f) => !!f.contents)!;
  assertStringIncludes(
    js.text(),
    "sourceMappingURL=data:application/json;base64",
  );
});

Deno.test("bundle: missing entrypoint rejects", async () => {
  using dir = new TempDir();
  const missing = dir.join("does_not_exist.ts");

  let threw = false;
  try {
    await Deno.bundle({
      entrypoints: [missing],
      write: false,
    });
  } catch (_e) {
    threw = true;
  }
  assert(threw);
});

Deno.test("bundle: returns errors for unresolved import", async () => {
  using dir = new TempDir();
  const entry = dir.join("main.ts");
  // entry exists, but imports a non-existent module
  await Deno.writeTextFile(
    entry,
    unindent`
      import './missing.ts';
      export const value = 42;
    `,
  );

  const result = await Deno.bundle({
    entrypoints: [entry],
    write: false,
    minify: false,
  });

  assertEquals(result.success, false);
  assert(result.errors.length > 0);
  // should reference the missing import path in one of the error messages
  const texts = result.errors.map((e) => e.text).join("\n");
  assertStringIncludes(texts, "missing.ts");
});

// deno-lint-ignore no-explicit-any
async function evalEsmString(code: string): Promise<any> {
  await using file = new TempFile({ suffix: ".js" });
  Deno.writeTextFileSync(file.path, code);
  return await import(toFileUrl(file.path).toString());
}

Deno.test("bundle: replaces require shim when platform is deno", async () => {
  using dir = new TempDir();
  const entry = dir.join("index.cjs");
  const input = unindent`
    const sep = require("node:path").sep;
    module.exports = ["good", sep.length];
  `;
  await Deno.writeTextFile(entry, input);

  const result = await Deno.bundle({
    entrypoints: [entry],
    platform: "deno",
    write: false,
  });

  assertEquals(result.success, true);
  const js = result.outputFiles!.find((f) => !!f.contents)!;

  const output = await evalEsmString(js.text());
  assertEquals(output.default, ["good", 1]);
});

Deno.test("bundle: html works", async () => {
  using dir = new TempDir();
  const entry = dir.join("index.html");
  const input = unindent`
    <html>
      <body>
        <script src="./index.ts"></script>
      </body>
    </html>
  `;
  const script = dir.join("index.ts");
  const scriptInput = unindent`
    console.log("hello");
    document.body.innerHTML = "hello";
  `;
  const outDir = dir.join("dist");

  await Deno.writeTextFile(entry, input);
  await Deno.writeTextFile(script, scriptInput);

  const result = await Deno.bundle({
    entrypoints: [entry],
    outputDir: outDir,
    write: false,
  });

  const js = result.outputFiles!.find((f) =>
    !!f.contents && f.path.endsWith(".js")
  );
  if (!js) {
    throw new Error("No JS file found");
  }

  const html = result.outputFiles!.find((f) =>
    !!f.contents && f.path.endsWith(".html")
  )!;
  if (!html) {
    throw new Error("No HTML file found");
  }

  assert(result.success);
  assertEquals(result.errors.length, 0);
  assertEquals(result.outputFiles!.length, 2);

  const jsFileName = basename(js.path);

  assertStringIncludes(html.text(), `src="./${jsFileName}`);

  assertStringIncludes(js.text(), "innerHTML");
  assertStringIncludes(js.text(), "hello");
});
