<?php

namespace App\Services;

use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use PDO;
use Phar;
use PharData;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use SplFileInfo;
use Throwable;

class SystemBackupService
{
    public function directory(): string
    {
        $directory = storage_path('app/private/backups');
        File::ensureDirectoryExists($directory, 0750, true);

        return $directory;
    }

    public function backups(): Collection
    {
        $files = glob($this->directory().DIRECTORY_SEPARATOR.'microfinance-*') ?: [];

        return collect($files)
            ->filter(fn (string $path): bool => is_file($path) && $this->isAllowedFilename(basename($path)))
            ->map(function (string $path): array {
                $filename = basename($path);

                return [
                    'filename' => $filename,
                    'type' => str_contains($filename, '-full-') ? 'full' : 'database',
                    'size' => (int) (filesize($path) ?: 0),
                    'created_at' => (int) (filemtime($path) ?: 0),
                ];
            })
            ->sortByDesc('created_at')
            ->values();
    }

    public function summary(): array
    {
        $backups = $this->backups();
        $directory = $this->directory();

        return [
            'count' => $backups->count(),
            'total_size' => (int) $backups->sum('size'),
            'latest' => $backups->first(),
            'directory_writable' => is_writable($directory),
            'free_space' => (int) (disk_free_space($directory) ?: 0),
            'archive_supported' => class_exists(PharData::class) && extension_loaded('zlib'),
        ];
    }

    public function create(string $type): array
    {
        if (! in_array($type, ['database', 'full'], true)) {
            throw new RuntimeException('Unsupported backup type.');
        }

        if ($type === 'full' && (! class_exists(PharData::class) || ! extension_loaded('zlib'))) {
            throw new RuntimeException('Full backups require the PHP Phar and zlib extensions.');
        }

        $stamp = now()->format('Ymd-His');
        $suffix = Str::lower(Str::random(6));
        $temporaryDirectory = storage_path('framework/cache/backup-build-'.Str::uuid());
        File::ensureDirectoryExists($temporaryDirectory, 0750, true);
        $databaseDump = $temporaryDirectory.DIRECTORY_SEPARATOR.'database.sql';

        try {
            $this->dumpDatabase($databaseDump);

            if ($type === 'database') {
                $filename = "microfinance-database-{$stamp}-{$suffix}.sql.gz";
                $temporaryArchive = $temporaryDirectory.DIRECTORY_SEPARATOR.$filename;
                $this->gzip($databaseDump, $temporaryArchive);
            } else {
                $filename = "microfinance-full-{$stamp}-{$suffix}.tar.gz";
                $tarPath = $temporaryDirectory.DIRECTORY_SEPARATOR.'microfinance-full.tar';
                $manifestPath = $temporaryDirectory.DIRECTORY_SEPARATOR.'manifest.json';

                File::put($manifestPath, json_encode([
                    'type' => 'full',
                    'created_at' => now()->toIso8601String(),
                    'application' => (string) config('app.name', 'Microfinance'),
                    'environment' => (string) app()->environment(),
                    'database_driver' => DB::connection()->getDriverName(),
                    'includes' => [
                        'database/database.sql',
                        'storage/app/private',
                        'storage/app/public',
                    ],
                    'excludes' => [
                        '.env',
                        'storage/app/private/backups',
                    ],
                ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));

                $archive = new PharData($tarPath);
                $archive->addFile($databaseDump, 'database/database.sql');
                $archive->addFile($manifestPath, 'manifest.json');
                $this->addDirectory($archive, storage_path('app/private'), 'storage/app/private');
                $this->addDirectory($archive, storage_path('app/public'), 'storage/app/public');
                $archive->compress(Phar::GZ);
                unset($archive);

                $temporaryArchive = $tarPath.'.gz';
            }

            $destination = $this->directory().DIRECTORY_SEPARATOR.$filename;
            if (! @rename($temporaryArchive, $destination)) {
                throw new RuntimeException('The backup archive could not be moved into protected storage.');
            }
            @chmod($destination, 0640);

            return [
                'filename' => $filename,
                'type' => $type,
                'size' => (int) (filesize($destination) ?: 0),
                'created_at' => (int) (filemtime($destination) ?: time()),
            ];
        } finally {
            File::deleteDirectory($temporaryDirectory);
        }
    }

    public function resolve(string $filename): string
    {
        if ($filename !== basename($filename) || ! $this->isAllowedFilename($filename)) {
            throw new RuntimeException('Invalid backup file.');
        }

        $path = $this->directory().DIRECTORY_SEPARATOR.$filename;
        if (! is_file($path)) {
            throw new RuntimeException('Backup file not found.');
        }

        return $path;
    }

    public function delete(string $filename): array
    {
        $path = $this->resolve($filename);
        $details = [
            'filename' => basename($path),
            'size' => (int) (filesize($path) ?: 0),
        ];

        if (! @unlink($path)) {
            throw new RuntimeException('The backup could not be deleted.');
        }

        return $details;
    }

    public function prune(int $days): array
    {
        $cutoff = now()->subDays($days)->timestamp;
        $deleted = 0;
        $bytes = 0;

        foreach ($this->backups() as $backup) {
            if ($backup['created_at'] >= $cutoff) {
                continue;
            }

            $path = $this->resolve($backup['filename']);
            $bytes += $backup['size'];
            if (@unlink($path)) {
                $deleted++;
            }
        }

        return ['deleted' => $deleted, 'bytes' => $bytes, 'days' => $days];
    }

    private function dumpDatabase(string $path): void
    {
        $connection = DB::connection();
        $driver = $connection->getDriverName();
        $pdo = $connection->getPdo();
        $handle = fopen($path, 'wb');

        if ($handle === false) {
            throw new RuntimeException('Unable to create the database backup file.');
        }

        try {
            fwrite($handle, '-- Microfinance database backup'.PHP_EOL);
            fwrite($handle, '-- Created: '.now()->toIso8601String().PHP_EOL.PHP_EOL);

            if ($driver === 'mysql') {
                fwrite($handle, "SET FOREIGN_KEY_CHECKS=0;\nSET NAMES utf8mb4;\n\n");
                $tableRows = $connection->select("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'");
                $tables = collect($tableRows)->map(fn ($row) => (string) (array_values((array) $row)[0] ?? ''))->filter();

                foreach ($tables as $table) {
                    $identifier = $this->quoteIdentifier($table, 'mysql');
                    $createRow = (array) $connection->selectOne("SHOW CREATE TABLE {$identifier}");
                    $createSql = (string) (array_values($createRow)[1] ?? '');
                    if ($createSql === '') {
                        continue;
                    }

                    fwrite($handle, "DROP TABLE IF EXISTS {$identifier};\n{$createSql};\n");
                    $this->writeRows($handle, $pdo, $table, 'mysql');
                    fwrite($handle, PHP_EOL);
                }

                fwrite($handle, "SET FOREIGN_KEY_CHECKS=1;\n");

                return;
            }

            if ($driver === 'sqlite') {
                fwrite($handle, "PRAGMA foreign_keys=OFF;\n\n");
                $tables = $connection->select("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name");

                foreach ($tables as $tableRow) {
                    $table = (string) $tableRow->name;
                    $identifier = $this->quoteIdentifier($table, 'sqlite');
                    $createSql = (string) $tableRow->sql;
                    fwrite($handle, "DROP TABLE IF EXISTS {$identifier};\n{$createSql};\n");
                    $this->writeRows($handle, $pdo, $table, 'sqlite');
                    fwrite($handle, PHP_EOL);
                }

                fwrite($handle, "PRAGMA foreign_keys=ON;\n");

                return;
            }

            throw new RuntimeException("Database backups are not implemented for the {$driver} driver.");
        } finally {
            fclose($handle);
        }
    }

    private function writeRows($handle, PDO $pdo, string $table, string $driver): void
    {
        $identifier = $this->quoteIdentifier($table, $driver);
        $statement = $pdo->query("SELECT * FROM {$identifier}");

        if ($statement === false) {
            return;
        }

        while (($row = $statement->fetch(PDO::FETCH_ASSOC)) !== false) {
            if ($row === []) {
                continue;
            }

            $columns = implode(', ', array_map(fn (string $column): string => $this->quoteIdentifier($column, $driver), array_keys($row)));
            $values = implode(', ', array_map(fn ($value): string => $this->sqlValue($pdo, $value), array_values($row)));
            fwrite($handle, "INSERT INTO {$identifier} ({$columns}) VALUES ({$values});\n");
        }
    }

    private function sqlValue(PDO $pdo, mixed $value): string
    {
        if ($value === null) {
            return 'NULL';
        }

        if (is_int($value) || is_float($value)) {
            return (string) $value;
        }

        if (is_bool($value)) {
            return $value ? '1' : '0';
        }

        $quoted = $pdo->quote((string) $value);
        if ($quoted === false) {
            throw new RuntimeException('Unable to encode a database value for backup.');
        }

        return $quoted;
    }

    private function quoteIdentifier(string $identifier, string $driver): string
    {
        return $driver === 'mysql'
            ? '`'.str_replace('`', '``', $identifier).'`'
            : '"'.str_replace('"', '""', $identifier).'"';
    }

    private function gzip(string $source, string $destination): void
    {
        $input = fopen($source, 'rb');
        $output = gzopen($destination, 'wb9');

        if ($input === false || $output === false) {
            if (is_resource($input)) {
                fclose($input);
            }
            throw new RuntimeException('Unable to create the compressed backup archive.');
        }

        try {
            while (! feof($input)) {
                $chunk = fread($input, 1024 * 1024);
                if ($chunk === false) {
                    throw new RuntimeException('Unable to read the database backup while compressing it.');
                }
                gzwrite($output, $chunk);
            }
        } finally {
            fclose($input);
            gzclose($output);
        }
    }

    private function addDirectory(PharData $archive, string $source, string $archivePrefix): void
    {
        if (! is_dir($source)) {
            return;
        }

        $backupDirectory = realpath($this->directory()) ?: $this->directory();
        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS),
            RecursiveIteratorIterator::SELF_FIRST,
        );

        /** @var SplFileInfo $file */
        foreach ($iterator as $file) {
            $pathname = $file->getPathname();
            $real = realpath($pathname) ?: $pathname;

            if ($real === $backupDirectory || str_starts_with($real, $backupDirectory.DIRECTORY_SEPARATOR)) {
                continue;
            }

            if ($file->isLink()) {
                continue;
            }

            $relative = ltrim(str_replace('\\', '/', substr($pathname, strlen($source))), '/');
            if ($relative === '') {
                continue;
            }

            $target = trim($archivePrefix, '/').'/'.$relative;
            if ($file->isDir()) {
                try {
                    $archive->addEmptyDir($target);
                } catch (Throwable) {
                    // The directory may already exist because a child was added first.
                }
            } elseif ($file->isFile()) {
                $archive->addFile($pathname, $target);
            }
        }
    }

    private function isAllowedFilename(string $filename): bool
    {
        return preg_match('/^microfinance-(database|full)-\d{8}-\d{6}-[A-Za-z0-9]{6}\.(sql\.gz|tar\.gz)$/', $filename) === 1;
    }
}
