import type { ManagementApiClient } from "@prisma/management-api-sdk";
import { type InferOk, Result } from "better-result";
import invariant from "tiny-invariant";
import {
  InternalApiClient,
  readUploadUrl,
  toDeploymentUrl,
} from "./api-client.ts";
import { createArchive } from "./archive.ts";
import type { BuildStrategy } from "./build-strategy.ts";
import type {
  DeployInteraction,
  DeployProgress,
  DestroyAppProgress,
  DestroyDeploymentInteraction,
  DestroyDeploymentProgress,
  PromoteProgress,
  UpdateEnvProgress,
} from "./callbacks.ts";
import {
  ApiError,
  type ApiRequestError,
  ArtifactError,
  type AuthenticationError,
  BuildError,
  CancelledError,
  type DeployError,
  type DeploymentOperationError,
  DestroyAggregateError,
  type DestroyAppError,
  type DestroyDeploymentError,
  InvalidOptionsError,
  MissingArgumentError,
  NoDeploymentsFoundError,
  NoExistingDeploymentError,
  type PromoteError,
  type UpdateEnvError,
} from "./errors.ts";
import { pollDeploymentStatus } from "./polling.ts";
import type {
  AppDetail,
  AppInfo,
  CreateProjectResult,
  DeploymentDetail,
  DeploymentInfo,
  PortMapping,
  ProjectInfo,
  ResolvedConfig,
} from "./types.ts";
import { REGIONS } from "./types.ts";
import { uploadArtifact } from "./upload-artifact.ts";

type DeploymentDetailData = InferOk<
  Awaited<ReturnType<InternalApiClient["getDeployment"]>>
>;

export interface DeployOptions {
  strategy: BuildStrategy;
  projectId?: string;
  appId?: string;
  appName?: string;
  region?: string;
  envVars?: Record<string, string | null>;
  portMapping?: PortMapping;
  timeoutSeconds?: number;
  pollIntervalMs?: number;
  signal?: AbortSignal;
  interaction?: DeployInteraction;
  progress?: DeployProgress;
  skipPromote?: boolean;
  destroyOldDeployment?: boolean;
}

export interface UpdateEnvOptions {
  projectId?: string;
  appId?: string;
  envVars?: Record<string, string | null>;
  portMapping?: PortMapping;
  timeoutSeconds?: number;
  pollIntervalMs?: number;
  signal?: AbortSignal;
  interaction?: DeployInteraction;
  progress?: UpdateEnvProgress;
}

export interface DestroyDeploymentOptions {
  deploymentId?: string;
  appId?: string;
  projectId?: string;
  timeoutSeconds?: number;
  pollIntervalMs?: number;
  signal?: AbortSignal;
  interaction?: DestroyDeploymentInteraction;
  progress?: DestroyDeploymentProgress;
}

export interface DestroyAppOptions {
  appId: string;
  keepApp?: boolean;
  timeoutSeconds?: number;
  pollIntervalMs?: number;
  signal?: AbortSignal;
  progress?: DestroyAppProgress;
}

export interface ListAppsOptions {
  projectId: string;
  signal?: AbortSignal;
}

export interface CreateAppOptions {
  projectId: string;
  appName: string;
  region?: string;
  signal?: AbortSignal;
}

export interface ShowAppOptions {
  appId: string;
  signal?: AbortSignal;
}

export interface DeleteAppOptions {
  appId: string;
  signal?: AbortSignal;
}

export interface CreateProjectOptions {
  name: string;
  createDatabase?: boolean;
  region?: string;
  signal?: AbortSignal;
}

export interface ListProjectsOptions {
  signal?: AbortSignal;
}

export interface ListDeploymentsOptions {
  appId: string;
  signal?: AbortSignal;
}

export interface ShowDeploymentOptions {
  deploymentId: string;
  signal?: AbortSignal;
}

export interface StartDeploymentOptions {
  deploymentId: string;
  signal?: AbortSignal;
}

export interface StopDeploymentOptions {
  deploymentId: string;
  signal?: AbortSignal;
}

export interface DeleteDeploymentOptions {
  deploymentId: string;
  signal?: AbortSignal;
}

export interface PromoteOptions {
  appId: string;
  deploymentId?: string;
  timeoutSeconds?: number;
  pollIntervalMs?: number;
  signal?: AbortSignal;
  interaction?: DestroyDeploymentInteraction;
  progress?: PromoteProgress;
}

export interface DeployResult {
  projectId: string;
  appId: string;
  appName: string;
  region: string;
  deploymentId: string;
  deploymentEndpointDomain: string;
  appEndpointDomain: string | null;
  promoted: boolean;
  previousDeploymentId: string | null;
  previousDeploymentAction: "stopped" | "destroyed" | "still-active" | null;
  resolvedConfig: ResolvedConfig;
}

export interface PromoteResult {
  appId: string;
  deploymentId: string;
  appEndpointDomain: string;
  deploymentStarted: boolean;
}

export interface UpdateEnvResult {
  projectId: string;
  appId: string;
  deploymentId: string;
  deploymentUrl: string;
  resolvedConfig: ResolvedConfig;
}

export interface DestroyDeploymentResult {
  deploymentId: string;
  previousStatus: string;
  stopped: boolean;
  deleted: boolean;
}

export interface DestroyAppResult {
  appId: string;
  deletedDeploymentIds: string[];
  appDeleted: boolean;
}

const DEFAULT_TIMEOUT_SECONDS = 120;
const DEFAULT_POLL_INTERVAL_MS = 1_000;

export class ComputeClient {
  readonly #api: InternalApiClient;

  constructor(managementApiClient: ManagementApiClient) {
    this.#api = new InternalApiClient(managementApiClient);
  }

  async deploy(
    options: DeployOptions,
  ): Promise<Result<DeployResult, DeployError>> {
    if (options.skipPromote && options.destroyOldDeployment) {
      return Result.err(
        new InvalidOptionsError({
          message:
            "destroyOldDeployment cannot be combined with skipPromote — the old deployment stays active when promotion is skipped",
        }),
      );
    }

    const self = this;
    return Result.gen(async function* () {
      yield* self.#checkAborted(options.signal);

      const target = yield* Result.await(self.#resolveDeployTarget(options));

      options.progress?.onBuildStart?.();
      const artifact = yield* Result.await(
        Result.tryPromise({
          try: () => options.strategy.execute(options.signal),
          catch: (e) =>
            new BuildError({
              message: e instanceof Error ? e.message : String(e),
            }),
        }),
      );
      options.progress?.onBuildComplete?.(artifact);

      try {
        yield* self.#checkAborted(options.signal);

        options.progress?.onArchiveCreating?.();
        const archiveBytes = yield* Result.await(
          Result.tryPromise({
            try: () =>
              createArchive(
                artifact.directory,
                artifact.entrypoint,
                options.signal,
              ),
            catch: (e) =>
              new ArtifactError({
                message:
                  e instanceof Error
                    ? e.message
                    : `Archive creation failed: ${String(e)}`,
                cause: e,
              }),
          }),
        );
        options.progress?.onArchiveReady?.(archiveBytes.byteLength);

        yield* self.#checkAborted(options.signal);

        const portMapping =
          options.portMapping ??
          (target.isNewApp && artifact.defaultPortMapping
            ? artifact.defaultPortMapping
            : undefined);

        yield* Result.await(
          self.#applyEnvVars(
            target.projectId,
            target.branchId,
            options.envVars,
            options.signal,
          ),
        );

        const createResponse = yield* Result.await(
          self.#api.createAppDeployment(
            target.appId,
            portMapping ? { portMapping } : {},
            options.signal,
          ),
        );
        options.progress?.onDeploymentCreated?.(createResponse.id);

        const uploadUrl = readUploadUrl(createResponse);
        if (!uploadUrl) {
          return Result.err(
            new ArtifactError({
              message: "Deployment creation did not return an upload URL",
            }),
          );
        }

        yield* self.#checkAborted(options.signal);

        options.progress?.onUploadStart?.();
        yield* Result.await(
          self.#uploadArtifactResult(uploadUrl, archiveBytes, options.signal),
        );
        options.progress?.onUploadComplete?.();

        yield* self.#checkAborted(options.signal);

        const app = yield* Result.await(
          self.#api.getApp(target.appId, options.signal),
        );
        const previousDeploymentId = app.latestDeploymentId ?? null;

        options.progress?.onStartRequested?.();
        yield* Result.await(
          self.#api.startDeployment(createResponse.id, options.signal),
        );

        const pollResult = yield* Result.await(
          pollDeploymentStatus(self.#api, createResponse.id, {
            targetStatus: "running",
            timeoutSeconds: options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS,
            pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
            signal: options.signal,
            onStatusChange: options.progress?.onStatusChange,
          }),
        );

        const deploymentEndpointDomain = toDeploymentUrl(
          pollResult.previewDomain,
        );
        options.progress?.onRunning?.(deploymentEndpointDomain);

        const resolvedConfig: ResolvedConfig = {
          projectId: target.projectId,
          appId: target.appId,
          appName: target.appName,
          region: target.region,
          portMapping,
        };

        if (options.skipPromote) {
          return Result.ok({
            projectId: target.projectId,
            appId: target.appId,
            appName: target.appName,
            region: target.region,
            deploymentId: createResponse.id,
            deploymentEndpointDomain,
            appEndpointDomain: null,
            promoted: false,
            previousDeploymentId,
            previousDeploymentAction: previousDeploymentId
              ? ("still-active" as const)
              : null,
            resolvedConfig,
          });
        }

        const switchoverResult = yield* Result.await(
          self.#promoteAndSwitchover(
            target.appId,
            createResponse.id,
            previousDeploymentId,
            options.destroyOldDeployment ?? false,
            options.signal,
            options.progress,
          ),
        );

        return Result.ok({
          projectId: target.projectId,
          appId: target.appId,
          appName: target.appName,
          region: target.region,
          deploymentId: createResponse.id,
          deploymentEndpointDomain,
          appEndpointDomain: switchoverResult.appEndpointDomain,
          promoted: true,
          previousDeploymentId: switchoverResult.previousDeploymentId,
          previousDeploymentAction: switchoverResult.previousDeploymentAction,
          resolvedConfig,
        });
      } finally {
        await artifact.cleanup?.();
      }
    });
  }

  async updateEnv(
    options: UpdateEnvOptions,
  ): Promise<Result<UpdateEnvResult, UpdateEnvError>> {
    const self = this;
    return Result.gen(async function* () {
      yield* self.#checkAborted(options.signal);

      const target = yield* Result.await(self.#resolveDeployTarget(options));

      const deployments = yield* Result.await(
        self.#api.listAppDeployments(target.appId, options.signal),
      );
      if (deployments.length === 0) {
        return Result.err(
          new NoExistingDeploymentError({ appId: target.appId }),
        );
      }

      yield* self.#checkAborted(options.signal);
      yield* Result.await(
        self.#applyEnvVars(
          target.projectId,
          target.branchId,
          options.envVars,
          options.signal,
        ),
      );

      yield* self.#checkAborted(options.signal);

      const createResponse = yield* Result.await(
        self.#api.createAppDeployment(
          target.appId,
          {
            ...(options.portMapping !== undefined
              ? { portMapping: options.portMapping }
              : {}),
            skipCodeUpload: true,
          },
          options.signal,
        ),
      );
      options.progress?.onDeploymentCreated?.(createResponse.id);

      yield* self.#checkAborted(options.signal);

      options.progress?.onStartRequested?.();
      yield* Result.await(
        self.#api.startDeployment(createResponse.id, options.signal),
      );

      const pollResult = yield* Result.await(
        pollDeploymentStatus(self.#api, createResponse.id, {
          targetStatus: "running",
          timeoutSeconds: options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS,
          pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
          signal: options.signal,
          onStatusChange: options.progress?.onStatusChange,
        }),
      );

      const deploymentUrl = toDeploymentUrl(pollResult.previewDomain);
      options.progress?.onRunning?.(deploymentUrl);

      return Result.ok({
        projectId: target.projectId,
        appId: target.appId,
        deploymentId: createResponse.id,
        deploymentUrl,
        resolvedConfig: {
          projectId: target.projectId,
          appId: target.appId,
          appName: target.appName,
          region: target.region,
          portMapping: options.portMapping,
        },
      });
    });
  }

  async destroyDeployment(
    options: DestroyDeploymentOptions,
  ): Promise<Result<DestroyDeploymentResult, DestroyDeploymentError>> {
    const self = this;
    return Result.gen(async function* () {
      yield* self.#checkAborted(options.signal);

      let deploymentId = options.deploymentId;
      let deploymentDetailsCache: Map<string, DeploymentDetailData> | undefined;

      if (!deploymentId) {
        const appId = options.appId;
        if (!appId) {
          return Result.err(
            new MissingArgumentError({ field: "deploymentId" }),
          );
        }

        const deployments = yield* Result.await(
          self.#api.listAppDeployments(appId, options.signal),
        );
        const deploymentDetailResults = await Promise.all(
          deployments.map((d) => self.#api.getDeployment(d.id, options.signal)),
        );

        const details: DeploymentDetailData[] = [];
        for (const result of deploymentDetailResults) {
          if (result.isErr()) return result;
          details.push(result.value);
        }

        deploymentDetailsCache = new Map(details.map((d) => [d.id, d]));

        const deploymentInfos = details
          .map(
            (d): DeploymentInfo => ({
              id: d.id,
              status: d.status,
              createdAt: d.createdAt,
              previewDomain: d.previewDomain,
            }),
          )
          .sort(deploymentSortComparator);

        if (deploymentInfos.length === 0) {
          return Result.err(new NoDeploymentsFoundError({ appId }));
        }

        if (!options.interaction?.selectDeployment) {
          return Result.err(
            new MissingArgumentError({ field: "deploymentId" }),
          );
        }

        deploymentId =
          await options.interaction.selectDeployment(deploymentInfos);
      }

      const deployment =
        deploymentDetailsCache?.get(deploymentId) ??
        (yield* Result.await(
          self.#api.getDeployment(deploymentId, options.signal),
        ));

      const previousStatus = deployment.status ?? "unknown";
      const shouldStop =
        deployment.status === "running" || deployment.status === "provisioning";
      let stopped = false;

      if (shouldStop) {
        options.progress?.onStopRequested?.(deploymentId);
        yield* Result.await(
          self.#api.stopDeployment(deploymentId, options.signal),
        );

        yield* Result.await(
          pollDeploymentStatus(self.#api, deploymentId, {
            targetStatus: "stopped",
            timeoutSeconds: options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS,
            pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
            signal: options.signal,
          }),
        );
        stopped = true;
        options.progress?.onStopped?.(deploymentId);
      }

      yield* Result.await(
        self.#api.deleteDeployment(deploymentId, options.signal),
      );
      options.progress?.onDeleted?.(deploymentId);

      return Result.ok({
        deploymentId,
        previousStatus,
        stopped,
        deleted: true,
      });
    });
  }

  async destroyApp(
    options: DestroyAppOptions,
  ): Promise<Result<DestroyAppResult, DestroyAppError>> {
    const self = this;
    return Result.gen(async function* () {
      yield* self.#checkAborted(options.signal);

      const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
      const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;

      const deployments = yield* Result.await(
        self.#api.listAppDeployments(options.appId, options.signal),
      );
      const deploymentDetailResults = await Promise.all(
        deployments.map((d) => self.#api.getDeployment(d.id, options.signal)),
      );

      const deploymentDetails: DeploymentDetailData[] = [];
      for (const result of deploymentDetailResults) {
        if (result.isErr()) return result;
        deploymentDetails.push(result.value);
      }

      const activeDeployments = deploymentDetails.filter(
        (d) => d.status === "running" || d.status === "provisioning",
      );

      const succeededIds: string[] = [];
      const failures: Array<{
        deploymentId: string;
        error: DeploymentOperationError;
      }> = [];

      if (activeDeployments.length > 0) {
        const activeIds = activeDeployments.map((d) => d.id);
        options.progress?.onStoppingDeployments?.(activeIds);

        const stopResults = await Promise.all(
          activeDeployments.map((d) =>
            self.#api.stopDeployment(d.id, options.signal),
          ),
        );

        const stoppedCandidates: typeof activeDeployments = [];
        for (const [i, result] of stopResults.entries()) {
          // biome-ignore lint/style/noNonNullAssertion: both lists have the same length
          const activeDeployment = activeDeployments[i]!;
          if (result.isErr()) {
            failures.push({
              deploymentId: activeDeployment.id,
              error: result.error,
            });
          } else {
            stoppedCandidates.push(activeDeployment);
          }
        }

        if (stoppedCandidates.length > 0) {
          const pollResults = await Promise.all(
            stoppedCandidates.map((d) =>
              pollDeploymentStatus(self.#api, d.id, {
                targetStatus: "stopped",
                timeoutSeconds,
                pollIntervalMs,
                signal: options.signal,
              }),
            ),
          );

          for (const [i, result] of pollResults.entries()) {
            // biome-ignore lint/style/noNonNullAssertion: both lists have the same length
            const stoppedDeployment = stoppedCandidates[i]!;
            if (result.isErr()) {
              failures.push({
                deploymentId: stoppedDeployment.id,
                error: result.error,
              });
            } else {
              options.progress?.onDeploymentStopped?.(stoppedDeployment.id);
            }
          }
        }

        if (failures.length > 0) {
          return Result.err(
            new DestroyAggregateError({
              succeededDeploymentIds: succeededIds,
              failures,
              appDeleted: false,
            }),
          );
        }

        options.progress?.onAllDeploymentsStopped?.();
      }

      if (deployments.length > 0) {
        const deploymentIds = deployments.map((d) => d.id);
        options.progress?.onDeletingDeployments?.(deploymentIds);

        const deleteResults = await Promise.all(
          deployments.map((d) =>
            self.#api.deleteDeployment(d.id, options.signal),
          ),
        );

        for (const [i, result] of deleteResults.entries()) {
          // biome-ignore lint/style/noNonNullAssertion: both lists have the same length
          const deployment = deployments[i]!;
          if (result.isErr()) {
            failures.push({
              deploymentId: deployment.id,
              error: result.error,
            });
          } else {
            succeededIds.push(deployment.id);
            options.progress?.onDeploymentDeleted?.(deployment.id);
          }
        }

        if (failures.length > 0) {
          return Result.err(
            new DestroyAggregateError({
              succeededDeploymentIds: succeededIds,
              failures,
              appDeleted: false,
            }),
          );
        }

        options.progress?.onAllDeploymentsDeleted?.();
      }

      let appDeleted = false;
      if (!options.keepApp) {
        yield* Result.await(self.#api.deleteApp(options.appId, options.signal));
        appDeleted = true;
        options.progress?.onAppDeleted?.(options.appId);
      }

      return Result.ok({
        appId: options.appId,
        deletedDeploymentIds: succeededIds,
        appDeleted,
      });
    });
  }

  async createProject(
    options: CreateProjectOptions,
  ): Promise<Result<CreateProjectResult, ApiRequestError>> {
    const self = this;
    return Result.gen(async function* () {
      yield* self.#checkAborted(options.signal);
      const createDatabase = options.createDatabase ?? false;
      const response = yield* Result.await(
        self.#api.createProject(
          {
            name: options.name,
            createDatabase,
            region: options.region,
          },
          options.signal,
        ),
      );

      const result: CreateProjectResult = {
        id: response.id,
        name: response.name,
        defaultRegion: response.defaultRegion ?? undefined,
      };

      if (response.database) {
        const connection = response.database.connections?.[0];
        result.database = {
          id: response.database.id,
          name: response.database.name,
          region: response.database.region.id,
          connectionString:
            connection?.endpoints?.direct?.connectionString ?? undefined,
        };
      }

      return Result.ok(result);
    });
  }

  async listProjects(
    options: ListProjectsOptions = {},
  ): Promise<Result<ProjectInfo[], ApiRequestError>> {
    const self = this;
    return Result.gen(async function* () {
      yield* self.#checkAborted(options.signal);
      const projects = yield* Result.await(
        self.#api.listProjects(options.signal),
      );
      return Result.ok(
        projects.map(
          (p): ProjectInfo => ({
            id: p.id,
            name: p.name,
            defaultRegion: p.defaultRegion ?? undefined,
          }),
        ),
      );
    });
  }

  async listApps(
    options: ListAppsOptions,
  ): Promise<Result<AppInfo[], ApiRequestError>> {
    const self = this;
    return Result.gen(async function* () {
      yield* self.#checkAborted(options.signal);
      const apps = yield* Result.await(
        self.#api.listProjectApps(options.projectId, options.signal),
      );
      return Result.ok(
        apps.map(
          (a): AppInfo => ({
            id: a.id,
            name: a.name,
            region: a.region.id,
            projectId: a.projectId,
          }),
        ),
      );
    });
  }

  async createApp(
    options: CreateAppOptions,
  ): Promise<Result<AppInfo, ApiRequestError>> {
    const self = this;
    return Result.gen(async function* () {
      yield* self.#checkAborted(options.signal);
      const response = yield* Result.await(
        self.#api.createProjectApp(
          options.projectId,
          {
            displayName: options.appName,
            ...(options.region !== undefined
              ? { regionId: options.region }
              : {}),
          },
          options.signal,
        ),
      );
      return Result.ok({
        id: response.id,
        name: response.name,
        region: response.region.id,
        projectId: options.projectId,
      });
    });
  }

  async showApp(
    options: ShowAppOptions,
  ): Promise<Result<AppDetail, ApiRequestError>> {
    const self = this;
    return Result.gen(async function* () {
      yield* self.#checkAborted(options.signal);
      const a = yield* Result.await(
        self.#api.getApp(options.appId, options.signal),
      );
      return Result.ok({
        id: a.id,
        name: a.name,
        region: a.region.id,
        projectId: a.projectId,
        latestDeploymentId: a.latestDeploymentId,
        appEndpointDomain: a.appEndpointDomain,
      });
    });
  }

  async deleteApp(
    options: DeleteAppOptions,
  ): Promise<Result<void, ApiRequestError>> {
    const self = this;
    return Result.gen(async function* () {
      yield* self.#checkAborted(options.signal);
      yield* Result.await(self.#api.deleteApp(options.appId, options.signal));
      return Result.ok();
    });
  }

  async listDeployments(
    options: ListDeploymentsOptions,
  ): Promise<Result<DeploymentInfo[], ApiRequestError>> {
    const self = this;
    return Result.gen(async function* () {
      yield* self.#checkAborted(options.signal);
      const deployments = yield* Result.await(
        self.#api.listAppDeployments(options.appId, options.signal),
      );
      const detailResults = await Promise.all(
        deployments.map((d) => self.#api.getDeployment(d.id, options.signal)),
      );
      const details: DeploymentDetailData[] = [];
      for (const result of detailResults) {
        if (result.isErr()) {
          // listAppDeployments returns deployments that have previously been deleted
          // and can't be fetched using getDeployment so we need to filter those out.
          if (ApiError.is(result.error) && result.error.statusCode === 404)
            continue;
          return result;
        }
        details.push(result.value);
      }
      return Result.ok(
        details.map(
          (d): DeploymentInfo => ({
            id: d.id,
            status: d.status,
            createdAt: d.createdAt,
            previewDomain: d.previewDomain,
          }),
        ),
      );
    });
  }

  async showDeployment(
    options: ShowDeploymentOptions,
  ): Promise<Result<DeploymentDetail, ApiRequestError>> {
    const self = this;
    return Result.gen(async function* () {
      yield* self.#checkAborted(options.signal);
      const d = yield* Result.await(
        self.#api.getDeployment(options.deploymentId, options.signal),
      );
      return Result.ok({
        id: d.id,
        serviceId: d.serviceId,
        status: d.status,
        createdAt: d.createdAt,
        previewDomain: d.previewDomain,
        envVars: d.envVars ?? undefined,
      });
    });
  }

  async startDeployment(
    options: StartDeploymentOptions,
  ): Promise<Result<void, ApiRequestError>> {
    const self = this;
    return Result.gen(async function* () {
      yield* self.#checkAborted(options.signal);
      yield* Result.await(
        self.#api.startDeployment(options.deploymentId, options.signal),
      );
      return Result.ok();
    });
  }

  async stopDeployment(
    options: StopDeploymentOptions,
  ): Promise<Result<void, ApiRequestError>> {
    const self = this;
    return Result.gen(async function* () {
      yield* self.#checkAborted(options.signal);
      yield* Result.await(
        self.#api.stopDeployment(options.deploymentId, options.signal),
      );
      return Result.ok();
    });
  }

  async deleteDeployment(
    options: DeleteDeploymentOptions,
  ): Promise<Result<void, ApiRequestError>> {
    const self = this;
    return Result.gen(async function* () {
      yield* self.#checkAborted(options.signal);
      yield* Result.await(
        self.#api.deleteDeployment(options.deploymentId, options.signal),
      );
      return Result.ok();
    });
  }

  async promote(
    options: PromoteOptions,
  ): Promise<Result<PromoteResult, PromoteError>> {
    const self = this;
    return Result.gen(async function* () {
      yield* self.#checkAborted(options.signal);

      let deploymentId = options.deploymentId;

      if (!deploymentId) {
        const deployments = yield* Result.await(
          self.#api.listAppDeployments(options.appId, options.signal),
        );
        const detailResults = await Promise.all(
          deployments.map((d) => self.#api.getDeployment(d.id, options.signal)),
        );
        const details: DeploymentDetailData[] = [];
        for (const result of detailResults) {
          if (result.isErr()) {
            if (ApiError.is(result.error) && result.error.statusCode === 404)
              continue;
            return result;
          }
          details.push(result.value);
        }

        const deploymentInfos = details
          .map(
            (d): DeploymentInfo => ({
              id: d.id,
              status: d.status,
              createdAt: d.createdAt,
              previewDomain: d.previewDomain,
            }),
          )
          .sort(deploymentSortComparator);

        if (deploymentInfos.length === 0) {
          return Result.err(
            new NoDeploymentsFoundError({ appId: options.appId }),
          );
        }

        if (!options.interaction?.selectDeployment) {
          return Result.err(
            new MissingArgumentError({ field: "deploymentId" }),
          );
        }

        deploymentId =
          await options.interaction.selectDeployment(deploymentInfos);
      }

      const deployment = yield* Result.await(
        self.#api.getDeployment(deploymentId, options.signal),
      );
      let deploymentStarted = false;

      if (deployment.status !== "running") {
        options.progress?.onDeploymentStarting?.(deploymentId);

        if (deployment.status !== "provisioning") {
          options.progress?.onDeploymentStartRequested?.();
          const startResult = await self.#api.startDeployment(
            deploymentId,
            options.signal,
          );
          if (startResult.isErr()) {
            options.progress?.onPromoteFailed?.(startResult.error);
            return startResult;
          }
        }

        const pollResult = await pollDeploymentStatus(self.#api, deploymentId, {
          targetStatus: "running",
          timeoutSeconds: options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS,
          pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
          signal: options.signal,
          onStatusChange: options.progress?.onStatusChange,
        });
        if (pollResult.isErr()) {
          options.progress?.onPromoteFailed?.(pollResult.error);
          return pollResult;
        }
        deploymentStarted = true;
        options.progress?.onDeploymentRunning?.();
      }

      options.progress?.onPromoteStart?.();
      const promoteResult = await self.#api.promoteApp(
        options.appId,
        { deploymentId },
        options.signal,
      );
      if (promoteResult.isErr()) {
        options.progress?.onPromoteFailed?.(promoteResult.error);
        return promoteResult;
      }
      options.progress?.onPromoted?.(promoteResult.value.appEndpointDomain);

      return Result.ok({
        appId: options.appId,
        deploymentId,
        appEndpointDomain: promoteResult.value.appEndpointDomain,
        deploymentStarted,
      });
    });
  }

  async #promoteAndSwitchover(
    appId: string,
    newDeploymentId: string,
    previousDeploymentId: string | null,
    destroyOldDeployment: boolean,
    signal?: AbortSignal,
    progress?: DeployProgress,
  ): Promise<
    Result<
      {
        appEndpointDomain: string;
        previousDeploymentId: string | null;
        previousDeploymentAction: "stopped" | "destroyed" | null;
      },
      DeployError
    >
  > {
    progress?.onPromoteStart?.();
    const promoteResult = await this.#api.promoteApp(
      appId,
      { deploymentId: newDeploymentId },
      signal,
    );

    if (promoteResult.isErr()) {
      progress?.onPromoteFailed?.(promoteResult.error);
      progress?.onCleanupDanglingDeployment?.(newDeploymentId);
      await this.#cleanupDanglingDeployment(
        newDeploymentId,
        undefined,
        progress,
      );
      return promoteResult;
    }

    const appEndpointDomain = promoteResult.value.appEndpointDomain;
    progress?.onPromoted?.(appEndpointDomain);

    let previousDeploymentAction: "stopped" | "destroyed" | null = null;

    if (previousDeploymentId && previousDeploymentId !== newDeploymentId) {
      progress?.onOldDeploymentStopping?.(previousDeploymentId);
      const stopResult = await this.#api.stopDeployment(
        previousDeploymentId,
        signal,
      );

      if (stopResult.isOk()) {
        const pollResult = await pollDeploymentStatus(
          this.#api,
          previousDeploymentId,
          {
            targetStatus: "stopped",
            timeoutSeconds: 30,
            pollIntervalMs: 1000,
            signal,
          },
        );

        if (pollResult.isOk()) {
          previousDeploymentAction = "stopped";
          progress?.onOldDeploymentStopped?.(previousDeploymentId);

          if (destroyOldDeployment) {
            progress?.onOldDeploymentDeleting?.(previousDeploymentId);
            const deleteResult = await this.#api.deleteDeployment(
              previousDeploymentId,
              signal,
            );
            if (deleteResult.isOk()) {
              previousDeploymentAction = "destroyed";
              progress?.onOldDeploymentDeleted?.(previousDeploymentId);
            } else {
              progress?.onOldDeploymentDeleteFailed?.(previousDeploymentId);
            }
          }
        } else {
          progress?.onOldDeploymentStopFailed?.(previousDeploymentId);
        }
      } else {
        progress?.onOldDeploymentStopFailed?.(previousDeploymentId);
      }
    }

    return Result.ok({
      appEndpointDomain,
      previousDeploymentId,
      previousDeploymentAction,
    });
  }

  async #cleanupDanglingDeployment(
    deploymentId: string,
    signal?: AbortSignal,
    progress?: DeployProgress,
  ): Promise<void> {
    const stopResult = await this.#api.stopDeployment(deploymentId, signal);
    if (stopResult.isErr()) {
      progress?.onCleanupDanglingDeploymentFailed?.(deploymentId);
      return;
    }

    const pollResult = await pollDeploymentStatus(this.#api, deploymentId, {
      targetStatus: "stopped",
      timeoutSeconds: 30,
      pollIntervalMs: 1000,
      signal,
    });
    if (pollResult.isErr()) {
      progress?.onCleanupDanglingDeploymentFailed?.(deploymentId);
      return;
    }

    const deleteResult = await this.#api.deleteDeployment(deploymentId, signal);
    if (deleteResult.isErr()) {
      progress?.onCleanupDanglingDeploymentFailed?.(deploymentId);
      return;
    }

    progress?.onCleanupDanglingDeploymentComplete?.(deploymentId);
  }

  #checkAborted(signal?: AbortSignal): Result<void, CancelledError> {
    try {
      signal?.throwIfAborted();
      return Result.ok(undefined);
    } catch {
      return Result.err(new CancelledError());
    }
  }

  async #resolveDeployTarget(options: {
    projectId?: string;
    appId?: string;
    appName?: string;
    region?: string;
    signal?: AbortSignal;
    interaction?: DeployInteraction;
  }): Promise<
    Result<
      {
        projectId: string;
        appId: string;
        appName: string;
        region: string;
        branchId: string | null;
        isNewApp: boolean;
      },
      | MissingArgumentError
      | CancelledError
      | AuthenticationError
      | ApiError
      | InvalidOptionsError
    >
  > {
    const self = this;
    return Result.gen(async function* () {
      if (options.appId) {
        const app = yield* Result.await(
          self.#api.getApp(options.appId, options.signal),
        );
        if (!app.projectId) {
          return Result.err(new MissingArgumentError({ field: "projectId" }));
        }
        const requestedProjectId = options.projectId?.replace(/^proj_/, "");
        const appProjectId = app.projectId.replace(/^proj_/, "");
        if (
          requestedProjectId !== undefined &&
          requestedProjectId !== appProjectId
        ) {
          return Result.err(
            new InvalidOptionsError({
              message: `App ${app.id} belongs to project ${app.projectId}, but project ${options.projectId} was provided`,
            }),
          );
        }
        return Result.ok({
          projectId: app.projectId,
          appId: app.id,
          appName: app.name ?? app.id,
          region: app.region.id,
          branchId: app.branchId ?? null,
          isNewApp: false,
        });
      }

      let projectId = options.projectId;
      if (!projectId) {
        const projects = yield* Result.await(
          self.#api.listProjects(options.signal),
        );
        const projectInfos = projects.map(
          (p): ProjectInfo => ({
            id: p.id,
            name: p.name,
            defaultRegion: p.defaultRegion ?? undefined,
          }),
        );

        if (!options.interaction?.selectProject) {
          return Result.err(new MissingArgumentError({ field: "projectId" }));
        }

        projectId = await options.interaction.selectProject(projectInfos);
      }

      if (options.appName && options.region) {
        const response = yield* Result.await(
          self.#api.createProjectApp(
            projectId,
            {
              displayName: options.appName,
              ...(options.region !== undefined
                ? { regionId: options.region }
                : {}),
            },
            options.signal,
          ),
        );
        return Result.ok({
          projectId,
          appId: response.id,
          appName: response.name,
          region: response.region.id,
          branchId: response.branchId ?? null,
          isNewApp: true,
        });
      }

      const apps = yield* Result.await(
        self.#api.listProjectApps(projectId, options.signal),
      );
      const appInfos = apps.map(
        (a): AppInfo => ({
          id: a.id,
          name: a.name,
          region: a.region.id,
          projectId: a.projectId,
        }),
      );

      if (!options.interaction?.selectApp) {
        return Result.err(new MissingArgumentError({ field: "appId" }));
      }

      const selectedAppId = await options.interaction.selectApp(appInfos);

      if (selectedAppId !== null) {
        const selected = apps.find((a) => a.id === selectedAppId);
        invariant(selected, "selectApp returned app ID not in the list");
        const app = yield* Result.await(
          self.#api.getApp(selectedAppId, options.signal),
        );
        return Result.ok({
          projectId,
          appId: selectedAppId,
          appName: app.name ?? selected.name,
          region: app.region.id,
          branchId: app.branchId ?? null,
          isNewApp: false,
        });
      }

      let appName = options.appName;
      if (!appName) {
        if (!options.interaction?.provideAppName) {
          return Result.err(new MissingArgumentError({ field: "appName" }));
        }
        appName = await options.interaction.provideAppName();
      }

      const region =
        options.region ?? (await options.interaction?.selectRegion?.(REGIONS));

      const response = yield* Result.await(
        self.#api.createProjectApp(
          projectId,
          {
            displayName: appName,
            ...(region !== undefined ? { regionId: region } : {}),
          },
          options.signal,
        ),
      );

      return Result.ok({
        projectId,
        appId: response.id,
        appName: response.name,
        region: response.region.id,
        branchId: response.branchId ?? null,
        isNewApp: true,
      });
    });
  }

  async #applyEnvVars(
    projectId: string,
    branchId: string | null,
    envVars: Record<string, string | null> | undefined,
    signal?: AbortSignal,
  ): Promise<Result<void, ApiRequestError>> {
    if (!envVars || Object.keys(envVars).length === 0) {
      return Result.ok(undefined);
    }

    const self = this;
    return Result.gen(async function* () {
      const scope = yield* Result.await(
        self.#resolveEnvironmentVariableScope(projectId, branchId, signal),
      );
      for (const [key, value] of Object.entries(envVars)) {
        yield* self.#checkAborted(signal);
        const existing = yield* Result.await(
          self.#api.listEnvironmentVariables({ ...scope, key }, signal),
        );
        const current = existing[0];

        if (value === null) {
          if (current) {
            yield* self.#checkAborted(signal);
            // Do not pass signal to mutating transport; aborting it can make remote completion ambiguous.
            yield* Result.await(
              self.#api.deleteEnvironmentVariable(current.id),
            );
          }
          continue;
        }

        if (current) {
          yield* self.#checkAborted(signal);
          // Do not pass signal to mutating transport; aborting it can make remote completion ambiguous.
          yield* Result.await(
            self.#api.updateEnvironmentVariable(current.id, { value }),
          );
          continue;
        }

        yield* self.#checkAborted(signal);
        // Do not pass signal to mutating transport; aborting it can make remote completion ambiguous.
        yield* Result.await(
          self.#api.createEnvironmentVariable({ ...scope, key, value }),
        );
      }

      return Result.ok(undefined);
    });
  }

  async #resolveEnvironmentVariableScope(
    projectId: string,
    branchId: string | null,
    signal?: AbortSignal,
  ): Promise<
    Result<
      | { projectId: string; class: "production" }
      | {
          projectId: string;
          class: "preview";
          branchId: string;
        },
      ApiRequestError
    >
  > {
    if (!branchId) {
      return Result.ok({ projectId, class: "production" });
    }

    const branch = await this.#api.getBranch(branchId, signal);
    if (branch.isErr()) return branch;

    if (branch.value.role === "preview") {
      return Result.ok({ projectId, class: "preview", branchId });
    }

    return Result.ok({ projectId, class: "production" });
  }

  async #uploadArtifactResult(
    url: string,
    body: Uint8Array,
    signal?: AbortSignal,
  ): Promise<Result<void, ArtifactError | CancelledError>> {
    return Result.tryPromise({
      try: async () => {
        await uploadArtifact(url, body, signal);
      },
      catch: (e) => {
        if (signal?.aborted) return new CancelledError();
        return new ArtifactError({
          message: e instanceof Error ? e.message : String(e),
          cause: e,
        });
      },
    });
  }
}

/**
 * Sort deployments: running/provisioning first, then by createdAt descending.
 */
function deploymentSortComparator(
  a: DeploymentInfo,
  b: DeploymentInfo,
): number {
  const statusPriority = (s: string) =>
    s === "running" ? 0 : s === "provisioning" ? 1 : 2;
  const aPriority = statusPriority(a.status);
  const bPriority = statusPriority(b.status);
  if (aPriority !== bPriority) return aPriority - bPriority;
  return b.createdAt.localeCompare(a.createdAt);
}
