<?php

declare(strict_types=1);

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

namespace Test\Cms;

use Com\Tecnick\Pdf\Sign\Cms\Asn1;
use Com\Tecnick\Pdf\Sign\Cms\Builder;
use Com\Tecnick\Pdf\Sign\Exception;
use OpenSSLAsymmetricKey;
use PHPUnit\Framework\TestCase;

/**
 * CMS Builder Test
 *
 * @since     2026-07-15
 * @category  Library
 * @package   PdfSign
 * @author    Nicola Asuni <info@tecnick.com>
 * @copyright 2026 Nicola Asuni - Tecnick.com LTD
 * @license   https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
 * @link      https://github.com/tecnickcom/tc-lib-pdf-sign
 */
class BuilderTest extends TestCase
{
    private const SIGNING_TIME = 1_700_000_000;

    private Asn1 $asn1;

    protected function setUp(): void
    {
        $this->asn1 = new Asn1();
    }

    public function testSignRsaSha256ProducesVerifiableCms(): void
    {
        $cred = $this->makeCredential('rsa');
        $data = 'The quick brown fox jumps over the lazy dog.';

        $builder = new Builder($this->asn1);
        $cms = $builder->sign($data, $cred['cert_der'], $cred['key'], [], 'sha256', self::SIGNING_TIME);

        $parts = $this->parseSignerInfo($cms);
        $this->assertSame(0xA0, $parts['signed_attrs']['tag']);
        $this->assertSame(0x04, $parts['signature']['tag']);
        $this->assertSame(0xA0, $parts['certificates']['tag']);
        $this->assertStringContainsString($cred['cert_der'], $parts['certificates']['value']);

        // Cryptographically verify the signature over the DER SET OF signed attributes.
        $this->assertVerifies($parts, $cred['cert_pem'], OPENSSL_ALGO_SHA256);

        // content-type present and equal to id-data.
        $contentType = $this->attributeValue($parts['signed_attrs']['value'], '1.2.840.113549.1.9.3');
        $this->assertNotNull($contentType);
        $this->assertSame($this->asn1->encodeObjectIdentifier('1.2.840.113549.1.7.1'), $contentType['raw']);

        // signing-time is a UTCTime for a 2023 timestamp.
        $signingTime = $this->attributeValue($parts['signed_attrs']['value'], '1.2.840.113549.1.9.5');
        $this->assertNotNull($signingTime);
        $this->assertSame(0x17, $signingTime['tag']);

        // message-digest equals SHA-256 of the content.
        $messageDigest = $this->attributeValue($parts['signed_attrs']['value'], '1.2.840.113549.1.9.4');
        $this->assertNotNull($messageDigest);
        $this->assertSame(\hash('sha256', $data, true), $messageDigest['value']);

        // signing-certificate-v2 carries the SHA-256 hash of the signer certificate;
        // for SHA-256 the ESSCertIDv2 hashAlgorithm is omitted so certHash is first.
        $certHash = $this->firstCertHash('1.2.840.113549.1.9.16.2.47', $parts['signed_attrs']['value']);
        $this->assertSame(0x04, $certHash['tag']);
        $this->assertSame(\hash('sha256', $cred['cert_der'], true), $certHash['value']);
    }

    public function testSignOmitsSigningTimeForPadesBaseline(): void
    {
        $cred = $this->makeCredential('rsa');
        $data = 'PAdES-BASELINE forbids the CMS signing-time attribute.';

        $builder = new Builder($this->asn1);
        // includeSigningTime = false: the PAdES-BASELINE case, where the signing time
        // is carried by the /M signature dictionary entry rather than the CMS.
        $cms = $builder->sign($data, $cred['cert_der'], $cred['key'], [], 'sha256', self::SIGNING_TIME, null, false);

        $parts = $this->parseSignerInfo($cms);
        // The signature still verifies over the (smaller) DER SET OF signed attributes.
        $this->assertVerifies($parts, $cred['cert_pem'], OPENSSL_ALGO_SHA256);

        // signing-time (1.2.840.113549.1.9.5) is absent.
        $this->assertNull($this->attributeValue($parts['signed_attrs']['value'], '1.2.840.113549.1.9.5'));

        // The other mandatory signed attributes remain present.
        $this->assertNotNull($this->attributeValue($parts['signed_attrs']['value'], '1.2.840.113549.1.9.3'));
        $this->assertNotNull($this->attributeValue($parts['signed_attrs']['value'], '1.2.840.113549.1.9.4'));
        $this->assertNotNull($this->attributeValue($parts['signed_attrs']['value'], '1.2.840.113549.1.9.16.2.47'));
    }

    public function testSignEcSha256ProducesVerifiableCms(): void
    {
        $cred = $this->makeCredential('ec');
        $data = 'elliptic-curve payload';

        $builder = new Builder($this->asn1);
        $cms = $builder->sign($data, $cred['cert_der'], $cred['key'], [], 'sha256', self::SIGNING_TIME);

        $parts = $this->parseSignerInfo($cms);
        $this->assertVerifies($parts, $cred['cert_pem'], OPENSSL_ALGO_SHA256);
    }

    public function testSignRsaSha384IncludesEssCertHashAlgorithm(): void
    {
        $cred = $this->makeCredential('rsa');
        $builder = new Builder($this->asn1);
        $cms = $builder->sign('data', $cred['cert_der'], $cred['key'], [], 'sha384', self::SIGNING_TIME);

        $parts = $this->parseSignerInfo($cms);
        $this->assertVerifies($parts, $cred['cert_pem'], OPENSSL_ALGO_SHA384);

        // For a non-default digest, ESSCertIDv2 begins with the hashAlgorithm SEQUENCE.
        $scv2 = $this->attributeValue($parts['signed_attrs']['value'], '1.2.840.113549.1.9.16.2.47');
        $this->assertNotNull($scv2);
        $certsOffset = 0;
        $certs = $this->asn1->readTlv($scv2['value'], $certsOffset);
        $essOffset = 0;
        $ess = $this->asn1->readTlv($certs['value'], $essOffset);
        $firstOffset = 0;
        $first = $this->asn1->readTlv($ess['value'], $firstOffset);
        $this->assertSame(0x30, $first['tag']);
    }

    public function testSignRsaSha512ProducesVerifiableCms(): void
    {
        $cred = $this->makeCredential('rsa');
        $builder = new Builder($this->asn1);
        $cms = $builder->sign('data', $cred['cert_der'], $cred['key'], [], 'sha512', self::SIGNING_TIME);

        $parts = $this->parseSignerInfo($cms);
        $this->assertVerifies($parts, $cred['cert_pem'], OPENSSL_ALGO_SHA512);
    }

    public function testSignEmbedsChainCertificates(): void
    {
        $cred = $this->makeCredential('rsa');
        $chainDer = $this->pemToDer((string) \file_get_contents(__DIR__ . '/../data/ocsp_ca.pem'));

        $builder = new Builder($this->asn1);
        $cms = $builder->sign('data', $cred['cert_der'], $cred['key'], [$chainDer], 'sha256', self::SIGNING_TIME);

        $parts = $this->parseSignerInfo($cms);
        $this->assertStringContainsString($cred['cert_der'], $parts['certificates']['value']);
        $this->assertStringContainsString($chainDer, $parts['certificates']['value']);
    }

    public function testSignWithoutTimestampHasNoUnsignedAttributes(): void
    {
        $cred = $this->makeCredential('rsa');
        $builder = new Builder($this->asn1);
        $cms = $builder->sign('data', $cred['cert_der'], $cred['key'], [], 'sha256', self::SIGNING_TIME);

        $parts = $this->parseSignerInfo($cms);
        $this->assertNull($parts['unsigned_attrs']);
    }

    public function testSignEmbedsSignatureTimestampUnsignedAttribute(): void
    {
        $cred = $this->makeCredential('rsa');
        $token = $this->asn1->encodeSequence($this->asn1->encodeOctetString('fake-rfc3161-token'));

        $captured = '';
        $provider = static function (string $signature) use (&$captured, $token): string {
            $captured = $signature;
            return $token;
        };

        $builder = new Builder($this->asn1);
        $cms = $builder->sign('data', $cred['cert_der'], $cred['key'], [], 'sha256', self::SIGNING_TIME, $provider);

        $parts = $this->parseSignerInfo($cms);
        // The signature is cryptographically unchanged by the added unsigned attribute.
        $this->assertVerifies($parts, $cred['cert_pem'], OPENSSL_ALGO_SHA256);

        // The provider timestamps the raw SignerInfo signature bytes.
        $this->assertSame($parts['signature']['value'], $captured);

        // unsignedAttrs is a [1] IMPLICIT context tag carrying id-aa-signatureTimeStampToken.
        $this->assertNotNull($parts['unsigned_attrs']);
        $this->assertSame(0xA1, $parts['unsigned_attrs']['tag']);

        $tstValue = $this->attributeValue($parts['unsigned_attrs']['value'], '1.2.840.113549.1.9.16.2.14');
        $this->assertNotNull($tstValue);
        $this->assertSame($token, $tstValue['raw']);
    }

    public function testSignRejectsEmptySignatureTimestampToken(): void
    {
        $cred = $this->makeCredential('rsa');
        $provider = static fn(): string => '';

        $builder = new Builder($this->asn1);
        $this->expectException(Exception::class);
        $builder->sign('data', $cred['cert_der'], $cred['key'], [], 'sha256', self::SIGNING_TIME, $provider);
    }

    public function testSignUsesGeneralizedTimeForFarFuture(): void
    {
        $cred = $this->makeCredential('rsa');
        $builder = new Builder($this->asn1);
        // 2100-01-01T00:00:00Z is outside the UTCTime range (1950-2049).
        $cms = $builder->sign('data', $cred['cert_der'], $cred['key'], [], 'sha256', 4_102_444_800);

        $parts = $this->parseSignerInfo($cms);
        $signingTime = $this->attributeValue($parts['signed_attrs']['value'], '1.2.840.113549.1.9.5');
        $this->assertNotNull($signingTime);
        $this->assertSame(0x18, $signingTime['tag']);
    }

    public function testSignRejectsUnsupportedDigest(): void
    {
        $cred = $this->makeCredential('rsa');
        $builder = new Builder($this->asn1);
        $this->expectException(Exception::class);
        $builder->sign('data', $cred['cert_der'], $cred['key'], [], 'md5', self::SIGNING_TIME);
    }

    public function testSignFailsWithNonSigningKey(): void
    {
        $cred = $this->makeCredential('rsa');
        $publicKey = \openssl_pkey_get_public($cred['cert_pem']);
        if ($publicKey === false) {
            $this->fail('Unable to load public key');
        }

        $builder = new Builder($this->asn1);
        $this->expectException(Exception::class);
        \set_error_handler(static fn(): bool => true);
        try {
            $builder->sign('data', $cred['cert_der'], $publicKey, [], 'sha256', self::SIGNING_TIME);
        } finally {
            \restore_error_handler();
        }
    }

    public function testSignRejectsUnsupportedKeyType(): void
    {
        $cred = $this->makeCredential('dsa');
        $builder = new Builder($this->asn1);
        $this->expectException(Exception::class);
        $builder->sign('data', $cred['cert_der'], $cred['key'], [], 'sha256', self::SIGNING_TIME);
    }

    /**
     * Generate a private key and a matching self-signed certificate.
     *
     * @return array{key: OpenSSLAsymmetricKey, cert_pem: string, cert_der: string}
     */
    private function makeCredential(string $keyType): array
    {
        $config = [
            'config' => __DIR__ . '/../../openssl.cnf',
            'digest_alg' => 'sha256',
            'private_key_bits' => 2048,
            'private_key_type' => OPENSSL_KEYTYPE_RSA,
        ];
        if ($keyType === 'ec') {
            $config['private_key_type'] = OPENSSL_KEYTYPE_EC;
            $config['curve_name'] = 'prime256v1';
        } elseif ($keyType === 'dsa') {
            $config['private_key_type'] = OPENSSL_KEYTYPE_DSA;
            $config['private_key_bits'] = 1024;
        }

        $key = \openssl_pkey_new($config);
        if (!$key instanceof OpenSSLAsymmetricKey) {
            $this->markTestSkipped($keyType . ' key generation is not available');
        }

        $csr = \openssl_csr_new(['commonName' => 'tc-lib-pdf-sign signer'], $key, $config);
        if (!$csr instanceof \OpenSSLCertificateSigningRequest) {
            $this->markTestSkipped('CSR generation failed for ' . $keyType);
        }

        $cert = \openssl_csr_sign($csr, null, $key, 365, $config);
        if (!$cert instanceof \OpenSSLCertificate) {
            $this->markTestSkipped('Certificate signing failed for ' . $keyType);
        }

        $certPem = '';
        \openssl_x509_export($cert, $certPem);

        return ['key' => $key, 'cert_pem' => $certPem, 'cert_der' => $this->pemToDer($certPem)];
    }

    private function pemToDer(string $pem): string
    {
        $stripped = (string) \preg_replace('/-----[^-]+-----|\s+/', '', $pem);
        $der = \base64_decode($stripped, true);
        if ($der === false) {
            $this->fail('Invalid PEM');
        }

        return $der;
    }

    /**
     * Verify the SignerInfo signature over the reconstructed DER SET OF signed attributes.
     *
     * @param array{signed_attrs: array{tag:int,value:string,raw:string}, signature: array{tag:int,value:string,raw:string}, certificates: array{tag:int,value:string,raw:string}, unsigned_attrs: array{tag:int,value:string,raw:string}|null} $parts
     */
    private function assertVerifies(array $parts, string $certPem, int $opensslAlgo): void
    {
        $publicKey = \openssl_pkey_get_public($certPem);
        if ($publicKey === false) {
            $this->fail('Unable to load public key');
        }

        $signedAttrsSet = $this->asn1->encodeSet($parts['signed_attrs']['value']);
        $result = \openssl_verify($signedAttrsSet, $parts['signature']['value'], $publicKey, $opensslAlgo);
        $this->assertSame(1, $result);
    }

    /**
     * Descend into a SigningCertificate attribute and return the ESSCertID certHash TLV.
     *
     * @return array{tag: int, value: string, raw: string}
     */
    private function firstCertHash(string $oid, string $attrsDer): array
    {
        $value = $this->attributeValue($attrsDer, $oid);
        $this->assertNotNull($value);
        $certsOffset = 0;
        $certs = $this->asn1->readTlv($value['value'], $certsOffset);
        $essOffset = 0;
        $ess = $this->asn1->readTlv($certs['value'], $essOffset);
        $hashOffset = 0;
        return $this->asn1->readTlv($ess['value'], $hashOffset);
    }

    /**
     * Find an Attribute by OID and return the first value TLV of its value SET.
     *
     * @return array{tag: int, value: string, raw: string}|null
     */
    private function attributeValue(string $attrsDer, string $oid): ?array
    {
        $oidDer = $this->asn1->encodeObjectIdentifier($oid);
        $offset = 0;
        $length = \strlen($attrsDer);
        while ($offset < $length) {
            $attribute = $this->asn1->readTlv($attrsDer, $offset);
            $inner = 0;
            $attrOid = $this->asn1->readTlv($attribute['value'], $inner);
            if ($attrOid['raw'] === $oidDer) {
                $set = $this->asn1->readTlv($attribute['value'], $inner);
                $valueOffset = 0;
                return $this->asn1->readTlv($set['value'], $valueOffset);
            }
        }

        return null;
    }

    /**
     * Navigate a CMS ContentInfo to the SignerInfo fields under test.
     *
     * @return array{signed_attrs: array{tag:int,value:string,raw:string}, signature: array{tag:int,value:string,raw:string}, certificates: array{tag:int,value:string,raw:string}, unsigned_attrs: array{tag:int,value:string,raw:string}|null}
     */
    private function parseSignerInfo(string $cms): array
    {
        $offset = 0;
        $contentInfo = $this->asn1->readTlv($cms, $offset);

        $ciOffset = 0;
        $this->asn1->readTlv($contentInfo['value'], $ciOffset); // contentType OID
        $explicit = $this->asn1->readTlv($contentInfo['value'], $ciOffset); // [0] EXPLICIT

        $sdOffset = 0;
        $signedData = $this->asn1->readTlv($explicit['value'], $sdOffset);

        $sdInner = 0;
        $this->asn1->readTlv($signedData['value'], $sdInner); // version
        $this->asn1->readTlv($signedData['value'], $sdInner); // digestAlgorithms
        $this->asn1->readTlv($signedData['value'], $sdInner); // encapContentInfo
        $certificates = $this->asn1->readTlv($signedData['value'], $sdInner); // certificates [0]
        $signerInfos = $this->asn1->readTlv($signedData['value'], $sdInner); // signerInfos SET

        $siOffset = 0;
        $signerInfo = $this->asn1->readTlv($signerInfos['value'], $siOffset);

        $siInner = 0;
        $this->asn1->readTlv($signerInfo['value'], $siInner); // version
        $this->asn1->readTlv($signerInfo['value'], $siInner); // sid
        $this->asn1->readTlv($signerInfo['value'], $siInner); // digestAlgorithm
        $signedAttrs = $this->asn1->readTlv($signerInfo['value'], $siInner); // [0] IMPLICIT
        $this->asn1->readTlv($signerInfo['value'], $siInner); // signatureAlgorithm
        $signature = $this->asn1->readTlv($signerInfo['value'], $siInner); // signature

        $unsignedAttrs = null;
        if ($siInner < \strlen($signerInfo['value'])) {
            $unsignedAttrs = $this->asn1->readTlv($signerInfo['value'], $siInner); // [1] IMPLICIT
        }

        return [
            'signed_attrs' => $signedAttrs,
            'signature' => $signature,
            'certificates' => $certificates,
            'unsigned_attrs' => $unsignedAttrs,
        ];
    }
}
