import requests
import re
import base64
import io
# activate PDF support in PIL
import PIL.PdfImagePlugin  # pylint: disable=W0611
from PIL import Image
from json.decoder import JSONDecodeError
from requests.exceptions import RequestException

from odoo import _
from odoo.exceptions import ValidationError
from odoo.tools import float_repr
from odoo.tools.urls import urljoin as url_join

TEST_BASE_URL = "https://wwwcie.ups.com"
PROD_BASE_URL = "https://onlinetools.ups.com"
API_VERSION = "v1"
TOKEN_TYPE = "Bearer"


class UPSRequest:

    def __init__(self, carrier):
        super_carrier = carrier.sudo()
        self.logger = carrier.log_xml
        self.base_url = PROD_BASE_URL if carrier.prod_environment else TEST_BASE_URL
        self.access_token = super_carrier.ups_access_token
        self.client_id = super_carrier.ups_client_id
        self.client_secret = super_carrier.ups_client_secret
        self.shipper_number = super_carrier.ups_shipper_number
        self.carrier = carrier
        self.session = requests.Session()

    def _send_request(self, url, method='GET', data=None, json=None, headers=None, auth=None):
        url = url_join(self.base_url, url)

        def _request_call(req_headers):
            if not req_headers and self.access_token:
                req_headers = {
                    "Authorization": '%s %s' % (TOKEN_TYPE, self.access_token)
                }
            self.logger(f'{url}\n{method}\n{req_headers}\n{data}\n{json}', f'ups request {url}')
            try:
                res = self.session.request(method=method, url=url, json=json, data=data, headers=req_headers, auth=auth, timeout=15)
                self.logger(f'{res.status_code} {res.text}', f'ups response {url}')
            except RequestException as err:
                self.logger(str(err), f'ups response {url}')
                raise ValidationError(_('Something went wrong, please try again later!!'))
            return res

        res = _request_call(headers)
        if res.status_code == 401 and auth is None:
            self.access_token = self._get_new_access_token()
            self.carrier.sudo().ups_access_token = self.access_token
            res = _request_call(None)

        return res

    def _process_errors(self, res_body):
        err_msgs = []
        response = res_body.get('response')
        if response:
            for err in response.get('errors', []):
                err_msgs.append(err['message'])
        return ','.join(err_msgs)

    def _process_alerts(self, response):
        alerts = response.get('Alert', [])
        if isinstance(alerts, list):
            messages = [alert['Description'] for alert in alerts]
            return '\n'.join(messages)
        return alerts['Description']

    def _get_new_access_token(self):
        if not self.client_id or not self.client_secret:
            raise ValidationError(_('You must setup a client ID and secret on the carrier first'))
        url = '/security/v1/oauth/token'
        headers = {
            'x-merchant-id': self.client_id
        }
        data = {
            "grant_type": "client_credentials"
        }
        res = self._send_request(url, 'POST', data=data, headers=headers, auth=(self.client_id, self.client_secret))
        try:
            res_body = res.json()
        except JSONDecodeError as err:
            self.logger(str(err), f'ups response decode error {url}')
            raise ValidationError(_('Could not decode response'))
        if not res.ok:
            raise ValidationError(self._process_errors(res_body))
        return res_body.get('access_token')

    def _clean_phone_number(self, phone):
        return re.sub('[^0-9]', '', phone)

    def _save_label(self, image64, label_file_type='GIF'):
        img_decoded = base64.decodebytes(image64.encode('utf-8'))
        if label_file_type == 'GIF':
            # Label format is GIF, so need to rotate and convert as PDF
            image_string = io.BytesIO(img_decoded)
            im = Image.open(image_string)
            label_result = io.BytesIO()
            im.save(label_result, 'pdf')
            return label_result.getvalue()
        else:
            return img_decoded

    def _check_required_value(self, order=False, picking=False, is_return=False):
        is_express_checkout_partial_delivery_address = False
        if order:
            is_express_checkout_partial_delivery_address = order.env.context.get('express_checkout_partial_delivery_address')
            shipper = order.company_id.partner_id
            ship_from = order.warehouse_id.partner_id
            ship_to = order.partner_shipping_id
        elif picking and is_return:
            ship_from = shipper = picking.partner_id
            ship_to = picking.picking_type_id.warehouse_id.partner_id
        else:
            shipper = picking.company_id.partner_id
            ship_from = picking.picking_type_id.warehouse_id.partner_id
            ship_to = picking.partner_id
        required_field = {'city': 'City', 'country_id': 'Country', 'phone': 'Phone'}
        # Check required field for shipper
        res = [required_field[field] for field in required_field if not shipper[field]]
        if shipper.country_id.code in ('US', 'CA', 'IE') and not shipper.state_id.code:
            res.append('State')
        if not shipper.street and not shipper.street2:
            res.append('Street')
        if shipper.country_id.code != 'HK' and not shipper.zip:
            res.append('ZIP code')
        if res:
            return _("The address of your company is missing or wrong.\n(Missing field(s) : %s)", ",".join(res))
        if len(self._clean_phone_number(shipper.phone)) < 10:
            return _("Shipper Phone must be at least 10 alphanumeric characters.")
        # Check required field for warehouse address
        res = [required_field[field] for field in required_field if not ship_from[field]]
        if ship_from.country_id.code in ('US', 'CA', 'IE') and not ship_from.state_id.code:
            res.append('State')
        if not ship_from.street and not ship_from.street2:
            res.append('Street')
        if ship_from.country_id.code != 'HK' and not ship_from.zip:
            res.append('ZIP code')
        if res:
            return _("The address of your warehouse is missing or wrong.\n(Missing field(s) : %s)", ",".join(res))
        if len(self._clean_phone_number(ship_from.phone)) < 10:
            return _("Warehouse Phone must be at least 10 alphanumeric characters."),
        # Check required field for recipient address
        res = [required_field[field] for field in required_field if field != 'phone' and not ship_to[field]]
        if ship_to.country_id.code in ('US', 'CA', 'IE') and not ship_to.state_id.code:
            res.append('State')
        if not ship_to.street and not ship_to.street2 and not is_express_checkout_partial_delivery_address:
            res.append('Street')
        if ship_to.country_id.code != 'HK' and not ship_to.zip:
            res.append('ZIP code')
        if len(ship_to.street or '') > 35 or len(ship_to.street2 or '') > 35:
            return _("UPS address lines can only contain a maximum of 35 characters. You can split the contacts addresses on multiple lines to try to avoid this limitation.")
        if picking and not order:
            order = picking.sale_id
        phone = ship_to.phone
        if order and not phone:
            phone = order.partner_id.phone
        if order:
            if not order.order_line:
                return _("Please provide at least one item to ship.")
            if error_lines := order.order_line._get_invalid_delivery_weight_lines():
                return _(
                    "The estimated shipping price cannot be computed because the weight is missing for the following product(s): \n %s",
                    ", ".join(error_lines.product_id.mapped('name')),
                )
        if picking:
            for ml in picking.move_line_ids.filtered(lambda ml: not ml.result_package_id and not ml.product_id.weight):
                return _("The delivery cannot be done because the weight of your product %s is missing.", ml.product_id.display_name)
            packages_without_weight = picking.move_line_ids.mapped('result_package_id').filtered(lambda p: not p.shipping_weight)
            if packages_without_weight:
                return _('Packages %s do not have a positive shipping weight.', ', '.join(packages_without_weight.mapped('display_name')))
        if not phone and not is_express_checkout_partial_delivery_address:
            res.append('Phone')
        if res:
            return _("The recipient address is missing or wrong.\n(Missing field(s) : %s)", ",".join(res))
        if phone and len(self._clean_phone_number(phone)) < 10:
            return _("Recipient Phone must be at least 10 alphanumeric characters."),
        return False

    def _set_package_details(self, packages, carrier, ship_from, ship_to, cod_info, ship=False, is_return=False):
        # Package Type key in ship request and rate request are different
        package_type_key = 'Packaging' if ship else 'PackagingType'
        res_packages = []
        for p in packages:
            # merchandise description: product names (len: [1-35]), strip non-alphanumeric chars
            desc = ','.join(['%s' % re.sub(r'[\W_]+', ' ', c.product_id.name) for c in p.commodities])
            if is_return:
                desc = 'return ' + desc
            if len(desc) > 35:
                desc = desc[:32] + '...'

            package = {
                package_type_key: {
                    'Code': p.packaging_type or '00',
                },
                'Description': desc or 'UPS shipment',
                'PackageWeight': {
                    'UnitOfMeasurement': {
                        'Code': carrier.ups_package_weight_unit,
                    },
                    'Weight': str(carrier._ups_convert_weight(p.weight, carrier.ups_package_weight_unit)),
                },
                'Dimensions': {
                    'UnitOfMeasurement': {
                        'Code': carrier.ups_package_dimension_unit or '',
                    },
                    'Length': str(p.dimension['length']) or '',
                    'Width': str(p.dimension['width']) or '',
                    'Height': str(p.dimension['height']) or '',
                }
            }

            package_service_options = {}

            if cod_info:
                package_service_options['COD'] = {
                    'CODFundsCode': cod_info['funds_code'],
                    'CODAmount': {
                        'MonetaryValue': str(cod_info['monetary_value']),
                        'CurrencyCode': cod_info['currency'],
                    }
                }
            if p.currency_id:
                package_service_options['DeclaredValue'] = {
                    'CurrencyCode': p.currency_id.name,
                    'MonetaryValue': float_repr(p.total_cost * carrier.shipping_insurance / 100, 2),
                }

            if package_service_options:
                package['PackageServiceOptions'] = package_service_options

            # Package and shipment reference text is only allowed for shipments within
            # the USA and within Puerto Rico. This is a UPS limitation.
            if (p.name and ' ' not in p.name and ship_from.country_id.code in ('US') and ship_to.country_id.code in ('US')):
                package.update({
                    'ReferenceNumber': {
                        'Code': 'PM',
                        'Value': p.name,
                        'BarCodeIndicator': p.name,
                    }
                })
            res_packages.append(package)
        return res_packages

    def _get_ship_data_from_partner(self, partner, shipper_no=None):
        return {
            'AttentionName': (partner.name or '')[:35],
            'Name': (partner.parent_id.name or partner.name or '')[:35],
            'EMailAddress': partner.email or '',
            'ShipperNumber': shipper_no or '',
            'Phone': {
                'Number': (partner.phone or '').replace(' ', ''),
            },
            'Address': {
                'AddressLine': [partner.street or '', partner.street2 or ''],
                'City': partner.city or '',
                'PostalCode': partner.zip or '',
                'CountryCode': partner.country_id.code or '',
                'StateProvinceCode': partner.state_id.code or '',
            },
        }

    def _get_shipping_price(self, shipper, ship_from, ship_to, total_qty, packages, carrier, cod_info=None):
        service_type = carrier.ups_default_service_type
        saturday_delivery = carrier.ups_saturday_delivery
        url = f'/api/rating/{API_VERSION}/Rate'

        shipment_service_options = {}
        if saturday_delivery:
            shipment_service_options['SaturdayDeliveryIndicator'] = saturday_delivery
        if carrier.ups_require_signature:
            shipment_service_options['DeliveryConfirmation'] = {'DCISType': '1'}

        data = {
            'RateRequest': {
                'Request': {
                    'RequestOption': 'Rate',
                },
                'Shipment': {
                    'Package': self._set_package_details(packages, carrier, ship_from, ship_to, cod_info),
                    'Shipper': self._get_ship_data_from_partner(shipper, self.shipper_number),
                    'ShipFrom': self._get_ship_data_from_partner(ship_from),
                    'ShipTo': self._get_ship_data_from_partner(ship_to),
                    'Service': {
                        'Code': service_type,
                    },
                    'NumOfPieces': str(int(total_qty)) if service_type == '96' else None,
                    'ShipmentServiceOptions': shipment_service_options if shipment_service_options else None,
                    'ShipmentRatingOptions': {
                        'NegotiatedRatesIndicator': "1",
                    }
                }
            },
        }
        res = self._send_request(url, method='POST', json=data)
        if not res.ok:
            return {'error_message': self._process_errors(res.json())}

        res = res.json()
        rate = res['RateResponse']['RatedShipment']
        charge = rate['TotalCharges']

        # Some users are qualified to receive negotiated rates
        if 'NegotiatedRateCharges' in rate and rate['NegotiatedRateCharges']['TotalCharge']['MonetaryValue']:
            charge = rate['NegotiatedRateCharges']['TotalCharge']

        return {
            'currency_code': charge['CurrencyCode'],
            'price': charge['MonetaryValue'],
            'alert_message': self._process_alerts(res['RateResponse']['Response']),
        }

    def _set_invoice(self, shipment_info, commodities, ship_to, is_return):
        invoice_products = []
        for commodity in commodities:
            # split the name of the product to maximum 3 substrings of length 35
            name = commodity.product_id.name
            product = {
                'Description': [line for line in [name[35 * i:35 * (i + 1)] for i in range(3)] if line],
                'Unit': {
                    'Number': str(int(commodity.qty)),
                    'UnitOfMeasurement': {
                        'Code': 'PC' if commodity.qty == 1 else 'PCS',
                    },
                    'Value': float_repr(commodity.monetary_value, 2)
                },
                'OriginCountryCode': commodity.country_of_origin,
                'CommodityCode': commodity.product_id.hs_code or '',
            }
            invoice_products.append(product)
        if len(ship_to.commercial_partner_id.name) > 35:
            raise ValidationError(_('The name of the customer should be no more than 35 characters.'))
        contacts = {
            'SoldTo': {
                'Name': ship_to.commercial_partner_id.name,
                'AttentionName': ship_to.name,
                'Address': {
                    'AddressLine': [line for line in (ship_to.street, ship_to.street2) if line],
                    'City': ship_to.city,
                    'PostalCode': ship_to.zip,
                    'CountryCode': ship_to.country_id.code,
                    'StateProvinceCode': ship_to.state_id.code or '' if ship_to.country_id.code in ('US', 'CA', 'IE') else None
                }
            }
        }
        return {
            'FormType': '01',
            'Product': invoice_products,
            'CurrencyCode': shipment_info.get('itl_currency_code'),
            'InvoiceDate': shipment_info.get('invoice_date'),
            'ReasonForExport': 'RETURN' if is_return else 'SALE',
            'Contacts': contacts,
        }

    def _send_shipping(self, shipment_info, packages, carrier, shipper, ship_from, ship_to, service_type, duty_payment,
                       saturday_delivery=False, cod_info=None, label_file_type='GIF', ups_carrier_account=False, is_return=False):
        url = f'/api/shipments/{API_VERSION}/ship'
        # Payment Info
        shipment_charge = {
            'Type': '01',
        }
        payment_info = [shipment_charge]
        if ups_carrier_account:
            shipment_charge['BillReceiver'] = {
                'AccountNumber': ups_carrier_account,
                'Address': {
                    'PostalCode': ship_to.zip,
                }
            }
        else:
            shipment_charge['BillShipper'] = {
                'AccountNumber': self.shipper_number,
            }
        if duty_payment == 'SENDER':
            payment_info.append({
                'Type': '02',
                'BillShipper': {'AccountNumber': self.shipper_number},
            })
        shipment_service_options = {}
        if shipment_info.get('require_invoice'):
            shipment_service_options['InternationalForms'] = self._set_invoice(shipment_info, [c for pkg in packages for c in pkg.commodities],
                                                                               ship_to, is_return)
            shipment_service_options['InternationalForms']['PurchaseOrderNumber'] = shipment_info.get('purchase_order_number')
            shipment_service_options['InternationalForms']['TermsOfShipment'] = shipment_info.get('terms_of_shipment')
        if saturday_delivery:
            shipment_service_options['SaturdayDeliveryIndicator'] = saturday_delivery
        if carrier.ups_require_signature:
            shipment_service_options['DeliveryConfirmation'] = {'DCISType': '1'}

        request = {
            'ShipmentRequest': {
                'Request': {
                    'RequestOption': 'nonvalidate',
                },
                'LabelSpecification': {
                    'LabelImageFormat': {
                        'Code': label_file_type,
                    },
                    'LabelStockSize': {'Height': '6', 'Width': '4'} if label_file_type != 'GIF' else None,
                },
                'Shipment': {
                    'Description': shipment_info.get('description'),
                    'ReturnService': {'Code': '9'}if is_return else None,
                    'Package': self._set_package_details(packages, carrier, ship_from, ship_to, cod_info, ship=True, is_return=is_return),
                    'Shipper': self._get_ship_data_from_partner(shipper, self.shipper_number),
                    'ShipFrom': self._get_ship_data_from_partner(ship_from),
                    'ShipTo': self._get_ship_data_from_partner(ship_to),
                    'Service': {
                        'Code': service_type,
                    },
                    'NumOfPiecesInShipment': int(shipment_info.get('total_qty')) if service_type == '96' else None,
                    'ShipmentServiceOptions': shipment_service_options if shipment_service_options else None,
                    'ShipmentRatingOptions': {
                        'NegotiatedRatesIndicator': '1',
                    },
                    'PaymentInformation': {
                        'ShipmentCharge': payment_info,
                    },
                },
            },
        }

        # Include ReferenceNumber only if the shipment is not US/US or PR/PR
        if not (
            (ship_from.country_id.code == 'US' and ship_to.country_id.code == 'US') or
            (ship_from.country_id.code == 'PR' and ship_to.country_id.code == 'PR')
        ):
            request['ShipmentRequest']['Shipment']['ReferenceNumber'] = {
                'Value': shipment_info.get('reference_number')
            }

        # Shipments from US to CA or PR require extra info
        if ship_from.country_id.code == 'US' and ship_to.country_id.code in ['CA', 'PR']:
            request['ShipmentRequest']['Shipment']['InvoiceLineTotal'] = {
                'CurrencyCode': shipment_info.get('itl_currency_code'),
                'MonetaryValue': shipment_info.get('ilt_monetary_value'),
            }
        res = self._send_request(url, 'POST', json=request)
        if res.status_code == 401:
            raise ValidationError(_("Invalid Authentication Information: Please check your credentials and configuration within UPS's system."))
        try:
            res_body = res.json()
        except JSONDecodeError as err:
            self.logger(str(err), f'ups response decode error {url}')
            raise ValidationError(_('Could not decode response'))
        if not res.ok:
            raise ValidationError(self._process_errors(res.json()))
        result = {}
        shipment_result = res_body['ShipmentResponse']['ShipmentResults']
        packs = shipment_result.get('PackageResults', [])
        # get package labels
        if not isinstance(packs, list):
            packs = [packs]
        result['tracking_ref'] = shipment_result['ShipmentIdentificationNumber']
        labels_binary = [(pack['TrackingNumber'], self._save_label(pack['ShippingLabel']['GraphicImage'], label_file_type=label_file_type)) for pack in packs]
        result['label_binary_data'] = labels_binary
        # save international form if in response
        international_form = shipment_result.get('Form', False)
        if international_form:
            result['invoice_binary_data'] = self._save_label(international_form['Image']['GraphicImage'], label_file_type='pdf')
        # Some users are qualified to receive negotiated rates
        if shipment_result.get('NegotiatedRateCharges'):
            charge = shipment_result['NegotiatedRateCharges']['TotalCharge']
        else:
            charge = shipment_result['ShipmentCharges']['TotalCharges']
        result['currency_code'] = charge['CurrencyCode']
        result['price'] = charge['MonetaryValue']
        return result

    def _cancel_shipping(self, shipping_id):
        url = f'/api/shipments/{API_VERSION}/void/cancel/{shipping_id}'
        res = self._send_request(url, 'DELETE')
        if res.status_code == 401:
            raise ValidationError(_("Invalid Authentication Information: Please check your credentials and configuration within UPS's system."))
        try:
            res_body = res.json()
        except JSONDecodeError as err:
            self.logger(str(err), f'ups response decode error {url}')
            raise ValidationError(_('Could not decode response'))
        if not res.ok:
            raise ValidationError(self._process_errors(res.json()))
        return res_body['VoidShipmentResponse']['SummaryResult']['Status']
