<?php

namespace QUI\InfoPopup;

use QUI;
use QUI\Interfaces\Users\User;
use QUI\Projects\Project;
use QUI\Utils\Doctrine;

/**
 * Class Handler
 *
 * CRUD and delivery logic for info popup entries.
 *
 * @package quiqqer/info-popup
 */
class Handler
{
    const TABLE = 'infoPopup';

    const VISIBILITY_MODES = ['always', 'guest', 'authenticated', 'groups'];

    const DISPLAY_MODES = ['content', 'sidebar', 'sidebarRight', 'top'];

    const VERTICAL_ALIGNMENTS = ['top', 'center', 'bottom'];

    /**
     * Returns the info popup table name (with system prefix).
     */
    public static function getTable(): string
    {
        return QUI::getDBTableName(self::TABLE);
    }

    //region CRUD

    /**
     * Return all entries of a project (unfiltered), sorted by priority.
     *
     * @return array<int, array<string, mixed>>
     */
    public static function getList(Project $Project): array
    {
        $QueryBuilder = QUI::getQueryBuilder();

        return $QueryBuilder
            ->select('*')
            ->from(Doctrine::quoteIdentifier(self::getTable()))
            ->where($QueryBuilder->expr()->eq('project', ':project'))
            ->orderBy('priority', 'DESC')
            ->addOrderBy('id', 'ASC')
            ->setParameter('project', $Project->getName())
            ->executeQuery()
            ->fetchAllAssociative();
    }

    /**
     * Return a single entry.
     *
     * @return array<string, mixed>
     * @throws QUI\Exception
     */
    public static function getEntry(int $id): array
    {
        $QueryBuilder = QUI::getQueryBuilder();

        $data = $QueryBuilder
            ->select('*')
            ->from(Doctrine::quoteIdentifier(self::getTable()))
            ->where($QueryBuilder->expr()->eq('id', ':id'))
            ->setParameter('id', $id)
            ->setMaxResults(1)
            ->executeQuery()
            ->fetchAssociative();

        if ($data === false) {
            throw new QUI\Exception('Info popup entry not found', 404);
        }

        return $data;
    }

    /**
     * Return and lock a project-bound entry for a following mutation.
     * Must be called inside an active database transaction.
     *
     * @return array<string, mixed>
     * @throws QUI\Exception
     */
    public static function getEntryForUpdate(int $id, string $project): array
    {
        $QueryBuilder = QUI::getQueryBuilder();

        $data = $QueryBuilder
            ->select('*')
            ->from(Doctrine::quoteIdentifier(self::getTable()))
            ->where($QueryBuilder->expr()->eq('id', ':id'))
            ->andWhere($QueryBuilder->expr()->eq('project', ':project'))
            ->setParameter('id', $id)
            ->setParameter('project', $project)
            ->setMaxResults(1)
            ->forUpdate()
            ->executeQuery()
            ->fetchAssociative();

        if ($data === false) {
            throw new QUI\Exception('Info popup entry not found', 404);
        }

        return $data;
    }

    /**
     * Create a new (inactive) entry for a project and return its id.
     */
    public static function create(Project $Project): int
    {
        $Connection = QUI::getDataBaseConnection();

        $Connection->insert(
            Doctrine::quoteIdentifier(self::getTable()),
            self::quoteColumns([
                'project' => $Project->getName(),
                'active' => 0,
                'priority' => 0,
                'visibility' => 'always',
                'displayMode' => '',
                'editDate' => time()
            ])
        );

        return (int)$Connection->lastInsertId();
    }

    /**
     * Save an entry. Sets a fresh editDate (version token) so the frontend
     * shows the popup again after an editorial change.
     *
     * @param array<string, mixed> $data
     * @throws QUI\Exception
     */
    public static function save(int $id, array $data): void
    {
        $entry = self::getEntry($id);

        $update = [
            'active' => empty($data['active']) ? 0 : 1,
            'priority' => (int)($data['priority'] ?? 0),
            'title' => self::encodeMultiLang($data['title'] ?? ''),
            'content' => self::encodeMultiLang($data['content'] ?? ''),
            'languages' => self::sanitizeLanguages($data['languages'] ?? null, (string)$entry['project']),
            'validFrom' => self::sanitizeDateTime($data['validFrom'] ?? null),
            'validTo' => self::sanitizeDateTime($data['validTo'] ?? null),
            'visibility' => self::sanitizeVisibility($data['visibility'] ?? 'always'),
            'visibilityGroups' => self::sanitizeGroups($data['visibilityGroups'] ?? ''),
            'displayMode' => self::normalizeDisplayMode($data['displayMode'] ?? ''),
            'verticalAlign' => self::sanitizeVerticalAlign($data['verticalAlign'] ?? 'top'),
            'centerContent' => empty($data['centerContent']) ? 0 : 1,
            'mediaImage' => self::nullableString($data['mediaImage'] ?? ''),
            'mediaColor' => self::nullableString($data['mediaColor'] ?? ''),
            'mediaAutoHeight' => empty($data['mediaAutoHeight']) ? 0 : 1,
            'maxWidth' => self::nullableInt($data['maxWidth'] ?? null),
            'maxHeight' => self::nullableInt($data['maxHeight'] ?? null),
            'editDate' => time()
        ];

        QUI::getDataBaseConnection()->update(
            Doctrine::quoteIdentifier(self::getTable()),
            self::quoteColumns($update),
            self::quoteColumns(['id' => $id])
        );
    }

    /**
     * Delete an entry.
     */
    public static function delete(int $id): void
    {
        QUI::getDataBaseConnection()->delete(
            Doctrine::quoteIdentifier(self::getTable()),
            self::quoteColumns(['id' => $id])
        );
    }

    //endregion

    //region Frontend delivery

    /**
     * Cheap, user-independent check whether the project has active and
     * time-valid entries at all. Used to decide the header bootstrap.
     */
    public static function hasActiveEntries(Project $Project): bool
    {
        $QueryBuilder = QUI::getQueryBuilder();

        $count = $QueryBuilder
            ->select('COUNT(id)')
            ->from(Doctrine::quoteIdentifier(self::getTable()))
            ->where($QueryBuilder->expr()->eq('project', ':project'))
            ->andWhere($QueryBuilder->expr()->eq('active', ':active'))
            ->andWhere('(validFrom IS NULL OR validFrom <= :now)')
            ->andWhere('(validTo IS NULL OR validTo >= :now)')
            ->setParameter('project', $Project->getName())
            ->setParameter('active', 1)
            ->setParameter('now', date('Y-m-d H:i:s'))
            ->executeQuery()
            ->fetchOne();

        return (int)$count > 0;
    }

    /**
     * Return the active, time-valid and (for the given user) visible entries
     * as lightweight metadata for the opening queue (id, editDate, size,
     * timeout).
     *
     * User-dependent -> must not be cached. The heavy popup markup is fetched
     * lazily and per entry through renderPopupHtml().
     *
     * @return array<int, array<string, int>>
     */
    public static function getActiveEntries(Project $Project, User $User): array
    {
        $QueryBuilder = QUI::getQueryBuilder();

        $rows = $QueryBuilder
            ->select('*')
            ->from(Doctrine::quoteIdentifier(self::getTable()))
            ->where($QueryBuilder->expr()->eq('project', ':project'))
            ->andWhere($QueryBuilder->expr()->eq('active', ':active'))
            ->andWhere('(validFrom IS NULL OR validFrom <= :now)')
            ->andWhere('(validTo IS NULL OR validTo >= :now)')
            ->orderBy('priority', 'DESC')
            ->addOrderBy('id', 'ASC')
            ->setParameter('project', $Project->getName())
            ->setParameter('active', 1)
            ->setParameter('now', date('Y-m-d H:i:s'))
            ->executeQuery()
            ->fetchAllAssociative();

        $timeout = max(0, (int)$Project->getConfig('infoPopup.timeout'));
        $lang = $Project->getLang();

        $result = [];

        foreach ($rows as $row) {
            if (!self::isEntryVisibleForUser($row, $User)) {
                continue;
            }

            if (!self::isEntryVisibleInLanguage($row, $lang)) {
                continue;
            }

            if (self::isEntryEmptyInLanguage($row, $lang)) {
                continue;
            }

            $result[] = [
                'id' => (int)$row['id'],
                'editDate' => (int)($row['editDate'] ?? 0),
                'maxWidth' => self::resolveSize($row['maxWidth'] ?? null, $Project, 'maxWidth', 800),
                'maxHeight' => self::resolveSize($row['maxHeight'] ?? null, $Project, 'maxHeight', 600),
                'timeout' => $timeout
            ];
        }

        return $result;
    }

    /**
     * Render the complete popup markup for one entry.
     *
     * Re-checks the active / time / visibility gate for the given user (the
     * endpoint is public), then renders the Smarty template. The resulting
     * markup is user-independent and cached by id + editDate + language.
     *
     * Returns an empty string when the entry must not be shown.
     */
    public static function renderPopupHtml(int $id, Project $Project, User $User): string
    {
        try {
            $row = self::getEntry($id);
        } catch (QUI\Exception) {
            return '';
        }

        if ((string)($row['project'] ?? '') !== $Project->getName()) {
            return '';
        }

        if (!self::isEntryActiveAndTimeValid($row) || !self::isEntryVisibleForUser($row, $User)) {
            return '';
        }

        $lang = $Project->getLang();

        if (!self::isEntryVisibleInLanguage($row, $lang) || self::isEntryEmptyInLanguage($row, $lang)) {
            return '';
        }

        $cacheKey = 'quiqqer/info-popup/rendered/' . (int)$row['id']
            . '-' . (int)($row['editDate'] ?? 0) . '-' . $lang;

        try {
            $cached = QUI\Cache\Manager::get($cacheKey);

            if (is_string($cached)) {
                return $cached;
            }
        } catch (QUI\Exception) {
            // not cached yet
        }

        try {
            $html = self::buildPopupHtml($row, $Project, $lang);
        } catch (\Exception $Exception) {
            QUI\System\Log::writeDebugException($Exception);

            return '';
        }

        try {
            QUI\Cache\Manager::set($cacheKey, $html);
        } catch (\Exception $Exception) {
            QUI\System\Log::writeDebugException($Exception);
        }

        return $html;
    }

    /**
     * Build the popup markup from a raw row via the Smarty template.
     *
     * The editor content runs through the normal QUIQQER output pipeline
     * (responsive images, internal links, ...); the media image is rendered
     * by the Smarty {image} function inside the template.
     *
     * @param array<string, mixed> $row
     */
    private static function buildPopupHtml(array $row, Project $Project, string $lang): string
    {
        $displayMode = self::resolveDisplayMode($row, $Project);
        // delivery: no cross-language fallback, empty translations stay empty
        $content = self::decodeMultiLang($row['content'] ?? '', $lang, false);

        $Engine = QUI::getTemplateManager()->getEngine();
        $Engine->assign([
            'displayMode' => $displayMode,
            'verticalAlign' => self::sanitizeVerticalAlign($row['verticalAlign'] ?? 'top'),
            'centerContent' => !empty($row['centerContent']),
            'title' => self::decodeMultiLang($row['title'] ?? '', $lang, false),
            'content' => QUI\Output::getInstance()->parse($content),
            'mediaImage' => $displayMode === 'content' ? '' : (string)($row['mediaImage'] ?? ''),
            'mediaColor' => $displayMode === 'content' ? '' : (string)($row['mediaColor'] ?? ''),
            'mediaAutoHeight' => $displayMode === 'top' && !empty($row['mediaAutoHeight'])
        ]);

        return $Engine->fetch(dirname(__FILE__) . '/Popup.html');
    }

    /**
     * Is the entry active and within its validity window?
     *
     * @param array<string, mixed> $row
     */
    private static function isEntryActiveAndTimeValid(array $row): bool
    {
        if (empty($row['active'])) {
            return false;
        }

        $now = date('Y-m-d H:i:s');
        $validFrom = $row['validFrom'] ?? null;
        $validTo = $row['validTo'] ?? null;

        if (!empty($validFrom) && (string)$validFrom > $now) {
            return false;
        }

        if (!empty($validTo) && (string)$validTo < $now) {
            return false;
        }

        return true;
    }

    //endregion

    //region Pure helpers (testable)

    /**
     * Decode a multilingual JSON value for a language. Accepts a plain string
     * as legacy input.
     *
     * With $fallback = true (grid, preview) an empty translation falls back to
     * the first non-empty language, so an admin row is never blank. With
     * $fallback = false (frontend delivery) a missing translation stays empty,
     * so no foreign-language text leaks into another language version.
     *
     * @param mixed $value
     */
    public static function decodeMultiLang(mixed $value, string $lang, bool $fallback = true): string
    {
        if ($value === null || $value === '') {
            return '';
        }

        $data = is_string($value) ? json_decode($value, true) : $value;

        if (!is_array($data)) {
            return (string)$value;
        }

        if (isset($data[$lang]) && $data[$lang] !== '') {
            return (string)$data[$lang];
        }

        if (!$fallback) {
            return '';
        }

        foreach ($data as $entry) {
            if (is_string($entry) && $entry !== '') {
                return $entry;
            }
        }

        return '';
    }

    /**
     * Parse the stored language targeting into a clean list of language codes.
     *
     * Returns null when the popup is not restricted (shown in all project
     * languages), otherwise the explicit set of target language codes.
     *
     * @param mixed $value
     * @return array<int, string>|null
     */
    public static function parseLanguages(mixed $value): ?array
    {
        if ($value === null || $value === '') {
            return null;
        }

        $data = is_string($value) ? json_decode($value, true) : $value;

        if (!is_array($data)) {
            return null;
        }

        $codes = [];

        foreach ($data as $code) {
            $code = trim((string)$code);

            if ($code !== '') {
                $codes[$code] = true;
            }
        }

        return empty($codes) ? null : array_keys($codes);
    }

    /**
     * Language gate: is the entry meant to be shown in the given language?
     * An unrestricted entry (languages = null) shows in every language.
     *
     * @param array<string, mixed> $entry
     */
    public static function isEntryVisibleInLanguage(array $entry, string $lang): bool
    {
        $languages = self::parseLanguages($entry['languages'] ?? null);

        return $languages === null || in_array($lang, $languages, true);
    }

    /**
     * Empty guard: has the entry nothing to show in the given language?
     * True when title, content and media image are all empty for $lang. The
     * text is read without cross-language fallback, matching the delivery.
     *
     * @param array<string, mixed> $entry
     */
    public static function isEntryEmptyInLanguage(array $entry, string $lang): bool
    {
        $title = self::decodeMultiLang($entry['title'] ?? '', $lang, false);
        $content = self::decodeMultiLang($entry['content'] ?? '', $lang, false);
        $image = trim((string)($entry['mediaImage'] ?? ''));

        return $title === '' && $content === '' && $image === '';
    }

    /**
     * Parse a group value (array or CSV) into a clean list of group tokens.
     *
     * @param mixed $value
     * @return array<int, string>
     */
    public static function parseGroupIds(mixed $value): array
    {
        if (is_array($value)) {
            $tokens = $value;
        } elseif (is_string($value) && $value !== '') {
            $tokens = explode(',', $value);
        } else {
            return [];
        }

        $tokens = array_map(static fn($token): string => trim((string)$token), $tokens);

        return array_values(array_filter($tokens, static fn(string $token): bool => $token !== ''));
    }

    /**
     * Visibility check, following the quiqqer/bricks pattern.
     *
     * @param array<string, mixed> $entry
     */
    public static function isEntryVisibleForUser(array $entry, User $User): bool
    {
        return self::isVisibleForUserStatus(
            self::sanitizeVisibility($entry['visibility'] ?? 'always'),
            self::parseGroupIds($entry['visibilityGroups'] ?? ''),
            $User,
            QUI::getUsers()->isAuth($User)
        );
    }

    /**
     * @param array<int, string> $groupIds
     */
    public static function isVisibleForUserStatus(
        string $visibility,
        array $groupIds,
        User $User,
        bool $isAuthenticated
    ): bool {
        // guest-like: Nobody but not the SystemUser (SystemUser extends Nobody)
        $isGuestLike = $User instanceof QUI\Users\Nobody
            && !($User instanceof QUI\Users\SystemUser);

        return match ($visibility) {
            'guest' => !$isAuthenticated || $isGuestLike,
            'authenticated' => $isAuthenticated && !$isGuestLike,
            'groups' => !empty($groupIds)
                && !empty(array_intersect($groupIds, $User->getGroups(false))),
            default => true
        };
    }

    //endregion

    //region Value sanitizing

    private static function sanitizeVisibility(mixed $value): string
    {
        $value = (string)$value;

        return in_array($value, self::VISIBILITY_MODES, true) ? $value : 'always';
    }

    /**
     * Normalize a display mode for storage. Returns a valid mode or an empty
     * string, where empty means "fall back to the project default".
     */
    private static function normalizeDisplayMode(mixed $value): string
    {
        $value = (string)$value;

        return in_array($value, self::DISPLAY_MODES, true) ? $value : '';
    }

    private static function sanitizeVerticalAlign(mixed $value): string
    {
        $value = (string)$value;

        return in_array($value, self::VERTICAL_ALIGNMENTS, true) ? $value : 'top';
    }

    private static function sanitizeGroups(mixed $value): ?string
    {
        $ids = self::parseGroupIds($value);

        return empty($ids) ? null : implode(',', $ids);
    }

    /**
     * @param mixed $value
     */
    private static function encodeMultiLang(mixed $value): ?string
    {
        if (is_array($value)) {
            $json = json_encode($value, JSON_UNESCAPED_UNICODE);

            return $json === false ? null : $json;
        }

        $value = (string)$value;

        return $value === '' ? null : $value;
    }

    /**
     * Normalize the language targeting for storage.
     *
     * null / empty selection -> null (all project languages, now and future).
     * Codes outside the project languages are dropped. A selection that covers
     * every project language is normalized back to null, so "all selected" and
     * "unrestricted" behave identically. Otherwise a JSON array of codes.
     *
     * @param mixed $value
     */
    private static function sanitizeLanguages(mixed $value, string $project): ?string
    {
        $codes = self::parseLanguages($value);

        if ($codes === null) {
            return null;
        }

        $projectLanguages = self::getProjectLanguages($project);

        if (!empty($projectLanguages)) {
            $codes = array_values(array_intersect($codes, $projectLanguages));

            if (empty($codes) || count($codes) === count($projectLanguages)) {
                return null;
            }
        }

        sort($codes);

        $json = json_encode($codes, JSON_UNESCAPED_UNICODE);

        return $json === false ? null : $json;
    }

    /**
     * Return the configured languages of a project, or an empty list when the
     * project cannot be resolved.
     *
     * @return array<int, string>
     */
    private static function getProjectLanguages(string $project): array
    {
        if ($project === '') {
            return [];
        }

        try {
            return QUI::getProject($project)->getLanguages();
        } catch (QUI\Exception) {
            return [];
        }
    }

    private static function sanitizeDateTime(mixed $value): ?string
    {
        if (empty($value)) {
            return null;
        }

        $time = strtotime((string)$value);

        if ($time === false) {
            return null;
        }

        return date('Y-m-d H:i:s', $time);
    }

    private static function nullableString(mixed $value): ?string
    {
        $value = trim((string)$value);

        return $value === '' ? null : $value;
    }

    private static function nullableInt(mixed $value): ?int
    {
        if ($value === null || $value === '') {
            return null;
        }

        $int = (int)$value;

        return $int > 0 ? $int : null;
    }

    /**
     * @param array<string, mixed> $row
     */
    private static function resolveDisplayMode(array $row, Project $Project): string
    {
        $mode = self::normalizeDisplayMode($row['displayMode'] ?? '');

        if ($mode !== '') {
            return $mode;
        }

        // entry defers to the project default; fall back to "content" if unset
        $mode = self::normalizeDisplayMode($Project->getConfig('infoPopup.displayMode'));

        return $mode !== '' ? $mode : 'content';
    }

    /**
     * Resolve the effective size: entry value, else project default, else fallback.
     *
     * @param mixed $value
     */
    private static function resolveSize(mixed $value, Project $Project, string $configKey, int $fallback): int
    {
        if ($value !== null && $value !== '' && (int)$value > 0) {
            return (int)$value;
        }

        $configValue = (int)$Project->getConfig('infoPopup.' . $configKey);

        return $configValue > 0 ? $configValue : $fallback;
    }

    /**
     * Quote column identifiers for DBAL insert/update/delete data arrays.
     *
     * @param array<string, mixed> $data
     * @return array<string, mixed>
     */
    private static function quoteColumns(array $data): array
    {
        $quoted = [];

        foreach ($data as $column => $value) {
            $quoted[Doctrine::quoteIdentifier($column)] = $value;
        }

        return $quoted;
    }

    //endregion
}
