<?php

declare(strict_types=1);

namespace OCA\Mitgliederverwaltung\Service;

use OCP\Http\Client\IClientService;
use OCP\IConfig;
use Psr\Log\LoggerInterface;

/**
 * Self-update service that checks for and applies updates from Gitea releases.
 *
 * Fetches the latest release from the configured Gitea repository,
 * downloads the tarball asset, and extracts it over the installed app.
 */
class SelfUpdateService {

    private const APP_ID = 'mitgliederverwaltung';
    private const GITEA_BASE = 'https://git.shahondin1624.de';
    private const GITEA_OWNER = 'shahondin1624';
    private const GITEA_REPO = 'Mitgliederverwaltung';

    /** Ed25519 public key for release signature verification (base64-encoded, 32 bytes). */
    private const PUBLIC_KEY = 'PQVDGgPZx7o6fO1GGR26dVtcAupUb519yDpQaz3/61U=';

    private IConfig $config;
    private IClientService $clientService;
    private LoggerInterface $logger;

    public function __construct(
        IConfig $config,
        IClientService $clientService,
        LoggerInterface $logger
    ) {
        $this->config = $config;
        $this->clientService = $clientService;
        $this->logger = $logger;
    }

    /**
     * Get the currently installed app version.
     */
    public function getCurrentVersion(): string {
        return $this->config->getAppValue(self::APP_ID, 'installed_version', '0.0.0');
    }

    /**
     * Check Gitea for the latest release.
     *
     * @return array{available: bool, currentVersion: string, latestVersion: string|null, releaseUrl: string|null, publishedAt: string|null}
     */
    public function checkForUpdate(): array {
        $currentVersion = $this->getCurrentVersion();

        try {
            $release = $this->fetchLatestRelease();
        } catch (\Exception $e) {
            $this->logger->warning('Failed to check for updates', [
                'exception' => $e,
                'app' => self::APP_ID,
            ]);
            $classification = $this->classifyNetworkError($e);
            return [
                'available' => false,
                'currentVersion' => $currentVersion,
                'latestVersion' => null,
                'releaseUrl' => null,
                'publishedAt' => null,
                'error' => $classification['message'],
                'errorType' => $classification['type'],
                'errorDetail' => $e->getMessage(),
                'targetUrl' => self::GITEA_BASE,
            ];
        }

        if ($release === null) {
            return [
                'available' => false,
                'currentVersion' => $currentVersion,
                'latestVersion' => null,
                'releaseUrl' => null,
                'publishedAt' => null,
            ];
        }

        $latestVersion = ltrim($release['tag_name'], 'v');
        $isNewer = version_compare($latestVersion, $currentVersion, '>');
        $assets = $this->findReleaseAssets($release);

        return [
            'available' => $isNewer,
            'currentVersion' => $currentVersion,
            'latestVersion' => $latestVersion,
            'releaseUrl' => $release['html_url'] ?? null,
            'publishedAt' => $release['published_at'] ?? null,
            'releaseName' => $release['name'] ?? null,
            'releaseBody' => $release['body'] ?? null,
            'signed' => $assets['sigUrl'] !== null,
        ];
    }

    /**
     * Download and install the latest release.
     *
     * @param bool $force Install even if version is not newer
     * @return array{success: bool, oldVersion: string, newVersion: string, message: string}
     */
    public function performUpdate(bool $force = false): array {
        $currentVersion = $this->getCurrentVersion();
        $release = $this->fetchLatestRelease();

        if ($release === null) {
            return [
                'success' => false,
                'oldVersion' => $currentVersion,
                'newVersion' => $currentVersion,
                'message' => 'Kein Release auf Gitea gefunden.',
            ];
        }

        $latestVersion = ltrim($release['tag_name'], 'v');

        if (!$force && !version_compare($latestVersion, $currentVersion, '>')) {
            return [
                'success' => false,
                'oldVersion' => $currentVersion,
                'newVersion' => $currentVersion,
                'message' => "Bereits auf dem neuesten Stand (v{$currentVersion}).",
            ];
        }

        // Find release assets (tarball + signature)
        $assets = $this->findReleaseAssets($release);
        if ($assets['tarballUrl'] === null) {
            return [
                'success' => false,
                'oldVersion' => $currentVersion,
                'newVersion' => $latestVersion,
                'message' => 'Kein .tar.gz Asset im Release gefunden.',
            ];
        }
        if ($assets['sigUrl'] === null) {
            return [
                'success' => false,
                'oldVersion' => $currentVersion,
                'newVersion' => $latestVersion,
                'message' => 'Keine Signatur (.sig) im Release gefunden — Update abgelehnt.',
            ];
        }

        // Download both files
        $tmpTarball = $this->downloadAsset($assets['tarballUrl']);
        $tmpSig = null;

        try {
            $tmpSig = $this->downloadAsset($assets['sigUrl'], SODIUM_CRYPTO_SIGN_BYTES);
            return $this->applyVerifiedUpdate($tmpTarball, $tmpSig, $currentVersion, $latestVersion);
        } finally {
            if (file_exists($tmpTarball)) {
                unlink($tmpTarball);
            }
            if ($tmpSig !== null && file_exists($tmpSig)) {
                unlink($tmpSig);
            }
        }
    }

    /**
     * Install a manually-uploaded release archive.
     *
     * Security model identical to {@see performUpdate}: the Ed25519
     * signature must verify against the hardcoded public key before
     * extraction. The only difference is that the bytes come from an
     * authenticated admin's upload instead of a Gitea fetch — so the
     * same trust boundary applies (possession of the private key).
     *
     * @param string $uploadedTarballPath Temp path of the uploaded .tar.gz
     * @param string $uploadedSigPath     Temp path of the uploaded .sig
     * @return array{success: bool, oldVersion: string, newVersion: string, message: string}
     * @throws \RuntimeException on invalid signature / malformed archive
     */
    public function performManualUpdate(string $uploadedTarballPath, string $uploadedSigPath): array {
        $currentVersion = $this->getCurrentVersion();

        // Copy uploads into our own tempnam paths — keeps file-permissions
        // predictable and makes the finally cleanup below unambiguous.
        $tmpTarball = $this->moveUploadToTempWithExtension($uploadedTarballPath, '.tar.gz');
        $tmpSig = null;

        try {
            $tmpSig = $this->moveUploadToTempWithExtension($uploadedSigPath, '.tar.gz.sig');

            if (filesize($tmpSig) !== SODIUM_CRYPTO_SIGN_BYTES) {
                throw new \RuntimeException(
                    'Ungueltige Signaturdatei (erwartet: ' . SODIUM_CRYPTO_SIGN_BYTES
                    . ' Bytes, erhalten: ' . filesize($tmpSig) . ').'
                );
            }
            if (filesize($tmpTarball) < 1024) {
                throw new \RuntimeException('Archiv zu klein — vermutlich kein gueltiges Update-Paket.');
            }
            if (!$this->looksLikeGzip($tmpTarball)) {
                throw new \RuntimeException('Archiv ist keine gueltige .tar.gz Datei.');
            }

            // Peek at the bundled appinfo/info.xml to discover the target version.
            // This is a best-effort read — the signature check below is authoritative.
            $declaredVersion = $this->peekVersionFromArchive($tmpTarball) ?? 'unbekannt';

            return $this->applyVerifiedUpdate($tmpTarball, $tmpSig, $currentVersion, $declaredVersion);
        } finally {
            if (file_exists($tmpTarball)) {
                unlink($tmpTarball);
            }
            if ($tmpSig !== null && file_exists($tmpSig)) {
                unlink($tmpSig);
            }
        }
    }

    /**
     * Verify the signature against the embedded public key, then extract
     * the archive over the installed app and run occ upgrade.
     *
     * This is the shared critical section for both the Gitea-fetch path
     * and the manual-upload path. Verification MUST complete successfully
     * before extraction starts.
     *
     * @return array{success: bool, oldVersion: string, newVersion: string, message: string}
     */
    private function applyVerifiedUpdate(
        string $tarballPath,
        string $signaturePath,
        string $currentVersion,
        string $newVersion
    ): array {
        $this->verifySignature($tarballPath, $signaturePath);

        $this->logger->info('Release signature verified', [
            'version' => $newVersion,
            'app' => self::APP_ID,
        ]);

        $this->extractUpdate($tarballPath);

        $this->logger->info('App updated successfully', [
            'from' => $currentVersion,
            'to' => $newVersion,
            'app' => self::APP_ID,
        ]);

        $occResult = $this->runOccUpgrade();

        $message = "Update von v{$currentVersion} auf v{$newVersion} erfolgreich (Signatur geprueft).";
        if ($occResult['success']) {
            $message .= ' occ upgrade wurde automatisch ausgefuehrt.';
        } else {
            $message .= ' occ upgrade konnte nicht automatisch ausgefuehrt werden: ' . $occResult['error'];
        }

        return [
            'success' => true,
            'oldVersion' => $currentVersion,
            'newVersion' => $newVersion,
            'message' => $message,
            'occUpgrade' => $occResult,
        ];
    }

    /**
     * Move an uploaded file into a uniquely-named temp file carrying the
     * expected extension (PharData requires recognizable extensions).
     */
    private function moveUploadToTempWithExtension(string $source, string $extension): string {
        $tmp = tempnam(sys_get_temp_dir(), 'mv_update_');
        if ($tmp === false) {
            throw new \RuntimeException('Temporaere Datei konnte nicht erstellt werden.');
        }
        $target = $tmp . $extension;
        rename($tmp, $target);
        if (!copy($source, $target)) {
            @unlink($target);
            throw new \RuntimeException('Hochgeladene Datei konnte nicht gelesen werden.');
        }
        chmod($target, 0600);
        return $target;
    }

    /**
     * Sanity-check the leading two bytes for the gzip magic (0x1f 0x8b).
     */
    private function looksLikeGzip(string $path): bool {
        $fh = fopen($path, 'rb');
        if ($fh === false) {
            return false;
        }
        $magic = fread($fh, 2);
        fclose($fh);
        return $magic === "\x1f\x8b";
    }

    /**
     * Best-effort: open the archive read-only and pull the <version> out
     * of appinfo/info.xml so the UI can show "Update auf vX.Y.Z" before
     * committing. Does NOT touch the filesystem outside a temp read.
     */
    private function peekVersionFromArchive(string $tarballPath): ?string {
        try {
            $phar = new \PharData($tarballPath, \FilesystemIterator::SKIP_DOTS);
            foreach (new \RecursiveIteratorIterator($phar) as $file) {
                $sub = $file->getSubPathname();
                if ($sub === self::APP_ID . '/appinfo/info.xml'
                    || $sub === 'appinfo/info.xml'
                ) {
                    $xml = file_get_contents($file->getPathname());
                    if ($xml !== false && preg_match('#<version>([^<]+)</version>#', $xml, $m)) {
                        return trim($m[1]);
                    }
                }
            }
        } catch (\Throwable $e) {
            // Don't block the update on a peek failure — verification is authoritative.
            $this->logger->debug('peekVersionFromArchive failed', [
                'exception' => $e->getMessage(),
                'app' => self::APP_ID,
            ]);
        }
        return null;
    }

    /**
     * Fetch the latest non-draft, non-prerelease from Gitea API.
     */
    private function fetchLatestRelease(): ?array {
        $url = self::GITEA_BASE . '/api/v1/repos/' . self::GITEA_OWNER . '/' . self::GITEA_REPO . '/releases?limit=5';

        $client = $this->clientService->newClient();
        $response = $client->get($url, [
            'timeout' => 15,
            'headers' => ['Accept' => 'application/json'],
        ]);

        $releases = json_decode($response->getBody(), true);
        if (!is_array($releases)) {
            return null;
        }

        // Find latest non-draft, non-prerelease
        foreach ($releases as $release) {
            if (!($release['draft'] ?? false) && !($release['prerelease'] ?? false)) {
                return $release;
            }
        }

        return null;
    }

    /**
     * Find the .tar.gz and .tar.gz.sig asset URLs from a release.
     *
     * @return array{tarballUrl: string|null, sigUrl: string|null}
     */
    private function findReleaseAssets(array $release): array {
        $tarballUrl = null;
        $sigUrl = null;
        $assets = $release['assets'] ?? [];

        foreach ($assets as $asset) {
            $name = strtolower($asset['name'] ?? '');
            $url = $asset['browser_download_url'] ?? null;
            if ($url === null) {
                continue;
            }
            if (str_ends_with($name, '.tar.gz.sig')) {
                $sigUrl = $url;
            } elseif (str_ends_with($name, '.tar.gz')) {
                $tarballUrl = $url;
            }
        }

        return ['tarballUrl' => $tarballUrl, 'sigUrl' => $sigUrl];
    }

    /**
     * Verify Ed25519 signature of a downloaded tarball.
     *
     * @throws \RuntimeException if verification fails
     */
    private function verifySignature(string $tarballPath, string $signaturePath): void {
        $publicKey = base64_decode(self::PUBLIC_KEY, true);
        if ($publicKey === false || strlen($publicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
            throw new \RuntimeException('Ungueltiger eingebetteter oeffentlicher Schluessel.');
        }

        $signature = file_get_contents($signaturePath);
        if ($signature === false || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) {
            throw new \RuntimeException('Ungueltige Signaturdatei (erwartet: ' . SODIUM_CRYPTO_SIGN_BYTES . ' Bytes).');
        }

        $content = file_get_contents($tarballPath);
        if ($content === false) {
            throw new \RuntimeException('Tarball konnte nicht gelesen werden.');
        }

        $valid = sodium_crypto_sign_verify_detached($signature, $content, $publicKey);

        if (!$valid) {
            $this->logger->error('Release signature verification FAILED', [
                'tarball' => basename($tarballPath),
                'app' => self::APP_ID,
            ]);
            throw new \RuntimeException('Signaturpruefung fehlgeschlagen — Update abgebrochen. Die Datei wurde moeglicherweise manipuliert.');
        }
    }

    /**
     * Download an asset to a temp file.
     */
    private function downloadAsset(string $url, int $minSize = 1024): string {
        $tmpFile = tempnam(sys_get_temp_dir(), 'mv_update_');
        if ($tmpFile === false) {
            throw new \RuntimeException('Temporaere Datei konnte nicht erstellt werden.');
        }

        // PharData requires a recognizable extension — derive it from the URL
        $basename = basename(parse_url($url, PHP_URL_PATH) ?: '');
        if (preg_match('/(\\.tar\\.gz(\\.sig)?)$/i', $basename, $m)) {
            $newPath = $tmpFile . $m[1];
            rename($tmpFile, $newPath);
            $tmpFile = $newPath;
        }

        chmod($tmpFile, 0600);

        $client = $this->clientService->newClient();
        $response = $client->get($url, ['timeout' => 120, 'sink' => $tmpFile]);

        if (filesize($tmpFile) < $minSize) {
            unlink($tmpFile);
            throw new \RuntimeException('Download fehlgeschlagen oder Datei zu klein.');
        }

        return $tmpFile;
    }

    /**
     * Extract a tarball over the installed app directory.
     *
     * The tarball is expected to contain a top-level `mitgliederverwaltung/` directory
     * with appinfo/, lib/, templates/, js/, vendor/, img/.
     */
    private function extractUpdate(string $tarballPath): void {
        $appPath = $this->getAppInstallPath();

        // Extract to temp dir first to validate structure
        $tmpDir = sys_get_temp_dir() . '/mv_update_extract_' . uniqid();
        mkdir($tmpDir, 0750, true);

        try {
            // Extract tarball
            $phar = new \PharData($tarballPath);
            $phar->extractTo($tmpDir, null, true);

            // Find the extracted app dir (should be mitgliederverwaltung/)
            $extractedApp = $tmpDir . '/' . self::APP_ID;
            if (!is_dir($extractedApp)) {
                // Maybe it extracted without the wrapper dir
                $extractedApp = $tmpDir;
            }

            // Validate it has appinfo/info.xml
            if (!file_exists($extractedApp . '/appinfo/info.xml')) {
                throw new \RuntimeException('Ungueltiges Update-Paket: appinfo/info.xml fehlt.');
            }

            // Copy each directory over the existing installation
            $dirs = ['appinfo', 'lib', 'templates', 'js', 'vendor', 'img'];
            foreach ($dirs as $dir) {
                $source = $extractedApp . '/' . $dir;
                $target = $appPath . '/' . $dir;
                if (is_dir($source)) {
                    // Remove old dir and replace
                    if (is_dir($target)) {
                        $this->removeDirectory($target);
                    }
                    $this->copyDirectory($source, $target);
                }
            }

            // Copy top-level files if present
            foreach (['LICENSE', 'README.md', 'CHANGELOG.md'] as $file) {
                $source = $extractedApp . '/' . $file;
                if (file_exists($source)) {
                    copy($source, $appPath . '/' . $file);
                }
            }
        } finally {
            $this->removeDirectory($tmpDir);
        }
    }

    /**
     * Determine where the app is installed.
     */
    private function getAppInstallPath(): string {
        // Check custom_apps first (writable), then apps
        $raw = $this->config->getSystemValue('apps_paths', []);
        $paths = is_string($raw) ? json_decode($raw, true) : $raw;

        if (is_array($paths)) {
            foreach ($paths as $pathConfig) {
                if (($pathConfig['writable'] ?? false) === true) {
                    $candidate = $pathConfig['path'] . '/' . self::APP_ID;
                    if (is_dir($candidate)) {
                        return $candidate;
                    }
                }
            }
        }

        // Fallback: standard custom_apps location
        $serverRoot = defined('OC_SERVERROOT') ? OC_SERVERROOT : '/var/www/html';
        $fallback = $serverRoot . '/custom_apps/' . self::APP_ID;
        if (is_dir($fallback)) {
            return $fallback;
        }

        throw new \RuntimeException('App-Installationspfad konnte nicht ermittelt werden.');
    }

    private function removeDirectory(string $dir): void {
        if (!is_dir($dir)) {
            return;
        }
        $items = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::CHILD_FIRST
        );
        foreach ($items as $item) {
            if ($item->isDir()) {
                rmdir($item->getRealPath());
            } else {
                unlink($item->getRealPath());
            }
        }
        rmdir($dir);
    }

    private function copyDirectory(string $source, string $dest): void {
        mkdir($dest, 0750, true);
        $items = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::SELF_FIRST
        );
        foreach ($items as $item) {
            $target = $dest . '/' . $items->getSubPathname();
            if ($item->isDir()) {
                if (!is_dir($target)) {
                    mkdir($target, 0750, true);
                }
            } else {
                copy($item->getRealPath(), $target);
            }
        }
    }

    /**
     * Run occ upgrade after extracting the update.
     *
     * @return array{success: bool, output: string|null, error: string|null}
     */
    /**
     * Classify a network exception into a user-friendly diagnostic hint.
     *
     * @return array{type: string, message: string}
     */
    private function classifyNetworkError(\Throwable $e): array {
        $raw = strtolower($e->getMessage());
        $host = parse_url(self::GITEA_BASE, PHP_URL_HOST) ?: 'Gitea-Server';

        if (str_contains($raw, 'could not resolve host') || str_contains($raw, 'name resolution') || str_contains($raw, 'getaddrinfo')) {
            return [
                'type' => 'dns',
                'message' => "DNS-Aufloesung fuer {$host} fehlgeschlagen. Pruefe DNS des Servers (z. B. Mobilfunk-/Hotspot-DNS blockiert die Domain).",
            ];
        }
        if (str_contains($raw, 'timed out') || str_contains($raw, 'timeout') || str_contains($raw, 'operation timed out')) {
            return [
                'type' => 'timeout',
                'message' => "Zeitueberschreitung beim Verbindungsaufbau zu {$host} (Timeout: 15 s). Bei Mobilfunk/Hotspot ist die Latenz oft zu hoch.",
            ];
        }
        if (str_contains($raw, 'ssl') || str_contains($raw, 'certificate') || str_contains($raw, 'tls')) {
            return [
                'type' => 'tls',
                'message' => "TLS-Fehler bei {$host}. Moegliche Ursache: Captive Portal, HTTPS-Inspection oder abgelaufenes Zertifikat.",
            ];
        }
        if (str_contains($raw, 'connection refused') || str_contains($raw, 'could not connect') || str_contains($raw, 'no route to host') || str_contains($raw, 'network is unreachable')) {
            return [
                'type' => 'network',
                'message' => "Verbindung zu {$host} nicht moeglich. Der Server hat keinen Zugriff auf diese Domain (Firewall, Routing, IPv6-only-Mobilfunk ohne IPv4-Fallback?).",
            ];
        }
        if (preg_match('/\b(4\d\d|5\d\d)\b/', $raw, $m)) {
            return [
                'type' => 'http',
                'message' => "HTTP-Fehler {$m[1]} beim Abruf von {$host}.",
            ];
        }
        if (str_contains($raw, 'json') || str_contains($raw, 'decode')) {
            return [
                'type' => 'parse',
                'message' => "Antwort von {$host} konnte nicht als JSON gelesen werden (evtl. Login-Seite eines Captive Portals).",
            ];
        }
        return [
            'type' => 'unknown',
            'message' => "Update-Pruefung fehlgeschlagen: " . $e->getMessage(),
        ];
    }

    private function runOccUpgrade(): array {
        $serverRoot = defined('OC_SERVERROOT') ? OC_SERVERROOT : '/var/www/html';
        $occPath = $serverRoot . '/occ';

        if (!file_exists($occPath)) {
            return ['success' => false, 'output' => null, 'error' => 'occ nicht gefunden unter ' . $occPath];
        }

        $phpBinary = PHP_BINARY ?: 'php';
        $command = sprintf('%s %s upgrade 2>&1', escapeshellarg($phpBinary), escapeshellarg($occPath));

        $output = [];
        $exitCode = 0;
        exec($command, $output, $exitCode);
        $outputStr = implode("\n", $output);

        $this->logger->info('occ upgrade executed', [
            'exitCode' => $exitCode,
            'output' => $outputStr,
            'app' => self::APP_ID,
        ]);

        // Exit code 0 = success, 3 = "already up to date" (also fine)
        if ($exitCode === 0 || $exitCode === 3) {
            return ['success' => true, 'output' => $outputStr, 'error' => null];
        }

        return ['success' => false, 'output' => $outputStr, 'error' => "occ upgrade beendet mit Exit-Code {$exitCode}"];
    }
}
