<?php

namespace External\VarioBundle;

use External\VarioBundle\Exception\SynchronizerException;
use External\VarioBundle\Util\VarioConfig;
use KupShop\KupShopBundle\Util\Functional\Mapping;

class SoapClient
{
    public $settings;

    public $config;

    private $client;

    public function __construct(VarioConfig $config)
    {
        $this->config = $config;
    }

    public function getClient(): \SoapClient
    {
        if (!$this->client) {
            ini_set('default_socket_timeout', 60);
            $this->client = new \SoapClient($this->getWsdl(), [
                'trace' => 1,
                'features' => SOAP_SINGLE_ELEMENT_ARRAYS,
                'connection_timeout' => 60,
            ]);

            if ($clientNumber = $this->getClientNumber()) {
                $auth = new \stdClass();
                $auth->ClientNumber = $clientNumber;

                $this->client->__setSoapHeaders(
                    new \SoapHeader('urn:VarioSOAPServiceIntf', 'TAuthHeader', $auth)
                );
            }
        }

        return $this->client;
    }

    public function getProductByCode(string $code): ?object
    {
        $products = $this->getClient()->GetProductsByCriteria($code);
        if (count($products) <= 0) {
            return null;
        }

        return reset($products);
    }

    public function getCustomerByEmail(string $email): ?object
    {
        $customer = $this->getClient()->GetCustomersByCriteria(null, null, $email);
        if (count($customer) <= 0) {
            return null;
        }

        return reset($customer);
    }

    public function getCustomerIds(): array
    {
        return $this->getClient()->GetCustomers();
    }

    public function getCategories(bool $onlyIds = false): array
    {
        static $categoriesCache = [];

        if (empty($categoriesCache[$onlyIds])) {
            $categories = $this->getClient()->GetCategories();
            foreach ($categories as $categoryId) {
                $categoriesCache[$onlyIds][$categoryId] = $onlyIds ? $categoryId : (array) $this->getClient()->GetCategory($categoryId);
            }
        }

        return $categoriesCache[$onlyIds] ?? [];
    }

    public function getParameters(): array
    {
        static $parametersCache = [];

        if (empty($parametersCache)) {
            $parameters = $this->getClient()->GetParametres();
            foreach ($parameters as $parameterId) {
                $parametersCache[$parameterId] = $this->getClient()->GetParameter($parameterId);
            }
        }

        return $parametersCache;
    }

    public function getStores(): array
    {
        static $storesCache = [];

        if (empty($storesCache)) {
            $stores = $this->getClient()->GetStores();
            foreach ($stores as $storeId) {
                $store = $this->getClient()->GetStore($storeId);

                $storesCache[$store->ID] = $store->StoreName;
            }
        }

        return $storesCache;
    }

    public function getPriceLists(): array
    {
        static $priceListsCache = [];

        if (empty($priceListsCache)) {
            $priceLists = $this->getClient()->GetPricelists();
            foreach ($priceLists as $priceListId) {
                $priceList = $this->getClient()->GetPricelist($priceListId);

                $priceListsCache[$priceList->ID] = $priceList->PriceListName;
            }
        }

        return $priceListsCache;
    }

    public function createOrUpdate($type, $data)
    {
        if (isDevelopment()) {
            return null;
        }

        if (empty($data)) {
            return null;
        }

        $method = null;
        switch ($type) {
            case '_otCustomer':
            case 'otCustomer':
                $method = 'CreateOrUpdateCustomer';
                break;
        }

        if (!$method) {
            throw new SynchronizerException(
                sprintf('Unknown type %s in method createOrUpdate()', $type)
            );
        }

        return $this->getClient()->$method($data);
    }

    public function createOrUpdateOrder($order)
    {
        if (isDevelopment()) {
            return;
        }

        $varioOrderId = $this->getClient()->CreateOrUpdateDocument($order);
        if (empty($varioOrderId)) {
            throw new SynchronizerException('Sending order to Vario failed!');
        }

        return $varioOrderId;
    }

    public function updateProduct($product, $fields): ?string
    {
        $varioProductId = $this->getClient()->UpdateProduct($product, $fields);
        if (empty($varioProductId)) {
            throw new SynchronizerException('Update product to Vario failed!');
        }

        return $varioProductId;
    }

    public function setJobsDone(array $jobIds): void
    {
        if (isDevelopment()) {
            return;
        }

        if (!empty($jobIds)) {
            try {
                $this->getClient()->SetJobs($jobIds, true);
            } catch (\Exception $e) {
                getRaven()->captureException($e);
            }
        }
    }

    public function setJobDone($jobId)
    {
        // do not set job done if in development
        if (isDevelopment()) {
            return;
        }

        if ($jobId) {
            try {
                $this->getClient()->SetJob($jobId, true);
            } catch (\Exception $e) {
                getRaven()->captureException($e);
            }
        }
    }

    public function forceObjectData(string $type, $objectID)
    {
        $idField = 'ID';
        switch ($type) {
            case 'otDocument':
                $method = 'GetDocument';
                break;
            case 'otProduct':
            case 'otProductSimple':
                $method = 'GetProduct';
                $idField = 'Id';
                break;
            case 'otAvailableSupply':
                $method = 'GetAvailableSuppliesByProduct';
                $data = $this->getClient()->$method($objectID);

                $return = [];
                foreach ($data as $item) {
                    $return[] = $this->createSyncItem([
                        'ID' => false,
                        'ObjectID' => trim($item->ID, '{}'),
                        'Action' => 'acInsert',
                    ], $item);
                }

                return $return;
            case 'otSupply':
                $method = 'GetSuppliesByProduct';
                $data = $this->getClient()->$method($objectID);

                $return = [];
                foreach ($data as $item) {
                    $return[] = $this->createSyncItem([
                        'ID' => false,
                        'ObjectID' => trim($item->ID, '{}'),
                        'Action' => 'acInsert',
                    ], $item);
                }

                return $return;
            case 'otCategory':
                $method = 'GetCategory';
                break;
            case 'otCustomer':
                $method = 'GetCustomer';
                break;
            case 'otTranslation':
                $method = 'GetTranslation';
                break;
            case 'otPrice':
                try {
                    $data = $this->getClient()->GetPricesByProduct($objectID);
                } catch (\Exception $e) {
                    getRaven()->captureException($e);
                    $data = [];
                }

                $return = [];
                foreach ($data as $item) {
                    $return[] = $this->createSyncItem([
                        'ID' => false,
                        'ObjectID' => trim($item->ID, '{}'),
                        'Action' => 'acInsert',
                    ], $item);
                }

                return $return;
            default:
                throw new SynchronizerException(
                    sprintf('Type %s does not support single object synchronization! You have to implement it if you need it.', $type)
                );
        }

        $data = $this->getClient()->$method($objectID);

        if ($data) {
            return [
                0 => $this->createSyncItem(
                    [
                        'ID' => false,
                        'ObjectID' => trim($data->$idField, '{}'),
                        'Action' => 'acInsert',
                    ],
                    $data
                ),
            ];
        }

        return [];
    }

    public function getJobsData(string $type, int $batch)
    {
        switch ($type) {
            // custom type - creating result equals to GetJobsData
            case 'otPricelist':
                $pricelists = $this->getClient()->GetPricelists();
                $return = [];
                foreach ($pricelists as $pricelist) {
                    if (!in_array(trim($pricelist, '{}'), $this->config->getPriceListIds())) {
                        continue;
                    }

                    $data = $this->getClient()->GetPricelist($pricelist);

                    $return[] = $this->createSyncItem([
                        'ID' => false,
                        'ObjectID' => trim($pricelist, '{}'),
                        'Action' => 'acInsert',
                    ], $data);
                }

                return $return;
            case 'otStore': // fetch all stores - to have mapping of store IDs (then in supplies sync is not need of fetching store)
                $stores = $this->getClient()->GetStores();
                $return = [];
                foreach ($stores as $store) {
                    if (!in_array(trim($store, '{}'), $this->config->get('stores'))) {
                        continue;
                    }

                    $return[] = $this->createSyncItem([
                        'ID' => false,
                        'ObjectID' => trim($store, '{}'),
                        'Action' => 'acInsert',
                    ], $this->getClient()->GetStore($store));
                }

                return $return;
            case 'otParameter':
                $parameters = $this->getParameters();

                $return = [];
                foreach ($parameters as $parameterId => $parameter) {
                    $return[] = $this->createSyncItem([
                        'ID' => false,
                        'ObjectID' => trim($parameterId, '{}'),
                        'Action' => 'acInsert',
                    ], $parameter);
                }

                return $return;
                // some types are not supported by GetJobsData :/ - use prepareJobsData to get same result as in GetJobsData
            case 'otDocument':
                return $this->prepareJobsData($batch, $type, 'GetDocument');
            case 'otAvailableSupply':
                return $this->prepareJobsData($batch, $type, 'GetAvailableSupply');
            case 'otSupply':
                return $this->prepareJobsData($batch, $type, 'GetSupply');
            case 'otCategory':
                return $this->prepareJobsData($batch, $type, 'GetCategory');
            case 'otProductCategory':
                return $this->prepareJobsData($batch, $type, 'GetProductCategory');
            case 'otTranslation':
                return $this->prepareJobsData($batch, $type, 'GetTranslation', null, false, true);
            case 'otProductSimple':
                $jobs = $this->getClient()->GetJobsByType($batch, $type);
                $items = Mapping::mapKeys($this->getClient()->GetProductsArray(
                    array_map(
                        function ($x) {
                            return $x->ObjectID;
                        },
                        $jobs
                    )
                ), function ($k, $v) { return [trim($v->Id ?? null, '{}'), $v]; });
                $result = [];
                foreach ($jobs as $job) {
                    if ($data = ($items[trim($job->ObjectID, '{}')] ?? null)) {
                        $result[] = $this->createSyncItem((array) $job, $data);
                    }
                }

                return $result;
            case 'otCustomer':
                return $this->prepareJobsData($batch, $type, 'GetCustomer');
            case 'otProductExt':
                return $this->prepareJobsData($batch, $type, 'GetProductExt');
            default:
                return $this->getClient()->GetJobsData(
                    $batch, $type
                );
        }
    }

    public function getSuppliesByProduct(string $productId, ?string $variationId = null): ?array
    {
        try {
            $supplies = $this->getClient()->GetAvailableSuppliesByProduct($productId);
        } catch (\Throwable $e) {
            $supplies = null;
        }

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

        // filter only active stores
        $supplies = array_filter($supplies, function ($x) { return in_array(trim($x->StoreID, '{}'), $this->config->getStores()); });

        // filter by variation
        if ($variationId) {
            $supplies = array_filter($supplies, function ($x) use ($variationId) { return $x->VariantID === '{'.$variationId.'}'; });
        }

        return $supplies;
    }

    public function getProductIds(): iterable
    {
        $page = 0;
        do {
            $products = $this->getClient()->GetProductsPage(1000, $page++);
            foreach ($products as $id) {
                yield $id;
            }
        } while (!empty($products));
    }

    protected function prepareJobsData(int $batch, string $type, string $objectGetMethod, ?callable $dataCallback = null, bool $multi = false, bool $ignoreErrors = false)
    {
        $items = [];
        $jobs = $this->getClient()->GetJobsByType($batch, $type);
        $index = 0;

        $multiData = [];
        if ($multi) {
            $multiData = Mapping::mapKeys(
                array_filter($this->getClient()->$objectGetMethod(array_map(function ($x) { return $x->ObjectID; }, $jobs))),
                function ($k, $v) {
                    return [$v->ID, $v];
                }
            );
        }

        $doneJobs = [];

        foreach ($jobs as $job) {
            if (isset($job->Action) && $job->Action == 'acDelete') {
                $data = new \stdClass();
            } else {
                $data = $this->ignoreErrors(function () use ($multi, $multiData, $job, $objectGetMethod) {
                    if ($multi && $multiData) {
                        return $multiData[$job->ObjectID] ?? null;
                    } else {
                        return $this->getClient()->$objectGetMethod($job->ObjectID);
                    }
                }, $ignoreErrors);

                if ($dataCallback !== null) {
                    $data = $dataCallback($data);
                }
            }

            if ($data) {
                $items[$index] = new \stdClass();
                $items[$index]->Job = $job;
                $items[$index]->Data = $data;
                $index++;
            } else {
                if (!empty($job->ID)) {
                    $doneJobs[] = $job->ID;
                }
            }
        }

        if (!empty($doneJobs)) {
            $this->setJobsDone($doneJobs);
        }

        return $items;
    }

    protected function getClientNumber(): ?int
    {
        return $this->config->get('client_number');
    }

    private function ignoreErrors(callable $callback, bool $ignoreErrors = false)
    {
        if ($ignoreErrors) {
            try {
                return $callback();
            } catch (\Exception $e) {
                return null;
            }
        }

        return $callback();
    }

    private function createSyncItem(array $job, $data)
    {
        $item = new \stdClass();
        $item->Job = new \stdClass();
        $item->Job->ID = $job['ID'];
        $item->Job->ObjectID = $job['ObjectID'];
        $item->Job->Action = $job['Action'];
        $item->Job->Created = $job['Created'] ?? null;
        $item->Data = $data;

        return $item;
    }

    protected function getWsdl()
    {
        return $this->config->get('wsdl');
    }
}
