# 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 logging
import urllib
import uuid

import braintree
from braintree.exceptions import TooManyRequestsError
from braintree.exceptions.braintree_error import BraintreeError

try:
    from braintree.exceptions import GatewayTimeoutError
except ImportError:
    class GatewayTimeoutError(Exception):
        pass

import trytond.config as config
from trytond.cache import Cache
from trytond.i18n import gettext
from trytond.model import (
    DeactivableMixin, Index, ModelSQL, ModelView, Workflow, fields)
from trytond.modules.account_payment.exceptions import (
    PaymentValidationError, ProcessError)
from trytond.modules.company.model import (
    employee_field, reset_employee, set_employee)
from trytond.modules.currency.fields import Monetary
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Bool, Eval
from trytond.report import Report
from trytond.tools import sql_pairing
from trytond.transaction import Transaction
from trytond.url import http_host
from trytond.wizard import Button, StateTransition, StateView, Wizard

from .common import BraintreeCustomerMethodMixin
from .exceptions import BraintreeAccountWarning

logger = logging.getLogger(__name__)

SUCCEEDED_STATUSES = {braintree.Transaction.Status.Settled}
FAILED_STATUSES = {
    braintree.Transaction.Status.AuthorizationExpired,
    braintree.Transaction.Status.SettlementDeclined,
    braintree.Transaction.Status.Failed,
    braintree.Transaction.Status.GatewayRejected,
    braintree.Transaction.Status.ProcessorDeclined,
    braintree.Transaction.Status.Voided,
    }
DISPUTE_FINAL_STATUSES = {
    braintree.Dispute.Status.Expired,
    braintree.Dispute.Status.Won,
    braintree.Dispute.Status.Lost,
    }


class PaymentJournal(metaclass=PoolMeta):
    __name__ = 'account.payment.journal'

    braintree_account = fields.Many2One(
        'account.payment.braintree.account', "Account", ondelete='RESTRICT',
        domain=[
            ('currency', '=', Eval('currency', -1)),
            ],
        states={
            'required': Eval('process_method') == 'braintree',
            'invisible': Eval('process_method') != 'braintree',
            })

    @classmethod
    def __setup__(cls):
        super().__setup__()
        braintree_method = ('braintree', "Braintree")
        if braintree_method not in cls.process_method.selection:
            cls.process_method.selection.append(braintree_method)


class PaymentGroup(metaclass=PoolMeta):
    __name__ = 'account.payment.group'

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls._buttons['succeed']['invisible'] |= (
            Eval('process_method') == 'braintree')


class CheckoutMixin:
    __slots__ = ()
    braintree_checkout_id = fields.Char(
        "Braintree Checkout ID", readonly=True, strip=False)
    braintree_client_token = fields.Char(
        "Braintree Client Token", readonly=True, strip=False)
    braintree_nonce = fields.Char(
        "Braintree Nonce", readonly=True, strip=False,
        states={
            'invisible': ~Eval('braintree_nonce'),
            })
    braintree_device_data = fields.Char(
        "Braintree Device Data", readonly=True, strip=False,
        states={
            'invisible': ~Eval('braintree_device_data'),
            })

    @classmethod
    def copy(cls, records, default=None):
        if default is None:
            default = {}
        else:
            default = default.copy()
        default.setdefault('braintree_checkout_id')
        default.setdefault('braintree_client_token', None)
        default.setdefault('braintree_nonce', None)
        default.setdefault('braintree_device_data', None)
        return super().copy(records, default=default)

    @classmethod
    @ModelView.button_action('account_payment_braintree.url_checkout')
    def braintree_checkout(cls, records):
        for record in records:
            record.braintree_checkout_id = uuid.uuid4().hex
            record.save()
            return {
                'url': record.braintree_checkout_url,
                }

    @property
    def braintree_checkout_url(self):
        pool = Pool()
        database = Transaction().database.name
        ModelData = pool.get('ir.model.data')
        URL = pool.get('ir.action.url')
        action = URL(ModelData.get_id(
                'account_payment_braintree.url_checkout'))
        return action.url % {
            'http_host': http_host(),
            'database': database,
            'model': self.__class__.__name__,
            'id': self.braintree_checkout_id,
            }

    def braintree_set_nonce(self, nonce, device_data=None):
        self.braintree_nonce = nonce
        self.braintree_device_data = device_data
        self.save()


class Payment(CheckoutMixin, BraintreeCustomerMethodMixin, metaclass=PoolMeta):
    __name__ = 'account.payment'

    braintree_transaction_id = fields.Char(
        "Braintree Transaction ID", readonly=True, strip=False,
        states={
            'invisible': ((Eval('process_method') != 'braintree')
                | ~Eval('braintree_transaction_id')),
            })
    braintree_settle_payment = fields.Boolean(
        "Braintree Settle Payment",
        states={
            'invisible': Eval('process_method') != 'braintree',
            'readonly': Eval('state') != 'draft',
            })
    braintree_payment_settled = fields.Boolean(
        "Braintree Payment Settled", readonly=True,
        states={
            'invisible': Eval('process_method') != 'braintree',
            })
    braintree_settlement_needed = fields.Function(
        fields.Boolean(
            "Braintree Settlement Needed",
            states={
                'invisible': Eval('process_method') != 'braintree',
                }),
        'get_braintree_settlement_needed')

    braintree_refunds = fields.One2Many(
        'account.payment.braintree.refund', 'payment', "Refunds",
        states={
            'invisible': ((Eval('process_method') != 'braintree')
                | ~Eval('braintree_transaction_id')),
            })

    braintree_dispute_reason = fields.Char(
        "Braintree Dispute Reason", readonly=True,
        states={
            'invisible': ~Eval('braintree_dispute_reason'),
            })
    braintree_dispute_status = fields.Char(
        "Braintree Dispute Status", readonly=True,
        states={
            'invisible': ~Eval('braintree_dispute_status'),
            })

    braintree_error_message = fields.Text(
        "Braintree Error Message", readonly=True,
        states={
            'invisible': (
                ~Eval('braintree_error_message')
                | (Eval('state') == 'succeeded')),
            })

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls.amount.states['readonly'] &= ~Eval('braintree_settlement_needed')

        cls.braintree_customer.states['readonly'] = (
                ~Eval('state').in_(['draft', 'submitted', 'approved'])
                | Eval('braintree_nonce'))

        cls.braintree_customer_method.states['invisible'] |= (
            Eval('braintree_nonce'))
        cls.braintree_customer_method.states['readonly'] = (
            ~Eval('state').in_(['draft', 'submitted', 'approved']))

        cls.braintree_customer_method_selection.states['invisible'] |= (
            Eval('braintree_nonce'))
        cls.braintree_customer_method_selection.states['readonly'] = (
            ~Eval('state').in_(['draft', 'submitted', 'approved']))

        cls._buttons.update({
                'braintree_checkout': {
                    'invisible': (~Eval('state', 'draft').in_(
                            ['submitted', 'approved', 'processing'])
                        | (Eval('process_method') != 'braintree')
                        | Eval('braintree_nonce')
                        | Eval('braintree_customer_method')),
                    'depends': [
                        'state',
                        'process_method',
                        'braintree_nonce',
                        'braintree_customer_method',
                        ],
                    },
                'braintree_do_settle_payment': {
                    'invisible': ((Eval('process_method') != 'braintree')
                        | ((Eval('state', 'draft') != 'processing')
                            | ~Eval('braintree_settlement_needed'))),
                    'depends': ['state', 'braintree_settlement_needed'],
                    },
                'braintree_do_pull': {
                    'invisible': ((Eval('process_method') != 'braintree')
                        | ~Eval('braintree_transaction_id')),
                    'depends': ['braintree_transaction_id'],
                    },
                })

    @classmethod
    def default_braintree_settle_payment(cls):
        return True

    @classmethod
    def default_braintree_payment_settled(cls):
        return False

    def get_braintree_settlement_needed(self, name):
        return (self.journal.process_method == 'braintree'
            and self.braintree_transaction_id
            and not self.braintree_settle_payment
            and not self.braintree_payment_settled)

    @classmethod
    def view_attributes(cls):
        return super().view_attributes() + [
            ('//page[@id="braintree"]', 'states', {
                    'invisible': Eval('process_method') != 'braintree',
                    }),
            ]

    @classmethod
    def validate_fields(cls, payments, field_names):
        super().validate_fields(payments, field_names)
        cls.check_braintree_journal(payments, field_names)

    @classmethod
    def check_braintree_journal(cls, payments, field_names=None):
        if field_names and not (field_names & {'kind', 'journal'}):
            return
        for payment in payments:
            if (payment.kind != 'receivable'
                    and payment.journal.process_method == 'braintree'):
                raise PaymentValidationError(gettext(
                        'account_payment_braintree.msg_braintree_receivable',
                        journal=payment.journal.rec_name,
                        payment=payment.rec_name))

    @classmethod
    def copy(cls, payments, default=None):
        if default is None:
            default = {}
        else:
            default = default.copy()
        default.setdefault('braintree_transaction_id', None)
        default.setdefault('braintree_payment_settled', False)
        default.setdefault('braintree_error_message')
        return super().copy(payments, default=default)

    @classmethod
    def braintree_checkout(cls, payments):
        for payment in payments:
            if payment.braintree_client_token:
                continue
            gateway = payment.braintree_account.gateway()
            client_token = gateway.client_token.generate()
            payment.braintree_client_token = client_token
        return super().braintree_checkout(payments)

    @classmethod
    @ModelView.button
    @Workflow.transition('draft')
    def draft(cls, payments):
        super().draft(payments)
        for payment in payments:
            if payment.braintree_nonce:
                payment.braintree_nonce = None
                payment.braintree_device_data = None
        cls.save(payments)

    def process_braintree(self):
        assert self.process_method == 'braintree'
        if not self.braintree_nonce and not self.braintree_customer:
            account = self.journal.braintree_account
            for customer in self.party.braintree_customers:
                if (customer.braintree_account == account
                        and customer.braintree_customer_id):
                    self.braintree_customer = customer
                    break
            else:
                raise ProcessError(
                    gettext(
                        'account_payment_braintree.msg_no_braintree_nonce',
                        self=self.rec_name))
        self.save()
        self.__class__.__queue__.braintree_transact([self])

    @classmethod
    def braintree_transact(cls, payments=None):
        """Create transactions for braintree payments

        The transaction is committed after each payment sale."""
        if payments is None:
            payments = cls.search([
                    ('state', '=', 'processing'),
                    ('journal.process_method', '=', 'braintree'),
                    ['OR',
                        ('braintree_nonce', '!=', None),
                        ('braintree_customer_method', '!=', None),
                        ('braintree_customer', '!=', None),
                        ],
                    ('braintree_transaction_id', '=', None),
                    ('company', '=', Transaction().context.get('company')),
                    ])

        for payment in payments:
            # Use clear cache after a commit
            payment = cls(payment.id)
            if (payment.braintree_transaction_id
                    or payment.journal.process_method != 'braintree'
                    or payment.state != 'processing'):
                continue
            payment.lock()
            gateway = payment.braintree_account.gateway()
            try:
                result = gateway.transaction.sale(
                    payment._braintree_transaction_parameters())
            except TooManyRequestsError as e:
                logger.warning(str(e))
                continue
            except BraintreeError as e:
                payment.braintree_error_message = str(e)
                payment.save()
                cls.fail([payment])
            except Exception:
                logger.error(
                    "Error when processing payment %d", payment.id,
                    exc_info=True)
                continue
            else:
                if result.is_success:
                    payment.braintree_transaction_id = result.transaction.id
                    payment.braintree_payment_settled = (
                        payment.braintree_settle_payment)
                    payment.braintree_update(result.transaction)
                    if (payment.braintree_account.environment == 'sandbox'
                            and (payment.braintree_account
                                .sandbox_settle_transaction)
                            and payment.braintree_settle_payment):
                        gateway.testing.settle_transaction(
                            result.transaction.id)
                else:
                    payment.braintree_error_message = result.message
                    payment.save()
                    cls.fail([payment])
            Transaction().commit()

    def _braintree_transaction_parameters(self):
        params = {
            'amount': self.amount,
            'options': {
                'submit_for_settlement': self.braintree_settle_payment,
                },
            }
        if self.braintree_nonce:
            params['payment_method_nonce'] = self.braintree_nonce
        elif self.braintree_customer_method:
            params['payment_method_token'] = self.braintree_customer_method
        elif self.braintree_customer:
            params['customer_id'] = (
                self.braintree_customer.braintree_customer_id)
        if self.braintree_device_data:
            params['device_data'] = self.braintree_device_data
        return params

    @classmethod
    @ModelView.button
    def braintree_do_settle_payment(cls, payments):
        cls.write(payments, {
                'braintree_settle_payment': True,
                })
        cls.__queue__.braintree_settle_payment_(payments)

    @classmethod
    def braintree_settle_payment_(cls, payments=None):
        """Settle braintree payments

        The transaction is committed after each payment settlement."""
        if payments is None:
            payments = cls.search([
                    ('state', '=', 'processing'),
                    ('journal.process_method', '=', 'braintree'),
                    ('braintree_transaction_id', '!=', None),
                    ('braintree_payment_settled', '=', False),
                    ('braintree_settle_payment', '=', True),
                    ('company', '=', Transaction().context.get('company')),
                    ])

        for payment in payments:
            # Use clear cache after a commit
            payment = cls(payment.id)
            if (payment.journal.process_method != 'braintree'
                    or payment.braintree_payment_settled
                    or payment.state != 'processing'):
                continue
            payment.lock()
            braintree_account = payment.braintree_account
            gateway = braintree_account.gateway()
            try:
                result = gateway.transaction.submit_for_settlement(
                    payment.braintree_transaction_id,
                    payment.amount,
                    payment._braintree_settlement_parameters())
            except TooManyRequestsError as e:
                logger.warning(str(e))
                continue
            except BraintreeError as e:
                payment.braintree_error_message = str(e)
                payment.save()
                cls.fail([payment])
            except Exception:
                logger.error(
                    "Error when processing payment %d", payment.id,
                    exc_info=True)
                continue
            else:
                if result.is_success:
                    payment.braintree_payment_settled = True
                    payment.braintree_update(result.transaction)
                    if (braintree_account.environment == 'sandbox'
                            and (braintree_account
                                .sandbox_settle_transaction)):
                        gateway.testing.settle_transaction(
                            result.transaction.id)
                else:
                    payment.braintree_error_message = result.message
                    payment.save()
                    cls.fail([payment])
            Transaction().commit()

    def _braintree_settlement_parameters(self):
        return {}

    @classmethod
    @ModelView.button
    def braintree_do_pull(cls, payments):
        cls.braintree_pull(payments)

    @classmethod
    def braintree_pull(cls, payments=None):
        "Update payments with braintree transactions"
        if payments is None:
            payments = cls.search([
                    ('state', '=', 'processing'),
                    ('journal.process_method', '=', 'braintree'),
                    ('braintree_transaction_id', '!=', None),
                    ('company', '=', Transaction().context.get('company')),
                    ])

        for payment in payments:
            gateway = payment.braintree_account.gateway()
            try:
                transaction = gateway.transaction.find(
                    payment.braintree_transaction_id)
            except TooManyRequestsError as e:
                logger.warning(str(e))
                continue
            except Exception:
                logger.error(
                    "Error when pulling payment %d", payment.id,
                    exc_info=True)
                continue
            else:
                payment.braintree_update(transaction)
        cls.save(payments)

    def braintree_update(self, transaction):
        "Update payment with braintree transaction"
        assert transaction.id == self.braintree_transaction_id
        gateway = transaction.gateway
        amount = transaction.amount
        for id_ in transaction.refund_ids:
            refund = gateway.transaction.find(id_)
            if refund.status in SUCCEEDED_STATUSES:
                amount -= refund.amount
        for dispute in transaction.disputes:
            if dispute.status in DISPUTE_FINAL_STATUSES:
                amount -= dispute.amount_disputed - dispute.amount_won
        if (self.state not in {'succeeded', 'failed'}
                or self.amount != amount
                or (not amount and self.state != 'failed')):
            if self.state == 'succeeded':
                self.__class__.proceed([self])
            if transaction.status in SUCCEEDED_STATUSES:
                self.braintree_error_message = None
            self.amount = amount
            self.save()
            if transaction.status in SUCCEEDED_STATUSES:
                if amount:
                    self.__class__.succeed([self])
                else:
                    self.__class__.fail([self])
            elif transaction.status in FAILED_STATUSES:
                self.__class__.fail([self])


class PaymentBraintreeRefund(Workflow, ModelSQL, ModelView):
    __name__ = 'account.payment.braintree.refund'

    payment = fields.Many2One(
        'account.payment', "Payment", required=True,
        domain=[
            ('process_method', '=', 'braintree'),
            ('braintree_transaction_id', '!=', None),
            ],
        states={
            'readonly': Eval('state') != 'draft',
            })
    amount = Monetary(
        "Amount", currency='currency', digits='currency', required=True,
        states={
            'readonly': Eval('state') != 'draft',
            })
    submitted_by = employee_field(
        "Submitted by",
        states=['submitted', 'approved', 'processing', 'succeeded', 'failed'])
    approved_by = employee_field(
        "Approved by",
        states=['approved', 'processing', 'succeeded', 'failed'])

    state = fields.Selection([
            ('draft', "Draft"),
            ('submitted', "Submitted"),
            ('approved', "Approved"),
            ('processing', "Processing"),
            ('succeeded', "Succeeded"),
            ('failed', "Failed"),
            ], "State", readonly=True, sort=False)

    braintree_transaction_id = fields.Char(
        "Braintree Transaction ID", readonly=True, strip=False)

    braintree_error_message = fields.Text(
        "Braintree Error Message", readonly=True,
        states={
            'invisible': (
                ~Eval('braintree_error_message')
                | (Eval('state') == 'succeeded')),
            })

    currency = fields.Function(fields.Many2One(
            'currency.currency', "Currency"),
        'on_change_with_currency')
    company = fields.Function(
        fields.Many2One('company.company', "Company"),
        'on_change_with_company')

    @classmethod
    def __setup__(cls):
        super().__setup__()
        t = cls.__table__()
        cls._sql_indexes.add(
            Index(
                t,
                (t.state, Index.Equality(cardinality='low')),
                where=t.state.in_([
                        'draft', 'submitted', 'approved', 'processing'])))
        cls.__access__.add('payment')
        cls._transitions |= set((
                ('draft', 'submitted'),
                ('submitted', 'approved'),
                ('approved', 'processing'),
                ('processing', 'succeeded'),
                ('processing', 'failed'),
                ('approved', 'draft'),
                ))
        cls._buttons.update({
                'draft': {
                    'invisible': ~Eval('state').in_(['approved', 'submitted']),
                    'icon': 'tryton-back',
                    'depends': ['state'],
                    },
                'submit': {
                    'invisible': Eval('state') != 'draft',
                    'icon': 'tryton-forward',
                    'depends': ['state'],
                    },
                'approve': {
                    'invisible': Eval('state') != 'submitted',
                    'icon': 'tryton-forward',
                    'depends': ['state'],
                    },
                })

    @classmethod
    def default_state(cls):
        return 'draft'

    @fields.depends('payment', '_parent_payment.currency')
    def on_change_with_currency(self, name=None):
        return self.payment.currency if self.payment else None

    @fields.depends('payment', '_parent_payment.company')
    def on_change_with_company(self, name=None):
        return self.payment.company if self.payment else None

    @classmethod
    def copy(cls, refunds, default=None):
        if default is None:
            default = {}
        else:
            default = default.copy()
        default.setdefault('braintree_transaction_id')
        default.setdefault('braintree_error_message')
        return super().copy(refunds, default=default)

    @classmethod
    @ModelView.button
    @Workflow.transition('draft')
    @reset_employee('submitted_by', 'approved_by')
    def draft(cls, refunds):
        pass

    @classmethod
    @ModelView.button
    @Workflow.transition('submitted')
    @set_employee('submitted_by')
    def submit(cls, refunds):
        pass

    @classmethod
    @ModelView.button
    @Workflow.transition('approved')
    @set_employee('approved_by')
    def approve(cls, refunds):
        pass

    @classmethod
    @Workflow.transition('processing')
    def process(cls, refunds):
        pass

    @classmethod
    @Workflow.transition('succeeded')
    def succeed(cls, refunds):
        pass

    @classmethod
    @Workflow.transition('failed')
    def fail(cls, refunds):
        pass

    @classmethod
    def braintree_refund(cls, refunds=None):
        """Refund braintree transaction

        The transaction is committed after each refund."""
        if refunds is None:
            refunds = cls.search([('state', '=', 'approved')])

        for refund in refunds:
            # User clear cache after a commit
            refund = cls(refund.id)
            if refund.braintree_transaction_id:
                continue
            refund.lock()
            braintree_account = refund.payment.braintree_account
            gateway = braintree_account.gateway()
            try:
                result = gateway.transaction.refund(
                    refund.payment.braintree_transaction_id,
                    refund._refund_parameters())
            except TooManyRequestsError as e:
                logger.warning(str(e))
                continue
            except BraintreeError as e:
                refund.braintree_error_message = str(e)
                refund.save()
                cls.process([refund])
                cls.fail([refund])
            except Exception:
                logger.error(
                    "Error when refund %d", refund.id,
                    exc_info=True)
                continue
            else:
                cls.process([refund])
                if result.is_success:
                    refund.braintree_transaction_id = result.transaction.id
                    refund.braintree_update(result.transaction)
                    if (braintree_account.environment == 'sandbox'
                            and (braintree_account
                                .sandbox_settle_transaction)):
                        gateway.testing.settle_transaction(
                            result.transaction.id)
                else:
                    refund.braintree_error_message = result.message
                    refund.save()
                    cls.fail([refund])
            Transaction().commit()

    def _refund_parameters(self):
        return {
            'amount': self.amount,
            }

    @classmethod
    def braintree_pull(cls, refunds=None):
        "Update refund with braintree transactions"
        if refunds is None:
            refunds = cls.search([
                    ('state', '=', 'processing'),
                    ('braintree_transaction_id', '!=', None),
                    ('payment.company',
                        '=', Transaction().context.get('company')),
                    ])

        for refund in refunds:
            gateway = refund.payment.braintree_account.gateway()
            try:
                transaction = gateway.transaction.find(
                    refund.braintree_transaction_id)
            except TooManyRequestsError as e:
                logger.warning(str(e))
                continue
            except Exception:
                logger.error(
                    "Error when pulling refund %d", refund.id,
                    exc_info=True)
                continue
            else:
                refund.braintree_update(transaction)
        cls.save(refunds)
        for refund in refunds:
            if refund.state in {'succeeded', 'failed'}:
                payment = refund.payment
                gateway = payment.braintree_account.gateway()
                transaction = gateway.transaction.find(
                    payment.braintree_transaction_id)
                payment.braintree_update(transaction)

    def braintree_update(self, transaction):
        "Update refund with braintree transaction"
        self.amount = transaction.amount
        self.save()
        if transaction.status in SUCCEEDED_STATUSES:
            self.__class__.succeed([self])
        elif transaction.status in FAILED_STATUSES:
            self.__class__.fail([self])


class PaymentBraintreeAccount(ModelSQL, ModelView):
    __name__ = 'account.payment.braintree.account'

    name = fields.Char("Name", required=True)
    currency = fields.Many2One('currency.currency', "Currency", required=True)
    environment = fields.Selection([
            ('sandbox', "Sandbox"),
            ('production', "Production"),
            ], "Environment", required=True)
    merchant_id = fields.Char("Merchant ID", required=True, strip=False)
    public_key = fields.Char("Public Key", required=True, strip=False)
    private_key = fields.Char("Private Key", required=True, strip=False)
    webhook_identifier = fields.Char(
        "Webhook Identifier", readonly=True, strip=False)
    webhook_endpoint = fields.Function(
        fields.Char(
            "Webhook Endpoint",
            help="The URL to be called by Braintree."),
        'on_change_with_webhook_endpoint')
    sandbox_settle_transaction = fields.Boolean(
        "Automatic Settlement",
        states={
            'invisible': Eval('environment') != 'sandbox',
            })

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls._buttons.update({
                'new_identifier': {
                    'icon': 'tryton-refresh',
                    },
                })

    @classmethod
    def default_environment(cls):
        return 'sandbox'

    @classmethod
    def default_sandbox_settle_transaction(cls):
        return False

    @fields.depends('webhook_identifier')
    def on_change_with_webhook_endpoint(self, name=None):
        if not self.webhook_identifier:
            return
        url_part = {
            'identifier': self.webhook_identifier,
            'database_name': Transaction().database.name,
            }
        return http_host() + (
            urllib.parse.quote(
                '/%(database_name)s/account_payment_braintree'
                '/webhook/%(identifier)s' % url_part))

    @property
    def configuration(self):
        return braintree.Configuration(
            environment=braintree.Environment.All[self.environment],
            merchant_id=self.merchant_id,
            public_key=self.public_key,
            private_key=self.private_key,
            )

    def gateway(self):
        return braintree.BraintreeGateway(self.configuration)

    @classmethod
    @ModelView.button
    def new_identifier(cls, accounts):
        for account in accounts:
            if account.webhook_identifier:
                account.webhook_identifier = None
            else:
                account.webhook_identifier = uuid.uuid4().hex
        cls.save(accounts)

    def webhook(self, notification):
        """Handles Braintree webhook notification

        The return values must be:
            - None if the method could not handle the notification kind
            - True if the notification has been handled
            - False if the notification should be retried by Braintree
        """
        if notification.kind in {
                braintree.WebhookNotification.Kind.DisputeOpened,
                braintree.WebhookNotification.Kind.DisputeLost,
                braintree.WebhookNotification.Kind.DisputeWon,
                braintree.WebhookNotification.Kind.DisputeAccepted,
                braintree.WebhookNotification.Kind.DisputeExpired,
                braintree.WebhookNotification.Kind.DisputeDisputed}:
            return self.webhook_dispute(notification.dispute)
        elif notification.kind in {
                braintree.WebhookNotification.Kind.
                TransactionSettlementDeclined,
                braintree.WebhookNotification.Kind.TransactionSettled}:
            return self.webhook_transaction(notification.transaction)
        return None

    def webhook_dispute(self, dispute):
        pool = Pool()
        Payment = pool.get('account.payment')

        payments = Payment.search([
                ('braintree_transaction_id', '=', dispute.transaction.id),
                ])
        if not payments:
            logger.error(
                "webhook dispute: No payment '%s'", dispute.transaction.id)
        for payment in payments:
            payment.braintree_dispute_reason = dispute.reason
            payment.braintree_dispute_status = dispute.status
            payment.save()
            gateway = payment.braintree_account.gateway()
            transaction = gateway.transaction.find(
                payment.braintree_transaction_id)
            payment.braintree_update(transaction)
        return bool(payments)

    def webhook_transaction(self, transaction):
        pool = Pool()
        Payment = pool.get('account.payment')

        payments = Payment.search([
                ('braintree_transaction_id', '=', transaction.id),
                ])
        if not payments:
            logger.error(
                "webhook transaction: No payment '%s'", transaction.id)
        for payment in payments:
            payment.braintree_update(transaction)
        return bool(payments)

    @classmethod
    def check_modification(cls, mode, accounts, values=None, external=False):
        pool = Pool()
        Warning = pool.get('res.user.warning')

        super().check_modification(
            mode, accounts, values=values, external=external)

        if (mode == 'write'
                and external
                and values.keys()
                & {'environment', 'merchant_id', 'public_key', 'private_key'}):
            warning_name = Warning.format('braintree_key', accounts)
            if Warning.check(warning_name):
                raise BraintreeAccountWarning(
                    warning_name,
                    gettext('account_payment_braintree'
                        '.msg_braintree_key_modified'))


class PaymentBraintreeCustomer(
        CheckoutMixin, DeactivableMixin, ModelSQL, ModelView):
    __name__ = 'account.payment.braintree.customer'
    _history = True
    _rec_name = 'braintree_customer_id'
    party = fields.Many2One(
        'party.party', "Party", required=True,
        states={
            'readonly': (
                Eval('braintree_customer_id') | Eval('braintree_nonce')),
            })
    braintree_account = fields.Many2One(
        'account.payment.braintree.account', "Account", required=True,
        states={
            'readonly': (
                Eval('braintree_customer_id') | Eval('braintree_nonce')),
            })
    braintree_customer_id = fields.Char(
        "Braintree Customer ID", strip=False,
        states={
            'readonly': Eval('braintree_customer_id') & (Eval('id', -1) >= 0),
            })
    braintree_error_message = fields.Text(
        "Braintree Error Message", readonly=True,
        states={
            'invisible': ~Eval('braintree_error_message'),
            })

    identical_customers = fields.Many2Many(
        'account.payment.braintree.customer.identical',
        'source', 'target', "Identical Customers", readonly=True,
        states={
            'invisible': ~Eval('identical_customers'),
            })

    _payment_methods_cache = Cache(
        'account_payment_braintree_customer.payment_methods',
        duration=config.getint(
            'account_payment_braintree', 'payment_methods_cache',
            default=15 * 60),
        context=False)

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls._buttons.update({
                'braintree_checkout': {
                    'invisible': Bool(Eval('braintree_nonce')),
                    'depends': ['braintree_nonce'],
                    },
                'braintree_update': {
                    'invisible': ~Eval('braintree_customer_id'),
                    'depends': ['braintree_customer_id'],
                    },
                'delete_payment_method': {
                    'invisible': ~Eval('braintree_customer_id'),
                    'depends': ['braintree_customer_id'],
                    },
                })

    def get_rec_name(self, name):
        name = super().get_rec_name(name)
        return (
            self.braintree_customer_id if self.braintree_customer_id else name)

    @classmethod
    def delete(cls, customers):
        ids, on_delete = cls._before_delete(customers)
        cls.write(customers, {
                'active': False,
                })
        cls._after_delete(ids, on_delete)

    @classmethod
    def copy(cls, customers, default=None):
        if default is None:
            default = {}
        else:
            default = default.copy()
        default.setdefault('braintree_customer_id', None)
        return super().copy(customers, default=default)

    @classmethod
    def braintree_checkout(cls, customers):
        for customer in customers:
            if customer.braintree_client_token:
                continue
            gateway = customer.braintree_account.gateway()
            client_token = gateway.client_token.generate(
                customer._client_token_parameters())
            customer.braintree_client_token = client_token
        return super().braintree_checkout(customers)

    def _client_token_parameters(self):
        params = {}
        if self.braintree_customer_id:
            params['customer_id'] = self.braintree_customer_id
        return params

    def braintree_set_nonce(self, nonce, device_data=None):
        super().braintree_set_nonce(nonce, device_data=device_data)
        self.braintree_create(customers=[self])

    @classmethod
    def braintree_create(cls, customers=None):
        """Creates braintree customers

        The transaction is committed after each customer."""
        if not customers:
            customers = cls.search([
                    ('braintree_nonce', '!=', None),
                    ])
        for customer in customers:
            # Use clear cache after commit
            customer = cls(customer.id)
            if not customer.braintree_nonce:
                continue
            customer.lock()
            gateway = customer.braintree_account.gateway()
            try:
                if not customer.braintree_customer_id:
                    result = gateway.customer.create(
                        customer._customer_parameters())
                else:
                    result = gateway.payment_method.create({
                            'customer_id': customer.braintree_customer_id,
                            'payment_method_nonce': customer.braintree_nonce,
                            })
            except TooManyRequestsError as e:
                logger.warning(str(e))
                continue
            except BraintreeError as e:
                customer.braintree_error_message = str(e)
            except Exception:
                logger.error(
                    "Error when creating customer %d", customer.id,
                    exc_info=True)
                continue
            else:
                if result.is_success:
                    if not customer.braintree_customer_id:
                        customer.braintree_customer_id = result.customer.id
                    customer.braintree_error_message = None
                else:
                    customer.braintree_error_message = result.message
            customer.braintree_client_token = None
            customer.braintree_nonce = None
            customer.save()
            cls._payment_methods_cache.clear()
            Transaction().commit()

    def _customer_parameters(self):
        params = {
            'email': self.party.email,
            'fax': self.party.fax[:255],
            'last_name': self.party.name[:255],
            'phone': self.party.phone[:255],
            'website': self.party.website[:255],
            }
        if self.braintree_nonce:
            params['payment_method_nonce'] = self.braintree_nonce
        return params

    @classmethod
    @ModelView.button
    def braintree_update(cls, customers):
        for customer in customers:
            if not customer.braintree_customer_id:
                continue
            gateway = customer.braintree_account.gateway()
            try:
                gateway.customer.update(
                    customer.braintree_customer_id,
                    customer._customer_parameters())
            except TooManyRequestsError as e:
                logger.warning(str(e))
                raise

    @classmethod
    def braintree_delete(cls, customers=None):
        """Deletes braintree customers

        The transaction is committed after each customer."""
        if not customers:
            customers = cls.search([
                    ('active', '=', False),
                    ('braintree_customer_id', '!=', None),
                    ])
        for customer in customers:
            # Use clear cache after commit
            customer = cls(customer.id)
            assert not customer.active
            customer.lock()
            gateway = customer.braintree_account.gateway()
            try:
                result = gateway.customer.delete(
                    customer.braintree_customer_id)
            except TooManyRequestsError as e:
                logger.warning(str(e))
                continue
            except Exception:
                logger.error(
                    "Error when deleting customer %d", customer.id,
                    exc_info=True)
                continue
            if result.is_success:
                customer.braintree_customer_id = None
            customer.save()
            Transaction().commit()

    def find(self):
        if not self.braintree_customer_id:
            return
        gateway = self.braintree_account.gateway()
        try:
            return gateway.customer.find(self.braintree_customer_id)
        except (TooManyRequestsError,
                GatewayTimeoutError) as e:
            logger.warning(str(e))

    def payment_methods(self):
        methods = self._payment_methods_cache.get(self.id)
        if methods is not None:
            return methods
        methods = []
        customer = self.find()
        if customer:
            for payment_method in customer.payment_methods:
                name = self._payment_method_name(payment_method)
                methods.append((payment_method.token, name))
        return self._payment_methods_cache.set(self.id, methods)

    def _payment_method_name(cls, payment_method):
        name = payment_method.token
        if hasattr(payment_method, 'card_type'):
            name = payment_method.card_type
            if payment_method.last_4:
                name += ' ****' + payment_method.last_4
            if (payment_method.expiration_month
                    and payment_method.expiration_year):
                name += ' %s/%s' % (
                    payment_method.expiration_month,
                    payment_method.expiration_year)
        elif isinstance(payment_method, braintree.PayPalAccount):
            name = "Paypal %s" % payment_method.email
        elif isinstance(payment_method, braintree.VenmoAccount):
            name = "Venmo %s" % payment_method.source_description
        elif isinstance(payment_method, braintree.UsBankAccount):
            name = "****" + payment_method.last_4
        return name

    @classmethod
    @ModelView.button_action(
        'account_payment_braintree.wizard_customer_payment_method_delete')
    def delete_payment_method(cls, customers):
        pass

    def delete_payment_method_(self, payment_method):
        gateway = self.braintree_account.gateway()
        try:
            result = gateway.payment_method.delete(payment_method)
        except (TooManyRequestsError,
                GatewayTimeoutError) as e:
            logger.warning(str(e))
        else:
            if not result.is_success:
                logger.error(result.message)
        self._payment_methods_cache.clear()


class PaymentBraintreeCustomerIdentical(ModelSQL):
    __name__ = 'account.payment.braintree.customer.identical'
    source = fields.Many2One('account.payment.braintree.customer', "Source")
    target = fields.Many2One('account.payment.braintree.customer', "Target")

    @classmethod
    def table_query(cls):
        pool = Pool()
        Customer = pool.get('account.payment.braintree.customer')
        source = Customer.__table__()
        target = Customer.__table__()
        return (
            source
            .join(target, condition=(
                    source.braintree_customer_id
                    == target.braintree_customer_id))
            .select(
                sql_pairing(source.id, target.id).as_('id'),
                source.id.as_('source'),
                target.id.as_('target'),
                where=source.id != target.id))


class PaymentBraintreeCustomerPaymentMethodDelete(Wizard):
    __name__ = 'account.payment.braintree.customer.payment_method.delete'
    start_state = 'ask'
    ask = StateView(
        'account.payment.braintree.customer.payment_method.delete.ask',
        'account_payment_braintree.'
        'customer_payment_method_delete_ask_view_form', [
            Button("Cancel", 'end', 'tryton-cancel'),
            Button("Delete", 'delete_', 'tryton-ok', default=True),
            ])
    delete_ = StateTransition()

    def default_ask(self, fields):
        default = {}
        if 'customer' in fields:
            default['customer'] = self.record.id
        return default

    def transition_delete_(self):
        self.record.delete_payment_method_(self.ask.payment_method)
        return 'end'


class PaymentBraintreeCustomerPaymentMethodDeleteAsk(ModelView):
    __name__ = 'account.payment.braintree.customer.payment_method.delete.ask'

    customer = fields.Many2One(
        'account.payment.braintree.customer', "Customer", readonly=True)
    payment_method = fields.Selection(
        'get_payment_methods', "Payment Method", required=True)

    @fields.depends('customer')
    def get_payment_methods(self):
        methods = [('', '')]
        if self.customer:
            methods.extend(self.customer.payment_methods())
        return methods


class PaymentBraintreeCheckoutPage(Report):
    __name__ = 'account.payment.braintree.checkout'
