<?php

declare(strict_types=1);

/**
 * Imb.php
 *
 * @since       2015-02-21
 * @category    Library
 * @package     Barcode
 * @author      Nicola Asuni <info@tecnick.com>
 * @copyright   2015-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-barcode
 *
 * This file is part of tc-lib-barcode software library.
 */

namespace Com\Tecnick\Barcode\Type\Linear;

use Com\Tecnick\Barcode\Exception as BarcodeException;

/**
 * Com\Tecnick\Barcode\Type\Linear\Imb;
 *
 * Imb Barcode type class
 * IMB - Intelligent Mail Barcode - Onecode - USPS-B-3200
 *
 * 65-bar code for use on mail in the United States.
 * The payload is the concatenation of the tracking code and the routing code,
 * separated by a hyphen:
 *  * Barcode Identifier: 2 digits, the second one in the range 0 to 4.
 *  * Service Type Identifier: 3 digits, 000 to 999.
 *  * Mailer Identifier: 6 digits (000000 to 899999) or 9 digits (900000000 to 999999999).
 *  * Serial Number: 9 digits with a 6 digit Mailer ID, 6 digits with a 9 digit one.
 *  * Delivery Point ZIP Code: 0, 5, 9, or 11 digits, after the hyphen.
 *
 * @since       2015-02-21
 * @category    Library
 * @package     Barcode
 * @author      Nicola Asuni <info@tecnick.com>
 * @copyright   2015-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-barcode
 */
class Imb extends \Com\Tecnick\Barcode\Type\Linear
{
    /**
     * Barcode format
     *
     * @var string
     */
    protected const FORMAT = 'IMB';

    /**
     * ASC characters
     *
     * @var array<int>
     */
    protected const ASC_CHR = [
        4,
        0,
        2,
        6,
        3,
        5,
        1,
        9,
        8,
        7,
        1,
        2,
        0,
        6,
        4,
        8,
        2,
        9,
        5,
        3,
        0,
        1,
        3,
        7,
        4,
        6,
        8,
        9,
        2,
        0,
        5,
        1,
        9,
        4,
        3,
        8,
        6,
        7,
        1,
        2,
        4,
        3,
        9,
        5,
        7,
        8,
        3,
        0,
        2,
        1,
        4,
        0,
        9,
        1,
        7,
        0,
        2,
        4,
        6,
        3,
        7,
        1,
        9,
        5,
        8,
    ];

    /**
     * DSC characters
     *
     * @var array<int>
     */
    protected const DSC_CHR = [
        7,
        1,
        9,
        5,
        8,
        0,
        2,
        4,
        6,
        3,
        5,
        8,
        9,
        7,
        3,
        0,
        6,
        1,
        7,
        4,
        6,
        8,
        9,
        2,
        5,
        1,
        7,
        5,
        4,
        3,
        8,
        7,
        6,
        0,
        2,
        5,
        4,
        9,
        3,
        0,
        1,
        6,
        8,
        2,
        0,
        4,
        5,
        9,
        6,
        7,
        5,
        2,
        6,
        3,
        8,
        5,
        1,
        9,
        8,
        7,
        4,
        0,
        2,
        6,
        3,
    ];

    /**
     * ASC positions
     *
     * @var array<int>
     */
    protected const ASC_POS = [
        3,
        0,
        8,
        11,
        1,
        12,
        8,
        11,
        10,
        6,
        4,
        12,
        2,
        7,
        9,
        6,
        7,
        9,
        2,
        8,
        4,
        0,
        12,
        7,
        10,
        9,
        0,
        7,
        10,
        5,
        7,
        9,
        6,
        8,
        2,
        12,
        1,
        4,
        2,
        0,
        1,
        5,
        4,
        6,
        12,
        1,
        0,
        9,
        4,
        7,
        5,
        10,
        2,
        6,
        9,
        11,
        2,
        12,
        6,
        7,
        5,
        11,
        0,
        3,
        2,
    ];

    /**
     * DSC positions
     *
     * @var array<int>
     */
    protected const DSC_POS = [
        2,
        10,
        12,
        5,
        9,
        1,
        5,
        4,
        3,
        9,
        11,
        5,
        10,
        1,
        6,
        3,
        4,
        1,
        10,
        0,
        2,
        11,
        8,
        6,
        1,
        12,
        3,
        8,
        6,
        4,
        4,
        11,
        0,
        6,
        1,
        9,
        11,
        5,
        3,
        7,
        3,
        10,
        7,
        11,
        8,
        2,
        10,
        3,
        5,
        8,
        0,
        3,
        12,
        11,
        8,
        4,
        5,
        1,
        3,
        0,
        7,
        12,
        9,
        8,
        10,
    ];

    /**
     * Reverse unsigned short value
     *
     * @param int $num Value to reverse
     *
     * @return int reversed value
     */
    protected function getReversedUnsignedShort(int $num): int
    {
        $rev = 0;
        for ($pos = 0; $pos < 16; ++$pos) {
            $rev <<= 1;
            $rev |= $num & 1;
            $num >>= 1;
        }

        return $rev;
    }

    /**
     * @param array<int, string> $code_arr
     */
    protected function getCodeByte(array $code_arr, int $index): string
    {
        return $code_arr[$index] ?? '00';
    }

    /**
     * @return numeric-string
     *
     * @throws BarcodeException in case of error
     */
    protected function toNumericString(string $value): string
    {
        if ($value === '' || !\ctype_digit($value)) {
            throw new BarcodeException('Invalid numeric string');
        }

        return $value;
    }

    /**
     * @return numeric-string
     *
     * @throws BarcodeException in case of error
     */
    protected function getTrackingDigit(string $tracking_number, int $index): string
    {
        $digit = $tracking_number[$index] ?? '';
        if (!\ctype_digit($digit)) {
            throw new BarcodeException('Invalid tracking number');
        }

        return $digit;
    }

    /**
     * @param array<int, int> $table
     */
    protected function getTableCode(array $table, int $index): int
    {
        return $table[$index] ?? 0;
    }

    /**
     * @param array<int, int> $chars
     */
    protected function getCharValue(array $chars, int $index): int
    {
        return $chars[$index] ?? 0;
    }

    /**
     * Get the Frame Check Sequence
     *
     * @param array<int, string> $code_arr Array of hexadecimal values (13 bytes holding 102 bits right justified).
     *
     * @return int 11 bit Frame Check Sequence as integer (decimal base)
     */
    protected function getFrameCheckSequence(array $code_arr): int
    {
        $genpoly = 0x0F35; // generator polynomial
        $fcs = 0x07FF; // Frame Check Sequence
        // do most significant byte skipping the 2 most significant bits
        $data = \hexdec($this->getCodeByte($code_arr, 0)) << 5;
        for ($bit = 2; $bit < 8; ++$bit) {
            $fcs = (($fcs ^ $data) & 0x400) !== 0 ? ($fcs << 1) ^ $genpoly : $fcs << 1;

            $fcs &= 0x7FF;
            $data <<= 1;
        }

        // do rest of bytes
        for ($byte = 1; $byte < 13; ++$byte) {
            $data = \hexdec($this->getCodeByte($code_arr, $byte)) << 3;
            for ($bit = 0; $bit < 8; ++$bit) {
                $fcs = (($fcs ^ $data) & 0x400) !== 0 ? ($fcs << 1) ^ $genpoly : $fcs << 1;

                $fcs &= 0x7FF;
                $data <<= 1;
            }
        }

        return $fcs;
    }

    /**
     * Nof13 tables, keyed by "type:size".
     *
     * @var array<string, array<int, int>>
     */
    private static array $tables = [];

    /**
     * Get the Nof13 tables
     *
     * @param int $type  Table type: 2 for 2of13 table, 5 for 5of13table
     * @param int $size Table size (78 for n=2 and 1287 for n=5)
     *
     * @return array<int, int> requested table
     */
    protected function getTables(int $type, int $size): array
    {
        return self::$tables[$type . ':' . $size] ??= $this->buildTables($type, $size);
    }

    /**
     * Build the Nof13 table for the given type and size
     *
     * @param int $type  Table type: 2 for 2of13 table, 5 for 5of13table
     * @param int $size Table size (78 for n=2 and 1287 for n=5)
     *
     * @return array<int, int> requested table
     */
    private function buildTables(int $type, int $size): array
    {
        $table = [];
        $lli = 0; // LUT lower index
        $lui = $size - 1; // LUT upper index
        for ($count = 0; $count < 8192; ++$count) {
            $bit_count = 0;
            for ($bit_index = 0; $bit_index < 13; ++$bit_index) {
                $bit_count += (int) (($count & (1 << $bit_index)) !== 0);
            }

            // if we don't have the right number of bits on, go on to the next value
            if ($bit_count === $type) {
                $reverse = $this->getReversedUnsignedShort($count) >> 3;
                // if the reverse is less than count, we have already visited this pair before
                if ($reverse >= $count) {
                    // If count is symmetric, place it at the first free slot from the end of the list.
                    // Otherwise, place it at the first free slot from the beginning of the list AND place
                    // $reverse ath the next free slot from the beginning of the list
                    if ($reverse === $count) {
                        $table[$lui] = $count;
                        --$lui;
                        continue;
                    }

                    $table[$lli] = $count;
                    ++$lli;
                    $table[$lli] = $reverse;
                    ++$lli;
                }
            }
        }

        return $table;
    }

    /**
     * Get the routing code binary block
     *
     * @param string $routing_code the routing code
     *
     * @throws BarcodeException in case of error
     */
    protected function getRoutingCode(string $routing_code): string
    {
        if ($routing_code !== '' && !\ctype_digit($routing_code)) {
            throw new BarcodeException('Invalid routing code');
        }

        if ($routing_code === '') {
            return '0';
        }

        $routing_code = $this->toNumericString($routing_code);

        // Conversion of Routing Code
        return match (\strlen($routing_code)) {
            5 => $this->addNumeric($routing_code, '1'),
            9 => $this->addNumeric($routing_code, '100001'),
            11 => $this->addNumeric($routing_code, '1000100001'),
            default => throw new BarcodeException('Invalid routing code'),
        };
    }

    /**
     * Get the processed array of characters
     *
     * @return array<int, int>
     *
     * @throws BarcodeException in case of error
     */
    protected function getCharsArray(): array
    {
        $this->ncols = 0;
        $this->nrows = 3;
        $this->bars = [];
        $code_arr = \explode('-', $this->code);
        $tracking_number = $code_arr[0];
        if (!\preg_match('/^\d{2,20}$/', $tracking_number)) {
            throw new BarcodeException('Invalid tracking number');
        }

        $binary_code = '0';
        if (($code_arr[1] ?? null) !== null) {
            $binary_code = $this->getRoutingCode($code_arr[1]);
        }

        $binary_code = $this->toNumericString($binary_code);

        $binary_code = $this->mulNumeric($binary_code, '10');
        $binary_code = $this->addNumeric($binary_code, $this->getTrackingDigit($tracking_number, 0));
        $binary_code = $this->mulNumeric($binary_code, '5');
        $binary_code = $this->addNumeric($binary_code, $this->getTrackingDigit($tracking_number, 1));
        $binary_code = $this->toNumericString($binary_code . \substr($tracking_number, 2, 18));
        // convert to hexadecimal
        $binary_code = $this->convertDecToHex($binary_code);
        // pad to get 13 bytes
        $binary_code = \str_pad($binary_code, 26, '0', STR_PAD_LEFT);
        // convert string to array of bytes
        $binary_code_arr = \chunk_split($binary_code, 2, "\r");
        $binary_code_arr = \substr($binary_code_arr, 0, -1);
        $binary_code_arr = \explode("\r", $binary_code_arr);
        // calculate frame check sequence
        $fcs = $this->getFrameCheckSequence($binary_code_arr);
        // exclude first 2 bits from first byte
        $first_byte = \str_pad(\dechex((int) \hexdec($binary_code_arr[0]) & 0x3F), 2, '0', STR_PAD_LEFT);
        $binary_code_102bit = $first_byte . \substr($binary_code, 2);
        // convert binary data to codewords
        $codewords = [];
        $data = $this->toNumericString($this->convertHexToDec($binary_code_102bit));
        $codewords[0] = $this->modNumeric($data, '636') * 2;
        $data = $this->divNumeric($data, '636');
        for ($pos = 1; $pos < 9; ++$pos) {
            $codewords[$pos] = $this->modNumeric($data, '1365');
            $data = $this->divNumeric($data, '1365');
        }

        $codewords[9] = (int) $data;
        if (($fcs >> 10) === 1) {
            $codewords[9] += 659;
        }

        // generate lookup tables
        $table2of13 = $this->getTables(2, 78);
        $table5of13 = $this->getTables(5, 1287);
        // convert codewords to characters
        $characters = [];
        $bitmask = 512;
        foreach ($codewords as $codeword) {
            $chrcode = $codeword <= 1286
                ? $this->getTableCode($table5of13, $codeword)
                : $this->getTableCode($table2of13, $codeword - 1287);

            if (($fcs & $bitmask) > 0) {
                // bitwise invert
                $chrcode = ~(int) $chrcode & 8191;
            }

            $characters[] = $chrcode;
            $bitmask /= 2;
        }

        return \array_reverse($characters);
    }

    /**
     * Set the bars array.
     *
     * @throws BarcodeException in case of error
     */
    protected function setBars(): void
    {
        $chars = $this->getCharsArray();
        for ($pos = 0; $pos < 65; ++$pos) {
            $asc_chr = self::ASC_CHR[$pos] ?? 0;
            $asc_pos = self::ASC_POS[$pos] ?? 0;
            $dsc_chr = self::DSC_CHR[$pos] ?? 0;
            $dsc_pos = self::DSC_POS[$pos] ?? 0;
            $asc = ($this->getCharValue($chars, $asc_chr) & (2 ** $asc_pos)) > 0;
            $dsc = ($this->getCharValue($chars, $dsc_chr) & (2 ** $dsc_pos)) > 0;
            if ($asc && $dsc) {
                // full bar (F)
                $this->bars[] = [$this->ncols, 0, 1, 3];
                $this->ncols += 2;
                continue;
            }

            if ($asc) {
                // ascender (A)
                $this->bars[] = [$this->ncols, 0, 1, 2];
                $this->ncols += 2;
                continue;
            }

            if ($dsc) {
                // descender (D)
                $this->bars[] = [$this->ncols, 1, 1, 2];
                $this->ncols += 2;
                continue;
            }

            // tracker (T)
            $this->bars[] = [$this->ncols, 1, 1, 1];
            $this->ncols += 2;
        }

        --$this->ncols;
    }
}
