<?php

declare(strict_types=1);

namespace App\Tests\Functional\Controller;

use App\Entity\Device;
use App\Entity\Image;
use App\Entity\RenderedAsset;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Enum\RenderStatus;
use App\Tests\Functional\AppWebTestCase;

class DeviceImageControllerTest extends AppWebTestCase
{
    private const MAC      = 'AA:BB:CC:11:22:33';
    private const BIN_PATH = 'var/storage/images/test-img/v1_landscape.bin';

    private string $binAbsPath;

    protected function setUp(): void
    {
        parent::setUp();
        $projectDir       = static::getContainer()->getParameter('kernel.project_dir');
        $this->binAbsPath = $projectDir . '/' . self::BIN_PATH;

        $dir = dirname($this->binAbsPath);
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }
        file_put_contents($this->binAbsPath, 'FAKEBIN');
    }

    protected function tearDown(): void
    {
        if (file_exists($this->binAbsPath)) {
            unlink($this->binAbsPath);
        }
        parent::tearDown();
    }

    private function createTestSetup(bool $approveImage = true, bool $withReadyAsset = true): array
    {
        $user = $this->createUser('devimg@example.com');

        $device = new Device();
        $device->setMac(self::MAC);
        $device->setName('Test Frame');
        $device->setUser($user);
        $device->setModel(DeviceModel::V1);
        $device->setOrientation(Orientation::Landscape);
        $device->setRotationIntervalMinutes(60);
        $this->em()->persist($device);

        $image = new Image();
        $image->setUser($user)->setOriginalFilename('test.jpg')->setStoragePath('x');
        if ($approveImage) {
            $image->approveForDevice($device);
        }
        $this->em()->persist($image);

        if ($withReadyAsset) {
            $asset = (new RenderedAsset())
                ->setImage($image)
                ->setDeviceModel(DeviceModel::V1)
                ->setOrientation(Orientation::Landscape)
                ->setStatus(RenderStatus::Ready)
                ->setFilePath(self::BIN_PATH)
                ->setRenderedAt(new \DateTimeImmutable());
            $this->em()->persist($asset);
        }

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

        return ['device' => $device, 'image' => $image, 'user' => $user];
    }

    public function test_returns_404_for_unknown_mac(): void
    {
        $this->client->request('GET', '/api/device/FF:FF:FF:FF:FF:FF/image');

        $this->assertResponseStatusCodeSame(404);
    }

    public function test_returns_204_when_no_images(): void
    {
        $user = $this->createUser('noimg@example.com');
        $device = new Device();
        $device->setMac('AA:BB:CC:44:55:66');
        $device->setName('No Image Device');
        $device->setUser($user);
        $this->em()->persist($device);
        $this->em()->flush();

        $this->client->request('GET', '/api/device/AA:BB:CC:44:55:66/image');

        $this->assertResponseStatusCodeSame(204);
    }

    public function test_returns_200_with_binary_body_and_headers(): void
    {
        $this->createTestSetup();

        $this->client->request('GET', '/api/device/' . self::MAC . '/image');

        $this->assertResponseStatusCodeSame(200);
        $response = $this->client->getResponse();
        $this->assertNotEmpty($response->headers->get('X-Image-Id'));
        $this->assertNotEmpty($response->headers->get('X-Interval-Ms'));
        $this->assertSame('application/octet-stream', $response->headers->get('Content-Type'));
    }

    public function test_returns_304_when_current_image_id_matches(): void
    {
        $setup   = $this->createTestSetup();
        $imageId = $setup['image']->getId();

        // First call to advance rotation and get the image
        $this->client->request('GET', '/api/device/' . self::MAC . '/image');
        $this->assertResponseStatusCodeSame(200);

        // Second call with matching X-Current-Image-Id
        $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
            'HTTP_X-Current-Image-Id' => (string) $imageId,
        ]);
        $this->assertResponseStatusCodeSame(304);
    }

    public function test_returns_200_when_current_image_id_is_stale(): void
    {
        $this->createTestSetup();

        $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
            'HTTP_X-Current-Image-Id' => '99999',
        ]);

        $this->assertResponseStatusCodeSame(200);
    }

    public function test_locked_image_served_without_rotation_advance(): void
    {
        $setup    = $this->createTestSetup();
        $device   = $setup['device'];
        $deviceId = $device->getId();
        $image    = $setup['image'];
        $imageId  = $image->getId();

        $device->setLockedImage($image);
        $this->em()->flush();

        $this->assertNull($device->getCurrentImage());

        $this->client->request('GET', '/api/device/' . self::MAC . '/image');

        $this->assertResponseStatusCodeSame(200);

        // After the locked-image poll, currentImage now points at the locked
        // image — the controller sets it directly because RotationService::
        // advance() was bypassed (lock takes precedence). This is what makes
        // Home's preview reflect what the frame is actually showing. The
        // "without rotation advance" guarantee is verified separately by
        // checking that no DeviceImageHistory row was written.
        $this->em()->clear();
        $device = $this->em()->find(\App\Entity\Device::class, $deviceId);
        $this->assertNotNull($device->getCurrentImage());
        $this->assertSame($imageId, $device->getCurrentImage()->getId());

        $historyCount = (int) $this->em()->createQueryBuilder()
            ->select('COUNT(h.id)')
            ->from(\App\Entity\DeviceImageHistory::class, 'h')
            ->where('h.device = :d')
            ->setParameter('d', $device)
            ->getQuery()
            ->getSingleScalarResult();
        $this->assertSame(0, $historyCount, 'lock path must NOT write a history row');
    }

    public function test_returns_304_when_locked_image_matches_current_image_id(): void
    {
        $setup   = $this->createTestSetup();
        $device  = $setup['device'];
        $image   = $setup['image'];
        $imageId = $image->getId();

        // Simulate the device having already received this image at the current
        // orientation and rendered_at — the 304 path now requires all three
        // (image id, orientation, rendered_at) to match.
        $asset = $this->em()->getRepository(RenderedAsset::class)->findOneBy(['image' => $image]);
        $device->setLockedImage($image);
        $device->setCurrentImageOrientation(Orientation::Landscape);
        $device->setCurrentRenderedAt($asset->getRenderedAt());
        $this->em()->flush();

        $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
            'HTTP_X-Current-Image-Id' => (string) $imageId,
        ]);

        $this->assertResponseStatusCodeSame(304);
    }

    public function test_orientation_flip_returns_200_even_when_image_id_matches(): void
    {
        $setup   = $this->createTestSetup();
        $device  = $setup['device'];
        $image   = $setup['image'];
        $imageId = $image->getId();

        // Seed: device receives the image at landscape orientation.
        $this->client->request('GET', '/api/device/' . self::MAC . '/image');
        $this->assertResponseStatusCodeSame(200);

        // User flips device to portrait and a portrait render is ready.
        $this->em()->clear();
        $device = $this->em()->find(Device::class, $setup['device']->getId());
        $device->setOrientation(Orientation::Portrait);
        $portraitAsset = (new RenderedAsset())
            ->setImage($this->em()->find(Image::class, $imageId))
            ->setDeviceModel(DeviceModel::V1)
            ->setOrientation(Orientation::Portrait)
            ->setStatus(RenderStatus::Ready)
            ->setFilePath(self::BIN_PATH);
        $this->em()->persist($portraitAsset);
        $this->em()->flush();

        // Same image ID, but device's stored orientation (landscape) no longer
        // matches the device's current orientation (portrait) → must re-send.
        $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
            'HTTP_X-Current-Image-Id' => (string) $imageId,
        ]);
        $this->assertResponseStatusCodeSame(200);
    }

    public function test_re_render_returns_200_even_when_image_id_and_orientation_match(): void
    {
        $setup    = $this->createTestSetup();
        $deviceId = $setup['device']->getId();
        $imageId  = $setup['image']->getId();

        // Seed: device receives the image once, server stores currentRenderedAt.
        $this->client->request('GET', '/api/device/' . self::MAC . '/image');
        $this->assertResponseStatusCodeSame(200);

        // Simulate a re-render: the asset's rendered_at advances (e.g. user
        // re-cropped the image, RenderImageMessageHandler ran again).
        $this->em()->clear();
        $asset = $this->em()->getRepository(RenderedAsset::class)->findOneBy([
            'image'       => $this->em()->find(Image::class, $imageId),
            'orientation' => Orientation::Landscape,
        ]);
        $asset->setRenderedAt(new \DateTimeImmutable('+1 minute'));
        $this->em()->flush();

        // Same image id, same orientation — but the bytes have changed, so
        // the 304 cache must invalidate and the device must re-fetch.
        $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
            'HTTP_X-Current-Image-Id' => (string) $imageId,
        ]);
        $this->assertResponseStatusCodeSame(200);
    }

    public function test_poll_advances_current_image(): void
    {
        $setup    = $this->createTestSetup();
        $deviceId = $setup['device']->getId();
        $imageId  = $setup['image']->getId();

        $this->client->request('GET', '/api/device/' . self::MAC . '/image');

        $this->assertResponseStatusCodeSame(200);

        // Re-fetch from DB after request clears EM state
        $this->em()->clear();
        $device = $this->em()->find(\App\Entity\Device::class, $deviceId);
        $this->assertNotNull($device->getCurrentImage());
        $this->assertSame($imageId, $device->getCurrentImage()->getId());
    }

    public function test_x_interval_ms_equals_rotation_interval_minutes_times_60000(): void
    {
        $setup  = $this->createTestSetup();
        $device = $setup['device'];

        $this->client->request('GET', '/api/device/' . self::MAC . '/image');

        $response   = $this->client->getResponse();
        $intervalMs = (int) $response->headers->get('X-Interval-Ms');

        $this->assertSame($device->getRotationIntervalMinutes() * 60 * 1000, $intervalMs);
    }

    public function test_last_seen_at_updated_after_200_poll(): void
    {
        $setup    = $this->createTestSetup();
        $deviceId = $setup['device']->getId();

        $this->client->request('GET', '/api/device/' . self::MAC . '/image');

        $this->assertResponseStatusCodeSame(200);

        $this->em()->clear();
        $device = $this->em()->find(\App\Entity\Device::class, $deviceId);
        $this->assertNotNull($device->getLastSeenAt());
    }

    public function test_last_seen_at_updated_after_304_poll(): void
    {
        $setup    = $this->createTestSetup();
        $deviceId = $setup['device']->getId();
        $imageId  = $setup['image']->getId();

        // Seed rotation first
        $this->client->request('GET', '/api/device/' . self::MAC . '/image');

        $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
            'HTTP_X-Current-Image-Id' => (string) $imageId,
        ]);
        $this->assertResponseStatusCodeSame(304);

        // Re-fetch from DB since the EM may have been cleared by the request
        $this->em()->clear();
        $device = $this->em()->find(\App\Entity\Device::class, $deviceId);
        $this->assertNotNull($device->getLastSeenAt());
    }

    // Returns 204 when image is approved but no Ready RenderedAsset exists
    public function test_returns_204_when_no_ready_asset_for_approved_image(): void
    {
        $this->createTestSetup(true, false);

        $this->client->request('GET', '/api/device/' . self::MAC . '/image');

        $this->assertResponseStatusCodeSame(204);
    }

    // Returns 204 when RenderedAsset has Ready status but the file is missing from disk
    public function test_returns_204_when_bin_file_missing_from_disk(): void
    {
        $setup = $this->createTestSetup(true, false);

        $asset = (new RenderedAsset())
            ->setImage($setup['image'])
            ->setDeviceModel(DeviceModel::V1)
            ->setOrientation(Orientation::Landscape)
            ->setStatus(RenderStatus::Ready)
            ->setFilePath('var/storage/images/nonexistent/missing.bin');
        $this->em()->persist($asset);
        $this->em()->flush();

        $this->client->request('GET', '/api/device/' . self::MAC . '/image');

        $this->assertResponseStatusCodeSame(204);
    }

    // When wakeTimes is set and one slot has already passed today, X-Interval-Ms
    // should be > 0 and <= 24h. Use [0] (midnight) so the slot is always past
    // regardless of wall-clock time at test execution.
    public function test_wake_times_interval_used_when_set(): void
    {
        $setup  = $this->createTestSetup();
        $device = $setup['device'];

        $device->setWakeTimes([0])->setTimezone('UTC');
        $this->em()->flush();

        $this->client->request('GET', '/api/device/' . self::MAC . '/image');

        $this->assertResponseStatusCodeSame(200);
        $intervalMs = (int) $this->client->getResponse()->headers->get('X-Interval-Ms');
        $this->assertGreaterThan(0, $intervalMs);
        $this->assertLessThanOrEqual(24 * 60 * 60 * 1000, $intervalMs);
    }

    // With multiple wake times, X-Interval-Ms must point to the *earliest*
    // upcoming time, not just the first in the list. Use evenly-spaced slots
    // including 00:00 so at least one is always past regardless of run time.
    public function test_wake_times_picks_earliest_upcoming(): void
    {
        $setup  = $this->createTestSetup();
        $device = $setup['device'];

        // 00:00, 08:00, 16:00 — gap to next slot is at most 8h regardless of
        // when the test runs, and at least one is always in the past.
        $device->setWakeTimes([0, 8 * 60, 16 * 60])->setTimezone('UTC');
        $this->em()->flush();

        $this->client->request('GET', '/api/device/' . self::MAC . '/image');

        $this->assertResponseStatusCodeSame(200);
        $intervalMs = (int) $this->client->getResponse()->headers->get('X-Interval-Ms');
        $this->assertGreaterThan(0, $intervalMs);
        $this->assertLessThanOrEqual(8 * 60 * 60 * 1000, $intervalMs);
    }

    // FORCE-RESYNC FEATURE: a cold-boot poll (X-Boot-Reason: cold) MUST
    // advance the rotation even when the schedule says we're not due. This
    // is documented behavior — unplug-and-replug = manual refresh — and
    // this test exists specifically to keep the feature from regressing.
    // See feedback_force_resync_via_powercycle.md in agent memory.
    public function test_cold_boot_force_resyncs_off_schedule(): void
    {
        $setup  = $this->createTestSetup();
        $device = $setup['device'];

        // Establish a current image, then configure wakeTimes such that the
        // most-recent-past slot has already been served — schedule says
        // we're NOT due for another rotation right now.
        $this->client->request('GET', '/api/device/' . self::MAC . '/image');
        $this->assertResponseStatusCodeSame(200);
        $imageId = $this->client->getResponse()->headers->get('X-Image-Id');
        $device->setWakeTimes([0])->setTimezone('UTC');
        $this->em()->flush();

        // Sanity-check: a *timer* wake at this point would 304 (no rotation).
        $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
            'HTTP_X_Boot_Reason'      => 'timer',
            'HTTP_X_Current_Image_Id' => $imageId,
        ]);
        $this->assertResponseStatusCodeSame(304, 'timer wake must respect the schedule');

        // The actual feature test: a cold-boot poll force-rotates regardless.
        // It returns 200 (or 304 if the rotation happened to land on the same
        // image again — depends on pool size). The proof is that it ran
        // through the rotation path, which we verify by checking that a new
        // history row was written.
        $beforeCount = $this->countHistory($device);
        $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
            'HTTP_X_Boot_Reason'      => 'cold',
            'HTTP_X_Current_Image_Id' => $imageId,
        ]);
        $afterCount = $this->countHistory($device);

        $this->assertSame(
            $beforeCount + 1,
            $afterCount,
            'cold-boot poll must advance the rotation (write a fresh history row) even off-schedule',
        );
    }

    // RECOVERY HANDSHAKE: X-Draw-Pending: 1 says the device is mid-recovery
    // from a power-loss-during-draw. Even on a cold boot, the server MUST
    // suppress rotation advancement and return the device's current image,
    // so the firmware can repaint from its cached /img.bin instead of
    // chasing a fresh image every reset. Without this override, force-
    // resync defeats the firmware's drawNeeded recovery branch and the
    // device churns through the pool leaving torn frames on the panel.
    public function test_draw_pending_overrides_cold_boot_force_resync(): void
    {
        $setup  = $this->createTestSetup();
        $device = $setup['device'];

        // Land a first image so the device has a current_image set.
        $this->client->request('GET', '/api/device/' . self::MAC . '/image');
        $this->assertResponseStatusCodeSame(200);
        $imageId = $this->client->getResponse()->headers->get('X-Image-Id');

        // Sanity-check the no-pending path: a cold boot at this point WILL
        // force-resync (this is the very behavior the pending header overrides).
        $beforeColdNoPending = $this->countHistory($device);
        $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
            'HTTP_X_Boot_Reason'      => 'cold',
            'HTTP_X_Current_Image_Id' => $imageId,
        ]);
        $afterColdNoPending = $this->countHistory($device);
        $this->assertSame(
            $beforeColdNoPending + 1,
            $afterColdNoPending,
            'sanity: bare cold-boot must force-resync (baseline for the override below)',
        );

        // The actual feature: cold boot + X-Draw-Pending must NOT advance.
        // Rotation advancement is what writes device_image_history rows, so
        // an unchanged count is the proof that advance() was skipped.
        $imageIdAfterFirstCold = $this->client->getResponse()->headers->get('X-Image-Id');
        $beforePending = $this->countHistory($device);
        $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
            'HTTP_X_Boot_Reason'      => 'cold',
            'HTTP_X_Current_Image_Id' => $imageIdAfterFirstCold,
            'HTTP_X_Draw_Pending'     => '1',
        ]);
        $afterPending = $this->countHistory($device);

        $this->assertSame(
            $beforePending,
            $afterPending,
            'X-Draw-Pending must override cold-boot force-resync — no advance, no new history row',
        );
        $this->assertResponseStatusCodeSame(
            304,
            'with draw-pending, the device gets its current image back as 304 (it already has the bytes)',
        );
    }

    // RECOVERY HANDSHAKE: X-Draw-Pending must also suppress the normal
    // schedule-based rotation. The device is asking to redraw what it has,
    // not for a fresh image — regardless of whether the schedule is due.
    public function test_draw_pending_overrides_due_schedule(): void
    {
        $setup  = $this->createTestSetup();
        $device = $setup['device'];

        // Establish current image, then make the schedule "always due"
        // (rotation interval of 0 means every poll is past-due).
        $this->client->request('GET', '/api/device/' . self::MAC . '/image');
        $this->assertResponseStatusCodeSame(200);
        $imageId = $this->client->getResponse()->headers->get('X-Image-Id');
        $device->setRotationIntervalMinutes(1);
        $this->em()->flush();
        // Backdate the served_at so isDue() reports true on the next poll.
        $this->em()->getConnection()->executeStatement(
            'UPDATE device_image_history SET served_at = served_at - INTERVAL \'1 hour\' WHERE device_id = :id',
            ['id' => $device->getId()],
        );

        $before = $this->countHistory($device);
        $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
            'HTTP_X_Boot_Reason'      => 'timer',
            'HTTP_X_Current_Image_Id' => $imageId,
            'HTTP_X_Draw_Pending'     => '1',
        ]);
        $after = $this->countHistory($device);

        $this->assertSame(
            $before,
            $after,
            'draw-pending must skip rotation even when the schedule reports due — no advance, no new history row',
        );
        $this->assertResponseStatusCodeSame(304);
    }

    private function countHistory(\App\Entity\Device $device): int
    {
        return (int) $this->em()
            ->createQueryBuilder()
            ->select('COUNT(h.id)')
            ->from(\App\Entity\DeviceImageHistory::class, 'h')
            ->where('h.device = :d')
            ->setParameter('d', $device)
            ->getQuery()
            ->getSingleScalarResult();
    }

    // BOOTSTRAP BYPASS: a freshly-claimed device has no NVS img_id and
    // therefore sends no X-Current-Image-Id header. The schedule
    // (defaulting to noon-daily) would otherwise refuse to advance for
    // up to 24h, and the buyer would see a dark panel after registering.
    // Bypass schedule-gating in that case and serve whatever's available.
    public function test_bootstrap_poll_advances_even_when_schedule_says_not_due(): void
    {
        $setup  = $this->createTestSetup();
        $device = $setup['device'];

        // First, take the device through a poll cycle so it has currentImage,
        // then configure wakeTimes such that the most-recent past slot has
        // already been served. From this state, an off-schedule timer-wake
        // poll WITH X-Current-Image-Id would 304 (covered elsewhere). But a
        // bootstrap poll (no X-Current-Image-Id, simulating fresh NVS) MUST
        // still advance — the user just claimed and is waiting for an image.
        $this->client->request('GET', '/api/device/' . self::MAC . '/image');
        $this->assertResponseStatusCodeSame(200);
        $device->setWakeTimes([0])->setTimezone('UTC');
        $this->em()->flush();

        $beforeCount = (int) $this->em()->createQueryBuilder()
            ->select('COUNT(h.id)')
            ->from(\App\Entity\DeviceImageHistory::class, 'h')
            ->where('h.device = :d')
            ->setParameter('d', $device)
            ->getQuery()
            ->getSingleScalarResult();

        // No X-Current-Image-Id header — bootstrap.
        $this->client->request('GET', '/api/device/' . self::MAC . '/image');
        $this->assertResponseIsSuccessful();

        $afterCount = (int) $this->em()->createQueryBuilder()
            ->select('COUNT(h.id)')
            ->from(\App\Entity\DeviceImageHistory::class, 'h')
            ->where('h.device = :d')
            ->setParameter('d', $device)
            ->getQuery()
            ->getSingleScalarResult();
        $this->assertSame($beforeCount + 1, $afterCount, 'bootstrap poll wrote a fresh history row → advance() ran');
    }

    // Off-schedule poll (wakeTimes set, but the most-recent past slot has
    // already been served): the controller MUST NOT advance the rotation.
    // It returns 304 (device's X-Current-Image-Id matches the existing
    // currentImage) instead of a fresh 200.
    public function test_off_schedule_poll_returns_304_without_advancing(): void
    {
        $setup  = $this->createTestSetup();
        $device = $setup['device'];

        // First poll establishes a current image while wakeTimes is unset
        // (so isDue is true and rotation runs).
        $this->client->request('GET', '/api/device/' . self::MAC . '/image');
        $this->assertResponseStatusCodeSame(200);
        $imageId = $this->client->getResponse()->headers->get('X-Image-Id');

        // Now configure wakeTimes such that the most-recent past slot has
        // already been served (the poll above wrote a history entry just now,
        // and 00:00 is the most-recent past slot in UTC).
        $device->setWakeTimes([0])->setTimezone('UTC');
        $this->em()->flush();

        // A second poll right now is "off-schedule" — server should NOT
        // advance, and since we report the current image, server should 304.
        $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
            'HTTP_X_Current_Image_Id' => $imageId,
        ]);
        $this->assertResponseStatusCodeSame(304);
    }

    // ── X-Just-Provisioned gate ──────────────────────────────────────────
    //
    // The device sets a "just provisioned" flag in NVS at WiFi-setup time
    // and sends X-Just-Provisioned: 1 on every poll until it sees X-Claimed
    // back. The server's contract:
    //
    //   - Header set + binding is stale → 204 (force the device to its
    //     setup-QR screen instead of leaking the prior owner's photo).
    //   - Header set + binding is fresh → normal response + X-Claimed: 1
    //     so the firmware clears the flag and resumes regular operation.
    //   - Header absent → no special behavior (legacy + steady-state polls).

    public function test_just_provisioned_with_stale_binding_returns_204(): void
    {
        $setup  = $this->createTestSetup();
        $device = $setup['device'];

        // Backdate linkedAt to look stale (older than the FRESH_CLAIM window).
        $ref = new \ReflectionProperty(\App\Entity\Device::class, 'linkedAt');
        $ref->setAccessible(true);
        $ref->setValue($device, new \DateTimeImmutable('-1 hour'));
        $this->em()->flush();

        $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
            'HTTP_X_Just_Provisioned' => '1',
        ]);

        $this->assertResponseStatusCodeSame(204, 'stale binding + just-provisioned must NOT serve the prior owner\'s image');
        $this->assertFalse(
            $this->client->getResponse()->headers->has('X-Claimed'),
            'X-Claimed must not be sent until the binding is fresh',
        );
    }

    public function test_just_provisioned_with_fresh_binding_serves_with_X_Claimed(): void
    {
        $setup = $this->createTestSetup();
        // createTestSetup() doesn't backdate linkedAt — it's now-ish, well
        // within the FRESH_CLAIM window — so this exercises the post-claim
        // transition branch.

        $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
            'HTTP_X_Just_Provisioned' => '1',
        ]);

        $this->assertResponseIsSuccessful();
        $this->assertSame('1', $this->client->getResponse()->headers->get('X-Claimed'));
    }

    public function test_no_just_provisioned_header_means_no_X_Claimed_response_header(): void
    {
        $this->createTestSetup();
        $this->client->request('GET', '/api/device/' . self::MAC . '/image');
        $this->assertResponseIsSuccessful();
        $this->assertFalse($this->client->getResponse()->headers->has('X-Claimed'));
    }

    // Returns 204 when RenderedAsset has Ready status but filePath is null (device.poll.no_asset path)
    public function test_returns_204_when_ready_asset_has_null_file_path(): void
    {
        $setup = $this->createTestSetup(true, false);

        $asset = (new RenderedAsset())
            ->setImage($setup['image'])
            ->setDeviceModel(DeviceModel::V1)
            ->setOrientation(Orientation::Landscape)
            ->setStatus(RenderStatus::Ready)
            ->setFilePath(null);
        $this->em()->persist($asset);
        $this->em()->flush();

        $this->client->request('GET', '/api/device/' . self::MAC . '/image');

        $this->assertResponseStatusCodeSame(204);
    }

    // Poll with X-Panel-Id matching a different DeviceModel must auto-update
    // the device's model. New Devices are created with the V1 default, so a
    // 13.3" unit ends up wrongly flagged until the controller corrects it.
    public function test_x_panel_id_header_updates_device_model(): void
    {
        $setup = $this->createTestSetup(true, false);

        $this->assertSame(DeviceModel::V1, $setup['device']->getModel());

        $this->client->request(
            'GET',
            '/api/device/' . self::MAC . '/image',
            [],
            [],
            ['HTTP_X_PANEL_ID' => 'waveshare-13.3-spectra6'],
        );

        $this->em()->refresh($setup['device']);
        $this->assertSame(DeviceModel::V2, $setup['device']->getModel());
    }

    // Same-model X-Panel-Id is a no-op — no churn on every poll.
    public function test_x_panel_id_header_matching_current_model_does_not_change(): void
    {
        $setup = $this->createTestSetup(true, false);

        $this->client->request(
            'GET',
            '/api/device/' . self::MAC . '/image',
            [],
            [],
            ['HTTP_X_PANEL_ID' => 'waveshare-7.3-spectra6'],
        );

        $this->em()->refresh($setup['device']);
        $this->assertSame(DeviceModel::V1, $setup['device']->getModel());
    }

    // Unknown panel-id strings must be ignored — never silently drop a known
    // device into an unknown state because firmware reported an unrecognised
    // panel.
    public function test_x_panel_id_header_unknown_leaves_model_alone(): void
    {
        $setup = $this->createTestSetup(true, false);

        $this->client->request(
            'GET',
            '/api/device/' . self::MAC . '/image',
            [],
            [],
            ['HTTP_X_PANEL_ID' => 'totally-fake-panel'],
        );

        $this->em()->refresh($setup['device']);
        $this->assertSame(DeviceModel::V1, $setup['device']->getModel());
    }
}
