<?php

declare(strict_types=1);

namespace App\Service;

use App\Entity\Device;
use App\Entity\DeviceImageHistory;
use App\Entity\Image;
use App\Enum\RenderStatus;
use App\Enum\RotationMode;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;

class RotationService
{
    public function __construct(
        private readonly EntityManagerInterface $em,
        private readonly LoggerInterface $logger,
    ) {}

    /**
     * True when the device's configured schedule says a rotation should occur
     * right now. The contract:
     *
     *   - In wakeTimes mode: there must be a wake time that has already passed
     *     today AND no history entry exists since that boundary.
     *   - In interval mode: the most recent history entry (if any) must be at
     *     least `rotationIntervalMinutes` old. A device with no history is due.
     *
     * Used by both AdvanceRotationMessageHandler (scheduler-driven) and
     * DeviceImageController (poll-driven) so off-schedule polls don't
     * surprise the user with an unrequested rotation — see the user-visible
     * regression the original poll-rotates-unconditionally code caused.
     */
    public function isDue(Device $device): bool
    {
        $wakeTimes = $device->getWakeTimes();
        if (!empty($wakeTimes)) {
            $tz  = new \DateTimeZone($device->getTimezone());
            $now = new \DateTimeImmutable('now', $tz);

            // Find the most recent wake time that has already passed today.
            // If none have hit yet, the next slot is in the future — not due.
            $boundary = null;
            foreach ($wakeTimes as $minutes) {
                $candidate = $now->setTime((int) ($minutes / 60), $minutes % 60, 0);
                if ($candidate <= $now && ($boundary === null || $candidate > $boundary)) {
                    $boundary = $candidate;
                }
            }
            if ($boundary === null) {
                return false;
            }

            // device_image_history.served_at is `timestamp without time zone`
            // and entries are written by `new DateTimeImmutable()` (UTC). The
            // boundary above carries the device's local tz, which Doctrine
            // would format with local-tz components — leading to a string
            // comparison against UTC-formatted rows in PostgreSQL. For
            // anything other than UTC that mismatch quietly breaks `isDue`:
            // west-of-UTC tzs falsely match every same-day row (rotation
            // never fires); east-of-UTC tzs falsely miss every row (rotation
            // fires every poll). Bind in UTC so both sides agree.
            $boundaryUtc = $boundary->setTimezone(new \DateTimeZone('UTC'));

            $entry = $this->em->createQueryBuilder()
                ->select('h')
                ->from(DeviceImageHistory::class, 'h')
                ->where('h.device = :device')
                ->andWhere('h.servedAt >= :wakeTime')
                ->setParameter('device', $device)
                ->setParameter('wakeTime', $boundaryUtc)
                ->setMaxResults(1)
                ->getQuery()
                ->getOneOrNullResult();

            return $entry === null;
        }

        // Interval-based: due if last history is older than rotationIntervalMinutes.
        $last = $this->em->createQueryBuilder()
            ->select('h')
            ->from(DeviceImageHistory::class, 'h')
            ->where('h.device = :device')
            ->setParameter('device', $device)
            ->orderBy('h.servedAt', 'DESC')
            ->setMaxResults(1)
            ->getQuery()
            ->getOneOrNullResult();

        if ($last === null) {
            return true;
        }

        $elapsed = (new \DateTimeImmutable())->getTimestamp() - $last->getServedAt()->getTimestamp();
        return $elapsed >= ($device->getRotationIntervalMinutes() * 60);
    }

    /**
     * Select the next image for the device, record history, update currentImage.
     * Returns null if no ready images exist in the pool.
     *
     * Pipeline:
     *   1. Pull the device's ready/approved pool.
     *   2. Drop the last `uniquenessWindow` served — fall back to the full pool
     *      if that empties the candidate set.
     *   3. If `prioritizeNeverShown` is on AND any candidates have never been
     *      served on this device, narrow to those.
     *   4. Apply the selection mode to pick exactly one image.
     */
    public function advance(Device $device): ?Image
    {
        $pool = $this->readyPool($device);

        if (empty($pool)) {
            $this->logger->info('rotation.no_ready_images', ['device_id' => $device->getId()]);
            return null;
        }

        $window    = min($device->getUniquenessWindow(), count($pool));
        $recentIds = $this->recentImageIds($device, $window);

        $candidates = array_values(array_filter(
            $pool,
            static fn(Image $i) => !in_array($i->getId(), $recentIds, true),
        ));

        if (empty($candidates)) {
            $candidates = $pool;
        }

        $neverShownPreferred = false;
        if ($device->isPrioritizeNeverShown()) {
            $shownIds = $this->everShownImageIds($device);
            $neverShown = array_values(array_filter(
                $candidates,
                static fn(Image $i) => !in_array($i->getId(), $shownIds, true),
            ));
            if (!empty($neverShown)) {
                $candidates = $neverShown;
                $neverShownPreferred = true;
            }
        }

        $image = $this->pickByMode($device, $candidates);

        $this->em->persist(new DeviceImageHistory($device, $image));
        $device->setCurrentImage($image);
        $this->em->flush();

        $this->logger->info('rotation.advanced', [
            'device_id'             => $device->getId(),
            'image_id'              => $image->getId(),
            'pool_size'             => count($pool),
            'recent_ids'            => $recentIds,
            'mode'                  => $device->getRotationMode()->value,
            'never_shown_preferred' => $neverShownPreferred,
        ]);

        return $image;
    }

    /** @param Image[] $candidates */
    private function pickByMode(Device $device, array $candidates): Image
    {
        return match ($device->getRotationMode()) {
            RotationMode::Random             => $candidates[array_rand($candidates)],
            RotationMode::LeastRecentlyShown => $this->pickLeastRecentlyShown($device, $candidates),
            RotationMode::OldestUpload       => $this->sortedByUpload($candidates, ascending: true)[0],
            RotationMode::NewestUpload       => $this->sortedByUpload($candidates, ascending: false)[0],
        };
    }

    /**
     * @param  Image[] $candidates
     * @return Image[]
     */
    private function sortedByUpload(array $candidates, bool $ascending): array
    {
        usort(
            $candidates,
            $ascending
                ? static fn(Image $a, Image $b) => $a->getUploadedAt() <=> $b->getUploadedAt()
                : static fn(Image $a, Image $b) => $b->getUploadedAt() <=> $a->getUploadedAt(),
        );
        return $candidates;
    }

    /**
     * Pick the candidate whose most recent serve on this device is oldest.
     * Never-shown candidates win because they have no history row at all.
     *
     * @param Image[] $candidates
     */
    private function pickLeastRecentlyShown(Device $device, array $candidates): Image
    {
        $imageIds = array_map(static fn(Image $i) => $i->getId(), $candidates);

        $rows = $this->em->createQueryBuilder()
            ->select('IDENTITY(h.image) AS image_id', 'MAX(h.servedAt) AS last_served')
            ->from(DeviceImageHistory::class, 'h')
            ->where('h.device = :device')
            ->andWhere('h.image IN (:image_ids)')
            ->groupBy('h.image')
            ->setParameter('device', $device)
            ->setParameter('image_ids', $imageIds)
            ->getQuery()
            ->getScalarResult();

        $lastServed = [];
        foreach ($rows as $row) {
            $lastServed[(int) $row['image_id']] = $row['last_served']; // 'Y-m-d H:i:s' or null
        }

        usort($candidates, static function (Image $a, Image $b) use ($lastServed) {
            $la = $lastServed[$a->getId()] ?? null;
            $lb = $lastServed[$b->getId()] ?? null;
            if ($la === null && $lb === null) return $a->getId() <=> $b->getId();
            if ($la === null) return -1; // never-shown sorts first
            if ($lb === null) return 1;
            return strcmp($la, $lb);    // ISO-ish strings sort lexically = chronologically
        });

        return $candidates[0];
    }

    /** @return Image[] */
    private function readyPool(Device $device): array
    {
        return $this->em->createQueryBuilder()
            ->select('i')
            ->from(Image::class, 'i')
            ->join('i.approvedDevices', 'd')
            ->join('i.renderedAssets', 'ra')
            ->where('d = :device')
            ->andWhere('i.deletedAt IS NULL')
            ->andWhere('ra.deviceModel = :model')
            ->andWhere('ra.orientation = :orientation')
            ->andWhere('ra.status = :status')
            ->setParameter('device', $device)
            ->setParameter('model', $device->getModel())
            ->setParameter('orientation', $device->getOrientation())
            ->setParameter('status', RenderStatus::Ready)
            ->getQuery()
            ->getResult();
    }

    /**
     * All image ids ever served on this device. Used when the user wants
     * never-shown images to be picked first.
     *
     * @return int[]
     */
    private function everShownImageIds(Device $device): array
    {
        $rows = $this->em->createQueryBuilder()
            ->select('DISTINCT IDENTITY(h.image) AS image_id')
            ->from(DeviceImageHistory::class, 'h')
            ->where('h.device = :device')
            ->setParameter('device', $device)
            ->getQuery()
            ->getScalarResult();

        return array_map('intval', array_column($rows, 'image_id'));
    }

    /** @return int[] */
    private function recentImageIds(Device $device, int $limit): array
    {
        $rows = $this->em->createQueryBuilder()
            ->select('IDENTITY(h.image) AS image_id')
            ->from(DeviceImageHistory::class, 'h')
            ->where('h.device = :device')
            ->setParameter('device', $device)
            ->orderBy('h.servedAt', 'DESC')
            ->setMaxResults($limit)
            ->getQuery()
            ->getScalarResult();

        return array_column($rows, 'image_id');
    }
}
