<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Device;
use App\Entity\Image;
use App\Entity\RenderedAsset;
use App\Entity\User;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Enum\RenderStatus;
use App\Enum\RotationMode;
use App\Service\DeviceSerializer;
use App\Service\DeviceService;
use App\Service\MercurePublisher;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/api/devices')]
#[IsGranted('ROLE_USER')]
class DeviceApiController extends AbstractController
{
    // Spectra 6 palette — must match RenderImageMessageHandler::PALETTE.
    // Used to decode the device's 4bpp .bin into an RGB preview that mirrors
    // exactly what the e-ink displays.
    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(
        #[Autowire('%kernel.project_dir%')]
        private readonly string           $projectDir,
        private readonly DeviceSerializer $serializer,
        private readonly MercurePublisher $mercure,
        private readonly DeviceService    $deviceService,
    ) {}

    #[Route('', name: 'api_devices_list', methods: ['GET'])]
    public function list(EntityManagerInterface $em): JsonResponse
    {
        /** @var User $user */
        $user = $this->getUser();
        $devices = $em->getRepository(Device::class)->findBy(['user' => $user]);

        return $this->json(array_map($this->serializer->serialize(...), $devices));
    }

    #[Route('/{id}', name: 'api_device_update', methods: ['PATCH'])]
    public function update(int $id, Request $request, EntityManagerInterface $em): JsonResponse
    {
        /** @var User $user */
        $user   = $this->getUser();
        $device = $em->getRepository(Device::class)->findOneBy(['id' => $id, 'user' => $user]);

        if (!$device) {
            return $this->json(['error' => 'Device not found'], Response::HTTP_NOT_FOUND);
        }

        $body = json_decode($request->getContent(), true) ?? [];

        if (isset($body['name'])) {
            $name = trim((string) $body['name']);
            if (empty($name)) {
                return $this->json(['error' => 'Name cannot be empty'], Response::HTTP_UNPROCESSABLE_ENTITY);
            }
            $device->setName($name);
        }

        if (isset($body['orientation'])) {
            $orientation = Orientation::tryFrom($body['orientation']);
            if (!$orientation) {
                return $this->json(['error' => 'Invalid orientation'], Response::HTTP_UNPROCESSABLE_ENTITY);
            }
            $device->setOrientation($orientation);
        }

        if (isset($body['rotationIntervalMinutes'])) {
            $device->setRotationIntervalMinutes(max(1, (int) $body['rotationIntervalMinutes']));
        }

        if (array_key_exists('wakeTimes', $body)) {
            $times = $body['wakeTimes'];
            if (!is_array($times)) {
                return $this->json(['error' => 'wakeTimes must be an array'], Response::HTTP_UNPROCESSABLE_ENTITY);
            }
            foreach ($times as $t) {
                if (!is_int($t) && !(is_string($t) && ctype_digit($t))) {
                    return $this->json(['error' => 'wakeTimes must contain integers 0-1439 (minutes since midnight)'], Response::HTTP_UNPROCESSABLE_ENTITY);
                }
                $ti = (int) $t;
                if ($ti < 0 || $ti > 1439) {
                    return $this->json(['error' => 'wakeTimes must contain integers 0-1439 (minutes since midnight)'], Response::HTTP_UNPROCESSABLE_ENTITY);
                }
            }
            $device->setWakeTimes(array_map('intval', $times));
        }

        if (isset($body['timezone'])) {
            try {
                new \DateTimeZone((string) $body['timezone']);
                $device->setTimezone((string) $body['timezone']);
            } catch (\Exception) {
                return $this->json(['error' => 'Invalid timezone identifier'], Response::HTTP_UNPROCESSABLE_ENTITY);
            }
        }

        if (isset($body['uniquenessWindow'])) {
            $device->setUniquenessWindow(max(1, (int) $body['uniquenessWindow']));
        }

        if (isset($body['rotationMode'])) {
            $mode = RotationMode::tryFrom((string) $body['rotationMode']);
            if (!$mode) {
                return $this->json(['error' => 'Invalid rotation mode'], Response::HTTP_UNPROCESSABLE_ENTITY);
            }
            $device->setRotationMode($mode);
        }

        if (array_key_exists('prioritizeNeverShown', $body)) {
            $device->setPrioritizeNeverShown((bool) $body['prioritizeNeverShown']);
        }

        $em->flush();
        $payload = $this->serializer->serialize($device);
        $this->mercure->publishDevice((int) $device->getId(), $payload);

        return $this->json($payload);
    }

    #[Route('/{id}/lock', name: 'api_device_lock', methods: ['PUT'])]
    public function lock(int $id, Request $request, EntityManagerInterface $em): JsonResponse
    {
        /** @var User $user */
        $user   = $this->getUser();
        $device = $em->getRepository(Device::class)->findOneBy(['id' => $id, 'user' => $user]);

        if (!$device) {
            return $this->json(['error' => 'Device not found'], Response::HTTP_NOT_FOUND);
        }

        $body    = json_decode($request->getContent(), true) ?? [];
        $imageId = $body['imageId'] ?? null;

        if (!$imageId) {
            return $this->json(['error' => 'imageId required'], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        $image = $em->getRepository(Image::class)->find($imageId);
        if (!$image || $image->getUser() !== $user) {
            return $this->json(['error' => 'Image not found'], Response::HTTP_NOT_FOUND);
        }

        if (!$image->isApprovedForDevice($device)) {
            return $this->json(['error' => 'Image is not approved for this device'], Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        $device->setLockedImage($image);
        $em->flush();
        $payload = $this->serializer->serialize($device);
        $this->mercure->publishDevice((int) $device->getId(), $payload);

        return $this->json($payload);
    }

    #[Route('/{id}/lock', name: 'api_device_unlock', methods: ['DELETE'])]
    public function unlock(int $id, EntityManagerInterface $em): JsonResponse
    {
        /** @var User $user */
        $user   = $this->getUser();
        $device = $em->getRepository(Device::class)->findOneBy(['id' => $id, 'user' => $user]);

        if (!$device) {
            return $this->json(['error' => 'Device not found'], Response::HTTP_NOT_FOUND);
        }

        $device->setLockedImage(null);
        $em->flush();
        $payload = $this->serializer->serialize($device);
        $this->mercure->publishDevice((int) $device->getId(), $payload);

        return $this->json($payload);
    }

    /**
     * Owner-initiated removal — "I sold this frame / I'm not using it
     * anymore." Revokes image approvals, drops display history, and removes
     * the Device row entirely so the MAC is unclaimed. The next time that
     * physical device polls, the server returns 404 and the device shows
     * its setup QR for whoever provisions it next.
     *
     * Pre-emptive purge: if the user runs this BEFORE handing the device
     * away, the new owner can't accidentally pull their photos even if the
     * seller forgets to hold the BOOT button to wipe NVS first.
     */
    #[Route('/{id}', name: 'api_device_delete', methods: ['DELETE'])]
    public function delete(int $id, EntityManagerInterface $em): Response
    {
        /** @var User $user */
        $user   = $this->getUser();
        $device = $em->getRepository(Device::class)->findOneBy(['id' => $id, 'user' => $user]);

        if (!$device) {
            return $this->json(['error' => 'Device not found'], Response::HTTP_NOT_FOUND);
        }

        $deviceId = (int) $device->getId();
        $this->deviceService->deleteDeviceForOwner($device);

        // Tell any subscribed PWA tabs the device is gone so multi-tab
        // sessions and Mom's-phone-still-watching stay consistent. The
        // payload is a deletion sentinel; the SPA listener splices the row
        // out of its store on receipt.
        $this->mercure->publishDevice($deviceId, ['id' => $deviceId, 'deleted' => true]);

        return new Response(null, Response::HTTP_NO_CONTENT);
    }

    /**
     * Serve a PNG preview of the image **currently shown on the frame**,
     * decoded from the device's rendered 4bpp Spectra-6 .bin so the colors
     * match what the e-ink actually displays.
     *
     * Always uses currentImage — the last image the device pulled — never
     * lockedImage. A lock is a queued override that won't take effect until
     * the device next polls; surfacing it on Home before the device has
     * actually fetched it would lie about what's on the frame.
     */
    #[Route('/{id}/preview', name: 'api_device_preview', methods: ['GET'])]
    public function preview(int $id, EntityManagerInterface $em): Response
    {
        /** @var User $user */
        $user   = $this->getUser();
        $device = $em->getRepository(Device::class)->findOneBy(['id' => $id, 'user' => $user]);
        if (!$device) {
            return $this->json(['error' => 'Device not found'], Response::HTTP_NOT_FOUND);
        }

        $image = $device->getCurrentImage();
        if (!$image || $image->isDeleted()) {
            return $this->json(['error' => 'No current image'], Response::HTTP_NOT_FOUND);
        }

        $asset = $em->getRepository(RenderedAsset::class)->findOneBy([
            'image'       => $image,
            'deviceModel' => $device->getModel(),
            'orientation' => $device->getOrientation(),
            'status'      => RenderStatus::Ready,
        ]);
        if (!$asset?->getFilePath()) {
            return $this->json(['error' => 'Render not ready'], Response::HTTP_NOT_FOUND);
        }

        $binPath = $this->projectDir . '/' . $asset->getFilePath();
        if (!file_exists($binPath)) {
            return $this->json(['error' => 'Render file missing'], Response::HTTP_NOT_FOUND);
        }

        $pngPath = preg_replace('/\.bin$/', '.png', $binPath);
        if (!file_exists($pngPath) || filemtime($pngPath) < filemtime($binPath)) {
            $this->renderBinToPng(
                $binPath,
                $pngPath,
                $device->getModel()->nativeWidth(),
                $device->getModel()->nativeHeight(),
                $device->getOrientation(),
                $device->getModel(),
            );
        }

        $response = new BinaryFileResponse($pngPath);
        $response->headers->set('Content-Type', 'image/png');
        $response->setContentDisposition(
            ResponseHeaderBag::DISPOSITION_INLINE,
            'frame-' . $device->getId() . '.png',
        );
        return $response;
    }

    private function renderBinToPng(string $binPath, string $pngPath, int $width, int $height, Orientation $orientation, DeviceModel $model): void
    {
        $bin   = (string) file_get_contents($binPath);
        $len   = strlen($bin);
        $rgb   = '';
        $white = self::PALETTE[0x1];
        for ($i = 0; $i < $len; $i++) {
            $byte = ord($bin[$i]);
            foreach ([($byte >> 4) & 0xF, $byte & 0xF] as $idx) {
                $c = self::PALETTE[$idx] ?? $white;
                $rgb .= chr($c[0]) . chr($c[1]) . chr($c[2]);
            }
        }

        // Wrap the raw RGB bytes in a PPM (P6) header so Imagick can decode the
        // blob directly. importImagePixels would also work but its current
        // signature requires an array of ints, which is ~70 MB for an 800×480.
        $ppm = "P6\n{$width} {$height}\n255\n" . $rgb;

        $im = new \Imagick();
        $im->readImageBlob($ppm);

        // The .bin is always panel-native scan order. The render pipeline
        // rotates the source 90° CCW only when the user's orientation differs
        // from the panel's native — see RenderImageMessageHandler. Mirror that
        // here so the preview un-rotates exactly when the renderer rotated:
        //   V1 (landscape-native) + Portrait → renderer rotated, preview rotates back.
        //   V2 (portrait-native)  + Portrait → renderer did NOT rotate, no rotate here.
        //   V2 (portrait-native)  + Landscape → renderer rotated, preview rotates back.
        if ($orientation !== $model->nativeOrientation()) {
            $im->rotateImage(new \ImagickPixel('white'), 90);
        }

        $im->setImageFormat('png');
        $im->writeImage($pngPath);
        $im->destroy();
    }
}
