<?php

/**
 * ============================================================
 *
 * @project   biiproject
 *
 * @author    Andika Debi Putra
 *
 * @email     andikadebiputra@gmail.com
 *
 * @website   https://biiproject.com
 *
 * @copyright Copyright (c) 2026 Andika Debi Putra
 * @license   Proprietary - All Rights Reserved
 *
 * @version   1.0.0
 *
 * @created   2026-05-01
 * ============================================================
 *
 * Unauthorized copying, modification, distribution, or use
 * of this file is strictly prohibited without prior written
 * permission from the author.
 * ============================================================
 */

namespace App\Services\System;

use App\Models\User;
use App\Notifications\SystemManagementNotification;
use App\Services\Monitoring\MonitoringFormatter;
use App\Services\SystemConfig\SystemConfigService;
use Exception;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
use Spatie\Backup\BackupDestination\Backup;
use Spatie\Backup\BackupDestination\BackupDestinationFactory;
use Spatie\Backup\Config\Config as SpatieConfig;
use Spatie\Backup\Notifications\Notifications\BackupHasFailedNotification;
use Spatie\Backup\Notifications\Notifications\BackupWasSuccessfulNotification;
use Spatie\Backup\Notifications\Notifications\CleanupHasFailedNotification;
use Spatie\Backup\Notifications\Notifications\CleanupWasSuccessfulNotification;
use Spatie\Backup\Notifications\Notifications\HealthyBackupWasFoundNotification;
use Spatie\Backup\Notifications\Notifications\UnhealthyBackupWasFoundNotification;
use Symfony\Component\Process\Process;

class BackupManagementService
{
    protected $configService;

    public function __construct(SystemConfigService $configService)
    {
        $this->configService = $configService;
    }

    /**
     * Apply database settings to Spatie Backup config at runtime.
     *
     * @param  bool  $skipNotifications  Whether to skip setting up notifications (useful for manual UI backups)
     * @param  string|null  $driver  Optional driver to override the database setting
     */
    public function applyDynamicConfig(bool $skipNotifications = false, ?string $driver = null)
    {
        $settings = $this->configService->all();

        // 1. Storage Disks
        $driver = $driver ?? get_setting('backup_db_driver', 'local');
        Config::set('backup.backup.destination.disks', [$driver]);
        Config::set('backup.monitor_backups.0.disks', [$driver]);

        // Dynamic Cloud Storage Configuration
        if ($driver === 'gdrive') {
            Config::set('filesystems.disks.gdrive.clientId', $settings['gdrive_client_id'] ?? '');
            Config::set('filesystems.disks.gdrive.clientSecret', $settings['gdrive_client_secret'] ?? '');
            Config::set('filesystems.disks.gdrive.refreshToken', $settings['gdrive_refresh_token'] ?? '');
            Config::set('filesystems.disks.gdrive.folder', $settings['gdrive_folder'] ?? 'LaravelBackups');
        }

        // 2. Cleanup Policy
        $retention = (int) ($settings['backup_db_retention'] ?? 7);
        Config::set('backup.cleanup.default_strategy.keep_all_backups_for_days', $retention);

        // 3. Encryption
        if (! empty($settings['backup_db_encrypt']) && ! empty($settings['backup_db_encrypt_key'])) {
            Config::set('backup.backup.password', $settings['backup_db_encrypt_key']);
            Config::set('backup.backup.encryption', 'aes256');
        } else {
            Config::set('backup.backup.password', null);
            Config::set('backup.backup.encryption', 'none');
        }

        // 4. Notifications
        $notifyOn = $settings['backup_db_notify_on'] ?? 'failed';
        $notifyTo = $settings['backup_db_notify_to'] ?? '';

        $channels = [];
        if (! empty($notifyTo)) {
            if (filter_var($notifyTo, FILTER_VALIDATE_EMAIL)) {
                $channels[] = 'mail';
                Config::set('backup.notifications.mail.to', $notifyTo);
            } elseif (str_starts_with($notifyTo, 'http')) {
                $channels[] = 'webhook';
                Config::set('backup.notifications.notifications.webhook.url', $notifyTo);
            }
        }

        if ($skipNotifications || $notifyOn === 'none') {
            // Use empty channel arrays per-class (Spatie requires all keys to exist)
            $allClasses = [
                BackupHasFailedNotification::class,
                UnhealthyBackupWasFoundNotification::class,
                CleanupHasFailedNotification::class,
                BackupWasSuccessfulNotification::class,
                HealthyBackupWasFoundNotification::class,
                CleanupWasSuccessfulNotification::class,
            ];
            Config::set('backup.notifications.notifications', array_fill_keys($allClasses, []));
        } else {
            $map = [
                'success' => [BackupWasSuccessfulNotification::class],
                'failed' => [BackupHasFailedNotification::class],
                'both' => [
                    BackupWasSuccessfulNotification::class,
                    BackupHasFailedNotification::class,
                ],
            ];

            $classes = $map[$notifyOn] ?? $map['failed'];
            $newNotifs = [];
            foreach ($classes as $class) {
                $newNotifs[$class] = $channels;
            }
            Config::set('backup.notifications.notifications', $newNotifs);
        }

        // 5. Exclude Tables (Injected via database config)
        $dbDriver = config('database.default');
        if (! empty($settings['backup_db_exclude'])) {
            $excluded = array_map('trim', explode(',', $settings['backup_db_exclude']));
            Config::set("database.connections.{$dbDriver}.dump.exclude_tables", $excluded);
        }

        // 6. DB-Specific Options (mysqldump / pg_dump)
        if ($dbDriver === 'mysql') {
            $extraOptions = ['--hex-blob'];
            Config::set('database.connections.mysql.dump.add_extra_option', implode(' ', $extraOptions));

            // Windows/Laragon mysqldump path fix
            if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
                $candidates = ['C:/laragon/bin/mysql/mysql-8.0.30-winx64/bin'];
                $globMatches = glob('C:/laragon/bin/mysql/*/bin', GLOB_ONLYDIR);
                if ($globMatches) {
                    $candidates = array_merge($candidates, $globMatches);
                }
                foreach ($candidates as $candidate) {
                    if (is_dir($candidate) && file_exists($candidate.'/mysqldump.exe')) {
                        Config::set('database.connections.mysql.dump.dump_binary_path', $candidate);
                        break;
                    }
                }
            }
        } elseif ($dbDriver === 'pgsql') {
            // Postgres specific dump options
            // Spatie pg_dump usually works well with defaults, but we can add path if needed
            if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
                $globMatches = glob('C:/laragon/bin/postgresql/*/bin', GLOB_ONLYDIR);
                if ($globMatches) {
                    Config::set('database.connections.pgsql.dump.dump_binary_path', end($globMatches));
                }
            }
        }
    }

    public function getBackupList(bool $forceConfig = true, bool $forceRefresh = false)
    {
        if ($forceConfig) {
            $this->applyDynamicConfig(true); // Don't need notifications just to list files
        }

        $cacheKey = 'system.backup_list';

        // Helper function for the scan logic
        $scan = function () {
            $config = SpatieConfig::fromArray(config('backup'));
            $backupDestinations = BackupDestinationFactory::createFromArray($config);

            $backups = [];
            foreach ($backupDestinations as $destination) {
                foreach ($destination->backups() as $backup) {
                    $backups[] = [
                        'name' => $backup->path(),
                        'size' => $this->formatBytes($backup->sizeInBytes()),
                        'storage' => $destination->diskName(),
                        'date' => $backup->date()->format('Y-m-d H:i:s'),
                        'timestamp' => $backup->date()->timestamp,
                        'status' => 'Success',
                    ];
                }
            }

            // Sort by date descending
            usort($backups, fn ($a, $b) => $b['timestamp'] <=> $a['timestamp']);

            return $backups;
        };

        if ($forceRefresh) {
            $data = $scan();
            Cache::put($cacheKey, $data, 30);

            return $data;
        }

        return Cache::remember($cacheKey, 30, $scan);
    }

    /**
     * Get storage health statistics.
     */
    public function getStorageStats(?string $driver = null)
    {
        $this->applyDynamicConfig(true, $driver);
        $driver = $driver ?? config('backup.backup.destination.disks.0', 'local');

        $total = 0;
        $free = 0;
        $used = 0;
        $label = 'Local Disk';

        try {
            if ($driver === 'local') {
                $total = @disk_total_space(storage_path('app')) ?: 0;
                $free = @disk_free_space(storage_path('app')) ?: 0;
                $used = $total - $free;
            } else {
                // For cloud, we might not get total/free easily depending on API
                // We'll return just the used size and assume a default total for UI progress (e.g., 15GB for GDrive free)
                $backups = $this->getBackupList(forceRefresh: false);
                foreach ($backups as $b) {
                    if ($b['storage'] === $driver) {
                        $used += $this->parseBytes($b['size']);
                    }
                }
                $label = strtoupper($driver);

                // Estimate total for progress bar visibility
                $total = $driver === 'gdrive' ? (15 * 1024 * 1024 * 1024) : 0;
                $free = max(0, $total - $used);
            }
        } catch (Exception $e) {
            Log::error('Failed to get storage stats: '.$e->getMessage());
        }

        return [
            'label' => $label,
            'driver' => $driver,
            'total' => $this->formatBytes($total),
            'free' => $this->formatBytes($free),
            'used' => $this->formatBytes($used),
            'percentage' => $total > 0 ? round(($used / $total) * 100, 1) : 0,
            'health' => ($total > 0 && ($free / $total) < 0.1) ? 'danger' : 'success',
            'requirements' => $this->checkRequirements(),
        ];
    }

    /**
     * Check if system requirements (binaries) for current driver are met.
     */
    public function checkRequirements()
    {
        $dbDriver = config('database.default');
        $binary = $dbDriver === 'pgsql' ? 'pg_dump' : ($dbDriver === 'mysql' ? 'mysqldump' : null);

        if (! $binary) {
            return ['status' => true];
        }

        $process = new Process(['which', $binary]);
        $process->run();

        $missing = ! $process->isSuccessful();

        return [
            'status' => ! $missing,
            'binary' => $binary,
            'message' => $missing ? __(':bin not found on server. Please install it to enable backups.', ['bin' => $binary]) : null,
        ];
    }

    /**
     * Test connection to a specific storage disk.
     */
    public function testConnection()
    {
        $this->applyDynamicConfig(true);
        $disk = config('backup.backup.destination.disks.0', 'local');

        try {
            $backupName = config('backup.backup.name', 'biiproject');

            // Pre-create directory for Google Drive to avoid reachability check failure
            if ($disk === 'gdrive' && ! Storage::disk($disk)->exists($backupName)) {
                Storage::disk($disk)->makeDirectory($backupName);
            }

            Storage::disk($disk)->files($backupName);

            return [
                'success' => true,
                'message' => __('Successfully connected to :disk storage.', ['disk' => strtoupper($disk)]),
            ];
        } catch (Exception $e) {
            return [
                'success' => false,
                'message' => __('Connection failed: ').$e->getMessage(),
            ];
        }
    }

    private function parseBytes(string $value): float
    {
        return MonitoringFormatter::parseBytes($value);
    }

    /**
     * Create a new backup.
     *
     * Uses a child process instead of Artisan::call() to avoid inheriting the
     * broken TCP/IP socket context from php artisan serve on Windows.
     */
    public function createBackup()
    {
        $phpBinary = PHP_BINARY;
        $artisan = base_path('artisan');

        // Ensure dynamic config is applied in the current process
        $this->applyDynamicConfig(true);

        $disk = config('backup.backup.destination.disks.0', 'local');
        $backupName = config('backup.backup.name', 'biiproject');

        // Pre-create directory for Google Drive to avoid reachability check failure in child process
        if ($disk === 'gdrive') {
            try {
                if (! Storage::disk($disk)->exists($backupName)) {
                    Storage::disk($disk)->makeDirectory($backupName);
                }
            } catch (Exception $e) {
                Log::warning('Failed to pre-create GDrive backup directory: '.$e->getMessage());
            }
        }

        $dbDriver = config('database.default');
        $binaryPath = config("database.connections.{$dbDriver}.dump.dump_binary_path");

        $env = getenv();

        // Prepend binary path to PATH for child process
        if ($binaryPath) {
            $separator = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN' ? ';' : ':';
            $env['PATH'] = $binaryPath.$separator.($env['PATH'] ?? '');
        }

        // Add some basic required env vars if missing
        $env['APP_ENV'] = app()->environment();
        $env['APP_KEY'] = config('app.key');

        $command = array_filter([
            $phpBinary,
            $artisan,
            'backup:run',
            '--only-db',
            '--disable-notifications',
        ]);

        $process = new Process($command, base_path(), $env);
        $process->setTimeout(300); // 5 minutes max

        Log::info('Backup: starting subprocess — '.implode(' ', $command));

        $process->run();

        $stdout = $process->getOutput();
        $stderr = $process->getErrorOutput();

        Log::info('Backup stdout: '.trim($stdout));
        if ($stderr) {
            Log::warning('Backup stderr: '.trim($stderr));
        }

        if (! $process->isSuccessful()) {
            $detail = trim($stderr ?: $stdout ?: 'No output captured.');

            Notification::send(
                User::permission('view notification center')->get(),
                new SystemManagementNotification('Backup Failed', "Database backup failed: {$detail}", 'warning', 'Developer')
            );

            throw new Exception(
                "Backup failed (exit {$process->getExitCode()}): {$detail}"
            );
        }

        Notification::send(
            User::permission('view notification center')->get(),
            new SystemManagementNotification('Backup Successful', 'Automated database backup completed successfully.', 'info', 'Developer')
        );

        // Bust the cache AFTER the file is confirmed written to disk
        Cache::forget('system.backup_list');

        return $stdout;
    }

    /**
     * Restore from a backup file.
     */
    public function restoreBackup($disk, $path)
    {
        $this->applyDynamicConfig();

        if (! Storage::disk($disk)->exists($path)) {
            throw new Exception("Backup file not found on disk: {$disk}");
        }

        // 1. Enable Maintenance Mode to prevent data corruption
        Artisan::call('down', [
            '--refresh' => 15,
            '--secret' => 'restore-mode',
            '--render' => 'errors::503',
        ]);

        try {
            // 2. Download/Copy to temp via Stream (Safer for large files)
            $tempPath = storage_path('app/temp_restore.zip');
            $diskStream = Storage::disk($disk)->readStream($path);
            $localStream = fopen($tempPath, 'w+');

            if (! $diskStream || ! $localStream) {
                throw new Exception('Failed to open streams for restoration.');
            }

            stream_copy_to_stream($diskStream, $localStream);

            fclose($localStream);
            if (is_resource($diskStream)) {
                fclose($diskStream);
            }

            // 3. Extract SQL from Zip (with Zip Slip protection)
            $zip = new \ZipArchive;
            $sqlFile = null;
            $extractBase = realpath(storage_path('app'));
            if ($zip->open($tempPath) === true) {
                for ($i = 0; $i < $zip->numFiles; $i++) {
                    $filename = $zip->getNameIndex($i);
                    if (! str_ends_with($filename, '.sql')) {
                        continue;
                    }
                    // Prevent path traversal
                    $resolvedPath = $extractBase.DIRECTORY_SEPARATOR.ltrim($filename, '/\\');
                    $realResolved = realpath(dirname($resolvedPath));
                    if ($realResolved === false || ! str_starts_with($realResolved, $extractBase)) {
                        $zip->close();
                        throw new Exception("Invalid path in backup archive: {$filename}");
                    }
                    $zip->extractTo($extractBase, $filename);
                    $sqlFile = $extractBase.DIRECTORY_SEPARATOR.$filename;
                    break;
                }
                $zip->close();
            }

            if (! $sqlFile || ! file_exists($sqlFile)) {
                throw new Exception('Could not find SQL file in the backup archive.');
            }

            // 4. Execute Restore using array-based Process with Streamed Input
            $dbDriver = config('database.default');
            $dbConfig = config("database.connections.{$dbDriver}");
            $binPath = config("database.connections.{$dbDriver}.dump.dump_binary_path", '');
            $env = array_merge(getenv(), [
                'SystemRoot' => getenv('SystemRoot') ?: 'C:\WINDOWS',
                'SystemDrive' => getenv('SystemDrive') ?: 'C:',
                'windir' => getenv('windir') ?: 'C:\WINDOWS',
            ]);

            if ($dbDriver === 'mysql') {
                $command = [
                    $binPath ? rtrim($binPath, '/\\').DIRECTORY_SEPARATOR.'mysql' : 'mysql',
                    '--host='.($dbConfig['host'] ?? '127.0.0.1'),
                    '--port='.($dbConfig['port'] ?? '3306'),
                    '--user='.($dbConfig['username'] ?? ''),
                    '--password='.($dbConfig['password'] ?? ''),
                    $dbConfig['database'] ?? '',
                ];
            } elseif ($dbDriver === 'pgsql') {
                $command = [
                    $binPath ? rtrim($binPath, '/\\').DIRECTORY_SEPARATOR.'psql' : 'psql',
                    '--host='.($dbConfig['host'] ?? '127.0.0.1'),
                    '--port='.($dbConfig['port'] ?? '5432'),
                    '--username='.($dbConfig['username'] ?? 'postgres'),
                    '--dbname='.($dbConfig['database'] ?? ''),
                ];
                $env['PGPASSWORD'] = $dbConfig['password'] ?? '';
            } else {
                throw new Exception('Restore currently only supports mysql or pgsql drivers.');
            }

            // High-performance streamed restore: open SQL file and pass the handle to Process
            $sqlHandle = fopen($sqlFile, 'r');
            $process = new Process($command, base_path(), $env, $sqlHandle);
            $process->setTimeout(1200); // 20 minutes for very large DBs
            $process->run();

            if (is_resource($sqlHandle)) {
                fclose($sqlHandle);
            }

            // Cleanup temp files
            @unlink($tempPath);
            @unlink($sqlFile);

            if (! $process->isSuccessful()) {
                throw new Exception('Restore execution failed: '.($process->getErrorOutput() ?: $process->getOutput() ?: 'Check logs.'));
            }

            // 5. Clear Caches & Optimization
            Artisan::call('optimize:clear');

            return true;

        } catch (Exception $e) {
            throw $e;
        } finally {
            // 6. Ensure system comes back UP even if restore failed
            Artisan::call('up');
        }
    }

    protected function formatBytes(int|float $bytes, int $precision = 2): string
    {
        return MonitoringFormatter::bytes($bytes, $precision);
    }
}
