<?php

declare(strict_types=1);

/**
 * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
 * SPDX-License-Identifier: AGPL-3.0-or-later
 */

namespace OCA\AppAPI\DeployActions;

use Exception;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use OCA\AppAPI\AppInfo\Application;
use OCA\AppAPI\Db\DaemonConfig;

use OCA\AppAPI\Db\ExApp;
use OCA\AppAPI\Service\AppAPICommonService;
use OCA\AppAPI\Service\ExAppDeployOptionsService;
use OCA\AppAPI\Service\ExAppService;
use OCA\AppAPI\Service\HarpService;
use OCP\App\IAppManager;

use OCP\IAppConfig;
use OCP\ICertificateManager;
use OCP\IConfig;
use OCP\ITempManager;
use OCP\IURLGenerator;
use OCP\Security\ICrypto;
use Phar;
use PharData;
use Psr\Log\LoggerInterface;

class DockerActions implements IDeployActions {
	public const DOCKER_API_VERSION = 'v1.44';
	public const EX_APP_CONTAINER_PREFIX = 'nc_app_';
	public const APP_API_HAPROXY_USER = 'app_api_haproxy_user';
	public const DEPLOY_ID = 'docker-install';

	private Client $guzzleClient;
	private bool $useSocket = false;  # for `pullImage` function, to detect can be stream used or not.
	private string $socketAddress;

	public function __construct(
		private readonly LoggerInterface $logger,
		private readonly IAppConfig $appConfig,
		private readonly IConfig $config,
		private readonly ICertificateManager $certificateManager,
		private readonly IAppManager $appManager,
		private readonly IURLGenerator $urlGenerator,
		private readonly AppAPICommonService $service,
		private readonly ExAppService $exAppService,
		private readonly ITempManager $tempManager,
		private readonly ICrypto $crypto,
		private readonly ExAppDeployOptionsService $exAppDeployOptionsService,
	) {
	}

	public function getAcceptsDeployId(): string {
		return self::DEPLOY_ID;
	}

	public function deployExApp(ExApp $exApp, DaemonConfig $daemonConfig, array $params = []): string {
		if (!isset($params['image_params'])) {
			return 'Missing image_params.';
		}
		if (!isset($params['container_params'])) {
			return 'Missing container_params.';
		}

		$dockerUrl = $this->buildDockerUrl($daemonConfig);
		$this->initGuzzleClient($daemonConfig);

		$this->exAppService->setAppDeployProgress($exApp, 0);
		$imageId = '';
		$result = $this->pullImage($dockerUrl, $params['image_params'], $exApp, 0, 94, $daemonConfig, $imageId);
		if ($result) {
			return $result;
		}

		$this->exAppService->setAppDeployProgress($exApp, 95);
		$containerName = $this->buildExAppContainerName($params['container_params']['name']);
		$containerInfo = $this->inspectContainer($dockerUrl, $containerName);
		if (isset($containerInfo['Id'])) {
			$result = $this->removeContainer($dockerUrl, $containerName);
			if ($result) {
				return $result;
			}
		}
		$this->exAppService->setAppDeployProgress($exApp, 96);
		$result = $this->createContainer($dockerUrl, $imageId, $daemonConfig, $params['container_params']);
		if (isset($result['error'])) {
			return $result['error'];
		}
		$this->exAppService->setAppDeployProgress($exApp, 97);

		$this->updateCerts($dockerUrl, $containerName);
		$this->exAppService->setAppDeployProgress($exApp, 98);

		$result = $this->startContainer($dockerUrl, $containerName);
		if (isset($result['error'])) {
			return $result['error'];
		}

		$this->exAppDeployOptionsService->removeExAppDeployOptions($exApp->getAppid());
		$this->exAppDeployOptionsService->addExAppDeployOptions($exApp->getAppid(), $params['deploy_options']);

		$this->exAppService->setAppDeployProgress($exApp, 99);
		if (!$this->waitTillContainerStart($containerName, $daemonConfig)) {
			return 'container startup failed';
		}
		$this->exAppService->setAppDeployProgress($exApp, 100);
		return '';
	}

	public function deployExAppHarp(ExApp $exApp, DaemonConfig $daemonConfig, array $params = []): string {
		if (!isset($params['image_params'])) {
			return 'Missing image_params.';
		}
		if (!isset($params['container_params'])) {
			return 'Missing container_params.';
		}

		$dockerUrl = $this->buildDockerUrl($daemonConfig);
		$this->initGuzzleClient($daemonConfig);

		$this->exAppService->setAppDeployProgress($exApp, 0);
		$imageId = '';
		$error = $this->pullImage($dockerUrl, $params['image_params'], $exApp, 0, 94, $daemonConfig, $imageId);
		if ($error) {
			return $error;
		}

		$this->exAppService->setAppDeployProgress($exApp, 95);

		$exAppName = $params['container_params']['name'];
		$instanceId = '';  // $this->config->getSystemValue('instanceid', '');

		$error = $this->removeExApp($dockerUrl, $exAppName, ignoreIfNotExists: true);
		if ($error) {
			return $error;
		}
		$this->exAppService->setAppDeployProgress($exApp, 96);

		$computeDevice = 'cpu';
		if (isset($params['container_params']['computeDevice']['id'])) {
			$computeDevice = $params['container_params']['computeDevice']['id'];
		}
		$mountPoints = $params['container_params']['mounts'] ?? [];
		if (!is_array($mountPoints)) {
			$mountPoints = [];
		}
		$createPayload = [
			'name' => $exAppName,
			'instance_id' => $instanceId,
			'image_id' => $imageId,
			'network_mode' => $params['container_params']['net'] ?? 'bridge',
			'environment_variables' => $params['container_params']['env'] ?? [],
			'restart_policy' => $this->appConfig->getValueString(Application::APP_ID, 'container_restart_policy', 'unless-stopped', lazy: true),
			'compute_device' => $computeDevice,
			'mount_points' => $mountPoints,
			'start_container' => true
		];

		if (isset($params['container_params']['resourceLimits']) && !empty($params['container_params']['resourceLimits'])) {
			$createPayload['resource_limits'] = $params['container_params']['resourceLimits'];
		}

		$logPayload = $createPayload;
		$logPayload['environment_variables'] = '[REDACTED]';
		$this->logger->debug(sprintf('Payload for /docker/exapp/create for %s: %s', $exAppName, json_encode($logPayload)));
		try {
			$response = $this->guzzleClient->post(
				sprintf('%s/%s', $dockerUrl, 'docker/exapp/create'),
				['json' => $createPayload],
			);

			if ($response->getStatusCode() !== 201) {
				$errorBody = (string)$response->getBody();
				$this->logger->error(sprintf('Failed to create ExApp container %s. Status: %d, Body: %s', $exAppName, $response->getStatusCode(), $errorBody));
				return sprintf('Failed to create ExApp container (status %d). Check HaRP logs. Details: %s', $response->getStatusCode(), $errorBody);
			}

			$responseData = json_decode((string)$response->getBody(), true);
			if ($responseData === null || !isset($responseData['name']) || !isset($responseData['id'])) {
				$this->logger->error(sprintf('Invalid JSON response from HaRP /docker/exapp/create for %s: %s', $exAppName, $response->getBody()));
				return 'Invalid response from HaRP agent after container creation.';
			}
			$this->logger->info(sprintf('Container %s (ID: %s) created successfully for ExApp %s.', $responseData['name'], $responseData['id'], $exAppName));
		} catch (GuzzleException $e) {
			$this->logger->error(sprintf('GuzzleException during HaRP /docker/exapp/create for %s: %s', $exAppName, $e->getMessage()), ['exception' => $e]);
			return 'Failed to communicate with HaRP agent for container creation: ' . $e->getMessage();
		} catch (Exception $e) {
			$this->logger->error(sprintf('Exception during HaRP /docker/exapp/create for %s: %s', $exAppName, $e->getMessage()), ['exception' => $e]);
			return 'An unexpected error occurred while creating container: ' . $e->getMessage();
		}
		$this->exAppService->setAppDeployProgress($exApp, 97);

		$this->updateCertsHarp($daemonConfig, $dockerUrl, $exAppName);
		$this->exAppService->setAppDeployProgress($exApp, 98);

		$error = $this->startExApp($dockerUrl, $exAppName);
		if ($error) {
			return $error;
		}

		$this->exAppDeployOptionsService->removeExAppDeployOptions($exApp->getAppid());
		$this->exAppDeployOptionsService->addExAppDeployOptions($exApp->getAppid(), $params['deploy_options']);

		$this->exAppService->setAppDeployProgress($exApp, 99);
		if (!$this->waitExAppStart($dockerUrl, $exAppName)) {
			return 'container startup failed';
		}
		$this->exAppService->setAppDeployProgress($exApp, 100);
		return '';
	}

	private function updateCerts(string $dockerUrl, string $containerName): void {
		try {
			$this->startContainer($dockerUrl, $containerName);

			$osInfo = $this->getContainerOsInfo($dockerUrl, $containerName);
			if (!$this->isSupportedOs($osInfo)) {
				$this->logger->warning(sprintf(
					'Unsupported OS detected for container: %s. OS info: %s',
					$containerName,
					$osInfo
				));
				return;
			}

			$bundlePath = $this->certificateManager->getAbsoluteBundlePath();
			$targetDir = $this->getTargetCertDir($osInfo); // Determine target directory based on OS
			$this->executeCommandInContainer($dockerUrl, $containerName, ['mkdir', '-p', $targetDir]);
			$this->installParsedCertificates($dockerUrl, $containerName, $bundlePath, $targetDir);

			$updateCommand = $this->getCertificateUpdateCommand($osInfo);
			$this->executeCommandInContainer($dockerUrl, $containerName, $updateCommand);
		} catch (Exception $e) {
			$this->logger->warning(sprintf(
				'Failed to update certificates in container: %s. Error: %s',
				$containerName,
				$e->getMessage()
			));
		} finally {
			$this->stopContainer($dockerUrl, $containerName);
		}
	}

	private function updateCertsHarp(DaemonConfig $daemonConfig, string $dockerUrl, string $exAppName): void {
		$instanceId = '';  // $this->config->getSystemValue('instanceid', '');
		try {
			$this->logger->info(sprintf('Starting certificate installation process for ExApp "%s" (instance "%s").', $exAppName, $instanceId));

			$payload = [
				'name' => $exAppName,
				'instance_id' => $instanceId,
				'system_certs_bundle' => null,
				'install_frp_certs' => !HarpService::isHarpDirectConnect($daemonConfig->getDeployConfig()),
			];

			$bundlePath = $this->certificateManager->getAbsoluteBundlePath();
			if (file_exists($bundlePath) && is_readable($bundlePath)) {
				$payload['system_certs_bundle'] = file_get_contents($bundlePath);
				if ($payload['system_certs_bundle'] === false) {
					$this->logger->warning(sprintf('Failed to read system CA bundle from "%s" for ExApp "%s". System certs will not be installed.', $bundlePath, $exAppName));
					$payload['system_certs_bundle'] = null;
				}
			} else {
				$this->logger->warning(sprintf('System CA bundle not found or not readable at "%s" for ExApp "%s". System certs will not be installed.', $bundlePath, $exAppName));
			}

			$response = $this->guzzleClient->post(
				sprintf('%s/%s', $dockerUrl, 'docker/exapp/install_certificates'),
				[
					'json' => $payload,
					'timeout' => 180,
				]
			);

			$statusCode = $response->getStatusCode();

			if ($statusCode === 204) {
				$this->logger->info(sprintf('Successfully installed certificates for ExApp "%s" (instance "%s").', $exAppName, $instanceId));
			} else {
				$errorBody = (string)$response->getBody();
				$this->logger->error(sprintf('Failed to install certificates for ExApp "%s" (instance "%s"). Status: %d, Body: %s', $exAppName, $instanceId, $statusCode, $errorBody));
			}

		} catch (GuzzleException $e) {
			$this->logger->error(sprintf('GuzzleException during certificate installation for ExApp "%s" (instance "%s"): %s', $exAppName, $instanceId, $e->getMessage()), ['exception' => $e]);
		} catch (Exception $e) {
			$this->logger->error(sprintf('Unexpected exception during certificate installation for ExApp "%s" (instance "%s"): %s', $exAppName, $instanceId, $e->getMessage()), ['exception' => $e]);
		}
	}

	private function parseCertificatesFromBundle(string $bundlePath): array {
		$contents = file_get_contents($bundlePath);

		// Match only certificates
		preg_match_all('/-----BEGIN CERTIFICATE-----(.+?)-----END CERTIFICATE-----/s', $contents, $matches);

		return $matches[0] ?? [];
	}

	private function installParsedCertificates(string $dockerUrl, string $containerId, string $bundlePath, string $targetDir): void {
		$certificates = $this->parseCertificatesFromBundle($bundlePath);
		$tempDir = sys_get_temp_dir();

		foreach ($certificates as $index => $certificate) {
			$tempFile = $tempDir . "/{$containerId}_cert_{$index}.crt";
			if (file_exists($tempFile)) {
				unlink($tempFile);
			}
			file_put_contents($tempFile, $certificate);

			// Build the path in the container
			$pathInContainer = $targetDir . "/custom_cert_$index.crt";

			$this->dockerCopy($dockerUrl, $containerId, $tempFile, $pathInContainer);
			unlink($tempFile);
		}
	}

	private function dockerCopy(string $dockerUrl, string $containerId, string $sourcePath, string $pathInContainer): void {
		$archivePath = $this->createTarArchive($sourcePath, $pathInContainer);
		$url = $this->buildApiUrl($dockerUrl, sprintf('containers/%s/archive?path=%s', $containerId, urlencode('/')));

		try {
			$archiveData = file_get_contents($archivePath);
			$this->guzzleClient->put($url, [
				'body' => $archiveData,
				'headers' => ['Content-Type' => 'application/x-tar']
			]);
		} catch (Exception $e) {
			throw new Exception(sprintf('Failed to copy %s to container %s: %s', $sourcePath, $containerId, $e->getMessage()));
		} finally {
			if (file_exists($archivePath)) {
				unlink($archivePath);
			}
		}
	}

	private function getTargetCertDir(string $osInfo): string {
		if (stripos($osInfo, 'alpine') !== false) {
			return '/usr/local/share/ca-certificates'; // Alpine Linux
		}

		if (stripos($osInfo, 'debian') !== false || stripos($osInfo, 'ubuntu') !== false) {
			return '/usr/local/share/ca-certificates'; // Debian and Ubuntu
		}

		if (stripos($osInfo, 'centos') !== false || stripos($osInfo, 'almalinux') !== false) {
			return '/etc/pki/ca-trust/source/anchors'; // CentOS and AlmaLinux
		}

		throw new Exception(sprintf('Unsupported OS: %s', $osInfo));
	}

	private function createTarArchive(string $filePath, string $pathInContainer): string {
		$tempFile = $this->tempManager->getTemporaryFile('.tar');
		if ($tempFile === false) {
			throw new Exception('Failed to create tar archive (getTemporaryFile fails).');
		}

		try {
			if (file_exists($tempFile)) {
				unlink($tempFile);
			}

			$archive = new PharData($tempFile, 0, null, Phar::TAR);
			$relativePathInArchive = ltrim($pathInContainer, '/');
			$archive->addFile($filePath, $relativePathInArchive);
		} catch (\Exception $e) {
			// Clean up the temporary file in case of an error
			if (file_exists($tempFile)) {
				unlink($tempFile);
			}
			throw new Exception(sprintf('Failed to create tar archive: %s', $e->getMessage()));
		}
		return $tempFile; // Return the path to the TAR archive
	}

	private function getCertificateUpdateCommand(string $osInfo): string {
		if (stripos($osInfo, 'alpine') !== false) {
			return 'update-ca-certificates';
		}
		if (stripos($osInfo, 'debian') !== false || stripos($osInfo, 'ubuntu') !== false) {
			return 'update-ca-certificates';
		}
		if (stripos($osInfo, 'centos') !== false || stripos($osInfo, 'almalinux') !== false) {
			return 'update-ca-trust extract';
		}
		throw new Exception('Unsupported OS');
	}

	private function getContainerOsInfo(string $dockerUrl, string $containerId): string {
		$command = ['cat', '/etc/os-release'];
		return $this->executeCommandInContainer($dockerUrl, $containerId, $command);
	}

	private function isSupportedOs(string $osInfo): bool {
		return (bool)preg_match('/(alpine|debian|ubuntu|centos|almalinux)/i', $osInfo);
	}

	private function executeCommandInContainer(string $dockerUrl, string $containerId, $command): string {
		$url = $this->buildApiUrl($dockerUrl, sprintf('containers/%s/exec', $containerId));
		$payload = [
			'Cmd' => is_array($command) ? $command : explode(' ', $command),
			'AttachStdout' => true,
			'AttachStderr' => true,
		];
		$response = $this->guzzleClient->post($url, ['json' => $payload]);
		$execId = json_decode((string)$response->getBody(), true)['Id'];

		// Start the exec process
		$startUrl = $this->buildApiUrl($dockerUrl, sprintf('exec/%s/start', $execId));
		$startResponse = $this->guzzleClient->post($startUrl, ['json' => ['Detach' => false, 'Tty' => false]]);
		return (string)$startResponse->getBody();
	}

	public function getDockerApiVersion(): string {
		$version = $this->appConfig->getValueString(Application::APP_ID, 'docker_api_version', lazy: true);
		if ($version !== '') {
			return $version;
		}
		return self::DOCKER_API_VERSION;
	}

	public function buildApiUrl(string $dockerUrl, string $route): string {
		return sprintf('%s/%s/%s', $dockerUrl, $this->getDockerApiVersion(), $route);
	}

	public function buildBaseImageName(array $imageParams, DaemonConfig $daemonConfig): string {
		$deployConfig = $daemonConfig->getDeployConfig();
		if (isset($deployConfig['registries'])) { // custom Docker registry, overrides ExApp's image_src
			foreach ($deployConfig['registries'] as $registry) {
				if ($registry['from'] === $imageParams['image_src'] && $registry['to'] !== 'local') { // local target skips image pull, imageId should be unchanged
					$imageParams['image_src'] = rtrim($registry['to'], '/');
					break;
				}
			}
		}
		return $imageParams['image_src'] . '/'
			. $imageParams['image_name'] . ':' . $imageParams['image_tag'];
	}

	private function buildExtendedImageName(array $imageParams, DaemonConfig $daemonConfig): ?string {
		$deployConfig = $daemonConfig->getDeployConfig();
		if (empty($deployConfig['computeDevice']['id'])) {
			return null;
		}
		if (isset($deployConfig['registries'])) { // custom Docker registry, overrides ExApp's image_src
			foreach ($deployConfig['registries'] as $registry) {
				if ($registry['from'] === $imageParams['image_src'] && $registry['to'] !== 'local') { // local target skips image pull, imageId should be unchanged
					$imageParams['image_src'] = rtrim($registry['to'], '/');
					break;
				}
			}
		}
		return $imageParams['image_src'] . '/'
			. $imageParams['image_name'] . ':' . $imageParams['image_tag'] . '-' . $daemonConfig->getDeployConfig()['computeDevice']['id'];
	}

	private function shouldPullImage(array $imageParams, DaemonConfig $daemonConfig): bool {
		$deployConfig = $daemonConfig->getDeployConfig();
		if (isset($deployConfig['registries'])) { // custom Docker registry, overrides ExApp's image_src
			foreach ($deployConfig['registries'] as $registry) {
				if ($registry['from'] === $imageParams['image_src'] && $registry['to'] === 'local') { // local target skips image pull, imageId should be unchanged
					return false;
				}
			}
		}
		return true;
	}

	public function imageExists(string $dockerUrl, string $imageId): bool {
		$url = $this->buildApiUrl($dockerUrl, sprintf('images/%s/json', $imageId));
		try {
			$response = $this->guzzleClient->get($url);
			return $response->getStatusCode() === 200;
		} catch (GuzzleException $e) {
			if ($e->getCode() !== 404) {
				$this->logger->error('Failed to check image existence', ['exception' => $e]);
			}
			return false;
		}
	}

	public function createContainer(string $dockerUrl, string $imageId, DaemonConfig $daemonConfig, array $params = []): array {
		$createVolumeResult = $this->createVolume($dockerUrl, $this->buildExAppVolumeName($params['name']));
		if (isset($createVolumeResult['error'])) {
			return $createVolumeResult;
		}

		$containerParams = [
			'Image' => $imageId,
			'Hostname' => $params['hostname'],
			'HostConfig' => [
				'NetworkMode' => $params['net'],
				'Mounts' => $this->buildDefaultExAppVolume($params['hostname']),
				'RestartPolicy' => [
					'Name' => $this->appConfig->getValueString(Application::APP_ID, 'container_restart_policy', 'unless-stopped', lazy: true),
				],
			],
			'Env' => $params['env'],
		];

		// Exposing the ExApp's primary port when the installation type is remote and the network is not a "host"
		if (($params['net'] !== 'host') && ($daemonConfig->getProtocol() === 'https')) {
			$exAppMainPort = $params['port'];
			$containerParams['ExposedPorts'] = [
				sprintf('%d/tcp', $exAppMainPort) => (object)[],
				sprintf('%d/udp', $exAppMainPort) => (object)[],
			];
			$containerParams['HostConfig']['PortBindings'] = [
				sprintf('%d/tcp', $exAppMainPort) => [
					['HostPort' => (string)$exAppMainPort, 'HostIp' => '127.0.0.1'],
					['HostPort' => (string)$exAppMainPort, 'HostIp' => '::1'],
				],
				sprintf('%d/udp', $exAppMainPort) => [
					['HostPort' => (string)$exAppMainPort, 'HostIp' => '127.0.0.1'],
					['HostPort' => (string)$exAppMainPort, 'HostIp' => '::1'],
				],
			];
		}

		if (!in_array($params['net'], ['host', 'bridge'])) {
			$networkingConfig = [
				'EndpointsConfig' => [
					$params['net'] => [
						'Aliases' => [
							$params['hostname']
						],
					],
				],
			];
			$containerParams['NetworkingConfig'] = $networkingConfig;
		}

		$isPodman = $this->isPodman($dockerUrl);
		if (isset($params['computeDevice'])) {
			if ($params['computeDevice']['id'] === 'cuda') {
				if ($isPodman === true) {
					// Podman 5.4+: use Docker-compat DeviceRequests with CDI driver.
					$selectors = $params['cdiDevices'] ?? ['nvidia.com/gpu=all'];
					$containerParams['HostConfig']['DeviceRequests'] = [[
						'Driver' => 'cdi',
						'DeviceIDs' => $selectors,
					]];
					// Remove NVIDIA_* envs to avoid hook/handoff conflicts under CDI
					if (!empty($containerParams['Env'])) {
						$containerParams['Env'] = array_values(array_filter(
							$containerParams['Env'],
							fn (string $e)
								=> !str_starts_with($e, 'NVIDIA_VISIBLE_DEVICES=')
								&& !str_starts_with($e, 'NVIDIA_DRIVER_CAPABILITIES=')
						));
					}
				} else {
					if (isset($params['deviceRequests'])) {
						$containerParams['HostConfig']['DeviceRequests'] = $params['deviceRequests'];
					} else {
						$containerParams['HostConfig']['DeviceRequests'] = $this->buildDefaultGPUDeviceRequests();
					}
				}
			}
			if ($params['computeDevice']['id'] === 'rocm') {
				if (isset($params['devices'])) {
					$containerParams['HostConfig']['Devices'] = $params['devices'];
				} else {
					$containerParams['HostConfig']['Devices'] = $this->buildDevicesParams(['/dev/kfd', '/dev/dri']);
				}
			}
		}

		if (isset($params['mounts'])) {
			$containerParams['HostConfig']['Mounts'] = array_merge(
				$containerParams['HostConfig']['Mounts'] ?? [],
				array_map(function ($mount) {
					return [
						'Source' => $mount['source'],
						'Target' => $mount['target'],
						'Type' => 'bind', // we don't support other types for now
						'ReadOnly' => $mount['mode'] === 'ro',
					];
				}, $params['mounts'])
			);
		}

		if (isset($params['resourceLimits'])) {
			if (isset($params['resourceLimits']['memory']) && $params['resourceLimits']['memory'] > 0) {
				// memory in bytes
				$containerParams['HostConfig']['Memory'] = $params['resourceLimits']['memory'];
			}
			if (isset($params['resourceLimits']['nanoCPUs']) && $params['resourceLimits']['nanoCPUs'] > 0) {
				$containerParams['HostConfig']['NanoCPUs'] = $params['resourceLimits']['nanoCPUs'];
			}
		}

		$url = $this->buildApiUrl($dockerUrl, sprintf('containers/create?name=%s', urlencode($this->buildExAppContainerName($params['name']))));
		try {
			$options['json'] = $containerParams;
			$response = $this->guzzleClient->post($url, $options);
			return json_decode((string)$response->getBody(), true);
		} catch (GuzzleException $e) {
			$this->logger->error('Failed to create container', ['exception' => $e]);
			error_log($e->getMessage());
			return ['error' => 'Failed to create container'];
		}
	}

	public function startContainer(string $dockerUrl, string $containerId): array {
		$url = $this->buildApiUrl($dockerUrl, sprintf('containers/%s/start', $containerId));
		try {
			$response = $this->guzzleClient->post($url);
			return ['success' => $response->getStatusCode() === 204];
		} catch (GuzzleException $e) {
			$this->logger->error('Failed to start container', ['exception' => $e]);
			error_log($e->getMessage());
			return ['error' => 'Failed to start container'];
		}
	}

	public function stopContainer(string $dockerUrl, string $containerId): array {
		$url = $this->buildApiUrl($dockerUrl, sprintf('containers/%s/stop', $containerId));
		try {
			$response = $this->guzzleClient->post($url);
			return ['success' => $response->getStatusCode() === 204];
		} catch (GuzzleException $e) {
			$this->logger->error('Failed to stop container', ['exception' => $e]);
			error_log($e->getMessage());
			return ['error' => 'Failed to stop container'];
		}
	}

	public function removeContainer(string $dockerUrl, string $containerId): string {
		$url = $this->buildApiUrl($dockerUrl, sprintf('containers/%s?force=true', $containerId));
		try {
			$response = $this->guzzleClient->delete($url);
			$this->logger->debug(sprintf('StatusCode of container removal: %d', $response->getStatusCode()));
			if ($response->getStatusCode() === 200 || $response->getStatusCode() === 204) {
				return '';
			}
		} catch (GuzzleException $e) {
			if ($e->getCode() === 409) {  // "removal of container ... is already in progress"
				return '';
			}
			$this->logger->error('Failed to remove container', ['exception' => $e]);
			error_log($e->getMessage());
		}
		return sprintf('Failed to remove container: %s', $containerId);
	}

	public function pullImage(
		string $dockerUrl, array $params, ExApp $exApp, int $startPercent, int $maxPercent, DaemonConfig $daemonConfig, string &$imageId,
	): string {
		$shouldPull = $this->shouldPullImage($params, $daemonConfig);
		$urlToLog = $this->useSocket ? $this->socketAddress : $dockerUrl;
		$imageId = $this->buildExtendedImageName($params, $daemonConfig);

		if ($imageId) {
			try {
				if ($shouldPull) {
					$r = $this->pullImageInternal($dockerUrl, $exApp, $startPercent, $maxPercent, $imageId);
					if ($r === '') {
						$this->logger->info(sprintf('Successfully pulled "extended" image: %s', $imageId));
						return '';
					}
					$this->logger->info(sprintf('Failed to pull "extended" image(%s): %s', $imageId, $r));
				} elseif ($this->imageExists($dockerUrl, $imageId)) {
					$this->logger->info('Daemon registry mapping set to "local", skipping image pull');
					$this->exAppService->setAppDeployProgress($exApp, $maxPercent);
					return '';
				} else {
					$this->logger->info(sprintf('Image(%s) not found, but daemon registry mapping set to "local", trying base image', $imageId));
				}
			} catch (GuzzleException $e) {
				$this->logger->info(
					sprintf('Failed to pull "extended" image via "%s", GuzzleException occur: %s', $urlToLog, $e->getMessage())
				);
			}
		}

		$imageId = $this->buildBaseImageName($params, $daemonConfig);
		try {
			if ($shouldPull) {
				$this->logger->info(sprintf('Pulling "base" image: %s', $imageId));
				$r = $this->pullImageInternal($dockerUrl, $exApp, $startPercent, $maxPercent, $imageId);
				if ($r === '') {
					$this->logger->info(sprintf('Image(%s) pulled successfully.', $imageId));
				}
			} elseif ($this->imageExists($dockerUrl, $imageId)) {
				$this->logger->info('Daemon registry mapping set to "local", skipping image pull');
				$this->exAppService->setAppDeployProgress($exApp, $maxPercent);
				return '';
			} else {
				$this->logger->warning(sprintf('Image(%s) not found, but daemon registry mapping set to "local", skipping image pull', $imageId));
				return '';
			}
		} catch (GuzzleException $e) {
			$r = sprintf('Failed to pull image via "%s", GuzzleException occur: %s', $urlToLog, $e->getMessage());
		}
		return $r;
	}

	/**
	 * @throws GuzzleException
	 */
	public function pullImageInternal(
		string $dockerUrl, ExApp $exApp, int $startPercent, int $maxPercent, string $imageId,
	): string {
		# docs: https://github.com/docker/compose/blob/main/pkg/compose/pull.go
		$layerInProgress = ['preparing', 'waiting', 'pulling fs layer', 'download', 'extracting', 'verifying checksum'];
		$layerFinished = ['already exists', 'pull complete'];
		$disableProgressTracking = false;
		$url = $this->buildApiUrl($dockerUrl, sprintf('images/create?fromImage=%s', urlencode($imageId)));
		if ($this->useSocket) {
			$response = $this->guzzleClient->post($url);
		} else {
			$response = $this->guzzleClient->post($url, ['stream' => true]);
		}
		if ($response->getStatusCode() !== 200) {
			return sprintf('Pulling ExApp Image: %s return status code: %d', $imageId, $response->getStatusCode());
		}
		if ($this->useSocket) {
			return '';
		}
		$lastPercent = $startPercent;
		$layers = [];
		$buffer = '';
		$responseBody = $response->getBody();
		while (!$responseBody->eof()) {
			$buffer .= $responseBody->read(1024);
			try {
				while (($newlinePos = strpos($buffer, "\n")) !== false) {
					$line = substr($buffer, 0, $newlinePos);
					$buffer = substr($buffer, $newlinePos + 1);
					$jsonLine = json_decode(trim($line));
					if ($jsonLine) {
						if (isset($jsonLine->id) && isset($jsonLine->status)) {
							$layerId = $jsonLine->id;
							$status = strtolower($jsonLine->status);
							foreach ($layerInProgress as $substring) {
								if (str_contains($status, $substring)) {
									$layers[$layerId] = false;
									break;
								}
							}
							foreach ($layerFinished as $substring) {
								if (str_contains($status, $substring)) {
									$layers[$layerId] = true;
									break;
								}
							}
						}
					} else {
						$this->logger->warning(
							sprintf('Progress tracking of image pulling(%s) disabled, error: %d, data: %s', $exApp->getAppid(), json_last_error(), $line)
						);
						$disableProgressTracking = true;
					}
				}
			} catch (Exception $e) {
				$this->logger->warning(
					sprintf('Progress tracking of image pulling(%s) disabled, exception: %s', $exApp->getAppid(), $e->getMessage()), ['exception' => $e]
				);
				$disableProgressTracking = true;
			}
			if (!$disableProgressTracking) {
				$completedLayers = count(array_filter($layers));
				$totalLayers = count($layers);
				$newLastPercent = intval($totalLayers > 0 ? (int)($completedLayers / $totalLayers) * ($maxPercent - $startPercent) : 0);
				if ($lastPercent != $newLastPercent) {
					$this->exAppService->setAppDeployProgress($exApp, $newLastPercent);
					$lastPercent = $newLastPercent;
				}
			}
		}
		return '';
	}

	public function inspectContainer(string $dockerUrl, string $containerId): array {
		$url = $this->buildApiUrl($dockerUrl, sprintf('containers/%s/json', $containerId));
		try {
			$response = $this->guzzleClient->get($url);
			return json_decode((string)$response->getBody(), true);
		} catch (GuzzleException $e) {
			return ['error' => $e->getMessage(), 'exception' => $e];
		}
	}

	/**
	 * @throws GuzzleException
	 */
	public function getContainerLogs(string $dockerUrl, string $containerId, string $tail = 'all'): string {
		$url = $this->buildApiUrl(
			$dockerUrl, sprintf('containers/%s/logs?stdout=true&stderr=true&tail=%s', $containerId, $tail)
		);
		$response = $this->guzzleClient->get($url);
		return array_reduce($this->processDockerLogs((string)$response->getBody()), function ($carry, $logEntry) {
			return $carry . $logEntry['content'];
		}, '');
	}

	private function processDockerLogs($binaryData): array {
		$offset = 0;
		$length = strlen($binaryData);
		$logs = [];

		while ($offset < $length) {
			if ($offset + 8 > $length) {
				break; // Incomplete header, handle this case as needed
			}

			// Unpack the header
			$header = unpack('C1type/C3skip/N1size', substr($binaryData, $offset, 8));
			$offset += 8; // Move past the header

			// Extract the log data based on the size from header
			$logSize = $header['size'];
			if ($offset + $logSize > $length) {
				break; // Incomplete data, handle this case as needed
			}

			$logs[] = [
				'stream_type' => $header['type'] === 1 ? 'stdout' : 'stderr',
				'content' => substr($binaryData, $offset, $logSize)
			];

			$offset += $logSize; // Move to the next log entry
		}

		return $logs;
	}

	public function createVolume(string $dockerUrl, string $volume): array {
		$url = $this->buildApiUrl($dockerUrl, 'volumes/create');
		try {
			$options['json'] = [
				'name' => $volume,
			];
			$response = $this->guzzleClient->post($url, $options);
			$result = json_decode((string)$response->getBody(), true);
			if ($response->getStatusCode() === 201) {
				return $result;
			}
			if ($response->getStatusCode() === 500) {
				error_log($result['message']);
				return ['error' => $result['message']];
			}
		} catch (GuzzleException $e) {
			$this->logger->error('Failed to create volume', ['exception' => $e]);
			error_log($e->getMessage());
		}
		return ['error' => 'Failed to create volume'];
	}

	public function removeVolume(string $dockerUrl, string $volume): array {
		$url = $this->buildApiUrl($dockerUrl, sprintf('volumes/%s', $volume));
		try {
			$options['json'] = [
				'name' => $volume,
			];
			$response = $this->guzzleClient->delete($url, $options);
			if ($response->getStatusCode() === 204) {
				return ['success' => true];
			}
			if ($response->getStatusCode() === 404) {
				error_log('Volume not found.');
				return ['error' => 'Volume not found.'];
			}
			if ($response->getStatusCode() === 409) {
				error_log('Volume is in use.');
				return ['error' => 'Volume is in use.'];
			}
			if ($response->getStatusCode() === 500) {
				error_log('Something went wrong.');
				return ['error' => 'Something went wrong.'];
			}
		} catch (GuzzleException $e) {
			$this->logger->error('Failed to create volume', ['exception' => $e]);
			error_log($e->getMessage());
		}
		return ['error' => 'Failed to remove volume'];
	}

	public function ping(string $dockerUrl): bool {
		$url = $this->buildApiUrl($dockerUrl, '_ping');
		try {
			$response = $this->guzzleClient->get($url, [
				'timeout' => 3,
			]);
			if ($response->getStatusCode() === 200) {
				return true;
			}
		} catch (Exception $e) {
			$urlToLog = $this->useSocket ? $this->socketAddress : $url;
			$this->logger->error('Could not connect to Docker daemon via {url}', ['exception' => $e, 'url' => $urlToLog]);
			error_log($e->getMessage());
		}
		return false;
	}

	public function startExApp(string $dockerUrl, string $exAppName, bool $ignoreIfAlready = false): string {
		$instanceId = '';  // $this->config->getSystemValue('instanceid', '');
		try {
			$response = $this->guzzleClient->post(
				sprintf('%s/%s', $dockerUrl, 'docker/exapp/start'),
				[
					'json' => [
						'name' => $exAppName,
						'instance_id' => $instanceId,
					]
				]
			);
			$statusCode = $response->getStatusCode();
			if ($statusCode === 204) {
				$this->logger->info(sprintf('ExApp container "%s" (instance "%s") successfully started.', $exAppName, $instanceId));
				return '';
			}
			if ($statusCode === 200) {
				if ($ignoreIfAlready) {
					$this->logger->info(sprintf('ExApp container "%s" (instance "%s") was already started.', $exAppName, $instanceId));
					return '';
				} else {
					$errorMsg = sprintf('ExApp container "%s" (instance "%s") was already started.', $exAppName, $instanceId);
					$this->logger->warning($errorMsg);
					return $errorMsg;
				}
			}
			$errorBody = (string)$response->getBody();
			$this->logger->error(sprintf('Failed to start ExApp container "%s" (instance "%s"). Status: %d, Body: %s', $exAppName, $instanceId, $statusCode, $errorBody));
			return sprintf('Failed to start ExApp container "%s" (Status: %d). Details: %s', $exAppName, $statusCode, $errorBody);
		} catch (GuzzleException $e) {
			$this->logger->error(sprintf('GuzzleException while trying to start ExApp container "%s" (instance "%s"): %s', $exAppName, $instanceId, $e->getMessage()), ['exception' => $e]);
			return sprintf('Failed to communicate with HaRP agent to start ExApp "%s": %s', $exAppName, $e->getMessage());
		} catch (Exception $e) {
			$this->logger->error(sprintf('Unexpected exception while starting ExApp container "%s" (instance "%s"): %s', $exAppName, $instanceId, $e->getMessage()), ['exception' => $e]);
			return sprintf('An unexpected error occurred while starting ExApp "%s": %s', $exAppName, $e->getMessage());
		}
	}

	public function stopExApp(string $dockerUrl, string $exAppName, bool $ignoreIfAlready = false): string {
		$instanceId = '';  // $this->config->getSystemValue('instanceid', '');
		try {
			$response = $this->guzzleClient->post(
				sprintf('%s/%s', $dockerUrl, 'docker/exapp/stop'),
				[
					'json' => [
						'name' => $exAppName,
						'instance_id' => $instanceId,
					]
				]
			);
			$statusCode = $response->getStatusCode();
			if ($statusCode === 204) {
				$this->logger->info(sprintf('ExApp container "%s" (instance "%s") successfully stopped.', $exAppName, $instanceId));
				return '';
			}
			if ($statusCode === 200) {
				if ($ignoreIfAlready) {
					$this->logger->info(sprintf('ExApp container "%s" (instance "%s") was already stopped.', $exAppName, $instanceId));
					return '';
				} else {
					$errorMsg = sprintf('ExApp container "%s" (instance "%s") was already stopped.', $exAppName, $instanceId);
					$this->logger->warning($errorMsg);
					return $errorMsg;
				}
			}
			$errorBody = (string)$response->getBody();
			$this->logger->error(sprintf('Failed to stop ExApp container "%s" (instance "%s"). Status: %d, Body: %s', $exAppName, $instanceId, $statusCode, $errorBody));
			return sprintf('Failed to stop ExApp container "%s" (Status: %d). Details: %s', $exAppName, $statusCode, $errorBody);
		} catch (GuzzleException $e) {
			$this->logger->error(sprintf('GuzzleException while trying to stop ExApp container "%s" (instance "%s"): %s', $exAppName, $instanceId, $e->getMessage()), ['exception' => $e]);
			return sprintf('Failed to communicate with HaRP agent to stop ExApp "%s": %s', $exAppName, $e->getMessage());
		} catch (Exception $e) {
			$this->logger->error(sprintf('Unexpected exception while stopping ExApp container "%s" (instance "%s"): %s', $exAppName, $instanceId, $e->getMessage()), ['exception' => $e]);
			return sprintf('An unexpected error occurred while stopping ExApp "%s": %s', $exAppName, $e->getMessage());
		}
	}

	public function waitExAppStart(string $dockerUrl, string $exAppName): bool {
		$instanceId = '';  // $this->config->getSystemValue('instanceid', '');
		try {
			$response = $this->guzzleClient->post(
				sprintf('%s/%s', $dockerUrl, 'docker/exapp/wait_for_start'),
				[
					'json' => [
						'name' => $exAppName,
						'instance_id' => $instanceId,
					],
					'timeout' => 150,
				]
			);

			$statusCode = $response->getStatusCode();
			if ($statusCode === 200) {
				$responseData = json_decode((string)$response->getBody(), true);
				if ($responseData === null) {
					$this->logger->error(sprintf('Invalid JSON response from HaRP /docker/exapp/wait_for_start for ExApp "%s" (instance "%s").', $exAppName, $instanceId));
					return false;
				}
				$started = $responseData['started'] ?? false;
				$status = $responseData['status'] ?? 'unknown';
				$health = $responseData['health'] ?? null;
				$reason = $responseData['reason'] ?? '';

				if ($started === true) {
					$this->logger->info(sprintf('ExApp container "%s" (instance "%s") started successfully. Final state: status=%s, health=%s.', $exAppName, $instanceId, $status, $health ?: 'N/A'));
					return true;
				} else {
					$this->logger->warning(sprintf('ExApp container "%s" (instance "%s") did not start successfully. Final state: status=%s, health=%s, reason="%s".', $exAppName, $instanceId, $status, $health ?: 'N/A', $reason));
					return false;
				}
			} else {
				$errorBody = (string)$response->getBody();
				$this->logger->error(sprintf('Failed to wait for ExApp container "%s" (instance "%s") start. Status: %d, Body: %s', $exAppName, $instanceId, $statusCode, $errorBody));
				return false;
			}
		} catch (GuzzleException $e) {
			$this->logger->error(sprintf('GuzzleException while waiting for ExApp container "%s" (instance "%s") start: %s', $exAppName, $instanceId, $e->getMessage()), ['exception' => $e]);
			return false;
		} catch (Exception $e) {
			$this->logger->error(sprintf('Unexpected exception while waiting for ExApp container "%s" (instance "%s") start: %s', $exAppName, $instanceId, $e->getMessage()), ['exception' => $e]);
			return false;
		}
	}

	public function removeExApp(string $dockerUrl, string $exAppName, bool $removeData = false, bool $ignoreIfNotExists = false): string {
		$instanceId = '';  // $this->config->getSystemValue('instanceid', '');
		try {
			$existsResponse = $this->guzzleClient->post(
				sprintf('%s/%s', $dockerUrl, 'docker/exapp/exists'),
				[
					'json' => [
						'name' => $exAppName,
						'instance_id' => $instanceId,
					]
				]
			);

			$existsStatusCode = $existsResponse->getStatusCode();
			if ($existsStatusCode !== 200) {
				$errorBody = (string)$existsResponse->getBody();
				$this->logger->error(sprintf('Failed to check existence for ExApp "%s" (instance "%s"). Status: %d, Body: %s', $exAppName, $instanceId, $existsStatusCode, $errorBody));
				return sprintf('Failed to check existence for ExApp "%s" (Status: %d). Details: %s', $exAppName, $existsStatusCode, $errorBody);
			}

			$existsData = json_decode((string)$existsResponse->getBody(), true);
			if ($existsData === null) {
				$this->logger->error(sprintf('Invalid JSON response from HaRP /docker/exapp/exists for ExApp "%s" (instance "%s").', $exAppName, $instanceId));
				return sprintf('Invalid JSON response from HaRP /docker/exapp/exists for ExApp "%s".', $exAppName);
			}

			if (isset($existsData['exists']) && $existsData['exists'] === true) {
				$this->logger->info(sprintf('Container for ExApp "%s" (instance "%s") exists. Removing it..', $exAppName, $instanceId));
				$removeResponse = $this->guzzleClient->post(
					sprintf('%s/%s', $dockerUrl, 'docker/exapp/remove'),
					[
						'json' => [
							'name' => $exAppName,
							'instance_id' => $instanceId,
							'remove_data' => $removeData,
						]
					]
				);

				$removeStatusCode = $removeResponse->getStatusCode();
				if ($removeStatusCode === 204) {
					$this->logger->info(sprintf('ExApp container "%s" (instance "%s") successfully removed.', $exAppName, $instanceId));
					return '';
				}
				$errorBody = (string)$removeResponse->getBody();
				$this->logger->error(sprintf('Failed to remove ExApp container "%s" (instance "%s"). Status: %d, Body: %s', $exAppName, $instanceId, $removeStatusCode, $errorBody));
				return sprintf('Failed to remove ExApp container "%s" (Status: %d). Details: %s', $exAppName, $removeStatusCode, $errorBody);
			} elseif (isset($existsData['exists']) && $existsData['exists'] === false) {
				if ($ignoreIfNotExists) {
					$this->logger->info(sprintf('ExApp container "%s" (instance "%s") does not exist. No removal needed.', $exAppName, $instanceId));
					return '';
				} else {
					$errorMsg = sprintf('ExApp container "%s" (instance "%s") does not exist and cannot be removed.', $exAppName, $instanceId);
					$this->logger->warning($errorMsg);
					return $errorMsg;
				}
			} else {
				$errorBody = (string)$existsResponse->getBody();
				$this->logger->error(sprintf('Unexpected "exists" data from /docker/exapp/exists for ExApp "%s" (instance "%s"). Body: %s', $exAppName, $instanceId, $errorBody));
				return sprintf('Unexpected "exists" data from HaRP for ExApp "%s".', $exAppName);
			}

		} catch (GuzzleException $e) {
			$this->logger->error(sprintf('GuzzleException while trying to remove ExApp container "%s" (instance "%s"): %s', $exAppName, $instanceId, $e->getMessage()), ['exception' => $e]);
			return sprintf('Failed to communicate with HaRP agent to remove ExApp "%s": %s', $exAppName, $e->getMessage());
		} catch (Exception $e) {
			$this->logger->error(sprintf('Unexpected exception while removing ExApp container "%s" (instance "%s"): %s', $exAppName, $instanceId, $e->getMessage()), ['exception' => $e]);
			return sprintf('An unexpected error occurred while removing ExApp "%s": %s', $exAppName, $e->getMessage());
		}
	}

	public function buildDeployParams(DaemonConfig $daemonConfig, array $appInfo): array {
		$appId = (string)$appInfo['id'];
		$externalApp = $appInfo['external-app'];
		$deployConfig = $daemonConfig->getDeployConfig();

		$deviceRequests = [];
		$devices = [];
		if (isset($deployConfig['computeDevice'])) {
			if ($deployConfig['computeDevice']['id'] === 'cuda') {
				$deviceRequests = $this->buildDefaultGPUDeviceRequests();
			} elseif ($deployConfig['computeDevice']['id'] === 'rocm') {
				$devices = $this->buildDevicesParams(['/dev/kfd', '/dev/dri']);
			}
		}
		$storage = $this->buildDefaultExAppVolume($appId)[0]['Target'];

		$imageParams = [
			'image_src' => (string)($externalApp['docker-install']['registry'] ?? 'docker.io'),
			'image_name' => (string)($externalApp['docker-install']['image'] ?? $appId),
			'image_tag' => (string)($externalApp['docker-install']['image-tag'] ?? 'latest'),
		];

		$harpEnvVars = [];
		if (isset($deployConfig['harp']) && !HarpService::isHarpDirectConnect($daemonConfig->getDeployConfig())) {
			$harpEnvVars['HP_FRP_ADDRESS'] = explode(':', $deployConfig['harp']['frp_address'])[0];
			$harpEnvVars['HP_FRP_PORT'] = explode(':', $deployConfig['harp']['frp_address'])[1];
			$harpEnvVars['HP_SHARED_KEY'] = $this->crypto->decrypt($deployConfig['haproxy_password']);
		}

		$envs = $this->buildDeployEnvs([
			'appid' => $appId,
			'name' => (string)$appInfo['name'],
			'version' => (string)$appInfo['version'],
			'host' => $this->service->buildExAppHost($deployConfig),
			'port' => $appInfo['port'],
			'storage' => $storage,
			'secret' => $appInfo['secret'],
			'environment_variables' => $appInfo['external-app']['environment-variables'] ?? [],
			'harp_env_vars' => $harpEnvVars,
		], $deployConfig);

		$containerParams = [
			'name' => $appId,
			'hostname' => $appId,
			'port' => $appInfo['port'],
			'net' => $deployConfig['net'] ?? 'host',
			'env' => $envs,
			'computeDevice' => $deployConfig['computeDevice'] ?? null,
			'devices' => $devices,
			'deviceRequests' => $deviceRequests,
			'mounts' => $appInfo['external-app']['mounts'] ?? [],
			'resourceLimits' => $deployConfig['resourceLimits'] ?? []
		];

		return [
			'image_params' => $imageParams,
			'container_params' => $containerParams,
			'deploy_options' => [
				'environment_variables' => $appInfo['external-app']['environment-variables'] ?? [],
				'mounts' => $appInfo['external-app']['mounts'] ?? [],
			]
		];
	}

	public function buildDeployEnvs(array $params, array $deployConfig): array {
		$autoEnvs = [
			sprintf('AA_VERSION=%s', $this->appManager->getAppVersion(Application::APP_ID, false)),
			sprintf('APP_SECRET=%s', $params['secret']),
			sprintf('APP_ID=%s', $params['appid']),
			sprintf('APP_DISPLAY_NAME=%s', $params['name']),
			sprintf('APP_VERSION=%s', $params['version']),
			sprintf('APP_HOST=%s', $params['host']),
			sprintf('APP_PORT=%s', $params['port']),
			sprintf('APP_PERSISTENT_STORAGE=%s', $params['storage']),
			sprintf('NEXTCLOUD_URL=%s', $deployConfig['nextcloud_url'] ?? str_replace('https', 'http', $this->urlGenerator->getAbsoluteURL(''))),
		];

		// Always set COMPUTE_DEVICE=CPU|CUDA|ROCM
		$autoEnvs[] = sprintf('COMPUTE_DEVICE=%s', strtoupper($deployConfig['computeDevice']['id']));
		// Add required GPU runtime envs if daemon configured to use GPU
		if (isset($deployConfig['computeDevice'])) {
			if ($deployConfig['computeDevice']['id'] === 'cuda') {
				$autoEnvs[] = sprintf('NVIDIA_VISIBLE_DEVICES=%s', 'all');
				$autoEnvs[] = sprintf('NVIDIA_DRIVER_CAPABILITIES=%s', 'compute,utility');
			}
		}

		// Appending additional deploy options to container envs
		foreach (array_keys($params['environment_variables']) as $envKey) {
			$autoEnvs[] = sprintf('%s=%s', $envKey, $params['environment_variables'][$envKey]['value'] ?? '');
		}

		// HaRP specific environment variables
		foreach ($params['harp_env_vars'] as $envKey => $envValue) {
			$autoEnvs[] = sprintf('%s=%s', $envKey, $envValue);
		}

		return $autoEnvs;
	}

	public function resolveExAppUrl(
		string $appId, string $protocol, string $host, array $deployConfig, int $port, array &$auth,
	): string {
		if (boolval($deployConfig['harp'] ?? false)) {
			$url = rtrim($deployConfig['nextcloud_url'], '/');
			if (str_ends_with($url, '/index.php')) {
				$url = substr($url, 0, -10);
			}
			return sprintf('%s/exapps/%s', $url, $appId);
		}

		$auth = [];
		if (isset($deployConfig['additional_options']['OVERRIDE_APP_HOST'])
			&& $deployConfig['additional_options']['OVERRIDE_APP_HOST'] !== ''
		) {
			$wideNetworkAddresses = ['0.0.0.0', '127.0.0.1', '::', '::1'];
			if (!in_array($deployConfig['additional_options']['OVERRIDE_APP_HOST'], $wideNetworkAddresses)) {
				return sprintf(
					'%s://%s:%s', $protocol, $deployConfig['additional_options']['OVERRIDE_APP_HOST'], $port
				);
			}
		}
		$host = explode(':', $host)[0];
		if ($protocol == 'https') {
			$exAppHost = $host;
		} elseif (isset($deployConfig['net']) && $deployConfig['net'] === 'host') {
			$exAppHost = 'localhost';
		} else {
			$exAppHost = $appId;
		}
		if ($protocol == 'https' && isset($deployConfig['haproxy_password']) && $deployConfig['haproxy_password'] !== '') {
			// we only set haproxy auth for remote installations, when all requests come through HaProxy.
			$haproxyPass = $this->crypto->decrypt($deployConfig['haproxy_password']);
			$auth = [self::APP_API_HAPROXY_USER, $haproxyPass];
		}
		return sprintf('%s://%s:%s', $protocol, $exAppHost, $port);
	}

	public function waitTillContainerStart(string $containerId, DaemonConfig $daemonConfig): bool {
		$dockerUrl = $this->buildDockerUrl($daemonConfig);
		$attempts = 0;
		$totalAttempts = 90; // ~90 seconds for container to start
		while ($attempts < $totalAttempts) {
			$containerInfo = $this->inspectContainer($dockerUrl, $containerId);
			if ($containerInfo['State']['Status'] === 'running') {
				return true;
			}
			$attempts++;
			sleep(1);
		}
		return false;
	}

	public function healthcheckContainer(string $containerId, DaemonConfig $daemonConfig, bool $waitForSuccess): bool {
		$dockerUrl = $this->buildDockerUrl($daemonConfig);
		$maxTotalAttempts = $waitForSuccess ? 900 : 1;
		while ($maxTotalAttempts > 0) {
			$containerInfo = $this->inspectContainer($dockerUrl, $containerId);
			if (!isset($containerInfo['State']['Health']['Status'])) {
				return true;  // container does not support Healthcheck
			}
			$status = $containerInfo['State']['Health']['Status'];
			if ($status === '') {
				return true;  // we treat empty status as 'success', see https://github.com/nextcloud/app_api/issues/439
			}
			if ($status === 'healthy') {
				return true;
			}
			if ($status === 'unhealthy') {
				return false;
			}
			$maxTotalAttempts--;
			if ($maxTotalAttempts > 0) {
				sleep(1);
			}
		}
		return false;
	}

	public function buildDockerUrl(DaemonConfig $daemonConfig): string {
		// When using local socket, we the curl URL needs to be set to http://localhost
		$url = $this->isLocalSocket($daemonConfig->getHost())
			? 'http://localhost'
			: $daemonConfig->getProtocol() . '://' . $daemonConfig->getHost();
		if (boolval($daemonConfig->getDeployConfig()['harp'] ?? false)) {
			// if there is a trailling slash, remove it
			$url = rtrim($url, '/') . '/exapps/app_api';
		}
		return $url;
	}

	public function initGuzzleClient(DaemonConfig $daemonConfig): void {
		$guzzleParams = [];
		if ($this->isLocalSocket($daemonConfig->getHost())) {
			$guzzleParams = [
				'curl' => [
					CURLOPT_UNIX_SOCKET_PATH => $daemonConfig->getHost(),
				],
			];
			$this->useSocket = true;
			$this->socketAddress = $daemonConfig->getHost();
		} elseif ($daemonConfig->getProtocol() === 'https') {
			$guzzleParams = $this->setupCerts($guzzleParams);
		}
		if (isset($daemonConfig->getDeployConfig()['haproxy_password']) && $daemonConfig->getDeployConfig()['haproxy_password'] !== '') {
			$haproxyPass = $this->crypto->decrypt($daemonConfig->getDeployConfig()['haproxy_password']);
			$guzzleParams['auth'] = [self::APP_API_HAPROXY_USER, $haproxyPass];
		}
		if (boolval($daemonConfig->getDeployConfig()['harp'] ?? false)) {
			$guzzleParams['headers'] = [
				'harp-shared-key' => $guzzleParams['auth'][1],
				'docker-engine-port' => $daemonConfig->getDeployConfig()['harp']['docker_socket_port'],
			];
		}
		$this->guzzleClient = new Client($guzzleParams);
	}

	private function setupCerts(array $guzzleParams): array {
		if (!$this->config->getSystemValueBool('installed', false)) {
			$certs = \OC::$SERVERROOT . '/resources/config/ca-bundle.crt';
		} else {
			$certs = $this->certificateManager->getAbsoluteBundlePath();
		}

		$guzzleParams['verify'] = $certs;
		return $guzzleParams;
	}

	private function buildDevicesParams(array $devices): array {
		return array_map(function (string $device) {
			return ['PathOnHost' => $device, 'PathInContainer' => $device, 'CgroupPermissions' => 'rwm'];
		}, $devices);
	}

	/**
	 * Build default volume for ExApp.
	 * For now only one volume created per ExApp.
	 */
	private function buildDefaultExAppVolume(string $appId): array {
		return [
			[
				'Type' => 'volume',
				'Source' => $this->buildExAppVolumeName($appId),
				'Target' => '/' . $this->buildExAppVolumeName($appId),
				'ReadOnly' => false
			],
		];
	}

	public function buildExAppContainerName(string $appId): string {
		return self::EX_APP_CONTAINER_PREFIX . $appId;
	}

	public function buildExAppVolumeName(string $appId): string {
		return self::EX_APP_CONTAINER_PREFIX . $appId . '_data';
	}

	/**
	 * Return default GPU device requests for container.
	 */
	private function buildDefaultGPUDeviceRequests(): array {
		return [
			[
				'Driver' => 'nvidia', // Currently only NVIDIA GPU vendor
				'Count' => -1, // All available GPUs
				'Capabilities' => [['compute', 'utility']], // Compute and utility capabilities
			],
		];
	}

	private function isLocalSocket(string $host): bool {
		$isLocalPath = strpos($host, '/') === 0;
		if ($isLocalPath) {
			if (!file_exists($host)) {
				$this->logger->error('Local docker socket path {path} does not exist', ['path' => $host]);
			} elseif (!is_writable($host)) {
				$this->logger->error('Local docker socket path {path} is not writable', ['path' => $host]);
			}
		}
		return $isLocalPath;
	}

	private function buildCompatUrlRaw(string $dockerUrl, string $route): string {
		return rtrim($dockerUrl, '/') . '/' . ltrim($route, '/');
	}

	private function isPodman(string $dockerUrl): bool {
		try {
			// _ping is unversioned and returns headers we can key on
			$resp = $this->guzzleClient->head($this->buildCompatUrlRaw($dockerUrl, '_ping'));
			if ($resp->hasHeader('Libpod-Api-Version')) {
				return true;
			}
			$server = $resp->getHeaderLine('Server');
			if ($server && stripos($server, 'Libpod') !== false) {
				return true;
			}
			// fallback: /version body
			$resp = $this->guzzleClient->get($this->buildCompatUrlRaw($dockerUrl, 'version'));
			$body = json_decode((string)$resp->getBody(), true);
			$platformName = $body['Platform']['Name'] ?? '';
			return stripos($platformName, 'podman') !== false;
		} catch (\Throwable $e) {
			return false;
		}
	}
}
