<?php
/**
 * QrCode — compact QR encoder used by the Pacific Staff-ID card.
 *
 * Scope (deliberately narrow — replaces the drawn placeholder that shipped
 * in the first cut of PacificStaffIdPdf):
 *   Mode        = byte (8-bit)
 *   Version     = 4 (33 × 33 modules) — fixed
 *   ECC level   = L (80 data + 20 ECC codewords in a single RS block)
 *   Max payload = 78 UTF-8 bytes
 *
 * That envelope is comfortably wider than any verify URL we generate
 * (`http…/Verify/PGC-####-A` is ~52 bytes local, ~43 bytes prod), so
 * every staff ID fits without version selection. If we ever need longer
 * data — pinning a JWT into the QR, batch-encoding multiple cards —
 * we'll extend to multi-version.
 *
 * Two entry points:
 *   QrCode::encode(string $data): int[][]      // 2D matrix (0 = light, 1 = dark)
 *   QrCode::png(string $data, string $file, int $scale = 4, int $margin = 4): void
 *
 * FPDF integration draws Rect() per dark module, so encode() is the primary path.
 *
 * Reference: ISO/IEC 18004 (QR Code 2005). Verified with an external
 * QR decoder against several sample URLs.
 */
class QrCode
{
    /* ── Fixed configuration for V4-L ─────────────────────────────── */
    const VERSION         = 4;
    const SIZE            = 33;   // 21 + 4*(V-1)
    const TOTAL_CODEWORDS = 100;
    const DATA_CODEWORDS  = 80;
    const ECC_CODEWORDS   = 20;

    const MODE_BYTE = 0b0100;
    const ECC_L     = 0b01;    // format-info bits for level L

    /* ── GF(256) log/antilog tables (primitive poly 0x11d) ────────── */
    private static ?array $exp = null;
    private static ?array $log = null;

    private static function initGf(): void
    {
        if (self::$exp !== null) return;
        self::$exp = array_fill(0, 512, 0);
        self::$log = array_fill(0, 256, 0);
        $x = 1;
        for ($i = 0; $i < 255; $i++) {
            self::$exp[$i] = $x;
            self::$log[$x] = $i;
            $x <<= 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]];
    }

    /** Reed–Solomon generator polynomial of the given degree. */
    private static function generatorPoly(int $degree): array
    {
        $g = [1];
        for ($i = 0; $i < $degree; $i++) {
            $new = array_fill(0, count($g) + 1, 0);
            for ($j = 0; $j < count($g); $j++) {
                $new[$j]     ^= $g[$j];
                $new[$j + 1] ^= self::gfMul($g[$j], self::$exp[$i]);
            }
            $g = $new;
        }
        return $g;
    }

    /** Systematic RS encoder — returns the ECC codeword tail. */
    private static function computeEcc(array $data, int $eccCount): array
    {
        $gen = self::generatorPoly($eccCount);
        $rem = array_merge($data, array_fill(0, $eccCount, 0));
        $dataLen = count($data);
        $genLen  = count($gen);
        for ($i = 0; $i < $dataLen; $i++) {
            $coef = $rem[$i];
            if ($coef !== 0) {
                for ($j = 0; $j < $genLen; $j++) {
                    $rem[$i + $j] ^= self::gfMul($gen[$j], $coef);
                }
            }
        }
        return array_slice($rem, $dataLen, $eccCount);
    }

    /* ══════════════════════════════════════════════════════════════════
       Public: encode data → 2D matrix of 0/1 modules
       ══════════════════════════════════════════════════════════════════ */
    public static function encode(string $data): array
    {
        self::initGf();

        $bytes = array_values(unpack('C*', $data));
        $len   = count($bytes);
        // 12 bits of overhead (mode 4 + CCI 8); 4-bit terminator; pad to byte.
        // → 78-byte cap comfortably.
        if ($len > self::DATA_CODEWORDS - 2) {
            throw new RuntimeException("QR data too long for V4-L ({$len} bytes; cap 78)");
        }

        // 1. Assemble bit stream: mode | CCI | data | terminator
        $bits = [];
        for ($i = 3; $i >= 0; $i--) $bits[] = (self::MODE_BYTE >> $i) & 1;
        for ($i = 7; $i >= 0; $i--) $bits[] = ($len >> $i) & 1;
        foreach ($bytes as $b) {
            for ($i = 7; $i >= 0; $i--) $bits[] = ($b >> $i) & 1;
        }
        $capacityBits = self::DATA_CODEWORDS * 8;
        for ($i = 0, $n = min(4, $capacityBits - count($bits)); $i < $n; $i++) $bits[] = 0;
        while (count($bits) % 8 !== 0) $bits[] = 0;

        // 2. Pack bits into data codewords
        $codewords = [];
        for ($i = 0, $m = count($bits); $i < $m; $i += 8) {
            $b = 0;
            for ($j = 0; $j < 8; $j++) $b = ($b << 1) | $bits[$i + $j];
            $codewords[] = $b;
        }

        // 3. Pad remaining data codewords with alternating 0xEC 0x11
        $pad = 0xEC;
        while (count($codewords) < self::DATA_CODEWORDS) {
            $codewords[] = $pad;
            $pad = ($pad === 0xEC) ? 0x11 : 0xEC;
        }

        // 4. Reed–Solomon ECC
        $ecc = self::computeEcc($codewords, self::ECC_CODEWORDS);
        $final = array_merge($codewords, $ecc);

        // 5. Unpack final codewords back to a raw bit stream for placement
        $bitStream = [];
        foreach ($final as $b) {
            for ($i = 7; $i >= 0; $i--) $bitStream[] = ($b >> $i) & 1;
        }

        // 6. Build matrix, apply masking, embed format info
        return self::buildMatrix($bitStream);
    }

    /* ══════════════════════════════════════════════════════════════════
       Public: encode → PNG file (standalone rendering)
       ══════════════════════════════════════════════════════════════════ */
    public static function png(string $data, string $file, int $scale = 4, int $margin = 4): void
    {
        $m = self::encode($data);
        $size = self::SIZE;
        $img = $size + 2 * $margin;
        $imw = $img * $scale;
        $gd = imagecreate($imw, $imw);
        $white = imagecolorallocate($gd, 255, 255, 255);
        $black = imagecolorallocate($gd, 0, 0, 0);
        imagefilledrectangle($gd, 0, 0, $imw - 1, $imw - 1, $white);
        for ($r = 0; $r < $size; $r++) {
            for ($c = 0; $c < $size; $c++) {
                if ($m[$r][$c] === 1) {
                    $x = ($c + $margin) * $scale;
                    $y = ($r + $margin) * $scale;
                    imagefilledrectangle($gd, $x, $y, $x + $scale - 1, $y + $scale - 1, $black);
                }
            }
        }
        imagepng($gd, $file);
        imagedestroy($gd);
    }

    /* ══════════════════════════════════════════════════════════════════
       Matrix construction
       ══════════════════════════════════════════════════════════════════ */
    private static function buildMatrix(array $bits): array
    {
        $size = self::SIZE;
        $m = $reserved = [];
        for ($i = 0; $i < $size; $i++) {
            $m[$i]        = array_fill(0, $size, 0);
            $reserved[$i] = array_fill(0, $size, false);
        }

        // Finders + separators
        self::placeFinder($m, $reserved, 0, 0);
        self::placeFinder($m, $reserved, 0, $size - 7);
        self::placeFinder($m, $reserved, $size - 7, 0);
        self::reserveSeparators($reserved);

        // Timing patterns (row 6 / col 6)
        self::placeTiming($m, $reserved);

        // Alignment pattern (V4 has one, centred at 26,26)
        self::placeAlignment($m, $reserved, 26, 26);

        // Always-dark module at (4V+9, 8) = (25, 8) for V=4
        $m[$size - 8][8] = 1;
        $reserved[$size - 8][8] = true;

        // Reserve format-info locations (populated per-mask below)
        self::reserveFormatInfo($reserved);

        // Data + ECC bits in the standard zigzag
        self::placeData($m, $reserved, $bits);

        // Try every mask, keep the one with the lowest penalty
        $bestScore  = PHP_INT_MAX;
        $bestMatrix = null;
        for ($mask = 0; $mask < 8; $mask++) {
            $cand = self::applyMask($m, $reserved, $mask);
            self::placeFormatInfo($cand, self::ECC_L, $mask);
            $score = self::penalty($cand);
            if ($score < $bestScore) {
                $bestScore  = $score;
                $bestMatrix = $cand;
            }
        }
        return $bestMatrix;
    }

    private static function placeFinder(array &$m, array &$reserved, int $row, int $col): void
    {
        for ($r = 0; $r < 7; $r++) {
            for ($c = 0; $c < 7; $c++) {
                $dark = 0;
                if ($r === 0 || $r === 6 || $c === 0 || $c === 6) {
                    $dark = 1;
                } elseif ($r >= 2 && $r <= 4 && $c >= 2 && $c <= 4) {
                    $dark = 1;
                }
                $m[$row + $r][$col + $c] = $dark;
                $reserved[$row + $r][$col + $c] = true;
            }
        }
    }

    private static function reserveSeparators(array &$reserved): void
    {
        $size = self::SIZE;
        for ($i = 0; $i <= 7; $i++) {
            $reserved[7][$i] = true;
            $reserved[$i][7] = true;
            $reserved[7][$size - 8 + $i] = true;
            $reserved[$i][$size - 8] = true;
            $reserved[$size - 8][$i] = true;
            $reserved[$size - 8 + $i][7] = true;
        }
    }

    private static function placeTiming(array &$m, array &$reserved): void
    {
        $size = self::SIZE;
        for ($i = 8; $i < $size - 8; $i++) {
            $bit = 1 - ($i % 2);
            $m[6][$i] = $bit;
            $m[$i][6] = $bit;
            $reserved[6][$i] = true;
            $reserved[$i][6] = true;
        }
    }

    private static function placeAlignment(array &$m, array &$reserved, int $cy, int $cx): void
    {
        for ($dr = -2; $dr <= 2; $dr++) {
            for ($dc = -2; $dc <= 2; $dc++) {
                $r = $cy + $dr;
                $c = $cx + $dc;
                $dark = 0;
                if (abs($dr) === 2 || abs($dc) === 2 || ($dr === 0 && $dc === 0)) {
                    $dark = 1;
                }
                $m[$r][$c] = $dark;
                $reserved[$r][$c] = true;
            }
        }
    }

    private static function reserveFormatInfo(array &$reserved): void
    {
        $size = self::SIZE;
        for ($i = 0; $i <= 8; $i++) {
            if ($i !== 6) { $reserved[8][$i] = true; $reserved[$i][8] = true; }
        }
        for ($i = 0; $i < 8; $i++) $reserved[8][$size - 8 + $i] = true;
        for ($i = 0; $i < 7; $i++) $reserved[$size - 7 + $i][8] = true;
    }

    /**
     * Standard zigzag: right-to-left 2-column strips, alternating vertical
     * direction each strip, skipping the vertical timing column (col 6).
     */
    private static function placeData(array &$m, array $reserved, array $bits): void
    {
        $size = self::SIZE;
        $idx  = 0;
        $up   = true;
        $bitCount = count($bits);

        for ($col = $size - 1; $col > 0; $col -= 2) {
            if ($col === 6) $col--;
            for ($j = 0; $j < $size; $j++) {
                $row = $up ? ($size - 1 - $j) : $j;
                for ($k = 0; $k < 2; $k++) {
                    $c = $col - $k;
                    if (!$reserved[$row][$c] && $idx < $bitCount) {
                        $m[$row][$c] = $bits[$idx++];
                    }
                }
            }
            $up = !$up;
        }
    }

    private static function applyMask(array $m, array $reserved, int $mask): array
    {
        $size = self::SIZE;
        for ($r = 0; $r < $size; $r++) {
            for ($c = 0; $c < $size; $c++) {
                if ($reserved[$r][$c]) continue;
                switch ($mask) {
                    case 0: $flip = (($r + $c) % 2) === 0; break;
                    case 1: $flip = ($r % 2) === 0; break;
                    case 2: $flip = ($c % 3) === 0; break;
                    case 3: $flip = (($r + $c) % 3) === 0; break;
                    case 4: $flip = ((intdiv($r, 2) + intdiv($c, 3)) % 2) === 0; break;
                    case 5: $flip = ((($r * $c) % 2) + (($r * $c) % 3)) === 0; break;
                    case 6: $flip = (((($r * $c) % 2) + (($r * $c) % 3)) % 2) === 0; break;
                    case 7: $flip = (((($r + $c) % 2) + (($r * $c) % 3)) % 2) === 0; break;
                    default: $flip = false;
                }
                if ($flip) $m[$r][$c] ^= 1;
            }
        }
        return $m;
    }

    /**
     * BCH(15,5) + XOR mask 0x5412, then place in both redundant locations.
     * Called on an already-masked matrix.
     */
    private static function placeFormatInfo(array &$m, int $eccLevel, int $mask): void
    {
        $size = self::SIZE;
        $data = ($eccLevel << 3) | $mask;
        // 10-bit BCH remainder using generator 0x537
        $rem = $data << 10;
        for ($i = 14; $i >= 10; $i--) {
            if (($rem >> $i) & 1) $rem ^= 0x537 << ($i - 10);
        }
        $bits = ((($data << 10) | ($rem & 0x3FF)) ^ 0x5412) & 0x7FFF;

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

            // Copy 1 — around top-left finder
            if ($i < 6)          $m[8][$i]        = $b;
            elseif ($i === 6)    $m[8][7]         = $b;
            elseif ($i === 7)    $m[8][8]         = $b;
            elseif ($i === 8)    $m[7][8]         = $b;
            else                 $m[14 - $i][8]   = $b;

            // Copy 2 — top-right + bottom-left
            if ($i <= 6)         $m[$size - 1 - $i][8]    = $b;
            else                 $m[8][$size - 15 + $i]   = $b;
        }
    }

    /* ── Penalty score (spec §8.8.2) ──────────────────────────────── */
    private static function penalty(array $m): int
    {
        $size = self::SIZE;
        $n = 0;

        // N1: runs of ≥5 same-colour modules (rows + cols)
        for ($r = 0; $r < $size; $r++) {
            $run = 1;
            for ($c = 1; $c < $size; $c++) {
                if ($m[$r][$c] === $m[$r][$c - 1]) {
                    $run++;
                    if ($run === 5)      $n += 3;
                    elseif ($run > 5)    $n += 1;
                } else {
                    $run = 1;
                }
            }
        }
        for ($c = 0; $c < $size; $c++) {
            $run = 1;
            for ($r = 1; $r < $size; $r++) {
                if ($m[$r][$c] === $m[$r - 1][$c]) {
                    $run++;
                    if ($run === 5)      $n += 3;
                    elseif ($run > 5)    $n += 1;
                } else {
                    $run = 1;
                }
            }
        }

        // N2: 2×2 same-colour blocks
        for ($r = 0; $r < $size - 1; $r++) {
            for ($c = 0; $c < $size - 1; $c++) {
                $v = $m[$r][$c];
                if ($v === $m[$r][$c + 1] && $v === $m[$r + 1][$c] && $v === $m[$r + 1][$c + 1]) {
                    $n += 3;
                }
            }
        }

        // N3: 1:1:3:1:1 finder-lookalike surrounded by 4 modules of quiet
        static $pat1 = [1,0,1,1,1,0,1,0,0,0,0];
        static $pat2 = [0,0,0,0,1,0,1,1,1,0,1];
        for ($r = 0; $r < $size; $r++) {
            for ($c = 0; $c <= $size - 11; $c++) {
                $ok1 = $ok2 = true;
                for ($k = 0; $k < 11; $k++) {
                    if ($m[$r][$c + $k] !== $pat1[$k]) $ok1 = false;
                    if ($m[$r][$c + $k] !== $pat2[$k]) $ok2 = false;
                    if (!$ok1 && !$ok2) break;
                }
                if ($ok1) $n += 40;
                if ($ok2) $n += 40;
            }
        }
        for ($c = 0; $c < $size; $c++) {
            for ($r = 0; $r <= $size - 11; $r++) {
                $ok1 = $ok2 = true;
                for ($k = 0; $k < 11; $k++) {
                    if ($m[$r + $k][$c] !== $pat1[$k]) $ok1 = false;
                    if ($m[$r + $k][$c] !== $pat2[$k]) $ok2 = false;
                    if (!$ok1 && !$ok2) break;
                }
                if ($ok1) $n += 40;
                if ($ok2) $n += 40;
            }
        }

        // N4: 10 × ⌊|darkPct − 50|/5⌋
        $dark = 0;
        for ($r = 0; $r < $size; $r++) {
            for ($c = 0; $c < $size; $c++) {
                if ($m[$r][$c] === 1) $dark++;
            }
        }
        $pct = ($dark * 100) / ($size * $size);
        $n += intdiv((int)abs($pct - 50), 5) * 10;

        return $n;
    }
}
