<?php
/**
 * Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
 *
 * This file is part of a Moko Consulting project.
 *
 * SPDX-License-Identifier: GPL-3.0-or-later
 */

/**
 * Favicon generator — creates ICO, Apple Touch Icon, and Android icons
 * from a single source image uploaded via the template config.
 *
 * Supports three backends in priority order:
 *   1. GD (fastest, most common)
 *   2. Imagick (common on shared hosting)
 *   3. Pure PHP (zero-dependency fallback using raw PNG manipulation)
 */

defined('_JEXEC') or die;

use Joomla\CMS\Log\Log;

class MokoFaviconHelper
{
    private const SIZES = [
        'apple-touch-icon.png' => [180, 180],
        'favicon-32x32.png'    => [32, 32],
        'favicon-16x16.png'    => [16, 16],
        'android-chrome-192x192.png' => [192, 192],
        'android-chrome-512x512.png' => [512, 512],
    ];

    /**
     * Generate all favicon files from a source image.
     */
    public static function generate(string $sourcePath, string $outputDir): bool
    {
        if (!is_file($sourcePath)) {
            self::log('Favicon: source file not found: ' . $sourcePath, 'warning');
            return false;
        }

        if (!is_dir($outputDir)) {
            mkdir($outputDir, 0755, true);
        }

        $sourceTime = filemtime($sourcePath);
        $stampFile  = $outputDir . '/.favicon_generated';

        if (is_file($stampFile) && filemtime($stampFile) >= $sourceTime) {
            return true;
        }

        // Strip #joomlaImage fragment if present
        $sourcePath = strtok($sourcePath, '#');

        // Select backend
        if (extension_loaded('gd')) {
            $result = self::generateWithGd($sourcePath, $outputDir);
        } elseif (extension_loaded('imagick')) {
            $result = self::generateWithImagick($sourcePath, $outputDir);
        } else {
            $result = self::generatePurePHP($sourcePath, $outputDir);
        }

        if ($result) {
            self::generateManifest($outputDir);
            file_put_contents($stampFile, date('c'));
        }

        return $result;
    }

    // ── GD Backend ──────────────────────────────────────────────────

    private static function generateWithGd(string $sourcePath, string $outputDir): bool
    {
        $imageInfo = @getimagesize($sourcePath);
        if ($imageInfo === false) {
            self::log('Favicon: cannot read image: ' . $sourcePath, 'warning');
            return false;
        }

        $source = match ($imageInfo[2]) {
            IMAGETYPE_PNG  => @imagecreatefrompng($sourcePath),
            IMAGETYPE_JPEG => @imagecreatefromjpeg($sourcePath),
            IMAGETYPE_GIF  => @imagecreatefromgif($sourcePath),
            IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($sourcePath) : false,
            default        => false,
        };

        if (!$source) {
            self::log('Favicon: unsupported image type', 'warning');
            return false;
        }

        imagealphablending($source, false);
        imagesavealpha($source, true);
        $srcW = imagesx($source);
        $srcH = imagesy($source);

        foreach (self::SIZES as $filename => [$w, $h]) {
            $resized = imagecreatetruecolor($w, $h);
            imagealphablending($resized, false);
            imagesavealpha($resized, true);
            imagefill($resized, 0, 0, imagecolorallocatealpha($resized, 0, 0, 0, 127));
            imagecopyresampled($resized, $source, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
            imagepng($resized, $outputDir . '/' . $filename, 9);
            imagedestroy($resized);
        }

        // ICO from GD
        $icoEntries = [];
        foreach ([16, 32] as $size) {
            $resized = imagecreatetruecolor($size, $size);
            imagealphablending($resized, false);
            imagesavealpha($resized, true);
            imagefill($resized, 0, 0, imagecolorallocatealpha($resized, 0, 0, 0, 127));
            imagecopyresampled($resized, $source, 0, 0, 0, 0, $size, $size, $srcW, $srcH);
            ob_start();
            imagepng($resized, null, 9);
            $icoEntries[] = ['size' => $size, 'data' => ob_get_clean()];
            imagedestroy($resized);
        }
        self::writeIco($icoEntries, $outputDir . '/favicon.ico');

        imagedestroy($source);
        self::log('Favicon: generated with GD');
        return true;
    }

    // ── Imagick Backend ─────────────────────────────────────────────

    private static function generateWithImagick(string $sourcePath, string $outputDir): bool
    {
        try {
            foreach (self::SIZES as $filename => [$w, $h]) {
                $img = new \Imagick($sourcePath);
                $img->setImageFormat('png');
                $img->setImageCompressionQuality(95);
                $img->thumbnailImage($w, $h, true);
                // Center on transparent canvas if not square
                $canvas = new \Imagick();
                $canvas->newImage($w, $h, new \ImagickPixel('transparent'), 'png');
                $offsetX = (int)(($w - $img->getImageWidth()) / 2);
                $offsetY = (int)(($h - $img->getImageHeight()) / 2);
                $canvas->compositeImage($img, \Imagick::COMPOSITE_OVER, $offsetX, $offsetY);
                $canvas->writeImage($outputDir . '/' . $filename);
                $img->destroy();
                $canvas->destroy();
            }

            // ICO from Imagick
            $icoEntries = [];
            foreach ([16, 32] as $size) {
                $img = new \Imagick($sourcePath);
                $img->setImageFormat('png');
                $img->thumbnailImage($size, $size, true);
                $icoEntries[] = ['size' => $size, 'data' => (string) $img];
                $img->destroy();
            }
            self::writeIco($icoEntries, $outputDir . '/favicon.ico');

            self::log('Favicon: generated with Imagick');
            return true;
        } catch (\Exception $e) {
            self::log('Favicon: Imagick failed: ' . $e->getMessage(), 'warning');
            return false;
        }
    }

    // ── Pure PHP Backend (zero dependencies) ────────────────────────

    private static function generatePurePHP(string $sourcePath, string $outputDir): bool
    {
        $pngData = @file_get_contents($sourcePath);
        if ($pngData === false) {
            self::log('Favicon: cannot read source file', 'warning');
            return false;
        }

        // Detect format — we can only resize PNG in pure PHP
        // For JPEG/other formats, just copy the source as-is for each size
        $isPng = (substr($pngData, 0, 8) === "\x89PNG\r\n\x1a\n");

        if (!$isPng) {
            // Non-PNG: copy source file for all sizes (no resize capability without extensions)
            foreach (self::SIZES as $filename => [$w, $h]) {
                copy($sourcePath, $outputDir . '/' . $filename);
            }
            // ICO: embed the raw source for 16 and 32 entries
            self::writeIco([
                ['size' => 16, 'data' => $pngData],
                ['size' => 32, 'data' => $pngData],
            ], $outputDir . '/favicon.ico');
            self::log('Favicon: non-PNG source copied without resize (no GD/Imagick)');
            return true;
        }

        // Parse PNG dimensions from IHDR
        $ihdr = self::parsePngIhdr($pngData);
        if (!$ihdr) {
            self::log('Favicon: cannot parse PNG header', 'warning');
            return false;
        }

        $srcW = $ihdr['width'];
        $srcH = $ihdr['height'];

        // Decode PNG to raw RGBA pixel array
        $pixels = self::decodePngToRgba($pngData, $srcW, $srcH, $ihdr);
        if ($pixels === null) {
            // Fallback: copy source for all sizes
            foreach (self::SIZES as $filename => [$w, $h]) {
                copy($sourcePath, $outputDir . '/' . $filename);
            }
            self::writeIco([
                ['size' => 16, 'data' => $pngData],
                ['size' => 32, 'data' => $pngData],
            ], $outputDir . '/favicon.ico');
            self::log('Favicon: PNG decode failed, copied source without resize');
            return true;
        }

        // Generate resized PNGs
        foreach (self::SIZES as $filename => [$w, $h]) {
            $resized = self::resizePixels($pixels, $srcW, $srcH, $w, $h);
            $png = self::encodePng($resized, $w, $h);
            file_put_contents($outputDir . '/' . $filename, $png);
        }

        // ICO
        $icoEntries = [];
        foreach ([16, 32] as $size) {
            $resized = self::resizePixels($pixels, $srcW, $srcH, $size, $size);
            $icoEntries[] = ['size' => $size, 'data' => self::encodePng($resized, $size, $size)];
        }
        self::writeIco($icoEntries, $outputDir . '/favicon.ico');

        self::log('Favicon: generated with pure PHP');
        return true;
    }

    /**
     * Parse PNG IHDR chunk.
     */
    private static function parsePngIhdr(string $data): ?array
    {
        if (strlen($data) < 33) return null;
        // Skip 8-byte signature, 4-byte chunk length, 4-byte "IHDR"
        $width     = unpack('N', substr($data, 16, 4))[1];
        $height    = unpack('N', substr($data, 20, 4))[1];
        $bitDepth  = ord($data[24]);
        $colorType = ord($data[25]);
        return ['width' => $width, 'height' => $height, 'bitDepth' => $bitDepth, 'colorType' => $colorType];
    }

    /**
     * Decode PNG to flat RGBA array using zlib decompression.
     *
     * @return array|null  Flat array of [r,g,b,a, r,g,b,a, ...] or null on failure.
     */
    private static function decodePngToRgba(string $data, int $w, int $h, array $ihdr): ?array
    {
        // Only support 8-bit RGBA (color type 6) and RGB (color type 2) for simplicity
        $colorType = $ihdr['colorType'];
        $bitDepth  = $ihdr['bitDepth'];

        if ($bitDepth !== 8 || ($colorType !== 6 && $colorType !== 2 && $colorType !== 3)) {
            return null; // Unsupported format
        }

        // Collect all IDAT chunks
        $idatData = '';
        $pos = 8; // Skip PNG signature
        $palette = null;
        $trns = null;

        while ($pos < strlen($data) - 4) {
            $chunkLen = unpack('N', substr($data, $pos, 4))[1];
            $chunkType = substr($data, $pos + 4, 4);

            if ($chunkType === 'IDAT') {
                $idatData .= substr($data, $pos + 8, $chunkLen);
            } elseif ($chunkType === 'PLTE') {
                $palette = substr($data, $pos + 8, $chunkLen);
            } elseif ($chunkType === 'tRNS') {
                $trns = substr($data, $pos + 8, $chunkLen);
            } elseif ($chunkType === 'IEND') {
                break;
            }

            $pos += 12 + $chunkLen; // 4 len + 4 type + data + 4 crc
        }

        $raw = @gzuncompress($idatData);
        if ($raw === false) {
            $raw = @gzinflate($idatData);
        }
        if ($raw === false) {
            // Try with zlib header
            $raw = @gzinflate(substr($idatData, 2));
        }
        if ($raw === false) {
            return null;
        }

        $bpp = $colorType === 6 ? 4 : ($colorType === 2 ? 3 : 1); // bytes per pixel
        $stride = 1 + $w * $bpp; // +1 for filter byte per row
        $pixels = [];

        $prevRow = array_fill(0, $w * $bpp, 0);

        for ($y = 0; $y < $h; $y++) {
            $rowStart = $y * $stride;
            if ($rowStart >= strlen($raw)) break;

            $filter = ord($raw[$rowStart]);
            $row = [];

            for ($x = 0; $x < $w * $bpp; $x++) {
                $rawByte = ord($raw[$rowStart + 1 + $x]);
                $a = ($x >= $bpp) ? $row[$x - $bpp] : 0;
                $b = $prevRow[$x];
                $c = ($x >= $bpp) ? $prevRow[$x - $bpp] : 0;

                $val = match ($filter) {
                    0 => $rawByte,
                    1 => ($rawByte + $a) & 0xFF,
                    2 => ($rawByte + $b) & 0xFF,
                    3 => ($rawByte + (int)(($a + $b) / 2)) & 0xFF,
                    4 => ($rawByte + self::paethPredictor($a, $b, $c)) & 0xFF,
                    default => $rawByte,
                };

                $row[] = $val;
            }

            // Convert row to RGBA
            for ($x = 0; $x < $w; $x++) {
                if ($colorType === 6) { // RGBA
                    $pixels[] = $row[$x * 4];
                    $pixels[] = $row[$x * 4 + 1];
                    $pixels[] = $row[$x * 4 + 2];
                    $pixels[] = $row[$x * 4 + 3];
                } elseif ($colorType === 2) { // RGB
                    $pixels[] = $row[$x * 3];
                    $pixels[] = $row[$x * 3 + 1];
                    $pixels[] = $row[$x * 3 + 2];
                    $pixels[] = 255;
                } elseif ($colorType === 3 && $palette) { // Indexed
                    $idx = $row[$x];
                    $pixels[] = ord($palette[$idx * 3]);
                    $pixels[] = ord($palette[$idx * 3 + 1]);
                    $pixels[] = ord($palette[$idx * 3 + 2]);
                    $pixels[] = ($trns && $idx < strlen($trns)) ? ord($trns[$idx]) : 255;
                }
            }

            $prevRow = $row;
        }

        return $pixels;
    }

    private static function paethPredictor(int $a, int $b, int $c): int
    {
        $p = $a + $b - $c;
        $pa = abs($p - $a);
        $pb = abs($p - $b);
        $pc = abs($p - $c);
        if ($pa <= $pb && $pa <= $pc) return $a;
        if ($pb <= $pc) return $b;
        return $c;
    }

    /**
     * Bilinear resize of RGBA pixel array.
     */
    private static function resizePixels(array $src, int $srcW, int $srcH, int $dstW, int $dstH): array
    {
        $dst = [];
        $xRatio = $srcW / $dstW;
        $yRatio = $srcH / $dstH;

        for ($y = 0; $y < $dstH; $y++) {
            $srcY = $y * $yRatio;
            $y0 = (int) $srcY;
            $y1 = min($y0 + 1, $srcH - 1);
            $yFrac = $srcY - $y0;

            for ($x = 0; $x < $dstW; $x++) {
                $srcX = $x * $xRatio;
                $x0 = (int) $srcX;
                $x1 = min($x0 + 1, $srcW - 1);
                $xFrac = $srcX - $x0;

                for ($c = 0; $c < 4; $c++) {
                    $tl = $src[($y0 * $srcW + $x0) * 4 + $c];
                    $tr = $src[($y0 * $srcW + $x1) * 4 + $c];
                    $bl = $src[($y1 * $srcW + $x0) * 4 + $c];
                    $br = $src[($y1 * $srcW + $x1) * 4 + $c];

                    $top = $tl + ($tr - $tl) * $xFrac;
                    $bot = $bl + ($br - $bl) * $xFrac;
                    $dst[] = (int) round($top + ($bot - $top) * $yFrac);
                }
            }
        }

        return $dst;
    }

    /**
     * Encode RGBA pixel array to PNG binary.
     */
    private static function encodePng(array $pixels, int $w, int $h): string
    {
        // Build raw image data with filter byte 0 (None) per row
        $raw = '';
        for ($y = 0; $y < $h; $y++) {
            $raw .= "\x00"; // filter: None
            for ($x = 0; $x < $w; $x++) {
                $i = ($y * $w + $x) * 4;
                $raw .= chr($pixels[$i]) . chr($pixels[$i + 1]) . chr($pixels[$i + 2]) . chr($pixels[$i + 3]);
            }
        }

        $compressed = gzcompress($raw);

        // Build PNG
        $png = "\x89PNG\r\n\x1a\n";

        // IHDR
        $ihdr = pack('NNCCCC', $w, $h, 8, 6, 0, 0, 0); // 8-bit RGBA
        $png .= self::pngChunk('IHDR', $ihdr);

        // IDAT
        $png .= self::pngChunk('IDAT', $compressed);

        // IEND
        $png .= self::pngChunk('IEND', '');

        return $png;
    }

    private static function pngChunk(string $type, string $data): string
    {
        $chunk = $type . $data;
        return pack('N', strlen($data)) . $chunk . pack('N', crc32($chunk));
    }

    // ── Shared Utilities ────────────────────────────────────────────

    /**
     * Write ICO file from PNG data entries.
     */
    private static function writeIco(array $entries, string $outPath): void
    {
        $count = count($entries);
        $ico = pack('vvv', 0, 1, $count);
        $offset = 6 + ($count * 16);
        $imageData = '';

        foreach ($entries as $entry) {
            $size = $entry['size'] >= 256 ? 0 : $entry['size'];
            $dataLen = strlen($entry['data']);
            $ico .= pack('CCCCvvVV', $size, $size, 0, 0, 1, 32, $dataLen, $offset);
            $imageData .= $entry['data'];
            $offset += $dataLen;
        }

        file_put_contents($outPath, $ico . $imageData);
    }

    private static function generateManifest(string $outputDir): void
    {
        $manifest = [
            'icons' => [
                ['src' => 'android-chrome-192x192.png', 'sizes' => '192x192', 'type' => 'image/png'],
                ['src' => 'android-chrome-512x512.png', 'sizes' => '512x512', 'type' => 'image/png'],
            ],
        ];
        file_put_contents(
            $outputDir . '/site.webmanifest',
            json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
        );
    }

    public static function getHeadTags(string $basePath): string
    {
        $basePath = htmlspecialchars(rtrim($basePath, '/'), ENT_QUOTES, 'UTF-8');

        return '<link rel="apple-touch-icon" sizes="180x180" href="' . $basePath . '/apple-touch-icon.png">' . "\n"
             . '<link rel="icon" type="image/png" sizes="32x32" href="' . $basePath . '/favicon-32x32.png">' . "\n"
             . '<link rel="icon" type="image/png" sizes="16x16" href="' . $basePath . '/favicon-16x16.png">' . "\n"
             . '<link rel="manifest" href="' . $basePath . '/site.webmanifest">' . "\n"
             . '<link rel="shortcut icon" href="' . $basePath . '/favicon.ico">' . "\n";
    }

    private static function log(string $message, string $priority = 'info'): void
    {
        $priorities = [
            'info'    => Log::INFO,
            'warning' => Log::WARNING,
            'error'   => Log::ERROR,
        ];

        Log::addLogger(
            ['text_file' => 'mokoonyx.log.php'],
            Log::ALL,
            ['mokoonyx']
        );

        Log::add($message, $priorities[$priority] ?? Log::INFO, 'mokoonyx');
    }
}
