<?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'];

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

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

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

        $this->assertResponseStatusCodeSame(200);

        // Re-fetch from DB; currentImage should still be null (advance() was never called)
        $this->em()->clear();
        $device = $this->em()->find(\App\Entity\Device::class, $deviceId);
        $this->assertNull($device->getCurrentImage());
    }

    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 wakeHour is set, X-Interval-Ms should be > 0 and <= 24h in ms
    public function test_wake_hour_interval_used_when_set(): void
    {
        $setup  = $this->createTestSetup();
        $device = $setup['device'];

        $device->setWakeHour(3)->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);
    }

    // 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);
    }
}
