<?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\AdminBundle\Components\Offer;

use Doctrine\ORM\EntityManagerInterface;
use Ferienpass\AdminBundle\Section\SectionComponentInterface;
use Ferienpass\CoreBundle\ConsentManager\ConsentState;
use Ferienpass\CoreBundle\Entity\ConsentForm;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Uid\Uuid;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentToolsTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;

/**
 * The "Einverständniserklärung" (agreementLetter) offer-edit section: a
 * two-pane editor for the offer's consent declarations
 * ({@see OfferInterface::getConsentForms()}) with a live preview that reuses
 * the *frontend* consent partial so the controls (opt-in / opt-out /
 * compulsory / force_decision) render byte-for-byte like the public form.
 *
 * Replaces the legacy `ConsentForms` LiveCollection form field (and drops the
 * deprecated agreement-letter PDF upload). The working copy of the consents
 * lives in the {@see $consents} LiveProp (not persisted until save), so the
 * preview reacts to every keystroke / scope / type change without touching the
 * database. On save the working list is reconciled into the offer's
 * consent-form collection (upsert by uuid, orphan-remove the rest).
 *
 * The consents are keyed by uuid (not list index): the Einwilligungstext is
 * edited with a Lexical markdown editor wrapped in `data-live-ignore`, whose
 * `data-model` binding must stay stable across add/remove (a list index would
 * go stale inside the ignored subtree).
 *
 * A consent whose current text already has active signatures is *frozen* (see
 * {@see lockedConsentIds()}): editing the text would invalidate those consents
 * and force a re-consent, so its text editor and delete control are locked.
 */
#[AsLiveComponent(template: '@FerienpassAdmin/components/Offer/AgreementLetter.html.twig', route: 'live_component_admin')]
class AgreementLetter extends AbstractController implements SectionComponentInterface
{
    use ComponentToolsTrait;
    use DefaultActionTrait;

    private const SUBJECTS = ['account', 'participant'];
    private const TYPES = ['compulsory', 'opt-in', 'opt-out', 'force_decision'];

    #[LiveProp]
    public OfferInterface $offer;

    /**
     * Working copy of the consent declarations, keyed by uuid, each value
     * `{id, title, text, subject, compulsory}`. Writable so title/text ride
     * along via `data-model`; scope/type/add/remove go through LiveActions.
     *
     * @var array<string, array{id: string, title: string, text: string, subject: string, compulsory: string}>
     */
    #[LiveProp(writable: true)]
    public array $consents = [];

    #[LiveProp]
    public array $sections = [];

    #[LiveProp]
    public ?string $section = null;

    #[LiveProp]
    public bool $isReadonly = false;

    /**
     * Whether the user explicitly unlocked the editor although signatures
     * exist (the lockbar's "Entsperren"). Editing an unlocked text mints a new
     * ConsentText on save — the changed hash invalidates all existing
     * signatures, and the consent manager re-prompts everyone. That is the
     * *intended* effect of unlocking; the lock only guards against doing it
     * accidentally.
     */
    #[LiveProp]
    public bool $unlocked = false;

    public bool $success = false;
    public string $message = '';

    public function __construct(private readonly EntityManagerInterface $entityManager)
    {
    }

    public static function getSectionName(): string
    {
        return 'agreementLetter';
    }

    public static function getSectionScope(): string
    {
        return 'offer';
    }

    public function mount(OfferInterface $offer): void
    {
        // A prop consumed by mount() is NOT auto-assigned to its matching
        // property by TwigComponent, so set $this->offer explicitly.
        $this->offer = $offer;

        $this->consents = [];
        foreach ($offer->getConsentForms() as $cf) {
            $id = (string) $cf->getId();
            $this->consents[$id] = [
                'id' => $id,
                'title' => (string) $cf->getTitle(),
                'text' => (string) $cf->getText(),
                'subject' => \in_array($cf->getSubject(), self::SUBJECTS, true) ? $cf->getSubject() : 'participant',
                'compulsory' => \in_array($cf->getCompulsory(), self::TYPES, true) ? $cf->getCompulsory() : 'compulsory',
            ];
        }
    }

    public function isLocked(): bool
    {
        return $this->isReadonly || $this->offer->isVariant();
    }

    /**
     * Preview-strip data for the shared template (subject-agnostic contract,
     * see also the edition-level component).
     *
     * @return array{title: string, date: ?\DateTimeInterface, minAge: ?int, maxAge: ?int}
     */
    public function previewStrip(): array
    {
        $firstDate = $this->offer->getDates()->first() ?: null;

        return [
            'title' => (string) $this->offer->getName(),
            'date' => $firstDate?->getBegin(),
            'minAge' => $this->offer->getMinAge(),
            'maxAge' => $this->offer->getMaxAge(),
        ];
    }

    public function footerParams(): array
    {
        return ['uuid' => $this->offer->getUuid()->toBase58()];
    }

    public function footerEditRoute(): string
    {
        return 'admin_offers_edit';
    }

    public function footerViewRoute(): string
    {
        return 'admin_offers_proof';
    }

    /**
     * Uuid strings of consent forms whose *current* text already has at least
     * one active (valid for that text, non-revoked) signature. Their text is
     * frozen — see the class docblock.
     *
     * @return list<string>
     */
    public function lockedConsentIds(): array
    {
        $ids = [];
        foreach ($this->offer->getConsentForms() as $cf) {
            $text = $cf->getText();
            foreach ($cf->getSignatures() as $signature) {
                if ($signature->isValid($text) && !$signature->isRevoked()) {
                    $ids[] = (string) $cf->getId();
                    break;
                }
            }
        }

        return $ids;
    }

    /**
     * Distinct persons (account holders or participants) holding an active
     * signature — valid for the current text, not revoked — on any of the
     * consent forms. Feeds the lockbar ("X Personen haben unterschrieben").
     */
    public function signees(): int
    {
        $persons = [];
        foreach ($this->offer->getConsentForms() as $cf) {
            $text = $cf->getText();
            foreach ($cf->getSignatures() as $signature) {
                if (!$signature->isValid($text) || $signature->isRevoked()) {
                    continue;
                }
                $persons['u'.($signature->getUser()?->getId() ?? '').'/p'.($signature->getParticipant()?->getId() ?? '')] = true;
            }
        }

        return \count($persons);
    }

    /**
     * Whether the whole section is frozen: signatures exist and the user has
     * not explicitly unlocked the editor via the lockbar.
     */
    public function isFrozen(): bool
    {
        return !$this->unlocked && [] !== $this->lockedConsentIds();
    }

    /**
     * The signed consents whose text must not be re-written on save: all of
     * them while locked, none once unlocked.
     *
     * @return list<string>
     */
    public function frozenConsentIds(): array
    {
        return $this->unlocked ? [] : $this->lockedConsentIds();
    }

    /**
     * Consent-form uuids for the lockbar's "view signatures" deep link into
     * the consents admin list (?filter[consentForm][]=…).
     *
     * @return list<string>
     */
    public function consentFormFilterValue(): array
    {
        $ids = [];
        foreach ($this->offer->getConsentForms() as $cf) {
            $ids[] = $cf->getId()->toRfc4122();
        }

        return $ids;
    }

    /**
     * The lockbar's Entsperren/Sperren. Re-locking discards pending text edits
     * by restoring the persisted texts into the working copy.
     */
    #[LiveAction]
    public function toggleLock(): void
    {
        $this->denyAccessUnlessEditable();

        if ([] === $this->lockedConsentIds()) {
            return;
        }

        $this->unlocked = !$this->unlocked;

        if (!$this->unlocked) {
            foreach ($this->offer->getConsentForms() as $cf) {
                $id = (string) $cf->getId();
                if (isset($this->consents[$id])) {
                    $this->consents[$id]['text'] = (string) $cf->getText();
                }
            }
        }
    }

    /**
     * Pristine {@see ConsentState} objects (no signature) for the given scope,
     * built from the unsaved working copy, to feed the frontend
     * `_consents.html.twig` preview partial — guaranteeing the controls render
     * exactly like the public application form.
     *
     * @return list<ConsentState>
     */
    public function previewStates(string $subject): array
    {
        $states = [];
        foreach ($this->consents as $c) {
            if (($c['subject'] ?? 'participant') !== $subject) {
                continue;
            }

            $form = new ConsentForm($this->isUuid($c['id'] ?? '') ? Uuid::fromString($c['id']) : Uuid::v7());
            $form->setOffer($this->offer);
            $form->setTitle(($c['title'] ?? '') !== '' ? $c['title'] : null);
            $form->setText(($c['text'] ?? '') !== '' ? $c['text'] : null);
            $form->setSubject(\in_array($c['subject'] ?? null, self::SUBJECTS, true) ? $c['subject'] : 'participant');
            $form->setCompulsory(\in_array($c['compulsory'] ?? null, self::TYPES, true) ? $c['compulsory'] : 'compulsory');

            $states[] = new ConsentState($form);
        }

        return $states;
    }

    #[LiveAction]
    public function addConsent(): void
    {
        $this->denyAccessUnlessEditable();
        if ($this->isFrozen()) {
            return;
        }

        $id = Uuid::v7()->toRfc4122();
        $this->consents[$id] = ['id' => $id, 'title' => '', 'text' => '', 'subject' => 'participant', 'compulsory' => 'compulsory'];
    }

    #[LiveAction]
    public function removeConsent(#[LiveArg] string $id): void
    {
        $this->denyAccessUnlessEditable();
        if ($this->isFrozen()) {
            return;
        }

        unset($this->consents[$id]);
    }

    #[LiveAction]
    public function setSubject(#[LiveArg] string $id, #[LiveArg] string $value): void
    {
        $this->denyAccessUnlessEditable();
        if ($this->isFrozen()) {
            return;
        }

        if (isset($this->consents[$id]) && \in_array($value, self::SUBJECTS, true)) {
            $this->consents[$id]['subject'] = $value;
        }
    }

    #[LiveAction]
    public function setCompulsory(#[LiveArg] string $id, #[LiveArg] string $value): void
    {
        $this->denyAccessUnlessEditable();
        if ($this->isFrozen()) {
            return;
        }

        if (isset($this->consents[$id]) && \in_array($value, self::TYPES, true)) {
            $this->consents[$id]['compulsory'] = $value;
        }
    }

    #[LiveAction]
    public function save(#[LiveArg] ?string $nextSection = null, #[LiveArg] ?string $redirect = null): ?Response
    {
        $this->denyAccessUnlessEditable();
        if ($this->isFrozen()) {
            return null;
        }

        // Frozen consents (active signatures, not explicitly unlocked) —
        // computed before any mutation. Unlocked consents save normally:
        // a changed text mints a new ConsentText (new hash) and the consent
        // manager re-prompts all signees.
        $lockedIds = $this->frozenConsentIds();

        // Drop blank rows (no title AND no text) so empty editors don't persist.
        $working = array_filter(
            $this->consents,
            static fn (array $c): bool => '' !== trim((string) ($c['title'] ?? '')) || '' !== trim((string) ($c['text'] ?? '')),
        );

        $keepIds = array_filter(array_map(static fn (array $c): string => (string) ($c['id'] ?? ''), $working));

        // Index existing offer consent forms by uuid string.
        $existing = [];
        foreach ($this->offer->getConsentForms() as $cf) {
            $existing[(string) $cf->getId()] = $cf;
        }

        // Remove consent forms no longer present (orphanRemoval deletes them) —
        // but never drop a frozen (signed) consent.
        foreach ($existing as $id => $cf) {
            if (!\in_array($id, $keepIds, true) && !\in_array($id, $lockedIds, true)) {
                $this->offer->removeConsentForm($cf);
            }
        }

        // Upsert each working consent.
        foreach ($working as $c) {
            $cf = $existing[$c['id'] ?? ''] ?? null;
            if (!$cf instanceof ConsentForm) {
                // New: let Doctrine's UUID generator assign the id on persist.
                $cf = new ConsentForm();
                $cf->setOffer($this->offer);
                $this->offer->addConsentForm($cf);
            }

            $cf->setTitle(('' !== trim((string) ($c['title'] ?? ''))) ? $c['title'] : null);

            // Never re-write the text of a frozen consent: setText() would mint
            // a new ConsentText and invalidate the existing signatures.
            if (!\in_array($c['id'] ?? '', $lockedIds, true)) {
                // setText() only creates a new ConsentText when the text actually
                // changed — preserving existing signatures' validity otherwise.
                $cf->setText(('' !== ($c['text'] ?? '')) ? $c['text'] : null);
            }

            $cf->setSubject(\in_array($c['subject'] ?? null, self::SUBJECTS, true) ? $c['subject'] : 'participant');
            $cf->setCompulsory(\in_array($c['compulsory'] ?? null, self::TYPES, true) ? $c['compulsory'] : 'compulsory');
        }

        $this->entityManager->flush();
        $this->entityManager->refresh($this->offer);

        // Reflect the persisted state (real uuids, cleaned list) back into the editor.
        $this->mount($this->offer);
        $this->unlocked = false;

        $this->success = true;
        $this->dispatchBrowserEvent('live:saved', ['success' => true]);

        if ($nextSection || $redirect) {
            $this->addFlash('success', ['type' => 'success', 'title' => 'Daten gespeichert', 'message' => 'Die Änderungen wurden erfolgreich übernommen.']);
        } else {
            $this->message = 'Die Änderungen wurden erfolgreich übernommen.';
        }

        if ($nextSection) {
            return $this->redirectToRoute('admin_offers_edit', ['uuid' => $this->offer->getUuid()->toBase58(), 'section' => $nextSection]);
        }
        if ($redirect) {
            return $this->redirectToRoute($redirect, ['uuid' => $this->offer->getUuid()->toBase58()]);
        }

        return null;
    }

    private function isUuid(string $value): bool
    {
        return '' !== $value && Uuid::isValid($value);
    }

    /**
     * Server-side write guard: variant offers are read-only and the user needs
     * the 'edit' voter. Re-checks the offer rather than trusting the
     * {@see $isReadonly} LiveProp, which the client could flip.
     */
    private function denyAccessUnlessEditable(): void
    {
        if ($this->offer->isVariant()) {
            throw new BadRequestHttpException('Cannot modify consent declarations on a variant offer.');
        }

        $this->denyAccessUnlessGranted('edit', $this->offer);
    }
}
