<?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\ViisonCommon\Classes\Subscribers;

use Doctrine\ORM\EntityManager;
use Enlight_Event_EventArgs;
use Enlight_Hook_HookArgs;
use Shopware\Models\Order\Order;

/**
 * Subscriber to listen to changes in order status and order payment status.
 *
 * The subscriber listens to true changes in order status and order payment status - this means that the value of the
 * respective status status field must have actually changed. It is possible to configure that at least
 * one of both or both status fields must be in one of a list of allowed states. Alternatively, the subscriber can be
 * configured to only care about one of the status fields and ignore the others.
 *
 * See the documentation of the constructor for how to configure the conditions that define when an order is matched.
 */
// phpcs:ignore VIISON.Classes.AbstractClassName
abstract class OrderStatusSubscriber extends AbstractBaseSubscriber
{
    /** order is matched only if both the payment state and the order state match */
    const PAYMENT_AND_ORDER_STATE_OPERATOR_AND = 'and';

    /** order is matched if at least one of the payment state and the order state match */
    const PAYMENT_AND_ORDER_STATE_OPERATOR_OR = 'or';

    /** @var array|null a list of order states that should be matched */
    protected $matchedOrderStates;

    /** @var array|null a list of order payment states that should be matched */
    protected $matchedPaymentStates;

    /** @var string whether both or any of order status and order payment status must match */
    protected $operator;

    /** @var bool false iff order payment status is irrelevant for determining whether the order is matched */
    protected $paymentStatusRelevant;

    /** @var bool false iff order status is irrelevant for determining whether the order is matched */
    protected $orderStatusRelevant;

    /** @var int remembers the order status before making changes */
    protected $previousOrderStatus = null;

    /** @var  int remembers the order payment status before making changes */
    protected $previousPaymentStatus = null;

    /**
     * Create a new OrderStatusSubscriber.
     *
     * @param \Shopware_Components_Plugin_Bootstrap $pluginBootstrap the plugin bootstrap
     * @param array|null $matchedOrderStates the order states which should be matched, or null if this subscriber should
     *                                       only match changes of the payment status
     * @param array|null $matchedPaymentStates the payment states which should be matched, or null if this subscriber should
     *                                         only match changes of the order status
     * @param string $operator a constant that defines whether both payment status and order status should match one of
     *                         the defined states or if one of them is sufficient for a match ('and' and 'or' semantics,
     *                         respectively)
     */
    public function __construct(
        \Shopware_Components_Plugin_Bootstrap $pluginBootstrap,
        $matchedOrderStates,
        $matchedPaymentStates,
        $operator = self::PAYMENT_AND_ORDER_STATE_OPERATOR_AND
    ) {
        parent::__construct($pluginBootstrap);
        $this->matchedOrderStates = $matchedOrderStates;
        $this->orderStatusRelevant = $matchedOrderStates !== null;
        $this->matchedPaymentStates = $matchedPaymentStates;
        $this->paymentStatusRelevant = $matchedPaymentStates !== null;
        if (!in_array($operator, [self::PAYMENT_AND_ORDER_STATE_OPERATOR_AND, self::PAYMENT_AND_ORDER_STATE_OPERATOR_OR])) {
            throw new \InvalidArgumentException('$operator must be one of \'' . self::PAYMENT_AND_ORDER_STATE_OPERATOR_AND . '\' and \'' . self::PAYMENT_AND_ORDER_STATE_OPERATOR_OR .'\'');
        }
        $this->operator = $operator;
    }

    /**
     * @inheritdoc
     */
    public static function getSubscribedEvents()
    {
        return [
            // For legacy compatibility
            'sOrder::setOrderStatus::before' => 'onBeforeSOrderSetStatus',
            'sOrder::setOrderStatus::after' => 'onAfterSOrderSetStatus',
            // Triggered by payment plugins calling sOrder::savePaymentStatus
            'sOrder::setPaymentStatus::before' => 'onBeforeSOrderSetStatus',
            'sOrder::setPaymentStatus::after' => 'onAfterSOrderSetStatus',
            // When saving the order:
            'sOrder::sSaveOrder::after' => 'onAfterSOrderSSaveOrder',
            // Some plugins may update the order status through doctrine (which is actually the best way to do it)
            'Shopware\\Models\\Order\\Order::prePersist' => 'onBeforeSaveOrderEntity',
            'Shopware\\Models\\Order\\Order::preUpdate' => 'onBeforeSaveOrderEntity',
            'Shopware\\Models\\Order\\Order::postPersist' => 'onAfterSaveOrderEntity',
            'Shopware\\Models\\Order\\Order::postUpdate' => 'onAfterSaveOrderEntity',
        ];
    }

    /**
     * This method is called when the order status or the payment status has actually changed (i.e. updates that did not
     * result in the value of one of both statuses changing do not qualify) and the combination of states matches the
     * rules defined by the arguments that were passed to the constructor.
     *
     * @param Order $order the order that has reached the specified order state
     * @return void
     */
    abstract public function onStatusMatched($order);

    /**
     * Called before order status or payment status is updated through sOrder.
     *
     * @param Enlight_Hook_HookArgs $args
     */
    public function onBeforeSOrderSetStatus(Enlight_Hook_HookArgs $args)
    {
        $orderId = $args->get('orderId');
        $order = $this->getOrder($orderId);
        $this->savePreviousOrderStatuses($order);
    }

    /**
     * Called after order status or paument status was updated through sOrder.
     *
     * @param Enlight_Hook_HookArgs $args
     */
    public function onAfterSOrderSetStatus(Enlight_Hook_HookArgs $args)
    {
        $orderId = $args->get('orderId');
        $order = $this->getOrder($orderId);
        $this->compareWithPreviousOrderStates($order);
    }

    /**
     * Called after the order has been written to the database completely
     *
     * @param Enlight_Hook_HookArgs $args
     */
    public function onAfterSOrderSSaveOrder(Enlight_Hook_HookArgs $args)
    {
        $orderNumber = $args->getReturn();
        $order = $this->get('models')->getRepository('Shopware\\Models\\Order\\Order')->findOneByNumber($orderNumber);
        $this->compareWithPreviousOrderStates($order);
    }

    /**
     * Called before an order is persist()ed or update()d through Doctrine.
     *
     * @param Enlight_Event_EventArgs $args
     */
    public function onBeforeSaveOrderEntity(Enlight_Event_EventArgs $args)
    {
        /** @var \Shopware\Models\Order\Order $order */
        $order = $args->get('entity');

        // Shopware unhelpfully swallows the original doctrine event, so we need to get the changeset manually :/
        /** @var EntityManager $entityManager */
        $entityManager = $args->get('entityManager');
        $changes = $entityManager->getUnitOfWork()->getEntityChangeSet($order);

        // If there was a change, set the "previous" value to null so $this->compareWithPreviousOrderStates() will
        // detect a change, no matter what the value is set to. Otherwise, set the previous value = the current value of
        // the field so $this->compareWithPreviousOrderStates() correctly detects no change.
        $this->previousOrderStatus = array_key_exists('orderStatus', $changes) ? null : $order->getOrderStatus()->getId();
        $this->previousPaymentStatus = array_key_exists('paymentStatus', $changes) ? null : $order->getPaymentStatus()->getId();
    }

    /**
     * Called after an order was persist()ed or update()d through Doctrine.
     *
     * @param Enlight_Event_EventArgs $args
     */
    public function onAfterSaveOrderEntity(Enlight_Event_EventArgs $args)
    {
        /** @var \Shopware\Models\Order\Order $order */
        $order = $args->get('entity');
        $this->compareWithPreviousOrderStates($order);
    }

    /**
     * Fetch an order.
     *
     * @param $orderId|null the order to fetch
     * @return null|Order the order, or null if it the order does not exist
     */
    private function getOrder($orderId)
    {
        if (!$orderId) {
            return null;
        }
        /** @var Order $order */
        $order = $this->get('models')->find('Shopware\\Models\\Order\\Order', $orderId);
        if (!$order) {
            return null;
        }
        // always make sure we have the newest data from the db
        $this->get('models')->refresh($order);

        return $order;
    }

    /**
     * Remember the values of the status fields before something happens so we can compare later to check whether the
     * value actually changed.
     *
     * @param Order $order
     */
    private function savePreviousOrderStatuses(Order $order = null)
    {
        if ($order) {
            $this->previousOrderStatus = $order->getOrderStatus()->getId();
            $this->previousPaymentStatus = $order->getPaymentStatus()->getId();
        } else {
            $this->previousOrderStatus = null;
            $this->previousPaymentStatus = null;
        }
    }

    /**
     * Check if order status or payment status actually changed and call checkIfStateMatches() with that information.
     *
     * @param Order $order
     */
    private function compareWithPreviousOrderStates(Order $order = null)
    {
        if (!$order) {
            return;
        }
        $newOrderStatus = $order->getOrderStatus()->getId();
        $newPaymentStatus = $order->getPaymentStatus()->getId();
        $orderStatusChanged = $newOrderStatus !== $this->previousOrderStatus;
        $paymentStatusChanged = $newPaymentStatus !== $this->previousPaymentStatus;
        $this->checkIfStateMatches($order, $orderStatusChanged, $paymentStatusChanged);
    }

    /**
     * Check if the state of the order matches the rules for this subscriber.
     */
    protected function checkIfStateMatches($order, $orderStatusChanged, $paymentStatusChanged)
    {
        // Make sure the status which has changed is actually relevant. For example, if this subscriber is configured
        // to only match payment status events, changing the order status should not trigger checking if the payment
        // status is matched.
        if (!($orderStatusChanged && $this->orderStatusRelevant) && !($paymentStatusChanged && $this->paymentStatusRelevant)) {
            return;
        }

        $orderStatusMatches = !$this->orderStatusRelevant ||  in_array($order->getOrderStatus()->getId(), $this->matchedOrderStates);
        $paymentStatusMatches = !$this->paymentStatusRelevant || in_array($order->getPaymentStatus()->getId(), $this->matchedPaymentStates);

        $orderMatches = false;
        if ($this->operator === self::PAYMENT_AND_ORDER_STATE_OPERATOR_OR) {
            $orderMatches = ($this->orderStatusRelevant && $orderStatusMatches) || ($this->paymentStatusRelevant && $paymentStatusMatches);
        } elseif ($this->operator === self::PAYMENT_AND_ORDER_STATE_OPERATOR_AND) {
            $orderMatches = (!$this->orderStatusRelevant || $orderStatusMatches) && (!$this->paymentStatusRelevant || $paymentStatusMatches);
        }

        if ($orderMatches) {
            $this->onStatusMatched($order);
        }
    }

    /**
     * Split an comma separated string and trim the individual components. This is useful for parsing matched states
     * out of config values.
     *
     * @param string $input a comma separated list of order or payment states represented as integers
     * @return int[] array
     */
    public static function parseCsvNumbers($input)
    {
        if (trim($input) === '') {
            return [];
        }

        return array_map(function ($stateString) {
            return (int) trim($stateString);
        }, explode(',', $input));
    }
}
