import { chalkStderr } from "chalk";
import path from "path";
import { performance } from "perf_hooks";
import { Context } from "../../bundler/context.js";
import {
  logError,
  logFailure,
  logMessage,
  showSpinner,
} from "../../bundler/log.js";
import * as Sentry from "@sentry/node";
import * as semver from "semver";
import { formatDuration, spawnAsync } from "./utils/utils.js";
import { readProjectConfig } from "./config.js";
import { WatchContext } from "./watch.js";

export type TypecheckResult = "cantTypeCheck" | "success" | "typecheckFailed";

export type TypeCheckMode = "enable" | "try" | "disable";

export type TypescriptCompiler = "tsc" | "tsgo";

const SLOW_TYPECHECK_THRESHOLD_MS = 10_000;
const SLOW_TYPECHECK_DOCS_URL =
  "https://docs.convex.dev/production/project-configuration#configuring-the-typescript-compiler";

/**
 * Resolves the TypeScript compiler to use based on CLI flag, config file, and default.
 * Precedence: CLI flag → config file → default "tsc"
 */
export async function resolveTypescriptCompiler(
  ctx: Context,
  cliOption?: TypescriptCompiler,
): Promise<TypescriptCompiler> {
  const { projectConfig } = await readProjectConfig(ctx);
  return cliOption ?? projectConfig?.typescriptCompiler ?? "tsc";
}

type TypecheckResultHandler = (
  result: TypecheckResult,
  logSpecificError?: () => void,
  // If given, we run it to print out errors.
  // We expect it to throw or resolve to "success"
  // if a concurrent change invalidated the error result.
  runOnError?: () => Promise<"success">,
) => Promise<void>;

/**
 * Conditionally run a typecheck function and interpret the result.
 *
 * If typeCheckMode === "disable", never run the typecheck function.
 * If typeCheckMode === "enable", run the typecheck and crash if typechecking
 * fails or we can't find tsc.
 * If typeCheckMode === "try", try and run the typecheck. crash if typechecking
 * fails but don't worry if tsc is missing and we can't run it.
 */
export async function typeCheckFunctionsInMode(
  ctx: Context,
  typeCheckMode: TypeCheckMode,
  functionsDir: string,
): Promise<void> {
  if (typeCheckMode === "disable") {
    return;
  }
  const typescriptCompiler = await resolveTypescriptCompiler(ctx);
  const typecheckStart = performance.now();
  await typeCheckFunctions(
    ctx,
    typescriptCompiler,
    functionsDir,
    async (result, logSpecificError, runOnError) => {
      if (
        (result === "cantTypeCheck" && typeCheckMode === "enable") ||
        result === "typecheckFailed"
      ) {
        logSpecificError?.();
        logError(
          chalkStderr.gray(
            "To ignore failing typecheck, use `--typecheck=disable`.",
          ),
        );
        try {
          const result = await runOnError?.();
          // Concurrent change invalidated the error, don't fail
          if (result === "success") {
            return;
          }
        } catch {
          // As expected, `runOnError` threw
        }
        await ctx.crash({
          exitCode: 1,
          errorType: "invalid filesystem data",
          printedMessage: null,
        });
      }
    },
  );
  maybeLogSlowTypecheckSuggestion(
    ctx,
    performance.now() - typecheckStart,
    typescriptCompiler,
  );
}

// Runs TypeScript compiler to typecheck Convex query and mutation functions.
export async function typeCheckFunctions(
  ctx: Context,
  typescriptCompiler: TypescriptCompiler,
  functionsDir: string,
  handleResult: TypecheckResultHandler,
): Promise<void> {
  const tsconfig = path.join(functionsDir, "tsconfig.json");
  if (!ctx.fs.exists(tsconfig)) {
    return handleResult("cantTypeCheck", () => {
      logError(
        "Found no convex/tsconfig.json to use to typecheck Convex functions, so skipping typecheck.",
      );
      logError("Run `npx convex codegen --init` to create one.");
    });
  }
  await runTsc(
    ctx,
    typescriptCompiler,
    ["--project", functionsDir],
    handleResult,
  );
}

async function runTsc(
  ctx: Context,
  typescriptCompiler: TypescriptCompiler,
  tscArgs: string[],
  handleResult: TypecheckResultHandler,
): Promise<void> {
  // Check if tsc is even installed
  const tscPath =
    typescriptCompiler === "tsgo"
      ? path.join(
          "node_modules",
          "@typescript",
          "native-preview",
          "bin",
          "tsgo.js",
        )
      : path.join("node_modules", "typescript", "bin", "tsc");
  if (!ctx.fs.exists(tscPath)) {
    return handleResult("cantTypeCheck", () => {
      logError(
        chalkStderr.gray(
          `No \`${typescriptCompiler}\` binary found, so skipping typecheck.`,
        ),
      );
    });
  }

  // Check the TypeScript version matches the recommendation from Convex
  const versionResult = await spawnAsync(ctx, process.execPath, [
    tscPath,
    "--version",
  ]);

  const version = versionResult.stdout.match(/Version (.*)/)?.[1] ?? null;
  const hasOlderTypeScriptVersion = version && semver.lt(version, "4.8.4");

  await runTscInner(ctx, tscPath, tscArgs, handleResult);

  // Print this warning after any logs from running `tsc`
  if (hasOlderTypeScriptVersion) {
    logError(
      chalkStderr.yellow(
        "Convex works best with TypeScript version 4.8.4 or newer -- npm i --save-dev typescript@latest to update.",
      ),
    );
  }
}

async function runTscInner(
  ctx: Context,
  tscPath: string,
  tscArgs: string[],
  handleResult: TypecheckResultHandler,
) {
  // Run `tsc` once and have it print out the files it touched. This output won't
  // be very useful if there's an error, but we'll run it again to get a nice
  // user-facing error in this exceptional case.
  // The `--listFiles` command prints out files touched on success or error.
  const result = await spawnAsync(ctx, process.execPath, [
    tscPath,
    ...tscArgs,
    "--listFiles",
  ]);
  if (result.status === null) {
    return handleResult("typecheckFailed", () => {
      logFailure(`TypeScript typecheck timed out.`);
      if (result.error) {
        logError(chalkStderr.red(`${result.error.toString()}`));
      }
    });
  }
  // Okay, we may have failed `tsc` but at least it returned. Try to parse its
  // output to discover which files it touched.
  const filesTouched = result.stdout
    .split("\n")
    .map((s) => s.trim())
    .filter((s) => s.length > 0);
  let anyPathsFound = false;
  for (const fileTouched of filesTouched) {
    const absPath = path.resolve(fileTouched);
    let st;
    try {
      st = ctx.fs.stat(absPath);
      anyPathsFound = true;
    } catch {
      // Just move on if we have a bogus path from `tsc`. We'll log below if
      // we fail to stat *any* of the paths emitted by `tsc`.
      // TODO: Switch to using their JS API so we can get machine readable output.
      continue;
    }
    ctx.fs.registerPath(absPath, st);
  }
  if (filesTouched.length > 0 && !anyPathsFound) {
    const err = new Error(
      `Failed to stat any files emitted by tsc (received ${filesTouched.length})`,
    );
    Sentry.captureException(err);
  }

  if (!result.error && result.status === 0) {
    return handleResult("success");
  }

  // This is the "No inputs were found", which is fine and we shouldn't
  // report it to the user.
  if (result.stdout.startsWith("error TS18003")) {
    return handleResult("success");
  }

  // At this point we know that `tsc` failed. Rerun it without `--listFiles`
  // and with stderr redirected to have it print out a nice error.
  return handleResult(
    "typecheckFailed",
    () => {
      logFailure("TypeScript typecheck via `tsc` failed.");
    },
    async () => {
      showSpinner("Collecting TypeScript errors");
      await spawnAsync(
        ctx,
        process.execPath,
        [tscPath, ...tscArgs, "--pretty", "true"],
        {
          stdio: "inherit",
        },
      );
      // If this passes, we had a concurrent file change that'll overlap with
      // our observations in the first run. Invalidate our context's filesystem
      // but allow the rest of the system to observe the success.
      ctx.fs.invalidate();
      return "success";
    },
  );
}

function maybeLogSlowTypecheckSuggestion(
  ctx: Context,
  durationMs: number,
  typescriptCompiler: TypescriptCompiler,
) {
  if (!(ctx instanceof WatchContext)) {
    return;
  }
  if (!ctx.isFirstPush) {
    return;
  }
  if (typescriptCompiler === "tsgo") {
    return;
  }
  if (durationMs <= SLOW_TYPECHECK_THRESHOLD_MS) {
    return;
  }
  const formattedDuration = formatDuration(durationMs);
  logMessage(
    chalkStderr.gray(
      `Typechecking took ${formattedDuration}. For faster iteration, consider enabling the TypeScript 7 compiler (tsgo) in your project configuration: ${SLOW_TYPECHECK_DOCS_URL}`,
    ),
  );
}
