# This file is part of Tryton.  The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.

import base64
import re
import ssl
import urllib.parse
from decimal import Decimal
from itertools import zip_longest
from math import ceil

import requests

import trytond.config as config
from trytond.i18n import gettext
from trytond.model import fields
from trytond.model.exceptions import AccessError
from trytond.modules.stock_package_shipping.exceptions import (
    PackingValidationError)
from trytond.modules.stock_package_shipping.stock import address_name
from trytond.pool import Pool, PoolMeta
from trytond.transaction import Transaction
from trytond.wizard import StateAction, StateTransition, Wizard

from .exceptions import UPSError

TRACKING_URL = 'https://www.ups.com/track'


class PackageType(metaclass=PoolMeta):
    __name__ = 'stock.package.type'

    ups_code = fields.Selection([
            (None, ''),
            ('01', 'UPS Letter'),
            ('02', 'Customer Supplied Package'),
            ('03', 'Tube'),
            ('04', 'PAK'),
            ('21', 'UPS Express Box'),
            ('24', 'UPS 25KG Box'),
            ('25', 'UPS 10KG Box'),
            ('30', 'Pallet'),
            ('2a', 'Small Express Box'),
            ('2b', 'Medium Express Box'),
            ('2c', 'Large Express Box'),
            ('56', 'Flats'),
            ('57', 'Parcels'),
            ('58', 'BPM'),
            ('59', 'First Class'),
            ('60', 'Priority'),
            ('61', 'Machinables'),
            ('62', 'Irregulars'),
            ('63', 'Parcel Post'),
            ('64', 'BPM Parcel'),
            ('65', 'Media Mail'),
            ('66', 'BPM Flat'),
            ('67', 'Standard Flat'),
            ], 'UPS Code', sort=False, translate=False)

    @classmethod
    def default_ups_code(cls):
        return None


class Package(metaclass=PoolMeta):
    __name__ = 'stock.package'

    def get_shipping_tracking_url(self, name):
        url = super().get_shipping_tracking_url(name)
        if (self.shipping_reference
                and self.shipment
                and self.shipment.id >= 0
                and self.shipment.carrier
                and self.shipment.carrier.shipping_service == 'ups'):
            party = self.shipment.shipping_to
            address = self.shipment.shipping_to_address
            parts = urllib.parse.urlsplit(TRACKING_URL)
            query = urllib.parse.parse_qsl(parts.query)
            if party and party.lang and address and address.country:
                loc = '_'.join(
                    (party.lang.code.split('_')[0], address.country.code))
                query.append(('loc', loc))
            query.append(('tracknum', self.shipping_reference))
            parts = list(parts)
            parts[3] = urllib.parse.urlencode(query)
            url = urllib.parse.urlunsplit(parts)
        return url


class ShippingUPSMixin:
    __slots__ = ()

    def validate_packing_ups(self, usage=None):
        warehouse = self.shipping_warehouse
        if not warehouse.address:
            raise PackingValidationError(
                gettext('stock_package_shipping_ups'
                    '.msg_warehouse_address_required',
                    shipment=self.rec_name,
                    warehouse=warehouse.rec_name))
        if warehouse.address.country != self.shipping_to_address.country:
            for address in [self.shipping_to_address, warehouse.address]:
                if not address.contact_mechanism_get(
                        {'phone', 'mobile'}, usage=usage):
                    raise PackingValidationError(
                        gettext('stock_package_shipping_ups'
                            '.msg_phone_required',
                            shipment=self.rec_name,
                            address=address.rec_name))


class CreateShipping(metaclass=PoolMeta):
    __name__ = 'stock.shipment.create_shipping'

    ups = StateAction(
        'stock_package_shipping_ups.act_create_shipping_ups_wizard')

    def transition_start(self):
        next_state = super().transition_start()
        if self.record.carrier.shipping_service == 'ups':
            next_state = 'ups'
        return next_state

    def do_ups(self, action):
        ctx = Transaction().context
        return action, {
            'model': ctx['active_model'],
            'id': ctx['active_id'],
            'ids': [ctx['active_id']],
            }


class CreateShippingUPS(Wizard):
    __name__ = 'stock.shipment.create_shipping.ups'

    start = StateTransition()

    def transition_start(self):
        pool = Pool()
        Package = pool.get('stock.package')

        shipment = self.record
        if shipment.shipping_reference:
            raise AccessError(
                gettext('stock_package_shipping_ups'
                    '.msg_shipment_has_shipping_reference_number',
                    shipment=shipment.rec_name))

        credential = self.get_credential(shipment)
        carrier = shipment.carrier
        packages = shipment.root_packages
        shipment_request = self.get_request(shipment, packages, credential)
        token = credential.get_token()
        api_url = credential.get_shipment_url()
        headers = {
            'transactionSrc': "Tryton",
            'Authorization': f"Bearer {token}",
            }
        nb_tries, response = 0, None
        error_message = ''
        timeout = config.getfloat(
            'stock_package_shipping_ups', 'requests_timeout', default=300)
        try:
            while nb_tries < 5 and response is None:
                try:
                    response = requests.post(
                        api_url, json=shipment_request, headers=headers,
                        timeout=timeout)
                except ssl.SSLError as e:
                    error_message = e.reason
                    nb_tries += 1
                    continue
                response.raise_for_status()
                response = response.json()
        except requests.HTTPError as e:
            try:
                errors = e.response.json()['response']['errors']
                error_message = "\n".join(m['message'] for m in errors)
            except (requests.JSONDecodeError, TypeError, KeyError):
                error_message = e.args[0]

        if error_message:
            raise UPSError(
                gettext('stock_package_shipping_ups.msg_ups_webservice_error',
                    message=error_message))

        shipment_response = response['ShipmentResponse']
        response_status = shipment_response['Response']['ResponseStatus']
        if response_status['Code'] != '1':
            raise UPSError(
                gettext('stock_package_shipping_ups.msg_ups_webservice_error',
                    message=response_status['Description']))

        shipment_results = shipment_response['ShipmentResults']
        shipment.shipping_reference = (
            shipment_results['ShipmentIdentificationNumber'])
        ups_packages = shipment_results['PackageResults']
        if not isinstance(ups_packages, list):
            # In case only one package is requested UPS may return a dictionary
            # instead of a list of one package
            ups_packages = [ups_packages]

        for tryton_pkg, ups_pkg in zip_longest(packages, ups_packages):
            label = fields.Binary.cast(base64.b64decode(
                    ups_pkg['ShippingLabel']['GraphicImage']))
            tryton_pkg.shipping_reference = ups_pkg['TrackingNumber']
            tryton_pkg.shipping_label = label
            tryton_pkg.shipping_label_mimetype = (
                carrier.shipping_label_mimetype)
        Package.save(packages)
        shipment.save()

        return 'end'

    def get_credential_pattern(self, shipment):
        return {
            'company': shipment.company.id,
            }

    def get_credential(self, shipment):
        pool = Pool()
        UPSCredential = pool.get('carrier.credential.ups')

        credential_pattern = self.get_credential_pattern(shipment)
        for credential in UPSCredential.search([]):
            if credential.match(credential_pattern):
                return credential

    def get_request_container(self, shipment):
        return {
            'RequestOption': 'validate',
            'TransactionReference': {
                'CustomerContext': (shipment.number or '')[:512],
                },
            }

    def get_shipping_party(self, party, address, usage=None):
        name = address_name(address, party)
        attention_name = party.full_name
        shipping_party = {
            'Name': name[:35],
            'AttentionName': attention_name[:35],
            'Address': {
                'AddressLine': [l[:35]
                    for l in (address.street or '').splitlines()[:3]],
                'City': (address.city or '')[:30],
                'PostalCode': (address.postal_code or '').replace(' ', '')[:9],
                'CountryCode': address.country.code if address.country else '',
                },
            }
        if address.post_box:
            shipping_party['Address']['POBoxIndicator'] = True
        phone = address.contact_mechanism_get({'phone', 'mobile'}, usage=usage)
        if phone:
            shipping_party['Phone'] = {
                'Number': re.sub('[() .-]', '', phone.value)[:15]
                }
        email = address.contact_mechanism_get('email')
        if email and len(email.value) <= 50:
            shipping_party['EMailAddress'] = email.value

        return shipping_party

    def get_payment_information(self, shipment, credential):
        return {
            'ShipmentCharge': {
                # Type 01 is for Transportation Charges
                'Type': '01',
                'BillShipper': {
                    'AccountNumber': credential.account_number,
                    },
                },
            }

    def get_package(self, use_metric, package):
        pool = Pool()
        UoM = pool.get('product.uom')
        ModelData = pool.get('ir.model.data')

        cm = UoM(ModelData.get_id('product', 'uom_centimeter'))
        inch = UoM(ModelData.get_id('product', 'uom_inch'))

        kg = UoM(ModelData.get_id('product', 'uom_kilogram'))
        lb = UoM(ModelData.get_id('product', 'uom_pound'))

        pkg = {
            'Packaging': {
                'Code': package.type.ups_code,
                },
            }
        if (package.length is not None
                and package.width is not None
                and package.height is not None):
            pkg['Dimensions'] = {
                'UnitOfMeasurement': {
                    'Code': 'CM' if use_metric else 'IN',
                    },
                'Length': '%i' % ceil(
                    UoM.compute_qty(
                        package.length_uom, package.length,
                        cm if use_metric else inch, round=False)),
                'Width': '%i' % ceil(
                    UoM.compute_qty(
                        package.width_uom, package.width,
                        cm if use_metric else inch, round=False)),
                'Height': '%i' % ceil(
                    UoM.compute_qty(
                        package.height_uom, package.height,
                        cm if use_metric else inch, round=False)),
                }
        if package.total_weight is not None:
            pkg['PackageWeight'] = {
                'UnitOfMeasurement': {
                    'Code': 'KGS' if use_metric else 'LBS',
                    },
                'Weight': '%i' % ceil(
                    UoM.compute_qty(
                        kg, package.total_weight,
                        kg if use_metric else lb, round=False)),
                }
        return pkg

    def get_service_options(self, shipment):
        service_options = {}
        notifications = list(self.get_notifications(shipment))
        if notifications:
            service_options['Notification'] = notifications
        return service_options

    def get_notifications(self, shipment):
        if not shipment.carrier.ups_notifications:
            return
        for code in shipment.carrier.ups_notifications:
            shipping_to_address = shipment.shipping_to_address
            email = shipping_to_address.contact_mechanism_get('email')
            if email and len(email.value) <= 50:
                notification = {
                    'NotificationCode': code,
                    'EMail': {
                        'EMailAddress': email.value,
                        },
                    }
                if code in {'012', '013'}:
                    phone = shipping_to_address.contact_mechanism_get(
                        {'phone', 'mobile'})
                    if phone and len(phone.value) <= 15:
                        notification['VoiceMessage'] = {
                            'PhoneNumber': phone.value,
                            }
                    mobile = shipping_to_address.contact_mechanism_get(
                        'mobile')
                    if mobile and len(mobile.value) <= 15:
                        notification['TextMessage'] = {
                            'PhoneNumber': phone.value,
                            }
                yield notification

    def get_request(self, shipment, packages, credential):
        shipper = self.get_shipping_party(
            shipment.company.party, shipment.shipping_warehouse.address)
        shipper['ShipperNumber'] = credential.account_number
        # email is not required but must be associated with the UserId
        # which can not be ensured.
        shipper.pop('EMailAddress', None)

        packages = [self.get_package(credential.use_metric, p)
            for p in packages]
        options = self.get_service_options(shipment)
        if options:
            # options are set on package instead of shipment
            # despite what UPS documentation says
            for pkg in packages:
                pkg['ShipmentServiceOptions'] = options
        description = (
            shipment.shipping_description_used
            or gettext('stock_package_shipping_ups.msg_general_merchandise'))
        return {
            'ShipmentRequest': {
                'Request': self.get_request_container(shipment),
                'Shipment': {
                    'Description': description[:50],
                    'Shipper': shipper,
                    'ShipTo': self.get_shipping_party(
                        shipment.shipping_to, shipment.shipping_to_address),
                    'PaymentInformation': self.get_payment_information(
                        shipment, credential),
                    'Service': {
                        'Code': shipment.carrier.ups_service_type,
                        },
                    'Package': packages,
                    },
                'LabelSpecification': {
                    'LabelImageFormat': {
                        'Code': shipment.carrier.ups_label_image_format,
                        },
                    'LabelStockSize': {
                        'Width': '4',
                        'Height': shipment.carrier.ups_label_height,
                        },
                    }
                },
            }


class CreateShippingUPS_Customs_Incoterm(metaclass=PoolMeta):
    __name__ = 'stock.shipment.create_shipping.ups'

    def get_request(self, shipment, packages, credential):
        request = super().get_request(shipment, packages, credential)
        if (shipment.customs_international
                and credential.use_international_forms):
            international_form = self.get_international_form(
                shipment, credential)
            request['ShipmentRequest']['Shipment'].setdefault(
                'ShipmentServiceOptions', {})['InternationalForms'] = (
                    international_form)
        return request

    def get_international_form(self, shipment, credential):
        form_type = self.get_international_form_type(shipment, credential)
        return getattr(self, f'get_international_form_{form_type}')(
            credential.use_metric, shipment)

    def get_international_form_type(self, shipment, credential):
        return 'invoice'

    def get_international_form_invoice(self, use_metric, shipment):
        pool = Pool()
        Date = pool.get('ir.date')

        with Transaction().set_context(company=shipment.company.id):
            today = Date.today()

        if customs_agent := shipment.customs_agent:
            sold_to_party = customs_agent.party
            sold_to_address = customs_agent.address
        else:
            sold_to_party = shipment.shipping_to
            sold_to_address = shipment.shipping_to_address

        invoice_date = shipment.effective_date or today
        currency, = {m.currency for m in shipment.customs_moves}
        sold_to = self.get_shipping_party(sold_to_party, sold_to_address)
        if customs_agent:
            sold_to['AccountNumber'] = customs_agent.ups_account_number
        return {
            'FormType': '01',  # Invoice
            'Contacts': {
                'SoldTo': sold_to,
                },
            'Product': [
                self.get_international_form_invoice_product(
                    use_metric, shipment, *k, **v)
                for k, v in shipment.customs_products.items()],
            'InvoiceNumber': shipment.number[-35:],
            'InvoiceDate': invoice_date.strftime('%Y%m%d'),
            'TermsOfShipment': (
                shipment.incoterm.code if shipment.incoterm else None),
            'ReasonForExport': self.get_international_form_invoice_reason(
                shipment),
            'CurrencyCode': currency.code,
            }

    def get_international_form_invoice_product(
            self, use_metric, shipment, product, price, currency, unit,
            quantity, weight):
        pool = Pool()
        UoM = pool.get('product.uom')
        ModelData = pool.get('ir.model.data')

        kg = UoM(ModelData.get_id('product', 'uom_kilogram'))
        lb = UoM(ModelData.get_id('product', 'uom_pound'))

        tariff_code = product.get_tariff_code({
                'date': shipment.effective_date or shipment.planned_date,
                'country': (
                    shipment.customs_to_country.id
                    if shipment.customs_to_country else None),
                })
        weight = UoM.compute_qty(
            kg, weight, kg if use_metric else lb, round=False)
        if len(str(weight)) > 5:
            weight = ceil(weight)

        if not quantity.is_integer():
            value = price * Decimal(str(quantity))
            quantity = 1
            unit_code = 'OTH'
        else:
            value = price
            unit_code = unit.ups_code

        for i in range(6, -1, -1):
            value = str(price.quantize(Decimal(10) ** -i)).rstrip('0')
            if len(value) <= 19:
                break
        return {
            'Description': product.name[:35],
            'Unit': {
                'Number': '%i' % quantity,
                'UnitOfMeasurement': {
                    'Code': unit_code,
                    'Description': (
                        unit.ups_code if unit.ups_code != 'OTH'
                        else unit.name)[:3],
                    },
                'Value': value,
                },
            'CommodityCode': tariff_code.code if tariff_code else None,
            'OriginCountryCode': (
                product.country_of_origin.code if product.country_of_origin
                else None),
            'ProductWeight': {
                'UnitOfMeasurement': {
                    'Code': 'KGS' if use_metric else 'LBS',
                    },
                'Weight': str(weight),
                },
            }

    def get_international_form_invoice_reason(self, shipment):
        return {
            'stock.shipment.out': 'SALE',
            'stock.shipment.in.return': 'RETURN',
            }.get(shipment.__class__.__name__)
