<?php

namespace App\Services;

use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use RuntimeException;
use Symfony\Component\Process\Process;

class BackupService
{
    /** @return array{path:string,manifest:array<string,mixed>} */
    public function create(): array
    {
        $root = $this->backupRoot();
        $privateRoot = $this->privateRoot();
        $this->assertSeparateRoots($root, $privateRoot);

        File::ensureDirectoryExists($root, 0700, true);
        $bundle = $root.DIRECTORY_SEPARATOR.now()->utc()->format('Ymd_His').'-'.Str::lower(Str::random(6));
        File::ensureDirectoryExists($bundle, 0700, true);

        try {
            $database = $this->backupDatabase($bundle);
            $privateFiles = $this->backupPrivateFiles($bundle, $privateRoot);
            $manifest = [
                'format_version' => 1,
                'created_at' => now()->utc()->toIso8601String(),
                'application' => (string) config('app.name', 'Microfinance'),
                'environment' => (string) app()->environment(),
                'database' => $database,
                'private_files' => $privateFiles,
            ];

            File::put(
                $bundle.DIRECTORY_SEPARATOR.'manifest.json',
                json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR).PHP_EOL
            );
            @chmod($bundle.DIRECTORY_SEPARATOR.'manifest.json', 0600);
            $this->verify($bundle);
            $this->prune($bundle);

            return ['path' => $bundle, 'manifest' => $manifest];
        } catch (\Throwable $exception) {
            File::deleteDirectory($bundle);
            throw $exception;
        }
    }

    /** @return array{database:array<string,mixed>,private_files:int,bytes:int} */
    public function verify(string $bundle): array
    {
        $bundle = rtrim($bundle, DIRECTORY_SEPARATOR);
        $manifestPath = $bundle.DIRECTORY_SEPARATOR.'manifest.json';
        if (! File::isFile($manifestPath)) {
            throw new RuntimeException('Backup manifest is missing.');
        }

        $manifest = json_decode(File::get($manifestPath), true, flags: JSON_THROW_ON_ERROR);
        if (($manifest['format_version'] ?? null) !== 1 || ! isset($manifest['database']['file'], $manifest['database']['sha256'])) {
            throw new RuntimeException('Backup manifest format is invalid.');
        }

        $databasePath = $bundle.DIRECTORY_SEPARATOR.$manifest['database']['file'];
        $this->assertChecksum($databasePath, (string) $manifest['database']['sha256'], (int) ($manifest['database']['bytes'] ?? -1));

        $count = 0;
        $bytes = 0;
        foreach (($manifest['private_files'] ?? []) as $file) {
            $relative = $this->safeRelativePath((string) ($file['path'] ?? ''));
            $path = $bundle.DIRECTORY_SEPARATOR.'private'.DIRECTORY_SEPARATOR.$relative;
            $size = (int) ($file['bytes'] ?? -1);
            $this->assertChecksum($path, (string) ($file['sha256'] ?? ''), $size);
            $count++;
            $bytes += max(0, $size);
        }

        return [
            'database' => $manifest['database'],
            'private_files' => $count,
            'bytes' => $bytes,
        ];
    }

    /** @return array{driver:string,file:string,bytes:int,sha256:string} */
    private function backupDatabase(string $bundle): array
    {
        $driver = (string) DB::connection()->getDriverName();
        $file = 'database.sql';
        $path = $bundle.DIRECTORY_SEPARATOR.$file;

        if ($driver === 'sqlite') {
            File::put($path, $this->sqliteDump(DB::connection()));
        } elseif (in_array($driver, ['mysql', 'mariadb'], true)) {
            $this->mysqlDump($path);
        } else {
            throw new RuntimeException("Database backup is not configured for driver [{$driver}].");
        }

        @chmod($path, 0600);

        return [
            'driver' => $driver,
            'file' => $file,
            'bytes' => File::size($path),
            'sha256' => hash_file('sha256', $path),
        ];
    }

    private function sqliteDump(Connection $connection): string
    {
        $pdo = $connection->getPdo();
        $tables = $connection->select("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY name");
        $schemaObjects = $connection->select("SELECT type, name, sql FROM sqlite_master WHERE type IN ('index','trigger','view') AND sql IS NOT NULL ORDER BY type, name");

        $sql = "PRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;\n";
        foreach ($tables as $table) {
            $name = (string) $table->name;
            $sql .= (string) $table->sql.";\n";
            $rows = $connection->table($name)->get();
            foreach ($rows as $row) {
                $values = (array) $row;
                $columns = array_map(fn (string $column): string => $this->quoteIdentifier($column), array_keys($values));
                $literals = array_map(fn ($value): string => $this->sqlLiteral($pdo, $value), array_values($values));
                $sql .= 'INSERT INTO '.$this->quoteIdentifier($name).' ('.implode(', ', $columns).') VALUES ('.implode(', ', $literals).');'."\n";
            }
        }
        foreach ($schemaObjects as $object) {
            $sql .= (string) $object->sql.";\n";
        }

        return $sql."COMMIT;\nPRAGMA foreign_keys=ON;\n";
    }

    private function mysqlDump(string $path): void
    {
        $connection = (string) config('database.default');
        $config = (array) config("database.connections.{$connection}");
        $database = (string) ($config['database'] ?? '');
        $username = (string) ($config['username'] ?? '');
        if ($database === '' || $username === '') {
            throw new RuntimeException('MySQL backup requires a database name and username.');
        }

        $command = [
            (string) config('backups.mysqldump_binary', 'mysqldump'),
            '--single-transaction',
            '--routines',
            '--triggers',
            '--events',
            '--host='.(string) ($config['host'] ?? '127.0.0.1'),
            '--port='.(string) ($config['port'] ?? '3306'),
            '--user='.$username,
            $database,
        ];

        $process = new Process($command, null, ['MYSQL_PWD' => (string) ($config['password'] ?? '')]);
        $process->setTimeout(300);
        $process->run();
        if (! $process->isSuccessful()) {
            throw new RuntimeException('mysqldump failed: '.trim($process->getErrorOutput()));
        }

        File::put($path, $process->getOutput());
    }

    /** @return array<int,array{path:string,bytes:int,sha256:string}> */
    private function backupPrivateFiles(string $bundle, string $privateRoot): array
    {
        $targetRoot = $bundle.DIRECTORY_SEPARATOR.'private';
        File::ensureDirectoryExists($targetRoot, 0700, true);
        if (! File::isDirectory($privateRoot)) {
            return [];
        }

        $files = [];
        foreach (File::allFiles($privateRoot) as $source) {
            $relative = ltrim(str_replace('\\', '/', substr($source->getPathname(), strlen(rtrim($privateRoot, DIRECTORY_SEPARATOR)))), '/');
            $relative = $this->safeRelativePath($relative);
            $destination = $targetRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
            File::ensureDirectoryExists(dirname($destination), 0700, true);
            if (! File::copy($source->getPathname(), $destination)) {
                throw new RuntimeException("Unable to back up private file [{$relative}].");
            }
            @chmod($destination, 0600);
            $files[] = [
                'path' => $relative,
                'bytes' => File::size($destination),
                'sha256' => hash_file('sha256', $destination),
            ];
        }

        usort($files, fn (array $a, array $b): int => $a['path'] <=> $b['path']);

        return $files;
    }

    private function prune(string $currentBundle): void
    {
        $cutoff = now()->subDays((int) config('backups.keep_days', 30))->getTimestamp();
        foreach (File::directories($this->backupRoot()) as $directory) {
            if ($directory === $currentBundle) {
                continue;
            }
            if ((File::lastModified($directory) ?: PHP_INT_MAX) < $cutoff) {
                File::deleteDirectory($directory);
            }
        }
    }

    private function assertChecksum(string $path, string $expected, int $expectedSize): void
    {
        if (! File::isFile($path)) {
            throw new RuntimeException("Backup file is missing [{$path}].");
        }
        if ($expectedSize >= 0 && File::size($path) !== $expectedSize) {
            throw new RuntimeException("Backup file size does not match manifest [{$path}].");
        }
        if ($expected === '' || ! hash_equals($expected, hash_file('sha256', $path))) {
            throw new RuntimeException("Backup checksum does not match manifest [{$path}].");
        }
    }

    private function assertSeparateRoots(string $backupRoot, string $privateRoot): void
    {
        $backup = rtrim(str_replace('\\', '/', $backupRoot), '/').'/';
        $private = rtrim(str_replace('\\', '/', $privateRoot), '/').'/';
        if (str_starts_with($backup, $private)) {
            throw new RuntimeException('BACKUP_PATH must be outside the private storage root.');
        }
    }

    private function backupRoot(): string
    {
        return rtrim((string) config('backups.path', storage_path('backups')), DIRECTORY_SEPARATOR);
    }

    private function privateRoot(): string
    {
        return rtrim((string) config('backups.private_root', storage_path('app/private')), DIRECTORY_SEPARATOR);
    }

    private function safeRelativePath(string $path): string
    {
        $path = trim(str_replace('\\', '/', $path), '/');
        if ($path === '' || str_contains($path, "\0") || str_contains($path, '../') || str_starts_with($path, '..')) {
            throw new RuntimeException('Backup contains an unsafe private file path.');
        }

        return $path;
    }

    private function quoteIdentifier(string $identifier): string
    {
        return '"'.str_replace('"', '""', $identifier).'"';
    }

    private function sqlLiteral(\PDO $pdo, mixed $value): string
    {
        if ($value === null) {
            return 'NULL';
        }
        if (is_bool($value)) {
            return $value ? '1' : '0';
        }
        if (is_int($value) || is_float($value)) {
            return (string) $value;
        }

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

        return $quoted;
    }
}
