import {
  changeSpinner,
  Context,
  logError,
  logFailure,
} from "../../bundler/context.js";
import {
  deploymentFetch,
  ErrorData,
  logAndHandleFetchError,
  ThrowingFetchError,
} from "./utils/utils.js";
import {
  schemaStatus,
  SchemaStatus,
  StartPushRequest,
  startPushResponse,
  StartPushResponse,
} from "./deployApi/startPush.js";
import {
  AppDefinitionConfig,
  ComponentDefinitionConfig,
} from "./deployApi/definitionConfig.js";
import chalk from "chalk";
import { getTargetDeploymentName } from "./deployment.js";
import { deploymentDashboardUrlPage } from "../dashboard.js";
import { finishPushDiff, FinishPushDiff } from "./deployApi/finishPush.js";
import { Reporter, Span } from "./tracing.js";

/** Push configuration2 to the given remote origin. */
export async function startPush(
  ctx: Context,
  span: Span,
  request: StartPushRequest,
  options: {
    url: string;
    verbose?: boolean;
  },
): Promise<StartPushResponse> {
  if (options.verbose) {
    const custom = (_k: string | number, s: any) =>
      typeof s === "string" ? s.slice(0, 40) + (s.length > 40 ? "..." : "") : s;
    console.log(JSON.stringify(request, custom, 2));
  }
  const onError = (err: any) => {
    if (err.toString() === "TypeError: fetch failed") {
      changeSpinner(
        ctx,
        `Fetch failed, is ${options.url} correct? Retrying...`,
      );
    }
  };
  const fetch = deploymentFetch(options.url, request.adminKey, onError);
  changeSpinner(ctx, "Analyzing and deploying source code...");
  try {
    const response = await fetch("/api/deploy2/start_push", {
      body: JSON.stringify(request),
      method: "POST",
      headers: {
        traceparent: span.encodeW3CTraceparent(),
      },
    });
    return startPushResponse.parse(await response.json());
  } catch (error: unknown) {
    const data: ErrorData | undefined =
      error instanceof ThrowingFetchError ? error.serverErrorData : undefined;
    if (data?.code === "AuthConfigMissingEnvironmentVariable") {
      const errorMessage = data.message || "(no error message given)";
      // If `npx convex dev` is running using --url there might not be a configured deployment
      const configuredDeployment = getTargetDeploymentName();
      const [, variableName] =
        errorMessage.match(/Environment variable (\S+)/i) ?? [];
      const variableQuery =
        variableName !== undefined ? `?var=${variableName}` : "";
      const dashboardUrl = deploymentDashboardUrlPage(
        configuredDeployment,
        `/settings/environment-variables${variableQuery}`,
      );
      const message =
        `Environment variable ${chalk.bold(
          variableName,
        )} is used in auth config file but ` +
        `its value was not set. Go to:\n\n    ${chalk.bold(
          dashboardUrl,
        )}\n\n  to set it up. `;
      await ctx.crash({
        exitCode: 1,
        errorType: "invalid filesystem or env vars",
        errForSentry: error,
        printedMessage: message,
      });
    }
    logFailure(ctx, "Error: Unable to start push to " + options.url);
    return await logAndHandleFetchError(ctx, error);
  }
}

// Long poll every 10s for progress on schema validation.
const SCHEMA_TIMEOUT_MS = 10_000;

export async function waitForSchema(
  ctx: Context,
  span: Span,
  startPush: StartPushResponse,
  options: {
    adminKey: string;
    url: string;
    dryRun: boolean;
  },
) {
  const fetch = deploymentFetch(options.url, options.adminKey);

  changeSpinner(
    ctx,
    "Backfilling indexes and checking that documents match your schema...",
  );

  while (true) {
    let currentStatus: SchemaStatus;
    try {
      const response = await fetch("/api/deploy2/wait_for_schema", {
        body: JSON.stringify({
          adminKey: options.adminKey,
          schemaChange: startPush.schemaChange,
          timeoutMs: SCHEMA_TIMEOUT_MS,
          dryRun: options.dryRun,
        }),
        method: "POST",
        headers: {
          traceparent: span.encodeW3CTraceparent(),
        },
      });
      currentStatus = schemaStatus.parse(await response.json());
    } catch (error: unknown) {
      logFailure(ctx, "Error: Unable to wait for schema from " + options.url);
      return await logAndHandleFetchError(ctx, error);
    }
    switch (currentStatus.type) {
      case "inProgress": {
        let schemaDone = true;
        let indexesComplete = 0;
        let indexesTotal = 0;
        for (const componentStatus of Object.values(currentStatus.components)) {
          if (!componentStatus.schemaValidationComplete) {
            schemaDone = false;
          }
          indexesComplete += componentStatus.indexesComplete;
          indexesTotal += componentStatus.indexesTotal;
        }
        const indexesDone = indexesComplete === indexesTotal;
        let msg: string;
        if (!indexesDone && !schemaDone) {
          msg = `Backfilling indexes (${indexesComplete}/${indexesTotal} ready) and checking that documents match your schema...`;
        } else if (!indexesDone) {
          msg = `Backfilling indexes (${indexesComplete}/${indexesTotal} ready)...`;
        } else {
          msg = "Checking that documents match your schema...";
        }
        changeSpinner(ctx, msg);
        break;
      }
      case "failed": {
        // Schema validation failed. This could be either because the data
        // is bad or the schema is wrong. Classify this as a filesystem error
        // because adjusting `schema.ts` is the most normal next step.
        let msg = "Schema validation failed";
        if (currentStatus.componentPath) {
          msg += ` in component "${currentStatus.componentPath}"`;
        }
        msg += ".";
        logFailure(ctx, msg);
        logError(ctx, chalk.red(`${currentStatus.error}`));
        return await ctx.crash({
          exitCode: 1,
          errorType: {
            "invalid filesystem or db data": currentStatus.tableName
              ? {
                  tableName: currentStatus.tableName,
                  componentPath: currentStatus.componentPath,
                }
              : null,
          },
          printedMessage: null, // TODO - move logging into here
        });
      }
      case "raceDetected": {
        return await ctx.crash({
          exitCode: 1,
          errorType: "fatal",
          printedMessage: `Schema was overwritten by another push.`,
        });
      }
      case "complete": {
        changeSpinner(ctx, "Schema validation complete.");
        return;
      }
    }
  }
}

export async function finishPush(
  ctx: Context,
  span: Span,
  startPush: StartPushResponse,
  options: {
    adminKey: string;
    url: string;
    dryRun: boolean;
  },
): Promise<FinishPushDiff> {
  changeSpinner(ctx, "Finalizing push...");
  const fetch = deploymentFetch(options.url, options.adminKey);
  try {
    const response = await fetch("/api/deploy2/finish_push", {
      body: JSON.stringify({
        adminKey: options.adminKey,
        startPush,
        dryRun: options.dryRun,
      }),
      method: "POST",
      headers: {
        traceparent: span.encodeW3CTraceparent(),
      },
    });
    return finishPushDiff.parse(await response.json());
  } catch (error: unknown) {
    logFailure(ctx, "Error: Unable to finish push to " + options.url);
    return await logAndHandleFetchError(ctx, error);
  }
}

export type ComponentDefinitionConfigWithoutImpls = Omit<
  ComponentDefinitionConfig,
  "schema" | "functions"
>;
export type AppDefinitionConfigWithoutImpls = Omit<
  AppDefinitionConfig,
  "schema" | "functions" | "auth"
>;

export async function reportPushCompleted(
  ctx: Context,
  adminKey: string,
  url: string,
  reporter: Reporter,
) {
  const fetch = deploymentFetch(url, adminKey);
  try {
    const response = await fetch("/api/deploy2/report_push_completed", {
      body: JSON.stringify({
        adminKey,
        spans: reporter.spans,
      }),
      method: "POST",
    });
    await response.json();
  } catch (error: unknown) {
    logFailure(
      ctx,
      "Error: Unable to report push completed to " + url + ": " + error,
    );
  }
}
