<?php

namespace Sitecake\Services;

use Sitecake\Http\Request;
use Sitecake\License;
use Sitecake\Sitecake as App;
use ZipArchive;

/**
 * System service — version check, update, rollback.
 *
 * checkUpdate is public (no auth). All other actions require login.
 */
class SystemService extends Service
{
    // ── Auth ──────────────────────────────────────────────────────────────────

    public function isAuthRequired($action): bool
    {
        return $action !== 'checkUpdate';
    }

    // ── Actions ───────────────────────────────────────────────────────────────

    /**
     * GET ?service=_system&action=checkUpdate
     *
     * Proxies the version check to admin.sitecake.com so the editor can use
     * this as a CORS-safe fallback if the direct fetch fails.
     */
    public function checkUpdate(Request $request)
    {
        $url     = 'https://admin.sitecake.com/api/versions/latest';
        $context = stream_context_create(['http' => [
            'timeout'        => 5,
            'ignore_errors'  => true,
        ]]);

        $body = @file_get_contents($url, false, $context);

        if ($body === false) {
            return $this->json($request, [
                'status'  => 1,
                'message' => 'Could not reach update server',
            ]);
        }

        $data = json_decode($body, true);
        if (!is_array($data)) {
            return $this->json($request, [
                'status'  => 1,
                'message' => 'Invalid response from update server',
            ]);
        }

        return $this->json($request, $data);
    }

    /**
     * POST ?service=_system&action=applyUpdate
     *
     * Downloads, backs up current version, and installs the new version.
     * Body params:
     *   version     (string) e.g. "4.1.0"
     *   downloadUrl (string) full URL to the ZIP
     */
    public function applyUpdate(Request $request)
    {
        $version     = trim($request->request->get('version', ''));
        $downloadUrl = trim($request->request->get('downloadUrl', ''));

        if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
            return $this->json($request, ['status' => 1, 'message' => 'Invalid version format']);
        }
        if (empty($downloadUrl)) {
            return $this->json($request, ['status' => 1, 'message' => 'downloadUrl is required']);
        }
        // HIGH-6: Restrict update downloads to the official admin server only
        if (strpos($downloadUrl, 'https://admin.sitecake.com/') !== 0) {
            return $this->json($request, ['status' => 1, 'errMessage' => 'Invalid update source'], 400);
        }

        // ── Request download token — server checks hashes + license ──────────
        [$downloadToken, $tokenError] = $this->requestUpdateToken($version);
        if ($tokenError !== null) {
            return $this->json($request, ['status' => 1, 'errMessage' => $tokenError]);
        }

        // Append server-issued token — download endpoint will 403 without it
        $downloadUrl .= '?token=' . urlencode($downloadToken);

        $siteRoot  = $this->siteRoot();
        $scDir     = $this->sitecakeDir();
        $scDirName = basename($scDir);
        $tempDir   = $siteRoot . '/cms-temp';

        // ── 1. Download ZIP ───────────────────────────────────────────────────
        $zipPath = $tempDir . '/sitecake-' . $version . '-update.zip';
        if (!$this->downloadFile($downloadUrl, $zipPath)) {
            return $this->json($request, ['status' => 1, 'message' => 'Failed to download update']);
        }

        // ── 2. Backup current version ─────────────────────────────────────────
        $currentVersion = SITECAKE_VERSION;
        $currentDir     = $scDir . '/' . $currentVersion;
        if (is_dir($currentDir)) {
            $backupDir = $siteRoot . '/cms-backup/' . $scDirName . '-' . $currentVersion;
            if (!is_dir($backupDir)) {
                $this->copyDir($currentDir, $backupDir);
            }
        }

        // ── 3. Extract ZIP ────────────────────────────────────────────────────
        if (!$this->extractZip($zipPath, $siteRoot)) {
            @unlink($zipPath);
            return $this->json($request, ['status' => 1, 'message' => 'Failed to extract update archive']);
        }
        @unlink($zipPath);

        // ── 4. Switch entry point to new version ──────────────────────────────
        $this->switchTo($siteRoot, $scDirName, $version);

        return $this->json($request, [
            'status'  => 0,
            'version' => $version,
            'message' => 'Update applied. Reload to use new version.',
        ]);
    }

    /**
     * POST ?service=_system&action=rollback
     *
     * Restores a previously backed-up version.
     * Body params:
     *   version (string) version to restore, e.g. "4.0.0"
     */
    public function rollback(Request $request)
    {
        $version = trim($request->request->get('version', ''));

        if (!preg_match('/^\d+\.\d+\.\d+$/', $version)) {
            return $this->json($request, ['status' => 1, 'message' => 'Invalid version format']);
        }

        $siteRoot  = $this->siteRoot();
        $scDir     = $this->sitecakeDir();
        $scDirName = basename($scDir);
        $backupDir = $siteRoot . '/cms-backup/' . $scDirName . '-' . $version;

        if (!is_dir($backupDir)) {
            return $this->json($request, [
                'status'  => 1,
                'message' => "No backup found for version $version",
            ]);
        }

        // Backup current version before overwriting
        $currentVersion = SITECAKE_VERSION;
        $currentDir     = $scDir . '/' . $currentVersion;
        if (is_dir($currentDir)) {
            $currentBackup = $siteRoot . '/cms-backup/' . $scDirName . '-' . $currentVersion;
            if (!is_dir($currentBackup)) {
                $this->copyDir($currentDir, $currentBackup);
            }
        }

        // Restore: copy backup → version dir
        $targetDir = $scDir . '/' . $version;
        if (is_dir($targetDir)) {
            $this->removeDir($targetDir);
        }
        $this->copyDir($backupDir, $targetDir);

        // Switch entry point
        $this->switchTo($siteRoot, $scDirName, $version);

        return $this->json($request, [
            'status'  => 0,
            'version' => $version,
            'message' => "Rolled back to $version. Reload to apply.",
        ]);
    }

    /**
     * GET ?service=_system&action=listBackups
     *
     * Returns a list of versions available for rollback.
     */
    public function listBackups(Request $request)
    {
        $siteRoot  = $this->siteRoot();
        $scDirName = basename($this->sitecakeDir());
        $backupBase = $siteRoot . '/cms-backup/';
        $versions   = [];

        if (is_dir($backupBase)) {
            foreach (glob($backupBase . $scDirName . '-*', GLOB_ONLYDIR) as $dir) {
                $ver = substr(basename($dir), strlen($scDirName) + 1);
                if (preg_match('/^\d+\.\d+\.\d+$/', $ver)) {
                    $versions[] = $ver;
                }
            }
        }

        return $this->json($request, ['status' => 0, 'versions' => $versions]);
    }

    // ── Private helpers ───────────────────────────────────────────────────────

    /**
     * Request a single-use download token from admin.sitecake.com.
     *
     * The server performs two checks before issuing a token:
     *   1. Hash integrity  — SHA256 of three critical files must match the
     *      known-good hashes stored when the version ZIP was uploaded.
     *   2. License validity — non-local domains must have an active license.
     *
     * Fail-CLOSED: server unreachable = no token = update blocked.
     * This is consistent — the ZIP download also requires the server.
     *
     * @return array{0: string|null, 1: string|null}  [token, errorMessage]
     *   Success: [token_string, null]
     *   Failure: [null, error_message_for_user]
     */
    private function requestUpdateToken(string $toVersion): array
    {
        // Hash the three critical files in the CURRENT running install
        $hashes = [];
        $licenseFile        = __DIR__ . '/../License.php';
        $sessionServiceFile = __DIR__ . '/SessionService.php';
        $systemServiceFile  = __FILE__;
        if (file_exists($licenseFile))        $hashes['License.php']        = hash_file('sha256', $licenseFile);
        if (file_exists($sessionServiceFile)) $hashes['SessionService.php'] = hash_file('sha256', $sessionServiceFile);
        if (file_exists($systemServiceFile))  $hashes['SystemService.php']  = hash_file('sha256', $systemServiceFile);

        // Read stored license key if a token file exists
        $licenseKey  = null;
        $licensePath = $this->sitecakeDir() . '/license.php';
        if (file_exists($licensePath)) {
            $data = @include $licensePath;
            $licenseKey = is_array($data) ? ($data['key'] ?? null) : null;
        }

        $payload = json_encode([
            'domain'       => License::currentDomain(),
            'from_version' => SITECAKE_VERSION,
            'to_version'   => $toVersion,
            'license_key'  => $licenseKey,
            'hashes'       => $hashes,
        ]);

        $ctx = stream_context_create(['http' => [
            'method'        => 'POST',
            'header'        => "Content-Type: application/json\r\nContent-Length: " . strlen($payload),
            'content'       => $payload,
            'timeout'       => 10,
            'ignore_errors' => true,
        ]]);

        $body = @file_get_contents('https://admin.sitecake.com/api/request-update-token', false, $ctx);
        if ($body === false) {
            return [null, 'Could not reach the update server. Please check your internet connection and try again.'];
        }

        $response = json_decode($body, true);
        if (!is_array($response)) {
            return [null, 'Unexpected response from update server. Please try again later.'];
        }

        if (($response['status'] ?? 1) !== 0 || empty($response['token'])) {
            return [null, $response['message'] ?? 'Update blocked by the update server. Contact support.'];
        }

        return [$response['token'], null]; // success — token ready to use
    }

    /**
     * Derive the site root from this file's location.
     * __FILE__ = {siteRoot}/{scDir}/{version}/src/Sitecake/Services/SystemService.php
     * Five levels up lands at {siteRoot}.
     */
    private function siteRoot()
    {
        return realpath(dirname(__FILE__) . '/../../../../..');
    }

    /**
     * Returns the directory that holds versioned SiteCake builds.
     * Usually {siteRoot}/sitecake or {siteRoot}/cms (white-label).
     */
    private function sitecakeDir()
    {
        return realpath(dirname(__FILE__) . '/../../../..');
    }

    /**
     * Download a remote file to a local path.
     */
    private function downloadFile($url, $dest)
    {
        if (!is_dir(dirname($dest))) {
            @mkdir(dirname($dest), 0755, true);
        }

        $context = stream_context_create(['http' => [
            'timeout'       => 120,
            'ignore_errors' => true,
        ]]);

        $data = @file_get_contents($url, false, $context);
        if ($data === false) return false;

        return file_put_contents($dest, $data) !== false;
    }

    /**
     * Extract a ZIP archive to a destination directory.
     * Performs path-traversal validation (ZIP Slip protection) before extracting.
     */
    private function extractZip($zipFile, $dest)
    {
        if (!class_exists('ZipArchive')) return false;

        $zip = new ZipArchive();
        if ($zip->open($zipFile) !== true) return false;

        // CRIT-4: ZIP Slip — validate every entry before extraction
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $name = $zip->getNameIndex($i);
            if ($name === false) {
                $zip->close();
                return false;
            }
            // Reject path traversal sequences and null bytes
            if (strpos($name, '..') !== false || strpos($name, "\0") !== false) {
                $zip->close();
                return false;
            }
            // On Windows also reject backslash traversal
            if (DIRECTORY_SEPARATOR === '\\' && strpos($name, '\\') !== false) {
                $zip->close();
                return false;
            }
        }

        $result = $zip->extractTo($dest);
        $zip->close();
        return $result;
    }

    /**
     * Rewrite the entry point file (sitecake.php / admin.php) to use the new version.
     */
    private function switchTo($siteRoot, $scDirName, $version)
    {
        // Regular: sitecake.php
        $entryRegular = $siteRoot . '/sitecake.php';
        if (file_exists($entryRegular)) {
            file_put_contents(
                $entryRegular,
                "<?php include '$scDirName/$version/src/app.php';\n"
            );
        }

        // White-label: admin.php
        $entryAdmin = $siteRoot . '/admin.php';
        if (file_exists($entryAdmin)) {
            file_put_contents(
                $entryAdmin,
                "<?php include '$scDirName/$version/src/app.php';\n"
            );
        }
    }

    /**
     * Recursively copy a directory.
     */
    private function copyDir($src, $dst)
    {
        if (!is_dir($dst)) {
            mkdir($dst, 0755, true);
        }
        foreach (scandir($src) as $item) {
            if ($item === '.' || $item === '..') continue;
            $s = $src . '/' . $item;
            $d = $dst . '/' . $item;
            is_dir($s) ? $this->copyDir($s, $d) : copy($s, $d);
        }
    }

    /**
     * Recursively remove a directory.
     */
    private function removeDir($dir)
    {
        if (!is_dir($dir)) return;
        foreach (scandir($dir) as $item) {
            if ($item === '.' || $item === '..') continue;
            $path = $dir . '/' . $item;
            is_dir($path) ? $this->removeDir($path) : unlink($path);
        }
        rmdir($dir);
    }
}
