<?php

namespace App\Services\System;

use App\Models\SystemUpdateModel;
use App\Services\Support\ActivityLogger;
use App\Services\Support\AuditService;
use ZipArchive;

/**
 * Lets a Super Admin apply a new version by uploading a ZIP instead of
 * manually re-uploading files via FTP/hosting file manager. Deliberately
 * scoped to ONLY overwrite files under app/ and public/ — the update
 * package can never touch system/ (the framework itself), writable/
 * (database credentials in .env live one level up, session/cache/logs),
 * or spark. That boundary is what keeps "upload a zip and it updates
 * itself" from also being "upload a zip and read the database password."
 */
class SystemUpdateService
{
    private const MAX_ZIP_SIZE_MB = 100;
    private const ALLOWED_TOP_LEVEL_DIRS = ['app', 'public'];

    private SystemUpdateModel $updates;
    private ActivityLogger $activity;
    private AuditService $audit;

    public function __construct()
    {
        $this->updates  = new SystemUpdateModel();
        $this->activity = new ActivityLogger();
        $this->audit    = new AuditService();
    }

    public function applyUpdate(string $zipPath, string $originalFilename, int $appliedByUserId): array
    {
        if (! is_file($zipPath)) {
            throw new \RuntimeException('Uploaded file not found.');
        }
        if (filesize($zipPath) > self::MAX_ZIP_SIZE_MB * 1024 * 1024) {
            throw new \InvalidArgumentException('Update package exceeds the ' . self::MAX_ZIP_SIZE_MB . 'MB limit.');
        }

        $zip = new ZipArchive();
        if ($zip->open($zipPath) !== true) {
            throw new \InvalidArgumentException('The uploaded file is not a valid ZIP archive.');
        }

        $entryPaths = [];
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $name = $zip->getNameIndex($i);
            $entryPaths[] = $name;

            if (str_contains($name, '..')) {
                $zip->close();
                throw new \InvalidArgumentException("Rejected: suspicious path in archive ({$name}).");
            }

            $topLevel = explode('/', ltrim($name, '/'))[0];
            if ($topLevel !== '' && ! in_array($topLevel, self::ALLOWED_TOP_LEVEL_DIRS, true)) {
                $zip->close();
                throw new \InvalidArgumentException("Rejected: archive contains files outside app/ and public/ ({$name}). Only these two folders can be updated this way.");
            }
        }

        if (empty($entryPaths)) {
            $zip->close();
            throw new \InvalidArgumentException('The archive is empty.');
        }

        $backupPath = $this->createBackup();

        $stagingDir = WRITEPATH . 'updates/staging_' . time();
        mkdir($stagingDir, 0755, true);

        $extracted = $zip->extractTo($stagingDir);
        $zip->close();

        if (! $extracted) {
            $this->recursiveDelete($stagingDir);
            throw new \RuntimeException('Failed to extract the update package.');
        }

        $filesChanged = 0;
        try {
            foreach (self::ALLOWED_TOP_LEVEL_DIRS as $dir) {
                $source = $stagingDir . '/' . $dir;
                if (is_dir($source)) {
                    $filesChanged += $this->copyRecursive($source, ROOTPATH . $dir);
                }
            }
        } catch (\Throwable $e) {
            $this->recursiveDelete($stagingDir);
            $this->updates->insert([
                'applied_by_user_id' => $appliedByUserId,
                'original_filename'  => $originalFilename,
                'backup_path'        => $backupPath,
                'files_changed'      => $filesChanged,
                'status'             => 'failed',
                'notes'              => $e->getMessage(),
                'created_at'         => date('Y-m-d H:i:s'),
            ]);
            throw new \RuntimeException('Update failed partway through: ' . $e->getMessage() . ' A backup was taken before changes started — use Rollback if the site is in a broken state.');
        }

        $this->recursiveDelete($stagingDir);
        @unlink($zipPath);

        $updateId = $this->updates->insert([
            'applied_by_user_id' => $appliedByUserId,
            'original_filename'  => $originalFilename,
            'backup_path'        => $backupPath,
            'files_changed'      => $filesChanged,
            'status'             => 'applied',
            'created_at'         => date('Y-m-d H:i:s'),
        ], true);

        $this->activity->log($appliedByUserId, null, 'super_admin', 'system_update_applied', ['files_changed' => $filesChanged, 'filename' => $originalFilename]);
        $this->audit->record($appliedByUserId, 'system', 'update', $updateId, [], ['files_changed' => $filesChanged]);

        return ['update_id' => $updateId, 'files_changed' => $filesChanged, 'backup_path' => $backupPath];
    }

    public function rollback(int $updateId, int $rolledBackByUserId): void
    {
        $update = $this->updates->find($updateId);
        if (! $update || ! $update['backup_path'] || ! is_file($update['backup_path'])) {
            throw new \RuntimeException('No backup available for this update — cannot roll back automatically.');
        }

        $zip = new ZipArchive();
        if ($zip->open($update['backup_path']) !== true) {
            throw new \RuntimeException('Backup file is corrupted or unreadable.');
        }

        $stagingDir = WRITEPATH . 'updates/rollback_' . time();
        mkdir($stagingDir, 0755, true);
        $zip->extractTo($stagingDir);
        $zip->close();

        foreach (self::ALLOWED_TOP_LEVEL_DIRS as $dir) {
            $source = $stagingDir . '/' . $dir;
            if (is_dir($source)) {
                $this->recursiveDelete(ROOTPATH . $dir);
                $this->copyRecursive($source, ROOTPATH . $dir);
            }
        }
        $this->recursiveDelete($stagingDir);

        $this->updates->update($updateId, ['status' => 'rolled_back']);
        $this->activity->log($rolledBackByUserId, null, 'super_admin', 'system_update_rolled_back', ['update_id' => $updateId]);
    }

    public function history(int $limit = 20): array
    {
        $db = \Config\Database::connect();
        return $db->table('system_updates su')
            ->select('su.*, u.name as applied_by_name')
            ->join('users u', 'u.id = su.applied_by_user_id', 'left')
            ->orderBy('su.created_at', 'DESC')
            ->limit($limit)
            ->get()->getResultArray();
    }

    private function createBackup(): string
    {
        $backupDir = WRITEPATH . 'updates/backups';
        if (! is_dir($backupDir)) {
            mkdir($backupDir, 0755, true);
        }

        $backupPath = $backupDir . '/backup_' . date('Y-m-d_His') . '.zip';
        $zip = new ZipArchive();
        $zip->open($backupPath, ZipArchive::CREATE);

        foreach (self::ALLOWED_TOP_LEVEL_DIRS as $dir) {
            $this->addDirToZip($zip, ROOTPATH . $dir, $dir);
        }

        $zip->close();
        return $backupPath;
    }

    private function addDirToZip(ZipArchive $zip, string $dirPath, string $zipSubDir): void
    {
        if (! is_dir($dirPath)) {
            return;
        }
        $files = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($dirPath, \RecursiveDirectoryIterator::SKIP_DOTS)
        );
        foreach ($files as $file) {
            if ($file->isDir()) {
                continue;
            }
            $relativePath = $zipSubDir . '/' . substr($file->getPathname(), strlen($dirPath) + 1);
            $zip->addFile($file->getPathname(), $relativePath);
        }
    }

    private function copyRecursive(string $source, string $destination): int
    {
        $count = 0;
        if (! is_dir($destination)) {
            mkdir($destination, 0755, true);
        }

        $files = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::SELF_FIRST
        );

        foreach ($files as $file) {
            $relativePath = substr($file->getPathname(), strlen($source) + 1);
            $target = $destination . '/' . $relativePath;

            if ($file->isDir()) {
                if (! is_dir($target)) {
                    mkdir($target, 0755, true);
                }
            } else {
                copy($file->getPathname(), $target);
                $count++;
            }
        }

        return $count;
    }

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