<?php

declare(strict_types=1);

/**
 * Asn1.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 Com\Tecnick\Pdf\Sign\Cms;

use Com\Tecnick\Pdf\Sign\Exception;

/**
 * Com\Tecnick\Pdf\Sign\Cms\Asn1
 *
 * Minimal DER ASN.1 encoder/decoder used to assemble and inspect CMS/CAdES
 * structures, RFC 3161 timestamp messages, and OCSP requests. Only the subset
 * of ASN.1 needed by PDF signatures is implemented.
 *
 * @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 Asn1
{
    /**
     * Encode a DER length octet sequence.
     *
     * @param int<0, max> $length Number of content octets.
     *
     * @throws Exception If the length is too large to encode.
     */
    public function encodeLength(int $length): string
    {
        if ($length < 128) {
            return \chr($length);
        }

        $encoded = '';
        $value = $length;
        while ($value > 0) {
            $encoded = \chr((int) ($value & 0xFF)) . $encoded;
            $value = (int) ($value / 256);
        }

        $encodedLength = \strlen($encoded);
        if ($encodedLength > 0x7F) {
            // Defensive: unreachable, as this needs content larger than 2^1016
            // bytes, which is unrepresentable and unallocatable.
            throw new Exception('ASN.1 length encoding overflow');
        }

        return \chr(0x80 | $encodedLength) . $encoded;
    }

    /**
     * Encode a non-negative integer as a DER INTEGER.
     *
     * @param int<0, max> $value Integer value.
     *
     * @throws Exception If the length cannot be encoded.
     */
    public function encodeInteger(int $value): string
    {
        $data = '';
        $num = $value;
        while ($num > 0) {
            $data = \chr((int) ($num & 0xFF)) . $data;
            $num = (int) ($num / 256);
        }

        if ($data === '') {
            $data = "\x00";
        }

        if ((\ord($data[0]) & 0x80) !== 0) {
            $data = "\x00" . $data;
        }

        return "\x02" . $this->encodeLength(\strlen($data)) . $data;
    }

    /**
     * Encode a big-endian magnitude byte string as a DER INTEGER.
     *
     * Trims superfluous leading zero octets and prepends a zero octet when the
     * most significant bit is set, so the value stays non-negative. Useful for
     * certificate serial numbers.
     *
     * @throws Exception If the length cannot be encoded.
     */
    public function encodeIntegerBytes(string $bytes): string
    {
        $len = \strlen($bytes);
        $start = 0;
        while ($start < ($len - 1) && $bytes[$start] === "\x00" && (\ord($bytes[$start + 1]) & 0x80) === 0) {
            ++$start;
        }

        $magnitude = \substr($bytes, $start);
        if ($magnitude === '') {
            $magnitude = "\x00";
        }

        if ((\ord($magnitude[0]) & 0x80) !== 0) {
            $magnitude = "\x00" . $magnitude;
        }

        return "\x02" . $this->encodeLength(\strlen($magnitude)) . $magnitude;
    }

    /**
     * Encode a DER BOOLEAN.
     */
    public function encodeBoolean(bool $value): string
    {
        return "\x01\x01" . ($value ? "\xFF" : "\x00");
    }

    /**
     * Encode a DER NULL.
     */
    public function encodeNull(): string
    {
        return "\x05\x00";
    }

    /**
     * Encode a DER OCTET STRING.
     *
     * @throws Exception If the length cannot be encoded.
     */
    public function encodeOctetString(string $value): string
    {
        return "\x04" . $this->encodeLength(\strlen($value)) . $value;
    }

    /**
     * Wrap pre-encoded content in a DER SEQUENCE.
     *
     * @throws Exception If the length cannot be encoded.
     */
    public function encodeSequence(string $value): string
    {
        return "\x30" . $this->encodeLength(\strlen($value)) . $value;
    }

    /**
     * Wrap pre-encoded content in a DER SET.
     *
     * @throws Exception If the length cannot be encoded.
     */
    public function encodeSet(string $value): string
    {
        return "\x31" . $this->encodeLength(\strlen($value)) . $value;
    }

    /**
     * Wrap pre-encoded content in a context-specific constructed tag [n].
     *
     * @param int<0, 30> $number Context tag number.
     *
     * @throws Exception If the length cannot be encoded.
     */
    public function encodeContext(int $number, string $value): string
    {
        return \chr(0xA0 | ($number & 0x1F)) . $this->encodeLength(\strlen($value)) . $value;
    }

    /**
     * Encode a dotted OID string as a DER OBJECT IDENTIFIER.
     *
     * @throws Exception If the OID is malformed or the length cannot be encoded.
     */
    public function encodeObjectIdentifier(string $oid): string
    {
        $parts = \array_map('intval', \explode('.', $oid));
        if (\count($parts) < 2) {
            throw new Exception('Invalid OID');
        }

        $data = \chr((int) ((($parts[0] * 40) + ($parts[1] ?? 0)) & 0xFF));
        $count = \count($parts);
        for ($idx = 2; $idx < $count; ++$idx) {
            $part = (int) ($parts[$idx] ?? 0);
            if ($part < 0) {
                $part = 0;
            }

            $data .= $this->encodeBase128Int($part);
        }

        return "\x06" . $this->encodeLength(\strlen($data)) . $data;
    }

    /**
     * Encode a non-negative integer in base-128 with continuation bits.
     *
     * @param int<0, max> $value Integer value.
     */
    public function encodeBase128Int(int $value): string
    {
        $bytes = [$value & 0x7F];
        $value = (int) ($value / 128);
        while ($value > 0) {
            \array_unshift($bytes, ($value & 0x7F) | 0x80);
            $value = (int) ($value / 128);
        }

        $out = '';
        foreach ($bytes as $byte) {
            $out .= \chr($byte);
        }

        return $out;
    }

    /**
     * Read one DER TLV triplet starting at the given offset.
     *
     * @param int $offset Read cursor; advanced past the parsed element.
     *
     * @return array{tag: int, value: string, raw: string}
     *
     * @throws Exception If the structure or length is malformed.
     */
    public function readTlv(string $data, int &$offset): array
    {
        if ($offset >= \strlen($data)) {
            throw new Exception('Malformed ASN.1 structure');
        }

        $start = $offset;
        $tag = \ord($data[$offset]);
        ++$offset;

        $length = $this->readLength($data, $offset);
        if (($offset + $length) > \strlen($data)) {
            throw new Exception('Malformed ASN.1 length');
        }

        $value = \substr($data, $offset, $length);
        $offset += $length;
        $raw = \substr($data, $start, $offset - $start);

        return ['tag' => $tag, 'value' => $value, 'raw' => $raw];
    }

    /**
     * Read a DER length starting at the given offset.
     *
     * @param int $offset Read cursor; advanced past the length octets.
     *
     * @throws Exception If the length is malformed or unsupported.
     */
    public function readLength(string $data, int &$offset): int
    {
        if ($offset >= \strlen($data)) {
            throw new Exception('Malformed ASN.1 length');
        }

        $first = \ord($data[$offset]);
        ++$offset;
        if (($first & 0x80) === 0) {
            return $first;
        }

        $numBytes = $first & 0x7F;
        if ($numBytes < 1 || $numBytes > 4 || ($offset + $numBytes) > \strlen($data)) {
            throw new Exception('Unsupported ASN.1 length');
        }

        $length = 0;
        for ($idx = 0; $idx < $numBytes; ++$idx) {
            $length = ($length * 256) + \ord($data[$offset + $idx]);
        }

        $offset += $numBytes;
        return $length;
    }

    /**
     * Decode a DER INTEGER content string to a PHP integer.
     *
     * @param string $value Content octets (without tag/length).
     *
     * @throws Exception If the value is empty.
     */
    public function decodeInteger(string $value): int
    {
        if ($value === '') {
            throw new Exception('Invalid ASN.1 integer');
        }

        $int = 0;
        $len = \strlen($value);
        for ($idx = 0; $idx < $len; ++$idx) {
            $int = ($int * 256) + \ord($value[$idx]);
        }

        return $int;
    }
}
