<?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.

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\NonUniqueResultException;
use Shopware\Components\Api\Exception as ApiException;
use Shopware\CustomModels\ViisonPickwareCommon\RestApi\RestApiDevice;
use Shopware\CustomModels\ViisonPickwareERP\Warehouse\Warehouse;
use Shopware\Models\Customer\Group;
use Shopware\Models\Document\Document;
use Shopware\Models\Shop\Shop;
use Shopware\Models\Tax\Tax;
use Shopware\Models\User\User;
use Shopware\Plugins\ViisonPickwareCommon\Classes\Exceptions\IncompatibleAppVersionException;
use Shopware\Plugins\ViisonPickwareCommon\Classes\Exceptions\NonUniqueDefaultCustomerGroupKeyException;
use Shopware\Plugins\ViisonPickwareCommon\Classes\Subscribers\PickwareAppConfig;
use Shopware\Plugins\ViisonPickwareCommon\Classes\Util;
use Shopware\Plugins\ViisonPickwareCommon\Structs\CustomDocumentType;
use Shopware\Plugins\ViisonPickwareERP\Components\RestApi\RequestLogging\RestApiRequestLoggerService;
use Shopware\Plugins\ViisonCommon\Components\ParameterValidator;

class Shopware_Controllers_Api_ViisonPickwareCommonPickware extends Shopware_Controllers_Api_Rest
{
    /**
     * The length of a fixed PIN as used by logins of newer app versions.
     */
    const FIXED_APP_PIN_LENGTH = 4;

    /**
     * Responds with the encrypted versions of all installed Pickware plugins as well as the encrypted
     * Shopware version.
     */
    public function statusAction()
    {
        $requestingAppVersion = Util::getRequestingAppVersion();
        if (version_compare($requestingAppVersion, '5.0.0') === -1) {
            throw IncompatibleAppVersionException::appVersionTooLow($requestingAppVersion, '5.0.0');
        }

        // Prepare and encrypt the payload
        $payload = $this->getEnvironment();
        $payload['hasAuthorizationHeader'] = isset($_SERVER['HTTP_AUTHORIZATION']) && !empty($_SERVER['HTTP_AUTHORIZATION']);
        $encryptedPayload = $this->get('viison_pickware_common.encryption')->encryptForApp(json_encode($payload));

        $this->View()->assign([
            'success' => true,
            'data' => $encryptedPayload,
        ]);
    }

    /**
     * Responds a list of all Pickware compatible backend users.
     */
    public function usersAction()
    {
        if (!$this->isPickwareDeviceValid()) {
            return;
        }

        $this->View()->assign([
            'success' => true,
            'data' => $this->getPickwareUsers(),
        ]);
    }

    /**
     * Responds a list of all available subshops.
     */
    public function shopsAction()
    {
        if (!$this->isPickwareDeviceValid()) {
            return;
        }

        $this->View()->assign([
            'success' => true,
            'data' => Util::getSubShopData(),
        ]);
    }

    /**
     * Responds a list of all order document types as well as all documents types collected from plugins.
     */
    public function documentTypesAction()
    {
        if (!$this->isPickwareDeviceValid()) {
            return;
        }

        $this->View()->assign([
            'success' => true,
            'data' => $this->getDocumentTypes(),
        ]);
    }

    /**
     * Tries to find a 'uuid' in the POSTed values and uses it to register a new device having the (optionally) passed
     * 'name'. In case the UUID is already registered, a positive (success is true) response is sent.
     */
    public function registerDeviceAction()
    {
        if ($this->isValidSignedAppRequest()) {
            $this->View()->assign([
                'success' => true,
                'data' => [
                    'deviceConfirmed' => true,
                ],
            ]);

            return;
        }

        // Check the request for the Pickware device UUID
        $uuid = $this->Request()->getParam('uuid');
        if (!$uuid) {
            throw new ApiException\ParameterMissingException('uuid');
        }

        $device = $this->get('viison_pickware_common.device_licensing')->registerDevice(
            $uuid,
            Util::getRequestingAppName(),
            $this->Request()->getParam('name')
        );

        $this->View()->assign([
            'success' => true,
            'data' => [
                'deviceConfirmed' => $device->isConfirmed(),
            ],
        ]);
    }

    /**
     * Tries to find an existing device and updates its UUID.
     */
    public function updateDeviceUuidAction()
    {
        if ($this->isValidSignedAppRequest()) {
            $this->View()->assign([
                'success' => true,
                'data' => [
                    'deviceConfirmed' => true,
                ],
            ]);

            return;
        }

        $oldUuid = $this->Request()->getParam('oldUuid');
        ParameterValidator::assertIsNotNull($oldUuid, 'oldUuid');
        $newUuid = $this->Request()->getParam('newUuid');
        ParameterValidator::assertIsNotNull($newUuid, 'newUuid');

        // Try to find the device using the old UUID and app name
        $appName = Util::getRequestingAppName();
        $device = $this->get('viison_pickware_common.device_licensing')->findPickwareDevice($oldUuid, $appName);
        if (!$device) {
            throw new ApiException\NotFoundException('Device not registered');
        }

        $device->setUUID($newUuid);
        $this->get('models')->flush($device);

        $this->View()->assign([
            'success' => true,
            'data' => [
                'deviceConfirmed' => $device->isConfirmed(),
            ],
        ]);
    }

    /**
     * Uses the 'Pickware-Device-UUID' header field to look up the device and its confirmation status.
     */
    public function deviceStatusAction()
    {
        if ($this->isValidSignedAppRequest()) {
            $this->View()->assign([
                'success' => true,
                'data' => [
                    'deviceConfirmed' => true,
                ],
            ]);

            return;
        }

        // Check the request header for the Pickware device UUID
        $uuid = $this->Request()->getHeader('Pickware-Device-UUID');
        if (!$uuid) {
            throw new ApiException\CustomValidationException('Missing device identifier');
        }

        // Try to find the device with the given UUID and name
        $appName = Util::getRequestingAppName();
        $device = $this->get('viison_pickware_common.device_licensing')->findPickwareDevice($uuid, $appName);
        if (!$device) {
            throw new ApiException\NotFoundException('Device not registered');
        }

        $this->View()->assign([
            'success' => true,
            'data' => [
                'deviceConfirmed' => $device->isConfirmed(),
            ],
        ]);
    }

    /**
     * Checks for HTTP basic auth credentials and uses them to find a user,
     * who is authenticated and whose privileges are checked. If the authentication
     * succeeded, the username, API credential and the Pickware plugin version are returned.
     *
     * GET /pickware/login
     */
    public function loginAction()
    {
        // Throw an exception, if any app version older than 5.0.0 tries to log in, because starting with PickwareCommon
        // v5.0.0 the response format changed, which prevents the app from validating its plugin constraints
        $requestingAppVersion = Util::getRequestingAppVersion();
        if (version_compare($requestingAppVersion, '5.0.0', '<')) {
            throw IncompatibleAppVersionException::appVersionTooLow($requestingAppVersion, '5.0.0');
        }

        if (!$this->isPickwareDeviceValid()) {
            return;
        }

        // Increase the API logger's response depth to make sure the whole config is logged
        $this->Request()->setAttribute(RestApiRequestLoggerService::MAX_RESPONSE_JSON_DEPTH, 10);

        // Check username and PIN
        list($username, $pin) = explode(':', base64_decode(mb_substr($_SERVER['HTTP_AUTHORIZATION'], 6)));
        if (empty($username) || empty($pin)) {
            $this->sendErrorResponse('The api did not receive any authorization.', 401, [
                'hasAuthorizationHeader' => false,
            ]);

            return;
        }

        // Load the user
        $user = $this->get('models')->getRepository(User::class)->findOneBy([
            'username' => $username,
        ]);
        if ($user === null || $user->getAttribute() === null) {
            $this->sendErrorResponse('Invalid or missing auth.', 401);

            return;
        }

        // Select the PIN has based on the length of the entered PIN
        $fixedLengthPinHash = $user->getAttribute()->getPickwareFixedLengthAppPin();
        $useFixedLengthPinHash = mb_strlen($pin) === self::FIXED_APP_PIN_LENGTH && $fixedLengthPinHash !== null;
        $hashedPin = ($useFixedLengthPinHash) ? $fixedLengthPinHash : $user->getAttribute()->getViisonPickwarePin();
        if ($hashedPin === null) {
            $this->sendErrorResponse('Invalid or missing auth.', 401);

            return;
        }

        // Get the used PIN encoder and use it validate the PIN
        $encoderName = $user->getAttribute()->getViisonPickwarePinEncoder();
        $isValid = $this->get('PasswordEncoder')->isPasswordValid($pin, $hashedPin, $encoderName);
        if (!$isValid) {
            $this->sendErrorResponse('Invalid or missing auth.', 401);

            return;
        }

        if (!$useFixedLengthPinHash) {
            // Save the hash of the first 4 characters/digits of the PIN in the user attributes
            $fixedLengthPin = mb_substr($pin, 0, self::FIXED_APP_PIN_LENGTH);
            $encodedFixedLengthPin = $this->get('PasswordEncoder')->encodePassword($fixedLengthPin, $encoderName);
            $user->getAttribute()->setPickwareFixedLengthAppPin($encodedFixedLengthPin);
            $this->get('models')->flush($user->getAttribute());
        }

        // Check that the user has API access
        $apiKey = $user->getApiKey();
        if (empty($apiKey)) {
            $this->sendErrorResponse('API access not granted.', 403);

            return;
        }

        // Check if the user has all required privileges
        $appName = Util::getRequestingAppName();
        $requiredPrivileges = $this->get('events')->collect(
            PickwareAppConfig::EVENT_COLLECT_REQUIRED_ACL_PRIVILEGES,
            new ArrayCollection(),
            [
                'appName' => $appName,
            ]
        );
        foreach ($requiredPrivileges as $resources) {
            foreach ($resources as $resource => $privileges) {
                if (!$this->get('acl')->has($resource) || $this->get('acl')->isAllowed($user->getRole(), $resource)) {
                    continue;
                }
                foreach ($privileges as $privilegeName) {
                    $privilegeExists = $this->get('acl')->get($resource)->getPrivileges()->exists(function ($index, $privilege) use ($privilegeName) {
                        return $privilege->getName() === $privilegeName;
                    });
                    if ($privilegeExists && !$this->get('acl')->isAllowed($user->getRole(), $resource, $privilegeName)) {
                        // Missing access rights
                        $this->sendErrorResponse('Insufficient rights on resource "' . $resource . '".', 403);

                        return;
                    }
                }
            }
        }

        // Update the last login timestamp of the device
        $uuid = $this->Request()->getHeader('Pickware-Device-UUID');
        $device = $this->get('viison_pickware_common.device_licensing')->findPickwareDevice($uuid, $appName);
        // Make sure that the device exists, because in Pickware demo mode any device,
        // even one that is not known in the database, is allowed to login in!
        if ($device !== null) {
            $device->setLastLogin(new \DateTime());
            $this->get('models')->flush($device);
        }

        // Return username, API key, version of this plugin and all app configurations
        $this->View()->assign([
            'success' => true,
            'data' => [
                'username' => $username,
                'apiKey' => $apiKey,
                'environment' => $this->getEnvironment(),
                'config' => $this->getAppConfig($user),
            ],
        ]);
    }

    /**
     * @return array
     */
    private function getPickwareUsers()
    {
        // Get the IDs and names of all backend users, who are active and have a Pickware PIN set
        $users = $this->get('db')->fetchAll(
            'SELECT a.id, a.username, a.name
            FROM s_core_auth a
            LEFT JOIN s_core_auth_attributes aa
                ON a.id = aa.authID
            WHERE a.active = 1
            AND aa.viison_pickware_pin IS NOT NULL'
        );

        // Convert the IDs to integers
        $users = array_map(function ($user) {
            $user['id'] = intval($user['id']);

            return $user;
        }, $users);

        return $users;
    }

    /**
     * @return array
     */
    private function getDocumentTypes()
    {
        // Get all order document types
        $orderDocumentTypes = $this->get('models')->getRepository(Document::class)->findAll();
        $documentTypes = array_map(function ($type) {
            return CustomDocumentType::createFromOrderDocumentType($type)->toArray();
        }, $orderDocumentTypes);

        // Get all custom document types
        $customDocumentTypes = $this->get('events')->collect(
            PickwareAppConfig::EVENT_COLLECT_CUSTOM_DOCUMENT_TYPES,
            new ArrayCollection(),
            [
                'appName' => Util::getRequestingAppName(),
            ]
        );
        foreach ($customDocumentTypes as $customType) {
            if ($customType instanceof CustomDocumentType) {
                $documentTypes[] = $customType->toArray();
            }
        }

        return $documentTypes;
    }

    /**
     * Creates the config for the requesting app, consisting of a base configuration
     * as well as configuration collected from the installed Pickware plugins, by firing
     * the respective collect event.
     *
     * @param User $user
     * @return array
     */
    private function getAppConfig(User $user)
    {
        $config = [];
        // Create general config
        $defaultShop = $this->get('models')->getRepository(Shop::class)->getDefault();
        $config['defaultCurrencyCode'] = $defaultShop->getCurrency()->getCurrency();
        $config['documentTypeIdInvoice'] = 1;
        $config['documentTypeIdDeliveryNote'] = 2;

        // Add the default customer group configuration
        $defaultShopCustomerGroupKey = $defaultShop->getCustomerGroup()->getKey();
        /** @var AbstractQuery $customerGroupQuery */
        $customerGroupQuery = $this->get('models')->createQueryBuilder()
            ->select(
                'customerGroup',
                'customerGroupDiscounts'
            )
            ->from(Group::class, 'customerGroup')
            ->leftJoin('customerGroup.discounts', 'customerGroupDiscounts')
            ->where('customerGroup.key = :groupKey')
            ->setParameter('groupKey', $defaultShopCustomerGroupKey)
            ->getQuery();
        try {
            $config['defaultCustomerGroup'] = $customerGroupQuery->getOneOrNullResult(AbstractQuery::HYDRATE_ARRAY);
        } catch (NonUniqueResultException $exception) {
            throw NonUniqueDefaultCustomerGroupKeyException::defaultCustomerGroupKeyNotUnique(
                $defaultShopCustomerGroupKey
            );
        }

        // Fetch all available tax rates
        $taxRates = $this->get('models')->getRepository(Tax::class)->findAll();
        $taxRates = array_map(function ($taxRate) {
            return [
                'id' => $taxRate->getId(),
                'tax' => floatval($taxRate->getTax()),
                'name' => $taxRate->getName(),
            ];
        }, $taxRates);
        $config['taxRates'] = $taxRates;

        // Fetch all available warehouses
        $warehouses = $this->get('models')->getRepository(Warehouse::class)->findAll();
        $config['warehouses'] = array_map(function ($warehouse) {
            $warehouseData = [
                'id' => $warehouse->getId(),
                'code' => $warehouse->getCode(),
                'name' => $warehouse->getName(),
                'stockAvailableForSale' => $warehouse->isStockAvailableForSale(),
                'defaultWarehouse' => $warehouse->isDefaultWarehouse(),
                'nullBinLocationId' => $warehouse->getNullBinLocation()->getId(),
            ];
            if (!empty($warehouse->getBinLocationFormatComponents())) {
                // Add an example bin location code
                try {
                    $binLocationCodeComponent = $this->get('pickware.erp.bin_location_code_generator_service')->createLinkedCodeComponent(
                        $warehouse->getBinLocationFormatComponents()
                    );
                    $warehouseData['exampleBinLocationCode'] = array_shift($binLocationCodeComponent->createCodes(1));
                } catch (BinLocationCodeGeneratorException $exception) {
                    $warehouseData['exampleBinLocationCode'] = null;
                }
            }

            return $warehouseData;
        }, $warehouses);

        // Add the configured device name
        $appName = Util::getRequestingAppName();
        $uuid = $this->Request()->getHeader('Pickware-Device-UUID');
        $device = $this->get('viison_pickware_common.device_licensing')->findPickwareDevice($uuid, $appName);
        if ($device) {
            $config['deviceName'] = $device->getName();
        }

        $config[PickwareAppConfig::APP_ACL_CONFIG_KEY] = [];

        // Collect app configuration from all Pickware plugins
        $config = $this->get('events')->filter(
            PickwareAppConfig::EVENT_COLLECT_PICKWARE_APP_CONFIG,
            $config,
            [
                'appName' => $appName,
                'user' => $user,
            ]
        );

        return $config;
    }

    private function getEnvironment()
    {
        return [
            'databaseVersion' => $this->getDatabaseVersion(),
            'phpVersion' => phpversion(),
            'pickwarePluginVersions' => Util::getPickwarePluginVersions(),
            'shopHost' => $this->get('models')->getRepository(Shop::class)->getActiveDefault()->getHost(),
            'shopwareVersion' => $this->get('config')->version,
        ];
    }

    private function getDatabaseVersion()
    {
        try {
            return $this->get('db')->query('SELECT version()')->fetchColumn();
        } catch (Exception $e) {
            return null;
        }
    }

    /**
     * Tries to get the 'Pickware-Device-UUID' from the request header
     * and uses it to find a confirmed device. In case one of the checks
     * fails, an error response is sent.
     *
     * @return boolean
     */
    protected function isPickwareDeviceValid()
    {
        if ($this->isValidSignedAppRequest()) {
            return true;
        }

        // Check the request header for the Pickware device UUID
        $uuid = $this->Request()->getHeader('Pickware-Device-UUID');
        if (!$uuid) {
            $this->sendErrorResponse('Missing device identifier', 400, [
                'deviceError' => true,
            ]);

            return false;
        }

        $appName = Util::getRequestingAppName();
        $isDeviceValid = $this->get('viison_pickware_common.device_licensing')->isPickwareDeviceValid($uuid, $appName);
        if ($isDeviceValid) {
            return true;
        }

        // Check whether we can speed up the setup of a new device, by infering its identity from a registration for
        // another app
        $repository = $this->get('models')->getRepository(RestApiDevice::class);
        $device = $repository->findOneBy([
            'uuid' => $uuid,
            'appName' => $appName,
        ]);
        if (!$device) {
            // Try to find a device registration for another app
            $device = $repository->findOneBy([
                'uuid' => $uuid,
            ]);
            if ($device) {
                // Add the unconfirmed device/app combination to the list
                $this->get('viison_pickware_common.device_licensing')->registerDevice(
                    $uuid,
                    $appName,
                    $device->getName()
                );
            }

            $this->sendErrorResponse('Device not registered', 403, [
                'deviceError' => true,
            ]);

            return false;
        }

        $this->sendErrorResponse('Device registered but not confirmed', 403, [
            'deviceError' => true,
        ]);

        return false;
    }

    /**
     * @return boolean True, if the request contains a 'Pickware-App-Signature' header and its value is a valid app
     *         signature.
     * @throws Exception If a 'Pickware-App-Signature' header exists, but the contained signature cannot be validated or
     *         is invalid.
     */
    private function isValidSignedAppRequest()
    {
        $appSignature = $this->Request()->getHeader('Pickware-App-Signature');
        if (!$appSignature) {
            return false;
        }

        $isValidSignature = $this->get('viison_pickware_common.encryption')->isAppSignatureValid(
            $appSignature,
            $this->getEnvironment()['shopHost']
        );
        if (!$isValidSignature) {
            throw new \Exception(sprintf(
                'The provided app signature is not valid for configured shop host "%1$s". Please make sure to use that exact host when connecting to the shop.',
                $this->getEnvironment()['shopHost']
            ));
        }

        return true;
    }

    /**
     * Sends a JSON response containing 'false' for the 'success' flag and
     * the passed message. Additionally the response code is set to the given
     * code.
     *
     * @param string $message
     * @param int $code
     * @param array $customData (optional)
     */
    private function sendErrorResponse($message, $code, $customData = [])
    {
        $data = array_merge([
            'success' => false,
            'message' => $message,
        ], $customData);
        $this->View()->assign($data);
        $this->Response()->setHttpResponseCode($code);
    }
}
