<?php

namespace classes;

use customers\economicCustomers;
use RuntimeException;
use Throwable;

require_once __DIR__ . '/cors_policy.php';

class release_manager
{
    private const APPS = ['frontend', 'api'];
    private const DEFAULT_BRANCH = 'master';
    private const RELEASE_ROUTE_SLUGS = [
        'stable' => 'master',
        'master' => 'master',
        'beta' => 'beta',
        'canary' => 'canary',
        'internal' => 'internal',
    ];
    private const RELEASE_ROUTE_CHANNELS = [
        'master' => 'stable',
        'beta' => 'beta',
        'canary' => 'canary',
        'internal' => 'internal',
    ];
    private const SERVICE_SET_MODES = ['attach_existing', 'clone_existing', 'fresh_empty', 'isolated_stack'];
    private const STACK_DATA_KINDS = ['database', 'redis', 'minio'];
    private const PRODUCTION_DATA_POLICY = 'production_shared';
    private const BETA_PRODUCTION_DATA_SOURCE_CHANNELS = ['stable', 'master', 'production', 'prod'];
    private const PRODUCTION_SERVICE_POLICY = 'production_shared';
    private const PRODUCTION_SERVICE_CHANNELS = ['beta'];
    private const RELEASE_STATUS_SERVICES = ['frontend', 'api', 'database', 'redis', 'minio'];
    private const DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS = ['stable', 'production', 'prod'];
    private const DEFAULT_COOLIFY_APPLICATION_PORT = '80';
    private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api';
    private const CRON_WORKER_APP = 'cron';
    private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker';
    private const CRON_WORKER_DESIRED_COUNT = 1;
    private const CRON_WORKER_HEARTBEAT_GRACE_SECONDS = 180;
    private const RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES = ['truckwash.io'];
    private const RELEASE_GATE_MAX_PATHS = 10;
    private const RELEASE_GATE_MAX_ASSETS = 50;
    private const RELEASE_API_RUNTIME_ENV_KEYS = [
        'USE_ENV',
        'DEBUG',
        'ENCRYPTION_KEY',
        'CORS',
        'CONFIG_TIMEZONE',
        'CONFIG_DB_TARGET',
        'CONFIG_DB_HOST',
        'CONFIG_DB_USER',
        'CONFIG_DB_PASSWORD',
        'CONFIG_DB_DATABASE',
        'CONFIG_DB_PORT',
        'CONFIG_DB_SSL_MODE',
        'CONFIG_DB_DEBUG_HOST',
        'CONFIG_DB_DEBUG_USER',
        'CONFIG_DB_DEBUG_PASSWORD',
        'CONFIG_DB_DEBUG_DATABASE',
        'CONFIG_DB_DEBUG_PORT',
        'CONFIG_DB_DEBUG_SSL_MODE',
        'REDIS_CONFIG_HOST',
        'REDIS_CONFIG_USER',
        'REDIS_CONFIG_PASSWORD',
        'REDIS_CONFIG_DATABASE',
        'REDIS_CONFIG_PORT',
        'REDIS_CONFIG_DEBUG_HOST',
        'REDIS_CONFIG_DEBUG_USER',
        'REDIS_CONFIG_DEBUG_PASSWORD',
        'REDIS_CONFIG_DEBUG_DATABASE',
        'REDIS_CONFIG_DEBUG_PORT',
        'ECONOMIC_API_APP_ACCESS_GRANT',
        'ECONOMIC_API_APP_ACCESS_GRANT2',
        'ECONOMIC_API_APP_SECRET_TOKEN',
        'WORDPRESS_STATIC_TOKEN',
        'EMAIL_WASH_CERTIFICATE_TOKEN',
        'WORDPRESS_API_URL',
        'MINIO_ENDPOINT',
        'MINIO_ACCESS_KEY',
        'MINIO_SECRET_KEY',
        'SLACK_DEFAULT_WEBHOOK',
        'API_COMMIT_SHA',
        'COMMIT_SHA',
        'GITHUB_SHA',
        'RELEASE_COMMIT_SHA',
    ];
    private const RELEASE_API_RUNTIME_ENV_PREFIXES = [
        'EDGE_',
        'RELEASE_MANAGER_',
        'COOLIFY_',
        'HETZNER_',
        'OPENAI_',
        'STRIPE_',
        'FXRATES_',
        'WEATHER_',
        'MOTOR_',
        'BIRD_',
        'OCR_',
        'LICENSE_',
        'VIRK_',
        'LIMBLE_',
        'ENTRA_',
        'REQUEST_QUEUE_',
        'WORKFEED_',
    ];
    private const SUBJECT_TYPES = ['user', 'subuser', 'customer'];
    private const CAPTURE_LEVELS = ['metadata', 'full_redacted', 'full'];
    private const MODULE_KEYS = [
        'economic',
        'reCAPTCHA',
        'email',
        'backups',
        'motorapi',
        'stripe',
        'fxratesapi',
        'weatherapi',
        'workfeed',
        'gatewayapi',
        'xlvask',
        'entra',
        'limble',
        'ocrspace',
        'openai',
        'licenseplaterecognizer',
        'virkdata',
        'shelly',
        'coolify',
        'failover',
        'edgegateway',
        'selfserve',
        'bird',
        'auth',
        'worker',
        'requestqueue',
        'moduleactionlogs',
        'releasemanager',
    ];

    private bool $schemaEnsured = false;
    private array $inProcessPassedReleaseGates = [];
    /** @var callable|null */
    private $coolifyClientFactory;

    public function __construct(?callable $coolifyClientFactory = null)
    {
        $this->coolifyClientFactory = $coolifyClientFactory;
    }

    public static function initializeRequestContext(): array
    {
        $traceId = self::safeIdentifier(
            self::requestHeaderValue('X-Release-Trace') ?: (string)($_GET['release_trace'] ?? ''),
            64
        );
        if ($traceId === '') {
            $traceId = bin2hex(random_bytes(16));
        }

        $requestedChannel = self::safeSlug((string)(
            self::requestHeaderValue('X-Release-Channel')
            ?: ($_GET['release_channel'] ?? $_GET['channel_slug'] ?? '')
        ));
        $frontendVersion = self::safeIdentifier(
            (string)(self::requestHeaderValue('X-Frontend-Version') ?: ($_GET['frontend_version'] ?? '')),
            128
        );

        $context = [
            'trace_id' => $traceId,
            'requested_channel' => $requestedChannel,
            'frontend_version' => $frontendVersion,
            'backend_version' => self::backendVersion(),
            'request_started_at' => date('c'),
            'original_request_uri' => (string)($_SERVER['REQUEST_URI'] ?? ''),
            'normalized_request_uri' => '',
            'ingress_prefix_stripped' => false,
        ];

        $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context;
        return $context;
    }

    public static function normalizeReleaseApiIngressPath(array $enabledChannelSlugs): array
    {
        $requestUri = (string)($_SERVER['REQUEST_URI'] ?? '');
        $parts = parse_url($requestUri);
        $path = is_array($parts) ? (string)($parts['path'] ?? '') : '';
        if ($path === '') {
            return [];
        }

        if (preg_match('#^/([A-Za-z0-9_-]{1,64})/api(?:/|$)(.*)$#', $path, $matches) !== 1) {
            return [];
        }

        $routeSlug = self::safeSlug((string)$matches[1]);
        $channelSlug = self::channelSlugForRoute($routeSlug);
        $enabled = array_flip(array_values(array_filter(array_map(
            static fn(mixed $value): string => self::safeSlug((string)$value),
            $enabledChannelSlugs
        ))));
        if ($routeSlug === '' || $channelSlug === '' || !isset($enabled[$channelSlug])) {
            return [];
        }

        $suffix = (string)($matches[2] ?? '');
        $normalizedPath = '/' . ltrim($suffix, '/');
        if ($normalizedPath === '/') {
            $normalizedPath = '/';
        }

        $query = is_array($parts) && isset($parts['query']) && $parts['query'] !== ''
            ? '?' . (string)$parts['query']
            : '';
        $normalizedUri = $normalizedPath . $query;
        $_SERVER['REQUEST_URI'] = $normalizedUri;
        $_SERVER['PATH_INFO'] = $normalizedPath;
        $_GET['release_channel'] = $channelSlug;

        $context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null)
            ? $GLOBALS['RELEASE_REQUEST_CONTEXT']
            : self::initializeRequestContext();
        $context['original_request_uri'] = $context['original_request_uri'] ?: $requestUri;
        $context['normalized_request_uri'] = $normalizedUri;
        $context['requested_channel'] = $channelSlug;
        $context['release_route_slug'] = $routeSlug;
        $context['ingress_prefix_stripped'] = true;
        $GLOBALS['RELEASE_REQUEST_CONTEXT'] = $context;

        return [
            'channel_slug' => $channelSlug,
            'route_slug' => $routeSlug,
            'original_request_uri' => $requestUri,
            'normalized_request_uri' => $normalizedUri,
            'normalized_path' => $normalizedPath,
        ];
    }

    public static function routeSlugForChannel(string $channelSlug): string
    {
        $slug = self::safeSlug($channelSlug);
        if ($slug === '') {
            return '';
        }
        return self::RELEASE_ROUTE_SLUGS[$slug] ?? $slug;
    }

    public static function channelSlugForRoute(string $routeSlug): string
    {
        $slug = self::safeSlug($routeSlug);
        if ($slug === '') {
            return '';
        }
        return self::RELEASE_ROUTE_CHANNELS[$slug] ?? $slug;
    }

    public static function moduleKeys(): array
    {
        return self::MODULE_KEYS;
    }

    public static function backendVersion(): string
    {
        foreach (['RELEASE_VERSION', 'GITHUB_SHA', 'COMMIT_SHA', 'VITE_COMMIT_HASH'] as $key) {
            $value = self::runtimeEnvValue($key);
            if ($value !== '') {
                return self::safeIdentifier($value, 128);
            }
        }
        return 'unknown';
    }

    public static function backendCommitSha(): string
    {
        foreach (['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'] as $key) {
            $sha = self::normalizeCommitSha(self::runtimeEnvValue($key));
            if ($sha !== '') {
                return $sha;
            }
        }

        $sha = self::localGitCommitSha();
        return $sha !== '' ? $sha : 'unknown';
    }

    private static function runtimeEnvValue(string $key): string
    {
        $value = getenv($key);
        if (($value === false || trim((string)$value) === '') && array_key_exists($key, $_ENV ?? [])) {
            $value = $_ENV[$key];
        }
        if (($value === false || trim((string)$value) === '') && array_key_exists($key, $_SERVER ?? [])) {
            $value = $_SERVER[$key];
        }

        return is_scalar($value) ? trim((string)$value) : '';
    }

    private static function normalizeCommitSha(string $value): string
    {
        $value = strtolower(trim($value));
        return preg_match('/^[a-f0-9]{7,40}$/', $value) === 1 ? $value : '';
    }

    private static function localGitCommitSha(): string
    {
        $base = defined('WD') ? (string)WD : dirname(__DIR__);
        $candidates = [];
        $current = $base;

        for ($i = 0; $i < 6; $i++) {
            if ($current === '' || isset($candidates[$current])) {
                break;
            }
            $candidates[$current] = true;
            $parent = dirname($current);
            if ($parent === $current) {
                break;
            }
            $current = $parent;
        }

        foreach (array_keys($candidates) as $directory) {
            $gitPath = $directory . DIRECTORY_SEPARATOR . '.git';
            if (!is_dir($directory) || (!is_dir($gitPath) && !is_file($gitPath))) {
                continue;
            }

            $output = [];
            $exitCode = 1;
            @exec('git -C ' . escapeshellarg($directory) . ' rev-parse HEAD 2>&1', $output, $exitCode);
            if ($exitCode !== 0 || !isset($output[0])) {
                continue;
            }

            $sha = self::normalizeCommitSha((string)$output[0]);
            if ($sha !== '') {
                return $sha;
            }
        }

        return '';
    }

    public static function verifyGithubSignature(string $secret, string $payload, string $signatureHeader): bool
    {
        $secret = trim($secret);
        $signatureHeader = trim($signatureHeader);
        if ($secret === '' || $signatureHeader === '' || !str_starts_with($signatureHeader, 'sha256=')) {
            return false;
        }

        $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
        return hash_equals($expected, $signatureHeader);
    }

    public function verifyReleaseGateToken(string $token): bool
    {
        $token = trim($token);
        if ($token === '') {
            return false;
        }

        $expected = trim((string)(getenv('RELEASE_MANAGER_GATE_TOKEN') ?: ($_SERVER['RELEASE_MANAGER_GATE_TOKEN'] ?? '')));
        if ($expected === '') {
            $expected = trim((string)$this->moduleConfigValue('ReleaseManager', 'release_gate_token', ''));
        }
        if ($expected !== '' && str_starts_with($expected, 'twsec:v1:') && class_exists(replication_secret_box::class)) {
            try {
                $expected = replication_secret_box::decrypt($expected);
            } catch (Throwable) {
                $expected = '';
            }
        }

        return $expected !== '' && hash_equals($expected, $token);
    }

    public static function normalizeGithubRepositoryName(string $value): string
    {
        $repository = trim($value);
        if ($repository === '') {
            return '';
        }

        if (preg_match('#^git@github\.com:(.+)$#i', $repository, $matches) === 1) {
            $repository = $matches[1];
        } elseif (preg_match('#^https?://#i', $repository) === 1) {
            $path = parse_url($repository, PHP_URL_PATH);
            $repository = is_string($path) ? ltrim($path, '/') : $repository;
        } else {
            $repository = preg_replace('#^github\.com/#i', '', $repository) ?? $repository;
        }

        $repository = preg_replace('#\.git$#i', '', $repository) ?? $repository;
        $repository = trim($repository, "/ \t\n\r\0\x0B");
        return preg_match('/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/', $repository) === 1 ? $repository : '';
    }

    public static function redactPayload(mixed $value, int $depth = 0): mixed
    {
        if ($depth > 8) {
            return '[depth-limit]';
        }

        if (is_array($value)) {
            $redacted = [];
            $index = 0;
            foreach ($value as $key => $item) {
                $index++;
                if ($index > 80) {
                    $redacted['[truncated]'] = 'More than 80 keys omitted.';
                    break;
                }

                $keyString = (string)$key;
                if (self::isSensitiveKey($keyString)) {
                    $redacted[$key] = '[redacted]';
                    continue;
                }
                $redacted[$key] = self::redactPayload($item, $depth + 1);
            }
            return $redacted;
        }

        if (is_object($value)) {
            return self::redactPayload((array)$value, $depth + 1);
        }

        if (is_string($value)) {
            if (strlen($value) > 4000) {
                return substr($value, 0, 4000) . "\n... [truncated]";
            }
            return $value;
        }

        return $value;
    }

    public static function deploymentCanBePromoted(string $status): bool
    {
        return in_array(strtolower(trim($status)), ['deployed'], true);
    }

    public static function deploymentPromotionBlockedReason(array $deployment): string
    {
        $status = strtolower(trim((string)($deployment['status'] ?? 'unknown'))) ?: 'unknown';
        $result = self::jsonDecode($deployment['result_json'] ?? null);
        if ($result === [] && is_array($deployment['result'] ?? null)) {
            $result = $deployment['result'];
        }
        $failure = is_array($result['failure_summary'] ?? null) ? $result['failure_summary'] : [];
        $rootCause = trim((string)($failure['root_cause'] ?? $deployment['error_message'] ?? ''));

        if ($status === 'active') {
            return 'Deployment is already active.';
        }

        if ($status === 'failed') {
            return 'Deployment failed and cannot be promoted.' . ($rootCause !== '' ? ' Cause: ' . $rootCause : '');
        }

        return 'Only successfully deployed release deployments can be promoted. Current status: ' . $status . '.';
    }

    public static function deploymentFailureSummary(Throwable $throwable, array $context = []): array
    {
        $message = trim($throwable->getMessage()) ?: 'Deployment failed without an error message.';
        $normalized = strtolower($message);
        $category = 'unknown';
        $stage = trim((string)($context['stage'] ?? 'deployment')) ?: 'deployment';
        $nextAction = 'Open the Coolify deployment logs for the service and compare the failing commit with the last successful deployment.';

        if (str_contains($normalized, 'github repository access') || str_contains($normalized, 'github api')) {
            $category = 'github_access';
            $stage = 'source_access';
            $nextAction = 'Verify the Release Manager GitHub token, repository, branch, and selected commit before deploying again.';
        } elseif (
            str_contains($normalized, 'coolify instance')
            || str_contains($normalized, 'base url')
            || str_contains($normalized, 'api token')
        ) {
            $category = 'coolify_connection';
            $stage = 'provider_connection';
            $nextAction = 'Test the configured Coolify instance and API token from Release Manager settings.';
        } elseif (
            str_contains($normalized, 'service uuid')
            || str_contains($normalized, 'select an existing coolify service')
            || str_contains($normalized, 'http 404')
        ) {
            $category = 'coolify_target';
            $stage = 'provider_target';
            $nextAction = 'Check that the saved deployment target points at the correct Coolify service UUID and instance.';
        } elseif (
            str_contains($normalized, 'docker_compose')
            || str_contains($normalized, 'explicit image')
            || str_contains($normalized, 'image/repository')
            || str_contains($normalized, 'pull access denied')
            || str_contains($normalized, 'manifest')
            || str_contains($normalized, 'denied')
            || str_contains($normalized, 'validation')
        ) {
            $category = 'configuration';
            $stage = 'provider_configuration';
            $nextAction = 'Review the deployment target image, registry access, compose payload, and required environment variables.';
        } elseif (str_contains($normalized, 'health') || str_contains($normalized, 'smoke')) {
            $category = 'smoke_test';
            $stage = 'post_deploy_smoke_test';
            $nextAction = 'Check container startup logs and the configured health URL before promoting the deployment.';
        } elseif (
            str_contains($normalized, 'timeout')
            || str_contains($normalized, 'timed out')
            || str_contains($normalized, 'could not connect')
            || str_contains($normalized, 'network')
        ) {
            $category = 'network';
            $stage = 'provider_connection';
            $nextAction = 'Check network access from the API container to GitHub and Coolify, then retry the deployment.';
        }

        $evidence = array_filter([
            'message' => $message,
            'app' => $context['app'] ?? null,
            'repository' => $context['repository'] ?? null,
            'branch' => $context['branch'] ?? null,
            'commit_sha' => $context['commit_sha'] ?? null,
            'target_id' => $context['target_id'] ?? null,
            'coolify_instance_id' => $context['coolify_instance_id'] ?? null,
            'coolify_service_uuid' => $context['coolify_service_uuid'] ?? null,
        ], static fn(mixed $value): bool => $value !== null && $value !== '');

        return [
            'category' => $category,
            'stage' => $stage,
            'root_cause' => $message,
            'next_action' => $nextAction,
            'promotion_blocked' => true,
            'captured_at' => date('c'),
            'evidence' => self::redactPayload($evidence),
        ];
    }

    public static function recordBackendFailure(bool $success, mixed $data, ?int $status): void
    {
        if ($success || ($status !== null && $status < 400)) {
            return;
        }

        $uri = (string)($_SERVER['REQUEST_URI'] ?? '');
        if (str_starts_with($uri, '/release/timeline/events')) {
            return;
        }

        try {
            if (!isset($GLOBALS['db']) || !release_manager_schema_bootstrap::tablesExist()) {
                return;
            }

            $context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null)
                ? $GLOBALS['RELEASE_REQUEST_CONTEXT']
                : self::initializeRequestContext();

            $manager = new self();
            $principalContext = $manager->currentPrincipalContext();
            $channel = $manager->resolveChannel($principalContext);
            $manager->ingestTimelineEvents([
                [
                    'type' => 'backend_response_failed',
                    'severity' => ($status ?? 500) >= 500 ? 'error' : 'warning',
                    'module_key' => $manager->inferModuleKeyFromUri($uri),
                    'route' => explode('?', $uri)[0] ?: '/',
                    'occurred_at' => date('c'),
                    'payload' => [
                        'status' => $status,
                        'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET',
                        'response' => $data,
                    ],
                ],
            ], [
                'trace_id' => $context['trace_id'] ?? '',
                'channel_slug' => $channel['slug'] ?? '',
                'principal_type' => $principalContext['principal_type'] ?? null,
                'principal_id' => $principalContext['principal_id'] ?? null,
                'customer_number' => $principalContext['customer_number'] ?? null,
            ], false);
        } catch (Throwable) {
            // Release telemetry must never block API responses.
        }
    }

    public function bootstrap(): array
    {
        $this->ensureSchema();
        $channel = $this->defaultChannel();
        $versions = $this->currentVersionsForChannel((int)$channel['id']);
        $urls = $this->releaseRuntimeUrls($channel, $versions);

        return [
            'source' => 'deployment',
            'generated_at' => date('c'),
            'trace_id' => $this->requestTraceId(),
            'channel' => $this->publicRuntimeChannel($channel),
            'versions' => $versions,
            'frontend_base_url' => $urls['frontend_base_url'],
            'api_base_url' => $urls['api_base_url'],
            'urls' => $urls,
            'availability' => $this->channelAvailability($channel),
            'capture_policy' => [
                'enabled' => false,
                'capture_level' => 'metadata',
                'all_failure_metadata' => true,
                'retention_days' => (int)($channel['retention_days'] ?? 14),
            ],
            'available_channels' => $this->publicRuntimeChannelOptions([$channel]),
            'selected_channel_slug' => (string)($channel['slug'] ?? ''),
        ];
    }

    public function runtimeForPayload(array $payload, array $input = []): array
    {
        $this->ensureSchema();

        $context = [
            'principal_type' => 'user',
            'principal_id' => isset($payload['id']) ? (string)$payload['id'] : null,
            'customer_number' => isset($payload['customer_number']) ? (int)$payload['customer_number'] : null,
        ];

        $resolvedChannel = $this->resolveChannel($context);
        $availableChannels = $this->runtimeChannelsForContext($context, $resolvedChannel);
        $channel = $this->chooseRuntimeChannel(
            $resolvedChannel,
            $availableChannels,
            $this->requestedRuntimeChannelSlug($input)
        );
        $serviceChannel = $this->runtimeServiceChannelFor($channel);
        $versions = $this->currentVersionsForChannel((int)$serviceChannel['id']);
        $capturePolicy = $this->capturePolicyFor($context, $channel);
        $urls = $this->releaseRuntimeUrls($serviceChannel, $versions);

        return [
            'source' => 'deployment',
            'generated_at' => date('c'),
            'trace_id' => $this->requestTraceId(),
            'channel' => $this->publicRuntimeChannel($channel),
            'service_channel' => $this->publicRuntimeChannel($serviceChannel),
            'versions' => $versions,
            'frontend_base_url' => $urls['frontend_base_url'],
            'api_base_url' => $urls['api_base_url'],
            'urls' => $urls,
            'availability' => $this->channelAvailability($channel),
            'capture_policy' => $capturePolicy,
            'available_channels' => $this->publicRuntimeChannelOptions($availableChannels),
            'selected_channel_slug' => (string)($channel['slug'] ?? ''),
            'selected_service_channel_slug' => (string)($serviceChannel['slug'] ?? ''),
            'module_keys' => self::MODULE_KEYS,
        ];
    }

    public function runtimeForCurrentPrincipal(array $input = []): array
    {
        $this->ensureSchema();
        $context = $this->currentPrincipalContext();
        $resolvedChannel = $this->resolveChannel($context);
        $availableChannels = $this->runtimeChannelsForContext($context, $resolvedChannel);
        $channel = $this->chooseRuntimeChannel(
            $resolvedChannel,
            $availableChannels,
            $this->requestedRuntimeChannelSlug($input)
        );

        $serviceChannel = $this->runtimeServiceChannelFor($channel);
        $versions = $this->currentVersionsForChannel((int)$serviceChannel['id']);
        $urls = $this->releaseRuntimeUrls($serviceChannel, $versions);

        return [
            'source' => 'deployment',
            'generated_at' => date('c'),
            'trace_id' => $this->requestTraceId(),
            'channel' => $this->publicRuntimeChannel($channel),
            'service_channel' => $this->publicRuntimeChannel($serviceChannel),
            'versions' => $versions,
            'frontend_base_url' => $urls['frontend_base_url'],
            'api_base_url' => $urls['api_base_url'],
            'urls' => $urls,
            'availability' => $this->channelAvailability($channel),
            'capture_policy' => $this->capturePolicyFor($context, $channel),
            'available_channels' => $this->publicRuntimeChannelOptions($availableChannels),
            'selected_channel_slug' => (string)($channel['slug'] ?? ''),
            'selected_service_channel_slug' => (string)($serviceChannel['slug'] ?? ''),
            'module_keys' => self::MODULE_KEYS,
        ];
    }

    public function enabledReleaseChannelSlugs(): array
    {
        $this->ensureSchema();
        return array_values(array_filter(array_map(
            static fn(array $channel): string => self::safeSlug((string)($channel['slug'] ?? '')),
            $this->selectRows("SELECT slug FROM release_channels WHERE deleted_at IS NULL AND enabled = 1")
        )));
    }

    public function summary(): array
    {
        $this->ensureSchema();
        $this->cleanupExpiredReplayData();

        $summary = [
            'generated_at' => date('c'),
            'channels' => $this->listChannels(),
            'assignments' => $this->listAssignments(),
            'deployment_targets' => $this->listDeploymentTargets(),
            'service_sets' => $this->listServiceSets(),
            'bundles' => $this->listBundles(25),
            'deployments' => $this->listDeployments(25),
            'operations' => $this->listOperations(['limit' => 20]),
            'data_services' => $this->releaseDataServicesSummary(),
            'replication_policy' => $this->releaseReplicationPolicySummary(),
            'coolify' => $this->releaseCoolifySummary(),
            'failover' => $this->releaseFailoverSummary(),
            'timeline' => $this->timelineSummary(),
            'module_health' => $this->latestModuleHealth(),
            'module_keys' => self::MODULE_KEYS,
            'suggestions' => $this->releaseSuggestions(),
        ];
        $summary['status_overview'] = $this->releaseStatusOverview($summary);

        return $summary;
    }

    public function suggestions(): array
    {
        $this->ensureSchema();
        return $this->releaseSuggestions();
    }

    public function listOperations(array $filters = []): array
    {
        if (!release_manager_schema_bootstrap::tablesExist()) {
            return [];
        }

        $limit = max(1, min(100, (int)($filters['limit'] ?? 50)));
        $where = [];
        $types = '';
        $params = [];

        $channelId = $this->nullablePositiveInt($filters['channel_id'] ?? null);
        if ($channelId !== null) {
            $where[] = 'r.channel_id = ?';
            $types .= 'i';
            $params[] = $channelId;
        }

        $operationType = self::safeIdentifier((string)($filters['operation_type'] ?? $filters['type'] ?? ''), 64);
        if ($operationType !== '') {
            $where[] = 'r.operation_type = ?';
            $types .= 's';
            $params[] = $operationType;
        }

        $status = self::safeIdentifier((string)($filters['status'] ?? ''), 32);
        if ($status !== '') {
            $where[] = 'r.status = ?';
            $types .= 's';
            $params[] = $status;
        }

        $whereSql = $where !== [] ? 'WHERE ' . implode(' AND ', $where) : '';
        $rows = $this->selectRows(
            "SELECT r.*, c.slug AS channel_slug, c.name AS channel_name,
                    (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id) AS step_count,
                    (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('passed', 'deployed')) AS passed_step_count,
                    (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status = 'failed') AS failed_step_count,
                    (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('warning', 'skipped')) AS warning_step_count
             FROM release_operation_runs r
             LEFT JOIN release_channels c ON c.id = r.channel_id
             $whereSql
             ORDER BY r.created_at DESC, r.id DESC
             LIMIT $limit",
            $types,
            $params
        );

        return array_map(fn(array $row): array => $this->publicOperationRun($row, false), $rows);
    }

    public function operationDetail(int $id): array
    {
        $this->ensureSchema();
        $operation = $this->getOperationRun($id);
        return $this->publicOperationRun($operation, true);
    }

    public function runReleaseTest(array $input, ?int $actorUserId = null): array
    {
        $this->ensureSchema();

        $channel = null;
        try {
            if ($this->nullablePositiveInt($input['channel_id'] ?? null) !== null || trim((string)($input['channel_slug'] ?? $input['channel'] ?? '')) !== '') {
                $channel = $this->channelFromInput($input);
            }
        } catch (Throwable $throwable) {
            $channel = null;
        }
        $gateInput = $this->normalizeReleaseGateInput($input, $channel);

        $operationId = $this->createOperationRun('release_test', [
            'subject_type' => $channel !== null ? 'channel' : 'release_manager',
            'subject_id' => $channel !== null ? (string)$channel['id'] : null,
            'channel_id' => $channel !== null ? (int)$channel['id'] : null,
            'title' => $channel !== null
                ? sprintf('Release checks for %s', (string)($channel['name'] ?? $channel['slug'] ?? 'channel'))
                : 'Release Manager checks',
            'actor_user_id' => $actorUserId,
            'context' => array_replace(self::redactPayload($input), [
                'release_gate' => self::redactPayload($gateInput),
            ]),
        ]);

        $statuses = [];
        $this->recordOperationStep($operationId, 'dashboard_contract', 'Dashboard data contract', 'passed', 'Release Manager exposes channels, operations, Coolify, failover, and data-service state.', null, null, [
            'summary_keys' => ['channels', 'operations', 'coolify', 'failover', 'data_services'],
        ]);
        $statuses[] = 'passed';

        if ($this->releaseGateAutoSyncRequested($gateInput)) {
            foreach ($this->releaseGateAutoSyncValidationSteps($gateInput, $channel) as $autoSyncStep) {
                $this->recordOperationStep(
                    $operationId,
                    (string)$autoSyncStep['step_key'],
                    (string)$autoSyncStep['label'],
                    (string)$autoSyncStep['status'],
                    $autoSyncStep['message'] ?? null,
                    $autoSyncStep['diagnostic'] ?? null,
                    $autoSyncStep['solution_hint'] ?? null,
                    is_array($autoSyncStep['context'] ?? null) ? $autoSyncStep['context'] : []
                );
                $statuses[] = (string)$autoSyncStep['status'];
            }
        }

        if (($gateInput['required_checks'] ?? []) !== []) {
            $this->recordOperationStep(
                $operationId,
                'release_gate_inputs',
                'Release gate payload',
                'passed',
                'Release gate payload includes CI deploy metadata and required checks.',
                null,
                null,
                $gateInput
            );
            $statuses[] = 'passed';

            foreach ($this->runReleaseGateChecks($gateInput) as $gateStep) {
                $this->recordOperationStep(
                    $operationId,
                    (string)$gateStep['step_key'],
                    (string)$gateStep['label'],
                    (string)$gateStep['status'],
                    $gateStep['message'] ?? null,
                    $gateStep['diagnostic'] ?? null,
                    $gateStep['solution_hint'] ?? null,
                    is_array($gateStep['context'] ?? null) ? $gateStep['context'] : []
                );
                $statuses[] = (string)$gateStep['status'];
            }
        }

        $appsToCheck = $this->releaseTestAppsFromInput($input);
        $channels = $channel !== null ? [$channel] : $this->selectRows("SELECT * FROM release_channels WHERE deleted_at IS NULL AND enabled = 1 ORDER BY default_channel DESC, slug");
        foreach ($channels as $testChannel) {
            foreach ($appsToCheck as $app) {
                $target = $this->deploymentTargetForChannelApp((int)$testChannel['id'], $app);
                $repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app)));
                $branch = self::releaseBranchForChannel($testChannel);
                if ($target === null) {
                    $this->recordOperationStep(
                        $operationId,
                        sprintf('%s_%s_target', (string)$testChannel['slug'], $app),
                        sprintf('%s %s target', (string)$testChannel['name'], strtoupper($app)),
                        'warning',
                        'No Coolify deployment target is configured for this app/channel pair.',
                        'Release Manager cannot deploy this app until a target exists.',
                        'Create or repair the channel deployment target, then run the test again.',
                        ['channel_slug' => $testChannel['slug'], 'app' => $app, 'retry_action' => 'configure_target']
                    );
                    $statuses[] = 'warning';
                    continue;
                }

                $access = $this->githubRepositoryAccess([
                    'repository' => $repository,
                    'branch' => $branch,
                    'commit_mode' => 'latest',
                ]);
                $ok = (bool)($access['ok'] ?? false);
                $status = $ok ? 'passed' : 'warning';
                $statuses[] = $status;
                $this->recordOperationStep(
                    $operationId,
                    sprintf('%s_%s_branch', (string)$testChannel['slug'], $app),
                    sprintf('%s %s branch', (string)$testChannel['name'], strtoupper($app)),
                    $status,
                    $ok
                        ? sprintf('Branch %s is reachable and resolves to the latest commit.', $branch)
                        : sprintf('Branch %s could not be verified and will be skipped by sync.', $branch),
                    $ok ? null : (string)($access['message'] ?? 'GitHub branch access failed.'),
                    $ok ? null : 'Create the missing branch or repair the Release Manager GitHub token, then use Retry.',
                    [
                        'channel_slug' => $testChannel['slug'],
                        'route_slug' => self::routeSlugForChannel((string)$testChannel['slug']),
                        'app' => $app,
                        'repository' => $repository,
                        'branch' => $branch,
                        'github_access' => $access,
                        'retry_action' => 'retry_branch_check',
                    ]
                );
            }
        }

        $dataSummary = $channel !== null
            ? $this->channelDataServicesSummary($channel)
            : $this->releaseDataServicesSummary();
        $this->recordOperationStep($operationId, 'data_services', 'Production-shared data services', 'passed', 'Normal channel sync keeps MariaDB, Redis, and MinIO on production_shared unless an explicit data-service action changes that mode.', null, null, [
            'data_services' => $dataSummary,
        ]);
        $statuses[] = 'passed';

        $finalStatus = in_array('failed', $statuses, true)
            ? 'failed'
            : (count(array_intersect($statuses, ['warning', 'skipped'])) > 0 ? 'warning' : 'passed');

        if ($finalStatus === 'passed' && $this->releaseGateAutoSyncRequested($gateInput)) {
            try {
                $this->inProcessPassedReleaseGates[$operationId] = [
                    'channel_id' => $channel !== null ? (int)$channel['id'] : null,
                    'release_gate' => $gateInput,
                ];
                $autoSyncResult = $this->processReleaseGateAutoSync($gateInput, $channel, $operationId, $actorUserId);
                $autoSyncStepStatus = (string)($autoSyncResult['step_status'] ?? 'passed');
                $this->recordOperationStep(
                    $operationId,
                    'auto_sync',
                    'Automatic container update',
                    $autoSyncStepStatus,
                    (string)($autoSyncResult['message'] ?? 'Automatic container update completed.'),
                    $autoSyncResult['diagnostic'] ?? null,
                    $autoSyncResult['solution_hint'] ?? null,
                    $autoSyncResult
                );
                $statuses[] = $autoSyncStepStatus;
            } catch (Throwable $throwable) {
                $this->recordOperationStep(
                    $operationId,
                    'auto_sync',
                    'Automatic container update',
                    'failed',
                    'Automatic container update failed after the release gate passed.',
                    $throwable->getMessage(),
                    'Open the channel sync operation or Release Manager target diagnostics, fix the failure, then rerun the gate.',
                    $gateInput
                );
                $statuses[] = 'failed';
            }

            $finalStatus = in_array('failed', $statuses, true)
                ? 'failed'
                : (count(array_intersect($statuses, ['warning', 'skipped'])) > 0 ? 'warning' : 'passed');
        }

        $this->completeOperationRun(
            $operationId,
            $finalStatus,
            match ($finalStatus) {
                'passed' => 'Release Manager tests completed without detected issues.',
                'failed' => 'Release Manager tests failed. Promotion is blocked until the failed gate checks pass.',
                default => 'Release Manager tests completed with warnings. Open the failed or warning steps for fixes.',
            },
            $finalStatus === 'passed' ? null : 'Resolve the failed or warning steps, then run the test again.'
        );

        return $this->operationDetail($operationId);
    }

    private function releaseTestAppsFromInput(array $input): array
    {
        $raw = $input['apps'] ?? $input['app'] ?? null;
        $values = $this->releaseGateStringArray($raw);
        $apps = [];
        foreach ($values as $value) {
            try {
                $app = $this->normalizeApp($value);
            } catch (Throwable) {
                continue;
            }
            if (!in_array($app, $apps, true)) {
                $apps[] = $app;
            }
        }

        return $apps !== [] ? $apps : self::APPS;
    }

    private function normalizeReleaseGateInput(array $input, ?array $channel): array
    {
        $channelSlug = self::safeSlug((string)($input['channel_slug'] ?? $input['channel'] ?? ($channel['slug'] ?? '')));
        $environmentUrl = $this->normalizeReleaseGateUrl((string)($input['environment_url'] ?? $input['frontend_url'] ?? ''));
        $apiBaseUrl = $this->normalizeReleaseGateUrl((string)($input['api_base_url'] ?? 'https://api-v2.truckwash.io'));
        $requiredChecks = $this->normalizeReleaseGateChecks($input, $environmentUrl);
        $routeSlug = self::routeSlugForChannel($channelSlug ?: 'stable') ?: 'master';
        $app = '';
        if (trim((string)($input['app'] ?? '')) !== '') {
            try {
                $app = $this->normalizeApp((string)$input['app']);
            } catch (Throwable) {
                $app = '';
            }
        }
        $repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? $input['repo'] ?? ''));
        $branch = trim((string)($input['branch'] ?? ''));
        $workflowUrl = $this->normalizeReleaseGateUrl((string)($input['workflow_url'] ?? $input['build_url'] ?? ''));

        return [
            'environment_url' => $environmentUrl,
            'channel_slug' => $channelSlug,
            'route_slug' => $routeSlug,
            'app' => $app,
            'apps' => $this->releaseTestAppsFromInput($input),
            'repository' => $repository,
            'branch' => $branch,
            'auto_sync' => $this->toBool($input['auto_sync'] ?? false),
            'workflow_url' => $workflowUrl,
            'expected_commit' => self::safeIdentifier((string)($input['expected_commit'] ?? $input['commit_sha'] ?? ''), 128),
            'build_id' => substr(trim((string)($input['build_id'] ?? '')), 0, 128),
            'wait_timeout_seconds' => max(0, min(300, (int)($input['wait_timeout_seconds'] ?? 300))),
            'poll_interval_seconds' => max(1, min(60, (int)($input['poll_interval_seconds'] ?? 10))),
            'required_checks' => $requiredChecks,
            'api_base_url' => $apiBaseUrl,
            'api_ping_paths' => $this->releaseGateStringArray(
                $input['api_ping_paths']
                    ?? $input['api_paths']
                    ?? ['/master/api/ping'],
                self::RELEASE_GATE_MAX_PATHS
            ),
            'shell_paths' => $this->releaseGateStringArray($input['shell_paths'] ?? ['/', '/guest/book/wash'], self::RELEASE_GATE_MAX_PATHS),
        ];
    }

    private function normalizeReleaseGateChecks(array $input, string $environmentUrl): array
    {
        $checks = $this->releaseGateStringArray($input['required_checks'] ?? []);
        if ($checks === [] && $environmentUrl !== '') {
            $checks = ['static_artifact'];
        }

        $allowed = ['static_artifact', 'api_gateway'];
        $normalized = [];
        foreach ($checks as $check) {
            $check = self::safeIdentifier(strtolower($check), 64);
            if (in_array($check, $allowed, true) && !in_array($check, $normalized, true)) {
                $normalized[] = $check;
            }
        }

        return $normalized;
    }

    private function releaseGateAutoSyncRequested(array $gateInput): bool
    {
        return (bool)($gateInput['auto_sync'] ?? false);
    }

    private function releaseGateAutoSyncValidationSteps(array $gateInput, ?array $channel): array
    {
        $steps = [];
        $requiredChecks = is_array($gateInput['required_checks'] ?? null) ? $gateInput['required_checks'] : [];
        $context = [
            'channel_slug' => $gateInput['channel_slug'] ?? null,
            'app' => $gateInput['app'] ?? null,
            'repository' => $gateInput['repository'] ?? null,
            'branch' => $gateInput['branch'] ?? null,
            'expected_commit' => $gateInput['expected_commit'] ?? null,
            'workflow_url' => $gateInput['workflow_url'] ?? null,
            'required_checks' => $requiredChecks,
        ];

        if ($channel === null) {
            $steps[] = [
                'step_key' => 'auto_sync_channel',
                'label' => 'Automatic update channel',
                'status' => 'failed',
                'message' => 'Automatic container updates require a release channel.',
                'diagnostic' => 'The gate payload did not resolve to a configured release channel.',
                'solution_hint' => 'Pass channel_slug from CI, for example stable for master.',
                'context' => $context,
            ];
        }
        if (trim((string)($gateInput['app'] ?? '')) === '') {
            $steps[] = [
                'step_key' => 'auto_sync_app',
                'label' => 'Automatic update app',
                'status' => 'failed',
                'message' => 'Automatic container updates require an app.',
                'diagnostic' => 'The gate payload must identify frontend or api so Release Manager updates exactly one container.',
                'solution_hint' => 'Pass app=frontend from the frontend workflow or app=api from the backend workflow.',
                'context' => $context,
            ];
        }
        if (trim((string)($gateInput['expected_commit'] ?? '')) === '') {
            $steps[] = [
                'step_key' => 'auto_sync_commit',
                'label' => 'Automatic update commit',
                'status' => 'failed',
                'message' => 'Automatic container updates require the CI-verified commit SHA.',
                'diagnostic' => 'expected_commit was empty.',
                'solution_hint' => 'Pass github.sha as expected_commit in the release gate payload.',
                'context' => $context,
            ];
        }
        if ($requiredChecks === []) {
            $steps[] = [
                'step_key' => 'auto_sync_required_checks',
                'label' => 'Automatic update required checks',
                'status' => 'failed',
                'message' => 'Automatic container updates require at least one release gate check.',
                'diagnostic' => 'required_checks was empty.',
                'solution_hint' => 'Include required_checks (for example static_artifact and/or api_gateway) in the release gate payload.',
                'context' => $context,
            ];
        }

        if ($steps === []) {
            $steps[] = [
                'step_key' => 'auto_sync_inputs',
                'label' => 'Automatic update inputs',
                'status' => 'passed',
                'message' => 'Release gate payload includes app, channel, and exact commit metadata for automatic container updates.',
                'context' => $context,
            ];
        }

        return $steps;
    }

    private function releaseGateStringArray(mixed $value, int $limit = 50): array
    {
        if (is_string($value)) {
            $value = preg_split('/\s*,\s*/', trim($value)) ?: [];
        }
        if (!is_array($value)) {
            return [];
        }

        $values = [];
        foreach ($value as $item) {
            $item = trim((string)$item);
            if ($item !== '' && !in_array($item, $values, true)) {
                $values[] = $item;
            }
        }
        return array_slice($values, 0, max(0, $limit));
    }

    private function normalizeReleaseGateUrl(string $value): string
    {
        $value = trim($value);
        if ($value === '') {
            return '';
        }
        if (preg_match('#^https?://#i', $value) !== 1) {
            $value = 'https://' . ltrim($value, '/');
        }
        $parts = parse_url($value);
        if (!is_array($parts) || empty($parts['host'])) {
            return '';
        }

        return rtrim($value, '/');
    }

    private function runReleaseGateChecks(array $gateInput): array
    {
        $steps = [];
        foreach ($gateInput['required_checks'] as $check) {
            $steps[] = match ($check) {
                'static_artifact' => $this->verifyReleaseStaticArtifact($gateInput),
                'api_gateway' => $this->verifyReleaseApiGateway($gateInput),
                default => [
                    'step_key' => $check,
                    'label' => 'Unknown release gate check',
                    'status' => 'skipped',
                    'message' => 'Unknown release gate check was skipped.',
                    'context' => ['check' => $check],
                ],
            };
        }

        return $steps;
    }

    private function assertReleaseGatePassedForPromotion(int $channelId, ?string $expectedCommit = null, ?string $buildId = null, ?string $app = null): void
    {
        if (!$this->releaseGateRequiredForPromotion()) {
            return;
        }

        $expectedCommit = trim((string)$expectedCommit);
        $buildId = trim((string)$buildId);
        $app = trim((string)$app) !== '' ? $this->normalizeApp((string)$app) : '';
        foreach ($this->inProcessPassedReleaseGates as $inProcessGate) {
            if ((int)($inProcessGate['channel_id'] ?? 0) !== $channelId) {
                continue;
            }
            $gate = is_array($inProcessGate['release_gate'] ?? null) ? $inProcessGate['release_gate'] : [];
            if ($app !== '' && !$this->releaseGateAppMatches($gate, $app)) {
                continue;
            }
            if ($expectedCommit !== '' && !$this->releaseGateCommitMatches((string)($gate['expected_commit'] ?? ''), $expectedCommit)) {
                continue;
            }
            if ($buildId !== '' && (string)($gate['build_id'] ?? '') !== $buildId) {
                continue;
            }
            return;
        }

        $rows = $this->selectRows(
            "SELECT id, context_json, completed_at
             FROM release_operation_runs
             WHERE operation_type = 'release_test'
               AND channel_id = ?
               AND status = 'passed'
               AND completed_at >= DATE_SUB(NOW(), INTERVAL 12 HOUR)
             ORDER BY completed_at DESC, id DESC
             LIMIT 20",
            'i',
            [$channelId]
        );

        foreach ($rows as $row) {
            $context = json_decode((string)($row['context_json'] ?? ''), true);
            $gate = is_array($context['release_gate'] ?? null) ? $context['release_gate'] : [];
            if ($app !== '' && !$this->releaseGateAppMatches($gate, $app)) {
                continue;
            }
            if ($expectedCommit !== '' && !$this->releaseGateCommitMatches((string)($gate['expected_commit'] ?? ''), $expectedCommit)) {
                continue;
            }
            if ($buildId !== '' && (string)($gate['build_id'] ?? '') !== $buildId) {
                continue;
            }

            return;
        }

        throw new RuntimeException('A passing Release Manager gate is required before promotion. Run the dev upload, public live smoke, credentialed smoke, and api-v2 health checks, then retry promotion.');
    }

    private function releaseGateAppMatches(array $gate, string $app): bool
    {
        $app = $this->normalizeApp($app);
        $gateApp = trim((string)($gate['app'] ?? ''));
        if ($gateApp !== '') {
            try {
                return $this->normalizeApp($gateApp) === $app;
            } catch (Throwable) {
                return false;
            }
        }

        $gateApps = $this->releaseGateStringArray($gate['apps'] ?? []);
        if ($gateApps !== []) {
            foreach ($gateApps as $value) {
                try {
                    if ($this->normalizeApp($value) === $app) {
                        return true;
                    }
                } catch (Throwable) {
                }
            }
            return false;
        }

        // Gates recorded before app-specific payloads existed were frontend release gates.
        return $app === 'frontend';
    }

    private function releaseGateRequiredForPromotion(): bool
    {
        $envValue = trim((string)(getenv('RELEASE_GATE_REQUIRED_FOR_PROMOTION') ?: ($_SERVER['RELEASE_GATE_REQUIRED_FOR_PROMOTION'] ?? '')));
        if ($envValue !== '') {
            return $this->toBool($envValue);
        }

        return $this->toBool($this->moduleConfigValue('ReleaseManager', 'release_gate_required_for_promotion', 'true'));
    }

    private function verifyReleaseStaticArtifact(array $gateInput): array
    {
        if (($gateInput['environment_url'] ?? '') === '') {
            return [
                'step_key' => 'static_artifact',
                'label' => 'Static artifact deployment',
                'status' => 'failed',
                'message' => 'environment_url is required for static artifact verification.',
                'solution_hint' => 'Pass the dev or channel frontend URL from CI.',
                'context' => $gateInput,
            ];
        }

        $deadline = time() + (int)$gateInput['wait_timeout_seconds'];
        $pollInterval = (int)$gateInput['poll_interval_seconds'];
        $attempts = 0;
        $lastMessage = 'Static artifact verification did not run.';
        do {
            $attempts++;
            try {
                $context = $this->releaseStaticArtifactAttempt($gateInput);
                $context['attempts'] = $attempts;
                return [
                    'step_key' => 'static_artifact',
                    'label' => 'Static artifact deployment',
                    'status' => 'passed',
                    'message' => 'The exact release manifest, app shell, JS, CSS, PWA assets, and release entry are reachable.',
                    'context' => $context,
                ];
            } catch (Throwable $throwable) {
                $lastMessage = $throwable->getMessage();
                if (time() >= $deadline) {
                    break;
                }
                sleep($pollInterval);
            }
        } while (true);

        return [
            'step_key' => 'static_artifact',
            'label' => 'Static artifact deployment',
            'status' => 'failed',
            'message' => 'The uploaded frontend artifact is not ready or does not match the expected build.',
            'diagnostic' => $lastMessage,
            'solution_hint' => 'Upload hashed assets first, keep old hashed assets, upload release-entry.json and index.html last, then rerun the gate.',
            'context' => [
                'environment_url' => $gateInput['environment_url'],
                'attempts' => $attempts,
                'wait_timeout_seconds' => $gateInput['wait_timeout_seconds'],
                'poll_interval_seconds' => $gateInput['poll_interval_seconds'],
            ],
        ];
    }

    private function releaseStaticArtifactAttempt(array $gateInput): array
    {
        $baseUrl = (string)$gateInput['environment_url'];
        $manifest = $this->releaseGateFetchJson($baseUrl, 'release-manifest.json');
        $releaseEntry = $this->releaseGateFetchJson($baseUrl, 'release-entry.json');
        $manifestData = $manifest['json'];
        $releaseEntryData = $releaseEntry['json'];

        if (trim((string)($manifestData['build_id'] ?? '')) === '') {
            throw new RuntimeException('release-manifest.json is missing build_id.');
        }
        if (!$this->releaseGateCommitMatches((string)($manifestData['commit_sha'] ?? ''), (string)$gateInput['expected_commit'])) {
            throw new RuntimeException(sprintf(
                'release-manifest.json commit_sha %s did not match expected commit %s.',
                (string)($manifestData['commit_sha'] ?? '(missing)'),
                (string)$gateInput['expected_commit']
            ));
        }
        if ((string)$gateInput['build_id'] !== '' && (string)($manifestData['build_id'] ?? '') !== (string)$gateInput['build_id']) {
            throw new RuntimeException(sprintf(
                'release-manifest.json build_id %s did not match expected build_id %s.',
                (string)($manifestData['build_id'] ?? '(missing)'),
                (string)$gateInput['build_id']
            ));
        }
        if ((string)($releaseEntryData['entry'] ?? '') !== (string)($manifestData['entry'] ?? '')) {
            throw new RuntimeException('release-entry.json entry does not match release-manifest.json.');
        }
        if (json_encode($releaseEntryData['css'] ?? []) !== json_encode($manifestData['css'] ?? [])) {
            throw new RuntimeException('release-entry.json css does not match release-manifest.json.');
        }

        foreach ($gateInput['shell_paths'] as $shellPath) {
            $shell = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $shellPath));
            if (($shell['status'] ?? 0) !== 200) {
                throw new RuntimeException(sprintf('%s returned HTTP %d.', $shellPath, (int)($shell['status'] ?? 0)));
            }
            if (!str_contains(strtolower((string)($shell['content_type'] ?? '')), 'text/html')) {
                throw new RuntimeException(sprintf('%s did not return HTML.', $shellPath));
            }
            $body = (string)($shell['body'] ?? '');
            if (strlen(trim(preg_replace('/\s+/', '', $body) ?? '')) < 40) {
                throw new RuntimeException(sprintf('%s returned an empty app shell.', $shellPath));
            }
            if (!str_contains($body, '<div id="app"></div>')) {
                throw new RuntimeException(sprintf('%s did not include the Vue app root.', $shellPath));
            }
        }

        $assetUrls = array_slice($this->releaseGateUniqueStrings(array_merge(
            ['release-manifest.json', 'release-entry.json'],
            [(string)($manifestData['entry'] ?? '')],
            is_array($manifestData['css'] ?? null) ? $manifestData['css'] : [],
            is_array($manifestData['index_asset_urls'] ?? null) ? $manifestData['index_asset_urls'] : [],
            is_array($manifestData['pwa_asset_urls'] ?? null) ? $manifestData['pwa_asset_urls'] : [],
            is_array($manifestData['asset_urls'] ?? null) ? $manifestData['asset_urls'] : []
        )), 0, self::RELEASE_GATE_MAX_ASSETS);
        $verifiedAssets = 0;
        foreach ($assetUrls as $assetUrl) {
            if ($assetUrl === '/index.html') {
                continue;
            }
            $this->releaseGateVerifyStaticAsset($baseUrl, $assetUrl, is_array($manifestData['asset_hashes'] ?? null) ? $manifestData['asset_hashes'] : []);
            $verifiedAssets++;
        }

        return [
            'environment_url' => $baseUrl,
            'build_id' => (string)$manifestData['build_id'],
            'commit_sha' => (string)($manifestData['commit_sha'] ?? ''),
            'entry' => (string)$manifestData['entry'],
            'verified_assets' => $verifiedAssets,
        ];
    }

    private function verifyReleaseApiGateway(array $gateInput): array
    {
        $apiBaseUrl = (string)($gateInput['api_base_url'] ?? '');
        if ($apiBaseUrl === '') {
            return [
                'step_key' => 'api_gateway',
                'label' => 'api-v2 channel health',
                'status' => 'failed',
                'message' => 'api_base_url is required for API gateway verification.',
                'solution_hint' => 'Pass the api-v2 base URL from CI.',
                'context' => $gateInput,
            ];
        }

        $expectedCommit = self::normalizeCommitSha((string)($gateInput['expected_commit'] ?? ''));
        $enforceExpectedCommit = $expectedCommit !== '' && !$this->releaseGateAutoSyncRequested($gateInput);
        $checked = [];
        try {
            foreach ($gateInput['api_ping_paths'] as $path) {
                $json = $this->releaseGateFetchJson($apiBaseUrl, $path);
                $payload = $json['json'];
                if (array_key_exists('success', $payload) && $payload['success'] !== true) {
                    throw new RuntimeException(sprintf('%s returned success=false.', $path));
                }
                $actualCommit = $this->releaseGateApiPayloadCommitSha($payload);
                if ($enforceExpectedCommit && !$this->releaseGateCommitMatches($actualCommit, $expectedCommit)) {
                    throw new RuntimeException(sprintf(
                        '%s returned commit %s, expected %s.',
                        $path,
                        $actualCommit !== '' ? $actualCommit : 'unknown',
                        $expectedCommit
                    ));
                }
                $checked[] = [
                    'path' => $path,
                    'status' => $json['status'],
                    'commit_sha' => $actualCommit !== '' ? $actualCommit : null,
                ];
            }
        } catch (Throwable $throwable) {
            return [
                'step_key' => 'api_gateway',
                'label' => 'api-v2 channel health',
                'status' => 'failed',
                'message' => 'api-v2 gateway or channel API health failed.',
                'diagnostic' => $throwable->getMessage(),
                'solution_hint' => 'Repair api-v2 routing so the configured channel API ping endpoints return 200 JSON before frontend promotion.',
                'context' => [
                    'api_base_url' => $apiBaseUrl,
                    'checked' => $checked,
                    'api_ping_paths' => $gateInput['api_ping_paths'],
                ],
            ];
        }

        return [
            'step_key' => 'api_gateway',
            'label' => 'api-v2 channel health',
            'status' => 'passed',
            'message' => 'api-v2 gateway and channel API prefixes returned 200 JSON.',
            'context' => [
                'api_base_url' => $apiBaseUrl,
                'checked' => $checked,
                'expected_commit' => $expectedCommit !== '' ? $expectedCommit : null,
            ],
        ];
    }

    private function releaseGateApiPayloadCommitSha(array $payload): string
    {
        $data = is_array($payload['data'] ?? null) ? $payload['data'] : $payload;
        foreach (['api_commit_sha', 'backend_version', 'commit_sha', 'version'] as $key) {
            $commit = self::normalizeCommitSha((string)($data[$key] ?? ''));
            if ($commit !== '') {
                return $commit;
            }
        }

        return '';
    }

    private function releaseGateFetchJson(string $baseUrl, string $path): array
    {
        $result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $path));
        if (($result['status'] ?? 0) !== 200) {
            throw new RuntimeException(sprintf('%s returned HTTP %d.', $path, (int)($result['status'] ?? 0)));
        }
        if (str_contains(strtolower((string)($result['content_type'] ?? '')), 'text/html')) {
            throw new RuntimeException(sprintf('%s was served as HTML.', $path));
        }

        $decoded = json_decode((string)($result['body'] ?? ''), true);
        if (!is_array($decoded)) {
            throw new RuntimeException(sprintf('%s did not return valid JSON.', $path));
        }

        $result['json'] = $decoded;
        return $result;
    }

    private function releaseGateVerifyStaticAsset(string $baseUrl, string $assetUrl, array $assetHashes): void
    {
        $result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $assetUrl));
        if (($result['status'] ?? 0) !== 200) {
            throw new RuntimeException(sprintf('%s returned HTTP %d.', $assetUrl, (int)($result['status'] ?? 0)));
        }

        $body = (string)($result['body'] ?? '');
        if ($body === '') {
            throw new RuntimeException(sprintf('%s returned an empty body.', $assetUrl));
        }
        if ($this->releaseGateRejectsHtml($assetUrl) && str_contains(strtolower((string)($result['content_type'] ?? '')), 'text/html')) {
            throw new RuntimeException(sprintf('%s was served as HTML.', $assetUrl));
        }

        $hashKey = str_starts_with($assetUrl, '/') ? $assetUrl : '/' . ltrim($assetUrl, '/');
        if (is_array($assetHashes[$hashKey] ?? null) && !empty($assetHashes[$hashKey]['sha256'])) {
            $actualHash = hash('sha256', $body);
            if (!hash_equals((string)$assetHashes[$hashKey]['sha256'], $actualHash)) {
                throw new RuntimeException(sprintf('%s sha256 hash mismatch.', $assetUrl));
            }
        }
    }

    private function releaseGateFetch(string $url): array
    {
        $this->assertReleaseGateFetchUrlAllowed($url);

        $curl = curl_init($url);
        if ($curl === false) {
            throw new RuntimeException('Could not initialize release gate request.');
        }

        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($curl, CURLOPT_FOLLOWLOCATION, false);
        curl_setopt($curl, CURLOPT_MAXREDIRS, 0);
        curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 3);
        curl_setopt($curl, CURLOPT_TIMEOUT, 8);
        curl_setopt($curl, CURLOPT_NOSIGNAL, true);
        curl_setopt($curl, CURLOPT_HTTPHEADER, [
            'Accept: application/json, text/html, */*',
            'Cache-Control: no-cache',
            'Pragma: no-cache',
            'User-Agent: Truckwash-Release-Gate',
        ]);

        $body = curl_exec($curl);
        $error = curl_error($curl);
        $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
        $contentType = (string)curl_getinfo($curl, CURLINFO_CONTENT_TYPE);
        curl_close($curl);

        if ($body === false) {
            throw new RuntimeException('Release gate request failed: ' . $error);
        }

        return [
            'url' => $url,
            'status' => $status,
            'content_type' => $contentType,
            'body' => (string)$body,
        ];
    }

    private function releaseGateJoinUrl(string $baseUrl, string $path): string
    {
        $path = trim($path);
        $parts = parse_url($path);
        if (is_array($parts) && (!empty($parts['scheme']) || !empty($parts['host']))) {
            throw new RuntimeException('Release gate paths must be relative to the configured Truckwash release host.');
        }
        if (str_starts_with($path, '//')) {
            throw new RuntimeException('Release gate paths must not be protocol-relative URLs.');
        }

        return rtrim($baseUrl, '/') . '/' . ltrim($path, '/');
    }


    private function assertReleaseGateFetchUrlAllowed(string $url): void
    {
        $parts = parse_url($url);
        $scheme = strtolower((string)($parts['scheme'] ?? ''));
        $host = strtolower(rtrim((string)($parts['host'] ?? ''), '.'));
        if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
            throw new RuntimeException('Release gate checks may only fetch HTTP(S) URLs from Truckwash release hosts.');
        }
        if (!$this->releaseGateFetchHostAllowed($host)) {
            throw new RuntimeException('Release gate checks may only fetch configured Truckwash release hosts.');
        }

        $addresses = $this->releaseGateResolveHost($host);
        if ($addresses === []) {
            throw new RuntimeException('Release gate host could not be resolved.');
        }
        foreach ($addresses as $address) {
            if (!$this->releaseGatePublicIpAllowed($address)) {
                throw new RuntimeException('Release gate host resolved to a private, loopback, or reserved address.');
            }
        }
    }

    private function releaseGateFetchHostAllowed(string $host): bool
    {
        $host = strtolower(rtrim($host, '.'));
        foreach (self::RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES as $allowedSuffix) {
            $allowedSuffix = strtolower($allowedSuffix);
            if ($host === $allowedSuffix || str_ends_with($host, '.' . $allowedSuffix)) {
                return true;
            }
        }

        return false;
    }

    private function releaseGateResolveHost(string $host): array
    {
        if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
            return [$host];
        }

        $addresses = gethostbynamel($host) ?: [];
        if (function_exists('dns_get_record')) {
            foreach (dns_get_record($host, DNS_AAAA) ?: [] as $record) {
                if (is_array($record) && !empty($record['ipv6'])) {
                    $addresses[] = (string)$record['ipv6'];
                }
            }
        }

        return array_values(array_unique(array_filter($addresses, fn(string $address): bool => filter_var($address, FILTER_VALIDATE_IP) !== false)));
    }

    private function releaseGatePublicIpAllowed(string $address): bool
    {
        return filter_var(
            $address,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
        ) !== false;
    }

    private function releaseGateCommitMatches(string $actual, string $expected): bool
    {
        $expected = strtolower(trim($expected));
        if ($expected === '') {
            return true;
        }
        $actual = strtolower(trim($actual));
        return $actual !== '' && ($actual === $expected || str_starts_with($actual, $expected));
    }

    private function verifyReleaseDeploymentReadiness(array $deployment, array $target, string $app, string $expectedCommit, array $options = []): array
    {
        $app = $this->normalizeApp($app);
        $baseUrl = $this->normalizeReleasePublicBaseUrl($deployment['deployment_url'] ?? null, $app)
            ?? $this->releaseTargetPublicBaseUrl($target);
        if ($baseUrl === null || $baseUrl === '') {
            throw new RuntimeException(sprintf('%s deployment has no public URL for readiness verification.', strtoupper($app)));
        }

        $timeout = max(0, min(300, (int)($options['wait_timeout_seconds'] ?? 300)));
        $pollInterval = max(1, min(60, (int)($options['poll_interval_seconds'] ?? 10)));
        $deadline = time() + $timeout;
        $attempts = 0;
        $lastMessage = 'Readiness verification did not run.';

        do {
            $attempts++;
            try {
                if ($app === 'frontend') {
                    $context = $this->releaseStaticArtifactAttempt([
                        'environment_url' => $baseUrl,
                        'expected_commit' => $expectedCommit,
                        'build_id' => (string)($options['build_id'] ?? ''),
                        'shell_paths' => $this->releaseGateStringArray($options['shell_paths'] ?? ['/', '/guest/book/wash']),
                    ]);
                    $context['app'] = $app;
                    $context['base_url'] = $baseUrl;
                    $context['attempts'] = $attempts;
                    return $context;
                }

                $json = $this->releaseGateFetchJson($baseUrl, 'ping');
                $payload = $json['json'];
                if (array_key_exists('success', $payload) && $payload['success'] !== true) {
                    throw new RuntimeException('API ping returned success=false.');
                }
                $data = is_array($payload['data'] ?? null) ? $payload['data'] : $payload;
                $actualCommit = (string)(
                    $data['api_commit_sha']
                    ?? $data['backend_commit_sha']
                    ?? $data['commit_sha']
                    ?? $data['backend_version']
                    ?? ''
                );
                if (!$this->releaseGateCommitMatches($actualCommit, $expectedCommit)) {
                    throw new RuntimeException(sprintf(
                        'API ping commit %s did not match expected commit %s.',
                        $actualCommit !== '' ? $actualCommit : '(missing)',
                        $expectedCommit
                    ));
                }

                return [
                    'app' => $app,
                    'base_url' => $baseUrl,
                    'path' => 'ping',
                    'status' => $json['status'] ?? null,
                    'commit_sha' => $actualCommit,
                    'attempts' => $attempts,
                ];
            } catch (Throwable $throwable) {
                $lastMessage = $throwable->getMessage();
                if (time() >= $deadline) {
                    break;
                }
                sleep($pollInterval);
            }
        } while (time() <= $deadline);

        throw new RuntimeException(sprintf(
            '%s container readiness did not match commit %s after %d attempts: %s',
            strtoupper($app),
            $expectedCommit,
            $attempts,
            $lastMessage
        ));
    }

    private function releaseGateRejectsHtml(string $assetUrl): bool
    {
        return preg_match('/\.(js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i', parse_url($assetUrl, PHP_URL_PATH) ?: '') === 1;
    }

    private function releaseGateUniqueStrings(array $values): array
    {
        $unique = [];
        foreach ($values as $value) {
            $value = trim((string)$value);
            if ($value !== '' && !in_array($value, $unique, true)) {
                $unique[] = $value;
            }
        }

        return $unique;
    }

    public function syncChannel(int $channelId, ?int $actorUserId = null, array $options = []): array
    {
        $this->ensureSchema();
        $channel = $this->getChannel($channelId);
        if ((int)($channel['enabled'] ?? 0) !== 1) {
            throw new RuntimeException('Release channel is disabled.');
        }

        $requestedApp = '';
        if (trim((string)($options['app'] ?? '')) !== '') {
            $requestedApp = $this->normalizeApp((string)$options['app']);
        }
        $apps = $requestedApp !== '' ? [$requestedApp] : self::APPS;
        $branch = trim((string)($options['branch'] ?? '')) ?: self::releaseBranchForChannel($channel);
        $routeSlug = self::routeSlugForChannel((string)$channel['slug']);
        $requestedCommitSha = self::normalizeCommitSha((string)($options['commit_sha'] ?? $options['commit'] ?? ''));
        $commitMode = $requestedCommitSha !== '' ? 'specific' : 'latest';
        $requireReadiness = $this->toBool($options['require_readiness'] ?? false);

        $operationId = $this->createOperationRun('channel_sync', [
            'subject_type' => 'channel',
            'subject_id' => (string)$channelId,
            'channel_id' => $channelId,
            'app' => $requestedApp !== '' ? $requestedApp : null,
            'title' => sprintf('Sync %s release channel', (string)($channel['name'] ?? $channel['slug'])),
            'actor_user_id' => $actorUserId,
            'context' => [
                'channel_slug' => $channel['slug'],
                'route_slug' => $routeSlug,
                'branch' => $branch,
                'apps' => $apps,
                'source' => $options['source'] ?? 'manual',
                'repository' => $options['repository'] ?? null,
                'commit_mode' => $commitMode,
                'commit_sha' => $requestedCommitSha !== '' ? $requestedCommitSha : null,
                'workflow_url' => $options['workflow_url'] ?? $options['build_url'] ?? null,
                'auto_sync_event_id' => $options['auto_sync_event_id'] ?? null,
                'auto_sync_event' => $options['auto_sync_event'] ?? null,
                'gate_operation_id' => $options['gate_operation_id'] ?? null,
            ],
        ]);

        $statuses = [];
        $deployments = [];
        $this->recordOperationStep(
            $operationId,
            'channel_mapping',
            'Channel route and branch mapping',
            'passed',
            sprintf('Channel %s syncs from branch %s and publishes under /%s/{api|frontend}.', (string)$channel['slug'], $branch, $routeSlug),
            null,
            null,
            ['channel_slug' => $channel['slug'], 'route_slug' => $routeSlug, 'branch' => $branch]
        );
        $statuses[] = 'passed';

        $this->recordOperationStep(
            $operationId,
            'data_services_guard',
            'Data services guard',
            'passed',
            'Code sync will not deploy or replace MariaDB, Redis, or MinIO.',
            null,
            null,
            ['data_services' => $this->channelDataServicesSummary($channel)]
        );
        $statuses[] = 'passed';

        if ($this->channelUsesProductionServices($channel)) {
            $serviceChannel = $this->productionServiceChannel();
            $this->recordOperationStep(
                $operationId,
                'production_services',
                'Production frontend and API services',
                'passed',
                sprintf(
                    '%s uses the %s production frontend and API services; channel sync does not deploy separate release services.',
                    (string)($channel['name'] ?? $channel['slug']),
                    (string)($serviceChannel['name'] ?? $serviceChannel['slug'])
                ),
                null,
                null,
                [
                    'channel_slug' => (string)($channel['slug'] ?? ''),
                    'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''),
                    'service_policy' => self::PRODUCTION_SERVICE_POLICY,
                ]
            );
            $statuses[] = 'passed';

            $this->completeOperationRun(
                $operationId,
                'passed',
                'Channel sync completed; production services remain active.',
                null
            );
            $operation = $this->operationDetail($operationId);
            $operation['deployments'] = [];
            $operation['channel'] = $this->publicChannel($this->getChannel($channelId));
            return $operation;
        }

        foreach ($apps as $app) {
            $target = $this->deploymentTargetForChannelApp($channelId, $app);
            if ($target === null) {
                $this->recordOperationStep(
                    $operationId,
                    $app . '_target',
                    strtoupper($app) . ' Coolify target',
                    'failed',
                    'No Coolify deployment target exists for this channel/app.',
                    'The channel cannot receive a new ' . $app . ' deployment.',
                    'Create the missing deployment target and use Retry.',
                    ['channel_id' => $channelId, 'channel_slug' => $channel['slug'], 'app' => $app, 'retry_action' => 'configure_target']
                );
                $statuses[] = 'failed';
                continue;
            }
            $target = $this->prepareChannelSyncApplicationTarget($target, $actorUserId);
            if (($target['_release_auto_prepared_application'] ?? false) === true) {
                $this->recordOperationStep(
                    $operationId,
                    $app . '_target_prepared',
                    strtoupper($app) . ' Coolify application target',
                    'passed',
                    sprintf('%s target was prepared to create a path-routed Coolify application.', strtoupper($app)),
                    null,
                    null,
                    ['target_id' => (int)$target['id'], 'app' => $app]
                );
                $statuses[] = 'passed';
            }

            $repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app)));
            $requestedRepository = self::normalizeGithubRepositoryName((string)($options['repository'] ?? ''));
            if ($requestedRepository !== '') {
                $repository = $requestedRepository;
            }
            if ($repository === '') {
                $repository = self::defaultRepositoryForApp($app);
            }
            $access = $this->githubRepositoryAccess([
                'repository' => $repository,
                'branch' => $branch,
                'commit_sha' => $requestedCommitSha,
                'commit_mode' => $commitMode,
            ]);
            if (!($access['ok'] ?? false)) {
                $this->recordOperationStep(
                    $operationId,
                    $app . '_branch',
                    strtoupper($app) . ' branch',
                    'warning',
                    sprintf('%s branch %s was not deployed because it could not be verified.', strtoupper($app), $branch),
                    (string)($access['message'] ?? 'GitHub branch access failed.'),
                    'Create the branch from master or repair GitHub access, then use Retry.',
                    [
                        'channel_slug' => $channel['slug'],
                        'route_slug' => $routeSlug,
                        'app' => $app,
                        'repository' => $repository,
                        'branch' => $branch,
                        'github_access' => $access,
                        'retry_action' => 'retry_sync',
                    ]
                );
                $statuses[] = 'warning';
                continue;
            }

            $commitSha = trim((string)($access['commit_sha'] ?? $access['latest_commit_sha'] ?? ''));
            $current = $this->currentDeploymentForChannelApp($channelId, $app);
            if ($commitSha !== '' && $current !== null && trim((string)($current['commit_sha'] ?? '')) === $commitSha) {
                $this->recordOperationStep(
                    $operationId,
                    $app . '_already_current',
                    strtoupper($app) . ' deployment',
                    'skipped',
                    sprintf('%s is already active at %s.', strtoupper($app), substr($commitSha, 0, 12)),
                    null,
                    null,
                    ['deployment' => $this->publicDeployment($current), 'github_access' => $access]
                );
                $statuses[] = 'skipped';
                continue;
            }

            try {
                $deployment = $this->startDeployment([
                    'target_id' => (int)$target['id'],
                    'channel_id' => $channelId,
                    'app' => $app,
                    'repository' => $repository,
                    'branch' => $branch,
                    'commit_mode' => $commitMode,
                    'commit_sha' => $commitSha,
                    'version_label' => $commitSha !== '' ? substr($commitSha, 0, 12) : date('Ymd-His'),
                    'build_url' => $options['build_url'] ?? null,
                    'metadata' => [
                        'release_operation_id' => $operationId,
                        'sync_source' => $options['source'] ?? 'manual',
                        'webhook_commit_sha' => $options['commit_sha'] ?? null,
                        'auto_sync_event_id' => $options['auto_sync_event_id'] ?? null,
                        'gate_operation_id' => $options['gate_operation_id'] ?? null,
                        'workflow_url' => $options['workflow_url'] ?? null,
                    ],
                ], $actorUserId);
                if (($deployment['status'] ?? '') === 'deployed' && !empty($deployment['id'])) {
                    if ($requireReadiness) {
                        $readiness = $this->verifyReleaseDeploymentReadiness($deployment, $target, $app, $commitSha, $options);
                        $this->recordOperationStep(
                            $operationId,
                            $app . '_readiness',
                            strtoupper($app) . ' container readiness',
                            'passed',
                            sprintf('%s container readiness matched commit %s.', strtoupper($app), substr($commitSha, 0, 12)),
                            null,
                            null,
                            $readiness
                        );
                    }
                    $promoted = $this->promoteDeployment((int)$deployment['id'], $actorUserId);
                    $deployment = $promoted['deployment'] ?? $deployment;
                }
                $deployments[] = $deployment;
                $this->recordOperationStep(
                    $operationId,
                    $app . '_deploy',
                    strtoupper($app) . ' deploy and activate',
                    (($deployment['status'] ?? '') === 'failed') ? 'failed' : 'passed',
                    (($deployment['status'] ?? '') === 'failed')
                        ? sprintf('%s deployment failed before activation.', strtoupper($app))
                        : sprintf('%s deployment was recorded and the latest deployed revision is active for this channel.', strtoupper($app)),
                    (($deployment['status'] ?? '') === 'failed') ? (string)($deployment['error_message'] ?? 'Deployment failed.') : null,
                    (($deployment['status'] ?? '') === 'failed') ? 'Open the deployment result diagnostics, fix the provider error, then use Retry.' : null,
                    ['deployment' => $deployment, 'github_access' => $access]
                );
                $statuses[] = (($deployment['status'] ?? '') === 'failed') ? 'failed' : 'passed';
            } catch (Throwable $throwable) {
                $this->recordOperationStep(
                    $operationId,
                    $app . '_deploy',
                    strtoupper($app) . ' deploy and activate',
                    'failed',
                    sprintf('%s deployment failed before activation.', strtoupper($app)),
                    $throwable->getMessage(),
                    'Review the deployment target, Coolify service, and GitHub branch, then use Retry.',
                    ['app' => $app, 'repository' => $repository, 'branch' => $branch]
                );
                $statuses[] = 'failed';
            }
        }

        $finalStatus = in_array('failed', $statuses, true)
            ? 'failed'
            : (count(array_intersect($statuses, ['warning'])) > 0 ? 'warning' : 'passed');
        $this->completeOperationRun(
            $operationId,
            $finalStatus,
            $finalStatus === 'passed'
                ? 'Channel sync completed.'
                : 'Channel sync finished with failures or warnings. Open the operation steps for exact diagnostics.',
            $finalStatus === 'passed' ? null : 'Use the step retry action after fixing the reported target, branch, or provider issue.'
        );

        $operation = $this->operationDetail($operationId);
        $operation['deployments'] = $deployments;
        $operation['channel'] = $this->publicChannel($this->getChannel($channelId));
        return $operation;
    }

    private function processReleaseGateAutoSync(array $gateInput, ?array $channel, int $gateOperationId, ?int $actorUserId): array
    {
        if ($channel === null) {
            throw new RuntimeException('Automatic container update requires a release channel.');
        }

        $channelId = (int)$channel['id'];
        $app = $this->normalizeApp((string)($gateInput['app'] ?? ''));
        $commitSha = self::normalizeCommitSha((string)($gateInput['expected_commit'] ?? ''));
        if ($commitSha === '') {
            throw new RuntimeException('Automatic container update requires a 7-40 character Git commit SHA.');
        }

        $branch = trim((string)($gateInput['branch'] ?? '')) ?: self::releaseBranchForChannel($channel);
        $repository = self::normalizeGithubRepositoryName((string)($gateInput['repository'] ?? ''));
        if ($repository === '') {
            $repository = self::defaultRepositoryForApp($app);
        }

        $target = $this->deploymentTargetForChannelApp($channelId, $app);
        if ($target === null) {
            throw new RuntimeException(sprintf('No %s deployment target is configured for %s.', strtoupper($app), (string)$channel['slug']));
        }
        if (!$this->toBool($target['auto_deploy'] ?? false)) {
            throw new RuntimeException(sprintf('%s automatic deployments are disabled for %s.', strtoupper($app), (string)$channel['slug']));
        }

        $targetRepository = self::normalizeGithubRepositoryName((string)($target['repository'] ?? ''));
        $targetBranch = trim((string)($target['branch'] ?? ''));
        if ($targetRepository !== '' && $repository !== $targetRepository) {
            throw new RuntimeException(sprintf('Gate repository %s does not match target repository %s.', $repository, $targetRepository));
        }
        if ($targetBranch !== '' && $branch !== $targetBranch) {
            throw new RuntimeException(sprintf('Gate branch %s does not match target branch %s.', $branch, $targetBranch));
        }

        $event = $this->upsertReleaseAutoSyncEvent([
            'channel_id' => $channelId,
            'app' => $app,
            'repository' => $repository,
            'branch' => $branch,
            'commit_sha' => $commitSha,
            'status' => 'gate_passed',
            'source' => 'release_gate',
            'workflow_url' => $gateInput['workflow_url'] ?? null,
            'gate_operation_id' => $gateOperationId,
            'metadata' => [
                'release_gate' => $gateInput,
            ],
        ]);

        $eventId = (int)$event['id'];
        if (!$this->acquireReleaseAutoSyncLock($eventId)) {
            return $this->waitForReleaseAutoSyncEventResult($eventId, $channelId, $app, $commitSha, $gateInput);
        }

        try {
            $event = $this->releaseAutoSyncEventById($eventId) ?? $event;
            if (in_array((string)($event['status'] ?? ''), ['promoted', 'deployed'], true)) {
                return [
                    'step_status' => 'passed',
                    'message' => 'Automatic container update was already completed for this commit.',
                    'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
                ];
            }

            $current = $this->currentDeploymentForChannelApp($channelId, $app);
            if ($current !== null && $this->releaseGateCommitMatches((string)($current['commit_sha'] ?? ''), $commitSha)) {
                $event = $this->updateReleaseAutoSyncEvent($eventId, 'promoted', [
                    'deployment_id' => (int)$current['id'],
                    'metadata' => ['already_current' => true],
                ]);
                return [
                    'step_status' => 'passed',
                    'message' => sprintf('%s is already active at %s.', strtoupper($app), substr($commitSha, 0, 12)),
                    'deployment' => $this->publicDeployment($current),
                    'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
                ];
            }

            $event = $this->updateReleaseAutoSyncEvent($eventId, 'syncing');
            $operation = $this->syncChannel($channelId, $actorUserId, [
                'app' => $app,
                'source' => 'release_gate',
                'repository' => $repository,
                'branch' => $branch,
                'commit_mode' => 'specific',
                'commit_sha' => $commitSha,
                'build_url' => $gateInput['workflow_url'] ?? null,
                'workflow_url' => $gateInput['workflow_url'] ?? null,
                'gate_operation_id' => $gateOperationId,
                'auto_sync_event_id' => $eventId,
                'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
                'require_readiness' => true,
                'wait_timeout_seconds' => $gateInput['wait_timeout_seconds'] ?? 300,
                'poll_interval_seconds' => $gateInput['poll_interval_seconds'] ?? 10,
                'build_id' => $gateInput['build_id'] ?? '',
                'shell_paths' => $gateInput['shell_paths'] ?? [],
            ]);

            if ((string)($operation['status'] ?? '') !== 'passed') {
                throw new RuntimeException((string)($operation['summary'] ?? 'Automatic channel sync did not pass.'));
            }

            $deployment = $this->currentDeploymentForChannelApp($channelId, $app);
            if ($deployment === null || !$this->releaseGateCommitMatches((string)($deployment['commit_sha'] ?? ''), $commitSha)) {
                throw new RuntimeException(sprintf('%s was deployed but was not promoted as the active %s release.', strtoupper($app), (string)$channel['slug']));
            }

            $event = $this->updateReleaseAutoSyncEvent($eventId, 'promoted', [
                'sync_operation_id' => (int)($operation['id'] ?? 0) ?: null,
                'deployment_id' => (int)$deployment['id'],
                'metadata' => [
                    'sync_operation_id' => $operation['id'] ?? null,
                    'deployment_id' => $deployment['id'] ?? null,
                ],
            ]);

            return [
                'step_status' => 'passed',
                'message' => sprintf('%s container was deployed and promoted at %s.', strtoupper($app), substr($commitSha, 0, 12)),
                'sync_operation' => $operation,
                'deployment' => $this->publicDeployment($deployment),
                'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
            ];
        } catch (Throwable $throwable) {
            $this->updateReleaseAutoSyncEvent($eventId, 'failed', [
                'error_message' => $throwable->getMessage(),
            ]);
            throw $throwable;
        } finally {
            $this->releaseReleaseAutoSyncLock($eventId);
        }
    }

    private function waitForReleaseAutoSyncEventResult(int $eventId, int $channelId, string $app, string $commitSha, array $gateInput): array
    {
        $timeout = max(0, min(300, (int)($gateInput['wait_timeout_seconds'] ?? 300)));
        $pollInterval = max(1, min(60, (int)($gateInput['poll_interval_seconds'] ?? 10)));
        $deadline = time() + $timeout;
        $attempts = 0;
        $lastStatus = 'unknown';

        do {
            $attempts++;
            $event = $this->releaseAutoSyncEventById($eventId);
            if ($event === null) {
                throw new RuntimeException('Automatic container update disappeared while another request was processing it.');
            }

            $lastStatus = (string)($event['status'] ?? 'unknown');
            if (in_array($lastStatus, ['promoted', 'deployed'], true)) {
                $deployment = null;
                $deploymentId = $this->nullablePositiveInt($event['deployment_id'] ?? null);
                if ($deploymentId !== null) {
                    $deployment = $this->getDeployment($deploymentId);
                }
                $deployment ??= $this->currentDeploymentForChannelApp($channelId, $app);
                if ($deployment !== null && $this->releaseGateCommitMatches((string)($deployment['commit_sha'] ?? ''), $commitSha)) {
                    return [
                        'step_status' => 'passed',
                        'message' => sprintf('%s container update completed by an in-flight request at %s.', strtoupper($app), substr($commitSha, 0, 12)),
                        'deployment' => $this->publicDeployment($deployment),
                        'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
                        'attempts' => $attempts,
                    ];
                }

                throw new RuntimeException(sprintf(
                    'Automatic container update completed for event %d but the active %s deployment does not match %s.',
                    $eventId,
                    strtoupper($app),
                    $commitSha
                ));
            }

            if ($lastStatus === 'failed') {
                $message = trim((string)($event['error_message'] ?? 'Automatic container update failed in another request.'));
                throw new RuntimeException($message !== '' ? $message : 'Automatic container update failed in another request.');
            }

            if (time() >= $deadline) {
                break;
            }

            sleep($pollInterval);
        } while (true);

        throw new RuntimeException(sprintf(
            'Automatic container update is already being processed for event %d but did not finish within %d seconds; last status was %s.',
            $eventId,
            $timeout,
            $lastStatus
        ));
    }

    private function upsertReleaseAutoSyncEvent(array $input): array
    {
        $channelId = (int)$input['channel_id'];
        $app = $this->normalizeApp((string)$input['app']);
        $repository = self::normalizeGithubRepositoryName((string)$input['repository']);
        $branch = trim((string)$input['branch']);
        $commitSha = self::normalizeCommitSha((string)$input['commit_sha']);
        $status = self::safeIdentifier((string)($input['status'] ?? 'pending'), 32) ?: 'pending';
        $source = self::safeIdentifier((string)($input['source'] ?? ''), 64) ?: null;
        $workflowUrl = $this->nullableString($input['workflow_url'] ?? null, 512);
        $gateOperationId = $this->nullablePositiveInt($input['gate_operation_id'] ?? null);
        $metadata = is_array($input['metadata'] ?? null) ? $input['metadata'] : [];

        if ($channelId <= 0 || $repository === '' || $branch === '' || $commitSha === '') {
            throw new RuntimeException('Automatic sync event requires channel, app, repository, branch, and commit.');
        }

        $existing = $this->releaseAutoSyncEventFor($channelId, $app, $repository, $branch, $commitSha);
        if ($existing === null) {
            $this->execute(
                "INSERT INTO release_auto_sync_events (
                    channel_id, app, repository, branch, commit_sha, status, source,
                    workflow_url, gate_operation_id, metadata_json, gate_passed_at
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = 'gate_passed' THEN NOW() ELSE NULL END)",
                'isssssssiss',
                [
                    $channelId,
                    $app,
                    $repository,
                    $branch,
                    $commitSha,
                    $status,
                    $source,
                    $workflowUrl,
                    $gateOperationId,
                    self::jsonEncode(self::redactPayload($metadata)),
                    $status,
                ]
            );
            return $this->releaseAutoSyncEventById($this->insertId()) ?? [];
        }

        $existingStatus = (string)($existing['status'] ?? 'pending');
        if ($status === 'pending' && !in_array($existingStatus, ['pending', 'failed'], true)) {
            $status = $existingStatus;
        }
        if ($status === 'gate_passed' && in_array($existingStatus, ['syncing', 'promoted', 'deployed'], true)) {
            $status = $existingStatus;
        }

        $this->execute(
            "UPDATE release_auto_sync_events
             SET status = ?,
                 source = COALESCE(?, source),
                 workflow_url = COALESCE(?, workflow_url),
                 gate_operation_id = COALESCE(NULLIF(?, 0), gate_operation_id),
                 error_message = NULL,
                 metadata_json = ?,
                 gate_passed_at = CASE WHEN ? = 'gate_passed' THEN COALESCE(gate_passed_at, NOW()) ELSE gate_passed_at END,
                 updated_at = NOW()
             WHERE id = ?",
            'sssissi',
            [
                $status,
                $source,
                $workflowUrl,
                $gateOperationId ?? 0,
                self::jsonEncode(self::redactPayload($metadata)),
                $status,
                (int)$existing['id'],
            ]
        );

        return $this->releaseAutoSyncEventById((int)$existing['id']) ?? [];
    }

    private function updateReleaseAutoSyncEvent(int $id, string $status, array $input = []): array
    {
        $status = self::safeIdentifier($status, 32) ?: 'pending';
        $syncOperationId = $this->nullablePositiveInt($input['sync_operation_id'] ?? null);
        $deploymentId = $this->nullablePositiveInt($input['deployment_id'] ?? null);
        $errorMessage = isset($input['error_message']) ? substr((string)$input['error_message'], 0, 4096) : null;
        $metadata = is_array($input['metadata'] ?? null) ? self::jsonEncode(self::redactPayload($input['metadata'])) : null;
        $this->execute(
            "UPDATE release_auto_sync_events
             SET status = ?,
                 sync_operation_id = COALESCE(NULLIF(?, 0), sync_operation_id),
                 deployment_id = COALESCE(NULLIF(?, 0), deployment_id),
                 error_message = ?,
                 metadata_json = COALESCE(?, metadata_json),
                 synced_at = CASE WHEN ? IN ('deployed', 'promoted') THEN COALESCE(synced_at, NOW()) ELSE synced_at END,
                 promoted_at = CASE WHEN ? = 'promoted' THEN COALESCE(promoted_at, NOW()) ELSE promoted_at END,
                 failed_at = CASE WHEN ? = 'failed' THEN NOW() ELSE failed_at END,
                 updated_at = NOW()
             WHERE id = ?",
            'siisssssi',
            [$status, $syncOperationId ?? 0, $deploymentId ?? 0, $errorMessage, $metadata, $status, $status, $status, $id]
        );

        return $this->releaseAutoSyncEventById($id) ?? [];
    }

    private function releaseAutoSyncEventFor(int $channelId, string $app, string $repository, string $branch, string $commitSha): ?array
    {
        return $this->selectOne(
            "SELECT e.*, c.slug AS channel_slug, c.name AS channel_name
             FROM release_auto_sync_events e
             INNER JOIN release_channels c ON c.id = e.channel_id
             WHERE e.channel_id = ? AND e.app = ? AND e.repository = ? AND e.branch = ? AND e.commit_sha = ?
             LIMIT 1",
            'issss',
            [$channelId, $app, $repository, $branch, $commitSha]
        );
    }

    private function releaseAutoSyncEventById(int $id): ?array
    {
        return $this->selectOne(
            "SELECT e.*, c.slug AS channel_slug, c.name AS channel_name
             FROM release_auto_sync_events e
             INNER JOIN release_channels c ON c.id = e.channel_id
             WHERE e.id = ?
             LIMIT 1",
            'i',
            [$id]
        );
    }

    private function publicReleaseAutoSyncEvent(array $event): array
    {
        return [
            'id' => (int)($event['id'] ?? 0),
            'channel_id' => (int)($event['channel_id'] ?? 0),
            'channel_slug' => $event['channel_slug'] ?? null,
            'channel_name' => $event['channel_name'] ?? null,
            'app' => (string)($event['app'] ?? ''),
            'repository' => (string)($event['repository'] ?? ''),
            'branch' => (string)($event['branch'] ?? ''),
            'commit_sha' => (string)($event['commit_sha'] ?? ''),
            'status' => (string)($event['status'] ?? 'unknown'),
            'source' => $event['source'] ?? null,
            'workflow_url' => $event['workflow_url'] ?? null,
            'gate_operation_id' => isset($event['gate_operation_id']) ? (int)$event['gate_operation_id'] : null,
            'sync_operation_id' => isset($event['sync_operation_id']) ? (int)$event['sync_operation_id'] : null,
            'deployment_id' => isset($event['deployment_id']) ? (int)$event['deployment_id'] : null,
            'error_message' => $event['error_message'] ?? null,
            'metadata' => self::jsonDecode($event['metadata_json'] ?? null),
            'received_at' => $event['received_at'] ?? null,
            'gate_passed_at' => $event['gate_passed_at'] ?? null,
            'synced_at' => $event['synced_at'] ?? null,
            'promoted_at' => $event['promoted_at'] ?? null,
            'failed_at' => $event['failed_at'] ?? null,
        ];
    }

    private function acquireReleaseAutoSyncLock(int $eventId): bool
    {
        $lockName = 'release_auto_sync:' . $eventId;
        $row = $this->selectOne('SELECT GET_LOCK(?, 0) AS acquired', 's', [$lockName]);
        return (int)($row['acquired'] ?? 0) === 1;
    }

    private function releaseReleaseAutoSyncLock(int $eventId): void
    {
        try {
            $this->selectOne('SELECT RELEASE_LOCK(?) AS released', 's', ['release_auto_sync:' . $eventId]);
        } catch (Throwable) {
        }
    }

    public function runIssueAction(array $input, ?int $actorUserId = null): array
    {
        $this->ensureSchema();

        $issueKey = trim((string)($input['issue_key'] ?? $input['key'] ?? ''));
        $actionId = self::safeIdentifier((string)($input['action_id'] ?? $input['action'] ?? ''), 64);
        $actionInputs = is_array($input['inputs'] ?? null) ? $input['inputs'] : [];
        $confirmed = $this->toBool($input['confirm'] ?? false);
        $summary = $this->summary();

        $issue = $this->releaseStatusIssueByKey($summary, $issueKey);
        if ($issue === null) {
            $this->audit(null, null, 'release_issue_action_attempted', $actorUserId, 'warning', [
                'issue_key' => $issueKey,
                'action_id' => $actionId,
                'inputs' => $actionInputs,
                'status' => 'stale_issue',
            ]);

            return [
                'status' => 'failed',
                'message' => 'This release issue is no longer active. Refresh Release Manager and review the current state.',
                'result' => null,
                'summary' => $summary,
            ];
        }

        $action = $this->releaseStatusActionById($issue, $actionId);
        if ($action === null) {
            $this->audit(
                $this->nullablePositiveInt($issue['channel_id'] ?? null),
                $this->nullablePositiveInt($issue['deployment_id'] ?? null),
                'release_issue_action_attempted',
                $actorUserId,
                'warning',
                [
                    'issue_key' => $issueKey,
                    'issue' => $issue,
                    'action_id' => $actionId,
                    'inputs' => $actionInputs,
                    'status' => 'unavailable_action',
                ]
            );

            return [
                'status' => 'failed',
                'message' => 'This release issue action is no longer available.',
                'issue' => $issue,
                'result' => null,
                'summary' => $summary,
            ];
        }

        if (trim((string)($action['disabled_reason'] ?? '')) !== '') {
            $this->audit(
                $this->nullablePositiveInt($issue['channel_id'] ?? null),
                $this->nullablePositiveInt($issue['deployment_id'] ?? null),
                'release_issue_action_attempted',
                $actorUserId,
                'warning',
                [
                    'issue_key' => $issueKey,
                    'issue' => $issue,
                    'action_id' => $actionId,
                    'inputs' => $actionInputs,
                    'status' => 'disabled',
                    'disabled_reason' => (string)$action['disabled_reason'],
                ]
            );

            return [
                'status' => 'needs_input',
                'message' => (string)$action['disabled_reason'],
                'issue' => $issue,
                'action' => $action,
                'result' => null,
                'summary' => $summary,
            ];
        }

        if (($action['requires_confirmation'] ?? false) && !$confirmed) {
            $this->audit(
                $this->nullablePositiveInt($issue['channel_id'] ?? null),
                $this->nullablePositiveInt($issue['deployment_id'] ?? null),
                'release_issue_action_attempted',
                $actorUserId,
                'warning',
                [
                    'issue_key' => $issueKey,
                    'issue' => $issue,
                    'action_id' => $actionId,
                    'inputs' => $actionInputs,
                    'status' => 'confirmation_required',
                ]
            );

            return [
                'status' => 'needs_input',
                'message' => 'Confirm this release issue action before Release Manager changes deployment state.',
                'issue' => $issue,
                'action' => $action,
                'result' => null,
                'summary' => $summary,
            ];
        }

        $this->audit(
            $this->nullablePositiveInt($issue['channel_id'] ?? null),
            $this->nullablePositiveInt($issue['deployment_id'] ?? null),
            'release_issue_action_attempted',
            $actorUserId,
            'info',
            [
                'issue_key' => $issueKey,
                'issue' => $issue,
                'action_id' => $actionId,
                'inputs' => $actionInputs,
            ]
        );

        try {
            $result = $this->executeReleaseIssueAction($issue, $actionId, $actionInputs, $actorUserId);
            $status = (string)($result['status'] ?? 'completed');
            $message = (string)($result['message'] ?? 'Release issue action completed.');
            $this->audit(
                $this->nullablePositiveInt($issue['channel_id'] ?? null),
                $this->nullablePositiveInt($issue['deployment_id'] ?? null),
                'release_issue_action_completed',
                $actorUserId,
                $status === 'failed' ? 'error' : 'info',
                [
                    'issue_key' => $issueKey,
                    'action_id' => $actionId,
                    'status' => $status,
                    'result' => $result['result'] ?? null,
                ]
            );

            return [
                'status' => $status,
                'message' => $message,
                'issue' => $issue,
                'action' => $action,
                'result' => $result['result'] ?? null,
                'summary' => $this->summary(),
            ];
        } catch (Throwable $throwable) {
            $this->audit(
                $this->nullablePositiveInt($issue['channel_id'] ?? null),
                $this->nullablePositiveInt($issue['deployment_id'] ?? null),
                'release_issue_action_failed',
                $actorUserId,
                'error',
                [
                    'issue_key' => $issueKey,
                    'action_id' => $actionId,
                    'error' => $throwable->getMessage(),
                ]
            );

            return [
                'status' => 'failed',
                'message' => $throwable->getMessage(),
                'issue' => $issue,
                'action' => $action,
                'result' => null,
                'summary' => $this->summary(),
            ];
        }
    }

    private function executeReleaseIssueAction(array $issue, string $actionId, array $inputs, ?int $actorUserId): array
    {
        return match ($actionId) {
            'retry_deployment' => $this->retryReleaseIssueDeployment($issue, $inputs, $actorUserId),
            'deploy_missing_version' => $this->deployReleaseIssueMissingVersion($issue, $inputs, $actorUserId),
            'set_bundle' => $this->setReleaseIssueBundle($issue, $inputs, $actorUserId),
            'complete_data_services' => $this->completeReleaseIssueDataServices($issue, $inputs, $actorUserId),
            'reconcile_coolify_target' => $this->runReleaseIssueCoolifyTargetAction($issue, 'reconcile', $actorUserId),
            'redeploy_coolify_target' => $this->runReleaseIssueCoolifyTargetAction($issue, 'deploy', $actorUserId),
            'restart_coolify_target' => $this->runReleaseIssueCoolifyTargetAction($issue, 'restart', $actorUserId),
            'prepare_application_target' => $this->prepareReleaseIssueApplicationTarget($issue, $actorUserId),
            'refresh_status' => [
                'status' => 'completed',
                'message' => 'Release status refreshed.',
                'result' => null,
            ],
            default => throw new RuntimeException('Unknown release issue action.'),
        };
    }

    private function retryReleaseIssueDeployment(array $issue, array $inputs, ?int $actorUserId): array
    {
        $deploymentId = $this->nullablePositiveInt($issue['deployment_id'] ?? null);
        if ($deploymentId === null) {
            throw new RuntimeException('The failed deployment record is missing.');
        }

        $deployment = $this->getDeployment($deploymentId);
        $payload = self::jsonDecode($deployment['requested_payload_json'] ?? null);
        $payload = is_array($payload) ? $payload : [];
        foreach (['version_id'] as $key) {
            if (array_key_exists($key, $inputs)) {
                $payload[$key] = $inputs[$key];
            }
        }
        $payload = array_replace($payload, [
            'channel_id' => (int)$deployment['channel_id'],
            'target_id' => $this->nullablePositiveInt($deployment['target_id'] ?? null),
            'app' => (string)$deployment['app'],
            'repository' => (string)($deployment['repository'] ?? ''),
            'branch' => (string)($deployment['branch'] ?? self::DEFAULT_BRANCH),
            'commit_mode' => trim((string)($deployment['commit_sha'] ?? '')) !== '' ? 'specific' : 'latest',
            'commit_sha' => (string)($deployment['commit_sha'] ?? ''),
            'service_set_id' => $this->nullablePositiveInt($deployment['service_set_id'] ?? null),
            'bundle_id' => $this->nullablePositiveInt($deployment['bundle_id'] ?? null),
            'deployment_kind' => (string)($deployment['deployment_kind'] ?? 'single_app'),
        ]);

        $newDeployment = $this->startDeployment($payload, $actorUserId);
        $status = strtolower((string)($newDeployment['status'] ?? ''));
        return [
            'status' => $status === 'failed' ? 'failed' : (in_array($status, ['queued', 'deploying'], true) ? 'queued' : 'completed'),
            'message' => $status === 'failed' ? 'Deployment retry failed.' : 'Deployment retry started.',
            'result' => ['deployment' => $newDeployment],
        ];
    }

    private function deployReleaseIssueMissingVersion(array $issue, array $inputs, ?int $actorUserId): array
    {
        $targetId = $this->nullablePositiveInt($inputs['target_id'] ?? $issue['target_id'] ?? null);
        if ($targetId === null) {
            return [
                'status' => 'needs_input',
                'message' => 'Select or create a deployment target before deploying the missing version.',
                'result' => [
                    'required_inputs' => ['target_id'],
                    'channel_id' => $issue['channel_id'] ?? null,
                    'app' => $issue['service_key'] ?? null,
                ],
            ];
        }

        $target = $this->getDeploymentTarget($targetId);
        $deployment = $this->startDeployment([
            'channel_id' => (int)$target['channel_id'],
            'target_id' => $targetId,
            'app' => (string)$target['app'],
            'repository' => (string)$target['repository'],
            'branch' => (string)$target['branch'],
            'commit_mode' => (string)($inputs['commit_mode'] ?? 'latest'),
            'commit_sha' => (string)($inputs['commit_sha'] ?? ''),
            'version_label' => (string)($inputs['version_label'] ?? ''),
        ], $actorUserId);

        $status = strtolower((string)($deployment['status'] ?? ''));
        return [
            'status' => $status === 'failed' ? 'failed' : (in_array($status, ['queued', 'deploying'], true) ? 'queued' : 'completed'),
            'message' => $status === 'failed' ? 'Missing version deployment failed.' : 'Missing version deployment started.',
            'result' => ['deployment' => $deployment],
        ];
    }

    private function setReleaseIssueBundle(array $issue, array $inputs, ?int $actorUserId): array
    {
        $channelId = $this->nullablePositiveInt($issue['channel_id'] ?? null);
        if ($channelId === null) {
            throw new RuntimeException('Release channel is missing.');
        }

        $bundleId = $this->nullablePositiveInt($inputs['bundle_id'] ?? null);
        if ($bundleId === null) {
            $eligible = self::releaseStatusEligibleBundles(array_filter(
                $this->listBundles(250),
                static fn(array $bundle): bool => (int)($bundle['channel_id'] ?? 0) === $channelId
            ));
            if (count($eligible) !== 1) {
                return [
                    'status' => 'needs_input',
                    'message' => $eligible === [] ? 'No deployed bundle is available for this channel.' : 'Choose which deployed bundle to set.',
                    'result' => [
                        'required_inputs' => ['bundle_id'],
                        'bundle_choices' => $eligible,
                    ],
                ];
            }
            $bundleId = (int)$eligible[0]['id'];
        }

        return [
            'status' => 'completed',
            'message' => 'Release bundle set for channel.',
            'result' => $this->setChannelBundle($channelId, ['bundle_id' => $bundleId], $actorUserId),
        ];
    }

    private function completeReleaseIssueDataServices(array $issue, array $inputs, ?int $actorUserId): array
    {
        $serviceSetId = $this->nullablePositiveInt($inputs['service_set_id'] ?? $issue['service_set_id'] ?? null);
        if ($serviceSetId === null) {
            return [
                'status' => 'needs_input',
                'message' => 'Select the isolated service set before creating missing data services.',
                'result' => ['required_inputs' => ['service_set_id']],
            ];
        }

        return [
            'status' => 'queued',
            'message' => 'Missing isolated data services were requested.',
            'result' => $this->completeIsolatedStackDataServices($serviceSetId, ['deploy_data_targets' => true], $actorUserId),
        ];
    }

    private function runReleaseIssueCoolifyTargetAction(array $issue, string $operation, ?int $actorUserId): array
    {
        $targetId = $this->nullablePositiveInt($issue['coolify_target_id'] ?? null);
        if ($targetId === null) {
            throw new RuntimeException('Coolify target is missing.');
        }
        if (!class_exists(coolify_manager::class) && function_exists('app_require')) {
            app_require('classes/coolify_manager.php');
        }
        if (!class_exists(coolify_manager::class)) {
            throw new RuntimeException('Coolify manager is not available.');
        }

        $manager = new coolify_manager();
        $result = match ($operation) {
            'reconcile' => $manager->reconcileTarget($targetId, $actorUserId),
            'deploy' => $manager->deployTarget($targetId, $actorUserId),
            'restart' => $manager->restartTarget($targetId, $actorUserId),
            default => throw new RuntimeException('Unknown Coolify target action.'),
        };

        return [
            'status' => 'queued',
            'message' => 'Coolify target action requested.',
            'result' => $result,
        ];
    }

    private function prepareReleaseIssueApplicationTarget(array $issue, ?int $actorUserId): array
    {
        $targetId = $this->nullablePositiveInt($issue['target_id'] ?? null);
        if ($targetId === null) {
            throw new RuntimeException('Release deployment target is missing.');
        }

        return $this->prepareDeploymentTargetAsApplication($targetId, $actorUserId, 'warning');
    }

    private function prepareDeploymentTargetAsApplication(int $targetId, ?int $actorUserId, string $severity = 'warning'): array
    {
        $target = $this->getDeploymentTarget($targetId);
        $context = self::jsonDecode($target['deploy_context_json'] ?? null);
        $context = is_array($context) ? $context : [];
        $context['coolify_resource_type'] = 'application';
        $context['coolify_auto_create'] = true;
        $context['coolify_enable_ssl'] = $this->toBool($context['coolify_enable_ssl'] ?? true);
        $replacedLegacyUuid = trim((string)($target['coolify_service_uuid'] ?? '')) !== '';

        $this->execute(
            'UPDATE release_deployment_targets SET coolify_service_uuid = NULL, deploy_context_json = ? WHERE id = ?',
            'si',
            [self::jsonEncode($context), $targetId]
        );
        $this->audit((int)$target['channel_id'], null, 'deployment_target_prepared_as_application', $actorUserId, $severity, [
            'target_id' => $targetId,
            'replaced_legacy_service_uuid' => $replacedLegacyUuid,
            'severity' => $severity,
        ]);

        return [
            'status' => 'completed',
            'message' => 'Deployment target will create a Coolify application on the next deployment.',
            'result' => $this->publicDeploymentTarget($this->getDeploymentTarget($targetId)),
        ];
    }

    private function prepareChannelSyncApplicationTarget(array $target, ?int $actorUserId): array
    {
        $context = self::jsonDecode($target['deploy_context_json'] ?? null);
        $context = is_array($context) ? $context : [];
        if (!$this->releaseTargetNeedsApplicationAutoCreate($target, $context)) {
            return $target;
        }

        $this->prepareDeploymentTargetAsApplication((int)$target['id'], $actorUserId, 'info');
        $prepared = $this->getDeploymentTarget((int)$target['id']);
        $prepared['_release_auto_prepared_application'] = true;
        return $prepared;
    }

    private function releaseStatusIssueByKey(array $summary, string $issueKey): ?array
    {
        foreach (is_array($summary['status_overview']['issues'] ?? null) ? $summary['status_overview']['issues'] : [] as $issue) {
            if (is_array($issue) && (string)($issue['key'] ?? '') === $issueKey) {
                return $issue;
            }
        }
        return null;
    }

    private function releaseStatusActionById(array $issue, string $actionId): ?array
    {
        foreach (is_array($issue['actions'] ?? null) ? $issue['actions'] : [] as $action) {
            if (is_array($action) && (string)($action['id'] ?? '') === $actionId) {
                return $action;
            }
        }
        return null;
    }

    private function releaseStatusOverview(array $summary): array
    {
        $channels = array_values(array_filter(
            is_array($summary['channels'] ?? null) ? $summary['channels'] : [],
            static fn(mixed $channel): bool => is_array($channel)
        ));
        $targetsByChannelApp = $this->releaseStatusTargetsByChannelApp(
            is_array($summary['deployment_targets'] ?? null) ? $summary['deployment_targets'] : []
        );
        $deployments = array_values(array_filter(
            is_array($summary['deployments'] ?? null) ? $summary['deployments'] : [],
            static fn(mixed $deployment): bool => is_array($deployment)
        ));
        $deploymentsByChannelApp = $this->releaseStatusLatestDeploymentsByChannelApp($deployments);
        $serviceSetsByChannel = $this->releaseStatusServiceSetsByChannel(
            is_array($summary['service_sets'] ?? null) ? $summary['service_sets'] : []
        );
        $bundlesByChannel = $this->releaseStatusBundlesByChannel(
            is_array($summary['bundles'] ?? null) ? $summary['bundles'] : []
        );
        $productionServiceChannel = $this->releaseStatusProductionServiceChannel($channels);

        $channelRows = [];
        $issues = [];
        foreach ($channels as $channel) {
            $row = $this->releaseStatusChannelRow(
                $channel,
                $productionServiceChannel,
                $targetsByChannelApp,
                $deployments,
                $deploymentsByChannelApp,
                $serviceSetsByChannel,
                $bundlesByChannel
            );
            $channelRows[] = $row;
            foreach ($row['issues'] as $issue) {
                $issues[] = $issue;
            }
        }

        usort($issues, static function (array $a, array $b): int {
            $rank = self::releaseStatusSeverityRank($b['severity'] ?? 'ok')
                <=> self::releaseStatusSeverityRank($a['severity'] ?? 'ok');
            if ($rank !== 0) {
                return $rank;
            }
            return strcmp((string)($a['channel_slug'] ?? ''), (string)($b['channel_slug'] ?? ''));
        });

        $affectedChannels = [];
        $serviceCount = 0;
        $unhealthyServiceCount = 0;
        $missingValueCount = 0;
        $criticalCount = 0;
        $warningCount = 0;
        foreach ($channelRows as $row) {
            foreach ($row['services'] as $service) {
                $serviceCount++;
                if (self::releaseStatusSeverityRank($service['severity'] ?? 'ok') > 0) {
                    $unhealthyServiceCount++;
                }
            }
        }
        foreach ($issues as $issue) {
            $severity = (string)($issue['severity'] ?? 'ok');
            if ($severity === 'critical') {
                $criticalCount++;
            } elseif ($severity === 'warning') {
                $warningCount++;
            }
            if (($issue['type'] ?? '') === 'missing_value') {
                $missingValueCount++;
            }
            if (self::releaseStatusSeverityRank($severity) > 0 && !empty($issue['channel_slug'])) {
                $affectedChannels[(string)$issue['channel_slug']] = true;
            }
        }

        return [
            'generated_at' => $summary['generated_at'] ?? date('c'),
            'state' => $criticalCount > 0 ? 'blocked' : ($warningCount > 0 ? 'attention' : 'ready'),
            'totals' => [
                'channels' => count($channelRows),
                'ready_channels' => count(array_filter(
                    $channelRows,
                    static fn(array $row): bool => ($row['readiness'] ?? '') === 'ready'
                )),
                'affected_channels' => count($affectedChannels),
                'issues' => count($issues),
                'critical' => $criticalCount,
                'warning' => $warningCount,
                'services' => $serviceCount,
                'unhealthy_services' => $unhealthyServiceCount,
                'missing_values' => $missingValueCount,
            ],
            'issues' => $issues,
            'channels' => $channelRows,
        ];
    }

    private function releaseStatusChannelRow(
        array $channel,
        ?array $productionServiceChannel,
        array $targetsByChannelApp,
        array $deployments,
        array $deploymentsByChannelApp,
        array $serviceSetsByChannel,
        array $bundlesByChannel
    ): array {
        $channelId = (int)($channel['id'] ?? 0);
        $channelSlug = (string)($channel['slug'] ?? '');
        $channelName = (string)($channel['name'] ?? $channelSlug);
        $channel = $this->releaseStatusChannelWithProductionServices($channel, $productionServiceChannel, $targetsByChannelApp);
        $channelWithTargetEndpoints = $this->releaseStatusChannelWithTargetEndpoints($channel, $targetsByChannelApp);
        $availability = $this->releaseStatusChannelAvailability($channelWithTargetEndpoints);
        $services = $this->releaseStatusServicesForChannel(
            $channelWithTargetEndpoints,
            $availability,
            $targetsByChannelApp,
            $deploymentsByChannelApp,
            $serviceSetsByChannel
        );

        $issues = [];
        $missingValues = [];
        foreach ($availability['missing'] as $missingKey) {
            $missing = [
                'key' => $missingKey,
                'label' => self::releaseStatusMissingValueLabel($missingKey),
                'service_key' => self::releaseStatusMissingServiceKey($missingKey),
                'target_tab' => self::releaseStatusMissingTargetTab($missingKey),
            ];
            $missingValues[] = $missing;
            $issues[] = self::releaseStatusIssue([
                'severity' => 'critical',
                'type' => 'missing_value',
                'channel_id' => $channelId,
                'channel_slug' => $channelSlug,
                'service_key' => $missing['service_key'],
                'label' => $missing['label'],
                'message' => $channelName . ' is missing ' . $missing['label'] . '.',
                'next_action' => self::releaseStatusMissingNextAction($missingKey),
                'target_tab' => $missing['target_tab'],
                'missing_key' => $missingKey,
            ]);
        }

        foreach ($services as $service) {
            if (self::releaseStatusSeverityRank($service['severity'] ?? 'ok') === 0 || empty($service['issue_type'])) {
                continue;
            }
            $issues[] = self::releaseStatusIssue([
                'severity' => (string)$service['severity'],
                'type' => (string)$service['issue_type'],
                'channel_id' => $channelId,
                'channel_slug' => $channelSlug,
                'service_key' => (string)$service['service_key'],
                'label' => (string)$service['label'],
                'message' => (string)$service['message'],
                'next_action' => (string)$service['next_action'],
                'target_tab' => (string)$service['target_tab'],
                'target_id' => $service['target_id'] ?? null,
                'deployment_id' => $service['deployment_id'] ?? null,
                'coolify_target_id' => $service['coolify_target_id'] ?? null,
                'missing_key' => $service['missing_key'] ?? null,
                'service_set_id' => $service['service_set_id'] ?? null,
            ]);
        }

        $issues = array_map(
            fn(array $issue): array => $this->releaseStatusIssueWithActions(
                $issue,
                $channel,
                $services,
                $bundlesByChannel[$channelId] ?? []
            ),
            $issues
        );

        $severity = 'ok';
        foreach ($issues as $issue) {
            $severity = self::releaseStatusMaxSeverity($severity, (string)($issue['severity'] ?? 'ok'));
        }
        $readiness = $severity === 'critical' ? 'blocked' : ($severity === 'warning' ? 'attention' : 'ready');
        $latestDeployments = array_slice(array_values(array_filter(
            $deployments,
            static fn(array $deployment): bool => (int)($deployment['channel_id'] ?? 0) === $channelId
        )), 0, 5);

        return [
            'channel_id' => $channelId,
            'channel_slug' => $channelSlug,
            'channel_name' => $channelName,
            'default_channel' => (bool)($channelWithTargetEndpoints['default_channel'] ?? false),
            'enabled' => (bool)($channelWithTargetEndpoints['enabled'] ?? true),
            'service_policy' => (string)($channelWithTargetEndpoints['service_policy'] ?? 'channel'),
            'service_channel_id' => $channelWithTargetEndpoints['_service_channel_id'] ?? $channelId,
            'service_channel_slug' => $channelWithTargetEndpoints['_service_channel_slug'] ?? $channelSlug,
            'severity' => $severity,
            'readiness' => $readiness,
            'message' => self::releaseStatusChannelMessage($readiness, count($issues)),
            'availability' => $availability,
            'missing_values' => $missingValues,
            'services' => $services,
            'versions' => is_array($channelWithTargetEndpoints['versions'] ?? null) ? $channelWithTargetEndpoints['versions'] : [],
            'replay' => [
                'enabled' => (bool)($channelWithTargetEndpoints['replay_enabled'] ?? false),
                'capture_level' => (string)($channelWithTargetEndpoints['capture_level'] ?? 'metadata'),
            ],
            'latest_deployments' => $latestDeployments,
            'issues' => $issues,
        ];
    }

    private function releaseStatusProductionServiceChannel(array $channels): ?array
    {
        $normalized = array_values(array_filter($channels, static fn(mixed $channel): bool => is_array($channel)));

        foreach ($normalized as $channel) {
            if (
                ((bool)($channel['default_channel'] ?? false) || (int)($channel['default_channel'] ?? 0) === 1)
                && !$this->channelUsesProductionServices($channel)
            ) {
                return $channel;
            }
        }

        foreach (self::BETA_PRODUCTION_DATA_SOURCE_CHANNELS as $slug) {
            foreach ($normalized as $channel) {
                if (
                    self::safeSlug((string)($channel['slug'] ?? '')) === $slug
                    && !$this->channelUsesProductionServices($channel)
                ) {
                    return $channel;
                }
            }
        }

        foreach ($normalized as $channel) {
            if (!$this->channelUsesProductionServices($channel)) {
                return $channel;
            }
        }

        return null;
    }

    private function releaseStatusChannelWithProductionServices(
        array $channel,
        ?array $productionServiceChannel,
        array $targetsByChannelApp
    ): array {
        if (!$this->channelUsesProductionServices($channel) || $productionServiceChannel === null) {
            return $channel;
        }

        $source = $this->releaseStatusChannelWithTargetEndpoints($productionServiceChannel, $targetsByChannelApp);
        $channel['_uses_production_services'] = true;
        $channel['_service_channel_id'] = (int)($source['id'] ?? 0);
        $channel['_service_channel_slug'] = (string)($source['slug'] ?? '');
        $channel['service_policy'] = self::PRODUCTION_SERVICE_POLICY;
        $channel['versions'] = is_array($source['versions'] ?? null) ? $source['versions'] : [];

        foreach (['frontend_base_url', 'api_base_url'] as $field) {
            if (!empty($source[$field])) {
                $channel[$field] = $source[$field];
            }
        }

        return $channel;
    }

    private function releaseStatusChannelWithTargetEndpoints(array $channel, array $targetsByChannelApp): array
    {
        $channelId = (int)($channel['id'] ?? 0);
        if ($channelId <= 0) {
            return $channel;
        }

        foreach (self::APPS as $app) {
            $field = $app === 'frontend' ? 'frontend_base_url' : 'api_base_url';
            if (!empty($channel[$field])) {
                continue;
            }
            $target = $targetsByChannelApp[$channelId . ':' . $app] ?? null;
            if (!is_array($target)) {
                continue;
            }
            $endpointUrl = is_array($target['endpoint'] ?? null)
                ? $this->normalizeReleasePublicBaseUrl($target['endpoint']['url'] ?? null, $app)
                : null;
            $endpointUrl ??= $this->releaseTargetPublicBaseUrl($target + [
                'channel_slug' => $channel['slug'] ?? $target['channel_slug'] ?? '',
            ]);
            if ($endpointUrl !== null) {
                $channel[$field] = $endpointUrl;
            }
        }

        return $channel;
    }

    private function releaseStatusServicesForChannel(
        array $channel,
        array $availability,
        array $targetsByChannelApp,
        array $deploymentsByChannelApp,
        array $serviceSetsByChannel
    ): array {
        $channelId = (int)($channel['id'] ?? 0);
        $appServiceChannelId = (int)($channel['_service_channel_id'] ?? $channelId);
        $versions = is_array($channel['versions'] ?? null) ? $channel['versions'] : [];
        $serviceSet = is_array($versions['service_set'] ?? null)
            ? $versions['service_set']
            : ($serviceSetsByChannel[$channelId][0] ?? null);
        $missingLookup = array_fill_keys($availability['missing'] ?? [], true);

        $services = [];
        foreach (self::APPS as $app) {
            $key = $appServiceChannelId . ':' . $app;
            $services[] = $this->releaseStatusAppServiceRow(
                $app,
                $channel,
                is_array($versions[$app] ?? null) ? $versions[$app] : null,
                is_array($targetsByChannelApp[$key] ?? null) ? $targetsByChannelApp[$key] : null,
                is_array($deploymentsByChannelApp[$key] ?? null) ? $deploymentsByChannelApp[$key] : null,
                $missingLookup
            );
        }

        foreach (self::STACK_DATA_KINDS as $kind) {
            $services[] = $this->releaseStatusDataServiceRow($kind, $channel, is_array($serviceSet) ? $serviceSet : null);
        }

        return $services;
    }

    private function releaseStatusAppServiceRow(
        string $app,
        array $channel,
        ?array $version,
        ?array $target,
        ?array $deployment,
        array $missingLookup
    ): array {
        $serviceLabel = self::releaseStatusServiceLabel($app);
        $missingKeys = $app === 'frontend'
            ? ['frontend_version', 'frontend_base_url']
            : ['api_version', 'api_base_url'];
        $missingKey = null;
        foreach ($missingKeys as $key) {
            if (isset($missingLookup[$key])) {
                $missingKey = $key;
                break;
            }
        }

        $row = [
            'service_key' => $app,
            'label' => $serviceLabel,
            'status' => (string)($deployment['status'] ?? $version['status'] ?? 'ready'),
            'state' => 'ready',
            'severity' => 'ok',
            'message' => $serviceLabel . ' release service is ready.',
            'next_action' => '',
            'target_tab' => 'overview',
            'target_id' => $target['id'] ?? null,
            'deployment_id' => $deployment['id'] ?? null,
            'version_label' => $version['version_label'] ?? null,
            'commit_sha' => $version['commit_sha'] ?? $deployment['commit_sha'] ?? null,
            'repository' => $target['repository'] ?? $deployment['repository'] ?? $version['repository'] ?? null,
            'branch' => $target['branch'] ?? $deployment['branch'] ?? $version['branch'] ?? null,
            'health_url' => $target['health_url'] ?? null,
            'issue_type' => null,
        ];

        $deploymentStatus = strtolower((string)($deployment['status'] ?? ''));
        if (in_array($deploymentStatus, ['failed', 'error'], true)) {
            return array_replace($row, [
                'state' => 'failed',
                'severity' => 'critical',
                'message' => trim((string)(
                    $deployment['failure_summary']['root_cause']
                    ?? $deployment['error_message']
                    ?? ($serviceLabel . ' deployment failed.')
                )),
                'next_action' => trim((string)(
                    $deployment['failure_summary']['next_action']
                    ?? 'Open the deployment details and fix the failing release before promotion.'
                )),
                'target_tab' => 'deployments',
                'issue_type' => 'failed_deployment',
            ]);
        }

        if (in_array($deploymentStatus, ['queued', 'running', 'deploying', 'building', 'pending'], true)) {
            return array_replace($row, [
                'state' => 'deployment_in_progress',
                'severity' => 'warning',
                'message' => $serviceLabel . ' deployment is still in progress.',
                'next_action' => 'Wait for the deployment to finish, then refresh Release Manager.',
                'target_tab' => 'deployments',
                'issue_type' => 'deployment_in_progress',
            ]);
        }

        if ($missingKey !== null) {
            return array_replace($row, [
                'state' => 'missing_value',
                'status' => 'missing',
                'severity' => 'critical',
                'message' => $serviceLabel . ' is missing ' . self::releaseStatusMissingValueLabel($missingKey) . '.',
                'next_action' => self::releaseStatusMissingNextAction($missingKey),
                'target_tab' => self::releaseStatusMissingTargetTab($missingKey),
                'missing_key' => $missingKey,
            ]);
        }

        if (($channel['_uses_production_services'] ?? false) === true) {
            return array_replace($row, [
                'status' => self::PRODUCTION_SERVICE_POLICY,
                'service_policy' => self::PRODUCTION_SERVICE_POLICY,
                'service_channel_slug' => (string)($channel['_service_channel_slug'] ?? ''),
                'message' => $serviceLabel . ' uses the production service for this channel.',
            ]);
        }

        $isDefaultChannel = (bool)($channel['default_channel'] ?? false) || (string)($channel['slug'] ?? '') === 'stable';
        if (!$isDefaultChannel && is_array($target) && trim((string)($target['coolify_service_uuid'] ?? '')) === '') {
            return array_replace($row, [
                'state' => 'stale_unknown',
                'status' => 'missing_coolify_service',
                'severity' => 'warning',
                'message' => $serviceLabel . ' target is missing its Coolify service UUID.',
                'next_action' => 'Open Integrations and connect or create the Coolify service.',
                'target_tab' => 'integrations',
                'issue_type' => 'stale_unknown',
                'missing_key' => $app . '_coolify_service_uuid',
            ]);
        }

        return $row;
    }

    private function releaseStatusDataServiceRow(string $kind, array $channel, ?array $serviceSet): array
    {
        $serviceLabel = self::releaseStatusServiceLabel($kind);
        $isDefaultChannel = (bool)($channel['default_channel'] ?? false) || (string)($channel['slug'] ?? '') === 'stable';
        $mode = (string)($serviceSet['mode'] ?? '');
        $dataPolicy = $this->serviceSetDataPolicy($serviceSet);
        $usesSharedProduction = $dataPolicy === self::PRODUCTION_DATA_POLICY;
        $stack = is_array($serviceSet['stack'] ?? null) ? $serviceSet['stack'] : [];
        $dataServices = is_array($serviceSet['data_services'] ?? null) ? $serviceSet['data_services'] : [];
        $service = is_array($stack[$kind] ?? null) ? $stack[$kind] : (is_array($dataServices[$kind] ?? null) ? $dataServices[$kind] : null);
        $row = [
            'service_key' => $kind,
            'label' => $serviceLabel,
            'status' => $usesSharedProduction ? 'production_shared' : 'ready',
            'data_policy' => $dataPolicy,
            'data_service_mode' => $dataPolicy,
            'state' => 'ready',
            'severity' => 'ok',
            'message' => $usesSharedProduction
                ? $serviceLabel . ' uses the production-shared service and is not replaced by channel sync.'
                : $serviceLabel . ' release service is ready.',
            'next_action' => '',
            'target_tab' => 'data-services',
            'service_set_id' => isset($serviceSet['id']) ? (int)$serviceSet['id'] : null,
            'coolify_target_id' => $service['id'] ?? null,
            'resource_uuid' => $service['resource_uuid'] ?? null,
            'resource_name' => $service['resource_name'] ?? $service['label'] ?? null,
            'issue_type' => null,
        ];

        if ($usesSharedProduction) {
            return $row;
        }

        if ($service === null) {
            $critical = $mode === 'isolated_stack';
            return array_replace($row, [
                'status' => 'missing',
                'state' => 'missing_value',
                'severity' => $critical ? 'critical' : 'warning',
                'message' => $serviceLabel . ' is not assigned to this service set.',
                'next_action' => $critical
                    ? 'Add the missing isolated data service before deploying or promoting this bundle.'
                    : 'Review the service set and attach the data service if this channel needs isolated data.',
                'issue_type' => 'missing_value',
                'missing_key' => $kind . '_service',
            ]);
        }

        $deploymentStatus = strtolower((string)($service['deployment_status'] ?? ''));
        $availabilityState = strtolower((string)($service['availability_state'] ?? ''));
        $replication = is_array($service['replication'] ?? null) ? $service['replication'] : [];
        $replicationLastStatus = is_array($replication['last_status'] ?? null) ? $replication['last_status'] : [];
        $replicationStatus = strtolower((string)($replicationLastStatus['status'] ?? $replication['status'] ?? ''));
        $blockers = array_values(array_filter(
            is_array($replicationLastStatus['blockers'] ?? null) ? $replicationLastStatus['blockers'] : []
        ));

        $row['status'] = $deploymentStatus ?: ($availabilityState ?: ($replicationStatus ?: 'ready'));

        if (
            in_array($deploymentStatus, ['failed', 'reconcile_failed', 'restart_failed', 'provision_blocked'], true)
            || in_array($availabilityState, ['degraded', 'failover_blocked', 'destructive_action_required'], true)
        ) {
            return array_replace($row, [
                'state' => 'service_unhealthy',
                'severity' => 'critical',
                'message' => $serviceLabel . ' Coolify target is unhealthy.',
                'next_action' => 'Open Bundles or Integrations and inspect the Coolify target before promotion.',
                'issue_type' => 'service_unhealthy',
            ]);
        }

        if (in_array($deploymentStatus, ['created', 'deploying', 'restarting', 'waiting_for_coolify', 'provisioning'], true)) {
            return array_replace($row, [
                'state' => 'deployment_in_progress',
                'severity' => 'warning',
                'message' => $serviceLabel . ' service provisioning is still in progress.',
                'next_action' => 'Wait for Coolify provisioning to finish, then refresh Release Manager.',
                'issue_type' => 'deployment_in_progress',
            ]);
        }

        if ($replicationStatus !== '' && !in_array($replicationStatus, ['ok', 'ready', 'protected', 'healthy'], true)) {
            return array_replace($row, [
                'state' => 'service_unhealthy',
                'severity' => $blockers === [] ? 'warning' : 'critical',
                'message' => $blockers[0] ?? ($serviceLabel . ' replication is not healthy.'),
                'next_action' => 'Check replication status before promoting this release bundle.',
                'issue_type' => 'service_unhealthy',
            ]);
        }

        return $row;
    }

    private function releaseStatusTargetsByChannelApp(array $targets): array
    {
        $indexed = [];
        foreach ($targets as $target) {
            if (!is_array($target)) {
                continue;
            }
            $channelId = (int)($target['channel_id'] ?? 0);
            $app = (string)($target['app'] ?? '');
            if ($channelId > 0 && in_array($app, self::APPS, true)) {
                $indexed[$channelId . ':' . $app] = $target;
            }
        }
        return $indexed;
    }

    private function releaseStatusLatestDeploymentsByChannelApp(array $deployments): array
    {
        $indexed = [];
        foreach ($deployments as $deployment) {
            $channelId = (int)($deployment['channel_id'] ?? 0);
            $app = (string)($deployment['app'] ?? '');
            $key = $channelId . ':' . $app;
            if ($channelId > 0 && in_array($app, self::APPS, true) && !isset($indexed[$key])) {
                $indexed[$key] = $deployment;
            }
        }
        return $indexed;
    }

    private function releaseStatusServiceSetsByChannel(array $serviceSets): array
    {
        $indexed = [];
        foreach ($serviceSets as $set) {
            if (!is_array($set)) {
                continue;
            }
            $channelId = (int)($set['channel_id'] ?? 0);
            if ($channelId > 0) {
                $indexed[$channelId][] = $set;
            }
        }
        return $indexed;
    }

    private function releaseStatusBundlesByChannel(array $bundles): array
    {
        $indexed = [];
        foreach ($bundles as $bundle) {
            if (!is_array($bundle)) {
                continue;
            }
            $channelId = (int)($bundle['channel_id'] ?? 0);
            if ($channelId > 0) {
                $indexed[$channelId][] = $bundle;
            }
        }
        return $indexed;
    }

    private function releaseStatusChannelAvailability(array $channel): array
    {
        if (($channel['_uses_production_services'] ?? false) !== true && is_array($channel['availability'] ?? null)) {
            $availability = $channel['availability'];
            $missing = self::releaseStatusReadinessMissingValues(
                is_array($availability['missing'] ?? null) ? $availability['missing'] : []
            );
            $status = (string)($availability['status'] ?? '');
            if ($status === '' || ($missing === [] && in_array($status, ['unconfigured', 'missing_target'], true))) {
                $status = $missing === [] ? 'ready' : 'unconfigured';
            }
            return [
                'configured' => $missing === []
                    ? true
                    : (($availability['configured'] ?? null) === null ? false : (bool)$availability['configured']),
                'missing' => $missing,
                'status' => $status,
                'bundle_id' => $availability['bundle_id'] ?? null,
                'frontend_base_url' => $availability['frontend_base_url'] ?? $channel['frontend_base_url'] ?? null,
                'api_base_url' => $availability['api_base_url'] ?? $channel['api_base_url'] ?? null,
            ];
        }

        $isDefault = (bool)($channel['default_channel'] ?? false) || (string)($channel['slug'] ?? '') === 'stable';
        if ($isDefault) {
            return [
                'configured' => true,
                'missing' => [],
                'status' => 'ready',
                'frontend_base_url' => $channel['frontend_base_url'] ?? null,
                'api_base_url' => $channel['api_base_url'] ?? null,
            ];
        }

        $versions = is_array($channel['versions'] ?? null) ? $channel['versions'] : [];
        $missing = [];
        if (empty($versions['frontend'])) {
            $missing[] = 'frontend_version';
        } elseif (empty($channel['frontend_base_url'])) {
            $missing[] = 'frontend_base_url';
        }
        if (empty($versions['api'])) {
            $missing[] = 'api_version';
        } elseif (empty($channel['api_base_url'])) {
            $missing[] = 'api_base_url';
        }

        return [
            'configured' => $missing === [],
            'missing' => $missing,
            'status' => $missing === [] ? 'ready' : 'unconfigured',
            'bundle_id' => $versions['bundle_id'] ?? null,
            'frontend_base_url' => $channel['frontend_base_url'] ?? null,
            'api_base_url' => $channel['api_base_url'] ?? null,
        ];
    }

    private static function releaseStatusReadinessMissingValues(array $missing): array
    {
        $normalized = [];
        foreach ($missing as $key) {
            $value = trim((string)$key);
            if ($value === '' || $value === 'release_bundle') {
                continue;
            }
            $normalized[] = $value;
        }

        return array_values(array_unique($normalized));
    }

    private static function releaseStatusIssue(array $issue): array
    {
        $normalized = [
            'severity' => (string)($issue['severity'] ?? 'warning'),
            'type' => (string)($issue['type'] ?? 'stale_unknown'),
            'channel_id' => isset($issue['channel_id']) ? (int)$issue['channel_id'] : null,
            'channel_slug' => (string)($issue['channel_slug'] ?? ''),
            'service_key' => $issue['service_key'] ?? null,
            'label' => (string)($issue['label'] ?? ''),
            'message' => (string)($issue['message'] ?? ''),
            'next_action' => (string)($issue['next_action'] ?? ''),
            'target_tab' => (string)($issue['target_tab'] ?? 'overview'),
            'target_id' => $issue['target_id'] ?? null,
            'deployment_id' => $issue['deployment_id'] ?? null,
            'coolify_target_id' => $issue['coolify_target_id'] ?? null,
            'service_set_id' => $issue['service_set_id'] ?? null,
            'missing_key' => $issue['missing_key'] ?? null,
            'impact' => (string)($issue['impact'] ?? ''),
            'resolution_state' => (string)($issue['resolution_state'] ?? 'open'),
            'actions' => is_array($issue['actions'] ?? null) ? $issue['actions'] : [],
        ];
        $normalized['key'] = (string)($issue['key'] ?? self::releaseStatusIssueKey($normalized));
        if ($normalized['impact'] === '') {
            $normalized['impact'] = self::releaseStatusIssueImpact($normalized);
        }
        return $normalized;
    }

    private function releaseStatusIssueWithActions(array $issue, array $channel, array $services, array $channelBundles): array
    {
        $issue = self::releaseStatusIssue($issue);
        $service = null;
        foreach ($services as $candidate) {
            if ((string)($candidate['service_key'] ?? '') === (string)($issue['service_key'] ?? '')) {
                $service = $candidate;
                break;
            }
        }

        if (($issue['target_id'] ?? null) === null && isset($service['target_id'])) {
            $issue['target_id'] = $service['target_id'];
        }
        if (($issue['deployment_id'] ?? null) === null && isset($service['deployment_id'])) {
            $issue['deployment_id'] = $service['deployment_id'];
        }
        if (($issue['coolify_target_id'] ?? null) === null && isset($service['coolify_target_id'])) {
            $issue['coolify_target_id'] = $service['coolify_target_id'];
        }
        if (($issue['service_set_id'] ?? null) === null && isset($service['service_set_id'])) {
            $issue['service_set_id'] = $service['service_set_id'];
        }
        $issue['key'] = self::releaseStatusIssueKey($issue);
        $issue['impact'] = $issue['impact'] !== '' ? $issue['impact'] : self::releaseStatusIssueImpact($issue);
        $issue['resolution_state'] = self::releaseStatusResolutionState($issue);
        $issue['actions'] = $this->releaseStatusIssueActions($issue, $channel, $service, $channelBundles);
        return $issue;
    }

    private function releaseStatusIssueActions(array $issue, array $channel, ?array $service, array $channelBundles): array
    {
        $type = (string)($issue['type'] ?? '');
        $missingKey = (string)($issue['missing_key'] ?? '');
        $serviceKey = (string)($issue['service_key'] ?? '');
        $actions = [];

        if ($type === 'failed_deployment') {
            $actions[] = self::releaseStatusAction(
                'retry_deployment',
                'Retry deployment',
                'mutation',
                true,
                empty($issue['deployment_id']),
                empty($issue['deployment_id']) ? 'The failed deployment record is missing.' : ''
            );
            if (self::releaseStatusIssueNeedsApplicationTarget($issue)) {
                $actions[] = self::releaseStatusAction(
                    'prepare_application_target',
                    'Prepare application target',
                    'mutation',
                    true,
                    empty($issue['target_id']),
                    empty($issue['target_id']) ? 'The deployment target is missing.' : ''
                );
            }
        }

        if ($type === 'missing_value' && in_array($missingKey, ['frontend_version', 'api_version'], true)) {
            $actions[] = self::releaseStatusAction(
                'deploy_missing_version',
                'Deploy missing version',
                'mutation',
                true,
                empty($issue['target_id']),
                empty($issue['target_id']) ? 'Select or create a deployment target first.' : ''
            );
        }

        if ($type === 'missing_value' && $missingKey === 'release_bundle') {
            $eligibleBundles = self::releaseStatusEligibleBundles($channelBundles);
            $actions[] = self::releaseStatusAction(
                'set_bundle',
                count($eligibleBundles) === 1 ? 'Set available bundle' : 'Choose release bundle',
                'mutation',
                true,
                count($eligibleBundles) !== 1,
                $eligibleBundles === [] ? 'No deployed bundle is available for this channel.' : '',
                ['bundle_choices' => $eligibleBundles]
            );
        }

        if ($type === 'missing_value' && in_array($missingKey, ['database_service', 'redis_service', 'minio_service'], true)) {
            $actions[] = self::releaseStatusAction(
                'complete_data_services',
                'Create missing data services',
                'mutation',
                true,
                empty($issue['service_set_id']),
                empty($issue['service_set_id']) ? 'The isolated service set is missing.' : ''
            );
        }

        if ($type === 'service_unhealthy' && in_array($serviceKey, self::STACK_DATA_KINDS, true)) {
            foreach ([
                'reconcile_coolify_target' => 'Reconcile target',
                'redeploy_coolify_target' => 'Redeploy target',
                'restart_coolify_target' => 'Restart target',
            ] as $id => $label) {
                $actions[] = self::releaseStatusAction(
                    $id,
                    $label,
                    'mutation',
                    true,
                    empty($issue['coolify_target_id']),
                    empty($issue['coolify_target_id']) ? 'The Coolify target is missing.' : ''
                );
            }
        }

        if ($type === 'deployment_in_progress') {
            $actions[] = self::releaseStatusAction('refresh_status', 'Refresh status', 'refresh', false, false);
        }

        return $actions;
    }

    private static function releaseStatusAction(
        string $id,
        string $label,
        string $kind,
        bool $requiresConfirmation,
        bool $requiresInput,
        string $disabledReason = '',
        array $extra = []
    ): array {
        return array_replace([
            'id' => $id,
            'label' => $label,
            'kind' => $kind,
            'requires_confirmation' => $requiresConfirmation,
            'requires_input' => $requiresInput,
            'disabled_reason' => $disabledReason,
            'permission' => 'superuser_release_manager_deploy',
        ], $extra);
    }

    private static function releaseStatusIssueKey(array $issue): string
    {
        return implode(':', [
            self::safeIdentifier((string)($issue['type'] ?? 'unknown'), 32) ?: 'unknown',
            self::safeIdentifier((string)($issue['channel_id'] ?? $issue['channel_slug'] ?? ''), 64),
            self::safeIdentifier((string)($issue['service_key'] ?? ''), 32),
            self::safeIdentifier((string)($issue['missing_key'] ?? ''), 64),
            self::safeIdentifier((string)($issue['deployment_id'] ?? ''), 64),
            self::safeIdentifier((string)($issue['coolify_target_id'] ?? ''), 64),
        ]);
    }

    private static function releaseStatusIssueImpact(array $issue): string
    {
        return match ((string)($issue['type'] ?? '')) {
            'failed_deployment' => 'This channel cannot be promoted until the failed deployment is replaced by a successful one.',
            'missing_value' => 'This channel is incomplete and cannot receive traffic safely.',
            'service_unhealthy' => 'This channel has an unhealthy runtime service and should not be promoted.',
            'deployment_in_progress' => 'Promotion should wait until the deployment or provisioning job finishes.',
            default => 'Review this release issue before publishing or promoting the channel.',
        };
    }

    private static function releaseStatusResolutionState(array $issue): string
    {
        if ((string)($issue['severity'] ?? '') === 'critical') {
            return 'blocked';
        }
        if ((string)($issue['severity'] ?? '') === 'warning') {
            return 'action_available';
        }
        return 'open';
    }

    private static function releaseStatusIssueNeedsApplicationTarget(array $issue): bool
    {
        $text = strtolower(trim((string)($issue['message'] ?? '') . ' ' . (string)($issue['next_action'] ?? '')));
        return str_contains($text, 'stripprefix')
            || str_contains($text, 'path-routed')
            || str_contains($text, 'service creation')
            || str_contains($text, 'coolify service');
    }

    private static function releaseStatusEligibleBundles(array $bundles): array
    {
        $eligible = [];
        foreach ($bundles as $bundle) {
            $status = strtolower((string)($bundle['status'] ?? ''));
            if (!in_array($status, ['deployed', 'promoted', 'active'], true)) {
                continue;
            }
            $eligible[] = [
                'id' => (int)($bundle['id'] ?? 0),
                'label' => (string)($bundle['version_label'] ?? ('Bundle #' . (int)($bundle['id'] ?? 0))),
                'status' => (string)($bundle['status'] ?? ''),
            ];
        }
        return $eligible;
    }

    private static function releaseStatusSeverityRank(string $severity): int
    {
        return match ($severity) {
            'critical' => 2,
            'warning' => 1,
            default => 0,
        };
    }

    private static function releaseStatusMaxSeverity(string $a, string $b): string
    {
        return self::releaseStatusSeverityRank($b) > self::releaseStatusSeverityRank($a) ? $b : $a;
    }

    private static function releaseStatusChannelMessage(string $readiness, int $issueCount): string
    {
        if ($readiness === 'ready') {
            return 'All release services are ready.';
        }
        if ($readiness === 'blocked') {
            return $issueCount . ' blocker' . ($issueCount === 1 ? '' : 's') . ' need attention before promotion.';
        }
        return $issueCount . ' warning' . ($issueCount === 1 ? '' : 's') . ' should be reviewed.';
    }

    private static function releaseStatusServiceLabel(string $service): string
    {
        return match ($service) {
            'frontend' => 'Frontend',
            'api' => 'API',
            'database' => 'Database',
            'redis' => 'Redis',
            'minio' => 'MinIO',
            default => ucfirst(str_replace('_', ' ', $service)),
        };
    }

    private static function releaseStatusMissingValueLabel(string $key): string
    {
        return match ($key) {
            'release_bundle' => 'release bundle',
            'frontend_version' => 'frontend version',
            'frontend_base_url' => 'frontend URL',
            'api_version' => 'API version',
            'api_base_url' => 'API URL',
            'database_service' => 'database service',
            'redis_service' => 'Redis service',
            'minio_service' => 'MinIO service',
            default => str_replace('_', ' ', $key),
        };
    }

    private static function releaseStatusMissingServiceKey(string $key): ?string
    {
        return match ($key) {
            'frontend_version', 'frontend_base_url', 'frontend_coolify_service_uuid' => 'frontend',
            'api_version', 'api_base_url', 'api_coolify_service_uuid' => 'api',
            'database_service' => 'database',
            'redis_service' => 'redis',
            'minio_service' => 'minio',
            default => null,
        };
    }

    private static function releaseStatusMissingTargetTab(string $key): string
    {
        return match ($key) {
            'release_bundle', 'database_service', 'redis_service', 'minio_service' => 'bundles',
            'frontend_version', 'api_version' => 'deployments',
            'frontend_base_url', 'api_base_url', 'frontend_coolify_service_uuid', 'api_coolify_service_uuid' => 'integrations',
            default => 'overview',
        };
    }

    private static function releaseStatusMissingNextAction(string $key): string
    {
        return match ($key) {
            'release_bundle' => 'Create or deploy a release bundle, then attach it to the channel.',
            'frontend_version', 'api_version' => 'Deploy the missing application version for this channel.',
            'frontend_base_url', 'api_base_url' => 'Set the public release URL from the deployment target or channel configuration.',
            'database_service', 'redis_service', 'minio_service' => 'Add the missing data service to the isolated service set.',
            default => 'Open Release Manager details and complete the missing value.',
        };
    }

    public function releaseConfig(): array
    {
        $this->ensureSchema();
        $storedToken = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_token', ''));
        $storedWebhookSecret = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_webhook_secret', ''));

        return [
            'github_token_configured' => $this->hasGithubApiToken(),
            'github_token_env_configured' => $this->githubEnvToken() !== '',
            'github_token_module_configured' => $storedToken !== '',
            'github_token_variable' => 'ReleaseManager.github_token',
            'github_token_env_variable' => 'RELEASE_MANAGER_GITHUB_TOKEN',
            'github_api_url' => $this->githubApiBaseUrl(),
            'github_api_url_variable' => 'ReleaseManager.github_api_url',
            'github_webhook_secret_configured' => $storedWebhookSecret !== '',
            'github_webhook_secret_variable' => 'ReleaseManager.github_webhook_secret',
        ];
    }

    public function updateReleaseConfig(array $input, ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        $updated = [];

        if (array_key_exists('github_api_url', $input)) {
            $apiUrl = rtrim(trim((string)$input['github_api_url']), '/');
            if ($apiUrl === '') {
                $apiUrl = 'https://api.github.com';
            }
            if (preg_match('#^https?://#i', $apiUrl) !== 1) {
                throw new RuntimeException('GitHub API URL must start with http:// or https://.');
            }
            $this->upsertModuleConfigValue('ReleaseManager', 'github_api_url', $apiUrl, 'string');
            $updated[] = 'github_api_url';
        }

        if (array_key_exists('github_token', $input)) {
            $token = trim((string)$input['github_token']);
            if ($token !== '' && $token !== '[redacted]' && !str_starts_with($token, 'twsec:v1:') && class_exists(replication_secret_box::class)) {
                $token = replication_secret_box::encrypt($token);
            }
            if ($token !== '' && $token !== '[redacted]') {
                $this->upsertModuleConfigValue('ReleaseManager', 'github_token', $token, 'string');
                $updated[] = 'github_token';
            }
        }

        if ($this->toBool($input['clear_github_token'] ?? false)) {
            $this->upsertModuleConfigValue('ReleaseManager', 'github_token', '', 'string');
            $updated[] = 'github_token';
        }

        if (array_key_exists('github_webhook_secret', $input)) {
            $secret = trim((string)$input['github_webhook_secret']);
            if ($secret !== '' && $secret !== '[redacted]') {
                $this->upsertModuleConfigValue('ReleaseManager', 'github_webhook_secret', $secret, 'string');
                $updated[] = 'github_webhook_secret';
            }
        }

        $this->audit(null, null, 'release_config_updated', $actorUserId, 'info', [
            'updated' => array_values(array_unique($updated)),
        ]);

        return $this->releaseConfig();
    }

    public function listGithubRepositories(array $filters = []): array
    {
        $this->ensureSchema();
        if (!$this->hasGithubApiToken()) {
            return $this->githubTokenMissingResponse();
        }

        $query = strtolower(trim((string)($filters['query'] ?? $filters['search'] ?? '')));
        $repositories = [];
        for ($page = 1; $page <= 5; $page++) {
            $rows = $this->githubRequest('GET', '/user/repos', [
                'visibility' => 'all',
                'affiliation' => 'owner,collaborator,organization_member',
                'sort' => 'updated',
                'direction' => 'desc',
                'per_page' => 100,
                'page' => $page,
            ]);
            if (!is_array($rows)) {
                break;
            }

            foreach ($rows as $row) {
                if (!is_array($row)) {
                    continue;
                }
                $repository = $this->publicGithubRepository($row);
                if ($query !== '') {
                    $haystack = strtolower(($repository['full_name'] ?? '') . ' ' . ($repository['description'] ?? ''));
                    if (!str_contains($haystack, $query)) {
                        continue;
                    }
                }
                $repositories[$repository['full_name']] = $repository;
            }

            if (count($rows) < 100) {
                break;
            }
        }

        return [
            'token_configured' => true,
            'github_api_url' => $this->githubApiBaseUrl(),
            'repositories' => array_values($repositories),
        ];
    }

    public function listGithubBranches(array $input): array
    {
        $this->ensureSchema();
        $repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? ''));
        if ($repository === '') {
            throw new RuntimeException('GitHub repository must use owner/repo format.');
        }
        if (!$this->hasGithubApiToken()) {
            return $this->githubTokenMissingResponse($repository);
        }

        $branches = [];
        for ($page = 1; $page <= 5; $page++) {
            $rows = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository) . '/branches', [
                'per_page' => 100,
                'page' => $page,
            ]);
            if (!is_array($rows)) {
                break;
            }

            foreach ($rows as $row) {
                if (is_array($row)) {
                    $branch = $this->publicGithubBranch($row);
                    $branches[$branch['name']] = $branch;
                }
            }

            if (count($rows) < 100) {
                break;
            }
        }

        return [
            'token_configured' => true,
            'repository' => $repository,
            'branches' => array_values($branches),
        ];
    }

    public function listGithubCommits(array $input): array
    {
        $this->ensureSchema();
        $repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? ''));
        if ($repository === '') {
            throw new RuntimeException('GitHub repository must use owner/repo format.');
        }
        if (!$this->hasGithubApiToken()) {
            return $this->githubTokenMissingResponse($repository, (string)($input['branch'] ?? ''));
        }

        $branch = trim((string)($input['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH;
        $commit = trim((string)($input['commit_sha'] ?? $input['commit'] ?? ''));
        $query = strtolower(trim((string)($input['query'] ?? $input['search'] ?? '')));

        if ($commit !== '' && !in_array(strtolower($commit), ['latest', 'head'], true)) {
            $row = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository) . '/commits/' . rawurlencode($commit));
            $publicCommit = is_array($row) ? $this->publicGithubCommit($row) : [];
            return [
                'token_configured' => true,
                'repository' => $repository,
                'branch' => $branch,
                'commits' => $publicCommit !== [] ? [$publicCommit] : [],
                'latest' => $publicCommit !== [] ? $publicCommit : null,
            ];
        }

        $rows = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository) . '/commits', [
            'sha' => $branch,
            'per_page' => 25,
        ]);
        $commits = [];
        foreach (is_array($rows) ? $rows : [] as $row) {
            if (!is_array($row)) {
                continue;
            }
            $publicCommit = $this->publicGithubCommit($row);
            if ($query !== '') {
                $haystack = strtolower(($publicCommit['sha'] ?? '') . ' ' . ($publicCommit['message'] ?? '') . ' ' . ($publicCommit['author_name'] ?? ''));
                if (!str_contains($haystack, $query)) {
                    continue;
                }
            }
            $commits[] = $publicCommit;
        }

        return [
            'token_configured' => true,
            'repository' => $repository,
            'branch' => $branch,
            'commits' => $commits,
            'latest' => $commits[0] ?? null,
        ];
    }

    public function testGithubRepositoryAccess(array $input): array
    {
        $this->ensureSchema();
        return $this->githubRepositoryAccess($input);
    }

    public function listChannels(): array
    {
        if (!release_manager_schema_bootstrap::tablesExist()) {
            return [];
        }

        $channels = $this->selectRows(
            "SELECT * FROM release_channels WHERE deleted_at IS NULL ORDER BY default_channel DESC, slug"
        );

        return array_map(function (array $channel): array {
            $public = $this->publicChannel($channel);
            $serviceChannel = $this->runtimeServiceChannelFor($channel);
            $public['service_channel'] = $this->publicChannel($serviceChannel);
            $public['service_policy'] = $this->channelUsesProductionServices($channel)
                ? self::PRODUCTION_SERVICE_POLICY
                : 'channel';
            $public['versions'] = $this->currentVersionsForChannel((int)$serviceChannel['id']);
            $public['current_deployments'] = $this->channelCurrentDeployments((int)$serviceChannel['id']);
            $public['branch_status'] = $this->channelBranchStatus($channel);
            $public['data_services'] = $this->channelDataServicesSummary($channel);
            $public['replication_policy'] = $public['data_services']['policy'] ?? $this->replicationPolicyForMode('production_shared');
            return $public;
        }, $channels);
    }

    public function createChannel(array $input, ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        $normalized = $this->normalizeChannelInput($input, true);

        $this->execute(
            "INSERT INTO release_channels (
                slug, name, description, enabled, default_channel, rollout_percent,
                frontend_base_url, api_base_url, replay_enabled, capture_level, retention_days, metadata_json
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
            'sssiidssisis',
            [
                $normalized['slug'],
                $normalized['name'],
                $normalized['description'],
                $normalized['enabled'],
                $normalized['default_channel'],
                $normalized['rollout_percent'],
                $normalized['frontend_base_url'],
                $normalized['api_base_url'],
                $normalized['replay_enabled'],
                $normalized['capture_level'],
                $normalized['retention_days'],
                self::jsonEncode($normalized['metadata']),
            ]
        );

        $id = $this->insertId();
        if ($normalized['default_channel'] === 1) {
            $this->clearOtherDefaultChannels($id);
        }
        $this->audit($id, null, 'channel_created', $actorUserId, 'info', $normalized);

        return $this->publicChannel($this->getChannel($id));
    }

    public function updateChannel(int $id, array $input, ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        $channel = $this->getChannel($id);
        $normalized = $this->normalizeChannelInput(array_replace($channel, $input), false);

        $this->execute(
            "UPDATE release_channels
             SET slug = ?, name = ?, description = ?, enabled = ?, default_channel = ?, rollout_percent = ?,
                 frontend_base_url = ?, api_base_url = ?, replay_enabled = ?, capture_level = ?,
                 retention_days = ?, metadata_json = ?
             WHERE id = ?",
            'sssiidssisisi',
            [
                $normalized['slug'],
                $normalized['name'],
                $normalized['description'],
                $normalized['enabled'],
                $normalized['default_channel'],
                $normalized['rollout_percent'],
                $normalized['frontend_base_url'],
                $normalized['api_base_url'],
                $normalized['replay_enabled'],
                $normalized['capture_level'],
                $normalized['retention_days'],
                self::jsonEncode($normalized['metadata']),
                $id,
            ]
        );

        if ($normalized['default_channel'] === 1) {
            $this->clearOtherDefaultChannels($id);
        }
        $this->audit($id, null, 'channel_updated', $actorUserId, 'info', $normalized);

        return $this->publicChannel($this->getChannel($id));
    }

    public function listAssignments(): array
    {
        if (!release_manager_schema_bootstrap::tablesExist()) {
            return [];
        }

        return array_map(
            fn(array $row): array => $this->publicAssignment($row),
            $this->selectRows(
                "SELECT a.*, c.slug AS channel_slug, c.name AS channel_name
                 FROM release_assignments a
                 INNER JOIN release_channels c ON c.id = a.channel_id
                 WHERE a.deleted_at IS NULL AND (a.expires_at IS NULL OR a.expires_at > NOW())
                 ORDER BY a.created_at DESC
                 LIMIT 250"
            )
        );
    }

    public function searchAssignmentSubjects(array $input): array
    {
        $query = self::normalizeAssignmentSubjectSearch($input['search'] ?? $input['query'] ?? '');
        if ($query === '') {
            return [];
        }

        $limit = self::normalizeAssignmentSubjectLimit($input['limit'] ?? 5);
        $subjects = array_merge(
            $this->searchAssignmentUsers($query, $limit),
            $this->searchAssignmentSubusers($query, $limit),
            $this->searchAssignmentCustomers($query, $limit)
        );

        $seen = [];
        $normalized = [];
        foreach ($subjects as $subject) {
            $item = self::publicAssignmentSubjectSuggestion($subject);
            if ($item === null) {
                continue;
            }
            $key = $item['subject_type'] . ':' . $item['subject_id'];
            if (isset($seen[$key])) {
                continue;
            }
            $seen[$key] = true;
            $normalized[] = $item;
        }

        return $normalized;
    }

    public function createAssignment(array $input, ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        $subjectType = strtolower(trim((string)($input['subject_type'] ?? '')));
        if (!in_array($subjectType, self::SUBJECT_TYPES, true)) {
            throw new RuntimeException('Invalid release assignment subject type.');
        }

        $subjectId = trim((string)($input['subject_id'] ?? ''));
        if ($subjectId === '') {
            throw new RuntimeException('Release assignment subject_id is required.');
        }

        $channel = $this->channelFromInput($input);
        $reason = trim((string)($input['reason'] ?? ''));
        $expiresAt = $this->normalizeDateTime($input['expires_at'] ?? null);

        $this->execute(
            "INSERT INTO release_assignments (subject_type, subject_id, channel_id, reason, expires_at, actor_user_id)
             VALUES (?, ?, ?, ?, ?, ?)",
            'ssissi',
            [$subjectType, $subjectId, (int)$channel['id'], $reason !== '' ? $reason : null, $expiresAt, $actorUserId]
        );

        $id = $this->insertId();
        $this->clearAssignmentCache($subjectType, $subjectId);
        $this->audit((int)$channel['id'], null, 'assignment_created', $actorUserId, 'info', [
            'subject_type' => $subjectType,
            'subject_id' => $subjectId,
            'channel_slug' => $channel['slug'],
        ]);

        $row = $this->selectOne(
            "SELECT a.*, c.slug AS channel_slug, c.name AS channel_name
             FROM release_assignments a INNER JOIN release_channels c ON c.id = a.channel_id
             WHERE a.id = ?",
            'i',
            [$id]
        );
        return $this->publicAssignment($row ?? []);
    }

    public function deleteAssignment(int $id, ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        $assignment = $this->selectOne('SELECT * FROM release_assignments WHERE id = ? AND deleted_at IS NULL', 'i', [$id]);
        if ($assignment === null) {
            throw new RuntimeException('Release assignment not found.');
        }

        $this->execute('UPDATE release_assignments SET deleted_at = NOW() WHERE id = ?', 'i', [$id]);
        $this->clearAssignmentCache((string)$assignment['subject_type'], (string)$assignment['subject_id']);
        $this->audit((int)$assignment['channel_id'], null, 'assignment_deleted', $actorUserId, 'info', [
            'assignment_id' => $id,
        ]);

        return ['deleted' => true, 'id' => $id];
    }

    public function listDeploymentTargets(): array
    {
        if (!release_manager_schema_bootstrap::tablesExist()) {
            return [];
        }

        return array_map(
            fn(array $row): array => $this->publicDeploymentTarget($row),
            $this->selectRows(
                "SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, i.label AS coolify_instance_label
                 FROM release_deployment_targets t
                 INNER JOIN release_channels c ON c.id = t.channel_id
                 LEFT JOIN coolify_instances i ON i.id = t.coolify_instance_id
                 WHERE t.deleted_at IS NULL
                 ORDER BY c.slug, FIELD(t.app, 'frontend', 'api'), t.repository"
            )
        );
    }

    public function upsertDeploymentTarget(array $input, ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        $id = (int)($input['id'] ?? 0);
        $channel = $this->channelFromInput($input);
        $app = $this->normalizeApp((string)($input['app'] ?? ''));
        $repository = trim((string)($input['repository'] ?? ''));
        $branch = trim((string)($input['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH;
        if ($repository === '') {
            throw new RuntimeException('Repository is required for release deployment targets.');
        }
        $normalizedRepository = self::normalizeGithubRepositoryName($repository);
        if ($normalizedRepository !== '') {
            $repository = $normalizedRepository;
        }

        $githubAccess = $this->githubRepositoryAccess([
            'repository' => $repository,
            'branch' => $branch,
            'commit_mode' => 'latest',
        ]);
        if (($githubAccess['token_configured'] ?? false) && !($githubAccess['ok'] ?? false)) {
            throw new RuntimeException('GitHub repository access test failed: ' . (string)($githubAccess['message'] ?? 'Repository is not accessible.'));
        }

        $deployContext = is_array($input['deploy_context'] ?? null) ? $input['deploy_context'] : [];
        foreach (['coolify_auto_create', 'coolify_enable_ssl', 'coolify_deploy_now'] as $key) {
            if (array_key_exists($key, $input)) {
                $deployContext[$key] = $this->toBool($input[$key]);
            }
        }
        foreach (['coolify_domain', 'coolify_public_url', 'coolify_url_name', 'manual_endpoint_host', 'coolify_ports_exposes'] as $key) {
            if (array_key_exists($key, $input)) {
                $deployContext[$key] = trim((string)$input[$key]);
            }
        }
        if (array_key_exists('endpoint_mode', $input)) {
            $mode = strtolower(trim((string)$input['endpoint_mode']));
            $deployContext['endpoint_mode'] = $mode === 'manual' ? 'manual' : 'auto';
        } elseif (!isset($deployContext['endpoint_mode'])) {
            $deployContext['endpoint_mode'] = 'auto';
        }
        if (array_key_exists('manual_endpoint_port', $input)) {
            $port = trim((string)$input['manual_endpoint_port']);
            $deployContext['manual_endpoint_port'] = $port;
        }
        $endpointMode = strtolower(trim((string)($deployContext['endpoint_mode'] ?? 'auto'))) === 'manual' ? 'manual' : 'auto';
        $deployContext['endpoint_mode'] = $endpointMode;
        if ($endpointMode === 'manual') {
            $manualHost = self::normalizeEndpointHost($deployContext['manual_endpoint_host'] ?? '');
            if ($manualHost === '') {
                throw new RuntimeException('Manual endpoint mode requires a public host.');
            }
            $deployContext['manual_endpoint_host'] = $manualHost;
        }
        if (array_key_exists('manual_endpoint_port', $deployContext)) {
            $manualPort = trim((string)$deployContext['manual_endpoint_port']);
            if ($manualPort !== '' && (filter_var($manualPort, FILTER_VALIDATE_INT) === false || (int)$manualPort < 1 || (int)$manualPort > 65535)) {
                throw new RuntimeException('Manual endpoint port must be between 1 and 65535.');
            }
            $deployContext['manual_endpoint_port'] = $manualPort;
        }
        foreach ([
            'coolify_project_uuid',
            'project_uuid',
            'coolify_environment_uuid',
            'environment_uuid',
            'coolify_environment_name',
            'environment_name',
            'coolify_github_app_uuid',
            'github_app_uuid',
            'coolify_git_app_uuid',
            'git_app_uuid',
            'coolify_build_pack',
            'build_pack',
        ] as $key) {
            if (array_key_exists($key, $input)) {
                $deployContext[$key] = trim((string)$input[$key]);
            }
        }

        $payload = [
            'channel_id' => (int)$channel['id'],
            'app' => $app,
            'coolify_instance_id' => $this->nullablePositiveInt($input['coolify_instance_id'] ?? null),
            'coolify_service_uuid' => trim((string)($input['coolify_service_uuid'] ?? '')) ?: null,
            'repository' => $repository,
            'branch' => $branch,
            'auto_deploy' => $this->toBool($input['auto_deploy'] ?? true) ? 1 : 0,
            'health_url' => trim((string)($input['health_url'] ?? '')) ?: null,
            'deploy_context' => $deployContext,
        ];

        if ($id > 0) {
            $this->execute(
                "UPDATE release_deployment_targets
                 SET channel_id = ?, app = ?, coolify_instance_id = ?, coolify_service_uuid = ?,
                     repository = ?, branch = ?, auto_deploy = ?, health_url = ?, deploy_context_json = ?
                 WHERE id = ? AND deleted_at IS NULL",
                'isisssissi',
                [
                    $payload['channel_id'],
                    $payload['app'],
                    $payload['coolify_instance_id'],
                    $payload['coolify_service_uuid'],
                    $payload['repository'],
                    $payload['branch'],
                    $payload['auto_deploy'],
                    $payload['health_url'],
                    self::jsonEncode($payload['deploy_context']),
                    $id,
                ]
            );
            $targetId = $id;
            $action = 'deployment_target_updated';
        } else {
            $this->execute(
                "INSERT INTO release_deployment_targets (
                    channel_id, app, coolify_instance_id, coolify_service_uuid,
                    repository, branch, auto_deploy, health_url, deploy_context_json
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
                'isisssiss',
                [
                    $payload['channel_id'],
                    $payload['app'],
                    $payload['coolify_instance_id'],
                    $payload['coolify_service_uuid'],
                    $payload['repository'],
                    $payload['branch'],
                    $payload['auto_deploy'],
                    $payload['health_url'],
                    self::jsonEncode($payload['deploy_context']),
                ]
            );
            $targetId = $this->insertId();
            $action = 'deployment_target_created';
        }

        $this->audit((int)$channel['id'], null, $action, $actorUserId, 'info', $payload + [
            'github_access' => $githubAccess,
        ]);
        return $this->publicDeploymentTarget($this->getDeploymentTarget($targetId));
    }

    public function deleteDeploymentTarget(int $id, ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        $target = $this->getDeploymentTarget($id);
        $this->execute('UPDATE release_deployment_targets SET deleted_at = NOW() WHERE id = ?', 'i', [$id]);
        $this->audit((int)$target['channel_id'], null, 'deployment_target_deleted', $actorUserId, 'warning', [
            'target_id' => $id,
        ]);
        return ['deleted' => true, 'id' => $id];
    }

    public function cronWorkerStatus(array $input = []): array
    {
        $this->ensureSchema();
        $channel = $this->channelFromInputOrDefault($input);
        $channelId = (int)$channel['id'];
        $apiTarget = $this->deploymentTargetFromInput(['channel_id' => $channelId], $channelId, 'api');
        $target = $this->cronWorkerTargetForChannel($channelId);
        $publicTarget = $target !== null ? $this->publicDeploymentTarget($target) : null;
        $publicApiTarget = $apiTarget !== null ? $this->publicDeploymentTarget($apiTarget) : null;
        $workerRows = (new cron_worker())->listWorkers();
        $workers = $this->cronWorkersForTarget(
            is_array($workerRows['workers'] ?? null) ? $workerRows['workers'] : [],
            $channelId,
            $target
        );
        $summary = $this->cronWorkerSummary($workers);
        $recentDeployments = $this->cronWorkerDeployments($channelId);
        $latestDeployment = $recentDeployments[0] ?? null;
        $providerStatus = $this->cronWorkerProviderStatus($target, $input);
        $health = $this->cronWorkerHealth($apiTarget, $target, $workers, $summary, $latestDeployment);
        $readiness = $this->cronWorkerDeploymentReadiness($apiTarget, $target, $providerStatus);
        $issues = $this->cronWorkerMergeIssues(
            is_array($health['issues'] ?? null) ? $health['issues'] : [],
            is_array($readiness['issues'] ?? null) ? $readiness['issues'] : []
        );
        $channelPayload = [
            'id' => $channelId,
            'slug' => (string)($channel['slug'] ?? ''),
            'name' => (string)($channel['name'] ?? ''),
        ];
        $deploymentPayload = [
            'ok' => true,
            'state' => $health['state'],
            'desired_workers' => self::CRON_WORKER_DESIRED_COUNT,
            'channel' => $channelPayload,
            'api_target' => $publicApiTarget,
            'target' => $publicTarget,
            'latest_deployment' => $latestDeployment,
            'provider' => $providerStatus,
            'action' => $readiness['action'],
            'can_deploy' => $readiness['can_deploy'],
            'issues' => $issues,
        ];

        return [
            'ok' => true,
            'state' => $health['state'],
            'desired_workers' => self::CRON_WORKER_DESIRED_COUNT,
            'channel' => $channelPayload,
            'channels' => $this->cronWorkerChannels(),
            'api_target' => $publicApiTarget,
            'cron_target' => $publicTarget,
            'target' => $publicTarget,
            'workers' => $workers,
            'summary' => $summary + [
                'desired' => self::CRON_WORKER_DESIRED_COUNT,
                'state' => $health['state'],
            ],
            'latest_deployment' => $latestDeployment,
            'recent_deployments' => $recentDeployments,
            'provider' => $providerStatus,
            'issues' => $issues,
            'deployment' => $deploymentPayload,
        ];
    }

    public function deployCronWorker(array $input = [], ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        $dryRun = $this->toBool($input['dry_run'] ?? false);
        $apiTarget = null;
        $channel = null;
        $existingCronTarget = null;

        $targetId = $this->nullablePositiveInt($input['api_target_id'] ?? $input['target_id'] ?? null);
        if ($targetId !== null) {
            $apiTarget = $this->getDeploymentTarget($targetId);
            if ((string)($apiTarget['app'] ?? '') !== 'api') {
                throw new RuntimeException('Cron workers must be deployed from an API deployment target.');
            }
        } else {
            $channel = $this->channelFromInputOrDefault($input);
            $channelId = (int)$channel['id'];
            $existingCronTarget = $this->cronWorkerTargetForChannel($channelId);
            $apiTarget = $this->deploymentTargetFromInput(['channel_id' => $channelId], $channelId, 'api');
            if ($apiTarget === null && $this->cronWorkerTargetCanDeployWithoutApiTarget($existingCronTarget)) {
                $apiTarget = $this->cronWorkerSourceFromCronTarget($existingCronTarget, $channel);
            }
        }

        if ($apiTarget === null) {
            throw new RuntimeException('No API deployment target is configured for this release channel.');
        }

        $commitSha = self::normalizeCommitSha((string)($input['commit_sha'] ?? $input['commit'] ?? ''));
        return $this->deployCronWorkerForApiTarget($apiTarget, $commitSha !== '' ? $commitSha : null, $actorUserId, $dryRun);
    }

    private function cronWorkerSourceFromCronTarget(array $target, ?array $channel = null): array
    {
        return array_replace($target, [
            'app' => self::CRON_WORKER_APP,
            'channel_id' => (int)($target['channel_id'] ?? $channel['id'] ?? 0),
            'channel_slug' => (string)($target['channel_slug'] ?? $channel['slug'] ?? ''),
            'channel_name' => (string)($target['channel_name'] ?? $channel['name'] ?? ''),
        ]);
    }

    private function deployCronWorkerForApiTarget(
        array $apiTarget,
        ?string $commitSha = null,
        ?int $actorUserId = null,
        bool $dryRun = false,
        ?int $parentDeploymentId = null
    ): array {
        $channelId = (int)($apiTarget['channel_id'] ?? 0);
        if ($channelId <= 0) {
            throw new RuntimeException('API deployment target has no release channel.');
        }
        if ((int)($apiTarget['coolify_instance_id'] ?? 0) <= 0) {
            throw new RuntimeException('API deployment target has no Coolify instance for cron worker deployment.');
        }

        $channel = $this->getChannel($channelId);
        $apiTarget = $apiTarget + [
            'channel_slug' => (string)($channel['slug'] ?? ''),
            'channel_name' => (string)($channel['name'] ?? ''),
        ];
        $existing = $this->cronWorkerTargetForChannel($channelId);
        $targetId = $existing !== null ? (int)$existing['id'] : 0;
        $context = $this->cronWorkerDeployContext($apiTarget, $existing, $commitSha, $targetId);
        $repository = self::normalizeGithubRepositoryName((string)($apiTarget['repository'] ?? ''));
        if ($repository === '') {
            $repository = trim((string)($apiTarget['repository'] ?? ''));
        }
        $branch = trim((string)($apiTarget['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH;
        $serviceUuid = trim((string)($existing['coolify_service_uuid'] ?? ''));
        $providerStatus = $existing !== null
            ? $this->cronWorkerProviderStatus($existing, ['include_provider' => true])
            : ['configured' => false, 'missing' => false];
        $repairMissingResource = $existing !== null
            && $serviceUuid !== ''
            && $this->toBool($providerStatus['missing'] ?? false);
        $sourceApp = (string)($apiTarget['app'] ?? 'api');
        $sourceTargetId = (int)($apiTarget['id'] ?? 0);
        if ($repairMissingResource) {
            $context['cron_worker_orphaned_coolify_service_uuid'] = $serviceUuid;
            $context['cron_worker_orphaned_at'] = date('Y-m-d H:i:s');
            $context['cron_worker_repair_reason'] = 'coolify_resource_missing';
            $serviceUuid = '';
        }
        $planAction = $targetId > 0
            ? ($repairMissingResource ? 'repair' : ($serviceUuid === '' ? 'create' : 'update'))
            : 'create';
        $plan = [
            'type' => 'deploy_cron_worker',
            'channel_id' => $channelId,
            'channel_slug' => (string)($channel['slug'] ?? ''),
            'api_target_id' => $sourceApp === 'api' && $sourceTargetId > 0 ? $sourceTargetId : null,
            'source_target_id' => $sourceTargetId > 0 ? $sourceTargetId : null,
            'source_app' => $sourceApp,
            'target_id' => $targetId > 0 ? $targetId : null,
            'action' => $planAction,
            'repository' => $repository,
            'branch' => $branch,
            'commit_sha' => $commitSha,
            'coolify_instance_id' => (int)$apiTarget['coolify_instance_id'],
            'coolify_service_uuid' => $serviceUuid !== '' ? $serviceUuid : null,
            'orphaned_coolify_service_uuid' => $repairMissingResource
                ? (string)($existing['coolify_service_uuid'] ?? '')
                : null,
            'start_command' => self::CRON_WORKER_START_COMMAND,
            'dry_run' => $dryRun,
        ];

        if ($dryRun) {
            return [
                'ok' => true,
                'dry_run' => true,
                'mutated' => false,
                'planned' => [$plan],
                'target' => $existing !== null ? $this->publicDeploymentTarget($existing) : null,
                'worker_status' => $this->cronWorkerStatus(['channel_id' => $channelId]),
            ];
        }

        $cronDeploymentId = $this->createCronWorkerDeploymentRecord(
            $channelId,
            $targetId > 0 ? $targetId : null,
            $repository,
            $branch,
            $commitSha,
            $actorUserId,
            ['plan' => $plan, 'parent_deployment_id' => $parentDeploymentId]
        );

        try {
            if ($targetId > 0) {
                $this->execute(
                    "UPDATE release_deployment_targets
                     SET coolify_instance_id = ?, coolify_service_uuid = ?, repository = ?, branch = ?, auto_deploy = 0,
                         health_url = NULL, deploy_context_json = ?
                     WHERE id = ? AND deleted_at IS NULL",
                    'issssi',
                    [
                        (int)$apiTarget['coolify_instance_id'],
                        $serviceUuid !== '' ? $serviceUuid : null,
                        $repository,
                        $branch,
                        self::jsonEncode($context),
                        $targetId,
                    ]
                );
                $action = $repairMissingResource ? 'cron_worker_target_repaired' : 'cron_worker_target_updated';
            } else {
                $this->execute(
                    "INSERT INTO release_deployment_targets (
                        channel_id, app, coolify_instance_id, coolify_service_uuid,
                        repository, branch, auto_deploy, health_url, deploy_context_json
                    ) VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?)",
                    'isissss',
                    [
                        $channelId,
                        self::CRON_WORKER_APP,
                        (int)$apiTarget['coolify_instance_id'],
                        $serviceUuid !== '' ? $serviceUuid : null,
                        $repository,
                        $branch,
                        self::jsonEncode($context),
                    ]
                );
                $targetId = $this->insertId();
                $context = $this->cronWorkerDeployContext($apiTarget, null, $commitSha, $targetId);
                $this->execute(
                    'UPDATE release_deployment_targets SET deploy_context_json = ? WHERE id = ?',
                    'si',
                    [self::jsonEncode($context), $targetId]
                );
                $this->execute('UPDATE release_deployments SET target_id = ? WHERE id = ?', 'ii', [$targetId, $cronDeploymentId]);
                $action = 'cron_worker_target_created';
            }

            $target = $this->getDeploymentTarget($targetId);
            $deployTarget = array_replace($target, [
                'repository' => $repository,
                'branch' => $branch,
                'commit_sha' => $commitSha ?? '',
            ]);
            $deployment = $this->deployCoolifyReleaseTarget($deployTarget);
            $providerOperationId = $this->coolifyDeploymentOperationId($deployment);
            $this->execute(
                "UPDATE release_deployments
                 SET status = 'deployed', provider_operation_id = ?, result_json = ?, completed_at = NOW()
                 WHERE id = ?",
                'ssi',
                [$providerOperationId, self::jsonEncode(self::redactPayload($deployment)), $cronDeploymentId]
            );

            $this->audit($channelId, $cronDeploymentId, $action, $actorUserId, 'info', [
                'api_target_id' => (int)($apiTarget['id'] ?? 0),
                'cron_target_id' => $targetId,
                'commit_sha' => $commitSha,
                'parent_deployment_id' => $parentDeploymentId,
                'deployment' => $deployment,
            ]);

            return [
                'ok' => true,
                'dry_run' => false,
                'mutated' => true,
                'planned' => [$plan],
                'applied' => [[
                    'target_id' => $targetId,
                    'deployment_id' => $cronDeploymentId,
                    'action' => $action,
                    'deployment' => $deployment,
                ]],
                'deployment' => $this->publicDeployment($this->getDeployment($cronDeploymentId)),
                'target' => $this->publicDeploymentTarget($this->getDeploymentTarget($targetId)),
                'worker_status' => $this->cronWorkerStatus(['channel_id' => $channelId]),
            ];
        } catch (Throwable $throwable) {
            $this->execute(
                "UPDATE release_deployments
                 SET status = 'failed', result_json = ?, error_message = ?, completed_at = NOW()
                 WHERE id = ?",
                'ssi',
                [
                    self::jsonEncode([
                        'message' => 'Cron worker deployment failed before a worker heartbeat was observed.',
                        'failure_summary' => self::deploymentFailureSummary($throwable, [
                            'app' => self::CRON_WORKER_APP,
                            'repository' => $repository,
                            'branch' => $branch,
                            'commit_sha' => $commitSha,
                            'target_id' => $targetId > 0 ? $targetId : null,
                            'coolify_instance_id' => (int)($apiTarget['coolify_instance_id'] ?? 0),
                            'coolify_service_uuid' => $serviceUuid,
                        ]),
                    ]),
                    $throwable->getMessage(),
                    $cronDeploymentId,
                ]
            );
            $this->audit($channelId, $cronDeploymentId, 'cron_worker_deploy_failed', $actorUserId, 'warning', [
                'api_target_id' => (int)($apiTarget['id'] ?? 0),
                'cron_target_id' => $targetId > 0 ? $targetId : null,
                'commit_sha' => $commitSha,
                'parent_deployment_id' => $parentDeploymentId,
                'error' => $throwable->getMessage(),
            ]);
            throw $throwable;
        }
    }

    private function cronWorkerTargetForChannel(int $channelId): ?array
    {
        return $this->selectOne(
            "SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, i.label AS coolify_instance_label
             FROM release_deployment_targets t
             INNER JOIN release_channels c ON c.id = t.channel_id
             LEFT JOIN coolify_instances i ON i.id = t.coolify_instance_id
             WHERE t.deleted_at IS NULL AND t.channel_id = ? AND t.app = ?
             ORDER BY t.id DESC
             LIMIT 1",
            'is',
            [$channelId, self::CRON_WORKER_APP]
        );
    }

    private function createCronWorkerDeploymentRecord(
        int $channelId,
        ?int $targetId,
        string $repository,
        string $branch,
        ?string $commitSha,
        ?int $actorUserId,
        array $requestedPayload
    ): int {
        $this->execute(
            "INSERT INTO release_deployments (
                channel_id, target_id, deployment_kind, app, provider, repository, branch,
                commit_sha, status, actor_user_id, requested_payload_json, started_at
            ) VALUES (?, ?, 'cron_worker', ?, 'coolify', ?, ?, ?, 'deploying', ?, ?, NOW())",
            'iissssis',
            [
                $channelId,
                $targetId,
                self::CRON_WORKER_APP,
                $repository,
                $branch,
                $commitSha,
                $actorUserId,
                self::jsonEncode(self::redactPayload($requestedPayload)),
            ]
        );

        return $this->insertId();
    }

    private function cronWorkerDeployments(int $channelId, int $limit = 5): array
    {
        $limit = max(1, min(25, $limit));
        return array_map(
            fn(array $deployment): array => $this->publicDeployment($deployment),
            $this->selectRows(
                "SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url
                 FROM release_deployments d
                 INNER JOIN release_channels c ON c.id = d.channel_id
                 LEFT JOIN release_versions v ON v.id = d.version_id
                 WHERE d.channel_id = ? AND d.app = ? AND d.deployment_kind = 'cron_worker'
                 ORDER BY d.id DESC
                 LIMIT $limit",
                'is',
                [$channelId, self::CRON_WORKER_APP]
            )
        );
    }

    private function cronWorkerChannels(): array
    {
        return array_map(
            static fn(array $channel): array => [
                'id' => (int)($channel['id'] ?? 0),
                'slug' => (string)($channel['slug'] ?? ''),
                'name' => (string)($channel['name'] ?? ''),
                'default_channel' => (bool)((int)($channel['default_channel'] ?? 0)),
            ],
            $this->selectRows(
                "SELECT id, slug, name, default_channel
                 FROM release_channels
                 WHERE deleted_at IS NULL AND enabled = 1
                 ORDER BY default_channel DESC, slug"
            )
        );
    }

    private function cronWorkersForTarget(array $workers, int $channelId, ?array $target): array
    {
        $targetId = $target !== null ? (int)($target['id'] ?? 0) : 0;
        $resourceUuid = $target !== null ? trim((string)($target['coolify_service_uuid'] ?? '')) : '';
        $matched = [];
        foreach ($workers as $worker) {
            if (!is_array($worker)) {
                continue;
            }

            $workerChannelId = (int)($worker['release_channel_id'] ?? 0);
            $workerTargetId = (int)($worker['release_target_id'] ?? 0);
            $workerResourceUuid = trim((string)($worker['coolify_resource_uuid'] ?? ''));
            if (
                ($workerChannelId > 0 && $workerChannelId === $channelId)
                || ($targetId > 0 && $workerTargetId === $targetId)
                || ($resourceUuid !== '' && $workerResourceUuid === $resourceUuid)
            ) {
                $matched[] = $worker;
            }
        }

        return $matched;
    }

    private function cronWorkerSummary(array $workers): array
    {
        $running = 0;
        $stale = 0;
        $failed = 0;
        foreach ($workers as $worker) {
            $status = (string)($worker['status'] ?? '');
            $isStale = (bool)($worker['stale'] ?? false);
            if ($status === 'running' && !$isStale) {
                $running++;
            }
            if ($isStale) {
                $stale++;
            }
            if ($status === 'failed') {
                $failed++;
            }
        }

        return [
            'total' => count($workers),
            'running' => $running,
            'stale' => $stale,
            'failed' => $failed,
        ];
    }

    private function cronWorkerHealth(
        ?array $apiTarget,
        ?array $target,
        array $workers,
        array $summary,
        ?array $latestDeployment
    ): array {
        $issues = [];
        if ($apiTarget === null) {
            $issues[] = $this->cronWorkerIssue('missing_api_target', 'danger', 'No API deployment target is configured for this release channel.');
            return ['state' => 'needs_deploy', 'issues' => $issues];
        }

        if ($target === null) {
            $issues[] = $this->cronWorkerIssue('missing_cron_target', 'warning', 'No cron worker Coolify target exists for this release channel.');
            return ['state' => 'needs_deploy', 'issues' => $issues];
        }

        $deploymentStatus = (string)($latestDeployment['status'] ?? '');
        if ($deploymentStatus === 'failed') {
            $issues[] = $this->cronWorkerIssue(
                'latest_deployment_failed',
                'danger',
                (string)($latestDeployment['error_message'] ?? 'Latest cron worker deployment failed.')
            );
            return ['state' => 'failed', 'issues' => $issues];
        }
        if (in_array($deploymentStatus, ['queued', 'deploying'], true)) {
            $issues[] = $this->cronWorkerIssue('deployment_in_progress', 'info', 'Cron worker deployment is in progress.');
            return ['state' => 'deploying', 'issues' => $issues];
        }

        $running = (int)($summary['running'] ?? 0);
        $stale = (int)($summary['stale'] ?? 0);
        $failed = (int)($summary['failed'] ?? 0);
        if ($running >= self::CRON_WORKER_DESIRED_COUNT && $stale === 0 && $failed === 0) {
            return ['state' => 'healthy', 'issues' => []];
        }

        if ($running > 0) {
            if ($stale > 0) {
                $issues[] = $this->cronWorkerIssue('stale_workers_present', 'warning', 'At least one cron worker heartbeat is stale.');
            }
            if ($failed > 0) {
                $issues[] = $this->cronWorkerIssue('failed_workers_present', 'warning', 'At least one cron worker reported a failed loop.');
            }
            if ($running < self::CRON_WORKER_DESIRED_COUNT) {
                $issues[] = $this->cronWorkerIssue('below_desired_worker_count', 'warning', 'Fewer cron workers are running than desired.');
            }
            return ['state' => 'degraded', 'issues' => $issues];
        }

        if ($workers === []) {
            $age = $this->cronWorkerDeploymentAgeSeconds($latestDeployment);
            if ($deploymentStatus === 'deployed' && ($age === null || $age <= self::CRON_WORKER_HEARTBEAT_GRACE_SECONDS)) {
                $issues[] = $this->cronWorkerIssue('waiting_for_first_heartbeat', 'info', 'Coolify accepted the deployment; waiting for the worker to write its first heartbeat.');
                return ['state' => 'waiting_for_heartbeat', 'issues' => $issues];
            }

            $issues[] = $this->cronWorkerIssue('no_worker_heartbeat', 'danger', 'Cron worker target exists, but no worker heartbeat has been recorded.');
            return ['state' => $deploymentStatus === 'deployed' ? 'failed' : 'degraded', 'issues' => $issues];
        }

        if ($stale > 0 || $failed > 0) {
            $issues[] = $this->cronWorkerIssue('no_fresh_running_worker', 'danger', 'Cron workers exist, but none have a fresh running heartbeat.');
            return ['state' => 'failed', 'issues' => $issues];
        }

        $issues[] = $this->cronWorkerIssue('worker_not_running', 'warning', 'Cron worker is not currently running.');
        return ['state' => 'degraded', 'issues' => $issues];
    }

    private function cronWorkerDeploymentReadiness(?array $apiTarget, ?array $target, array $providerStatus): array
    {
        $issues = [];
        if ($target === null) {
            if ($apiTarget === null) {
                $issues[] = $this->cronWorkerIssue(
                    'missing_api_target',
                    'danger',
                    'No API deployment target is configured for this release channel.'
                );
                return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues];
            }

            return ['action' => 'create', 'can_deploy' => true, 'issues' => []];
        }

        $resourceUuid = trim((string)($target['coolify_service_uuid'] ?? ''));
        $canUseCronTargetContext = $this->cronWorkerTargetCanDeployWithoutApiTarget($target);

        if ($resourceUuid === '') {
            if ($apiTarget !== null || $canUseCronTargetContext) {
                if ($apiTarget === null) {
                    $issues[] = $this->cronWorkerIssue(
                        'repairable_cron_target',
                        'info',
                        'No API target is configured, but the cron target has enough deployment context to deploy a worker.'
                    );
                }
                return ['action' => 'create', 'can_deploy' => true, 'issues' => $issues];
            }

            $issues[] = $this->cronWorkerIssue(
                'missing_api_target',
                'danger',
                'No API deployment target is configured for this release channel.'
            );
            return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues];
        }

        if ($this->toBool($providerStatus['missing'] ?? false)) {
            $issues[] = $this->cronWorkerIssue(
                'missing_coolify_worker_resource',
                'danger',
                'The stored Coolify cron worker resource was not found and must be recreated.'
            );

            if ($apiTarget !== null || $canUseCronTargetContext) {
                if ($apiTarget === null) {
                    $issues[] = $this->cronWorkerIssue(
                        'repairable_cron_target',
                        'info',
                        'No API target is configured, but the cron target has enough deployment context to repair itself.'
                    );
                }
                return ['action' => 'repair', 'can_deploy' => true, 'issues' => $issues];
            }

            $issues[] = $this->cronWorkerIssue(
                'missing_api_target',
                'danger',
                'No API deployment target is configured for this release channel.'
            );
            return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues];
        }

        if ($apiTarget === null && !$canUseCronTargetContext) {
            $issues[] = $this->cronWorkerIssue(
                'missing_api_target',
                'danger',
                'No API deployment target is configured for this release channel.'
            );
            return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues];
        }

        return ['action' => 'update', 'can_deploy' => true, 'issues' => $issues];
    }

    private function cronWorkerTargetCanDeployWithoutApiTarget(?array $target): bool
    {
        if ($target === null) {
            return false;
        }

        $repository = self::normalizeGithubRepositoryName((string)($target['repository'] ?? ''));
        if ($repository === '') {
            $repository = trim((string)($target['repository'] ?? ''));
        }

        return $this->nullablePositiveInt($target['coolify_instance_id'] ?? null) !== null
            && $repository !== '';
    }

    private function cronWorkerMergeIssues(array ...$issueGroups): array
    {
        $merged = [];
        $seen = [];
        foreach ($issueGroups as $issues) {
            foreach ($issues as $issue) {
                if (!is_array($issue)) {
                    continue;
                }
                $key = trim((string)($issue['code'] ?? ''));
                if ($key === '') {
                    $key = trim((string)($issue['message'] ?? ''));
                }
                if ($key !== '' && isset($seen[$key])) {
                    continue;
                }
                if ($key !== '') {
                    $seen[$key] = true;
                }
                $merged[] = $issue;
            }
        }

        return $merged;
    }

    private function cronWorkerIssue(string $code, string $severity, string $message): array
    {
        return [
            'code' => $code,
            'severity' => $severity,
            'message' => $message,
        ];
    }

    private function cronWorkerDeploymentAgeSeconds(?array $deployment): ?int
    {
        if ($deployment === null) {
            return null;
        }

        foreach (['completed_at', 'started_at', 'created_at'] as $key) {
            $value = trim((string)($deployment[$key] ?? ''));
            if ($value === '') {
                continue;
            }
            $timestamp = strtotime($value);
            if ($timestamp !== false) {
                return max(0, time() - $timestamp);
            }
        }

        return null;
    }

    private function cronWorkerProviderStatus(?array $target, array $input): array
    {
        if ($target === null) {
            return [
                'configured' => false,
                'resource' => null,
            ];
        }

        $context = self::jsonDecode($target['deploy_context_json'] ?? null);
        $resourceUuid = trim((string)($target['coolify_service_uuid'] ?? ''));
        $resourceType = trim((string)($context['coolify_resource_type'] ?? 'application')) ?: 'application';
        $status = [
            'configured' => $resourceUuid !== '',
            'instance_id' => isset($target['coolify_instance_id']) ? (int)$target['coolify_instance_id'] : null,
            'instance_label' => $target['coolify_instance_label'] ?? null,
            'resource_uuid' => $resourceUuid !== '' ? $resourceUuid : null,
            'resource_type' => $resourceType,
            'resource' => null,
            'checked' => false,
            'missing' => false,
        ];

        if (!$this->toBool($input['include_provider'] ?? $input['include_provider_status'] ?? false) || $resourceUuid === '') {
            return $status;
        }

        try {
            $status['checked'] = true;
            $instance = $this->selectOne(
                'SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL',
                'i',
                [(int)($target['coolify_instance_id'] ?? 0)]
            );
            if ($instance === null) {
                throw new RuntimeException('Coolify instance for cron worker target was not found.');
            }
            $client = $this->coolifyClientForInstance($instance, 3);
            $resource = $resourceType === 'service'
                ? $client->getService($resourceUuid)
                : $client->getApplication($resourceUuid);
            $status['resource'] = [
                'ok' => true,
                'uuid' => $resource['uuid'] ?? $resourceUuid,
                'name' => $resource['name'] ?? null,
                'status' => $resource['status'] ?? $resource['state'] ?? null,
                'fqdn' => $resource['fqdn'] ?? $resource['domains'] ?? null,
            ];
        } catch (Throwable $throwable) {
            $status['checked'] = true;
            $status['missing'] = self::coolifyResourceMissing($throwable);
            $status['resource'] = [
                'ok' => false,
                'error' => $throwable->getMessage(),
                'missing' => $status['missing'],
            ];
        }

        return $status;
    }

    private static function coolifyResourceMissing(Throwable $throwable): bool
    {
        $message = strtolower($throwable->getMessage());
        return str_contains($message, 'http 404')
            || str_contains($message, 'not found')
            || preg_match('/\b404\b/', $message) === 1;
    }

    private function coolifyDeploymentOperationId(array $deployment): ?string
    {
        foreach ([$deployment, $deployment['deployment'] ?? null, $deployment['data'] ?? null] as $candidate) {
            if (!is_array($candidate)) {
                continue;
            }
            foreach (['deployment_uuid', 'operation_id', 'deployment_id', 'uuid', 'id'] as $key) {
                $value = trim((string)($candidate[$key] ?? ''));
                if ($value !== '') {
                    return substr($value, 0, 128);
                }
            }
        }

        return null;
    }

    private function cronWorkerDeployContext(array $apiTarget, ?array $existing, ?string $commitSha, int $targetId): array
    {
        $apiContext = self::jsonDecode($apiTarget['deploy_context_json'] ?? null);
        $context = self::jsonDecode($existing['deploy_context_json'] ?? null);
        foreach ([
            'coolify_project_uuid',
            'project_uuid',
            'coolify_environment_uuid',
            'environment_uuid',
            'coolify_environment_name',
            'environment_name',
            'coolify_github_app_uuid',
            'github_app_uuid',
            'coolify_git_app_uuid',
            'git_app_uuid',
            'coolify_server_uuid',
            'server_uuid',
            'coolify_destination_uuid',
            'destination_uuid',
        ] as $key) {
            if (!array_key_exists($key, $context) && array_key_exists($key, $apiContext)) {
                $context[$key] = $apiContext[$key];
            }
        }

        $channelSlug = self::safeSlug((string)($apiTarget['channel_slug'] ?? $apiTarget['channel_id'] ?? 'release')) ?: 'release';
        $workerName = self::safeIdentifier('release-' . $channelSlug . '-cron-worker', 64);
        $context['coolify_auto_create'] = true;
        $context['coolify_resource_type'] = 'application';
        $context['coolify_build_pack'] = 'dockerfile';
        $context['coolify_dockerfile_location'] = self::DEFAULT_COOLIFY_API_DOCKERFILE;
        $context['coolify_ports_exposes'] = self::DEFAULT_COOLIFY_APPLICATION_PORT;
        $context['coolify_start_command'] = self::CRON_WORKER_START_COMMAND;
        $context['coolify_deploy_now'] = true;
        $context['coolify_enable_ssl'] = false;
        $context['coolify_force_rebuild'] = true;
        $context['coolify_is_auto_deploy_enabled'] = false;
        $context['coolify_service_name'] = $workerName;
        $context['cron_worker_autoprovision'] = true;
        if ((string)($apiTarget['app'] ?? 'api') === 'api') {
            $context['cron_worker_source_api_target_id'] = (int)($apiTarget['id'] ?? 0);
            unset($context['cron_worker_source_cron_target_id']);
        } else {
            $context['cron_worker_source_cron_target_id'] = (int)($apiTarget['id'] ?? 0);
            unset($context['cron_worker_source_api_target_id']);
        }
        unset(
            $context['coolify_domain'],
            $context['coolify_public_url'],
            $context['domains'],
            $context['manual_endpoint_host'],
            $context['manual_endpoint_port']
        );
        if ($commitSha !== null && trim($commitSha) !== '') {
            $context['coolify_git_commit_sha'] = $commitSha;
        }

        $env = is_array($context['coolify_env'] ?? null) ? $context['coolify_env'] : [];
        $context['coolify_env'] = array_replace($env, [
            'CRON_WORKER_ENABLED' => 'true',
            'CRON_WORKER_NAME' => $workerName,
            'CRON_WORKER_SOURCE' => 'coolify_worker',
            'CRON_WORKER_POLL_SECONDS' => '15',
            'CRON_WORKER_HEARTBEAT_SECONDS' => '30',
            'CRON_WORKER_RELEASE_CHANNEL_ID' => (string)(int)($apiTarget['channel_id'] ?? 0),
            'CRON_WORKER_RELEASE_TARGET_ID' => $targetId > 0 ? (string)$targetId : '',
            'CRON_WORKER_COOLIFY_RESOURCE_TYPE' => 'application',
        ]);

        return $context;
    }

    public function listServiceSets(): array
    {
        if (!release_manager_schema_bootstrap::tablesExist()) {
            return [];
        }

        return array_map(
            fn(array $row): array => $this->publicServiceSet($row),
            $this->selectRows(
                "SELECT s.*, c.slug AS channel_slug, c.name AS channel_name
                 FROM release_service_sets s
                 LEFT JOIN release_channels c ON c.id = s.channel_id
                 WHERE s.deleted_at IS NULL
                 ORDER BY s.updated_at DESC, s.created_at DESC, s.id DESC"
            )
        );
    }

    public function createServiceSet(array $input, ?int $actorUserId = null): array
    {
        $this->ensureSchema();

        $mode = $this->normalizeServiceSetMode((string)($input['mode'] ?? $input['dataset_mode'] ?? 'attach_existing'));
        $sourceId = $this->nullablePositiveInt($input['source_service_set_id'] ?? $input['source_id'] ?? null);
        if ($mode === 'isolated_stack') {
            $sourceId = null;
        }
        $source = $sourceId !== null ? $this->getServiceSet($sourceId) : null;
        $dataSourceId = $mode === 'isolated_stack'
            ? null
            : $this->nullablePositiveInt($input['data_source_service_set_id'] ?? $input['data_source_id'] ?? null);
        $dataSource = $dataSourceId !== null ? $this->getServiceSet($dataSourceId) : null;
        $channel = $this->channelFromInputOrDefault($input, $source);
        $isBetaChannel = $this->isBetaChannel($channel);
        if ($isBetaChannel && $mode !== 'attach_existing') {
            throw new RuntimeException('Beta release service sets must use production-shared data services.');
        }
        if ($isBetaChannel && $this->serviceSetInputHasExplicitDataTargets($input)) {
            throw new RuntimeException('Beta data-only service sets must copy data targets from Stable/Master or leave them production_shared.');
        }

        $frontendTargetId = $this->serviceSetTargetIdFromInput($input, 'frontend', $source);
        $apiTargetId = $this->serviceSetTargetIdFromInput($input, 'api', $source);
        $dataTargets = [];
        $dataTargetSource = $isBetaChannel ? $dataSource : ($dataSource ?? $source);
        foreach (self::STACK_DATA_KINDS as $kind) {
            $dataTargets[$kind] = $this->serviceSetDataTargetIdFromInput($input, $kind, $dataTargetSource);
        }
        if ($isBetaChannel) {
            $this->assertBetaDataSourceChannel($dataSource);
        }

        $name = trim((string)($input['name'] ?? ''));
        if ($name === '') {
            $name = $source !== null
                ? sprintf('%s %s', (string)($source['name'] ?? 'Release service set'), str_replace('_', ' ', $mode))
                : sprintf('%s service set', ucfirst(str_replace('_', ' ', $mode)));
        }

        if ($mode === 'isolated_stack') {
            $this->assertIsolatedStackTarget($frontendTargetId, 'frontend');
            $this->assertIsolatedStackTarget($apiTargetId, 'api');
            $createDataTargets = $this->toBool(
                $input['create_data_targets']
                    ?? $input['create_isolated_data_targets']
                    ?? $input['create_empty_data_targets']
                    ?? true
            );
            foreach (self::STACK_DATA_KINDS as $kind) {
                if ($dataTargets[$kind] !== null) {
                    $this->assertIsolatedStackDataTarget($dataTargets[$kind], $kind);
                    continue;
                }
                if ($createDataTargets) {
                    $dataTargets[$kind] = $this->createIsolatedStackDataTarget(
                        $kind,
                        $input,
                        $channel,
                        $name,
                        $frontendTargetId,
                        $apiTargetId,
                        $actorUserId
                    );
                }
            }
        }

        if (!$isBetaChannel && !in_array($mode, ['fresh_empty', 'isolated_stack'], true) && $source === null && $frontendTargetId === null && $apiTargetId === null) {
            throw new RuntimeException('Select an existing release deployment or target before creating a reusable service set.');
        }

        $metadata = is_array($input['metadata'] ?? null) ? $input['metadata'] : self::jsonDecode($input['metadata_json'] ?? null);
        $metadata['dataset_mode'] = $mode;
        $metadata['source_service_set_id'] = $sourceId;
        $metadata['data_source_service_set_id'] = $dataSourceId;
        if ($dataSource !== null) {
            $metadata['data_source_channel_slug'] = (string)($dataSource['channel_slug'] ?? '');
        }
        if ($mode === 'attach_existing') {
            $metadata['data_policy'] = self::PRODUCTION_DATA_POLICY;
            $metadata['data_service_mode'] = self::PRODUCTION_DATA_POLICY;
        }
        $metadata['replica_integration'] = $this->replicaProvisioningPlan($mode, $dataTargetSource, $dataTargets);
        if ($mode === 'fresh_empty') {
            $metadata['isolated_empty_services'] = true;
            $metadata['production_replication_attached'] = false;
        }
        if ($mode === 'isolated_stack') {
            $metadata['isolated_stack'] = true;
            $metadata['isolated_empty_services'] = true;
            $metadata['production_replication_attached'] = false;
            $metadata['production_code_targets_attached'] = false;
        }

        $slug = $this->uniqueServiceSetSlug(self::safeSlug((string)($input['slug'] ?? $name)));
        $status = $isBetaChannel ? 'ready' : $this->serviceSetStatus($mode, $frontendTargetId, $apiTargetId, $dataTargets);
        $stackComplete = $isBetaChannel || ($frontendTargetId !== null
            && $apiTargetId !== null
            && (!in_array(null, $dataTargets, true) || $mode === 'attach_existing'));
        $health = [
            'status' => $status,
            'stack_complete' => $stackComplete,
            'data_policy' => $this->serviceSetDataPolicy([
                'mode' => $mode,
                'metadata_json' => self::jsonEncode($metadata),
            ]),
            'checked_at' => date('c'),
        ];

        $this->execute(
            "INSERT INTO release_service_sets (
                channel_id, name, slug, mode, source_service_set_id,
                frontend_target_id, api_target_id,
                database_coolify_target_id, redis_coolify_target_id, minio_coolify_target_id,
                status, health_json, metadata_json, actor_user_id
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
            'isssiiiiiisssi',
            [
                (int)$channel['id'],
                substr($name, 0, 128),
                $slug,
                $mode,
                $sourceId,
                $frontendTargetId,
                $apiTargetId,
                $dataTargets['database'],
                $dataTargets['redis'],
                $dataTargets['minio'],
                $status,
                self::jsonEncode($health),
                self::jsonEncode($metadata),
                $actorUserId,
            ]
        );
        $id = $this->insertId();

        $this->audit((int)$channel['id'], null, 'service_set_created', $actorUserId, 'info', [
            'service_set_id' => $id,
            'mode' => $mode,
            'source_service_set_id' => $sourceId,
            'data_source_service_set_id' => $dataSourceId,
            'data_policy' => $this->serviceSetDataPolicy($this->getServiceSet($id)),
            'data_targets' => $dataTargets,
        ]);

        return $this->publicServiceSet($this->getServiceSet($id));
    }

    public function deleteServiceSet(int $id, array $input = [], ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        $serviceSet = $this->getServiceSet($id);
        if ((string)($serviceSet['mode'] ?? '') !== 'isolated_stack') {
            throw new RuntimeException('Only isolated stack service sets can be removed from Release Manager.');
        }
        if ($this->serviceSetIsActive($id)) {
            throw new RuntimeException('The active release service set cannot be removed.');
        }

        $frontendTargetId = $this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null);
        $apiTargetId = $this->nullablePositiveInt($serviceSet['api_target_id'] ?? null);
        $dataTargetIds = [];
        foreach (self::STACK_DATA_KINDS as $kind) {
            $dataTargetIds[$kind] = $this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null);
        }

        $this->execute(
            "UPDATE release_bundles
             SET status = 'removed', deleted_at = NOW()
             WHERE service_set_id = ? AND deleted_at IS NULL",
            'i',
            [$id]
        );
        $this->execute(
            "UPDATE release_deployments
             SET status = 'removed'
             WHERE service_set_id = ?",
            'i',
            [$id]
        );
        $this->execute(
            "UPDATE release_service_sets
             SET status = 'removed', deleted_at = NOW(), actor_user_id = ?
             WHERE id = ?",
            'ii',
            [$actorUserId, $id]
        );

        foreach ([$frontendTargetId, $apiTargetId] as $targetId) {
            if ($this->isolatedDeploymentTargetCanBeForgotten($targetId, $id)) {
                $this->execute('UPDATE release_deployment_targets SET deleted_at = NOW() WHERE id = ?', 'i', [$targetId]);
            }
        }
        foreach ($dataTargetIds as $targetId) {
            if ($this->isolatedCoolifyTargetCanBeForgotten($targetId, $id)) {
                $this->execute('UPDATE coolify_targets SET deleted_at = NOW() WHERE id = ?', 'i', [$targetId]);
            }
        }

        $this->audit((int)$serviceSet['channel_id'], null, 'service_set_removed', $actorUserId, 'warning', [
            'service_set_id' => $id,
            'mode' => 'isolated_stack',
            'provider_resources_deleted' => false,
            'frontend_target_id' => $frontendTargetId,
            'api_target_id' => $apiTargetId,
            'data_target_ids' => $dataTargetIds,
        ]);

        return [
            'id' => $id,
            'removed' => true,
            'provider_resources_deleted' => false,
        ];
    }

    public function completeIsolatedStackDataServices(int $serviceSetId, array $input = [], ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        $serviceSet = $this->getServiceSet($serviceSetId);
        if ((string)($serviceSet['mode'] ?? '') !== 'isolated_stack') {
            throw new RuntimeException('Only isolated stack service sets can create isolated data services.');
        }

        $frontendTargetId = $this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null);
        $apiTargetId = $this->nullablePositiveInt($serviceSet['api_target_id'] ?? null);
        $this->assertIsolatedStackTarget($frontendTargetId, 'frontend', true);
        $this->assertIsolatedStackTarget($apiTargetId, 'api', true);

        $channel = $this->getChannel((int)$serviceSet['channel_id']);
        $name = trim((string)($input['name'] ?? $serviceSet['name'] ?? 'Isolated stack'));
        $dataTargets = [];
        foreach (self::STACK_DATA_KINDS as $kind) {
            $dataTargets[$kind] = $this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null);
            if ($dataTargets[$kind] !== null) {
                $this->assertIsolatedStackDataTarget($dataTargets[$kind], $kind);
                continue;
            }

            $dataTargets[$kind] = $this->createIsolatedStackDataTarget(
                $kind,
                $input,
                $channel,
                $name,
                $frontendTargetId,
                $apiTargetId,
                $actorUserId
            );
        }

        $metadata = self::jsonDecode($serviceSet['metadata_json'] ?? null);
        $metadata['dataset_mode'] = 'isolated_stack';
        $metadata['isolated_stack'] = true;
        $metadata['isolated_empty_services'] = true;
        $metadata['production_replication_attached'] = false;
        $metadata['production_code_targets_attached'] = false;
        $metadata['replica_integration'] = $this->replicaProvisioningPlan('isolated_stack', null, $dataTargets);

        $status = $this->serviceSetStatus('isolated_stack', $frontendTargetId, $apiTargetId, $dataTargets);
        $health = [
            'status' => $status,
            'stack_complete' => $frontendTargetId !== null && $apiTargetId !== null && !in_array(null, $dataTargets, true),
            'checked_at' => date('c'),
        ];

        $this->execute(
            "UPDATE release_service_sets
             SET database_coolify_target_id = ?, redis_coolify_target_id = ?, minio_coolify_target_id = ?,
                 status = ?, health_json = ?, metadata_json = ?, actor_user_id = ?
             WHERE id = ?",
            'iiisssii',
            [
                $dataTargets['database'],
                $dataTargets['redis'],
                $dataTargets['minio'],
                $status,
                self::jsonEncode($health),
                self::jsonEncode($metadata),
                $actorUserId,
                $serviceSetId,
            ]
        );

        $this->audit((int)$channel['id'], null, 'isolated_stack_data_services_created', $actorUserId, 'info', [
            'service_set_id' => $serviceSetId,
            'data_targets' => $dataTargets,
        ]);

        return $this->publicServiceSet($this->getServiceSet($serviceSetId));
    }

    public function previewCoolifyCleanup(array $input = [], ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        return $this->buildCoolifyCleanupPreview($this->normalizeCoolifyCleanupInput($input));
    }

    public function applyCoolifyCleanup(array $input, ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        $policy = $this->normalizeCoolifyCleanupInput($input);
        $preview = $this->buildCoolifyCleanupPreview($policy);
        $selectionHash = self::safeIdentifier((string)($input['selection_hash'] ?? ''), 128);
        $confirmation = trim((string)($input['confirm'] ?? $input['confirmation'] ?? ''));

        if ($preview['candidates'] === []) {
            throw new RuntimeException('Coolify cleanup has no eligible release-managed resources to apply.');
        }
        if ($selectionHash === '' || !hash_equals((string)$preview['selection_hash'], $selectionHash)) {
            throw new RuntimeException('Coolify cleanup selection changed. Preview again before applying cleanup.');
        }
        if ($confirmation === '' || !hash_equals((string)$preview['confirmation_phrase'], $confirmation)) {
            throw new RuntimeException('Coolify cleanup confirmation phrase does not match the current preview.');
        }

        $operationId = $this->createOperationRun('coolify_cleanup', [
            'channel_id' => $policy['channel_id'],
            'app' => $policy['app'] ?: null,
            'title' => 'Coolify cleanup',
            'actor_user_id' => $actorUserId,
            'context' => [
                'policy' => $policy,
                'selection_hash' => $preview['selection_hash'],
                'candidate_count' => count($preview['candidates']),
            ],
        ]);
        $this->recordOperationStep(
            $operationId,
            'preview',
            'Verified cleanup preview',
            'passed',
            sprintf('%d release-managed Coolify resource(s) matched the confirmed selection.', count($preview['candidates'])),
            null,
            null,
            [
                'selection_hash' => $preview['selection_hash'],
                'candidate_count' => count($preview['candidates']),
                'protected_count' => count($preview['protected']),
                'blocked_count' => count($preview['blocked']),
            ]
        );

        $results = [];
        $failures = 0;
        foreach ($preview['candidates'] as $candidate) {
            try {
                $result = $this->applyCoolifyCleanupResource($candidate, $policy['action']);
                $stepStatus = ($result['status'] ?? '') === 'already_absent' ? 'warning' : 'passed';
                $this->recordOperationStep(
                    $operationId,
                    'resource_' . substr(hash('sha256', (string)$candidate['resource_key']), 0, 16),
                    sprintf('%s %s', ucfirst($policy['action']), (string)$candidate['name']),
                    $stepStatus,
                    (string)($result['message'] ?? 'Coolify cleanup action completed.'),
                    null,
                    null,
                    [
                        'resource' => $candidate,
                        'result' => $result,
                    ]
                );
                $results[] = $candidate + ['result' => $result];
            } catch (Throwable $throwable) {
                $failures++;
                $result = [
                    'status' => 'failed',
                    'message' => $throwable->getMessage(),
                ];
                $this->recordOperationStep(
                    $operationId,
                    'resource_' . substr(hash('sha256', (string)$candidate['resource_key']), 0, 16),
                    sprintf('%s %s', ucfirst($policy['action']), (string)$candidate['name']),
                    'failed',
                    $throwable->getMessage(),
                    null,
                    'Inspect the Coolify resource directly, then preview cleanup again before retrying.',
                    [
                        'resource' => $candidate,
                    ]
                );
                $results[] = $candidate + ['result' => $result];
            }
        }

        $status = $failures > 0 ? 'failed' : 'completed';
        $summary = sprintf(
            'Coolify cleanup %s: %d processed, %d failed.',
            $status,
            count($results),
            $failures
        );
        $this->completeOperationRun(
            $operationId,
            $status,
            $summary,
            $failures > 0 ? 'Review failed cleanup steps before retrying. Successful steps may already have changed Coolify state.' : null
        );
        $this->audit($policy['channel_id'], null, 'coolify_cleanup_applied', $actorUserId, $failures > 0 ? 'warning' : 'info', [
            'policy' => $policy,
            'selection_hash' => $preview['selection_hash'],
            'processed_count' => count($results),
            'failure_count' => $failures,
        ]);

        return [
            'status' => $status,
            'operation_id' => $operationId,
            'summary' => $summary,
            'policy' => $policy,
            'selection_hash' => $preview['selection_hash'],
            'processed_count' => count($results),
            'failure_count' => $failures,
            'results' => $results,
            'protected' => $preview['protected'],
            'blocked' => $preview['blocked'],
        ];
    }

    private function normalizeCoolifyCleanupInput(array $input): array
    {
        $instanceId = $this->nullablePositiveInt($input['instance_id'] ?? $input['coolify_instance_id'] ?? null);
        $channelId = $this->nullablePositiveInt($input['channel_id'] ?? null);
        $channelSlug = self::safeSlug((string)($input['channel_slug'] ?? $input['channel'] ?? ''));
        if ($channelId === null && $channelSlug !== '') {
            $channel = $this->findChannelBySlug($channelSlug);
            if ($channel === null) {
                throw new RuntimeException('Release channel was not found for Coolify cleanup.');
            }
            $channelId = (int)$channel['id'];
        }

        $appValue = strtolower(trim((string)($input['app'] ?? '')));
        $app = null;
        if ($appValue !== '' && !in_array($appValue, ['all', '*'], true)) {
            $app = $this->normalizeApp($appValue);
        }

        $resourceType = strtolower(trim((string)($input['resource_type'] ?? $input['type'] ?? '')));
        if (!in_array($resourceType, ['service', 'application'], true)) {
            $resourceType = null;
        }

        $action = strtolower(trim((string)($input['action'] ?? 'delete')));
        if (!in_array($action, ['delete', 'stop'], true)) {
            throw new RuntimeException('Coolify cleanup action must be stop or delete.');
        }

        return [
            'instance_id' => $instanceId,
            'channel_id' => $channelId,
            'channel_slug' => $channelId !== null ? (string)($this->getChannel($channelId)['slug'] ?? '') : null,
            'app' => $app,
            'resource_type' => $resourceType,
            'action' => $action,
        ];
    }

    private function buildCoolifyCleanupPreview(array $policy): array
    {
        $instances = $this->coolifyCleanupInstances($policy);
        $graph = $this->coolifyCleanupOwnershipGraph();
        $candidates = [];
        $protected = [];
        $blocked = [];
        $scanned = 0;

        if (!$this->tableExists('coolify_instances')) {
            $blocked[] = [
                'reason' => 'coolify_instances_missing',
                'message' => 'Coolify infrastructure tables are not available in this environment.',
            ];
        }

        foreach ($instances as $instance) {
            $instanceId = (int)($instance['id'] ?? 0);
            $rowsByType = [];
            try {
                $client = $this->coolifyClientForInstance($instance, 8);
                if ($policy['resource_type'] === null || $policy['resource_type'] === 'application') {
                    $rowsByType['application'] = $this->payloadRows($client->listApplications());
                }
                if ($policy['resource_type'] === null || $policy['resource_type'] === 'service') {
                    $rowsByType['service'] = $this->payloadRows($client->listServices());
                }
            } catch (Throwable $throwable) {
                $blocked[] = [
                    'reason' => 'inventory_failed',
                    'instance_id' => $instanceId,
                    'instance_label' => (string)($instance['label'] ?? ''),
                    'message' => $throwable->getMessage(),
                ];
                continue;
            }

            foreach ($rowsByType as $type => $rows) {
                foreach ($rows as $row) {
                    if (!is_array($row)) {
                        continue;
                    }
                    $resource = $this->normalizeCoolifyCleanupResource($row, $instance, $type);
                    if ($resource === null) {
                        continue;
                    }
                    $scanned++;
                    $key = (string)$resource['resource_key'];
                    $reference = $graph['references'][$key] ?? null;
                    if ($reference !== null && $this->coolifyCleanupReferenceMatchesPolicy($reference, $policy, (string)$resource['type'])) {
                        $publicResource = $this->publicCoolifyCleanupResource($resource, $reference, $graph['protected'][$key] ?? [], $policy);
                        if (!empty($graph['protected'][$key])) {
                            $protected[] = $publicResource;
                        } else {
                            $candidates[] = $publicResource;
                        }
                        continue;
                    }

                    if ($reference === null && $this->coolifyCleanupResourceLooksReleaseManaged($resource, $policy)) {
                        $blocked[] = [
                            'reason' => 'unowned_live_resource',
                            'message' => 'The Coolify resource looks release-related, but Release Manager has no UUID ownership record for it.',
                            'resource' => $this->publicCoolifyCleanupResource($resource, null, [], $policy),
                        ];
                    }
                }
            }
        }

        $this->sortCoolifyCleanupResources($candidates);
        $this->sortCoolifyCleanupResources($protected);
        usort($blocked, static fn(array $a, array $b): int => strcmp((string)($a['reason'] ?? ''), (string)($b['reason'] ?? '')));
        $selectionHash = $this->coolifyCleanupSelectionHash($policy, $candidates);

        return [
            'policy' => $policy,
            'summary' => [
                'instances_scanned' => count($instances),
                'live_resources_scanned' => $scanned,
                'candidates' => count($candidates),
                'protected' => count($protected),
                'blocked' => count($blocked),
            ],
            'candidates' => $candidates,
            'protected' => $protected,
            'blocked' => $blocked,
            'selection_hash' => $selectionHash,
            'confirmation_phrase' => $this->coolifyCleanupConfirmationPhrase($selectionHash),
            'generated_at' => date('c'),
        ];
    }

    private function coolifyCleanupInstances(array $policy): array
    {
        if (!$this->tableExists('coolify_instances')) {
            return [];
        }

        if ($policy['instance_id'] !== null) {
            return $this->selectRows(
                'SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL ORDER BY label, id',
                'i',
                [(int)$policy['instance_id']]
            );
        }

        return $this->selectRows('SELECT * FROM coolify_instances WHERE deleted_at IS NULL ORDER BY label, id');
    }

    private function coolifyCleanupOwnershipGraph(): array
    {
        $references = [];
        $protected = [];

        foreach ($this->coolifyCleanupDeploymentTargets() as $row) {
            $instanceId = (int)($row['coolify_instance_id'] ?? 0);
            $uuid = trim((string)($row['coolify_service_uuid'] ?? ''));
            $context = self::jsonDecode($row['deploy_context_json'] ?? null);
            $this->addCoolifyCleanupReference(
                $references,
                $protected,
                $instanceId,
                $uuid,
                [
                    'source' => 'deployment_target',
                    'target_id' => (int)($row['id'] ?? 0),
                    'channel_id' => (int)($row['channel_id'] ?? 0),
                    'channel_slug' => (string)($row['channel_slug'] ?? ''),
                    'app' => (string)($row['app'] ?? ''),
                    'resource_type' => $this->releaseCoolifyResourceType($context, $uuid),
                    'deleted' => !empty($row['deleted_at']),
                ],
                empty($row['deleted_at']),
                'configured_target'
            );
        }

        foreach ($this->coolifyCleanupServiceSetTargets() as $row) {
            $instanceId = (int)($row['coolify_instance_id'] ?? 0);
            $uuid = trim((string)($row['coolify_service_uuid'] ?? ''));
            $context = self::jsonDecode($row['deploy_context_json'] ?? null);
            $this->addCoolifyCleanupReference(
                $references,
                $protected,
                $instanceId,
                $uuid,
                [
                    'source' => 'service_set_target',
                    'service_set_id' => (int)($row['service_set_id'] ?? 0),
                    'service_set_slug' => (string)($row['service_set_slug'] ?? ''),
                    'channel_id' => (int)($row['channel_id'] ?? 0),
                    'channel_slug' => (string)($row['channel_slug'] ?? ''),
                    'app' => (string)($row['app'] ?? ''),
                    'resource_type' => $this->releaseCoolifyResourceType($context, $uuid),
                ],
                true,
                'active_service_set_target'
            );
        }

        $deploymentProtection = $this->coolifyCleanupDeploymentProtection();
        foreach ($this->coolifyCleanupDeployments() as $row) {
            $deploymentId = (int)($row['id'] ?? 0);
            $instanceId = (int)($row['coolify_instance_id'] ?? 0);
            $result = self::jsonDecode($row['result_json'] ?? null);
            $serviceUuid = trim((string)($result['service_uuid'] ?? $result['resource_uuid'] ?? ''));
            $context = self::jsonDecode($row['deploy_context_json'] ?? null);
            $resourceType = strtolower(trim((string)($result['resource_type'] ?? ''))) ?: $this->releaseCoolifyResourceType($context, $serviceUuid);
            $protectReason = null;
            if (in_array($deploymentId, $deploymentProtection['active_ids'], true)) {
                $protectReason = 'active_deployment';
            } elseif (in_array($deploymentId, $deploymentProtection['rollback_ids'], true)) {
                $protectReason = 'rollback_deployment';
            }
            $this->addCoolifyCleanupReference(
                $references,
                $protected,
                $instanceId,
                $serviceUuid,
                [
                    'source' => 'deployment',
                    'deployment_id' => $deploymentId,
                    'channel_id' => (int)($row['channel_id'] ?? 0),
                    'channel_slug' => (string)($row['channel_slug'] ?? ''),
                    'app' => (string)($row['app'] ?? ''),
                    'status' => (string)($row['status'] ?? ''),
                    'resource_type' => $resourceType,
                ],
                $protectReason !== null,
                $protectReason ?? ''
            );

            $previousApplications = is_array($result['previous_applications'] ?? null) ? $result['previous_applications'] : [];
            $previousIndex = 0;
            foreach ($previousApplications as $previousApplication) {
                $previousIndex++;
                $previousUuid = is_array($previousApplication)
                    ? trim((string)($previousApplication['uuid'] ?? $previousApplication['id'] ?? ''))
                    : trim((string)$previousApplication);
                $protectPrevious = $protectReason === 'active_deployment' && $previousIndex === 1;
                $this->addCoolifyCleanupReference(
                    $references,
                    $protected,
                    $instanceId,
                    $previousUuid,
                    [
                        'source' => 'deployment_previous_application',
                        'deployment_id' => $deploymentId,
                        'channel_id' => (int)($row['channel_id'] ?? 0),
                        'channel_slug' => (string)($row['channel_slug'] ?? ''),
                        'app' => (string)($row['app'] ?? ''),
                        'status' => (string)($row['status'] ?? ''),
                        'resource_type' => 'application',
                    ],
                    $protectPrevious,
                    $protectPrevious ? 'rollback_deployment' : ''
                );
            }
        }

        foreach ($this->coolifyCleanupCoolifyTargets() as $row) {
            $kind = strtolower(trim((string)($row['kind'] ?? '')));
            $resourceUuid = trim((string)($row['resource_uuid'] ?? ''));
            $isReleaseApp = in_array($kind, self::APPS, true);
            $this->addCoolifyCleanupReference(
                $references,
                $protected,
                (int)($row['instance_id'] ?? 0),
                $resourceUuid,
                [
                    'source' => 'coolify_target',
                    'coolify_target_id' => (int)($row['id'] ?? 0),
                    'app' => $isReleaseApp ? $kind : null,
                    'kind' => $kind,
                    'resource_type' => (string)($row['resource_type'] ?? 'service'),
                    'deleted' => !empty($row['deleted_at']),
                ],
                empty($row['deleted_at']),
                $isReleaseApp ? 'active_coolify_target' : 'active_data_service'
            );
        }

        return [
            'references' => $references,
            'protected' => $protected,
        ];
    }

    private function coolifyCleanupDeploymentTargets(): array
    {
        return $this->selectRows(
            "SELECT t.*, c.slug AS channel_slug
             FROM release_deployment_targets t
             LEFT JOIN release_channels c ON c.id = t.channel_id
             WHERE t.coolify_instance_id IS NOT NULL
               AND COALESCE(t.coolify_service_uuid, '') <> ''"
        );
    }

    private function coolifyCleanupServiceSetTargets(): array
    {
        return array_merge(
            $this->selectRows(
                "SELECT s.id AS service_set_id, s.slug AS service_set_slug, s.channel_id,
                        c.slug AS channel_slug, 'frontend' AS app,
                        t.coolify_instance_id, t.coolify_service_uuid, t.deploy_context_json
                 FROM release_service_sets s
                 INNER JOIN release_deployment_targets t ON t.id = s.frontend_target_id
                 LEFT JOIN release_channels c ON c.id = s.channel_id
                 WHERE s.deleted_at IS NULL
                   AND t.coolify_instance_id IS NOT NULL
                   AND COALESCE(t.coolify_service_uuid, '') <> ''"
            ),
            $this->selectRows(
                "SELECT s.id AS service_set_id, s.slug AS service_set_slug, s.channel_id,
                        c.slug AS channel_slug, 'api' AS app,
                        t.coolify_instance_id, t.coolify_service_uuid, t.deploy_context_json
                 FROM release_service_sets s
                 INNER JOIN release_deployment_targets t ON t.id = s.api_target_id
                 LEFT JOIN release_channels c ON c.id = s.channel_id
                 WHERE s.deleted_at IS NULL
                   AND t.coolify_instance_id IS NOT NULL
                   AND COALESCE(t.coolify_service_uuid, '') <> ''"
            )
        );
    }

    private function coolifyCleanupDeployments(): array
    {
        return $this->selectRows(
            "SELECT d.*, c.slug AS channel_slug, t.coolify_instance_id, t.deploy_context_json
             FROM release_deployments d
             LEFT JOIN release_channels c ON c.id = d.channel_id
             LEFT JOIN release_deployment_targets t ON t.id = d.target_id
             WHERE d.provider = 'coolify'
               AND d.result_json IS NOT NULL
               AND t.coolify_instance_id IS NOT NULL"
        );
    }

    private function coolifyCleanupDeploymentProtection(): array
    {
        $activeIds = [];
        $rollbackIds = [];
        $rollbackSeen = [];
        $rows = $this->selectRows(
            "SELECT id, channel_id, app, status, active_channel_app_key, completed_at, created_at
             FROM release_deployments
             WHERE provider = 'coolify'
               AND status IN ('active', 'deployed')
             ORDER BY channel_id ASC, app ASC, COALESCE(completed_at, created_at) DESC, id DESC"
        );

        foreach ($rows as $row) {
            $id = (int)($row['id'] ?? 0);
            $group = (int)($row['channel_id'] ?? 0) . ':' . (string)($row['app'] ?? '');
            $isActive = trim((string)($row['active_channel_app_key'] ?? '')) !== '' || (string)($row['status'] ?? '') === 'active';
            if ($isActive) {
                $activeIds[] = $id;
                continue;
            }
            if (!isset($rollbackSeen[$group])) {
                $rollbackIds[] = $id;
                $rollbackSeen[$group] = true;
            }
        }

        return [
            'active_ids' => array_values(array_unique($activeIds)),
            'rollback_ids' => array_values(array_unique($rollbackIds)),
        ];
    }

    private function coolifyCleanupCoolifyTargets(): array
    {
        if (!$this->tableExists('coolify_targets')) {
            return [];
        }

        return $this->selectRows(
            "SELECT id, instance_id, kind, resource_uuid, resource_type, resource_name, deleted_at
             FROM coolify_targets
             WHERE resource_uuid IS NOT NULL
               AND resource_uuid <> ''"
        );
    }

    private function addCoolifyCleanupReference(
        array &$references,
        array &$protected,
        int $instanceId,
        string $uuid,
        array $evidence,
        bool $protect,
        string $protectReason
    ): void {
        $uuid = trim($uuid);
        if ($instanceId <= 0 || $uuid === '') {
            return;
        }

        $key = $this->coolifyCleanupResourceKey($instanceId, $uuid);
        if (!isset($references[$key])) {
            $references[$key] = [
                'instance_id' => $instanceId,
                'uuid' => $uuid,
                'apps' => [],
                'channels' => [],
                'channel_slugs' => [],
                'resource_types' => [],
                'evidence' => [],
            ];
        }

        $app = strtolower(trim((string)($evidence['app'] ?? '')));
        if (in_array($app, self::APPS, true)) {
            $references[$key]['apps'][$app] = $app;
        }
        $channelId = $this->nullablePositiveInt($evidence['channel_id'] ?? null);
        if ($channelId !== null) {
            $references[$key]['channels'][$channelId] = $channelId;
        }
        $channelSlug = self::safeSlug((string)($evidence['channel_slug'] ?? ''));
        if ($channelSlug !== '') {
            $references[$key]['channel_slugs'][$channelSlug] = $channelSlug;
        }
        $resourceType = strtolower(trim((string)($evidence['resource_type'] ?? '')));
        if (in_array($resourceType, ['service', 'application'], true)) {
            $references[$key]['resource_types'][$resourceType] = $resourceType;
        }
        $references[$key]['evidence'][] = self::redactPayload($evidence);

        if ($protect && $protectReason !== '') {
            $protected[$key][] = [
                'reason' => $protectReason,
                'source' => (string)($evidence['source'] ?? ''),
                'channel_id' => $channelId,
                'channel_slug' => $channelSlug !== '' ? $channelSlug : null,
                'app' => in_array($app, self::APPS, true) ? $app : null,
                'deployment_id' => isset($evidence['deployment_id']) ? (int)$evidence['deployment_id'] : null,
                'target_id' => isset($evidence['target_id']) ? (int)$evidence['target_id'] : null,
                'service_set_id' => isset($evidence['service_set_id']) ? (int)$evidence['service_set_id'] : null,
            ];
        }
    }

    private function normalizeCoolifyCleanupResource(array $row, array $instance, string $type): ?array
    {
        $uuid = trim((string)($row['uuid'] ?? $row['id'] ?? ''));
        if ($uuid === '') {
            return null;
        }

        $urls = [];
        foreach (['fqdn', 'domain', 'url', 'base_url'] as $key) {
            $this->appendSuggestion($urls, $row[$key] ?? null);
        }
        foreach (['urls', 'domains', 'fqdns'] as $key) {
            if (!is_array($row[$key] ?? null)) {
                continue;
            }
            foreach ($row[$key] as $url) {
                if (is_array($url)) {
                    $this->appendSuggestion($urls, $url['url'] ?? $url['fqdn'] ?? $url['domain'] ?? null);
                } else {
                    $this->appendSuggestion($urls, $url);
                }
            }
        }

        $name = trim((string)($row['name'] ?? $row['service_name'] ?? $row['application_name'] ?? ''));
        if ($name === '') {
            $name = $urls[0] ?? $uuid;
        }

        $instanceId = (int)($instance['id'] ?? 0);
        return [
            'resource_key' => $this->coolifyCleanupResourceKey($instanceId, $uuid),
            'instance_id' => $instanceId,
            'instance_label' => (string)($instance['label'] ?? ''),
            'instance_base_url' => (string)($instance['base_url'] ?? ''),
            'type' => $type,
            'uuid' => $uuid,
            'name' => $name,
            'status' => (string)($row['status'] ?? $row['deployment_status'] ?? $row['state'] ?? 'unknown'),
            'project_uuid' => $row['project_uuid'] ?? null,
            'environment_uuid' => $row['environment_uuid'] ?? null,
            'server_uuid' => $row['server_uuid'] ?? null,
            'destination_uuid' => $row['destination_uuid'] ?? null,
            'urls' => array_values($urls),
        ];
    }

    private function publicCoolifyCleanupResource(array $resource, ?array $reference, array $protection, array $policy): array
    {
        $apps = $reference !== null ? array_values($reference['apps']) : [];
        $channels = $reference !== null ? array_values($reference['channels']) : [];
        $channelSlugs = $reference !== null ? array_values($reference['channel_slugs']) : [];
        $evidence = $reference !== null ? array_slice($reference['evidence'], 0, 20) : [];

        return [
            'resource_key' => (string)$resource['resource_key'],
            'instance_id' => (int)$resource['instance_id'],
            'instance_label' => (string)$resource['instance_label'],
            'type' => (string)$resource['type'],
            'uuid' => (string)$resource['uuid'],
            'name' => (string)$resource['name'],
            'status' => (string)$resource['status'],
            'urls' => array_values($resource['urls'] ?? []),
            'apps' => $apps,
            'channels' => $channels,
            'channel_slugs' => $channelSlugs,
            'action' => (string)$policy['action'],
            'evidence' => $evidence,
            'protection' => array_values($protection),
        ];
    }

    private function coolifyCleanupReferenceMatchesPolicy(array $reference, array $policy, ?string $liveType = null): bool
    {
        $apps = array_values(array_intersect(array_values($reference['apps'] ?? []), self::APPS));
        if ($apps === []) {
            return false;
        }
        if ($policy['app'] !== null && !in_array($policy['app'], $apps, true)) {
            return false;
        }
        if ($policy['channel_id'] !== null && !in_array((int)$policy['channel_id'], array_values($reference['channels'] ?? []), true)) {
            return false;
        }
        if (
            $policy['resource_type'] !== null
            && $liveType !== $policy['resource_type']
            && !in_array($policy['resource_type'], array_values($reference['resource_types'] ?? []), true)
        ) {
            return false;
        }
        return true;
    }

    private function coolifyCleanupResourceLooksReleaseManaged(array $resource, array $policy): bool
    {
        $haystack = strtolower(implode(' ', array_filter([
            $resource['name'] ?? '',
            $resource['uuid'] ?? '',
            implode(' ', array_values($resource['urls'] ?? [])),
        ])));
        if ($haystack === '') {
            return false;
        }

        $appNeedles = $policy['app'] !== null
            ? [$policy['app']]
            : ['frontend', 'front-end', 'vue', 'api', 'backend'];
        $matchesApp = false;
        foreach ($appNeedles as $needle) {
            if (str_contains($haystack, $needle)) {
                $matchesApp = true;
                break;
            }
        }
        if (!$matchesApp) {
            return false;
        }

        foreach (['pleno', 'truckwash', 'release', 'stable', 'master', 'beta', 'canary', 'internal'] as $needle) {
            if (str_contains($haystack, $needle)) {
                return true;
            }
        }

        return false;
    }

    private function sortCoolifyCleanupResources(array &$resources): void
    {
        usort($resources, static function (array $a, array $b): int {
            return strcmp(
                implode(':', [
                    implode(',', $a['channel_slugs'] ?? []),
                    implode(',', $a['apps'] ?? []),
                    (string)($a['instance_label'] ?? ''),
                    (string)($a['name'] ?? ''),
                    (string)($a['uuid'] ?? ''),
                ]),
                implode(':', [
                    implode(',', $b['channel_slugs'] ?? []),
                    implode(',', $b['apps'] ?? []),
                    (string)($b['instance_label'] ?? ''),
                    (string)($b['name'] ?? ''),
                    (string)($b['uuid'] ?? ''),
                ])
            );
        });
    }

    private function coolifyCleanupSelectionHash(array $policy, array $candidates): string
    {
        $selection = array_map(static function (array $candidate): array {
            return [
                'instance_id' => (int)$candidate['instance_id'],
                'type' => (string)$candidate['type'],
                'uuid' => (string)$candidate['uuid'],
                'action' => (string)$candidate['action'],
            ];
        }, $candidates);
        usort($selection, static fn(array $a, array $b): int => strcmp(self::jsonEncode($a), self::jsonEncode($b)));

        return hash('sha256', self::jsonEncode([
            'policy' => [
                'instance_id' => $policy['instance_id'],
                'channel_id' => $policy['channel_id'],
                'app' => $policy['app'],
                'resource_type' => $policy['resource_type'],
                'action' => $policy['action'],
            ],
            'selection' => $selection,
        ]));
    }

    private function coolifyCleanupConfirmationPhrase(string $hash): string
    {
        return 'cleanup-coolify-' . substr($hash, 0, 12);
    }

    private function applyCoolifyCleanupResource(array $candidate, string $action): array
    {
        $instanceId = (int)($candidate['instance_id'] ?? 0);
        $uuid = trim((string)($candidate['uuid'] ?? ''));
        $type = strtolower(trim((string)($candidate['type'] ?? '')));
        if ($instanceId <= 0 || $uuid === '' || !in_array($type, ['service', 'application'], true)) {
            throw new RuntimeException('Cleanup candidate is missing a Coolify instance, type, or UUID.');
        }

        $instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL', 'i', [$instanceId]);
        if ($instance === null) {
            throw new RuntimeException('Coolify instance for cleanup was not found.');
        }
        $client = $this->coolifyClientForInstance($instance, 20);

        try {
            if ($action === 'stop') {
                $result = $type === 'application'
                    ? $client->stopApplication($uuid)
                    : $client->stopService($uuid);
                return [
                    'status' => 'stop_requested',
                    'message' => 'Coolify stop was requested.',
                    'response' => self::redactPayload($result),
                ];
            }

            $result = $type === 'application'
                ? $client->deleteApplication($uuid)
                : $client->deleteService($uuid);
            return [
                'status' => 'delete_requested',
                'message' => 'Coolify delete was requested.',
                'response' => self::redactPayload($result),
            ];
        } catch (Throwable $throwable) {
            if ($this->coolifyCleanupThrowableIsNotFound($throwable)) {
                return [
                    'status' => 'already_absent',
                    'message' => 'Coolify resource was already absent.',
                ];
            }
            throw $throwable;
        }
    }

    private function coolifyCleanupThrowableIsNotFound(Throwable $throwable): bool
    {
        $message = strtolower($throwable->getMessage());
        return str_contains($message, 'http 404') || str_contains($message, 'not found') || str_contains($message, '404');
    }

    private function coolifyCleanupResourceKey(int $instanceId, string $uuid): string
    {
        return $instanceId . ':' . trim($uuid);
    }

    public function listBundles(int $limit = 50): array
    {
        if (!release_manager_schema_bootstrap::tablesExist()) {
            return [];
        }

        $limit = max(1, min(250, $limit));
        return array_map(
            fn(array $row): array => $this->publicBundle($row),
            $this->selectRows(
                "SELECT b.*, c.slug AS channel_slug, c.name AS channel_name, s.name AS service_set_name, s.slug AS service_set_slug
                 FROM release_bundles b
                 INNER JOIN release_channels c ON c.id = b.channel_id
                 INNER JOIN release_service_sets s ON s.id = b.service_set_id
                 WHERE b.deleted_at IS NULL
                 ORDER BY b.created_at DESC, b.id DESC
                 LIMIT $limit"
            )
        );
    }

    public function createBundle(array $input, ?int $actorUserId = null): array
    {
        $this->ensureSchema();

        $serviceSetId = $this->nullablePositiveInt($input['service_set_id'] ?? null);
        if ($serviceSetId === null) {
            throw new RuntimeException('A release service set is required before creating a bundle.');
        }
        $serviceSet = $this->getServiceSet($serviceSetId);
        $channel = $this->channelFromInputOrDefault($input, $serviceSet);
        if ($this->channelUsesProductionServices($channel)) {
            throw new RuntimeException('Beta release channel uses production services and does not create separate release bundles.');
        }
        $this->assertBetaProductionDataPolicy($channel, $serviceSet);

        $versionLabel = trim((string)($input['version_label'] ?? ''));
        if ($versionLabel === '') {
            $versionLabel = sprintf('%s-bundle-%s', (string)($channel['slug'] ?? 'release'), date('Ymd-His'));
        }

        $frontendTarget = $this->nullableDeploymentTarget((int)($serviceSet['frontend_target_id'] ?? 0) ?: null);
        $apiTarget = $this->nullableDeploymentTarget((int)($serviceSet['api_target_id'] ?? 0) ?: null);
        $frontend = $this->bundleAppInput($input, 'frontend', $frontendTarget, $versionLabel);
        $api = $this->bundleAppInput($input, 'api', $apiTarget, $versionLabel);

        $frontendVersionId = $this->createBundleVersion($frontend, 'frontend');
        $apiVersionId = $this->createBundleVersion($api, 'api');

        $metadata = is_array($input['metadata'] ?? null) ? $input['metadata'] : self::jsonDecode($input['metadata_json'] ?? null);
        $metadata['service_set_id'] = $serviceSetId;
        $metadata['stack_services'] = array_merge(['frontend', 'api'], self::STACK_DATA_KINDS);
        $metadata['promotion_policy'] = 'attach_code_and_service_set_only';

        $this->execute(
            "INSERT INTO release_bundles (
                channel_id, service_set_id, version_label,
                frontend_version_id, api_version_id,
                frontend_repository, frontend_branch, frontend_commit_sha,
                api_repository, api_branch, api_commit_sha,
                metadata_json, actor_user_id
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
            'iisiisssssssi',
            [
                (int)$channel['id'],
                $serviceSetId,
                $versionLabel,
                $frontendVersionId,
                $apiVersionId,
                $frontend['repository'],
                $frontend['branch'],
                $frontend['commit_sha'],
                $api['repository'],
                $api['branch'],
                $api['commit_sha'],
                self::jsonEncode($metadata),
                $actorUserId,
            ]
        );
        $id = $this->insertId();

        $this->audit((int)$channel['id'], null, 'bundle_created', $actorUserId, 'info', [
            'bundle_id' => $id,
            'service_set_id' => $serviceSetId,
            'frontend_repository' => $frontend['repository'],
            'api_repository' => $api['repository'],
        ]);

        return $this->publicBundle($this->getBundle($id));
    }

    public function deployBundle(int $bundleId, ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        $bundle = $this->getBundle($bundleId);
        $serviceSet = $this->getServiceSet((int)$bundle['service_set_id']);
        $channel = $this->getChannel((int)$bundle['channel_id']);
        if ($this->channelUsesProductionServices($channel)) {
            throw new RuntimeException('Beta release channel uses production services and does not deploy separate release bundles.');
        }
        $this->assertBetaProductionDataPolicy($channel, $serviceSet);
        $results = [];
        $deploymentIds = ['frontend' => null, 'api' => null];

        foreach (['frontend', 'api'] as $app) {
            $versionId = $this->nullablePositiveInt($bundle[$app . '_version_id'] ?? null);
            if ($versionId === null) {
                $results[$app] = ['status' => 'skipped', 'message' => 'No release version is attached to this app.'];
                continue;
            }

            $deployment = $this->startDeployment([
                'channel_id' => (int)$bundle['channel_id'],
                'target_id' => $this->nullablePositiveInt($serviceSet[$app . '_target_id'] ?? null),
                'version_id' => $versionId,
                'service_set_id' => (int)$bundle['service_set_id'],
                'bundle_id' => $bundleId,
                'deployment_kind' => 'bundle_member',
                'app' => $app,
                'repository' => $bundle[$app . '_repository'] ?? '',
                'branch' => $bundle[$app . '_branch'] ?? self::DEFAULT_BRANCH,
                'commit_mode' => trim((string)($bundle[$app . '_commit_sha'] ?? '')) !== '' ? 'specific' : 'latest',
                'commit_sha' => $bundle[$app . '_commit_sha'] ?? '',
                'version_label' => $bundle['version_label'] ?? null,
            ], $actorUserId);
            $deploymentIds[$app] = (int)($deployment['id'] ?? 0) ?: null;
            $results[$app] = $deployment;
        }

        $statuses = array_map(static fn(array $result): string => strtolower((string)($result['status'] ?? 'unknown')), $results);
        $status = in_array('failed', $statuses, true)
            ? 'failed'
            : (count(array_intersect($statuses, ['queued', 'deploying', 'unknown', 'skipped'])) > 0 ? 'deploying' : 'deployed');

        $this->execute(
            "UPDATE release_bundles
             SET status = ?, frontend_deployment_id = ?, api_deployment_id = ?,
                 deployment_result_json = ?, deployed_at = CASE WHEN ? IN ('deployed', 'deploying') THEN NOW() ELSE deployed_at END
             WHERE id = ?",
            'siissi',
            [
                $status,
                $deploymentIds['frontend'],
                $deploymentIds['api'],
                self::jsonEncode(self::redactPayload($results)),
                $status,
                $bundleId,
            ]
        );

        $this->audit((int)$bundle['channel_id'], null, 'bundle_deployed', $actorUserId, $status === 'failed' ? 'error' : 'info', [
            'bundle_id' => $bundleId,
            'service_set_id' => (int)$bundle['service_set_id'],
            'status' => $status,
        ]);

        return $this->publicBundle($this->getBundle($bundleId));
    }

    public function promoteBundle(int $bundleId, ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        $bundle = $this->getBundle($bundleId);
        $status = strtolower((string)($bundle['status'] ?? ''));
        if (!in_array($status, ['deployed', 'active', 'promoted'], true)) {
            throw new RuntimeException('Only deployed release bundles can be promoted.');
        }

        $channelId = (int)$bundle['channel_id'];
        $channel = $this->getChannel($channelId);
        $serviceSetId = (int)$bundle['service_set_id'];
        $serviceSet = $this->getServiceSet($serviceSetId);
        if ($this->channelUsesProductionServices($channel)) {
            throw new RuntimeException('Beta release channel uses production services and does not promote separate release bundles.');
        }
        $this->assertBetaProductionDataPolicy($channel, $serviceSet);
        $frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null);
        $apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null);
        $this->assertReleaseGatePassedForPromotion(
            $channelId,
            (string)($bundle['frontend_commit_sha'] ?? ''),
            null,
            'frontend'
        );
        if ($apiVersionId !== null) {
            $this->assertReleaseGatePassedForPromotion(
                $channelId,
                (string)($bundle['api_commit_sha'] ?? ''),
                null,
                'api'
            );
        }
        $deploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null)
            ?? $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null);

        $this->execute('UPDATE release_channel_versions SET active = 0 WHERE channel_id = ?', 'i', [$channelId]);
        $this->execute(
            "UPDATE release_bundles
             SET status = 'superseded'
             WHERE channel_id = ? AND id <> ? AND status = 'promoted' AND deleted_at IS NULL",
            'ii',
            [$channelId, $bundleId]
        );
        $this->execute(
            "UPDATE release_deployments
             SET status = 'superseded', active_channel_app_key = NULL
             WHERE channel_id = ? AND bundle_id IS NOT NULL AND bundle_id <> ? AND status = 'active'",
            'ii',
            [$channelId, $bundleId]
        );
        $this->execute(
            "INSERT INTO release_channel_versions (
                channel_id, frontend_version_id, api_version_id, deployment_id,
                service_set_id, bundle_id, actor_user_id, active
            ) VALUES (?, ?, ?, ?, ?, ?, ?, 1)",
            'iiiiiii',
            [$channelId, $frontendVersionId, $apiVersionId, $deploymentId, $serviceSetId, $bundleId, $actorUserId]
        );

        $this->execute(
            "UPDATE release_bundles SET status = 'promoted', promoted_at = NOW() WHERE id = ?",
            'i',
            [$bundleId]
        );
        foreach ($this->selectRows('SELECT id, app FROM release_deployments WHERE bundle_id = ?', 'i', [$bundleId]) as $bundleDeployment) {
            $bundleDeploymentId = (int)($bundleDeployment['id'] ?? 0);
            $app = (string)($bundleDeployment['app'] ?? '');
            if ($bundleDeploymentId > 0 && in_array($app, self::APPS, true)) {
                $this->activateDeploymentForChannelApp($bundleDeploymentId, $channelId, $app);
            }
        }
        foreach ([$frontendVersionId, $apiVersionId] as $versionId) {
            if ($versionId !== null) {
                $this->execute(
                    "UPDATE release_versions SET status = 'active', deployed_at = COALESCE(deployed_at, NOW()) WHERE id = ?",
                    'i',
                    [$versionId]
                );
            }
        }

        $this->audit($channelId, $deploymentId, 'bundle_promoted', $actorUserId, 'info', [
            'bundle_id' => $bundleId,
            'service_set_id' => $serviceSetId,
            'data_promotion' => false,
            'replica_failover' => false,
        ]);

        return [
            'channel' => $this->publicChannel($this->getChannel($channelId)),
            'versions' => $this->currentVersionsForChannel($channelId),
            'service_set' => $this->publicServiceSet($this->getServiceSet($serviceSetId)),
            'bundle' => $this->publicBundle($this->getBundle($bundleId)),
        ];
    }

    public function setChannelBundle(int $channelId, array $input, ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        $channel = $this->getChannel($channelId);
        $bundleId = $this->nullablePositiveInt($input['bundle_id'] ?? null);
        if ($bundleId === null) {
            throw new RuntimeException('A release bundle is required.');
        }

        $bundle = $this->getBundle($bundleId);
        if ((int)$bundle['channel_id'] !== (int)$channel['id']) {
            throw new RuntimeException('Release bundle does not belong to this channel.');
        }

        $status = strtolower((string)($bundle['status'] ?? ''));
        if (!in_array($status, ['deployed', 'promoted', 'active'], true)) {
            throw new RuntimeException('Only deployed release bundles can be set on a channel.');
        }

        $previous = $this->currentChannelVersionRow($channelId);
        $result = $this->promoteBundle($bundleId, $actorUserId);
        $this->audit($channelId, null, 'channel_bundle_set', $actorUserId, 'info', [
            'bundle_id' => $bundleId,
            'previous_bundle_id' => $this->nullablePositiveInt($previous['bundle_id'] ?? null),
        ]);

        return $result;
    }

    public function listDeployments(int $limit = 50): array
    {
        if (!release_manager_schema_bootstrap::tablesExist()) {
            return [];
        }

        $limit = max(1, min(250, $limit));
        return array_map(
            fn(array $row): array => $this->publicDeployment($row),
            $this->selectRows(
                "SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url
                 FROM release_deployments d
                 INNER JOIN release_channels c ON c.id = d.channel_id
                 LEFT JOIN release_versions v ON v.id = d.version_id
                 ORDER BY d.created_at DESC
                 LIMIT $limit"
            )
        );
    }

    public function startDeployment(array $input, ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        $channel = $this->channelFromInput($input);
        if ($this->channelUsesProductionServices($channel)) {
            throw new RuntimeException('Beta release channel uses production services and cannot deploy separate frontend or API services.');
        }
        $app = $this->normalizeApp((string)($input['app'] ?? ''));
        $target = $this->deploymentTargetFromInput($input, (int)$channel['id'], $app);

        $repository = trim((string)($input['repository'] ?? $target['repository'] ?? ''));
        $branch = trim((string)($input['branch'] ?? $target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH;
        $normalizedRepository = self::normalizeGithubRepositoryName($repository);
        if ($normalizedRepository !== '') {
            $repository = $normalizedRepository;
        }
        $rawCommitSha = trim((string)($input['commit_sha'] ?? $input['commit'] ?? ''));
        $commitMode = $this->normalizeCommitMode((string)($input['commit_mode'] ?? ''), $rawCommitSha);
        $commitSha = $commitMode === 'specific' && $rawCommitSha !== '' ? $rawCommitSha : null;
        $githubAccess = null;
        if ($repository !== '') {
            $githubAccess = $this->githubRepositoryAccess([
                'repository' => $repository,
                'branch' => $branch,
                'commit_sha' => $commitSha,
                'commit_mode' => $commitMode,
            ]);
            if (($githubAccess['token_configured'] ?? false) && !($githubAccess['ok'] ?? false)) {
                throw new RuntimeException('GitHub repository access test failed: ' . (string)($githubAccess['message'] ?? 'Repository is not accessible.'));
            }
            if (($githubAccess['ok'] ?? false) && !empty($githubAccess['commit_sha'])) {
                $commitSha = (string)$githubAccess['commit_sha'];
                $branch = (string)($githubAccess['branch'] ?? $branch);
            }
        }
        $versionLabel = trim((string)($input['version_label'] ?? $input['tag'] ?? $commitSha ?? date('Ymd-His'))) ?: date('Ymd-His');
        $targetPublicUrl = is_array($target) ? $this->releaseTargetPublicBaseUrl($target) : null;
        $deployedUrl = $this->normalizeReleasePublicBaseUrl(
            $input['deployed_url'] ?? $targetPublicUrl ?? $target['health_url'] ?? null,
            $app
        );

        $versionId = $this->nullablePositiveInt($input['version_id'] ?? null);
        if ($versionId !== null) {
            $version = $this->getVersion($versionId);
            if ((string)($version['app'] ?? '') !== $app) {
                throw new RuntimeException('Release bundle version does not match the deployment app.');
            }
            $this->execute(
                "UPDATE release_versions
                 SET repository = COALESCE(NULLIF(?, ''), repository),
                     branch = COALESCE(NULLIF(?, ''), branch),
                     commit_sha = COALESCE(?, commit_sha),
                     version_label = COALESCE(NULLIF(?, ''), version_label),
                     deployed_url = COALESCE(?, deployed_url),
                     status = 'deploying'
                 WHERE id = ?",
                'sssssi',
                [$repository, $branch, $commitSha, $versionLabel, $deployedUrl, $versionId]
            );
        } else {
            $versionId = $this->createVersion([
                'app' => $app,
                'repository' => $repository,
                'branch' => $branch,
                'commit_sha' => $commitSha,
                'tag' => trim((string)($input['tag'] ?? '')) ?: null,
                'version_label' => $versionLabel,
                'build_url' => trim((string)($input['build_url'] ?? '')) ?: null,
                'artifact_url' => trim((string)($input['artifact_url'] ?? '')) ?: null,
                'deployed_url' => $deployedUrl,
                'status' => 'deploying',
                'metadata' => is_array($input['metadata'] ?? null) ? $input['metadata'] : [],
            ]);
        }

        $requestedPayload = self::redactPayload($input);
        if (is_array($requestedPayload)) {
            $requestedPayload['commit_mode'] = $commitMode;
            $requestedPayload['github_access'] = $githubAccess;
        }
        $targetId = isset($target['id']) ? (int)$target['id'] : null;
        $serviceSetId = $this->nullablePositiveInt($input['service_set_id'] ?? null);
        $bundleId = $this->nullablePositiveInt($input['bundle_id'] ?? null);
        $deploymentKind = self::safeIdentifier((string)($input['deployment_kind'] ?? 'single_app'), 32) ?: 'single_app';
        $this->execute(
            "INSERT INTO release_deployments (
                channel_id, target_id, version_id, service_set_id, bundle_id, deployment_kind, app, provider, repository, branch,
                commit_sha, status, actor_user_id, requested_payload_json, started_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, 'coolify', ?, ?, ?, 'deploying', ?, ?, NOW())",
            'iiiiisssssis',
            [
                (int)$channel['id'],
                $targetId,
                $versionId,
                $serviceSetId,
                $bundleId,
                $deploymentKind,
                $app,
                $repository,
                $branch,
                $commitSha,
                $actorUserId,
                self::jsonEncode($requestedPayload),
            ]
        );
        $deploymentId = $this->insertId();

        try {
            $result = ['message' => 'Deployment recorded; no Coolify service target is configured.'];
            $status = 'queued';
            if ($target !== null && !empty($target['coolify_instance_id'])) {
                $coolifyTarget = array_replace($target, [
                    'repository' => $repository,
                    'branch' => $branch,
                    'commit_sha' => $commitSha ?? '',
                ]);
                $result = $this->deployCoolifyReleaseTarget($coolifyTarget);
                $status = 'deployed';
                if ($app === 'api') {
                    $result['cron_worker'] = $this->deployCronWorkerAfterApiDeployment(
                        $coolifyTarget,
                        $commitSha,
                        $actorUserId,
                        $deploymentId
                    );
                }
            }
            $effectiveDeployedUrl = $this->normalizeReleasePublicBaseUrl($result['public_url'] ?? $deployedUrl, $app);

            $this->execute(
                "UPDATE release_deployments
                 SET status = ?, result_json = ?, deployment_url = ?, completed_at = CASE WHEN ? = 'deployed' THEN NOW() ELSE NULL END
                 WHERE id = ?",
                'ssssi',
                [$status, self::jsonEncode(self::redactPayload($result)), $effectiveDeployedUrl, $status, $deploymentId]
            );
            $this->execute(
                "UPDATE release_versions
                 SET status = ?,
                     deployed_url = COALESCE(?, deployed_url),
                     deployed_at = CASE WHEN ? = 'deployed' THEN NOW() ELSE deployed_at END
                 WHERE id = ?",
                'sssi',
                [$status === 'deployed' ? 'deployed' : 'deploying', $effectiveDeployedUrl, $status, $versionId]
            );
            $this->audit((int)$channel['id'], $deploymentId, 'deployment_started', $actorUserId, 'info', [
                'app' => $app,
                'repository' => $repository,
                'branch' => $branch,
                'commit_sha' => $commitSha,
                'commit_mode' => $commitMode,
                'github_access_status' => $githubAccess['status'] ?? null,
                'status' => $status,
            ]);
        } catch (Throwable $throwable) {
            $failureSummary = self::deploymentFailureSummary($throwable, [
                'app' => $app,
                'repository' => $repository,
                'branch' => $branch,
                'commit_sha' => $commitSha,
                'target_id' => $targetId,
                'coolify_instance_id' => is_array($target) ? ($target['coolify_instance_id'] ?? null) : null,
                'coolify_service_uuid' => is_array($target) ? ($target['coolify_service_uuid'] ?? null) : null,
            ]);
            $failureResult = [
                'message' => 'Deployment failed before promotion. A successful deployment is required before promotion.',
                'failure_summary' => $failureSummary,
            ];
            $this->execute(
                "UPDATE release_deployments SET status = 'failed', result_json = ?, error_message = ?, completed_at = NOW() WHERE id = ?",
                'ssi',
                [self::jsonEncode($failureResult), $throwable->getMessage(), $deploymentId]
            );
            $this->execute("UPDATE release_versions SET status = 'failed' WHERE id = ?", 'i', [$versionId]);
            $this->audit((int)$channel['id'], $deploymentId, 'deployment_failed', $actorUserId, 'error', [
                'error' => $throwable->getMessage(),
                'failure_summary' => $failureSummary,
                'app' => $app,
            ]);
        }

        return $this->publicDeployment($this->getDeployment($deploymentId));
    }

    private function deployCronWorkerAfterApiDeployment(array $apiTarget, ?string $commitSha, ?int $actorUserId, int $deploymentId): array
    {
        if (!$this->cronWorkerAutoprovisionEnabled($apiTarget)) {
            return [
                'ok' => true,
                'skipped' => true,
                'required' => false,
                'reason' => 'cron_worker_autoprovision_disabled',
            ];
        }

        try {
            $result = $this->deployCronWorkerForApiTarget($apiTarget, $commitSha, $actorUserId, false, $deploymentId);
            $result['required'] = $this->cronWorkerAutoprovisionRequired($apiTarget);
            return $result;
        } catch (Throwable $throwable) {
            $channelId = (int)($apiTarget['channel_id'] ?? 0) ?: null;
            $this->audit($channelId, $deploymentId, 'cron_worker_deploy_failed', $actorUserId, 'warning', [
                'api_target_id' => (int)($apiTarget['id'] ?? 0),
                'commit_sha' => $commitSha,
                'error' => $throwable->getMessage(),
                'required' => $this->cronWorkerAutoprovisionRequired($apiTarget),
            ]);

            if ($this->cronWorkerAutoprovisionRequired($apiTarget)) {
                throw new RuntimeException(
                    'Cron worker deployment is required for API deployments but did not complete: ' . $throwable->getMessage(),
                    0,
                    $throwable
                );
            }

            return [
                'ok' => false,
                'required' => false,
                'warning' => 'API deployment completed, but the cron worker deployment did not complete.',
                'error' => $throwable->getMessage(),
            ];
        }
    }

    private function cronWorkerAutoprovisionEnabled(array $apiTarget): bool
    {
        $context = self::jsonDecode($apiTarget['deploy_context_json'] ?? null);
        foreach (['cron_worker_autoprovision', 'cron_worker_enabled'] as $key) {
            if (array_key_exists($key, $context)) {
                return $this->toBool($context[$key]);
            }
        }

        return true;
    }

    private function cronWorkerAutoprovisionRequired(array $apiTarget): bool
    {
        if (!$this->cronWorkerAutoprovisionEnabled($apiTarget)) {
            return false;
        }

        $context = self::jsonDecode($apiTarget['deploy_context_json'] ?? null);
        foreach (['cron_worker_autoprovision_required', 'cron_worker_required'] as $key) {
            if (array_key_exists($key, $context)) {
                return $this->toBool($context[$key]);
            }
        }

        return true;
    }

    public function promoteDeployment(int $deploymentId, ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        $deployment = $this->getDeployment($deploymentId);
        if (!self::deploymentCanBePromoted((string)($deployment['status'] ?? ''))) {
            throw new RuntimeException(self::deploymentPromotionBlockedReason($deployment));
        }
        $versionId = (int)($deployment['version_id'] ?? 0);
        if ($versionId <= 0) {
            throw new RuntimeException('Deployment has no release version to promote.');
        }

        $channelId = (int)$deployment['channel_id'];
        if ($this->channelUsesProductionServices($this->getChannel($channelId))) {
            throw new RuntimeException('Beta release channel uses production services and does not promote separate deployments.');
        }
        $this->assertReleaseGatePassedForPromotion(
            $channelId,
            (string)($deployment['commit_sha'] ?? ''),
            null,
            (string)($deployment['app'] ?? '')
        );
        $current = $this->currentChannelVersionRow($channelId);
        $frontendVersionId = (int)($current['frontend_version_id'] ?? 0) ?: null;
        $apiVersionId = (int)($current['api_version_id'] ?? 0) ?: null;
        if ((string)$deployment['app'] === 'frontend') {
            $frontendVersionId = $versionId;
        } else {
            $apiVersionId = $versionId;
        }

        $this->execute('UPDATE release_channel_versions SET active = 0 WHERE channel_id = ?', 'i', [$channelId]);
        $this->execute(
            "INSERT INTO release_channel_versions (channel_id, frontend_version_id, api_version_id, deployment_id, actor_user_id, active)
             VALUES (?, ?, ?, ?, ?, 1)",
            'iiiii',
            [$channelId, $frontendVersionId, $apiVersionId, $deploymentId, $actorUserId]
        );
        $this->activateDeploymentForChannelApp($deploymentId, $channelId, (string)$deployment['app']);
        $this->execute("UPDATE release_versions SET status = 'active', deployed_at = COALESCE(deployed_at, NOW()) WHERE id = ?", 'i', [$versionId]);

        $this->audit($channelId, $deploymentId, 'deployment_promoted', $actorUserId, 'info', [
            'app' => $deployment['app'],
            'version_id' => $versionId,
        ]);

        return [
            'channel' => $this->publicChannel($this->getChannel($channelId)),
            'versions' => $this->currentVersionsForChannel($channelId),
            'deployment' => $this->publicDeployment($this->getDeployment($deploymentId)),
        ];
    }

    public function rollbackChannel(int $channelId, ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        $channel = $this->getChannel($channelId);
        $previous = $this->selectOne(
            "SELECT * FROM release_channel_versions
             WHERE channel_id = ? AND active = 0
             ORDER BY activated_at DESC, id DESC
             LIMIT 1",
            'i',
            [$channelId]
        );
        if ($previous === null) {
            throw new RuntimeException('No previous release version exists for this channel.');
        }

        $this->execute('UPDATE release_channel_versions SET active = 0 WHERE channel_id = ?', 'i', [$channelId]);
        $this->execute(
            "INSERT INTO release_channel_versions (
                channel_id, frontend_version_id, api_version_id, deployment_id,
                service_set_id, bundle_id, actor_user_id, active
            ) VALUES (?, ?, ?, ?, ?, ?, ?, 1)",
            'iiiiiii',
            [
                $channelId,
                (int)($previous['frontend_version_id'] ?? 0) ?: null,
                (int)($previous['api_version_id'] ?? 0) ?: null,
                (int)($previous['deployment_id'] ?? 0) ?: null,
                (int)($previous['service_set_id'] ?? 0) ?: null,
                (int)($previous['bundle_id'] ?? 0) ?: null,
                $actorUserId,
            ]
        );
        foreach (self::APPS as $app) {
            $versionId = $this->nullablePositiveInt($previous[$app . '_version_id'] ?? null);
            if ($versionId === null) {
                continue;
            }
            $deployment = $this->selectOne(
                "SELECT id FROM release_deployments
                 WHERE channel_id = ? AND app = ? AND version_id = ?
                 ORDER BY completed_at DESC, id DESC
                 LIMIT 1",
                'isi',
                [$channelId, $app, $versionId]
            );
            if ($deployment !== null) {
                $this->activateDeploymentForChannelApp((int)$deployment['id'], $channelId, $app);
            }
            $this->execute(
                "UPDATE release_versions SET status = 'active', deployed_at = COALESCE(deployed_at, NOW()) WHERE id = ?",
                'i',
                [$versionId]
            );
        }

        $this->audit($channelId, (int)($previous['deployment_id'] ?? 0) ?: null, 'channel_rolled_back', $actorUserId, 'warning', [
            'previous_channel_version_id' => $previous['id'] ?? null,
            'frontend_version_id' => $this->nullablePositiveInt($previous['frontend_version_id'] ?? null),
            'api_version_id' => $this->nullablePositiveInt($previous['api_version_id'] ?? null),
            'service_set_id' => $this->nullablePositiveInt($previous['service_set_id'] ?? null),
            'bundle_id' => $this->nullablePositiveInt($previous['bundle_id'] ?? null),
            'public_smoke_required' => true,
        ]);

        return [
            'channel' => $this->publicChannel($channel),
            'versions' => $this->currentVersionsForChannel($channelId),
        ];
    }

    public function setReplayTarget(array $input, ?int $actorUserId = null): array
    {
        $this->ensureSchema();
        $targetType = strtolower(trim((string)($input['target_type'] ?? '')));
        if (!in_array($targetType, ['user', 'subuser', 'customer', 'channel'], true)) {
            throw new RuntimeException('Invalid replay target type.');
        }

        $targetId = trim((string)($input['target_id'] ?? '')) ?: null;
        $channel = null;
        if ($targetType === 'channel' || isset($input['channel_id']) || isset($input['channel_slug'])) {
            $channel = $this->channelFromInput($input);
            $targetId = $targetId ?: (string)$channel['slug'];
        }

        $captureLevel = $this->normalizeCaptureLevel((string)($input['capture_level'] ?? 'full_redacted'));
        $enabled = $this->toBool($input['enabled'] ?? true) ? 1 : 0;
        $expiresAt = $this->normalizeDateTime($input['expires_at'] ?? null);

        $this->execute(
            "INSERT INTO release_replay_targets (target_type, target_id, channel_id, capture_level, enabled, expires_at, actor_user_id)
             VALUES (?, ?, ?, ?, ?, ?, ?)",
            'ssisisi',
            [$targetType, $targetId, $channel['id'] ?? null, $captureLevel, $enabled, $expiresAt, $actorUserId]
        );

        $id = $this->insertId();
        $this->audit($channel !== null ? (int)$channel['id'] : null, null, 'replay_target_created', $actorUserId, 'warning', [
            'target_type' => $targetType,
            'target_id' => $targetId,
            'capture_level' => $captureLevel,
            'enabled' => (bool)$enabled,
        ]);

        return $this->selectOne('SELECT * FROM release_replay_targets WHERE id = ?', 'i', [$id]) ?? [];
    }

    public function ingestTimelineEvents(array $events, array $context = [], bool $ensureSchema = true): array
    {
        if ($ensureSchema) {
            $this->ensureSchema();
        }
        if ($events === [] || !isset($events[0])) {
            $events = [$events];
        }

        $traceId = self::safeIdentifier((string)($context['trace_id'] ?? $this->requestTraceId()), 64);
        if ($traceId === '') {
            $traceId = $this->requestTraceId();
        }

        $principalContext = $this->currentPrincipalContext();
        $context = array_replace($principalContext, array_filter($context, static fn(mixed $value): bool => $value !== null && $value !== ''));

        $channelSlug = self::safeSlug((string)($context['channel_slug'] ?? $context['release_channel'] ?? ''));
        $channel = $channelSlug !== '' ? $this->findChannelBySlug($channelSlug) : null;
        if ($channel === null) {
            $channel = $this->resolveChannel($context);
        }
        if (empty($context['route_path']) && empty($context['route'])) {
            foreach (array_reverse($events) as $eventForRoute) {
                if (!is_array($eventForRoute)) {
                    continue;
                }
                $route = trim((string)($eventForRoute['route_path'] ?? $eventForRoute['route'] ?? ''));
                if ($route !== '') {
                    $context['route_path'] = $route;
                    break;
                }
            }
        }
        $sessionId = $this->timelineSessionId($traceId, $context, $channel);
        $accepted = 0;

        foreach ($events as $event) {
            if (!is_array($event)) {
                continue;
            }
            $eventType = self::safeIdentifier((string)($event['type'] ?? $event['event_type'] ?? 'event'), 64) ?: 'event';
            $severity = self::safeIdentifier((string)($event['severity'] ?? 'info'), 16) ?: 'info';
            $payload = self::redactPayload($event['payload'] ?? $event);
            $occurredAt = $this->normalizeDateTime($event['occurred_at'] ?? null) ?? date('Y-m-d H:i:s');

            $this->execute(
                "INSERT INTO release_timeline_events (
                    timeline_session_id, trace_id, event_type, severity, module_key,
                    route_path, component, request_id, occurred_at, payload_json
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                'isssssssss',
                [
                    $sessionId,
                    $traceId,
                    $eventType,
                    $severity,
                    self::safeIdentifier((string)($event['module_key'] ?? ''), 64) ?: null,
                    trim((string)($event['route'] ?? $event['route_path'] ?? '')) ?: null,
                    trim((string)($event['component'] ?? '')) ?: null,
                    trim((string)($event['request_id'] ?? '')) ?: null,
                    $occurredAt,
                    self::jsonEncode($payload),
                ]
            );
            $accepted++;
        }

        return ['accepted' => $accepted, 'trace_id' => $traceId, 'timeline_session_id' => $sessionId];
    }

    public function searchTimeline(array $filters = []): array
    {
        $this->ensureSchema();
        $this->cleanupExpiredReplayData();
        $types = '';
        $params = [];
        $where = ['1 = 1'];

        foreach ([
            'trace_id' => 'e.trace_id',
            'event_type' => 'e.event_type',
            'severity' => 'e.severity',
            'module_key' => 'e.module_key',
            'channel_slug' => 's.channel_slug',
            'principal_type' => 's.principal_type',
            'principal_id' => 's.principal_id',
        ] as $filterKey => $column) {
            $value = trim((string)($filters[$filterKey] ?? ''));
            if ($value === '') {
                continue;
            }
            $where[] = "$column = ?";
            $types .= 's';
            $params[] = $value;
        }

        if (isset($filters['customer_number']) && is_numeric($filters['customer_number'])) {
            $where[] = 's.customer_number = ?';
            $types .= 'i';
            $params[] = (int)$filters['customer_number'];
        }

        $limit = max(1, min(500, (int)($filters['limit'] ?? 100)));
        $rows = $this->selectRows(
            "SELECT e.*, s.principal_type, s.principal_id, s.customer_number, s.channel_slug
             FROM release_timeline_events e
             LEFT JOIN release_timeline_sessions s ON s.id = e.timeline_session_id
             WHERE " . implode(' AND ', $where) . "
             ORDER BY e.occurred_at DESC, e.id DESC
             LIMIT $limit",
            $types,
            $params
        );

        return array_map(fn(array $row): array => $this->publicTimelineEvent($row), $rows);
    }

    public function listTimelineSessions(array $filters = []): array
    {
        $this->ensureSchema();
        $this->cleanupExpiredReplayData();

        $types = '';
        $params = [];
        $where = ['1 = 1'];

        foreach ([
            'trace_id' => 's.trace_id',
            'principal_type' => 's.principal_type',
            'principal_id' => 's.principal_id',
            'device_type' => 's.device_type',
            'channel_slug' => 's.channel_slug',
            'frontend_version' => 's.frontend_version_label',
            'api_version' => 's.api_version_label',
            'event_type' => 'e.event_type',
            'severity' => 'e.severity',
            'module_key' => 'e.module_key',
        ] as $filterKey => $column) {
            $value = trim((string)($filters[$filterKey] ?? ''));
            if ($value === '') {
                continue;
            }
            $where[] = "$column = ?";
            $types .= 's';
            $params[] = $value;
        }

        if (isset($filters['customer_number']) && is_numeric($filters['customer_number'])) {
            $where[] = 's.customer_number = ?';
            $types .= 'i';
            $params[] = (int)$filters['customer_number'];
        }

        foreach (['date_from' => '>=', 'date_to' => '<='] as $filterKey => $operator) {
            $date = $this->normalizeDateTime($filters[$filterKey] ?? null);
            if ($date === null) {
                continue;
            }
            $where[] = "s.last_seen_at $operator ?";
            $types .= 's';
            $params[] = $date;
        }

        $hasErrorReport = $this->toBool($filters['has_error_report'] ?? false);
        $hasErrorReportTable = $this->tableExists('error_reports');
        if ($hasErrorReport && $hasErrorReportTable) {
            $where[] = 'EXISTS (SELECT 1 FROM error_reports er_filter WHERE er_filter.release_trace_id = s.trace_id)';
        } elseif ($hasErrorReport) {
            $where[] = '1 = 0';
        }

        $errorReportCountSelect = $hasErrorReportTable
            ? "(SELECT COUNT(*) FROM error_reports er_count WHERE er_count.release_trace_id = s.trace_id) AS error_report_count"
            : '0 AS error_report_count';

        $limit = max(1, min(500, (int)($filters['limit'] ?? 100)));
        $rows = $this->selectRows(
            "SELECT
                s.*,
                COUNT(e.id) AS event_count,
                SUM(CASE WHEN e.severity = 'error' THEN 1 ELSE 0 END) AS error_count,
                MIN(e.occurred_at) AS first_event_at,
                MAX(e.occurred_at) AS last_event_at,
                GROUP_CONCAT(DISTINCT e.module_key ORDER BY e.module_key SEPARATOR ',') AS module_keys,
                $errorReportCountSelect
             FROM release_timeline_sessions s
             LEFT JOIN release_timeline_events e ON e.timeline_session_id = s.id
             WHERE " . implode(' AND ', $where) . "
             GROUP BY s.id
             ORDER BY COALESCE(MAX(e.occurred_at), s.last_seen_at) DESC, s.id DESC
             LIMIT $limit",
            $types,
            $params
        );

        return array_map(fn(array $row): array => $this->publicTimelineSession($row), $rows);
    }

    public function timelineSessionDetail(string $traceId): array
    {
        $this->ensureSchema();
        $this->cleanupExpiredReplayData();

        $traceId = self::safeIdentifier($traceId, 64);
        if ($traceId === '') {
            throw new RuntimeException('Invalid timeline trace id.');
        }

        $session = $this->selectOne(
            'SELECT * FROM release_timeline_sessions WHERE trace_id = ? LIMIT 1',
            's',
            [$traceId]
        );
        if ($session === null) {
            throw new RuntimeException('Timeline session not found.');
        }

        $events = $this->selectRows(
            "SELECT e.*, s.principal_type, s.principal_id, s.customer_number, s.channel_slug
             FROM release_timeline_events e
             LEFT JOIN release_timeline_sessions s ON s.id = e.timeline_session_id
             WHERE e.trace_id = ?
             ORDER BY e.occurred_at ASC, e.id ASC",
            's',
            [$traceId]
        );

        $channel = isset($session['channel_id']) ? $this->selectOne('SELECT * FROM release_channels WHERE id = ? LIMIT 1', 'i', [(int)$session['channel_id']]) : null;

        return [
            'session' => $this->publicTimelineSession($session),
            'events' => array_map(fn(array $row): array => $this->publicTimelineEvent($row), $events),
            'error_reports' => $this->timelineErrorReports($traceId),
            'release' => $this->timelineReleaseContext($session, $channel),
        ];
    }

    public function handleGithubWebhook(array $headers, string $rawBody): array
    {
        $this->ensureSchema();
        $secret = (string)$this->moduleConfigValue('ReleaseManager', 'github_webhook_secret', '');
        $signature = self::headerValue($headers, 'X-Hub-Signature-256');
        if (!self::verifyGithubSignature($secret, $rawBody, $signature)) {
            throw new RuntimeException('Invalid GitHub webhook signature.');
        }

        $event = self::headerValue($headers, 'X-GitHub-Event') ?: 'unknown';
        $payload = json_decode($rawBody, true);
        if (!is_array($payload)) {
            throw new RuntimeException('Invalid GitHub webhook JSON payload.');
        }

        if ($event !== 'push') {
            $this->audit(null, null, 'github_webhook_ignored', null, 'info', ['event' => $event]);
            return ['event' => $event, 'deployments' => [], 'ignored' => true];
        }

        $repository = (string)($payload['repository']['full_name'] ?? $payload['repository']['name'] ?? '');
        $branch = preg_replace('#^refs/heads/#', '', (string)($payload['ref'] ?? ''));
        $commitSha = (string)($payload['after'] ?? '');
        if ($repository === '' || $branch === '' || $commitSha === '') {
            throw new RuntimeException('GitHub push payload is missing repository, branch, or commit.');
        }

        $normalizedRepository = self::normalizeGithubRepositoryName($repository);
        if ($normalizedRepository !== '') {
            $repository = $normalizedRepository;
        }

        $mappedChannelSlug = self::channelSlugForRoute($branch);
        $mappedChannel = $this->findChannelBySlug($mappedChannelSlug);
        $mappedApp = '';
        if ($repository === self::defaultRepositoryForApp('frontend')) {
            $mappedApp = 'frontend';
        } elseif ($repository === self::defaultRepositoryForApp('api')) {
            $mappedApp = 'api';
        }

        $targets = $this->selectRows(
            "SELECT * FROM release_deployment_targets
             WHERE deleted_at IS NULL AND auto_deploy = 1 AND repository = ? AND branch = ?",
            'ss',
            [$repository, $branch]
        );

        $autoSyncEvents = [];
        foreach ($targets as $target) {
            $autoSyncEvents[] = $this->publicReleaseAutoSyncEvent($this->upsertReleaseAutoSyncEvent([
                'channel_id' => (int)$target['channel_id'],
                'app' => (string)$target['app'],
                'repository' => $repository,
                'branch' => $branch,
                'commit_sha' => $commitSha,
                'status' => 'pending',
                'source' => 'github_webhook',
                'workflow_url' => (string)($payload['compare'] ?? ''),
                'metadata' => [
                    'github_event' => $event,
                    'head_commit' => self::redactPayload($payload['head_commit'] ?? []),
                ],
            ]));
        }

        $this->audit(null, null, 'github_webhook_processed', null, 'info', [
            'repository' => $repository,
            'branch' => $branch,
            'commit_sha' => $commitSha,
            'auto_sync_event_count' => count($autoSyncEvents),
        ]);

        return [
            'event' => $event,
            'repository' => $repository,
            'branch' => $branch,
            'commit_sha' => $commitSha,
            'mapped_channel_slug' => $mappedChannelSlug,
            'mapped_app' => $mappedApp !== '' ? $mappedApp : null,
            'auto_sync_events' => $autoSyncEvents,
            'operations' => [],
            'deployments' => [],
        ];
    }

    public function healthProbe(): array
    {
        $startedAt = microtime(true);
        try {
            $summary = $this->summary();
            $channels = $summary['channels'] ?? [];
            $deployments = $summary['deployments'] ?? [];
            $failedDeployments = array_values(array_filter($deployments, static fn(array $deployment): bool => ($deployment['status'] ?? '') === 'failed'));
            $channelsWithoutVersions = array_values(array_filter($channels, static function (array $channel): bool {
                if (($channel['enabled'] ?? false) !== true) {
                    return false;
                }
                $versions = $channel['versions'] ?? [];
                return empty($versions['frontend']) && empty($versions['api']);
            }));

            $status = 'ok';
            $reason = 'Release channels and deployment telemetry are available.';
            $reasonKey = 'release_manager_available';
            if ($failedDeployments !== []) {
                $status = 'degraded';
                $reason = 'One or more recent release deployments failed.';
                $reasonKey = 'release_deployments_failed';
            } elseif ($channelsWithoutVersions !== []) {
                $status = 'degraded';
                $reason = 'One or more enabled release channels have no active versions yet.';
                $reasonKey = 'release_channels_without_versions';
            }

            return [
                'status' => $status,
                'status_reason' => $reason,
                'status_reason_key' => $reasonKey,
                'status_reason_params' => [
                    'channels' => count($channels),
                    'deployment_targets' => count($summary['deployment_targets'] ?? []),
                    'recent_failed_deployments' => count($failedDeployments),
                ],
                'checked_at' => date('c'),
                'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
            ];
        } catch (Throwable $throwable) {
            return [
                'status' => 'down',
                'status_reason' => 'Release manager probe failed: ' . $throwable->getMessage(),
                'status_reason_key' => 'release_manager_probe_failed',
                'status_reason_params' => ['error' => $throwable->getMessage()],
                'checked_at' => date('c'),
                'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
            ];
        }
    }

    private function ensureSchema(): void
    {
        if ($this->schemaEnsured) {
            return;
        }
        release_manager_schema_bootstrap::ensureTables();
        $this->schemaEnsured = true;
    }

    private function resolveChannel(array $context): array
    {
        $cacheKey = $this->assignmentCacheKey($context);
        if ($cacheKey !== '' && defined('redis')) {
            try {
                $cached = redis->get($cacheKey);
                if (is_string($cached) && $cached !== '') {
                    $decoded = json_decode($cached, true);
                    if (is_array($decoded) && !empty($decoded['id'])) {
                        return $decoded;
                    }
                }
            } catch (Throwable) {
            }
        }

        $candidates = [];
        if (!empty($context['principal_type']) && !empty($context['principal_id'])) {
            $candidates[] = [(string)$context['principal_type'], (string)$context['principal_id']];
        }
        if (!empty($context['customer_number'])) {
            $candidates[] = ['customer', (string)(int)$context['customer_number']];
        }

        foreach ($candidates as [$subjectType, $subjectId]) {
            $row = $this->selectOne(
                "SELECT c.*
                 FROM release_assignments a
                 INNER JOIN release_channels c ON c.id = a.channel_id
                 WHERE a.deleted_at IS NULL
                   AND c.deleted_at IS NULL
                   AND c.enabled = 1
                   AND a.subject_type = ?
                   AND a.subject_id = ?
                   AND (a.expires_at IS NULL OR a.expires_at > NOW())
                 ORDER BY a.created_at DESC, a.id DESC
                 LIMIT 1",
                'ss',
                [$subjectType, $subjectId]
            );
            if ($row !== null) {
                $this->cacheResolvedChannel($cacheKey, $row);
                return $row;
            }
        }

        $rolloutChannel = $this->rolloutChannelForContext($context);
        if ($rolloutChannel !== null) {
            $this->cacheResolvedChannel($cacheKey, $rolloutChannel);
            return $rolloutChannel;
        }

        $default = $this->defaultChannel();
        $this->cacheResolvedChannel($cacheKey, $default);
        return $default;
    }

    private function runtimeChannelsForContext(array $context, ?array $resolvedChannel = null): array
    {
        $channelsByKey = [];

        $this->addRuntimeChannel($channelsByKey, $this->defaultChannel());

        foreach ($this->assignmentCandidates($context) as [$subjectType, $subjectId]) {
            $rows = $this->selectRows(
                "SELECT c.*
                 FROM release_assignments a
                 INNER JOIN release_channels c ON c.id = a.channel_id
                 WHERE a.deleted_at IS NULL
                   AND c.deleted_at IS NULL
                   AND c.enabled = 1
                   AND a.subject_type = ?
                   AND a.subject_id = ?
                   AND (a.expires_at IS NULL OR a.expires_at > NOW())
                 ORDER BY a.created_at DESC, a.id DESC",
                'ss',
                [$subjectType, $subjectId]
            );

            foreach ($rows as $row) {
                $this->addRuntimeChannel($channelsByKey, $row);
            }
        }

        if ($resolvedChannel !== null) {
            $this->addRuntimeChannel($channelsByKey, $resolvedChannel);
        }

        return array_values($channelsByKey);
    }

    private function assignmentCandidates(array $context): array
    {
        $candidates = [];
        if (!empty($context['principal_type']) && !empty($context['principal_id'])) {
            $candidates[] = [(string)$context['principal_type'], (string)$context['principal_id']];
        }
        if (!empty($context['customer_number'])) {
            $candidates[] = ['customer', (string)(int)$context['customer_number']];
        }
        return $candidates;
    }

    private function addRuntimeChannel(array &$channelsByKey, array $channel): void
    {
        $id = (int)($channel['id'] ?? 0);
        $slug = self::safeSlug((string)($channel['slug'] ?? ''));
        $key = $id > 0 ? 'id:' . $id : ($slug !== '' ? 'slug:' . $slug : '');
        if ($key === '' || isset($channelsByKey[$key])) {
            return;
        }
        $channelsByKey[$key] = $channel;
    }

    private function chooseRuntimeChannel(array $resolvedChannel, array $availableChannels, string $requestedSlug): array
    {
        if ($requestedSlug === '') {
            return $resolvedChannel;
        }

        foreach ($availableChannels as $channel) {
            if (self::safeSlug((string)($channel['slug'] ?? '')) === $requestedSlug) {
                return $channel;
            }
        }

        return $resolvedChannel;
    }

    private function runtimeServiceChannelFor(array $channel): array
    {
        if (!$this->channelUsesProductionServices($channel)) {
            return $channel;
        }

        return $this->productionServiceChannel();
    }

    private function productionServiceChannel(): array
    {
        $channel = $this->selectOne(
            "SELECT *
             FROM release_channels
             WHERE deleted_at IS NULL
               AND enabled = 1
               AND default_channel = 1
               AND slug <> 'beta'
             ORDER BY id
             LIMIT 1"
        );
        if ($channel !== null) {
            return $channel;
        }

        foreach (self::BETA_PRODUCTION_DATA_SOURCE_CHANNELS as $slug) {
            $channel = $this->findChannelBySlug($slug);
            if ($channel !== null && (int)($channel['enabled'] ?? 0) === 1 && !$this->channelUsesProductionServices($channel)) {
                return $channel;
            }
        }

        $default = $this->defaultChannel();
        if ($this->channelUsesProductionServices($default)) {
            throw new RuntimeException('No production release channel is configured for beta services.');
        }

        return $default;
    }

    private function requestedRuntimeChannelSlug(array $input = []): string
    {
        $value = $input['release_channel']
            ?? $input['channel_slug']
            ?? ($_GET['release_channel'] ?? $_GET['channel_slug'] ?? '');
        return self::safeSlug((string)$value);
    }

    private function rolloutChannelForContext(array $context): ?array
    {
        $seed = (string)($context['principal_id'] ?? $context['customer_number'] ?? '');
        if ($seed === '') {
            return null;
        }

        $bucket = (hexdec(substr(hash('sha256', $seed), 0, 8)) % 10000) / 100;
        $channels = $this->selectRows(
            "SELECT * FROM release_channels
             WHERE deleted_at IS NULL AND enabled = 1 AND default_channel = 0 AND rollout_percent > 0
             ORDER BY rollout_percent DESC, slug"
        );

        foreach ($channels as $channel) {
            if ($bucket < (float)$channel['rollout_percent']) {
                return $channel;
            }
        }

        return null;
    }

    private function capturePolicyFor(array $context, array $channel): array
    {
        $enabled = (bool)((int)($channel['replay_enabled'] ?? 0));
        $captureLevel = $this->normalizeCaptureLevel((string)($channel['capture_level'] ?? 'metadata'));
        $retentionDays = max(1, (int)($channel['retention_days'] ?? 14));

        $targets = [];
        if (!empty($context['principal_type']) && !empty($context['principal_id'])) {
            $targets[] = [(string)$context['principal_type'], (string)$context['principal_id']];
        }
        if (!empty($context['customer_number'])) {
            $targets[] = ['customer', (string)(int)$context['customer_number']];
        }
        $targets[] = ['channel', (string)$channel['slug']];

        foreach ($targets as [$targetType, $targetId]) {
            $row = $this->selectOne(
                "SELECT capture_level
                 FROM release_replay_targets
                 WHERE deleted_at IS NULL
                   AND enabled = 1
                   AND target_type = ?
                   AND (target_id = ? OR (target_type = 'channel' AND channel_id = ?))
                   AND (expires_at IS NULL OR expires_at > NOW())
                 ORDER BY created_at DESC, id DESC
                 LIMIT 1",
                'ssi',
                [$targetType, $targetId, (int)$channel['id']]
            );
            if ($row !== null) {
                $enabled = true;
                $captureLevel = $this->normalizeCaptureLevel((string)$row['capture_level']);
                break;
            }
        }

        return [
            'enabled' => $enabled,
            'capture_level' => $captureLevel,
            'all_failure_metadata' => true,
            'retention_days' => $retentionDays,
        ];
    }

    private function currentPrincipalContext(): array
    {
        try {
            $auth = new authentication();
            $subuser = $auth->get_subuser();
            if ($subuser !== false) {
                return [
                    'principal_type' => 'subuser',
                    'principal_id' => (string)$subuser->id,
                    'customer_number' => $auth->get_subuser_customer_number_target() ?: null,
                ];
            }
            $user = $auth->get_user();
            if ($user !== false) {
                return [
                    'principal_type' => 'user',
                    'principal_id' => (string)$user->id,
                    'customer_number' => isset($user->customer_number) ? (int)$user->customer_number->value() : null,
                ];
            }
        } catch (Throwable) {
        }

        return [
            'principal_type' => null,
            'principal_id' => null,
            'customer_number' => null,
        ];
    }

    private function defaultChannel(): array
    {
        $channel = $this->selectOne(
            "SELECT * FROM release_channels WHERE deleted_at IS NULL AND enabled = 1 AND default_channel = 1 ORDER BY id LIMIT 1"
        );
        if ($channel !== null) {
            return $channel;
        }

        $channel = $this->selectOne(
            "SELECT * FROM release_channels WHERE deleted_at IS NULL AND slug = 'stable' ORDER BY id LIMIT 1"
        );
        if ($channel !== null) {
            return $channel;
        }

        throw new RuntimeException('No release channel is configured.');
    }

    private function currentVersionsForChannel(int $channelId): array
    {
        $current = $this->currentChannelVersionRow($channelId);
        $frontend = null;
        $api = null;
        if (!empty($current['frontend_version_id'])) {
            $frontend = $this->publicVersion($this->getVersion((int)$current['frontend_version_id']));
        }
        if (!empty($current['api_version_id'])) {
            $api = $this->publicVersion($this->getVersion((int)$current['api_version_id']));
        }
        $serviceSet = null;
        if (!empty($current['service_set_id'])) {
            try {
                $serviceSet = $this->publicServiceSet($this->getServiceSet((int)$current['service_set_id']), false);
            } catch (Throwable) {
                $serviceSet = null;
            }
        }
        $bundle = null;
        if (!empty($current['bundle_id'])) {
            try {
                $bundle = $this->publicBundle($this->getBundle((int)$current['bundle_id']), false);
            } catch (Throwable) {
                $bundle = null;
            }
        }

        return [
            'frontend' => $frontend,
            'api' => $api,
            'service_set' => $serviceSet,
            'bundle_id' => isset($current['bundle_id']) ? (int)$current['bundle_id'] : null,
            'bundle' => $bundle,
        ];
    }

    private function channelAvailability(array $channel): array
    {
        $channelId = (int)($channel['id'] ?? 0);
        if ($this->channelUsesProductionServices($channel)) {
            $serviceChannel = $this->productionServiceChannel();
            $versions = $this->currentVersionsForChannel((int)$serviceChannel['id']);
            $urls = $this->releaseRuntimeUrls($serviceChannel, $versions);
            $availability = $this->channelAvailability($serviceChannel);

            return array_replace($availability, [
                'configured' => (bool)($availability['configured'] ?? false),
                'missing' => is_array($availability['missing'] ?? null) ? $availability['missing'] : [],
                'frontend_base_url' => $urls['frontend_base_url'],
                'api_base_url' => $urls['api_base_url'],
                'service_policy' => self::PRODUCTION_SERVICE_POLICY,
                'service_channel_id' => (int)($serviceChannel['id'] ?? 0),
                'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''),
            ]);
        }

        $isDefault = ((int)($channel['default_channel'] ?? 0) === 1) || (string)($channel['slug'] ?? '') === 'stable';
        if ($isDefault || $channelId <= 0) {
            return [
                'configured' => true,
                'missing' => [],
                'status' => 'ready',
            ];
        }

        $versions = $this->currentVersionsForChannel($channelId);
        $urls = $this->releaseRuntimeUrls($channel, $versions);
        $missing = [];
        if (empty($versions['frontend'])) {
            $missing[] = 'frontend_version';
        } elseif (empty($urls['frontend_base_url'])) {
            $missing[] = 'frontend_base_url';
        }
        if (empty($versions['api'])) {
            $missing[] = 'api_version';
        } elseif (empty($urls['api_base_url'])) {
            $missing[] = 'api_base_url';
        }

        return [
            'configured' => count($missing) === 0,
            'missing' => $missing,
            'bundle_id' => $versions['bundle_id'] ?? null,
            'frontend_base_url' => $urls['frontend_base_url'],
            'api_base_url' => $urls['api_base_url'],
            'status' => count($missing) === 0 ? 'ready' : 'unconfigured',
        ];
    }

    private function releaseRuntimeUrls(array $channel, array $versions): array
    {
        $frontend = is_array($versions['frontend'] ?? null) ? $versions['frontend'] : [];
        $api = is_array($versions['api'] ?? null) ? $versions['api'] : [];
        $serviceSet = is_array($versions['service_set'] ?? null) ? $versions['service_set'] : [];
        $targets = is_array($serviceSet['targets'] ?? null) ? $serviceSet['targets'] : [];
        $frontendTarget = is_array($targets['frontend'] ?? null) ? $targets['frontend'] : null;
        $apiTarget = is_array($targets['api'] ?? null) ? $targets['api'] : null;

        return [
            'frontend_base_url' => $this->normalizeReleasePublicBaseUrl($frontend['deployed_url'] ?? null, 'frontend')
                ?? $this->normalizeReleasePublicBaseUrl($channel['frontend_base_url'] ?? null, 'frontend')
                ?? (is_array($frontendTarget) ? $this->releaseTargetPublicBaseUrl($frontendTarget) : null),
            'api_base_url' => $this->normalizeReleasePublicBaseUrl($api['deployed_url'] ?? null, 'api')
                ?? $this->normalizeReleasePublicBaseUrl($channel['api_base_url'] ?? null, 'api')
                ?? (is_array($apiTarget) ? $this->releaseTargetPublicBaseUrl($apiTarget) : null),
        ];
    }

    private function normalizeReleasePublicBaseUrl(mixed $value, string $app = ''): ?string
    {
        $raw = trim((string)$value);
        if ($raw === '') {
            return null;
        }

        $raw = preg_replace('#/(health|ping)$#i', '', rtrim($raw, '/')) ?: $raw;
        if (preg_match('#^https?://#i', $raw) !== 1) {
            $raw = 'https://' . ltrim($raw, '/');
        }

        $parts = parse_url($raw);
        if (!is_array($parts) || empty($parts['host'])) {
            return null;
        }

        $scheme = strtolower((string)($parts['scheme'] ?? 'https'));
        if (!in_array($scheme, ['http', 'https'], true)) {
            return null;
        }

        $path = isset($parts['path']) ? '/' . trim((string)$parts['path'], '/') : '';
        $port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';
        return rtrim($scheme . '://' . strtolower((string)$parts['host']) . $port . $path, '/');
    }

    private function currentChannelVersionRow(int $channelId): ?array
    {
        return $this->selectOne(
            "SELECT * FROM release_channel_versions
             WHERE channel_id = ? AND active = 1
             ORDER BY activated_at DESC, id DESC
             LIMIT 1",
            'i',
            [$channelId]
        );
    }

    private function createVersion(array $input): int
    {
        $this->execute(
            "INSERT INTO release_versions (
                app, repository, branch, commit_sha, tag, version_label,
                build_url, artifact_url, deployed_url, status, metadata_json
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
            'sssssssssss',
            [
                $input['app'],
                $input['repository'] ?? null,
                $input['branch'] ?? null,
                $input['commit_sha'] ?? null,
                $input['tag'] ?? null,
                $input['version_label'] ?? null,
                $input['build_url'] ?? null,
                $input['artifact_url'] ?? null,
                $input['deployed_url'] ?? null,
                $input['status'] ?? 'discovered',
                self::jsonEncode($input['metadata'] ?? []),
            ]
        );
        return $this->insertId();
    }

    private function restartCoolifyService(int $instanceId, string $serviceUuid): array
    {
        $instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL', 'i', [$instanceId]);
        if ($instance === null) {
            throw new RuntimeException('Coolify instance for release deployment was not found.');
        }
        $client = $this->coolifyClientForInstance($instance, 20);
        return $client->restartService($serviceUuid);
    }

    private function coolifyClientForInstance(array $instance, int $timeoutSeconds = 20): coolify_api_client
    {
        if ($this->coolifyClientFactory !== null) {
            $client = ($this->coolifyClientFactory)($instance, $timeoutSeconds);
            if (!$client instanceof coolify_api_client) {
                throw new RuntimeException('Release Manager Coolify client factory returned an invalid client.');
            }
            return $client;
        }

        $token = replication_secret_box::decrypt((string)$instance['api_token_secret']);
        return new coolify_api_client((string)$instance['base_url'], $token, $timeoutSeconds);
    }

    private function deployCoolifyReleaseTarget(array $target): array
    {
        $instanceId = (int)($target['coolify_instance_id'] ?? 0);
        $instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL', 'i', [$instanceId]);
        if ($instance === null) {
            throw new RuntimeException('Coolify instance for release deployment was not found.');
        }

        $client = $this->coolifyClientForInstance($instance, 20);
        $context = self::jsonDecode($target['deploy_context_json'] ?? null);
        $serviceUuid = trim((string)($target['coolify_service_uuid'] ?? ''));
        $resourceType = $this->releaseCoolifyResourceType($context, $serviceUuid);
        $created = null;
        $publicUrl = $this->releaseCoolifyPublicUrl($target, $context);
        $runtimeEnvUpdate = null;

        if ($serviceUuid === '' && $this->toBool($context['coolify_auto_create'] ?? false)) {
            $githubAppUuid = $this->releaseCoolifyGithubAppUuid($context, $target, $instance, true);
            if ($githubAppUuid !== '') {
                $context['coolify_github_app_uuid'] = $githubAppUuid;
                $applicationPayload = $this->releaseCoolifyApplicationPayload($target, $context, $instance);
                $applicationPayload['instant_deploy'] = false;
                $created = $client->createPrivateGithubAppApplication($applicationPayload);
                $resourceType = 'application';
            } else {
                if (self::releaseCoolifyPublicUrlNeedsStripPrefixLabels($publicUrl)) {
                    throw new RuntimeException('Path-routed release targets require a Coolify application resource with StripPrefix labels. Set coolify_resource_type=application or migrate this target before deploying.');
                }
                if ($this->releaseCoolifyServiceSourceIsMissing($context)) {
                    throw new RuntimeException('Release Manager could not resolve a Coolify GitHub App for this private source repository. Configure one GitHub App in Coolify or set coolify_github_app_uuid on the target; no source-code credentials are required in Release Manager.');
                }
                $servicePayload = $this->releaseCoolifyServicePayload($target, $context, $instance);
                $servicePayload['instant_deploy'] = false;
                $created = $client->createService($servicePayload);
                $resourceType = 'service';
            }
            $serviceUuid = trim((string)($created['uuid'] ?? ''));
            if ($serviceUuid === '') {
                throw new RuntimeException('Coolify did not return a resource UUID for the release target.');
            }
            $context['coolify_resource_type'] = $resourceType;
            $target['coolify_service_uuid'] = $serviceUuid;
            $this->execute(
                'UPDATE release_deployment_targets SET coolify_service_uuid = ?, deploy_context_json = ? WHERE id = ?',
                'ssi',
                [$serviceUuid, self::jsonEncode($context), (int)$target['id']]
            );
        }

        if ($serviceUuid === '') {
            throw new RuntimeException('Select an existing Coolify service or enable Coolify service creation before deployment.');
        }

        $update = null;
        if ($resourceType === 'application') {
            $applicationUpdate = $this->releaseCoolifyApplicationUpdatePayload($target, $context);
            if ($this->toBool($context['coolify_enable_ssl'] ?? false) && $publicUrl !== null) {
                $resource = [];
                try {
                    $resource = $client->getApplication($serviceUuid);
                } catch (Throwable) {
                    $resource = is_array($created) ? $created : [];
                }
                $applicationUpdate = array_replace(
                    $applicationUpdate,
                    $this->releaseCoolifyApplicationRoutePayload(
                        $target,
                        $context,
                        $publicUrl,
                        $serviceUuid,
                        $resource['custom_labels'] ?? null
                    )
                );
            }
            if ($applicationUpdate !== []) {
                $update = $client->updateApplication($serviceUuid, $applicationUpdate);
            }
            $runtimeEnvUpdate = $this->updateCoolifyReleaseRuntimeEnv($client, $serviceUuid, 'application', $target, $context);
        } elseif ($this->toBool($context['coolify_enable_ssl'] ?? false) && $publicUrl !== null) {
            if (self::releaseCoolifyPublicUrlNeedsStripPrefixLabels($publicUrl)) {
                throw new RuntimeException('Path-routed release targets require a Coolify application resource with StripPrefix labels. Set coolify_resource_type=application or migrate this target before deploying.');
            }
            $update = $client->updateService($serviceUuid, [
                'urls' => [
                    [
                        'name' => (string)($target['app'] ?? 'release'),
                        'url' => self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context)),
                    ],
                ],
                'force_domain_override' => true,
            ]);
            $runtimeEnvUpdate = $this->updateCoolifyReleaseRuntimeEnv($client, $serviceUuid, 'service', $target, $context);
        }

        if ($runtimeEnvUpdate === null) {
            $runtimeEnvUpdate = $this->updateCoolifyReleaseRuntimeEnv($client, $serviceUuid, $resourceType, $target, $context);
        }

        $deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context));
        $previousApplications = $this->stopCoolifyPreviousApplications($client, $context, $serviceUuid);
        return [
            'service_uuid' => $serviceUuid,
            'resource_type' => $resourceType,
            'ssl_enabled' => $this->toBool($context['coolify_enable_ssl'] ?? false),
            'public_url' => $publicUrl,
            'created' => self::redactPayload($created ?? []),
            'updated' => self::redactPayload($update ?? []),
            'runtime_env' => $runtimeEnvUpdate,
            'deployment' => self::redactPayload($deployment),
            'previous_applications' => self::redactPayload($previousApplications),
        ];
    }

    private function stopCoolifyPreviousApplications(coolify_api_client $client, array $context, string $activeUuid): array
    {
        $stopped = [];
        foreach ($this->releaseCoolifyPreviousApplicationUuids($context, $activeUuid) as $uuid) {
            try {
                $stopped[] = [
                    'uuid' => $uuid,
                    'status' => 'stop_requested',
                    'result' => $client->stopApplication($uuid),
                ];
            } catch (Throwable $throwable) {
                $stopped[] = [
                    'uuid' => $uuid,
                    'status' => 'warning',
                    'error' => $throwable->getMessage(),
                ];
            }
        }

        return $stopped;
    }

    private function releaseCoolifyPreviousApplicationUuids(array $context, string $activeUuid): array
    {
        $values = [];
        foreach ([
            'coolify_previous_application_uuid',
            'coolify_previous_artifact_app_uuid',
            'coolify_previous_artifact_application_uuid',
            'previous_application_uuid',
            'previous_app_uuid',
        ] as $key) {
            if (is_scalar($context[$key] ?? null)) {
                $values[] = (string)$context[$key];
            }
        }

        foreach ([
            'coolify_previous_application_uuids',
            'coolify_previous_artifact_app_uuids',
            'previous_application_uuids',
            'previous_app_uuids',
        ] as $key) {
            if (!is_array($context[$key] ?? null)) {
                continue;
            }
            foreach ($context[$key] as $value) {
                if (is_scalar($value)) {
                    $values[] = (string)$value;
                }
            }
        }

        $activeUuid = trim($activeUuid);
        $uuids = [];
        foreach ($values as $value) {
            $uuid = trim((string)$value);
            if ($uuid === '' || $uuid === $activeUuid || in_array($uuid, $uuids, true)) {
                continue;
            }
            $uuids[] = $uuid;
        }

        return $uuids;
    }

    private function updateCoolifyReleaseRuntimeEnv(coolify_api_client $client, string $resourceUuid, string $resourceType, array $target, array $context): ?array
    {
        $env = $this->releaseCoolifyRuntimeEnv($target, $context);
        if ($env === []) {
            return null;
        }

        if ($resourceType === 'application') {
            $this->deleteCoolifyGeneratedCommitEnvs($client, $resourceUuid, $target, $context);
            $client->updateApplicationEnvsBulk($resourceUuid, $env);
        } else {
            $client->updateServiceEnvsBulk($resourceUuid, $env);
        }

        return [
            'resource_type' => $resourceType,
            'count' => count($env),
            'keys' => array_keys($env),
        ];
    }

    private function deleteCoolifyGeneratedCommitEnvs(coolify_api_client $client, string $resourceUuid, array $target, array $context): void
    {
        $keys = $this->releaseCoolifyGeneratedCommitEnvKeys($target, $context);
        if ($keys === []) {
            return;
        }

        try {
            $rows = $this->payloadRows($client->listApplicationEnvs($resourceUuid));
        } catch (Throwable) {
            return;
        }

        foreach ($rows as $row) {
            if (!is_array($row)) {
                continue;
            }
            $key = trim((string)($row['key'] ?? $row['name'] ?? ''));
            $uuid = trim((string)($row['uuid'] ?? $row['id'] ?? ''));
            if ($key === '' || $uuid === '' || !in_array($key, $keys, true)) {
                continue;
            }

            try {
                $client->deleteApplicationEnv($resourceUuid, $uuid);
            } catch (Throwable) {
            }
        }
    }

    private function releaseCoolifyRuntimeEnv(array $target, array $context): array
    {
        $contextEnv = $this->releaseCoolifyContextEnv($context);
        $env = $contextEnv;
        $app = strtolower(trim((string)($target['app'] ?? '')));
        $deploymentCommitSha = self::normalizeCommitSha($this->releaseCoolifyGitCommitSha($target, $context));

        if (!in_array($app, ['api', self::CRON_WORKER_APP], true)) {
            $this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
            return $env;
        }

        $env['USE_ENV'] = $env['USE_ENV'] ?? 'true';
        foreach (self::RELEASE_API_RUNTIME_ENV_KEYS as $key) {
            $this->appendRuntimeEnvValue($env, $key);
        }

        $runtime = array_replace(
            is_array($_ENV ?? null) ? $_ENV : [],
            is_array($_SERVER ?? null) ? $_SERVER : [],
            is_array(getenv()) ? getenv() : []
        );
        foreach ($runtime as $key => $value) {
            $key = (string)$key;
            if (!$this->releaseRuntimeEnvKeyAllowed($key)) {
                continue;
            }
            $this->appendRuntimeEnvValue($env, $key, $value);
        }

        $env = array_replace($env, $contextEnv);
        $env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true';
        $env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? ''));
        if ($app === self::CRON_WORKER_APP) {
            $resourceUuid = trim((string)($target['coolify_service_uuid'] ?? ''));
            if ($resourceUuid !== '') {
                $env['CRON_WORKER_COOLIFY_RESOURCE_UUID'] = $resourceUuid;
            }
        }
        $this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
        return $this->normalizeCoolifyRuntimeEnv($env);
    }

    private function applyReleaseCoolifyCommitRuntimeEnv(array &$env, string $app, string $deploymentCommitSha): void
    {
        if ($deploymentCommitSha === '') {
            return;
        }

        foreach ($this->releaseCoolifyGeneratedCommitEnvKeys(['app' => $app], []) as $key) {
            $env[$key] = $deploymentCommitSha;
        }
    }

    private function releaseCoolifyGeneratedCommitEnvKeys(array $target, array $context): array
    {
        $app = strtolower(trim((string)($target['app'] ?? $context['app'] ?? '')));
        if ($app === 'frontend') {
            return ['SOURCE_COMMIT', 'RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'VITE_COMMIT_HASH'];
        }
        if ($app === 'api') {
            return ['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'];
        }
        if ($app === self::CRON_WORKER_APP) {
            return ['CRON_WORKER_COMMIT_SHA', 'API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'];
        }

        return [];
    }

    private function releaseCoolifyContextEnv(array $context): array
    {
        $env = [];
        foreach (['coolify_env', 'runtime_env', 'environment_variables'] as $key) {
            if (is_array($context[$key] ?? null)) {
                foreach ($context[$key] as $envKey => $value) {
                    $this->appendRuntimeEnvValue($env, (string)$envKey, $value);
                }
            }
        }

        foreach (['coolify_env_file', 'env'] as $key) {
            $raw = $context[$key] ?? null;
            if (!is_string($raw) || trim($raw) === '') {
                continue;
            }
            foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $line) {
                $line = trim((string)$line);
                if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
                    continue;
                }
                [$envKey, $value] = explode('=', $line, 2);
                $this->appendRuntimeEnvValue($env, trim($envKey), $value);
            }
        }

        return $this->normalizeCoolifyRuntimeEnv($env);
    }

    private function appendRuntimeEnvValue(array &$env, string $key, mixed $value = null): void
    {
        $key = trim($key);
        if ($key === '' || !preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) {
            return;
        }
        if ($value === null) {
            $value = getenv($key);
            if ($value === false && array_key_exists($key, $_ENV ?? [])) {
                $value = $_ENV[$key];
            }
            if ($value === false && array_key_exists($key, $_SERVER ?? [])) {
                $value = $_SERVER[$key];
            }
        }
        if ($value === false || $value === null || is_array($value) || is_object($value)) {
            return;
        }

        $env[$key] = (string)$value;
    }

    private function releaseRuntimeEnvKeyAllowed(string $key): bool
    {
        if (in_array($key, self::RELEASE_API_RUNTIME_ENV_KEYS, true)) {
            return true;
        }

        foreach (self::RELEASE_API_RUNTIME_ENV_PREFIXES as $prefix) {
            if (str_starts_with($key, $prefix)) {
                return true;
            }
        }

        return false;
    }

    private function normalizeCoolifyRuntimeEnv(array $env): array
    {
        $normalized = [];
        foreach ($env as $key => $value) {
            $this->appendRuntimeEnvValue($normalized, (string)$key, $value);
        }
        ksort($normalized);
        return $normalized;
    }

    private function releaseCoolifyApplicationPayload(array $target, array $context, array $instance): array
    {
        $publicUrl = $this->releaseCoolifyPublicUrl($target, $context);
        $projectUuid = trim((string)($context['coolify_project_uuid'] ?? $context['project_uuid'] ?? $instance['default_project_uuid'] ?? ''));
        if ($projectUuid === '') {
            throw new RuntimeException('Select a Coolify project for this release target before creating an application.');
        }

        $githubAppUuid = $this->releaseCoolifyGithubAppUuid($context, $target, $instance, true);
        if ($githubAppUuid === '') {
            throw new RuntimeException('Release Manager could not resolve a Coolify GitHub App UUID so Coolify can pull with the app token.');
        }

        $serverUuid = $this->releaseCoolifyServerUuid($context, $instance);
        if ($serverUuid === '') {
            throw new RuntimeException('Release Manager could not resolve a Coolify server UUID automatically for this instance.');
        }

        $repository = trim((string)($target['repository'] ?? ''));
        if ($repository === '') {
            throw new RuntimeException('Repository is required before creating a Coolify GitHub App application.');
        }

        $environment = $this->releaseCoolifyEnvironment($target, $context, $instance);
        $payload = [
            'name' => $this->releaseCoolifyResourceName($target, $context),
            'description' => 'Truckwash release manager target for ' . $repository,
            'project_uuid' => $projectUuid,
            'environment_name' => $environment['name'],
            'environment_uuid' => $environment['uuid'],
            'server_uuid' => $serverUuid,
            'destination_uuid' => trim((string)($context['coolify_destination_uuid'] ?? $context['destination_uuid'] ?? $instance['default_destination_uuid'] ?? '')),
            'github_app_uuid' => $githubAppUuid,
            'git_repository' => $repository,
            'git_branch' => trim((string)($target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH,
            'git_commit_sha' => $this->releaseCoolifyGitCommitSha($target, $context),
            'build_pack' => $this->releaseCoolifyBuildPack($target, $context),
            'ports_exposes' => $this->releaseCoolifyPortsExposes($target, $context),
            'instant_deploy' => $this->toBool($context['coolify_deploy_now'] ?? true),
            'is_auto_deploy_enabled' => $this->toBool($target['auto_deploy'] ?? true),
            'force_domain_override' => true,
        ];

        foreach ($this->releaseCoolifyApplicationDefaultFields($target, $context) as $key => $value) {
            $payload[$key] = $value;
        }

        if ($publicUrl !== null) {
            $payload['domains'] = self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context));
            $payload['is_force_https_enabled'] = $this->toBool($context['coolify_enable_ssl'] ?? false);
        }

        foreach ($this->releaseCoolifyApplicationOptionalFields($context) as $key => $value) {
            $payload[$key] = $value;
        }

        return array_filter($payload, static fn(mixed $value): bool => $value !== null && $value !== '');
    }

    private function releaseCoolifyApplicationRoutePayload(
        array $target,
        array $context,
        string $publicUrl,
        string $resourceUuid,
        mixed $existingLabels = null
    ): array
    {
        $decodedLabels = self::decodeCoolifyLabels($existingLabels);
        $routePort = $this->releaseCoolifyProxyPort($target, $context)
            ?? self::coolifyLabelFirstServicePort($decodedLabels, $resourceUuid);
        $payload = [
            'domains' => self::coolifyProxyUrl($publicUrl, $routePort),
            'is_force_https_enabled' => true,
            'force_domain_override' => true,
        ];

        $labels = self::releaseCoolifyApplicationLabels(
            $publicUrl,
            $resourceUuid,
            $routePort,
            self::gatewayRouteDefaultCertResolver($publicUrl)
        );
        if ($labels !== []) {
            $payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels(
                $decodedLabels,
                $labels
            )));
        }

        return $payload;
    }

    private function releaseCoolifyApplicationUpdatePayload(array $target, array $context): array
    {
        $app = strtolower(trim((string)($target['app'] ?? '')));
        $buildPack = $this->releaseCoolifyBuildPack($target, $context);
        $payload = [
            'git_repository' => trim((string)($target['repository'] ?? '')),
            'git_branch' => trim((string)($target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH,
            'git_commit_sha' => $this->releaseCoolifyGitCommitSha($target, $context),
            'build_pack' => $buildPack,
            'ports_exposes' => $this->releaseCoolifyPortsExposes($target, $context),
        ];

        foreach ($this->releaseCoolifyApplicationDefaultFields($target, $context) as $key => $value) {
            $payload[$key] = $value;
        }

        foreach ($this->releaseCoolifyApplicationOptionalFields($context) as $key => $value) {
            $payload[$key] = $value;
        }

        if ($app === 'frontend' && $buildPack === 'dockerfile') {
            $payload['install_command'] = '';
            $payload['build_command'] = '';
            $payload['start_command'] = '';
            $payload['publish_directory'] = '';
            $payload['is_static'] = false;
            $payload['is_spa'] = false;
        }

        return array_filter(
            $payload,
            static fn(mixed $value, string $key): bool => $value !== null
                && ($value !== '' || in_array($key, ['install_command', 'build_command', 'start_command', 'publish_directory'], true)),
            ARRAY_FILTER_USE_BOTH
        );
    }

    private static function releaseCoolifyApplicationLabels(
        string $publicUrl,
        string $resourceUuid,
        ?int $port = null,
        ?string $certResolver = null
    ): array
    {
        $resourceUuid = self::coolifyRouteLabelId($resourceUuid);
        if ($resourceUuid === '') {
            return [];
        }

        $parts = parse_url($publicUrl);
        $host = trim((string)($parts['host'] ?? ''));
        if ($host === '') {
            return [];
        }

        $scheme = strtolower((string)($parts['scheme'] ?? 'https'));
        $path = trim((string)($parts['path'] ?? '/'));
        $path = $path !== '' ? $path : '/';
        if ($path[0] !== '/') {
            $path = '/' . $path;
        }

        $routePort = $port ?? self::firstInteger($parts['port'] ?? null);
        $certResolver = trim((string)($certResolver ?? ''));
        $httpLabel = 'http-0-' . $resourceUuid;
        $httpsLabel = 'https-0-' . $resourceUuid;
        $priority = (string)(1000 + strlen($path));
        $labels = [
            'traefik.enable=true',
            'traefik.http.middlewares.gzip.compress=true',
            'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https',
        ];

        if ($scheme === 'https') {
            $labels[] = "traefik.http.routers.{$httpsLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)";
            $labels[] = "traefik.http.routers.{$httpsLabel}.entryPoints=https";
            $labels[] = "traefik.http.routers.{$httpsLabel}.priority={$priority}";
            if ($routePort !== null) {
                $labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}";
                $labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}";
            }
            if ($path !== '/') {
                $labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}";
                $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip";
            } else {
                $labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip";
            }
            $labels[] = "traefik.http.routers.{$httpsLabel}.tls=true";
            if ($certResolver !== '') {
                $labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}";
            }
            $labels[] = "traefik.http.routers.{$httpsLabel}.tls.domains[0].main={$host}";
            $labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)";
            $labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http";
            $labels[] = "traefik.http.routers.{$httpLabel}.priority={$priority}";
            if ($routePort !== null) {
                $labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}";
                $labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}";
            }
            $labels[] = "traefik.http.routers.{$httpLabel}.middlewares=redirect-to-https";
        } else {
            $labels[] = "traefik.http.routers.{$httpLabel}.rule=Host(`{$host}`) && PathPrefix(`{$path}`)";
            $labels[] = "traefik.http.routers.{$httpLabel}.entryPoints=http";
            $labels[] = "traefik.http.routers.{$httpLabel}.priority={$priority}";
            if ($routePort !== null) {
                $labels[] = "traefik.http.routers.{$httpLabel}.service={$httpLabel}";
                $labels[] = "traefik.http.services.{$httpLabel}.loadbalancer.server.port={$routePort}";
            }
            if ($path !== '/') {
                $labels[] = "traefik.http.middlewares.{$httpLabel}-stripprefix.stripprefix.prefixes={$path}";
                $labels[] = "traefik.http.routers.{$httpLabel}.middlewares={$httpLabel}-stripprefix,gzip";
            } else {
                $labels[] = "traefik.http.routers.{$httpLabel}.middlewares=gzip";
            }
        }

        sort($labels);
        return $labels;
    }

    private static function mergeCoolifyLabels(array $existingLabels, array $generatedLabels): array
    {
        $merged = [];
        foreach (array_merge($existingLabels, $generatedLabels) as $label) {
            $label = trim((string)$label);
            if ($label === '') {
                continue;
            }
            $merged[self::coolifyLabelKey($label)] = $label;
        }

        return array_values($merged);
    }

    private static function coolifyLabelFirstServicePort(array $labels, string $resourceUuid): ?int
    {
        $resourceUuid = self::coolifyRouteLabelId($resourceUuid);
        $fallback = null;
        foreach ($labels as $label) {
            if (preg_match('/^traefik\.http\.services\.([^=]+)\.loadbalancer\.server\.port=(\d+)$/', trim((string)$label), $matches) !== 1) {
                continue;
            }
            $port = (int)$matches[2];
            if ($port <= 0) {
                continue;
            }
            if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) {
                return $port;
            }
            $fallback ??= $port;
        }

        return $fallback;
    }

    private static function coolifyLabelCertResolver(array $labels, string $resourceUuid): ?string
    {
        $resourceUuid = self::coolifyRouteLabelId($resourceUuid);
        $fallback = null;
        foreach ($labels as $label) {
            if (preg_match('/^traefik\.http\.routers\.([^=]+)\.tls\.certresolver=([A-Za-z0-9_.-]+)$/', trim((string)$label), $matches) !== 1) {
                continue;
            }
            $resolver = trim((string)$matches[2]);
            if ($resolver === '') {
                continue;
            }
            if ($resourceUuid !== '' && str_contains((string)$matches[1], $resourceUuid)) {
                return $resolver;
            }
            $fallback ??= $resolver;
        }

        return $fallback;
    }

    private static function gatewayRouteDefaultCertResolver(string $publicUrl): string
    {
        return 'letsencrypt';
    }

    private static function decodeCoolifyLabels(mixed $labels): array
    {
        if (!is_scalar($labels)) {
            return [];
        }

        $raw = trim((string)$labels);
        if ($raw === '') {
            return [];
        }

        $decoded = base64_decode($raw, true);
        $content = $decoded !== false ? $decoded : $raw;
        return array_values(array_filter(
            preg_split('/\r\n|\r|\n/', (string)$content) ?: [],
            static fn(string $label): bool => trim($label) !== ''
        ));
    }

    private static function coolifyLabelKey(string $label): string
    {
        $position = strpos($label, '=');
        return $position === false ? trim($label) : trim(substr($label, 0, $position));
    }

    private static function coolifyRouteLabelId(string $value): string
    {
        $value = strtolower(trim($value));
        $value = preg_replace('/[^a-z0-9-]+/', '-', $value) ?: '';
        return trim($value, '-');
    }

    private static function firstInteger(mixed $value): ?int
    {
        if (is_int($value)) {
            return $value > 0 ? $value : null;
        }
        if (is_float($value)) {
            return $value > 0 ? (int)$value : null;
        }
        if (is_array($value)) {
            foreach ($value as $item) {
                $integer = self::firstInteger($item);
                if ($integer !== null) {
                    return $integer;
                }
            }
            return null;
        }
        if (!is_scalar($value)) {
            return null;
        }
        if (preg_match('/\d+/', (string)$value, $matches) !== 1) {
            return null;
        }

        $integer = (int)$matches[0];
        return $integer > 0 ? $integer : null;
    }

    private static function coolifyProxyUrl(string $publicUrl, ?int $port): string
    {
        if ($port === null || $port <= 0) {
            return $publicUrl;
        }

        $parts = parse_url($publicUrl);
        if (!is_array($parts) || trim((string)($parts['host'] ?? '')) === '' || isset($parts['port'])) {
            return $publicUrl;
        }

        $scheme = trim((string)($parts['scheme'] ?? 'https')) ?: 'https';
        $host = trim((string)$parts['host']);
        $path = (string)($parts['path'] ?? '');
        $query = isset($parts['query']) ? '?' . $parts['query'] : '';
        $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : '';
        return "{$scheme}://{$host}:{$port}{$path}{$query}{$fragment}";
    }

    private static function releaseCoolifyPublicUrlNeedsStripPrefixLabels(?string $publicUrl): bool
    {
        if ($publicUrl === null || trim($publicUrl) === '') {
            return false;
        }

        $parts = parse_url($publicUrl);
        if (!is_array($parts)) {
            return false;
        }

        return trim((string)($parts['path'] ?? ''), '/') !== '';
    }

    private function releaseCoolifyServicePayload(array $target, array $context, array $instance): array
    {
        $publicUrl = $this->releaseCoolifyPublicUrl($target, $context);
        $projectUuid = trim((string)($context['coolify_project_uuid'] ?? $context['project_uuid'] ?? $instance['default_project_uuid'] ?? ''));
        if ($projectUuid === '') {
            throw new RuntimeException('Select a Coolify project for this release target before creating a service.');
        }
        $environment = $this->releaseCoolifyEnvironment($target, $context, $instance);
        $serverUuid = $this->releaseCoolifyServerUuid($context, $instance);
        if ($serverUuid === '') {
            throw new RuntimeException('Release Manager could not resolve a Coolify server UUID automatically for this instance.');
        }

        $name = $this->releaseCoolifyResourceName($target, $context);
        $compose = (string)($context['docker_compose_raw'] ?? '');
        if (trim($compose) === '') {
            $image = $this->releaseCoolifyExplicitImage($context);
            if ($image === '' && $this->toBool($context['coolify_assume_ghcr_image'] ?? $context['assume_ghcr_image'] ?? false)) {
                $repository = strtolower(trim((string)($target['repository'] ?? '')));
                $branch = self::safeIdentifier((string)($target['branch'] ?? self::DEFAULT_BRANCH), 64);
                if ($repository !== '') {
                    $image = 'ghcr.io/' . $repository . ':' . ($branch !== '' ? $branch : self::DEFAULT_BRANCH);
                }
            }
            if ($image === '') {
                throw new RuntimeException('Coolify service creation needs docker_compose_raw or an explicit image in deploy_context. Release Manager will not assume a GHCR image from repository and branch.');
            }
            $compose = "services:\n  app:\n    image: " . $image . "\n    restart: unless-stopped\n";
        }

        $payload = [
            'name' => $name,
            'description' => 'Truckwash release manager target for ' . (string)($target['repository'] ?? ''),
            'project_uuid' => $projectUuid,
            'environment_name' => $environment['name'],
            'environment_uuid' => $environment['uuid'],
            'server_uuid' => $serverUuid,
            'instant_deploy' => $this->toBool($context['coolify_deploy_now'] ?? true),
            'docker_compose_raw' => base64_encode($compose),
            'force_domain_override' => true,
        ];

        if ($publicUrl !== null) {
            $payload['urls'] = [
                [
                    'name' => (string)($target['app'] ?? 'release'),
                    'url' => self::coolifyProxyUrl($publicUrl, $this->releaseCoolifyProxyPort($target, $context)),
                ],
            ];
        }

        return array_filter($payload, static fn(mixed $value): bool => $value !== null && $value !== '');
    }

    private function releaseCoolifyResourceName(array $target, array $context): string
    {
        $requestedName = trim((string)($context['coolify_service_name'] ?? $context['service_name'] ?? $context['coolify_application_name'] ?? $context['application_name'] ?? ''));
        return self::safeIdentifier(
            $requestedName !== ''
                ? $requestedName
                : 'release-' . (string)($target['channel_slug'] ?? $target['channel_id'] ?? 'channel') . '-' . (string)($target['app'] ?? 'app'),
            64
        );
    }

    private function releaseCoolifyServiceSourceIsMissing(array $context): bool
    {
        return trim((string)($context['docker_compose_raw'] ?? '')) === ''
            && $this->releaseCoolifyExplicitImage($context) === ''
            && !$this->toBool($context['coolify_assume_ghcr_image'] ?? $context['assume_ghcr_image'] ?? false);
    }

    private function releaseCoolifyGithubAppUuid(array $context, array $target = [], array $instance = [], bool $discover = false): string
    {
        foreach ([
            'coolify_github_app_uuid',
            'github_app_uuid',
            'coolify_git_app_uuid',
            'git_app_uuid',
            'default_github_app_uuid',
            'default_coolify_github_app_uuid',
        ] as $key) {
            $value = trim((string)($context[$key] ?? ''));
            if ($value !== '') {
                return $value;
            }
        }

        if (!$discover) {
            return '';
        }

        return $this->releaseCoolifyDefaultGithubAppUuid($target, $context, $instance);
    }

    private function releaseCoolifyDefaultGithubAppUuid(array $target, array $context, array $instance): string
    {
        foreach ([
            'default_github_app_uuid',
            'default_coolify_github_app_uuid',
            'coolify_github_app_uuid',
            'github_app_uuid',
        ] as $key) {
            $value = trim((string)($instance[$key] ?? ''));
            if ($value !== '') {
                return $value;
            }
        }

        foreach ([
            getenv('RELEASE_MANAGER_COOLIFY_GITHUB_APP_UUID') ?: ($_SERVER['RELEASE_MANAGER_COOLIFY_GITHUB_APP_UUID'] ?? null),
            getenv('COOLIFY_GITHUB_APP_UUID') ?: ($_SERVER['COOLIFY_GITHUB_APP_UUID'] ?? null),
            $this->moduleConfigValue('ReleaseManager', 'coolify_github_app_uuid', ''),
            $this->moduleConfigValue('Coolify', 'github_app_uuid', ''),
        ] as $value) {
            $value = trim((string)$value);
            if ($value !== '') {
                return $value;
            }
        }

        $tokenSecret = trim((string)($instance['api_token_secret'] ?? ''));
        if ($tokenSecret === '') {
            return '';
        }

        try {
            $token = replication_secret_box::decrypt($tokenSecret);
            $apps = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listGithubApps();
        } catch (Throwable) {
            return '';
        }

        $rows = array_values(array_filter($this->payloadRows($apps), static function (mixed $row): bool {
            return is_array($row) && trim((string)($row['uuid'] ?? '')) !== '';
        }));
        if (count($rows) === 1) {
            return trim((string)$rows[0]['uuid']);
        }

        $repository = self::normalizeGithubRepositoryName((string)($target['repository'] ?? ''));
        $owner = strtolower(trim(strtok($repository, '/') ?: ''));
        if ($owner === '') {
            return '';
        }

        $matches = array_values(array_filter($rows, static function (array $row) use ($owner): bool {
            foreach (['organization', 'name', 'custom_user', 'html_url'] as $key) {
                $value = strtolower(trim((string)($row[$key] ?? '')));
                if ($value === '') {
                    continue;
                }
                if ($value === $owner || str_contains($value, '/' . $owner) || str_contains($value, $owner . '-')) {
                    return true;
                }
            }
            return false;
        }));

        return count($matches) === 1 ? trim((string)$matches[0]['uuid']) : '';
    }

    private function releaseCoolifyResourceType(array $context, string $serviceUuid = ''): string
    {
        $type = strtolower(trim((string)($context['coolify_resource_type'] ?? $context['resource_type'] ?? '')));
        if (in_array($type, ['application', 'app'], true)) {
            return 'application';
        }
        if ($type === '' && trim($serviceUuid) === '' && $this->releaseCoolifyGithubAppUuid($context) !== '') {
            return 'application';
        }

        return 'service';
    }

    private function releaseTargetNeedsApplicationAutoCreate(array $target, array $context): bool
    {
        if (!in_array(strtolower(trim((string)($target['app'] ?? ''))), self::APPS, true)) {
            return false;
        }
        if ($this->nullablePositiveInt($target['coolify_instance_id'] ?? null) === null) {
            return false;
        }
        if (trim((string)($target['coolify_service_uuid'] ?? '')) !== '') {
            return false;
        }
        if ($this->toBool($context['coolify_auto_create'] ?? false)) {
            return false;
        }

        $publicUrl = $this->releaseCoolifyPublicUrl($target, $context);
        return self::releaseCoolifyPublicUrlNeedsStripPrefixLabels($publicUrl)
            || $this->releaseCoolifyResourceType($context, '') === 'application';
    }

    private function releaseCoolifyBuildPack(array $target, array $context): string
    {
        $app = strtolower(trim((string)($target['app'] ?? '')));
        $buildPack = strtolower(trim((string)($context['coolify_build_pack'] ?? $context['build_pack'] ?? '')));
        if ($buildPack !== '') {
            if ($app === 'frontend' && $buildPack === 'nixpacks') {
                return 'dockerfile';
            }
            return $buildPack;
        }

        return in_array($app, ['api', 'frontend', self::CRON_WORKER_APP], true) ? 'dockerfile' : 'static';
    }

    private function releaseCoolifyPortsExposes(array $target, array $context): string
    {
        foreach ([
            'coolify_ports_exposes',
            'ports_exposes',
            'coolify_exposed_port',
            'exposed_port',
            'coolify_port',
            'port',
        ] as $key) {
            $value = trim((string)($context[$key] ?? ''));
            if ($value !== '') {
                return $value;
            }
        }

        $app = strtolower(trim((string)($target['app'] ?? '')));
        $envKeys = in_array($app, ['api', self::CRON_WORKER_APP], true)
            ? ['RELEASE_MANAGER_API_PORTS_EXPOSES', 'RELEASE_API_PORTS_EXPOSES', 'API_PORTS_EXPOSES']
            : ['RELEASE_MANAGER_FRONTEND_PORTS_EXPOSES', 'RELEASE_FRONTEND_PORTS_EXPOSES', 'FRONTEND_PORTS_EXPOSES'];
        foreach ($envKeys as $key) {
            $value = trim((string)(getenv($key) ?: ($_SERVER[$key] ?? '')));
            if ($value !== '') {
                return $value;
            }
        }

        return self::DEFAULT_COOLIFY_APPLICATION_PORT;
    }

    private function releaseCoolifyProxyPort(array $target, array $context): ?int
    {
        foreach ([
            'coolify_ports_exposes',
            'ports_exposes',
            'coolify_exposed_port',
            'exposed_port',
            'coolify_port',
            'port',
        ] as $key) {
            $port = self::firstInteger($context[$key] ?? null);
            if ($port !== null) {
                return $port;
            }
        }

        $app = strtolower(trim((string)($target['app'] ?? '')));
        if ($app !== 'api') {
            return null;
        }

        return self::firstInteger($this->releaseCoolifyPortsExposes($target, $context));
    }

    private function releaseCoolifyGitCommitSha(array $target, array $context): string
    {
        foreach (['commit_sha', 'commit'] as $key) {
            $value = trim((string)($target[$key] ?? ''));
            if ($value !== '') {
                return $value;
            }
        }

        foreach ([
            'coolify_git_commit_sha',
            'git_commit_sha',
            'commit_sha',
            'commit',
        ] as $key) {
            $value = trim((string)($context[$key] ?? $target[$key] ?? ''));
            if ($value !== '') {
                return $value;
            }
        }

        return '';
    }

    private function releaseCoolifyForceRebuild(array $context): bool
    {
        if (array_key_exists('coolify_force_rebuild', $context) || array_key_exists('force_rebuild', $context)) {
            return $this->toBool($context['coolify_force_rebuild'] ?? $context['force_rebuild'] ?? false);
        }

        return true;
    }

    private function releaseCoolifyApplicationDefaultFields(array $target, array $context): array
    {
        $app = strtolower(trim((string)($target['app'] ?? '')));
        $buildPack = $this->releaseCoolifyBuildPack($target, $context);

        if (in_array($app, ['api', self::CRON_WORKER_APP], true) && $buildPack === 'dockerfile') {
            return [
                'dockerfile_location' => self::DEFAULT_COOLIFY_API_DOCKERFILE,
            ];
        }

        if ($app === 'frontend' && $buildPack === 'dockerfile') {
            return [
                'dockerfile_location' => '/Dockerfile.coolify-frontend',
            ];
        }

        if ($app !== 'frontend' || $buildPack !== 'static') {
            return [];
        }

        return [
            'install_command' => 'npm ci',
            'build_command' => 'npm run build',
            'publish_directory' => 'dist',
            'is_static' => true,
            'is_spa' => true,
        ];
    }

    private function releaseCoolifyApplicationOptionalFields(array $context): array
    {
        $fields = [];
        foreach ([
            'base_directory',
            'publish_directory',
            'dockerfile',
            'dockerfile_location',
            'docker_compose_location',
            'ports_exposes',
            'ports_mappings',
            'install_command',
            'build_command',
            'start_command',
        ] as $key) {
            $value = trim((string)($context['coolify_' . $key] ?? $context[$key] ?? ''));
            if ($value !== '') {
                $fields[$key] = $value;
            }
        }

        foreach ([
            'is_static',
            'is_spa',
            'is_force_https_enabled',
            'is_auto_deploy_enabled',
        ] as $key) {
            if (array_key_exists('coolify_' . $key, $context) || array_key_exists($key, $context)) {
                $fields[$key] = $this->toBool($context['coolify_' . $key] ?? $context[$key] ?? false);
            }
        }

        return $fields;
    }

    private function releaseCoolifyEnvironment(array $target, array $context, array $instance): array
    {
        $explicitUuid = trim((string)($context['coolify_environment_uuid'] ?? $context['environment_uuid'] ?? ''));
        $explicitName = trim((string)($context['coolify_environment_name'] ?? $context['environment_name'] ?? ''));
        $releaseEnvironmentName = $this->releaseBranchCoolifyEnvironmentName($target);
        if ($explicitUuid !== '' || ($explicitName !== '' && !($releaseEnvironmentName !== null && strtolower($explicitName) === 'production'))) {
            return [
                'uuid' => $explicitUuid !== '' ? $explicitUuid : null,
                'name' => $explicitName !== '' ? $explicitName : (trim((string)($instance['default_environment_name'] ?? 'production')) ?: 'production'),
            ];
        }

        if ($releaseEnvironmentName !== null) {
            return [
                'uuid' => null,
                'name' => $releaseEnvironmentName,
            ];
        }

        return [
            'uuid' => trim((string)($instance['default_environment_uuid'] ?? '')) ?: null,
            'name' => trim((string)($instance['default_environment_name'] ?? 'production')) ?: 'production',
        ];
    }

    private function releaseBranchCoolifyEnvironmentName(array $target): ?string
    {
        $channelSlug = self::safeSlug((string)($target['channel_slug'] ?? $target['channel'] ?? ''));
        if ($channelSlug !== '' && in_array($channelSlug, self::DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS, true)) {
            return null;
        }

        $branchSlug = self::safeSlug(preg_replace('#^refs/heads/#', '', (string)($target['branch'] ?? '')) ?? '');
        if ($channelSlug === '' && $branchSlug === '') {
            return null;
        }

        if ($branchSlug !== '' && !in_array($branchSlug, ['main', 'master'], true)) {
            return $branchSlug;
        }

        return $channelSlug !== '' ? $channelSlug : null;
    }

    private function releaseCoolifyExplicitImage(array $context): string
    {
        foreach (['image', 'docker_image', 'coolify_image', 'coolify_docker_image', 'registry_image'] as $key) {
            $value = $context[$key] ?? null;
            if (is_scalar($value)) {
                $image = trim((string)$value);
                if ($image !== '') {
                    return $image;
                }
            }
        }

        return '';
    }

    private function releaseCoolifyServerUuid(array $context, array $instance): string
    {
        $explicit = trim((string)($context['coolify_server_uuid'] ?? $context['server_uuid'] ?? ''));
        if ($explicit !== '') {
            return $explicit;
        }

        $default = trim((string)($instance['default_server_uuid'] ?? ''));
        if ($default !== '') {
            return $default;
        }

        $tokenSecret = trim((string)($instance['api_token_secret'] ?? ''));
        if ($tokenSecret === '') {
            return '';
        }

        try {
            $token = replication_secret_box::decrypt($tokenSecret);
            $servers = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listServers();
        } catch (Throwable) {
            return '';
        }

        $firstServerUuid = '';
        foreach ($this->payloadRows($servers) as $server) {
            if (!is_array($server)) {
                continue;
            }
            $uuid = trim((string)($server['uuid'] ?? ''));
            if ($uuid === '') {
                continue;
            }
            if ($firstServerUuid === '') {
                $firstServerUuid = $uuid;
            }
            $settings = is_array($server['settings'] ?? null) ? $server['settings'] : [];
            if (($settings['is_reachable'] ?? true) !== false && ($settings['is_usable'] ?? true) !== false) {
                return $uuid;
            }
        }

        return $firstServerUuid;
    }

    private function releaseDeploymentEndpoint(array $target): array
    {
        $context = is_array($target['deploy_context'] ?? null)
            ? $target['deploy_context']
            : self::jsonDecode($target['deploy_context_json'] ?? null);
        $mode = strtolower(trim((string)($context['endpoint_mode'] ?? 'auto'))) === 'manual' ? 'manual' : 'auto';
        $app = strtolower(trim((string)($target['app'] ?? '')));

        if ($app === self::CRON_WORKER_APP) {
            return self::releasePendingEndpoint(
                'auto',
                'private_worker',
                'Cron worker targets do not expose a public endpoint.'
            );
        }

        if ($mode === 'manual') {
            $host = self::normalizeEndpointHost($context['manual_endpoint_host'] ?? '');
            $port = $this->releaseEndpointPort($context['manual_endpoint_port'] ?? null);
            if ($host !== '') {
                return $this->releaseEndpointFromParts(
                    'manual',
                    'resolved',
                    $host,
                    $port,
                    'manual',
                    'Manual endpoint override is configured.'
                );
            }

            return self::releasePendingEndpoint(
                'manual',
                'manual',
                'Manual endpoint mode needs a public host before deployment.'
            );
        }

        foreach ([
            'coolify_public_url' => 'coolify_public_url',
            'health_url' => 'health_url',
            'coolify_domain' => 'coolify_domain',
        ] as $key => $source) {
            $value = $key === 'health_url'
                ? ($target['health_url'] ?? null)
                : ($context[$key] ?? null);
            $url = $key === 'coolify_domain'
                ? $this->releaseRoutedPublicBaseUrl($value, $target, $context)
                : ($key === 'coolify_public_url'
                    ? $this->releaseRoutedPublicBaseUrl($value, $target, $context)
                    : $this->normalizeReleasePublicBaseUrl($value, $app));
            if ($url !== null) {
                return $this->releaseEndpointFromUrl(
                    $url,
                    'auto',
                    'resolved',
                    $source,
                    'Automatic endpoint resolved from ' . str_replace('_', ' ', $source) . '.'
                );
            }
        }

        $gatewayUrl = $this->releaseAutoGatewayPublicBaseUrl($target, $context);
        if ($gatewayUrl !== null) {
            return $this->releaseEndpointFromUrl(
                $gatewayUrl,
                'auto',
                'pending',
                'auto_gateway',
                'Automatic gateway endpoint will be used when Coolify routing is ready.'
            );
        }

        foreach ([
            'coolify_deployed_public_url',
            'deployed_public_url',
            'resource_public_url',
            'public_url',
            'coolify_deployed_url',
            'deployed_url',
        ] as $key) {
            $url = $this->normalizeReleasePublicBaseUrl($context[$key] ?? null, $app);
            if ($url !== null) {
                return $this->releaseEndpointFromUrl(
                    $url,
                    'auto',
                    'resolved',
                    'coolify_resource_metadata',
                    'Automatic endpoint resolved from deployed Coolify resource metadata.'
                );
            }
        }

        return self::releasePendingEndpoint(
            'auto',
            'auto',
            'Automatic endpoint resolution is pending deployment metadata.'
        );
    }

    private static function releasePendingEndpoint(string $mode, string $source, string $message): array
    {
        return [
            'mode' => $mode,
            'status' => 'pending',
            'host' => null,
            'port' => null,
            'url' => null,
            'source' => $source,
            'message' => $message,
        ];
    }

    private function releaseEndpointFromUrl(string $url, string $mode, string $status, string $source, string $message): array
    {
        $normalized = $this->normalizeReleasePublicBaseUrl($url);
        if ($normalized === null) {
            return self::releasePendingEndpoint($mode, $source, $message);
        }

        $parts = parse_url($normalized);
        if (!is_array($parts) || empty($parts['host'])) {
            return self::releasePendingEndpoint($mode, $source, $message);
        }

        $scheme = strtolower((string)($parts['scheme'] ?? 'https'));
        $port = isset($parts['port']) ? (int)$parts['port'] : ($scheme === 'http' ? 80 : 443);
        return [
            'mode' => $mode,
            'status' => $status,
            'host' => strtolower((string)$parts['host']),
            'port' => $port,
            'url' => $normalized,
            'source' => $source,
            'message' => $message,
        ];
    }

    private function releaseEndpointFromParts(
        string $mode,
        string $status,
        string $host,
        ?int $port,
        string $source,
        string $message
    ): array {
        $host = self::normalizeEndpointHost($host);
        if ($host === '') {
            return self::releasePendingEndpoint($mode, $source, $message);
        }

        $url = 'https://' . $host . ($port !== null && $port !== 443 ? ':' . $port : '');
        return [
            'mode' => $mode,
            'status' => $status,
            'host' => strtolower($host),
            'port' => $port,
            'url' => $url,
            'source' => $source,
            'message' => $message,
        ];
    }

    private function releaseEndpointPort(mixed $value): ?int
    {
        $raw = trim((string)$value);
        if ($raw === '') {
            return null;
        }
        if (filter_var($raw, FILTER_VALIDATE_INT) === false) {
            return null;
        }
        $port = (int)$raw;
        return $port >= 1 && $port <= 65535 ? $port : null;
    }

    private static function normalizeEndpointHost(mixed $value): string
    {
        $raw = trim((string)$value);
        if ($raw === '') {
            return '';
        }
        if (preg_match('#^https?://#i', $raw) === 1) {
            $parts = parse_url($raw);
            $raw = is_array($parts) ? (string)($parts['host'] ?? '') : '';
        }
        $raw = trim($raw);
        $raw = preg_replace('#[/\s].*$#', '', $raw) ?? '';
        if (str_contains($raw, ':') && preg_match('/^\x5b[^\x5d]+\x5d:(\d+)$/', $raw) !== 1) {
            $parts = parse_url('https://' . $raw);
            if (is_array($parts) && !empty($parts['host'])) {
                $raw = (string)$parts['host'];
            }
        }
        return strtolower(trim($raw, " \t\n\r\0\x0B[]"));
    }

    private function releaseAutoGatewayPublicBaseUrl(array $target, array $context): ?string
    {
        $host = $this->releasePublicGatewayHost($context);
        if ($host === '') {
            return null;
        }

        return $this->releaseRoutedPublicBaseUrl('https://' . $host, $target, array_replace($context, [
            'gateway_route_autoprovision' => false,
        ]));
    }

    private function releasePublicGatewayHost(array $context = []): string
    {
        foreach ([
            $context['public_gateway_host'] ?? null,
            $context['coolify_public_gateway_host'] ?? null,
        ] as $value) {
            $host = self::normalizeEndpointHost($value);
            if ($host !== '') {
                return $host;
            }
        }

        foreach ([
            getenv('RELEASE_MANAGER_PUBLIC_GATEWAY_HOST') ?: ($_SERVER['RELEASE_MANAGER_PUBLIC_GATEWAY_HOST'] ?? null),
            getenv('COOLIFY_PUBLIC_GATEWAY_HOST') ?: ($_SERVER['COOLIFY_PUBLIC_GATEWAY_HOST'] ?? null),
        ] as $value) {
            $host = self::normalizeEndpointHost($value);
            if ($host !== '') {
                return $host;
            }
        }

        try {
            if ($this->tableExists('module_config')) {
                $host = self::normalizeEndpointHost($this->moduleConfigValue('Coolify', 'public_gateway_host', ''));
                if ($host !== '') {
                    return $host;
                }
            }
        } catch (Throwable) {
        }

        return 'api-v2.truckwash.io';
    }

    private function releaseCoolifyPublicUrl(array $target, array $context): ?string
    {
        $endpoint = $this->releaseDeploymentEndpoint($target + ['deploy_context' => $context]);
        if (!empty($endpoint['url']) && in_array((string)($endpoint['source'] ?? ''), [
            'manual',
            'coolify_public_url',
            'health_url',
            'coolify_domain',
            'auto_gateway',
            'coolify_resource_metadata',
        ], true)) {
            return (string)$endpoint['url'];
        }

        $explicitPublicUrl = $this->releaseRoutedPublicBaseUrl($context['coolify_public_url'] ?? null, $target, $context);
        if ($explicitPublicUrl !== null) {
            return $explicitPublicUrl;
        }

        $raw = trim((string)($context['coolify_domain'] ?? ''));
        $hasCoolifyDomain = $raw !== '';
        if ($raw === '') {
            $raw = trim((string)($target['health_url'] ?? ''));
        }
        if ($raw === '') {
            return null;
        }
        if ($this->toBool($context['coolify_enable_ssl'] ?? false)) {
            $domain = self::domainSuggestionHost($raw);
            if ($domain === null) {
                throw new RuntimeException('Coolify SSL requires a DNS domain routed to the load balancer.');
            }
            return $hasCoolifyDomain
                ? $this->releaseRoutedPublicBaseUrl('https://' . $domain, $target, $context)
                : $this->normalizeReleasePublicBaseUrl('https://' . $domain, (string)($target['app'] ?? ''));
        }
        if (preg_match('#^https?://#i', $raw) !== 1) {
            $raw = 'http://' . $raw;
        }
        return $hasCoolifyDomain
            ? $this->releaseRoutedPublicBaseUrl($raw, $target, $context)
            : $this->normalizeReleasePublicBaseUrl($raw, (string)($target['app'] ?? ''));
    }

    private function releaseRoutedPublicBaseUrl(mixed $value, array $target, array $context = []): ?string
    {
        $app = (string)($target['app'] ?? '');
        $baseUrl = $this->normalizeReleasePublicBaseUrl($value, $app);
        if ($baseUrl === null) {
            return null;
        }

        $parts = parse_url($baseUrl);
        if (!is_array($parts) || empty($parts['host'])) {
            return $baseUrl;
        }

        if ($this->toBool($context['gateway_route_autoprovision'] ?? false)) {
            return $baseUrl;
        }

        $path = trim((string)($parts['path'] ?? ''), '/');
        if ($path !== '') {
            return $baseUrl;
        }

        $channelSlug = self::safeSlug((string)(
            $target['channel_slug']
            ?? $context['channel_slug']
            ?? $target['release_channel']
            ?? $context['release_channel']
            ?? $target['channel']
            ?? $context['channel']
            ?? ''
        ));
        $appSlug = self::safeSlug($app);
        if ($channelSlug === '' || $appSlug === '') {
            return $baseUrl;
        }
        $routeSlug = self::routeSlugForChannel($channelSlug);

        $scheme = strtolower((string)($parts['scheme'] ?? 'https'));
        $host = strtolower((string)$parts['host']);
        $port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';

        return sprintf('%s://%s%s/%s/%s', $scheme, $host, $port, $routeSlug, $appSlug);
    }

    private function releaseTargetPublicBaseUrl(array $target): ?string
    {
        $context = is_array($target['deploy_context'] ?? null)
            ? $target['deploy_context']
            : self::jsonDecode($target['deploy_context_json'] ?? null);
        $app = (string)($target['app'] ?? '');
        $endpoint = is_array($target['endpoint'] ?? null)
            ? $target['endpoint']
            : $this->releaseDeploymentEndpoint($target + ['deploy_context' => $context]);
        return $this->normalizeReleasePublicBaseUrl($endpoint['url'] ?? null, $app)
            ?? $this->releaseRoutedPublicBaseUrl($context['coolify_public_url'] ?? null, $target, $context)
            ?? $this->normalizeReleasePublicBaseUrl($target['health_url'] ?? null, $app)
            ?? $this->releaseRoutedPublicBaseUrl($context['coolify_domain'] ?? null, $target, $context)
            ?? (is_array($target['endpoint'] ?? null) ? ($target['endpoint']['url'] ?? null) : null)
            ?? ($this->releaseDeploymentEndpoint($target)['url'] ?? null);
    }

    private function timelineSessionContext(array $context): array
    {
        $device = is_array($context['device'] ?? null) ? $context['device'] : [];
        $browser = is_array($context['browser'] ?? null) ? $context['browser'] : [];
        $os = is_array($context['os'] ?? null) ? $context['os'] : [];
        $viewport = is_array($context['viewport'] ?? null) ? $context['viewport'] : [];
        $frontend = is_array($context['frontend'] ?? null) ? $context['frontend'] : [];
        $api = is_array($context['api'] ?? null) ? $context['api'] : [];

        return [
            'device_type' => $this->nullableIdentifier($context['device_type'] ?? $device['type'] ?? null, 16),
            'browser_name' => $this->nullableString($context['browser_name'] ?? $browser['name'] ?? null, 64),
            'browser_version' => $this->nullableString($context['browser_version'] ?? $browser['version'] ?? null, 64),
            'os_name' => $this->nullableString($context['os_name'] ?? $os['name'] ?? null, 64),
            'os_version' => $this->nullableString($context['os_version'] ?? $os['version'] ?? null, 64),
            'viewport_width' => $this->nullableInt($context['viewport_width'] ?? $viewport['width'] ?? null),
            'viewport_height' => $this->nullableInt($context['viewport_height'] ?? $viewport['height'] ?? null),
            'device_pixel_ratio' => $this->nullableFloat($context['device_pixel_ratio'] ?? $viewport['device_pixel_ratio'] ?? null),
            'frontend_version_label' => $this->nullableString(
                $context['frontend_version_label'] ?? $frontend['version_label'] ?? $context['frontend_version'] ?? null,
                128
            ),
            'frontend_commit_sha' => $this->nullableString(
                $context['frontend_commit_sha'] ?? $frontend['commit_sha'] ?? $context['frontend_commit'] ?? null,
                128
            ),
            'api_version_label' => $this->nullableString(
                $context['api_version_label'] ?? $api['version_label'] ?? $context['api_version'] ?? null,
                128
            ),
            'api_commit_sha' => $this->nullableString(
                $context['api_commit_sha'] ?? $api['commit_sha'] ?? $context['backend_version'] ?? null,
                128
            ),
            'last_route_path' => $this->nullableString($context['route_path'] ?? $context['route'] ?? null, 255),
        ];
    }

    private function latestRouteFromContext(array $context): ?string
    {
        foreach (['route_path', 'route'] as $key) {
            $value = $this->nullableString($context[$key] ?? null, 255);
            if ($value !== null) {
                return $value;
            }
        }
        return null;
    }

    private function timelineSessionId(string $traceId, array $context, ?array $channel): int
    {
        $existing = $this->selectOne('SELECT id FROM release_timeline_sessions WHERE trace_id = ? LIMIT 1', 's', [$traceId]);
        $userAgent = substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 512);
        $principalType = trim((string)($context['principal_type'] ?? '')) ?: null;
        $principalId = trim((string)($context['principal_id'] ?? '')) ?: null;
        $customerNumber = isset($context['customer_number']) && is_numeric($context['customer_number'])
            ? (int)$context['customer_number']
            : null;
        $sessionContext = $this->timelineSessionContext($context);
        $lastRoutePath = $sessionContext['last_route_path'] ?? null;
        if ($lastRoutePath === null) {
            $lastRoutePath = $this->latestRouteFromContext($context);
        }

        if ($existing !== null) {
            $this->execute(
                "UPDATE release_timeline_sessions
                 SET last_seen_at = NOW(), channel_id = COALESCE(?, channel_id), channel_slug = COALESCE(?, channel_slug),
                     principal_type = COALESCE(?, principal_type), principal_id = COALESCE(?, principal_id),
                     customer_number = COALESCE(?, customer_number),
                     device_type = COALESCE(?, device_type), browser_name = COALESCE(?, browser_name),
                     browser_version = COALESCE(?, browser_version), os_name = COALESCE(?, os_name),
                     os_version = COALESCE(?, os_version), viewport_width = COALESCE(?, viewport_width),
                     viewport_height = COALESCE(?, viewport_height), device_pixel_ratio = COALESCE(?, device_pixel_ratio),
                     frontend_version_label = COALESCE(?, frontend_version_label),
                     frontend_commit_sha = COALESCE(?, frontend_commit_sha),
                     api_version_label = COALESCE(?, api_version_label),
                     api_commit_sha = COALESCE(?, api_commit_sha),
                     last_route_path = COALESCE(?, last_route_path),
                     user_agent = COALESCE(?, user_agent)
                 WHERE id = ?",
                'isssisssssiidssssssi',
                [
                    $channel['id'] ?? null,
                    $channel['slug'] ?? null,
                    $principalType,
                    $principalId,
                    $customerNumber,
                    $sessionContext['device_type'],
                    $sessionContext['browser_name'],
                    $sessionContext['browser_version'],
                    $sessionContext['os_name'],
                    $sessionContext['os_version'],
                    $sessionContext['viewport_width'],
                    $sessionContext['viewport_height'],
                    $sessionContext['device_pixel_ratio'],
                    $sessionContext['frontend_version_label'],
                    $sessionContext['frontend_commit_sha'],
                    $sessionContext['api_version_label'],
                    $sessionContext['api_commit_sha'],
                    $lastRoutePath,
                    $userAgent !== '' ? $userAgent : null,
                    (int)$existing['id'],
                ]
            );
            return (int)$existing['id'];
        }

        $this->execute(
            "INSERT INTO release_timeline_sessions (
                trace_id, session_hash, principal_type, principal_id, customer_number,
                channel_id, channel_slug, device_type, browser_name, browser_version, os_name,
                os_version, viewport_width, viewport_height, device_pixel_ratio,
                frontend_version_label, frontend_commit_sha, api_version_label, api_commit_sha,
                last_route_path, user_agent
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
            'ssssiisssssiidsssssss',
            [
                $traceId,
                $this->sessionHash(),
                $principalType,
                $principalId,
                $customerNumber,
                $channel['id'] ?? null,
                $channel['slug'] ?? null,
                $sessionContext['device_type'],
                $sessionContext['browser_name'],
                $sessionContext['browser_version'],
                $sessionContext['os_name'],
                $sessionContext['os_version'],
                $sessionContext['viewport_width'],
                $sessionContext['viewport_height'],
                $sessionContext['device_pixel_ratio'],
                $sessionContext['frontend_version_label'],
                $sessionContext['frontend_commit_sha'],
                $sessionContext['api_version_label'],
                $sessionContext['api_commit_sha'],
                $lastRoutePath,
                $userAgent !== '' ? $userAgent : null,
            ]
        );

        return $this->insertId();
    }

    private function sessionHash(): ?string
    {
        $authorization = (string)($_SERVER['HTTP_AUTHORIZATION'] ?? '');
        if ($authorization === '') {
            return null;
        }
        return hash('sha256', str_replace('Bearer ', '', $authorization));
    }

    private function inferModuleKeyFromUri(string $uri): ?string
    {
        $path = strtolower(explode('?', $uri)[0] ?? '');
        $map = [
            '/auth' => 'auth',
            '/superuser/releases' => 'releasemanager',
            '/release' => 'releasemanager',
            '/superuser/coolify' => 'coolify',
            '/coolify' => 'coolify',
            '/failover' => 'failover',
            '/edge-gateway' => 'edgegateway',
            '/edgegateway' => 'edgegateway',
            '/modules/action-logs' => 'moduleactionlogs',
            '/worker' => 'worker',
            '/economic' => 'economic',
            '/stripe' => 'stripe',
            '/selfserve' => 'selfserve',
            '/bird' => 'bird',
            '/xlvask' => 'xlvask',
        ];

        foreach ($map as $prefix => $moduleKey) {
            if (str_starts_with($path, $prefix)) {
                return $moduleKey;
            }
        }

        return null;
    }

    private function timelineSummary(): array
    {
        $events = $this->selectOne(
            "SELECT COUNT(*) AS total,
                    SUM(CASE WHEN severity = 'error' THEN 1 ELSE 0 END) AS errors,
                    MAX(created_at) AS last_event_at
             FROM release_timeline_events"
        ) ?? [];
        $sessions = $this->selectOne('SELECT COUNT(*) AS total FROM release_timeline_sessions') ?? [];

        return [
            'sessions' => (int)($sessions['total'] ?? 0),
            'events' => (int)($events['total'] ?? 0),
            'errors' => (int)($events['errors'] ?? 0),
            'last_event_at' => $events['last_event_at'] ?? null,
        ];
    }

    private function latestModuleHealth(): array
    {
        return $this->selectRows(
            "SELECT h.*
             FROM release_module_health_snapshots h
             INNER JOIN (
                 SELECT module_key, MAX(checked_at) AS checked_at
                 FROM release_module_health_snapshots
                 GROUP BY module_key
             ) latest ON latest.module_key = h.module_key AND latest.checked_at = h.checked_at
             ORDER BY h.module_key"
        );
    }

    private static function releaseBranchForChannel(array|string $channel): string
    {
        $slug = is_array($channel) ? (string)($channel['slug'] ?? '') : $channel;
        $routeSlug = self::routeSlugForChannel($slug);
        return $routeSlug !== '' ? $routeSlug : self::DEFAULT_BRANCH;
    }

    private static function defaultRepositoryForApp(string $app): string
    {
        return match (strtolower(trim($app))) {
            'frontend' => 'copenhagentruckwash/pleno-vue',
            'api' => 'copenhagentruckwash/api',
            default => '',
        };
    }

    private function getOperationRun(int $id): array
    {
        $row = $this->selectOne(
            "SELECT r.*, c.slug AS channel_slug, c.name AS channel_name,
                    (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id) AS step_count,
                    (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('passed', 'deployed')) AS passed_step_count,
                    (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status = 'failed') AS failed_step_count,
                    (SELECT COUNT(*) FROM release_operation_steps s WHERE s.operation_run_id = r.id AND s.status IN ('warning', 'skipped')) AS warning_step_count
             FROM release_operation_runs r
             LEFT JOIN release_channels c ON c.id = r.channel_id
             WHERE r.id = ?
             LIMIT 1",
            'i',
            [$id]
        );
        if ($row === null) {
            throw new RuntimeException('Release operation not found.');
        }
        return $row;
    }

    private function createOperationRun(string $operationType, array $input): int
    {
        $operationType = self::safeIdentifier($operationType, 64) ?: 'operation';
        $subjectType = self::safeIdentifier((string)($input['subject_type'] ?? ''), 64) ?: null;
        $subjectId = trim((string)($input['subject_id'] ?? '')) ?: null;
        $channelId = $this->nullablePositiveInt($input['channel_id'] ?? null);
        $app = trim((string)($input['app'] ?? '')) !== '' ? $this->normalizeApp((string)$input['app']) : null;
        $title = substr(trim((string)($input['title'] ?? $operationType)), 0, 255);
        $context = is_array($input['context'] ?? null) ? $input['context'] : [];
        $actorUserId = $this->nullablePositiveInt($input['actor_user_id'] ?? null);

        $this->execute(
            "INSERT INTO release_operation_runs (
                operation_type, subject_type, subject_id, channel_id, app, status,
                title, actor_user_id, context_json, started_at
            ) VALUES (?, ?, ?, ?, ?, 'running', ?, ?, ?, NOW())",
            'sssissis',
            [
                $operationType,
                $subjectType,
                $subjectId,
                $channelId,
                $app,
                $title,
                $actorUserId,
                self::jsonEncode(self::redactPayload($context)),
            ]
        );

        return $this->insertId();
    }

    private function recordOperationStep(
        int $operationId,
        string $stepKey,
        string $label,
        string $status,
        ?string $message = null,
        ?string $diagnostic = null,
        ?string $solutionHint = null,
        array $context = []
    ): void {
        $stepKey = self::safeIdentifier($stepKey, 64) ?: 'step';
        $status = self::safeIdentifier($status, 32) ?: 'queued';
        $completedSql = in_array($status, ['passed', 'deployed', 'failed', 'warning', 'skipped'], true) ? 'NOW()' : 'NULL';
        $this->execute(
            "INSERT INTO release_operation_steps (
                operation_run_id, step_key, label, status, message, diagnostic,
                solution_hint, context_json, started_at, completed_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), $completedSql)",
            'isssssss',
            [
                $operationId,
                $stepKey,
                substr($label, 0, 255),
                $status,
                $message,
                $diagnostic,
                $solutionHint,
                self::jsonEncode(self::redactPayload($context)),
            ]
        );
    }

    private function completeOperationRun(int $operationId, string $status, string $summary, ?string $solutionHint = null): void
    {
        $status = self::safeIdentifier($status, 32) ?: 'completed';
        $this->execute(
            "UPDATE release_operation_runs
             SET status = ?, summary = ?, solution_hint = ?, completed_at = NOW()
             WHERE id = ?",
            'sssi',
            [$status, $summary, $solutionHint, $operationId]
        );
    }

    private function publicOperationRun(array $operation, bool $includeSteps = true): array
    {
        $operationId = (int)($operation['id'] ?? 0);
        $steps = [];
        if ($includeSteps && $operationId > 0) {
            $steps = array_map(
                fn(array $row): array => $this->publicOperationStep($row),
                $this->selectRows(
                    'SELECT * FROM release_operation_steps WHERE operation_run_id = ? ORDER BY id ASC',
                    'i',
                    [$operationId]
                )
            );
        }

        return [
            'id' => $operationId,
            'operation_type' => (string)($operation['operation_type'] ?? ''),
            'subject_type' => $operation['subject_type'] ?? null,
            'subject_id' => $operation['subject_id'] ?? null,
            'channel_id' => isset($operation['channel_id']) ? (int)$operation['channel_id'] : null,
            'channel_slug' => $operation['channel_slug'] ?? null,
            'channel_name' => $operation['channel_name'] ?? null,
            'app' => $operation['app'] ?? null,
            'status' => (string)($operation['status'] ?? 'unknown'),
            'title' => $operation['title'] ?? null,
            'summary' => $operation['summary'] ?? null,
            'solution_hint' => $operation['solution_hint'] ?? null,
            'context' => self::jsonDecode($operation['context_json'] ?? null),
            'step_count' => (int)($operation['step_count'] ?? count($steps)),
            'passed_step_count' => (int)($operation['passed_step_count'] ?? 0),
            'failed_step_count' => (int)($operation['failed_step_count'] ?? 0),
            'warning_step_count' => (int)($operation['warning_step_count'] ?? 0),
            'steps' => $includeSteps ? $steps : null,
            'actor_user_id' => isset($operation['actor_user_id']) ? (int)$operation['actor_user_id'] : null,
            'started_at' => $operation['started_at'] ?? null,
            'completed_at' => $operation['completed_at'] ?? null,
            'created_at' => $operation['created_at'] ?? null,
            'updated_at' => $operation['updated_at'] ?? null,
        ];
    }

    private function publicOperationStep(array $step): array
    {
        return [
            'id' => (int)($step['id'] ?? 0),
            'operation_run_id' => (int)($step['operation_run_id'] ?? 0),
            'step_key' => (string)($step['step_key'] ?? ''),
            'label' => (string)($step['label'] ?? ''),
            'status' => (string)($step['status'] ?? 'unknown'),
            'message' => $step['message'] ?? null,
            'diagnostic' => $step['diagnostic'] ?? null,
            'solution_hint' => $step['solution_hint'] ?? null,
            'context' => self::jsonDecode($step['context_json'] ?? null),
            'started_at' => $step['started_at'] ?? null,
            'completed_at' => $step['completed_at'] ?? null,
            'created_at' => $step['created_at'] ?? null,
            'updated_at' => $step['updated_at'] ?? null,
        ];
    }

    private function activeDeploymentKey(int $channelId, string $app): string
    {
        return $channelId . ':' . $this->normalizeApp($app);
    }

    private function activateDeploymentForChannelApp(int $deploymentId, int $channelId, string $app): void
    {
        $app = $this->normalizeApp($app);
        $key = $this->activeDeploymentKey($channelId, $app);
        $this->execute(
            "UPDATE release_deployments
             SET status = 'superseded', active_channel_app_key = NULL
             WHERE active_channel_app_key = ? AND id <> ?",
            'si',
            [$key, $deploymentId]
        );
        $this->execute(
            "UPDATE release_deployments
             SET status = 'superseded', active_channel_app_key = NULL
             WHERE channel_id = ? AND app = ? AND id <> ? AND status = 'active'",
            'isi',
            [$channelId, $app, $deploymentId]
        );
        $this->execute(
            "UPDATE release_deployments
             SET status = 'active', active_channel_app_key = ?, completed_at = COALESCE(completed_at, NOW())
             WHERE id = ?",
            'si',
            [$key, $deploymentId]
        );
    }

    private function currentDeploymentForChannelApp(int $channelId, string $app): ?array
    {
        $app = $this->normalizeApp($app);
        $key = $this->activeDeploymentKey($channelId, $app);
        $row = $this->selectOne(
            "SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url
             FROM release_deployments d
             INNER JOIN release_channels c ON c.id = d.channel_id
             LEFT JOIN release_versions v ON v.id = d.version_id
             WHERE d.active_channel_app_key = ?
             LIMIT 1",
            's',
            [$key]
        );
        if ($row !== null) {
            return $row;
        }

        return $this->selectOne(
            "SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url
             FROM release_deployments d
             INNER JOIN release_channels c ON c.id = d.channel_id
             LEFT JOIN release_versions v ON v.id = d.version_id
             WHERE d.channel_id = ? AND d.app = ? AND d.status = 'active'
             ORDER BY d.completed_at DESC, d.id DESC
             LIMIT 1",
            'is',
            [$channelId, $app]
        );
    }

    private function deploymentTargetForChannelApp(int $channelId, string $app): ?array
    {
        return $this->selectOne(
            "SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, i.label AS coolify_instance_label
             FROM release_deployment_targets t
             INNER JOIN release_channels c ON c.id = t.channel_id
             LEFT JOIN coolify_instances i ON i.id = t.coolify_instance_id
             WHERE t.deleted_at IS NULL AND t.channel_id = ? AND t.app = ?
             ORDER BY t.auto_deploy DESC, t.id DESC
             LIMIT 1",
            'is',
            [$channelId, $this->normalizeApp($app)]
        );
    }

    private function channelCurrentDeployments(int $channelId): array
    {
        $deployments = [];
        foreach (self::APPS as $app) {
            $row = $this->currentDeploymentForChannelApp($channelId, $app);
            $deployments[$app] = $row !== null ? $this->publicDeployment($row) : null;
        }
        return $deployments;
    }

    private function channelBranchStatus(array $channel): array
    {
        $channelId = (int)($channel['id'] ?? 0);
        if ($this->channelUsesProductionServices($channel)) {
            $serviceChannel = $this->productionServiceChannel();
            $serviceChannelId = (int)($serviceChannel['id'] ?? 0);
            $branch = self::releaseBranchForChannel($serviceChannel);
            $status = [];
            foreach (self::APPS as $app) {
                $target = $this->deploymentTargetForChannelApp($serviceChannelId, $app);
                $repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app)));
                $status[$app] = [
                    'repository' => $repository,
                    'branch' => $branch,
                    'route_slug' => self::routeSlugForChannel((string)($channel['slug'] ?? '')),
                    'target_configured' => true,
                    'target_branch' => $target['branch'] ?? $branch,
                    'state' => self::PRODUCTION_SERVICE_POLICY,
                    'retry_action' => null,
                    'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''),
                ];
            }
            return $status;
        }

        $branch = self::releaseBranchForChannel($channel);
        $status = [];
        foreach (self::APPS as $app) {
            $target = $this->deploymentTargetForChannelApp($channelId, $app);
            $repository = trim((string)($target['repository'] ?? self::defaultRepositoryForApp($app)));
            $status[$app] = [
                'repository' => $repository,
                'branch' => $branch,
                'route_slug' => self::routeSlugForChannel((string)($channel['slug'] ?? '')),
                'target_configured' => $target !== null,
                'target_branch' => $target['branch'] ?? null,
                'state' => $target !== null ? 'ready_to_check' : 'missing_target',
                'retry_action' => $target !== null ? 'sync_channel' : 'configure_target',
            ];
        }
        return $status;
    }

    private function releaseDataServicesSummary(): array
    {
        $summary = [];
        foreach ($this->selectRows("SELECT * FROM release_channels WHERE deleted_at IS NULL ORDER BY default_channel DESC, slug") as $channel) {
            $summary[(string)$channel['slug']] = $this->channelDataServicesSummary($channel);
        }
        return $summary;
    }

    private function channelDataServicesSummary(array $channel): array
    {
        $serviceChannel = $this->channelUsesProductionServices($channel)
            ? $this->productionServiceChannel()
            : $channel;
        $serviceSet = $this->activeServiceSetForChannel((int)($serviceChannel['id'] ?? 0));
        $serviceSetMode = $serviceSet !== null ? (string)($serviceSet['mode'] ?? 'attach_existing') : self::PRODUCTION_DATA_POLICY;
        $mode = $this->serviceSetDataPolicy($serviceSet);
        $policy = $this->replicationPolicyForMode($mode);
        $services = [];
        foreach (self::STACK_DATA_KINDS as $kind) {
            $target = $serviceSet !== null
                ? $this->nullableCoolifyTarget($this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null))
                : null;
            $replication = is_array($target['replication'] ?? null) ? $target['replication'] : [];
            $lastStatus = is_array($replication['last_status'] ?? null) ? $replication['last_status'] : [];
            $services[$kind] = [
                'kind' => $kind,
                'mode' => $mode,
                'service_set_mode' => $serviceSetMode,
                'data_policy' => $mode,
                'state' => $target !== null ? (string)($target['availability_state'] ?? 'configured') : 'production_shared',
                'target' => $target,
                'replication_policy' => $policy,
                'default_shared_production' => $mode === self::PRODUCTION_DATA_POLICY,
                'change_requires_explicit_action' => true,
                'entity_facts' => [
                    'service' => $kind,
                    'mode' => $mode,
                    'service_set_mode' => $serviceSetMode,
                    'data_policy' => $mode,
                    'node' => $target['instance_label'] ?? null,
                    'online_state' => $target['deployment_status'] ?? $target['availability_state'] ?? 'production_shared',
                    'hostname' => $replication['host'] ?? null,
                    'port' => $replication['port'] ?? null,
                    'uptime' => $lastStatus['uptime'] ?? $lastStatus['uptime_text'] ?? null,
                    'version' => $lastStatus['version'] ?? null,
                    'replication_role' => $replication['role'] ?? null,
                    'replication_lag' => $lastStatus['lag'] ?? $lastStatus['lag_seconds'] ?? null,
                    'last_check' => $replication['last_checked_at'] ?? $target['last_reconciled_at'] ?? null,
                ],
            ];
        }

        return [
            'channel_id' => (int)($channel['id'] ?? 0),
            'channel_slug' => (string)($channel['slug'] ?? ''),
            'service_channel_id' => (int)($serviceChannel['id'] ?? 0),
            'service_channel_slug' => (string)($serviceChannel['slug'] ?? ''),
            'mode' => $serviceSet === null ? 'production_shared' : $mode,
            'service_set_mode' => $serviceSetMode,
            'data_policy' => $mode,
            'data_service_mode' => $mode,
            'policy' => $policy,
            'services' => $services,
        ];
    }

    private function releaseReplicationPolicySummary(): array
    {
        return [
            'default_mode' => 'production_shared',
            'normal_sync_changes_data_services' => false,
            'allowed_modes' => [
                'production_shared',
                'attach_existing',
                'clone_existing',
                'fresh_empty',
                'isolated_stack',
            ],
            'service_kinds' => self::STACK_DATA_KINDS,
            'change_control' => 'Data service mode changes are only allowed through explicit replication/failover actions.',
        ];
    }

    private function replicationPolicyForMode(string $mode): array
    {
        return [
            'mode' => $mode,
            'production_shared' => in_array($mode, ['production_shared', 'attach_existing'], true),
            'replication_configurable' => true,
            'failover_configurable' => true,
            'normal_sync_changes_service' => false,
        ];
    }

    private function activeServiceSetForChannel(int $channelId): ?array
    {
        return $this->selectOne(
            "SELECT s.*, c.slug AS channel_slug, c.name AS channel_name
             FROM release_channel_versions v
             INNER JOIN release_service_sets s ON s.id = v.service_set_id
             LEFT JOIN release_channels c ON c.id = s.channel_id
             WHERE v.channel_id = ? AND v.active = 1 AND s.deleted_at IS NULL
             ORDER BY v.activated_at DESC, v.id DESC
             LIMIT 1",
            'i',
            [$channelId]
        );
    }

    private function releaseCoolifySummary(): array
    {
        $targetCount = (int)($this->selectOne('SELECT COUNT(*) AS total FROM release_deployment_targets WHERE deleted_at IS NULL')['total'] ?? 0);
        $instanceCount = $this->tableExists('coolify_instances')
            ? (int)($this->selectOne('SELECT COUNT(*) AS total FROM coolify_instances WHERE deleted_at IS NULL')['total'] ?? 0)
            : 0;
        return [
            'integrated' => $this->tableExists('coolify_instances'),
            'panel' => 'release_manager',
            'instances' => $instanceCount,
            'deployment_targets' => $targetCount,
            'legacy_route' => '/superuser/configuration/coolify',
            'redirect_panel' => '/superuser/configuration/releases/integrations?panel=coolify',
            'entity_facts' => [
                'instances' => $instanceCount,
                'deployment_targets' => $targetCount,
                'status' => $this->tableExists('coolify_instances') ? 'integrated' : 'not_configured',
                'last_check' => date('c'),
            ],
        ];
    }

    private function releaseFailoverSummary(): array
    {
        $replicationHosts = $this->tableExists('replication_hosts')
            ? (int)($this->selectOne('SELECT COUNT(*) AS total FROM replication_hosts')['total'] ?? 0)
            : 0;
        return [
            'integrated' => $this->tableExists('replication_hosts'),
            'panel' => 'release_manager',
            'replication_hosts' => $replicationHosts,
            'default_data_mode' => 'production_shared',
            'normal_sync_triggers_failover' => false,
            'legacy_route' => '/superuser/configuration/failover',
            'redirect_panel' => '/superuser/configuration/releases/data-services?panel=failover',
            'entity_facts' => [
                'replication_hosts' => $replicationHosts,
                'default_data_mode' => 'production_shared',
                'readiness' => $this->tableExists('replication_hosts') ? 'ready' : 'not_configured',
                'normal_sync_triggers_failover' => false,
                'last_check' => date('c'),
            ],
        ];
    }

    private function githubRepositoryAccess(array $input): array
    {
        $tokenConfigured = $this->hasGithubApiToken();
        $repository = self::normalizeGithubRepositoryName((string)($input['repository'] ?? ''));
        $branch = trim((string)($input['branch'] ?? ''));
        $rawCommitSha = trim((string)($input['commit_sha'] ?? $input['commit'] ?? ''));
        $commitMode = $this->normalizeCommitMode((string)($input['commit_mode'] ?? ''), $rawCommitSha);

        if ($repository === '') {
            return [
                'ok' => false,
                'status' => 'invalid_repository',
                'token_configured' => $tokenConfigured,
                'message' => 'GitHub repository must use owner/repo format.',
                'repository' => trim((string)($input['repository'] ?? '')),
                'branch' => $branch,
                'commit_mode' => $commitMode,
            ];
        }
        if ($commitMode === 'specific' && $rawCommitSha === '') {
            return [
                'ok' => false,
                'status' => 'commit_required',
                'token_configured' => $tokenConfigured,
                'message' => 'Specific commit deployment requires a commit SHA.',
                'repository' => $repository,
                'branch' => $branch,
                'commit_mode' => $commitMode,
            ];
        }

        if (!$tokenConfigured) {
            $response = $this->githubTokenMissingResponse($repository, $branch);
            $response['commit_mode'] = $commitMode;
            return $response;
        }

        try {
            $repo = $this->githubRequest('GET', '/repos/' . $this->githubRepositoryPath($repository));
            $defaultBranch = trim((string)($repo['default_branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH;
            $branch = $branch !== '' ? $branch : $defaultBranch;
            $branchRow = $this->githubRequest(
                'GET',
                '/repos/' . $this->githubRepositoryPath($repository) . '/branches/' . rawurlencode($branch)
            );
            $latestCommitSha = trim((string)($branchRow['commit']['sha'] ?? ''));
            $commitSha = $latestCommitSha;
            $commitUrl = (string)($branchRow['commit']['url'] ?? '');
            $latestCommit = [];
            if ($latestCommitSha !== '') {
                $latestCommitRow = $this->githubRequest(
                    'GET',
                    '/repos/' . $this->githubRepositoryPath($repository) . '/commits/' . rawurlencode($latestCommitSha)
                );
                $latestCommit = is_array($latestCommitRow) ? $this->publicGithubCommit($latestCommitRow) : [];
                $commitUrl = (string)($latestCommit['html_url'] ?? $commitUrl);
            }
            $commit = $latestCommit;
            if ($commitMode === 'specific') {
                $commitRow = $this->githubRequest(
                    'GET',
                    '/repos/' . $this->githubRepositoryPath($repository) . '/commits/' . rawurlencode($rawCommitSha)
                );
                $commit = is_array($commitRow) ? $this->publicGithubCommit($commitRow) : [];
                $commitSha = trim((string)($commit['sha'] ?? (is_array($commitRow) ? ($commitRow['sha'] ?? null) : null) ?? $rawCommitSha));
                $commitUrl = (string)($commit['html_url'] ?? (is_array($commitRow) ? ($commitRow['html_url'] ?? null) : null) ?? $commitUrl);
                if ($latestCommitSha !== '' && $commitSha !== '' && $commitSha !== $latestCommitSha) {
                    $comparison = $this->githubRequest(
                        'GET',
                        '/repos/' . $this->githubRepositoryPath($repository) . '/compare/' . rawurlencode($commitSha) . '...' . rawurlencode($latestCommitSha)
                    );
                    $comparisonStatus = (string)($comparison['status'] ?? '');
                    if (!in_array($comparisonStatus, ['behind', 'identical'], true)) {
                        throw new RuntimeException(sprintf('Commit %s is not reachable from branch %s.', $commitSha, $branch));
                    }
                }
            }

            return [
                'ok' => true,
                'status' => 'accessible',
                'token_configured' => true,
                'message' => $commitMode === 'specific'
                    ? 'Repository, branch, and commit are accessible with the configured GitHub token.'
                    : 'Repository and branch are accessible with the configured GitHub token.',
                'repository' => $repository,
                'branch' => $branch,
                'default_branch' => $defaultBranch,
                'private' => (bool)($repo['private'] ?? false),
                'html_url' => $repo['html_url'] ?? null,
                'commit_mode' => $commitMode,
                'commit_sha' => $commitSha !== '' ? $commitSha : null,
                'latest_commit_sha' => $latestCommitSha !== '' ? $latestCommitSha : null,
                'commit' => $commit !== [] ? $commit : null,
                'latest_commit' => $latestCommit !== [] ? $latestCommit : null,
                'commit_authored_at' => $commit['authored_at'] ?? null,
                'commit_url' => $commitUrl !== '' ? $commitUrl : null,
            ];
        } catch (Throwable $throwable) {
            return [
                'ok' => false,
                'status' => 'inaccessible',
                'token_configured' => true,
                'message' => $throwable->getMessage(),
                'repository' => $repository,
                'branch' => $branch,
                'commit_mode' => $commitMode,
            ];
        }
    }

    private function normalizeCommitMode(string $commitMode, string $commitSha): string
    {
        $mode = strtolower(trim($commitMode));
        $commit = strtolower(trim($commitSha));
        if ($mode === 'specific') {
            return 'specific';
        }
        if ($mode === 'latest' || $mode === 'head' || $commit === '' || in_array($commit, ['latest', 'head'], true)) {
            return 'latest';
        }
        return 'specific';
    }

    private function githubTokenMissingResponse(?string $repository = null, ?string $branch = null): array
    {
        return [
            'ok' => false,
            'status' => 'not_configured',
            'token_configured' => false,
            'message' => 'Configure ReleaseManager github_token or RELEASE_MANAGER_GITHUB_TOKEN before using private GitHub repositories.',
            'repository' => $repository,
            'branch' => $branch,
            'repositories' => [],
            'branches' => [],
            'commits' => [],
        ];
    }

    private function hasGithubApiToken(): bool
    {
        return $this->githubApiToken() !== '';
    }

    private function githubEnvToken(): string
    {
        foreach (['RELEASE_MANAGER_GITHUB_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN'] as $key) {
            $value = trim((string)(getenv($key) ?: ($_SERVER[$key] ?? '')));
            if ($value !== '') {
                return $value;
            }
        }

        return '';
    }

    private function githubApiToken(): string
    {
        $envToken = $this->githubEnvToken();
        if ($envToken !== '') {
            return $envToken;
        }

        $token = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_token', ''));
        if ($token !== '' && str_starts_with($token, 'twsec:v1:') && class_exists(replication_secret_box::class)) {
            try {
                $token = replication_secret_box::decrypt($token);
            } catch (Throwable) {
                $token = '';
            }
        }
        return trim($token);
    }

    private function githubApiBaseUrl(): string
    {
        $value = trim((string)(getenv('RELEASE_MANAGER_GITHUB_API_URL') ?: ($_SERVER['RELEASE_MANAGER_GITHUB_API_URL'] ?? '')));
        if ($value === '') {
            $value = trim((string)$this->moduleConfigValue('ReleaseManager', 'github_api_url', 'https://api.github.com'));
        }
        $value = rtrim($value, '/');
        return preg_match('#^https?://#i', $value) === 1 ? $value : 'https://api.github.com';
    }

    private function githubRepositoryPath(string $repository): string
    {
        [$owner, $name] = explode('/', $repository, 2);
        return rawurlencode($owner) . '/' . rawurlencode($name);
    }

    private function githubRequest(string $method, string $path, array $query = []): array
    {
        $token = $this->githubApiToken();
        if ($token === '') {
            throw new RuntimeException('GitHub token is not configured.');
        }

        $url = $this->githubApiBaseUrl() . '/' . ltrim($path, '/');
        if ($query !== []) {
            $url .= '?' . http_build_query($query);
        }

        $curl = curl_init($url);
        if ($curl === false) {
            throw new RuntimeException('Could not initialize GitHub API request.');
        }

        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method));
        curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 3);
        curl_setopt($curl, CURLOPT_TIMEOUT, 10);
        curl_setopt($curl, CURLOPT_NOSIGNAL, true);
        curl_setopt($curl, CURLOPT_HTTPHEADER, [
            'Accept: application/vnd.github+json',
            'Authorization: Bearer ' . $token,
            'User-Agent: Truckwash-Release-Manager',
            'X-GitHub-Api-Version: 2022-11-28',
        ]);

        $raw = curl_exec($curl);
        $error = curl_error($curl);
        $status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
        curl_close($curl);

        if ($raw === false) {
            throw new RuntimeException('GitHub API request failed: ' . $error);
        }

        $decoded = [];
        if (trim((string)$raw) !== '') {
            $decodedJson = json_decode((string)$raw, true);
            $decoded = is_array($decodedJson) ? $decodedJson : ['raw' => (string)$raw];
        }

        if ($status < 200 || $status >= 300) {
            $message = is_array($decoded)
                ? (string)($decoded['message'] ?? $decoded['error'] ?? ('HTTP ' . $status))
                : ('HTTP ' . $status);
            throw new RuntimeException('GitHub API request failed: ' . $message);
        }

        return $decoded;
    }

    private function publicGithubRepository(array $row): array
    {
        $fullName = self::normalizeGithubRepositoryName((string)($row['full_name'] ?? ''));
        return [
            'id' => isset($row['id']) ? (int)$row['id'] : null,
            'name' => (string)($row['name'] ?? ''),
            'full_name' => $fullName,
            'private' => (bool)($row['private'] ?? false),
            'default_branch' => (string)($row['default_branch'] ?? self::DEFAULT_BRANCH),
            'description' => $row['description'] ?? null,
            'html_url' => $row['html_url'] ?? null,
            'clone_url' => $row['clone_url'] ?? null,
            'ssh_url' => $row['ssh_url'] ?? null,
            'pushed_at' => $row['pushed_at'] ?? null,
            'updated_at' => $row['updated_at'] ?? null,
        ];
    }

    private function publicGithubBranch(array $row): array
    {
        return [
            'name' => (string)($row['name'] ?? ''),
            'commit_sha' => $row['commit']['sha'] ?? null,
            'protected' => (bool)($row['protected'] ?? false),
        ];
    }

    private function publicGithubCommit(array $row): array
    {
        $sha = (string)($row['sha'] ?? '');
        $message = (string)($row['commit']['message'] ?? '');
        $title = trim(strtok($message, "\n") ?: $message);
        return [
            'sha' => $sha,
            'short_sha' => substr($sha, 0, 12),
            'message' => $title,
            'author_name' => $row['commit']['author']['name'] ?? $row['author']['login'] ?? null,
            'authored_at' => $row['commit']['author']['date'] ?? null,
            'html_url' => $row['html_url'] ?? null,
        ];
    }

    private function releaseSuggestions(): array
    {
        $channels = $this->listChannels();
        $targets = $this->listDeploymentTargets();
        $deployments = $this->listDeployments(50);
        $versions = release_manager_schema_bootstrap::tablesExist()
            ? $this->selectRows(
                "SELECT app, repository, branch, deployed_url, build_url
                 FROM release_versions
                 WHERE repository IS NOT NULL OR branch IS NOT NULL OR deployed_url IS NOT NULL
                 ORDER BY created_at DESC
                 LIMIT 100"
            )
            : [];

        $repositories = [];
        $branches = [self::DEFAULT_BRANCH, 'main', 'develop', 'staging'];
        $frontendUrls = [];
        $apiUrls = [];
        $healthUrls = [];
        $loadBalancerDomains = [];
        $serviceUuids = [];

        foreach ([
            $this->moduleConfigValue('Coolify', 'public_gateway_host', 'api-v2.truckwash.io'),
            getenv('COOLIFY_PUBLIC_GATEWAY_HOST') ?: ($_SERVER['COOLIFY_PUBLIC_GATEWAY_HOST'] ?? null),
            getenv('PUBLIC_GATEWAY_HOST') ?: ($_SERVER['PUBLIC_GATEWAY_HOST'] ?? null),
            getenv('RELEASE_LOAD_BALANCER_DOMAIN') ?: ($_SERVER['RELEASE_LOAD_BALANCER_DOMAIN'] ?? null),
            getenv('RELEASE_LOAD_BALANCER_DOMAINS') ?: ($_SERVER['RELEASE_LOAD_BALANCER_DOMAINS'] ?? null),
        ] as $domain) {
            $this->appendDomainSuggestion($loadBalancerDomains, $domain);
        }

        foreach (array_merge($targets, $deployments, $versions) as $row) {
            $this->appendSuggestion($repositories, $row['repository'] ?? null);
            $this->appendSuggestion($branches, $row['branch'] ?? null);
            $this->appendSuggestion($healthUrls, $row['health_url'] ?? null);
            $this->appendSuggestion($healthUrls, $row['deployment_url'] ?? null);
            $this->appendSuggestion($healthUrls, $row['deployed_url'] ?? null);
            $this->appendSuggestion($serviceUuids, $row['coolify_service_uuid'] ?? null);
        }

        foreach ($channels as $channel) {
            $slug = (string)($channel['slug'] ?? '');
            $this->appendSuggestion($branches, $slug !== '' ? 'release/' . $slug : null);
            $this->appendSuggestion($frontendUrls, $channel['frontend_base_url'] ?? null);
            $this->appendSuggestion($apiUrls, $channel['api_base_url'] ?? null);
            if (!empty($channel['frontend_base_url'])) {
                $this->appendSuggestion($healthUrls, rtrim((string)$channel['frontend_base_url'], '/') . '/health');
            }
            if (!empty($channel['api_base_url'])) {
                $this->appendSuggestion($healthUrls, rtrim((string)$channel['api_base_url'], '/') . '/ping');
            }
        }

        foreach ([
            'GITHUB_REPOSITORY',
            'RELEASE_FRONTEND_REPOSITORY',
            'RELEASE_API_REPOSITORY',
            'FRONTEND_GITHUB_REPOSITORY',
            'API_GITHUB_REPOSITORY',
        ] as $key) {
            $this->appendSuggestion($repositories, getenv($key) ?: ($_SERVER[$key] ?? null));
        }

        foreach (['GITHUB_REF_NAME', 'RELEASE_BRANCH', 'FRONTEND_BRANCH', 'API_BRANCH'] as $key) {
            $this->appendSuggestion($branches, getenv($key) ?: ($_SERVER[$key] ?? null));
        }

        foreach (['FRONTEND_URL', 'APP_URL', 'VITE_APP_URL'] as $key) {
            $this->appendSuggestion($frontendUrls, getenv($key) ?: ($_SERVER[$key] ?? null));
        }
        foreach (['API_URL', 'BACKEND_URL', 'PUBLIC_API_URL'] as $key) {
            $this->appendSuggestion($apiUrls, getenv($key) ?: ($_SERVER[$key] ?? null));
        }

        $origin = trim((string)($_SERVER['HTTP_ORIGIN'] ?? ''));
        if ($origin !== '') {
            $this->appendSuggestion($frontendUrls, $origin);
        }
        $host = trim((string)($_SERVER['HTTP_HOST'] ?? ''));
        if ($host !== '') {
            $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
            $this->appendSuggestion($apiUrls, $scheme . '://' . $host);
        }

        $coolifyInstances = [];
        $coolifyProjects = [];
        $coolifyServices = [];
        $coolifyGithubApps = [];
        if ($this->tableExists('coolify_instances')) {
            $instanceRows = $this->selectRows(
                'SELECT id, label, base_url, api_token_secret, status, default_project_uuid, default_environment_uuid, default_environment_name, default_server_uuid
                 FROM coolify_instances
                 WHERE deleted_at IS NULL
                 ORDER BY status = \'ok\' DESC, label'
            );
            $coolifyInstances = array_map(static function (array $row): array {
                return [
                    'id' => (int)($row['id'] ?? 0),
                    'label' => (string)($row['label'] ?? ''),
                    'base_url' => (string)($row['base_url'] ?? ''),
                    'status' => (string)($row['status'] ?? 'unknown'),
                    'default_project_uuid' => $row['default_project_uuid'] ?? null,
                    'default_environment_uuid' => $row['default_environment_uuid'] ?? null,
                    'default_environment_name' => $row['default_environment_name'] ?? null,
                    'default_server_uuid' => $row['default_server_uuid'] ?? null,
                ];
            }, $instanceRows);

            foreach ($instanceRows as $instanceRow) {
                foreach ($this->coolifyProjectSuggestions($instanceRow) as $project) {
                    $coolifyProjects[] = $project;
                }
                foreach ($this->coolifyGithubAppSuggestions($instanceRow) as $githubApp) {
                    $coolifyGithubApps[] = $githubApp;
                }
                foreach ($this->coolifyServiceSuggestions($instanceRow) as $service) {
                    $coolifyServices[] = $service;
                    $this->appendSuggestion($serviceUuids, $service['uuid'] ?? null);
                    foreach (($service['urls'] ?? []) as $url) {
                        $this->appendSuggestion($healthUrls, $url);
                    }
                }
            }
        }

        $channelPresets = [
            [
                'slug' => 'stable',
                'name' => 'Stable',
                'description' => 'Default production release channel.',
                'rollout_percent' => 100,
                'default_channel' => true,
                'replay_enabled' => false,
                'capture_level' => 'metadata',
                'retention_days' => 14,
            ],
            [
                'slug' => 'canary',
                'name' => 'Canary',
                'description' => 'Small early-access channel for validating a release before broad rollout.',
                'rollout_percent' => 5,
                'default_channel' => false,
                'replay_enabled' => true,
                'capture_level' => 'full_redacted',
                'retention_days' => 7,
            ],
            [
                'slug' => 'beta',
                'name' => 'Beta',
                'description' => 'Customer or staff opt-in channel for release candidate validation.',
                'rollout_percent' => 0,
                'default_channel' => false,
                'replay_enabled' => true,
                'capture_level' => 'metadata',
                'retention_days' => 14,
            ],
            [
                'slug' => 'internal',
                'name' => 'Internal',
                'description' => 'Staff-only channel for internal verification and support replay.',
                'rollout_percent' => 0,
                'default_channel' => false,
                'replay_enabled' => true,
                'capture_level' => 'full_redacted',
                'retention_days' => 14,
            ],
        ];

        $frontendRepository = $this->firstSuggestion($repositories, ['front-end', 'frontend', 'vue']) ?? ($repositories[0] ?? '');
        $apiRepository = $this->firstSuggestion($repositories, ['backend', 'api', 'php']) ?? ($repositories[1] ?? $repositories[0] ?? '');

        return [
            'github_token_configured' => $this->hasGithubApiToken(),
            'github_api_url' => $this->githubApiBaseUrl(),
            'repositories' => array_values($repositories),
            'branches' => array_values($branches),
            'frontend_base_urls' => array_values($frontendUrls),
            'api_base_urls' => array_values($apiUrls),
            'health_urls' => array_values($healthUrls),
            'load_balancer_domains' => array_values($loadBalancerDomains),
            'coolify_instances' => $coolifyInstances,
            'coolify_projects' => $coolifyProjects,
            'coolify_github_apps' => $coolifyGithubApps,
            'coolify_services' => $coolifyServices,
            'coolify_service_uuids' => array_values($serviceUuids),
            'channel_presets' => $channelPresets,
            'target_presets' => [
                [
                    'label' => 'Frontend target',
                    'app' => 'frontend',
                    'repository' => $frontendRepository,
                    'branch' => $branches[0] ?? self::DEFAULT_BRANCH,
                    'health_url' => $healthUrls[0] ?? '',
                    'auto_deploy' => true,
                ],
                [
                    'label' => 'API target',
                    'app' => 'api',
                    'repository' => $apiRepository,
                    'branch' => $branches[0] ?? self::DEFAULT_BRANCH,
                    'health_url' => $this->firstSuggestion($healthUrls, ['/ping'])
                        ?? $this->firstSuggestion($healthUrls, ['/health'])
                        ?? '',
                    'auto_deploy' => true,
                ],
            ],
            'setup_steps' => [
                ['key' => 'channels', 'done' => count($channels) > 0],
                ['key' => 'targets', 'done' => count($targets) > 0],
                ['key' => 'deployments', 'done' => count($deployments) > 0],
                ['key' => 'timeline', 'done' => (int)($this->timelineSummary()['events'] ?? 0) > 0],
            ],
        ];
    }

    private function appendSuggestion(array &$values, mixed $value): void
    {
        $value = trim((string)($value ?? ''));
        if ($value === '') {
            return;
        }
        foreach (preg_split('/\s*,\s*/', $value) ?: [] as $part) {
            $part = trim($part);
            if ($part !== '' && !in_array($part, $values, true)) {
                $values[] = $part;
            }
        }
    }

    private function appendDomainSuggestion(array &$values, mixed $value): void
    {
        $value = trim((string)($value ?? ''));
        if ($value === '') {
            return;
        }

        foreach (preg_split('/\s*,\s*/', $value) ?: [] as $part) {
            $domain = self::domainSuggestionHost($part);
            if ($domain !== null && !in_array($domain, $values, true)) {
                $values[] = $domain;
            }
        }
    }

    private static function domainSuggestionHost(mixed $value): ?string
    {
        $raw = trim((string)($value ?? ''));
        if ($raw === '') {
            return null;
        }

        $candidate = preg_match('#^https?://#i', $raw) === 1 ? $raw : 'https://' . $raw;
        $host = parse_url($candidate, PHP_URL_HOST);
        $port = parse_url($candidate, PHP_URL_PORT);
        $host = strtolower(trim((string)$host, "[] \t\n\r\0\x0B."));

        if (
            $host === ''
            || $port !== null
            || $host === 'localhost'
            || str_ends_with($host, '.localhost')
            || str_contains($host, '/')
            || filter_var($host, FILTER_VALIDATE_IP) !== false
        ) {
            return null;
        }

        return $host;
    }

    private function firstSuggestion(array $values, array $needles): ?string
    {
        foreach ($values as $value) {
            foreach ($needles as $needle) {
                if (stripos((string)$value, $needle) !== false) {
                    return (string)$value;
                }
            }
        }
        return null;
    }

    private function coolifyProjectSuggestions(array $instance): array
    {
        $tokenSecret = trim((string)($instance['api_token_secret'] ?? ''));
        if ($tokenSecret === '') {
            return [];
        }

        try {
            $token = replication_secret_box::decrypt($tokenSecret);
            $projects = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listProjects();
        } catch (Throwable) {
            return [];
        }

        $suggestions = [];
        foreach ($this->payloadRows($projects) as $row) {
            if (!is_array($row)) {
                continue;
            }
            $uuid = trim((string)($row['uuid'] ?? ''));
            if ($uuid === '') {
                continue;
            }
            $suggestions[] = [
                'instance_id' => (int)($instance['id'] ?? 0),
                'instance_label' => (string)($instance['label'] ?? ''),
                'uuid' => $uuid,
                'name' => (string)($row['name'] ?? $uuid),
                'description' => (string)($row['description'] ?? ''),
                'default' => $uuid === trim((string)($instance['default_project_uuid'] ?? '')),
            ];
        }

        return array_slice($suggestions, 0, 50);
    }

    private function coolifyGithubAppSuggestions(array $instance): array
    {
        $tokenSecret = trim((string)($instance['api_token_secret'] ?? ''));
        if ($tokenSecret === '') {
            return [];
        }

        try {
            $token = replication_secret_box::decrypt($tokenSecret);
            $apps = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listGithubApps();
        } catch (Throwable) {
            return [];
        }

        $suggestions = [];
        foreach ($this->payloadRows($apps) as $row) {
            if (!is_array($row)) {
                continue;
            }
            $uuid = trim((string)($row['uuid'] ?? ''));
            if ($uuid === '') {
                continue;
            }
            $suggestions[] = [
                'instance_id' => (int)($instance['id'] ?? 0),
                'instance_label' => (string)($instance['label'] ?? ''),
                'uuid' => $uuid,
                'name' => (string)($row['name'] ?? $uuid),
                'organization' => (string)($row['organization'] ?? ''),
                'type' => (string)($row['type'] ?? ''),
                'is_system_wide' => (bool)($row['is_system_wide'] ?? false),
                'html_url' => (string)($row['html_url'] ?? ''),
            ];
        }

        return array_slice($suggestions, 0, 50);
    }

    private function coolifyServiceSuggestions(array $instance): array
    {
        $tokenSecret = trim((string)($instance['api_token_secret'] ?? ''));
        if ($tokenSecret === '') {
            return [];
        }

        try {
            $token = replication_secret_box::decrypt($tokenSecret);
            $services = (new coolify_api_client((string)($instance['base_url'] ?? ''), $token, 4))->listServices();
        } catch (Throwable) {
            return [];
        }

        $rows = $this->payloadRows($services);
        $suggestions = [];
        foreach ($rows as $row) {
            if (!is_array($row)) {
                continue;
            }
            $uuid = trim((string)($row['uuid'] ?? $row['id'] ?? ''));
            if ($uuid === '') {
                continue;
            }
            $urls = [];
            foreach (['fqdn', 'domain', 'url'] as $key) {
                $this->appendSuggestion($urls, $row[$key] ?? null);
            }
            foreach (['urls', 'domains'] as $key) {
                if (!is_array($row[$key] ?? null)) {
                    continue;
                }
                foreach ($row[$key] as $url) {
                    if (is_array($url)) {
                        $this->appendSuggestion($urls, $url['url'] ?? $url['domain'] ?? $url['fqdn'] ?? null);
                    } else {
                        $this->appendSuggestion($urls, $url);
                    }
                }
            }

            $suggestions[] = [
                'instance_id' => (int)($instance['id'] ?? 0),
                'instance_label' => (string)($instance['label'] ?? ''),
                'uuid' => $uuid,
                'name' => (string)($row['name'] ?? $row['service_name'] ?? $uuid),
                'status' => (string)($row['status'] ?? $row['deployment_status'] ?? 'unknown'),
                'urls' => array_values($urls),
            ];
        }

        return array_slice($suggestions, 0, 50);
    }

    private function payloadRows(array $payload): array
    {
        if ($payload === []) {
            return [];
        }
        if (array_keys($payload) === range(0, count($payload) - 1)) {
            return $payload;
        }
        foreach (['data', 'services', 'applications', 'projects', 'servers', 'github_apps', 'envs', 'environment_variables', 'results'] as $key) {
            if (is_array($payload[$key] ?? null)) {
                return $this->payloadRows($payload[$key]);
            }
        }
        return [];
    }

    private function normalizeChannelInput(array $input, bool $creating): array
    {
        $slug = self::safeSlug((string)($input['slug'] ?? ''));
        if ($slug === '') {
            throw new RuntimeException('Release channel slug is required.');
        }

        $name = trim((string)($input['name'] ?? ($creating ? '' : $slug)));
        if ($name === '') {
            throw new RuntimeException('Release channel name is required.');
        }

        $retention = (int)($input['retention_days'] ?? 14);
        return [
            'slug' => $slug,
            'name' => substr($name, 0, 128),
            'description' => trim((string)($input['description'] ?? '')) ?: null,
            'enabled' => $this->toBool($input['enabled'] ?? true) ? 1 : 0,
            'default_channel' => $this->toBool($input['default_channel'] ?? false) ? 1 : 0,
            'rollout_percent' => max(0, min(100, (float)($input['rollout_percent'] ?? 0))),
            'frontend_base_url' => trim((string)($input['frontend_base_url'] ?? '')) ?: null,
            'api_base_url' => trim((string)($input['api_base_url'] ?? '')) ?: null,
            'replay_enabled' => $this->toBool($input['replay_enabled'] ?? false) ? 1 : 0,
            'capture_level' => $this->normalizeCaptureLevel((string)($input['capture_level'] ?? 'metadata')),
            'retention_days' => max(1, min(365, $retention > 0 ? $retention : 14)),
            'metadata' => is_array($input['metadata'] ?? null) ? $input['metadata'] : self::jsonDecode($input['metadata_json'] ?? null),
        ];
    }

    private function channelFromInput(array $input): array
    {
        $id = $this->nullablePositiveInt($input['channel_id'] ?? null);
        if ($id !== null) {
            return $this->getChannel($id);
        }

        $slug = self::safeSlug((string)($input['channel_slug'] ?? $input['channel'] ?? ''));
        if ($slug !== '') {
            $channel = $this->findChannelBySlug(self::channelSlugForRoute($slug));
            if ($channel !== null) {
                return $channel;
            }
        }

        throw new RuntimeException('Release channel is required.');
    }

    private function deploymentTargetFromInput(array $input, int $channelId, string $app): ?array
    {
        $targetId = $this->nullablePositiveInt($input['target_id'] ?? null);
        if ($targetId !== null) {
            return $this->getDeploymentTarget($targetId);
        }

        return $this->selectOne(
            "SELECT * FROM release_deployment_targets
             WHERE deleted_at IS NULL AND channel_id = ? AND app = ?
             ORDER BY auto_deploy DESC, id DESC
             LIMIT 1",
            'is',
            [$channelId, $app]
        );
    }

    private function normalizeServiceSetMode(string $value): string
    {
        $mode = strtolower(trim($value));
        if (!in_array($mode, self::SERVICE_SET_MODES, true)) {
            throw new RuntimeException('Release service set mode must be attach_existing, clone_existing, fresh_empty, or isolated_stack.');
        }
        return $mode;
    }

    private function channelFromInputOrDefault(array $input, ?array $source = null): array
    {
        foreach (['channel_id', 'channel_slug', 'channel'] as $key) {
            if (array_key_exists($key, $input) && trim((string)$input[$key]) !== '') {
                return $this->channelFromInput($input);
            }
        }

        if ($source !== null && !empty($source['channel_id'])) {
            return $this->getChannel((int)$source['channel_id']);
        }

        return $this->defaultChannel();
    }

    private function serviceSetTargetIdFromInput(array $input, string $app, ?array $source): ?int
    {
        $aliases = $app === 'api'
            ? ['api_target_id', 'php_target_id', 'backend_target_id']
            : ['frontend_target_id'];
        $targets = is_array($input['targets'] ?? null) ? $input['targets'] : [];
        if (is_array($targets[$app] ?? null)) {
            foreach (['target_id', 'id'] as $key) {
                $aliases[] = $app . '.' . $key;
            }
        }

        foreach ($aliases as $key) {
            $value = str_contains($key, '.')
                ? ($targets[$app][substr($key, strpos($key, '.') + 1)] ?? null)
                : ($input[$key] ?? null);
            $id = $this->nullablePositiveInt($value);
            if ($id === null) {
                continue;
            }
            $target = $this->getDeploymentTarget($id);
            if ((string)($target['app'] ?? '') !== $app) {
                throw new RuntimeException(sprintf('Selected %s target does not match the requested app.', $app));
            }
            return $id;
        }

        $sourceKey = $app . '_target_id';
        return $source !== null ? $this->nullablePositiveInt($source[$sourceKey] ?? null) : null;
    }

    private function serviceSetDataTargetIdFromInput(array $input, string $kind, ?array $source): ?int
    {
        $dataTargets = is_array($input['data_targets'] ?? null) ? $input['data_targets'] : [];
        $value = $input[$kind . '_coolify_target_id']
            ?? $input[$kind . '_target_id']
            ?? $dataTargets[$kind . '_coolify_target_id']
            ?? $dataTargets[$kind . '_target_id']
            ?? $dataTargets[$kind]
            ?? null;
        $id = $this->nullablePositiveInt($value);
        if ($id !== null) {
            $target = $this->nullableCoolifyTarget($id);
            if ($target !== null && (string)($target['kind'] ?? '') !== $kind) {
                throw new RuntimeException(sprintf('Selected %s data service target has the wrong replica kind.', $kind));
            }
            return $id;
        }

        $sourceKey = $kind . '_coolify_target_id';
        return $source !== null ? $this->nullablePositiveInt($source[$sourceKey] ?? null) : null;
    }

    private function serviceSetInputHasExplicitDataTargets(array $input): bool
    {
        $dataTargets = is_array($input['data_targets'] ?? null) ? $input['data_targets'] : [];
        foreach (self::STACK_DATA_KINDS as $kind) {
            foreach ([
                $input[$kind . '_coolify_target_id'] ?? null,
                $input[$kind . '_target_id'] ?? null,
                $dataTargets[$kind . '_coolify_target_id'] ?? null,
                $dataTargets[$kind . '_target_id'] ?? null,
                $dataTargets[$kind] ?? null,
            ] as $value) {
                if ($this->nullablePositiveInt($value) !== null) {
                    return true;
                }
            }
        }
        return false;
    }

    private function isBetaChannel(array $channel): bool
    {
        return self::safeSlug((string)($channel['slug'] ?? '')) === 'beta';
    }

    private function channelUsesProductionServices(array $channel): bool
    {
        return in_array(
            self::safeSlug((string)($channel['slug'] ?? '')),
            self::PRODUCTION_SERVICE_CHANNELS,
            true
        );
    }

    private function serviceSetDataPolicy(?array $serviceSet): string
    {
        if ($serviceSet === null) {
            return self::PRODUCTION_DATA_POLICY;
        }

        $mode = strtolower(trim((string)($serviceSet['mode'] ?? '')));
        if (in_array($mode, ['clone_existing', 'fresh_empty', 'isolated_stack'], true)) {
            return $mode;
        }
        if (in_array($mode, ['', 'attach_existing', self::PRODUCTION_DATA_POLICY], true)) {
            return self::PRODUCTION_DATA_POLICY;
        }

        $metadata = is_array($serviceSet['metadata'] ?? null)
            ? $serviceSet['metadata']
            : self::jsonDecode($serviceSet['metadata_json'] ?? null);
        $policy = strtolower(trim((string)($serviceSet['data_policy'] ?? $serviceSet['data_service_mode'] ?? $metadata['data_policy'] ?? $metadata['data_service_mode'] ?? '')));
        if ($policy === self::PRODUCTION_DATA_POLICY) {
            return self::PRODUCTION_DATA_POLICY;
        }

        return $policy !== '' ? $policy : self::PRODUCTION_DATA_POLICY;
    }

    private function assertBetaProductionDataPolicy(array $channel, array $serviceSet): void
    {
        if (!$this->isBetaChannel($channel)) {
            return;
        }

        if ($this->serviceSetDataPolicy($serviceSet) !== self::PRODUCTION_DATA_POLICY) {
            throw new RuntimeException('Beta release bundles must use production-shared data services.');
        }
    }

    private function assertBetaDataSourceChannel(?array $source): void
    {
        if ($source === null) {
            return;
        }

        $slug = self::safeSlug((string)($source['channel_slug'] ?? ''));
        if (!in_array($slug, self::BETA_PRODUCTION_DATA_SOURCE_CHANNELS, true)) {
            throw new RuntimeException('Beta data-only service sets can copy data targets only from Stable/Master production service sets.');
        }
    }

    private function assertServiceSetTargetBelongsToChannel(?int $targetId, string $app, array $channel): void
    {
        if ($targetId === null) {
            return;
        }

        $target = $this->getDeploymentTarget($targetId);
        if ((string)($target['app'] ?? '') !== $app) {
            throw new RuntimeException(sprintf('Selected %s target does not match the requested app.', $app));
        }
        if ((int)($target['channel_id'] ?? 0) !== (int)($channel['id'] ?? 0)) {
            throw new RuntimeException(sprintf('Beta production-data service sets require %s targets from the beta channel.', $app));
        }
    }

    private function assertIsolatedStackTarget(?int $targetId, string $app, bool $allowCreatedService = false): void
    {
        if ($targetId === null) {
            throw new RuntimeException(sprintf('Isolated stack deployments require a new %s Coolify target.', $app));
        }

        $target = $this->getDeploymentTarget($targetId);
        $context = self::jsonDecode($target['deploy_context_json'] ?? null);
        if ((string)($target['app'] ?? '') !== $app) {
            throw new RuntimeException(sprintf('Isolated stack %s target does not match the requested app.', $app));
        }
        if ($this->nullablePositiveInt($target['coolify_instance_id'] ?? null) === null) {
            throw new RuntimeException(sprintf('Isolated stack %s target must select a Coolify instance.', $app));
        }
        if (trim((string)($target['coolify_service_uuid'] ?? '')) !== '' && !$allowCreatedService) {
            throw new RuntimeException(sprintf('Isolated stack %s target must not point at an existing Coolify service.', $app));
        }

        if (!$this->toBool($context['coolify_auto_create'] ?? false)) {
            throw new RuntimeException(sprintf('Isolated stack %s target must create a new Coolify service.', $app));
        }
        if (!$this->toBool($context['isolated_stack'] ?? false)) {
            throw new RuntimeException(sprintf('Isolated stack %s target must be marked as isolated.', $app));
        }
    }

    private function assertIsolatedStackDataTarget(?int $targetId, string $kind): void
    {
        if ($targetId === null) {
            throw new RuntimeException(sprintf('Isolated stack deployments require a new %s data target.', $kind));
        }

        $target = $this->nullableCoolifyTarget($targetId);
        if ($target === null) {
            throw new RuntimeException(sprintf('Selected %s isolated data target was not found.', $kind));
        }
        if ((string)($target['kind'] ?? '') !== $kind) {
            throw new RuntimeException(sprintf('Selected %s isolated data target has the wrong kind.', $kind));
        }

        $options = is_array($target['options'] ?? null) ? $target['options'] : [];
        if (!$this->toBool($options['isolated_stack'] ?? false)) {
            throw new RuntimeException(sprintf('Selected %s data target is not marked as an isolated stack target.', $kind));
        }
        if ($this->toBool($options['production_data_attached'] ?? true)) {
            throw new RuntimeException(sprintf('Selected %s data target must not attach production data.', $kind));
        }
        if (!$this->toBool($options['skip_replication_provisioning'] ?? false)) {
            throw new RuntimeException(sprintf('Selected %s data target must skip production replication provisioning.', $kind));
        }
    }

    private function createIsolatedStackDataTarget(
        string $kind,
        array $input,
        array $channel,
        string $serviceSetName,
        ?int $frontendTargetId,
        ?int $apiTargetId,
        ?int $actorUserId
    ): int {
        if (!class_exists(coolify_manager::class) && function_exists('app_require')) {
            app_require('classes/coolify_manager.php');
        }
        if (!class_exists(coolify_manager::class)) {
            throw new RuntimeException('Coolify integration is required to create isolated stack data services.');
        }

        $placementTarget = $this->isolatedStackPlacementTarget($apiTargetId, $frontendTargetId);
        $context = self::jsonDecode($placementTarget['deploy_context_json'] ?? null);
        $dataServicesInput = is_array($input['data_services'] ?? null) ? $input['data_services'] : [];
        $dataInput = is_array($dataServicesInput[$kind] ?? null) ? $dataServicesInput[$kind] : [];
        $placementContext = array_replace($context, $dataInput);
        $instanceId = $this->nullablePositiveInt($placementTarget['coolify_instance_id'] ?? null);
        if ($instanceId === null) {
            throw new RuntimeException('Isolated stack data services require a Coolify instance.');
        }

        $instance = $this->selectOne('SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL', 'i', [$instanceId]);
        if ($instance === null) {
            throw new RuntimeException('Coolify instance for isolated stack data services was not found.');
        }

        $serverUuid = $this->releaseCoolifyServerUuid($placementContext, $instance);
        if ($serverUuid === '') {
            throw new RuntimeException('Release Manager could not resolve a Coolify server UUID for isolated data services.');
        }
        $placementTarget['channel_slug'] = $placementTarget['channel_slug'] ?? $channel['slug'] ?? '';
        $environment = $this->releaseCoolifyEnvironment($placementTarget, $placementContext, $instance);

        $stackSlug = self::safeSlug($serviceSetName !== '' ? $serviceSetName : ((string)($channel['slug'] ?? 'release') . '-isolated-stack'));
        $serviceName = substr('release-' . ($stackSlug !== '' ? $stackSlug : 'isolated-stack') . '-' . $kind, 0, 64);
        $payload = array_replace($dataInput, [
            'kind' => $kind,
            'role' => 'replica',
            'instance_id' => $instanceId,
            'server_uuid' => $serverUuid,
            'project_uuid' => trim((string)($placementContext['coolify_project_uuid'] ?? $placementContext['project_uuid'] ?? $instance['default_project_uuid'] ?? '')),
            'environment_uuid' => $environment['uuid'] ?? '',
            'environment_name' => $environment['name'],
            'destination_uuid' => trim((string)($placementContext['coolify_destination_uuid'] ?? $placementContext['destination_uuid'] ?? $instance['default_destination_uuid'] ?? '')),
            'label' => $serviceName,
            'service_name' => $serviceName,
            'resource_name' => $serviceName,
            'isolated_stack' => true,
            'skip_replication_provisioning' => true,
            'deploy' => $this->toBool($input['deploy_data_targets'] ?? $input['deploy_isolated_data_targets'] ?? true),
            'options' => [
                'isolated_stack' => true,
                'skip_replication_provisioning' => true,
                'production_data_attached' => false,
                'release_service_set_name' => $serviceSetName,
                'release_channel_slug' => (string)($channel['slug'] ?? ''),
            ],
        ]);

        $created = (new coolify_manager())->createTarget($payload, $actorUserId);
        $targetId = $this->nullablePositiveInt($created['target']['id'] ?? null);
        if ($targetId === null) {
            throw new RuntimeException(sprintf('Coolify did not return a %s isolated data target id.', $kind));
        }

        $this->assertIsolatedStackDataTarget($targetId, $kind);
        return $targetId;
    }

    private function isolatedStackPlacementTarget(?int $apiTargetId, ?int $frontendTargetId): array
    {
        foreach ([$apiTargetId, $frontendTargetId] as $targetId) {
            if ($targetId === null) {
                continue;
            }
            $target = $this->getDeploymentTarget($targetId);
            if ($this->nullablePositiveInt($target['coolify_instance_id'] ?? null) !== null) {
                return $target;
            }
        }

        throw new RuntimeException('Isolated stack data services require a frontend or API Coolify target.');
    }

    private function uniqueServiceSetSlug(string $slug): string
    {
        $base = $slug !== '' ? $slug : 'service-set-' . date('Ymd-His');
        $candidate = substr($base, 0, 64);
        $suffix = 2;
        while ($this->selectOne('SELECT id FROM release_service_sets WHERE slug = ? LIMIT 1', 's', [$candidate]) !== null) {
            $tail = '-' . $suffix;
            $candidate = substr($base, 0, 64 - strlen($tail)) . $tail;
            $suffix++;
        }
        return $candidate;
    }

    private function serviceSetStatus(string $mode, ?int $frontendTargetId, ?int $apiTargetId, array $dataTargets): string
    {
        $hasCode = $frontendTargetId !== null && $apiTargetId !== null;
        $hasData = !in_array(null, $dataTargets, true);
        if ($mode === 'isolated_stack') {
            return $hasCode && $hasData ? 'isolated_stack' : 'needs_isolated_targets';
        }
        if ($mode === 'attach_existing') {
            return $hasCode ? 'ready' : 'needs_configuration';
        }
        if ($hasCode && $hasData) {
            return $mode === 'clone_existing' ? 'provisioning' : 'ready';
        }
        if ($mode === 'fresh_empty') {
            return 'isolated_empty';
        }
        return $mode === 'clone_existing' ? 'needs_clone_targets' : 'needs_configuration';
    }

    private function replicaProvisioningPlan(string $mode, ?array $source, array $dataTargets): array
    {
        $plan = [];
        foreach (self::STACK_DATA_KINDS as $kind) {
            $sourceTargetId = $source !== null ? $this->nullablePositiveInt($source[$kind . '_coolify_target_id'] ?? null) : null;
            $sourceTarget = $this->nullableCoolifyTarget($sourceTargetId);
            $target = $this->nullableCoolifyTarget($dataTargets[$kind] ?? null);
            $plan[$kind] = [
                'action' => match ($mode) {
                    'clone_existing' => 'clone_replica_from_source',
                    'isolated_stack' => 'create_isolated_empty_stack_service',
                    'fresh_empty' => 'register_isolated_empty_service',
                    default => 'attach_existing_service',
                },
                'source_coolify_target_id' => $sourceTargetId,
                'source_replication_host_id' => $sourceTarget['replication']['id'] ?? null,
                'target_coolify_target_id' => $dataTargets[$kind] ?? null,
                'target_replication_host_id' => $target['replication']['id'] ?? null,
                'production_replication_attached' => $mode === 'attach_existing',
            ];
        }

        return $plan;
    }

    private function bundleAppInput(array $input, string $app, ?array $target, string $versionLabel): array
    {
        $appPayload = is_array($input[$app] ?? null) ? $input[$app] : [];
        if ($app === 'api') {
            $appPayload = array_replace(
                is_array($input['php'] ?? null) ? $input['php'] : [],
                is_array($input['backend'] ?? null) ? $input['backend'] : [],
                $appPayload
            );
        }

        $repository = trim((string)($appPayload['repository'] ?? $input[$app . '_repository'] ?? $target['repository'] ?? ''));
        $normalizedRepository = self::normalizeGithubRepositoryName($repository);
        if ($normalizedRepository !== '') {
            $repository = $normalizedRepository;
        }
        if ($repository === '') {
            throw new RuntimeException(sprintf('%s repository is required for bundle releases.', $app === 'api' ? 'PHP backend' : 'Frontend'));
        }

        $branch = trim((string)($appPayload['branch'] ?? $input[$app . '_branch'] ?? $target['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH;
        $rawCommitSha = trim((string)($appPayload['commit_sha'] ?? $appPayload['commit'] ?? $input[$app . '_commit_sha'] ?? ''));
        $commitMode = $this->normalizeCommitMode((string)($appPayload['commit_mode'] ?? $input[$app . '_commit_mode'] ?? ''), $rawCommitSha);
        $commitSha = $commitMode === 'specific' && $rawCommitSha !== '' ? $rawCommitSha : null;
        $githubAccess = $this->githubRepositoryAccess([
            'repository' => $repository,
            'branch' => $branch,
            'commit_sha' => $commitSha,
            'commit_mode' => $commitMode,
        ]);
        if (($githubAccess['token_configured'] ?? false) && !($githubAccess['ok'] ?? false)) {
            throw new RuntimeException('GitHub repository access test failed: ' . (string)($githubAccess['message'] ?? 'Repository is not accessible.'));
        }
        if (($githubAccess['ok'] ?? false) && !empty($githubAccess['commit_sha'])) {
            $commitSha = (string)$githubAccess['commit_sha'];
            $branch = (string)($githubAccess['branch'] ?? $branch);
        }

        return [
            'app' => $app,
            'repository' => $repository,
            'branch' => $branch,
            'commit_mode' => $commitMode,
            'commit_sha' => $commitSha,
            'version_label' => sprintf('%s-%s', $versionLabel, $app === 'api' ? 'php' : 'frontend'),
            'deployed_url' => is_array($target) ? $this->releaseTargetPublicBaseUrl($target) : null,
            'github_access' => $githubAccess,
        ];
    }

    private function createBundleVersion(array $input, string $app): int
    {
        return $this->createVersion([
            'app' => $app,
            'repository' => $input['repository'],
            'branch' => $input['branch'],
            'commit_sha' => $input['commit_sha'],
            'version_label' => $input['version_label'],
            'deployed_url' => $input['deployed_url'] ?? null,
            'status' => 'draft',
            'metadata' => [
                'commit_mode' => $input['commit_mode'],
                'github_access' => self::releaseVersionGithubAccessMetadata($input['github_access'] ?? null),
                'bundle_member' => true,
            ],
        ]);
    }

    private function getChannel(int $id): array
    {
        $row = $this->selectOne('SELECT * FROM release_channels WHERE id = ? AND deleted_at IS NULL', 'i', [$id]);
        if ($row === null) {
            throw new RuntimeException('Release channel not found.');
        }
        return $row;
    }

    private function findChannelBySlug(string $slug): ?array
    {
        return $this->selectOne(
            'SELECT * FROM release_channels WHERE slug = ? AND deleted_at IS NULL LIMIT 1',
            's',
            [$slug]
        );
    }

    private function getVersion(int $id): array
    {
        return $this->selectOne('SELECT * FROM release_versions WHERE id = ?', 'i', [$id]) ?? [];
    }

    private function getDeployment(int $id): array
    {
        $row = $this->selectOne(
            "SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url
             FROM release_deployments d
             INNER JOIN release_channels c ON c.id = d.channel_id
             LEFT JOIN release_versions v ON v.id = d.version_id
             WHERE d.id = ?",
            'i',
            [$id]
        );
        if ($row === null) {
            throw new RuntimeException('Release deployment not found.');
        }
        return $row;
    }

    private function getServiceSet(int $id): array
    {
        $row = $this->selectOne(
            "SELECT s.*, c.slug AS channel_slug, c.name AS channel_name
             FROM release_service_sets s
             LEFT JOIN release_channels c ON c.id = s.channel_id
             WHERE s.id = ? AND s.deleted_at IS NULL",
            'i',
            [$id]
        );
        if ($row === null) {
            throw new RuntimeException('Release service set not found.');
        }
        return $row;
    }

    private function getBundle(int $id): array
    {
        $row = $this->selectOne(
            "SELECT b.*, c.slug AS channel_slug, c.name AS channel_name, s.name AS service_set_name, s.slug AS service_set_slug
             FROM release_bundles b
             INNER JOIN release_channels c ON c.id = b.channel_id
             INNER JOIN release_service_sets s ON s.id = b.service_set_id
             WHERE b.id = ? AND b.deleted_at IS NULL",
            'i',
            [$id]
        );
        if ($row === null) {
            throw new RuntimeException('Release bundle not found.');
        }
        return $row;
    }

    private function getDeploymentTarget(int $id): array
    {
        $row = $this->selectOne(
            "SELECT t.*, c.slug AS channel_slug, c.name AS channel_name, i.label AS coolify_instance_label
             FROM release_deployment_targets t
             INNER JOIN release_channels c ON c.id = t.channel_id
             LEFT JOIN coolify_instances i ON i.id = t.coolify_instance_id
             WHERE t.id = ? AND t.deleted_at IS NULL",
            'i',
            [$id]
        );
        if ($row === null) {
            throw new RuntimeException('Release deployment target not found.');
        }
        return $row;
    }

    private function publicChannel(array $channel): array
    {
        return [
            'id' => (int)($channel['id'] ?? 0),
            'slug' => (string)($channel['slug'] ?? ''),
            'route_slug' => self::routeSlugForChannel((string)($channel['slug'] ?? '')),
            'name' => (string)($channel['name'] ?? ''),
            'description' => $channel['description'] ?? null,
            'enabled' => (bool)((int)($channel['enabled'] ?? 0)),
            'default_channel' => (bool)((int)($channel['default_channel'] ?? 0)),
            'rollout_percent' => (float)($channel['rollout_percent'] ?? 0),
            'frontend_base_url' => $channel['frontend_base_url'] ?? null,
            'api_base_url' => $channel['api_base_url'] ?? null,
            'replay_enabled' => (bool)((int)($channel['replay_enabled'] ?? 0)),
            'capture_level' => (string)($channel['capture_level'] ?? 'metadata'),
            'retention_days' => (int)($channel['retention_days'] ?? 14),
            'metadata' => self::jsonDecode($channel['metadata_json'] ?? null),
            'created_at' => $channel['created_at'] ?? null,
            'updated_at' => $channel['updated_at'] ?? null,
        ];
    }

    private function publicRuntimeChannel(array $channel): array
    {
        $public = $this->publicChannel($channel);
        unset($public['frontend_base_url'], $public['api_base_url']);
        return $public;
    }

    private function publicRuntimeChannelOptions(array $channels): array
    {
        return array_map(function (array $channel): array {
            $serviceChannel = $this->runtimeServiceChannelFor($channel);
            return [
                'channel' => $this->publicRuntimeChannel($channel),
                'service_channel' => $this->publicRuntimeChannel($serviceChannel),
                'versions' => $this->currentVersionsForChannel((int)($serviceChannel['id'] ?? 0)),
                'availability' => $this->channelAvailability($channel),
            ];
        }, $channels);
    }

    public static function publicAssignmentSubjectSuggestion(array $candidate): ?array
    {
        $subjectType = strtolower(trim((string)($candidate['subject_type'] ?? '')));
        if (!in_array($subjectType, self::SUBJECT_TYPES, true)) {
            return null;
        }

        $subjectId = self::safeIdentifier((string)($candidate['subject_id'] ?? ''), 64);
        if ($subjectId === '') {
            return null;
        }

        $title = self::safeDisplayText($candidate['title'] ?? '', 120);
        if ($title === '') {
            $title = $subjectType . ':' . $subjectId;
        }
        $description = self::safeDisplayText($candidate['description'] ?? '', 180);
        $icon = self::safeIconClass($candidate['icon'] ?? self::assignmentSubjectIcon($subjectType));
        $source = self::safeIdentifier((string)($candidate['source'] ?? $subjectType), 32) ?: $subjectType;

        return [
            'subject_type' => $subjectType,
            'subject_id' => $subjectId,
            'label' => $description !== '' ? $title . ' - ' . $description : $title,
            'title' => $title,
            'description' => $description,
            'icon' => $icon,
            'source' => $source,
        ];
    }

    private function searchAssignmentUsers(string $query, int $limit): array
    {
        $like = '%' . $query . '%';
        $rows = $this->selectRows(
            "SELECT id, customer_number, display_name, email, phone_country_code, phone
             FROM users
             WHERE CAST(id AS CHAR) LIKE ?
                OR CAST(customer_number AS CHAR) LIKE ?
                OR display_name LIKE ?
                OR email LIKE ?
             ORDER BY id DESC
             LIMIT ?",
            'ssssi',
            [$like, $like, $like, $like, $limit]
        );

        return array_map(function (array $row): array {
            $id = (string)($row['id'] ?? '');
            $customerNumber = trim((string)($row['customer_number'] ?? ''));
            $displayName = self::safeDisplayText($row['display_name'] ?? '', 80);
            $email = self::safeDisplayText($row['email'] ?? '', 80);
            $parts = array_filter([
                $customerNumber !== '' ? 'Customer #' . $customerNumber : '',
                $email,
                $this->phoneLabel($row),
            ]);

            return [
                'subject_type' => 'user',
                'subject_id' => $id,
                'title' => $displayName !== '' ? $displayName : 'User #' . $id,
                'description' => implode(' / ', $parts),
                'icon' => self::assignmentSubjectIcon('user'),
                'source' => 'users',
            ];
        }, $rows);
    }

    private function searchAssignmentSubusers(string $query, int $limit): array
    {
        $like = '%' . $query . '%';
        $rows = $this->selectRows(
            "SELECT id, username, name, email, phone_country_code, phone
             FROM subusers
             WHERE CAST(id AS CHAR) LIKE ?
                OR username LIKE ?
                OR name LIKE ?
                OR email LIKE ?
                OR CAST(phone AS CHAR) LIKE ?
             ORDER BY id DESC
             LIMIT ?",
            'sssssi',
            [$like, $like, $like, $like, $like, $limit]
        );

        return array_map(function (array $row): array {
            $id = (string)($row['id'] ?? '');
            $name = self::safeDisplayText($row['name'] ?? '', 80);
            $username = self::safeDisplayText($row['username'] ?? '', 80);
            $email = self::safeDisplayText($row['email'] ?? '', 80);
            $parts = array_filter([
                $username !== '' ? '@' . ltrim($username, '@') : '',
                $email,
                $this->phoneLabel($row),
            ]);

            return [
                'subject_type' => 'subuser',
                'subject_id' => $id,
                'title' => $name !== '' ? $name : 'Subuser #' . $id,
                'description' => implode(' / ', $parts),
                'icon' => self::assignmentSubjectIcon('subuser'),
                'source' => 'subusers',
            ];
        }, $rows);
    }

    private function searchAssignmentCustomers(string $query, int $limit): array
    {
        try {
            $result = (new economicCustomers())->listCustomers(1, $limit, $query, null);
        } catch (Throwable) {
            return [];
        }

        $customers = is_array($result->collection ?? null) ? $result->collection : [];
        return array_map(static function (object $customer): array {
            $customerNumber = (string)($customer->customerNumber ?? $customer->customer_number ?? '');
            $name = self::safeDisplayText($customer->name ?? $customer->customer_name ?? '', 100);
            $email = self::safeDisplayText($customer->email ?? '', 80);
            $city = self::safeDisplayText($customer->city ?? '', 80);
            $parts = array_filter([
                $customerNumber !== '' ? 'Customer #' . $customerNumber : '',
                $email,
                $city,
            ]);

            return [
                'subject_type' => 'customer',
                'subject_id' => $customerNumber,
                'title' => $name !== '' ? $name : 'Customer #' . $customerNumber,
                'description' => implode(' / ', $parts),
                'icon' => self::assignmentSubjectIcon('customer'),
                'source' => 'customers',
            ];
        }, $customers);
    }

    private static function normalizeAssignmentSubjectSearch(mixed $value): string
    {
        $query = self::safeDisplayText($value, 80);
        return trim($query);
    }

    private static function normalizeAssignmentSubjectLimit(mixed $value): int
    {
        $limit = (int)$value;
        if ($limit <= 0) {
            return 5;
        }
        return min(10, max(1, $limit));
    }

    private static function safeDisplayText(mixed $value, int $maxLength): string
    {
        $text = trim(strip_tags((string)$value));
        $text = preg_replace('/\s+/', ' ', $text) ?? '';
        return substr($text, 0, max(1, $maxLength));
    }

    private static function safeIconClass(mixed $value): string
    {
        $icon = trim((string)$value);
        if (!preg_match('/^[a-z0-9 _-]+$/i', $icon)) {
            return 'fas fa-tag';
        }
        return $icon;
    }

    private static function assignmentSubjectIcon(string $subjectType): string
    {
        return match ($subjectType) {
            'customer' => 'fas fa-building',
            'subuser' => 'fas fa-id-badge',
            default => 'fas fa-user',
        };
    }

    private function phoneLabel(array $row): string
    {
        $countryCode = trim((string)($row['phone_country_code'] ?? ''));
        $phone = trim((string)($row['phone'] ?? ''));
        if ($phone === '') {
            return '';
        }
        return $countryCode !== '' ? '+' . $countryCode . ' ' . $phone : $phone;
    }

    private function publicVersion(?array $version): ?array
    {
        if (!$version || empty($version['id'])) {
            return null;
        }

        $metadata = self::publicReleaseVersionMetadata(self::jsonDecode($version['metadata_json'] ?? null));
        $commit = $this->versionGithubCommit(['metadata' => $metadata]);

        return [
            'id' => (int)$version['id'],
            'app' => (string)$version['app'],
            'repository' => $version['repository'] ?? null,
            'branch' => $version['branch'] ?? null,
            'commit_sha' => $version['commit_sha'] ?? null,
            'commit' => $commit,
            'commit_authored_at' => $commit['authored_at'] ?? null,
            'tag' => $version['tag'] ?? null,
            'version_label' => $version['version_label'] ?? null,
            'build_url' => $version['build_url'] ?? null,
            'artifact_url' => $version['artifact_url'] ?? null,
            'deployed_url' => $version['deployed_url'] ?? null,
            'status' => (string)($version['status'] ?? 'unknown'),
            'metadata' => $metadata,
            'created_at' => $version['created_at'] ?? null,
            'deployed_at' => $version['deployed_at'] ?? null,
        ];
    }

    private static function publicReleaseVersionMetadata(mixed $metadata): array
    {
        if (!is_array($metadata)) {
            return [];
        }

        unset($metadata['github_access']);
        return $metadata;
    }

    private static function releaseVersionGithubAccessMetadata(mixed $githubAccess): ?array
    {
        if (!is_array($githubAccess)) {
            return null;
        }

        unset($githubAccess['commit'], $githubAccess['latest_commit'], $githubAccess['commit_authored_at']);
        return $githubAccess;
    }

    private function publicAssignment(array $assignment): array
    {
        return [
            'id' => (int)($assignment['id'] ?? 0),
            'subject_type' => (string)($assignment['subject_type'] ?? ''),
            'subject_id' => (string)($assignment['subject_id'] ?? ''),
            'channel_id' => (int)($assignment['channel_id'] ?? 0),
            'channel_slug' => (string)($assignment['channel_slug'] ?? ''),
            'channel_name' => (string)($assignment['channel_name'] ?? ''),
            'reason' => $assignment['reason'] ?? null,
            'expires_at' => $assignment['expires_at'] ?? null,
            'actor_user_id' => isset($assignment['actor_user_id']) ? (int)$assignment['actor_user_id'] : null,
            'created_at' => $assignment['created_at'] ?? null,
        ];
    }

    private function publicDeploymentTarget(array $target): array
    {
        $deployContext = self::jsonDecode($target['deploy_context_json'] ?? null);
        $targetWithContext = $target + ['deploy_context' => $deployContext];
        return [
            'id' => (int)($target['id'] ?? 0),
            'channel_id' => (int)($target['channel_id'] ?? 0),
            'channel_slug' => (string)($target['channel_slug'] ?? ''),
            'channel_name' => (string)($target['channel_name'] ?? ''),
            'app' => (string)($target['app'] ?? ''),
            'coolify_instance_id' => isset($target['coolify_instance_id']) ? (int)$target['coolify_instance_id'] : null,
            'coolify_instance_label' => $target['coolify_instance_label'] ?? null,
            'coolify_service_uuid' => $target['coolify_service_uuid'] ?? null,
            'repository' => (string)($target['repository'] ?? ''),
            'branch' => (string)($target['branch'] ?? ''),
            'auto_deploy' => (bool)((int)($target['auto_deploy'] ?? 0)),
            'health_url' => $target['health_url'] ?? null,
            'deploy_context' => $deployContext,
            'endpoint' => $this->releaseDeploymentEndpoint($targetWithContext),
            'created_at' => $target['created_at'] ?? null,
            'updated_at' => $target['updated_at'] ?? null,
        ];
    }

    private function publicServiceSet(array $serviceSet, bool $includeBundles = true): array
    {
        $dataServices = [];
        foreach (self::STACK_DATA_KINDS as $kind) {
            $dataServices[$kind] = $this->nullableCoolifyTarget(
                $this->nullablePositiveInt($serviceSet[$kind . '_coolify_target_id'] ?? null)
            );
        }

        $attachedBundles = $includeBundles ? $this->serviceSetBundles((int)($serviceSet['id'] ?? 0)) : [];
        $serviceSetId = (int)($serviceSet['id'] ?? 0);
        $metadata = self::jsonDecode($serviceSet['metadata_json'] ?? null);
        $dataPolicy = $this->serviceSetDataPolicy(array_replace($serviceSet, ['metadata' => $metadata]));

        return [
            'id' => $serviceSetId,
            'channel_id' => isset($serviceSet['channel_id']) ? (int)$serviceSet['channel_id'] : null,
            'channel_slug' => $serviceSet['channel_slug'] ?? null,
            'channel_name' => $serviceSet['channel_name'] ?? null,
            'name' => (string)($serviceSet['name'] ?? ''),
            'slug' => (string)($serviceSet['slug'] ?? ''),
            'mode' => (string)($serviceSet['mode'] ?? 'attach_existing'),
            'data_policy' => $dataPolicy,
            'data_service_mode' => $dataPolicy,
            'source_service_set_id' => isset($serviceSet['source_service_set_id']) ? (int)$serviceSet['source_service_set_id'] : null,
            'data_source_service_set_id' => $this->nullablePositiveInt($metadata['data_source_service_set_id'] ?? null),
            'data_source_channel_slug' => $metadata['data_source_channel_slug'] ?? null,
            'status' => (string)($serviceSet['status'] ?? 'unknown'),
            'active' => $serviceSetId > 0 && $this->serviceSetIsActive($serviceSetId),
            'targets' => [
                'frontend' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null)),
                'api' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['api_target_id'] ?? null)),
            ],
            'data_services' => $dataServices,
            'stack' => [
                'frontend' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['frontend_target_id'] ?? null)),
                'api' => $this->nullableDeploymentTarget($this->nullablePositiveInt($serviceSet['api_target_id'] ?? null)),
                'database' => $dataServices['database'],
                'redis' => $dataServices['redis'],
                'minio' => $dataServices['minio'],
            ],
            'health' => self::jsonDecode($serviceSet['health_json'] ?? null),
            'metadata' => $metadata,
            'attached_bundle_count' => count($attachedBundles),
            'attached_bundles' => $attachedBundles,
            'created_at' => $serviceSet['created_at'] ?? null,
            'updated_at' => $serviceSet['updated_at'] ?? null,
        ];
    }

    private function publicBundle(array $bundle, bool $includeServiceSet = true): array
    {
        $bundleId = (int)($bundle['id'] ?? 0);
        $frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null);
        $apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null);
        $frontendDeploymentId = $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null);
        $apiDeploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null);
        $frontendVersion = $frontendVersionId !== null ? $this->publicVersion($this->getVersion($frontendVersionId)) : null;
        $apiVersion = $apiVersionId !== null ? $this->publicVersion($this->getVersion($apiVersionId)) : null;
        $frontendCommit = $this->versionGithubCommit($frontendVersion);
        $apiCommit = $this->versionGithubCommit($apiVersion);
        $active = $bundleId > 0 && $this->bundleIsActive($bundleId);
        $status = (string)($bundle['status'] ?? 'draft');
        if ($status === 'promoted' && !$active) {
            $status = 'superseded';
        }

        return [
            'id' => $bundleId,
            'channel_id' => (int)($bundle['channel_id'] ?? 0),
            'channel_slug' => (string)($bundle['channel_slug'] ?? ''),
            'channel_name' => (string)($bundle['channel_name'] ?? ''),
            'service_set_id' => (int)($bundle['service_set_id'] ?? 0),
            'service_set_name' => $bundle['service_set_name'] ?? null,
            'service_set_slug' => $bundle['service_set_slug'] ?? null,
            'service_set' => $includeServiceSet ? $this->publicServiceSet($this->getServiceSet((int)$bundle['service_set_id']), false) : null,
            'version_label' => $bundle['version_label'] ?? null,
            'status' => $status,
            'active' => $active,
            'apps' => [
                'frontend' => [
                    'version_id' => $frontendVersionId,
                    'deployment_id' => $frontendDeploymentId,
                    'repository' => $bundle['frontend_repository'] ?? null,
                    'branch' => $bundle['frontend_branch'] ?? null,
                    'commit_sha' => $bundle['frontend_commit_sha'] ?? null,
                    'commit' => $frontendCommit,
                    'commit_authored_at' => $frontendCommit['authored_at'] ?? null,
                    'version' => $frontendVersion,
                    'deployment' => $this->nullableDeployment($frontendDeploymentId),
                ],
                'api' => [
                    'version_id' => $apiVersionId,
                    'deployment_id' => $apiDeploymentId,
                    'repository' => $bundle['api_repository'] ?? null,
                    'branch' => $bundle['api_branch'] ?? null,
                    'commit_sha' => $bundle['api_commit_sha'] ?? null,
                    'commit' => $apiCommit,
                    'commit_authored_at' => $apiCommit['authored_at'] ?? null,
                    'version' => $apiVersion,
                    'deployment' => $this->nullableDeployment($apiDeploymentId),
                ],
            ],
            'deployment_result' => self::jsonDecode($bundle['deployment_result_json'] ?? null),
            'metadata' => self::jsonDecode($bundle['metadata_json'] ?? null),
            'actor_user_id' => isset($bundle['actor_user_id']) ? (int)$bundle['actor_user_id'] : null,
            'deployed_at' => $bundle['deployed_at'] ?? null,
            'promoted_at' => $bundle['promoted_at'] ?? null,
            'created_at' => $bundle['created_at'] ?? null,
            'updated_at' => $bundle['updated_at'] ?? null,
        ];
    }

    private function versionGithubCommit(?array $version): ?array
    {
        $metadata = is_array($version['metadata'] ?? null) ? $version['metadata'] : [];
        $access = is_array($metadata['github_access'] ?? null) ? $metadata['github_access'] : [];
        foreach (['commit', 'latest_commit'] as $key) {
            $commit = is_array($access[$key] ?? null) ? $access[$key] : [];
            if (trim((string)($commit['sha'] ?? '')) !== '') {
                return $commit;
            }
        }

        return null;
    }

    private function serviceSetBundles(int $serviceSetId): array
    {
        if ($serviceSetId <= 0 || !release_manager_schema_bootstrap::tablesExist()) {
            return [];
        }

        return array_map(
            fn(array $row): array => $this->publicBundle($row, false),
            $this->selectRows(
                "SELECT b.*, c.slug AS channel_slug, c.name AS channel_name, s.name AS service_set_name, s.slug AS service_set_slug
                 FROM release_bundles b
                 INNER JOIN release_channels c ON c.id = b.channel_id
                 INNER JOIN release_service_sets s ON s.id = b.service_set_id
                 WHERE b.deleted_at IS NULL AND b.service_set_id = ?
                 ORDER BY b.created_at DESC, b.id DESC
                 LIMIT 10",
                'i',
                [$serviceSetId]
            )
        );
    }

    private function serviceSetIsActive(int $serviceSetId): bool
    {
        return $this->selectOne(
            'SELECT id FROM release_channel_versions WHERE service_set_id = ? AND active = 1 LIMIT 1',
            'i',
            [$serviceSetId]
        ) !== null;
    }

    private function bundleIsActive(int $bundleId): bool
    {
        return $this->selectOne(
            'SELECT id FROM release_channel_versions WHERE bundle_id = ? AND active = 1 LIMIT 1',
            'i',
            [$bundleId]
        ) !== null;
    }

    private function isolatedDeploymentTargetCanBeForgotten(?int $targetId, int $serviceSetId): bool
    {
        if ($targetId === null || $targetId <= 0) {
            return false;
        }

        $target = $this->nullableDeploymentTarget($targetId);
        if ($target === null) {
            return false;
        }

        $context = is_array($target['deploy_context'] ?? null) ? $target['deploy_context'] : [];
        if (!$this->toBool($context['isolated_stack'] ?? false)) {
            return false;
        }
        if ($this->toBool($context['production_data_attached'] ?? false)) {
            return false;
        }

        return $this->selectOne(
            "SELECT id
             FROM release_service_sets
             WHERE id <> ? AND deleted_at IS NULL AND (frontend_target_id = ? OR api_target_id = ?)
             LIMIT 1",
            'iii',
            [$serviceSetId, $targetId, $targetId]
        ) === null;
    }

    private function isolatedCoolifyTargetCanBeForgotten(?int $targetId, int $serviceSetId): bool
    {
        if ($targetId === null || $targetId <= 0 || !$this->tableExists('coolify_targets')) {
            return false;
        }

        $target = $this->nullableCoolifyTarget($targetId);
        if ($target === null) {
            return false;
        }

        $options = is_array($target['options'] ?? null) ? $target['options'] : [];
        if (!$this->toBool($options['isolated_stack'] ?? false)) {
            return false;
        }
        if ($this->toBool($options['production_data_attached'] ?? false)) {
            return false;
        }

        return $this->selectOne(
            "SELECT id
             FROM release_service_sets
             WHERE id <> ? AND deleted_at IS NULL
               AND (
                   database_coolify_target_id = ?
                   OR redis_coolify_target_id = ?
                   OR minio_coolify_target_id = ?
               )
             LIMIT 1",
            'iiii',
            [$serviceSetId, $targetId, $targetId, $targetId]
        ) === null;
    }

    private function nullableDeploymentTarget(?int $id): ?array
    {
        if ($id === null || $id <= 0) {
            return null;
        }

        try {
            return $this->publicDeploymentTarget($this->getDeploymentTarget($id));
        } catch (Throwable) {
            return null;
        }
    }

    private function nullableDeployment(?int $id): ?array
    {
        if ($id === null || $id <= 0) {
            return null;
        }

        try {
            return $this->publicDeployment($this->getDeployment($id));
        } catch (Throwable) {
            return null;
        }
    }

    private function nullableCoolifyTarget(?int $id): ?array
    {
        if ($id === null || $id <= 0 || !$this->tableExists('coolify_targets')) {
            return null;
        }

        $hasReplicationHosts = $this->tableExists('replication_hosts');
        $replicationColumns = $hasReplicationHosts
            ? "h.id AS host_id, h.kind AS host_kind, h.label AS host_label, h.host AS host_host,
                    h.port AS host_port, h.role AS host_role, h.status AS host_status,
                    h.replication_source_id AS host_replication_source_id,
                    h.last_status_json AS host_last_status_json, h.last_checked_at AS host_last_checked_at"
            : "NULL AS host_id, NULL AS host_kind, NULL AS host_label, NULL AS host_host,
                    NULL AS host_port, NULL AS host_role, NULL AS host_status,
                    NULL AS host_replication_source_id,
                    NULL AS host_last_status_json, NULL AS host_last_checked_at";
        $replicationJoin = $hasReplicationHosts ? 'LEFT JOIN replication_hosts h ON h.id = t.replication_host_id' : '';

        $row = $this->selectOne(
            "SELECT t.*, i.label AS instance_label, i.base_url AS instance_base_url,
                    $replicationColumns
             FROM coolify_targets t
             LEFT JOIN coolify_instances i ON i.id = t.instance_id
             $replicationJoin
             WHERE t.id = ? AND t.deleted_at IS NULL",
            'i',
            [$id]
        );
        if ($row === null) {
            return null;
        }

        $replication = !empty($row['host_id']) ? [
            'id' => (int)$row['host_id'],
            'kind' => (string)($row['host_kind'] ?? $row['kind'] ?? ''),
            'label' => (string)($row['host_label'] ?? ''),
            'host' => $row['host_host'] ?? null,
            'port' => isset($row['host_port']) ? (int)$row['host_port'] : null,
            'role' => (string)($row['host_role'] ?? 'unknown'),
            'status' => (string)($row['host_status'] ?? 'unknown'),
            'source_host_id' => isset($row['host_replication_source_id']) ? (int)$row['host_replication_source_id'] : null,
            'last_status' => self::jsonDecode($row['host_last_status_json'] ?? null),
            'last_checked_at' => $row['host_last_checked_at'] ?? null,
        ] : null;
        $endpoint = $replication !== null && !empty($replication['host'])
            ? $this->releaseEndpointFromParts(
                'auto',
                'resolved',
                (string)$replication['host'],
                isset($replication['port']) ? (int)$replication['port'] : null,
                'replication_host',
                'Endpoint resolved from the attached replication host.'
            )
            : self::releasePendingEndpoint('auto', 'coolify_target', 'Automatic endpoint resolution is pending Coolify target metadata.');

        return [
            'id' => (int)($row['id'] ?? 0),
            'kind' => (string)($row['kind'] ?? ''),
            'label' => (string)($row['label'] ?? ''),
            'role' => (string)($row['role'] ?? ''),
            'instance_id' => isset($row['instance_id']) ? (int)$row['instance_id'] : null,
            'instance_label' => $row['instance_label'] ?? null,
            'resource_uuid' => $row['resource_uuid'] ?? null,
            'resource_name' => $row['resource_name'] ?? null,
            'deployment_status' => (string)($row['deployment_status'] ?? 'unknown'),
            'availability_state' => (string)($row['availability_state'] ?? 'unknown'),
            'last_reconcile_status' => $row['last_reconcile_status'] ?? null,
            'last_reconciled_at' => $row['last_reconciled_at'] ?? null,
            'endpoint' => $endpoint,
            'replication' => $replication,
            'options' => self::jsonDecode($row['options_json'] ?? null),
        ];
    }

    private function publicDeployment(array $deployment): array
    {
        $status = (string)($deployment['status'] ?? 'unknown');
        $result = self::jsonDecode($deployment['result_json'] ?? null);
        $failureSummary = is_array($result['failure_summary'] ?? null) ? $result['failure_summary'] : null;
        $promotable = self::deploymentCanBePromoted($status);

        return [
            'id' => (int)($deployment['id'] ?? 0),
            'channel_id' => (int)($deployment['channel_id'] ?? 0),
            'channel_slug' => (string)($deployment['channel_slug'] ?? ''),
            'channel_name' => (string)($deployment['channel_name'] ?? ''),
            'target_id' => isset($deployment['target_id']) ? (int)$deployment['target_id'] : null,
            'version_id' => isset($deployment['version_id']) ? (int)$deployment['version_id'] : null,
            'service_set_id' => isset($deployment['service_set_id']) ? (int)$deployment['service_set_id'] : null,
            'bundle_id' => isset($deployment['bundle_id']) ? (int)$deployment['bundle_id'] : null,
            'deployment_kind' => (string)($deployment['deployment_kind'] ?? 'single_app'),
            'version_label' => $deployment['version_label'] ?? null,
            'app' => (string)($deployment['app'] ?? ''),
            'active_channel_app_key' => $deployment['active_channel_app_key'] ?? null,
            'active_current' => trim((string)($deployment['active_channel_app_key'] ?? '')) !== '',
            'provider' => (string)($deployment['provider'] ?? 'coolify'),
            'provider_operation_id' => $deployment['provider_operation_id'] ?? null,
            'repository' => $deployment['repository'] ?? null,
            'branch' => $deployment['branch'] ?? null,
            'commit_sha' => $deployment['commit_sha'] ?? null,
            'status' => $status,
            'deployment_url' => $deployment['deployment_url'] ?? $deployment['deployed_url'] ?? null,
            'actor_user_id' => isset($deployment['actor_user_id']) ? (int)$deployment['actor_user_id'] : null,
            'result' => $result,
            'failure_summary' => $failureSummary,
            'error_message' => $deployment['error_message'] ?? null,
            'promotable' => $promotable,
            'promotion_blocked_reason' => $promotable ? null : self::deploymentPromotionBlockedReason($deployment),
            'started_at' => $deployment['started_at'] ?? null,
            'completed_at' => $deployment['completed_at'] ?? null,
            'created_at' => $deployment['created_at'] ?? null,
            'updated_at' => $deployment['updated_at'] ?? null,
        ];
    }

    private function publicTimelineEvent(array $event): array
    {
        return [
            'id' => (int)($event['id'] ?? 0),
            'timeline_session_id' => isset($event['timeline_session_id']) ? (int)$event['timeline_session_id'] : null,
            'trace_id' => (string)($event['trace_id'] ?? ''),
            'event_type' => (string)($event['event_type'] ?? ''),
            'severity' => (string)($event['severity'] ?? 'info'),
            'module_key' => $event['module_key'] ?? null,
            'route_path' => $event['route_path'] ?? null,
            'component' => $event['component'] ?? null,
            'request_id' => $event['request_id'] ?? null,
            'occurred_at' => $event['occurred_at'] ?? null,
            'payload' => self::jsonDecode($event['payload_json'] ?? null),
            'principal_type' => $event['principal_type'] ?? null,
            'principal_id' => $event['principal_id'] ?? null,
            'customer_number' => isset($event['customer_number']) ? (int)$event['customer_number'] : null,
            'channel_slug' => $event['channel_slug'] ?? null,
        ];
    }

    private function publicTimelineSession(array $session): array
    {
        $moduleKeys = array_filter(array_map('trim', explode(',', (string)($session['module_keys'] ?? ''))));
        $principal = $this->timelinePrincipal($session);

        return [
            'id' => (int)($session['id'] ?? 0),
            'trace_id' => (string)($session['trace_id'] ?? ''),
            'principal_type' => $session['principal_type'] ?? null,
            'principal_id' => $session['principal_id'] ?? null,
            'customer_number' => isset($session['customer_number']) ? (int)$session['customer_number'] : null,
            'user' => $principal,
            'channel_id' => isset($session['channel_id']) ? (int)$session['channel_id'] : null,
            'channel_slug' => $session['channel_slug'] ?? null,
            'release' => [
                'frontend' => [
                    'version_label' => $session['frontend_version_label'] ?? null,
                    'commit_sha' => $session['frontend_commit_sha'] ?? null,
                ],
                'api' => [
                    'version_label' => $session['api_version_label'] ?? null,
                    'commit_sha' => $session['api_commit_sha'] ?? null,
                ],
            ],
            'device' => [
                'type' => $session['device_type'] ?? null,
                'browser_name' => $session['browser_name'] ?? null,
                'browser_version' => $session['browser_version'] ?? null,
                'os_name' => $session['os_name'] ?? null,
                'os_version' => $session['os_version'] ?? null,
                'viewport_width' => isset($session['viewport_width']) ? (int)$session['viewport_width'] : null,
                'viewport_height' => isset($session['viewport_height']) ? (int)$session['viewport_height'] : null,
                'device_pixel_ratio' => isset($session['device_pixel_ratio']) ? (float)$session['device_pixel_ratio'] : null,
                'user_agent' => $session['user_agent'] ?? null,
            ],
            'last_route_path' => $session['last_route_path'] ?? null,
            'event_count' => isset($session['event_count']) ? (int)$session['event_count'] : 0,
            'error_count' => isset($session['error_count']) ? (int)$session['error_count'] : 0,
            'error_report_count' => isset($session['error_report_count']) ? (int)$session['error_report_count'] : 0,
            'module_keys' => array_values($moduleKeys),
            'first_event_at' => $session['first_event_at'] ?? null,
            'last_event_at' => $session['last_event_at'] ?? $session['last_seen_at'] ?? null,
            'created_at' => $session['created_at'] ?? null,
            'last_seen_at' => $session['last_seen_at'] ?? null,
        ];
    }

    private function timelinePrincipal(array $session): array
    {
        $type = $session['principal_type'] ?? null;
        $id = $session['principal_id'] ?? null;
        $customerNumber = isset($session['customer_number']) ? (int)$session['customer_number'] : null;
        $label = trim(implode(':', array_filter([(string)$type, (string)$id])));
        $name = null;
        $email = null;

        if ($type === 'user' && is_numeric($id) && $this->tableExists('users')) {
            $row = $this->selectOne(
                'SELECT id, customer_number, display_name, email FROM users WHERE id = ? LIMIT 1',
                'i',
                [(int)$id]
            );
            if ($row !== null) {
                $name = $row['display_name'] ?? null;
                $email = $row['email'] ?? null;
                $customerNumber = isset($row['customer_number']) ? (int)$row['customer_number'] : $customerNumber;
            }
        }

        if ($type === 'subuser' && is_numeric($id) && $this->tableExists('subusers')) {
            $row = $this->selectOne(
                'SELECT id, username, name, email FROM subusers WHERE id = ? LIMIT 1',
                'i',
                [(int)$id]
            );
            if ($row !== null) {
                $name = $row['name'] ?? $row['username'] ?? null;
                $email = $row['email'] ?? null;
            }
        }

        $displayLabel = trim((string)($name ?: $email ?: $label));
        if ($displayLabel === '') {
            $displayLabel = $customerNumber !== null ? 'customer:' . $customerNumber : 'unknown';
        }

        return [
            'type' => $type,
            'id' => $id,
            'customer_number' => $customerNumber,
            'name' => $name,
            'email' => $email,
            'label' => $displayLabel,
        ];
    }

    private function timelineErrorReports(string $traceId): array
    {
        if (!$this->tableExists('error_reports')) {
            return [];
        }

        $rows = $this->selectRows(
            "SELECT id, status, reporter_type, reporter_user_id, reporter_subuser_id,
                    reporter_customer_number, reporter_customer_number_context, reporter_name,
                    reporter_email, route_path, page_url, release_trace_id, frontend_version,
                    api_version, request_error_count, vue_error_count, created_at, updated_at
             FROM error_reports
             WHERE release_trace_id = ?
             ORDER BY created_at DESC, id DESC
             LIMIT 25",
            's',
            [$traceId]
        );

        return array_map(static fn(array $row): array => [
            'id' => (int)($row['id'] ?? 0),
            'status' => (string)($row['status'] ?? ''),
            'reporter' => [
                'type' => $row['reporter_type'] ?? null,
                'user_id' => isset($row['reporter_user_id']) ? (int)$row['reporter_user_id'] : null,
                'subuser_id' => isset($row['reporter_subuser_id']) ? (int)$row['reporter_subuser_id'] : null,
                'customer_number' => isset($row['reporter_customer_number']) ? (int)$row['reporter_customer_number'] : null,
                'customer_number_context' => isset($row['reporter_customer_number_context']) ? (int)$row['reporter_customer_number_context'] : null,
                'name' => $row['reporter_name'] ?? null,
                'email' => $row['reporter_email'] ?? null,
            ],
            'route_path' => $row['route_path'] ?? null,
            'page_url' => $row['page_url'] ?? null,
            'release_trace_id' => $row['release_trace_id'] ?? null,
            'frontend_version' => $row['frontend_version'] ?? null,
            'api_version' => $row['api_version'] ?? null,
            'request_error_count' => isset($row['request_error_count']) ? (int)$row['request_error_count'] : 0,
            'vue_error_count' => isset($row['vue_error_count']) ? (int)$row['vue_error_count'] : 0,
            'created_at' => $row['created_at'] ?? null,
            'updated_at' => $row['updated_at'] ?? null,
        ], $rows);
    }

    private function timelineReleaseContext(array $session, ?array $channel): array
    {
        $frontend = $this->timelineAppReleaseContext(
            'frontend',
            $session['frontend_version_label'] ?? null,
            $session['frontend_commit_sha'] ?? null
        );
        $api = $this->timelineAppReleaseContext(
            'api',
            $session['api_version_label'] ?? null,
            $session['api_commit_sha'] ?? null
        );

        $bundle = $this->timelineBundleReference(
            $this->nullablePositiveInt($frontend['version']['id'] ?? null),
            $this->nullablePositiveInt($api['version']['id'] ?? null),
            $this->nullablePositiveInt($frontend['deployment']['bundle_id'] ?? $api['deployment']['bundle_id'] ?? null)
        );

        return [
            'channel' => $channel !== null ? $this->publicChannel($channel) : null,
            'frontend' => $frontend,
            'api' => $api,
            'bundle' => $bundle,
        ];
    }

    private function timelineAppReleaseContext(string $app, mixed $versionLabel, mixed $commitSha): array
    {
        $versionLabel = $this->nullableString($versionLabel, 128);
        $commitSha = $this->nullableString($commitSha, 128);
        $context = [
            'version_label' => $versionLabel,
            'commit_sha' => $commitSha,
            'version' => null,
            'deployment' => null,
        ];

        $where = ['app = ?'];
        $types = 's';
        $params = [$app];
        if ($versionLabel !== null && $commitSha !== null) {
            $where[] = '(version_label = ? OR commit_sha = ?)';
            $types .= 'ss';
            $params[] = $versionLabel;
            $params[] = $commitSha;
        } elseif ($versionLabel !== null) {
            $where[] = 'version_label = ?';
            $types .= 's';
            $params[] = $versionLabel;
        } elseif ($commitSha !== null) {
            $where[] = 'commit_sha = ?';
            $types .= 's';
            $params[] = $commitSha;
        } else {
            return $context;
        }

        $version = $this->selectOne(
            "SELECT id, app, repository, branch, commit_sha, tag, version_label, build_url,
                    artifact_url, deployed_url, status, created_at, deployed_at
             FROM release_versions
             WHERE " . implode(' AND ', $where) . "
             ORDER BY deployed_at DESC, id DESC
             LIMIT 1",
            $types,
            $params
        );
        if ($version === null) {
            return $context;
        }

        $context['version_label'] = $version['version_label'] ?? $versionLabel;
        $context['commit_sha'] = $version['commit_sha'] ?? $commitSha;
        $context['version'] = $this->publicTimelineVersionReference($version);

        $deployment = $this->selectOne(
            "SELECT id, channel_id, target_id, version_id, service_set_id, bundle_id,
                    deployment_kind, app, provider, repository, branch, commit_sha,
                    status, deployment_url, started_at, completed_at, created_at
             FROM release_deployments
             WHERE version_id = ?
             ORDER BY id DESC
             LIMIT 1",
            'i',
            [(int)$version['id']]
        );
        if ($deployment !== null) {
            $context['deployment'] = $this->publicTimelineDeploymentReference($deployment);
        }

        return $context;
    }

    private function timelineBundleReference(?int $frontendVersionId, ?int $apiVersionId, ?int $bundleId): ?array
    {
        if ($bundleId !== null) {
            $bundle = $this->selectOne(
                "SELECT id, channel_id, service_set_id, version_label, frontend_version_id,
                        api_version_id, frontend_deployment_id, api_deployment_id,
                        status, deployed_at, promoted_at, created_at
                 FROM release_bundles
                 WHERE id = ? AND deleted_at IS NULL
                 LIMIT 1",
                'i',
                [$bundleId]
            );
            return $bundle !== null ? $this->publicTimelineBundleReference($bundle) : null;
        }

        $where = ['deleted_at IS NULL'];
        $types = '';
        $params = [];
        if ($frontendVersionId !== null && $apiVersionId !== null) {
            $where[] = 'frontend_version_id = ?';
            $where[] = 'api_version_id = ?';
            $types .= 'ii';
            $params[] = $frontendVersionId;
            $params[] = $apiVersionId;
        } elseif ($frontendVersionId !== null) {
            $where[] = 'frontend_version_id = ?';
            $types .= 'i';
            $params[] = $frontendVersionId;
        } elseif ($apiVersionId !== null) {
            $where[] = 'api_version_id = ?';
            $types .= 'i';
            $params[] = $apiVersionId;
        } else {
            return null;
        }

        $bundle = $this->selectOne(
            "SELECT id, channel_id, service_set_id, version_label, frontend_version_id,
                    api_version_id, frontend_deployment_id, api_deployment_id,
                    status, deployed_at, promoted_at, created_at
             FROM release_bundles
             WHERE " . implode(' AND ', $where) . "
             ORDER BY id DESC
             LIMIT 1",
            $types,
            $params
        );

        return $bundle !== null ? $this->publicTimelineBundleReference($bundle) : null;
    }

    private function publicTimelineVersionReference(array $version): array
    {
        return [
            'id' => (int)($version['id'] ?? 0),
            'app' => (string)($version['app'] ?? ''),
            'repository' => $version['repository'] ?? null,
            'branch' => $version['branch'] ?? null,
            'commit_sha' => $version['commit_sha'] ?? null,
            'tag' => $version['tag'] ?? null,
            'version_label' => $version['version_label'] ?? null,
            'build_url' => $version['build_url'] ?? null,
            'artifact_url' => $version['artifact_url'] ?? null,
            'deployed_url' => $version['deployed_url'] ?? null,
            'status' => (string)($version['status'] ?? ''),
            'created_at' => $version['created_at'] ?? null,
            'deployed_at' => $version['deployed_at'] ?? null,
        ];
    }

    private function publicTimelineDeploymentReference(array $deployment): array
    {
        return [
            'id' => (int)($deployment['id'] ?? 0),
            'channel_id' => isset($deployment['channel_id']) ? (int)$deployment['channel_id'] : null,
            'target_id' => isset($deployment['target_id']) ? (int)$deployment['target_id'] : null,
            'version_id' => isset($deployment['version_id']) ? (int)$deployment['version_id'] : null,
            'service_set_id' => isset($deployment['service_set_id']) ? (int)$deployment['service_set_id'] : null,
            'bundle_id' => isset($deployment['bundle_id']) ? (int)$deployment['bundle_id'] : null,
            'deployment_kind' => (string)($deployment['deployment_kind'] ?? ''),
            'app' => (string)($deployment['app'] ?? ''),
            'provider' => (string)($deployment['provider'] ?? ''),
            'repository' => $deployment['repository'] ?? null,
            'branch' => $deployment['branch'] ?? null,
            'commit_sha' => $deployment['commit_sha'] ?? null,
            'status' => (string)($deployment['status'] ?? ''),
            'deployment_url' => $deployment['deployment_url'] ?? null,
            'started_at' => $deployment['started_at'] ?? null,
            'completed_at' => $deployment['completed_at'] ?? null,
            'created_at' => $deployment['created_at'] ?? null,
        ];
    }

    private function publicTimelineBundleReference(array $bundle): array
    {
        return [
            'id' => (int)($bundle['id'] ?? 0),
            'channel_id' => isset($bundle['channel_id']) ? (int)$bundle['channel_id'] : null,
            'service_set_id' => isset($bundle['service_set_id']) ? (int)$bundle['service_set_id'] : null,
            'version_label' => $bundle['version_label'] ?? null,
            'frontend_version_id' => isset($bundle['frontend_version_id']) ? (int)$bundle['frontend_version_id'] : null,
            'api_version_id' => isset($bundle['api_version_id']) ? (int)$bundle['api_version_id'] : null,
            'frontend_deployment_id' => isset($bundle['frontend_deployment_id']) ? (int)$bundle['frontend_deployment_id'] : null,
            'api_deployment_id' => isset($bundle['api_deployment_id']) ? (int)$bundle['api_deployment_id'] : null,
            'status' => (string)($bundle['status'] ?? ''),
            'deployed_at' => $bundle['deployed_at'] ?? null,
            'promoted_at' => $bundle['promoted_at'] ?? null,
            'created_at' => $bundle['created_at'] ?? null,
        ];
    }

    private function clearOtherDefaultChannels(int $channelId): void
    {
        $this->execute('UPDATE release_channels SET default_channel = 0 WHERE id <> ?', 'i', [$channelId]);
    }

    private function normalizeApp(string $value): string
    {
        $app = strtolower(trim($value));
        if (!in_array($app, self::APPS, true)) {
            throw new RuntimeException('Release app must be frontend or api.');
        }
        return $app;
    }

    private function normalizeCaptureLevel(string $value): string
    {
        $level = strtolower(trim($value));
        return in_array($level, self::CAPTURE_LEVELS, true) ? $level : 'metadata';
    }

    private function nullableString(mixed $value, int $maxLength): ?string
    {
        if ($value === null) {
            return null;
        }
        $value = trim((string)$value);
        if ($value === '') {
            return null;
        }
        return substr($value, 0, max(1, $maxLength));
    }

    private function nullableIdentifier(mixed $value, int $maxLength): ?string
    {
        $identifier = self::safeIdentifier((string)($value ?? ''), $maxLength);
        return $identifier === '' ? null : $identifier;
    }

    private function nullableInt(mixed $value): ?int
    {
        if ($value === null || $value === '' || !is_numeric($value)) {
            return null;
        }
        return (int)$value;
    }

    private function nullableFloat(mixed $value): ?float
    {
        if ($value === null || $value === '' || !is_numeric($value)) {
            return null;
        }
        return (float)$value;
    }

    private function normalizeDateTime(mixed $value): ?string
    {
        if (!is_string($value) || trim($value) === '') {
            return null;
        }
        $timestamp = strtotime($value);
        return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
    }

    private function nullablePositiveInt(mixed $value): ?int
    {
        if ($value === null || $value === '') {
            return null;
        }
        $int = (int)$value;
        return $int > 0 ? $int : null;
    }

    private function toBool(mixed $value): bool
    {
        if (is_bool($value)) {
            return $value;
        }
        return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
    }

    private function requestTraceId(): string
    {
        $context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null)
            ? $GLOBALS['RELEASE_REQUEST_CONTEXT']
            : self::initializeRequestContext();
        return (string)($context['trace_id'] ?? '');
    }

    private function assignmentCacheKey(array $context): string
    {
        if (!empty($context['principal_type']) && !empty($context['principal_id'])) {
            return 'release_manager:assignment:' . $context['principal_type'] . ':' . $context['principal_id'];
        }
        if (!empty($context['customer_number'])) {
            return 'release_manager:assignment:customer:' . (int)$context['customer_number'];
        }
        return '';
    }

    private function cacheResolvedChannel(string $cacheKey, array $channel): void
    {
        if ($cacheKey === '' || !defined('redis')) {
            return;
        }
        try {
            redis->setEx($cacheKey, self::jsonEncode($channel), 60);
        } catch (Throwable) {
        }
    }

    private function clearAssignmentCache(string $subjectType, string $subjectId): void
    {
        if (!defined('redis')) {
            return;
        }
        try {
            redis->delete('release_manager:assignment:' . $subjectType . ':' . $subjectId);
        } catch (Throwable) {
        }
    }

    private function moduleConfigValue(string $module, string $variable, mixed $default = null): mixed
    {
        $row = $this->selectOne(
            'SELECT value FROM module_config WHERE module = ? AND variable = ? LIMIT 1',
            'ss',
            [$module, $variable]
        );
        return $row['value'] ?? $default;
    }

    private function upsertModuleConfigValue(string $module, string $variable, string $value, string $type): void
    {
        $existing = $this->selectOne(
            'SELECT value FROM module_config WHERE module = ? AND variable = ? LIMIT 1',
            'ss',
            [$module, $variable]
        );

        if ($existing === null) {
            $this->execute(
                'INSERT INTO module_config (module, variable, value, type) VALUES (?, ?, ?, ?)',
                'ssss',
                [$module, $variable, $value, $type]
            );
            return;
        }

        $this->execute(
            'UPDATE module_config SET value = ?, type = ? WHERE module = ? AND variable = ?',
            'ssss',
            [$value, $type, $module, $variable]
        );
    }

    private function audit(?int $channelId, ?int $deploymentId, string $action, ?int $actorUserId, string $severity, array $context): void
    {
        $this->execute(
            "INSERT INTO release_audit_logs (channel_id, deployment_id, action, actor_user_id, severity, context_json)
             VALUES (?, ?, ?, ?, ?, ?)",
            'iisiss',
            [$channelId, $deploymentId, $action, $actorUserId, $severity, self::jsonEncode(self::redactPayload($context))]
        );
    }

    private function selectOne(string $sql, string $types = '', array $params = []): ?array
    {
        $rows = $this->selectRows($sql, $types, $params);
        return $rows[0] ?? null;
    }

    private function selectRows(string $sql, string $types = '', array $params = []): array
    {
        global $db;
        if ($types === '') {
            $result = $db->query($sql);
            return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
        }

        $stmt = $db->prepare($sql);
        if ($stmt === false) {
            throw new RuntimeException('Could not prepare release manager query.');
        }
        $stmt->bind_param($types, ...$params);
        $stmt->execute();
        $result = $stmt->get_result();
        return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
    }

    private function tableExists(string $table): bool
    {
        $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table) ?? '';
        if ($table === '') {
            return false;
        }

        try {
            return $this->selectOne(
                'SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ? LIMIT 1',
                's',
                [$table]
            ) !== null;
        } catch (Throwable) {
            return false;
        }
    }

    private function cleanupExpiredReplayData(): void
    {
        try {
            $this->execute(
                "UPDATE release_replay_targets
                 SET deleted_at = NOW(), enabled = 0
                 WHERE deleted_at IS NULL AND expires_at IS NOT NULL AND expires_at <= NOW()"
            );

            $this->execute(
                "DELETE e
                 FROM release_timeline_events e
                 INNER JOIN release_timeline_sessions s ON s.id = e.timeline_session_id
                 LEFT JOIN release_channels c ON c.id = s.channel_id
                 WHERE s.last_seen_at < DATE_SUB(NOW(), INTERVAL COALESCE(c.retention_days, 14) DAY)"
            );

            $this->execute(
                "DELETE s
                 FROM release_timeline_sessions s
                 LEFT JOIN release_channels c ON c.id = s.channel_id
                 WHERE s.last_seen_at < DATE_SUB(NOW(), INTERVAL COALESCE(c.retention_days, 14) DAY)"
            );
        } catch (Throwable) {
            // Retention cleanup should never block release debugging reads.
        }
    }

    private function execute(string $sql, string $types = '', array $params = []): void
    {
        global $db;
        if ($types === '') {
            $db->query($sql);
            return;
        }

        $stmt = $db->prepare($sql);
        if ($stmt === false) {
            throw new RuntimeException('Could not prepare release manager statement.');
        }
        $stmt->bind_param($types, ...$params);
        $stmt->execute();
    }

    private function insertId(): int
    {
        global $db;
        return (int)$db->insert_id();
    }

    private static function headerValue(array $headers, string $name): string
    {
        foreach ($headers as $key => $value) {
            if (strcasecmp((string)$key, $name) === 0) {
                return trim((string)$value);
            }
        }
        $serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
        return trim((string)($_SERVER[$serverKey] ?? ''));
    }

    private static function requestHeaderValue(string $name): string
    {
        $headers = function_exists('getallheaders') ? getallheaders() : [];
        return self::headerValue(is_array($headers) ? $headers : [], $name);
    }

    private static function safeSlug(string $value): string
    {
        $slug = strtolower(trim($value));
        $slug = preg_replace('/[^a-z0-9_-]/', '-', $slug) ?? '';
        $slug = trim(preg_replace('/-+/', '-', $slug) ?? '', '-');
        return substr($slug, 0, 64);
    }

    private static function safeIdentifier(string $value, int $maxLength): string
    {
        $value = trim($value);
        $value = preg_replace('/[^a-zA-Z0-9_.:-]/', '', $value) ?? '';
        return substr($value, 0, max(1, $maxLength));
    }

    private static function isSensitiveKey(string $key): bool
    {
        return preg_match('/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn/i', $key) === 1;
    }

    private static function jsonEncode(mixed $value): string
    {
        $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if ($json === false) {
            throw new RuntimeException('Could not encode release manager JSON payload.');
        }
        return $json;
    }

    private static function jsonDecode(mixed $value): array
    {
        if (!is_string($value) || trim($value) === '') {
            return [];
        }
        $decoded = json_decode($value, true);
        return is_array($decoded) ? $decoded : [];
    }
}
