<?php

/**
 * DecryptTest.php
 *
 * @since     2026-04-30
 * @category  Library
 * @package   PdfEncrypt
 * @author    Nicola Asuni <info@tecnick.com>
 * @copyright 2011-2026 Nicola Asuni - Tecnick.com LTD
 * @license   https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE.TXT)
 * @link      https://github.com/tecnickcom/tc-lib-pdf-encrypt
 *
 * This file is part of tc-lib-pdf-encrypt software library.
 */

namespace Test;

use Com\Tecnick\Pdf\Encrypt\Decrypt;
use Com\Tecnick\Pdf\Encrypt\Encrypt;

/**
 * Decrypt test
 *
 * Coverage notes (unreachable / untestable defensive guards):
 *   - Decrypt::tryDecryptRecipient() `$tmpIn === false || $tmpOut === false`:
 *     tempnam() failure requires a filesystem-level fault; cannot be reliably
 *     induced in unit tests.
 *   - Decrypt::tryDecryptRecipient() `file_put_contents === false`:
 *     same as above.
 *   - AESnopad::decrypt() `$dec === false`:
 *     openssl_decrypt() cannot return false for well-formed AES-CBC ciphertext
 *     with a valid key; this guard protects against hypothetical extension failures.
 *   - Decrypt::decryptAes() `$dec === false` return '':
 *     same reasoning; openssl_decrypt is called with correct key/iv/cipher.
 *
 * @since     2026-04-30
 * @category  Library
 * @package   PdfEncrypt
 * @author    Nicola Asuni <info@tecnick.com>
 * @copyright 2011-2026 Nicola Asuni - Tecnick.com LTD
 * @license   https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE.TXT)
 * @link      https://github.com/tecnickcom/tc-lib-pdf-encrypt
 */
class DecryptTest extends TestUtil
{
    /** Build a Decrypt object from an Encrypt instance's encryption data. */
    private function decryptFromEncrypt(Encrypt $enc): Decrypt
    {
        return new Decrypt($enc->getEncryptionData());
    }

    // -------------------------------------------------------------------------
    // Mode 2 (AES-128) — standard password authentication
    // -------------------------------------------------------------------------

    public function testAuthenticateUserMode2(): void
    {
        $enc = new Encrypt(true, \md5('file'), 2, ['print'], 'userpass', 'ownerpass');
        $dec = $this->decryptFromEncrypt($enc);
        $this->assertTrue($dec->authenticate('userpass'));
        $this->assertNotEmpty($dec->getDocumentKey());
    }

    public function testAuthenticateOwnerMode2(): void
    {
        $enc = new Encrypt(true, \md5('file'), 2, ['print'], 'userpass', 'ownerpass');
        $dec = $this->decryptFromEncrypt($enc);
        $this->assertTrue($dec->authenticate('ownerpass'));
        $this->assertNotEmpty($dec->getDocumentKey());
    }

    public function testAuthenticateWrongPasswordMode2(): void
    {
        $enc = new Encrypt(true, \md5('file'), 2, ['print'], 'userpass', 'ownerpass');
        $dec = $this->decryptFromEncrypt($enc);
        $this->assertFalse($dec->authenticate('wrongpassword'));
        // Key must remain empty after failed authentication.
        $this->assertSame('', $dec->getDocumentKey());
    }

    // -------------------------------------------------------------------------
    // Mode 3 (AES-256 R5) — standard password authentication
    // -------------------------------------------------------------------------

    public function testAuthenticateUserMode3(): void
    {
        $enc = new Encrypt(true, \md5('file'), 3, ['print'], 'userpass', 'ownerpass');
        $dec = $this->decryptFromEncrypt($enc);
        $this->assertTrue($dec->authenticate('userpass'));
        $this->assertEquals(32, \strlen($dec->getDocumentKey()));
    }

    public function testAuthenticateOwnerMode3(): void
    {
        $enc = new Encrypt(true, \md5('file'), 3, ['print'], 'userpass', 'ownerpass');
        $dec = $this->decryptFromEncrypt($enc);
        $this->assertTrue($dec->authenticate('ownerpass'));
        $this->assertEquals(32, \strlen($dec->getDocumentKey()));
    }

    public function testAuthenticateWrongPasswordMode3(): void
    {
        $enc = new Encrypt(true, \md5('file'), 3, ['print'], 'userpass', 'ownerpass');
        $dec = $this->decryptFromEncrypt($enc);
        $this->assertFalse($dec->authenticate('wrong'));
    }

    // -------------------------------------------------------------------------
    // Mode 4 (AES-256 R6 / PDF 2.0) — standard password authentication
    // -------------------------------------------------------------------------

    public function testAuthenticateUserMode4(): void
    {
        $enc = new Encrypt(true, \md5('file'), 4, ['print'], 'userpass', 'ownerpass');
        $dec = $this->decryptFromEncrypt($enc);
        $this->assertTrue($dec->authenticate('userpass'));
        $this->assertEquals(32, \strlen($dec->getDocumentKey()));
    }

    public function testAuthenticateOwnerMode4(): void
    {
        $enc = new Encrypt(true, \md5('file'), 4, ['print'], 'userpass', 'ownerpass');
        $dec = $this->decryptFromEncrypt($enc);
        $this->assertTrue($dec->authenticate('ownerpass'));
        $this->assertEquals(32, \strlen($dec->getDocumentKey()));
    }

    public function testAuthenticateWrongPasswordMode4(): void
    {
        $enc = new Encrypt(true, \md5('file'), 4, ['print'], 'userpass', 'ownerpass');
        $dec = $this->decryptFromEncrypt($enc);
        $this->assertFalse($dec->authenticate('wrong'));
    }

    // -------------------------------------------------------------------------
    // Modes 0 and 1 (RC4 — deprecated but must still authenticate correctly)
    // -------------------------------------------------------------------------

    public function testAuthenticateUserMode0(): void
    {
        $this->bcAssertUserDeprecationMessageMatches('/RC4 encryption.*deprecated/i', function (): void {
            $enc = new Encrypt(true, \md5('file'), 0, ['print'], 'userpass', 'ownerpass');
            $dec = $this->decryptFromEncrypt($enc);
            $this->assertTrue($dec->authenticate('userpass'));
            $this->assertNotEmpty($dec->getDocumentKey());
        });
    }

    public function testAuthenticateOwnerMode0(): void
    {
        $this->bcAssertUserDeprecationMessageMatches('/RC4 encryption.*deprecated/i', function (): void {
            $enc = new Encrypt(true, \md5('file'), 0, ['print'], 'userpass', 'ownerpass');
            $dec = $this->decryptFromEncrypt($enc);
            $this->assertTrue($dec->authenticate('ownerpass'));
            $this->assertNotEmpty($dec->getDocumentKey());
        });
    }

    public function testAuthenticateUserMode1(): void
    {
        $this->bcAssertUserDeprecationMessageMatches('/RC4 encryption.*deprecated/i', function (): void {
            $enc = new Encrypt(true, \md5('file'), 1, ['print'], 'userpass', 'ownerpass');
            $dec = $this->decryptFromEncrypt($enc);
            $this->assertTrue($dec->authenticate('userpass'));
            $this->assertNotEmpty($dec->getDocumentKey());
        });
    }

    public function testAuthenticateOwnerMode1(): void
    {
        $this->bcAssertUserDeprecationMessageMatches('/RC4 encryption.*deprecated/i', function (): void {
            $enc = new Encrypt(true, \md5('file'), 1, ['print'], 'userpass', 'ownerpass');
            $dec = $this->decryptFromEncrypt($enc);
            $this->assertTrue($dec->authenticate('ownerpass'));
            $this->assertNotEmpty($dec->getDocumentKey());
        });
    }

    // -------------------------------------------------------------------------
    // decryptString round-trips
    // -------------------------------------------------------------------------

    /**
     * RC4 modes are symmetric: encrypt(encrypt(data, key)) = data.
     * The plaintext is recovered exactly (no padding).
     */
    public function testDecryptStringRoundtripMode0(): void
    {
        $this->bcAssertUserDeprecationMessageMatches('/RC4 encryption.*deprecated/i', function (): void {
            $enc = new Encrypt(true, \md5('file'), 0, ['print'], 'alpha', 'beta');
            $plaintext = 'hello world';
            $ciphertext = $enc->encryptString($plaintext, 1);
            $dec = $this->decryptFromEncrypt($enc);
            $this->assertTrue($dec->authenticate('alpha'));
            $this->assertSame($plaintext, $dec->decryptString($ciphertext, 1));
        });
    }

    /**
     * AES-128: IV-prefixed stream; PKCS#7 padding is stripped so the exact
     * plaintext is recovered.
     */
    public function testDecryptStringRoundtripMode2(): void
    {
        $enc = new Encrypt(true, \md5('file'), 2, ['print'], 'alpha', 'beta');
        $plaintext = 'hello world';
        $ciphertext = $enc->encryptString($plaintext, 1);
        $dec = $this->decryptFromEncrypt($enc);
        $this->assertTrue($dec->authenticate('alpha'));
        $this->assertSame($plaintext, $dec->decryptString($ciphertext, 1));
    }

    /**
     * AES-256 R5: full document key used; exact plaintext recovered.
     */
    public function testDecryptStringRoundtripMode3(): void
    {
        $enc = new Encrypt(true, \md5('file'), 3, ['print'], 'alpha', 'beta');
        $plaintext = 'hello world';
        $ciphertext = $enc->encryptString($plaintext, 1);
        $dec = $this->decryptFromEncrypt($enc);
        $this->assertTrue($dec->authenticate('alpha'));
        $this->assertSame($plaintext, $dec->decryptString($ciphertext, 1));
    }

    /**
     * AES-256 R6: same as R5 but with hash2B key derivation.
     */
    public function testDecryptStringRoundtripMode4(): void
    {
        $enc = new Encrypt(true, \md5('file'), 4, ['print'], 'alpha', 'beta');
        $plaintext = 'hello world';
        $ciphertext = $enc->encryptString($plaintext, 1);
        $dec = $this->decryptFromEncrypt($enc);
        $this->assertTrue($dec->authenticate('alpha'));
        $this->assertSame($plaintext, $dec->decryptString($ciphertext, 1));
    }

    /**
     * Block-aligned plaintext (exactly 16 bytes) must round-trip exactly: the
     * PKCS#7 scheme appends a full extra padding block on encryption that must
     * be removed on decryption.
     */
    public function testDecryptStringRoundtripBlockAligned(): void
    {
        foreach ([2, 3, 4] as $mode) {
            $enc = new Encrypt(true, \md5('file'), $mode, ['print'], 'alpha', 'beta');
            $plaintext = \str_repeat('A', 16);
            $ciphertext = $enc->encryptString($plaintext, 7);
            $dec = $this->decryptFromEncrypt($enc);
            $this->assertTrue($dec->authenticate('alpha'));
            $this->assertSame($plaintext, $dec->decryptString($ciphertext, 7), "mode {$mode}");
        }
    }

    /**
     * Empty plaintext must round-trip to an empty string for all AES modes.
     */
    public function testDecryptStringRoundtripEmpty(): void
    {
        foreach ([2, 3, 4] as $mode) {
            $enc = new Encrypt(true, \md5('file'), $mode, ['print'], 'alpha', 'beta');
            $ciphertext = $enc->encryptString('', 3);
            $dec = $this->decryptFromEncrypt($enc);
            $this->assertTrue($dec->authenticate('alpha'));
            $this->assertSame('', $dec->decryptString($ciphertext, 3), "mode {$mode}");
        }
    }

    /**
     * Without authentication the key is empty; decryptString returns data that
     * differs from the original plaintext (garbage decrypt, not the correct value).
     */
    public function testDecryptStringWithoutAuthProducesGarbage(): void
    {
        $enc = new Encrypt(true, \md5('file'), 3, ['print'], 'userpass', 'ownerpass');
        $dec = $this->decryptFromEncrypt($enc);
        // Key is cleared in constructor; without authenticate(), key is empty.
        $ciphertext = $enc->encryptString('hello world', 1);
        $result = $dec->decryptString($ciphertext, 1);
        // Without the correct key the output must differ from the plaintext.
        $this->assertStringNotContainsString('hello world', $result);
    }

    /**
     * decryptString with too-short AES data (≤ 16 bytes) returns empty string.
     */
    public function testDecryptStringAesTooShortData(): void
    {
        $enc = new Encrypt(true, \md5('file'), 3, ['print'], 'alpha', 'beta');
        $dec = $this->decryptFromEncrypt($enc);
        $this->assertTrue($dec->authenticate('alpha'));
        $this->assertSame('', $dec->decryptString(\str_repeat('x', 16), 0));
    }

    // -------------------------------------------------------------------------
    // getDocumentKey after failed/successful authentication
    // -------------------------------------------------------------------------

    public function testGetDocumentKeyAfterFailedAuth(): void
    {
        $enc = new Encrypt(true, \md5('file'), 3, ['print'], 'userpass', 'ownerpass');
        // Store the real key, then construct Decrypt with an overwritten empty key
        $data = $enc->getEncryptionData();
        $data['key'] = '';
        $dec = new Decrypt($data);
        $this->assertFalse($dec->authenticate('wrong'));
        $this->assertSame('', $dec->getDocumentKey());
    }

    // -------------------------------------------------------------------------
    // Public-key mode authentication
    // -------------------------------------------------------------------------

    public function testAuthenticatePublicKeyMode3(): void
    {
        $certPath = __DIR__ . '/data/cert.pem';
        $pubkeys = [['c' => $certPath, 'p' => ['print']]];
        $enc = new Encrypt(true, \md5('file'), 3, ['print'], '', '', $pubkeys);
        $dec = $this->decryptFromEncrypt($enc);
        $this->assertTrue($dec->authenticate('', $certPath));
        $this->assertEquals(32, \strlen($dec->getDocumentKey()));
    }

    public function testAuthenticatePublicKeyMode1(): void
    {
        $this->bcRunIgnoringUserDeprecations(function (): void {
            // Mode 1 pubkey silently promotes mode 0 → 1 (covered elsewhere).
            $certPath = __DIR__ . '/data/cert.pem';
            $pubkeys = [['c' => $certPath, 'p' => ['print']]];
            $enc = new Encrypt(true, \md5('file'), 1, ['print'], '', '', $pubkeys);
            $dec = $this->decryptFromEncrypt($enc);
            $this->assertTrue($dec->authenticate('', $certPath));
            $this->assertNotEmpty($dec->getDocumentKey());
        });
    }

    public function testAuthenticatePublicKeyEmptyPathReturnsFalse(): void
    {
        $certPath = __DIR__ . '/data/cert.pem';
        $pubkeys = [['c' => $certPath, 'p' => ['print']]];
        $enc = new Encrypt(true, \md5('file'), 3, ['print'], '', '', $pubkeys);
        $dec = $this->decryptFromEncrypt($enc);
        $this->assertFalse($dec->authenticate('', ''));
    }

    public function testAuthenticatePublicKeyWrongKeyReturnsFalse(): void
    {
        $certPath = __DIR__ . '/data/cert.pem';
        $pubkeys = [['c' => $certPath, 'p' => ['print']]];
        $enc = new Encrypt(true, \md5('file'), 3, ['print'], '', '', $pubkeys);
        $dec = $this->decryptFromEncrypt($enc);
        // Use the test PHP file as a "wrong" key — openssl_pkcs7_decrypt will fail.
        $this->assertFalse($dec->authenticate('', __FILE__));
    }

    /**
     * Cover the `hex2bin() === false` branch in findDecryptedRecipientSeed().
     *
     * When a Recipients entry contains non-hexadecimal characters, hex2bin()
     * returns false and the entry is skipped via `continue`.  With no valid
     * entries the method returns null and authenticate() returns false.
     */
    public function testAuthenticatePublicKeyInvalidHexRecipientReturnsFalse(): void
    {
        $certPath = __DIR__ . '/data/cert.pem';
        // Manually build an encryptdata array in pubkey mode whose Recipients
        // list contains only a string that is not valid hex (non-hex characters
        // cause hex2bin() to return false).
        $data = [
            'V' => 6,
            'Length' => 256,
            'O' => \str_repeat('x', 32),
            'U' => \str_repeat('x', 48),
            'P' => 0,
            'fileid' => \md5('test'),
            'mode' => 3,
            'pubkey' => true,
            'Recipients' => ['ZZZZINVALID!!'], // hex2bin returns false for non-hex chars
        ];
        $dec = new \Com\Tecnick\Pdf\Encrypt\Decrypt($data);
        $this->assertFalse($dec->authenticate('', $certPath));
    }

    // -------------------------------------------------------------------------
    // AESnopad::decrypt() direct tests
    // -------------------------------------------------------------------------

    public function testAesnopadDecryptRoundtrip32Bytes(): void
    {
        $aesnopad = new \Com\Tecnick\Pdf\Encrypt\Type\AESnopad();
        $key = \str_repeat('k', 32);
        $plaintext = \str_repeat('p', 32); // exact 32-byte payload (e.g. file key)
        $ciphertext = $aesnopad->encrypt($plaintext, $key);
        $decrypted = $aesnopad->decrypt($ciphertext, $key);
        $this->assertSame($plaintext, $decrypted);
    }

    public function testAesnopadDecryptRoundtripAes128(): void
    {
        $aesnopad = new \Com\Tecnick\Pdf\Encrypt\Type\AESnopad();
        $key = \str_repeat('k', 16);
        $plaintext = \str_repeat('p', 16);
        $ivect = \Com\Tecnick\Pdf\Encrypt\Type\AESnopad::IVECT;
        $ciphertext = $aesnopad->encrypt($plaintext, $key, $ivect, 'aes-128-cbc');
        $decrypted = $aesnopad->decrypt($ciphertext, $key, $ivect, 'aes-128-cbc');
        $this->assertSame($plaintext, $decrypted);
    }

    public function testAesnopadDecryptInvalidCipherThrows(): void
    {
        $this->bcExpectException(\Com\Tecnick\Pdf\Encrypt\Exception::class);
        $aesnopad = new \Com\Tecnick\Pdf\Encrypt\Type\AESnopad();
        $aesnopad->decrypt('data', 'key', \Com\Tecnick\Pdf\Encrypt\Type\AESnopad::IVECT, 'des-cbc');
    }
}
