import {
  BuildServerMetadata,
  type InitializeDeploymentRequestBody,
  type ExternalBuildData,
} from "@trigger.dev/core/v3";
import { customAlphabet } from "nanoid";
import { env } from "~/env.server";
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { createRemoteImageBuild, remoteBuildsEnabled } from "../remoteImageBuilder.server";
import { BaseService, ServiceValidationError } from "./baseService.server";
import { TimeoutDeploymentService } from "./timeoutDeployment.server";
import { getDeploymentImageRef } from "../getDeploymentImageRef.server";
import { tryCatch } from "@trigger.dev/core";
import { getRegistryConfig } from "../registryConfig.server";
import { DeploymentService } from "./deployment.server";
import { createDeploymentWithNextVersion } from "./initializeDeployment/createDeploymentWithNextVersion.server";
import { errAsync } from "neverthrow";

const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8);

export class InitializeDeploymentService extends BaseService {
  public async call(
    environment: AuthenticatedEnvironment,
    payload: InitializeDeploymentRequestBody
  ) {
    return this.traceWithEnv("call", environment, async () => {
      if (payload.gitMeta?.commitSha?.startsWith("deployment_")) {
        // When we introduced automatic deployments via the build server, we slightly changed the deployment flow
        // mainly in the initialization and starting step: now deployments are first initialized in the `PENDING` status
        // and updated to `BUILDING` once the build server dequeues the build job.
        // Newer versions of the `deploy` command in the CLI will automatically attach to the existing deployment
        // and continue with the build process. For older versions, we can't change the command's client-side behavior,
        // so we need to handle this case here in the initialization endpoint. As we control the env variables which
        // the git meta is extracted from in the build server, we can use those to pass the existing deployment ID
        // to this endpoint. This doesn't affect the git meta on the deployment as it is set prior to this step using the
        // /start endpoint. It's a rather hacky solution, but it will do for now as it enables us to avoid degrading the
        // build server experience for users with older CLI versions. We'll eventually be able to remove this workaround
        // once we stop supporting 3.x CLI versions.

        const existingDeploymentId = payload.gitMeta.commitSha;
        const existingDeployment = await this._prisma.workerDeployment.findFirst({
          where: {
            environmentId: environment.id,
            friendlyId: existingDeploymentId,
          },
        });

        if (!existingDeployment) {
          throw new ServiceValidationError(
            "Existing deployment not found during deployment initialization"
          );
        }

        return {
          deployment: existingDeployment,
          imageRef: existingDeployment.imageReference ?? "",
        };
      }

      // v4 CLI versions always send `payload.type` ("MANAGED" or "V1"). v3 CLI
      // versions never do, so the absence of `type` is a reliable signal that
      // the request came from a 3.x CLI. Detection always runs (so we can
      // observe how many deploys are still using v3), enforcement is gated
      // behind DEPRECATE_V3_CLI_DEPLOYS_ENABLED so it can be rolled out safely.
      if (!payload.type) {
        const enforced = env.DEPRECATE_V3_CLI_DEPLOYS_ENABLED === "1";

        logger.warn("Detected deploy from deprecated v3 CLI", {
          environmentId: environment.id,
          projectId: environment.projectId,
          organizationId: environment.project.organizationId,
          enforced,
        });

        if (enforced) {
          throw new ServiceValidationError(
            "The trigger.dev CLI v3 is no longer supported for deployments. Please upgrade your project to v4: https://trigger.dev/docs/migrating-from-v3"
          );
        }
      }

      if (payload.type === "UNMANAGED") {
        throw new ServiceValidationError("UNMANAGED deployments are not supported");
      }

      // Upgrade the project to engine "V2" if it's not already. This should cover cases where people deploy to V2 without running dev first.
      if (payload.type === "MANAGED" && environment.project.engine === "V1") {
        await this._prisma.project.update({
          where: {
            id: environment.project.id,
          },
          data: {
            engine: "V2",
          },
        });
      }

      if (payload.selfHosted && remoteBuildsEnabled()) {
        throw new ServiceValidationError(
          "Self-hosted deployments are not supported on this instance"
        );
      }

      // For the `PENDING` initial status, defer the creation of the Depot build until the deployment is started to avoid token expiration issues.
      // For local and native builds we don't need to generate the Depot tokens. We still need to create an empty object sadly due to a bug in older CLI versions.
      const generateExternalBuildToken =
        payload.initialStatus === "PENDING" || payload.isNativeBuild || payload.isLocalBuild;

      const externalBuildData = generateExternalBuildToken
        ? ({
            projectId: "-",
            buildToken: "-",
            buildId: "-",
          } satisfies ExternalBuildData)
        : await createRemoteImageBuild(environment.project);

      const triggeredBy = payload.userId
        ? await this._prisma.user.findFirst({
            where: {
              id: payload.userId,
              orgMemberships: {
                some: {
                  organizationId: environment.project.organizationId,
                },
              },
            },
          })
        : undefined;

      const isV4Deployment = payload.type === "MANAGED";
      const registryConfig = getRegistryConfig(isV4Deployment);

      const deploymentShortCode = nanoid(8);

      // We keep using `BUILDING` as the initial status if not explicitly set
      // to avoid changing the behavior for deployments not created in the build server.
      // Native builds always start in the `PENDING` status.
      const initialStatus =
        payload.initialStatus ?? (payload.isNativeBuild ? "PENDING" : "BUILDING");

      const deploymentService = new DeploymentService();
      const s2StreamOrFail = await deploymentService
        .createEventStream(environment.project, { shortCode: deploymentShortCode })
        .andThen(({ basin, stream }) =>
          deploymentService.getEventStreamAccessToken(environment.project).map((accessToken) => ({
            basin,
            stream,
            accessToken,
          }))
        );

      if (s2StreamOrFail.isErr()) {
        logger.error(
          "Failed to create S2 event stream on deployment initialization, continuing without logs stream",
          {
            environmentId: environment.id,
            projectId: environment.projectId,
            error: s2StreamOrFail.error,
          }
        );
      }

      const eventStream = s2StreamOrFail.isOk()
        ? {
            s2: {
              basin: s2StreamOrFail.value.basin,
              stream: s2StreamOrFail.value.stream,
              accessToken: s2StreamOrFail.value.accessToken,
            },
          }
        : undefined;

      const buildServerMetadata: BuildServerMetadata | undefined =
        payload.isNativeBuild || payload.buildId
          ? {
              buildId: payload.buildId,
              ...(payload.isNativeBuild
                ? {
                    isNativeBuild: payload.isNativeBuild,
                    artifactKey: payload.artifactKey,
                    skipPromotion: payload.skipPromotion,
                    configFilePath: payload.configFilePath,
                    skipEnqueue: payload.skipEnqueue,
                  }
                : {}),
            }
          : undefined;

      // Concurrent deploys to the same environment race on the
      // `(environmentId, version)` unique constraint. The helper retries on
      // P2002, recomputing the version (and re-running the image ref call so
      // the persisted imageReference always matches the persisted version)
      // each attempt.
      const deployment = await createDeploymentWithNextVersion(
        this._prisma,
        environment.id,
        async (nextVersion) => {
          const [imageRefError, imageRefResult] = await tryCatch(
            getDeploymentImageRef({
              registry: registryConfig,
              projectRef: environment.project.externalRef,
              nextVersion,
              environmentType: environment.type,
              deploymentShortCode,
            })
          );

          if (imageRefError) {
            logger.error("Failed to get deployment image ref", {
              environmentId: environment.id,
              projectId: environment.projectId,
              version: nextVersion,
              triggeredById: triggeredBy?.id,
              type: payload.type,
              cause: imageRefError.message,
            });
            throw new ServiceValidationError("Failed to get deployment image ref");
          }

          const { imageRef, isEcr, repoCreated } = imageRefResult;

          logger.debug("Creating deployment", {
            environmentId: environment.id,
            projectId: environment.projectId,
            version: nextVersion,
            triggeredById: triggeredBy?.id,
            type: payload.type,
            imageRef,
            isEcr,
            repoCreated,
            initialStatus,
            artifactKey: payload.isNativeBuild ? payload.artifactKey : undefined,
            isNativeBuild: payload.isNativeBuild,
          });

          return {
            // Regenerated per attempt: each attempt is a fresh `create` that
            // must satisfy `WorkerDeployment.friendlyId @unique`, so reusing a
            // friendlyId across retries would risk a spurious P2002 on
            // friendlyId instead of the version collision we're retrying.
            friendlyId: generateFriendlyId("deployment"),
            contentHash: payload.contentHash,
            shortCode: deploymentShortCode,
            status: initialStatus,
            projectId: environment.projectId,
            externalBuildData,
            buildServerMetadata,
            triggeredById: triggeredBy?.id,
            type: payload.type,
            imageReference: imageRef,
            imagePlatform: env.DEPLOY_IMAGE_PLATFORM,
            git: payload.gitMeta ?? undefined,
            commitSHA: payload.gitMeta?.commitSha ?? undefined,
            runtime: payload.runtime ?? undefined,
            triggeredVia: payload.triggeredVia ?? undefined,
            startedAt: initialStatus === "BUILDING" ? new Date() : undefined,
          };
        }
      );

      const timeoutMs =
        deployment.status === "PENDING" ? env.DEPLOY_QUEUE_TIMEOUT_MS : env.DEPLOY_TIMEOUT_MS;

      await TimeoutDeploymentService.enqueue(
        deployment.id,
        deployment.status,
        "Building timed out",
        new Date(Date.now() + timeoutMs)
      );

      // For github integration there is no artifactKey, hence we skip it here
      if (payload.isNativeBuild && payload.artifactKey && !payload.skipEnqueue) {
        const result = await deploymentService
          .enqueueBuild(environment, deployment, payload.artifactKey, {
            skipPromotion: payload.skipPromotion,
            configFilePath: payload.configFilePath,
          })
          .orElse((error) => {
            logger.error("Failed to enqueue build", {
              environmentId: environment.id,
              projectId: environment.projectId,
              deploymentId: deployment.id,
              error: error.cause,
            });

            return deploymentService
              .cancelDeployment(environment, deployment.friendlyId, {
                canceledReason: "Failed to enqueue build, please try again shortly.",
              })
              .orTee((cancelError) =>
                logger.error("Failed to cancel deployment after failed build enqueue", {
                  environmentId: environment.id,
                  projectId: environment.projectId,
                  deploymentId: deployment.id,
                  error: cancelError,
                })
              )
              .andThen(() => errAsync(error))
              .orElse(() => errAsync(error));
          });

        if (result.isErr()) {
          throw Error("Failed to enqueue build");
        }
      }

      return {
        deployment,
        imageRef: deployment.imageReference ?? "",
        eventStream,
      };
    });
  }
}
