<?php

declare(strict_types=1);

namespace App\Tests\Functional\Controller;

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

class DeviceApiControllerTest extends AppWebTestCase
{
    private function makeDevice(string $mac, $user): Device
    {
        $device = new Device();
        $device->setMac($mac);
        $device->setName('Frame ' . $mac);
        $device->setUser($user);
        $device->setModel(DeviceModel::V1);
        $device->setOrientation(Orientation::Landscape);
        $this->em()->persist($device);
        $this->em()->flush();
        return $device;
    }

    private function makeImage($user): Image
    {
        $image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
        $this->em()->persist($image);
        $this->em()->flush();
        return $image;
    }

    public function test_list_returns_own_devices(): void
    {
        $user   = $this->createUser('list@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:A1', $user);
        $client = $this->loginAs($user);

        $client->request('GET', '/api/devices');

        $this->assertResponseIsSuccessful();
        $data = json_decode($client->getResponse()->getContent(), true);
        $this->assertCount(1, $data);
        $this->assertSame($device->getMac(), $data[0]['mac']);
    }

    public function test_list_unauthenticated_returns_redirect(): void
    {
        $this->client->request('GET', '/api/devices');

        // form_login firewall redirects unauthenticated requests to /login
        $this->assertResponseRedirects('/login');
    }

    public function test_patch_updates_name_orientation_and_interval(): void
    {
        $user   = $this->createUser('patch@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:A2', $user);
        $client = $this->loginAs($user);

        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode([
            'name'                    => 'New Name',
            'orientation'             => 'portrait',
            'rotationIntervalMinutes' => 30,
        ]));

        $this->assertResponseIsSuccessful();
        $data = json_decode($client->getResponse()->getContent(), true);
        $this->assertSame('New Name', $data['name']);
        $this->assertSame('portrait', $data['orientation']);
        $this->assertSame(30, $data['rotationIntervalMinutes']);
    }

    public function test_patch_wrong_users_device_returns_404(): void
    {
        $owner  = $this->createUser('owner@example.com');
        $other  = $this->createUser('other@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:A3', $owner);
        $client = $this->loginAs($other);

        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['name' => 'Hack']));

        $this->assertResponseStatusCodeSame(404);
    }

    public function test_put_lock_sets_locked_image_id(): void
    {
        $user   = $this->createUser('lock@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:A4', $user);
        $image  = $this->makeImage($user);
        $image->approveForDevice($device);
        $this->em()->flush();
        $client = $this->loginAs($user);

        $client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['imageId' => $image->getId()]));

        $this->assertResponseIsSuccessful();
        $data = json_decode($client->getResponse()->getContent(), true);
        $this->assertSame($image->getId(), $data['lockedImageId']);
    }

    // A-06: PUT /lock with image not approved for the device → 422
    public function test_put_lock_unapproved_image_returns_422(): void
    {
        $user   = $this->createUser('lock422@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:A8', $user);
        $image  = $this->makeImage($user);
        // Image is owned by user but NOT approved for this device
        $client = $this->loginAs($user);

        $client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['imageId' => $image->getId()]));

        $this->assertResponseStatusCodeSame(422);
    }

    public function test_put_lock_image_not_owned_by_user_returns_404(): void
    {
        $user1  = $this->createUser('lockown1@example.com');
        $user2  = $this->createUser('lockown2@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:A5', $user1);
        $image  = $this->makeImage($user2);
        $client = $this->loginAs($user1);

        $client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['imageId' => $image->getId()]));

        $this->assertResponseStatusCodeSame(404);
    }

    public function test_delete_lock_clears_locked_image_id(): void
    {
        $user   = $this->createUser('unlock@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:A6', $user);
        $image  = $this->makeImage($user);
        $device->setLockedImage($image);
        $this->em()->flush();
        $client = $this->loginAs($user);

        $client->request('DELETE', '/api/devices/' . $device->getId() . '/lock');

        $this->assertResponseIsSuccessful();
        $data = json_decode($client->getResponse()->getContent(), true);
        $this->assertNull($data['lockedImageId']);
    }

    public function test_put_lock_wrong_users_device_returns_404(): void
    {
        $owner  = $this->createUser('lockwrong1@example.com');
        $other  = $this->createUser('lockwrong2@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:A7', $owner);
        $image  = $this->makeImage($other);
        $client = $this->loginAs($other);

        $client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['imageId' => $image->getId()]));

        $this->assertResponseStatusCodeSame(404);
    }

    public function test_patch_empty_name_returns_422(): void
    {
        $user   = $this->createUser('patchname@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:B1', $user);
        $client = $this->loginAs($user);

        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['name' => '']));

        $this->assertResponseStatusCodeSame(422);
    }

    public function test_patch_invalid_orientation_returns_422(): void
    {
        $user   = $this->createUser('patchorient@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:B2', $user);
        $client = $this->loginAs($user);

        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['orientation' => 'diagonal']));

        $this->assertResponseStatusCodeSame(422);
    }

    public function test_patch_invalid_timezone_returns_422(): void
    {
        $user   = $this->createUser('patchtzone@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:B3', $user);
        $client = $this->loginAs($user);

        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['timezone' => 'Not/Real']));

        $this->assertResponseStatusCodeSame(422);
    }

    public function test_patch_sets_wake_times(): void
    {
        $user   = $this->createUser('patchwake@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:B4', $user);
        $client = $this->loginAs($user);

        // 6:00 AM, 3:00 PM, 7:30 PM expressed as minutes since midnight
        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['wakeTimes' => [6 * 60, 15 * 60, 19 * 60 + 30]]));

        $this->assertResponseIsSuccessful();
        $data = json_decode($client->getResponse()->getContent(), true);
        $this->assertSame([360, 900, 1170], $data['wakeTimes']);
    }

    public function test_patch_rejects_out_of_range_wake_times(): void
    {
        $user   = $this->createUser('patchwakebad@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:B7', $user);
        $client = $this->loginAs($user);

        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['wakeTimes' => [1500]]));

        $this->assertResponseStatusCodeSame(422);
    }

    public function test_patch_clears_wake_times_with_empty_array(): void
    {
        $user   = $this->createUser('patchwakeclear@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:B8', $user);
        $device->setWakeTimes([6 * 60, 18 * 60]);
        $em = static::getContainer()->get('doctrine')->getManager();
        $em->flush();

        $client = $this->loginAs($user);

        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['wakeTimes' => []]));

        $this->assertResponseIsSuccessful();
        $data = json_decode($client->getResponse()->getContent(), true);
        $this->assertSame([], $data['wakeTimes']);
    }

    public function test_patch_sets_uniqueness_window(): void
    {
        $user   = $this->createUser('patchuniq@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:B5', $user);
        $client = $this->loginAs($user);

        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['uniquenessWindow' => 14]));

        $this->assertResponseIsSuccessful();
        $data = json_decode($client->getResponse()->getContent(), true);
        $this->assertSame(14, $data['uniquenessWindow']);
    }

    public function test_patch_sets_valid_timezone(): void
    {
        $user   = $this->createUser('patchtzvld@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:C1', $user);
        $client = $this->loginAs($user);

        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['timezone' => 'America/New_York']));

        $this->assertResponseIsSuccessful();
        $data = json_decode($client->getResponse()->getContent(), true);
        $this->assertSame('America/New_York', $data['timezone']);
    }

    public function test_lock_with_no_image_id_returns_422(): void
    {
        $user   = $this->createUser('locknoimgid@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:C2', $user);
        $client = $this->loginAs($user);

        $client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode([]));

        $this->assertResponseStatusCodeSame(422);
    }

    public function test_unlock_wrong_users_device_returns_404(): void
    {
        $owner  = $this->createUser('unlockown@example.com');
        $other  = $this->createUser('unlockoth@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:C3', $owner);
        $client = $this->loginAs($other);

        $client->request('DELETE', '/api/devices/' . $device->getId() . '/lock');

        $this->assertResponseStatusCodeSame(404);
    }

    // ── Validation branch coverage for PATCH /api/devices/{id} ───────────

    public function test_patch_rejects_non_array_wakeTimes(): void
    {
        $user   = $this->createUser('vbad-array@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:D1', $user);
        $client = $this->loginAs($user);
        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['wakeTimes' => 'not-an-array']));
        $this->assertResponseStatusCodeSame(422);
    }

    public function test_patch_rejects_non_integer_wakeTimes_entries(): void
    {
        $user   = $this->createUser('vbad-non-int@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:D2', $user);
        $client = $this->loginAs($user);
        // Float-as-string is neither int nor digit-only-string → rejected.
        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['wakeTimes' => ['1.5']]));
        $this->assertResponseStatusCodeSame(422);
    }

    public function test_patch_accepts_digit_string_wakeTimes_for_pwa_form_submits(): void
    {
        // The PWA's <select> value comes through as a string when the body is
        // form-encoded; the controller's ctype_digit branch must accept that.
        $user   = $this->createUser('vstr@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:D3', $user);
        $client = $this->loginAs($user);
        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['wakeTimes' => ['360']]));
        $this->assertResponseIsSuccessful();
        $data = json_decode($client->getResponse()->getContent(), true);
        $this->assertSame([360], $data['wakeTimes']);
    }

    public function test_patch_rejects_invalid_rotation_mode(): void
    {
        $user   = $this->createUser('vmode@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:D4', $user);
        $client = $this->loginAs($user);
        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['rotationMode' => 'shuffle-please']));
        $this->assertResponseStatusCodeSame(422);
    }

    public function test_patch_accepts_valid_rotation_modes(): void
    {
        $user   = $this->createUser('vmode-ok@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:D5', $user);
        $client = $this->loginAs($user);
        foreach (['random', 'least_recently_shown', 'newest_upload', 'oldest_upload'] as $mode) {
            $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
                'CONTENT_TYPE' => 'application/json',
            ], json_encode(['rotationMode' => $mode]));
            $this->assertResponseIsSuccessful('mode=' . $mode);
            $data = json_decode($client->getResponse()->getContent(), true);
            $this->assertSame($mode, $data['rotationMode']);
        }
    }

    public function test_patch_sets_prioritize_never_shown(): void
    {
        $user   = $this->createUser('vprio@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:D6', $user);
        $client = $this->loginAs($user);

        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['prioritizeNeverShown' => true]));
        $this->assertResponseIsSuccessful();
        $this->assertTrue(json_decode($client->getResponse()->getContent(), true)['prioritizeNeverShown']);

        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['prioritizeNeverShown' => false]));
        $this->assertFalse(json_decode($client->getResponse()->getContent(), true)['prioritizeNeverShown']);
    }

    public function test_patch_rejects_empty_name(): void
    {
        $user   = $this->createUser('vname@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:D7', $user);
        $client = $this->loginAs($user);
        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['name' => '   ']));
        $this->assertResponseStatusCodeSame(422);
    }

    public function test_patch_rejects_invalid_orientation(): void
    {
        $user   = $this->createUser('vori@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:D8', $user);
        $client = $this->loginAs($user);
        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['orientation' => 'sideways']));
        $this->assertResponseStatusCodeSame(422);
    }

    public function test_patch_rejects_invalid_timezone(): void
    {
        $user   = $this->createUser('vtz@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:D9', $user);
        $client = $this->loginAs($user);
        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['timezone' => 'Mars/Olympus_Mons']));
        $this->assertResponseStatusCodeSame(422);
    }

    public function test_patch_404_for_other_users_device(): void
    {
        $owner = $this->createUser('vown@example.com');
        $other = $this->createUser('vother@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:DA', $owner);
        $client = $this->loginAs($other);
        $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['name' => 'pwn']));
        $this->assertResponseStatusCodeSame(404);
    }

    // ── /preview endpoint coverage ───────────────────────────────────────

    public function test_preview_404_for_other_users_device(): void
    {
        $owner = $this->createUser('pown@example.com');
        $other = $this->createUser('pother@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:E0', $owner);
        $client = $this->loginAs($other);
        $client->request('GET', '/api/devices/' . $device->getId() . '/preview');
        $this->assertResponseStatusCodeSame(404);
    }

    public function test_preview_404_when_device_has_no_current_image(): void
    {
        $user = $this->createUser('pnocur@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:E1', $user);
        $client = $this->loginAs($user);
        $client->request('GET', '/api/devices/' . $device->getId() . '/preview');
        $this->assertResponseStatusCodeSame(404);
        $this->assertStringContainsString('No current image', $client->getResponse()->getContent());
    }

    public function test_preview_404_when_no_ready_render_for_devices_orientation(): void
    {
        $user = $this->createUser('pnoasset@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:E2', $user);
        $image = $this->makeImage($user);
        $device->setCurrentImage($image);
        $this->em()->flush();

        $client = $this->loginAs($user);
        $client->request('GET', '/api/devices/' . $device->getId() . '/preview');
        $this->assertResponseStatusCodeSame(404);
        $this->assertStringContainsString('Render not ready', $client->getResponse()->getContent());
    }

    public function test_preview_404_when_render_file_is_missing_from_disk(): void
    {
        $user = $this->createUser('pmissing@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:E3', $user);
        $image = $this->makeImage($user);
        $device->setCurrentImage($image);

        $asset = (new \App\Entity\RenderedAsset())
            ->setImage($image)
            ->setDeviceModel(DeviceModel::V1)
            ->setOrientation(Orientation::Landscape)
            ->setStatus(\App\Enum\RenderStatus::Ready)
            ->setFilePath('var/storage/images/nope/v1_landscape.bin');
        $this->em()->persist($asset);
        $this->em()->flush();

        $client = $this->loginAs($user);
        $client->request('GET', '/api/devices/' . $device->getId() . '/preview');
        $this->assertResponseStatusCodeSame(404);
        $this->assertStringContainsString('Render file missing', $client->getResponse()->getContent());
    }

    public function test_preview_returns_png_for_landscape_device(): void
    {
        $user = $this->createUser('ppng-l@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:E4', $user);
        $image = $this->makeImage($user);
        $device->setCurrentImage($image);

        // 800×480 EPD = 384,000 pixels. 4bpp packed = 192,000 bytes. Fill
        // with palette index 0x1 (white) so the renderer produces a valid PNG.
        $projectDir = static::getContainer()->getParameter('kernel.project_dir');
        $relPath = 'var/storage/images/test-preview/v1_landscape.bin';
        $absPath = $projectDir . '/' . $relPath;
        if (!is_dir(dirname($absPath))) {
            mkdir(dirname($absPath), 0755, true);
        }
        file_put_contents($absPath, str_repeat(chr(0x11), 192000));

        $asset = (new \App\Entity\RenderedAsset())
            ->setImage($image)
            ->setDeviceModel(DeviceModel::V1)
            ->setOrientation(Orientation::Landscape)
            ->setStatus(\App\Enum\RenderStatus::Ready)
            ->setFilePath($relPath);
        $this->em()->persist($asset);
        $this->em()->flush();

        $client = $this->loginAs($user);
        $client->request('GET', '/api/devices/' . $device->getId() . '/preview');

        $this->assertResponseIsSuccessful();
        $this->assertSame('image/png', $client->getResponse()->headers->get('Content-Type'));

        // Cleanup the bin + the cached png.
        @unlink($absPath);
        @unlink(preg_replace('/\.bin$/', '.png', $absPath));
    }

    public function test_preview_returns_png_for_portrait_device(): void
    {
        // Portrait covers the rotateImage(90) branch in renderBinToPng. Same
        // 192,000-byte buffer (the .bin is always EPD-native 800×480; orientation
        // only affects post-decode rotation).
        $user = $this->createUser('ppng-p@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:E6', $user);
        $device->setOrientation(Orientation::Portrait);
        $image = $this->makeImage($user);
        $device->setCurrentImage($image);

        $projectDir = static::getContainer()->getParameter('kernel.project_dir');
        $relPath = 'var/storage/images/test-preview-portrait/v1_portrait.bin';
        $absPath = $projectDir . '/' . $relPath;
        if (!is_dir(dirname($absPath))) {
            mkdir(dirname($absPath), 0755, true);
        }
        // Mix in some 0x4 nibbles — 0x4 is unused in the Spectra-6 palette;
        // the renderer must fall back to the white default rather than
        // throwing on the unknown index.
        file_put_contents($absPath, str_repeat(chr(0x14), 192000));

        $asset = (new \App\Entity\RenderedAsset())
            ->setImage($image)
            ->setDeviceModel(DeviceModel::V1)
            ->setOrientation(Orientation::Portrait)
            ->setStatus(\App\Enum\RenderStatus::Ready)
            ->setFilePath($relPath);
        $this->em()->persist($asset);
        $this->em()->flush();

        $client = $this->loginAs($user);
        $client->request('GET', '/api/devices/' . $device->getId() . '/preview');
        $this->assertResponseIsSuccessful();
        $this->assertSame('image/png', $client->getResponse()->headers->get('Content-Type'));

        @unlink($absPath);
        @unlink(preg_replace('/\.bin$/', '.png', $absPath));
    }

    // V2 panel (13.3", 1200×1600 portrait-native) + Portrait orientation MUST
    // NOT rotate the preview — Portrait is the natural scan orientation, the
    // render pipeline didn't rotate the source, so the preview must mirror
    // that. The bug before this guard: every V2 portrait preview came out
    // 90° sideways in the web UI.
    public function test_preview_v2_portrait_not_rotated(): void
    {
        $user   = $this->createUser('ppng-v2p@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:E7', $user);
        $device->setModel(DeviceModel::V2);
        $device->setOrientation(Orientation::Portrait);
        $image = $this->makeImage($user);
        $device->setCurrentImage($image);

        // 1200×1600 = 1,920,000 nibbles = 960,000 bytes.
        $projectDir = static::getContainer()->getParameter('kernel.project_dir');
        $relPath = 'var/storage/images/test-preview-v2-portrait/v2_portrait.bin';
        $absPath = $projectDir . '/' . $relPath;
        if (!is_dir(dirname($absPath))) {
            mkdir(dirname($absPath), 0755, true);
        }
        file_put_contents($absPath, str_repeat(chr(0x11), 960000));

        $asset = (new \App\Entity\RenderedAsset())
            ->setImage($image)
            ->setDeviceModel(DeviceModel::V2)
            ->setOrientation(Orientation::Portrait)
            ->setStatus(\App\Enum\RenderStatus::Ready)
            ->setFilePath($relPath);
        $this->em()->persist($asset);
        $this->em()->flush();

        $client = $this->loginAs($user);
        $client->request('GET', '/api/devices/' . $device->getId() . '/preview');
        $this->assertResponseIsSuccessful();
        $this->assertSame('image/png', $client->getResponse()->headers->get('Content-Type'));

        // Decode the PNG and confirm orientation — taller than wide means
        // no spurious 90° rotation happened (would be 1600×1200 if it had).
        $pngPath = preg_replace('/\.bin$/', '.png', $absPath);
        $this->assertFileExists($pngPath);
        $im = new \Imagick($pngPath);
        $this->assertSame(1200, $im->getImageWidth(),  'V2 portrait PNG must be 1200 wide (not rotated)');
        $this->assertSame(1600, $im->getImageHeight(), 'V2 portrait PNG must be 1600 tall (not rotated)');
        $im->destroy();

        @unlink($absPath);
        @unlink($pngPath);
    }

    // V2 panel + Landscape orientation MUST rotate — Landscape is non-native
    // for V2 (portrait-native), so the renderer pre-rotated and the preview
    // needs to rotate back to upright landscape.
    public function test_preview_v2_landscape_rotated(): void
    {
        $user   = $this->createUser('ppng-v2l@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:E8', $user);
        $device->setModel(DeviceModel::V2);
        $device->setOrientation(Orientation::Landscape);
        $image = $this->makeImage($user);
        $device->setCurrentImage($image);

        $projectDir = static::getContainer()->getParameter('kernel.project_dir');
        $relPath = 'var/storage/images/test-preview-v2-landscape/v2_landscape.bin';
        $absPath = $projectDir . '/' . $relPath;
        if (!is_dir(dirname($absPath))) {
            mkdir(dirname($absPath), 0755, true);
        }
        file_put_contents($absPath, str_repeat(chr(0x11), 960000));

        $asset = (new \App\Entity\RenderedAsset())
            ->setImage($image)
            ->setDeviceModel(DeviceModel::V2)
            ->setOrientation(Orientation::Landscape)
            ->setStatus(\App\Enum\RenderStatus::Ready)
            ->setFilePath($relPath);
        $this->em()->persist($asset);
        $this->em()->flush();

        $client = $this->loginAs($user);
        $client->request('GET', '/api/devices/' . $device->getId() . '/preview');
        $this->assertResponseIsSuccessful();

        // The .bin is panel-native 1200×1600 (tall); after the 90° rotation
        // for non-native orientation the PNG must be 1600×1200 (wide).
        $pngPath = preg_replace('/\.bin$/', '.png', $absPath);
        $im = new \Imagick($pngPath);
        $this->assertSame(1600, $im->getImageWidth(),  'V2 landscape PNG must be 1600 wide (rotated)');
        $this->assertSame(1200, $im->getImageHeight(), 'V2 landscape PNG must be 1200 tall (rotated)');
        $im->destroy();

        @unlink($absPath);
        @unlink($pngPath);
    }

    // ── DELETE /api/devices/{id} (sell/give-away) ────────────────────────

    public function test_delete_removes_device_and_purges_history_and_approvals(): void
    {
        $owner  = $this->createUser('del@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:F0', $owner);
        $deviceId = $device->getId();

        // Pre-existing approvals + history we expect the delete to wipe.
        $image = $this->makeImage($owner);
        $image->approveForDevice($device);
        $history = new \App\Entity\DeviceImageHistory($device, $image);
        $this->em()->persist($history);
        $this->em()->flush();

        $client = $this->loginAs($owner);
        $client->request('DELETE', '/api/devices/' . $deviceId);

        $this->assertResponseStatusCodeSame(204);

        $this->em()->clear();
        $this->assertNull($this->em()->find(\App\Entity\Device::class, $deviceId), 'device row removed');

        // Approval revoked: image still exists, but no longer approved for the deleted device id.
        $reloadedImage = $this->em()->find(\App\Entity\Image::class, $image->getId());
        $approvedIds = array_map(
            fn(\App\Entity\Device $d) => $d->getId(),
            $reloadedImage->getApprovedDevices()->toArray(),
        );
        $this->assertNotContains($deviceId, $approvedIds);

        // History rows for the deleted device cascaded out.
        $count = (int) $this->em()->createQueryBuilder()
            ->select('COUNT(h.id)')
            ->from(\App\Entity\DeviceImageHistory::class, 'h')
            ->where('h.device = :id')
            ->setParameter('id', $deviceId)
            ->getQuery()
            ->getSingleScalarResult();
        $this->assertSame(0, $count);
    }

    public function test_delete_404_for_other_users_device(): void
    {
        $owner = $this->createUser('del-own@example.com');
        $other = $this->createUser('del-other@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:F1', $owner);
        $deviceId = $device->getId();

        $client = $this->loginAs($other);
        $client->request('DELETE', '/api/devices/' . $deviceId);

        $this->assertResponseStatusCodeSame(404);

        // Owner's device must still exist — no cross-tenant nuking.
        $this->em()->clear();
        $this->assertNotNull($this->em()->find(\App\Entity\Device::class, $deviceId));
    }

    public function test_delete_unauthenticated_returns_unauthorized(): void
    {
        $owner  = $this->createUser('del-anon@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:F2', $owner);

        $this->client->request('DELETE', '/api/devices/' . $device->getId());

        // Anon hits ROLE_USER firewall first — login redirect.
        $this->assertResponseRedirects();
    }

    // After the seller deletes the device, the next poll from the physical
    // hardware (with that MAC) sees an unknown MAC and the firmware shows the
    // setup QR. Verifies the "delete leaves no server-side claim" guarantee.
    public function test_after_delete_device_poll_returns_404(): void
    {
        $owner  = $this->createUser('del-poll@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:F3', $owner);
        $mac    = $device->getMac();

        $client = $this->loginAs($owner);
        $client->request('DELETE', '/api/devices/' . $device->getId());
        $this->assertResponseStatusCodeSame(204);

        // Anonymous device-poll endpoint — same MAC, must now 404.
        $client->request('GET', '/api/device/' . $mac . '/image');
        $this->assertResponseStatusCodeSame(404);
    }

    public function test_preview_404_when_image_is_soft_deleted(): void
    {
        $user = $this->createUser('pdeleted@example.com');
        $device = $this->makeDevice('AA:BB:CC:DD:EE:E5', $user);
        $image = $this->makeImage($user);
        $device->setCurrentImage($image);
        // Soft-delete the image — preview must refuse rather than serve the
        // last cached render.
        $image->setDeletedAt(new \DateTimeImmutable());
        $this->em()->flush();

        $client = $this->loginAs($user);
        $client->request('GET', '/api/devices/' . $device->getId() . '/preview');
        $this->assertResponseStatusCodeSame(404);
    }
}
