import chalk from "chalk";
import { Command, Option } from "@commander-js/extra-typings";
import path from "path";
import { performance } from "perf_hooks";
import {
  OneoffCtx,
  logFinishedStep,
  logMessage,
  logVerbose,
  logWarning,
  oneoffContext,
  showSpinner,
  showSpinnerIfSlow,
  stopSpinner,
} from "../bundler/context.js";
import { deploymentCredentialsOrConfigure } from "./configure.js";
import { checkAuthorization, performLogin } from "./lib/login.js";
import { PushOptions } from "./lib/push.js";
import {
  formatDuration,
  getCurrentTimeString,
  waitForever,
  waitUntilCalled,
} from "./lib/utils/utils.js";
import { Crash, WatchContext, Watcher } from "./lib/watch.js";
import { watchLogs } from "./lib/logs.js";
import { subscribe } from "./lib/run.js";
import { Value } from "../values/index.js";
import { usageStateWarning } from "./lib/usage.js";
import { runPush } from "./lib/components.js";

export const dev = new Command("dev")
  .summary("Develop against a dev deployment, watching for changes")
  .description(
    "Develop against a dev deployment, watching for changes\n\n" +
      "  1. Configures a new or existing project (if needed)\n" +
      "  2. Updates generated types and pushes code to the configured dev deployment\n" +
      "  3. Runs the provided function (if `--run` is used)\n" +
      "  4. Watches for file changes, and repeats step 2\n",
  )
  .option("-v, --verbose", "Show full listing of changes")
  .addOption(
    new Option(
      "--typecheck <mode>",
      `Check TypeScript files with \`tsc --noEmit\`.`,
    )
      .choices(["enable", "try", "disable"] as const)
      .default("try" as const),
  )
  .addOption(
    new Option("--codegen <mode>", "Regenerate code in `convex/_generated/`")
      .choices(["enable", "disable"] as const)
      .default("enable" as const),
  )
  .addOption(
    new Option(
      "--configure [choice]",
      "Ignore existing configuration and configure new or existing project",
    ).choices(["new", "existing"] as const),
  )
  .option("--team <team_slug>", "The team you'd like to use for this project")
  .option(
    "--project <project_slug>",
    "The name of the project you'd like to configure",
  )
  .option(
    "--once",
    "Execute only the first 3 steps, stop on any failure",
    false,
  )
  .option(
    "--until-success",
    "Execute only the first 3 steps, on failure watch for local and remote changes and retry steps 2 and 3",
    false,
  )
  .option(
    "--run <functionName>",
    "The identifier of the function to run in step 3, " +
      "like `init` or `dir/file:myFunction`",
  )
  .addOption(
    new Option(
      "--prod",
      "Develop live against this project's production deployment.",
    )
      .default(false)
      .hideHelp(),
  )
  .addOption(
    new Option(
      "--tail-logs",
      "Tail this project's Convex logs in this terminal.",
    ),
  )
  .addOption(new Option("--trace-events").default(false).hideHelp())
  .addOption(new Option("--verbose").default(false).hideHelp())
  .addOption(new Option("--admin-key <adminKey>").hideHelp())
  .addOption(new Option("--url <url>").hideHelp())
  .addOption(new Option("--debug-bundle-path <path>").hideHelp())
  // Options for testing
  .addOption(new Option("--override-auth-url <url>").hideHelp())
  .addOption(new Option("--override-auth-client <id>").hideHelp())
  .addOption(new Option("--override-auth-username <username>").hideHelp())
  .addOption(new Option("--override-auth-password <password>").hideHelp())
  .addOption(
    new Option("--local", "Develop live against a locally running backend.")
      .default(false)
      .conflicts(["--prod", "--url", "--admin-key"])
      .hideHelp(),
  )
  .addOption(new Option("--local-cloud-port <port>").hideHelp())
  .addOption(new Option("--local-site-port <port>").hideHelp())
  .addOption(new Option("--local-backend-version <version>").hideHelp())
  .addOption(new Option("--local-force-upgrade").default(false).hideHelp())
  .addOption(new Option("--live-component-sources").hideHelp())
  .showHelpAfterError()
  .action(async (cmdOptions) => {
    const ctx = oneoffContext();
    process.on("SIGINT", async () => {
      logVerbose(ctx, "Received SIGINT, cleaning up...");
      await ctx.flushAndExit(-2);
    });

    if (cmdOptions.debugBundlePath !== undefined && !cmdOptions.once) {
      return await ctx.crash({
        exitCode: 1,
        errorType: "fatal",
        printedMessage: "`--debug-bundle-path` can only be used with `--once`.",
      });
    }

    const localOptions: {
      ports?: { cloud: number; site: number };
      backendVersion?: string | undefined;
      forceUpgrade: boolean;
    } = { forceUpgrade: false };
    if (!cmdOptions.local) {
      if (
        cmdOptions.localCloudPort !== undefined ||
        cmdOptions.localSitePort !== undefined ||
        cmdOptions.localBackendVersion !== undefined ||
        cmdOptions.localForceUpgrade === true
      ) {
        return await ctx.crash({
          exitCode: 1,
          errorType: "fatal",
          printedMessage:
            "`--local-*` options can only be used with `--local`.",
        });
      }
    } else {
      if (cmdOptions.localCloudPort !== undefined) {
        if (cmdOptions.localSitePort === undefined) {
          return await ctx.crash({
            exitCode: 1,
            errorType: "fatal",
            printedMessage:
              "`--local-cloud-port` requires `--local-site-port` to be set.",
          });
        }
        localOptions["ports"] = {
          cloud: parseInt(cmdOptions.localCloudPort),
          site: parseInt(cmdOptions.localSitePort),
        };
      }
      localOptions["backendVersion"] = cmdOptions.localBackendVersion;
      localOptions["forceUpgrade"] = cmdOptions.localForceUpgrade;
    }

    if (!cmdOptions.url || !cmdOptions.adminKey) {
      if (!(await checkAuthorization(ctx, false))) {
        await performLogin(ctx, cmdOptions);
      }
    }

    const configure =
      cmdOptions.configure === true ? "ask" : cmdOptions.configure ?? null;
    const credentials = await deploymentCredentialsOrConfigure(ctx, configure, {
      ...cmdOptions,
      localOptions,
    });

    await usageStateWarning(ctx);

    const promises = [];
    if (cmdOptions.tailLogs) {
      promises.push(
        watchLogs(ctx, credentials.url, credentials.adminKey, "stderr"),
      );
    }
    promises.push(
      watchAndPush(
        ctx,
        {
          ...credentials,
          verbose: !!cmdOptions.verbose,
          dryRun: false,
          typecheck: cmdOptions.typecheck,
          debug: false,
          debugBundlePath: cmdOptions.debugBundlePath,
          codegen: cmdOptions.codegen === "enable",
          liveComponentSources: !!cmdOptions.liveComponentSources,
        },
        cmdOptions,
      ),
    );
    await Promise.race(promises);
    await ctx.flushAndExit(0);
  });

export async function watchAndPush(
  outerCtx: OneoffCtx,
  options: PushOptions,
  cmdOptions: {
    once: boolean;
    untilSuccess: boolean;
    traceEvents: boolean;
  },
) {
  const watch: { watcher: Watcher | undefined } = { watcher: undefined };
  let numFailures = 0;
  let pushed = false;
  let tableNameTriggeringRetry;
  let shouldRetryOnDeploymentEnvVarChange;

  while (true) {
    const start = performance.now();
    tableNameTriggeringRetry = null;
    shouldRetryOnDeploymentEnvVarChange = false;
    const ctx = new WatchContext(cmdOptions.traceEvents);
    showSpinner(ctx, "Preparing Convex functions...");
    try {
      await runPush(ctx, options);
      const end = performance.now();
      numFailures = 0;
      logFinishedStep(
        ctx,
        `${getCurrentTimeString()} Convex functions ready! (${formatDuration(
          end - start,
        )})`,
      );
      pushed = true;
    } catch (e: any) {
      // Crash the app on unexpected errors.
      if (!(e instanceof Crash) || !e.errorType) {
        // eslint-disable-next-line no-restricted-syntax
        throw e;
      }
      if (e.errorType === "fatal") {
        break;
      }
      // Retry after an exponential backoff if we hit a transient error.
      if (e.errorType === "transient") {
        const delay = nextBackoff(numFailures);
        numFailures += 1;
        logWarning(
          ctx,
          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.errorType === "invalid filesystem data" ||
          e.errorType === "invalid filesystem or env vars" ||
          e.errorType["invalid filesystem or db data"] !== undefined,
      );
      if (e.errorType === "invalid filesystem or env vars") {
        shouldRetryOnDeploymentEnvVarChange = true;
      } else if (
        e.errorType !== "invalid filesystem data" &&
        e.errorType["invalid filesystem or db data"] !== undefined
      ) {
        tableNameTriggeringRetry = e.errorType["invalid filesystem or db data"];
      }
      if (cmdOptions.once) {
        await outerCtx.flushAndExit(1, e.errorType);
      }
      // Make sure that we don't spin if this push failed
      // in any edge cases that didn't call `logFailure`
      // before throwing.
      stopSpinner(ctx);
    }
    if (cmdOptions.once) {
      return;
    }
    if (pushed && cmdOptions.untilSuccess) {
      return;
    }
    const fileSystemWatch = getFileSystemWatch(ctx, watch, cmdOptions);
    const tableWatch = getTableWatch(
      ctx,
      options,
      tableNameTriggeringRetry?.tableName ?? null,
      tableNameTriggeringRetry?.componentPath,
    );
    const envVarWatch = getDeplymentEnvVarWatch(
      ctx,
      options,
      shouldRetryOnDeploymentEnvVarChange,
    );
    await Promise.race([
      fileSystemWatch.watch(),
      tableWatch.watch(),
      envVarWatch.watch(),
    ]);
    fileSystemWatch.stop();
    void tableWatch.stop();
    void envVarWatch.stop();
  }
}

function getTableWatch(
  ctx: WatchContext,
  credentials: {
    url: string;
    adminKey: string;
  },
  tableName: string | null,
  componentPath: string | undefined,
) {
  return getFunctionWatch(
    ctx,
    credentials,
    "_system/cli/queryTable",
    () => (tableName !== null ? { tableName } : null),
    componentPath,
  );
}

function getDeplymentEnvVarWatch(
  ctx: WatchContext,
  credentials: {
    url: string;
    adminKey: string;
  },
  shouldRetryOnDeploymentEnvVarChange: boolean,
) {
  return getFunctionWatch(
    ctx,
    credentials,
    "_system/cli/queryEnvironmentVariables",
    () => (shouldRetryOnDeploymentEnvVarChange ? {} : null),
    undefined,
  );
}

function getFunctionWatch(
  ctx: WatchContext,
  credentials: {
    url: string;
    adminKey: string;
  },
  functionName: string,
  getArgs: () => Record<string, Value> | null,
  componentPath: string | undefined,
) {
  const [stopPromise, stop] = waitUntilCalled();
  return {
    watch: async () => {
      const args = getArgs();
      if (args === null) {
        return waitForever();
      }
      let changes = 0;
      return subscribe(
        ctx,
        credentials.url,
        credentials.adminKey,
        functionName,
        args,
        componentPath,
        stopPromise,
        {
          onChange: () => {
            changes++;
            // First bump is just the initial results reporting
            if (changes > 1) {
              stop();
            }
          },
        },
      );
    },
    stop: () => {
      stop();
    },
  };
}

function getFileSystemWatch(
  ctx: WatchContext,
  watch: { watcher: Watcher | undefined },
  cmdOptions: { traceEvents: boolean },
) {
  let hasStopped = false;
  return {
    watch: async () => {
      const observations = ctx.fs.finalize();
      if (observations === "invalidated") {
        logMessage(ctx, "Filesystem changed during push, retrying...");
        return;
      }
      // 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 (!watch.watcher) {
        watch.watcher = new Watcher(observations);
        await showSpinnerIfSlow(
          ctx,
          "Preparing to watch files...",
          500,
          async () => {
            await watch.watcher!.ready();
          },
        );
        stopSpinner(ctx);
      }
      // Watch new directories if needed.
      watch.watcher.update(observations);

      // Process events until we find one that overlaps with our previous observations.
      let anyChanges = false;
      do {
        await watch.watcher.waitForEvent();
        if (hasStopped) {
          return;
        }
        for (const event of watch.watcher.drainEvents()) {
          if (cmdOptions.traceEvents) {
            logMessage(
              ctx,
              "Processing",
              event.name,
              path.relative("", event.absPath),
            );
          }
          const result = observations.overlaps(event);
          if (result.overlaps) {
            const relPath = path.relative("", event.absPath);
            if (cmdOptions.traceEvents) {
              logMessage(ctx, `${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;
      while (true) {
        const now = performance.now();
        if (now >= deadline) {
          break;
        }
        const remaining = deadline - now;
        if (cmdOptions.traceEvents) {
          logMessage(
            ctx,
            `Waiting for ${formatDuration(remaining)} to quiesce...`,
          );
        }
        const remainingWait = new Promise<"timeout">((resolve) =>
          setTimeout(() => resolve("timeout"), deadline - now),
        );
        const result = await Promise.race([
          remainingWait,
          watch.watcher.waitForEvent().then<"newEvents">(() => "newEvents"),
        ]);
        if (result === "newEvents") {
          for (const event of watch.watcher.drainEvents()) {
            const result = observations.overlaps(event);
            // Delay another `quiescenceDelay` since we had an overlapping event.
            if (result.overlaps) {
              if (cmdOptions.traceEvents) {
                logMessage(
                  ctx,
                  `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.
        }
      }
    },
    stop: () => {
      hasStopped = true;
    },
  };
}

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

export 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;
}
