# Source: connectors/hybris.js

    /** @module */

    import * as hybris from 'hybris-occ-client'
    import * as errors from '../errors'
    import superagent from 'superagent'
    import URL from 'url-parse'

    const clone = (x) => JSON.parse(JSON.stringify(x))

    /**
     * A connector for the Hybris API.
     *
     * @implements {module:connectors/interfaces.CommerceConnector}
     * @implements {module:connectors/interfaces.ParserHooks}
     */
    export class HybrisConnector {
        constructor({client, catalogId, catalogVersionId, authentication}) {
            this.client = client
            this.catalogId = catalogId
            this.catalogVersionId = catalogVersionId
            this.authentication = authentication
        }

        static fromConfig(config) {
            return new this.prototype.constructor({
                client: new hybris.ApiClient(config.clientConfig),
                catalogId: config.catalogId,
                catalogVersionId: config.catalogVersionId,
                authentication: config.authentication
            })
        }

        absoluteURL(path) {
            return new URL(path, this.client.basePath || '').href
        }

        /**
         * Register a new customer account and login.
         *
         * @param {module:types/HybrisCustomerRegistration} data
         * @return {Promise<module:types/Customer>}
         */
        registerCustomer(data) {
            const api = new hybris.UsersApi(this.client)
            return api
                .postUsers({
                    firstName: data.firstName,
                    lastName: data.lastName,
                    password: data.password,
                    titleCode: data.titleCode,
                    uid: data.email // Must be an email
                })
                .then(() => this.login(data.email, data.password))
        }

        /**
         * @inheritDoc
         */
        login(username, password) {
            const isRegisteredUser = username && password
            const auth = this.authentication
            const body = {
                client_id: auth.clientId,
                client_secret: auth.clientSecret,
                grant_type: 'client_credentials'
            }
            if (isRegisteredUser) {
                Object.assign(body, {
                    grant_type: 'password',
                    username,
                    password
                })
            }

            return superagent
                .post(auth.authorizationUrl)
                .set('Content-Type', 'application/x-www-form-urlencoded')
                .send(body)
                .then((res) => {
                    const updatedAuth = {
                        accessToken: res.body.access_token,
                        expiresIn: res.body.expires_in,
                        refreshToken: res.body.refresh_token
                    }
                    Object.assign(this.client.authentications.auth, updatedAuth)
                    return isRegisteredUser
                        ? this.getCustomer('current')
                        : this.getCustomer('anonymous')
                })
                .catch(() => {
                    throw new errors.ForbiddenError('Login failed')
                })
        }

        /**
         * @inheritDoc
         */
        logout() {
            const invalidatedAuth = {
                accessToken: undefined,
                expiresIn: undefined,
                refreshToken: undefined
            }
            Object.assign(this.client.authentications.auth, invalidatedAuth)
            return Promise.resolve(undefined)
        }

        /**
         * @inheritDoc
         */
        refreshSession() {
            const refreshToken = this.client.authentications.auth.refreshToken
            const grantType = refreshToken ? 'refresh_token' : 'client_credentials'

            const auth = this.authentication
            const body = {
                client_id: auth.clientId,
                client_secret: auth.clientSecret,
                grant_type: grantType,
                refresh_token: refreshToken
            }

            return superagent
                .post(auth.authorizationUrl)
                .set('Content-Type', 'application/x-www-form-urlencoded')
                .send(body)
                .then((res) => {
                    const newAuth = {
                        accessToken: res.body.access_token,
                        expiresIn: res.body.expires_in,
                        refreshToken: res.body.refresh_token
                    }
                    Object.assign(this.client.authentications.auth, newAuth)
                    return refreshToken ? this.getCustomer('current') : this.getCustomer('anonymous')
                })
                .catch(() => {
                    throw new errors.ForbiddenError('Failed To Refresh Session')
                })
        }

        /**
         * @inheritDoc
         */
        getDefaultHeaders() {
            return Object.assign({}, this.client.defaultHeaders)
        }

        /**
         * @inheritDoc
         */
        setDefaultHeaders(headers) {
            this.client.defaultHeaders = Object.assign({}, headers)
        }

        /**
         * Get current authentications object, which can be used to
         * save/restore auth state, eg. for server-side usage.
         *
         * @return {Object}
         */
        getAuthentications() {
            return clone(this.client.authentications)
        }

        /**
         * Set the current authentications object, as returned from getAuthentications.
         * Use to save/restore auth state, eg. for server-side usage.
         *
         * @param {Object} authentications
         */
        setAuthentications(authentications) {
            this.client.authentications = clone(authentications)
        }

        /**
         * @inheritDoc
         */
        getCustomer(id, opts = {}) {
            const api = new hybris.UsersApi(this.client)
            return api.getUser(id, {fields: 'FULL', ...opts}).then((res) => this.parseCustomer(res))
        }

        /**
         * @inheritDoc
         */
        parseCustomer(data) {
            // Hybris validates that uid is an email for registered users.
            const d = data
            const isAnon = d.uid === 'anonymous'
            const anonEmail = d.defaultAddress ? d.defaultAddress.email : undefined
            return {
                id: isAnon ? 'anonymous' : 'current',
                firstName: d.firstName,
                lastName: d.lastName,
                email: isAnon ? anonEmail : d.uid
            }
        }

        /**
         * @inheritDoc
         */
        searchProducts(query, opts = {}) {
            const searchRequest = this.transformSearchParams(query)
            const api = new hybris.ProductsApi(this.client)

            return api
                .getProductSearch(searchRequest, opts)
                .then((data) => this.parseSearchProducts(data, query))
        }

        transformSearchParams(params) {
            const {query, count = 20, start = 0, sort = 'relevance', filters = {}} = params

            // Please refer to Hybris documentation for notes on the shape of the request object.
            // https://help.hybris.com/6.4.0/api/ycommercewebservices/main.html#_products_search_get
            return {
                query: []
                    .concat(
                        ...[
                            query ? [`${query}:`] : [],
                            ['', sort],
                            Object.keys(filters)
                                .filter((key) => !['categoryId', 'query'].includes(key))
                                .filter((key) => Boolean(filters[key]))
                                .map((key) => `${key}:${filters[key]}`),
                            filters.categoryId ? [`allCategories:${filters.categoryId}`] : []
                        ]
                    )
                    .join(':'),
                pageSize: count,
                currentPage: start / count,
                sort,
                fields: 'FULL'
            }
        }

        parseFilterValue({count, name, query}) {
            // Parse query string to get filter value.
            const value = query.query.value.split(':').slice(-1)[0]

            return {
                count,
                label: name,
                value
            }
        }

        parseFilter({name, values}) {
            // Get the filter identifier from the first values query string. There should
            // always be a value or else the filter wouldn't be listed, but to be sure
            // return undefiend if there isn't one.
            let filterId
            const firstValue = values[0]

            if (firstValue) {
                // Parse query string to get filter identifier.
                filterId = firstValue.query.query.value.split(':').slice(-2)[0]
            }

            return {
                id: filterId,
                label: name,
                values: values.map(this.parseFilterValue.bind(this))
            }
        }

        parseSearchProducts(data) {
            const {
                products = [],
                sorts = [],
                pagination,
                freeTextSearch,
                facets = [],
                breadcrumbs = []
            } = data

            const getFilterCrumbs = () => {
                const selectedFilterVals = [].concat(
                    ...facets.map((filter) => filter.values.filter((f) => f.selected))
                )

                const selectedBreadcrumbs = [].concat(
                    ...selectedFilterVals.map((fv) =>
                        breadcrumbs.filter((bc) => bc.facetValueName === fv.name)
                    )
                )

                const allCatsCrumb = breadcrumbs.find((bc) => bc.facetCode === 'allCategories')

                allCatsCrumb && selectedBreadcrumbs.push(allCatsCrumb)
                return selectedBreadcrumbs
            }

            return {
                results: products.map(({stock = {}, images = [], ...product}) => ({
                    available: stock.stockLevelStatus === 'inStock',
                    productId: product.code,
                    productName: product.name,
                    link: product.url,
                    defaultImage: images[0] && {
                        alt: product.name,
                        src: this.absoluteURL(images[0].url)
                    },
                    price: product.price.value
                })),
                query: freeTextSearch,
                count: pagination.pageSize,
                start: pagination.currentPage * pagination.pageSize,
                total: pagination.totalResults,
                filters: facets.map(this.parseFilter.bind(this)),
                selectedFilters: getFilterCrumbs().reduce((obj, curr) => {
                    obj[curr.facetCode] = curr.facetValueCode
                    return obj
                }, {}),
                sortingOptions: sorts.map(({code, name}) => ({
                    id: code,
                    label: name
                })),
                selectedSortingOption: pagination.sort
            }
        }

        /**
         * @inheritDoc
         */
        getCategory(id, options = {}) {
            const api = new hybris.CatalogsApi(this.client)
            return api
                .getCategory(this.catalogId, this.catalogVersionId, id, options)
                .then((data) => this.parseCategory(data))
        }

        /**
         * @inheritDoc
         */
        getCategories(ids, options = {}) {
            const getCategoryPromises = ids.map((id) => this.getCategory(id, options).catch(() => null))

            return Promise.all(getCategoryPromises).then((categories) => {
                const validCategories = categories.filter((category) => !!category)

                return {
                    data: validCategories,
                    count: Object.keys(categories).length,
                    total: ids.length
                }
            })
        }

        /**
         * Takes an array of OCAPI Category objects and parses it into an
         * commerce-integrations Category type.
         * @param {Object[]} categories an array of OCAPI {@link https://mobify.github.io/hybris-occ-client/module-models_CategoryHierarchy.html|Category} document
         *
         * @returns {Promise<module:types.Category>}
         */
        /* eslint-disable camelcase */
        parseCategories(categories) {
            const parsedCategories = []

            categories.forEach((category) => parsedCategories.push(this.parseCategory(category)))

            return parsedCategories
        }

        /**
         * Takes an OCAPI Category objects and parses it into an
         * commerce-integrations Category type.
         * @param {Object[]} categories an OCAPI {@link https://mobify.github.io/hybris-occ-client/module-models_CategoryHierarchy.html|Category} document
         *
         * @returns {Promise<module:types.Category>}
         */
        parseCategory({id, name, subcategories}) {
            const category = {
                id,
                name
            }

            if (subcategories) {
                category.categories = this.parseCategories(subcategories)
            }

            return category
        }

        /**
         * @inheritDoc
         */
        getProduct(id, opts = {}) {
            let options = {}

            const api = new hybris.ProductsApi(this.client)
            const defaultOptions = {
                fields: 'FULL'
            }

            // Assign options to new object. This allows the user to override
            // the defaults we set.
            options = {
                ...defaultOptions,
                ...opts
            }

            // Get the base product by creating a promise chain recursively.
            const productCache = []
            const getBaseProduct = (id, options) =>
                api.getProduct(id, options).then((product) => {
                    const {baseProduct} = product

                    // Maintain a cache of all products requested in this command.
                    // It will be used lated to de-dup any fetches.
                    productCache.push(product)

                    return baseProduct ? getBaseProduct(baseProduct, options) : product
                })

            return getBaseProduct(id, options)
                .then((baseProduct) => {
                    // Overwrite the base product id with the id we initiality requested so it's parsed
                    // out correctly later on.
                    baseProduct.code = id

                    return Promise.all(
                        (baseProduct.variantOptions || []).map((variantOption) => {
                            // Search product cache and return products already fetched.
                            const cachedProduct = productCache.find(
                                ({code}) => code === variantOption.code
                            )

                            return cachedProduct || api.getProduct(variantOption.code, options)
                        })
                    ).then((variations) => ({baseProduct, variations}))
                })
                .then(({baseProduct, variations}) => this.parseProduct(baseProduct, variations))
                .then((parsedProduct) => {
                    // Get the variation values from the first product requested.
                    const {baseOptions = []} = productCache[0]
                    const {selected = {}} = baseOptions[0] || {}
                    const {variantOptionQualifiers = []} = selected
                    const variationValues = variantOptionQualifiers.reduce(
                        (acc, {qualifier, value}) => ({
                            ...acc,
                            [qualifier]: value
                        }),
                        {}
                    )

                    // Set the products values.
                    parsedProduct.variationValues = variationValues

                    return parsedProduct
                })
                .catch(() => {
                    throw new errors.NotFoundError('Product Not Found')
                })
        }

        /**
         * @inheritDoc
         */
        getProducts(ids, opts = {}) {
            if (!ids || ids.length <= 0) {
                throw new Error('Please specify list of product ids to get.')
            }

            const getProductPromises = ids.map((id) => this.getProduct(id, opts).catch(() => null))

            return Promise.all(getProductPromises).then((products) => {
                const validProducts = products.filter((product) => !!product)
                return {
                    count: validProducts.length,
                    data: validProducts,
                    total: validProducts.length
                }
            })
        }

        /**
         * Takes a OCAPI ImageGroup object and parses it into a commerce-integrations ImageSet type.
         * @param {Object} data a OCAPI {@link https://documentation.demandware.com/DOC1/topic/com.demandware.dochelp/OCAPI/18.3/shop/Documents/ImageGroup.html|ImageGroup} document
         *
         * @returns {Promise<module:types.ImageSet>}
         */
        /* eslint-disable camelcase */
        parseImageSets(variation) {
            const {images, variantOptions} = variation
            const {variantOptionQualifiers} = variantOptions[0]

            const variationProperties = variantOptionQualifiers.map(({qualifier, name, value}) => ({
                id: qualifier,
                label: name,
                values: [
                    {
                        name: value,
                        value
                    }
                ]
            }))

            return ['zoom', 'product', 'thumbnail'].map((type) => ({
                images: images
                    .filter((image) => image.format === type)
                    .map(({altText, url}) => ({
                        alt: altText,
                        description: altText,
                        src: this.absoluteURL(url),
                        title: altText
                    })),
                variationProperties,
                sizeType: type
            }))
        }
        /* eslint-enable camelcase */

        /**
         * Takes a OCC Product object and parses it into a commerce-integrations Product type.
         * @param {Object} data a OCC product document
         *
         * @returns {Promise<module:types.Product>}
         */
        /* eslint-disable camelcase */
        parseProduct({code, name, description, categories, price, variantOptions}, variations = []) {
            // Parse all the image sets. We'll use these in our return value as well
            // as for parsing property value images.
            let allImageSets = []
            variations.forEach((variation) => {
                allImageSets = [...allImageSets, ...this.parseImageSets(variation)]
            })

            const allVariantOptions = [
                ...variantOptions,
                ...variations.reduce((acc, {variantOptions}) => [...acc, ...variantOptions], [])
            ]

            const variationProperties = allVariantOptions
                .reduce((acc, variantOption) => [...acc, ...variantOption.variantOptionQualifiers], [])
                .reduce((acc, {qualifier, name, value}) => {
                    const variationProperty = acc.find(
                        (variationOptionQualifier) => variationOptionQualifier.id === qualifier
                    )

                    // Lets get the first image of the correct type from the image sets.
                    const variationImageSets = allImageSets.filter(({variationProperties}) =>
                        variationProperties.find(
                            ({id, values}) => id === qualifier && values[0].value === value
                        )
                    )

                    const filteredProductImageSets = variationImageSets.filter(
                        ({sizeType}) => sizeType === 'product'
                    )

                    const filteredThumbnailImageSets = variationImageSets.filter(
                        ({sizeType}) => sizeType === 'thumbnail'
                    )

                    if (variationProperty) {
                        const isNew = !variationProperty.values.find((v) => v.value === value)

                        if (isNew) {
                            variationProperty.values.push({
                                name: value,
                                value,
                                mainImage:
                                    filteredProductImageSets.length &&
                                    filteredProductImageSets[0].images[0],
                                swatchImage:
                                    filteredThumbnailImageSets.length &&
                                    filteredThumbnailImageSets[0].images[0]
                            })
                        }
                    } else {
                        acc.push({
                            id: qualifier,
                            label: name,
                            values: [
                                {
                                    name: value,
                                    value,
                                    mainImage:
                                        filteredProductImageSets.length &&
                                        filteredProductImageSets[0].images[0],
                                    swatchImage:
                                        filteredThumbnailImageSets.length &&
                                        filteredThumbnailImageSets[0].images[0]
                                }
                            ]
                        })
                    }

                    return acc
                }, [])

            const allVariations = variations
                .map(({variantOptions}) =>
                    variantOptions.map(({code, priceData, stock, variantOptionQualifiers}) => ({
                        id: code,
                        price: priceData.value,
                        orderable: stock.stockLevelStatus === 'inStock',
                        values: variantOptionQualifiers.reduce((acc, curr) => {
                            acc[curr.qualifier] = curr.value
                            return acc
                        }, {})
                    }))
                )
                .reduce((acc, curr) => [...acc, ...curr], [])

            return {
                id: code,
                name,
                imageSets: allImageSets,
                description,
                categoryId: categories.length ? categories.pop().code : undefined,
                minimumOrderQuantity: 1,
                stepQuantity: 1,
                price: price.value,
                variations: allVariations,
                variationProperties
            }
        }
        /* eslint-enable camelcase */

        /**
         * @inheritDoc
         */
        getStore(id) {
            const api = new hybris.StoresApi(this.client)
            return api.getStore(id, {fields: 'FULL'}).then((data) => {
                return this.parseGetStore({...data, id})
            })
        }

        /**
         * Takes a OCCAPI WeekdayOpeningDay object and parses it into a commerce-integrations Store.hours type.
         * @param {Object} hours a OCCAPI {@link https://help.hybris.com/6.7.0/api/ycommercewebservices/index.html#_weekdayopeningdaywsdto|WeekdayOpneningDay} document
         *
         * @returns {Promise<module:types.Store.hours>}
         */
        parseOpeningHours(hours) {
            const parsedHours = {}

            hours.forEach((h) => {
                parsedHours[h.weekDay] = {
                    openingTime: h.openingTime ? h.openingTime.formattedHour : undefined,
                    closingTime: h.closingTime ? h.closingTime.formattedHour : undefined
                }
            })
            return parsedHours
        }

        /**
         * Takes a OCCAPI PointOfService object and parses it into a commerce-integrations Store type.
         * @param {Object} data a OCCAPI {@link https://help.hybris.com/6.7.0/api/ycommercewebservices/index.html#_pointofservicewsdto|PointOfService} document
         *
         * @returns {Promise<module:types.Store>}
         */
        parseGetStore(data) {
            const d = data
            const address = d.address
            return {
                addressLine1: address ? address.line1 : undefined,
                addressLine2: address ? address.line2 : undefined,
                city: address ? address.town : undefined,
                country: address && address.country ? address.country.isocode : undefined,
                description: d.description,
                features: d.features ? d.features.entry.map((feature) => feature.value) : undefined,
                email: address ? address.email : undefined,
                name: d.name,
                phone:
                    address && address.phone
                        ? address.phone.replace(new RegExp('[(]|[)]|[.]|[ ]|[-]|[#]|[x]', 'g'), '')
                        : undefined,
                postalCode: address ? address.postalCode : undefined,
                hours: this.parseOpeningHours(d.openingHours.weekDayOpeningList),
                images: d.storeImages
                    ? d.storeImages.map((image) => {
                          return {src: this.absoluteURL(image.url), alt: d.name}
                      })
                    : undefined,
                id: d.id
            }
        }

        transformSearchStoreParams(searchParams) {
            const sp = searchParams
            const count = sp.count > 0 ? sp.count : 20
            const start = sp.start > 0 ? sp.start : 0
            return {
                pageSize: count.toString(),
                currentPage: start.toString(),
                sort: 'relevance',
                latitude: !isNaN(sp.latlon.latitude) ? sp.latlon.latitude.toString() : undefined,
                longitude: !isNaN(sp.latlon.longitude) ? sp.latlon.longitude.toString() : undefined,
                fields: 'FULL'
            }
        }

        /**
         * @inheritDoc
         */
        // eslint-disable-next-line no-unused-vars
        searchStores(storeSearchRequest, opts = {}) {
            if (
                isNaN(storeSearchRequest.latlon.latitude) ||
                isNaN(storeSearchRequest.latlon.longitude)
            ) {
                throw new Error('Provide Latitude and Longitude coordinates')
            }

            const api = new hybris.StoresApi(this.client)
            const searchRequest = this.transformSearchStoreParams(storeSearchRequest)
            return api.getStores(searchRequest).then((data) => {
                if (data.stores.length <= 0) {
                    throw new Error('No stores found')
                }
                return this.parseSearchStores(data)
            })
        }

        parseSearchStores(data) {
            const d = data

            let count
            const start = d.pagination.currentPage
            const total = d.pagination.totalResults
            const pageSize = d.pagination.pageSize
            const totalPages = d.pagination.totalPages
            if (totalPages === start + 1) {
                // on last page
                if (total > pageSize) {
                    const remainder = total % pageSize
                    count = remainder !== 0 ? remainder : pageSize
                } else {
                    count = total
                }
            } else {
                // middle pages
                count = pageSize
            }
            return {
                count,
                start,
                total,
                stores:
                    d.stores.length > 0
                        ? d.stores.map((store) => this.parseGetStore({...store, id: store.name}))
                        : undefined
            }
        }

        isLoggedIn() {
            return this.getAuthentications().auth !== undefined
        }

        /**
         * Returns customer id 'current' if a refresh token is set in the auth
         * Otherwise, returns customer id 'anonymous'
         *
         * @private
         */
        getCustomerIdFromAuth() {
            if (!this.isLoggedIn()) {
                throw new errors.NotAuthenticatedError()
            }
            return this.getAuthentications().auth.refreshToken === undefined ? 'anonymous' : 'current'
        }

        /**
         * @inheritDoc
         */
        createCart(oldCart, opts = {}) {
            const api = new hybris.UsersApi(this.client)
            const customerId = this.getCustomerIdFromAuth()
            return api
                .postCart(customerId, {fields: 'FULL', ...opts})
                .then((newCart) => {
                    if (customerId === 'current' && oldCart !== undefined) {
                        return api
                            .postCart(customerId, {
                                // postCart will merge and delete the old cart
                                fields: 'FULL',
                                toMergeCartGuid: newCart.guid,
                                oldCartId: oldCart.id
                            })
                            .catch(() => {
                                throw new errors.ServerError('Could not merge old cart')
                            })
                    }
                    return newCart
                })
                .then((cart) => this.parseCart(cart))
                .catch(() => {
                    throw new errors.ServerError('Could not create cart')
                })
        }

        /**
         * @inheritDoc
         */
        getCart(cartId, opts = {}) {
            const api = new hybris.UsersApi(this.client)
            if (!cartId) {
                throw new errors.InvalidArgumentError(`Parameter 'cartId' is required`)
            }

            return api
                .getCart(this.getCustomerIdFromAuth(), cartId, {fields: 'FULL', ...opts})
                .then((data) => this.parseCart(data))
                .catch(() => {
                    throw new errors.ServerError('Could not get cart')
                })
        }

        /**
         * @inheritDoc
         */
        // eslint-disable-next-line no-unused-vars
        deleteCart(cartId, opts = {}) {
            if (!cartId) {
                throw new errors.InvalidArgumentError(`Parameter 'cartId' is required`)
            }

            const api = new hybris.UsersApi(this.client)
            return api.deleteCart(this.getCustomerIdFromAuth(), cartId).catch(() => {
                throw new errors.ServerError('Could not delete cart')
            })
        }

        /**
         * Parse a coupon item
         */
        parseCouponEntry(data) {
            return {
                id: data.code,
                code: data.code
            }
        }

        /**
         * @inheritDoc
         */
        parseCart(data) {
            const d = data
            if (!d.guid && !d.code) {
                throw new Error('Error parsing cart')
            }
            const customerId = this.getCustomerIdFromAuth()
            const cartId = customerId === 'anonymous' ? d.guid : d.code
            const parseNumberValue = (price) => {
                return price ? price.value : undefined
            }
            const parsedDiscounts = parseNumberValue(d.totalDiscounts)
            const discounts = parsedDiscounts === 0 ? parsedDiscounts : Math.abs(parsedDiscounts) * -1 // Our interface requires a negative value

            return {
                id: cartId,
                couponEntries: d.appliedVouchers ? d.appliedVouchers.map(this.parseCouponEntry) : [],
                customerInfo: {
                    id: customerId,
                    email: d.email
                },
                items: d.entries ? d.entries.map(this.parseCartItem) : [],
                shippingAddress: d.deliveryAddress
                    ? this.parseOrderAddress(d.deliveryAddress)
                    : undefined,
                billingAddress:
                    d.paymentInfo && d.paymentInfo.billingAddress
                        ? this.parseOrderAddress(d.paymentInfo.billingAddress)
                        : undefined,
                selectedShippingMethodId: d.deliveryMode ? d.deliveryMode.code : undefined,
                payments: d.paymentInfo
                    ? [this.parsePayment({...d.paymentInfo, amount: d.totalPriceWithTax.value})]
                    : [],
                subtotal: parseNumberValue(d.subTotal),
                shipping: parseNumberValue(d.deliveryCost),
                discounts,
                tax: parseNumberValue(d.totalTax),
                total: parseNumberValue(d.totalPriceWithTax)
            }
        }

        /**
         * @inheritDoc
         */
        parseCartItem(data) {
            const d = data
            const quantity = d.quantity || 0

            return {
                id: `${d.entryNumber}`,
                productId: d.product ? d.product.code : undefined,
                productName: d.product ? d.product.name : undefined,
                quantity,
                baseItemPrice: d.basePrice ? d.basePrice.value : 0,
                baseLinePrice: d.basePrice ? d.basePrice.value * quantity : 0,
                itemPrice: d.totalPrice && quantity !== 0 ? d.totalPrice.value / quantity : 0,
                linePrice: d.totalPrice ? d.totalPrice.value : 0
            }
        }

        /**
         * @inheritDoc
         */
        parseShippingMethod(data) {
            return {
                id: data.code,
                name: data.name,
                cost: data.deliveryCost || 0
            }
        }

        /**
         * @inheritDoc
         */
        parseOrderAddress(data) {
            const d = data
            return {
                id: d.id,
                titleCode: d.titleCode,
                firstName: d.firstName,
                lastName: d.lastName,
                phone: d.phone, // BUG: fixed in hybris 6.5, phone field is not saved and returned
                addressLine1: d.line1,
                addressLine2: d.line2,
                countryCode: d.country ? d.country.isocode : undefined,
                stateCode: d.region ? d.region.isocode : undefined,
                city: d.town,
                postalCode: d.postalCode
            }
        }

        /**
         * @inheritDoc
         */
        parsePayment(data) {
            const d = data
            return {
                id: d.id,
                amount: d.amount, // amount is always cart total for now.
                methodId: 'CREDIT_CARD', // only 1 type of payment method, we'll call it 'CREDIT_CARD'
                details: {
                    type: d.cardType.code,
                    holderName: d.accountHolderName,
                    number: undefined, // to add/update payment, provide full number.
                    username: undefined,
                    maskedNumber: d.cardNumber, // hybris will give back masked number.
                    expiryMonth: parseInt(d.expiryMonth),
                    expiryYear: parseInt(d.expiryYear),
                    ccv: undefined // hybris doesnt have ccv?
                }
            }
        }

        /**
         * @inheritDoc
         */
        parsePaymentMethod(data) {
            return {
                id: data.id,
                name: data.name,
                types: data.types.map((types) => ({id: types.code, name: types.name}))
            }
        }

        /**
         * Transform an orderAddress into hybris' models: billingAddress or shippingAddress
         */
        transformOrderAddress(orderAddress) {
            return {
                country: {
                    isocode: orderAddress.countryCode
                },
                firstName: orderAddress.firstName,
                lastName: orderAddress.lastName,
                id: orderAddress.id,
                line1: orderAddress.addressLine1,
                line2: orderAddress.addressLine2,
                phone: orderAddress.phone,
                postalCode: orderAddress.postalCode,
                region: {
                    isocode: orderAddress.stateCode
                },
                titleCode: orderAddress.titleCode,
                town: orderAddress.city
            }
        }

        /**
         * Transform a shippingMethod into hybris' model deliveryMethod.
         */
        transformShippingMethod(shippingMethod) {
            return {
                code: shippingMethod.id,
                name: shippingMethod.name,
                deliveryCost: shippingMethod.cost
            }
        }

        /**
         * Transform a cartItem into hybris' model OrderEntryOCC.
         */
        transformCartItem(cartItem) {
            return {
                product: {
                    code: cartItem.productId
                },
                quantity: cartItem.quantity
            }
        }

        /**
         * Transform a payment into hybris' model paymentDetail.
         */
        transformPayment(payment, billingAddress) {
            const details = payment.details
            return {
                id: payment.id,
                accountHolderName: details ? details.holderName : undefined,
                billingAddress: this.transformOrderAddress(billingAddress),
                cardNumber: details ? details.number : undefined,
                cardType: {
                    code: details ? details.type : undefined
                },
                expiryMonth: details ? details.expiryMonth : undefined,
                expiryYear: details ? details.expiryYear : undefined,
                subscriptionId: details ? details.username : undefined
            }
        }

        /**
         * @inheritDoc
         */
        addCartItem(cart, cartItem, opts = {}) {
            if (!cart) {
                throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
            }
            if (!cartItem) {
                throw new errors.InvalidArgumentError(`Parameter 'cartItem' is required`)
            }

            const api = new hybris.UsersApi(this.client)

            const entryOrder = this.transformCartItem(cartItem, opts)

            return api
                .postCartEntries(cart.customerInfo.id, cart.id, entryOrder, {...opts, fields: 'FULL'})
                .then(() => api.getCart(cart.customerInfo.id, cart.id, {...opts, fields: 'FULL'}))
                .then((data) => {
                    const updatedCart = this.parseCart({...data, email: cart.customerInfo.email})
                    // BUG: does not save billing address region when sending billing address through a POST cart payment detail.
                    // So lets persist this field on the incoming cart parameter to the returned order.
                    if (
                        updatedCart.billingAddress &&
                        !updatedCart.billingAddress.stateCode &&
                        cart.billingAddress.stateCode
                    ) {
                        updatedCart.billingAddress.stateCode = cart.billingAddress.stateCode
                    }
                    return updatedCart
                })
                .catch(() => {
                    throw new errors.ServerError('Could not add item to cart')
                })
        }

        /**
         * @inheritDoc
         */
        removeCartItem(cart, cartItemId, opts = {}) {
            if (!cart) {
                throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
            }
            if (!cartItemId) {
                throw new errors.InvalidArgumentError(`Parameter 'cartItemId' is required`)
            }

            const api = new hybris.UsersApi(this.client)

            return api
                .deleteCartEntry(cart.customerInfo.id, cart.id, cartItemId)
                .then(() => api.getCart(cart.customerInfo.id, cart.id, {...opts, fields: 'FULL'}))
                .then((data) => {
                    const updatedCart = this.parseCart({...data, email: cart.customerInfo.email})

                    // BUG: does not save billing address region when sending billing address through a POST cart payment detail.
                    // So lets persist this field on the incoming cart parameter to the returned order.
                    if (
                        updatedCart.billingAddress &&
                        !updatedCart.billingAddress.stateCode &&
                        cart.billingAddress.stateCode
                    ) {
                        updatedCart.billingAddress.stateCode = cart.billingAddress.stateCode
                    }
                    return updatedCart
                })
                .catch(() => {
                    throw new errors.ServerError('Could not delete cart item')
                })
        }

        /**
         * @inheritDoc
         */
        updateCartItem(cart, cartItem, opts = {}) {
            if (!cart) {
                throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
            }
            if (!cartItem) {
                throw new errors.InvalidArgumentError(`Parameter 'cartItem' is required`)
            }

            const api = new hybris.UsersApi(this.client)

            const entryOrder = this.transformCartItem(cartItem, opts)
            return api
                .patchCartEntry(cart.customerInfo.id, cart.id, cartItem.id, entryOrder, {
                    fields: 'FULL'
                })
                .then(() => api.getCart(cart.customerInfo.id, cart.id, {fields: 'FULL'}))
                .then((data) => {
                    const updatedCart = this.parseCart({...data, email: cart.customerInfo.email})

                    // BUG: does not save billing address region when sending billing address through a POST cart payment detail.
                    // So lets persist this field on the incoming cart parameter to the returned order.
                    if (
                        updatedCart.billingAddress &&
                        !updatedCart.billingAddress.stateCode &&
                        cart.billingAddress.stateCode
                    ) {
                        updatedCart.billingAddress.stateCode = cart.billingAddress.stateCode
                    }
                    return updatedCart
                })
                .catch(() => {
                    throw new errors.ServerError('Could not update cart item')
                })
        }

        /**
         * Add a supported payment type to the cart
         * @param {module:types.Cart} cart The cart to add the payment to.
         * @param {module:types.Payment} payment The payment to add.
         * @param {Object} opts
         * @return {Promise<module:types.Cart>}
         *
         * Need to set a billing address before setting a payment.
         * Must call setCustomerInformation() to add an email before setting payment for an anonymous user.
         * cart.paymentDetails.number will be returned as undefined and set as a masked number in cart.paymentDetails.maskedNumber.
         */
        setPayment(cart, payment, opts = {}) {
            if (!cart) {
                throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
            }
            if (!payment) {
                throw new errors.InvalidArgumentError(`Parameter 'payment' is required`)
            }

            const api = new hybris.UsersApi(this.client)

            if (cart.customerInfo.id === 'anonymous' && cart.customerInfo.email === undefined) {
                throw new errors.InvalidArgumentError('Please set email for guest user cart')
            }

            if (!cart.billingAddress) {
                throw new errors.InvalidArgumentError('Please set billing address first')
            }

            const isMissingFields =
                !payment.details.type ||
                !payment.details.expiryMonth ||
                !payment.details.expiryYear ||
                !payment.details.holderName ||
                !payment.details.number

            if (isMissingFields) {
                throw new errors.InvalidArgumentError('Missing hybris required fields')
            }

            const paymentDetails = this.transformPayment(payment, cart.billingAddress)

            // If the customer is a registered user and a payment already exists on the cart,
            // then the payment is has also been added to the user account. So, update the
            // corresponding payment on the user account to see the reflected changes on the cart.
            const apiCommand =
                cart.payments.length > 0 && cart.customerInfo.id === 'current'
                    ? api
                          .putPaymentDetail(cart.customerInfo.id, cart.payments[0].id, paymentDetails)
                          .then(() => this.getCart(cart.id))
                    : api
                          .postCartPaymentDetail(cart.customerInfo.id, cart.id, paymentDetails, {
                              ...opts,
                              fields: 'FULL'
                          })
                          .then((data) => {
                              cart.payments[0] = this.parsePayment({...data, amount: cart.total})
                              return cart
                          })

            return apiCommand.catch(() => {
                throw new errors.ServerError('Could not set payment')
            })
        }

        /**
         * Set the customer information.
         * @param {module:types.Cart} cart The customer's cart.
         * @param {module:types.CustomerInformation} customerInformation The new or modified customer information.
         * @param {Object} opts
         * @return {Promise<module:types.Cart>}
         *
         * Email can be set for a guest user but not a registered user.
         */
        setCustomerInformation(cart, customerInfo, opts = {}) {
            if (!cart) {
                throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
            }
            if (!customerInfo) {
                throw new errors.InvalidArgumentError(`Parameter 'customerInformation' is required`)
            }

            const api = new hybris.UsersApi(this.client)
            const customerId = cart.customerInfo.id

            return api
                .putCartEmail(customerId, cart.id, {...opts, email: customerInfo.email})
                .then(() => {
                    cart.customerInfo = {...customerInfo, id: customerId}
                    return cart
                })
                .catch(() => {
                    throw new errors.ServerError('Could not set customer information')
                })
        }

        /**
         * @inheritDoc
         */
        getShippingMethods(cart, opts = {}) {
            if (!cart) {
                throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
            }

            const api = new hybris.UsersApi(this.client)

            return api
                .getCartDeliveryModes(cart.customerInfo.id, cart.id, {...opts, fields: 'FULL'})
                .then(({deliveryModes}) => deliveryModes.map(this.parseShippingMethod.bind(this)))
        }

        /**
         * @inheritDoc
         */
        setShippingMethod(cart, shippingMethod) {
            if (!cart) {
                throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
            }
            if (!shippingMethod) {
                throw new errors.InvalidArgumentError(`Parameter 'shippingMethod' is required`)
            }

            const api = new hybris.UsersApi(this.client)
            return api
                .putCartDeliveryMode(cart.customerInfo.id, cart.id, shippingMethod.id)
                .then(() => {
                    cart.selectedShippingMethodId = shippingMethod.id
                    return cart
                })
                .catch(() => {
                    throw new errors.ServerError('Could not set shipping method')
                })
        }

        /**
         * Set the billing address on the cart.
         * @param {module:types.Cart} cart The cart to update the billing address for.
         * @param {module:types.OrderAddress} billingAddress The new or modified address.
         * @param {Object} opts
         * @return {Promise<module:types.Cart>}
         *
         * Billing Address model : this order address needs to have a titleCode defined.
         * The default titleCodes are: ['mr', 'ms', 'miss', 'mrs', 'dr', 'rev']
         * But these may change depending on your backend, please refer to your backend instance to confirm valid titleCodes.
         *
         * If the billing address is set before adding a payment, the billing address will not yet be saved in the hybris instance.
         * Instead, the billing address will be saved in the hybris instance upon setting a payment.
         */
        setBillingAddress(cart, billingAddress, opts = {}) {
            if (!cart) {
                throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
            }
            if (!billingAddress) {
                throw new errors.InvalidArgumentError(`Parameter 'billingAddress' is required`)
            }

            const isMissingFields =
                !billingAddress.countryCode ||
                !billingAddress.addressLine1 ||
                !billingAddress.stateCode ||
                !billingAddress.titleCode ||
                !billingAddress.postalCode ||
                !billingAddress.city ||
                !billingAddress.firstName ||
                !billingAddress.lastName
            if (isMissingFields) {
                throw new errors.InvalidArgumentError('Missing hybris required fields')
            }

            // if the cart does not have a payment yet, give back a cart with the billing address set.
            // else, there a payment already exists and it's billing address needs to be updated
            if (cart.payments.length <= 0) {
                cart.billingAddress = billingAddress
                return Promise.resolve(cart)
            }

            const api = new hybris.UsersApi(this.client)

            // For a registered user, the existing payment can be patched to update it's billing address
            if (cart.customerInfo.id === 'current') {
                return api
                    .patchPaymentDetail(cart.customerInfo.id, cart.payments[0].id, {
                        billingAddress: this.transformOrderAddress(billingAddress)
                    })
                    .then(() => {
                        cart.billingAddress = billingAddress
                        return cart
                    })
                    .catch(() => {
                        throw new errors.ServerError('Could not set billing address')
                    })
            }

            // For a guest user, the existing payment can only be updated if the payment detail number is re-injected.
            if (cart.payments[0].details.number === undefined) {
                throw new errors.InvalidArgumentError(
                    'As a guest user, please re-inject payment details number before updating billing address'
                )
            }

            return api
                .postCartPaymentDetail(
                    cart.customerInfo.id,
                    cart.id,
                    this.transformPayment(cart.payments[0], billingAddress),
                    {...opts, fields: 'FULL'}
                )
                .then((data) => {
                    cart.billingAddress = billingAddress
                    cart.payments[0] = this.parsePayment({...data, amount: cart.total})
                    return cart
                })
                .catch(() => {
                    throw new errors.ServerError('Could not set billing address')
                })
        }

        /**
         * Set the shipping method for the cart.
         * @param {module:types.Cart} cart The cart to set the shipping method for.
         * @param {module:types.ShippingMethod} shippingMethod The shipping method to set.
         * @param {Object} opts
         * @return {Promise<module:types.Cart>}
         *
         * Must call setCustomerInformation() to add an email before setting payment for a guest user.
         */
        setShippingAddress(cart, shippingAddress, opts = {}) {
            if (!cart) {
                throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
            }
            if (!shippingAddress) {
                throw new errors.InvalidArgumentError(`Parameter 'shippingAddress' is required`)
            }

            if (cart.customerInfo.id === 'anonymous' && cart.customerInfo.email === undefined) {
                throw new errors.InvalidArgumentError('Please set email for guest user cart')
            }

            const isMissingFields =
                !shippingAddress.firstName ||
                !shippingAddress.lastName ||
                !shippingAddress.addressLine1 ||
                !shippingAddress.city ||
                !shippingAddress.countryCode ||
                !shippingAddress.stateCode ||
                !shippingAddress.postalCode

            if (isMissingFields) {
                throw new errors.InvalidArgumentError('Missing hybris required fields')
            }

            const api = new hybris.UsersApi(this.client)
            // For a registered user, api.postCartDeliveryAddress() returns an unauthorized error (unknown reason)
            // A workaround: post or put (if already exists a shipping address) the user address and then put that address to the cart by its id.
            let apiCommand
            if (cart.customerInfo.id === 'current') {
                const userAddressApiCommand =
                    cart.shippingAddress === undefined
                        ? api.postUserAddress(
                              cart.customerInfo.id,
                              this.transformOrderAddress(shippingAddress),
                              {fields: 'FULL'}
                          )
                        : api.putUserAddress(cart.customerInfo.id, shippingAddress.id, {
                              ...this.transformOrderAddress(shippingAddress),
                              id: shippingAddress.id
                          })

                apiCommand = userAddressApiCommand.then((data) => {
                    return api.putCartDeliveryAddress(cart.customerInfo.id, cart.id, data.id)
                })
            } else {
                apiCommand = api.postCartDeliveryAddress(
                    cart.customerInfo.id,
                    cart.id,
                    this.transformOrderAddress(shippingAddress),
                    {...opts, fields: 'FULL'}
                )
            }

            return apiCommand
                .then(() => {
                    cart.shippingAddress = shippingAddress
                    return cart
                })
                .catch(() => {
                    throw new errors.ServerError('Could not set shipping address')
                })
        }

        /**
         * Get the available payment methods for the cart
         * @param {module:types.Cart} cart The customer's cart.
         * @param {Object} opts
         * @return {Promise<Array.module:types.PaymentMethod>}
         *
         * Hybris only supports payments by credit card attained from their cardtypes endpoint
         * so this command will return only one payment method with the id 'CREDIT_CARD', name 'Credit Card'
         * and all of it's valid credit card types.
         */
        getPaymentMethods(cart, opts = {}) {
            const api = new hybris.CardtypesApi(this.client)
            return api
                .getCardTypes({...opts, fields: 'FULL'})
                .then((data) => [
                    this.parsePaymentMethod({
                        id: 'CREDIT_CARD',
                        name: 'Credit Card',
                        types: data.cardTypes
                    })
                ])
                .catch(() => {
                    throw new errors.ServerError('Could not get payment methods')
                })
        }

        /**
         * @inheritDoc
         */
        parseOrder(data) {
            const order = this.parseCart(data)
            return {
                ...order,
                id: data.code,
                creationDate: new Date(data.created),
                status: data.statusDisplay,
                customerInfo: {
                    id: data.guestCustomer ? 'anonymous' : 'current',
                    email: data.paymentInfo.billingAddress.email
                }
            }
        }

        /**
         * @inheritDoc
         */
        getOrder(id, opts = {}) {
            if (!id) {
                throw new errors.InvalidArgumentError(`Parameter 'id' is required`)
            }
            let getOrderPromise
            // BUG: does not save billing address region when sending billing address through a POST cart payment detail.
            // So when retrieving an order by id, region (stateCode field) may be missing in the returned order.
            if (this.getCustomerIdFromAuth() === 'current') {
                const api = new hybris.UsersApi(this.client)
                getOrderPromise = api.getOrder('current', id, {...opts, fields: 'FULL'})
            } else {
                const api = new hybris.OrdersApi(this.client)
                getOrderPromise = api.getOrder(id, {...opts, fields: 'FULL'})
            }

            return getOrderPromise
                .then((data) => this.parseOrder(data))
                .catch(() => {
                    throw new errors.ServerError('Order not found')
                })
        }

        /**
         * @inheritDoc
         */
        getOrders(ids, opts = {}) {
            if (!ids || ids.length <= 0) {
                throw new errors.InvalidArgumentError('Please specify list of order ids to get.')
            }

            const getOrderPromises = ids.map((id) => this.getOrder(id, opts).catch(() => null))

            return Promise.all(getOrderPromises).then((orders) => {
                const validOrders = orders.filter((order) => !!order)
                return {
                    count: validOrders.length,
                    data: validOrders,
                    total: validOrders.length
                }
            })
        }

        /**
         * @inheritDoc
         */
        createOrder(cart, opts = {}) {
            const api = new hybris.UsersApi(this.client)
            return api
                .postOrder(cart.customerInfo.id, cart.id, {...opts, fields: 'FULL'})
                .then((data) => {
                    const order = this.parseOrder(data)

                    // BUG: does not save billing address region when sending billing address through a POST cart payment detail.
                    // So lets persist this field on the incoming cart parameter to the returned order.
                    if (order.billingAddress && !order.billingAddress.stateCode) {
                        order.billingAddress.stateCode = cart.billingAddress.stateCode
                    }

                    return order
                })
                .catch(() => {
                    throw new errors.ServerError('Could not create order')
                })
        }

        /**
         * @inheritDoc
         */
        // eslint-disable-next-line no-unused-vars
        addCouponEntry(cart, couponCode, opts = {}) {
            if (!cart) {
                throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
            }
            if (!couponCode) {
                throw new errors.InvalidArgumentError(`Parameter 'couponCode' is required`)
            }

            const api = new hybris.UsersApi(this.client)

            return api
                .postCartVoucher(cart.customerInfo.id, cart.id, couponCode)
                .then(() => this.getCart(cart.id))
                .catch(() => {
                    throw new errors.ServerError('Could Not Add Coupon')
                })
        }

        /**
         * @inheritDoc
         */
        // eslint-disable-next-line no-unused-vars
        removeCouponEntry(cart, couponEntryId, opts = {}) {
            if (!cart) {
                throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
            }
            if (!couponEntryId) {
                throw new errors.InvalidArgumentError(`Parameter 'couponEntryId' is required`)
            }

            const api = new hybris.UsersApi(this.client)

            return api
                .deleteCartVouchers(cart.customerInfo.id, cart.id, couponEntryId)
                .then(() => this.getCart(cart.id))
                .catch(() => {
                    throw new errors.ServerError('Could Not Remove Coupon')
                })
        }
    }