<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Device;
use App\Entity\RenderedAsset;
use App\Enum\DeviceModel;
use App\Enum\RenderStatus;
use App\Service\DeviceSerializer;
use App\Service\MercurePublisher;
use App\Service\RotationService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class DeviceImageController extends AbstractController
{
    public function __construct(
        #[Autowire('%kernel.project_dir%')]
        private readonly string           $projectDir,
        private readonly RotationService  $rotation,
        private readonly LoggerInterface  $logger,
        private readonly DeviceSerializer $serializer,
        private readonly MercurePublisher $mercure,
    ) {}

    private function computeIntervalMs(Device $device): int
    {
        $wakeTimes = $device->getWakeTimes();
        if (!empty($wakeTimes)) {
            $tz  = new \DateTimeZone($device->getTimezone());
            $now = new \DateTimeImmutable('now', $tz);
            $earliest = null;
            foreach ($wakeTimes as $minutes) {
                $candidate = $now->setTime((int) ($minutes / 60), $minutes % 60, 0);
                if ($candidate->getTimestamp() <= $now->getTimestamp()) {
                    $candidate = $candidate->modify('+1 day');
                }
                if ($earliest === null || $candidate < $earliest) {
                    $earliest = $candidate;
                }
            }
            return (int) (($earliest->getTimestamp() - $now->getTimestamp()) * 1000);
        }

        return $device->getRotationIntervalMinutes() * 60 * 1000;
    }

    /** Recent-claim window — see image() for the full semantics. */
    private const FRESH_CLAIM_WINDOW_SECONDS = 300;

    #[Route('/api/device/{mac}/image', name: 'api_device_image', methods: ['GET'])]
    public function image(string $mac, Request $request, EntityManagerInterface $em): Response
    {
        $device = $em->getRepository(Device::class)->findOneBy(['mac' => strtoupper($mac)]);
        if (!$device) {
            $this->logger->warning('device.poll.unknown_mac', ['mac' => $mac]);
            return new Response(null, Response::HTTP_NOT_FOUND);
        }

        // The firmware sends X-Just-Provisioned: 1 on every poll until it
        // sees X-Claimed: 1 in a response. The flag survives only inside the
        // device's NVS — it gets set at WiFi-setup completion (after a BOOT-5s
        // wipe or first-ever boot) and only ever clears once the server has
        // affirmatively acknowledged the user has linked the device.
        //
        // Why we need this: NVS-wiping the device gives the firmware no way
        // to tell the server "I just got reset." So if a frame is sold and
        // the seller forgot to "Remove this frame" via the PWA, the buyer's
        // first poll after WiFi setup would otherwise return 200 with the
        // seller's image — leaking the seller's photos onto the buyer's
        // panel until the buyer happens to navigate to /setup/{mac}.
        //
        // With this header, the server short-circuits to 204 (which the
        // firmware paints as the setup QR) when the binding looks stale.
        // Once the user re-links via /setup/{mac}, linkedAt becomes recent
        // and we transition out of the gate.
        $justProvisioned = $request->headers->get('X-Just-Provisioned') === '1';
        $linkedAtAge     = (new \DateTimeImmutable())->getTimestamp() - $device->getLinkedAt()->getTimestamp();
        $bindingIsFresh  = $linkedAtAge <= self::FRESH_CLAIM_WINDOW_SECONDS;

        $intervalMs     = $this->computeIntervalMs($device);
        $currentImageId = (int) $request->headers->get('X-Current-Image-Id', '-1');
        $device->markSeen();

        // TEMP: power-monitor — surface the firmware's previous-cycle
        // telemetry (awake duration + panel-init time) so we can see
        // whether the PIN_PWR rail cut affects either. Logged on every
        // poll; greppable via `device.poll.power_telemetry`. Remove
        // alongside the firmware's NVS_KEY_PREV_* writes once validated.
        $prevAwakeMs     = $request->headers->get('X-Prev-Awake-Ms');
        $prevPanelInitMs = $request->headers->get('X-Prev-Panel-Init-Ms');
        if ($prevAwakeMs !== null || $prevPanelInitMs !== null) {
            $this->logger->info('device.poll.power_telemetry', [
                'mac'                => $mac,
                'prev_awake_ms'      => $prevAwakeMs,
                'prev_panel_init_ms' => $prevPanelInitMs,
                'boot_reason'        => $request->headers->get('X-Boot-Reason'),
            ]);
        }

        // Auto-correct Device.model from the firmware's X-Panel-Id header. New
        // Devices are created with the default V1 model (see Device entity), so
        // a freshly-claimed 13.3" unit ends up wrongly flagged until its first
        // poll reaches here. Mis-routed renders produce wrong-dimension .bin
        // files that the firmware rejects on size mismatch, blocking the
        // first-image-after-claim flow. Existing rendered assets at the old
        // model stay in storage but become irrelevant — RotationService will
        // dispatch a fresh render at the new model on the next image change.
        $panelIdHeader = (string) $request->headers->get('X-Panel-Id', '');
        if ($panelIdHeader !== '') {
            $detected = DeviceModel::fromPanelId($panelIdHeader);
            if ($detected !== null && $detected !== $device->getModel()) {
                $this->logger->info('[device] panel-id correction', [
                    'mac'  => $device->getMac(),
                    'from' => $device->getModel()->value,
                    'to'   => $detected->value,
                ]);
                $device->setModel($detected);
            }
        }

        // Stamp when we expect the device to call back — the PWA reads this
        // directly so its "next sync" label reflects the schedule the device
        // is actually on, not the freshly-saved one that won't reach it
        // until that next poll.
        $device->setNextPollExpectedAt(
            (new \DateTimeImmutable())->modify('+' . (int) ceil($intervalMs / 1000) . ' seconds')
        );
        // Flush up-front so the 204/no_image/no_asset paths persist these too
        // (they previously didn't flush at all — latent bug for lastSeenAt).
        $em->flush();

        // Push the new state to any subscribed PWA clients. Done before we
        // know which response branch we'll take — lastSeenAt + nextPollExpectedAt
        // moved on every successful poll regardless of image change, and the
        // PWA cares about both. If the 200 path mutates currentImage below,
        // the second flush triggers a second publish to keep it accurate.
        $this->mercure->publishDevice((int) $device->getId(), $this->serializer->serialize($device));

        // The fresh-provisioning gate: refuse to serve images for stale
        // bindings. Forces the device to display the setup QR (its 204 path)
        // until the user re-links via /setup/{mac}.
        if ($justProvisioned && !$bindingIsFresh) {
            $this->logger->info('device.poll.awaiting_claim', [
                'device_id'      => $device->getId(),
                'mac'            => $mac,
                'linked_age_sec' => $linkedAtAge,
            ]);
            $r = new Response(null, Response::HTTP_NO_CONTENT);
            $r->headers->set('X-Interval-Ms', (string) $intervalMs);
            return $r;
        }

        // Past the gate: any response we build below should tell the device
        // it's been acknowledged so it can clear its just-provisioned flag.
        $stampClaimed = static function (Response $r) use ($justProvisioned, $bindingIsFresh): Response {
            if ($justProvisioned && $bindingIsFresh) {
                $r->headers->set('X-Claimed', '1');
            }
            return $r;
        };

        // Locked image bypasses rotation entirely. Otherwise, advance only
        // when the device's configured schedule says it's due — except in
        // three deliberate bypass cases:
        //
        //   1. Cold-boot poll (X-Boot-Reason: cold) — unplug→replug treated
        //      as a manual refresh.
        //   2. Bootstrap poll — the device hasn't sent X-Current-Image-Id,
        //      meaning its NVS img_id is still -1 and it has never
        //      successfully painted an image. Common after a BOOT-button
        //      reset + new claim: the buyer's account has approved images
        //      ready, but the noon-daily schedule says "not due till
        //      tomorrow" so without this bypass the panel sits dark until
        //      the next wakeTime fires. Schedule-gating resumes once the
        //      first image is painted (the device starts sending
        //      X-Current-Image-Id with that id).
        //   3. Schedule says due (the normal case).
        //
        // Timer wakes after first-image otherwise stay schedule-gated.
        //
        // Recovery override: X-Draw-Pending: 1 means the device's drawNeeded
        // NVS flag survived a power-loss-during-draw. We give it back its
        // own current image so the firmware can finish repainting from its
        // cached /img.bin. This explicitly overrides the cold-boot
        // force-resync, because the typical interrupted-draw cause IS a
        // reset that turns the next wake into a cold boot — without this
        // bypass, force-resync chases a fresh image every interruption and
        // the device churns through the rotation leaving torn frames.
        $bootReason     = strtolower((string) $request->headers->get('X-Boot-Reason', ''));
        $forceResync    = ($bootReason === 'cold');
        $wantsBootstrap = $currentImageId < 0;
        $drawPending    = $request->headers->get('X-Draw-Pending') === '1';

        if ($device->getLockedImage() !== null) {
            $image = $device->getLockedImage();
        } elseif ($drawPending) {
            $image = $device->getCurrentImage();
        } elseif ($forceResync || $wantsBootstrap || $this->rotation->isDue($device)) {
            $image = $this->rotation->advance($device);
        } else {
            $image = $device->getCurrentImage();
        }

        if ($image === null) {
            $this->logger->info('device.poll.no_image', [
                'device_id'   => $device->getId(),
                'mac'         => $mac,
                'interval_ms' => $intervalMs,
            ]);
            $r = new Response(null, Response::HTTP_NO_CONTENT);
            $r->headers->set('X-Interval-Ms', (string) $intervalMs);
            return $stampClaimed($r);
        }

        // Asset lookup is needed before the 304 check so we can compare its
        // rendered_at timestamp — otherwise a re-cropped image (same id, same
        // orientation, new bytes) would be incorrectly served as 304.
        $asset = $em->getRepository(RenderedAsset::class)->findOneBy([
            'image'       => $image,
            'deviceModel' => $device->getModel(),
            'orientation' => $device->getOrientation(),
            'status'      => RenderStatus::Ready,
        ]);

        if (!$asset?->getFilePath()) {
            $this->logger->warning('device.poll.no_asset', [
                'device_id'   => $device->getId(),
                'mac'         => $mac,
                'image_id'    => $image->getId(),
                'model'       => $device->getModel()->value,
                'orientation' => $device->getOrientation()->value,
            ]);
            $r = new Response(null, Response::HTTP_NO_CONTENT);
            $r->headers->set('X-Interval-Ms', (string) $intervalMs);
            return $stampClaimed($r);
        }

        // 304: device already has this image — skip the binary transfer and redraw.
        // The image id, the orientation we last served at, AND the asset's
        // rendered_at must all match. A re-render (e.g. after re-crop) advances
        // rendered_at, so the device's cached bytes are stale and we re-send.
        $renderedAt = $asset->getRenderedAt();
        if ($image->getId() === $currentImageId
            && $device->getCurrentImageOrientation() === $device->getOrientation()
            && $renderedAt !== null
            && $device->getCurrentRenderedAt()?->getTimestamp() === $renderedAt->getTimestamp()) {
            // Self-heal currentImage: locked-image polls bypass advance() which
            // would otherwise have set this. Without the assignment, currentImage
            // stays stale — Home would keep showing the previous photo even
            // though the device has been confirming the new one for cycles.
            $device->setCurrentImage($image);
            $em->flush();
            $this->mercure->publishDevice((int) $device->getId(), $this->serializer->serialize($device));

            $this->logger->info('device.poll.no_change', [
                'device_id'   => $device->getId(),
                'mac'         => $mac,
                'image_id'    => $image->getId(),
                'interval_ms' => $intervalMs,
            ]);
            $r = new Response(null, Response::HTTP_NOT_MODIFIED);
            $r->headers->set('X-Image-Id',    (string) $image->getId());
            $r->headers->set('X-Interval-Ms', (string) $intervalMs);
            return $stampClaimed($r);
        }

        $binPath = $this->projectDir . '/' . $asset->getFilePath();
        if (!file_exists($binPath)) {
            $this->logger->error('device.poll.file_missing', [
                'device_id' => $device->getId(),
                'mac'       => $mac,
                'image_id'  => $image->getId(),
                'path'      => $binPath,
            ]);
            $r = new Response(null, Response::HTTP_NO_CONTENT);
            $r->headers->set('X-Interval-Ms', (string) $intervalMs);
            return $stampClaimed($r);
        }

        // Record what the device is now showing, plus the orientation and
        // rendered_at we served at (so the next 304 check can detect a flip
        // or a re-render and force a re-fetch).
        //
        // currentImage must be set here for the locked-image path: rotation
        // is bypassed when a lock is in effect, so RotationService.advance()
        // never runs to update currentImage. Without this assignment, Home
        // would keep showing the previous photo even after the device pulled
        // the locked one.
        $device->setCurrentImage($image);
        $device->setCurrentImageOrientation($device->getOrientation());
        $device->setCurrentRenderedAt($renderedAt);
        $em->flush();

        // Re-publish: currentImageId just changed, so the SPA needs the
        // updated thumbnail URL. Cheap; the hub deduplicates per topic.
        $this->mercure->publishDevice((int) $device->getId(), $this->serializer->serialize($device));

        $this->logger->info('device.poll.served', [
            'device_id'   => $device->getId(),
            'mac'         => $mac,
            'image_id'    => $image->getId(),
            'orientation' => $device->getOrientation()->value,
            'interval_ms' => $intervalMs,
            'bytes'       => filesize($binPath),
        ]);

        // SHA-256 of the .bin lets the device verify integrity end-to-end:
        // anything that gets corrupted between Imagick's render and the
        // ESP32 framebuffer (TCP edge cases, memory glitch, partial flush)
        // is caught before we commit the bytes to NVS or paint the panel.
        // The ESP32-S3 has hardware SHA so the verification is essentially
        // free on the device side.
        $response = new BinaryFileResponse($binPath);
        $response->headers->set('Content-Type',   'application/octet-stream');
        $response->headers->set('X-Image-Id',     (string) $image->getId());
        $response->headers->set('X-Image-Sha256', hash_file('sha256', $binPath));
        $response->headers->set('X-Interval-Ms',  (string) $intervalMs);

        return $stampClaimed($response);
    }
}
