import boxen from "boxen";
import chalk from "chalk";
import { Command, Option } from "commander";
import httpProxy from "http-proxy";
import path from "path";
import { performance } from "perf_hooks";
import { LOCALHOST_PORT } from "./codegen_templates/clientConfig";
import { getDevDeployment } from "./lib/api";
import { readProjectConfig } from "./lib/config";
import { oneoffContext } from "./lib/context";
import { checkAuthorization, performLogin } from "./lib/login";
import { PushOptions, runPush } from "./lib/push";
import { ensureProjectDirectory, formatDuration } from "./lib/utils";
import { FatalError, WatchContext, Watcher } from "./lib/watch";

export const dev = new Command("dev")
  .description(
    "Watch the local filesystem. When your Convex functions change, push them to your dev deployment and update generated code."
  )
  .option("-v, --verbose", "Show full listing of changes")
  .addOption(
    new Option(
      "--typecheck <mode>",
      `Check TypeScript files with \`tsc --noEmit\`.`
    )
      .choices(["enable", "try", "disable"])
      .default("try")
  )
  .addOption(new Option("--trace-events").hideHelp())
  .addOption(new Option("--once").hideHelp())
  .addOption(new Option("--admin-key <adminKey>").hideHelp())
  .addOption(new Option("--url <url>").hideHelp())
  .addOption(
    new Option("--codegen <mode>", "Regenerate code in `convex/_generated/`")
      .choices(["enable", "disable"])
      .default("enable")
  )
  .action(async cmdOptions => {
    const ctx = oneoffContext;

    if (!(await checkAuthorization(ctx))) {
      await performLogin(ctx);
    }

    await ensureProjectDirectory(ctx, true);

    const config = await readProjectConfig(oneoffContext);

    const projectSlug = config.projectConfig.project;
    const teamSlug = config.projectConfig.team;

    let devDeployment;
    if (!cmdOptions.url || !cmdOptions.adminKey) {
      devDeployment = await getDevDeployment(oneoffContext, {
        projectSlug,
        teamSlug,
      });
    }
    const adminKey = cmdOptions.adminKey ?? devDeployment?.adminKey;
    const url = cmdOptions.url ?? devDeployment?.url;
    const options: PushOptions = {
      adminKey,
      verbose: !!cmdOptions.verbose,
      dryRun: false,
      typecheck: cmdOptions.typecheck,
      debug: false,
      codegen: cmdOptions.codegen === "enable",
      deploymentType: "dev",
      url,
    };
    let watcher: Watcher | undefined;
    let numFailures = 0;

    httpProxy
      .createProxyServer({
        target: url,
        // Proxy WebSockets.
        ws: true,
        // Update the `Host` header to the target so that TLS works.
        changeOrigin: true,
      })
      .on("error", err => {
        console.log(
          `Network error connecting to dev deployment: ${err.message}`
        );
      })
      .listen(LOCALHOST_PORT);

    const boxedText =
      chalk.whiteBright.bold("Personal dev deployment ready!") +
      chalk.white(
        "\n\nKeep this command running to sync Convex functions when they change."
      );
    const boxenOptions = {
      align: "center",
      padding: 1,
      margin: 1,
      borderColor: "green",
      backgroundColor: "#555555",
    } as const;
    console.log(boxen(boxedText, boxenOptions));

    // eslint-disable-next-line no-constant-condition
    while (true) {
      console.log("Preparing Convex functions...");
      const start = performance.now();
      const ctx = new WatchContext(cmdOptions.traceEvents);

      // If the project or team slugs change, exit because we might be
      // creating a proxy to the wrong dev deployment.
      const config = await readProjectConfig(ctx);
      if (
        projectSlug !== config.projectConfig.project ||
        teamSlug !== config.projectConfig.team
      ) {
        console.log("Detected a change in your `convex.json`. Exiting...");
        return await ctx.fatalError(1, "fs");
      }

      try {
        await runPush(ctx, options);
        const end = performance.now();
        numFailures = 0;
        console.log(
          chalk.green(
            `Convex functions ready! (${formatDuration(end - start)})`
          )
        );
      } catch (e: any) {
        // Crash the app on unexpected errors.
        if (!(e instanceof FatalError) || !e.reason) {
          throw e;
        }
        // Retry after an exponential backoff if we hit a network error.
        if (e.reason === "network") {
          const delay = nextBackoff(numFailures);
          numFailures += 1;
          console.log(
            chalk.yellow(
              `Failed due to network error, retrying in ${formatDuration(
                delay
              )}...`
            )
          );
          await new Promise(resolve => setTimeout(resolve, delay));
          continue;
        }
        // Fall through if we had a filesystem-based error.
        console.assert(e.reason === "fs");
      }
      if (cmdOptions.once) {
        process.exit(0);
      }
      const observations = ctx.fs.finalize();
      if (observations === "invalidated") {
        console.log("Filesystem changed during push, retrying...");
        continue;
      }
      // Initialize the watcher if we haven't done it already. Chokidar expects to have a
      // nonempty watch set at initialization, so we can't do it before running our first
      // push.
      if (!watcher) {
        watcher = new Watcher(observations);
        await watcher.ready();
      }
      // Watch new directories if needed.
      watcher.update(observations);

      // Process events until we find one that overlaps with our previous observations.
      let anyChanges = false;
      do {
        await watcher.waitForEvent();
        for (const event of watcher.drainEvents()) {
          if (cmdOptions.traceEvents) {
            console.log(
              "Processing",
              event.name,
              path.relative("", event.absPath)
            );
          }
          const result = observations.overlaps(event);
          if (result.overlaps) {
            const relPath = path.relative("", event.absPath);
            if (cmdOptions.traceEvents) {
              console.log(`${relPath} ${result.reason}, rebuilding...`);
            }
            anyChanges = true;
            break;
          }
        }
      } while (!anyChanges);

      // Wait for the filesystem to quiesce before starting a new push. It's okay to
      // drop filesystem events at this stage since we're already committed to doing
      // a push and resubscribing based on that push's observations.
      let deadline = performance.now() + quiescenceDelay;
      // eslint-disable-next-line no-constant-condition
      while (true) {
        const now = performance.now();
        if (now >= deadline) {
          break;
        }
        const remaining = deadline - now;
        if (cmdOptions.traceEvents) {
          console.log(`Waiting for ${formatDuration(remaining)} to quiesce...`);
        }
        const remainingWait = new Promise<"timeout">(resolve =>
          setTimeout(() => resolve("timeout"), deadline - now)
        );
        const result = await Promise.race([
          remainingWait,
          watcher.waitForEvent().then<"newEvents">(() => "newEvents"),
        ]);
        if (result === "newEvents") {
          for (const event of watcher.drainEvents()) {
            const result = observations.overlaps(event);
            // Delay another `quiescenceDelay` since we had an overlapping event.
            if (result.overlaps) {
              if (cmdOptions.traceEvents) {
                console.log(
                  `Received an overlapping event at ${event.absPath}, delaying push.`
                );
              }
              deadline = performance.now() + quiescenceDelay;
            }
          }
        } else {
          console.assert(result === "timeout");
          // Let the check above `break` from the loop if we're past our deadlne.
        }
      }
    }
  });

const initialBackoff = 500;
const maxBackoff = 16000;
const quiescenceDelay = 500;

function nextBackoff(prevFailures: number): number {
  const baseBackoff = initialBackoff * Math.pow(2, prevFailures);
  const actualBackoff = Math.min(baseBackoff, maxBackoff);
  const jitter = actualBackoff * (Math.random() - 0.5);
  return actualBackoff + jitter;
}
