<?php

declare(strict_types=1);

namespace SpomkyLabs\Pki\ASN1\Component;

use function array_key_exists;
use Brick\Math\BigInteger;
use function mb_strlen;
use function ord;
use SpomkyLabs\Pki\ASN1\Exception\DecodeException;
use SpomkyLabs\Pki\ASN1\Feature\Encodable;
use SpomkyLabs\Pki\ASN1\Util\BigInt;

/**
 * Class to represent BER/DER identifier octets.
 * @see \SpomkyLabs\Pki\Test\ASN1\Component\IdentifierTest
 */
final class Identifier implements Encodable
{
    // Type class enumerations
    public const CLASS_UNIVERSAL = 0b00;

    public const CLASS_APPLICATION = 0b01;

    public const CLASS_CONTEXT_SPECIFIC = 0b10;

    public const CLASS_PRIVATE = 0b11;

    // P/C enumerations
    public const PRIMITIVE = 0b0;

    public const CONSTRUCTED = 0b1;

    /**
     * Mapping from type class to human readable name.
     *
     * @internal
     *
     * @var array<int, string>
     */
    private const MAP_CLASS_TO_NAME = [
        self::CLASS_UNIVERSAL => 'UNIVERSAL',
        self::CLASS_APPLICATION => 'APPLICATION',
        self::CLASS_CONTEXT_SPECIFIC => 'CONTEXT SPECIFIC',
        self::CLASS_PRIVATE => 'PRIVATE',
    ];

    /**
     * Type class.
     */
    private int $_class;

    /**
     * Primitive or Constructed.
     */
    private readonly int $_pc;

    /**
     * Content type tag.
     */
    private BigInt $_tag;

    /**
     * @param int $class Type class
     * @param int $pc Primitive / Constructed
     * @param BigInteger|int $tag Type tag number
     */
    private function __construct(int $class, int $pc, BigInteger|int $tag)
    {
        $this->_class = 0b11 & $class;
        $this->_pc = 0b1 & $pc;
        $this->_tag = BigInt::create($tag);
    }

    public static function create(int $class, int $pc, BigInteger|int $tag): self
    {
        return new self($class, $pc, $tag);
    }

    /**
     * Decode identifier component from DER data.
     *
     * @param string $data DER encoded data
     * @param null|int $offset Reference to the variable that contains offset
     * into the data where to start parsing.
     * Variable is updated to the offset next to the
     * parsed identifier. If null, start from offset 0.
     */
    public static function fromDER(string $data, ?int &$offset = null): self
    {
        $idx = $offset ?? 0;
        $datalen = mb_strlen($data, '8bit');
        if ($idx >= $datalen) {
            throw new DecodeException('Invalid offset.');
        }
        $byte = ord($data[$idx++]);
        // bits 8 and 7 (class)
        // 0 = universal, 1 = application, 2 = context-specific, 3 = private
        $class = (0b11000000 & $byte) >> 6;
        // bit 6 (0 = primitive / 1 = constructed)
        $pc = (0b00100000 & $byte) >> 5;
        // bits 5 to 1 (tag number)
        $tag = (0b00011111 & $byte);
        // long-form identifier
        if ($tag === 0x1F) {
            $tag = self::decodeLongFormTag($data, $idx);
        }
        if (isset($offset)) {
            $offset = $idx;
        }
        return self::create($class, $pc, $tag);
    }

    public function toDER(): string
    {
        $bytes = [];
        $byte = $this->_class << 6 | $this->_pc << 5;
        $tag = $this->_tag->getValue();
        if ($tag->isLessThan(0x1F)) {
            $bytes[] = $byte | $tag->toInt();
        } // long-form identifier
        else {
            $bytes[] = $byte | 0x1F;
            $octets = [];
            for (; $tag->isGreaterThan(0); $tag = $tag->shiftedRight(7)) {
                $octets[] = 0x80 | $tag->and(0x7F)->toInt();
            }
            // last octet has bit 8 set to zero
            $octets[0] &= 0x7F;
            foreach (array_reverse($octets) as $octet) {
                $bytes[] = $octet;
            }
        }
        return pack('C*', ...$bytes);
    }

    /**
     * Get class of the type.
     */
    public function typeClass(): int
    {
        return $this->_class;
    }

    public function pc(): int
    {
        return $this->_pc;
    }

    /**
     * Get the tag number.
     *
     * @return string Base 10 integer string
     */
    public function tag(): string
    {
        return $this->_tag->base10();
    }

    /**
     * Get the tag as an integer.
     */
    public function intTag(): int
    {
        return $this->_tag->toInt();
    }

    /**
     * Check whether type is of an universal class.
     */
    public function isUniversal(): bool
    {
        return $this->_class === self::CLASS_UNIVERSAL;
    }

    /**
     * Check whether type is of an application class.
     */
    public function isApplication(): bool
    {
        return $this->_class === self::CLASS_APPLICATION;
    }

    /**
     * Check whether type is of a context specific class.
     */
    public function isContextSpecific(): bool
    {
        return $this->_class === self::CLASS_CONTEXT_SPECIFIC;
    }

    /**
     * Check whether type is of a private class.
     */
    public function isPrivate(): bool
    {
        return $this->_class === self::CLASS_PRIVATE;
    }

    /**
     * Check whether content is primitive type.
     */
    public function isPrimitive(): bool
    {
        return $this->_pc === self::PRIMITIVE;
    }

    /**
     * Check hether content is constructed type.
     */
    public function isConstructed(): bool
    {
        return $this->_pc === self::CONSTRUCTED;
    }

    /**
     * Get self with given type class.
     *
     * @param int $class One of `CLASS_*` enumerations
     */
    public function withClass(int $class): self
    {
        $obj = clone $this;
        $obj->_class = 0b11 & $class;
        return $obj;
    }

    /**
     * Get self with given type tag.
     *
     * @param int $tag Tag number
     */
    public function withTag(int $tag): self
    {
        $obj = clone $this;
        $obj->_tag = BigInt::create($tag);
        return $obj;
    }

    /**
     * Get human readable name of the type class.
     */
    public static function classToName(int $class): string
    {
        if (! array_key_exists($class, self::MAP_CLASS_TO_NAME)) {
            return "CLASS {$class}";
        }
        return self::MAP_CLASS_TO_NAME[$class];
    }

    /**
     * Parse long form tag.
     *
     * @param string $data DER data
     * @param int $offset Reference to the variable containing offset to data
     *
     * @return BigInteger Tag number
     */
    private static function decodeLongFormTag(string $data, int &$offset): BigInteger
    {
        $datalen = mb_strlen($data, '8bit');
        $tag = BigInteger::of(0);
        while (true) {
            if ($offset >= $datalen) {
                throw new DecodeException('Unexpected end of data while decoding long form identifier.');
            }
            $byte = ord($data[$offset++]);
            $tag = $tag->shiftedLeft(7);
            $tag = $tag->or(0x7F & $byte);
            // last byte has bit 8 set to zero
            if ((0x80 & $byte) === 0) {
                break;
            }
        }
        return $tag;
    }
}
