import { walk } from "@std/fs";
import { debounce } from "@std/async/debounce";
import { parseArgs } from "@std/cli/parse-args";
import * as path from "@std/path";

import {
  type CompletedConfig,
  type Config,
  createFormatter,
  createParser,
  createProgram,
  SchemaGenerator,
} from "ts-json-schema-generator";

// @ ts-expect-error: no types
// import schemaWalker from "oas-schema-walker";
// import walkerCallback from "./schema/postWalker.js";

// import yfNumberTypeFormatter from "./schema/TypeFormatter/yfNumberTypeFormatter.ts";
// import yfReferenceTypeFormatter from "./schema/TypeFormatter/yfReferenceTypeFormatter.ts";
import yfFunctionIgnorer from "./schema/TypeFormatter/yfFunctionIgnorer.ts";

// TODO, we should also lstat this file and all it's imports!

const relevant = /@yf-schema/;

function createSchema(path: string, force = false, verbose = false) {
  const encoder = new TextEncoder();
  const outputPath = path.replace(/\.ts$/, ".schema.json");

  const inputStat = Deno.lstatSync(path);
  let outputStat: Deno.FileInfo | undefined;

  // Convenience for creating the file if it doesn't exist, so that import
  // won't fail on first time running ts-schema-gen on the file (later we'll
  // overwrite it with the actual generated schema)
  try {
    outputStat = Deno.lstatSync(outputPath);
  } catch (err) {
    if (!(err instanceof Deno.errors.NotFound)) {
      throw err;
    }
    Deno.writeFileSync(outputPath, encoder.encode("{}"));
  }

  if (!force && outputStat && inputStat.mtime! < outputStat.mtime!) {
    console.log("* " + path + ": skipping unchanged file.");
    return;
  }

  console.log("* " + path + ": creating schema...");

  const config: CompletedConfig = {
    path,
    tsconfig: "scripts/schema-tsconfig.json",
    type: "*",
    discriminatorType: "open-api",
    // skipTypeCheck: true,
    additionalProperties: false,
    jsDoc: "basic",
    expose: "export",
    topRef: true,
  } as Config as CompletedConfig;

  const formatter = createFormatter(
    config,
    (chainTypeFormatter, _circularReferenceTypeFormatter) => {
      chainTypeFormatter
        /*
        .addTypeFormatter(
          new yfReferenceTypeFormatter(
            circularReferenceTypeFormatter,
            config.encodeRefs ?? true,
          ),
        )
        .addTypeFormatter(new yfNumberTypeFormatter())
        */
        .addTypeFormatter(new yfFunctionIgnorer());
    },
  );

  let program: ReturnType<typeof createProgram>;
  try {
    program = createProgram(config);
  } catch (error) {
    if (error instanceof Error) {
      if (verbose) {
        const keys = Object.getOwnPropertyNames(error);
        for (const key of keys) {
          console.log(key, (error as unknown as Record<string, unknown>)[key]);
        }
      } else {
        console.log(
          "Error:",
          error.message,
          "Run with -v (verbose) for more details.",
        );
      }
    } else {
      console.log(error);
    }
    // TODO, only log this if in watch mode
    console.log(`(still watching ${path}...)`);
    return;
  }

  const parser = createParser(program, config);
  const generator = new SchemaGenerator(program, parser, formatter, config);

  let _schema: ReturnType<typeof generator.createSchema>;
  try {
    _schema = generator.createSchema(config.type);
  } catch (error) {
    if (error instanceof Error) {
      if (verbose) {
        const keys = Object.getOwnPropertyNames(error);
        for (const key of keys) {
          console.log(key, (error as unknown as Record<string, unknown>)[key]);
        }
      } else {
        console.log(
          "Error:",
          error.message,
          "Run with -v (verbose) for more details.",
        );
      }
    } else {
      console.log(error);
    }
    console.log(`(still watching ${path}...)`);
    return;
  }

  const schema = {
    $schema: _schema!.$schema,
    $comment: "DO NOT EDIT THIS FILE.  It is generated automatically " +
      "from typescript interfaces in the project.  To update, run " +
      "`deno task schema` (with optional `--watch`).  In VSCode, this " +
      "is run automatically for you on folder open.",
    ..._schema!,
  };

  /*
  // @ts-expect-error: no types
  for (const key of Object.keys(schema.definitions)) {
    // @ts-expect-error: no types
    schemaWalker.walkSchema(schema.definitions[key], {}, {}, walkerCallback);
  }
  */

  const schemaString = JSON.stringify(schema, null, 2);

  const encoded = encoder.encode(schemaString);
  Deno.writeFileSync(outputPath, encoded);
}

const NPM_DEPS = ["tough-cookie"];

let _denoDir: string | undefined;
function denoDir() {
  if (_denoDir) return _denoDir;
  const cmd = new Deno.Command("deno", { args: ["info"] });
  const stdout = new TextDecoder().decode(cmd.outputSync().stdout);
  // The extra .*'s are to match ANSI escape sequences
  _denoDir = stdout.match(/^.*DENO_DIR location:.* (.*)$/m)![1];
  return _denoDir;
}

function depsCheck() {
  try {
    Deno.statSync("node_modules");
  } catch {
    Deno.mkdirSync("node_modules");
  }

  const denoLock = JSON.parse(Deno.readTextFileSync(`deno.lock`));

  for (const name of NPM_DEPS) {
    const joinedPath = path.join("node_modules", name);

    let stat: Deno.FileInfo | null = null;
    try {
      stat = Deno.lstatSync(joinedPath);
    } catch {
      stat = null;
    }

    const specifier = denoLock.workspace.dependencies.find((dep: string) =>
      dep.startsWith(`npm:${name}@`)
    );
    const depVersion = denoLock.specifiers[specifier];

    if (stat) {
      if (stat.isSymlink) {
        const realPath = Deno.realPathSync(joinedPath);
        const linkedVersion = realPath.split("/").pop();
        if (linkedVersion !== depVersion) {
          console.log(
            "Updating node_modules/tough-cookie symlink from",
            linkedVersion,
            "to",
            depVersion,
          );
          Deno.removeSync(joinedPath);
          stat = null;
        }
      }
    }

    if (!stat) {
      console.log(
        "Symlinking `tough-cookie` from deno cache to `node_modules`.",
      );
      console.log(
        "(This is required by ts-json-schema-generator for cookie types in options)",
      );
      console.log();

      Deno.symlinkSync(
        path.join(
          denoDir(),
          "npm",
          "registry.npmjs.org",
          name,
          depVersion,
        ),
        path.join("node_modules", name),
      );
    }
  }
}

async function check(path: string, force = false, verbose = false) {
  const file = await Deno.readTextFile(path);
  if (relevant.test(file)) {
    createSchema(path, force, verbose);
  }
}

const debouncedCheck = debounce(check, 1000);

const flags = parseArgs(Deno.args, {
  boolean: ["watch", "force", "help", "verbose"],
  default: { watch: false, force: false, help: false, verbose: false },
  negatable: ["watch", "force", "help", "verbose"],
  alias: { w: "watch", f: "force", h: "help", v: "verbose" },
});

if (flags.help) {
  console.log(`
Usage
  $ deno task schema [options] [files...]

Options
  -h, --help    Show this help
  -w, --watch   Watch for changes
  -f, --force   Force update of all schemas
  -v, --verbose Show verbose output
`);
  Deno.exit();
}

depsCheck();

const files = flags._;
if (files.length) {
  for (const file of files) {
    // Always force update for explicitly specified files
    await check(file as string, true, /* flags.force */ flags.verbose);
  }
  Deno.exit();
}

console.log('Scanning project for .ts files containing "@yf-schema"...');
console.log();
for await (const entry of walk("src", { exts: [".ts"] })) {
  check(entry.path, flags.force, flags.verbose);
}
console.log();
console.log("Scan complete.");

if (flags.watch) {
  console.log();
  console.log("Watching `src` for changes (Ctrl-C to exit)...");
  console.log();
  const watcher = Deno.watchFs("src");

  /*
  Deno.addSignalListener("SIGINT", () => {
    // watcher.close();
    Deno.exit();
  });
  */

  (async () => {
    for await (const event of watcher) {
      if (event.kind === "modify") {
        debouncedCheck(event.paths[0]);
      }
    }
  })();
}
