<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CoreBundle\ApplicationSystem\Lotting;

use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\AttendanceStash;
use Ferienpass\CoreBundle\Entity\FriendCode;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Enum\DecisionReason;
use Ferienpass\CoreBundle\Settings\SystemSettingsManager;

class Assignment
{
    public function __construct(private readonly SystemSettingsManager $systemSettingsManager)
    {
    }

    /**
     * Draw the winners for a single oversubscribed offer.
     *
     * The lottery element is the shuffle below. Pass a $seed to make the draw
     * reproducible (and therefore auditable): the same seed and the same input
     * always produce the same outcome. When $seed is null a cryptographically
     * random shuffle is used. Seeding a local Randomizer keeps the global RNG
     * state untouched, so it does not leak into anything else in the process.
     */
    public function drawForOffer(OfferInterface $offer, ?int $maxWaitlist = null, ?AttendanceStash $stash = null, ?int $seed = null): void
    {
        $postalCodes = $this->systemSettingsManager->getPreferredPostalCodesArray();
        $seats = new Seats();
        $attendances = $offer->getAttendancesWaiting();
        $engine = null === $seed ? new \Random\Engine\Secure() : new \Random\Engine\Mt19937($seed);
        $waitingAttendances = new \Random\Randomizer($engine)->shuffleArray($attendances->toArray());

        foreach ($waitingAttendances as $attendance) {
            $participant = $attendance->getParticipant();
            if ($seats->contains($participant)) {
                continue;
            }

            /** @var FriendCode $friendCode */
            $friendCode = $participant->getFriendCodes()->findFirst(static fn (int $i, FriendCode $c) => $c->getOffer() === $offer);
            $friends = $friendCode?->getParticipants();

            if ($friends) {
                $seats[] = new Seat(array_values(array_filter($waitingAttendances, static fn (Attendance $a) => $friends->contains($a->getParticipant()))), $postalCodes);
            } else {
                $seats[] = new Seat([$attendance], $postalCodes);
            }
        }

        $availableSeats = max(0, (int) $offer->getMaxParticipants() - $offer->getAttendancesConfirmed()->count());
        [, $seatIndices] = $this->solveKnapsack($seats, $availableSeats);

        // Whether a waitlist was offered at all decides how an eligible loss is framed:
        // with no waitlist the participant was simply outcompeted, with an (exhausted)
        // waitlist the seat ran out of waitlist capacity. null = unlimited waitlist.
        $unfulfilledReason = 0 === $maxWaitlist ? DecisionReason::OUTCOMPETED : DecisionReason::WAITLIST_EXHAUSTED;

        foreach ($seats as $i => $seat) {
            if (\in_array($i, $seatIndices, true)) {
                $this->decide($seat, AttendanceStatus::CONFIRMED, $stash);

                continue;
            }

            if (null === $maxWaitlist) {
                $status = AttendanceStatus::WAITLISTED;
            } elseif ($maxWaitlist > 0) {
                $status = AttendanceStatus::WAITLISTED;

                $maxWaitlist -= $seat->getSeats();
            } else {
                $status = AttendanceStatus::UNFULFILLED;
            }

            $this->decide($seat, $status, $stash, AttendanceStatus::UNFULFILLED === $status ? $unfulfilledReason : null);
        }
    }

    /**
     * Apply the lottery's decision to every attendance of a seat, recording the reason
     * (a hard-exclusion gate always wins over the seat-level outcome reason) and tagging
     * the attendances onto the stash. The reason is read back by the attendance log
     * listener so each resulting log row carries it under the "Losverfahren" actor.
     */
    private function decide(Seat $seat, string $status, ?AttendanceStash $stash, ?string $outcomeReason = null): void
    {
        foreach ($seat->getAttendances() as $attendance) {
            // A confirmed seat is never annotated — the reason explains a non-confirmation
            // (and a friend-group member may ride in despite tripping a gate).
            $reason = AttendanceStatus::CONFIRMED === $status ? null : ($seat->gateReason($attendance) ?? $outcomeReason);
            $attendance->lottingDecision($status, $reason);
            if ($stash) {
                $attendance->setStash($stash);
            }
        }
    }

    /**
     * @param Seat[] $seats
     *
     * @return array<float, array<int>>
     */
    private function solveKnapsack(Seats $seats, $availableSeats, ?int $i = null, ?array &$memo = null): array
    {
        $i ??= \count($seats) - 1;
        $memo ??= [];

        if (isset($memo[$i][$availableSeats])) {
            return [$memo[$i][$availableSeats], $memo['picked'][$i][$availableSeats]];
        }

        $currentValue = $seats[$i]->getValue();

        // At end of decision branch
        if (0 === $i) {
            if ($seats[$i]->getSeats() <= $availableSeats && $currentValue > 0) {
                $memo[$i][$availableSeats] = $currentValue;
                $memo['picked'][$i][$availableSeats] = [$i];

                return [$currentValue, [$i]];
            }

            $memo[$i][$availableSeats] = 0;
            $memo['picked'][$i][$availableSeats] = [];

            return [0, []];
        }

        // Get the result of the next branch (without this one)
        [$resultingValueWithoutCurrent, $pickedIndicesWithoutCurrent] = $this->solveKnapsack($seats, $availableSeats, $i - 1, $memo);

        // Skip current seat if to big
        if ($seats[$i]->getSeats() > $availableSeats || $currentValue <= 0) {
            $memo[$i][$availableSeats] = $resultingValueWithoutCurrent;
            $memo['picked'][$i][$availableSeats] = $pickedIndicesWithoutCurrent;

            return [$resultingValueWithoutCurrent, $pickedIndicesWithoutCurrent];
        }

        // Get the result of the next branch (WITH this one picked, so available weight is reduced)
        [$resultingValueWithCurrent, $pickedIndicesWithCurrent] = $this->solveKnapsack($seats, $availableSeats - $seats[$i]->getSeats(), $i - 1, $memo);
        $resultingValueWithCurrent += $currentValue;

        // Skip or include current seat
        if ($resultingValueWithCurrent > $resultingValueWithoutCurrent) {
            $resultingValue = $resultingValueWithCurrent;
            $pickedIndices = $pickedIndicesWithCurrent;
            $pickedIndices[] = $i;
        } else {
            $resultingValue = $resultingValueWithoutCurrent;
            $pickedIndices = $pickedIndicesWithoutCurrent;
        }

        $memo[$i][$availableSeats] = $resultingValue;
        $memo['picked'][$i][$availableSeats] = $pickedIndices;

        return [$resultingValue, $pickedIndices];
    }
}
