import assert from "node:assert";
import {mkdirSync, renameSync, unlinkSync, utimesSync, writeFileSync} from "node:fs";
import {basename, dirname, extname, join} from "node:path/posix";
import {InternSet, difference} from "d3-array";
import {temporaryDirectoryTask} from "tempy";
import {LoaderResolver} from "../src/loader.js";
import {resolvePath} from "../src/path.js";

describe("FileWatchers.of(loaders, path, names, callback)", () => {
  it(
    "watches a file",
    withTemporyWatcher("files.md", ["file-top.csv"], async (root, wait) => {
      touch(join(root, "file-top.csv"));
      assert.deepStrictEqual(await wait(), ["file-top.csv"]);
    })
  );
  it(
    "watches multiple files",
    withTemporyWatcher("files.md", ["files.md", "subsection/file-sub.csv"], async (root, wait) => {
      touch(join(root, "files.md"));
      touch(join(root, "subsection/file-sub.csv"));
      assert.deepStrictEqual(await wait(), ["files.md", "subsection/file-sub.csv"]);
    })
  );
  it(
    "watches a file generated by a data loader",
    withTemporyWatcher("simple.md", ["data.txt.js"], ["data.txt"], async (root, wait) => {
      touch(join(root, "data.txt.js"));
      assert.deepStrictEqual(await wait(), ["data.txt"]);
    })
  );
  it(
    "watches a file within a static archive",
    withTemporyWatcher("zip.md", ["static.zip"], ["static/file.txt"], async (root, wait) => {
      touch(join(root, "static.zip"));
      assert.deepStrictEqual(await wait(), ["static/file.txt"]);
    })
  );
  it(
    "watches a file within an archive created by a data loader",
    withTemporyWatcher("zip.md", ["dynamic.zip.sh"], ["dynamic/file.txt"], async (root, wait) => {
      touch(join(root, "dynamic.zip.sh"));
      assert.deepStrictEqual(await wait(), ["dynamic/file.txt"]);
    })
  );
  it(
    "deduplicates watched files",
    withTemporyWatcher("files.md", ["file-top.csv", "file-top.csv"], async (root, wait) => {
      touch(join(root, "file-top.csv"));
      assert.deepStrictEqual(await wait(), ["file-top.csv"]);
    })
  );
  it(
    "deduplicates watched files based on name, not normalized path",
    withTemporyWatcher("files.md", ["file-top.csv", "./file-top.csv"], async (root, wait) => {
      touch(join(root, "file-top.csv"));
      assert.deepStrictEqual(await wait(), ["./file-top.csv", "file-top.csv"]);
    })
  );
  it(
    "resolves relative paths",
    withTemporyWatcher("subsection/subfiles.md", ["./file-sub.csv", "../file-top.csv"], async (root, wait) => {
      touch(join(root, "file-top.csv"));
      assert.deepStrictEqual(await wait(), ["../file-top.csv"]);
    })
  );
  it(
    "resolves absolute paths",
    withTemporyWatcher("subsection/subfiles.md", ["/file-top.csv"], async (root, wait) => {
      touch(join(root, "file-top.csv"));
      assert.deepStrictEqual(await wait(), ["/file-top.csv"]);
    })
  );
  it(
    "ignores missing files",
    withTemporyWatcher("files.md", ["file-top.csv"], ["does-not-exist.csv", "file-top.csv"], async (root, wait) => {
      touch(join(root, "file-top.csv"));
      assert.deepStrictEqual(await wait(), ["file-top.csv"]);
    })
  );
  it(
    "ignores changes that don’t affect the modification time",
    withTemporyWatcher("files.md", ["file-top.csv"], async (root, wait) => {
      const then = new Date();
      touch(join(root, "file-top.csv"), then);
      assert.deepStrictEqual(await wait(), ["file-top.csv"]);
      touch(join(root, "file-top.csv"), then);
      assert.deepStrictEqual(await wait(10), []);
    })
  );
  it(
    "ignores changes to empty files",
    withTemporyWatcher("comment.md", ["empty.js"], async (root, wait) => {
      touch(join(root, "empty.js"));
      assert.deepStrictEqual(await wait(10), []);
    })
  );
  it(
    "handles a file being renamed",
    withTemporyWatcher("files.md", ["temp1.csv"], async (root, wait) => {
      // First rename the file, while writing a new file to the same place.
      renameSync(join(root, "temp1.csv"), join(root, "temp2.csv"));
      writeFileSync(join(root, "temp1.csv"), "hello 2", "utf-8");
      assert.deepStrictEqual(await wait(), ["temp1.csv"]);

      // Then test that writing to the original location watches the new file.
      writeFileSync(join(root, "temp1.csv"), "hello 3", "utf-8");
      assert.deepStrictEqual(await wait(), ["temp1.csv"]);
    })
  );
  it(
    "handles a file being renamed and removed",
    withTemporyWatcher("files.md", ["file-top.csv", "temp3.csv"], async (root, wait) => {
      // First delete the temp file. We don’t care if this is reported as a change or not.
      unlinkSync(join(root, "temp3.csv"));
      await pause();

      // Then touch a different file to make sure the watcher is still alive.
      touch(join(root, "file-top.csv"));
      assert.deepStrictEqual(difference(await wait(), ["temp3.csv"]), new InternSet(["file-top.csv"]));
    })
  );
});

function withTemporyWatcher(
  path: string,
  paths: string[], // paths to create and watch
  run: (root: string, wait: (delay?: number) => Promise<string[]>) => Promise<void>
): () => Promise<void>;
function withTemporyWatcher(
  path: string,
  paths: string[], // paths to create
  watchPaths: string[], // paths to watch
  run: (root: string, wait: (delay?: number) => Promise<string[]>) => Promise<void>
): () => Promise<void>;
function withTemporyWatcher(...args: any): () => Promise<void> {
  const [path, paths, watchPaths = paths, run]: [
    path: string,
    paths: string[],
    watchPaths: string[],
    run: (root: string, wait: (delay?: number) => Promise<string[]>) => Promise<void>
  ] = arguments.length > 3 ? args : [args[0], args[1], , args[2]];
  return () =>
    temporaryDirectoryTask(async (root) => {
      let watches = new Set<string>();
      let resume: ((value: string[]) => void) | null = null;

      const wait = (delay?: number) => {
        if (resume) throw new Error("already waiting");
        const promise = new Promise<string[]>((y) => (resume = y));
        if (delay == null) return promise;
        const timeout = new Promise<string[]>((y) => setTimeout(() => y([...watches].sort()), delay));
        return Promise.race([promise, timeout]);
      };

      const watch = (name: string) => {
        watches.add(name);
        const r = resume;
        if (r == null) return;
        resume = null;
        setTimeout(() => (r([...watches].sort()), (watches = new Set<string>())), 10);
      };

      for (const p of [path, ...paths.map((p) => resolvePath(path, p))]) {
        mkdirSync(dirname(join(root, p)), {recursive: true});
        writeFileSync(join(root, p), basename(p, extname(p)) === "empty" ? "" : p);
      }

      const watcher = await new LoaderResolver({root}).watchFiles(path, watchPaths, watch);

      await pause();
      try {
        return await run(root, wait);
      } finally {
        watcher.close();
      }
    });
}

async function pause(): Promise<void> {
  await new Promise((resolve) => setTimeout(resolve, 10));
}

function touch(path: string, date = new Date()): void {
  utimesSync(path, date, date);
}
