<?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\Edition;

use Doctrine\ORM\EntityManagerInterface;
use Ferienpass\AdminBundle\Section\SectionComponentInterface;
use Ferienpass\CoreBundle\Entity\AccessCodeStrategy;
use Ferienpass\CoreBundle\Entity\Edition;
use Ferienpass\CoreBundle\Entity\EditionTask;
use Ferienpass\CoreBundle\Repository\AccessCodeStrategyRepository;
use Ferienpass\CoreBundle\Settings\PageConfigManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
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\Attribute\PreReRender;
use Symfony\UX\LiveComponent\ComponentToolsTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;

/**
 * Tasks (phases) section of the Edition-Bearbeiten screen.
 *
 * Replaces a flat form with a Gantt-style timeline + colored phase cards.
 *
 * The component holds an editable snapshot of every phase as a plain array
 * keyed by a stable token (`id_N` for persisted tasks, `new_N` for unsaved
 * ones). Inputs bind to that array via `data-model` and writes round-trip
 * through Symfony LiveComponent. Save flushes the snapshot back onto the
 * `EditionTask` entities — same `isDirty` UX as every other section component.
 *
 * Each task type has a dedicated tone (color), an icon, a friendly label, a
 * short label (used inside the timeline bar), and a one-line description
 * (used as a contextual hint inside the card body and inside the add-phase
 * picker).
 */
#[AsLiveComponent('Admin:Edition:Tasks', template: '@FerienpassAdmin/components/Edition/Tasks.html.twig', route: 'live_component_admin')]
class Tasks extends AbstractController implements SectionComponentInterface
{
    use ComponentToolsTrait;
    use DefaultActionTrait;

    /**
     * Predefined phase taxonomy in workflow order — one section per entry.
     * Keep in sync with translations under `editions.task.*` and with the
     * `typeClasses` / `phaseTypes` / `typeIcons` / `multiTypes` maps in
     * `components/Edition/Tasks.html.twig`.
     */
    public const PHASE_TYPES = [
        'host_editing_stage',
        'show_offers',
        'application_system',
        'allocation',
        'publish_lists',
        'holiday',
        'custom',
    ];

    /**
     * Types that allow more than one instance in a single edition (the
     * section header gets a "Weitere" button for these). All others are
     * one-slot — once configured, the section's CTA disappears.
     */
    public const MULTI_TYPES = ['application_system', 'custom'];

    #[LiveProp]
    public Edition $edition;

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

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

    /**
     * Per-phase form data, indexed by stable token (`id_N` / `new_N`). All
     * inputs in the template bind here via `data-model="phases[<key>][field]"`.
     *
     * @var array<string, array{
     *     type: ?string, title: ?string,
     *     periodBegin: ?string, periodEnd: ?string,
     *     alwaysActive: bool,
     *     applicationSystem: ?string,
     *     maxApplications: ?int, maxApplicationsDay: ?int, maxWaitlist: ?int,
     *     skipMaxApplications: bool, allowAnonymous: bool, allowAnonymousFee: bool,
     *     accessCodeStrategyId: ?int,
     * }>
     */
    #[LiveProp(writable: true)]
    public array $phases = [];

    /** Monotonic counter for tokens of unsaved phases (key prefix `new_`). */
    #[LiveProp(writable: true)]
    public int $newCounter = 0;

    /** Scroll/highlight target for the post-action focus on a card. */
    #[LiveProp(writable: true)]
    public ?string $focusKey = null;

    /**
     * Drives the Speichern button enabled-state. Recomputed before every render
     * in {@see detectDirty()} — `LiveProp(onUpdated: …)` on top-level arrays
     * does not fire for nested writes (`phases.id_5.periodBegin`), so we
     * compare the in-memory snapshot to the persisted entities instead.
     */
    #[LiveProp]
    public bool $isDirty = false;

    public string $message = '';

    private readonly string $timezone;

    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly AccessCodeStrategyRepository $accessCodeStrategies,
        PageConfigManager $pageConfigManager,
    ) {
        $this->timezone = $pageConfigManager->getPageConfig()->getTimezone();
    }

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

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

    /**
     * Recompute `isDirty` by comparing the writable `$phases` snapshot (which
     * receives every `data-model` write — including nested ones like
     * `phases.id_5.periodBegin`) to a freshly-serialized snapshot of the
     * persisted entities. Diff ⇒ dirty. After {@see save()} the entities
     * match the snapshot again, so this naturally resets to false.
     */
    #[PreReRender]
    public function detectDirty(): void
    {
        $persisted = [];
        foreach ($this->edition->getTasks() as $task) {
            if (null !== $task->getId()) {
                $persisted['id_'.$task->getId()] = $this->serializeTask($task);
            }
        }

        $this->isDirty = ($this->phases !== $persisted);
    }

    public function mount(Edition $edition): void
    {
        $this->edition = $edition;
        $this->phases = [];
        foreach ($edition->getTasks() as $task) {
            $this->phases['id_'.$task->getId()] = $this->serializeTask($task);
        }
    }

    /**
     * @return list<AccessCodeStrategy>
     */
    public function getAccessCodeStrategies(): array
    {
        return $this->accessCodeStrategies->findAll();
    }

    public function getToday(): \DateTimeImmutable
    {
        return new \DateTimeImmutable('now')->setTimezone(new \DateTimeZone($this->timezone));
    }

    /**
     * Phase keys in display order — sorted by start date with `holiday`
     * pinned last so the event window reads like a footer to the timeline.
     *
     * @return list<string>
     */
    public function getOrderedKeys(): array
    {
        $keys = array_keys($this->phases);
        usort($keys, function (string $a, string $b): int {
            $pa = $this->phases[$a];
            $pb = $this->phases[$b];
            if ('holiday' === $pa['type'] && 'holiday' !== $pb['type']) {
                return 1;
            }
            if ('holiday' === $pb['type'] && 'holiday' !== $pa['type']) {
                return -1;
            }
            $sa = $pa['periodBegin'] ?? '';
            $sb = $pb['periodBegin'] ?? '';

            return $sa <=> $sb;
        });

        return $keys;
    }

    /**
     * Group the in-memory snapshot by phase type, preserving the chronological
     * row order from {@see getOrderedKeys()}. Every predefined type appears in
     * the result — empty arrays for unconfigured types — so the template can
     * walk PHASE_TYPES and render a section per slot without checking for
     * missing keys.
     *
     * Unknown types (e.g. legacy `payment` rows lingering in the DB) fall
     * back to the `custom` bucket so they remain visible and editable.
     *
     * @return array<string, list<array{key: string, data: array<string, mixed>}>>
     */
    public function getPhasesByType(): array
    {
        $byType = [];
        foreach (self::PHASE_TYPES as $type) {
            $byType[$type] = [];
        }

        foreach ($this->getOrderedKeys() as $key) {
            $data = $this->phases[$key];
            $type = $data['type'] ?? null;
            if (!\in_array($type, self::PHASE_TYPES, true)) {
                $type = 'custom';
            }
            $byType[$type][] = ['key' => $key, 'data' => $data];
        }

        return $byType;
    }

    public function getConfiguredCount(): int
    {
        return \count(array_filter($this->getPhasesByType(), static fn (array $list): bool => [] !== $list));
    }

    /**
     * Aggregate status pill shown next to a section title. Returns null for
     * empty sections (the "Nicht konfiguriert" eyebrow takes that spot
     * instead). Priority: any live > any always-active > sum of upcoming >
     * past.
     *
     * @return ?array{kind: string, label: string, tone: string}
     */
    public function sectionSummary(string $type): ?array
    {
        $list = $this->getPhasesByType()[$type] ?? [];
        if ([] === $list) {
            return null;
        }

        $stats = array_map(fn (array $entry) => $this->statusFor($entry['data']), $list);

        foreach ($stats as $s) {
            if ('live' === $s['kind']) {
                return ['kind' => 'live', 'label' => 'Läuft jetzt', 'tone' => 'live'];
            }
        }
        foreach ($stats as $s) {
            if ('always' === $s['kind']) {
                return ['kind' => 'always', 'label' => 'Immer aktiv', 'tone' => 'indigo'];
            }
        }

        $upcoming = array_filter($stats, static fn (array $s): bool => 'upcoming' === $s['kind']);
        $n = \count($upcoming);
        if ($n > 0) {
            return [
                'kind' => 'upcoming',
                'label' => 1 === $n ? '1 kommende Phase' : \sprintf('%d kommende Phasen', $n),
                'tone' => 'gray',
            ];
        }

        return ['kind' => 'past', 'label' => 'Vergangen', 'tone' => 'past'];
    }

    /**
     * Live-status derivation. Used by both the card status pill and the
     * timeline legend.
     *
     * @param array<string, mixed> $data
     *
     * @return array{kind: string, label: string, tone: string}
     */
    public function statusFor(array $data): array
    {
        if (!empty($data['alwaysActive'])) {
            return ['kind' => 'always', 'label' => 'Immer aktiv', 'tone' => 'indigo'];
        }

        $start = $this->parseDate($data['periodBegin'] ?? null);
        $end = $this->parseDate($data['periodEnd'] ?? null);

        if (null === $start || null === $end) {
            return ['kind' => 'undef', 'label' => 'Termin fehlt', 'tone' => 'gray'];
        }

        $today = $this->getToday();

        if ($today < $start) {
            $days = (int) $today->diff($start)->format('%r%a');

            return ['kind' => 'upcoming', 'label' => $this->relative($days, 'Startet'), 'tone' => 'gray'];
        }

        if ($today > $end) {
            return ['kind' => 'past', 'label' => 'Vergangen', 'tone' => 'past'];
        }

        $left = (int) $today->diff($end)->format('%r%a');
        $label = $left > 0 ? \sprintf('Läuft · noch %d Tage', $left) : 'Läuft · letzter Tag';

        return ['kind' => 'live', 'label' => $label, 'tone' => 'live'];
    }

    /**
     * Timeline metrics consumed by the template.
     *
     * @return ?array{
     *     start: \DateTimeImmutable, end: \DateTimeImmutable, todayPct: float,
     *     months: list<array{label: string, leftPct: float, widthPct: float}>,
     *     bars: list<array{key: string, type: string, leftPct: float, widthPct: float, range: string}>,
     *     count: int,
     * }
     */
    public function timeline(): ?array
    {
        $dated = [];
        foreach ($this->phases as $key => $data) {
            if (!empty($data['alwaysActive'])) {
                continue;
            }
            $s = $this->parseDate($data['periodBegin'] ?? null);
            $e = $this->parseDate($data['periodEnd'] ?? null);
            if (null === $s || null === $e) {
                continue;
            }
            $dated[$key] = ['data' => $data, 'start' => $s, 'end' => $e];
        }

        if ([] === $dated) {
            return null;
        }

        $minStart = min(array_map(static fn (array $d) => $d['start'], $dated));
        $maxEnd = max(array_map(static fn (array $d) => $d['end'], $dated));
        $today = $this->getToday();

        $start = ($minStart < $today ? $minStart : $today)->modify('-7 days');
        $end = ($maxEnd > $today ? $maxEnd : $today)->modify('+7 days');

        $total = $end->getTimestamp() - $start->getTimestamp();
        $pct = static fn (\DateTimeInterface $d): float => max(0.0, min(100.0, (($d->getTimestamp() - $start->getTimestamp()) / $total) * 100));

        // Month axis
        $months = [];
        $cur = new \DateTimeImmutable($start->format('Y-m-01'), new \DateTimeZone($this->timezone));
        while ($cur <= $end) {
            $months[] = $cur;
            $cur = $cur->modify('+1 month');
        }
        $monthBlocks = [];
        foreach ($months as $i => $m) {
            $next = $months[$i + 1] ?? $end;
            $left = $pct($m);
            $width = $pct($next) - $left;
            $monthBlocks[] = [
                'label' => $m->format('M').(0 === $i ? ' ’'.$m->format('y') : ''),
                'leftPct' => $left,
                'widthPct' => $width,
            ];
        }

        // Pin holiday rows last so the event window reads like a footer.
        $ordered = $dated;
        uasort($ordered, static function (array $a, array $b): int {
            $ta = $a['data']['type'] ?? 'custom';
            $tb = $b['data']['type'] ?? 'custom';
            if ('holiday' === $ta && 'holiday' !== $tb) {
                return 1;
            }
            if ('holiday' === $tb && 'holiday' !== $ta) {
                return -1;
            }

            return $a['start'] <=> $b['start'];
        });

        $bars = [];
        foreach ($ordered as $key => $d) {
            $type = $d['data']['type'] ?? 'custom';
            $left = $pct($d['start']);
            $width = max(0.5, $pct($d['end']) - $left);
            $bars[] = [
                'key' => $key,
                'type' => $type,
                'leftPct' => $left,
                'widthPct' => $width,
                'range' => $d['start']->format('d.m.Y').' – '.$d['end']->format('d.m.Y'),
            ];
        }

        return [
            'start' => $start,
            'end' => $end,
            'todayPct' => $pct($today),
            'months' => $monthBlocks,
            'bars' => $bars,
            'count' => \count($dated),
        ];
    }

    #[LiveAction]
    public function addPhase(#[LiveArg] string $type): void
    {
        if (!\in_array($type, self::PHASE_TYPES, true)) {
            return;
        }

        ++$this->newCounter;
        $key = 'new_'.$this->newCounter;

        $today = $this->getToday()->setTime(9, 0);
        $this->phases[$key] = [
            'type' => $type,
            'title' => null,
            'periodBegin' => $today->format('Y-m-d\TH:i'),
            'periodEnd' => $today->modify('+7 days')->setTime(23, 59)->format('Y-m-d\TH:i'),
            'alwaysActive' => false,
            'applicationSystem' => 'application_system' === $type ? 'lot' : null,
            'maxApplications' => 5,
            'maxApplicationsDay' => 99,
            'maxWaitlist' => 0,
            'skipMaxApplications' => false,
            'allowAnonymous' => false,
            'allowAnonymousFee' => false,
            'accessCodeStrategyId' => null,
        ];

        $this->focusKey = $key;
        // `isDirty` is recomputed by detectDirty() on the next re-render — no
        // need to set it manually here.
    }

    #[LiveAction]
    public function deletePhase(#[LiveArg] string $key): void
    {
        if (!isset($this->phases[$key])) {
            return;
        }

        unset($this->phases[$key]);
    }

    #[LiveAction]
    public function setApplicationSystem(#[LiveArg] string $key, #[LiveArg] string $system): void
    {
        if (!isset($this->phases[$key]) || !\in_array($system, ['lot', 'firstcome'], true)) {
            return;
        }

        $this->phases[$key]['applicationSystem'] = $system;
    }

    #[LiveAction]
    public function jumpTo(#[LiveArg] string $key): void
    {
        $this->focusKey = $key;
    }

    /**
     * Flush in-memory snapshot to the EditionTask entities. New keys → new
     * entities, removed keys → entity removal, everything else → update.
     */
    #[LiveAction]
    public function save(#[LiveArg] ?string $nextSection = null, #[LiveArg] ?string $redirect = null): ?Response
    {
        $this->applyPhases();
        $this->entityManager->flush();

        // After save, normalize snapshot keys so subsequent edits target the
        // persisted IDs instead of stale `new_*` tokens.
        $this->phases = [];
        foreach ($this->edition->getTasks() as $task) {
            $this->phases['id_'.$task->getId()] = $this->serializeTask($task);
        }
        $this->newCounter = 0;
        $this->isDirty = false;

        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.';
        }

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

        if ($nextSection) {
            return $this->redirectToRoute('admin_editions_edit', ['alias' => $this->edition->getAlias(), 'section' => $nextSection]);
        }

        if ($redirect) {
            return $this->redirectToRoute($redirect, ['alias' => $this->edition->getAlias()]);
        }

        return null;
    }

    private function relative(int $days, string $prefix): string
    {
        if (0 === $days) {
            return $prefix.' heute';
        }
        if (1 === $days) {
            return $prefix.' morgen';
        }
        if ($days > 1) {
            return \sprintf('%s in %d Tagen', $prefix, $days);
        }

        return \sprintf('vor %d Tagen gestartet', abs($days));
    }

    private function applyPhases(): void
    {
        $existing = [];
        foreach ($this->edition->getTasks() as $task) {
            $existing[$task->getId()] = $task;
        }

        $seenIds = [];
        foreach ($this->phases as $key => $data) {
            $task = null;
            if (str_starts_with($key, 'id_')) {
                $id = (int) substr($key, 3);
                $task = $existing[$id] ?? null;
                if (null === $task) {
                    continue;
                }
                $seenIds[$id] = true;
            } else {
                $task = new EditionTask($this->edition);
                $this->edition->addTask($task);
                $this->entityManager->persist($task);
            }

            $this->hydrateTask($task, $data);
        }

        // Entities not present in the snapshot were removed in the UI.
        foreach ($existing as $id => $task) {
            if (!isset($seenIds[$id])) {
                $this->edition->removeTask($task);
                $this->entityManager->remove($task);
            }
        }
    }

    /**
     * @return array{type: ?string, title: ?string, periodBegin: ?string, periodEnd: ?string, alwaysActive: bool, applicationSystem: ?string, maxApplications: ?int, maxApplicationsDay: ?int, maxWaitlist: ?int, skipMaxApplications: bool, allowAnonymous: bool, allowAnonymousFee: bool, accessCodeStrategyId: ?int}
     */
    private function serializeTask(EditionTask $task): array
    {
        $tz = new \DateTimeZone($this->timezone);

        return [
            'type' => $task->getType(),
            'title' => $task->getTitle(),
            'periodBegin' => $task->getPeriodBegin()?->setTimezone($tz)->format('Y-m-d\TH:i'),
            'periodEnd' => $task->getPeriodEnd()?->setTimezone($tz)->format('Y-m-d\TH:i'),
            'alwaysActive' => $task->isAlwaysActive(),
            'applicationSystem' => $task->getApplicationSystem(),
            'maxApplications' => $task->getMaxApplications(),
            'maxApplicationsDay' => $task->getMaxApplicationsDay(),
            'maxWaitlist' => $task->getMaxWaitlist(),
            'skipMaxApplications' => $task->isSkipMaxApplications(),
            'allowAnonymous' => $task->isAllowAnonymous(),
            'allowAnonymousFee' => $task->isAllowAnonymousFee(),
            'accessCodeStrategyId' => $task->getAccessCodeStrategy()?->getId(),
        ];
    }

    /**
     * @param array<string, mixed> $data
     */
    private function hydrateTask(EditionTask $task, array $data): void
    {
        $task->setType($data['type'] ?? null);
        if (isset($data['title']) && '' !== $data['title']) {
            $task->setTitle((string) $data['title']);
        }
        $task->setAlwaysActive((bool) ($data['alwaysActive'] ?? false));
        $task->setPeriodBegin($this->parseDate($data['periodBegin'] ?? null));
        $task->setPeriodEnd($this->parseDate($data['periodEnd'] ?? null));

        if (!empty($data['applicationSystem'])) {
            $task->setApplicationSystem((string) $data['applicationSystem']);
        }

        if (null !== ($data['maxApplications'] ?? null) && '' !== $data['maxApplications']) {
            $task->setMaxApplications((int) $data['maxApplications']);
        }
        if (null !== ($data['maxApplicationsDay'] ?? null) && '' !== $data['maxApplicationsDay']) {
            $task->setMaxApplicationsDay((int) $data['maxApplicationsDay']);
        }
        if (null !== ($data['maxWaitlist'] ?? null) && '' !== $data['maxWaitlist']) {
            $task->setMaxWaitlist((int) $data['maxWaitlist']);
        }

        $task->setSkipMaxApplications((bool) ($data['skipMaxApplications'] ?? false));
        $task->setAllowAnonymous((bool) ($data['allowAnonymous'] ?? false));
        $task->setAllowAnonymousFee((bool) ($data['allowAnonymousFee'] ?? false));

        $strategyId = $data['accessCodeStrategyId'] ?? null;
        if (null === $strategyId || '' === $strategyId) {
            $task->setAccessCodeStrategy(null);
        } else {
            $strategy = $this->accessCodeStrategies->find((int) $strategyId);
            $task->setAccessCodeStrategy($strategy instanceof AccessCodeStrategy ? $strategy : null);
        }
    }

    /**
     * Interpret a `Y-m-d\TH:i` string from the client as wall-clock in the
     * admin's configured timezone, then convert to PHP's default timezone so
     * Doctrine persists the canonical wall-clock for that column. Reading the
     * value back via `serializeTask()` shifts it to admin TZ again — same
     * normalization the offer-edit `OfferDates` component uses.
     */
    private function parseDate(?string $value): ?\DateTimeImmutable
    {
        if (null === $value || '' === $value) {
            return null;
        }

        try {
            return new \DateTimeImmutable($value, new \DateTimeZone($this->timezone))
                ->setTimezone(new \DateTimeZone(date_default_timezone_get()));
        } catch (\Exception) {
            return null;
        }
    }
}
