<?php

declare(strict_types=1);

namespace PpPhp\Core\Build;

use PpPhp\Core\ModuleState;
use PpPhp\Core\ProjectLocator;
use PpPhp\Core\Update\UpdateProtocol;

/**
 * CUSTOMER side of server-side builds — the thin-client half.
 *
 * A client edition ships with NO code generator (BuildWriter / ComponentRenderer /
 * BuildEngine live on your servers only). When the customer clicks "Build", this
 * class gathers the project's DESIGN (plain JSON — the customer's own work),
 * sends it to the licence mesh, verifies the signed reply against the trusted
 * key, and writes the returned files to output/. It contains no generation logic
 * whatsoever — the crown jewel never reaches the customer's disk.
 *
 * Deliberately mirrors {@see \PpPhp\Core\Projects\TemplateClient}: same endpoint
 * resolution (primary → backups), same credentials, same Ed25519 verification.
 * It returns a {@see BuildResult} so existing callers need no change.
 */
final class BuildClient
{
    private string $root;

    public function __construct(?string $root = null)
    {
        $this->root = rtrim($root ?? (defined('BASE_PATH') ? (string) constant('BASE_PATH') : getcwd()), '/');
    }

    /**
     * Build $slug by sending its design to the licence mesh and writing the
     * signed result to the output directory. Never runs a generator locally.
     *
     * @param array<string,mixed> $options clean/compress/outputDir/forceInstaller…
     */
    public function build(string $slug, array $options = [], bool $allowDelta = true): BuildResult
    {
        $started = microtime(true);

        $key = $this->trustedKey();
        if ($key === '') {
            return BuildResult::error('This build is not configured to talk to a build server.');
        }
        $endpoints = $this->endpoints();
        if ($endpoints === []) {
            return BuildResult::error('No build server address is known for this installation. Activate your licence first.');
        }
        $creds = $this->credentials();
        if (($creds['instanceId'] ?? '') === '') {
            return BuildResult::error('This installation is not activated. Enter a licence to build.');
        }

        $outputDir = $this->outputDir($slug, $options);
        $clean     = (bool) ($options['clean'] ?? true);

        // A rebuild that isn't a forced clean can ask for a DELTA: tell the server
        // what we already have (from the last build's manifest) so it returns only
        // what changed. A first build, a forced clean, or a fallback pass sends none.
        $have = ($allowDelta && !$clean) ? $this->loadManifest($outputDir) : [];

        // The design (customer's own JSON) + any custom files the generator reads.
        $payload = [
            'design'  => $this->collectDesign($slug),
            'custom'  => $this->collectCustomFiles($slug),
            'options' => $this->safeOptions($options),
            'have'    => $have,
        ] + $creds;

        // Try each build server in turn and accept the FIRST that returns a signed,
        // SUCCESSFUL build. A server that responds but cannot build for us — it is not
        // a build server, its code generator is missing/broken (a signed failure OR
        // the newer unsigned "not configured" answer), a daily quota was hit, its
        // reply won't verify, or it does not recognise the licence — is skipped so a
        // healthy peer can still build. A single broken/misconfigured server never
        // blocks a build when another is available; only if EVERY server declines do
        // we surface the reason. (A genuine DESIGN error fails the same way on every
        // server, so it is still reported — just after each server was tried.)
        $claims = null;
        $whyNot = '';
        foreach ($endpoints as $base) {
            try {
                $r = $this->post(rtrim($base, '/') . BuildProtocol::ROUTE_GENERATE, $payload);
            } catch (\Throwable $e) {
                $this->mesh('recordFailure', $base);
                $whyNot = 'Could not reach a build server. ' . $e->getMessage();
                continue;   // transport failure → try the next server
            }
            $this->mesh('recordSuccess', $base);
            if (!isset($r['build']) || $r['build'] === '') {
                // Responded without a signed build — quota, not-a-build-server, or an
                // unrecognised licence. Remember why and fail over.
                $whyNot = (string) ($r['message'] ?? 'The build server did not return a build.');
                continue;
            }
            $c = UpdateProtocol::verify((string) $r['build'], $key, BuildProtocol::PREFIX);
            if ($c === null) {
                $whyNot = 'A build server reply could not be verified.';
                continue;
            }
            if (empty($c['success'])) {
                // A SIGNED failure (e.g. a server whose generator is missing returned
                // "Class BuildWriter not found"). Do not accept it — another build
                // server may be healthy. Keep the message in case all of them fail.
                $whyNot = (string) ($c['message'] ?? 'The build did not complete on the server.');
                continue;
            }
            $claims = $c;   // signed AND successful
            break;
        }
        if ($claims === null) {
            return BuildResult::error($whyNot !== '' ? $whyNot
                : 'No build server was able to complete the build. Please try again shortly.');
        }

        $isDelta    = (bool) ($claims['delta'] ?? false);
        $assetPaths = array_values(array_filter((array) ($claims['assets'] ?? []), 'is_string'));
        $manifest   = is_array($claims['manifest'] ?? null) ? $claims['manifest'] : [];

        if ($isDelta) {
            // ── DELTA: overlay in place (fast — only the changed files are here).
            // A verify failure auto-falls-back to a full CLEAN build (which is
            // atomic, below); a crash mid-overlay self-heals — the next build's
            // drift check forces a full rebuild.
            $written = $this->extractZip($outputDir, (string) ($claims['zip'] ?? ''), false);
            if ($written < 0) {
                return BuildResult::error('The build result contained an unsafe path and was rejected — nothing was written.');
            }
            $this->applyDeletions($outputDir, array_values(array_filter((array) ($claims['deleted'] ?? []), 'is_string')));
            $filled = $assetPaths !== [] ? BuildAssets::fill($this->root, $outputDir, $assetPaths) : 0;
            if ($manifest !== []) {
                $bad = BuildAssets::verify($outputDir, $manifest);
                if ($bad !== []) {
                    if ($allowDelta) return $this->build($slug, ['clean' => true] + $options, false);
                    return BuildResult::error('The finished app failed its integrity check ('
                        . count($bad) . ' file' . (count($bad) === 1 ? '' : 's') . ' did not match). Nothing was shipped — please build again.');
                }
                $this->saveManifest($outputDir, $manifest);
            }
            $this->finalizeLocalConfig($slug, $outputDir, $options);
            return BuildResult::success((string) ($claims['message'] ?? 'Build complete.'), [
                'filesGenerated' => count($manifest), 'changedFiles' => $written,
                'outputDir' => $outputDir, 'delta' => true, 'verified' => $manifest !== [],
                'duration' => round(microtime(true) - $started, 2),
            ]);
        }

        // ── FULL build: ATOMIC with rollback. Materialise + verify into a sibling
        // STAGING dir, then swap it over the live output in one atomic rename. So a
        // network drop, a timeout, disk-full or a crash can NEVER leave a half-built
        // app — either the new app fully replaces the old, or the old keeps running
        // untouched. The previous build is kept as <output>.bak for instant rollback.
        // Sweep any staging dirs orphaned by a previously-interrupted build, so a
        // crash can't leave them accumulating under output/ over time.
        foreach ((array) @glob($outputDir . '.new-*', GLOB_ONLYDIR) as $stale) {
            $this->rmrf((string) $stale);
        }
        $staging = $outputDir . '.new-' . bin2hex(random_bytes(4));
        $this->rmrf($staging);
        $written = $this->extractZip($staging, (string) ($claims['zip'] ?? ''), true);
        if ($written < 0) {
            $this->rmrf($staging);
            return BuildResult::error('The build result contained an unsafe path and was rejected — nothing was written. Your existing app is unchanged.');
        }
        $filled = $assetPaths !== [] ? BuildAssets::fill($this->root, $staging, $assetPaths) : 0;
        if ($manifest !== []) {
            $bad = BuildAssets::verify($staging, $manifest);
            if ($bad !== []) {
                $this->rmrf($staging);   // live output is UNTOUCHED
                return BuildResult::error('The finished app failed its integrity check ('
                    . count($bad) . ' file' . (count($bad) === 1 ? '' : 's') . ' did not match, e.g. '
                    . implode(', ', array_slice($bad, 0, 3)) . '). Nothing was shipped — your existing app is unchanged. Please build again.');
            }
            $this->saveManifest($staging, $manifest);
        }
        if (!$this->atomicSwap($outputDir, $staging)) {
            $this->rmrf($staging);
            return BuildResult::error('The app was built and verified, but could not be moved into place — check that ' . $outputDir . ' is writable. Your existing app is unchanged.');
        }

        $this->finalizeLocalConfig($slug, $outputDir, $options);
        return BuildResult::success((string) ($claims['message'] ?? 'Build complete.'), [
            'filesGenerated' => $written + $filled,
            'changedFiles'   => $written,
            'outputDir'      => $outputDir,
            'delta'          => false,
            'verified'       => $manifest !== [],
            'duration'       => round(microtime(true) - $started, 2),
        ]);
    }

    /**
     * Make the freshly-built app serve IMMEDIATELY — no install step. The build
     * server never receives the customer's database credentials (they are not part
     * of the design payload, by privacy design), so it ships a self-installing
     * bundle (install.php + config/database.sample.php). Here on the CLIENT, where
     * the project's own DB profile DOES live, we bake it into the built output so
     * the generated app connects to the same database the project uses, out of the
     * box. Credentials therefore never leave this machine.
     *
     * Skipped when:
     *  - forceInstaller is set (the "Deployment Package" action ships a portable,
     *    self-installing bundle meant for a DIFFERENT server — baking local creds
     *    there would be wrong), or
     *  - the project is genuinely database-free (standalone) — the server's
     *    standalone marker stands and there is nothing to bake.
     *
     * install.php is left in place (it is in the signed manifest, so removing it
     * would fail the next delta build's integrity check); writing installed.lock
     * makes it self-lock ("Already installed"), so it is inert and safe.
     */
    private function finalizeLocalConfig(string $slug, string $outputDir, array $options): void
    {
        if (!empty($options['forceInstaller'])) return;   // portable deploy bundle — keep the installer live
        try {
            $projDir = \PpPhp\Core\ProjectLocator::dir($slug);
            if (\PpPhp\Core\ProjectDatabaseProfile::isStandalone($projDir)) return;   // no DB by design
            $srcFile = \PpPhp\Core\ProjectDatabaseProfile::configPath($projDir);
            if (!is_file($srcFile)) return;               // no local profile → install.php remains the path
            $src = require $srcFile;
            if (!is_array($src) || $src === []) return;

            $profile = [
                'type'    => $src['type']    ?? 'mysql',
                'driver'  => $src['driver']  ?? ($src['type'] ?? 'mysql'),
                'host'    => $src['host']    ?? 'localhost',
                'port'    => (int) ($src['port'] ?? 3306),
                'dbname'  => $src['dbname']  ?? ($src['name'] ?? $slug),
                'user'    => $src['user']    ?? '',
                'pass'    => $src['pass']    ?? '',
                'charset' => $src['charset'] ?? 'utf8mb4',
            ];
            // Preserve driver-specific extras when the profile carries them:
            // SQLite file path, Postgres/SQL Server schema, Oracle service, SSL block.
            foreach (['ssl', 'path', 'schema', 'service'] as $k) {
                if (isset($src[$k]) && $src[$k] !== '' && $src[$k] !== []) $profile[$k] = $src[$k];
            }

            $configDir = rtrim($outputDir, '/') . '/config';
            if (!is_dir($configDir)) @mkdir($configDir, 0775, true);
            @file_put_contents(
                $configDir . '/database.php',
                "<?php\n\ndeclare(strict_types=1);\n\n// Baked locally by the client after build — credentials never left this machine.\nreturn "
                . var_export($profile, true) . ";\n"
            );
            @unlink($configDir . '/standalone.php');            // this is a DB app, not standalone
            // NB: leave config/database.sample.php in place — it is in the signed
            // manifest, so deleting it would fail the next delta build's integrity
            // check and force a full rebuild. It is inert once database.php exists.
            if (!is_file($configDir . '/installed.lock')) {     // neutralise the bundled installer
                @file_put_contents($configDir . '/installed.lock', date('c') . " (baked by client build)\n");
            }
        } catch (\Throwable $e) {
            // Best effort: if anything fails, the shipped install.php is the fallback.
        }
    }

    /**
     * Atomically replace the live build with a fully-materialised, already-verified
     * staging dir, keeping the previous build as "<live>.bak" for rollback. Both
     * dirs are siblings under output/, so every rename is same-filesystem and
     * atomic. If putting the new build in place fails, the previous build is
     * restored. Returns false only on a genuine filesystem failure.
     */
    private function atomicSwap(string $live, string $staging): bool
    {
        if (!is_dir($live)) {
            return @rename($staging, $live);   // first build — nothing to replace
        }
        $bak = $live . '.bak';
        $this->rmrf($bak);
        if (!@rename($live, $bak)) return false;      // couldn't set the old build aside
        if (!@rename($staging, $live)) {
            @rename($bak, $live);                       // ROLLBACK — restore the old build
            return false;
        }
        return true;   // keep $bak as the single rollback point (overwritten next full build)
    }

    /** Recursively remove a directory (and itself). No-op if absent. */
    private function rmrf(string $dir): void
    {
        if ($dir === '' || !is_dir($dir)) return;
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::CHILD_FIRST
        );
        foreach ($it as $f) { $f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname()); }
        @rmdir($dir);
    }

    // ── design collection (input to the build) ────────────────────────

    /** @return array<string,mixed> every design module for $slug */
    private function collectDesign(string $slug): array
    {
        $design = [];
        foreach (BuildProtocol::DESIGN_MODULES as $module) {
            $design[$module] = ModuleState::load($slug, $module);
        }
        return $design;
    }

    /**
     * Custom files the generator copies into the app (author code, assets). Sent
     * as {relative-path: base64}. Bounded, best-effort — a project with none just
     * sends an empty map.
     *
     * @return array<string,string>
     */
    private function collectCustomFiles(string $slug): array
    {
        $out = [];
        try {
            $dir = ProjectLocator::dir($slug) . '/custom';
            if (!is_dir($dir)) return $out;
            $base = rtrim(str_replace('\\', '/', realpath($dir) ?: $dir), '/');
            $total = 0;
            $it = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
            );
            foreach ($it as $f) {
                if (!$f->isFile()) continue;
                if (basename((string) $f) === '.DS_Store') continue;
                $rel = ltrim(str_replace($base, '', str_replace('\\', '/', $f->getPathname())), '/');
                $size = (int) $f->getSize();
                $total += $size;
                if ($size > 4 * 1024 * 1024 || $total > 24 * 1024 * 1024) continue;   // skip oversized
                $out[$rel] = base64_encode((string) @file_get_contents($f->getPathname()));
            }
        } catch (\Throwable $e) { /* best effort */ }
        return $out;
    }

    /** Only forward options that are safe to honour remotely. */
    private function safeOptions(array $options): array
    {
        return [
            'clean'          => (bool) ($options['clean'] ?? true),
            'compress'       => (bool) ($options['compress'] ?? false),
            'forceInstaller' => (bool) ($options['forceInstaller'] ?? false),
        ];
    }

    private function outputDir(string $slug, array $options): string
    {
        $dir = trim((string) ($options['outputDir'] ?? ''));
        if ($dir === '') $dir = $this->root . '/output/' . $slug;
        if (!str_starts_with($dir, '/')) $dir = $this->root . '/' . $dir;
        return rtrim($dir, '/');
    }

    /**
     * Verify-and-extract the signed ZIP into $outputDir. Every entry is validated
     * to be a plain relative path inside the directory — an absolute path or any
     * ".." component fails the whole extraction (returns -1), so a malicious or
     * corrupt reply can never write outside output/. Returns the count written.
     */
    private function extractZip(string $outputDir, string $zipB64, bool $clean): int
    {
        if ($zipB64 === '' || !class_exists(\ZipArchive::class)) return -1;
        $bytes = base64_decode($zipB64, true);
        if ($bytes === false) return -1;

        $base = rtrim($outputDir, '/');
        if (!is_dir($base) && !@mkdir($base, 0775, true) && !is_dir($base)) return -1;
        $baseReal = rtrim(str_replace('\\', '/', realpath($base) ?: $base), '/');

        $tmp = rtrim(sys_get_temp_dir(), '/\\') . '/ppdlzip_' . bin2hex(random_bytes(6)) . '.zip';
        if (@file_put_contents($tmp, $bytes) === false) return -1;

        $zip = new \ZipArchive();
        if ($zip->open($tmp) !== true) { @unlink($tmp); return -1; }

        // First pass: reject the WHOLE archive if any entry escapes the directory,
        // before writing anything.
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $name = str_replace('\\', '/', (string) $zip->getNameIndex($i));
            if ($name === '' || $name[0] === '/' || preg_match('#(^|/)\.\.(/|$)#', $name)) {
                $zip->close(); @unlink($tmp); return -1;
            }
        }

        if ($clean) $this->rmrfContents($baseReal);

        $n = 0;
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $name = str_replace('\\', '/', (string) $zip->getNameIndex($i));
            $dest = $baseReal . '/' . $name;
            if (str_ends_with($name, '/')) { @mkdir($dest, 0775, true); continue; }
            $parent = dirname($dest);
            if (!is_dir($parent) && !@mkdir($parent, 0775, true) && !is_dir($parent)) continue;
            $content = $zip->getFromIndex($i);
            if ($content === false) continue;
            if (@file_put_contents($dest, $content) !== false) $n++;
        }
        $zip->close();
        @unlink($tmp);
        return $n;
    }

    /** Where the last build's integrity manifest is remembered (for delta requests). */
    private function manifestPath(string $outputDir): string
    {
        return rtrim($outputDir, '/') . '/.ppmanifest.json';
    }

    /** The {path: sha256} manifest of the current build on disk, or [] if none. */
    private function loadManifest(string $outputDir): array
    {
        $f = $this->manifestPath($outputDir);
        if (!is_file($f)) return [];
        $data = json_decode((string) @file_get_contents($f), true);
        if (!is_array($data) || !is_array($data['files'] ?? null)) return [];
        $out = [];
        foreach ($data['files'] as $rel => $h) {
            if (is_string($rel) && is_string($h)) $out[$rel] = $h;
        }
        return $out;
    }

    /** Record what we now hold so the next build can ask for a delta. Best-effort. */
    private function saveManifest(string $outputDir, array $manifest): void
    {
        @file_put_contents($this->manifestPath($outputDir),
            json_encode(['files' => $manifest, 'at' => time()], JSON_UNESCAPED_SLASHES));
    }

    /**
     * Delete files a delta says are gone from the new build. Each path is validated
     * to be a plain relative path inside $outputDir (no absolute, no ".."), so a
     * malicious list can never remove anything outside the build directory.
     *
     * @param list<string> $paths
     */
    private function applyDeletions(string $outputDir, array $paths): void
    {
        $base = rtrim(str_replace('\\', '/', realpath($outputDir) ?: $outputDir), '/');
        if ($base === '') return;
        foreach ($paths as $rel) {
            $rel = str_replace('\\', '/', (string) $rel);
            if ($rel === '' || $rel[0] === '/' || preg_match('#(^|/)\.\.(/|$)#', $rel)) continue;
            $f = $base . '/' . $rel;
            if (!is_file($f)) continue;
            if (!str_starts_with(str_replace('\\', '/', realpath($f) ?: $f), $base . '/')) continue;
            @unlink($f);
        }
    }

    /** Empty a directory's contents (guarded to stay within $baseReal). */
    private function rmrfContents(string $baseReal): void
    {
        if (!is_dir($baseReal)) return;
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($baseReal, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::CHILD_FIRST
        );
        foreach ($it as $f) {
            $p = str_replace('\\', '/', $f->getPathname());
            if (!str_starts_with($p . '/', $baseReal . '/')) continue;   // never escape
            $f->isDir() ? @rmdir($p) : @unlink($p);
        }
    }

    // ── mesh resolution (identical to TemplateClient / UpdateClient) ──

    /**
     * Build servers to try, in order. The Owner may PIN a build server (Primary or
     * a Backup, to balance load) when generating the licence; that assignment rides
     * inside the signed lease (claim `bld`) and is tried first. Otherwise — and as a
     * fallback if the pinned one is down — the normal licence mesh (primary → backups)
     * is used, so a build never fails just because the assigned server hiccuped.
     *
     * @return list<string>
     */
    private function endpoints(): array
    {
        $list = [];
        if (class_exists(\PpPhp\Core\Licensing\LicenseClient::class)) {
            try {
                $client = new \PpPhp\Core\Licensing\LicenseClient($this->root);
                // Owner-assigned build server (from the signed lease), if any.
                foreach ($this->assignedBuildServers($client) as $u) {
                    $u = rtrim(trim($u), '/');
                    if ($u !== '') $list[] = $u;
                }
                foreach ($client->endpoints() as $u) {
                    $u = rtrim(trim((string) $u), '/');
                    if ($u !== '') $list[] = $u;
                }
            } catch (\Throwable $e) { /* not activated */ }
        }
        $list = array_values(array_unique(array_filter($list)));
        // DNS-like ordering: healthiest live build server first (shared with the
        // licence mesh), never dropping one. Falls back to the plain list on error.
        try {
            if (class_exists(\PpPhp\Core\MeshResolver::class)) {
                $ordered = \PpPhp\Core\MeshResolver::order($list);
                if ($ordered !== []) $list = $ordered;
            }
        } catch (\Throwable $e) { /* keep plain list */ }
        return array_slice($list, 0, 5);
    }

    /**
     * The Owner-pinned build server(s) carried in the signed lease, if present.
     * Read defensively so it is a no-op until the server issues the `bld` claim.
     *
     * @return list<string>
     */
    private function assignedBuildServers(\PpPhp\Core\Licensing\LicenseClient $client): array
    {
        try {
            $st = $client->status();
            $raw = $st['buildUrl'] ?? ($st['bld'] ?? null);
            if (is_string($raw) && $raw !== '') return [$raw];
            if (is_array($raw)) return array_values(array_filter(array_map('strval', $raw)));
        } catch (\Throwable $e) { /* older lease has no assignment */ }
        return [];
    }

    private function trustedKey(): string
    {
        if (class_exists(\PpPhp\Core\Licensing\LicenseTrust::class)) {
            $k = \PpPhp\Core\Licensing\LicenseTrust::key();
            if ($k !== '') return $k;
        }
        return '';
    }

    /** @return array{instanceId?:string,secret?:string} */
    private function credentials(): array
    {
        if (!class_exists(\PpPhp\Core\Licensing\LicenseClient::class)) return [];
        try {
            $c = (new \PpPhp\Core\Licensing\LicenseClient($this->root))->updateCredentials();
            $id = (string) ($c['instanceId'] ?? '');
            return $id !== '' ? ['instanceId' => $id, 'secret' => (string) ($c['secret'] ?? '')] : [];
        } catch (\Throwable $e) {
            return [];
        }
    }

    /**
     * @param list<string>        $endpoints
     * @param array<string,mixed> $payload
     * @return array<string,mixed>
     */
    private function postAny(array $endpoints, string $path, array $payload): array
    {
        $lastError = null;
        foreach ($endpoints as $base) {
            try {
                $r = $this->post(rtrim($base, '/') . $path, $payload);
                $this->mesh('recordSuccess', $base);
                return $r;
            } catch (\RuntimeException $e) {
                $this->mesh('recordFailure', $base);   // cool this server down
                $lastError = $e;   // transport failure → try the next server
            }
        }
        throw $lastError ?? new \RuntimeException('Could not reach any build server.');
    }

    /** Best-effort mesh-health note — never lets a health write break a build. */
    private function mesh(string $method, string $url): void
    {
        try {
            if (class_exists(\PpPhp\Core\MeshResolver::class)) {
                \PpPhp\Core\MeshResolver::$method($url);
            }
        } catch (\Throwable $e) { /* advisory */ }
    }

    /** @param array<string,mixed> $payload @return array<string,mixed> */
    private function post(string $url, array $payload): array
    {
        $body = (string) json_encode($payload, JSON_UNESCAPED_SLASHES);
        $raw = false; $code = 0; $err = '';

        if (function_exists('curl_init')) {
            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body,
                CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json'],
                CURLOPT_RETURNTRANSFER => true,
                // Advertise gzip and let curl transparently decompress the reply. The
                // build payload is a large base64 JSON that the host compresses well
                // (~25% off), recovering the base64 overhead. curl decodes exactly the
                // encoding the server used, so this can never double-decode.
                CURLOPT_ENCODING => '',
                CURLOPT_CONNECTTIMEOUT => 8, CURLOPT_TIMEOUT => 120,   // a build can take a little while
                CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => 'PPPHP-Build/1',
            ]);
            $raw = curl_exec($ch);
            $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
            $err = (string) curl_error($ch);
            unset($ch);
        } else {
            $ctx = stream_context_create(['http' => [
                'method' => 'POST', 'header' => "Content-Type: application/json\r\nAccept: application/json\r\n",
                'content' => $body, 'timeout' => 120, 'ignore_errors' => true,
            ]]);
            $raw = @file_get_contents($url, false, $ctx);
        }

        if ($raw === false || $code === 0 && !function_exists('curl_init') && $raw === false) {
            throw new \RuntimeException('build server unreachable' . ($err !== '' ? " ({$err})" : ''));
        }
        if ($raw === false) {
            throw new \RuntimeException('build server unreachable' . ($err !== '' ? " ({$err})" : ''));
        }
        $data = json_decode((string) $raw, true);
        if (!is_array($data)) {
            throw new \RuntimeException('the build server did not answer as expected (HTTP ' . $code . ')');
        }
        return $data;
    }
}
