<?php

declare(strict_types=1);

namespace App\MessageHandler;

use App\Entity\RenderedAsset;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Enum\RenderStatus;
use App\Message\RenderImageMessage;
use App\Repository\ImageRepository;
use App\Repository\RenderedAssetRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class RenderImageMessageHandler
{
    // Waveshare Spectra 6 palette — must match gen_screens.py
    private const PALETTE = [
        0x0 => [26,  26,  26 ],  // BLACK
        0x1 => [245, 245, 240],  // WHITE
        0x2 => [240, 208, 0  ],  // YELLOW
        0x3 => [192, 48,  32 ],  // RED
        0x5 => [24,  64,  192],  // BLUE
        0x6 => [16,  160, 64 ],  // GREEN
    ];

    public function __construct(
        private readonly ImageRepository        $imageRepo,
        private readonly RenderedAssetRepository $assetRepo,
        private readonly EntityManagerInterface  $em,
        #[Autowire('%kernel.project_dir%')]
        private readonly string $projectDir,
    ) {}

    public function __invoke(RenderImageMessage $msg): void
    {
        $image = $this->imageRepo->find($msg->imageId);
        if (!$image || $image->isDeleted()) {
            return;
        }

        $model       = DeviceModel::from($msg->deviceModel);
        $orientation = Orientation::from($msg->orientation);

        $asset = $this->assetRepo->findOneBy([
            'image'       => $image,
            'deviceModel' => $model,
            'orientation' => $orientation,
        ]);
        if (!$asset) {
            $asset = (new RenderedAsset())
                ->setImage($image)
                ->setDeviceModel($model)
                ->setOrientation($orientation);
            $this->em->persist($asset);
        }

        $asset->setStatus(RenderStatus::Processing);
        $this->em->flush();

        try {
            // Prefer composited.jpg (cropped+stickered) over the raw original
            $compositedPath = $this->projectDir . '/var/storage/images/' . $image->getId() . '/composited.jpg';
            $originalPath   = file_exists($compositedPath)
                ? $compositedPath
                : $this->projectDir . '/' . $image->getStoragePath();
            $width        = $model->width($orientation);
            $height       = $model->height($orientation);
            $bin          = $this->renderToBin($originalPath, $width, $height, $orientation, $model);

            $relPath = 'var/storage/images/' . $image->getId()
                . '/' . $model->value . '_' . $orientation->value . '.bin';
            $absPath = $this->projectDir . '/' . $relPath;
            file_put_contents($absPath, $bin);

            $asset->setFilePath($relPath)
                  ->setStatus(RenderStatus::Ready)
                  ->setRenderedAt(new \DateTimeImmutable());
        } catch (\Throwable) {
            $asset->setStatus(RenderStatus::Failed);
        }

        $this->em->flush();
    }

    private function renderToBin(string $path, int $width, int $height, Orientation $orientation, DeviceModel $model): string
    {
        $imagick = new \Imagick($path);
        $imagick->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE);
        $imagick->setBackgroundColor('white');
        $imagick->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN);
        $imagick->autoOrient();

        // Fit the source into the target box without cropping aggressively.
        // For correctly-cropped photos the source matches the target aspect
        // and `thumbnailImage` produces target dims with no padding; for an
        // aspect mismatch the photo comes out smaller than the target and
        // gets composited onto a white canvas below so it shows up as a
        // letterbox instead of a brutally center-cropped slice.
        $imagick->thumbnailImage($width, $height, true);

        $imagick->setImageColorspace(\Imagick::COLORSPACE_SRGB);

        // Per-pixel adjustments run on the PHOTO before the white canvas is
        // composited under it — otherwise the bars dominate the histogram and
        // would skew any auto-leveling toward white.

        // Auto-levels via normalizeImage (default 2%/2% clip). Empirically
        // contrastStretchImage's args are quantum-range intensity thresholds,
        // not pixel counts, and even small ints (>= ~1) collapse low-tonal-
        // range photos to pure white. normalizeImage uses the image's actual
        // histogram percentiles and produces gentle, correct stretching.
        $imagick->normalizeImage();

        // Boost saturation 130%.  Dark desaturated photos otherwise map almost
        // entirely to BLACK with scattered noise dots from error diffusion.
        $imagick->modulateImage(100, 130, 100);

        // Light sharpen so edges survive the dithering scatter.
        $imagick->sharpenImage(0, 0.8);

        // Now composite onto a white canvas of exact target dims so that any
        // aspect mismatch shows up as letterbox bars, not as a sliced crop.
        if ($imagick->getImageWidth() !== $width || $imagick->getImageHeight() !== $height) {
            $canvas = new \Imagick();
            $canvas->newImage($width, $height, new \ImagickPixel('white'));
            $canvas->setImageFormat('png');
            $offsetX = (int) (($width  - $imagick->getImageWidth())  / 2);
            $offsetY = (int) (($height - $imagick->getImageHeight()) / 2);
            $canvas->compositeImage($imagick, \Imagick::COMPOSITE_OVER, $offsetX, $offsetY);
            $imagick->destroy();
            $imagick = $canvas;
        }

        // Rotate the fitted photo 90° CCW when the user's orientation differs
        // from the panel's natural scan orientation, so the packed .bin's row
        // layout always matches the panel's native scan order. Firmware streams
        // bytes raw — no orientation awareness on-device.
        //
        // V1 (7.3", wide-native) rotates for Portrait. V2 (13.3", tall-native)
        // rotates for Landscape. The model's nativeOrientation() makes the
        // pipeline panel-agnostic — no per-model branches in this file.
        if ($orientation !== $model->nativeOrientation()) {
            $imagick->rotateImage(new \ImagickPixel('white'), -90);
        }

        // Build a strip of 6 palette pixels for remapImage
        $palImagick = new \Imagick();
        foreach (self::PALETTE as $rgb) {
            $hex = sprintf('#%02x%02x%02x', $rgb[0], $rgb[1], $rgb[2]);
            $tmp = new \Imagick();
            $tmp->newImage(1, 1, new \ImagickPixel($hex));
            $tmp->setImageFormat('png');
            $palImagick->addImage($tmp);
            $tmp->destroy();
        }
        $palImagick->resetIterator();
        $palStrip = $palImagick->appendImages(false);

        $imagick->remapImage($palStrip, \Imagick::DITHERMETHOD_FLOYDSTEINBERG);
        $palStrip->destroy();
        $palImagick->destroy();

        // Export as raw RGB bytes
        $imagick->setImageDepth(8);
        $imagick->setFormat('RGB');
        $blob = $imagick->getImageBlob();
        $imagick->destroy();

        // Pack into 4bpp: high nibble = left pixel, low nibble = right pixel
        $output = '';
        $total  = $width * $height;
        for ($i = 0; $i < $total; $i += 2) {
            $base0 = $i * 3;
            $base1 = $base0 + 3;
            $n0    = $this->nearestPalette(ord($blob[$base0]), ord($blob[$base0 + 1]), ord($blob[$base0 + 2]));
            $n1    = $this->nearestPalette(ord($blob[$base1]), ord($blob[$base1 + 1]), ord($blob[$base1 + 2]));
            $output .= chr(($n0 << 4) | $n1);
        }

        return $output;
    }

    private function nearestPalette(int $r, int $g, int $b): int
    {
        $best     = 0x1;
        $bestDist = PHP_INT_MAX;
        foreach (self::PALETTE as $index => $rgb) {
            $dist = ($r - $rgb[0]) ** 2 + ($g - $rgb[1]) ** 2 + ($b - $rgb[2]) ** 2;
            if ($dist < $bestDist) {
                $bestDist = $dist;
                $best     = $index;
            }
        }
        return $best;
    }
}
