<?php

namespace App\Domain\Shared\Support;

use App\Domain\Shared\Exceptions\DomainException;

/**
 * A minimal QR encoder, byte mode, error-correction level M, versions 1–10.
 *
 * Written rather than pulled in because the deployment has no Composer step we
 * control: the image runs `composer install` against a committed lock file, and
 * there is no way to add a package to that lock from here. The alternative was
 * an <img> pointing at api.qrserver.com — which is what the one existing QR in
 * this codebase does, and which sends every member's identity token to a third
 * party and stops working the moment the venue's wifi does.
 *
 * Scope is deliberately narrow. Byte mode covers the check-in token; level M
 * is the usual 15% redundancy; ten versions carry up to 122 bytes, and the
 * token is about seventy. Anything outside that throws rather than guessing.
 *
 * Verified against the `segno` reference implementation: for every version and
 * mask this produces a byte-identical module matrix (tests/Unit/QrCodeTest).
 */
final class QrCode
{
    /** Data codeword capacity per version at ECC level M. */
    private const DATA_CODEWORDS_M = [
        1 => 16, 2 => 28, 3 => 44, 4 => 64, 5 => 86,
        6 => 108, 7 => 124, 8 => 154, 9 => 182, 10 => 216,
    ];

    /** ECC codewords per block, and the block layout, per version at level M. */
    private const ECC_M = [
        //        ecc/block, [ (blocks, data codewords per block), ... ]
        1 => [10, [[1, 16]]],
        2 => [16, [[1, 28]]],
        3 => [26, [[1, 44]]],
        4 => [18, [[2, 32]]],
        5 => [24, [[2, 43]]],
        6 => [16, [[4, 27]]],
        7 => [18, [[4, 31]]],
        8 => [22, [[2, 38], [2, 39]]],
        9 => [22, [[3, 36], [2, 37]]],
        10 => [26, [[4, 43], [1, 44]]],
    ];

    /** Alignment-pattern centre coordinates per version. */
    private const ALIGNMENT = [
        1 => [], 2 => [6, 18], 3 => [6, 22], 4 => [6, 26], 5 => [6, 30],
        6 => [6, 34], 7 => [6, 22, 38], 8 => [6, 24, 42], 9 => [6, 26, 46],
        10 => [6, 28, 50],
    ];

    /** Pre-computed 15-bit format strings for level M, masks 0–7. */
    private const FORMAT_BITS_M = [
        0b101010000010010, 0b101000100100101, 0b101111001111100, 0b101101101001011,
        0b100010111111001, 0b100000011001110, 0b100111110010111, 0b100101010100000,
    ];

    private array $matrix = [];

    private array $reserved = [];

    private int $size;

    private function __construct(private readonly int $version, private readonly int $mask)
    {
        $this->size = 17 + 4 * $version;
    }

    /**
     * The module matrix: [row][col] => bool, true meaning dark.
     *
     * @return array<int, array<int, bool>>
     */
    public static function matrix(string $data): array
    {
        $version = self::pickVersion($data);
        $codewords = self::codewords($data, $version);

        $best = null;
        $bestPenalty = PHP_INT_MAX;

        // The mask is chosen by the spec's four penalty rules, not picked. A
        // fixed mask produces large uniform areas for some payloads, which is
        // exactly what a scanner fails to lock onto.
        for ($mask = 0; $mask < 8; $mask++) {
            $qr = new self($version, $mask);
            $qr->build($codewords);
            $penalty = $qr->penalty();

            if ($penalty < $bestPenalty) {
                $bestPenalty = $penalty;
                $best = $qr;
            }
        }

        return $best->matrix;
    }

    /**
     * Render as an inline SVG.
     *
     * Server-side and self-contained on purpose: it works with no network, no
     * CDN and no third party ever seeing the token.
     *
     * The accessible label is passed in rather than translated here, so this
     * class stays a plain support class with no framework dependency — and so
     * a caller can say what the code is actually for.
     */
    public static function svg(string $data, int $sizePx = 240, int $quietZone = 4, string $label = 'QR'): string
    {
        $matrix = self::matrix($data);
        $modules = count($matrix);
        $total = $modules + $quietZone * 2;

        $path = '';

        foreach ($matrix as $y => $row) {
            foreach ($row as $x => $dark) {
                if ($dark) {
                    $path .= 'M' . ($x + $quietZone) . ' ' . ($y + $quietZone) . 'h1v1h-1z';
                }
            }
        }

        return sprintf(
            '<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" '
            . 'shape-rendering="crispEdges" role="img" aria-label="%s">'
            . '<rect width="%d" height="%d" fill="#ffffff"/>'
            . '<path d="%s" fill="#000000"/></svg>',
            $sizePx, $sizePx, $total, $total,
            htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
            $total, $total,
            $path
        );
    }

    // ---------------------------------------------------------------- data

    private static function pickVersion(string $data): int
    {
        $length = strlen($data);

        foreach (self::DATA_CODEWORDS_M as $version => $capacity) {
            // 4 bits mode + 8 or 16 bits length + data, rounded up to bytes.
            $lengthBits = $version <= 9 ? 8 : 16;
            $needed = (int) ceil((4 + $lengthBits + $length * 8) / 8);

            if ($needed <= $capacity) {
                return $version;
            }
        }

        throw new DomainException('البيانات أطول من أن تُشفَّر في رمز QR');
    }

    /** Mode + length + data + terminator + padding, then interleaved with ECC. */
    private static function codewords(string $data, int $version): array
    {
        $capacity = self::DATA_CODEWORDS_M[$version];
        $lengthBits = $version <= 9 ? 8 : 16;

        $bits = '0100';                                            // byte mode
        $bits .= str_pad(decbin(strlen($data)), $lengthBits, '0', STR_PAD_LEFT);

        foreach (str_split($data) as $char) {
            $bits .= str_pad(decbin(ord($char)), 8, '0', STR_PAD_LEFT);
        }

        // Terminator, up to four zero bits, then pad to a byte boundary.
        $bits .= str_repeat('0', min(4, $capacity * 8 - strlen($bits)));
        $bits .= str_repeat('0', (8 - strlen($bits) % 8) % 8);

        $bytes = array_map('bindec', str_split($bits, 8));

        // The spec's alternating pad bytes, which is what fills the rest.
        $pad = [0xEC, 0x11];
        $i = 0;

        while (count($bytes) < $capacity) {
            $bytes[] = $pad[$i++ % 2];
        }

        return self::interleave($bytes, $version);
    }

    /**
     * Split into blocks, compute Reed-Solomon parity for each, then interleave
     * — data codeword 0 of every block, then codeword 1 of every block, and so
     * on. Interleaving is what makes a burst of damage survivable: it spreads
     * one physical smudge across every block's error budget instead of
     * destroying one block outright.
     */
    private static function interleave(array $bytes, int $version): array
    {
        [$eccPerBlock, $layout] = self::ECC_M[$version];

        $dataBlocks = [];
        $eccBlocks = [];
        $offset = 0;

        foreach ($layout as [$count, $perBlock]) {
            for ($i = 0; $i < $count; $i++) {
                $block = array_slice($bytes, $offset, $perBlock);
                $offset += $perBlock;

                $dataBlocks[] = $block;
                $eccBlocks[] = self::reedSolomon($block, $eccPerBlock);
            }
        }

        $out = [];

        for ($i = 0; $i < max(array_map('count', $dataBlocks)); $i++) {
            foreach ($dataBlocks as $block) {
                if (isset($block[$i])) {
                    $out[] = $block[$i];
                }
            }
        }

        for ($i = 0; $i < $eccPerBlock; $i++) {
            foreach ($eccBlocks as $block) {
                $out[] = $block[$i];
            }
        }

        return $out;
    }

    // -------------------------------------------------- GF(256) arithmetic

    private static array $exp = [];

    private static array $log = [];

    private static function initGalois(): void
    {
        if (self::$exp !== []) {
            return;
        }

        $x = 1;

        for ($i = 0; $i < 256; $i++) {
            self::$exp[$i] = $x;
            self::$log[$x] = $i;

            $x <<= 1;

            // The QR field's primitive polynomial, x^8 + x^4 + x^3 + x^2 + 1.
            if ($x & 0x100) {
                $x ^= 0x11D;
            }
        }

        for ($i = 255; $i < 512; $i++) {
            self::$exp[$i] = self::$exp[$i - 255];
        }
    }

    private static function gfMul(int $a, int $b): int
    {
        if ($a === 0 || $b === 0) {
            return 0;
        }

        return self::$exp[self::$log[$a] + self::$log[$b]];
    }

    private static function reedSolomon(array $data, int $eccLength): array
    {
        self::initGalois();

        // Generator polynomial: the product of (x - a^i) for i in 0..eccLength-1,
        // stored highest-degree-first.
        //
        // The term order matters and is easy to get backwards: multiplying by
        // (x + a^i) shifts each coefficient up one degree AND scales it, so the
        // shifted copy goes at [j] and the scaled copy at [j+1]. Reversing those
        // two produces the mirror image of the correct polynomial, which still
        // yields plausible-looking parity bytes that no scanner accepts.
        $generator = [1];

        for ($i = 0; $i < $eccLength; $i++) {
            $next = array_fill(0, count($generator) + 1, 0);

            foreach ($generator as $j => $coefficient) {
                $next[$j] ^= $coefficient;
                $next[$j + 1] ^= self::gfMul($coefficient, self::$exp[$i]);
            }

            $generator = $next;
        }

        $remainder = array_merge($data, array_fill(0, $eccLength, 0));

        for ($i = 0; $i < count($data); $i++) {
            $factor = $remainder[$i];

            if ($factor === 0) {
                continue;
            }

            foreach ($generator as $j => $coefficient) {
                $remainder[$i + $j] ^= self::gfMul($coefficient, $factor);
            }
        }

        return array_slice($remainder, count($data));
    }

    // ------------------------------------------------------------- drawing

    private function build(array $codewords): void
    {
        for ($y = 0; $y < $this->size; $y++) {
            $this->matrix[$y] = array_fill(0, $this->size, false);
            $this->reserved[$y] = array_fill(0, $this->size, false);
        }

        $this->drawFinders();
        $this->drawAlignment();
        $this->drawTiming();
        $this->reserveFormat();
        $this->drawVersionInfo();
        $this->drawData($codewords);
        $this->drawFormat();
    }

    private function drawFinders(): void
    {
        foreach ([[0, 0], [$this->size - 7, 0], [0, $this->size - 7]] as [$col, $row]) {
            for ($y = -1; $y <= 7; $y++) {
                for ($x = -1; $x <= 7; $x++) {
                    $py = $row + $y;
                    $px = $col + $x;

                    if ($py < 0 || $px < 0 || $py >= $this->size || $px >= $this->size) {
                        continue;
                    }

                    $inRing = ($x >= 0 && $x <= 6 && ($y === 0 || $y === 6))
                        || ($y >= 0 && $y <= 6 && ($x === 0 || $x === 6));
                    $inCore = $x >= 2 && $x <= 4 && $y >= 2 && $y <= 4;

                    $this->matrix[$py][$px] = $inRing || $inCore;
                    $this->reserved[$py][$px] = true;
                }
            }
        }

        // The dark module: always set, always at this coordinate.
        $this->matrix[4 * $this->version + 9][8] = true;
        $this->reserved[4 * $this->version + 9][8] = true;
    }

    private function drawAlignment(): void
    {
        $centres = self::ALIGNMENT[$this->version];

        foreach ($centres as $row) {
            foreach ($centres as $col) {
                // Skip the three that would collide with a finder pattern.
                if ($this->reserved[$row][$col] ?? false) {
                    continue;
                }

                for ($y = -2; $y <= 2; $y++) {
                    for ($x = -2; $x <= 2; $x++) {
                        $this->matrix[$row + $y][$col + $x] =
                            max(abs($x), abs($y)) !== 1;
                        $this->reserved[$row + $y][$col + $x] = true;
                    }
                }
            }
        }
    }

    private function drawTiming(): void
    {
        for ($i = 8; $i < $this->size - 8; $i++) {
            $dark = $i % 2 === 0;

            $this->matrix[6][$i] = $dark;
            $this->reserved[6][$i] = true;

            $this->matrix[$i][6] = $dark;
            $this->reserved[$i][6] = true;
        }
    }

    /**
     * Versions 7 and up carry an 18-bit version indicator, twice — a 6x3 block
     * beside the top-right finder and its transpose above the bottom-left one.
     *
     * Omitting these does not merely lose the version hint: those 36 modules
     * are not data modules, so writing payload into them shifts the entire
     * codeword stream and nothing decodes at all. Every version below 7 was
     * fine and v7 failed outright, which is exactly the shape of that mistake.
     */
    private function drawVersionInfo(): void
    {
        if ($this->version < 7) {
            return;
        }

        // BCH(18,6), generator 0x1F25, no final XOR mask. The remainder is
        // taken over the six version bits themselves, then appended — not over
        // the already-shifted value.
        $remainder = $this->version;

        for ($i = 0; $i < 12; $i++) {
            $remainder = ($remainder << 1) ^ (($remainder >> 11) * 0x1F25);
        }

        $value = ($this->version << 12) | ($remainder & 0xFFF);

        // Two 6x3 blocks: one left of the top-right finder, one above the
        // bottom-left finder, each the transpose of the other.
        for ($i = 0; $i < 6; $i++) {
            for ($j = 0; $j < 3; $j++) {
                $bit = (bool) (($value >> ($i * 3 + $j)) & 1);

                $this->matrix[$this->size - 11 + $j][$i] = $bit;
                $this->reserved[$this->size - 11 + $j][$i] = true;

                $this->matrix[$i][$this->size - 11 + $j] = $bit;
                $this->reserved[$i][$this->size - 11 + $j] = true;
            }
        }
    }

    private function reserveFormat(): void
    {
        for ($i = 0; $i < 9; $i++) {
            $this->reserved[8][$i] = true;
            $this->reserved[$i][8] = true;
        }

        for ($i = 0; $i < 8; $i++) {
            $this->reserved[8][$this->size - 1 - $i] = true;
            $this->reserved[$this->size - 1 - $i][8] = true;
        }
    }

    /** Zig-zag up and down two columns at a time, skipping the timing column. */
    private function drawData(array $codewords): void
    {
        $bits = '';

        foreach ($codewords as $byte) {
            $bits .= str_pad(decbin($byte), 8, '0', STR_PAD_LEFT);
        }

        $index = 0;
        $upward = true;

        for ($right = $this->size - 1; $right > 0; $right -= 2) {
            if ($right === 6) {
                $right = 5;   // the vertical timing pattern occupies column 6
            }

            for ($step = 0; $step < $this->size; $step++) {
                $y = $upward ? $this->size - 1 - $step : $step;

                foreach ([$right, $right - 1] as $x) {
                    if ($this->reserved[$y][$x]) {
                        continue;
                    }

                    $bit = $index < strlen($bits) ? $bits[$index] === '1' : false;
                    $index++;

                    $this->matrix[$y][$x] = $bit !== $this->maskAt($y, $x);
                }
            }

            $upward = ! $upward;
        }
    }

    private function maskAt(int $y, int $x): bool
    {
        return match ($this->mask) {
            0 => ($y + $x) % 2 === 0,
            1 => $y % 2 === 0,
            2 => $x % 3 === 0,
            3 => ($y + $x) % 3 === 0,
            4 => (intdiv($y, 2) + intdiv($x, 3)) % 2 === 0,
            5 => ($y * $x) % 2 + ($y * $x) % 3 === 0,
            6 => (($y * $x) % 2 + ($y * $x) % 3) % 2 === 0,
            7 => (($y + $x) % 2 + ($y * $x) % 3) % 2 === 0,
        };
    }

    private function drawFormat(): void
    {
        $bits = self::FORMAT_BITS_M[$this->mask];

        for ($i = 0; $i < 15; $i++) {
            $bit = (bool) (($bits >> $i) & 1);

            // Copy one: around the top-left finder.
            if ($i < 6) {
                $this->matrix[$i][8] = $bit;
            } elseif ($i === 6) {
                $this->matrix[7][8] = $bit;
            } elseif ($i === 7) {
                $this->matrix[8][8] = $bit;
            } elseif ($i === 8) {
                $this->matrix[8][7] = $bit;
            } else {
                $this->matrix[8][14 - $i] = $bit;
            }

            // Copy two: split between the other two finders.
            if ($i < 8) {
                $this->matrix[8][$this->size - 1 - $i] = $bit;
            } else {
                $this->matrix[$this->size - 15 + $i][8] = $bit;
            }
        }
    }

    // ------------------------------------------------------------- masking

    /** The spec's four penalty rules; the lowest total wins. */
    private function penalty(): int
    {
        return $this->penaltyRuns()
            + $this->penaltyBlocks()
            + $this->penaltyFinderLike()
            + $this->penaltyBalance();
    }

    private function penaltyRuns(): int
    {
        $score = 0;

        foreach ([true, false] as $vertical) {
            for ($a = 0; $a < $this->size; $a++) {
                $run = 1;
                $previous = null;

                for ($b = 0; $b < $this->size; $b++) {
                    $value = $vertical ? $this->matrix[$b][$a] : $this->matrix[$a][$b];

                    if ($value === $previous) {
                        $run++;
                    } else {
                        if ($run >= 5) {
                            $score += 3 + ($run - 5);
                        }

                        $run = 1;
                        $previous = $value;
                    }
                }

                if ($run >= 5) {
                    $score += 3 + ($run - 5);
                }
            }
        }

        return $score;
    }

    private function penaltyBlocks(): int
    {
        $score = 0;

        for ($y = 0; $y < $this->size - 1; $y++) {
            for ($x = 0; $x < $this->size - 1; $x++) {
                $v = $this->matrix[$y][$x];

                if ($v === $this->matrix[$y][$x + 1]
                    && $v === $this->matrix[$y + 1][$x]
                    && $v === $this->matrix[$y + 1][$x + 1]) {
                    $score += 3;
                }
            }
        }

        return $score;
    }

    private function penaltyFinderLike(): int
    {
        $patterns = [
            [true, false, true, true, true, false, true, false, false, false, false],
            [false, false, false, false, true, false, true, true, true, false, true],
        ];

        $score = 0;

        for ($y = 0; $y < $this->size; $y++) {
            for ($x = 0; $x < $this->size; $x++) {
                foreach ($patterns as $pattern) {
                    if ($x + 11 <= $this->size) {
                        $match = true;

                        for ($i = 0; $i < 11; $i++) {
                            if ($this->matrix[$y][$x + $i] !== $pattern[$i]) {
                                $match = false;
                                break;
                            }
                        }

                        if ($match) {
                            $score += 40;
                        }
                    }

                    if ($y + 11 <= $this->size) {
                        $match = true;

                        for ($i = 0; $i < 11; $i++) {
                            if ($this->matrix[$y + $i][$x] !== $pattern[$i]) {
                                $match = false;
                                break;
                            }
                        }

                        if ($match) {
                            $score += 40;
                        }
                    }
                }
            }
        }

        return $score;
    }

    private function penaltyBalance(): int
    {
        $dark = 0;

        foreach ($this->matrix as $row) {
            foreach ($row as $value) {
                if ($value) {
                    $dark++;
                }
            }
        }

        $percent = $dark * 100 / ($this->size * $this->size);

        return (int) (abs($percent - 50) / 5) * 10;
    }
}
