<?php

declare(strict_types=1);

namespace Grav\Plugin\Api\Services;

use Grav\Common\Cache;
use Grav\Common\Filesystem\Folder;
use Grav\Common\GPM\Common\Package;
use Grav\Common\GPM\GPM as GravGPM;
use Grav\Common\GPM\Installer;
use Grav\Common\GPM\Licenses;
use Grav\Common\GPM\Upgrader;
use Grav\Common\Grav;
use Grav\Common\HTTP\Response;
use Grav\Common\Utils;

/**
 * GpmService — GPM write operations (install / update / remove / direct-install / self-upgrade).
 *
 * This is a port of Grav\Plugin\Admin\Gpm that removes the dependency on the
 * classic admin plugin so admin-next / admin2 users can manage packages
 * without needing the old admin plugin installed.
 *
 * Admin-specific callsites (Admin::translate, Admin::getTempDir) have been
 * replaced with inlined English strings and a local temp-dir resolver.
 */
class GpmService
{
    /** @var GravGPM|null */
    protected static ?GravGPM $GPM = null;

    /** @var string|null Raw installer error captured during the last selfupgrade(). */
    protected static ?string $lastError = null;

    /** @var array<string, mixed>|null Preflight report captured during the last selfupgrade(). */
    protected static ?array $lastPreflightReport = null;

    /**
     * Default options for install operations.
     *
     * @var array<string, mixed>
     */
    protected static array $options = [
        'destination'     => GRAV_ROOT,
        'overwrite'       => true,
        'ignore_symlinks' => true,
        'skip_invalid'    => true,
        'install_deps'    => false,
        'theme'           => false,
    ];

    public static function GPM(): GravGPM
    {
        if (self::$GPM === null) {
            self::$GPM = new GravGPM();
        }

        return self::$GPM;
    }

    /**
     * Install one or more packages.
     *
     * @param Package[]|string[]|string $packages
     * @param array<string, mixed> $options
     * @return string|bool
     */
    public static function install($packages, array $options)
    {
        $options = array_merge(self::$options, $options);

        if (!Installer::isGravInstance($options['destination']) || !Installer::isValidDestination($options['destination'],
                [Installer::EXISTS, Installer::IS_LINK])
        ) {
            return false;
        }

        $packages = is_array($packages) ? $packages : [$packages];
        $count = count($packages);

        $packages = array_filter(array_map(static function ($p) {
            return !is_string($p) ? ($p instanceof Package ? $p : false) : self::GPM()->findPackage($p);
        }, $packages));

        if (!$options['skip_invalid'] && $count !== count($packages)) {
            return false;
        }

        // Refuse a package built for another Grav generation. Nothing else on
        // this path checks: the caller resolves dependencies and hands us
        // slugs, so before this a plugin still requiring the Grav 1.7 `admin`
        // plugin could install it next to Admin 2 whenever the repository
        // happened to serve it — which the testing release channel did, because
        // that beta declared no compatibility for the feed to filter on
        // (getgrav/grav-premium-issues#618).
        foreach ($packages as $package) {
            if (!GravGPM::declaresGravCompatibility($package->compatibility ?? null)) {
                return false;
            }
        }

        $messages = '';

        foreach ($packages as $package) {
            // Dependency resolution is the caller's responsibility (see
            // GpmController::install / ::update which use GPM::getDependencies()).
            // The blueprint `dependencies` structure is a list of
            // ['name' => slug, 'version' => constraint] entries, not slugs or
            // Package objects, so it can't be passed back into install().

            Installer::isValidDestination($options['destination'] . DS . $package->install_path);

            if (!$options['overwrite'] && Installer::lastErrorCode() === Installer::EXISTS) {
                return false;
            }

            if (!$options['ignore_symlinks'] && Installer::lastErrorCode() === Installer::IS_LINK) {
                return false;
            }

            $license = Licenses::forPackage($package);
            $local = static::download($package, $license);

            Installer::install(
                $local,
                $options['destination'],
                ['install_path' => $package->install_path, 'theme' => $options['theme']]
            );
            Folder::delete(dirname($local));

            $errorCode = Installer::lastErrorCode();
            if ($errorCode) {
                throw new \RuntimeException(Installer::lastErrorMsg());
            }

            if (count($packages) === 1) {
                $message = Installer::getMessage();
                if ($message) {
                    return $message;
                }

                $messages .= $message;
            }
        }

        Cache::clearCache();

        return $messages !== '' ? $messages : true;
    }

    /**
     * Update one or more packages.
     *
     * @param Package[]|string[]|string $packages
     * @param array<string, mixed> $options
     * @return string|bool
     */
    public static function update($packages, array $options)
    {
        $options['overwrite'] = true;

        return static::install($packages, $options);
    }

    /**
     * Uninstall one or more packages.
     *
     * @param Package[]|string[]|string $packages
     * @param array<string, mixed> $options
     * @return string|bool
     */
    public static function uninstall($packages, array $options)
    {
        $options = array_merge(self::$options, $options);

        $packages = (array) $packages;
        $count = count($packages);

        $packages = array_filter(array_map(static function ($p) {
            if (is_string($p)) {
                $p = strtolower($p);
                $plugin = self::GPM()->getInstalledPlugin($p);
                $p = $plugin ?: self::GPM()->getInstalledTheme($p);
            }

            return $p instanceof Package ? $p : false;
        }, $packages));

        if (!$options['skip_invalid'] && $count !== count($packages)) {
            return false;
        }

        foreach ($packages as $package) {
            $location = Grav::instance()['locator']->findResource($package->package_type . '://' . $package->slug);

            Installer::isValidDestination($location);

            if (!$options['ignore_symlinks'] && Installer::lastErrorCode() === Installer::IS_LINK) {
                return false;
            }

            Installer::uninstall($location);

            $errorCode = Installer::lastErrorCode();
            if ($errorCode && $errorCode !== Installer::IS_LINK && $errorCode !== Installer::EXISTS) {
                throw new \RuntimeException(Installer::lastErrorMsg());
            }

            if (count($packages) === 1) {
                $message = Installer::getMessage();
                if ($message) {
                    return $message;
                }
            }
        }

        Cache::clearCache();

        return true;
    }

    /**
     * Install a package directly from a local zip or remote URL.
     *
     * @param string $packageFile
     * @return string|bool
     */
    public static function directInstall(string $packageFile)
    {
        if ($packageFile === '') {
            return 'No package file provided.';
        }

        $tmpDir = static::getTempDir();
        $tmpZip = $tmpDir . '/Grav-' . uniqid('', false);

        if (Response::isRemote($packageFile)) {
            $zip = GravGPM::downloadPackage($packageFile, $tmpZip);
        } else {
            $zip = GravGPM::copyPackage($packageFile, $tmpZip);
        }

        if (!file_exists($zip)) {
            return 'Zip package not found.';
        }

        $tmpSource = $tmpDir . '/Grav-' . uniqid('', false);
        $extracted = Installer::unZip($zip, $tmpSource);

        if (!$extracted) {
            Folder::delete($tmpSource);
            Folder::delete($tmpZip);
            return 'Package extraction failed.';
        }

        $type = GravGPM::getPackageType($extracted);

        if (!$type) {
            Folder::delete($tmpSource);
            Folder::delete($tmpZip);
            return 'Not a valid Grav package.';
        }

        if ($type === 'grav') {
            Installer::isValidDestination(GRAV_ROOT . '/system');
            if (Installer::IS_LINK === Installer::lastErrorCode()) {
                Folder::delete($tmpSource);
                Folder::delete($tmpZip);
                return 'Cannot overwrite symlinks.';
            }

            static::upgradeGrav($zip, $extracted);
        } else {
            $name = GravGPM::getPackageName($extracted);

            if (!$name) {
                Folder::delete($tmpSource);
                Folder::delete($tmpZip);
                return 'Package name could not be determined.';
            }

            $installPath = GravGPM::getInstallPath($type, $name);
            $isUpdate = file_exists($installPath);

            Installer::isValidDestination(GRAV_ROOT . DS . $installPath);
            if (Installer::lastErrorCode() === Installer::IS_LINK) {
                Folder::delete($tmpSource);
                Folder::delete($tmpZip);
                return 'Cannot overwrite symlinks.';
            }

            Installer::install(
                $zip,
                GRAV_ROOT,
                ['install_path' => $installPath, 'theme' => $type === 'theme', 'is_update' => $isUpdate],
                $extracted
            );
        }

        Folder::delete($tmpSource);

        if (Installer::lastErrorCode()) {
            return Installer::lastErrorMsg();
        }

        Folder::delete($tmpZip);
        Cache::clearCache();

        return true;
    }

    /**
     * Self-upgrade Grav core to the latest release.
     *
     * @param array<string, mixed> $options Supported: 'override' (bool) to bypass
     *                                       blocking preflight checks, mirroring the CLI.
     * @return bool
     */
    public static function selfupgrade(array $options = []): bool
    {
        static::$lastError = null;
        static::$lastPreflightReport = null;

        $upgrader = new Upgrader();

        if (!Installer::isGravInstance(GRAV_ROOT)) {
            static::$lastError = 'Target directory is not a valid Grav instance.';
            return false;
        }

        if (is_link(GRAV_ROOT . DS . 'index.php')) {
            Installer::setError(Installer::IS_LINK);
            static::$lastError = 'Cannot self-upgrade: index.php is a symlink.';
            return false;
        }

        if (method_exists($upgrader, 'meetsRequirements') &&
            method_exists($upgrader, 'minPHPVersion') &&
            !$upgrader->meetsRequirements()) {
            $error = [];
            $error[] = '<p>Grav has increased the minimum PHP requirement.<br />';
            $error[] = 'You are currently running PHP <strong>' . phpversion() . '</strong>';
            $error[] = ', but PHP <strong>' . $upgrader->minPHPVersion() . '</strong> is required.</p>';
            Installer::setError(implode("\n", $error));
            static::$lastError = sprintf(
                'PHP %s or higher is required; this server runs PHP %s.',
                $upgrader->minPHPVersion(),
                phpversion()
            );
            return false;
        }

        $update = $upgrader->getAssets()['grav-update'];
        $tmp = static::getTempDir() . '/Grav-' . uniqid('', false);

        $file = static::downloadSelfupgrade($update, $tmp);
        $folder = Installer::unZip($file, $tmp . '/zip');

        static::upgradeGrav($file, $folder, false, $options);

        $errorCode = Installer::lastErrorCode();

        Folder::delete($tmp);

        $success = !(is_string($errorCode) || ($errorCode & (Installer::ZIP_OPEN_ERROR | Installer::ZIP_EXTRACT_ERROR)));

        // Capture the real reason so the controller can surface it instead of a generic 500.
        if (!$success && null === static::$lastError) {
            $msg = Installer::lastErrorMsg();
            static::$lastError = ('' !== $msg && 'No Error' !== $msg) ? $msg : 'Failed to upgrade Grav core.';
        }

        return $success;
    }

    /**
     * The raw installer error from the last selfupgrade() attempt, if any.
     */
    public static function getLastError(): ?string
    {
        return static::$lastError;
    }

    /**
     * The preflight report from the last selfupgrade() attempt, if one was generated.
     *
     * @return array<string, mixed>|null
     */
    public static function getLastPreflightReport(): ?array
    {
        return static::$lastPreflightReport;
    }

    /**
     * Download a GPM package zip into a temp directory.
     */
    private static function download(Package $package, ?string $license = null): string
    {
        $query = '';

        if ($package->premium) {
            $query = \json_encode(array_merge($package->premium, [
                'slug' => $package->slug,
                'license_key' => $license,
                'sid' => md5(GRAV_ROOT),
            ]));

            $query = '?d=' . base64_encode($query);
        }

        try {
            $contents = Response::get($package->zipball_url . $query, []);
        } catch (\Exception $e) {
            // A refused premium download is worth explaining in the store's
            // words when it gave any (updates lapsed, key does not cover this
            // add-on); otherwise say what happened without echoing the
            // request URL, which carries the licence key.
            $reason = Licenses::refusalReason($e);
            if ($reason !== null) {
                throw new \RuntimeException($reason);
            }
            if ($package->premium && $e->getCode() === 401) {
                throw new \RuntimeException("The licence key for '{$package->slug}' was not accepted by the download server. Check the key in user/data/licenses.yaml.");
            }
            throw new \RuntimeException($e->getMessage());
        }

        $tmpDir = static::getTempDir() . '/Grav-' . uniqid('', false);
        Folder::mkdir($tmpDir);

        $badChars = array_merge(array_map('chr', range(0, 31)), ['<', '>', ':', '"', '/', '\\', '|', '?', '*']);

        $filename = $package->slug . str_replace($badChars, '', Utils::basename($package->zipball_url));
        $filename = preg_replace('/[\\\\\/:"*?&<>|]+/m', '-', $filename);

        file_put_contents($tmpDir . DS . $filename . '.zip', $contents);

        return $tmpDir . DS . $filename . '.zip';
    }

    /**
     * Download the Grav self-upgrade zip.
     *
     * @param array<string, mixed> $package
     */
    private static function downloadSelfupgrade(array $package, string $tmp): string
    {
        $output = Response::get($package['download'], []);
        Folder::mkdir($tmp);
        file_put_contents($tmp . DS . $package['name'], $output);

        return $tmp . DS . $package['name'];
    }

    /**
     * Run the Grav core upgrade install script against an extracted zip.
     */
    private static function upgradeGrav(string $zip, string $folder, bool $keepFolder = false, array $options = []): void
    {
        static $ignores = [
            'backup',
            'cache',
            'images',
            'logs',
            'tmp',
            'user',
            '.htaccess',
            'robots.txt',
        ];

        if (!is_dir($folder)) {
            Installer::setError('Invalid source folder');
            return;
        }

        try {
            $script = $folder . '/system/install.php';
            if ((file_exists($script) && $install = include $script) && is_callable($install)) {
                // Preflight parity with `bin/gpm self-upgrade`: inspect the blocking checks
                // and honor an explicit override, rather than failing with an opaque error.
                if (is_object($install) && method_exists($install, 'generatePreflightReport')) {
                    $report = $install->generatePreflightReport();
                    static::$lastPreflightReport = $report;

                    if (!empty($report['blocking'] ?? [])) {
                        if (!empty($options['override'])) {
                            if (method_exists($install, 'allowIncompatibleOverride')) {
                                $install::allowIncompatibleOverride(true);
                            }
                            // Core names it allowPendingPackageOverride(); the older name never
                            // existed, so pending plugin updates could not be overridden here.
                            if (method_exists($install, 'allowPendingPackageOverride')) {
                                $install::allowPendingPackageOverride(true);
                            }
                            // Recompute so install() reuses an unblocked, cached report.
                            $report = $install->generatePreflightReport();
                            static::$lastPreflightReport = $report;
                        }

                        if (!empty($report['blocking'] ?? [])) {
                            Installer::setError('Upgrade preflight checks failed.');
                            return;
                        }
                    }
                }

                $install($zip);
            } else {
                Installer::install(
                    $zip,
                    GRAV_ROOT,
                    ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $ignores],
                    $folder,
                    $keepFolder
                );

                Cache::clearCache();
            }
        } catch (\Throwable $e) {
            Installer::setError($e->getMessage());
            static::$lastError = $e->getMessage();
        }
    }

    /**
     * Resolve a writable temporary directory, falling back to cache/tmp if tmp://
     * isn't configured.
     */
    private static function getTempDir(): string
    {
        try {
            $tmpDir = Grav::instance()['locator']->findResource('tmp://', true, true);
        } catch (\Exception $e) {
            $tmpDir = Grav::instance()['locator']->findResource('cache://', true, true) . '/tmp';
        }

        return $tmpDir;
    }
}
