<?php
/*
 * Copyright (C) 2024 Xibo Signage Ltd
 *
 * Xibo - Digital Signage - https://xibosignage.com
 *
 * This file is part of Xibo.
 *
 * Xibo is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * any later version.
 *
 * Xibo is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with Xibo.  If not, see <http://www.gnu.org/licenses/>.
 */

namespace Xibo\XTR;

use Carbon\Carbon;
use Xibo\Entity\Display;
use Xibo\Entity\Module;
use Xibo\Entity\Widget;
use Xibo\Event\WidgetDataRequestEvent;
use Xibo\Factory\WidgetDataFactory;
use Xibo\Helper\DateFormatHelper;
use Xibo\Support\Exception\GeneralException;
use Xibo\Support\Exception\NotFoundException;
use Xibo\Widget\Provider\WidgetProviderInterface;

/**
 * Class WidgetSyncTask
 * Keep all widgets which have data up to date
 * @package Xibo\XTR
 */
class WidgetSyncTask implements TaskInterface
{
    use TaskTrait;

    /** @var \Xibo\Factory\ModuleFactory */
    private $moduleFactory;

    /** @var \Xibo\Factory\WidgetFactory */
    private $widgetFactory;

    /** @var \Xibo\Factory\WidgetDataFactory */
    private WidgetDataFactory $widgetDataFactory;

    /** @var \Xibo\Factory\MediaFactory */
    private $mediaFactory;

    /** @var \Xibo\Factory\DisplayFactory */
    private $displayFactory;

    /** @var \Symfony\Component\EventDispatcher\EventDispatcher */
    private $eventDispatcher;

    /** @inheritdoc */
    public function setFactories($container)
    {
        $this->moduleFactory = $container->get('moduleFactory');
        $this->widgetFactory = $container->get('widgetFactory');
        $this->widgetDataFactory = $container->get('widgetDataFactory');
        $this->mediaFactory = $container->get('mediaFactory');
        $this->displayFactory = $container->get('displayFactory');
        $this->eventDispatcher = $container->get('dispatcher');
        return $this;
    }

    /** @inheritdoc */
    public function run()
    {
        // Track the total time we've spent caching
        $timeCaching = 0.0;
        $countWidgets = 0;
        $cutOff = Carbon::now()->subHours(2);

        // Update for widgets which are active on displays, or for displays which have been active recently.
        $sql = '
          SELECT DISTINCT `requiredfile`.itemId, `requiredfile`.complete 
            FROM `requiredfile` 
              INNER JOIN `display`
              ON `display`.displayId = `requiredfile`.displayId
           WHERE `requiredfile`.type = \'D\' 
              AND (`display`.loggedIn = 1 OR `display`.lastAccessed > :lastAccessed)
          ORDER BY `requiredfile`.complete DESC, `requiredfile`.itemId
        ';

        $smt = $this->store->getConnection()->prepare($sql);
        $smt->execute(['lastAccessed' => $cutOff->unix()]);

        $row = true;
        while ($row) {
            $row = $smt->fetch(\PDO::FETCH_ASSOC);
            try {
                if ($row !== false) {
                    $widgetId = (int)$row['itemId'];

                    $this->getLogger()->debug('widgetSyncTask: processing itemId ' . $widgetId);

                    // What type of widget do we have here.
                    $widget = $this->widgetFactory->getById($widgetId)->load();

                    // Get the module
                    $module = $this->moduleFactory->getByType($widget->type);

                    // If this widget's module expects data to be provided (i.e. has a datatype) then make sure that
                    // data is cached ahead of time here.
                    // This also refreshes any library or external images referenced by the data so that they aren't
                    // considered for removal.
                    if ($module->isDataProviderExpected()) {
                        $this->getLogger()->debug('widgetSyncTask: data provider expected.');

                        // Record start time
                        $countWidgets++;
                        $startTime = microtime(true);

                        // Grab a widget interface, if there is one
                        $widgetInterface = $module->getWidgetProviderOrNull();

                        // Is the cache key display specific?
                        $dataProvider = $module->createDataProvider($widget);
                        $cacheKey = $widgetInterface?->getDataCacheKey($dataProvider);

                        if ($cacheKey === null) {
                            $cacheKey = $module->dataCacheKey;
                        }

                        // Refresh the cache if needed.
                        $isDisplaySpecific = str_contains($cacheKey, '%displayId%')
                            || (str_contains($cacheKey, '%useDisplayLocation%')
                                && $dataProvider->getProperty('useDisplayLocation') == 1);

                        // We're either assigning all media to all displays, or we're assigning them one by one
                        if ($isDisplaySpecific) {
                            $this->getLogger()->debug('widgetSyncTask: cache is display specific');

                            // We need to run the cache for every display this widget is assigned to.
                            foreach ($this->getDisplays($widget) as $display) {
                                $cacheData = $this->cache(
                                    $module,
                                    $widget,
                                    $widgetInterface,
                                    $display,
                                );

                                $this->linkDisplays(
                                    $widget->widgetId,
                                    [$display],
                                    $cacheData['mediaIds'],
                                    $cacheData['fromCache']
                                );
                            }
                        } else {
                            $this->getLogger()->debug('widgetSyncTask: cache is not display specific');

                            // Just a single run will do it.
                            $cacheData = $this->cache($module, $widget, $widgetInterface, null);

                            $this->linkDisplays(
                                $widget->widgetId,
                                $this->getDisplays($widget),
                                $cacheData['mediaIds'],
                                $cacheData['fromCache']
                            );
                        }

                        // Record end time and aggregate for final total
                        $duration = (microtime(true) - $startTime);
                        $timeCaching = $timeCaching + $duration;
                        $this->log->debug('widgetSyncTask: Took ' . $duration
                            . ' seconds to check and/or cache widgetId ' . $widget->widgetId);

                        // Commit so that any images we've downloaded have their cache times updated for the
                        // next request, this makes sense because we've got a file cache that is already written
                        // out.
                        $this->store->commitIfNecessary();
                    }
                }
            } catch (GeneralException $xiboException) {
                $this->log->debug($xiboException->getTraceAsString());
                $this->log->error('widgetSyncTask: Cannot process widget ' . $widgetId
                    . ', E = ' . $xiboException->getMessage());
            }
        }

        // Remove display_media records which have not been touched for a defined period of time.
        $this->removeOldDisplayLinks($cutOff);

        $this->log->info('Total time spent caching is ' . $timeCaching . ', synced ' . $countWidgets . ' widgets');

        $this->appendRunMessage('Synced ' . $countWidgets . ' widgets');
    }

    /**
     * @param Module $module
     * @param Widget $widget
     * @param WidgetProviderInterface|null $widgetInterface
     * @param Display|null $display
     * @return array
     * @throws GeneralException
     */
    private function cache(
        Module $module,
        Widget $widget,
        ?WidgetProviderInterface $widgetInterface,
        ?Display $display
    ): array {
        $this->getLogger()->debug('cache: ' . $widget->widgetId . ' for display: ' . ($display?->displayId ?? 0));

        // Each time we call this we use a new provider
        $dataProvider = $module->createDataProvider($widget);
        $dataProvider->setMediaFactory($this->mediaFactory);

        // Set our provider up for the display
        $dataProvider->setDisplayProperties(
            $display?->latitude ?: $this->getConfig()->getSetting('DEFAULT_LAT'),
            $display?->longitude ?: $this->getConfig()->getSetting('DEFAULT_LONG'),
            $display?->displayId ?? 0
        );

        $widgetDataProviderCache = $this->moduleFactory->createWidgetDataProviderCache();

        // Get the cache key
        $cacheKey = $this->moduleFactory->determineCacheKey(
            $module,
            $widget,
            $display?->displayId ?? 0,
            $dataProvider,
            $widgetInterface
        );

        // Get the data modified date
        $dataModifiedDt = null;
        if ($widgetInterface !== null) {
            $dataModifiedDt = $widgetInterface->getDataModifiedDt($dataProvider);

            if ($dataModifiedDt !== null) {
                $this->getLogger()->debug('cache: data modifiedDt is ' . $dataModifiedDt->toAtomString());
            }
        }

        // Will we use fallback data if available?
        $showFallback = $widget->getOptionValue('showFallback', 'never');
        if ($showFallback !== 'never') {
            // What data type are we dealing with?
            try {
                $dataTypeFields = [];
                foreach ($this->moduleFactory->getDataTypeById($module->dataType)->fields as $field) {
                    $dataTypeFields[$field->id] = $field->type;
                }

                // Potentially we will, so get the modifiedDt of this fallback data.
                $fallbackModifiedDt = $this->widgetDataFactory->getModifiedDtForWidget($widget->widgetId);

                if ($fallbackModifiedDt !== null) {
                    $this->getLogger()->debug('cache: fallback modifiedDt is ' . $fallbackModifiedDt->toAtomString());

                    $dataModifiedDt = max($dataModifiedDt, $fallbackModifiedDt);
                }
            } catch (NotFoundException) {
                $this->getLogger()->info('cache: widget will fallback set where the module does not support it');
                $dataTypeFields = null;
            }
        } else {
            $dataTypeFields = null;
        }

        // Is this data from cache?
        $fromCache = false;

        if (!$widgetDataProviderCache->decorateWithCache($dataProvider, $cacheKey, $dataModifiedDt)
            || $widgetDataProviderCache->isCacheMissOrOld()
        ) {
            $this->getLogger()->debug('cache: Cache expired, pulling fresh: key: ' . $cacheKey);

            $dataProvider->clearData();
            $dataProvider->clearMeta();
            $dataProvider->addOrUpdateMeta('showFallback', $showFallback);

            try {
                if ($widgetInterface !== null) {
                    $widgetInterface->fetchData($dataProvider);
                } else {
                    $dataProvider->setIsUseEvent();
                }

                if ($dataProvider->isUseEvent()) {
                    $this->getDispatcher()->dispatch(
                        new WidgetDataRequestEvent($dataProvider),
                        WidgetDataRequestEvent::$NAME
                    );
                }

                // Before caching images, check to see if the data provider is handled
                $isFallback = false;
                if ($showFallback !== 'never'
                    && $dataTypeFields !== null
                    && (
                        count($dataProvider->getErrors()) > 0
                        || count($dataProvider->getData()) <= 0
                        || $showFallback === 'always'
                    )
                ) {
                    // Error or no data.
                    // Pull in the fallback data
                    foreach ($this->widgetDataFactory->getByWidgetId($dataProvider->getWidgetId()) as $item) {
                        // Handle any special data types in the fallback data
                        foreach ($item->data as $itemId => $itemData) {
                            if (!empty($itemData)
                                && array_key_exists($itemId, $dataTypeFields)
                                && $dataTypeFields[$itemId] === 'image'
                            ) {
                                $item->data[$itemId] = $dataProvider->addLibraryFile($itemData);
                            }
                        }

                        $dataProvider->addItem($item->data);

                        // Indicate we've been handled by fallback data
                        $isFallback = true;
                    }

                    if ($isFallback) {
                        $dataProvider->addOrUpdateMeta('includesFallback', true);
                    }
                }

                // Remove fallback data from the cache if no-longer needed
                if (!$isFallback) {
                    $dataProvider->addOrUpdateMeta('includesFallback', false);
                }

                // Do we have images?
                // They could be library images (i.e. they already exist) or downloads
                $mediaIds = $dataProvider->getImageIds();
                if (count($mediaIds) > 0) {
                    // Process the downloads.
                    $this->mediaFactory->processDownloads(function ($media) {
                        /** @var \Xibo\Entity\Media $media */
                        // Success
                        // We don't need to do anything else, references to mediaId will be built when we decorate
                        // the HTML.
                        $this->getLogger()->debug('cache: Successfully downloaded ' . $media->mediaId);
                    }, function ($media) use (&$mediaIds) {
                        /** @var \Xibo\Entity\Media $media */
                        // Error
                        // Remove it
                        unset($mediaIds[$media->mediaId]);
                    });
                }

                // Save to cache
                if ($dataProvider->isHandled() || $isFallback) {
                    $widgetDataProviderCache->saveToCache($dataProvider);
                }
            } finally {
                $widgetDataProviderCache->finaliseCache();
            }
        } else {
            $this->getLogger()->debug('cache: Cache still valid, key: ' . $cacheKey);

            $fromCache = true;

            // Get the existing mediaIds so that we can maintain the links to displays.
            $mediaIds = $widgetDataProviderCache->getCachedMediaIds();
        }

        return [
            'mediaIds' => $mediaIds,
            'fromCache' => $fromCache
        ];
    }

    /**
     * @param \Xibo\Entity\Widget $widget
     * @return Display[]
     */
    private function getDisplays(Widget $widget): array
    {
        $sql = '
            SELECT DISTINCT `requiredfile`.`displayId`
              FROM `requiredfile`
             WHERE itemId = :widgetId
                AND type = \'D\'
        ';

        $displayIds = [];
        foreach ($this->store->select($sql, ['widgetId' => $widget->widgetId]) as $record) {
            $displayId = intval($record['displayId']);
            try {
                $displayIds[] = $this->displayFactory->getById($displayId);
            } catch (NotFoundException) {
                $this->getLogger()->error('getDisplayIds: unknown displayId: ' . $displayId);
            }
        }

        return $displayIds;
    }

    /**
     * Link an array of displays with an array of media
     * @param int $widgetId
     * @param Display[] $displays
     * @param int[] $mediaIds
     * @param bool $fromCache
     * @return void
     */
    private function linkDisplays(int $widgetId, array $displays, array $mediaIds, bool $fromCache): void
    {
        $this->getLogger()->debug('linkDisplays: ' . count($displays) . ' displays, ' . count($mediaIds) . ' media');

        $sql = '
            INSERT INTO `display_media` (`displayId`, `mediaId`, `modifiedAt`) 
                VALUES (:displayId, :mediaId, CURRENT_TIMESTAMP)
            ON DUPLICATE KEY UPDATE `modifiedAt` = CURRENT_TIMESTAMP
        ';

        // Run invididual updates so that we can see if we've made a change.
        // With ON DUPLICATE KEY UPDATE, the affected-rows value per row is
        // 1 if the row is inserted as a new row,
        // 2 if an existing row is updated and
        // 0 if the existing row is set to its current values.
        foreach ($displays as $display) {
            $shouldNotify = false;
            foreach ($mediaIds as $mediaId) {
                try {
                    $affected = $this->store->update($sql, [
                        'displayId' => $display->displayId,
                        'mediaId' => $mediaId
                    ]);

                    if ($affected == 1) {
                        $shouldNotify = true;
                    }
                } catch (\PDOException) {
                    // We link what we can, and log any failures.
                    $this->getLogger()->error('linkDisplays: unable to link displayId: ' . $display->displayId
                        . ' to mediaId: ' . $mediaId . ', most likely the media has since gone');
                }
            }

            // When should we notify?
            // ----------------------
            // Newer displays (>= v4) should clear their cache only if linked media has changed
            // Older displays (< v4) should check in immediately on change
            if ($display->clientCode >= 400) {
                if ($shouldNotify) {
                    $this->displayFactory->getDisplayNotifyService()->collectLater()
                        ->notifyByDisplayId($display->displayId);
                }

                if (!$fromCache) {
                    $this->displayFactory->getDisplayNotifyService()
                        ->notifyDataUpdate($display, $widgetId);
                }
            } else {
                $this->displayFactory->getDisplayNotifyService()->collectNow()
                    ->notifyByDisplayId($display->displayId);
            }
        }
    }

    /**
     * Remove any display/media links which have expired.
     * @param Carbon $cutOff
     * @return void
     */
    private function removeOldDisplayLinks(Carbon $cutOff): void
    {
        $sql = '
            DELETE 
                FROM `display_media`
             WHERE `modifiedAt` < :modifiedAt
                AND `display_media`.`displayId` IN (
                    SELECT `displayId` 
                      FROM `display`
                     WHERE `display`.`loggedIn` = 1
                        OR `display`.`lastAccessed` > :lastAccessed
                )
        ';

        $this->store->update($sql, [
            'modifiedAt' => Carbon::now()->subDay()->format(DateFormatHelper::getSystemFormat()),
            'lastAccessed' => $cutOff->unix(),
        ]);
    }
}
