<?php

namespace craft\feedme\services;

use Cake\Utility\Hash;
use Craft;
use craft\base\Component;
use craft\base\ElementInterface as CraftElementInterface;
use craft\elements\User;
use craft\errors\ShellCommandException;
use craft\feedme\base\ElementInterface;
use craft\feedme\events\CompareContentEvent;
use craft\feedme\events\FeedProcessEvent;
use craft\feedme\helpers\DataHelper;
use craft\feedme\helpers\DuplicateHelper;
use craft\feedme\models\FeedModel;
use craft\feedme\Plugin;
use craft\helpers\App;
use craft\helpers\FileHelper;
use craft\helpers\Json;
use craft\helpers\StringHelper;
use yii\base\Exception;

class Process extends Component
{
    // Constants
    // =========================================================================

    public const EVENT_BEFORE_PROCESS_FEED = 'onBeforeProcessFeed';
    public const EVENT_STEP_BEFORE_ELEMENT_MATCH = 'onStepBeforeElementMatch';
    public const EVENT_STEP_BEFORE_PARSE_CONTENT = 'onStepBeforeParseContent';
    public const EVENT_STEP_BEFORE_ELEMENT_SAVE = 'onStepBeforeElementSave';
    public const EVENT_STEP_AFTER_ELEMENT_SAVE = 'onStepElementSave';
    public const EVENT_AFTER_PROCESS_FEED = 'onAfterProcessFeed';

    /**
     * @event CompareContentEvent The event that is triggered before content is compared to check for changes.
     * @since 5.12.0
     */
    public const EVENT_COMPARE_CONTENT = 'onCompareContent';


    // Properties
    // =========================================================================

    /**
     * @var
     */
    public mixed $time_start = null;

    /**
     * @var ElementInterface
     */
    private ElementInterface $_service;


    // Public Methods
    // =========================================================================

    /**
     * @param FeedModel $feed
     * @param array $feedData
     * @return array|void
     * @throws \Exception
     */
    public function beforeProcessFeed(FeedModel $feed, array $feedData)
    {
        Plugin::$feedName = $feed->name;

        Plugin::info('Preparing for feed processing.');

        if (!$feedData) {
            throw new \Exception(Craft::t('feed-me', 'No data to import.'));
        }

        // Check for backup, best to do this before we do anything
        if ($feed->backup) {
            $this->_backupBeforeFeed($feed);
        }

        $runGcBeforeFeed = Plugin::$plugin->service->getConfig('runGcBeforeFeed', $feed['id']);

        if ($runGcBeforeFeed) {
            $gc = Craft::$app->getGc();
            $gc->deleteAllTrashed = true;
            $gc->run(true);

            // close DB connection so that \PDO::ATTR_EMULATE_PREPARES is set back to false;
            // https://github.com/yiisoft/yii2/blob/2.0.52/framework/db/pgsql/QueryBuilder.php#L225
            // this caused an SQL error when using pgsql and attempting to match existing elements
            // based on e.g. title that consists of only a number;
            // "SQLSTATE[42883]: Undefined function: 7 ERROR: operator does not exist: character varying = bigint LINE 8"
            // "HINT: No operator matches the given name and argument types. You might need to add explicit type casts."
            Craft::$app->getDb()->close();
        } else {
            Plugin::getInstance()->getLogs()->prune();
        }

        // Set our start time to track feed processing time
        $this->time_start = microtime(true);
    }

    /**
     * @param FeedModel $feed
     * @param array $feedData
     * @return array
     * @throws \yii\base\InvalidConfigException
     */
    public function getFeedSettings(FeedModel $feed, array $feedData)
    {
        $data = $feedData;
        $this->_service = $feed->element;

        $return = $feed->attributes;

        App::maxPowerCaptain();

        // Add some additional information to our FeedModel - for ease of use in processing
        // $return['fields'] = [];
        $return['existingElements'] = [];

        // Clear out Feed Mapping and Field Uniques - we need to do some filtering
        $return['fieldMapping'] = $this->_filterUnmappedFields($feed['fieldMapping']);
        $return['fieldUnique'] = [];

        if (!$return['fieldMapping']) {
            throw new \Exception(Craft::t('feed-me', 'Field mapping not setup.'));
        }

        // Ditch all fields we aren't checking for uniques on. Just simplifies each run (we don't have to check)
        if (!empty($feed['fieldUnique'])) {
            foreach ($feed['fieldUnique'] as $key => $value) {
                if ((int)$value === 1) {
                    $return['fieldUnique'][$key] = $value;
                }
            }
        }

        if (empty($feed['singleton']) && empty($return['fieldUnique'])) {
            throw new \Exception(Craft::t('feed-me', 'No unique fields checked.'));
        }

        // Get the service for the Element Type we're dealing with
        if (!$feed->element) {
            throw new \Exception(Craft::t('feed-me', 'Unknown Element Type Service called.'));
        }

        // If our duplication handling is to delete - we delete all elements
        // If our duplication handling is to disable - we disable all elements
        if (
            DuplicateHelper::isDelete($feed) ||
            DuplicateHelper::isDisable($feed) ||
            DuplicateHelper::isDisableForSite($feed)) {
            $query = $feed->element->getQuery($feed);
            $return['existingElements'] = $query->ids();
        }

        // Return the feed data
        $return['feedData'] = $data;

        Plugin::$stepKey = null;
        Plugin::info('Finished preparing for feed processing.');

        return $return;
    }

    /**
     * @param $step
     * @param $feed
     * @param $processedElementIds
     * @param $feedData
     * @param $batchIndex
     * @return mixed|void
     * @throws \Exception
     */
    public function processFeed($step, $feed, &$processedElementIds, $feedData, $batchIndex = 0)
    {
        $attributeData = [];
        $fieldData = [];
        $nativeFieldData = [];
        $batchIndex++;

        // We can opt-out of updating certain elements if a field is switched on
        $skipUpdateFieldHandle = Plugin::$plugin->service->getConfig('skipUpdateFieldHandle', $feed['id']);

        //
        // Let's get started!
        //

        $logKey = StringHelper::randomString(20);

        // Save this to session, so we don't have to pass it around everywhere.
        Plugin::$stepKey = $logKey;

        // Try to fix an elusive bug...
        if (!is_numeric($step)) {
            Plugin::error('Error `{i}`.', ['i' => Json::encode($step)]);
        }

        if (!is_array($feedData) || empty($feedData)) {
            Plugin::info('There is no data in the feed to process.');
            return;
        }

        Plugin::info('Starting processing of node `#{i}` in batch `{j}`.', ['i' => ($step + 1), 'j' => $batchIndex]);

        // Set up a model for this Element Type
        $element = $this->_service->setModel($feed);

        // We need to first find a potentially existing element, and to do that, we need to prep just the fields
        // that are selected as the unique identifier. We prep everything else later on.
        $matchExistingElementData = [];

        if (empty($feed['singleton'])) {
            foreach ($feed['fieldUnique'] as $fieldHandle => $value) {
                $mappingInfo = Hash::get($feed['fieldMapping'], $fieldHandle);

                if (!$mappingInfo) {
                    continue;
                }

                if (Hash::get($mappingInfo, 'attribute')) {
                    $attributeValue = $this->_service->parseAttribute($feedData, $fieldHandle, $mappingInfo);

                    if ($attributeValue !== null) {
                        $matchExistingElementData[$fieldHandle] = $attributeValue;
                    }
                }

                if (Hash::get($mappingInfo, 'field')) {
                    $fieldValue = Plugin::$plugin->fields->parseField($feed, $element, $feedData, $fieldHandle, $mappingInfo);

                    if ($fieldValue !== null) {
                        $matchExistingElementData[$fieldHandle] = $fieldValue;
                    }
                }
            }

            Plugin::info('Match existing element with data `{i}`.', ['i' => Json::encode($matchExistingElementData)]);
        }


        //
        // Check for Add/Update/Delete for existing elements
        //

        // Fire an 'onStepBeforeElementMatch' event
        if ($this->hasEventHandlers(self::EVENT_STEP_BEFORE_ELEMENT_MATCH)) {
            $event = new FeedProcessEvent([
                'feed' => $feed,
                'feedData' => $feedData,
                'contentData' => $matchExistingElementData,
            ]);

            $this->trigger(self::EVENT_STEP_BEFORE_ELEMENT_MATCH, $event);

            if (!$event->isValid) {
                return;
            }

            // Allow event to modify variables
            $feed = $event->feed;
            $feedData = $event->feedData;
            $matchExistingElementData = $event->contentData;
        }

        // Check to see if an element already exists
        $existingElement = $this->_service->matchExistingElement($matchExistingElementData, $feed);

        // If there's an existing matching element
        if ($existingElement) {
            Plugin::info('Existing element [`#{id}`]({url}) found.', ['id' => $existingElement->id, 'url' => $existingElement->cpEditUrl]);

            // If we're deleting or updating an existing element, we want to focus on that one
            if (DuplicateHelper::isUpdate($feed)) {
                if (method_exists($this->_service, 'checkPropagation')) {
                    $existingElement = $this->_service->checkPropagation($existingElement, $feed);
                }

                $element = clone $existingElement;

                // Update our service with the correct element
                $this->_service->element = $element;
            }

            // There's also a config settings for a field to opt-out of updating. Check against that
            if ($skipUpdateFieldHandle) {
                $updateField = $element->$skipUpdateFieldHandle ?? '';

                // We've got our special field on this element, and it's switched on
                if ($updateField === '1') {
                    Plugin::info('Skipped due to config setting.');

                    return;
                }
            }

            // If we're adding only, and there's an existing element - quit now
            if (DuplicateHelper::isAdd($feed, true)) {
                Plugin::info('Skipped due to an existing element found, and elements are set to add only.');

                return;
            }
        } else {
            // Have we set to update-only? There are no existing elements, so skip
            if (DuplicateHelper::isUpdate($feed, true)) {
                Plugin::info('Skipped due to an existing element not found, and elements are set to update only.');

                return;
            }

            // If this variable is explicitly false, this means there's no data in the feed for mapping
            // existing elements - that's a problem no matter which option is selected, so don't proceed.
            // Even if Add is selected, we'll end up with duplicates because it can't find existing elements to skip over
            if ($existingElement === false) {
                Plugin::info('No existing element mapping data found. Have you ensured you\'ve supplied all correct data in your feed?');

                return;
            }
        }

        // Are we only disabling/deleting only, we need to quit right here
        // https://github.com/craftcms/feed-me/issues/696
        if (
            DuplicateHelper::isDisable($feed, true) ||
            DuplicateHelper::isDisableForSite($feed, true) ||
            DuplicateHelper::isDelete($feed, true) ||
            (!DuplicateHelper::isUpdate($feed) && $existingElement)
        ) {
            // If there's an existing element, we want to keep it, otherwise remove it
            if ($existingElement) {
                $processedElementIds[] = $existingElement->id;
            }

            return;
        }


        //
        // Now, parse all element attributes and custom fields
        //

        // Fire an 'onStepBeforeParseContent' event
        if ($this->hasEventHandlers(self::EVENT_STEP_BEFORE_PARSE_CONTENT)) {
            $event = new FeedProcessEvent([
                'feed' => $feed,
                'feedData' => $feedData,
                'element' => $element,
            ]);

            $this->trigger(self::EVENT_STEP_BEFORE_PARSE_CONTENT, $event);

            if (!$event->isValid) {
                return;
            }

            // Allow event to modify variables
            $feed = $event->feed;
            $feedData = $event->feedData;
            $element = $event->element;
        }

        // Parse just the element attributes first. We use these in our field contexts, and need a fully-prepped element
        foreach ($feed['fieldMapping'] as $fieldHandle => $fieldInfo) {
            if (Hash::get($fieldInfo, 'attribute')) {
                $attributeValue = $this->_service->parseAttribute($feedData, $fieldHandle, $fieldInfo);

                if ($attributeValue !== null) {
                    $attributeData[$fieldHandle] = $attributeValue;
                }
            }
        }

        $contentData = [];
        if (isset($attributeData['enabled'])) {
            // Set the site-specific status as well, but retain all other site statuses
            $enabledForSite = [];
            foreach (Craft::$app->getSites()->getAllSiteIds(true) as $siteId) {
                $status = $element->getEnabledForSite($siteId);
                if ($status !== null) {
                    $enabledForSite[$siteId] = $status;
                }
            }

            $enabledForSite[$element->siteId] = $attributeData['enabled'];

            // Set the global status to true if it's enabled for *any* sites, or if already enabled.
            $element->enabled = in_array(true, $enabledForSite) || $element->enabled;
            $element->setEnabledForSite($enabledForSite);
            $contentData['enabled'] = $element->enabled;
            $contentData['enabledForSite'] = $element->getEnabledForSite($element->siteId);

            unset($attributeData['enabled']);
        }

        // Set the attributes for the element
        $element->setAttributes($attributeData, false);

        foreach ($feed['fieldMapping'] as $fieldHandle => $fieldInfo) {
            if (Hash::get($fieldInfo, 'nativeField')) {
                $fieldValue = Plugin::$plugin->fields->parseNativeField($feed, $element, $feedData, $fieldHandle, $fieldInfo);

                if ($fieldValue !== null) {
                    if ($feed['setEmptyValues'] ||
                        (!empty($fieldValue) || is_numeric($fieldValue) || is_bool($fieldValue))
                    ) {
                        // we set this for $fieldValue['hasElements'] too so that the logs are accurate when it comes to comparing values
                        $nativeFieldData[$fieldHandle] = $fieldValue;
                    }
                }
            }
        }

        $element->setAttributes($nativeFieldData, false);

        // Then, do the same for custom fields. Again, this should be done after populating the element attributes
        foreach ($feed['fieldMapping'] as $fieldHandle => $fieldInfo) {
            if (Hash::get($fieldInfo, 'field')) {
                $fieldValue = Plugin::$plugin->fields->parseField($feed, $element, $feedData, $fieldHandle, $fieldInfo);

                if ($fieldValue !== null) {
                    if ($feed['setEmptyValues'] ||
                        (!empty($fieldValue) || is_numeric($fieldValue) || is_bool($fieldValue))
                    ) {
                        $fieldData[$fieldHandle] = $fieldValue;
                    }
                }
            }
        }

        // Do the same with our custom field data
        $element->setFieldValues($fieldData);

        // Now we've fully prepped our element, one last final check each attribute and field for Twig shorthand to parse
        // We have to do this at the end, separately so we've got full access to the prepped element content
        $parseTwig = Plugin::$plugin->service->getConfig('parseTwig', $feed['id']);

        if ($parseTwig) {
            foreach ($attributeData as $key => $value) {
                $attributeData[$key] = DataHelper::parseFieldDataForElement($value, $element);
            }

            foreach ($nativeFieldData as $key => $value) {
                $nativeFieldData[$key] = DataHelper::parseFieldDataForElement($value, $element);
            }

            foreach ($fieldData as $key => $value) {
                $fieldData[$key] = DataHelper::parseFieldDataForElement($value, $element);
            }

            // Set the attributes and fields again
            $element->setAttributes($attributeData, false);
            $element->setAttributes($nativeFieldData, false);
            $element->setFieldValues($fieldData);
        }

        // We need to keep these separate to apply to the element but required when matching against existing elements
        $contentData += $attributeData + $nativeFieldData + $fieldData;

        //
        // It's time to actually save the element!
        //

        // Fire an 'onStepBeforeElementSave' event
        if ($this->hasEventHandlers(self::EVENT_STEP_BEFORE_ELEMENT_SAVE)) {
            $event = new FeedProcessEvent([
                'feed' => $feed,
                'feedData' => $feedData,
                'contentData' => $contentData,
                'element' => $element,
            ]);

            $this->trigger(self::EVENT_STEP_BEFORE_ELEMENT_SAVE, $event);

            if (!$event->isValid) {
                return;
            }

            // Allow event to modify variables
            $feed = $event->feed;
            $feedData = $event->feedData;
            $contentData = $event->contentData;
            $element = $event->element;
        }

        // If we want to check the existing element's content against this new one, let's do it.
        if (Plugin::$plugin->service->getConfig('compareContent', $feed['id'])) {
            $unchangedContent = DataHelper::compareElementContent($contentData, $existingElement);

            if ($unchangedContent) {
                $info = Craft::t(
                    'feed-me',
                    'Node `#{i}` in batch `{j}` skipped. No content has changed.',
                    ['i' => ($step + 1), 'j' => $batchIndex]);

                Plugin::info($info);
                Plugin::debug($info);
                Plugin::debug($contentData);

                $processedElementIds[] = $element->id;

                return;
            }
        }

        Plugin::info('Data ready to import `{i}`.', ['i' => Json::encode($contentData)]);
        Plugin::debug($contentData);

        // Save the element
        if ($this->_service->save($element, $feed)) {

            // save user's preferences only after user has been successfully saved
            if ($element instanceof User && isset($attributeData['preferredLocale'])) {
                if (!empty($attributeData['preferredLocale']) || $feed['setEmptyValues'] === 1) {
                    $preferences = ['locale' => $attributeData['preferredLocale']];
                    Craft::$app->getUsers()->saveUserPreferences($element, $preferences);
                }
            }

            // Give elements a chance to perform actions after save
            $this->_service->afterSave($contentData, $feed);

            // Fire an 'onStepElementSave' event
            $event = new FeedProcessEvent([
                'feed' => $feed,
                'feedData' => $feedData,
                'contentData' => $contentData,
                'element' => $element,
            ]);

            $this->trigger(self::EVENT_STEP_AFTER_ELEMENT_SAVE, $event);

            if ($existingElement) {
                Plugin::info('{name} [`#{id}`]({url}) updated successfully.', ['name' => $this->_service::displayName(), 'id' => $element->id, 'url' => $element->cpEditUrl]);
            } else {
                Plugin::info('{name} [`#{id}`]({url}) added successfully.', ['name' => $this->_service::displayName(), 'id' => $element->id, 'url' => $element->cpEditUrl]);
            }

            // Store our successfully processed element for feedback in logs, but also in case we're deleting
            $processedElementIds[] = $element->id;

            Plugin::info('Finished processing of node `#{i}` in batch `{j}`.', ['i' => ($step + 1), 'j' => $batchIndex]);

            // Sleep if required
            $sleepTime = Plugin::$plugin->service->getConfig('sleepTime', $feed['id']);

            if ($sleepTime) {
                sleep($sleepTime);
            }

            return $element;
        }

        if ($element->getErrors()) {
            throw new \Exception('Node #' . ($step + 1) . ' - ' . Json::encode($element->getErrors()));
        }

        throw new \Exception(Craft::t('feed-me', 'Unknown Element saving error occurred.'));
    }

    /**
     * @param $settings
     * @param $feed
     * @param $processedElementIds
     * @param $startTime
     */
    public function afterProcessFeed($settings, $feed, $processedElementIds, $startTime = null): void
    {
        if ((int)DuplicateHelper::isDelete($feed) + (int)DuplicateHelper::isDisable($feed) + (int)DuplicateHelper::isDisableForSite($feed) > 1) {
            Plugin::info("You can't have Delete and Disabled enabled at the same time as an Import Strategy.");
            return;
        }

        if (DuplicateHelper::isDisableForSite($feed) && !$feed->siteId) {
            Plugin::info('You can’t choose “Disable missing elements in the target site” for feeds without a target site.');
            return;
        }

        if ($processedElementIds) {
            $elementsToDeleteDisable = array_diff($settings['existingElements'], $processedElementIds);

            if ($elementsToDeleteDisable) {
                if (DuplicateHelper::isDisable($feed)) {
                    $this->_service->disable($elementsToDeleteDisable);
                    $message = 'The following elements have been disabled: ' . Json::encode($elementsToDeleteDisable) . '.';
                } elseif (DuplicateHelper::isDisableForSite($feed)) {
                    $this->_service->disableForSite($elementsToDeleteDisable);
                    $message = 'The following elements have been disabled for the target site: ' . Json::encode($elementsToDeleteDisable) . '.';
                } else {
                    $this->_service->delete($elementsToDeleteDisable);
                    $message = 'The following elements have been deleted: ' . Json::encode($elementsToDeleteDisable) . '.';
                }

                Plugin::info($message);
                Plugin::debug($message);
            }
        }

        // Log the total time taken to process the feed
        $time_end = microtime(true);
        $time_start = $startTime ?? $this->time_start;
        $execution_time = number_format(($time_end - $time_start), 2);

        Plugin::$stepKey = null;

        $message = 'Processing ' . ($processedElementIds ? count($processedElementIds) : 0) . ' elements finished in ' . $execution_time . 's';
        Plugin::info($message);
        Plugin::debug($message);

        // Fire an 'onProcessFeed' event
        $event = new FeedProcessEvent([
            'feed' => $feed,
        ]);

        $this->trigger(self::EVENT_AFTER_PROCESS_FEED, $event);
    }

    /**
     * @param $feed
     * @param $limit
     * @param $offset
     * @param $processedElementIds
     * @throws \Exception
     */
    public function debugFeed($feed, $limit, $offset, $processedElementIds): void
    {
        $feed->debug = true;

        $feedData = $feed->getFeedData();

        if ($offset) {
            $feedData = array_slice($feedData, $offset);
        }

        if ($limit) {
            $feedData = array_slice($feedData, 0, $limit);
        }

        // Do we even have any data to process?
        if (!$feedData) {
            Plugin::debug('No feed items to process.');
            return;
        }

        $data = $feedData;

        foreach ($feedData as $key => $nodeData) {
            if (!is_array($nodeData)) {
                $nodeData = [$nodeData];
            }

            $data[$key] = Hash::flatten($nodeData, '/');
        }

        $feedData = array_values($data);

        $this->beforeProcessFeed($feed, $feedData);
        $feedSettings = $this->getFeedSettings($feed, $feedData);

        foreach ($feedData as $key => $data) {
            $this->processFeed($key, $feedSettings, $processedElementIds, $data);
        }

        // Check if we need to paginate the feed to run again
        if ($feed->getNextPagination()) {
            $this->debugFeed($feed, null, null, $processedElementIds);
        } else {
            $this->afterProcessFeed($feedSettings, $feed, $processedElementIds);
        }
    }

    /**
     * Prepare data for content comparison and allow plugins to do so via an event.
     *
     * @param array $content
     * @param CraftElementInterface $element
     * @param string $key
     * @param mixed $existingValue
     * @param mixed $newValue
     * @return mixed
     * @since 5.12.0
     */
    public function onCompareContent(array $content, CraftElementInterface $element, string $key, mixed $existingValue, mixed $newValue): mixed
    {
        [$existingValue, $newValue] = DataHelper::prepDatesForComparison($existingValue, $newValue);

        if ($this->hasEventHandlers(self::EVENT_COMPARE_CONTENT)) {
            $event = new CompareContentEvent([
                'content' => $content,
                'element' => $element,
                'handle' => $key,
                'existingValue' => $existingValue,
                'newValue' => $newValue,
            ]);

            $this->trigger(self::EVENT_COMPARE_CONTENT, $event);

            // Allow event to overwrite existing and new value to be used for comparison
            return [$event->existingValue, $event->newValue];
        }

        // Allow event to overwrite existing and new value to be used for comparison
        return [$existingValue, $newValue];
    }



    // Private Methods
    // =========================================================================

    /**
     * @param $feed
     * @throws ShellCommandException
     * @throws Exception
     */
    private function _backupBeforeFeed($feed): void
    {
        if (Craft::$app->getConfig()->getGeneral()->backupCommand === false) {
            Plugin::info('Database not backed up because the backup command is false.');
            return;
        }

        $logKey = StringHelper::randomString(20);

        $limit = Plugin::$plugin->service->getConfig('backupLimit', $feed['id']);

        Plugin::info('Preparing for database backup.', [], ['key' => $logKey]);

        $backupPath = Craft::$app->getPath()->getDbBackupPath();

        if (is_dir($backupPath)) {
            // Check for any existing backups, if more than our limit, we need to kill some off...
            $currentBackups = FileHelper::findFiles($backupPath, [
                'only' => ['feedme-*.sql'],
                'recursive' => false,
            ]);

            // Remove all the previous backups, except the amount we want to limit
            $backupsToDelete = [];

            if (is_array($currentBackups) && count($currentBackups) > $limit) {
                $backupsToDelete = array_splice($currentBackups, 0, (count($currentBackups) - $limit));
            }

            // If we have any to remove, lets delete them
            if (count($backupsToDelete)) {
                foreach ($backupsToDelete as $file) {
                    Plugin::info('Deleting old backup `{i}`.', ['i' => $file], ['key' => $logKey]);

                    FileHelper::unlink($file);
                }
            }
        }

        Plugin::info('Starting database backup.', [], ['key' => $logKey]);

        $file = $backupPath . '/feedme-' . gmdate('ymd_His') . '_' . strtolower(StringHelper::randomString(10)) . '.sql';

        Plugin::info('Limit: `{i}` Path: `{j}`.', ['i' => $limit, 'j' => $file], ['key' => $logKey]);

        Craft::$app->getDb()->backupTo($file);

        Plugin::info('Finished database backup successfully.', [], ['key' => $logKey]);
    }

    /**
     * Function to weed out fields that are set to 'noimport'. More complex than usual by the fact
     * that complex fields (Table, Matrix) have multiple fields, some of which aren't mapped. This is why all nested fields
     * should be templated through the 'fields' index, and this function will take care of things from there.
     *
     * @param $fields
     * @return array
     */
    private function _filterUnmappedFields($fields): array
    {
        if (!is_array($fields)) {
            return [];
        }

        // Find any items like `[title.node] => noimport` and remove the outer field info. Slightly complicated
        // for nested block/fields, and if I was better at recursion, this could be more elegant, but loop through a
        // bunch of times, removing stuff as we go, starting at the inner nested level. Each loop will remove more levels
        // of un-mapped nodes
        for ($i = 0; $i < 5; $i++) {
            foreach (Hash::flatten($fields) as $key => $value) {
                $explode = explode('.', $key);
                $lastIndex = array_pop($explode);
                $infoPath = implode('.', $explode);

                $node = Hash::get($fields, $infoPath . '.node');

                if ($lastIndex === 'node' && $value === 'noimport') {
                    $fields = Hash::remove($fields, $infoPath);
                }

                if ($lastIndex === 'fields' && empty($value)) {
                    // Remove any empty field definitions - but only if there's no node mapping.
                    // This is the case when mapping a value to entries, but not mapping any of its inner element fields.
                    // We want to retain the mapping to the outer field, but ditch any inner fields not mapped
                    if ($node) {
                        $fields = Hash::remove($fields, $infoPath . '.fields');
                    } else {
                        if (Hash::get($fields, $infoPath . '.nativeFields')) {
                            $fields = Hash::remove($fields, $key);
                        } else {
                            $fields = Hash::remove($fields, $infoPath);
                        }
                    }
                }

                if ($lastIndex === 'nativeFields' && empty($value)) {
                    if ($node) {
                        $fields = Hash::remove($fields, $infoPath . '.nativeFields');
                    } else {
                        if (Hash::get($fields, $infoPath . '.fields')) {
                            $fields = Hash::remove($fields, $key);
                        } else {
                            $fields = Hash::remove($fields, $infoPath);
                        }
                    }
                }

                if ($lastIndex === 'blocks' && empty($value)) {
                    $fields = Hash::remove($fields, $infoPath);
                }
            }
        }

        return $fields;
    }
}
