<?php

/*
 * This file is part of Chevereto.
 *
 * (c) Rodolfo Berrios <rodolfo@chevereto.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

/*
Download (auto license):
    php app/upgrading.php
-
Download (with license):
    CHEVERETO_LICENSE_KEY=your_license_key php app/upgrading.php
-
.upgrading/upgrading.lock
It contains the token for upgrade process, must be checked against request.
-
.upgrading/downloading.lock
It exists when the upgrade is downloading the new version.
-
.upgrading/extracting.lock
It exists when the upgrade is extracting the new version.
 */

namespace Chevereto;

use Exception;
use RuntimeException;
use stdClass;
use Throwable;
use ZipArchive;
use function Chevere\Filesystem\directoryForPath;
use function Chevereto\Legacy\getCheveretoEnv;

require_once __DIR__ . '/legacy/load/php-boot.php';

const ZIP_BALL = 'https://chevereto.com/api/download/%tag%';
const LOGGER = __DIR__ . '/.upgrading/process.log';
if (! file_exists(LOGGER)) {
    $loggerDir = dirname(LOGGER);
    directoryForPath($loggerDir)->createIfNotExists();
    touch(LOGGER);
}
ob_start();
ob_implicit_flush(true);
$rootDir = __DIR__ . '/..';
$workingDir = __DIR__ . '/.upgrading';
if (is_file($workingDir)) {
    unlink($workingDir);
}
ini_set('log_errors', true);
ini_set('display_errors', true);
ini_set('error_log', $workingDir . '/error.log');
ignore_user_abort(true);
@set_time_limit(0);
ini_set('default_charset', 'utf-8');
setlocale(LC_ALL, 'en_US.UTF8');
ini_set('output_buffering', 'off');
ini_set('zlib.output_compression', false);
$logProcess = $workingDir . '/process.log';
$lockUpgrading = $workingDir . '/upgrading.lock';
$lockDownloading = $workingDir . '/downloading.lock';
$lockExtracting = $workingDir . '/extracting.lock';
$FileKeyLegacy = $rootDir . '/app/CHEVERETO_LICENSE_KEY';
$fileKey = $rootDir . '/app/CHEVERETO_LICENSE_KEY.php';
if (file_exists($FileKeyLegacy)) {
    $licenseKeyLegacy = file_get_contents($FileKeyLegacy);
    $licenseKeyLegacy = trim($licenseKeyLegacy);
    file_put_contents($fileKey, "<?php return '{$licenseKeyLegacy}';");
    unlink($FileKeyLegacy);
}
if (PHP_SAPI !== 'cli') {
    header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
    echo <<<HTML
    <html><head><style>body {padding: 0.5em;}</style><script>
    function goToUrl(url) {
        window.location.href = url;
    }
    </script></head><body><pre>
    HTML;
}
if (! is_dir($workingDir)) {
    mkdir($workingDir, 0755, true);
}
if (! is_writable($workingDir)) {
    abort('[!] Working dir is not writable', 500);
}
$envFile = __DIR__ . '/env.php';
$env = [];
if (file_exists($envFile)) {
    $env = require $envFile;
}
$env = array_merge(getCheveretoEnv(), $_SERVER, $env);
if (($env['CHEVERETO_SERVICING'] ?? null) === 'docker') {
    abort('[!] This feature is not available when using Docker', 403);
}
if (! class_exists('ZipArchive')) {
    abort('[!] ZipArchive is not available');
}
$licenseKey = $env['CHEVERETO_LICENSE_KEY'] ?? '';
if ($licenseKey === '' && file_exists($fileKey)) {
    $licenseKey = require $fileKey;
}
$return = $_GET['return'] ?? '';
$parseUri = parse_url($_SERVER['REQUEST_URI'] ?? '');
$query = $parseUri['query'] ?? '';
$pathUrl = $parseUri['path'] ?? '';
$rootUrl = rtrim(dirname($pathUrl), '/') . '/';
$actions = ['download', 'extract'];
$filePath = $workingDir . '/' . 'chevereto.zip';
if (PHP_SAPI === 'cli') {
    echo <<<LOGO
          __                        __
     ____/ /  ___ _  _____ _______ / /____
    / __/ _ \/ -_) |/ / -_) __/ -_) __/ _ \
    \__/_//_/\__/|___/\__/_/  \__/\__/\___/


    LOGO;
    $singleStep = true;
    $clear = getopt('c::') ?? null;
    if ($clear) {
        unlinkIfExists($lockUpgrading);
        unlinkIfExists($lockDownloading);
        unlinkIfExists($lockExtracting);
        logger('Locks cleared');
        exit(0);
    }
} else {
    $singleStep = false;
    $action = (string) ($_GET['action'] ?? '');
    $token = (string) ($_GET['token'] ?? '');
    if (! file_exists($lockUpgrading)) {
        abort('[!] Upgrade is not expected', 403);
    }
    $upgradeToken = file_get_contents($lockUpgrading);
    if ($upgradeToken === false) {
        abort('[!] Invalid token file', 403);
    }
    if (! hash_equals($upgradeToken, $token)) {
        abort('[!] Invalid token', 403);
    }
    if (($env['CHEVERETO_CONTEXT'] ?? null) === 'saas') {
        abort('[!] Self-upgrade is not allowed on SaaS context', 403);
    }
    if (! in_array($action, $actions, true)) {
        abort('[!] Provide action=download or action=extract', 400);
    }
}
$upgradeToken ??= time();
if ($singleStep || $action === 'download') {
    if (file_exists($lockDownloading)) {
        abort('[!] Downloading is already in progress', 400);
    }
    logger('Lock downloading process');
    file_put_contents($lockDownloading, $upgradeToken);
    $params['tag'] = '4';
    $params['license'] = $licenseKey;
    if ($params['license'] === '') {
        logger('Using free version [no CHEVERETO_LICENSE_KEY provided]');
    } else {
        logger('Attempt to use licensed version [CHEVERETO_LICENSE_KEY provided]');
    }
    logger(sprintf('About to download Chevereto %s', $params['tag']));

    try {
        $response = downloadAction($workingDir, $params);
    } catch (Throwable $e) {
        logger('Unlock downloading process');
        unlink($lockDownloading);
        abort($e->getMessage(), 400);
    }
    logger($response->message);
    logger('Unlock downloading process');
    unlink($lockDownloading);
    $query = str_replace('action=download', 'action=extract', $query);
    if (PHP_SAPI !== 'cli') {
        $continueUri = $pathUrl . '?' . $query;
        logger('Continue extraction in 3s at... ' . $continueUri);
        sleep(3);
    }
}
if ($singleStep || $action === 'extract') {
    if (PHP_SAPI !== 'cli') {
        echo file_get_contents(LOGGER);
    }
    if (file_exists($lockExtracting)) {
        abort('[!] Extracting is already in progress', 400);
    }
    if (! file_exists($filePath)) {
        abort('[!] Package not downloaded', 400);
    }
    logger('Lock extracting process');
    file_put_contents($lockExtracting, $upgradeToken);

    try {
        $response = extractAction($rootDir, $filePath);
    } catch (Throwable $e) {
        logger('Unlock extracting process');
        unlink($lockExtracting);
        abort($e->getMessage(), $e->getCode());
    }
    logger($response->message);
    unlink($filePath);
    logger('Unlock extracting process');
    unlink($lockExtracting);
    logger('Chevereto filesystem upgraded');
    unlinkIfExists($lockUpgrading);
    $safeResult = false;
    if (passthruEnabled()) {
        logger('Update command passthru');
        $command = $rootDir . '/app/bin/cli -C update';
        $safeResult = passthru($command);
    }
    if ($safeResult === false) {
        logger('Continuing with database update at /update');
        $return = 'update';
    }
    if (PHP_SAPI !== 'cli') {
        $continueUri = $rootUrl . $return;
        logger('Redirecting in 3s...');
        sleep(3);
    }
    unlink(LOGGER);
}
if (PHP_SAPI !== 'cli') {
    echo '</pre></body>';
    if (isset($continueUri)) {
        echo <<<HTML
        <script>goToUrl("{$continueUri}")</script>
        HTML;
    }
    echo '</html>';
}

function logger(string $message): void
{
    $hour = gmdate('H:i:s');
    $message = $hour . ' * ' . $message . PHP_EOL;
    fwrite(fopen('php://output', 'r+'), $message);
    fwrite(fopen(LOGGER, 'a+'), $message);
    ob_flush();
}

function curl(string $url, array $curlOpts = []): object
{
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_AUTOREFERER, 1);
    curl_setopt($ch, CURLOPT_TIMEOUT, 60);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
    curl_setopt($ch, CURLOPT_HEADER, 0);
    curl_setopt($ch, CURLOPT_FAILONERROR, 0);
    curl_setopt($ch, CURLOPT_VERBOSE, 0);
    curl_setopt($ch, CURLOPT_USERAGENT, 'Chevereto Upgrade');
    $fp = false;
    foreach ($curlOpts as $k => $v) {
        if ($k == CURLOPT_FILE) {
            $fp = $v;
        }
        curl_setopt($ch, $k, $v);
    }
    $file_get_contents = curl_exec($ch);
    $transfer = curl_getinfo($ch);
    if (curl_errno($ch)) {
        $curl_error = curl_error($ch);
        curl_close($ch);

        throw new Exception('Curl error ' . $curl_error, 500);
    }
    curl_close($ch);
    $return = new stdClass();
    if (is_resource($fp)) {
        rewind($fp);
        $return->raw = stream_get_contents($fp);
    } else {
        $return->raw = $file_get_contents;
    }
    if (strpos($transfer['content_type'], 'application/json') !== false) {
        $return->json = json_decode($return->raw);
        if (is_resource($fp)) {
            $meta_data = stream_get_meta_data($fp);
            unlink($meta_data['uri']);
        }
    }
    $code = $transfer['http_code'];
    if ($code != 200 && ! isset($return->json)) {
        $return->json = new stdClass();
        $return->json->error = new stdClass();
        $return->json->error->message = 'Error performing HTTP request';
        $return->json->error->code = $code;
    }
    $return->transfer = $transfer;

    return $return;
}

function getFormatBytes($bytes, int $round = 1): string
{
    if (! is_numeric($bytes)) {
        return (string) $bytes;
    }
    if ($bytes < 1000) {
        return "{$bytes} B";
    }
    $units = ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
    foreach ($units as $k => $v) {
        $multiplier = pow(1000, $k + 1);
        $threshold = $multiplier * 1000;
        if ($bytes < $threshold) {
            $size = round($bytes / $multiplier, $round);

            return "{$size} {$v}";
        }
    }
}

function getBytesToMb($bytes, int $round = 2): float
{
    $mb = $bytes / pow(10, 6);
    if ($round) {
        $mb = round($mb, $round);
    }

    return $mb;
}

function downloadFile(string $url, array $params, string $filePath, bool $post = true): object
{
    $fp = fopen($filePath, 'wb+');
    if (! $fp) {
        throw new Exception("Can't open temp file " . $filePath . ' (wb+)');
    }
    $ops = [
        CURLOPT_FILE => $fp,
    ];
    if ($params !== []) {
        $ops[CURLOPT_POSTFIELDS] = http_build_query($params);
    }
    if ($post) {
        $ops[CURLOPT_POST] = true;
    }
    $curl = curl($url, $ops);
    fclose($fp);

    return $curl;
}

function downloadAction(string $workingDir, array $params): Response
{
    $fileBasename = 'chevereto.zip';
    $filePath = $workingDir . '/' . $fileBasename;
    unlinkIfExists($filePath);
    $isPost = false;
    $zipBall = ZIP_BALL;
    $tag = $params['tag'] ?? 'latest';
    $zipBall = str_replace('%tag%', $tag, $zipBall);
    $isPost = true;
    $curl = downloadFile($zipBall, $params, $filePath, $isPost);
    if (isset($curl->json->error)) {
        throw new RuntimeException(
            $curl->json->error->message
            . sprintf(' [%s]', $curl->json->error->code),
            $curl->json->status_code
        );
    }
    if ($curl->transfer['http_code'] !== 200) {
        $error = '[HTTP ' . $curl->transfer['http_code'] . '] ' . $zipBall;

        throw new RuntimeException($error, $curl->transfer['http_code']);
    }
    $fileSize = filesize($filePath);

    return new Response(
        strtr('Downloaded %f (%w @%s)', [
            '%f' => $fileBasename,
            '%w' => getFormatBytes($fileSize),
            '%s' => getBytesToMb($curl->transfer['speed_download']) . 'MB/s.',
        ]),
        [
            'fileBasename' => $fileBasename,
            'filePath' => $filePath,
        ]
    );
}

function extractAction(string $pathTo, string $filePath): Response
{
    if (! file_exists($pathTo) && ! mkdir($pathTo)) {
        throw new Exception(sprintf("Working path %s doesn't exists and can't be created", $pathTo), 500);
    }
    if (! is_readable($pathTo)) {
        throw new Exception(sprintf('Working path %s is not readable', $pathTo), 500);
    }
    if (! is_readable($filePath)) {
        throw new Exception(sprintf("Can't read %s", basename($filePath)), 500);
    }
    $zip = new ZipArchive();
    $timeStart = microtime(true);
    $zipOpen = $zip->open($filePath);
    if ($zipOpen !== true) {
        throw new Exception(strtr("Can't extract %f - %m (ZipArchive #%z)", [
            '%f' => $filePath,
            '%m' => 'ZipArchive ' . $zipOpen . ' error',
            '%z' => $zipOpen,
        ]), 500);
    }
    $numFiles = $zip->numFiles - 1;
    $extraction = $zip->extractTo($pathTo);
    if (! $extraction) {
        throw new Exception('Unable to extract to');
    }
    $zip->close();
    $timeTaken = round(microtime(true) - $timeStart, 2); //
    clearstatcache(true, $pathTo);

    return new Response(
        strtr('Extraction completed for %n files in %ss', [
            '%n' => $numFiles,
            '%s' => $timeTaken,
        ]),
        [
            'numFiles' => $numFiles,
            'timeTaken' => $timeTaken,
        ]
    );
}

function abort(string $message)
{
    logger('[ERROR] ' . $message);
    exit(255);
}

function passthruEnabled(): bool
{
    if (! function_exists('passthru')) {
        return false;
    }
    $disabled = explode(',', ini_get('disable_functions'));

    return ! in_array('passthru', $disabled);
}

function unlinkIfExists(string $file): void
{
    if (! file_exists($file)) {
        return;
    }
    unlink($file);
}

class Response
{
    public string $message;

    public array $data;

    public function __construct(string $message, array $data = [])
    {
        $this->message = $message;
        $this->data = $data;
    }
}
