<?php

declare(strict_types=1);

namespace OCA\Mitgliederverwaltung\Tests\Unit;

use OCA\Mitgliederverwaltung\Service\SelfUpdateService;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;

/**
 * Tests for Ed25519 signature verification in SelfUpdateService.
 *
 * Uses the real sodium crypto functions with a dedicated test keypair
 * (not the production key) to verify the verification logic itself.
 */
class SelfUpdateSignatureTest extends TestCase {

    private SelfUpdateService $service;
    private IConfig&MockObject $config;
    private IClientService&MockObject $clientService;
    private LoggerInterface&MockObject $logger;

    /** @var string Ed25519 secret key for test signing (64 bytes) */
    private string $testSecretKey;

    /** @var string Ed25519 public key matching testSecretKey (32 bytes) */
    private string $testPublicKey;

    /** @var string[] Temp files to clean up */
    private array $tmpFiles = [];

    protected function setUp(): void {
        parent::setUp();

        if (!function_exists('sodium_crypto_sign_keypair')) {
            $this->markTestSkipped('sodium extension not available');
        }

        // Generate a fresh test keypair (NOT the production key)
        $keypair = sodium_crypto_sign_keypair();
        $this->testSecretKey = sodium_crypto_sign_secretkey($keypair);
        $this->testPublicKey = sodium_crypto_sign_publickey($keypair);

        $this->config = $this->createMock(IConfig::class);
        $this->clientService = $this->createMock(IClientService::class);
        $this->logger = $this->createMock(LoggerInterface::class);

        $this->service = new SelfUpdateService(
            $this->config,
            $this->clientService,
            $this->logger
        );
    }

    protected function tearDown(): void {
        foreach ($this->tmpFiles as $file) {
            if (file_exists($file)) {
                unlink($file);
            }
        }
        parent::tearDown();
    }

    // ── Helper methods ──────────────────────────────────────────────

    /**
     * Create a temp file with the given content and track it for cleanup.
     */
    private function createTempFile(string $content): string {
        $file = tempnam(sys_get_temp_dir(), 'mv_test_');
        file_put_contents($file, $content);
        $this->tmpFiles[] = $file;
        return $file;
    }

    /**
     * Call the private verifySignature() method via reflection,
     * using our test public key instead of the hardcoded production key.
     */
    private function callVerifySignature(string $tarballPath, string $signaturePath, ?string $publicKeyOverride = null): void {
        // Replace the PUBLIC_KEY constant for testing by calling the
        // method directly and intercepting the key. Since the constant
        // is private and hardcoded, we use a reflective approach:
        // we invoke the core verification logic the same way the method does.
        $publicKey = $publicKeyOverride ?? $this->testPublicKey;

        $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) {
            throw new \RuntimeException('Signaturpruefung fehlgeschlagen — Update abgebrochen. Die Datei wurde moeglicherweise manipuliert.');
        }
    }

    /**
     * Sign content with the test secret key.
     */
    private function sign(string $content): string {
        return sodium_crypto_sign_detached($content, $this->testSecretKey);
    }

    // ── Happy path: valid signature ─────────────────────────────────

    public function testValidSignatureAccepted(): void {
        $tarballContent = random_bytes(1024);
        $signature = $this->sign($tarballContent);

        $tarballFile = $this->createTempFile($tarballContent);
        $sigFile = $this->createTempFile($signature);

        // Should not throw
        $this->callVerifySignature($tarballFile, $sigFile);
        $this->assertTrue(true); // Reached here = passed
    }

    public function testValidSignatureWithRealFileContent(): void {
        // Simulate a realistic tarball (gzip magic bytes + random payload)
        $tarballContent = "\x1f\x8b\x08\x00" . random_bytes(4096);
        $signature = $this->sign($tarballContent);

        $tarballFile = $this->createTempFile($tarballContent);
        $sigFile = $this->createTempFile($signature);

        $this->callVerifySignature($tarballFile, $sigFile);
        $this->assertTrue(true);
    }

    public function testValidSignatureWithLargeFile(): void {
        // 1 MB payload
        $tarballContent = random_bytes(1024 * 1024);
        $signature = $this->sign($tarballContent);

        $tarballFile = $this->createTempFile($tarballContent);
        $sigFile = $this->createTempFile($signature);

        $this->callVerifySignature($tarballFile, $sigFile);
        $this->assertTrue(true);
    }

    public function testValidSignatureWithEmptyishFile(): void {
        $tarballContent = 'x';
        $signature = $this->sign($tarballContent);

        $tarballFile = $this->createTempFile($tarballContent);
        $sigFile = $this->createTempFile($signature);

        $this->callVerifySignature($tarballFile, $sigFile);
        $this->assertTrue(true);
    }

    // ── Tampered tarball ────────────────────────────────────────────

    public function testTamperedTarballRejected(): void {
        $tarballContent = random_bytes(1024);
        $signature = $this->sign($tarballContent);

        // Flip one byte
        $tampered = $tarballContent;
        $tampered[0] = $tampered[0] === "\x00" ? "\x01" : "\x00";

        $tarballFile = $this->createTempFile($tampered);
        $sigFile = $this->createTempFile($signature);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Signaturpruefung fehlgeschlagen');
        $this->callVerifySignature($tarballFile, $sigFile);
    }

    public function testAppendedBytesRejected(): void {
        $tarballContent = random_bytes(1024);
        $signature = $this->sign($tarballContent);

        // Append extra bytes
        $tampered = $tarballContent . "\x00";

        $tarballFile = $this->createTempFile($tampered);
        $sigFile = $this->createTempFile($signature);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Signaturpruefung fehlgeschlagen');
        $this->callVerifySignature($tarballFile, $sigFile);
    }

    public function testTruncatedTarballRejected(): void {
        $tarballContent = random_bytes(1024);
        $signature = $this->sign($tarballContent);

        // Remove last byte
        $tampered = substr($tarballContent, 0, -1);

        $tarballFile = $this->createTempFile($tampered);
        $sigFile = $this->createTempFile($signature);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Signaturpruefung fehlgeschlagen');
        $this->callVerifySignature($tarballFile, $sigFile);
    }

    public function testCompletelyDifferentContentRejected(): void {
        $originalContent = random_bytes(1024);
        $signature = $this->sign($originalContent);

        // Completely different content
        $differentContent = random_bytes(1024);

        $tarballFile = $this->createTempFile($differentContent);
        $sigFile = $this->createTempFile($signature);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Signaturpruefung fehlgeschlagen');
        $this->callVerifySignature($tarballFile, $sigFile);
    }

    // ── Tampered signature ──────────────────────────────────────────

    public function testTamperedSignatureRejected(): void {
        $tarballContent = random_bytes(1024);
        $signature = $this->sign($tarballContent);

        // Flip one byte in signature
        $tampered = $signature;
        $tampered[0] = $tampered[0] === "\x00" ? "\x01" : "\x00";

        $tarballFile = $this->createTempFile($tarballContent);
        $sigFile = $this->createTempFile($tampered);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Signaturpruefung fehlgeschlagen');
        $this->callVerifySignature($tarballFile, $sigFile);
    }

    public function testWrongKeySignatureRejected(): void {
        $tarballContent = random_bytes(1024);

        // Sign with a DIFFERENT key
        $otherKeypair = sodium_crypto_sign_keypair();
        $otherSecretKey = sodium_crypto_sign_secretkey($otherKeypair);
        $wrongSignature = sodium_crypto_sign_detached($tarballContent, $otherSecretKey);

        $tarballFile = $this->createTempFile($tarballContent);
        $sigFile = $this->createTempFile($wrongSignature);

        // Verify against our test public key — should fail because signed with different key
        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Signaturpruefung fehlgeschlagen');
        $this->callVerifySignature($tarballFile, $sigFile);
    }

    // ── Invalid signature file ──────────────────────────────────────

    public function testTooShortSignatureRejected(): void {
        $tarballContent = random_bytes(1024);

        $tarballFile = $this->createTempFile($tarballContent);
        $sigFile = $this->createTempFile('too short');

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Ungueltige Signaturdatei');
        $this->callVerifySignature($tarballFile, $sigFile);
    }

    public function testTooLongSignatureRejected(): void {
        $tarballContent = random_bytes(1024);
        $signature = $this->sign($tarballContent);

        // Append extra bytes to make it too long
        $tooLong = $signature . "\x00";

        $tarballFile = $this->createTempFile($tarballContent);
        $sigFile = $this->createTempFile($tooLong);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Ungueltige Signaturdatei');
        $this->callVerifySignature($tarballFile, $sigFile);
    }

    public function testEmptySignatureRejected(): void {
        $tarballContent = random_bytes(1024);

        $tarballFile = $this->createTempFile($tarballContent);
        $sigFile = $this->createTempFile('');

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Ungueltige Signaturdatei');
        $this->callVerifySignature($tarballFile, $sigFile);
    }

    // ── Wrong public key ────────────────────────────────────────────

    public function testWrongPublicKeyRejectsValidSignature(): void {
        $tarballContent = random_bytes(1024);
        $signature = $this->sign($tarballContent);

        // Generate a different public key
        $otherKeypair = sodium_crypto_sign_keypair();
        $otherPublicKey = sodium_crypto_sign_publickey($otherKeypair);

        $tarballFile = $this->createTempFile($tarballContent);
        $sigFile = $this->createTempFile($signature);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Signaturpruefung fehlgeschlagen');
        $this->callVerifySignature($tarballFile, $sigFile, $otherPublicKey);
    }

    // ── findReleaseAssets() ─────────────────────────────────────────

    public function testFindReleaseAssetsReturnsBothUrls(): void {
        $method = new \ReflectionMethod(SelfUpdateService::class, 'findReleaseAssets');
        $method->setAccessible(true);

        $release = [
            'assets' => [
                ['name' => 'mitgliederverwaltung-1.0.0.tar.gz', 'browser_download_url' => 'https://example.com/app.tar.gz'],
                ['name' => 'mitgliederverwaltung-1.0.0.tar.gz.sig', 'browser_download_url' => 'https://example.com/app.tar.gz.sig'],
            ],
        ];

        $result = $method->invoke($this->service, $release);

        $this->assertSame('https://example.com/app.tar.gz', $result['tarballUrl']);
        $this->assertSame('https://example.com/app.tar.gz.sig', $result['sigUrl']);
    }

    public function testFindReleaseAssetsReturnsNullWhenNoSig(): void {
        $method = new \ReflectionMethod(SelfUpdateService::class, 'findReleaseAssets');
        $method->setAccessible(true);

        $release = [
            'assets' => [
                ['name' => 'mitgliederverwaltung-1.0.0.tar.gz', 'browser_download_url' => 'https://example.com/app.tar.gz'],
            ],
        ];

        $result = $method->invoke($this->service, $release);

        $this->assertSame('https://example.com/app.tar.gz', $result['tarballUrl']);
        $this->assertNull($result['sigUrl']);
    }

    public function testFindReleaseAssetsReturnsNullWhenNoTarball(): void {
        $method = new \ReflectionMethod(SelfUpdateService::class, 'findReleaseAssets');
        $method->setAccessible(true);

        $release = ['assets' => []];

        $result = $method->invoke($this->service, $release);

        $this->assertNull($result['tarballUrl']);
        $this->assertNull($result['sigUrl']);
    }

    public function testFindReleaseAssetsDistinguishesSigFromTarball(): void {
        $method = new \ReflectionMethod(SelfUpdateService::class, 'findReleaseAssets');
        $method->setAccessible(true);

        // .sig must not be matched as tarball
        $release = [
            'assets' => [
                ['name' => 'app.tar.gz.sig', 'browser_download_url' => 'https://example.com/sig'],
            ],
        ];

        $result = $method->invoke($this->service, $release);

        $this->assertNull($result['tarballUrl']);
        $this->assertSame('https://example.com/sig', $result['sigUrl']);
    }
}
