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

use Shopware\Models\Order\Order;
use Shopware\Plugins\ViisonCommon\Classes\Document\PaperLayout;
use Shopware\Plugins\ViisonCommon\Classes\Document\RenderingEngine\DomPdfRenderingEngine;
use Shopware\Plugins\ViisonCommon\Classes\Exceptions\FileSystemExceptions\DirectoryNotWritableException;
use Shopware\Plugins\ViisonUPS\Components\UpsHttpLogger;
use Shopware\Plugins\ViisonCommon\Components\FileStorage\FileStorage;
use Shopware\Plugins\ViisonShippingCommon\Classes\ShippingDocument;
use Shopware\Plugins\ViisonShippingCommon\Classes\ShippingLabelCreationResult;
use Shopware\Plugins\ViisonUPS\Classes\Exceptions\UPSCommunicationException;
use Shopware\Plugins\ViisonUPS\Util;

class UPSCommunication
{

    /**
     * Three constants used to define the documents whose URLs can be fetched by this class.
     */
    const DOCUMENT_TYPE_SHIPPING_LABEL = 'label';
    const DOCUMENT_TYPE_RETURN_LABEL = 'returnLabel';

    // The origin size of UPS labels is 4"x7", what is the values below in mm.
    const LABEL_WIDTH_IN_MM = 101.6;
    const LABEL_HEIGHT_IN_MM = 177.8;

    /**
     * The production and test endpoints of the UPS XML API.
     */
    private static $productionEndpoint = 'https://onlinetools.ups.com/ups.app/xml';
    private static $testEndpoint = 'https://wwwcie.ups.com/ups.app/xml';

    private $util;

    public function __construct()
    {
        $this->util = Util::instance();
    }

    /**
     * Creates a new UPS label and, if required, an export document. In debug mode, the sent request as well as the
     * full SOAP response is written to the log file.
     *
     * @param int $orderId The id of the order where to add a label.
     * @param float $shipmentWeight The weight to use as the shipment's weight.
     * @param bool $isReturn A flag indicating whether a return label is to be generated.
     * @param bool $useItemWeights A flag indicating whether to use the weight defined in each item or to calculate the
     *     item weights from the total weight. This parameter is only relevant for shipments containing an export
     *     document (e.g. Weltpaket).
     * @param array $shippingDetails An array containing the receivers address data.
     * @param array|null $packageDimensions An optional array containing the dimensions of the package, for which a new
     *     label shall be created.
     * @param array|null $settings An optional array containing the settings for the new label.
     * @param array|null $extraSettings An optional array containing UPS specific data like the export information / cash on
     *     delivery data when creating a free form label.
     * @return ShippingLabelCreationResult The tracking code of the newly created label.
     * @throws UPSCommunicationException
     * @throws DirectoryNotWritableException
     */
    public function createLabel($orderId, $shipmentWeight, $isReturn, $useItemWeights, $shippingDetails, $packageDimensions = null, $settings = null, $extraSettings = null)
    {
        $this->util->hasValidConfig($orderId);
        $shopId = $this->util->originatingShop($orderId);
        $activeDefaultShopId = Shopware()->Models()->getRepository('Shopware\Models\Shop\Shop')->getActiveDefault()->getId();

        if ($orderId != 0) {
            $order = $this->util->getOrder($orderId);
            $this->util->checkIfLabelCreationIsPossible($order, $orderId);
            /** @var Order $orderFromDb */
            $orderFromDb = Shopware()->Models()->find('Shopware\Models\Order\Order', $orderId);
            $isCustomerContactDataTransferAllowed = $this->isCustomerContactDataTransferAllowed($orderFromDb);
        } else {
            // Initialize a pseudo order when creating a free form label
            $order = array(
                'orderDate' => date('Y-m-d'), // just use today
                'customerNumber' => '',
            );
            $gdprConfiguration = $this->util->config(
                $activeDefaultShopId,
                'gdprMailConfiguration'
            );
            $isCustomerContactDataTransferAllowed = $gdprConfiguration !== ShippingProvider::GDPR_NEVER;
        }

        // Clear email because we are not allowed to transmit it
        if (!$isCustomerContactDataTransferAllowed) {
            // only set email/phone to empty string here if shippingDetails exists and isn't empty.
            // Else it breaks batch label creation and mobile.
            if ($shippingDetails !== null && count($shippingDetails) !== 0) {
                $shippingDetails['email'] = '';
            }
            $order['email'] = '';
        }

        // Set dummy phone depending on the gdprPhoneConfiguration configuration of the adapter
        $sendPhone = $this->util->config(
            $shopId ?: $activeDefaultShopId,
            'gdprPhoneConfiguration'
        );
        if ($sendPhone === ShippingProvider::GDPR_NEVER) {
            if ($shippingDetails !== null && count($shippingDetails) !== 0) {
                $shippingDetails['phone'] = '202-555-0191';
            }
            $order['phone'] = '202-555-0191';
        }

        // Store Cash On Delivery parameters in order if available
        if (!empty($extraSettings) && !empty($extraSettings['amount'])) {
            $order['amount'] = $extraSettings['amount'];
            $order['currency'] = $extraSettings['currency'];
        }

        $shopId = $this->util->originatingShop($orderId);
        $endPointURL = $this->getEndPoint($shopId);

        // Make germany the default sender country if the user has not entered the country in the config yet
        $senderCountryISO = 'DE';
        $senderCountryId = $this->util->config($shopId, 'countryId');
        if (!is_null($senderCountryId)) {
            $senderCountryISO = $this->util->findCountryISO($senderCountryId);
        }

        $sender = array(
            'companyName' => $this->util->config($shopId, 'senderName'),
            'contactPerson' => $this->util->config($shopId, 'contactPerson'),
            'phoneNumber' => $this->util->config($shopId, 'phoneNumber'),
            'email' => $this->util->config($shopId, 'email'),
            'streetName' => $this->util->config($shopId, 'streetName'),
            'streetNumber' => $this->util->config($shopId, 'streetNumber'),
            'city' => $this->util->config($shopId, 'city'),
            'zipCode' => $this->util->config($shopId, 'zipCode'),
            'countryISO' => $senderCountryISO
        );
        $accountNumber = $this->util->config($shopId, 'accountNumber');
        $negotiatedRates = (bool) $this->util->config($shopId, 'negotiatedRates');

        // Determine packaging code
        if (empty($settings) || !array_key_exists('packagingtype', $settings)) {
            $packagingType = $this->util->getDefaultPackagingType($orderId);
        } else {
            $packagingType = $settings['packagingtype'];
        }

        if ($isReturn || is_null($packagingType)) {
            // Always use 'Customer Supplied Package' as the packaging type for return shipments
            // Use it also as a fallback value if no packaging type has been passed and also no default is set
            $packagingCode = '02';
        } else {
            $packagingTypeTable = new \Zend_Db_Table($this->util->getPluginInfo()->getPackagingTypeTableName());
            $packagingCode = Shopware()->Db()->fetchOne(
                $packagingTypeTable->select()
                ->from($packagingTypeTable, array('code'))
                ->where('id = ?', $packagingType)
            );
        }

        // Find UPS product
        if ($isReturn) {
            $product = $this->util->getProductFromId($shopId, $this->util->config($shopId, 'returnProduct'));
        } elseif (!empty($settings) && array_key_exists('product', $settings)) {
            // Use the specific UPS product that has been chosen as part of the settings
            $product = $this->util->getProductFromId($shopId, $settings['product']);
            if (empty($product)) {
                throw UPSCommunicationException::productNotFound($settings['product']);
            }
            $product['exportDocumentRequired'] = $settings['createexportdocument'];
        } else {
            // Find UPS product using the shipment method of the order
            $product = $this->util->getProduct($orderId);
            if (empty($product)) {
                throw UPSCommunicationException::noValidProductFound($orderId);
            }
        }

        if (empty($shippingDetails)) {
            $order['residentialaddress'] = $this->util->getResidentialAddress($orderId);
        }

        $dispatchMethodLevelSettings = $this->util->getDispatchMethodLevelSettings($orderId);

        $gotAdditionalHandlingRequiredParam = !empty($settings) && array_key_exists('additionalhandlingrequired', $settings);
        $additionalHandlingRequired = $gotAdditionalHandlingRequiredParam && $settings['additionalhandlingrequired'] || (!$isReturn && !$gotAdditionalHandlingRequiredParam && $dispatchMethodLevelSettings && $dispatchMethodLevelSettings['additionalHandlingRequired']);

        $request = new ShipConfirmRequest($order, $sender, $accountNumber, $negotiatedRates, $additionalHandlingRequired, $product, $packagingCode, $shipmentWeight, $isReturn, $packageDimensions, $shopId);

        if (!empty($shippingDetails)) {
            if ($isReturn) {
                $request->setShipFrom($shippingDetails);
            } else {
                $shippingDetails['addressline1'] = $shippingDetails['street'] . ' ' . $shippingDetails['streetnumber'];
                $shippingDetails['addressline2'] = $shippingDetails['additionaladdressline'];
                $shippingDetails['addressline3'] = '';
                $request->setReceiver(
                    array_merge(
                        $shippingDetails,
                        array(
                            'customerNumber' => $order['customerNumber'],
                            'orderNumber' => $order['orderNumber'],
                        )
                    )
                );
            }
            $order['email'] = $shippingDetails['email'];
        }

        if ($request->isAccessPointShipment()) {
            if ($orderId == 0) {
                throw UPSCommunicationException::accessPointShipmentRequiresOrder();
            }
            $request->setBillingAddress($this->util->getBillingAddress($orderId));
        }

        $doesCashOnDeliveryLabelAlreadyExist = false;
        if ($orderFromDb) {
            $doesCashOnDeliveryLabelAlreadyExist = $orderFromDb->getAttribute()->getViisonCashOnDeliveryShipmentIdentifier() !== null;
        }

        $gotCashOnDeliveryParam = !$isReturn && !empty($settings) && array_key_exists('cashondelivery', $settings);
        $useCashOnDeliveryService = ($gotCashOnDeliveryParam && $settings['cashondelivery']) || (!$gotCashOnDeliveryParam && !$doesCashOnDeliveryLabelAlreadyExist && $this->util->hasCashOnDeliveryPaymentMeans($orderId));
        if ($useCashOnDeliveryService) {
            // Add 'cash on delivery' to the request
            $request->setCashOnDelivery($order['amount'], $order['currency']);
        }

        // Activate SaturdayDelivery option
        if (!empty($settings) && array_key_exists('issaturdaydelivery', $settings) && $settings['issaturdaydelivery']) {
            $request->activateSaturdayDeliveryOption();
        }

        if (!empty($settings) && array_key_exists('higherinsurance', $settings) && $settings['higherinsurance']) {
            $request->setInsuredValue($settings['insuredvalue'], $settings['insuredvaluecurrency']);
        } elseif (!$isReturn && ((empty($settings) || !array_key_exists('higherinsurance', $settings)))) {
            if ($dispatchMethodLevelSettings && $dispatchMethodLevelSettings['higherInsurance']) {
                $request->setInsuredValue($order['amount'], $order['currency']);
            }
        }

        if (!$isReturn && $this->util->config($shopId, 'sendDispatchNotification') && !empty($order['email'])) {
            $request->setDispatchEmailNotification($order['email'], $this->util->config($shopId, 'dispatchNotificationText'));
        }

        if ($this->util->config($shopId, 'sendDeliveryNotification') && !empty($order['email'])) {
            // Add information for delivery notification
            $request->setDeliveryEmailNotification($order['email']);
        }

        $data = $request->getBody();
        $xmlShipmentConfirm = new \SimpleXMLElement("<?xml version=\"1.0\"?><ShipmentConfirmRequest></ShipmentConfirmRequest>");
        $this->arrayToXML($data, $xmlShipmentConfirm);

        $result = $this->sendAuthenticatedPOST($endPointURL . '/ShipConfirm', $xmlShipmentConfirm->asXML(), $shopId);

        $xmlResult = new \SimpleXMLElement($result);

        if ($xmlResult->Response->ResponseStatusCode != 1) {
            throw UPSCommunicationException::requestFailed($xmlResult);
        }

        $shipmentDigest = $xmlResult->ShipmentDigest[0];

        $xmlShipmentAccept = new \SimpleXMLElement("<?xml version=\"1.0\"?><ShipmentAcceptRequest></ShipmentAcceptRequest>");
        $this->arrayToXML(array(
            'Request' => array(
                'RequestAction' => 'ShipAccept'
            ),
            'ShipmentDigest' => $shipmentDigest
        ), $xmlShipmentAccept);

        $result = $this->sendAuthenticatedPOST($endPointURL . '/ShipAccept', $xmlShipmentAccept->asXML(), $shopId);

        $xmlResult = new \SimpleXMLElement($result);
        $upsLabelImageAsBase64 = $xmlResult->ShipmentResults[0]->PackageResults[0]->LabelImage[0]->GraphicImage[0];

        /** @var FileStorage $fileStorageService */
        $fileStorageService = Shopware()->Container()->get('viison_common.document_file_storage_service');
        // Cast tracking code to a string to avoid side-effects when serializing it e.g. to JSON
        $trackingCode = strval($xmlResult->ShipmentResults[0]->ShipmentIdentificationNumber);
        $identifier = $isReturn ? self::DOCUMENT_TYPE_RETURN_LABEL : self::DOCUMENT_TYPE_SHIPPING_LABEL;
        $documentFileName = $this->util->getDocumentFileName($trackingCode, $identifier);
        $pdfSize = $this->util->config($shopId, 'pdfSize');
        $pdf = UPSLabelImage::convertUpsLabelImageToPdf(base64_decode($upsLabelImageAsBase64), $pdfSize);
        $fileStorageService->writeFileContents($documentFileName, $pdf);

        $shippingUtil = new ShippingUtil(new PluginInfo(), $this->util);
        $trackingCodes = $shippingUtil->saveTrackingCode($orderId, $identifier, $trackingCode, $shippingDetails, $product['productId'], $shipmentWeight, $pdfSize);
        $result = new ShippingLabelCreationResult($trackingCode, $trackingCodes);

        $result->addDocument(new UPSShippingDocument($identifier, $trackingCode, $pdfSize));
        $result->setIsCashOnDeliveryShipment($useCashOnDeliveryService);

        return $result;
    }

    /**
     * This action requests a deletion from UPS of the shipment corresponding to
     * the given tracking code.
     *
     * @param string $trackingCode The UPS tracking code whose corresponding shipment should be deleted.
     * @throws \Exception, if the request fails and UPS errors are not ignored.
     * @return void
     */
    public function deleteLabel($trackingCode)
    {
        // Determine the id of the shop, in which the order associated with the tracking code was made
        $shopId = $this->util->originatingShopOfTrackingCode($trackingCode);

        // Create request object
        $request = new VoidShipmentRequest($trackingCode);

        $data = $request->getBody();
        $xmlVoidShipment = new \SimpleXMLElement("<?xml version=\"1.0\"?><VoidShipmentRequest></VoidShipmentRequest>");
        $this->arrayToXML($data, $xmlVoidShipment);

        $result = $this->sendAuthenticatedPOST($this->getEndPoint($shopId).'/Void', $xmlVoidShipment->asXML(), $shopId);

        $xmlResult = new \SimpleXMLElement($result);

        // Show error message when the request failed. Do not show an error message in test mode because the labels
        // created in the UPS test environment can not be deleted again (we do not want to display an error message
        // in the demo shop every time the user tries to delete a label)
        if ($xmlResult->Response->ResponseStatusCode != 1) {
            throw UPSCommunicationException::requestFailed($xmlResult);
        }
    }

    private function getAccessRequest($shopId)
    {
        // Decrypt password and access key first
        $password = $this->util->config($shopId, 'password');
        $accessKey = $this->util->config($shopId, 'accessKey');

        return array(
            'AccessLicenseNumber' => $accessKey,
            'UserId' => $this->util->config($shopId, 'userId'),
            'Password' => $password
        );
    }

    /**
     * Depending on the devMode, returns the testing or the production API URL.
     *
     * @param int $shopId
     * @return string The XML API endpoint URL to be used
     */
    private function getEndPoint($shopId)
    {
        return $this->util->config($shopId, 'useTestingWebservice') ? self::$testEndpoint : self::$productionEndpoint;
    }

    /**
     * Converts a PHP array to XML in form of a \SimpleXMLElement. This is done analogous to
     * the implementation that is part of the PHP SoapClient. Multiple identical tags within
     * the same parent are supported by using numeric arrays.
     *
     * @param array $arr
     * @param \SimpleXMLElement $xmlElement
     */
    private function arrayToXML($arr, \SimpleXMLElement &$xmlElement)
    {
        foreach ($arr as $key => $value) {
            if (is_array($value)) {
                if (!is_numeric($key)) {
                    $subnode = $xmlElement->addChild("$key");
                    $this->arrayToXMLAcceptNumericArray($value, $xmlElement, $key, $subnode);
                } else {
                    throw new \OutOfBoundsException('Numeric array not supported at this point in the hierarchy');
                }
            } else {
                $xmlElement->addChild("$key", htmlspecialchars("$value"));
            }
        }
    }

    /**
     * Helper function for array_to_xml that is necessary to correctly deal with the
     * creation of multiple identical tags within the same parent via numeric arrays.
     *
     * @param array $arr
     * @param \SimpleXMLElement $xmlElement
     * @throws \Exception
     */
    private function arrayToXMLAcceptNumericArray($arr, \SimpleXMLElement &$parentXmlElement, $parentKey, \SimpleXMLElement &$xmlElement)
    {
        foreach ($arr as $key => $value) {
            if (is_array($value)) {
                if (!is_numeric($key)) {
                    $subnode = $xmlElement->addChild("$key");
                    $this->arrayToXMLAcceptNumericArray($value, $xmlElement, $key, $subnode);
                } else {
                    // Numeric array
                    if ($key == 0) {
                        // The XML Element for the first tag of its kind has already been created, so use that
                        $subnode = $xmlElement;
                    } else {
                        // We need to create another identical tag
                        $subnode = $parentXmlElement->addChild($parentKey);
                    }
                    // Another numeric array inside this numeric array is not supported, therefore we do not call array_to_xml_accept_numeric_array
                    $this->arrayToXML($value, $subnode);
                }
            } else {
                $xmlElement->addChild("$key", htmlspecialchars("$value"));
            }
        }
    }

    /**
     * Prepends an authentication XML to the beginning of the content and
     * afterwards sends an POST request to the given URL
     *
     * @param string $url The URL.
     * @param string $content The data that is to be sent as the body of the POST request.
     * @param int $shopId
     * @return string
     */
    private function sendAuthenticatedPOST($url, $content, $shopId)
    {
        $accessRequest = $this->getAccessRequest($shopId);
        $xmlAccessRequest = new \SimpleXMLElement("<?xml version=\"1.0\"?><AccessRequest></AccessRequest>");
        $this->arrayToXML($accessRequest, $xmlAccessRequest);
        $content = $xmlAccessRequest->asXML().$content;
        return $this->sendPOST($url, $content);
    }

    /**
     * Sends a POST request.
     *
     * @param string $url The URL to which the POST request should be sent.
     * @param string $content The content data that should be transmitted in the request.
     * @return string The server response.
     * @throws UPSCommunicationException
     */
    private function sendPOST($url, $content)
    {
        // use key 'http' even if you send the request to https://...
        $options = array(
            'http' => array(
                'header'  => "Content-type: application/x-www-form-urlencoded\r\n",
                'method'  => 'POST',
                'content' => $content
            ),
        );
        if (ini_get('max_execution_time') > 10) {
            $options['http']['timeout'] = ini_get('max_execution_time') - 5;
        }
        $context  = stream_context_create($options);

        $result = file_get_contents($url, false, $context);

        /** @var UpsHttpLogger $upsHttpLogger */
        $upsHttpLogger = Shopware()->Container()->get('viison_ups.http_logger');
        if ($result === false) {
            $upsHttpLogger->logFailedRequest($url, $content);
            throw UPSCommunicationException::webserviceOffline($url);
        }
        $upsHttpLogger->logSuccessfulRequest($url, $content, $result);

        return $result;
    }

    /**
     * Decides whether the customer data is allowed to be transferred to UPS.
     *
     * @param Order $order
     * @return bool
     */
    private function isCustomerContactDataTransferAllowed(Order $order)
    {
        $orderAttributes = $order->getAttribute();
        $transferCustomerContactDataAllowedInOrder = $orderAttributes
            ? $orderAttributes->getViisonTransferCustomerContactDataAllowed()
            : false;

        $gdprConfiguration = $this->util->config($order->getShop()->getId(), 'gdprMailConfiguration');

        if ($gdprConfiguration === ShippingProvider::GDPR_ALWAYS) {
            return true;
        }

        return ($gdprConfiguration === ShippingProvider::GDPR_CUSTOMER_CHOICE) && $transferCustomerContactDataAllowedInOrder;
    }
}
