import { existsSync, mkdirSync, rmSync, chmodSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve, dirname, basename, relative } from 'node:path';
import { spawn as nodeSpawn, spawnSync } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import {
	getGitRepository,
	getGitCredential,
	updateGitRepository,
	getGitStack,
	updateGitStack,
	upsertStackSource,
	getEnvironment,
	type GitRepository,
	type GitCredential,
	type GitStackWithRepo
} from './db';
import { deployStack, getStackDir } from './stacks';

const MERGED_CA_BUNDLE_PATH = '/tmp/dockhand-merged-ca-bundle.crt';
let mergedCaBundleReady = false;

/**
 * Create a merged CA bundle combining system CAs with the custom cert from
 * NODE_EXTRA_CA_CERTS. GIT_SSL_CAINFO replaces the default CA store, so without
 * merging, public CAs (GitHub, GitLab) break.
 */
function getMergedCaBundlePath(): string {
	if (mergedCaBundleReady && existsSync(MERGED_CA_BUNDLE_PATH)) {
		console.log(`[Git] Using cached merged CA bundle: ${MERGED_CA_BUNDLE_PATH}`);
		return MERGED_CA_BUNDLE_PATH;
	}

	const customCertPath = process.env.NODE_EXTRA_CA_CERTS!;
	console.log(`[Git] NODE_EXTRA_CA_CERTS set to: ${customCertPath}`);

	const systemCaPaths = [
		process.env.SSL_CERT_FILE,
		'/etc/ssl/certs/ca-certificates.crt',
		'/etc/pki/tls/certs/ca-bundle.crt',
		'/etc/ssl/cert.pem'
	];

	let systemCaContent = '';
	let systemCaSource = '';
	for (const caPath of systemCaPaths) {
		if (caPath && existsSync(caPath)) {
			try {
				systemCaContent = readFileSync(caPath, 'utf-8');
				systemCaSource = caPath;
				console.log(`[Git] Found system CA bundle: ${caPath} (${systemCaContent.split('-----BEGIN CERTIFICATE-----').length - 1} certs)`);
				break;
			} catch (err) {
				console.log(`[Git] Failed to read system CA bundle ${caPath}: ${err}`);
			}
		}
	}

	if (!systemCaSource) {
		console.log(`[Git] No system CA bundle found, using custom cert only: ${customCertPath}`);
	}

	try {
		const customCaContent = readFileSync(customCertPath, 'utf-8');
		const customCertCount = customCaContent.split('-----BEGIN CERTIFICATE-----').length - 1;
		console.log(`[Git] Custom CA file contains ${customCertCount} cert(s)`);

		const merged = systemCaContent
			? systemCaContent.trimEnd() + '\n' + customCaContent.trimEnd() + '\n'
			: customCaContent;
		writeFileSync(MERGED_CA_BUNDLE_PATH, merged);
		mergedCaBundleReady = true;

		const totalCerts = merged.split('-----BEGIN CERTIFICATE-----').length - 1;
		console.log(`[Git] Created merged CA bundle: ${MERGED_CA_BUNDLE_PATH} (${totalCerts} total certs — system from ${systemCaSource || 'none'} + custom from ${customCertPath})`);
	} catch (err) {
		console.warn(`[Git] Failed to create merged CA bundle, falling back to custom cert only: ${customCertPath}`, err);
		return customCertPath;
	}

	return MERGED_CA_BUNDLE_PATH;
}

/**
 * Collect stdout, stderr and exit code from a spawned process.
 */
function collectProcess(proc: ChildProcess): Promise<{ exitCode: number; stdout: string; stderr: string }> {
	return new Promise((resolve, reject) => {
		const stdoutChunks: Buffer[] = [];
		const stderrChunks: Buffer[] = [];
		proc.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
		proc.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
		proc.on('error', reject);
		proc.on('close', (code) => {
			resolve({
				exitCode: code ?? 1,
				stdout: Buffer.concat(stdoutChunks).toString(),
				stderr: Buffer.concat(stderrChunks).toString()
			});
		});
	});
}

// Directory for storing cloned repositories
const dataDir = process.env.DATA_DIR || './data';
const GIT_REPOS_DIR = resolve(process.env.GIT_REPOS_DIR || join(dataDir, 'git-repos'));

// Ensure git repos directory exists
if (!existsSync(GIT_REPOS_DIR)) {
	mkdirSync(GIT_REPOS_DIR, { recursive: true });
}

export function getGitReposDir(): string {
	return GIT_REPOS_DIR;
}

/**
 * Redact all env var values for safe logging. Only key names are preserved.
 */
function redactEnvVarsForLog(vars: Record<string, string>): Record<string, string> {
	const redacted: Record<string, string> = {};
	for (const key of Object.keys(vars)) {
		redacted[key] = '***';
	}
	return redacted;
}

function getRepoPath(repoId: number): string {
	return join(GIT_REPOS_DIR, `repo-${repoId}`);
}

interface GitEnv {
	[key: string]: string;
}

const NSS_WRAPPER_LIB = '/usr/lib/libnss_wrapper.so';
const TMP_PASSWD = '/tmp/dockhand-passwd';
const TMP_GROUP = '/tmp/dockhand-group';

// Cache the check so we only do it once per process
let _nssWrapperChecked = false;
let _nssWrapperNeeded = false;

/**
 * Ensures the current UID exists in /etc/passwd for git/SSH operations.
 * SSH calls getpwuid() which fails with "No user exists for uid XXXX" if the
 * UID isn't in /etc/passwd (common with Docker --user or read-only containers).
 * Creates a temp passwd file and configures LD_PRELOAD with libnss_wrapper.
 */
async function ensurePasswdEntry(env: GitEnv): Promise<void> {
	if (_nssWrapperChecked) {
		if (_nssWrapperNeeded) {
			env.LD_PRELOAD = env.LD_PRELOAD ? `${env.LD_PRELOAD}:${NSS_WRAPPER_LIB}` : NSS_WRAPPER_LIB;
			env.NSS_WRAPPER_PASSWD = TMP_PASSWD;
			env.NSS_WRAPPER_GROUP = TMP_GROUP;
		}
		return;
	}
	_nssWrapperChecked = true;

	// Check if current UID is in /etc/passwd
	const uid = process.getuid?.();
	if (uid === undefined || uid === 0) return; // root or not available

	try {
		const passwd = readFileSync('/etc/passwd', 'utf-8');
		const uidStr = `:${uid}:`;
		if (passwd.split('\n').some(line => {
			const parts = line.split(':');
			return parts[2] === String(uid);
		})) {
			return; // UID exists, nothing to do
		}
	} catch {
		return; // can't read passwd, bail
	}

	// UID not found — check if libnss_wrapper is available
	if (!existsSync(NSS_WRAPPER_LIB)) {
		console.warn(`[git] UID ${uid} not in /etc/passwd and libnss_wrapper not found — SSH may fail`);
		return;
	}

	// Create temp passwd/group with the missing entry
	try {
		const gid = process.getgid?.() ?? uid;
		const passwd = readFileSync('/etc/passwd', 'utf-8');
		const group = readFileSync('/etc/group', 'utf-8');

		const passwdEntry = `dockhand:x:${uid}:${gid}:Dockhand:/home/dockhand:/bin/sh`;
		writeFileSync(TMP_PASSWD, passwd.trimEnd() + '\n' + passwdEntry + '\n');

		const gidExists = group.split('\n').some(line => line.split(':')[2] === String(gid));
		if (gidExists) {
			writeFileSync(TMP_GROUP, group);
		} else {
			writeFileSync(TMP_GROUP, group.trimEnd() + '\n' + `dockhand:x:${gid}:\n`);
		}

		_nssWrapperNeeded = true;
		env.LD_PRELOAD = env.LD_PRELOAD ? `${env.LD_PRELOAD}:${NSS_WRAPPER_LIB}` : NSS_WRAPPER_LIB;
		env.NSS_WRAPPER_PASSWD = TMP_PASSWD;
		env.NSS_WRAPPER_GROUP = TMP_GROUP;
		console.log(`[git] Created temp passwd for UID ${uid} with libnss_wrapper`);
	} catch (err) {
		console.warn(`[git] Failed to create temp passwd:`, err);
	}
}

async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
	const env: GitEnv = {
		...process.env as GitEnv,
		GIT_TERMINAL_PROMPT: '0',
		// Prevent SSH agent from providing keys automatically
		SSH_AUTH_SOCK: ''
	};

	// Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js).
	// GIT_SSL_CAINFO replaces the default CA store, so we merge system CAs with the
	// custom cert so both self-signed repos and public repos (GitHub etc.) work (#967).
	if (process.env.NODE_EXTRA_CA_CERTS) {
		env.GIT_SSL_CAINFO = getMergedCaBundlePath();
	}

	// Ensure current UID is resolvable for SSH/git operations
	await ensurePasswdEntry(env);

	// For HTTPS password/token auth, inject credentials via http.extraHeader env vars
	// instead of embedding them in the URL (which leaks via /proc/<pid>/cmdline, #1081).
	// Uses GIT_CONFIG_COUNT mechanism (git >= 2.31) to set Authorization header.
	if (credential?.authType === 'password' && (credential.username || credential.password)) {
		const token = credential.password || '';
		const username = credential.username || '';
		// Use Basic auth (base64 of username:password) — works with GitHub PATs,
		// GitLab tokens, Gitea tokens, and standard username/password combos.
		const basicAuth = Buffer.from(`${username}:${token}`).toString('base64');
		env.GIT_CONFIG_COUNT = '1';
		env.GIT_CONFIG_KEY_0 = 'http.extraHeader';
		env.GIT_CONFIG_VALUE_0 = `Authorization: Basic ${basicAuth}`;
	}

	if (credential?.authType === 'ssh' && credential.sshPrivateKey) {
		// Write SSH key to /tmp instead of data volume — some filesystems (TrueNAS ZFS,
		// NFS, CIFS) silently ignore chmod, leaving the key group-readable (e.g. 0670).
		// SSH refuses keys that are accessible by others. /tmp is always a proper filesystem.
		const sshKeyPath = `/tmp/.ssh-key-${credential.id}`;

		// Ensure SSH key ends with a newline (newer SSH versions are strict about this)
		let keyContent = credential.sshPrivateKey;
		if (!keyContent.endsWith('\n')) {
			keyContent += '\n';
		}

		writeFileSync(sshKeyPath, keyContent);
		// Ensure SSH key has correct permissions (0600 = owner read/write only)
		// writeFileSync's mode option doesn't always work reliably, so use chmodSync
		chmodSync(sshKeyPath, 0o600);

		// If key has a passphrase, decrypt it in-place so SSH can use it non-interactively
		if (credential.sshPassphrase) {
			const result = spawnSync(
				'ssh-keygen',
				['-p', '-f', sshKeyPath, '-P', credential.sshPassphrase, '-N', ''],
				{ env, stdio: ['pipe', 'pipe', 'pipe'] }
			);
			if (result.status !== 0) {
				const stderr = result.stderr.toString().trim();
				console.warn(`[git] Failed to decrypt SSH key: ${stderr}`);
			}
		}

		// Configure SSH to use ONLY this key (no agent, no default keys)
		env.GIT_SSH_COMMAND = `ssh -i "${sshKeyPath}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes`;
	} else {
		// No SSH credential - prevent using any keys (IdentitiesOnly=yes with no -i means no keys)
		env.GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes -o PasswordAuthentication=no -o PubkeyAuthentication=no';
	}

	return env;
}

function cleanupSshKey(credential: GitCredential | null): void {
	if (credential?.authType === 'ssh') {
		const sshKeyPath = `/tmp/.ssh-key-${credential.id}`;
		try {
			if (existsSync(sshKeyPath)) {
				rmSync(sshKeyPath);
			}
		} catch {
			// Ignore cleanup errors
		}
	}
}

function buildRepoUrl(url: string, credential: GitCredential | null): string {
	// Never embed credentials in the URL — they leak via /proc/<pid>/cmdline (see #1081).
	// HTTPS credentials are injected via GIT_CONFIG_COUNT env vars in buildGitEnv().
	// Strip any existing credentials from the URL for safety.
	if (credential?.authType === 'password' && !url.startsWith('git@')) {
		try {
			const parsed = new URL(url);
			parsed.username = '';
			parsed.password = '';
			return parsed.toString();
		} catch {
			return url;
		}
	}
	return url;
}

async function execGit(args: string[], cwd: string, env: GitEnv): Promise<{ stdout: string; stderr: string; code: number }> {
	try {
		const proc = nodeSpawn('git', args, {
			cwd,
			env,
			stdio: ['pipe', 'pipe', 'pipe']
		});

		const result = await collectProcess(proc);

		return { stdout: result.stdout.trim(), stderr: result.stderr.trim(), code: result.exitCode };
	} catch (err: any) {
		return { stdout: '', stderr: err.message, code: 1 };
	}
}

/**
 * Get list of files that changed between two commits in a specific directory.
 * Returns array of changed file paths (relative to repo root).
 */
async function getChangedFilesInDir(
	repoPath: string,
	previousCommit: string,
	newCommit: string,
	dirPath: string,
	env: GitEnv
): Promise<{ changed: boolean; files: string[]; error?: string }> {
	if (!previousCommit) {
		// No previous commit means this is a new clone - always deploy
		return { changed: true, files: ['(new clone - all files)'] };
	}

	// Use git diff --name-only to get all changed files in the directory
	// The trailing slash ensures we only match files IN that directory (and subdirs)
	const dirPattern = dirPath.endsWith('/') ? dirPath : `${dirPath}/`;
	const result = await execGit(
		['diff', '--name-only', previousCommit, newCommit, '--', dirPattern],
		repoPath,
		env
	);

	// If the command fails (e.g., previousCommit no longer exists after force push),
	// assume files changed to be safe
	if (result.code !== 0) {
		return { changed: true, files: ['(diff failed - assuming changed)'], error: result.stderr };
	}

	// Parse changed files
	const changedFiles = result.stdout.trim()
		.split('\n')
		.filter(f => f.length > 0);

	return { changed: changedFiles.length > 0, files: changedFiles };
}

export interface SyncResult {
	success: boolean;
	commit?: string;
	composeContent?: string;
	composeDir?: string; // Directory containing the compose file (for copying all files)
	composeFileName?: string; // Filename of the compose file (e.g., "docker-compose.yaml")
	envFileVars?: Record<string, string>; // Variables from .env file in repo
	envFileContent?: string; // Raw .env file content (for Hawser deployments)
	envFileName?: string; // Filename of env file relative to composeDir (e.g., ".env" or "../.env")
	error?: string;
	updated?: boolean;
	changedFiles?: string[]; // List of files that changed (for logging/debugging)
}

export interface TestResult {
	success: boolean;
	branch?: string;
	lastCommit?: string;
	composeFileExists?: boolean;
	error?: string;
}

/**
 * Clean up git/SSH error messages for user display
 */
function cleanGitError(stderr: string): string {
	// Remove SSH warnings and noise
	const lines = stderr.split('\n').filter(line => {
		const l = line.trim().toLowerCase();
		// Skip SSH warnings
		if (l.startsWith('warning:')) return false;
		if (l.includes('added') && l.includes('to the list of known hosts')) return false;
		// Skip empty lines
		if (!l) return false;
		return true;
	});

	// Find the most relevant error
	const fatalLine = lines.find(l => l.toLowerCase().includes('fatal:'));
	const permissionLine = lines.find(l => l.toLowerCase().includes('permission denied'));
	const errorLine = lines.find(l => l.toLowerCase().includes('error:'));

	// Return cleaner message
	if (permissionLine) {
		return 'Permission denied. Check your SSH credentials.';
	}
	if (fatalLine) {
		// Clean up common fatal messages
		const msg = fatalLine.replace(/^fatal:\s*/i, '').trim();
		if (msg.includes('Could not read from remote repository')) {
			return 'Could not access repository. Check URL and credentials.';
		}
		return msg;
	}
	if (errorLine) {
		return errorLine.replace(/^error:\s*/i, '').trim();
	}

	// Fallback to original (joined and trimmed)
	return lines.join(' ').trim() || 'Failed to connect to repository';
}

/**
 * Core function to test a git repository connection.
 * Tests the URL, branch, and credentials passed directly (not from DB).
 */
async function testRepositoryConnection(options: {
	url: string;
	branch: string;
	credential: GitCredential | null;
}): Promise<TestResult> {
	const { url, branch, credential } = options;

	const env = await buildGitEnv(credential);
	const repoUrl = buildRepoUrl(url, credential);

	try {
		// Use git ls-remote to test connection and verify branch
		const result = await execGit(
			['ls-remote', '--heads', '--refs', repoUrl, branch || 'HEAD'],
			process.cwd(),
			env
		);

		if (result.code !== 0) {
			console.error('[Git] Connection test failed:', result.stderr);
			return { success: false, error: cleanGitError(result.stderr) };
		}

		// Parse the output to get commit hash
		const lines = result.stdout.split('\n').filter(l => l.trim());
		if (lines.length === 0) {
			// Branch not found, but connection worked - check if repo has any branches
			const allBranchesResult = await execGit(
				['ls-remote', '--heads', '--refs', repoUrl],
				process.cwd(),
				env
			);

			if (allBranchesResult.code !== 0) {
				return { success: false, error: cleanGitError(allBranchesResult.stderr) };
			}

			const allBranches = allBranchesResult.stdout.split('\n')
				.filter(l => l.trim())
				.map(l => {
					const m = l.match(/refs\/heads\/(.+)$/);
					return m ? m[1] : null;
				})
				.filter(Boolean);

			if (allBranches.length === 0) {
				return { success: true, branch: '(empty repository)' };
			}

			return {
				success: false,
				error: `Branch '${branch}' not found. Available branches: ${allBranches.slice(0, 5).join(', ')}${allBranches.length > 5 ? '...' : ''}`
			};
		}

		const match = lines[0].match(/^([a-f0-9]+)\s+refs\/heads\/(.+)$/);
		const lastCommit = match ? match[1].substring(0, 7) : undefined;
		const foundBranch = match ? match[2] : branch;

		return {
			success: true,
			branch: foundBranch,
			lastCommit
		};
	} catch (error: any) {
		return { success: false, error: error.message };
	} finally {
		cleanupSshKey(credential);
	}
}

/**
 * Test a saved repository from the database (used by grid test button).
 */
export async function testRepository(repoId: number): Promise<TestResult> {
	const repo = await getGitRepository(repoId);
	if (!repo) {
		return { success: false, error: 'Repository not found' };
	}

	const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;

	return testRepositoryConnection({
		url: repo.url,
		branch: repo.branch,
		credential
	});
}

/**
 * Test a repository configuration before saving (used by modal test button).
 * Uses credentialId to fetch stored credentials from the database.
 */
export async function testRepositoryConfig(options: {
	url: string;
	branch: string;
	credentialId?: number | null;
}): Promise<TestResult> {
	const { url, branch, credentialId } = options;

	if (!url) {
		return { success: false, error: 'Repository URL is required' };
	}

	// Fetch credential from database if credentialId is provided
	const credential = credentialId ? await getGitCredential(credentialId) : null;
	if (credentialId && !credential) {
		return { success: false, error: 'Credential not found' };
	}

	return testRepositoryConnection({
		url,
		branch: branch || 'main',
		credential
	});
}

export async function syncRepository(repoId: number): Promise<SyncResult> {
	const repo = await getGitRepository(repoId);
	if (!repo) {
		return { success: false, error: 'Repository not found' };
	}

	// Check if sync is already in progress
	if (repo.syncStatus === 'syncing') {
		return { success: false, error: 'Sync already in progress' };
	}

	const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
	const repoPath = getRepoPath(repoId);
	const env = await buildGitEnv(credential);

	try {
		// Update sync status
		await updateGitRepository(repoId, { syncStatus: 'syncing', syncError: null });

		let updated = false;
		let currentCommit = '';

		if (!existsSync(repoPath)) {
			// Clone the repository (blobless clone - fetches all commits but blobs on-demand)
			const repoUrl = buildRepoUrl(repo.url, credential);

			const result = await execGit(
				['clone', '--filter=blob:none', '--branch', repo.branch, repoUrl, repoPath],
				process.cwd(),
				env
			);
			if (result.code !== 0) {
				// Clean up partial clone directory on failure
				if (existsSync(repoPath)) {
					rmSync(repoPath, { recursive: true, force: true });
				}
				throw new Error(`Git clone failed: ${result.stderr}`);
			}

			updated = true;
		} else {
			// Get current commit before pull
			const beforeResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
			const beforeCommit = beforeResult.stdout;

			// Pull latest changes
			const result = await execGit(['pull', 'origin', repo.branch], repoPath, env);
			if (result.code !== 0) {
				throw new Error(`Git pull failed: ${result.stderr}`);
			}

			// Get commit after pull
			const afterResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
			const afterCommit = afterResult.stdout;

			updated = beforeCommit !== afterCommit;
		}

		// Get current commit hash
		const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
		currentCommit = commitResult.stdout.substring(0, 7);

		// Read the compose file
		const composePath = join(repoPath, repo.composePath);
		if (!existsSync(composePath)) {
			throw new Error(`Compose file not found: ${repo.composePath}`);
		}

		const composeContent = readFileSync(composePath, 'utf-8');

		// Update repository status
		await updateGitRepository(repoId, {
			syncStatus: 'synced',
			lastSync: new Date().toISOString(),
			lastCommit: currentCommit,
			syncError: null
		});

		cleanupSshKey(credential);

		return {
			success: true,
			commit: currentCommit,
			composeContent,
			updated
		};
	} catch (error: any) {
		cleanupSshKey(credential);
		await updateGitRepository(repoId, {
			syncStatus: 'error',
			syncError: error.message
		});
		return { success: false, error: error.message };
	}
}

export async function deployFromRepository(repoId: number): Promise<{ success: boolean; output?: string; error?: string }> {
	const repo = await getGitRepository(repoId);
	if (!repo) {
		return { success: false, error: 'Repository not found' };
	}

	// Sync first
	const syncResult = await syncRepository(repoId);
	if (!syncResult.success) {
		return { success: false, error: syncResult.error };
	}

	const stackName = repo.name;

	// Deploy using unified function - handles both new and existing stacks
	const result = await deployStack({
		name: stackName,
		compose: syncResult.composeContent!,
		envId: repo.environmentId
	});

	if (result.success) {
		// Record the stack source
		await upsertStackSource({
			stackName: stackName,
			environmentId: repo.environmentId,
			sourceType: 'git',
			gitRepositoryId: repoId
		});
	}

	return result;
}

export async function checkForUpdates(repoId: number): Promise<{ hasUpdates: boolean; currentCommit?: string; latestCommit?: string; error?: string }> {
	const repo = await getGitRepository(repoId);
	if (!repo) {
		return { hasUpdates: false, error: 'Repository not found' };
	}

	const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
	const repoPath = getRepoPath(repoId);
	const env = await buildGitEnv(credential);

	try {
		if (!existsSync(repoPath)) {
			return { hasUpdates: true, currentCommit: 'none', latestCommit: 'unknown' };
		}

		// Get current commit
		const currentResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
		const currentCommit = currentResult.stdout.substring(0, 7);

		// Fetch latest without merging
		await execGit(['fetch', 'origin', repo.branch], repoPath, env);

		// Get remote commit
		const latestResult = await execGit(['rev-parse', `origin/${repo.branch}`], repoPath, env);
		const latestCommit = latestResult.stdout.substring(0, 7);

		cleanupSshKey(credential);

		return {
			hasUpdates: currentCommit !== latestCommit,
			currentCommit,
			latestCommit
		};
	} catch (error: any) {
		cleanupSshKey(credential);
		return { hasUpdates: false, error: error.message };
	}
}

export function deleteRepositoryFiles(repoId: number): void {
	const repoPath = getRepoPath(repoId);
	try {
		if (existsSync(repoPath)) {
			rmSync(repoPath, { recursive: true, force: true });
		}
	} catch (error) {
		const errorMsg = error instanceof Error ? error.message : String(error);
		console.error('[Git] Failed to delete repository files:', errorMsg);
	}
}

// === Git Stack Functions ===

async function getStackRepoPath(stackId: number, stackName?: string, environmentId?: number | null): Promise<string> {
	if (stackName && environmentId) {
		// Use old path if it already exists (backward compat), otherwise use name-based path
		const oldPath = join(GIT_REPOS_DIR, `stack-${stackId}`);
		if (existsSync(oldPath)) {
			return oldPath;
		}
		// Format: envName/stackName (e.g. production/webapp) - consistent with internal stacks
		const env = await getEnvironment(environmentId);
		const envDir = join(GIT_REPOS_DIR, env ? env.name : String(environmentId));
		if (!existsSync(envDir)) {
			mkdirSync(envDir, { recursive: true });
		}
		return join(envDir, stackName);
	}
	return join(GIT_REPOS_DIR, `stack-${stackId}`);
}

/**
 * Get the current commit hash from a repo path (if it exists).
 * Used to detect if repo was updated after re-clone.
 */
async function getPreviousCommit(repoPath: string, env: GitEnv): Promise<string | null> {
	if (!existsSync(repoPath)) {
		return null;
	}
	try {
		const result = await execGit(['rev-parse', 'HEAD'], repoPath, env);
		return result.code === 0 ? result.stdout.trim() : null;
	} catch {
		return null;
	}
}

export async function syncGitStack(stackId: number): Promise<SyncResult> {
	const gitStack = await getGitStack(stackId);
	if (!gitStack) {
		return { success: false, error: 'Git stack not found' };
	}

	const logPrefix = `[Stack:${gitStack.stackName}]`;
	console.log(`${logPrefix} ========================================`);
	console.log(`${logPrefix} SYNC GIT STACK START`);
	console.log(`${logPrefix} ========================================`);
	console.log(`${logPrefix} Stack ID:`, stackId);
	console.log(`${logPrefix} Stack name:`, gitStack.stackName);
	console.log(`${logPrefix} Repository ID:`, gitStack.repositoryId);
	console.log(`${logPrefix} Compose path:`, gitStack.composePath);
	console.log(`${logPrefix} Env file path:`, gitStack.envFilePath || '(none)');
	console.log(`${logPrefix} Environment ID:`, gitStack.environmentId);

	// Check if sync is already in progress
	if (gitStack.syncStatus === 'syncing') {
		console.log(`${logPrefix} ERROR: Sync already in progress`);
		return { success: false, error: 'Sync already in progress' };
	}

	const repo = await getGitRepository(gitStack.repositoryId);
	if (!repo) {
		console.log(`${logPrefix} ERROR: Repository not found`);
		return { success: false, error: 'Repository not found' };
	}

	console.log(`${logPrefix} Repository URL:`, repo.url);
	console.log(`${logPrefix} Repository branch:`, repo.branch);

	const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
	const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId);
	const env = await buildGitEnv(credential);

	console.log(`${logPrefix} Local repo path:`, repoPath);
	console.log(`${logPrefix} Has credential:`, !!credential);

	try {
		// Update sync status
		await updateGitStack(stackId, { syncStatus: 'syncing', syncError: null });

		let updated = false;
		let currentCommit = '';

		// Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.)
		// Blobless clones fetch all commits (for git diff) but download blobs on-demand
		// Fall back to DB lastCommit when repo dir was deleted by a previous failed sync (#693)
		const previousCommit = await getPreviousCommit(repoPath, env) ?? gitStack.lastCommit ?? null;
		if (existsSync(repoPath)) {
			console.log(`${logPrefix} Removing existing clone for fresh sync...`);
			rmSync(repoPath, { recursive: true, force: true });
		}

		console.log(`${logPrefix} Cloning repository...`);
		const repoUrl = buildRepoUrl(repo.url, credential);

		const result = await execGit(
			['clone', '--filter=blob:none', '--branch', repo.branch, repoUrl, repoPath],
			process.cwd(),
			env
		);
		console.log(`${logPrefix} Clone exit code:`, result.code);
		if (result.stdout) console.log(`${logPrefix} Clone stdout:`, result.stdout);
		if (result.stderr) console.log(`${logPrefix} Clone stderr:`, result.stderr);

		if (result.code !== 0) {
			// Clean up partial clone directory on failure
			if (existsSync(repoPath)) {
				rmSync(repoPath, { recursive: true, force: true });
			}
			throw new Error(`Git clone failed: ${result.stderr}`);
		}

		// Check if commit changed
		const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
		const newCommit = newCommitResult.stdout.trim();
		// Normalize to 7-char short hash for comparison (DB stores 7-char, git returns 40-char)
		const commitChanged = previousCommit?.substring(0, 7) !== newCommit.substring(0, 7);
		console.log(`${logPrefix} Previous commit: ${previousCommit || '(none)'}, new commit: ${newCommit.substring(0, 7)}, commit changed: ${commitChanged}`);

		// Check if any files in the compose file's directory have changed
		// This catches changes to the compose file, env files, and any other referenced files
		// (e.g., config files, scripts, additional env files)
		let changedFiles: string[] = [];
		if (commitChanged) {
			// Use contextDir if set, otherwise fall back to compose file's directory
			const diffDirRelative = gitStack.contextDir || dirname(gitStack.composePath);
			console.log(`${logPrefix} Checking for changes in directory: ${diffDirRelative || '(root)'}`);

			const diffResult = await getChangedFilesInDir(
				repoPath,
				previousCommit,
				newCommit,
				diffDirRelative || '.',
				env
			);

			updated = diffResult.changed;
			changedFiles = diffResult.files;

			if (diffResult.error) {
				console.log(`${logPrefix} Diff error: ${diffResult.error}`);
			}

			if (changedFiles.length > 0) {
				console.log(`${logPrefix} Changed files (${changedFiles.length}):`);
				for (const file of changedFiles) {
					console.log(`${logPrefix}   - ${file}`);
				}
			} else {
				console.log(`${logPrefix} No files changed in stack directory`);
			}
		} else {
			updated = false;
			console.log(`${logPrefix} No commit change, skipping file diff`);
		}

		// Get current commit hash
		const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
		currentCommit = commitResult.stdout.substring(0, 7);
		console.log(`${logPrefix} Current commit:`, currentCommit);

		// Read the compose file
		const composePath = join(repoPath, gitStack.composePath);
		console.log(`${logPrefix} Reading compose file from:`, composePath);
		if (!existsSync(composePath)) {
			console.log(`${logPrefix} ERROR: Compose file not found at:`, composePath);
			throw new Error(`Compose file not found: ${gitStack.composePath}`);
		}

		const composeContent = readFileSync(composePath, 'utf-8');
		console.log(`${logPrefix} Compose content length:`, composeContent.length, 'chars');
		console.log(`${logPrefix} Compose content:`);
		console.log(composeContent);

		// Determine the source directory and compose filename
		// If contextDir is set, use it as the source directory (relative to repo root)
		// and compute composeFileName as relative path from contextDir to compose file
		let composeDir: string;
		let composeFileName: string;
		if (gitStack.contextDir) {
			const contextDirAbsolute = resolve(repoPath, gitStack.contextDir);
			// Validate: context dir must be within repo
			if (!contextDirAbsolute.startsWith(repoPath)) {
				throw new Error('Context directory must be within the repository');
			}
			// Validate: compose file must be within context directory
			const relCompose = relative(contextDirAbsolute, composePath);
			if (relCompose.startsWith('..')) {
				throw new Error('Compose file must be within the context directory');
			}
			composeDir = contextDirAbsolute;
			composeFileName = relCompose; // e.g., "apps/myapp/compose.yaml"
		} else {
			composeDir = dirname(composePath);
			composeFileName = basename(gitStack.composePath); // e.g., "docker-compose.yaml"
		}
		console.log(`${logPrefix} Source directory (composeDir):`, composeDir);
		console.log(`${logPrefix} Compose filename:`, composeFileName);

		// Read env file if configured (optional - don't fail if missing)
		let envFileVars: Record<string, string> | undefined;
		let envFileContent: string | undefined;
		let envFileName: string | undefined;
		if (gitStack.envFilePath) {
			const envFilePath = join(repoPath, gitStack.envFilePath);
			console.log(`${logPrefix} Looking for env file at:`, envFilePath);
			if (existsSync(envFilePath)) {
				try {
					console.log(`${logPrefix} Reading env file...`);
					envFileContent = readFileSync(envFilePath, 'utf-8');
					envFileVars = parseEnvFileContent(envFileContent, gitStack.stackName);
					console.log(`${logPrefix} Env file parsed, vars count:`, Object.keys(envFileVars).length);

					// Compute env file path relative to compose directory
					// This is needed for --env-file flag after files are copied to stack directory
					envFileName = relative(composeDir, envFilePath);
					console.log(`${logPrefix} Env filename relative to compose dir:`, envFileName);
				} catch (err) {
					// Log but don't fail - env file is optional
					console.warn(`${logPrefix} Failed to read env file ${gitStack.envFilePath}:`, err);
				}
			} else {
				console.warn(`${logPrefix} Configured env file not found:`, gitStack.envFilePath);
			}
		} else {
			console.log(`${logPrefix} No env file path configured`);
		}

		// Update git stack status
		await updateGitStack(stackId, {
			syncStatus: 'synced',
			lastSync: new Date().toISOString(),
			lastCommit: currentCommit,
			syncError: null
		});

		cleanupSshKey(credential);

		console.log(`${logPrefix} ----------------------------------------`);
		console.log(`${logPrefix} SYNC GIT STACK COMPLETE`);
		console.log(`${logPrefix} ----------------------------------------`);
		console.log(`${logPrefix} Success: true`);
		console.log(`${logPrefix} Updated:`, updated);
		console.log(`${logPrefix} Changed files:`, changedFiles.length > 0 ? changedFiles.join(', ') : '(none)');
		console.log(`${logPrefix} Commit:`, currentCommit);
		console.log(`${logPrefix} Env file vars count:`, envFileVars ? Object.keys(envFileVars).length : 0);

		return {
			success: true,
			commit: currentCommit,
			composeContent,
			composeDir,
			composeFileName,
			envFileVars,
			envFileName,
			updated,
			changedFiles
		};
	} catch (error: any) {
		cleanupSshKey(credential);
		await updateGitStack(stackId, {
			syncStatus: 'error',
			syncError: error.message
		});
		console.log(`${logPrefix} SYNC ERROR:`, error.message);
		return { success: false, error: error.message };
	}
}

export async function deployGitStack(stackId: number, options?: { force?: boolean }): Promise<{ success: boolean; output?: string; error?: string; skipped?: boolean }> {
	const force = options?.force ?? true; // Default to force for backward compatibility

	const gitStack = await getGitStack(stackId);
	if (!gitStack) {
		return { success: false, error: 'Git stack not found' };
	}

	const logPrefix = `[Stack:${gitStack.stackName}]`;
	console.log(`${logPrefix} ========================================`);
	console.log(`${logPrefix} DEPLOY GIT STACK START`);
	console.log(`${logPrefix} ========================================`);
	console.log(`${logPrefix} Stack ID:`, stackId);
	console.log(`${logPrefix} Force deploy:`, force);

	// Sync first
	console.log(`${logPrefix} Syncing git repository...`);
	const syncResult = await syncGitStack(stackId);
	if (!syncResult.success) {
		console.log(`${logPrefix} Sync failed:`, syncResult.error);
		return { success: false, error: syncResult.error };
	}

	console.log(`${logPrefix} Sync successful`);
	console.log(`${logPrefix} Sync result - updated:`, syncResult.updated);
	console.log(`${logPrefix} Sync result - commit:`, syncResult.commit);
	console.log(`${logPrefix} Sync result - env file vars:`, syncResult.envFileVars ? Object.keys(syncResult.envFileVars).length : 0);
	if (syncResult.envFileVars && Object.keys(syncResult.envFileVars).length > 0) {
		console.log(`${logPrefix} Env file var keys:`, Object.keys(syncResult.envFileVars).join(', '));
		console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(redactEnvVarsForLog(syncResult.envFileVars), null, 2));
	}

	// Check if there are changes - skip redeploy if no changes and not forced
	// Note: For new stacks (first deploy), syncResult.updated will be true
	// forceRedeploy setting overrides the skip logic for webhooks/scheduled syncs
	const shouldDeploy = force || gitStack.forceRedeploy || syncResult.updated;
	if (!shouldDeploy) {
		console.log(`${logPrefix} No changes detected and force=false, forceRedeploy=false, skipping redeploy`);
		return {
			success: true,
			output: 'No changes detected, skipping redeploy',
			skipped: true
		};
	}

	const forceRecreate = syncResult.updated;
	console.log(`${logPrefix} Will force recreate:`, forceRecreate, `(updated=${syncResult.updated})`);
	console.log(`${logPrefix} Build on deploy:`, gitStack.buildOnDeploy);
	console.log(`${logPrefix} Re-pull images:`, gitStack.repullImages);
	console.log(`${logPrefix} Force redeploy setting:`, gitStack.forceRedeploy);

	// Deploy using unified function - handles both new and existing stacks
	// Uses `docker compose up -d --remove-orphans` which only recreates changed services
	// Force recreate whenever git detected changes to ensure containers pick up
	// new env var values even if compose file itself didn't change
	console.log(`${logPrefix} Calling deployStack...`);
	console.log(`${logPrefix} Source directory (composeDir):`, syncResult.composeDir);
	console.log(`${logPrefix} Compose filename:`, syncResult.composeFileName);
	console.log(`${logPrefix} Env filename:`, syncResult.envFileName ?? '(none)');

	const result = await deployStack({
		name: gitStack.stackName,
		compose: syncResult.composeContent!,
		envId: gitStack.environmentId,
		sourceDir: syncResult.composeDir, // Copy entire directory from git repo
		composeFileName: syncResult.composeFileName, // Use original compose filename from repo
		envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional)
		forceRecreate,
		build: gitStack.buildOnDeploy,
		noBuildCache: gitStack.noBuildCache,
		pullPolicy: gitStack.repullImages ? 'always' : undefined
	});

	console.log(`${logPrefix} ----------------------------------------`);
	console.log(`${logPrefix} DEPLOY GIT STACK RESULT`);
	console.log(`${logPrefix} ----------------------------------------`);
	console.log(`${logPrefix} Success:`, result.success);
	if (result.output) console.log(`${logPrefix} Output:`, result.output);
	if (result.error) console.log(`${logPrefix} Error:`, result.error);

	if (result.success) {
		// Record the stack source with resolved compose path for consistency
		const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
		const resolvedComposePath = syncResult.composeFileName
			? join(stackDir, syncResult.composeFileName)
			: undefined;

		console.log(`${logPrefix} Resolved compose path for stack_sources:`, resolvedComposePath);

		await upsertStackSource({
			stackName: gitStack.stackName,
			environmentId: gitStack.environmentId,
			sourceType: 'git',
			gitRepositoryId: gitStack.repositoryId,
			gitStackId: stackId,
			composePath: resolvedComposePath
		});
	}

	return result;
}

export async function testGitStack(stackId: number): Promise<TestResult> {
	const gitStack = await getGitStack(stackId);
	if (!gitStack) {
		return { success: false, error: 'Git stack not found' };
	}

	const repo = await getGitRepository(gitStack.repositoryId);
	if (!repo) {
		return { success: false, error: 'Repository not found' };
	}

	const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
	const env = await buildGitEnv(credential);
	const repoUrl = buildRepoUrl(repo.url, credential);

	try {
		// Use git ls-remote to test connection and get branch info
		const result = await execGit(
			['ls-remote', '--heads', '--refs', repoUrl, repo.branch],
			process.cwd(),
			env
		);

		cleanupSshKey(credential);

		if (result.code !== 0) {
			return { success: false, error: result.stderr || 'Failed to connect to repository' };
		}

		// Parse the output to get commit hash
		const lines = result.stdout.split('\n').filter(l => l.trim());
		if (lines.length === 0) {
			return { success: false, error: `Branch '${repo.branch}' not found in repository` };
		}

		const match = lines[0].match(/^([a-f0-9]+)\s+refs\/heads\/(.+)$/);
		const lastCommit = match ? match[1].substring(0, 7) : undefined;
		const branch = match ? match[2] : repo.branch;

		cleanupSshKey(credential);

		return {
			success: true,
			branch,
			lastCommit
		};
	} catch (error: any) {
		cleanupSshKey(credential);
		return { success: false, error: error.message };
	}
}

export async function deleteGitStackFiles(stackId: number, stackName?: string, environmentId?: number | null): Promise<void> {
	const repoPath = await getStackRepoPath(stackId, stackName, environmentId);
	try {
		if (existsSync(repoPath)) {
			rmSync(repoPath, { recursive: true, force: true });
		}
	} catch (error) {
		const errorMsg = error instanceof Error ? error.message : String(error);
		console.error('[Git] Failed to delete git stack files:', errorMsg);
	}
}

// Progress callback type
type ProgressCallback = (data: {
	status: 'connecting' | 'cloning' | 'fetching' | 'reading' | 'deploying' | 'complete' | 'error';
	message?: string;
	step?: number;
	totalSteps?: number;
	error?: string;
}) => void;

export async function deployGitStackWithProgress(
	stackId: number,
	onProgress: ProgressCallback
): Promise<{ success: boolean; output?: string; error?: string }> {
	const gitStack = await getGitStack(stackId);
	if (!gitStack) {
		onProgress({ status: 'error', error: 'Git stack not found' });
		return { success: false, error: 'Git stack not found' };
	}

	// Check if sync is already in progress
	if (gitStack.syncStatus === 'syncing') {
		onProgress({ status: 'error', error: 'Sync already in progress' });
		return { success: false, error: 'Sync already in progress' };
	}

	const repo = await getGitRepository(gitStack.repositoryId);
	if (!repo) {
		onProgress({ status: 'error', error: 'Repository not found' });
		return { success: false, error: 'Repository not found' };
	}

	const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
	const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId);
	const env = await buildGitEnv(credential);

	const totalSteps = 5;

	try {
		// Step 1: Connecting
		onProgress({ status: 'connecting', message: 'Connecting to repository...', step: 1, totalSteps });
		await updateGitStack(stackId, { syncStatus: 'syncing', syncError: null });

		let updated = false;
		let currentCommit = '';

		// Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.)
		// Shallow clones are fast so this is acceptable
		// Fall back to DB lastCommit when repo dir was deleted by a previous failed sync (#693)
		const previousCommit = await getPreviousCommit(repoPath, env) ?? gitStack.lastCommit ?? null;

		// Step 2: Cloning
		onProgress({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps });

		if (existsSync(repoPath)) {
			rmSync(repoPath, { recursive: true, force: true });
		}

		const repoUrl = buildRepoUrl(repo.url, credential);

		// Step 3: Fetching (blobless clone - fetches all commits but blobs on-demand)
		onProgress({ status: 'fetching', message: `Fetching branch ${repo.branch}...`, step: 3, totalSteps });
		const cloneResult = await execGit(
			['clone', '--filter=blob:none', '--branch', repo.branch, repoUrl, repoPath],
			process.cwd(),
			env
		);
		if (cloneResult.code !== 0) {
			// Clean up partial clone directory on failure
			if (existsSync(repoPath)) {
				rmSync(repoPath, { recursive: true, force: true });
			}
			throw new Error(`Git clone failed: ${cloneResult.stderr}`);
		}

		// Check if commit changed
		const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
		const newCommit = newCommitResult.stdout.trim();
		// Normalize to 7-char short hash for comparison (DB stores 7-char, git returns 40-char)
		const commitChanged = previousCommit?.substring(0, 7) !== newCommit.substring(0, 7);

		// Check if any files in the context/compose directory have changed
		// (for consistency with syncGitStack, though this function always deploys)
		if (commitChanged) {
			const diffDir = gitStack.contextDir || dirname(gitStack.composePath);
			const diffResult = await getChangedFilesInDir(
				repoPath,
				previousCommit,
				newCommit,
				diffDir || '.',
				env
			);
			updated = diffResult.changed;
		} else {
			updated = false;
		}

		// Get current commit hash
		const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
		currentCommit = commitResult.stdout.substring(0, 7);

		// Step 4: Reading compose file
		onProgress({ status: 'reading', message: `Reading ${gitStack.composePath}...`, step: 4, totalSteps });
		const composePath = join(repoPath, gitStack.composePath);
		if (!existsSync(composePath)) {
			throw new Error(`Compose file not found: ${gitStack.composePath}`);
		}

		const composeContent = readFileSync(composePath, 'utf-8');

		// Determine the source directory and compose filename
		let composeDir: string;
		let progressComposeFileName: string;
		if (gitStack.contextDir) {
			const contextDirAbsolute = resolve(repoPath, gitStack.contextDir);
			if (!contextDirAbsolute.startsWith(repoPath)) {
				throw new Error('Context directory must be within the repository');
			}
			const relCompose = relative(contextDirAbsolute, composePath);
			if (relCompose.startsWith('..')) {
				throw new Error('Compose file must be within the context directory');
			}
			composeDir = contextDirAbsolute;
			progressComposeFileName = relCompose;
		} else {
			composeDir = dirname(composePath);
			progressComposeFileName = basename(gitStack.composePath);
		}

		// Read env file if configured (optional - don't fail if missing)
		let envFileVars: Record<string, string> | undefined;
		if (gitStack.envFilePath) {
			const envFilePath = join(repoPath, gitStack.envFilePath);
			if (existsSync(envFilePath)) {
				try {
					const envContent = readFileSync(envFilePath, 'utf-8');
					envFileVars = parseEnvFileContent(envContent, gitStack.stackName);
				} catch (err) {
					// Log but don't fail - env file is optional
					console.warn(`Failed to read env file ${gitStack.envFilePath}:`, err);
				}
			} else {
				console.warn(`Configured env file not found: ${gitStack.envFilePath}`);
			}
		}

		// Update git stack status
		await updateGitStack(stackId, {
			syncStatus: 'synced',
			lastSync: new Date().toISOString(),
			lastCommit: currentCommit,
			syncError: null
		});

		cleanupSshKey(credential);

		// Step 5: Deploying stack
		// Uses `docker compose up -d --remove-orphans` which only recreates changed services
		onProgress({ status: 'deploying', message: `Deploying ${gitStack.stackName}...`, step: 5, totalSteps });

		// Determine env filename relative to compose dir (same logic as syncGitStack)
		let envFileName: string | undefined;
		if (gitStack.envFilePath) {
			const envFilePath = join(repoPath, gitStack.envFilePath);
			if (existsSync(envFilePath)) {
				envFileName = relative(composeDir, envFilePath);
			}
		}

		const result = await deployStack({
			name: gitStack.stackName,
			compose: composeContent,
			envId: gitStack.environmentId,
			sourceDir: composeDir, // Copy entire directory from git repo
			composeFileName: progressComposeFileName, // Compose filename relative to source dir
			envFileName, // Env file relative to compose dir (for --env-file flag, optional)
			build: gitStack.buildOnDeploy,
			noBuildCache: gitStack.noBuildCache,
			pullPolicy: gitStack.repullImages ? 'always' : undefined
		});

		if (result.success) {
			// Record the stack source with resolved compose path for consistency
			const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
			const resolvedComposePath = join(stackDir, progressComposeFileName);

			await upsertStackSource({
				stackName: gitStack.stackName,
				environmentId: gitStack.environmentId,
				sourceType: 'git',
				gitRepositoryId: gitStack.repositoryId,
				gitStackId: stackId,
				composePath: resolvedComposePath
			});

			onProgress({ status: 'complete', message: `Successfully deployed ${gitStack.stackName}` });
		} else {
			throw new Error(result.error || 'Failed to deploy stack');
		}

		return result;
	} catch (error: any) {
		cleanupSshKey(credential);
		await updateGitStack(stackId, {
			syncStatus: 'error',
			syncError: error.message
		});
		onProgress({ status: 'error', error: error.message });
		return { success: false, error: error.message };
	}
}

// =============================================================================
// ENV FILE OPERATIONS
// =============================================================================

/**
 * List all .env* files in a git stack's repository.
 * Returns relative paths from the repository root.
 */
export async function listGitStackEnvFiles(stackId: number): Promise<{ files: string[]; error?: string }> {
	const gitStack = await getGitStack(stackId);
	if (!gitStack) {
		return { files: [], error: 'Git stack not found' };
	}

	const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId);
	if (!existsSync(repoPath)) {
		return { files: [], error: 'Repository not synced - deploy the stack first' };
	}

	try {
		// Find all .env* files recursively (but not too deep)
		const maxDepth = 3;

		// Use find to locate all .env* files
		const proc = nodeSpawn('find', [repoPath, '-maxdepth', String(maxDepth), '-type', 'f', '-name', '.env*'], {
			stdio: ['pipe', 'pipe', 'pipe']
		});
		const findResult = await collectProcess(proc);
		const output = findResult.stdout;

		const files = output.trim().split('\n').filter(f => f);
		const envFiles: string[] = [];

		for (const file of files) {
			// Convert absolute path to relative from repo root
			const relativePath = file.replace(repoPath + '/', '');
			// Skip files in node_modules or .git directories
			if (!relativePath.includes('node_modules/') && !relativePath.includes('.git/')) {
				envFiles.push(relativePath);
			}
		}

		return { files: envFiles.sort() };
	} catch (error: any) {
		return { files: [], error: error.message };
	}
}

/**
 * Parse a .env file content into key-value pairs.
 * Handles comments, empty lines, and quoted values.
 */
export function parseEnvFileContent(content: string, stackName?: string): Record<string, string> {
	const logPrefix = stackName ? `[Stack:${stackName}]` : '[Git]';
	const result: Record<string, string> = {};
	const skippedLines: string[] = [];
	const invalidKeys: string[] = [];

	console.log(`${logPrefix} ----------------------------------------`);
	console.log(`${logPrefix} PARSE ENV FILE CONTENT`);
	console.log(`${logPrefix} ----------------------------------------`);
	console.log(`${logPrefix} Raw content length:`, content.length, 'chars');
	console.log(`${logPrefix} Raw content:`);
	console.log(content);

	const lines = content.split('\n');
	console.log(`${logPrefix} Total lines:`, lines.length);

	for (let i = 0; i < lines.length; i++) {
		const line = lines[i];
		const trimmed = line.trim();
		// Skip empty lines and comments
		if (!trimmed || trimmed.startsWith('#')) {
			if (trimmed) skippedLines.push(`Line ${i + 1}: ${trimmed.substring(0, 50)}...`);
			continue;
		}

		// Find the first = sign
		const eqIndex = trimmed.indexOf('=');
		if (eqIndex === -1) {
			skippedLines.push(`Line ${i + 1} (no =): ${trimmed.substring(0, 50)}`);
			continue;
		}

		const key = trimmed.substring(0, eqIndex).trim();
		const value = trimmed.substring(eqIndex + 1).trim();

		// Only add if key is valid env var name
		if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
			result[key] = value;
		} else {
			invalidKeys.push(`Line ${i + 1}: "${key}" (invalid key format)`);
		}
	}

	console.log(`${logPrefix} Parsed env vars count:`, Object.keys(result).length);
	console.log(`${logPrefix} Parsed env var keys:`, Object.keys(result).join(', '));
	console.log(`${logPrefix} Parsed env vars (masked):`, JSON.stringify(redactEnvVarsForLog(result), null, 2));
	if (skippedLines.length > 0) {
		console.log(`${logPrefix} Skipped lines (${skippedLines.length}):`, skippedLines.slice(0, 10).join('; '));
	}
	if (invalidKeys.length > 0) {
		console.log(`${logPrefix} Invalid keys (${invalidKeys.length}):`, invalidKeys.join('; '));
	}

	return result;
}

/**
 * Read and parse a .env file from a git stack's repository.
 */
export async function readGitStackEnvFile(
	stackId: number,
	envFilePath: string
): Promise<{ vars: Record<string, string>; error?: string }> {
	const gitStack = await getGitStack(stackId);
	if (!gitStack) {
		return { vars: {}, error: 'Git stack not found' };
	}

	const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId);
	if (!existsSync(repoPath)) {
		return { vars: {}, error: 'Repository not synced - deploy the stack first' };
	}

	// Security check: ensure the path doesn't escape the repo
	const normalizedPath = envFilePath.replace(/\.\./g, '').replace(/^\//, '');
	const fullPath = join(repoPath, normalizedPath);

	if (!fullPath.startsWith(repoPath)) {
		return { vars: {}, error: 'Invalid file path' };
	}

	if (!existsSync(fullPath)) {
		return { vars: {}, error: `File not found: ${envFilePath}` };
	}

	try {
		const content = readFileSync(fullPath, 'utf-8');
		const vars = parseEnvFileContent(content);
		return { vars };
	} catch (error: any) {
		return { vars: {}, error: error.message };
	}
}

interface PreviewEnvOptions {
	repoUrl: string;
	branch: string;
	credential: {
		id: number;
		authType: string;
		sshPrivateKey?: string | null;
		username?: string | null;
		password?: string | null;
	} | null;
	composePath: string;
	envFilePath: string | null;
}

interface PreviewEnvResult {
	vars: Record<string, string>;
	sources: Record<string, '.env' | 'envFile'>;
	error?: string;
}

/**
 * Clone a repository to a temp directory and read env files for preview.
 * Used to populate env editor when creating a new git stack.
 * Cleans up temp directory after reading.
 */
export async function previewRepoEnvFiles(options: PreviewEnvOptions): Promise<PreviewEnvResult> {
	const { repoUrl, branch, credential, composePath, envFilePath } = options;
	const logPrefix = '[Git:Preview]';

	// Create a unique temp directory
	const tempId = `preview-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
	const tempDir = join(GIT_REPOS_DIR, tempId);

	console.log(`${logPrefix} Starting preview for ${repoUrl}`);
	console.log(`${logPrefix} Temp directory: ${tempDir}`);

	try {
		// Ensure temp directory exists
		mkdirSync(tempDir, { recursive: true });

		// Build git environment with credentials
		// Cast credential to GitCredential type (only uses id, authType, sshPrivateKey)
		const env = await buildGitEnv(credential as GitCredential | null);
		const authenticatedUrl = buildRepoUrl(repoUrl, credential as GitCredential | null);

		// Clone with depth 1 (shallow clone for speed)
		const cloneProc = nodeSpawn(
			'git',
			['clone', '--depth', '1', '--branch', branch, '--single-branch', authenticatedUrl, tempDir],
			{
				stdio: ['pipe', 'pipe', 'pipe'],
				env
			}
		);

		const cloneResult = await collectProcess(cloneProc);
		const cloneStderr = cloneResult.stderr;
		const cloneExitCode = cloneResult.exitCode;

		if (cloneExitCode !== 0) {
			console.error(`${logPrefix} Clone failed:`, cloneStderr);
			return { vars: {}, sources: {}, error: `Failed to clone repository: ${cloneStderr.trim()}` };
		}

		console.log(`${logPrefix} Clone successful`);

		// Determine the compose directory (where .env file should be)
		const composeDir = dirname(composePath);
		const baseEnvPath = join(tempDir, composeDir, '.env');

		const vars: Record<string, string> = {};
		const sources: Record<string, '.env' | 'envFile'> = {};

		// Read base .env file if it exists
		if (existsSync(baseEnvPath)) {
			console.log(`${logPrefix} Reading .env from: ${baseEnvPath}`);
			const content = readFileSync(baseEnvPath, 'utf-8');
			const baseVars = parseEnvFileContent(content, 'preview');
			for (const [key, value] of Object.entries(baseVars)) {
				vars[key] = value;
				sources[key] = '.env';
			}
			console.log(`${logPrefix} Found ${Object.keys(baseVars).length} vars in .env`);
		} else {
			console.log(`${logPrefix} No .env file at ${baseEnvPath}`);
		}

		// Read additional env file if specified
		if (envFilePath) {
			const additionalEnvPath = join(tempDir, envFilePath);
			if (existsSync(additionalEnvPath)) {
				console.log(`${logPrefix} Reading additional env file: ${additionalEnvPath}`);
				const content = readFileSync(additionalEnvPath, 'utf-8');
				const additionalVars = parseEnvFileContent(content, 'preview');
				for (const [key, value] of Object.entries(additionalVars)) {
					vars[key] = value;
					sources[key] = 'envFile';
				}
				console.log(`${logPrefix} Found ${Object.keys(additionalVars).length} vars in ${envFilePath}`);
			} else {
				console.log(`${logPrefix} Additional env file not found: ${additionalEnvPath}`);
			}
		}

		console.log(`${logPrefix} Total variables: ${Object.keys(vars).length}`);

		return { vars, sources };
	} catch (error: any) {
		console.error(`${logPrefix} Error:`, error);
		return { vars: {}, sources: {}, error: error.message };
	} finally {
		// Always clean up temp directory
		cleanupSshKey(credential as GitCredential | null);
		try {
			if (existsSync(tempDir)) {
				rmSync(tempDir, { recursive: true, force: true });
				console.log(`${logPrefix} Cleaned up temp directory`);
			}
		} catch (cleanupError) {
			console.error(`${logPrefix} Failed to cleanup temp directory:`, cleanupError);
		}
	}
}
