<?php
declare(strict_types=1);

final class BuildRunner {
    public static function dockerBinary(): string {
        $configured = trim((string) cfg('build.docker_bin', ''));
        if ($configured !== '' && is_executable($configured)) {
            return $configured;
        }
        foreach (['/usr/bin/docker', '/usr/local/bin/docker'] as $path) {
            if (is_executable($path)) {
                return $path;
            }
        }
        $which = trim((string) shell_exec('command -v docker 2>/dev/null'));
        return $which !== '' ? $which : 'docker';
    }

    public static function dockerAvailable(): bool {
        $bin = escapeshellarg(self::dockerBinary());
        return trim((string) shell_exec("{$bin} info >/dev/null 2>&1 && echo ok")) === 'ok';
    }

    public static function dockerCheckDetail(): string {
        $bin = self::dockerBinary();
        $sock = '/var/run/docker.sock';
        $parts = ['bin=' . $bin];
        if (!is_executable($bin)) {
            $parts[] = 'bin_not_executable';
        }
        if (!is_readable($sock)) {
            $parts[] = 'sock_not_readable';
        }
        $parts[] = self::dockerAvailable() ? 'info_ok' : 'info_failed';
        return implode(', ', $parts);
    }

    public static function generateBuildCode(): string {
        return 'bld-' . gmdate('Ymd-His') . '-' . substr(bin2hex(random_bytes(3)), 0, 6);
    }

    public static function resolvePipelineYaml(array $params): string {
        $candidates = array_filter([
            (string) cfg('build.pipeline_config', ''),
            rtrim((string) cfg('build.repo_root', ''), '/') . '/build.config.yml',
        ]);
        foreach ($candidates as $path) {
            if ($path !== '' && is_file($path)) {
                $text = (string) file_get_contents($path);
                if ($text !== '') {
                    return $text;
                }
            }
        }
        return self::generatePipelineYaml($params);
    }

    public static function generatePipelineYaml(array $params): string {
        $gitRef = (string) ($params['git_ref'] ?? 'next');
        $gradle = (string) ($params['gradle_task'] ?? 'assembleDebug');
        $channel = (string) ($params['ota_channel'] ?? 'staging');
        $runTests = !empty($params['run_tests']) ? 'true' : 'false';
        $runNative = !empty($params['run_native']) ? 'true' : 'false';
        $runApk = !empty($params['run_apk']) ? 'true' : 'false';
        $autoOta = !empty($params['auto_ota']) ? 'true' : 'false';
        $ciVer = (string) cfg('build.ci_version', '00.01.00.1000');
        return <<<YAML
# Generated pipeline snapshot (build.config.yml missing on builder host)
version: 2.1
parameters:
  git_ref: {$gitRef}
  gradle_task: {$gradle}
  run_tests: {$runTests}
  run_native: {$runNative}
  run_apk: {$runApk}
  auto_ota: {$autoOta}
  ota_channel: {$channel}
jobs:
  android-build:
    docker:
      - image: androidcast-ci:{$ciVer}
    steps:
      - checkout
      - run:
          name: Gradle pipeline
          command: ./scripts/ci-build-and-publish-ota.sh
YAML;
    }

    public static function artifactDir(int $buildId): string {
        $root = rtrim((string) cfg('build.artifacts_root', '/workspace/out/builds'), '/');
        return $root . '/' . $buildId;
    }

    public static function enqueue(array $params, ?int $userId): array {
        BuildRepository::ensureSchema();
        $params['pipeline_yaml'] = $params['pipeline_yaml'] ?? self::resolvePipelineYaml($params);
        $buildCode = self::generateBuildCode();
        $id = BuildRepository::create([
            'build_code' => $buildCode,
            'status' => 'queued',
            'phase' => 'queued',
            'git_ref' => $params['git_ref'] ?? null,
            'git_sha' => $params['git_sha'] ?? null,
            'branch' => $params['branch'] ?? ($params['git_ref'] ?? null),
            'pipeline_yaml' => $params['pipeline_yaml'] ?? null,
            'params_json' => json_encode($params, JSON_UNESCAPED_SLASHES),
            'dockerfile_version' => cfg('build.ci_version', '00.01.00.1000'),
            'builder_id' => gethostname() ?: 'builder',
            'ota_channel' => $params['ota_channel'] ?? 'staging',
            'auto_ota' => !empty($params['auto_ota']),
            'auto_deploy' => !empty($params['auto_deploy']),
            'created_by_user_id' => $userId,
        ]);
        $dir = self::artifactDir($id);
        if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
            throw new RuntimeException('Cannot create artifact dir: ' . $dir);
        }
        $logPath = $dir . '/build.log';
        BuildRepository::update($id, ['log_path' => $logPath]);

        if (!self::dockerAvailable()) {
            $detail = self::dockerCheckDetail();
            self::failBuild(
                $id,
                'Docker daemon not available (' . $detail . '). '
                . 'Ensure docker service is running and PHP-FPM user (nginx) is in group docker, then restart php-fpm.',
                $logPath
            );
            return BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode, 'status' => 'failed'];
        }

        $phase = !empty($params['ssh_debug']) ? 'ssh_debug' : 'docker';
        $runner = (string) cfg('build.runner_script', '/workspace/scripts/docker-build-runner.sh');
        $repo = (string) cfg('build.repo_root', '/workspace');
        $gitRemote = trim((string) shell_exec(
            'git -c safe.directory=' . escapeshellarg($repo)
            . ' -C ' . escapeshellarg($repo)
            . ' remote get-url origin 2>/dev/null'
        ));
        $env = [
            'BUILD_ID=' . $id,
            'BUILD_LOG_FILE=' . escapeshellarg($logPath),
            'BUILD_LOG_STDOUT_ONLY=1',
            'BUILD_ISOLATE_SOURCE=' . (cfg('build.isolate_source', true) ? '1' : '0'),
            'BUILD_GIT_DEPTH=' . max(1, (int) cfg('build.git_clone_depth', 1)),
            'BUILD_WORK_DIR=' . escapeshellarg($repo),
            'BUILD_OUT_DIR=' . escapeshellarg($dir),
            'ANDROIDCAST_CI_VERSION=' . escapeshellarg((string) cfg('build.ci_version', '00.01.00.1000')),
            'GIT_REF=' . escapeshellarg((string) ($params['git_ref'] ?? '')),
            'GIT_SHA=' . escapeshellarg((string) ($params['git_sha'] ?? '')),
            'GIT_REMOTE=' . escapeshellarg($gitRemote),
            'GRADLE_TASK=' . escapeshellarg((string) ($params['gradle_task'] ?? 'assembleDebug')),
            'RUN_UNIT_TESTS=' . (!empty($params['run_tests']) ? '1' : '0'),
            'RUN_NATIVE=' . (!empty($params['run_native']) ? '1' : '0'),
            'RUN_APK=' . (!empty($params['run_apk']) ? '1' : '0'),
            'OTA_BASE_URL=' . escapeshellarg((string) ($params['ota_base_url'] ?? cfg('build.ota_base_url', ''))),
            'OTA_CHANNEL=' . escapeshellarg((string) ($params['ota_channel'] ?? 'staging')),
            'AUTO_OTA=' . (!empty($params['auto_ota']) ? '1' : '0'),
        ];
        if (!empty($params['resume_from'])) {
            $env[] = 'BUILD_RESUME_FROM=' . escapeshellarg((string) $params['resume_from']);
        }
        if (!empty($params['reuse_build_id'])) {
            $reuseSrc = self::artifactDir((int) $params['reuse_build_id']) . '/src';
            $env[] = 'BUILD_REUSE_SRC_DIR=' . escapeshellarg($reuseSrc);
        }
        if (!empty($params['ssh_debug'])) {
            $env[] = 'BUILD_SSH_DEBUG=1';
        }
        $cmd = implode(' ', $env) . ' nohup ' . escapeshellarg($runner) . ' >> ' . escapeshellarg($logPath) . ' 2>&1 & echo $!';
        $pid = trim((string) shell_exec($cmd));
        if ($pid === '' || !ctype_digit($pid)) {
            self::failBuild($id, 'Build runner failed to start', $logPath);
            return BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode, 'status' => 'failed'];
        }
        file_put_contents($dir . '/runner.pid', $pid);
        BuildRepository::update($id, [
            'status' => 'running',
            'phase' => $phase,
            'started_at' => gmdate('Y-m-d H:i:s'),
        ]);

        return BuildRepository::getById($id) ?? ['id' => $id, 'build_code' => $buildCode];
    }

    public static function failBuild(int $id, string $message, ?string $logPath = null): void {
        if ($logPath) {
            file_put_contents($logPath, "\n[builder] ERROR: {$message}\n", FILE_APPEND);
        }
        BuildRepository::update($id, [
            'status' => 'failed',
            'phase' => 'error',
            'error_message' => substr($message, 0, 512),
            'finished_at' => gmdate('Y-m-d H:i:s'),
        ]);
    }

    public static function stopBuild(int $id): bool {
        $build = BuildRepository::getById($id);
        if (!$build) {
            return false;
        }
        $status = (string) ($build['status'] ?? '');
        if ($status === 'queued') {
            $dir = self::artifactDir($id);
            $logPath = $build['log_path'] ?? ($dir . '/build.log');
            if ($logPath) {
                file_put_contents($logPath, "\n[builder] Stopped while queued\n", FILE_APPEND);
            }
            BuildRepository::update($id, [
                'status' => 'cancelled',
                'phase' => 'stopped',
                'error_message' => 'Stopped while queued',
                'finished_at' => gmdate('Y-m-d H:i:s'),
            ]);
            return true;
        }
        if ($status !== 'running') {
            return false;
        }
        $dir = self::artifactDir($id);
        $pidFile = $dir . '/runner.pid';
        $pid = is_file($pidFile) ? trim((string) file_get_contents($pidFile)) : '';
        if ($pid !== '' && ctype_digit($pid)) {
            shell_exec('kill ' . escapeshellarg($pid) . ' 2>/dev/null');
        }
        self::stopDebugContainer($id);
        $logPath = $build['log_path'] ?? ($dir . '/build.log');
        if ($logPath) {
            file_put_contents($logPath, "\n[builder] Stopped by operator\n", FILE_APPEND);
        }
        BuildRepository::update($id, [
            'status' => 'cancelled',
            'phase' => 'stopped',
            'error_message' => 'Stopped by operator',
            'finished_at' => gmdate('Y-m-d H:i:s'),
        ]);
        return true;
    }

    /** @return array<string, mixed> */
    public static function rerunBuild(int $id, ?array $overrideParams = null, ?int $userId = null): array {
        $build = BuildRepository::getById($id);
        if (!$build) {
            throw new InvalidArgumentException('build_not_found');
        }
        $params = json_decode((string) ($build['params_json'] ?? '{}'), true);
        if (!is_array($params)) {
            $params = [];
        }
        if ($overrideParams) {
            $params = array_merge($params, $overrideParams);
        }
        if (empty($params['git_ref'])) {
            $params['git_ref'] = $build['git_ref'] ?? $build['branch'] ?? 'next';
        }
        if (!isset($params['rerun_of'])) {
            $params['rerun_of'] = $id;
        }
        return self::enqueue($params, $userId);
    }

    /** Stop an in-flight build (if any) and enqueue a fresh run with the same params. */
    public static function forceRerun(int $id, ?int $userId = null): array {
        $build = BuildRepository::getById($id);
        if (!$build) {
            throw new InvalidArgumentException('build_not_found');
        }
        $status = (string) ($build['status'] ?? '');
        if (in_array($status, ['running', 'queued'], true)) {
            self::stopBuild($id);
        }
        return self::rerunBuild($id, null, $userId);
    }

    /** CircleCI-style: resume from the step that failed (git → docker-build → docker-run). */
    public static function detectFailedStep(int $buildId): string {
        $dir = self::artifactDir($buildId);
        $logTail = '';
        $logPath = $dir . '/build.log';
        if (is_file($logPath)) {
            $size = filesize($logPath) ?: 0;
            $logTail = (string) file_get_contents($logPath, false, null, max(0, $size - 65536));
        }
        $hasRun = is_file($dir . '/docker-run.log') && (filesize($dir . '/docker-run.log') ?: 0) > 0;
        $hasBuild = is_file($dir . '/docker-build.log') && (filesize($dir . '/docker-build.log') ?: 0) > 0;

        if ($hasRun && !is_file($dir . '/android_cast-latest.apk')) {
            return 'docker-run';
        }
        if ($hasBuild || str_contains($logTail, '[runner] step docker-build')) {
            if (!str_contains($logTail, '[runner] step docker-run') && !str_contains($logTail, '[runner] finished')) {
                return 'docker-build';
            }
            $buildStep = $hasBuild ? (string) file_get_contents($dir . '/docker-build.log') : '';
            if (str_contains($logTail, 'ERROR: failed to build') || str_contains($buildStep, 'ERROR: failed to build')) {
                return 'docker-build';
            }
        }
        if (str_contains($logTail, 'isolate:') || str_contains($logTail, 'syncing shared checkout')
            || str_contains($logTail, 'dubious ownership') || is_file($dir . '/git-clone.log')) {
            return 'git';
        }
        return $hasRun ? 'docker-run' : ($hasBuild ? 'docker-build' : 'git');
    }

    /** @return array<string, mixed> */
    public static function rerunFromFailed(int $id, ?int $userId = null, bool $sshDebug = false): array {
        $build = BuildRepository::getById($id);
        if (!$build) {
            throw new InvalidArgumentException('build_not_found');
        }
        $status = (string) ($build['status'] ?? '');
        if (!in_array($status, ['failed', 'cancelled'], true)) {
            throw new InvalidArgumentException('rerun_from_failed_requires_failed_build');
        }
        $step = self::detectFailedStep($id);
        return self::rerunBuild($id, [
            'resume_from' => $step,
            'reuse_build_id' => $id,
            'ssh_debug' => $sshDebug,
            'rerun_of' => $id,
        ], $userId);
    }

    public static function debugContainerName(int $buildId): string {
        return 'androidcast-bld-' . $buildId;
    }

    public static function debugContainerAlive(int $buildId): bool {
        $bin = escapeshellarg(self::dockerBinary());
        $name = escapeshellarg(self::debugContainerName($buildId));
        return trim((string) shell_exec("{$bin} inspect -f '{{.State.Running}}' {$name} 2>/dev/null")) === 'true';
    }

    public static function stopDebugContainer(int $buildId): void {
        $bin = escapeshellarg(self::dockerBinary());
        $name = escapeshellarg(self::debugContainerName($buildId));
        shell_exec("{$bin} rm -f {$name} 2>/dev/null");
    }

    public static function runnerAlive(int $buildId): bool {
        $pidFile = self::artifactDir($buildId) . '/runner.pid';
        $pid = is_file($pidFile) ? trim((string) file_get_contents($pidFile)) : '';
        if ($pid === '' || !ctype_digit($pid)) {
            return false;
        }
        $out = trim((string) shell_exec('kill -0 ' . escapeshellarg($pid) . ' 2>/dev/null && echo alive'));
        return $out === 'alive';
    }

    public static function reconcileRunningBuilds(): void {
        BuildRepository::ensureSchema();
        $stmt = Database::pdo()->query("SELECT id, log_path FROM builds WHERE status = 'running'");
        if (!$stmt) {
            return;
        }
        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
            $id = (int) ($row['id'] ?? 0);
            if ($id <= 0) {
                continue;
            }
            $dir = self::artifactDir($id);
            $logPath = (string) ($row['log_path'] ?? ($dir . '/build.log'));
            if (is_file($dir . '/android_cast-latest.apk')) {
                self::finalizeFromArtifacts($id);
                continue;
            }
            $tail = '';
            if (is_file($logPath)) {
                $size = filesize($logPath) ?: 0;
                $tail = (string) file_get_contents($logPath, false, null, max(0, $size - 8192));
            }
            $modeFile = $dir . '/runner.mode';
            if (is_file($modeFile) && trim((string) file_get_contents($modeFile)) === 'ssh_debug') {
                if (self::debugContainerAlive($id)) {
                    BuildRepository::update($id, ['status' => 'running', 'phase' => 'ssh_debug']);
                    continue;
                }
                self::failBuild($id, 'SSH debug container ended', $logPath);
                continue;
            }
            if (self::runnerAlive($id)) {
                continue;
            }
            if (str_contains($tail, 'Cannot connect to the Docker daemon') || !self::dockerAvailable()) {
                self::failBuild($id, 'Docker daemon not available', $logPath);
            } elseif (str_contains($tail, '[runner] finished')) {
                self::finalizeFromArtifacts($id);
            } elseif ($tail !== '') {
                $hint = self::summarizeLogFailure($logPath);
                $msg = $hint !== '' ? $hint : 'Build runner exited unexpectedly';
                self::failBuild($id, $msg, $logPath);
            } else {
                self::failBuild($id, 'Build runner never started', $logPath);
            }
        }
    }

    public static function reconcileQueuedBuilds(): void {
        BuildRepository::ensureSchema();
        $stmt = Database::pdo()->query("SELECT id, log_path, created_at FROM builds WHERE status = 'queued'");
        if (!$stmt) {
            return;
        }
        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
            $id = (int) ($row['id'] ?? 0);
            if ($id <= 0) {
                continue;
            }
            $logPath = (string) ($row['log_path'] ?? (self::artifactDir($id) . '/build.log'));
            if (self::runnerAlive($id)) {
                BuildRepository::update($id, [
                    'status' => 'running',
                    'phase' => 'docker',
                    'started_at' => gmdate('Y-m-d H:i:s'),
                ]);
                continue;
            }
            $created = strtotime((string) ($row['created_at'] ?? '')) ?: 0;
            if ($created > 0 && $created < time() - 120) {
                self::failBuild($id, 'Build stuck in queued (runner never started)', $logPath);
            }
        }
    }

    /** Last lines / markers from build.log for reconcile error_message (max 512 chars). */
    public static function summarizeLogFailure(?string $logPath): string {
        if (!$logPath || !is_file($logPath)) {
            return '';
        }
        $size = filesize($logPath) ?: 0;
        $chunk = (string) file_get_contents($logPath, false, null, max(0, $size - 32768));
        if ($chunk === '') {
            return '';
        }
        $lines = preg_split('/\r\n|\r|\n/', $chunk) ?: [];
        $markers = [];
        foreach ($lines as $line) {
            $trim = trim($line);
            if ($trim === '') {
                continue;
            }
            if (preg_match('/\[runner\] ERROR:|\[builder\] ERROR:|fatal:|BUILD FAILED|Cannot connect to the Docker daemon/i', $trim)) {
                $markers[] = $trim;
            }
        }
        $tailLines = array_values(array_filter($lines, static fn(string $l): bool => trim($l) !== ''));
        $last = array_slice($tailLines, -4);
        $parts = $markers !== [] ? array_slice($markers, -2) : $last;
        $summary = implode(' | ', $parts);
        if ($summary === '') {
            return '';
        }
        if (strlen($summary) > 480) {
            $summary = '…' . substr($summary, -477);
        }
        return 'Build runner exited unexpectedly: ' . $summary;
    }

    public static function readLogTail(?string $logPath, int $offset = 0, int $maxBytes = 65536): array {
        if (!$logPath || !is_file($logPath)) {
            return ['offset' => 0, 'eof' => true, 'text' => '', 'size' => 0];
        }
        $size = filesize($logPath) ?: 0;
        $offset = max(0, min($offset, $size));
        $fh = fopen($logPath, 'rb');
        if (!$fh) {
            return ['offset' => $offset, 'eof' => true, 'text' => '', 'size' => $size];
        }
        fseek($fh, $offset);
        $chunk = fread($fh, $maxBytes) ?: '';
        $newOffset = $offset + strlen($chunk);
        fclose($fh);
        return ['offset' => $newOffset, 'eof' => $newOffset >= $size, 'text' => $chunk, 'size' => $size];
    }

    public static function readLogFull(?string $logPath, int $maxBytes = 2097152): array {
        if (!$logPath || !is_file($logPath)) {
            return ['offset' => 0, 'eof' => true, 'text' => '', 'size' => 0, 'truncated' => false];
        }
        $size = filesize($logPath) ?: 0;
        $truncated = $size > $maxBytes;
        $start = $truncated ? $size - $maxBytes : 0;
        $text = (string) file_get_contents($logPath, false, null, $start);
        if ($truncated) {
            $text = "[log truncated: showing last {$maxBytes} of {$size} bytes]\n" . $text;
        }
        return ['offset' => $size, 'eof' => true, 'text' => $text, 'size' => $size, 'truncated' => $truncated];
    }

    public static function finalizeFromArtifacts(int $buildId): void {
        $dir = self::artifactDir($buildId);
        $infoPath = $dir . '/BUILD_INFO.json';
        $status = is_file($dir . '/android_cast-latest.apk') ? 'passed' : 'failed';
        $artifacts = [];
        if (is_file($dir . '/android_cast-latest.apk')) {
            $artifacts['apk'] = 'android_cast-latest.apk';
        }
        if (is_dir($dir . '/ota/v0')) {
            $artifacts['ota'] = 'ota/v0';
        }
        $versionApp = null;
        if (is_file($infoPath)) {
            $info = json_decode((string) file_get_contents($infoPath), true);
            if (is_array($info)) {
                $versionApp = $info['gitSha'] ?? null;
            }
        }
        BuildRepository::update($buildId, [
            'status' => $status,
            'phase' => 'done',
            'artifacts_json' => json_encode($artifacts, JSON_UNESCAPED_SLASHES),
            'version_app' => $versionApp,
            'finished_at' => gmdate('Y-m-d H:i:s'),
        ]);
        if (!empty($artifacts['ota']) && cfg('build.ota_mount')) {
            $build = BuildRepository::getById($buildId);
            if (!empty($build['auto_deploy'])) {
                self::publishOta($buildId, $dir);
            }
        }
    }

    private static function publishOta(int $buildId, string $dir): void {
        $mount = rtrim((string) cfg('build.ota_mount', ''), '/');
        if ($mount === '' || !is_dir($mount)) {
            return;
        }
        $src = $dir . '/ota/v0';
        if (!is_dir($src)) {
            return;
        }
        $build = BuildRepository::getById($buildId);
        $channel = $build['ota_channel'] ?? 'staging';
        $dest = $mount . '/v0';
        shell_exec('mkdir -p ' . escapeshellarg($dest) . ' && cp -a ' . escapeshellarg($src . '/.') . ' ' . escapeshellarg($dest . '/'));
        if ($channel !== '' && $channel !== 'staging') {
            shell_exec('cp -a ' . escapeshellarg($src . '/ota/channel/' . $channel . '.json') . ' ' . escapeshellarg($dest . '/ota/channel/' . $channel . '.json 2>/dev/null'));
        }
    }
}
