<?php

declare(strict_types=1);

/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

namespace Meta\Catalog\Model\Product\Feed\Method;

use GuzzleHttp\Exception\GuzzleException;
use Magento\Framework\Exception\FileSystemException;
use Meta\BusinessExtension\Helper\FBEHelper;
use Meta\BusinessExtension\Helper\GraphAPIAdapter;
use Meta\BusinessExtension\Model\System\Config as SystemConfig;
use Meta\Catalog\Model\Config\Source\FeedUploadMethod;
use Meta\Catalog\Model\Product\Feed\Builder;
use Magento\Framework\App\Filesystem\DirectoryList;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Filesystem;
use Magento\Framework\Filesystem\Driver\File;
use ReflectionClass;

/**
 * Class Use for Feed Api
 *
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
 */
class FeedApi
{
    private const FEED_FILE_NAME = 'facebook_products%s.csv.gz';
    private const FEED_FILE_LOCK_EXT = '.lock';
    private const FB_FEED_NAME = 'Magento Product Feed';

    private const OLD_FB_FEED_NAME = 'Magento Autogenerated Feed';

    /**
     * @var SystemConfig
     */
    private $systemConfig;

    /**
     * @var GraphAPIAdapter
     */
    private $graphApiAdapter;

    /**
     * @var Filesystem
     */
    private $fileSystem;

    /**
     * @var array
     */
    private $productRetrievers;

    /**
     * @var FBEHelper
     */
    private FBEHelper $fbeHelper;

    /**
     * @var Builder
     */
    private $builder;

    /**
     * @var File
     */
    private File $fileHandler;

    private const MAX_PRODUCT_ERRORS_LOGGED = 10;

    /**
     * @param SystemConfig $systemConfig
     * @param GraphAPIAdapter $graphApiAdapter
     * @param Filesystem $filesystem
     * @param File $fileHandler
     * @param array $productRetrievers
     * @param Builder $builder
     * @param FBEHelper $fbeHelper
     */
    public function __construct(
        SystemConfig    $systemConfig,
        GraphAPIAdapter $graphApiAdapter,
        Filesystem      $filesystem,
        File            $fileHandler,
        array           $productRetrievers,
        Builder         $builder,
        FBEHelper       $fbeHelper
    ) {
        $this->systemConfig = $systemConfig;
        $this->graphApiAdapter = $graphApiAdapter;
        $this->fileSystem = $filesystem;
        $this->productRetrievers = $productRetrievers;
        $this->builder = $builder;
        $this->builder->setUploadMethod(FeedUploadMethod::UPLOAD_METHOD_FEED_API);
        $this->fbeHelper = $fbeHelper;
        $this->fileHandler = $fileHandler;
    }

    /**
     * Get FB Feed ID
     *
     * @param int $storeId
     * @return mixed|null
     * @throws GuzzleException
     */
    private function getFbFeedId($storeId)
    {
        $feedId = $this->systemConfig->getFeedId($storeId);
        $feedName = self::FB_FEED_NAME;
        $oldFeedName = self::OLD_FB_FEED_NAME;
        $catalogId = $this->systemConfig->getCatalogId($storeId);
        $catalogFeeds = $this->graphApiAdapter->getCatalogFeeds($catalogId);

        // make sure feed exists on meta side, not deleted
        $feedId = $this->verifyFeedExistsInMetaCatalog($feedId, $catalogFeeds);

        // If feedId does not exist in Magento or deleted on Meta,
        // try to find feed with Name
        if (!$feedId) {
            $magentoFeeds = array_filter($catalogFeeds, function ($a) use ($feedName, $oldFeedName) {
                return ($a['name'] === $feedName || $a['name'] === $oldFeedName);
            });
            if (!empty($magentoFeeds)) {
                $feedId = $magentoFeeds[array_key_first($magentoFeeds)]['id'];
            }
            if ($feedId) {
                $this->saveFeedId($feedId, $storeId);
            }
        }

        // still if feed id is not found, create new feed
        if (!$feedId) {
            $feedId = $this->createNewFeedAndSave($catalogId, $feedName, $storeId);
        }
        return $feedId;
    }

    /**
     * Creates new feed and save it
     *
     * @param string $catalogId
     * @param string $feedName
     * @param int|null $storeId
     * @return mixed
     * @throws GuzzleException
     */
    private function createNewFeedAndSave(string $catalogId, string $feedName, $storeId)
    {
        $feedId = $this->graphApiAdapter->createEmptyFeed($catalogId, $feedName);

        $maxAttempts = 5;
        $attempts = 0;
        do {
            $feedData = $this->graphApiAdapter->getFeed($feedId);
            if ($feedData !== false) {
                break;
            }
            $attempts++;
            usleep(2000000);
        } while ($attempts < $maxAttempts);

        if ($feedId) {
            $this->saveFeedId($feedId, $storeId);
        }

        return $feedId;
    }

    /**
     * Verify is feed exists in Meta Catalog, if not returns null
     *
     * @param string|null $feedId
     * @param array $catalogFeeds
     * @return string|null
     */
    private function verifyFeedExistsInMetaCatalog(?string $feedId, array $catalogFeeds): ?string
    {
        // make sure feed exists on meta side, not deleted
        if ($feedId) {
            $magentoFeeds = array_filter($catalogFeeds, fn($a) => $a['id'] === $feedId);
            // in case feed id is not found in meta catalog, feed id on
            // magento will be flushed and new feed will be created in Meta Catalog
            if (empty($magentoFeeds)) {
                return null;
            }
        }
        return $feedId;
    }

    /**
     * Saves FB Feed ID to configurations
     *
     * @param string $feedId
     * @param int $storeId
     * @return void
     */
    private function saveFeedId(string $feedId, $storeId): void
    {
        $this->systemConfig->saveConfig(
            SystemConfig::XML_PATH_FACEBOOK_BUSINESS_EXTENSION_FEED_ID,
            $feedId,
            $storeId
        )->cleanCache();
    }

    /**
     * Write file
     *
     * @param resource $resource
     * @param int $storeId
     * @param string $flowName
     * @param string $traceId
     * @throws FileSystemException
     */
    private function writeCsvGzipFile(
        $resource,
        $storeId,
        string $flowName,
        string $traceId
    ) {
        $context = $this->getFeedUploadLoggerContext(
            $storeId,
            $flowName,
            'feed_upload_product_progress',
            [
                'external_trace_id' => $traceId,
            ]
        );
        $this->fileHandler->filePutcsv($resource, $this->builder->getHeaderFields());

        $total = 0;
        $totalErrors = 0;
        foreach ($this->productRetrievers as $productRetriever) {
            $productRetriever->setStoreId($storeId);
            $offset = 0;
            $limit = $productRetriever->getLimit();
            do {
                if ($this->systemConfig->isMemoryProfilingEnabled()) {
                    $memory_limit = ini_get('memory_limit');
                    $retriever_class = (new ReflectionClass($productRetriever))->getShortName();
                    $this->fbeHelper->logTelemetryToMeta(
                        sprintf(
                            'Feed Upload Progress: Retriever = %s, Offset = %d, Current MemUsed = %d,' .
                            ' PeakMemory = %d, MemoryLimit = %s',
                            $retriever_class,
                            $offset,
                            memory_get_usage(),
                            memory_get_peak_usage(),
                            $memory_limit
                        ),
                        $context
                    );
                }
                $products = $productRetriever->retrieve($offset);
                $offset += $limit;
                if (empty($products)) {
                    break;
                }
                foreach ($products as $product) {
                    try {
                        if (!$product->isVisibleInSiteVisibility() && !$product->getParentId()) {
                            continue;
                        }
                        $entry = array_values($this->builder->buildProductEntry($product));
                        $this->fileHandler->filePutcsv($resource, $entry);
                        $total++;
                        $product = null;
                        $entry = null;
                    } catch (\Throwable $e) {
                        $totalErrors++;
                        // To prevent all the product errors logs, added cap on number of error logs.
                        if ($totalErrors <= self::MAX_PRODUCT_ERRORS_LOGGED) {
                            $this->fbeHelper->logExceptionImmediatelyToMeta(
                                $e,
                                [
                                    'store_id' => $storeId,
                                    'event' => 'feed_upload',
                                    'event_type' => 'feed_upload_product_builder_entry_failed',
                                    'flow_name' => $flowName,
                                    'flow_step' => 'feed_file_creation_product_entry_error',
                                    'catalog_id' => $this->systemConfig->getCatalogId($storeId),
                                    'extra_data' => [
                                        'sku' => $product->getSku(),
                                        'product_id' => $product->getId(),
                                        'feed_id' => $this->systemConfig->getFeedId($storeId),
                                        'external_trace_id' => $traceId
                                    ]
                                ]
                            );
                        }
                    }
                }
                $products = null;
                if ($this->systemConfig->isMemoryProfilingEnabled()) {
                    gc_collect_cycles();
                }
            } while (true);
        }

        $context = $this->getFeedUploadLoggerContext(
            $storeId,
            $flowName,
            'feed_file_product_entries_completed',
            [
                'external_trace_id' => $traceId,
                'product_entries_failed' => $totalErrors,
                'product_entries_success' => $total
            ]
        );

        $this->fbeHelper->logTelemetryToMeta(
            sprintf(
                'Generated feed file: Successful Products = %d, Failed Products = %d, StoreID = %d',
                $total,
                $totalErrors,
                $storeId
            ),
            $context
        );
    }

    /**
     * Get file name with store code suffix for non-default store (no suffix for default one)
     *
     * @param int $storeId
     * @return string
     * @throws NoSuchEntityException
     */
    private function getFeedFileName($storeId): string
    {
        $defaultStoreId = $this->systemConfig->getStoreManager()->getDefaultStoreView()->getId();
        $storeCode = $this->systemConfig->getStoreManager()->getStore($storeId)->getCode();
        return sprintf(
            self::FEED_FILE_NAME,
            ($storeId && $storeId !== $defaultStoreId) ? ('_' . $storeCode) : ''
        );
    }

    /**
     * Generate product feed and push to meta catalog
     *
     * @param string $feedId
     * @param int $storeId
     * @param string $flowName
     * @param string $traceId
     * @return mixed
     * @throws \Throwable
     */
    private function generateAndPushProductFeed(
        string $feedId,
        $storeId,
        string $flowName,
        string $traceId
    ) {
        $fileName = 'export/' . $this->getFeedFileName($storeId);
        $directory = $this->fileSystem->getDirectoryWrite(DirectoryList::VAR_DIR);
        $directory->create('export');
        $filePath = $directory->getAbsolutePath($fileName);

        // Function gzopen does not work along with flock, here we are having another lock file to take lock here.
        $lock = $this->fileHandler->fileOpen($filePath . self::FEED_FILE_LOCK_EXT, "w+");
        // Acquire an exclusive lock on the Gzip file
        $this->fileHandler->fileLock($lock);

        $context = $this->getFeedUploadLoggerContext(
            $storeId,
            $flowName,
            'feed_file_creation_start',
            [
                'external_trace_id' => $traceId
            ]
        );
        $startTime = $this->fbeHelper->getCurrentTimeInMS();

        $this->fbeHelper->logTelemetryToMeta(
            sprintf("Feed file creation started: storeId: %d, flow: %s", $storeId, $flowName),
            $context
        );

        // phpcs:ignore Magento2.Functions.DiscouragedFunction
        $stream = gzopen($filePath, 'w6'); // used compression lvl 6 to have balance b/w CPU and compression data

        $this->writeCsvGzipFile($stream, $storeId, $flowName, $traceId);

        // phpcs:ignore Magento2.Functions.DiscouragedFunction
        gzclose($stream);

        $context = $this->getFeedUploadLoggerContext(
            $storeId,
            $flowName,
            'feed_file_creation_finished',
            [
                'external_trace_id' => $traceId,
                'time_taken_ms' => $this->fbeHelper->getCurrentTimeInMS() - $startTime
            ]
        );

        $this->fbeHelper->logTelemetryToMeta(
            sprintf("Feed file creation finished: storeId: %d, flow: %s", $storeId, $flowName),
            $context
        );

        $feedUploadResponse = $this->graphApiAdapter->pushProductFeed($feedId, $filePath);

        // Release the lock from the lock file
        $this->fileHandler->fileUnlock($lock);

        return $feedUploadResponse;
    }

    /**
     * Execute function
     *
     * @param int $storeId
     * @param string $flowName
     * @param string $traceId
     * @return mixed|null
     * @throws GuzzleException
     * @throws LocalizedException
     * @throws \Throwable
     */
    public function execute($storeId, string $flowName, string $traceId)
    {
        $this->builder->setStoreId($storeId);
        $accessToken = $this->systemConfig->getAccessToken($storeId);
        if ($accessToken === null) {
            $this->fbeHelper->log(sprintf(
                "Full Catalog Sync: can't find access token, StoreID = %d",
                $storeId
            ));
            return null;
        }

        $context = $this->getFeedUploadLoggerContext(
            $storeId,
            $flowName,
            'feed_upload_start',
            [
                'external_trace_id' => $traceId
            ]
        );
        $startTime = $this->fbeHelper->getCurrentTimeInMS();

        $this->fbeHelper->logTelemetryToMeta(
            sprintf("Feed upload started: storeId: %d, flow: %s", $storeId, $flowName),
            $context
        );

        $this->graphApiAdapter->setDebugMode($this->systemConfig->isDebugMode($storeId))
            ->setAccessToken($accessToken);
        try {
            $feedId = $this->getFbFeedId($storeId);
            if (!$feedId) {
                throw new LocalizedException(__('Cannot fetch feed ID'));
            }
            $response = $this->generateAndPushProductFeed($feedId, $storeId, $flowName, $traceId);

            $context = $this->getFeedUploadLoggerContext(
                $storeId,
                $flowName,
                'feed_upload_finished',
                [
                    'external_trace_id' => $traceId,
                    'time_taken_ms' => $this->fbeHelper->getCurrentTimeInMS() - $startTime
                ]
            );
            $this->fbeHelper->logTelemetryToMeta(
                sprintf("Feed upload finished: storeId: %d, flow: %s", $storeId, $flowName),
                $context
            );
            return $response;
        } catch (\Throwable $e) {
            $this->fbeHelper->logExceptionImmediatelyToMeta(
                $e,
                [
                    'store_id' => $storeId,
                    'event' => 'feed_upload',
                    'event_type' => 'feed_upload',
                    'flow_name' => $flowName,
                    'flow_step' => 'feed_upload_error',
                    'catalog_id' => $this->systemConfig->getCatalogId($storeId),
                    'extra_data' => [
                        'external_trace_id' => $traceId,
                        'feed_id' => $this->systemConfig->getFeedId($storeId)
                    ]
                ]
            );
            throw $e;
        }
    }

    /**
     * Return Category logger context for be logged
     *
     * @param int|null $storeId
     * @param string $flowName
     * @param string $flowStep
     * @param array $extraData
     * @return array
     */
    public function getFeedUploadLoggerContext(
        $storeId,
        string $flowName,
        string $flowStep,
        array $extraData
    ): array {
        return [
            'store_id' => $storeId,
            'flow_name' => $flowName,
            'flow_step' => $flowStep,
            'catalog_id' => $this->systemConfig->getCatalogId($storeId),
            'extra_data' => $extraData
        ];
    }
}
