# Part of Odoo. See LICENSE file for full copyright and licensing details.

import logging
import pprint

import requests
from werkzeug import urls
from werkzeug.exceptions import Forbidden

from odoo import _, http
from odoo.exceptions import ValidationError
from odoo.http import request
from odoo.tools import html_escape

from odoo.addons.payment import utils as payment_utils


_logger = logging.getLogger(__name__)


class PaypalController(http.Controller):
    _return_url = '/payment/paypal/return/'
    _cancel_url = '/payment/paypal/cancel/'
    _webhook_url = '/payment/paypal/webhook/'

    @http.route(
        _return_url, type='http', auth='public', methods=['GET', 'POST'], csrf=False,
        save_session=False
    )
    def paypal_return_from_checkout(self, **pdt_data):
        """ Process the PDT notification sent by PayPal after redirection from checkout.

        The PDT (Payment Data Transfer) notification contains the parameters necessary to verify the
        origin of the notification and retrieve the actual notification data, if PDT is enabled on
        the account. See https://developer.paypal.com/api/nvp-soap/payment-data-transfer/.

        The route accepts both GET and POST requests because PayPal seems to switch between the two
        depending on whether PDT is enabled, whether the customer pays anonymously (without logging
        in on PayPal), whether they click on "Return to Merchant" after paying, etc.

        The route is flagged with `save_session=False` to prevent Odoo from assigning a new session
        to the user if they are redirected to this route with a POST request. Indeed, as the session
        cookie is created without a `SameSite` attribute, some browsers that don't implement the
        recommended default `SameSite=Lax` behavior will not include the cookie in the redirection
        request from the payment provider to Odoo. As the redirection to the '/payment/status' page
        will satisfy any specification of the `SameSite` attribute, the session of the user will be
        retrieved and with it the transaction which will be immediately post-processed.

        :param dict pdt_data: The PDT notification data send by PayPal.
        """
        _logger.info("Handling redirection from PayPal with data:\n%s", pprint.pformat(pdt_data))

        tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
            'paypal', pdt_data
        )
        try:
            notification_data = self._verify_pdt_notification_origin(pdt_data, tx_sudo)
        except Forbidden:
            _logger.exception("Could not verify the origin of the PDT; discarding it.")
        else:
            tx_sudo._handle_notification_data('paypal', notification_data)

        return request.redirect('/payment/status')

    @http.route(
        _cancel_url, type='http', auth='public', methods=['GET'], csrf=False, save_session=False
    )
    def paypal_return_from_canceled_checkout(self, tx_ref, return_access_tkn):
        """ Process the transaction after the customer has canceled the payment.

        :param str tx_ref: The reference of the transaction having been canceled.
        :param str return_access_tkn: The access token to verify the authenticity of the request.
                                      PayPal forbids any parameter with the name "token" inside.
        """
        _logger.info(
            "Handling redirection from Paypal for cancellation of transaction with reference %s",
            tx_ref,
        )

        tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
            'paypal', {'item_number': tx_ref}
        )
        if not payment_utils.check_access_token(return_access_tkn, tx_ref):
            raise Forbidden()
        tx_sudo._handle_notification_data('paypal', {})

        return request.redirect('/payment/status')

    def _verify_pdt_notification_origin(self, pdt_data, tx_sudo):
        """ Validate the authenticity of a PDT and return the retrieved notification data.

        The validation is done in four steps:

        1. Make a POST request to Paypal with `tx`, the GET param received with the PDT data, and
           with the two other required params `cmd` and `at`.
        2. PayPal sends back a response text starting with either 'SUCCESS' or 'FAIL'. If the
           validation was a success, the notification data are appended to the response text as a
           string formatted as follows: 'SUCCESS\nparam1=value1\nparam2=value2\n...'
        3. Extract the notification data and process these instead of the PDT.
        4. Return an empty HTTP 200 response (done at the end of the route controller).

        See https://developer.paypal.com/docs/api-basics/notifications/payment-data-transfer/.

        :param dict pdt_data: The PDT data whose authenticity must be checked.
        :param recordset tx_sudo: The sudoed transaction referenced in the PDT, as a
                                  `payment.transaction` record
        :return: The retrieved notification data
        :raise :class:`werkzeug.exceptions.Forbidden`: if the notification origin can't be verified
        """
        if 'tx' not in pdt_data:  # PDT is not enabled; PayPal directly sent the notification data.
            tx_sudo._log_message_on_linked_documents(_(
                "The status of transaction with reference %(ref)s was not synchronized because the "
                "'Payment data transfer' option is not enabled on the PayPal dashboard.",
                ref=tx_sudo.reference,
            ))
            raise Forbidden("PayPal: PDT are not enabled; cannot verify data origin")
        else:  # The PayPal account is configured to send PDT data.
            # Request a PDT data authenticity check and the notification data to PayPal.
            provider_sudo = tx_sudo.provider_id
            url = provider_sudo._paypal_get_api_url()
            payload = {
                'cmd': '_notify-synch',
                'tx': pdt_data['tx'],
                'at': tx_sudo.provider_id.paypal_pdt_token,
            }
            try:
                response = requests.post(url, data=payload, timeout=10)
                response.raise_for_status()
            except (requests.exceptions.ConnectionError, requests.exceptions.HTTPError):
                raise Forbidden("PayPal: Encountered an error when verifying PDT origin")
            else:
                notification_data = self._parse_pdt_validation_response(response.text)
                if notification_data is None:
                    raise Forbidden("PayPal: The PDT origin was not verified by PayPal")

        return notification_data

    @staticmethod
    def _parse_pdt_validation_response(response_content):
        """ Parse the PDT validation request response and return the parsed notification data.

        :param str response_content: The PDT validation request response
        :return: The parsed notification data
        :rtype: dict
        """
        response_items = response_content.splitlines()
        if response_items[0] == 'SUCCESS':
            notification_data = {}
            for notification_data_param in response_items[1:]:
                key, raw_value = notification_data_param.split('=', 1)
                notification_data[key] = urls.url_unquote_plus(raw_value)
            return notification_data
        return None

    @http.route(_webhook_url, type='http', auth='public', methods=['GET', 'POST'], csrf=False)
    def paypal_webhook(self, **data):
        """ Process the notification data (IPN) sent by PayPal to the webhook.

        The "Instant Payment Notification" is a classical webhook notification.
        See https://developer.paypal.com/api/nvp-soap/ipn/.

        :param dict data: The notification data
        :return: An empty string to acknowledge the notification
        :rtype: str
        """
        _logger.info("notification received from PayPal with data:\n%s", pprint.pformat(data))
        try:
            # Check the origin and integrity of the notification
            tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
                'paypal', data
            )
            self._verify_webhook_notification_origin(data, tx_sudo)

            # Handle the notification data
            tx_sudo._handle_notification_data('paypal', data)
        except ValidationError:  # Acknowledge the notification to avoid getting spammed
            _logger.warning(
                "unable to handle the notification data; skipping to acknowledge", exc_info=True
            )
        return ''

    @staticmethod
    def _verify_webhook_notification_origin(notification_data, tx_sudo):
        """ Check that the notification was sent by PayPal.

        The verification is done in three steps:

        1. POST the complete message back to Paypal with the additional param
           `cmd=_notify-validate`, in the same encoding.
        2. PayPal sends back either 'VERIFIED' or 'INVALID'.
        3. Return an empty HTTP 200 response if the notification origin is verified by PayPal, raise
           an HTTP 403 otherwise.

        See https://developer.paypal.com/docs/api-basics/notifications/ipn/IPNIntro/.

        :param dict notification_data: The notification data
        :param recordset tx_sudo: The sudoed transaction referenced in the notification data, as a
                                        `payment.transaction` record
        :return: None
        :raise: :class:`werkzeug.exceptions.Forbidden` if the notification origin can't be verified
        """
        # Request PayPal for an authenticity check
        url = tx_sudo.provider_id._paypal_get_api_url()
        payload = dict(notification_data, cmd='_notify-validate')
        try:
            response = requests.post(url, payload, timeout=60)
            response.raise_for_status()
        except (requests.exceptions.ConnectionError, requests.exceptions.HTTPError) as error:
            _logger.exception(
                "could not verify notification origin at %(url)s with data: %(data)s:\n%(error)s",
                {
                    'url': url,
                    'data': pprint.pformat(notification_data),
                    'error': pprint.pformat(error.response.text),
                },
            )
            raise Forbidden()
        else:
            response_content = response.text
            if response_content != 'VERIFIED':
                _logger.warning(
                    "PayPal did not confirm the origin of the notification with data:\n%s",
                    pprint.pformat(notification_data),
                )
                raise Forbidden()
