import { createPublicKey } from 'node:crypto';
import {
	createReadStream,
	createWriteStream,
	existsSync,
	mkdirSync,
	unlinkSync,
	writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { createGzip } from 'node:zlib';
import { StructuredError } from '@agentuity/core';
import {
	type BuildMetadata,
	type Deployment,
	type DeploymentComplete,
	type DeploymentInstructions,
	type DeploymentStatusResult,
	getAppBaseURL,
	type MalwareCheckResult,
	projectDeploymentComplete,
	projectDeploymentCreate,
	projectDeploymentMalwareCheck,
	projectDeploymentStatus,
	projectDeploymentUpdate,
	projectEnvUpdate,
	projectGet,
	projectUpdateRegion,
	validateResources,
} from '@agentuity/server';
import { z } from 'zod';
import { getUserAgent } from '../../api';
import { BuildReportCollector, clearGlobalCollector, setGlobalCollector } from '../../build-report';
import { getCachedProject, setCachedProject } from '../../cache';
import { getCommand } from '../../command-prefix';
import {
	getDefaultConfigDir,
	getGlobalCatalystAPIClient,
	getStreamURL,
	loadBuildMetadata,
	loadProjectSDKKey,
	saveProjectDir,
	updateProjectConfig,
} from '../../config';
import { encryptFIPSKEMDEMStream } from '../../crypto/box';
import * as domain from '../../domain';
import {
	mergeDeployRolloutMetadata,
	resolveDeployRolloutMetadata,
} from '../../deploy-rollout-metadata';
import {
	filterAgentuitySdkKeys,
	findExistingEnvFile,
	readEnvFile,
	splitEnvAndSecrets,
} from '../../env-util';
import { ErrorCode, getExitCode } from '../../errors';
import {
	pauseStepUI,
	runSteps,
	type Step,
	type StepContext,
	StepInterruptError,
	stepError,
	stepSkipped,
	stepSuccess,
} from '../../steps';
import * as tui from '../../tui';
import { createSubcommand, DeployOptionsSchema } from '../../types';
import { validateAptDependencies } from '../../utils/apt-validator';
import { extractDependencies } from '../../utils/deps';
import { zipDir } from '../../utils/zip';
import { typecheck } from '../build/typecheck';
import { viteBundle } from '../build/vite-bundler';
import { getProjectGithubStatus } from '../git/api';
import { runGitLink } from '../git/link';
import { runForkedDeploy } from './deploy-fork';

const DeploymentCancelledError = StructuredError(
	'DeploymentCancelled',
	'Deployment cancelled by user'
);

const DeployResponseSchema = z.object({
	success: z.boolean().describe('Whether deployment succeeded'),
	deploymentId: z.string().describe('Deployment ID'),
	projectId: z.string().describe('Project ID'),
	rolloutId: z
		.string()
		.optional()
		.describe('Genesis managed rollout id when fan-out was triggered'),
	logs: z.array(z.string()).optional().describe('The deployment startup logs'),
	urls: z
		.object({
			deployment: z.string().describe('Deployment-specific URL'),
			latest: z.string().describe('Latest/active deployment URL'),
			custom: z.array(z.string()).optional().describe('Custom domain URLs'),
			dashboard: z.string().describe('The dashboard URL for the deployment'),
		})
		.optional()
		.describe('Deployment URLs'),
});

/**
 * Render the final "Uploaded N assets ..." line after a successful CDN
 * upload pass. Reports both the original and on-the-wire totals when gzip
 * compressed at least one file, so the user can see the real transfer cost.
 */
function formatUploadSummary(count: number, rawBytes: number, transferredBytes: number): string {
	const noun = tui.plural(count, 'asset', 'assets');
	// When no compression happened (icons, fonts, binaries), rawBytes ===
	// transferredBytes and the extra detail would just be noise.
	if (transferredBytes === rawBytes) {
		return `✓ Uploaded ${count} ${noun} (${tui.formatBytes(rawBytes)}) to CDN`;
	}
	return `✓ Uploaded ${count} ${noun} (${tui.formatBytes(transferredBytes)} on wire, ${tui.formatBytes(rawBytes)} raw) to CDN`;
}

export const deploySubcommand = createSubcommand({
	name: 'deploy',
	description: 'Deploy project to the Agentuity Cloud',
	tags: [
		'mutating',
		'creates-resource',
		'slow',
		'api-intensive',
		'requires-auth',
		'requires-project',
	],
	examples: [
		{ command: getCommand('cloud deploy'), description: 'Deploy current project' },
		{
			command: getCommand('cloud deploy --log-level=debug'),
			description: 'Deploy with verbose output',
		},
	],
	toplevel: true,
	idempotent: false,
	requires: { auth: true, project: true, apiClient: true },
	prerequisites: ['auth login'],
	resourceRules: [
		{
			resource: 'project',
			required: true,
			flag: 'project-id',
			envVar: 'AGENTUITY_CLOUD_PROJECT_ID',
			impliedFrom: 'agentuity.json',
		},
		{
			resource: 'region',
			required: true,
			flag: 'region',
			envVar: 'AGENTUITY_REGION',
			configPref: 'region',
			operationType: 'mutate',
		},
	],
	schema: {
		options: z.intersection(
			DeployOptionsSchema,
			z.object({
				reportFile: z
					.string()
					.optional()
					.describe(
						'file path to save build report JSON with errors, warnings, and diagnostics'
					),
				childMode: z
					.boolean()
					.optional()
					.default(false)
					.describe('Internal: run as forked child process'),
				confirm: z
					.boolean()
					.optional()
					.default(false)
					.describe('Confirm region change without prompting (for non-TTY environments)'),
			})
		),
		response: DeployResponseSchema,
	},

	async handler(ctx) {
		let { project } = ctx;
		const { apiClient, projectDir, config, options, logger, opts, auth } = ctx;

		// Verify project access and offer import if needed
		const { reconcileProject } = await import('../project/reconcile');
		const { isTTY } = await import('../../auth');

		const reconcileResult = await reconcileProject({
			dir: projectDir,
			auth,
			apiClient,
			config: config!,
			logger,
			interactive: isTTY(),
		});

		if (reconcileResult.status === 'error') {
			tui.fatal(reconcileResult.message!, ErrorCode.PROJECT_NOT_FOUND);
		}

		if (reconcileResult.status === 'skipped') {
			tui.fatal(
				'Project must be registered with Agentuity Cloud to deploy.',
				ErrorCode.PROJECT_NOT_FOUND
			);
		}

		if (reconcileResult.status === 'imported' && reconcileResult.project) {
			// Project was imported - use the new project config
			project = reconcileResult.project;
			tui.newline();
		}

		// Check if local region differs from server region and handle confirmation
		const hasTTY = process.stdin.isTTY && process.stdout.isTTY;
		if (project.region) {
			try {
				// Check cache first to avoid duplicate API calls
				const profile = config?.name ?? 'default';
				let serverProject = getCachedProject(profile, project.projectId);
				if (!serverProject) {
					serverProject = await projectGet(apiClient, {
						id: project.projectId,
						keys: false,
					});
					setCachedProject(profile, project.projectId, serverProject);
				}
				const serverRegion = serverProject.cloudRegion;

				if (serverRegion && serverRegion !== project.region) {
					logger.debug(
						'Region mismatch detected: local=%s, server=%s',
						project.region,
						serverRegion
					);

					if (hasTTY) {
						// Interactive mode: prompt for confirmation
						tui.newline();
						tui.warning(
							`Region change detected: ${tui.bold(serverRegion)} → ${tui.bold(project.region)}`
						);
						const confirmChange = await tui.confirm(
							'Do you want to update the project region?',
							false
						);

						if (!confirmChange) {
							tui.newline();
							tui.fatal(
								'Deployment cancelled. Update the region in agentuity.json or keep the current region.',
								ErrorCode.CONFIG_INVALID
							);
						}
					} else {
						// Non-interactive mode: require --confirm flag
						if (!opts.confirm) {
							tui.fatal(
								`Region change detected (${serverRegion} → ${project.region}). Use --confirm flag to proceed with region change in non-interactive mode.`,
								ErrorCode.CONFIG_INVALID
							);
						}
						logger.debug('Region change confirmed via --confirm flag');
					}

					// Update the region on the server
					await tui.spinner({
						message: 'Updating project region...',
						type: 'simple',
						callback: async () => {
							await projectUpdateRegion(apiClient, project.projectId, project.region);
						},
					});
					tui.success(`Project region updated to ${tui.bold(project.region)}`);
					tui.newline();
				}
			} catch (err) {
				// If it's a fatal error we threw, re-throw it
				if (err instanceof Error && err.message.includes('Region change detected')) {
					throw err;
				}
				// Log other errors as non-fatal and continue (e.g., network issues fetching project)
				logger.trace('Failed to check project region: %s', err);
			}
		}

		// Initialize build report collector if reportFile is specified
		const collector = new BuildReportCollector();
		if (opts.reportFile) {
			collector.setOutputPath(opts.reportFile);
			collector.enableAutoWrite();
			setGlobalCollector(collector);
		}

		let deployment: Deployment | undefined;
		let build: BuildMetadata | undefined;
		let instructions: DeploymentInstructions | undefined;
		let complete: DeploymentComplete | undefined;
		let statusResult: DeploymentStatusResult | undefined;
		let malwareCheckPromise: Promise<MalwareCheckResult | null> | undefined;
		const logs: string[] = [];

		const sdkKey = await loadProjectSDKKey(ctx.logger, ctx.projectDir);

		// Ensure SDK key is present before proceeding
		if (!sdkKey) {
			ctx.logger.fatal(
				'The AGENTUITY_SDK_KEY value not found in the .env file in this folder. Ensure you are inside a valid Agentuity project folder and run "%s" to pull your environment from the cloud.',
				getCommand('cloud env pull')
			);
		}

		// Check if we're running as a forked child process
		const isChildProcess = opts.childMode || process.env.AGENTUITY_FORK_PARENT === '1';
		const deploymentEnv = process.env.AGENTUITY_DEPLOYMENT;

		// If not in child mode and no pre-created deployment, run as fork wrapper to capture crashes
		// (CI builds set AGENTUITY_DEPLOYMENT, fork wrapper also sets it for the child)
		if (!isChildProcess && !deploymentEnv) {
			logger.debug('Running deploy as fork wrapper');

			// First, create the deployment to get the ID, publicKey, and stream URL
			const deploymentConfig = project.deployment ?? {};

			// Validate resource configuration before creating deployment
			if (deploymentConfig.resources) {
				const validation = validateResources(deploymentConfig.resources);
				if (!validation.valid) {
					tui.error('Invalid resource configuration in agentuity.json:');
					for (const error of validation.errors) {
						tui.error(`  ${error}`);
					}
					tui.fatal('Fix the resource configuration and try again.', ErrorCode.CONFIG_INVALID);
				}
			}

			// Validate apt dependencies before creating deployment
			if (deploymentConfig.dependencies && deploymentConfig.dependencies.length > 0) {
				const aptValidation = await tui.spinner({
					message: 'Validating apt dependencies...',
					type: 'simple',
					callback: async () => {
						return await validateAptDependencies(
							deploymentConfig.dependencies!,
							project.region,
							config,
							logger
						);
					},
				});

				if (aptValidation.invalid.length > 0) {
					if (options.json) {
						return {
							success: false,
							deploymentId: '',
							projectId: project.projectId,
							errors: aptValidation.invalid.map((pkg) => ({
								type: 'invalid-apt-dependency',
								package: pkg.package,
								error: pkg.error,
								searchUrl: pkg.searchUrl,
								availableVersions: pkg.availableVersions,
							})),
						} as never;
					}

					tui.error('Invalid apt dependencies in agentuity.json:');
					tui.newline();
					for (const pkg of aptValidation.invalid) {
						tui.bullet(`${tui.bold(pkg.package)}: ${pkg.error}`);
						if (pkg.availableVersions && pkg.availableVersions.length > 0) {
							tui.muted(`    Available versions: ${pkg.availableVersions.join(', ')}`);
						}
						tui.muted(`    Search: ${tui.link(pkg.searchUrl)}`);
					}
					tui.newline();
					tui.fatal(
						'Fix the apt dependencies and try again. Search for valid packages at: https://packages.debian.org/stable/',
						ErrorCode.CONFIG_INVALID
					);
				}
			}

			const initialDeployment = await projectDeploymentCreate(
				apiClient,
				project.projectId,
				deploymentConfig
			);

			logger.debug('Created deployment: %s', initialDeployment.id);

			// Build args to pass to child, excluding child-mode specific ones
			const childArgs: string[] = [];
			if (opts.logsUrl) childArgs.push(`--logs-url=${opts.logsUrl}`);
			if (opts.trigger) childArgs.push(`--trigger=${opts.trigger}`);
			if (opts.commitUrl) childArgs.push(`--commit-url=${opts.commitUrl}`);
			if (opts.message) childArgs.push(`--message=${opts.message}`);
			if (opts.commit) childArgs.push(`--commit=${opts.commit}`);
			if (opts.branch) childArgs.push(`--branch=${opts.branch}`);
			if (opts.provider) childArgs.push(`--provider=${opts.provider}`);
			if (opts.repo) childArgs.push(`--repo=${opts.repo}`);
			if (opts.event) childArgs.push(`--event=${opts.event}`);
			if (opts.pullRequestNumber)
				childArgs.push(`--pull-request-number=${opts.pullRequestNumber}`);
			if (opts.pullRequestUrl) childArgs.push(`--pull-request-url=${opts.pullRequestUrl}`);
			if (opts.skipDnsValidation) childArgs.push('--skip-dns-validation');
			if (opts.skipTypeCheck) childArgs.push('--skip-type-check');
			if (opts.metadata) childArgs.push(`--metadata=${opts.metadata}`);

			const result = await runForkedDeploy({
				projectDir,
				apiClient,
				logger,
				sdkKey: sdkKey!,
				deployment: initialDeployment,
				args: childArgs,
			});

			if (!result.success) {
				const appUrl = getAppBaseURL(
					process.env.AGENTUITY_REGION ?? config?.name,
					config?.overrides
				);
				const deploymentLink = `${appUrl}/projects/${project.projectId}/deployments/${initialDeployment.id}`;
				tui.fatal(
					`Deployment failed: ${tui.link(deploymentLink, 'Deployment Page')}`,
					ErrorCode.BUILD_FAILED
				);
			}

			return {
				success: true,
				deploymentId: initialDeployment.id,
				projectId: project.projectId,
				logs: result.deployResult?.logs,
				urls: result.deployResult?.urls,
			};
		}
		let useExistingDeployment = false;
		if (deploymentEnv) {
			const ExistingDeploymentSchema = z.object({
				id: z.string(),
				orgId: z.string(),
				publicKey: z.string(),
			});
			try {
				const parsed = JSON.parse(deploymentEnv);
				const result = ExistingDeploymentSchema.safeParse(parsed);
				if (result.success) {
					deployment = result.data;
					useExistingDeployment = true;
					logger.debug('Using existing deployment: %s', result.data.id);
				} else {
					const errors = result.error.issues
						.map((i) => `${i.path.join('.')}: ${i.message}`)
						.join(', ');
					logger.fatal(`Invalid AGENTUITY_DEPLOYMENT schema: ${errors}`);
				}
			} catch (err) {
				logger.fatal(`Failed to parse AGENTUITY_DEPLOYMENT: ${err}`);
			}
		}

		// Create a unified abort controller for the entire deploy flow
		const deployAbortController = new AbortController();
		const deployAbortHandler = () => {
			deployAbortController.abort();
		};
		process.on('SIGINT', deployAbortHandler);

		// Start malware check async (runs in parallel with build)
		if (deployment) {
			malwareCheckPromise = (async () => {
				try {
					logger.debug('Starting malware dependency check');
					const packages = await extractDependencies(projectDir, logger);
					if (packages.length === 0) {
						logger.debug('No packages to check for malware');
						return null;
					}
					logger.debug('Checking %d packages for malware', packages.length);
					// Use Catalyst client directly for malware check (security routes are on Catalyst)
					const catalystClient = await getGlobalCatalystAPIClient(
						logger,
						auth,
						config?.name,
						undefined,
						config
					);
					const result = await projectDeploymentMalwareCheck(
						catalystClient,
						deployment!.id,
						packages,
						deployAbortController.signal
					);
					logger.debug(
						'Malware check complete: action=%s, flagged=%d',
						result.action,
						result.summary.flagged
					);
					return result;
				} catch (error) {
					logger.warn('Malware check failed: %s', error);
					return null;
				}
			})();
		}

		try {
			await saveProjectDir(projectDir);

			// Check GitHub status and prompt for setup if not linked
			// Skip in non-TTY environments (CI, automated runs) to prevent hanging
			if (!useExistingDeployment && !project.skipGitSetup && hasTTY) {
				try {
					const githubStatus = await getProjectGithubStatus(apiClient, project.projectId);

					if (githubStatus.linked && githubStatus.autoDeploy) {
						// GitHub is already set up with auto-deploy, tell user to push instead
						tui.newline();
						tui.info(
							`This project is linked to ${tui.bold(githubStatus.repoFullName ?? 'GitHub')} with automatic deployments enabled.`
						);
						tui.newline();
						tui.info(
							`Push a commit to the ${tui.bold(githubStatus.branch ?? 'main')} branch to trigger a deployment.`
						);
						tui.newline();
						throw new DeploymentCancelledError();
					}

					if (!githubStatus.linked) {
						tui.newline();
						const wantSetup = await tui.confirm(
							'Would you like to set up automatic deployments from GitHub?'
						);

						if (wantSetup) {
							const result = await runGitLink({
								apiClient,
								projectId: project.projectId,
								logger,
								skipAlreadyLinkedCheck: true,
								config,
							});

							if (result.linked && result.autoDeploy) {
								// GitHub linked with auto-deploy, tell user to push instead
								tui.newline();
								tui.info('GitHub integration set up successfully!');
								tui.newline();
								tui.info('Push a commit to trigger your first deployment.');
								tui.newline();
								throw new DeploymentCancelledError();
							}
							if (result.linked) {
								// Linked but auto-deploy disabled, continue with manual deploy
								tui.newline();
								tui.info('GitHub repository linked. Continuing with deployment...');
								tui.newline();
							}
						} else {
							await updateProjectConfig(projectDir, { skipGitSetup: true }, config);
							tui.newline();
							tui.info(
								`Skipping GitHub setup. Run ${tui.bold(getCommand('git link'))} later to enable it.`
							);
							tui.newline();
						}
					}
				} catch (err) {
					// Re-throw intentional cancellations
					if (err instanceof DeploymentCancelledError) {
						throw err;
					}
					// Log other errors as non-fatal and continue
					logger.trace('Failed to check GitHub status: %s', err);
				}
			}

			await runSteps(
				[
					opts.skipDnsValidation || !project.deployment?.domains?.length
						? null
						: {
								label: `Validate Custom ${tui.plural(project.deployment.domains.length, 'Domain', 'Domains')}`,
								run: async () => {
									if (project.deployment?.domains?.length) {
										try {
											await domain.promptForDNS(
												project.projectId,
												project.deployment.domains,
												project.region,
												config!,
												() => pauseStepUI(true)
											);
											return stepSuccess();
										} catch (ex) {
											return stepError(String(ex), ex as Error);
										}
									}
									return stepSkipped();
								},
							},
					{
						label: 'Sync Env & Secrets',
						run: async () => {
							try {
								const isCIBuild =
									useExistingDeployment && process.env.AGENTUITY_FORK_PARENT !== '1';
								if (isCIBuild) {
									return stepSkipped('skipped in CI build');
								}
								// Read env file
								const envFilePath = await findExistingEnvFile(projectDir);
								const localEnv = await readEnvFile(envFilePath);

								// Filter out AGENTUITY_ keys
								const filteredEnv = filterAgentuitySdkKeys(localEnv);

								if (Object.keys(filteredEnv).length === 0) {
									return stepSkipped('no variables to sync');
								}

								// Split into env and secrets
								const { env, secrets } = splitEnvAndSecrets(filteredEnv);

								if (Object.keys(env).length === 0 && Object.keys(secrets).length === 0) {
									return stepSkipped('no variables to sync');
								}

								// Push to cloud
								await projectEnvUpdate(apiClient, {
									id: project.projectId,
									env,
									secrets,
								});

								return stepSuccess();
							} catch (ex) {
								// Non-fatal: log warning but continue deployment
								const _ex = ex as Error;
								return stepSkipped(_ex.message ?? 'failed to sync env variables');
							}
						},
					},

					{
						label: 'Build, Verify and Package',
						run: async (stepCtx: StepContext) => {
							if (!deployment) {
								return stepError('deployment was null');
							}
							let capturedOutput: string[] = [];
							const rootDir = resolve(projectDir);

							if (!opts.skipTypeCheck) {
								// Run typecheck with collector for error reporting
								const endTypecheckDiagnostic = collector.startDiagnostic('typecheck');
								const started = Date.now();
								const typeResult = await typecheck(rootDir, { collector });
								endTypecheckDiagnostic();

								if (typeResult.success) {
									capturedOutput.push(
										tui.muted(`✓ Typechecked in ${Date.now() - started}ms`)
									);
								} else {
									// Errors already added to collector by typecheck()
									// Write report before returning error
									if (opts.reportFile) {
										await collector.forceWrite();
									}
									return stepError('Typecheck failed\n\n' + typeResult.output);
								}
							}
							try {
								const bundleResult = await viteBundle({
									rootDir,
									dev: false,
									deploymentId: deployment.id,
									orgId: deployment.orgId,
									projectId: project.projectId,
									region: project.region,
									logger: ctx.logger,
									deploymentOptions: opts,
									deploymentConfig: project.deployment,
									collector,
								});
								capturedOutput = [...capturedOutput, ...bundleResult.output];
								build = await loadBuildMetadata(join(projectDir, '.agentuity'));
								build = mergeDeployRolloutMetadata(
									build,
									resolveDeployRolloutMetadata(opts)
								);
								instructions = await projectDeploymentUpdate(
									apiClient,
									deployment.id,
									build,
									stepCtx.signal
								);
								return stepSuccess(capturedOutput.length > 0 ? capturedOutput : undefined);
							} catch (ex) {
								const _ex = ex as Error;
								// Write report before returning error
								if (opts.reportFile) {
									await collector.forceWrite();
								}
								return stepError(
									_ex.message ?? 'Error building your project',
									_ex,
									capturedOutput.length > 0 ? capturedOutput : undefined
								);
							}
						},
					},
					{
						label: 'Security Scan',
						run: async () => {
							if (!malwareCheckPromise) {
								return stepSkipped('malware check not started');
							}

							const result = await malwareCheckPromise;
							if (!result) {
								return stepSkipped('malware check unavailable');
							}

							if (result.action === 'block' && result.findings.length > 0) {
								if (opts.reportFile) {
									for (const finding of result.findings) {
										collector.addGeneralError(
											'deploy',
											`Malicious package: ${finding.name}@${finding.version} (${finding.reason})`
										);
									}
									await collector.forceWrite();
								}

								const packageList = result.findings
									.map((f) => `• ${f.name}@${f.version} (${f.reason})`)
									.join('\n');

								// Pause step UI to cleanly render error box
								pauseStepUI(true);

								tui.newline();
								tui.errorBox(
									'Malicious Packages Detected',
									`Your deployment was blocked because it contains known malicious packages:\n\n${packageList}\n\nRemove these packages from your project and try again.`
								);
								tui.newline();

								process.exit(getExitCode(ErrorCode.MALWARE_DETECTED));
							}

							return stepSuccess([`Scanned ${result.summary.scanned} packages`]);
						},
					},
					{
						label: 'Encrypt and Upload Deployment',
						run: async (stepCtx: StepContext) => {
							const progress = stepCtx.progress;
							if (!deployment) {
								return stepError('deployment was null');
							}
							if (!instructions) {
								return stepError('deployment instructions were null');
							}

							// Start diagnostic for zip/encrypt phase
							const endZipDiagnostic = collector.startDiagnostic('zip-package');
							progress(5);
							ctx.logger.trace('Starting deployment zip creation');
							// zip up the assets folder
							const deploymentZip = join(tmpdir(), `${deployment.id}.zip`);
							await zipDir(join(projectDir, '.agentuity'), deploymentZip, {
								filter: (_filename: string, relative: string) => {
									if (relative.startsWith('.vite/')) {
										return false;
									}
									if (relative.startsWith('workbench-src/')) {
										return false;
									}
									if (relative.startsWith('workbench/')) {
										return false;
									}
									// ignore common stuff we never want to include in the zip
									if (relative.startsWith('.env')) return false;
									if (relative.startsWith('.git/')) return false;
									if (relative.startsWith('.ssh/')) return false;
									if (relative === '.DS_Store') return false;
									return true;
								},
							});
							ctx.logger.trace(`Deployment zip created: ${deploymentZip}`);

							endZipDiagnostic();

							progress(20);
							// Encrypt the deployment zip using the public key from deployment
							const endEncryptDiagnostic = collector.startDiagnostic('encrypt');
							const encryptedZip = join(tmpdir(), `${deployment.id}.enc.zip`);
							try {
								ctx.logger.trace('Creating public key');
								const publicKey = createPublicKey({
									key: deployment.publicKey,
									format: 'pem',
									type: 'spki',
								});

								ctx.logger.trace('Creating read/write streams');
								const src = createReadStream(deploymentZip);
								const dst = createWriteStream(encryptedZip);

								ctx.logger.trace('Starting encryption');
								// Wait for encryption to complete
								await encryptFIPSKEMDEMStream(publicKey, src, dst);
								ctx.logger.trace('Encryption complete');

								progress(40);
								ctx.logger.trace('Waiting for stream to finish');
								// End stream and wait for it to finish writing
								await new Promise<void>((resolve, reject) => {
									dst.once('finish', resolve);
									dst.once('error', reject);
									dst.end();
								});
								ctx.logger.trace('Stream finished');
								endEncryptDiagnostic();

								progress(50);
								// Start code upload diagnostic
								const endCodeUploadDiagnostic = collector.startDiagnostic('code-upload');
								ctx.logger.trace(`Uploading deployment to ${instructions.deployment}`);
								const zipfile = Bun.file(encryptedZip);
								const fileSize = await zipfile.size;
								ctx.logger.trace(`Upload file size: ${fileSize} bytes`);
								const resp = await fetch(instructions.deployment, {
									method: 'PUT',
									headers: {
										'Content-Type': 'application/zip',
										'Content-Length': String(fileSize),
									},
									body: zipfile,
									signal: stepCtx.signal,
								});
								ctx.logger.trace(`Upload response: ${resp.status}`);
								if (!resp.ok) {
									endCodeUploadDiagnostic();
									const errorMsg = `Error uploading deployment: ${await resp.text()}`;
									collector.addGeneralError('deploy', errorMsg, 'DEPLOY002');
									if (opts.reportFile) {
										await collector.forceWrite();
									}
									return stepError(errorMsg);
								}
								endCodeUploadDiagnostic();

								progress(70);
								ctx.logger.trace('Cancelling upload response body');
								// No response payload is needed for successful uploads.
								// Cancel to release resources without buffering into memory.
								await resp.body?.cancel();
								ctx.logger.trace('Deleting encrypted zip');
								await zipfile.delete();
							} finally {
								ctx.logger.trace('Cleanup');
								// Cleanup in case of error
								if (await Bun.file(encryptedZip).exists()) {
									await Bun.file(encryptedZip).delete();
								}
								await Bun.file(deploymentZip).delete();
							}

							progress(80);
							// Track both the raw on-disk size and the actual bytes we put on
							// the wire. For gzipped assets these differ significantly; reporting
							// the transferred total gives the user an honest view of CDN cost.
							let rawBytes = 0;
							let transferredBytes = 0;
							if (build?.assets && build.assets.length > 0) {
								// Start CDN upload diagnostic
								const endCdnUploadDiagnostic = collector.startDiagnostic('cdn-upload');
								ctx.logger.trace(`Uploading ${build.assets.length} assets`);
								if (!instructions.assets) {
									const errorMsg =
										'server did not provide asset upload URLs; upload aborted';
									collector.addGeneralError('deploy', errorMsg, 'DEPLOY006');
									if (opts.reportFile) {
										await collector.forceWrite();
									}
									return stepError(errorMsg);
								}

								// Pre-flight: every asset the build emitted must have a signed
								// PUT URL from the backend. Failing up-front gives a single
								// clear error instead of aborting mid-batch with partial uploads.
								for (const asset of build.assets) {
									if (!instructions.assets[asset.filename]) {
										const errorMsg = `server did not provide upload URL for asset "${asset.filename}"; upload aborted`;
										collector.addGeneralError('deploy', errorMsg, 'DEPLOY006');
										if (opts.reportFile) {
											await collector.forceWrite();
										}
										return stepError(errorMsg);
									}
									rawBytes += asset.size;
								}

								// Track every temp gzip file we create so we can clean them up
								// even if the deploy is aborted mid-upload (e.g. Ctrl+C).
								const tempFiles = new Set<string>();
								const cleanupTempFiles = () => {
									for (const p of tempFiles) {
										try {
											unlinkSync(p);
										} catch {
											// ignore — may already be gone
										}
									}
									tempFiles.clear();
								};

								// Hoist narrowed locals for use inside the async upload closure;
								// TS doesn't propagate narrowings of `build` / `instructions`
								// across async callbacks, and we've already null-checked both
								// above.
								const assets = build.assets;
								const assetUrls = instructions.assets;

								try {
									// Upload each asset with bounded concurrency. gzip compression
									// runs inside the per-asset task, so compressible files
									// compress in parallel (up to `concurrency`) rather than
									// serially — a meaningful win for builds with many JS/CSS
									// chunks since gzip is single-threaded per call.
									const uploadOne = async (
										asset: (typeof assets)[number]
									): Promise<void> => {
										const assetUrl = assetUrls[asset.filename]!;
										// Asset filename already includes the subdirectory
										// (e.g., "client/assets/main-abc123.js").
										const filePath = join(projectDir, '.agentuity', asset.filename);

										const headers: Record<string, string> = {
											'Content-Type': asset.contentType,
										};

										let body: Blob;
										let gzTempPath: string | undefined;
										let onWireSize = asset.size;

										if (asset.contentEncoding === 'gzip') {
											// Gzip to a temp file so Bun.file() can provide
											// Content-Length to S3 (streaming bodies use chunked
											// transfer encoding which S3 rejects).
											gzTempPath = join(
												tmpdir(),
												`agentuity-asset-${deployment.id}-${Date.now()}-${asset.filename.replace(/\//g, '_')}.gz`
											);
											tempFiles.add(gzTempPath);
											await pipeline(
												createReadStream(filePath),
												createGzip(),
												createWriteStream(gzTempPath)
											);
											headers['Content-Encoding'] = 'gzip';
											body = Bun.file(gzTempPath);
											onWireSize = body.size;
											ctx.logger.trace(
												`Gzip compressed ${asset.filename} (${asset.size} -> ${onWireSize} bytes)`
											);
										} else {
											body = Bun.file(filePath);
										}

										const response = await fetch(assetUrl, {
											method: 'PUT',
											headers,
											body,
											signal: stepCtx.signal,
										});

										if (gzTempPath) {
											try {
												unlinkSync(gzTempPath);
											} catch {
												// ignore
											}
											tempFiles.delete(gzTempPath);
										}

										if (!response.ok) {
											throw new Error(
												`asset "${asset.filename}" upload failed: ${response.status} ${await response.text()}`
											);
										}

										transferredBytes += onWireSize;
									};

									const concurrency = Math.min(4, assets.length);
									for (let i = 0; i < assets.length; i += concurrency) {
										const batch = assets.slice(i, i + concurrency);
										await Promise.all(batch.map(uploadOne));
									}
								} catch (error) {
									cleanupTempFiles();
									const errorMsg = error instanceof Error ? error.message : String(error);
									collector.addGeneralError('deploy', errorMsg, 'DEPLOY006');
									if (opts.reportFile) {
										await collector.forceWrite();
									}
									return stepError(errorMsg);
								}

								ctx.logger.trace(
									`Asset uploads complete: ${build.assets.length} files, raw=${rawBytes}B, on-wire=${transferredBytes}B`
								);
								endCdnUploadDiagnostic();
								progress(95);
							} else {
								ctx.logger.debug('No assets to upload to CDN');
							}

							progress(100);
							const output = build?.assets.length
								? [
										tui.muted(
											formatUploadSummary(
												build.assets.length,
												rawBytes,
												transferredBytes
											)
										),
									]
								: undefined;
							return stepSuccess(output);
						},
					},
					{
						label: 'Provision Deployment',
						run: async (stepCtx: StepContext) => {
							if (!deployment) {
								return stepError('deployment was null');
							}
							complete = await projectDeploymentComplete(
								apiClient,
								deployment.id,
								stepCtx.signal
							);
							return stepSuccess();
						},
					},
				].filter(Boolean) as Step[],
				options.logLevel
			);

			if (!deployment) {
				return {
					success: false,
					deploymentId: '',
					projectId: project.projectId,
				};
			}

			// TODO: send the deployment failure to the backend otherwise we staying in a deploying state

			const streamId = complete?.streamId;
			const appUrl = getAppBaseURL(
				process.env.AGENTUITY_REGION ?? config?.name,
				config?.overrides
			);
			const dashboard = `${appUrl}/r/${deployment.id}`;

			// Poll for deployment status with optional log streaming
			const endDeploymentWaitDiagnostic = collector.startDiagnostic('deployment-wait');
			const pollInterval = 500;
			const maxAttempts = 600;
			let attempts = 0;

			// Reuse the deploy abort controller for polling (already aborted on Ctrl+C)

			try {
				if (streamId) {
					// Use progress logger to stream logs while polling
					const streamsUrl = getStreamURL(project.region, config);

					await tui
						.progress({
							message: 'Deploying project...',
							type: 'logger',
							maxLines: 2,
							clearOnSuccess: true,
							callback: async (log) => {
								// Start log streaming
								const logStreamController = new AbortController();
								const logStreamPromise = (async () => {
									try {
										logger.debug('fetching stream: %s/%s', streamsUrl, streamId);
										const resp = await fetch(`${streamsUrl}/${streamId}`, {
											signal: logStreamController.signal,
											headers: {
												Authorization: `Bearer ${sdkKey}`,
												'User-Agent': getUserAgent(),
											},
										});
										if (!resp.ok || !resp.body) {
											ctx.logger.trace(
												`Failed to connect to warmup log stream: ${resp.status}`
											);
											return;
										}
										const reader = resp.body.getReader();
										const decoder = new TextDecoder();
										let buffer = '';
										while (true) {
											const { done, value } = await reader.read();
											if (done) break;
											buffer += decoder.decode(value, { stream: true });
											const lines = buffer.split('\n');
											buffer = lines.pop() || ''; // Keep incomplete line in buffer
											for (const line of lines) {
												// Strip ISO 8601 timestamp prefix if present
												const message = line.replace(
													/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s?/,
													''
												);
												if (message) {
													logs.push(message);
													log(message);
												}
											}
										}
									} catch (err) {
										if (err instanceof Error && err.name === 'AbortError') {
											return;
										}
										ctx.logger.trace(`Warmup log stream error: ${err}`);
									}
								})();

								// Poll for deployment status
								while (attempts < maxAttempts) {
									// Check if user pressed Ctrl+C
									if (deployAbortController.signal.aborted) {
										logStreamController.abort();
										throw new DeploymentCancelledError();
									}

									attempts++;
									try {
										statusResult = await projectDeploymentStatus(
											apiClient,
											deployment?.id ?? '',
											deployAbortController.signal
										);

										logger.trace('status result: %s', statusResult);

										if (statusResult.state === 'completed') {
											logStreamController.abort();
											break;
										}

										if (statusResult.state === 'failed') {
											throw new Error('Deployment failed');
										}

										await Bun.sleep(pollInterval);
									} catch (err) {
										logStreamController.abort();
										throw err;
									}
								}

								// Wait for log stream to finish
								await logStreamPromise;

								if (attempts >= maxAttempts) {
									throw new Error('Deployment timed out');
								}
							},
						})
						.then(() => {
							endDeploymentWaitDiagnostic();
							tui.success('Your project was deployed!');
						})
						.catch(async (ex) => {
							endDeploymentWaitDiagnostic();
							// Handle cancellation
							if (ex instanceof DeploymentCancelledError) {
								if (opts.reportFile) {
									await collector.forceWrite();
								}
								tui.warning('Deployment cancelled');
								process.exit(130); // Standard exit code for SIGINT
							}
							const exwithmessage = ex as { message: string };
							const msg =
								exwithmessage.message === 'Deployment failed'
									? ''
									: exwithmessage.toString();

							// Add error to collector
							const isTimeout = exwithmessage.message === 'Deployment timed out';
							collector.addGeneralError(
								'deploy',
								msg || 'Deployment failed',
								isTimeout ? 'DEPLOY003' : 'DEPLOY004'
							);
							if (opts.reportFile) {
								await collector.forceWrite();
							}

							tui.error(`Your deployment failed to start${msg ? `: ${msg}` : ''}`);
							if (logs.length) {
								const logsDir = join(getDefaultConfigDir(), 'logs');
								if (!existsSync(logsDir)) {
									mkdirSync(logsDir, { recursive: true });
								}
								const errorFile = join(logsDir, `${deployment?.id ?? Date.now()}.txt`);
								writeFileSync(errorFile, logs.join('\n'));
								const count = Math.min(logs.length, 10);
								const last = logs.length - count;
								tui.newline();
								tui.warning(`The last ${count} lines of the log:`);
								let offset = last + 1; // we want to show the offset from inside the log starting at 1
								const max = String(logs.length).length;
								for (const _log of logs.slice(last)) {
									console.log(tui.muted(`${offset.toFixed().padEnd(max)} | ${_log}`));
									offset++;
								}
								tui.newline();
								tui.fatal(`The logs were written to ${errorFile}`, ErrorCode.BUILD_FAILED);
							}
							tui.fatal('Deployment failed', ErrorCode.BUILD_FAILED);
						});
				} else {
					// No stream ID - poll without log streaming
					await tui.spinner({
						message: 'Deploying project...',
						type: 'simple',
						clearOnSuccess: true,
						callback: async () => {
							while (attempts < maxAttempts) {
								// Check if user pressed Ctrl+C
								if (deployAbortController.signal.aborted) {
									throw new DeploymentCancelledError();
								}

								attempts++;
								statusResult = await projectDeploymentStatus(
									apiClient,
									deployment?.id ?? '',
									deployAbortController.signal
								);

								if (statusResult.state === 'completed') {
									break;
								}

								if (statusResult.state === 'failed') {
									throw new Error('Deployment failed');
								}

								await Bun.sleep(pollInterval);
							}

							if (attempts >= maxAttempts) {
								throw new Error('Deployment timed out');
							}
						},
					});

					endDeploymentWaitDiagnostic();
					tui.success('Your project was deployed!');
				}
			} catch (ex) {
				endDeploymentWaitDiagnostic();
				const exwithmessage = ex as { message: string };
				const isTimeout = exwithmessage?.message === 'Deployment timed out';
				collector.addGeneralError(
					'deploy',
					exwithmessage?.message || String(ex),
					isTimeout ? 'DEPLOY003' : 'DEPLOY004'
				);
				if (opts.reportFile) {
					await collector.forceWrite();
				}

				const lines = [`${ex}`, ''];
				lines.push(
					`${tui.ICONS.arrow} ${tui.bold(tui.padRight('Dashboard:', 12)) + tui.link(dashboard)}`
				);
				tui.banner(tui.colorError(`Deployment: ${deployment.id} Failed`), lines.join('\n'), {
					centerTitle: false,
					topSpacer: false,
					bottomSpacer: false,
				});
				tui.fatal('Deployment failed', ErrorCode.BUILD_FAILED);
			} finally {
				// Clean up signal handler
				process.off('SIGINT', deployAbortHandler);
			}

			// Show deployment URLs
			if (complete?.publicUrls && !options.json) {
				const lines: string[] = [];
				if (complete.publicUrls.custom?.length) {
					for (const url of complete.publicUrls.custom) {
						lines.push(
							`${tui.ICONS.arrow} ${tui.bold(tui.padRight('Deployment:', 12)) + tui.link(url)}`
						);
					}
				} else {
					// Prefer vanity URLs, fall back to hash-based
					const deploymentUrl =
						complete.publicUrls.vanityDeployment ?? complete.publicUrls.deployment;
					const latestUrl = complete.publicUrls.vanityProject ?? complete.publicUrls.latest;
					lines.push(
						`${tui.ICONS.arrow} ${
							tui.bold(tui.padRight('Deployment:', 12)) + tui.link(deploymentUrl)
						}`
					);
					lines.push(
						`${tui.ICONS.arrow} ${tui.bold(tui.padRight('Project:', 12)) + tui.link(latestUrl)}`
					);
				}
				lines.push(
					`${tui.ICONS.arrow} ${tui.bold(tui.padRight('Dashboard:', 12)) + tui.link(dashboard)}`
				);
				tui.banner(`Deployment: ${tui.colorPrimary(deployment.id)}`, lines.join('\n'), {
					centerTitle: false,
					topSpacer: false,
					bottomSpacer: false,
				});
			}

			// Trigger TLS certificate provisioning for custom domains (fire-and-forget)
			if (project.deployment?.domains?.length) {
				void domain.triggerTLSProvisioning(project.deployment.domains);
			}

			// Write final report on success
			if (opts.reportFile) {
				await collector.forceWrite();
			}
			clearGlobalCollector();

			// Write deploy result to file for fork parent to consume
			const deployResultFile = process.env.AGENTUITY_DEPLOY_RESULT_FILE;
			if (deployResultFile) {
				try {
					const resultData = {
						urls: complete?.publicUrls
							? {
									deployment:
										complete.publicUrls.vanityDeployment ??
										complete.publicUrls.deployment,
									latest: complete.publicUrls.vanityProject ?? complete.publicUrls.latest,
									custom: complete.publicUrls.custom,
									dashboard,
								}
							: undefined,
						logs,
					};
					writeFileSync(deployResultFile, JSON.stringify(resultData));
				} catch {
					// Non-fatal: result file is optional
				}
			}

			return {
				success: true,
				deploymentId: deployment.id,
				projectId: project.projectId,
				rolloutId: complete?.rolloutId,
				logs,
				urls: complete?.publicUrls
					? {
							deployment:
								complete.publicUrls.vanityDeployment ?? complete.publicUrls.deployment,
							latest: complete.publicUrls.vanityProject ?? complete.publicUrls.latest,
							custom: complete.publicUrls.custom,
							dashboard,
						}
					: undefined,
			};
		} catch (ex) {
			// Handle step interruption (Ctrl+C during build steps)
			if (ex instanceof StepInterruptError) {
				tui.warning('Deployment cancelled');
				process.exit(ex.exitCode);
			}
			collector.addGeneralError('deploy', String(ex), 'DEPLOY004');
			if (opts.reportFile) {
				await collector.forceWrite();
			}
			clearGlobalCollector();
			tui.fatal(`unexpected error trying to deploy project. ${ex}`);
		} finally {
			process.off('SIGINT', deployAbortHandler);
		}
	},
});
