<?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\CmsBundle\Wishlist;

use Doctrine\ORM\EntityManagerInterface;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Entity\Wishlist;
use Ferienpass\CoreBundle\Repository\WishlistRepository;
use Symfony\Component\Uid\Uuid;

/**
 * Reconciles a client's wishlist set (sent from localStorage) with the
 * account-side mirror, last-write-wins per wishlist by `modifiedAt`.
 *
 * The client always sends its full set (the union of its localStorage and the
 * server state it previously fetched), so reconciliation is a per-wishlist
 * decision: the newer side wins, deletions are soft (tombstones), and the
 * authoritative set is returned for the client to write back.
 *
 * Stateless and side-effect-bounded to the given user — safe to unit-test.
 */
class WishlistSynchronizer
{
    private const MAX_LISTS = 100;
    private const MAX_ITEMS_PER_LIST = 500;
    private const NAME_MAX_LENGTH = 255;

    public function __construct(
        private readonly EntityManagerInterface $em,
        private readonly WishlistRepository $wishlists,
        private readonly OfferRefResolver $refResolver,
    ) {
    }

    /**
     * @param array<int, array{uuid?: string, name?: string, modifiedAt?: string, deletedAt?: string|null, offers?: array<int, string>}> $clientLists
     *
     * @return array{wishlists: list<array{uuid: string, name: string, createdAt: string, modifiedAt: string, offers: list<string>}>, deleted: list<string>}
     */
    public function reconcile(User $user, array $clientLists): array
    {
        foreach (\array_slice($clientLists, 0, self::MAX_LISTS) as $clientList) {
            $this->reconcileOne($user, \is_array($clientList) ? $clientList : []);
        }

        $this->em->flush();

        return $this->authoritativePayload($user);
    }

    /**
     * @param array{uuid?: string, name?: string, modifiedAt?: string, deletedAt?: string|null, offers?: array<int, string>} $clientList
     */
    private function reconcileOne(User $user, array $clientList): void
    {
        $uuid = $this->parseUuid($clientList['uuid'] ?? null);
        if (!$uuid instanceof Uuid) {
            return;
        }

        $clientModifiedAt = $this->parseDate($clientList['modifiedAt'] ?? null) ?? new \DateTimeImmutable();
        $clientDeletedAt = $this->parseDate($clientList['deletedAt'] ?? null);
        $name = $this->normalizeName($clientList['name'] ?? '');
        $offers = $this->resolveOffers($clientList['offers'] ?? []);

        $server = $this->wishlists->findOneForUserByClientUuid($user, $uuid);

        if (!$server instanceof Wishlist) {
            // Nothing to create for a list the client already deleted.
            if ($clientDeletedAt instanceof \DateTimeImmutable) {
                return;
            }

            // The client uuid is unique per user (the PK is a separate
            // server-generated v7), so two users colliding on the same v4 is
            // harmless — each gets their own row.
            $server = new Wishlist($user, $name, $uuid);
            $server->setModifiedAt($clientModifiedAt);
            $server->syncOffers($offers);
            $this->em->persist($server);

            return;
        }

        // Last-write-wins: only the strictly-newer client version overrides the
        // server. Ties and older client copies leave the server untouched.
        if ($clientModifiedAt <= $server->getModifiedAt()) {
            return;
        }

        $server->setName($name);
        $server->setModifiedAt($clientModifiedAt);

        if ($clientDeletedAt instanceof \DateTimeImmutable) {
            $server->setDeletedAt($clientDeletedAt);

            return;
        }

        // Revive a previously-tombstoned list the client edited again.
        $server->setDeletedAt(null);
        $server->syncOffers($offers);
    }

    /**
     * @return array{wishlists: list<array{uuid: string, name: string, createdAt: string, modifiedAt: string, offers: list<string>}>, deleted: list<string>}
     */
    private function authoritativePayload(User $user): array
    {
        $wishlists = [];
        foreach ($this->wishlists->findActiveForUser($user) as $wishlist) {
            // Echo the client uuid (the key the browser stores under); fall
            // back to the server pk for any server-origin list.
            $wishlists[] = [
                'uuid' => ($wishlist->getClientUuid() ?? $wishlist->getId())->toRfc4122(),
                'name' => $wishlist->getName(),
                'createdAt' => $wishlist->getCreatedAt()->format(\DateTimeInterface::ATOM),
                'modifiedAt' => $wishlist->getModifiedAt()->format(\DateTimeInterface::ATOM),
                'offers' => array_map(
                    fn (OfferInterface $o): string => $this->refResolver->canonicalRef($o),
                    $wishlist->getOffers(),
                ),
            ];
        }

        return [
            'wishlists' => $wishlists,
            'deleted' => $this->wishlists->findDeletedClientUuidsForUser($user),
        ];
    }

    /**
     * @param array<int, string> $refs
     *
     * @return list<OfferInterface>
     */
    private function resolveOffers(array $refs): array
    {
        $offers = [];
        foreach (\array_slice($refs, 0, self::MAX_ITEMS_PER_LIST) as $ref) {
            if (!\is_string($ref)) {
                continue;
            }

            $offer = $this->refResolver->resolve($ref);
            if ($offer instanceof OfferInterface && !\in_array($offer, $offers, true)) {
                $offers[] = $offer;
            }
        }

        return $offers;
    }

    private function parseUuid(?string $value): ?Uuid
    {
        if (null === $value || '' === $value) {
            return null;
        }

        try {
            return Uuid::fromString($value);
        } catch (\InvalidArgumentException) {
            return null;
        }
    }

    private function parseDate(?string $value): ?\DateTimeImmutable
    {
        if (null === $value || '' === $value) {
            return null;
        }

        try {
            return new \DateTimeImmutable($value);
        } catch (\Exception) {
            return null;
        }
    }

    private function normalizeName(string $name): string
    {
        $name = trim($name);
        if ('' === $name) {
            $name = 'Merkliste';
        }

        return mb_substr($name, 0, self::NAME_MAX_LENGTH);
    }
}
