<?php
/*
 * Copyright (C) 2025 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\Entity;

use Carbon\Carbon;
use Xibo\Event\SubPlaylistDurationEvent;
use Xibo\Event\WidgetDeleteEvent;
use Xibo\Event\WidgetEditEvent;
use Xibo\Factory\ActionFactory;
use Xibo\Factory\PermissionFactory;
use Xibo\Factory\WidgetAudioFactory;
use Xibo\Factory\WidgetMediaFactory;
use Xibo\Factory\WidgetOptionFactory;
use Xibo\Helper\DateFormatHelper;
use Xibo\Service\DisplayNotifyServiceInterface;
use Xibo\Service\LogServiceInterface;
use Xibo\Storage\StorageServiceInterface;
use Xibo\Support\Exception\NotFoundException;
use Xibo\Widget\Definition\Property;

/**
 * Class Widget
 * @package Xibo\Entity
 *
 * @SWG\Definition()
 */
class Widget implements \JsonSerializable
{
    public static $DATE_MIN = 0;
    public static $DATE_MAX = 2147483647;

    use EntityTrait;

    /**
     * @SWG\Property(description="The Widget ID")
     * @var int
     */
    public $widgetId;

    /**
     * @SWG\Property(description="The ID of the Playlist this Widget belongs to")
     * @var int
     */
    public $playlistId;

    /**
     * @SWG\Property(description="The ID of the User that owns this Widget")
     * @var int
     */
    public $ownerId;

    /**
     * @SWG\Property(description="The Module Type Code")
     * @var string
     */
    public $type;

    /**
     * @SWG\Property(description="The duration in seconds this widget should be shown")
     * @var int
     */
    public $duration;

    /**
     * @SWG\Property(description="The display order of this widget")
     * @var int
     */
    public $displayOrder;

    /**
     * @SWG\Property(description="Flag indicating if this widget has a duration that should be used")
     * @var int
     */
    public $useDuration;

    /**
     * @SWG\Property(description="Calculated Duration of this widget after taking into account the useDuration flag")
     * @var int
     */
    public $calculatedDuration = 0;

    /**
     * @var string
     * @SWG\Property(
     *  description="The datetime the Layout was created"
     * )
     */
    public $createdDt;

    /**
     * @var string
     * @SWG\Property(
     *  description="The datetime the Layout was last modified"
     * )
     */
    public $modifiedDt;

    /**
     * @SWG\Property(description="Widget From Date")
     * @var int
     */
    public $fromDt;

    /**
     * @SWG\Property(description="Widget To Date")
     * @var int
     */
    public $toDt;

    /**
     * @SWG\Property(description="Widget Schema Version")
     * @var int
     */
    public $schemaVersion;

    /**
     * @SWG\Property(description="Transition Type In")
     * @var int
     */
    public $transitionIn;

    /**
     * @SWG\Property(description="Transition Type out")
     * @var int
     */
    public $transitionOut;

    /**
     * @SWG\Property(description="Transition duration in")
     * @var int
     */
    public $transitionDurationIn;

    /**
     * @SWG\Property(description="Transition duration out")
     * @var int
     */
    public $transitionDurationOut;

    /**
     * @SWG\Property(description="An array of Widget Options")
     * @var WidgetOption[]
     */
    public $widgetOptions = [];

    /**
     * @SWG\Property(description="An array of MediaIds this widget is linked to")
     * @var int[]
     */
    public $mediaIds = [];

    /**
     * @SWG\Property(description="An array of Audio MediaIds this widget is linked to")
     * @var WidgetAudio[]
     */
    public $audio = [];

    /**
     * @SWG\Property(description="An array of permissions for this widget")
     * @var Permission[]
     */
    public $permissions = [];

    /**
     * @SWG\Property(description="The name of the Playlist this Widget is on")
     * @var string $playlist
     */
    public $playlist;

    /** @var Action[] */
    public $actions = [];

    /**
     * Hash Key of Media Assignments
     * @var string
     */
    private $mediaHash = null;

    /**
     * Temporary media Id used during import/upgrade/sub-playlist ordering
     * @var string read only string
     */
    public $tempId = null;

    /**
     * Temporary widget Id used during import/upgrade/sub-playlist ordering
     * @var string read only string
     */
    public $tempWidgetId = null;

    /**
     * Flag to indicate whether the widget is valid
     * @var bool
     */
    public $isValid = false;

    /**
     * Flag to indicate whether the widget is newly added
     * @var bool
     */
    public $isNew = false;

    public $folderId;
    public $permissionsFolderId;

    /** @var int[] Original Media IDs */
    private $originalMediaIds = [];

    /** @var array[WidgetAudio] Original Widget Audio */
    private $originalAudio = [];

    /** @var \Xibo\Entity\WidgetOption[] Original widget options when this widget was laded */
    private $originalWidgetOptions = [];

    /**
     * Minimum duration for widgets
     * @var int
     */
    public static $widgetMinDuration = 1;

    private $datesToFormat = ['toDt', 'fromDt'];

    //<editor-fold desc="Factories and Dependencies">

    /**
     * @var WidgetOptionFactory
     */
    private $widgetOptionFactory;

    /**
     * @var WidgetMediaFactory
     */
    private $widgetMediaFactory;

    /** @var  WidgetAudioFactory */
    private $widgetAudioFactory;

    /**
     * @var PermissionFactory
     */
    private $permissionFactory;

    /** @var DisplayNotifyServiceInterface */
    private $displayNotifyService;

    /** @var ActionFactory */
    private $actionFactory;
    //</editor-fold>

    /**
     * Entity constructor.
     * @param StorageServiceInterface $store
     * @param LogServiceInterface $log
     * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
     * @param WidgetOptionFactory $widgetOptionFactory
     * @param WidgetMediaFactory $widgetMediaFactory
     * @param WidgetAudioFactory $widgetAudioFactory
     * @param PermissionFactory $permissionFactory
     * @param DisplayNotifyServiceInterface $displayNotifyService
     * @param ActionFactory $actionFactory
     */
    public function __construct(
        $store,
        $log,
        $dispatcher,
        $widgetOptionFactory,
        $widgetMediaFactory,
        $widgetAudioFactory,
        $permissionFactory,
        $displayNotifyService,
        $actionFactory
    ) {
        $this->setCommonDependencies($store, $log, $dispatcher);
        $this->excludeProperty('module');
        $this->widgetOptionFactory = $widgetOptionFactory;
        $this->widgetMediaFactory = $widgetMediaFactory;
        $this->widgetAudioFactory = $widgetAudioFactory;
        $this->permissionFactory = $permissionFactory;
        $this->displayNotifyService = $displayNotifyService;
        $this->actionFactory = $actionFactory;
    }

    public function __clone()
    {
        $this->hash = null;
        $this->widgetId = null;
        $this->widgetOptions = array_map(function ($object) { return clone $object; }, $this->widgetOptions);
        $this->permissions = [];

        // No need to clone the media, but we should empty the original arrays of ids
        $this->originalMediaIds = [];
        $this->originalAudio = [];

        // Clone actions
        $this->actions = array_map(function ($object) { return clone $object; }, $this->actions);
    }

    /**
     * String
     * @return string
     */
    public function __toString()
    {
        return sprintf('Widget. %s on playlist %d in position %d. WidgetId = %d', $this->type, $this->playlistId, $this->displayOrder, $this->widgetId);
    }

    public function getPermissionFolderId()
    {
        return $this->permissionsFolderId;
    }

    /**
     * Get the Display Notify Service
     * @return DisplayNotifyServiceInterface
     */
    public function getDisplayNotifyService(): DisplayNotifyServiceInterface
    {
        return $this->displayNotifyService->init();
    }

    /**
     * Unique Hash
     * @return string
     */
    private function hash()
    {
        return md5($this->widgetId
            . $this->playlistId
            . $this->ownerId
            . $this->type
            . $this->duration
            . $this->displayOrder
            . $this->useDuration
            . $this->calculatedDuration
            . $this->fromDt
            . $this->toDt
            . json_encode($this->widgetOptions)
            . json_encode($this->actions)
        );
    }

    /**
     * Hash of all media id's
     * @return string
     */
    private function mediaHash()
    {
        sort($this->mediaIds);
        return md5(implode(',', $this->mediaIds));
    }

    /**
     * Get the Id
     * @return int
     */
    public function getId()
    {
        return $this->widgetId;
    }

    /**
     * Get the OwnerId
     * @return int
     */
    public function getOwnerId()
    {
        return $this->ownerId;
    }

    /**
     * Set the Owner
     * @param int $ownerId
     */
    public function setOwner($ownerId)
    {
        $this->ownerId = $ownerId;
    }

    /**
     * Get Option
     * @param string $option
     * @param bool $originalValue
     * @return WidgetOption
     * @throws \Xibo\Support\Exception\NotFoundException
     */
    public function getOption(string $option, bool $originalValue = false): WidgetOption
    {
        $widgetOptions = $originalValue ? $this->originalWidgetOptions : $this->widgetOptions;
        foreach ($widgetOptions as $widgetOption) {
            if (strtolower($widgetOption->option) == strtolower($option)) {
                return $widgetOption;
            }
        }

        throw new NotFoundException(__('Widget Option not found'));
    }

    /**
     * Remove an option
     * @param string $option
     * @return $this
     */
    public function removeOption(string $option): Widget
    {
        try {
            $widgetOption = $this->getOption($option);

            $this->getLog()->debug('removeOption: ' . $option);

            // Unassign
            foreach ($this->widgetOptions as $key => $value) {
                if ($value->option === $option) {
                    unset($this->widgetOptions[$key]);
                }
            }

            // Delete now
            $widgetOption->delete();
        } catch (NotFoundException $exception) {
            // This is good, notihng to do.
        }
        return $this;
    }

    /**
     * Change an option
     * @param string $option
     * @param string $newOption
     * @return $this
     */
    public function changeOption(string $option, string $newOption): Widget
    {
        try {
            $widgetOption = $this->getOption($option);

            $this->getLog()->debug('changeOption: ' . $option);

            // Unassign
            foreach ($this->widgetOptions as $key => $value) {
                if ($value->option === $option) {
                    unset($this->widgetOptions[$key]);
                }
            }

            // Change now
            $widgetOption->delete();
            $this->widgetOptions[] = $this->widgetOptionFactory->create($this->widgetId, $widgetOption->type, $newOption, $widgetOption->value);

        } catch (NotFoundException $exception) {
            // This is good, nothing to do.
        }
        return $this;
    }

    /**
     * Get Widget Option Value
     * @param string $option
     * @param mixed $default
     * @param bool $originalValue
     * @return mixed
     */
    public function getOptionValue(string $option, $default, bool $originalValue = false)
    {
        try {
            $widgetOption = $this->getOption($option, $originalValue);
            $widgetOption = (($widgetOption->value) === null) ? $default : $widgetOption->value;

            if (is_integer($default)) {
                $widgetOption = intval($widgetOption);
            }

            return $widgetOption;
        } catch (NotFoundException $e) {
            return $default;
        }
    }

    /**
     * Set Widget Option Value
     * @param string $option
     * @param string $type
     * @param mixed $value
     */
    public function setOptionValue(string $option, string $type, $value)
    {
        $this->getLog()->debug('setOptionValue: ' . $option . ', ' . $type . '. Value = ' . $value);
        try {
            $widgetOption = $this->getOption($option);
            $widgetOption->type = $type;
            $widgetOption->value = $value;
        } catch (NotFoundException $e) {
            $this->widgetOptions[] = $this->widgetOptionFactory->create($this->widgetId, $type, $option, $value);
        }
    }

    /**
     * Assign File Media
     * @param int $mediaId
     */
    public function assignMedia($mediaId)
    {
        $this->load();

        if (!in_array($mediaId, $this->mediaIds))
            $this->mediaIds[] = $mediaId;
    }

    /**
     * Unassign File Media
     * @param int $mediaId
     */
    public function unassignMedia($mediaId)
    {
        $this->load();

        $this->mediaIds = array_diff($this->mediaIds, [$mediaId]);
    }

    /**
     * Count media
     * @return int count of media
     */
    public function countMedia()
    {
        $this->load();
        return count($this->mediaIds);
    }

    /**
     * @return int
     * @throws NotFoundException
     */
    public function getPrimaryMediaId()
    {
        $primary = $this->getPrimaryMedia();

        if (count($primary) <= 0)
            throw new NotFoundException(__('No file to return'));

        return $primary[0];
    }

    /**
     * Get Primary Media
     * @return int[]
     */
    public function getPrimaryMedia()
    {
        $this->load();

        $this->getLog()->debug('Getting first primary media for Widget: ' . $this->widgetId . ' Media: ' . json_encode($this->mediaIds) . ' audio ' . json_encode($this->getAudioIds()));

        if (count($this->mediaIds) <= 0)
            return [];

        // Remove the audio media from this array
        return array_values(array_diff($this->mediaIds, $this->getAudioIds()));
    }

    /**
     * Get the temporary path
     * @return string
     */
    public function getLibraryTempPath(): string
    {
        return $this->widgetMediaFactory->getLibraryTempPath();
    }

    /**
     * Get the path of the primary media
     * @return string
     * @throws NotFoundException
     */
    public function getPrimaryMediaPath(): string
    {
        return $this->widgetMediaFactory->getPathForMediaId($this->getPrimaryMediaId());
    }

    /**
     * Assign Audio Media
     * @param WidgetAudio $audio
     */
    public function assignAudio($audio)
    {
        $this->load();

        $found = false;
        foreach ($this->audio as $existingAudio) {
            if ($existingAudio->mediaId == $audio->mediaId) {
                $existingAudio->loop = $audio->loop;
                $existingAudio->volume = $audio->volume;
                $found = true;
                break;
            }
        }

        if (!$found)
            $this->audio[] = $audio;

        // Assign the media
        $this->assignMedia($audio->mediaId);
    }

    /**
     * Unassign Audio Media
     * @param int $mediaId
     */
    public function assignAudioById($mediaId)
    {
        $this->load();

        $widgetAudio = $this->widgetAudioFactory->createEmpty();
        $widgetAudio->mediaId = $mediaId;
        $widgetAudio->volume = 100;
        $widgetAudio->loop = 0;

        $this->assignAudio($widgetAudio);
    }

    /**
     * Unassign Audio Media
     * @param WidgetAudio $audio
     */
    public function unassignAudio($audio)
    {
        $this->load();

        $this->audio = array_udiff($this->audio, [$audio], function($a, $b) {
            /**
             * @var WidgetAudio $a
             * @var WidgetAudio $b
             */
            return $a->getId() - $b->getId();
        });

        // Unassign the media
        $this->unassignMedia($audio->mediaId);
    }

    /**
     * Unassign Audio Media
     * @param int $mediaId
     */
    public function unassignAudioById($mediaId)
    {
        $this->load();

        foreach ($this->audio as $audio) {

            if ($audio->mediaId == $mediaId)
                $this->unassignAudio($audio);
        }
    }

    /**
     * Count Audio
     * @return int
     */
    public function countAudio()
    {
        $this->load();

        return count($this->audio);
    }

    /**
     * Get AudioIds
     * @return int[]
     */
    public function getAudioIds()
    {
        $this->load();

        return array_map(function($element) {
            /** @var WidgetAudio $element */
            return $element->mediaId;
        }, $this->audio);
    }

    /**
     * Have the media assignments changed.
     */
    public function hasMediaChanged()
    {
        return ($this->mediaHash != $this->mediaHash());
    }

    /**
     * @return bool true if this widget has an expiry date
     */
    public function hasExpiry()
    {
        return $this->toDt !== self::$DATE_MAX;
    }

    /**
     * @return bool true if this widget has expired
     */
    public function isExpired()
    {
        return ($this->toDt !== self::$DATE_MAX && Carbon::createFromTimestamp($this->toDt)->format('U') < Carbon::now()->format('U'));
    }

    /**
     * Calculates the duration of this widget according to some rules
     * @param \Xibo\Entity\Module $module
     * @param bool $import
     * @return $this
     */
    public function calculateDuration(
        Module $module,
        bool $import = false
    ): Widget {
        $this->getLog()->debug('calculateDuration: Calculating for ' . $this->type
            . ' - existing value is ' . $this->calculatedDuration
            . ' import is ' . ($import ? 'true' : 'false'));

        // Import
        // ------
        // If we are importing a layout we need to adjust the `duration` **before** we pass to any duration
        // provider, as providers will use the duration set on the widget in their calculations.
        // $this->duration from xml is `duration * (numItems/itemsPerPage)`
        if ($import) {
            $numItems = $this->getOptionValue('numItems', 1);
            if ($this->getOptionValue('durationIsPerItem', 0) == 1 && $numItems > 1) {
                // If we have paging involved then work out the page count.
                $itemsPerPage = $this->getOptionValue('itemsPerPage', 0);
                if ($itemsPerPage > 0) {
                    $numItems = ceil($numItems / $itemsPerPage);
                }

                // This is a change to v3
                //  in v3 we only divide by numItems if useDuration = 0, which I think was wrong.
                $this->duration = ($this->useDuration == 1 ? $this->duration : $module->defaultDuration) / $numItems;
            }
        }

        // Start with either the default module duration, or the duration provided
        if ($this->useDuration == 1) {
            // Widget duration is as specified
            $this->calculatedDuration = $this->duration;
        } else {
            // Use the default duration
            $this->calculatedDuration = $module->defaultDuration;
        }

        // Modify the duration if necessary
        if ($module->type === 'subplaylist') {
            // Sub Playlists are a special case and provide their own duration.
            $this->getLog()->debug('calculateDuration: subplaylist using SubPlaylistDurationEvent');

            $event = new SubPlaylistDurationEvent($this);
            $this->getDispatcher()->dispatch($event, SubPlaylistDurationEvent::$NAME);
            $this->calculatedDuration = $event->getDuration();
        } else {
            // Our module will calculate the duration for us.
            $duration = $module->calculateDuration($this);
            if ($duration !== null) {
                $this->calculatedDuration = $duration;
            } else {
                $this->getLog()->debug('calculateDuration: Duration not set by module');
            }
        }

        $this->getLog()->debug('calculateDuration: set to ' . $this->calculatedDuration);
        return $this;
    }

    /**
     * @return int
     * @throws NotFoundException
     */
    public function getDurationForMedia(): int
    {
        return $this->widgetMediaFactory->getDurationForMediaId($this->getPrimaryMediaId());
    }

    /**
     * Load the Widget
     * @param bool $loadActions
     * @return Widget
     */
    public function load(bool $loadActions = true): Widget
    {
        if ($this->loaded || $this->widgetId == null || $this->widgetId == 0) {
            return $this;
        }

        // Load permissions
        $this->permissions = $this->permissionFactory->getByObjectId(get_class($this), $this->widgetId);

        // Load the widget options
        $this->widgetOptions = $this->widgetOptionFactory->getByWidgetId($this->widgetId);
        foreach ($this->widgetOptions as $widgetOption) {
            $this->originalWidgetOptions[] = clone $widgetOption;
        }

        // Load any media assignments for this widget
        $this->mediaIds = $this->widgetMediaFactory->getByWidgetId($this->widgetId);
        $this->originalMediaIds = $this->mediaIds;

        // Load any widget audio assignments
        $this->audio = $this->widgetAudioFactory->getByWidgetId($this->widgetId);
        $this->originalAudio = $this->audio;

        if ($loadActions) {
            $this->actions = $this->actionFactory->getBySourceAndSourceId('widget', $this->widgetId);
        }

        $this->hash = $this->hash();
        $this->mediaHash = $this->mediaHash();
        $this->loaded = true;
        return $this;
    }

    /**
     * Load the Widget with minimal data i.e., options
     */
    public function loadMinimum(): void
    {
        if ($this->loaded || $this->widgetId == null || $this->widgetId == 0) {
            return;
        }

        // Load the widget options
        $this->widgetOptions = $this->widgetOptionFactory->getByWidgetId($this->widgetId);
        foreach ($this->widgetOptions as $widgetOption) {
            $this->originalWidgetOptions[] = clone $widgetOption;
        }

        $this->loaded = true;
    }

    /**
     * @param Property[] $properties
     * @return \Xibo\Entity\Widget
     */
    public function applyProperties(array $properties): Widget
    {
        foreach ($properties as $property) {
            // Do not save null properties.
            if ($property->value === null) {
                $this->removeOption($property->id);
            } else {
                // Apply filters
                $property->applyFilters();

                // Set the property for saving into the widget options
                $this->setOptionValue($property->id, $property->isCData() ? 'cdata' : 'attrib', $property->value);

                // If this property allows library references to be added, we parse them out here and assign
                // the matching media to the widget.
                if ($property->allowLibraryRefs) {
                    // Parse them out and replace for our special syntax.
                    $matches = [];
                    preg_match_all('/\[(.*?)\]/', $property->value, $matches);
                    foreach ($matches[1] as $match) {
                        if (is_numeric($match)) {
                            $this->assignMedia(intval($match));
                        }
                    }
                }

                // Is this a media selector? and if so should we assign the library media
                if ($property->type === 'mediaSelector') {
                    if (!empty($value) && is_numeric($value)) {
                        $this->assignMedia(intval($value));
                    }
                }
            }
        }

        return $this;
    }

    /**
     * Save the widget
     * @param array $options
     * @throws \Xibo\Support\Exception\NotFoundException
     */
    public function save($options = [])
    {
        // Default options
        $options = array_merge([
            'saveWidgetOptions' => true,
            'saveWidgetAudio' => true,
            'saveWidgetMedia' => true,
            'notify' => true,
            'notifyPlaylists' => true,
            'notifyDisplays' => false,
            'audit' => true,
            'auditWidgetOptions' => true,
            'auditMessage' => 'Saved',
            'alwaysUpdate' => false,
            'import' => false,
            'upgrade' => false,
        ], $options);

        $this->getLog()->debug('Saving widgetId ' . $this->getId() . ' with options. '
            . json_encode($options, JSON_PRETTY_PRINT));

        // if we are auditing get layout specific campaignId
        $campaignId = 0;
        $layoutId = 0;
        if ($options['audit']) {
            $results = $this->store->select('
                SELECT `campaign`.campaignId,
                    `layout`.layoutId
                  FROM `playlist`
                    INNER JOIN `region`
                    ON `playlist`.regionId = `region`.regionId
                    INNER JOIN `layout`
                    ON `region`.layoutId = `layout`.layoutId
                    INNER JOIN `lkcampaignlayout`
                    ON `layout`.layoutId = `lkcampaignlayout`.layoutId
                    INNER JOIN `campaign`
                    ON `campaign`.campaignId = `lkcampaignlayout`.campaignId
                 WHERE `campaign`.isLayoutSpecific = 1
                    AND `playlist`.playlistId = :playlistId
            ', [
                'playlistId' => $this->playlistId
            ]);

            foreach ($results as $row) {
                $campaignId = intval($row['campaignId']);
                $layoutId = intval($row['layoutId']);
            }
        }

        // Add/Edit
        $isNew = $this->widgetId == null || $this->widgetId == 0;
        if ($isNew) {
            $this->add();
        } else {
            // When saving after Widget compatibility upgrade
            // do not trigger this event, as it will throw an error
            // this is due to mismatch between playlist closure table (already populated)
            // and subPlaylists option original values (empty array) - attempt to add the same child will error out.
            if (!$options['upgrade']) {
                $this->getDispatcher()->dispatch(new WidgetEditEvent($this), WidgetEditEvent::$NAME);
            }

            if ($this->hash != $this->hash() || $options['alwaysUpdate']) {
                $this->update();
            }
        }

        // Save the widget options
        if ($options['saveWidgetOptions']) {
            foreach ($this->widgetOptions as $widgetOption) {
                // Assert the widgetId
                $widgetOption->widgetId = $this->widgetId;
                $widgetOption->save();
            }
        }

        // Save the widget audio
        if ($options['saveWidgetAudio']) {
            foreach ($this->audio as $audio) {
                // Assert the widgetId
                $audio->widgetId = $this->widgetId;
                $audio->save();
            }

            $removedAudio = array_udiff($this->originalAudio, $this->audio, function ($a, $b) {
                /**
                 * @var WidgetAudio $a
                 * @var WidgetAudio $b
                 */
                return $a->getId() - $b->getId();
            });

            foreach ($removedAudio as $audio) {
                /* @var \Xibo\Entity\WidgetAudio $audio */

                // Assert the widgetId
                $audio->widgetId = $this->widgetId;
                $audio->delete();
            }
        }

        // Manage the assigned media
        if ($options['saveWidgetMedia'] || $options['saveWidgetAudio']) {
            $this->linkMedia();
            $this->unlinkMedia();
        }

        // Call notify with the notify options passed in
        $this->notify($options);

        if ($options['audit']) {
            if ($isNew) {
                if ($campaignId != 0 && $layoutId != 0) {
                    $this->audit($this->widgetId, 'Added', [
                        'widgetId' => $this->widgetId,
                        'type' => $this->type,
                        'layoutId' => $layoutId,
                        'campaignId' => $campaignId
                    ]);
                }
            } else {
                // For elements, do not try to look up changed properties.
                $changedProperties = $options['auditWidgetOptions'] ? $this->getChangedProperties() : [];
                $changedItems = [];

                if ($options['auditWidgetOptions']) {
                    foreach ($this->widgetOptions as $widgetOption) {
                        $itemsProperties = $widgetOption->getChangedProperties();

                        // for widget options what we get from getChangedProperities is an array with value as key and
                        // changed value as value we want to override the key in the returned array, so that we get a
                        // clear option name that was changed
                        if (array_key_exists('value', $itemsProperties)) {
                            $itemsProperties[$widgetOption->option] = $itemsProperties['value'];
                            unset($itemsProperties['value']);
                        }

                        if (count($itemsProperties) > 0) {
                            $changedItems[] = $itemsProperties;
                        }
                    }
                }

                if (count($changedItems) > 0) {
                    $changedProperties['widgetOptions'] = json_encode($changedItems, JSON_PRETTY_PRINT);
                }

                // if we are editing a widget assigned to a regionPlaylist add the layout specific campaignId to
                // the audit log
                if ($campaignId != 0 && $layoutId != 0) {
                    $changedProperties['campaignId'][] = $campaignId;
                    $changedProperties['layoutId'][] = $layoutId;
                }

                $this->audit($this->widgetId, $options['auditMessage'], $changedProperties);
            }
        }
    }

    /**
     * @param array $options
     */
    public function delete($options = [])
    {
        $options = array_merge([
            'notify' => true,
            'notifyPlaylists' => true,
            'forceNotifyPlaylists' => true,
            'notifyDisplays' => false
        ], $options);

        // We must ensure everything is loaded before we delete
        $this->load();

        // Widget Delete Event
        $this->getDispatcher()->dispatch(new WidgetDeleteEvent($this), WidgetDeleteEvent::$NAME);

        // Delete Permissions
        foreach ($this->permissions as $permission) {
            $permission->deleteAll();
        }

        // Delete all Options
        foreach ($this->widgetOptions as $widgetOption) {
            // Assert the widgetId
            $widgetOption->widgetId = $this->widgetId;
            $widgetOption->delete();
        }

        // Delete the widget audio
        foreach ($this->audio as $audio) {
            // Assert the widgetId
            $audio->widgetId = $this->widgetId;
            $audio->delete();
        }

        foreach ($this->actions as $action) {
            $action->delete();
        }

        // Set widgetId to null on any navWidget action that was using this drawer Widget.
        $this->getStore()->update(
            'UPDATE `action` SET `action`.widgetId = NULL
                WHERE widgetId = :widgetId AND `action`.actionType = \'navWidget\' ',
            ['widgetId' => $this->widgetId]
        );
        
        $this->mediaIds = [];

        // initialize media Ids to unlink
        $mediaIdsToUnlink = $this->getMediaIdsToUnlink();

        // Unlink Media
        $this->unlinkMedia();

        // Delete any fallback data
        $this->getStore()->update('DELETE FROM `widgetdata` WHERE `widgetId` = :widgetId', [
            'widgetId' => $this->widgetId,
        ]);

        // Delete this
        $this->getStore()->update('DELETE FROM `widget` WHERE `widgetId` = :widgetId', [
            'widgetId' => $this->widgetId,
        ]);

        // Call notify with the notify options passed in
        $this->notify($options);

        $this->getLog()->debug('Delete Widget Complete');

        // Audit
        $this->audit($this->widgetId,
            'Deleted',
            array_merge(
                ['widgetId' => $this->widgetId, 'playlistId' => $this->playlistId],
                $mediaIdsToUnlink
            )
        );
    }

    /**
     * Notify
     * @param $options
     */
    private function notify($options)
    {
        // By default we do nothing in here, options have to be explicitly enabled.
        $options = array_merge([
            'notify' => false,
            'notifyPlaylists' => false,
            'forceNotifyPlaylists' => false,
            'notifyDisplays' => false
        ], $options);

        $this->getLog()->debug('Notifying upstream playlist. Notify Layout: ' . $options['notify'] . ' Notify Displays: ' . $options['notifyDisplays']);

        // Should we notify the Playlist
        // we do this if the duration has changed on this widget.
        if ($options['forceNotifyPlaylists']|| ($options['notifyPlaylists'] && (
                $this->hasPropertyChanged('calculatedDuration')
                || $this->hasPropertyChanged('fromDt')
                || $this->hasPropertyChanged('toDt')
            ))) {
            // Notify the Playlist
            $this->getStore()->update('UPDATE `playlist` SET requiresDurationUpdate = 1, `modifiedDT` = :modifiedDt WHERE playlistId = :playlistId', [
                'playlistId' => $this->playlistId,
                'modifiedDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
            ]);
        }

        // Notify Layout
        // We do this for draft and published versions of the Layout to keep the Layout Status fresh and the modified
        // date updated.
        if ($options['notify']) {
            // Notify the Layout
            $this->getStore()->update('
                UPDATE `layout` SET `status` = 3, `modifiedDT` = :modifiedDt WHERE layoutId IN (
                  SELECT `region`.layoutId
                    FROM `lkplaylistplaylist`
                      INNER JOIN `playlist`
                      ON `playlist`.playlistId = `lkplaylistplaylist`.parentId
                      INNER JOIN `region`
                      ON `region`.regionId = `playlist`.regionId 
                   WHERE `lkplaylistplaylist`.childId = :playlistId
                )
            ', [
                'playlistId' => $this->playlistId,
                'modifiedDt' => Carbon::now()->format(DateFormatHelper::getSystemFormat())
            ]);
        }

        // Notify any displays (clearing their cache)
        // this is typically done when there has been a dynamic change to the Widget - i.e. the Layout doesn't need
        // to be rebuilt, but the Widget has some change that will be pushed out through getResource
        if ($options['notifyDisplays']) {
            $this->getDisplayNotifyService()->collectNow()->notifyByPlaylistId($this->playlistId);
        }
    }

    private function add()
    {
        $this->getLog()->debug('Adding Widget ' . $this->type . ' to PlaylistId ' . $this->playlistId);

        $this->isNew = true;

        $sql = '
            INSERT INTO `widget` (`playlistId`, `ownerId`, `type`, `duration`, `displayOrder`, `useDuration`, `calculatedDuration`, `fromDt`, `toDt`, `createdDt`, `modifiedDt`, `schemaVersion`)
            VALUES (:playlistId, :ownerId, :type, :duration, :displayOrder, :useDuration, :calculatedDuration, :fromDt, :toDt, :createdDt, :modifiedDt, :schemaVersion)
        ';

        $this->widgetId = $this->getStore()->insert($sql, array(
            'playlistId' => $this->playlistId,
            'ownerId' => $this->ownerId,
            'type' => $this->type,
            'duration' => $this->duration,
            'displayOrder' => $this->displayOrder,
            'useDuration' => $this->useDuration,
            'calculatedDuration' => $this->calculatedDuration,
            'fromDt' => ($this->fromDt == null) ? self::$DATE_MIN : $this->fromDt,
            'toDt' => ($this->toDt == null) ? self::$DATE_MAX : $this->toDt,
            'createdDt' => ($this->createdDt === null) ? Carbon::now()->format('U') : $this->createdDt,
            'modifiedDt' => Carbon::now()->format('U'),
            'schemaVersion' => $this->schemaVersion
        ));
    }

    private function update()
    {
        $this->getLog()->debug('Saving Widget ' . $this->type . ' on PlaylistId ' . $this->playlistId . ' WidgetId: ' . $this->widgetId);

        $sql = '
          UPDATE `widget` SET `playlistId` = :playlistId,
            `ownerId` = :ownerId,
            `type` = :type,
            `duration` = :duration,
            `displayOrder` = :displayOrder,
            `useDuration` = :useDuration,
            `calculatedDuration` = :calculatedDuration,
            `fromDt` = :fromDt,
            `toDt` = :toDt, 
            `modifiedDt` = :modifiedDt,
            `schemaVersion` = :schemaVersion
           WHERE `widgetId` = :widgetId
        ';

        $params = [
            'playlistId' => $this->playlistId,
            'ownerId' => $this->ownerId,
            'type' => $this->type,
            'duration' => $this->duration,
            'widgetId' => $this->widgetId,
            'displayOrder' => $this->displayOrder,
            'useDuration' => $this->useDuration,
            'calculatedDuration' => $this->calculatedDuration,
            'fromDt' => ($this->fromDt == null) ? self::$DATE_MIN : $this->fromDt,
            'toDt' => ($this->toDt == null) ? self::$DATE_MAX : $this->toDt,
            'modifiedDt' => Carbon::now()->format('U'),
            'schemaVersion' => $this->schemaVersion
        ];

        $this->getStore()->update($sql, $params);
    }

    /**
     * Link Media
     */
    private function linkMedia()
    {
        // Calculate the difference between the current assignments and the original.
        $mediaToLink = array_diff($this->mediaIds, $this->originalMediaIds);

        $this->getLog()->debug('Linking %d new media to Widget %d', count($mediaToLink), $this->widgetId);

        // TODO: Make this more efficient by storing the prepared SQL statement
        $sql = 'INSERT INTO `lkwidgetmedia` (widgetId, mediaId) VALUES (:widgetId, :mediaId) ON DUPLICATE KEY UPDATE mediaId = :mediaId2';

        foreach ($mediaToLink as $mediaId) {
            $this->getStore()->insert($sql, array(
                'widgetId' => $this->widgetId,
                'mediaId' => $mediaId,
                'mediaId2' => $mediaId
            ));

            // audit the media being added
            $this->getLog()->audit('Media', $mediaId, 'Media added to widget', ['mediaId' => $mediaId, 'widgetId' => $this->widgetId]);
        }
    }

    /**
     * Unlink Media
     */
    private function unlinkMedia()
    {
        // Calculate the difference between the current assignments and the original.
        $mediaToUnlink = array_diff($this->originalMediaIds, $this->mediaIds);

        $this->getLog()->debug('Unlinking %d old media from Widget %d', count($mediaToUnlink), $this->widgetId);

        if (count($mediaToUnlink) <= 0) {
            return;
        }

        // Unlink any media in the collection
        $params = ['widgetId' => $this->widgetId];

        $sql = 'DELETE FROM `lkwidgetmedia` WHERE widgetId = :widgetId AND mediaId IN (0';

        $i = 0;
        foreach ($mediaToUnlink as $mediaId) {
            $i++;
            $sql .= ',:mediaId' . $i;
            $params['mediaId' . $i] = $mediaId;

            // audit the media being deleted
            $this->getLog()->audit('Media', $mediaId, 'Media removed from widget', ['mediaId' => $mediaId, 'widgetId' => $this->widgetId]);
        }

        $sql .= ')';

        $this->getStore()->update($sql, $params);
    }

    /**
     * Returns an array of MediaIds to unlink
     *
     * @return array
     */
    private function getMediaIdsToUnlink(): array
    {
        // Calculate the difference between the current assignments and the original.
        $mediaToUnlink = array_diff($this->originalMediaIds, $this->mediaIds);

        if (count($mediaToUnlink) <= 0) {
            return [];
        }

        // If there is only one mediaId, add it without a suffix
        if (count($mediaToUnlink) === 1) {
            $mediaId = reset($mediaToUnlink);
            return ['mediaId' => $mediaId];
        }

        // More than one mediaId, append a suffix to the key
        $mediaIdsToUnlink = [];
        $i = 1;

        foreach ($mediaToUnlink as $mediaId) {
            $mediaIdsToUnlink['mediaId_' . $i] = $mediaId;
            $i++;
        }

        return $mediaIdsToUnlink;
    }
}
