<?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 Doctrine\Common\Collections\Collection;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Enum\DecisionReason;

/**
 * A unit of admission for the lottery: one participant, or a friend group that is
 * admitted or rejected together.
 *
 * Fairness model
 * --------------
 * The draw is a *weighted-priority allocation*, not a uniform random lottery. Each
 * seat carries a deservingness score in the half-open range (0, 1] (see
 * {@see self::getValue()}); the knapsack in {@see Assignment} admits the set of
 * seats that maximises the total score within the available capacity. Pure chance
 * only enters as a tie-breaker, via the (seeded, reproducible) shuffle in
 * {@see Assignment::drawForOffer()}.
 *
 * The score combines three terms, each normalised to [0, 1] and weighted so the
 * weights sum to 1 — that way the nominal weights below reflect each term's real
 * influence (previously the bad-luck term was an unbounded average of
 * log-popularity that could silently dominate everything else):
 *
 *  - priority:         how high the participant ranked this offer (their wish order);
 *  - bad luck:         popularity-weighted fraction of the participant's other draws
 *                      they have already lost in this run — equity compensation;
 *  - few applications: a boost for participants who applied to few oversubscribed
 *                      offers, to stop heavy appliers from crowding everyone out.
 *
 * Known limitation: the bad-luck term can only compensate for losses that have
 * *already happened* in this run. Because offers are drawn most-crowded-first
 * ({@see \Ferienpass\CoreBundle\MessageHandler\LotEdition}), the single most
 * contested offer is decided before anyone has a recorded loss, so it receives no
 * equity smoothing. Removing that blind spot needs a two-pass (provisional then
 * final) draw, which is a policy decision and is intentionally left out here.
 *
 * Seats with value 0 are never admitted (see hard-exclusion gates in getValue()).
 */
class Seat
{
    /**
     * Relative influence of each fairness term. The three weights sum to 1, so a
     * fully-eligible seat scores at most 1.0 and the numbers below are directly
     * comparable. The ratios preserve the previous intent (≈0.85 : 0.5 : 0.6).
     */
    private const WEIGHT_PRIORITY = 0.45;
    private const WEIGHT_BAD_LUCK = 0.25;
    private const WEIGHT_FEW_APPLICATIONS = 0.30;

    /**
     * Strictly-positive floor for an otherwise-eligible seat, so the knapsack will
     * still use it to fill capacity that would otherwise go unused.
     */
    private const MIN_ELIGIBLE_VALUE = 0.0001;

    /**
     * Per-attendance hard-exclusion reason, keyed by the attendance UUID, captured while
     * scoring in {@see self::valueForAttendance()}. Holds a {@see DecisionReason}
     * constant when a gate rejected the attendance, or null once it passed all gates.
     *
     * @var array<string, string|null>
     */
    private array $gateReasons = [];

    /**
     * @param Attendance[] $attendances
     */
    public function __construct(private readonly array $attendances, private readonly array $postalCodes = [])
    {
    }

    /**
     * The hard-exclusion gate ({@see DecisionReason}) that kept this attendance out of
     * the draw, or null when it was eligible (and therefore lost on score, not on a gate).
     */
    public function gateReason(Attendance $attendance): ?string
    {
        $id = (string) $attendance->getUuid();
        if (!\array_key_exists($id, $this->gateReasons)) {
            // Populate the cache as a side effect of scoring.
            $this->valueForAttendance($attendance);
        }

        return $this->gateReasons[$id] ?? null;
    }

    public function getAttendances(): array
    {
        return $this->attendances;
    }

    public function getSeats(): int
    {
        return \count($this->attendances);
    }

    public function setStatus(string $status): void
    {
        foreach ($this->attendances as $attendance) {
            $attendance->setStatus($status);
        }
    }

    public function getValue(): float
    {
        return array_sum(array_map($this->valueForAttendance(...), $this->attendances));
    }

    /**
     * Record a hard-exclusion reason and return the score it implies (always 0).
     */
    private function gate(string $id, string $reason): float
    {
        $this->gateReasons[$id] = $reason;

        return 0.0;
    }

    private function valueForAttendance(Attendance $attendance): float
    {
        $id = (string) $attendance->getUuid();

        // --- Hard exclusions: these seats are never admitted (value 0) ---

        // A firstcome application has no place in a lottery draw.
        if ('firstcome' === $attendance->getTask()?->getType()) {
            return $this->gate($id, DecisionReason::FIRSTCOME_EXCLUDED);
        }

        // Already holds a confirmed seat in a date variant, so does not need this one.
        if ($attendance->getParticipant()->getAttendancesNotHidden(includedCancelled: false)->filter(static fn (Attendance $a) => $a->isConfirmed() && $a->getOffer()->isVariantOf($attendance->getOffer()))->count()) {
            return $this->gate($id, DecisionReason::ALREADY_CONFIRMED_VARIANT);
        }

        // Geographic locality gate. When a preferred-postal-code list is configured,
        // only residents of those codes may win an oversubscribed seat. A missing
        // postal code counts as "not preferred" as well, so leaving the field blank
        // can no longer bypass the gate (which previously rewarded incomplete data).
        if ($this->postalCodes) {
            $postalCode = $attendance->getParticipant()->getPostalCode();
            if (!$postalCode || !\in_array($postalCode, $this->postalCodes, true)) {
                return $this->gate($id, DecisionReason::POSTAL_CODE_NOT_PREFERRED);
            }
        }

        $attendancesInTheDraw = $attendance->getParticipant()->getAttendancesNotWithdrawn($attendance->getOffer()->getEdition(), false)->filter(static fn (Attendance $a) => $a->getOffer()->getMaxParticipants() && $a->getOffer()->getAttendancesNotHidden()->count() > $a->getOffer()->getMaxParticipants() && (!$a->getTask()?->getMaxApplications() || ($a->getUserPriority() && $a->getUserPriority() < $a->getTask()->getMaxApplications())));
        if (!$attendancesInTheDraw->count()) {
            return $this->gate($id, DecisionReason::NOT_IN_DRAW);
        }

        // Passed every gate: eligible, so any later loss is on score, not a gate.
        $this->gateReasons[$id] = null;

        // --- Fairness terms, each normalised to [0, 1] ---
        $xUserPriority = $this->userPriorityScore($attendance);
        $xBadLuckOfTheDraw = $this->badLuckScore($attendancesInTheDraw);
        $xOnlyFewApplications = max(3 - $attendancesInTheDraw->count(), 0) / 2;

        $value = (self::WEIGHT_PRIORITY * $xUserPriority)
            + (self::WEIGHT_BAD_LUCK * $xBadLuckOfTheDraw)
            + (self::WEIGHT_FEW_APPLICATIONS * $xOnlyFewApplications);

        return max(self::MIN_ELIGIBLE_VALUE, $value);
    }

    /**
     * How high the participant ranked this offer, in [0, 1] (1 = top wish).
     *
     * Higher-ranked wishes that have already been rejected in this run are
     * compensated for: a child whose 1st choice is gone is scored on their best
     * *remaining* wish rather than on the literal, now-stale wish number. This only
     * ever raises the effective priority, so it cannot disadvantage anyone.
     */
    private function userPriorityScore(Attendance $attendance): float
    {
        $max = $attendance->getTask()?->getMaxApplications() ?: 10;
        $livePriority = min($attendance->getUserPriority() ?: $max + 1, $max + 1);

        $rejectedHigherWishes = $attendance->getParticipant()
            ->getAttendancesNotWithdrawn($attendance->getOffer()->getEdition(), false)
            ->filter(static fn (Attendance $a) => $a->isUnfulfilled() && $a->getUserPriority() && $a->getUserPriority() < $livePriority)
            ->count();

        $effectivePriority = max(1, $livePriority - $rejectedHigherWishes);

        return (($max + 2) - $effectivePriority) / ($max + 1);
    }

    /**
     * Popularity-weighted fraction of the participant's draws already lost, in [0, 1].
     *
     * Losing popular offers (more applicants) weighs more than losing niche ones.
     * Normalising by the total popularity weight — rather than by the raw count, as
     * before — keeps the term bounded in [0, 1] so its weight is meaningful.
     *
     * @param Collection<int, Attendance> $attendancesInTheDraw
     */
    private function badLuckScore(Collection $attendancesInTheDraw): float
    {
        $popularity = static fn (OfferInterface $o): float => max(0.0, log(max(1, $o->getAttendancesNotHidden()->count())));

        $totalWeight = array_sum($attendancesInTheDraw->map(static fn (Attendance $a): float => $popularity($a->getOffer()))->toArray());
        if ($totalWeight <= 0.0) {
            // Every offer in the draw has at most one applicant — no luck signal.
            return 0.0;
        }

        $lostWeight = array_sum($attendancesInTheDraw->map(static fn (Attendance $a): float => ($a->isWaitlisted() || $a->isUnfulfilled()) ? $popularity($a->getOffer()) : 0.0)->toArray());

        return $lostWeight / $totalWeight;
    }
}
