import logging
import threading
import time
from unittest.mock import patch

import psycopg2

from odoo import SUPERUSER_ID, api, fields, tools
from odoo.tests import Form, TransactionCase, tagged

_logger = logging.getLogger(__name__)


class ThreadRaiseJoin(threading.Thread):
    """Custom Thread Class to raise the exception to main thread in the join"""

    def run(self, *args, **kwargs):
        self.exc = None
        try:
            return super().run(*args, **kwargs)
        except BaseException as e:
            self.exc = e

    def join(self, *args, **kwargs):
        res = super().join(*args, **kwargs)
        # Wait for the thread finishes
        while self.is_alive():
            pass
        # raise exception in the join
        # to raise it in the main thread
        if self.exc:
            raise self.exc
        return res


@tagged("post_install", "-at_install", "test_move_sequence")
class TestSequenceConcurrency(TransactionCase):
    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.product = cls.env.ref("product.product_delivery_01")
        cls.partner = cls.env.ref("base.res_partner_12")
        cls.partner2 = cls.env.ref("base.res_partner_1")
        cls.date = fields.Date.to_date("1985-04-14")
        cls.journal_sale_std = cls.env.ref(
            "account_move_name_sequence.journal_sale_std_demo"
        )
        cls.journal_cash_std = cls.env.ref(
            "account_move_name_sequence.journal_cash_std_demo"
        )

        cls.cr0 = cls.cursor(cls)
        cls.env0 = api.Environment(cls.cr0, SUPERUSER_ID, {})
        cls.cr1 = cls.cursor(cls)
        cls.env1 = api.Environment(cls.cr1, SUPERUSER_ID, {})
        cls.cr2 = cls.cursor(cls)
        cls.env2 = api.Environment(cls.cr2, SUPERUSER_ID, {})
        for cr in [cls.cr0, cls.cr1, cls.cr2]:
            # Set a 10-second timeout to avoid waiting too long for release locks
            cr.execute("SET LOCAL statement_timeout = '10s'")
        cls.registry.enter_test_mode(cls.cursor(cls))
        cls.addClassCleanup(cls.registry.leave_test_mode)
        cls.last_existing_move = cls.env["account.move"].search(
            [], limit=1, order="id desc"
        )

    @classmethod
    def _clean_moves_and_payments(cls, last_move):
        """Delete moves and payments created after finish test."""
        moves = (
            cls.env["account.move"]
            .with_context(force_delete=True)
            .search([("id", ">=", last_move)])
        )
        payments = moves.payment_ids
        moves_without_payments = moves - payments.move_id
        if payments:
            payments.action_draft()
            payments.unlink()
        if moves_without_payments:
            moves_without_payments.filtered(
                lambda move: move.state != "draft"
            ).button_draft()
            moves_without_payments.unlink()

    def _commit_crs(self, *envs):
        for env in envs:
            env.cr.commit()

    def _create_invoice_form(
        self, env, post=True, partner=None, ir_sequence_standard=False
    ):
        ctx = {"default_move_type": "out_invoice"}
        with Form(env["account.move"].with_context(**ctx)) as invoice_form:
            # Use another partner to bypass "increase_rank" lock error
            invoice_form.partner_id = partner or self.partner
            invoice_form.invoice_date = self.date

            with invoice_form.invoice_line_ids.new() as line_form:
                line_form.product_id = self.product
                line_form.price_unit = 100.0
                line_form.tax_ids.clear()
            invoice = invoice_form.save()
        if ir_sequence_standard:
            invoice.journal_id = self.journal_sale_std
        if post:
            # This patch was added to avoid test failures in the CI pipeline caused by
            # the `account_journal_restrict_mode` module. It avoids errors when setting
            # posted moves to draft and deleting them by bypassing the method that
            # writes the hash field used for validation.
            with patch(
                "odoo.addons.account.models.account_move.AccountMove._hash_moves"
            ):
                invoice.action_post()
        return invoice

    def _create_payment_form(self, env, partner=None, ir_sequence_standard=False):
        with Form(
            env["account.payment"].with_context(
                default_payment_type="inbound",
                default_partner_type="customer",
                default_move_journal_types=("bank", "cash"),
            )
        ) as payment_form:
            payment_form.partner_id = partner or self.partner
            payment_form.amount = 100
            payment_form.date = self.date
            if ir_sequence_standard:
                payment_form.journal_id = self.journal_cash_std
            payment = payment_form.save()
        # This patch was added to avoid test failures in the CI pipeline caused by
        # the `account_journal_restrict_mode` module. It avoids errors when setting
        # posted moves to draft and deleting them by bypassing the method that
        # writes the hash field used for validation.
        with patch("odoo.addons.account.models.account_move.AccountMove._hash_moves"):
            payment.action_post()
        return payment

    def _create_invoice_payment(
        self, deadlock_timeout, payment_first=False, ir_sequence_standard=False
    ):
        with self.cursor() as cr, cr.savepoint():
            env = api.Environment(cr, SUPERUSER_ID, {})
            cr_pid = cr.connection.get_backend_pid()
            # Avoid waiting for a long time and it needs to be less than deadlock
            cr.execute("SET LOCAL statement_timeout = '%ss'", (deadlock_timeout + 10,))
            if payment_first:
                _logger.info("Creating payment cr %s", cr_pid)
                self._create_payment_form(
                    env, ir_sequence_standard=ir_sequence_standard
                )
                _logger.info("Creating invoice cr %s", cr_pid)
                self._create_invoice_form(
                    env, ir_sequence_standard=ir_sequence_standard
                )
            else:
                _logger.info("Creating invoice cr %s", cr_pid)
                self._create_invoice_form(
                    env, ir_sequence_standard=ir_sequence_standard
                )
                _logger.info("Creating payment cr %s", cr_pid)
                self._create_payment_form(
                    env, ir_sequence_standard=ir_sequence_standard
                )
        # sleep in order to avoid release the locks too faster
        # It could be many methods called after creating these
        # kind of records e.g. reconcile
        _logger.info("Finishing waiting %s", (deadlock_timeout + 12))
        time.sleep(deadlock_timeout + 12)

    def test_sequence_concurrency_10_draft_invoices(self):
        """Creating 2 DRAFT invoices not should raises errors"""
        # Create "last move" to lock
        self._create_invoice_form(self.env0)
        self.cr0.commit()
        with self.cr1.savepoint(), self.cr2.savepoint():
            invoice1 = self._create_invoice_form(self.env1, post=False)
            self.assertEqual(invoice1.state, "draft")
            invoice2 = self._create_invoice_form(self.env2, post=False)
            self.assertEqual(invoice2.state, "draft")
        self._commit_crs(self.env0, self.env1, self.env2)

    def test_sequence_concurrency_20_editing_last_invoice(self):
        """Edit last invoice and create a new invoice
        should not raises errors"""
        # Create "last move" to lock
        invoice = self._create_invoice_form(self.env0)
        self.cr0.commit()
        with self.cr0.savepoint(), self.cr1.savepoint():
            # Edit something in "last move"
            invoice.write({"write_uid": self.env0.uid})
            self.env0.flush_all()
            self._create_invoice_form(self.env1)
        self._commit_crs(self.env0, self.env1)

    def test_sequence_concurrency_30_editing_last_payment(self):
        """Edit last payment and create a new payment
        should not raises errors"""
        # Create "last move" to lock
        payment = self._create_payment_form(self.env0)
        payment_move = payment.move_id
        self.cr0.commit()
        with self.cr0.savepoint(), self.cr1.savepoint():
            # Edit something in "last move"
            payment_move.write({"write_uid": self.env0.uid})
            self.env0.flush_all()
            self._create_payment_form(self.env1)
        self._commit_crs(self.env0, self.env1)

    @tools.mute_logger("odoo.sql_db")
    def test_sequence_concurrency_40_reconciling_last_invoice(self):
        """Reconcile last invoice and create a new one
        should not raises errors"""
        # Create "last move" to lock
        invoice = self._create_invoice_form(self.env0)
        payment = self._create_payment_form(self.env0)
        payment_move = payment.move_id
        self.cr0.commit()
        lines2reconcile = (
            (payment_move | invoice)
            .mapped("line_ids")
            .filtered(lambda line: line.account_id.account_type == "asset_receivable")
        )
        with self.cr0.savepoint(), self.cr1.savepoint():
            # Reconciling "last move"
            # reconcile a payment with many invoices spend a lot so it could
            # lock records too many time
            lines2reconcile.reconcile()
            # Many pieces of code call flush directly
            self.env0.flush_all()
            self._create_invoice_form(self.env1)
        self._commit_crs(self.env0, self.env1)

    def test_sequence_concurrency_50_reconciling_last_payment(self):
        """Reconcile last payment and create a new one
        should not raises errors"""
        # Create "last move" to lock
        invoice = self._create_invoice_form(self.env0)
        payment = self._create_payment_form(self.env0)
        payment_move = payment.move_id
        self.cr0.commit()
        lines2reconcile = (
            (payment_move | invoice)
            .mapped("line_ids")
            .filtered(lambda line: line.account_id.account_type == "asset_receivable")
        )
        with self.cr0.savepoint(), self.cr1.savepoint():
            # Reconciling "last move"
            # reconcile a payment with many invoices spend a lot so it could
            # lock records too many time
            lines2reconcile.reconcile()
            # Many pieces of code call flush directly
            self.env0.flush_all()
            self._create_payment_form(self.env1)
        self._commit_crs(self.env0, self.env1)

    def test_sequence_concurrency_90_payments(self):
        """Creating concurrent payments should not raises errors"""
        # Create "last move" to lock
        self._create_payment_form(self.env0, ir_sequence_standard=True)
        self.cr0.commit()
        with self.cr1.savepoint(), self.cr2.savepoint():
            self._create_payment_form(self.env1, ir_sequence_standard=True)
            self._create_payment_form(self.env2, ir_sequence_standard=True)
        self._commit_crs(self.env0, self.env1, self.env2)

    def test_sequence_concurrency_92_invoices(self):
        """Creating concurrent invoices should not raises errors"""
        # Create "last move" to lock
        self._create_invoice_form(self.env0, ir_sequence_standard=True)
        self.cr0.commit()
        with self.cr1.savepoint(), self.cr2.savepoint():
            self._create_invoice_form(self.env1, ir_sequence_standard=True)
            # Using another partner to bypass "increase_rank" lock error
            self._create_invoice_form(
                self.env2, partner=self.partner2, ir_sequence_standard=True
            )
        self._commit_crs(self.env0, self.env1, self.env2)

    @tools.mute_logger("odoo.sql_db")
    def test_sequence_concurrency_95_pay2inv_inv2pay(self):
        """Creating concurrent payment then invoice and invoice then payment
        should not raises errors
        It raises deadlock sometimes"""
        # Create "last move" to lock
        self._create_invoice_form(self.env0)
        # Create "last move" to lock
        self._create_payment_form(self.env0)
        self.cr0.commit()
        self.cr0.execute(
            "SELECT setting FROM pg_settings WHERE name = 'deadlock_timeout'"
        )
        deadlock_timeout = int(self.cr0.fetchone()[0])  # ms
        # You could not have permission to set this parameter
        # psycopg2.errors.InsufficientPrivilege
        self.assertTrue(
            deadlock_timeout,
            "You need to configure PG parameter deadlock_timeout='1s'",
        )
        deadlock_timeout = int(deadlock_timeout / 1000)  # s
        t_pay_inv = ThreadRaiseJoin(
            target=self._create_invoice_payment,
            args=(deadlock_timeout, True, True),
            name="Thread payment invoice",
        )
        t_inv_pay = ThreadRaiseJoin(
            target=self._create_invoice_payment,
            args=(deadlock_timeout, False, True),
            name="Thread invoice payment",
        )
        t_pay_inv.start()
        t_inv_pay.start()
        # the thread could raise the error before to wait for it so disable coverage
        self._thread_join(t_pay_inv, deadlock_timeout + 15)
        self._thread_join(t_inv_pay, deadlock_timeout + 15)

    def _thread_join(self, thread_obj, timeout):
        try:
            thread_obj.join(timeout=timeout)  # pragma: no cover
            self.assertFalse(
                thread_obj.is_alive(),
                "The thread wait is over. but the cursor may still be in use!",
            )
        except psycopg2.OperationalError as e:
            if e.pgcode in [
                psycopg2.errorcodes.SERIALIZATION_FAILURE,
                psycopg2.errorcodes.LOCK_NOT_AVAILABLE,
            ]:  # pragma: no cover
                # Concurrency error is expected but not deadlock so ok
                pass
            elif e.pgcode == psycopg2.errorcodes.DEADLOCK_DETECTED:  # pragma: no cover
                self.assertFalse(True, "Deadlock detected.")
            else:  # pragma: no cover
                raise
