<?php

declare(strict_types=1);

namespace OCA\Mitgliederverwaltung\Service;

use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;

/**
 * Core backup and restore service.
 *
 * Creates JSON snapshots of all mv_* tables plus app settings as a ZIP file.
 * Restore is all-or-nothing: truncate all tables, re-insert in dependency order.
 * Encrypted field values are preserved as ciphertext.
 */
class BackupService {

    private const APP_ID = 'mitgliederverwaltung';
    private const SCHEMA_VERSION = 1;
    private const BACKUP_DIR = 'backups';

    /**
     * All mv_* tables in dependency order (parents before children).
     * Restore inserts in this order; truncate deletes in reverse.
     */
    private const TABLE_RESTORE_ORDER = [
        'mv_stufen',
        'mv_families',
        'mv_fee_rules',
        'mv_lager',
        'mv_saved_queries',
        'mv_permissions',
        'mv_members',
        'mv_addresses',
        'mv_phones',
        'mv_emails',
        'mv_stufe_history',
        'mv_fee_records',
        'mv_lager_stufen',
        'mv_lager_teilnehmer',
        'mv_injuries',
        'mv_injury_involved',
        'mv_sync_queue',
        'mv_calendar_events',
        'mv_contact_cards',
        'mv_audit_log',
    ];

    /**
     * App config keys to include in the backup.
     */
    private const SETTINGS_KEYS = [
        'file_base_path',
        'file_subfolder_pattern',
        'file_auto_create',
        'file_lager_base_path',
        'milestone_thresholds',
        'backup_schedule',
        'backup_retention_count',
    ];

    private IDBConnection $db;
    private IConfig $config;
    private IUserSession $userSession;
    private BackupSettingsService $backupSettings;
    private LoggerInterface $logger;

    public function __construct(
        IDBConnection $db,
        IConfig $config,
        IUserSession $userSession,
        BackupSettingsService $backupSettings,
        LoggerInterface $logger
    ) {
        $this->db = $db;
        $this->config = $config;
        $this->userSession = $userSession;
        $this->backupSettings = $backupSettings;
        $this->logger = $logger;
    }

    /**
     * Create a full backup ZIP containing all tables + settings as JSON.
     *
     * @param string|null $password Optional password for ZIP encryption
     * @param string $triggeredBy 'manual' or 'scheduled'
     * @return array{filename: string, size: int, tables: array<string, int>}
     */
    public function createBackup(?string $password = null, string $triggeredBy = 'manual'): array {
        $timestamp = date('Y-m-d_His');
        $filename = "backup_mitgliederverwaltung_{$timestamp}.zip";

        $tmpFile = tempnam(sys_get_temp_dir(), 'mv_backup_');
        if ($tmpFile === false) {
            throw new \RuntimeException('Temporaere Datei konnte nicht erstellt werden.');
        }
        chmod($tmpFile, 0600);

        try {
            $zip = new \ZipArchive();
            $result = $zip->open($tmpFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
            if ($result !== true) {
                throw new \RuntimeException('ZIP konnte nicht erstellt werden. Fehlercode: ' . $result);
            }

            if ($password !== null && $password !== '') {
                $zip->setPassword($password);
            }

            // Dump each table
            $tableCounts = [];
            foreach (self::TABLE_RESTORE_ORDER as $table) {
                $rows = $this->exportTable($table);
                $tableCounts[$table] = count($rows);
                $json = json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
                $entryName = "tables/{$table}.json";
                $zip->addFromString($entryName, $json);
                if ($password !== null && $password !== '') {
                    $zip->setEncryptionName($entryName, \ZipArchive::EM_AES_256);
                }
            }

            // Settings
            $settings = $this->exportAppSettings();
            $settingsJson = json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
            $zip->addFromString('settings.json', $settingsJson);
            if ($password !== null && $password !== '') {
                $zip->setEncryptionName('settings.json', \ZipArchive::EM_AES_256);
            }

            // Manifest
            $userId = $this->userSession->getUser()?->getUID() ?? 'system';
            $manifest = [
                'appVersion' => $this->config->getAppValue(self::APP_ID, 'installed_version', '0.0.0'),
                'schemaVersion' => self::SCHEMA_VERSION,
                'createdAt' => date('c'),
                'triggeredBy' => $triggeredBy,
                'userId' => $userId,
                'tables' => $tableCounts,
                'settingsIncluded' => true,
                'auditLogIncluded' => true,
            ];
            $manifestJson = json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
            $zip->addFromString('manifest.json', $manifestJson);

            $zip->close();

            // Move to backup storage
            $storageDir = $this->getBackupStoragePath();
            $targetPath = $storageDir . '/' . $filename;
            if (!rename($tmpFile, $targetPath)) {
                throw new \RuntimeException('Backup konnte nicht in den Speicher verschoben werden.');
            }
            chmod($targetPath, 0600);
            $tmpFile = null; // Prevent cleanup

            $this->logger->info('Backup created', [
                'filename' => $filename,
                'triggeredBy' => $triggeredBy,
                'tables' => $tableCounts,
                'app' => self::APP_ID,
            ]);

            return [
                'filename' => $filename,
                'size' => filesize($targetPath),
                'tables' => $tableCounts,
                'createdAt' => $manifest['createdAt'],
            ];
        } finally {
            if ($tmpFile !== null && file_exists($tmpFile)) {
                unlink($tmpFile);
            }
        }
    }

    /**
     * Restore from a backup ZIP. All-or-nothing via transaction.
     *
     * @return array{restoredTables: int, totalRows: int}
     */
    public function restoreBackup(string $filename, ?string $password = null): array {
        $filePath = $this->getBackupStoragePath() . '/' . basename($filename);
        if (!file_exists($filePath)) {
            throw new ValidationException('Backup-Datei nicht gefunden: ' . $filename);
        }

        $zip = new \ZipArchive();
        $result = $zip->open($filePath);
        if ($result !== true) {
            throw new \RuntimeException('ZIP konnte nicht geoeffnet werden. Fehlercode: ' . $result);
        }

        if ($password !== null && $password !== '') {
            $zip->setPassword($password);
        }

        try {
            // Read manifest
            $manifestJson = $zip->getFromName('manifest.json');
            if ($manifestJson === false) {
                throw new ValidationException('Ungueltige Backup-Datei: manifest.json fehlt');
            }
            $manifest = json_decode($manifestJson, true);
            if (!is_array($manifest) || !isset($manifest['schemaVersion'])) {
                throw new ValidationException('Ungueltige manifest.json');
            }
            if ($manifest['schemaVersion'] > self::SCHEMA_VERSION) {
                throw new ValidationException(
                    'Backup-Schema-Version ' . $manifest['schemaVersion'] .
                    ' ist neuer als unterstuetzte Version ' . self::SCHEMA_VERSION
                );
            }

            // Load all table data into memory first to validate before truncating
            $tableData = [];
            foreach (self::TABLE_RESTORE_ORDER as $table) {
                $json = $zip->getFromName("tables/{$table}.json");
                if ($json === false) {
                    // Table may not exist in older backups — skip
                    $tableData[$table] = [];
                    continue;
                }
                $rows = json_decode($json, true);
                if (!is_array($rows)) {
                    throw new ValidationException("Ungueltiges JSON fuer Tabelle {$table}");
                }
                $tableData[$table] = $rows;
            }

            // Load settings
            $settingsJson = $zip->getFromName('settings.json');
            $settings = $settingsJson !== false ? json_decode($settingsJson, true) : null;

            $zip->close();

            // Execute restore in transaction
            $this->db->beginTransaction();
            try {
                // Truncate in reverse dependency order
                $reversed = array_reverse(self::TABLE_RESTORE_ORDER);
                foreach ($reversed as $table) {
                    $this->truncateTable($table);
                }

                // Insert in forward dependency order
                $totalRows = 0;
                $restoredTables = 0;
                foreach (self::TABLE_RESTORE_ORDER as $table) {
                    $rows = $tableData[$table];
                    if (!empty($rows)) {
                        $this->insertRows($table, $rows);
                        $totalRows += count($rows);
                        $restoredTables++;
                    }
                }

                // Restore settings
                if (is_array($settings)) {
                    $this->restoreAppSettings($settings);
                }

                $this->db->commit();
            } catch (\Exception $e) {
                $this->db->rollBack();
                throw $e;
            }

            $this->logger->info('Backup restored', [
                'filename' => $filename,
                'restoredTables' => $restoredTables,
                'totalRows' => $totalRows,
                'app' => self::APP_ID,
            ]);

            return [
                'restoredTables' => $restoredTables,
                'totalRows' => $totalRows,
            ];
        } catch (\Exception $e) {
            if ($zip->count() > 0) {
                $zip->close();
            }
            throw $e;
        }
    }

    /**
     * List all available backup files.
     *
     * @return array[] Each: {filename, size, createdAt, tables}
     */
    public function listBackups(): array {
        $dir = $this->getBackupStoragePath();
        $files = glob($dir . '/backup_mitgliederverwaltung_*.zip');
        if ($files === false) {
            return [];
        }

        $backups = [];
        foreach ($files as $file) {
            $info = $this->getBackupInfoFromFile($file);
            if ($info !== null) {
                $backups[] = $info;
            }
        }

        // Sort newest first
        usort($backups, fn($a, $b) => strcmp($b['createdAt'], $a['createdAt']));

        return $backups;
    }

    /**
     * Get info about a single backup (from manifest).
     */
    public function getBackupInfo(string $filename): ?array {
        $filePath = $this->getBackupStoragePath() . '/' . basename($filename);
        if (!file_exists($filePath)) {
            return null;
        }
        return $this->getBackupInfoFromFile($filePath);
    }

    /**
     * Get the full filesystem path for downloading a backup.
     */
    public function getBackupFilePath(string $filename): string {
        $filePath = $this->getBackupStoragePath() . '/' . basename($filename);
        if (!file_exists($filePath)) {
            throw new ValidationException('Backup-Datei nicht gefunden: ' . $filename);
        }
        return $filePath;
    }

    /**
     * Delete a backup file.
     */
    public function deleteBackup(string $filename): void {
        $filePath = $this->getBackupStoragePath() . '/' . basename($filename);
        if (file_exists($filePath)) {
            unlink($filePath);
        }
    }

    /**
     * Delete oldest backups beyond the configured retention count.
     *
     * @return int Number of deleted backups
     */
    public function rotateBackups(): int {
        $retention = $this->backupSettings->getRetentionCount();
        $backups = $this->listBackups();

        if (count($backups) <= $retention) {
            return 0;
        }

        $toDelete = array_slice($backups, $retention);
        foreach ($toDelete as $backup) {
            $this->deleteBackup($backup['filename']);
        }

        return count($toDelete);
    }

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

    private function getBackupStoragePath(): string {
        $dataDir = $this->config->getSystemValue('datadirectory', '/var/www/html/data');
        $instanceId = $this->config->getSystemValue('instanceid', '');
        $path = $dataDir . '/appdata_' . $instanceId . '/' . self::APP_ID . '/' . self::BACKUP_DIR;

        if (!is_dir($path)) {
            mkdir($path, 0750, true);
        }

        return $path;
    }

    /**
     * Export all rows from a table as an array of associative arrays.
     */
    private function exportTable(string $tableName): array {
        $qb = $this->db->getQueryBuilder();
        $qb->select('*')->from($tableName);
        $result = $qb->executeQuery();
        $rows = $result->fetchAll();
        $result->closeCursor();
        return $rows;
    }

    /**
     * Export all app config keys for this app.
     */
    private function exportAppSettings(): array {
        $settings = [];
        foreach (self::SETTINGS_KEYS as $key) {
            $val = $this->config->getAppValue(self::APP_ID, $key, '');
            if ($val !== '') {
                $settings[$key] = $val;
            }
        }
        return $settings;
    }

    /**
     * Delete all rows from a table.
     */
    private function truncateTable(string $tableName): void {
        $qb = $this->db->getQueryBuilder();
        $qb->delete($tableName);
        $qb->executeStatement();
    }

    /**
     * Batch-insert rows into a table.
     */
    private function insertRows(string $tableName, array $rows): void {
        if (empty($rows)) {
            return;
        }

        $columns = array_keys($rows[0]);

        // Insert in batches for performance
        $batchSize = 200;
        foreach (array_chunk($rows, $batchSize) as $batch) {
            foreach ($batch as $row) {
                $qb = $this->db->getQueryBuilder();
                $qb->insert($tableName);
                foreach ($columns as $col) {
                    $value = $row[$col] ?? null;
                    $qb->setValue($col, $qb->createNamedParameter($value));
                }
                $qb->executeStatement();
            }
        }
    }

    /**
     * Restore app config settings.
     */
    private function restoreAppSettings(array $settings): void {
        foreach ($settings as $key => $value) {
            // Only restore known keys to avoid injecting unexpected config
            if (in_array($key, self::SETTINGS_KEYS, true)) {
                $this->config->setAppValue(self::APP_ID, $key, $value);
            }
        }
    }

    /**
     * Read backup info from a ZIP file's manifest.
     */
    private function getBackupInfoFromFile(string $filePath): ?array {
        $zip = new \ZipArchive();
        if ($zip->open($filePath, \ZipArchive::RDONLY) !== true) {
            return null;
        }

        $manifestJson = $zip->getFromName('manifest.json');
        $zip->close();

        if ($manifestJson === false) {
            return [
                'filename' => basename($filePath),
                'size' => filesize($filePath),
                'createdAt' => date('c', filemtime($filePath)),
                'tables' => [],
            ];
        }

        $manifest = json_decode($manifestJson, true);
        return [
            'filename' => basename($filePath),
            'size' => filesize($filePath),
            'createdAt' => $manifest['createdAt'] ?? date('c', filemtime($filePath)),
            'appVersion' => $manifest['appVersion'] ?? null,
            'schemaVersion' => $manifest['schemaVersion'] ?? null,
            'triggeredBy' => $manifest['triggeredBy'] ?? null,
            'userId' => $manifest['userId'] ?? null,
            'tables' => $manifest['tables'] ?? [],
            'auditLogIncluded' => $manifest['auditLogIncluded'] ?? false,
        ];
    }
}
