import {
  ExternalBuildData,
  type FinalizeDeploymentRequestBody,
} from "@trigger.dev/core/v3/schemas";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { BaseService, ServiceValidationError } from "./baseService.server";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { mkdtemp, writeFile } from "node:fs/promises";
import { env } from "~/env.server";
import { depot as execDepot } from "@depot/cli";
import { FinalizeDeploymentService } from "./finalizeDeployment.server";
import { remoteBuildsEnabled } from "../remoteImageBuilder.server";
import { getEcrAuthToken, isEcrRegistry } from "../getDeploymentImageRef.server";
import { tryCatch } from "@trigger.dev/core";
import { getRegistryConfig, type RegistryConfig } from "../registryConfig.server";
import { ComputeTemplateCreationService } from "./computeTemplateCreation.server";
import { ecrImageExists } from "./verifyDeploymentImage.server";

export class FinalizeDeploymentV2Service extends BaseService {
  public async call(
    authenticatedEnv: AuthenticatedEnvironment,
    id: string,
    body: FinalizeDeploymentRequestBody,
    writer?: WritableStreamDefaultWriter
  ) {
    const deployment = await this._prisma.workerDeployment.findFirst({
      where: {
        friendlyId: id,
        environmentId: authenticatedEnv.id,
      },
      select: {
        status: true,
        id: true,
        version: true,
        externalBuildData: true,
        environment: true,
        imageReference: true,
        type: true,
        worker: {
          select: {
            project: true,
          },
        },
      },
    });

    if (!deployment) {
      logger.error("Worker deployment not found", { id });
      return;
    }

    if (!deployment.worker) {
      logger.error("Worker deployment does not have a worker", { id });
      throw new ServiceValidationError("Worker deployment does not have a worker");
    }

    if (deployment.status === "DEPLOYED") {
      logger.debug("Worker deployment is already deployed", { id });
      return deployment;
    }

    if (deployment.status !== "DEPLOYING") {
      logger.error("Worker deployment is not in DEPLOYING status", { id });
      throw new ServiceValidationError("Worker deployment is not in DEPLOYING status");
    }

    const finalizeService = new FinalizeDeploymentService();

    // If remote builds are not enabled, skip image push and go straight to template + finalize
    if (!remoteBuildsEnabled() || body.skipPushToRegistry) {
      if (body.skipPushToRegistry) {
        logger.debug("Skipping push to registry during deployment finalization", {
          deployment,
        });
      }

      // The CLI claims the image is already in the registry (local build, or a
      // self-hosted setup). Verify before promoting so we never mark a
      // deployment DEPLOYED when nothing was actually pushed.
      await this.#assertImagePullable(deployment, body);

      await this.#createTemplateIfNeeded(deployment, id, authenticatedEnv, writer);
      return finalizeService.call(authenticatedEnv, id, body);
    }

    const externalBuildData = deployment.externalBuildData
      ? ExternalBuildData.safeParse(deployment.externalBuildData)
      : undefined;

    if (!externalBuildData) {
      throw new ServiceValidationError("External build data is missing");
    }

    if (!externalBuildData.success) {
      throw new ServiceValidationError("External build data is invalid");
    }

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

    // For non-ECR registries, username and password are required upfront
    if (
      !isEcrRegistry(registryConfig.host) &&
      (!registryConfig.username || !registryConfig.password)
    ) {
      throw new ServiceValidationError("Missing deployment registry credentials");
    }

    if (!env.DEPOT_TOKEN) {
      throw new ServiceValidationError("Missing depot token");
    }

    // All new deployments will set the image reference at creation time
    if (!deployment.imageReference) {
      throw new ServiceValidationError("Missing image reference");
    }

    logger.debug("Pushing image to registry", { id, deployment, body });

    const pushResult = await executePushToRegistry(
      {
        depot: {
          buildId: externalBuildData.data.buildId,
          orgToken: env.DEPOT_TOKEN,
          projectId: externalBuildData.data.projectId,
        },
        registry: registryConfig,
        deployment: {
          version: deployment.version,
          environmentSlug: deployment.environment.slug,
          projectExternalRef: deployment.worker.project.externalRef,
          imageReference: deployment.imageReference,
        },
      },
      writer
    );

    if (!pushResult.ok) {
      throw new ServiceValidationError(pushResult.error);
    }

    logger.debug("Image pushed to registry", {
      id,
      deployment,
      body,
      pushedImage: pushResult.image,
    });

    // Belt and suspenders: confirm the push actually landed before promoting.
    await this.#assertImagePullable(deployment, body);

    await this.#createTemplateIfNeeded(deployment, id, authenticatedEnv, writer);
    return finalizeService.call(authenticatedEnv, id, body);
  }

  async #assertImagePullable(
    deployment: { imageReference: string | null; type: string | null },
    body: FinalizeDeploymentRequestBody
  ): Promise<void> {
    if (!env.DEPLOY_IMAGE_VERIFICATION_ENABLED) {
      return;
    }

    if (!deployment.imageReference) {
      return;
    }

    const registryConfig = getRegistryConfig(deployment.type === "MANAGED");

    // ECR-only: non-ECR (self-hosted) registries can't be checked this way, so skip.
    if (!isEcrRegistry(registryConfig.host)) {
      return;
    }

    const result = await ecrImageExists({
      imageReference: deployment.imageReference,
      imageDigest: body.imageDigest,
      registryConfig,
    });

    if (result === "missing") {
      throw new ServiceValidationError(
        "Deployment image was not found in the registry. It may not have been pushed (for example a local build without a push, or a push to a different registry). Aborting the deploy to avoid promoting a version that cannot start."
      );
    }

    if (result === "nonconformant") {
      throw new ServiceValidationError(
        "Deployment image is not runnable: it contains zstd-compressed layers inside a Docker (v2s2) manifest, which the container runtime cannot pull. This typically comes from an outdated CLI version. Please upgrade to the latest trigger.dev CLI and re-deploy."
      );
    }

    // Fail closed: if we can't confirm the image is present, don't promote a version
    // that might not start. Set DEPLOY_IMAGE_VERIFICATION_ENABLED=0 for out-of-band pushes.
    if (result === "unknown") {
      throw new ServiceValidationError(
        "Could not verify the deployment image exists in the registry. Aborting the deploy."
      );
    }
  }

  async #createTemplateIfNeeded(
    deployment: { imageReference: string | null; worker: { project: { id: string } } | null },
    deploymentFriendlyId: string,
    authenticatedEnv: AuthenticatedEnvironment,
    writer?: WritableStreamDefaultWriter
  ): Promise<void> {
    if (!deployment.imageReference || !deployment.worker) {
      return;
    }

    const templateService = new ComputeTemplateCreationService();
    await templateService.handleDeployTemplate({
      projectId: deployment.worker.project.id,
      imageReference: deployment.imageReference,
      deploymentFriendlyId,
      authenticatedEnv,
      prisma: this._prisma,
      writer,
    });
  }
}

type ExecutePushToRegistryOptions = {
  depot: {
    buildId: string;
    orgToken: string;
    projectId: string;
  };
  registry: RegistryConfig;
  deployment: {
    version: string;
    environmentSlug: string;
    projectExternalRef: string;
    imageReference: string;
  };
};

type ExecutePushResult =
  | {
      ok: true;
      image: string;
      logs: string;
    }
  | {
      ok: false;
      error: string;
      logs: string;
    };

async function executePushToRegistry(
  { depot, registry, deployment }: ExecutePushToRegistryOptions,
  writer?: WritableStreamDefaultWriter
): Promise<ExecutePushResult> {
  // Step 1: We need to "login" to the registry
  const [loginError, configDir] = await tryCatch(ensureLoggedIntoDockerRegistry(registry));

  if (loginError) {
    logger.error("Failed to login to registry", {
      deployment,
      registryHost: registry.host,
      error: loginError.message,
    });

    return {
      ok: false as const,
      error: "Failed to login to registry",
      logs: "",
    };
  }

  const imageTag = deployment.imageReference;

  // Step 2: We need to run the depot push command
  const childProcess = execDepot(["push", depot.buildId, "-t", imageTag, "--progress", "plain"], {
    env: {
      NODE_ENV: process.env.NODE_ENV,
      DEPOT_TOKEN: depot.orgToken,
      DEPOT_PROJECT_ID: depot.projectId,
      DEPOT_NO_SUMMARY_LINK: "1",
      DEPOT_NO_UPDATE_NOTIFIER: "1",
      DOCKER_CONFIG: configDir,
    },
  });

  const errors: string[] = [];

  try {
    const processCode = await new Promise<number | null>((res, rej) => {
      // For some reason everything is output on stderr, not stdout
      childProcess.stderr?.on("data", async (data: Buffer) => {
        const text = data.toString();

        // Emitted data chunks can contain multiple lines. Remove empty lines.
        const lines = text.split("\n").filter(Boolean);

        errors.push(...lines);
        logger.debug(text, { deployment });

        // Now we can write strings directly
        if (writer) {
          for (const line of lines) {
            await writer.write(`event: log\ndata: ${JSON.stringify({ message: line })}\n\n`);
          }
        }
      });

      childProcess.on("error", (e) => rej(e));
      childProcess.on("close", (code) => res(code));
    });

    const logs = extractLogs(errors);

    if (processCode !== 0) {
      return {
        ok: false as const,
        error: `Error pushing image`,
        logs,
      };
    }

    return {
      ok: true as const,
      image: imageTag,
      logs,
    };
  } catch (e) {
    return {
      ok: false as const,
      error: e instanceof Error ? e.message : JSON.stringify(e),
      logs: extractLogs(errors),
    };
  }
}

async function ensureLoggedIntoDockerRegistry(registryConfig: RegistryConfig) {
  const tmpDir = await createTempDir();
  const dockerConfigPath = join(tmpDir, "config.json");

  let auth: { username: string; password: string };

  // If this is an ECR registry, get fresh credentials
  if (isEcrRegistry(registryConfig.host)) {
    auth = await getEcrAuthToken({
      registryHost: registryConfig.host,
      assumeRole: registryConfig.ecrAssumeRoleArn
        ? {
            roleArn: registryConfig.ecrAssumeRoleArn,
            externalId: registryConfig.ecrAssumeRoleExternalId,
          }
        : undefined,
    });
  } else if (!registryConfig.username || !registryConfig.password) {
    throw new Error("Authentication required for non-ECR registry");
  } else {
    auth = {
      username: registryConfig.username,
      password: registryConfig.password,
    };
  }

  await writeJSONFile(dockerConfigPath, {
    auths: {
      [registryConfig.host]: {
        auth: Buffer.from(`${auth.username}:${auth.password}`).toString("base64"),
      },
    },
  });

  logger.debug(`Writing docker config to ${dockerConfigPath}`);

  return tmpDir;
}

// Create a temporary directory within the OS's temp directory
async function createTempDir(): Promise<string> {
  // Generate a unique temp directory path
  const tempDirPath: string = join(tmpdir(), "trigger-");

  // Create the temp directory synchronously and return the path
  const directory = await mkdtemp(tempDirPath);

  return directory;
}

async function writeJSONFile(path: string, json: any, pretty = false) {
  await writeFile(path, JSON.stringify(json, undefined, pretty ? 2 : undefined), "utf8");
}

function extractLogs(outputs: string[]) {
  // Remove empty lines
  const cleanedOutputs = outputs.map((line) => line.trim()).filter((line) => line !== "");

  return cleanedOutputs.map((line) => line.trim()).join("\n");
}
