<?php
// Copyright (c) Pickware GmbH. All rights reserved.
// This file is part of software that is released under a proprietary license.
// You must not copy, modify, distribute, make publicly available, or execute
// its contents or parts thereof without express permission by the copyright
// holder, unless otherwise permitted by law.

namespace Shopware\Plugins\ViisonPickwareMobile\Components\PickingOrderFilter;

use Exception;
use Shopware\Plugins\ViisonPickwareMobile\Components\PickingOrderFilter\OrderItemRelevance\OrderItemRelevanceService;

class UnfulfilledPickingOrderFilterConditions
{
    // The aliases of derived order conditions, i.e. conditions that are computed by this class based on the conditions
    // contained in the query result
    const DERIVED_CONDITION_ALIAS_ALL_ITEMS_ARE_RELEASED = 'allItemsAreReleased';
    const DERIVED_CONDITION_ALIAS_ORDER_HAS_VALID_ITEMS = 'orderHasValidItems';
    const DERIVED_CONDITION_ALIAS_ORDER_MEETS_STOCK_BASED_FILTER = 'orderMeetsStockBasedFilter';
    const DERIVED_CONDITION_ALIAS_ORDER_IS_VALID = 'orderIsValid';

    /**
     * @var array[]
     */
    protected $queryResult;

    /**
     * @var StockBasedFilterConfiguration
     */
    protected $stockBasedFilterConfig;

    /**
     * A nested array used as a cache by {@link self::findRow()} to speed up repeated searches of the same row.
     *
     * @var array
     */
    protected $queryResultRowIndex = [];

    /**
     * An array used as a cache by {@link self::evaluateStockBasedFilterForOrder()} to speed up repeated evaluations of
     * the same order.
     *
     * @var array
     */
    protected $stockBasedFilterCache = [];

    /**
     * @param array[] $queryResult
     * @param StockBasedFilterConfiguration $stockBasedFilterConfig
     */
    public function __construct(array $queryResult, StockBasedFilterConfiguration $stockBasedFilterConfig)
    {
        $this->queryResult = $queryResult;
        $this->stockBasedFilterConfig = $stockBasedFilterConfig;
    }

    /**
     * @return StockBasedFilterConfiguration
     */
    public function getStockBasedFilterConfig()
    {
        return $this->stockBasedFilterConfig;
    }

    /**
     * Returns an array containing the aliases of all unfulfilled conditions of the order with the given $orderId.
     *
     * @param int $orderId
     * @return string[]
     */
    public function getUnfulfilledConditionsForOrderWithId($orderId)
    {
        // Make sure the passed order ID is part of this result
        if (!$this->findRow($orderId, 'orderId')) {
            return [];
        }

        // Try to find unfulfilled order conditions
        $orderConditionTypeFilter = [
            self::class,
            'isOrderCondition',
        ];
        $unfulfilledOrderConditions = $this->getUnfulfilledConditions($orderId, 'orderId', $orderConditionTypeFilter);
        if (!$this->evaluateStockBasedFilterForOrder($orderId)) {
            $unfulfilledOrderConditions[] = self::DERIVED_CONDITION_ALIAS_ORDER_MEETS_STOCK_BASED_FILTER;
        }
        if (!in_array(PickingOrderFilterService::CONDITION_ALIAS_ORDER_PASSES_CUSTOM_FILTER, $unfulfilledOrderConditions)) {
            // Filter out all sub conditions of the custom pick profile filter
            $unfulfilledOrderConditions = array_values(array_filter(
                $unfulfilledOrderConditions,
                function ($conditionAlias) {
                    return !self::isCustomPickProfileSubCondition($conditionAlias);
                }
            ));
        }
        if (count($unfulfilledOrderConditions) > 0) {
            return $unfulfilledOrderConditions;
        }

        // No unfulfilled order conditions found, hence check all order item conditions of the order's items
        $numberOfValidOrderItems = 0;
        $itemConditionTypeFilter = [
            self::class,
            'isItemCondition',
        ];
        foreach ($this->queryResult as $queryRow) {
            if ($queryRow['orderId'] != $orderId) {
                continue;
            }

            // Check the order item for any unfulfilled conditions
            $unfulfilledItemConditions = $this->getUnfulfilledConditions(
                $queryRow['orderItemId'],
                'orderItemId',
                $itemConditionTypeFilter
            );

            if ($this->stockBasedFilterConfig->isModeAllItemsMustHaveAllRequiredStock() && in_array(PickingOrderFilterService::CONDITION_ALIAS_ORDER_ITEM_IS_RELEASED, $unfulfilledItemConditions)) {
                // All items are required to have all stock, but the current item has not been released yet
                return [self::DERIVED_CONDITION_ALIAS_ALL_ITEMS_ARE_RELEASED];
            }

            if (count($unfulfilledItemConditions) === 0) {
                // No unfulfilled conditions, hence it's valid
                $numberOfValidOrderItems += 1;
            }
        }

        if ($numberOfValidOrderItems === 0) {
            // None of the order items fulfill their conditions
            return [self::DERIVED_CONDITION_ALIAS_ORDER_HAS_VALID_ITEMS];
        }

        // All conditions fulfilled
        return [];
    }

    /**
     * Returns an array containing the aliases of all unfulfilled conditions of the order item with the
     * given $orderItemId.
     *
     * @param int $orderItemId
     * @return string[]
     */
    public function getUnfulfilledConditionsForOrderItemWithId($orderItemId)
    {
        // Make sure the passed order item ID is part of this result
        if (!$this->findRow($orderItemId, 'orderItemId')) {
            return [];
        }

        // Try to find unfulfilled order conditions for the item's order
        $itemRow = $this->findRow($orderItemId, 'orderItemId');
        $unfulfilledOrderConditions = $this->getUnfulfilledConditionsForOrderWithId($itemRow['orderId']);

        // Return a single unfulfilled 'orderIsValid' item condition, if
        //   a) The order has unfulfilled conditions
        //      AND
        //   b) The order does have valid items
        //      AND
        //   c) The order does meet the stock based filter
        //      AND
        //   d) The order does meet the 'all items are released' filter (relevant only in in case of filter mode 'all
        //      items must have all required stock')
        //      AND
        //   e) Stock based filtering is disabled (in which case c) will always be TRUE) or the order item's stock
        //      is sufficient
        //
        // This makes sure that all items have the same unfulfilled conditions, if the order is not valid for any
        // reason that is unrelated to its items.
        if (count($unfulfilledOrderConditions) > 0
            && !in_array(self::DERIVED_CONDITION_ALIAS_ORDER_HAS_VALID_ITEMS, $unfulfilledOrderConditions)
            && !in_array(self::DERIVED_CONDITION_ALIAS_ORDER_MEETS_STOCK_BASED_FILTER, $unfulfilledOrderConditions)
            && (!$this->stockBasedFilterConfig->isModeAllItemsMustHaveAllRequiredStock() || !in_array(self::DERIVED_CONDITION_ALIAS_ALL_ITEMS_ARE_RELEASED, $unfulfilledOrderConditions))
            && (!$this->stockBasedFilterConfig->isStockBasedFilterEnabled() || array_key_exists(PickingOrderFilterService::CONDITION_ALIAS_ORDER_ITEM_HAS_SUFFICIENT_STOCK, $itemRow))
        ) {
            return [self::DERIVED_CONDITION_ALIAS_ORDER_IS_VALID];
        }

        // Find any unfulfilled item conditions
        $itemConditionTypeFilter = [
            self::class,
            'isItemCondition',
        ];
        $unfulfilledItemConditions = $this->getUnfulfilledConditions(
            $orderItemId,
            'orderItemId',
            $itemConditionTypeFilter
        );

        // Check whether the item has no associated article. In this case, only the that unfulfilled condition shall be
        // returned to avoid confusion, since missing article associations can cause random conditions to fail due to
        // missing data
        if (in_array(OrderItemRelevanceService::CONDITION_ALIAS_ITEM_HAS_ASSOCIATED_ARTICLE, $unfulfilledItemConditions)) {
            return [OrderItemRelevanceService::CONDITION_ALIAS_ITEM_HAS_ASSOCIATED_ARTICLE];
        }

        // Remove the description of the stock based item filter, if
        //   a) The configured stock based filter does NOT require all items to have all required stock or the order's
        //      dispatch method is exempt from the standard stock based filter condition
        //      AND
        //   b) The current item does not have sufficient stock
        //      AND
        //   c) The order does fulfill the stock based filter or the item has more unfulfilled conditions than just the
        //      insufficient stock
        //
        // This makes sure that no items have a stock based error description, if the order is valid.
        $stockItemConditionKey = array_search(PickingOrderFilterService::CONDITION_ALIAS_ORDER_ITEM_HAS_SUFFICIENT_STOCK, $unfulfilledItemConditions);
        if (($this->stockBasedFilterConfig->isModeOfTypeAtLeastOneItemMustFulfillStockCondition() || in_array($itemRow['dispatchMethodId'], $this->stockBasedFilterConfig->getExemptDispatchMethodIds()))
            && $stockItemConditionKey !== false
            && (!in_array(self::DERIVED_CONDITION_ALIAS_ORDER_MEETS_STOCK_BASED_FILTER, $unfulfilledOrderConditions) || count($unfulfilledItemConditions) > 1)) {
            unset($unfulfilledItemConditions[$stockItemConditionKey]);
        }

        // Remove all unfulfilled, 'soft' item conditions, if the order has no unfulfilled conditions and hence is
        // valid, because unfilled 'soft' item conditions should never prevent an item from being displayed in the app.
        if (count($unfulfilledOrderConditions) === 0) {
            $hardConditionTypeFilter = [
                self::class,
                'isHardCondition',
            ];
            $unfulfilledItemConditions = array_filter($unfulfilledItemConditions, $hardConditionTypeFilter);
        }

        return array_values($unfulfilledItemConditions);
    }

    /**
     * First finds a row, whose value for the given $idFieldKey matches the given $rowId. The matching row's conditions
     * are filtered using the $conditionTypeFilter and the remaining conditions are check whether the are fulfilled.
     * The aliases of any unmet conditions are returned.
     *
     * @param int $rowId
     * @param string $idFieldKey
     * @param callable $conditionTypeFilter
     * @return string[]
     */
    protected function getUnfulfilledConditions($rowId, $idFieldKey, callable $conditionTypeFilter)
    {
        // Find a matching row
        $row = $this->findRow($rowId, $idFieldKey);
        if (!$row) {
            return [];
        }

        // Determine any unfulfilled conditions of the row
        $unfulfilledConditions = array_filter(
            array_keys($row),
            function ($condition) use ($row, $conditionTypeFilter) {
                return $conditionTypeFilter($condition) && !boolval($row[$condition]);
            }
        );

        return array_values($unfulfilledConditions);
    }

    /**
     * Evaluates the stock based filter for the order with the given ID and saves the resulting
     * boolean in the cache for later checks. If result is true, the order passes the filter.
     * Finally the result is returned. Before the evaluation, the cache is checked for an already
     * computed result to speed up the filter.
     *
     * @param int $orderId
     * @return bool
     */
    protected function evaluateStockBasedFilterForOrder($orderId)
    {
        if (!$this->stockBasedFilterConfig->isStockBasedFilterEnabled()) {
            return true;
        }

        // Check for a cached result
        if (isset($this->stockBasedFilterCache[$orderId])) {
            return $this->stockBasedFilterCache[$orderId];
        }

        $allItemsMustFulfillStockCondition = $this->stockBasedFilterConfig->isModeOfTypeAllItemsMustFulfillStockCondition();
        $exemptDispatchMethodIds = $this->stockBasedFilterConfig->getExemptDispatchMethodIds();

        // Filter the items of the order with two conditions
        //   a) All items that don't have any unfulfilled item conditions, excluding the 'itemHasSufficientStock'
        //      condition.
        //   b) All items conforming to a) that do fulfill the item stock condition.
        $validItems = [];
        $validItemsWithFulfilledStockCondition = [];
        foreach ($this->queryResult as $row) {
            if ($row['orderId'] != $orderId) {
                continue;
            }

            // Evaluate item filters
            // a)
            $unfulfilledItemConditions = array_filter(
                $row,
                function ($value, $conditionAlias) {
                    return self::isItemCondition($conditionAlias) && !boolval($value);
                },
                ARRAY_FILTER_USE_BOTH
            );
            $unfulfilledNonStockBasedItemConditions = $unfulfilledItemConditions;
            unset($unfulfilledNonStockBasedItemConditions[PickingOrderFilterService::CONDITION_ALIAS_ORDER_ITEM_HAS_SUFFICIENT_STOCK]);
            if (count($unfulfilledNonStockBasedItemConditions) === 0) {
                // Item is valid
                $validItems[] = $row;

                // b)
                if (!array_key_exists(PickingOrderFilterService::CONDITION_ALIAS_ORDER_ITEM_HAS_SUFFICIENT_STOCK, $unfulfilledItemConditions)) {
                    $validItemsWithFulfilledStockCondition[] = $row;
                } elseif ($allItemsMustFulfillStockCondition && (count($exemptDispatchMethodIds) === 0 || !in_array($row['dispatchMethodId'], $exemptDispatchMethodIds))) {
                    // Order cannot pass the filter anymore, because at least one item does not have sufficient stock
                    break;
                }
            }
        }

        // Use the filtered item lists to evaluate the stock based filter for the order. First of all, if an order does
        // not have any valid items, the stock based filter result is always TRUE. In case a) the stock based filter
        // requires all itmes to fulfill their stock condition, all valid items must do so, unless b) the order's
        // dispatch method is exempt from the standard stock based filter condition, in which case only one valid item
        // must fulfill its stock condition. For all other stock based filter modes, the order is always valid if only
        // one valid item fulfills its stock condition.
        $stockbasedFilterResult = (
            // Order has no valid items
            count($validItems) === 0
            // OR all items are required to fulfill their stock condition and do so
            || (
                $allItemsMustFulfillStockCondition
                && (
                    // a) All valid items have sufficient stock
                    count($validItemsWithFulfilledStockCondition) === count($validItems)
                    || (
                        // b) Dispatch method is exempt from stock filter and at least one valid item has sufficient stock
                        count($validItemsWithFulfilledStockCondition) > 0
                        && in_array($validItemsWithFulfilledStockCondition[0]['dispatchMethodId'], $this->stockBasedFilterConfig->getExemptDispatchMethodIds())
                    )
                )
            )
            // OR at least one item is required to fulfill its stock condition and does so
            || (
                $this->stockBasedFilterConfig->isModeOfTypeAtLeastOneItemMustFulfillStockCondition()
                && count($validItemsWithFulfilledStockCondition) > 0
            )
        );

        // Save the result in the cache for later use
        $this->stockBasedFilterCache[$orderId] = $stockbasedFilterResult;

        return $stockbasedFilterResult;
    }

    /**
     * Searches and returns the row whose value in the field with the given $idFieldKey matches the given $rowId.
     * To speed up repeated searches for the same field/id combinations, matching results are added to an index, which
     * is always checked first. Returns null, if the query result does not contain a matching row.
     *
     * @param int $rowId
     * @param string $idFieldKey
     * @return array|null
     */
    protected function findRow($rowId, $idFieldKey)
    {
        // Check row index first
        if (isset($this->queryResultRowIndex[$idFieldKey][$rowId])) {
            return $this->queryResultRowIndex[$idFieldKey][$rowId];
        }

        foreach ($this->queryResult as $row) {
            if ($row[$idFieldKey] == $rowId) {
                // Save the matching row in the index to speed up repeated searches for the same row
                if (!isset($this->queryResultRowIndex[$idFieldKey])) {
                    $this->queryResultRowIndex[$idFieldKey] = [];
                }
                $this->queryResultRowIndex[$idFieldKey][$rowId] = $row;

                return $row;
            }
        }

        return null;
    }

    /**
     * An array containing the aliases of all 'hard' filter conditions, which are required to determine valid orders and
     * and valid order items. That is, an unmet 'hard' condition will mark an order item as not valid, which in turn
     * hides it in the Picking app.
     *
     * @var string[]
     */
    protected static $aliasesOfHardConditions = [
        PickingOrderFilterService::CONDITION_ALIAS_ORDER_HAS_VALID_DISPATCH_METHOD,
        PickingOrderFilterService::CONDITION_ALIAS_ORDER_HAS_VALID_STATUS,
        PickingOrderFilterService::CONDITION_ALIAS_ORDER_PASSES_CUSTOM_FILTER,
        OrderItemRelevanceService::CONDITION_ALIAS_ITEM_DROP_SHIPPING_NOT_ENABLED,
        OrderItemRelevanceService::CONDITION_ALIAS_ITEM_HAS_ASSOCIATED_ARTICLE,
        OrderItemRelevanceService::CONDITION_ALIAS_ITEM_HAS_NO_NEGATIVE_PRICE,
        OrderItemRelevanceService::CONDITION_ALIAS_ITEM_IS_NO_ESD,
        OrderItemRelevanceService::CONDITION_ALIAS_ITEM_NOT_MARKED_AS_IRRELEVANT_FOR_PICKING,
    ];

    /**
     * @param string $conditionAlias
     * @return bool
     */
    protected static function isHardCondition($conditionAlias)
    {
        return (
            in_array($conditionAlias, self::$aliasesOfHardConditions)
            || self::isCustomPickProfileSubCondition($conditionAlias)
        );
    }

    /**
     * @var string[]
     */
    protected static $orderConditionAliases = [
        PickingOrderFilterService::CONDITION_ALIAS_ORDER_HAS_VALID_DISPATCH_METHOD,
        PickingOrderFilterService::CONDITION_ALIAS_ORDER_HAS_VALID_STATUS,
        PickingOrderFilterService::CONDITION_ALIAS_ORDER_PASSES_CUSTOM_FILTER,
    ];

    /**
     * @param string $conditionAlias
     * @return bool
     */
    protected static function isOrderCondition($conditionAlias)
    {
        return (
            in_array($conditionAlias, self::$orderConditionAliases)
            || self::isCustomPickProfileSubCondition($conditionAlias)
        );
    }

    /**
     * @var string[]
     */
    protected static $itemConditionAliases = [
        PickingOrderFilterService::CONDITION_ALIAS_ORDER_ITEM_HAS_SUFFICIENT_STOCK,
        PickingOrderFilterService::CONDITION_ALIAS_ORDER_ITEM_IS_NOT_COMPLETELY_SHIPPED,
        PickingOrderFilterService::CONDITION_ALIAS_ORDER_ITEM_IS_RELEASED,
        OrderItemRelevanceService::CONDITION_ALIAS_ITEM_DROP_SHIPPING_NOT_ENABLED,
        OrderItemRelevanceService::CONDITION_ALIAS_ITEM_HAS_ASSOCIATED_ARTICLE,
        OrderItemRelevanceService::CONDITION_ALIAS_ITEM_HAS_NO_NEGATIVE_PRICE,
        OrderItemRelevanceService::CONDITION_ALIAS_ITEM_IS_NO_ESD,
        OrderItemRelevanceService::CONDITION_ALIAS_ITEM_NOT_MARKED_AS_IRRELEVANT_FOR_PICKING,
    ];

    /**
     * @param string $conditionAlias
     * @return bool
     */
    protected static function isItemCondition($conditionAlias)
    {
        return in_array($conditionAlias, self::$itemConditionAliases);
    }

    /**
     * @param string $conditionAlias
     * @return bool
     */
    public static function isCustomPickProfileSubCondition($conditionAlias)
    {
        return (
            $conditionAlias !== PickingOrderFilterService::CONDITION_ALIAS_ORDER_PASSES_CUSTOM_FILTER
            && mb_strpos($conditionAlias, PickingOrderFilterService::CONDITION_ALIAS_ORDER_PASSES_CUSTOM_FILTER) === 0
        );
    }
}
