<?php

namespace KeyLair;

/**
 * Checks for and installs updates from a GitLab Package Registry "generic"
 * package, instead of requiring the deployment directory to be a git
 * checkout. Downloads over plain HTTPS with a Deploy Token scoped to
 * read_package_registry only — deliberately never a token with repository
 * read access, since that would let a compromised/leaked token browse the
 * private source, not just fetch release zips.
 *
 * The GitLab connection is the vendor's, not the customer's: it's the same
 * for every installation, loaded from src/deploy-credentials.php (built
 * into the release zip, but gitignored — never committed) rather than
 * configured per install, so a customer never has to see or touch it.
 * That means this token is only ever as secret as "known to every customer
 * running this software" — acceptable because it's read-only against the
 * package registry and every customer already has the full source anyway;
 * it does mean rotating it requires shipping a new release and re-updating
 * every install still on an older one.
 *
 * Release convention: alongside the permanent, version-named package
 * (name "key-lair", version "X.Y.Z", file "key-lair-X.Y.Z.zip", kept for
 * reference), every release also overwrites a package at fixed
 * "channel" coordinates -- name "key-lair", version "latest" for the
 * stable channel (a plain vX.Y.Z tag) or "latest-alpha"/"latest-beta"/
 * "latest-dev" for a pre-release one (a vX.Y.Z-alpha/-beta/-dev tag) --
 * each containing a small VERSION file and the same zip as
 * "key-lair-latest.zip". Which one this class reads is
 * Settings::updateChannel() (admin/deploy.php, master-admin only,
 * defaults to "stable"); switching channels takes effect on the very
 * next check, no separate opt-in step. Deliberately NOT discovered via
 * the packages-list API (GET .../packages?...): a Deploy Token scoped to
 * read_package_registry can authenticate a direct generic-package file
 * download, but GitLab rejects it on the general list endpoint (returns
 * 404, confirmed against a live deploy token) — so every check fetches
 * the fixed channel VERSION file instead, and update fetches the fixed
 * channel zip. Published automatically by .gitlab-ci.yml whenever a
 * matching tag is pushed. Never touches the database — migrations stay a
 * separate, deliberate step via admin/migrations.php.
 */
class Deployer
{
    // Renamed from "key-vault" to "key-lair" here first -- .gitlab-ci.yml
    // deliberately still PUBLISHES this exact release under the old
    // "key-vault" coordinates, so a site still running the previous
    // (pre-rename) build -- which still looks for "key-vault" -- can find
    // and install it. Only once every install has had a chance to pick up
    // this release does .gitlab-ci.yml switch to publishing as "key-lair"
    // too, in a follow-up release. Switching both at once would strand any
    // site that hadn't already updated: it would keep checking a package
    // name nothing is published under anymore.
    private const PACKAGE_NAME = 'key-lair';
    private const LATEST_TAG = 'latest';

    /**
     * copyRecursive() below only ever overlays the new release's files on
     * top of the install -- a file renamed or removed in a newer release
     * is never deleted, since there's nothing in the new package telling
     * pull() it used to exist. Rather than a generic "delete anything not
     * in the new package" sync (real data-loss risk against
     * public/uploads/ or a customer's own config.php), each rename/removal
     * gets its exact old path added here explicitly, relative to the
     * install root -- so cleanup is auditable and only ever touches
     * exactly what's listed.
     */
    private const STALE_PATHS = [
        'public/admin/feedback.php', // renamed to bug_reporting.php in 0.7.6
    ];

    /** @return array{gitlab_host?: string, gitlab_project?: string, deploy_token?: string} */
    private static function credentials(): array
    {
        static $creds = null;
        if ($creds === null) {
            $path = dirname(__DIR__) . '/src/deploy-credentials.php';
            $creds = file_exists($path) ? (require $path) : [];
        }
        return $creds;
    }

    private static function repoPath(): string
    {
        return ROOT_PATH;
    }

    private static function apiBase(): string
    {
        $host = self::credentials()['gitlab_host'] ?? 'https://gitlab.com';
        return rtrim($host, '/') . '/api/v4';
    }

    private static function projectPath(): string
    {
        return rawurlencode((string) (self::credentials()['gitlab_project'] ?? ''));
    }

    private static function deployToken(): string
    {
        return (string) (self::credentials()['deploy_token'] ?? '');
    }

    /** @return array{ok: bool, status: int, body: ?string} */
    private static function httpGet(string $url): array
    {
        $context = stream_context_create([
            'http' => [
                'method' => 'GET',
                'header' => 'Deploy-Token: ' . self::deployToken() . "\r\n",
                'timeout' => 20,
                'ignore_errors' => true,
            ],
        ]);

        $body = @file_get_contents($url, false, $context);
        $status = 0;
        if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) {
            $status = (int) $m[1];
        }

        return ['ok' => $body !== false && $status >= 200 && $status < 300, 'status' => $status, 'body' => $body];
    }

    private static function downloadTo(string $url, string $destination): bool
    {
        $context = stream_context_create([
            'http' => [
                'method' => 'GET',
                'header' => 'Deploy-Token: ' . self::deployToken() . "\r\n",
                'timeout' => 120,
                'ignore_errors' => true,
            ],
        ]);

        return (bool) @copy($url, $destination, $context);
    }

    public static function currentVersion(): ?string
    {
        $path = self::repoPath() . '/VERSION';
        return file_exists($path) ? trim(file_get_contents($path)) : null;
    }

    /**
     * "stable" uses the plain "latest" coordinates a plain vX.Y.Z tag
     * publishes to, unchanged from before update channels existed; any
     * other channel uses its own "latest-{channel}" coordinates, which
     * only a tag with that same -alpha/-beta/-dev suffix ever publishes
     * to (see .gitlab-ci.yml) -- so the two can never cross-contaminate
     * each other regardless of which one an install is tracking.
     */
    private static function channelTag(): string
    {
        $channel = Settings::updateChannel();
        return $channel === 'stable' ? self::LATEST_TAG : self::LATEST_TAG . '-' . $channel;
    }

    private static function latestPackageUrl(string $fileName): string
    {
        return self::apiBase() . '/projects/' . self::projectPath()
            . '/packages/generic/' . self::PACKAGE_NAME . '/' . self::channelTag() . "/{$fileName}";
    }

    /**
     * The public sales page's "Download Community" button always offers
     * the stable channel, regardless of which channel this particular
     * install happens to be tracking internally (Settings::updateChannel())
     * -- a public download link should never hand a first-time visitor a
     * dev/alpha/beta build just because this server's own admin switched
     * channels to test something.
     */
    public static function fetchStableZip(): ?string
    {
        $url = self::apiBase() . '/projects/' . self::projectPath()
            . '/packages/generic/' . self::PACKAGE_NAME . '/' . self::LATEST_TAG . '/' . self::PACKAGE_NAME . '-latest.zip';

        $context = stream_context_create([
            'http' => [
                'method' => 'GET',
                'header' => 'Deploy-Token: ' . self::deployToken() . "\r\n",
                'timeout' => 120,
                'ignore_errors' => true,
            ],
        ]);

        $body = @file_get_contents($url, false, $context);
        $status = 0;
        if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) {
            $status = (int) $m[1];
        }

        return ($body !== false && $status >= 200 && $status < 300) ? $body : null;
    }

    public static function remoteVersion(): ?string
    {
        $result = self::httpGet(self::latestPackageUrl('VERSION'));
        if (!$result['ok']) {
            return null;
        }
        $version = trim((string) $result['body']);
        return $version !== '' ? $version : null;
    }

    /** Full CHANGELOG.md of the currently-installed copy, as already shipped in this install. */
    public static function localChangelog(): ?string
    {
        $path = self::repoPath() . '/CHANGELOG.md';
        return file_exists($path) ? file_get_contents($path) : null;
    }

    /** Full CHANGELOG.md of the version available to update to, fetched from the same channel coordinates as VERSION. */
    public static function remoteChangelog(): ?string
    {
        $result = self::httpGet(self::latestPackageUrl('CHANGELOG.md'));
        return $result['ok'] ? (string) $result['body'] : null;
    }

    /** @return array{ok: bool, output: string} */
    public static function pull(): array
    {
        $version = self::remoteVersion();
        if ($version === null) {
            return ['ok' => false, 'output' => "Couldn't reach the package registry. Try again later."];
        }

        $tmpZip = tempnam(sys_get_temp_dir(), 'kv-update-') . '.zip';
        if (!self::downloadTo(self::latestPackageUrl(self::PACKAGE_NAME . '-latest.zip'), $tmpZip)) {
            @unlink($tmpZip);
            return ['ok' => false, 'output' => 'Download failed.'];
        }

        $zip = new \ZipArchive();
        if ($zip->open($tmpZip) !== true) {
            @unlink($tmpZip);
            return ['ok' => false, 'output' => 'Downloaded file is not a valid zip archive.'];
        }

        $extractDir = sys_get_temp_dir() . '/kv-update-extract-' . uniqid();
        mkdir($extractDir, 0755, true);
        $zip->extractTo($extractDir);
        $zip->close();
        @unlink($tmpZip);

        // A zip built with a --prefix wraps everything in one folder; if
        // that's what we got, treat that folder as the real root instead of
        // copying an extra useless directory level into the install.
        $sourceRoot = $extractDir;
        $entries = array_values(array_diff(scandir($extractDir), ['.', '..']));
        if (count($entries) === 1 && is_dir($extractDir . '/' . $entries[0])) {
            $sourceRoot = $extractDir . '/' . $entries[0];
        }

        $failures = self::copyRecursive($sourceRoot, self::repoPath());
        self::deleteRecursive($extractDir);
        self::removeStalePaths();

        if (!empty($failures)) {
            $preview = implode(', ', array_slice($failures, 0, 3));
            $more = count($failures) > 3 ? ' and ' . (count($failures) - 3) . ' more' : '';
            return ['ok' => false, 'output' => "Downloaded {$version} but couldn't write " . count($failures) . " file(s) ({$preview}{$more}). Check that the web server user owns the install directory (e.g. chown -R www-data:www-data)."];
        }

        Settings::setInstalledChannel(Settings::updateChannel());

        return ['ok' => true, 'output' => "Updated to {$version}."];
    }

    /** @return string[] paths that couldn't be written */
    private static function copyRecursive(string $source, string $destination): array
    {
        $failures = [];
        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::SELF_FIRST
        );

        foreach ($iterator as $item) {
            $target = $destination . '/' . $iterator->getSubPathName();
            if ($item->isDir()) {
                if (!is_dir($target) && !@mkdir($target, 0755, true) && !is_dir($target)) {
                    $failures[] = $target;
                }
            } elseif (!@copy($item->getPathname(), $target)) {
                $failures[] = $target;
            }
        }

        return $failures;
    }

    private static function deleteRecursive(string $dir): void
    {
        if (!is_dir($dir)) {
            return;
        }
        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::CHILD_FIRST
        );
        foreach ($iterator as $item) {
            $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
        }
        rmdir($dir);
    }

    /** Best-effort: a stale file that can't be removed (permissions) is logged, not fatal to the update. */
    private static function removeStalePaths(): void
    {
        foreach (self::STALE_PATHS as $relativePath) {
            $path = self::repoPath() . '/' . $relativePath;
            if (is_file($path) && !@unlink($path)) {
                error_log("Deployer: couldn't remove stale file {$path}");
            }
        }
    }
}
