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

import datetime
import logging
import re
import traceback
from collections import defaultdict
from uuid import uuid4

from dateutil.relativedelta import relativedelta
from odoo import _, api, exceptions, fields, models
from odoo.exceptions import LockError, MissingError
from odoo.fields import Domain
from odoo.http import request
from odoo.tools import safe_eval

_logger = logging.getLogger(__name__)

DOMAIN_FIELDS_RE = re.compile(r"""
    [([]\s*                 # opening bracket with any whitespace
    (?P<quote>['"])         # opening quote
    (?P<field>[a-z]\w*)     # field name, should start with a letter then any [a-z0-9_]
    (?:\.[.\w]*)?           # dot followed by dots or text in between i.e. relation traversal (optional)
    (?P=quote)              # closing quote, matching the opening one
    (?:[^,]*?,){2}          # anything with two commas (to ensure that we are inside a triplet)
    [^,]*?[()[\]]           # anything except a comma followed by a closing bracket or another opening bracket
""", re.VERBOSE)


def _get_domain_fields(env, model, domain):
    IrModelFields = env["ir.model.fields"]
    if not domain:
        return IrModelFields
    fields = IrModelFields
    # wondering why we use a regex instead of safe_eval?
    # because this method is called on a compute method hence could be triggered
    # from an onchange call (i.e. a manually crafted malicious one)
    # see: https://github.com/odoo/odoo/pull/189772#issuecomment-2548804283
    for match in DOMAIN_FIELDS_RE.finditer(domain):
        if field := match.groupdict().get('field'):
            fields |= IrModelFields._get(model, field)
    return fields


def _domain_fields_differences(automation, domain1, domain2):
    IrModelFields = automation.env["ir.model.fields"]
    if not automation.model_id:
        return IrModelFields, IrModelFields
    d1_fields = _get_domain_fields(automation.env, automation.model_id.model, domain1)
    d2_fields = _get_domain_fields(automation.env, automation.model_id.model, domain2)
    in_d1_only_fields = d1_fields - d2_fields
    in_d2_only_fields = d2_fields - d1_fields
    return in_d1_only_fields, in_d2_only_fields


DATE_RANGE = {
    'minutes': relativedelta(minutes=1),
    'hour': relativedelta(hours=1),
    'day': relativedelta(days=1),
    'month': relativedelta(months=1),
    False: relativedelta(0),
}

DATE_RANGE_FACTOR = {
    'minutes': 1,
    'hour': 60,
    'day': 24 * 60,
    'month': 30 * 24 * 60,
    False: 0,
}

TIMEDELTA_TYPES = {
    'minutes': lambda interval: datetime.timedelta(minutes=interval),
    'hours': lambda interval: datetime.timedelta(hours=interval),
    'days': lambda interval: datetime.timedelta(days=interval),
    'weeks': lambda interval: datetime.timedelta(weeks=interval),
    'months': lambda interval: datetime.timedelta(days=30 * interval),
}

CREATE_TRIGGERS = [
    'on_create',
    'on_create_or_write',
    'on_priority_set',
    'on_stage_set',
    'on_state_set',
    'on_tag_set',
    'on_user_set',
]

WRITE_TRIGGERS = [
    'on_write',
    'on_archive',
    'on_unarchive',

    'on_create_or_write',
    'on_priority_set',
    'on_stage_set',
    'on_state_set',
    'on_tag_set',
    'on_user_set',
]

MAIL_TRIGGERS = ("on_message_received", "on_message_sent")

CREATE_WRITE_SET = set(CREATE_TRIGGERS + WRITE_TRIGGERS)

TIME_TRIGGERS = [
    'on_time',
    'on_time_created',
    'on_time_updated',
]


def get_webhook_request_payload():
    if not request:
        return None
    try:
        payload = request.get_json_data()
    except ValueError:
        payload = {**request.httprequest.args}
    return payload


class BaseAutomation(models.Model):
    _name = 'base.automation'
    _description = 'Automation Rule'
    _inherit = ['mail.thread', 'mail.activity.mixin']

    name = fields.Char(string="Automation Rule Name", required=True, translate=True, tracking=True)
    description = fields.Html(string="Description")
    model_id = fields.Many2one(
        "ir.model", string="Model", domain=[("abstract", "=", False)], required=True, ondelete="cascade", tracking=True
    )
    model_name = fields.Char(related="model_id.model", string="Model Name", readonly=True, inverse="_inverse_model_name")
    model_is_mail_thread = fields.Boolean(related="model_id.is_mail_thread")
    action_server_ids = fields.One2many("ir.actions.server", "base_automation_id",
        context={'default_usage': 'base_automation'},
        string="Actions",
        compute="_compute_action_server_ids",
        store=True,
        readonly=False,
    )
    url = fields.Char(compute='_compute_url', help="Use this URL in the third-party app to call this webhook.")
    webhook_uuid = fields.Char(string="Webhook UUID", readonly=True, copy=False, default=lambda self: str(uuid4()))
    record_getter = fields.Char(default="model.env[payload.get('_model')].browse(int(payload.get('_id')))",
                                help="This code will be run to find on which record the automation rule should be run.")
    log_webhook_calls = fields.Boolean(string="Log Calls", default=False)
    active = fields.Boolean(default=True, help="When unchecked, the rule is hidden and will not be executed.")

    @api.constrains("trigger", "model_id")
    def _check_trigger(self):
        for automation in self:
            if automation.trigger in MAIL_TRIGGERS and not automation.model_id.is_mail_thread:
                raise exceptions.ValidationError(_("Mail event can not be configured on model %s. Only models with discussion feature can be used.", automation.model_id.name))

    trigger = fields.Selection(
        [
            ('on_stage_set', "Stage is set to"),
            ('on_user_set', "User is set"),
            ('on_tag_set', "Tag is added"),
            ('on_state_set', "State is set to"),
            ('on_priority_set', "Priority is set to"),
            ('on_archive', "On archived"),
            ('on_unarchive', "On unarchived"),
            ('on_create', "On create"),
            ('on_create_or_write', "On create and edit"),
            ('on_write', "On update"),  # deprecated, use 'on_create_or_write' instead

            ('on_unlink', "On deletion"),
            ('on_change', "On UI change"),

            ('on_time', "Based on date field"),
            ('on_time_created', "After creation"),
            ('on_time_updated', "After last update"),

            ("on_message_received", "On incoming message"),
            ("on_message_sent", "On outgoing message"),

            ('on_webhook', "On webhook"),
        ], string='Trigger',
        compute='_compute_trigger', readonly=False, store=True, required=True, tracking=True)
    trg_selection_field_id = fields.Many2one(
        'ir.model.fields.selection',
        string='Trigger Field',
        domain="[('field_id', 'in', trigger_field_ids)]",
        compute='_compute_trg_selection_field_id',
        readonly=False, store=True,
        help="Some triggers need a reference to a selection field. This field is used to store it.")
    trg_field_ref_model_name = fields.Char(
        string='Trigger Field Model',
        compute='_compute_trg_field_ref_model_name')
    trg_field_ref = fields.Many2oneReference(
        model_field='trg_field_ref_model_name',
        compute='_compute_trg_field_ref',
        string='Trigger Reference',
        readonly=False,
        store=True,
        help="Some triggers need a reference to another field. This field is used to store it.")
    trg_date_id = fields.Many2one(
        'ir.model.fields', string='Trigger Date',
        compute='_compute_trg_date_id',
        readonly=False, store=True, tracking=True,
        domain="[('model_id', '=', model_id), ('ttype', 'in', ('date', 'datetime'))]",
        help="""When should the condition be triggered.
                If present, will be checked by the scheduler. If empty, will be checked at creation and update.""")
    trg_date_range = fields.Integer(
        string='Delay',
        compute='_compute_trg_date_range_data',
        readonly=False, store=True, tracking=True)
    trg_date_range_mode = fields.Selection(
        [('after', 'After'), ('before', 'Before')],
        string='Delay mode',
        compute='_compute_trg_date_range_data',
        readonly=False, store=True, tracking=True)
    trg_date_range_type = fields.Selection(
        [('minutes', 'Minutes'), ('hour', 'Hours'), ('day', 'Days'), ('month', 'Months')],
        string='Delay unit',
        compute='_compute_trg_date_range_data',
        readonly=False, store=True, tracking=True)
    trg_date_calendar_id = fields.Many2one(
        "resource.calendar", string='Use Calendar',
        compute='_compute_trg_date_calendar_id',
        readonly=False, store=True,
        help="When calculating a day-based timed condition, it is possible "
             "to use a calendar to compute the date based on working days.")
    filter_pre_domain = fields.Char(
        string='Before Update Domain',
        compute='_compute_filter_pre_domain',
        readonly=False, store=True,
        help="If present, this condition must be satisfied before the update of the record. "
             "Not checked on record creation.")
    previous_domain = fields.Char(store=False, default=lambda self: self.filter_domain)
    filter_domain = fields.Char(
        string='Apply on',
        help="If present, this condition must be satisfied before executing the automation rule.",
        compute='_compute_filter_domain',
        readonly=False, store=True
    )
    last_run = fields.Datetime(readonly=True, copy=False)
    on_change_field_ids = fields.Many2many(
        "ir.model.fields",
        relation="base_automation_onchange_fields_rel",
        compute='_compute_on_change_field_ids',
        readonly=False, store=True,
        string="On Change Fields Trigger",
        help="Fields that trigger the onchange.",
    )
    trigger_field_ids = fields.Many2many(
        'ir.model.fields', string='Trigger Fields',
        compute='_compute_trigger_field_ids', readonly=False, store=True,
        help="The automation rule will be triggered if and only if one of these fields is updated."
             "If empty, all fields are watched.")

    # which fields have an impact on the registry and the cron
    CRITICAL_FIELDS = ['model_id', 'active', 'trigger', 'on_change_field_ids']
    RANGE_FIELDS = ['trg_date_range', 'trg_date_range_type']

    @api.constrains('model_id', 'action_server_ids')
    def _check_action_server_model(self):
        for rule in self:
            failing_actions = rule.action_server_ids.filtered(
                lambda action: action.model_id != rule.model_id
            )
            if failing_actions:
                raise exceptions.ValidationError(
                    _('Target model of actions %(action_names)s are different from rule model.',
                      action_names=', '.join(failing_actions.mapped('name'))
                     )
                )

    @api.depends("trigger", "webhook_uuid")
    def _compute_url(self):
        for automation in self:
            if automation.trigger != "on_webhook":
                automation.url = ""
            else:
                automation.url = "%s/web/hook/%s" % (automation.get_base_url(), automation.webhook_uuid)

    def _inverse_model_name(self):
        for rec in self:
            rec.model_id = self.env["ir.model"]._get(rec.model_name)

    @api.constrains('trigger', 'trg_date_range')
    def _check_time_trigger(self):
        for record in self:
            if record.trigger in TIME_TRIGGERS and record.trg_date_range < 0:
                raise exceptions.ValidationError(_("Delay must be positive. Set 'Delay mode' to 'Before' to negate the delay."))

    @api.constrains('trigger', 'action_server_ids')
    def _check_trigger_state(self):
        for record in self:
            warning_actions = record.action_server_ids.filtered('warning')
            if warning_actions:
                raise exceptions.ValidationError(
                    _("Following child actions have warnings: %(children)s", children=', '.join(warning_actions.mapped('name')))
                )
            no_code_actions = record.action_server_ids.filtered(lambda a: a.state != 'code')
            if record.trigger == 'on_change' and no_code_actions:
                raise exceptions.ValidationError(
                    _('"On live update" automation rules can only be used with "Execute Python Code" action type.')
                )
            mail_actions = record.action_server_ids.filtered(
                lambda a: a.state in ['mail_post', 'followers', 'next_activity']
            )
            if record.trigger == 'on_unlink' and mail_actions:
                raise exceptions.ValidationError(
                    _('Email, follower or activity action types cannot be used when deleting records, '
                      'as there are no more records to apply these changes to!')
                )

    @api.depends('model_id')
    def _compute_action_server_ids(self):
        """ When changing / setting model, remove actions that are not targeting
        the same model anymore. """
        for rule in self.filtered('model_id'):
            actions_to_remove = rule.action_server_ids.filtered(
                lambda action: action.model_id != rule.model_id
            )
            if actions_to_remove:
                rule.action_server_ids = [(3, action.id) for action in actions_to_remove]

    @api.depends('trigger')
    def _compute_trg_date_id(self):
        to_reset = self.filtered(lambda a: a.trigger not in TIME_TRIGGERS)
        to_reset.trg_date_id = False
        for record in (self - to_reset):
            record.trg_date_id = record._get_trigger_specific_field()

    @api.onchange('trg_date_range')
    def _onchange_trg_date_range_data(self):
        if self.trg_date_range < 0:
            self.trg_date_range = abs(self.trg_date_range)
            if self.trigger == 'on_time':
                self.trg_date_range_mode = 'before' if self.trg_date_range_mode == 'after' else 'after'

    @api.depends('trigger')
    def _compute_trg_date_range_data(self):
        for record in self:
            if record.trigger not in TIME_TRIGGERS:
                record.trg_date_range = False
                record.trg_date_range_type = False
                record.trg_date_range_mode = False
                continue
            if not record.trg_date_range_type:
                record.trg_date_range_type = 'hour'
            if not record.trg_date_range_mode or record.trigger not in 'on_time':
                record.trg_date_range_mode = 'after'

    @api.depends('trigger', 'trg_date_id', 'trg_date_range_type')
    def _compute_trg_date_calendar_id(self):
        to_reset = self.filtered(
            lambda a: a.trigger not in TIME_TRIGGERS or not a.trg_date_id or a.trg_date_range_type != 'day'
        )
        to_reset.trg_date_calendar_id = False

    @api.depends('trigger')
    def _compute_trg_selection_field_id(self):
        self.trg_selection_field_id = False

    @api.depends('trigger')
    def _compute_trg_field_ref(self):
        self.trg_field_ref = False

    @api.depends('trigger', 'trg_field_ref')
    def _compute_trg_field_ref_model_name(self):
        to_compute = self.filtered(lambda a: a.trigger in ['on_stage_set', 'on_tag_set'] and a.trg_field_ref is not False)
        # wondering why we check based on 'is not'? Because the ref could be an empty recordset
        # and we still need to introspec on the model in that case - not just ignore it
        to_reset = (self - to_compute)
        to_reset.trg_field_ref_model_name = False
        for automation in to_compute:
            relation = automation._get_trigger_specific_field().relation
            if not relation:
                automation.trg_field_ref_model_name = False
                continue
            automation.trg_field_ref_model_name = relation

    @api.depends('trigger', 'trg_field_ref')
    def _compute_filter_pre_domain(self):
        to_reset = self.filtered(lambda a: a.trigger != 'on_tag_set')
        to_reset.filter_pre_domain = False
        for automation in (self - to_reset):
            field = automation._get_trigger_specific_field().name
            value = automation.trg_field_ref
            automation.filter_pre_domain = repr([(field, 'not in', [value])]) if value else False

    @api.depends('trigger', 'trg_selection_field_id', 'trg_field_ref')
    def _compute_filter_domain(self):
        for automation in self:
            field = (
                automation._get_trigger_specific_field()
                if automation.trigger not in ["on_create_or_write", *TIME_TRIGGERS]
                else False
            )
            if not field:
                automation.filter_domain = False
                continue

            # some triggers require a domain
            match automation.trigger:
                case 'on_state_set' | 'on_priority_set':
                    value = automation.trg_selection_field_id.value
                    automation.filter_domain = repr([(field.name, '=', value)]) if value else False
                case 'on_stage_set':
                    value = automation.trg_field_ref
                    automation.filter_domain = repr([(field.name, '=', value)]) if value else False
                case 'on_tag_set':
                    value = automation.trg_field_ref
                    automation.filter_domain = repr([(field.name, 'in', [value])]) if value else False
                case 'on_user_set':
                    automation.filter_domain = repr([(field.name, '!=', False)])
                case 'on_archive':
                    automation.filter_domain = repr([(field.name, '=', False)])
                case 'on_unarchive':
                    automation.filter_domain = repr([(field.name, '=', True)])

    @api.depends('model_id', 'trigger', 'filter_domain')
    def _compute_on_change_field_ids(self):
        to_reset = self.filtered(lambda a: a.trigger != 'on_change')
        to_reset.on_change_field_ids = False
        for automation in (self - to_reset):
            automation._onchange_domain()

    @api.depends('model_id', 'trigger', 'filter_domain')
    def _compute_trigger_field_ids(self):
        for automation in self:
            if automation.trigger == "on_create_or_write":
                automation._onchange_domain()
                continue
            automation._onchange_trigger()

    @api.depends('model_id')
    def _compute_trigger(self):
        self.trigger = False

    @api.onchange("filter_domain")
    def _onchange_domain(self):
        removed_fields, added_fields = _domain_fields_differences(self, self.previous_domain, self.filter_domain)
        if self.trigger == "on_change":
            self.on_change_field_ids = self.on_change_field_ids.filtered(lambda f: f._origin.id not in removed_fields.ids)
            self.on_change_field_ids |= added_fields
        if self.trigger == "on_create_or_write":
            self.trigger_field_ids = self.trigger_field_ids.filtered(lambda f: f._origin.id not in removed_fields.ids)
            self.trigger_field_ids |= added_fields
        self.previous_domain = self.filter_domain

    @api.onchange('trigger')
    def _onchange_trigger(self):
        self.ensure_one()
        field = (
            self._get_trigger_specific_field()
            if self.trigger not in TIME_TRIGGERS
            else False
        )
        self.trigger_field_ids = field

    @api.onchange('trigger', 'action_server_ids')
    def _onchange_trigger_or_actions(self):
        no_code_actions = self.action_server_ids.filtered(lambda a: a.state != 'code')
        if self.trigger == 'on_change' and len(no_code_actions) > 0:
            trigger_field = self._fields['trigger']
            action_states = dict(self.action_server_ids._fields['state']._description_selection(self.env))
            return {'warning': {
                'title': _("Warning"),
                'message': _(
                    "The \"%(trigger_value)s\" %(trigger_label)s can only be "
                    "used with the \"%(state_value)s\" action type",
                    trigger_value=dict(trigger_field._description_selection(self.env))['on_change'],
                    trigger_label=trigger_field._description_string(self.env),
                    state_value=action_states['code'])
            }}

        MAIL_STATES = ('mail_post', 'followers', 'next_activity')
        mail_actions = self.action_server_ids.filtered(lambda a: a.state in MAIL_STATES)
        if self.trigger == 'on_unlink' and len(mail_actions) > 0:
            return {'warning': {
                'title': _("Warning"),
                'message': _(
                    "You cannot send an email, add followers or create an activity "
                    "for a deleted record.  It simply does not work."
                ),
            }}

    def _has_trigger_onchange(self):
        return any(
            automation.active and automation.trigger == 'on_change' and automation.on_change_field_ids
            for automation in self
        )

    @api.model_create_multi
    def create(self, vals_list):
        base_automations = super(BaseAutomation, self).create(vals_list)
        self._update_cron()
        self._update_registry()
        if base_automations._has_trigger_onchange():
            # Invalidate templates cache to update on_change attributes if needed
            self.env.registry.clear_cache('templates')
        return base_automations

    def write(self, vals: dict):
        clear_templates = self._has_trigger_onchange()
        res = super(BaseAutomation, self).write(vals)
        if set(vals).intersection(self.CRITICAL_FIELDS):
            self._update_cron()
            self._update_registry()
            if clear_templates or self._has_trigger_onchange():
                # Invalidate templates cache to update on_change attributes if needed
                self.env.registry.clear_cache('templates')
        elif set(vals).intersection(self.RANGE_FIELDS):
            self._update_cron()
        return res

    def unlink(self):
        clear_templates = self._has_trigger_onchange()
        res = super(BaseAutomation, self).unlink()
        self._update_cron()
        self._update_registry()
        if clear_templates:
            # Invalidate templates cache to update on_change attributes if needed
            self.env.registry.clear_cache('templates')
        return res

    def copy(self, default=None):
        """Copy the actions of the automation while
        copying the automation itself."""
        actions = self.action_server_ids.copy()
        record_copy = super().copy(default)
        record_copy.action_server_ids = actions
        return record_copy

    def action_open_scheduled_action(self):
        cron = self.env.ref('base_automation.ir_cron_data_base_automation_check', raise_if_not_found=False)
        if not cron:
            message = _("The scheduled action for Automation Rules seems to have vanished.")
            raise exceptions.MissingError(message)
        return {
            'type': 'ir.actions.act_window',
            'name': _('Scheduled Action'),
            'view_mode': 'form',
            'res_model': 'ir.cron',
            'res_id': cron.id,
        }

    def action_rotate_webhook_uuid(self):
        for automation in self:
            automation.webhook_uuid = str(uuid4())

    def action_view_webhook_logs(self):
        self.ensure_one()
        return {
            'type': 'ir.actions.act_window',
            'name': _('Webhook Logs'),
            'res_model': 'ir.logging',
            'view_mode': 'list,form',
            'domain': [('path', '=', "base_automation(%s)" % self.id)],
        }

    def _get_trigger_specific_field(self):
        self.ensure_one()
        match self.trigger:
            case 'on_create_or_write':
                return _get_domain_fields(self.env, self.model_id.model, self.filter_domain)
            case 'on_stage_set':
                domain = [('ttype', '=', 'many2one'), ('name', 'in', ['stage_id', 'x_studio_stage_id'])]
            case 'on_tag_set':
                domain = [('ttype', '=', 'many2many'), ('name', 'in', ['tag_ids', 'x_studio_tag_ids'])]
            case 'on_priority_set':
                domain = [('ttype', '=', 'selection'), ('name', 'in', ['priority', 'x_studio_priority'])]
            case 'on_state_set':
                domain = [('ttype', '=', 'selection'), ('name', 'in', ['state', 'x_studio_state'])]
            case 'on_user_set':
                domain = [
                    ('relation', '=', 'res.users'),
                    ('ttype', 'in', ['many2one', 'many2many']),
                    ('name', 'in', ['user_id', 'user_ids', 'x_studio_user_id', 'x_studio_user_ids']),
                ]
            case 'on_archive' | 'on_unarchive':
                domain = [('ttype', '=', 'boolean'), ('name', 'in', ['active', 'x_active'])]
            case 'on_time_created':
                domain = [('ttype', '=', 'datetime'), ('name', '=', 'create_date')]
            case 'on_time_updated':
                domain = [('ttype', '=', 'datetime'), ('name', '=', 'write_date')]
            case _:
                return self.env['ir.model.fields']
        domain += [('model_id', '=', self.model_id.id)]
        return self.env['ir.model.fields'].search(domain, limit=1)

    def _prepare_loggin_values(self, **values):
        self.ensure_one()
        defaults = {
            'name': _("Webhook Log"),
            'type': 'server',
            'dbname': self.env.cr.dbname,
            'level': 'INFO',
            'path': "base_automation(%s)" % self.id,
            'func': '',
            'line': ''
        }
        defaults.update(**values)
        return defaults

    def _execute_webhook(self, payload):
        """ Execute the webhook for the given payload.
        The payload is a dictionnary that can be used by the `record_getter` to
        identify the record on which the automation should be run.
        """
        self.ensure_one()
        ir_logging_sudo = self.env['ir.logging'].sudo()

        # info logging is done by the ir.http logger
        msg = "Webhook #%s triggered with payload %s"
        msg_args = (self.id, payload)
        _logger.debug(msg, *msg_args)
        if self.log_webhook_calls:
            ir_logging_sudo.create(self._prepare_loggin_values(message=msg % msg_args))

        record = self.env[self.model_name]
        if self.record_getter:
            try:
                record = safe_eval.safe_eval(self.record_getter, self._get_eval_context(payload=payload))
            except Exception as e: # noqa: BLE001
                msg = "Webhook #%s could not be triggered because the record_getter failed:\n%s"
                msg_args = (self.id, traceback.format_exc())
                _logger.warning(msg, *msg_args)
                if self.log_webhook_calls:
                    ir_logging_sudo.create(self._prepare_loggin_values(message=msg % msg_args, level="ERROR"))
                raise e

        if not record.exists():
            msg = "Webhook #%s could not be triggered because no record to run it on was found."
            msg_args = (self.id,)
            _logger.warning(msg, *msg_args)
            if self.log_webhook_calls:
                ir_logging_sudo.create(self._prepare_loggin_values(message=msg % msg_args, level="ERROR"))
            raise exceptions.ValidationError(_("No record to run the automation on was found."))

        try:
            return self._process(record)
        except Exception as e: # noqa: BLE001
            msg = "Webhook #%s failed with error:\n%s"
            msg_args = (self.id, traceback.format_exc())
            _logger.warning(msg, *msg_args)
            if self.log_webhook_calls:
                ir_logging_sudo.create(self._prepare_loggin_values(message=msg % msg_args, level="ERROR"))
            raise e

    def _update_cron(self):
        """ Activate the cron job depending on whether there exists automation rules
        based on time conditions.  Also update its frequency according to
        the smallest automation delay, or restore the default 4 hours if there
        is no time based automation.
        """
        cron = self.env.ref('base_automation.ir_cron_data_base_automation_check', raise_if_not_found=False)
        if cron:
            try:
                cron.lock_for_update(allow_referencing=True)
            except LockError:
                return
            automations = self.with_context(active_test=True).search([('trigger', 'in', TIME_TRIGGERS)])
            interval_number, interval_type = self._get_cron_interval(automations)
            vals = {'active': bool(automations)}

            actual_cron_timedelta = TIMEDELTA_TYPES[cron.interval_type](cron.interval_number)
            new_cron_timedelta = TIMEDELTA_TYPES[interval_type](interval_number)
            if new_cron_timedelta < actual_cron_timedelta:
                # we only update the cron interval if the new delay is shorter than the current one
                vals.update({
                    'interval_type': interval_type,
                    'interval_number': interval_number,
                })
            cron.write(vals)

    def _update_registry(self):
        """ Update the registry after a modification on automation rules. """
        if self.env.registry.ready and not self.env.context.get('import_file'):
            # re-install the model patches, and notify other workers
            self._unregister_hook()
            self._register_hook()
            self.env.registry.registry_invalidated = True

    def _get_actions(self, records, triggers):
        """ Return the automations of the given triggers for records' model. The
            returned automations' context contain an object to manage processing.
        """
        # Note: we keep the old action naming for the method and context variable
        # to avoid breaking existing code/downstream modules
        if '__action_done' not in self.env.context:
            self = self.with_context(__action_done={})
        domain = [('model_name', '=', records._name), ('trigger', 'in', triggers)]
        automations = self.with_context(active_test=True).sudo().search(domain)
        return automations.with_env(self.env)

    def _get_eval_context(self, payload=None):
        """ Prepare the context used when evaluating python code
            :returns: dict -- evaluation context given to safe_eval
        """
        self.ensure_one()
        model = self.env[self.model_name]
        eval_context = {
            'datetime': safe_eval.datetime,
            'dateutil': safe_eval.dateutil,
            'time': safe_eval.time,
            'uid': self.env.uid,
            'user': self.env.user,
            'model': model,
        }
        if payload is not None:
            eval_context['payload'] = payload
        return eval_context

    def _get_cron_interval(self, automations=None):
        """Return the expected time interval used by the cron, in minutes or hours."""
        def get_delay(rec):
            return abs(rec.trg_date_range) * DATE_RANGE_FACTOR[rec.trg_date_range_type]

        if automations is None:
            automations = self.with_context(active_test=True).search([('trigger', 'in', TIME_TRIGGERS)])

        # Minimum 1 minute, maximum 4 hours, 10% tolerance, ignore automations with no delay
        delays = [d for d in automations.mapped(get_delay) if d]
        interval = min(max(1, min(delays) // 10), 4 * 60) if delays else 4 * 60
        interval_type = 'minutes'
        if interval % 60 == 0:
            interval //= 60
            interval_type = 'hours'
        return interval, interval_type

    def _filter_pre(self, records, feedback=False):
        """ Filter the records that satisfy the precondition of automation ``self``. """
        self_sudo = self.sudo()
        if self_sudo.filter_pre_domain and records:
            if feedback:
                # this context flag enables to detect the executions of
                # automations while evaluating their precondition
                records = records.with_context(__action_feedback=True)
            domain = safe_eval.safe_eval(self_sudo.filter_pre_domain, self._get_eval_context())
            # keep computed fields depending on the currently changed field
            # as-is so they are recomputed after the value is set
            # see `test_computation_sequence`
            changed_fields = self.env.context.get('changed_fields', ())
            to_compute = {
                dep: comp
                for f in changed_fields
                for dep in self.env.registry.get_dependent_fields(f)
                if (comp := self.env.records_to_compute(dep))
            }
            records = records.with_context(changed_fields=()).sudo().filtered_domain(domain).sudo(records.env.su)
            for dep, comp in to_compute.items():
                self.env.add_to_compute(dep, comp)
        return records

    def _filter_post(self, records, feedback=False):
        return self._filter_post_export_domain(records, feedback)[0]

    def _filter_post_export_domain(self, records, feedback=False):
        """ Filter the records that satisfy the postcondition of automation ``self``. """
        self_sudo = self.sudo()
        if self_sudo.filter_domain and records:
            if feedback:
                # this context flag enables to detect the executions of
                # automations while evaluating their postcondition
                records = records.with_context(__action_feedback=True)
            domain = safe_eval.safe_eval(self_sudo.filter_domain, self._get_eval_context())
            return records.sudo().filtered_domain(domain).with_env(records.env), domain
        else:
            return records, None

    @api.model
    def _add_postmortem(self, e):
        if self.env.user._is_internal():
            e.context = {}
            e.context['exception_class'] = 'base_automation'
            e.context['base_automation'] = {
                'id': self.id,
                'name': self.sudo().name,
            }

    def _process(self, records, domain_post=None):
        """ Process automation ``self`` on the ``records`` that have not been done yet. """
        # filter out the records on which self has already been done
        automation_done = self.env.context.get('__action_done', {})
        records_done = automation_done.get(self, records.browse())
        records -= records_done
        if not records:
            return

        # mark the remaining records as done (to avoid recursive processing)
        if self.env.context.get('__action_feedback'):
            # modify the context dict in place: this is useful when fields are
            # computed during the pre/post filtering, in order to know which
            # automations have already been run by the computation itself
            automation_done[self] = records_done + records
        else:
            automation_done = dict(automation_done)
            automation_done[self] = records_done + records
            self = self.with_context(__action_done=automation_done)
            records = records.with_context(__action_done=automation_done)

        # we process the automation on the records for which any watched field
        # has been modified, and only mark the automation as done for those
        records = records.filtered(self._check_trigger_fields)
        automation_done[self] = records_done + records

        if records and 'date_automation_last' in records._fields:
            records.date_automation_last = self.env.cr.now()

        # prepare the contexts for server actions
        contexts = [
            {
                'active_model': record._name,
                'active_ids': record.ids,
                'active_id': record.id,
                'domain_post': domain_post,
            }
            for record in records
        ]

        # execute server actions
        for action in self.sudo().action_server_ids:
            for ctx in contexts:
                try:
                    action.with_context(**ctx).run()
                except Exception as e:
                    self._add_postmortem(e)
                    raise

    def _check_trigger_fields(self, record):
        """ Return whether any of the trigger fields has been modified on ``record``. """
        self_sudo = self.sudo()
        if not self_sudo.trigger_field_ids:
            # all fields are implicit triggers
            return True

        if self.env.context.get('old_values') is None:
            # this is a create: all fields are considered modified
            return True

        # note: old_vals are in the record format
        old_vals = self.env.context['old_values'].get(record.id, {})

        def differ(name):
            return name in old_vals and record[name] != old_vals[name]

        return any(differ(field.name) for field in self_sudo.trigger_field_ids)

    def _register_hook(self):
        """ Patch models that should trigger action rules based on creation,
            modification, deletion of records and form onchanges.
        """
        #
        # Note: the patched methods must be defined inside another function,
        # otherwise their closure may be wrong. For instance, the function
        # create refers to the outer variable 'create', which you expect to be
        # bound to create itself. But that expectation is wrong if create is
        # defined inside a loop; in that case, the variable 'create' is bound to
        # the last function defined by the loop.
        #

        def make_create():
            """ Instanciate a create method that processes automation rules. """
            @api.model_create_multi
            def create(self, vals_list, **kw):
                # retrieve the automation rules to possibly execute
                automations = self.env['base.automation']._get_actions(self, CREATE_TRIGGERS)
                if not automations:
                    return create.origin(self, vals_list, **kw)
                # call original method
                records = create.origin(self.with_env(automations.env), vals_list, **kw)
                # check postconditions, and execute actions on the records that satisfy them
                for automation in automations.with_context(old_values=None):
                    _logger.debug(
                        "Processing automation rule %s (#%s) on %s records (create)",
                        automation.sudo().name, automation.sudo().id, len(records),
                    )
                    automation._process(automation._filter_post(records, feedback=True))
                return records.with_env(self.env)

            return create

        def make_write():
            """ Instanciate a write method that processes automation rules. """
            def write(self, vals, **kw):
                # retrieve the automation rules to possibly execute
                automations = self.env['base.automation']._get_actions(self, WRITE_TRIGGERS)
                if not (automations and self):
                    return write.origin(self, vals, **kw)
                records = self.with_env(automations.env).filtered('id')
                # check preconditions on records
                pre = {a: a._filter_pre(records) for a in automations}
                # read old values before the update
                old_values = {
                    record.id: {field_name: record[field_name] for field_name in vals if field_name in record._fields and record._fields[field_name].store}
                    for record in records
                }
                # call original method
                write.origin(self.with_env(automations.env), vals, **kw)
                # check postconditions, and execute actions on the records that satisfy them
                for automation in automations.with_context(old_values=old_values):
                    _logger.debug(
                        "Processing automation rule %s (#%s) on %s records (write)",
                        automation.sudo().name, automation.sudo().id, len(records),
                    )
                    records, domain_post = automation._filter_post_export_domain(pre[automation], feedback=True)
                    automation._process(records, domain_post=domain_post)
                return True

            return write

        def make_compute_field_value():
            """ Instanciate a compute_field_value method that processes automation rules. """
            #
            # Note: This is to catch updates made by field recomputations.
            #
            def _compute_field_value(self, field):
                # determine fields that may trigger an automation
                stored_fnames = [f.name for f in self.pool.field_computed[field] if f.store]
                if not stored_fnames:
                    return _compute_field_value.origin(self, field)
                # retrieve the action rules to possibly execute
                automations = self.env['base.automation']._get_actions(self, WRITE_TRIGGERS)
                records = self.filtered('id').with_env(automations.env)
                if not (automations and records):
                    _compute_field_value.origin(self, field)
                    return True
                # check preconditions on records
                # changed fields are all fields computed by the function
                changed_fields = [f for f in records._fields.values() if f.compute == field.compute]
                pre = {a: a.with_context(changed_fields=changed_fields)._filter_pre(records) for a in automations}
                # read old values before the update
                old_values = {
                    record.id: {fname: record[fname] for fname in stored_fnames}
                    for record in records
                }
                # call original method
                _compute_field_value.origin(self, field)
                # check postconditions, and execute automations on the records that satisfy them
                for automation in automations.with_context(old_values=old_values):
                    _logger.debug(
                        "Processing automation rule %s (#%s) on %s records (_compute_field_value)",
                        automation.sudo().name, automation.sudo().id, len(records),
                    )
                    records, domain_post = automation._filter_post_export_domain(pre[automation], feedback=True)
                    automation._process(records, domain_post=domain_post)
                return True

            return _compute_field_value

        def make_unlink():
            """ Instanciate an unlink method that processes automation rules. """
            def unlink(self, **kwargs):
                # retrieve the action rules to possibly execute
                automations = self.env['base.automation']._get_actions(self, ['on_unlink'])
                records = self.with_env(automations.env)
                # check conditions, and execute actions on the records that satisfy them
                for automation in automations:
                    _logger.debug(
                        "Processing automation rule %s (#%s) on %s records (unlink)",
                        automation.sudo().name, automation.sudo().id, len(records),
                    )
                    automation._process(automation._filter_post(records, feedback=True))
                # call original method
                return unlink.origin(self, **kwargs)

            return unlink

        def make_onchange(automation_rule_id):
            """ Instanciate an onchange method for the given automation rule. """
            def base_automation_onchange(self):
                automation_rule = self.env['base.automation'].browse(automation_rule_id)

                if not automation_rule._filter_post(self):
                    # Do nothing if onchange record does not satisfy the filter_domain
                    return

                result = {}
                actions = automation_rule.sudo().action_server_ids.with_context(
                    active_model=self._name,
                    active_id=self._origin.id,
                    active_ids=self._origin.ids,
                    onchange_self=self,
                )
                for action in actions:
                    try:
                        res = action.run()
                    except Exception as e:
                        automation_rule._add_postmortem(e)
                        raise

                    if res:
                        if 'value' in res:
                            res['value'].pop('id', None)
                            self.update({key: val for key, val in res['value'].items() if key in self._fields})
                        if 'domain' in res:
                            result.setdefault('domain', {}).update(res['domain'])
                        if 'warning' in res:
                            result['warning'] = res["warning"]
                return result

            return base_automation_onchange

        def make_message_post():
            def _message_post(self, *args, **kwargs):
                message = _message_post.origin(self, *args, **kwargs)
                # Don't execute automations for a message emitted during
                # the run of automations for a real message
                # Don't execute if we know already that a message is only internal
                message_sudo = message.sudo().with_context(active_test=False)
                if "__action_done"  in self.env.context or message_sudo.is_internal or message_sudo.subtype_id.internal:
                    return message
                if message_sudo.message_type in ('notification', 'auto_comment', 'user_notification'):
                    return message

                # always execute actions when the author is a customer
                # if author is not set, it means the message is coming from outside
                mail_trigger = "on_message_received" if not message_sudo.author_id or message_sudo.author_id.partner_share else "on_message_sent"
                automations = self.env['base.automation']._get_actions(self, [mail_trigger])
                for automation in automations.with_context(old_values=None):
                    records = automation._filter_pre(self, feedback=True)
                    _logger.debug(
                        "Processing automation rule %s (#%s) on %s records (_message_post)",
                        automation.sudo().name, automation.sudo().id, len(records),
                    )
                    automation._process(records)

                return message
            return _message_post

        patched_models = defaultdict(set)

        def patch(model, name, method):
            """ Patch method `name` on `model`, unless it has been patched already. """
            if model not in patched_models[name]:
                patched_models[name].add(model)
                ModelClass = model.env.registry[model._name]
                method.origin = getattr(ModelClass, name)
                setattr(ModelClass, name, method)

        # retrieve all actions, and patch their corresponding model
        for automation_rule in self.with_context({}).search([]):
            Model = self.env.get(automation_rule.model_name)

            # Do not crash if the model of the base_action_rule was uninstalled
            if Model is None:
                _logger.warning(
                    "Automation rule with name '%s' (ID %d) depends on model %s (ID: %d)",
                    automation_rule.name,
                    automation_rule.id,
                    automation_rule.model_name,
                    automation_rule.model_id.id)
                continue

            if automation_rule.trigger in CREATE_WRITE_SET:
                if automation_rule.trigger in CREATE_TRIGGERS:
                    patch(Model, 'create', make_create())
                if automation_rule.trigger in WRITE_TRIGGERS:
                    patch(Model, 'write', make_write())
                    patch(Model, '_compute_field_value', make_compute_field_value())

            elif automation_rule.trigger == 'on_unlink':
                patch(Model, 'unlink', make_unlink())

            elif automation_rule.trigger == 'on_change':
                # register an onchange method for the automation_rule
                method = make_onchange(automation_rule.id)
                for field in automation_rule.on_change_field_ids:
                    Model._onchange_methods[field.name].append(method)

            if automation_rule.model_id.is_mail_thread and automation_rule.trigger in MAIL_TRIGGERS:
                patch(Model, "message_post", make_message_post())

    def _unregister_hook(self):
        """ Remove the patches installed by _register_hook() """
        NAMES = ['create', 'write', '_compute_field_value', 'unlink', '_onchange_methods', "message_post"]
        for Model in self.env.registry.values():
            for name in NAMES:
                try:
                    delattr(Model, name)
                except AttributeError:
                    pass

    @api.model
    def _get_calendar(self, automation, record):
        return automation.trg_date_calendar_id

    @api.deprecated("Since 19.0, use _cron_process_time_based_automations")
    def _check(self, automatic=False, use_new_cursor=False):
        if not automatic:
            raise RuntimeError("can run time-based automations only in automatic mode")
        self._cron_process_time_based_actions()

    def _search_time_based_automation_records(self, *, until):
        automation = self.ensure_one()

        # retrieve the domain and field
        domain = Domain.TRUE
        if automation.filter_domain:
            eval_context = automation._get_eval_context()
            domain = Domain(safe_eval.safe_eval(automation.filter_domain, eval_context))
        Model = self.env[automation.model_name]
        date_field = Model._fields.get(automation.trg_date_id.name)
        if not date_field:
            _logger.warning("Missing date trigger field in automation rule `%s`", automation.name)
            return Model

        # get the time information and find the records
        last_run = automation.last_run or datetime.datetime.fromtimestamp(0, tz=None)
        is_date_automation_last = date_field.name == "date_automation_last" and "create_date" in Model._fields
        range_sign = 1 if automation.trg_date_range_mode == 'before' else -1
        date_range = range_sign * automation.trg_date_range

        def get_record_dt(record):
            # the field can be a date or datetime, cast always to a datetime
            dt = record[date_field.name]
            if not dt and is_date_automation_last:
                dt = record.create_date
            return fields.Datetime.to_datetime(dt)

        if automation.trg_date_calendar_id and automation.trg_date_range_type == 'day':
            # use the calendar information from the record
            # _get_calendar can be overwritten and cannot be optimized
            time_domain = Domain.TRUE if is_date_automation_last else Domain(date_field.name, '!=', False)
            if (date_field.store or date_field.search):
                records = Model.search(time_domain & domain)
            else:
                records = Model.search(domain).filtered_domain(time_domain)

            past_until = {}
            past_last_run = {}

            def calendar_filter(record):
                record_dt = get_record_dt(record)
                if not record_dt:
                    return False
                calendar = self._get_calendar(automation, record)
                if calendar.id not in past_until:
                    past_until[calendar.id] = calendar.plan_days(
                        date_range,
                        until,
                        compute_leaves=True,
                    )
                    past_last_run[calendar.id] = calendar.plan_days(
                        date_range,
                        last_run,
                        compute_leaves=True,
                    )
                return past_last_run[calendar.id] <= record_dt < past_until[calendar.id]

            return records.filtered(calendar_filter)

        # we can search for the records to trigger
        # find the relative dates
        relative_offset = DATE_RANGE[automation.trg_date_range_type] * date_range
        relative_until = until + relative_offset
        relative_last_run = last_run + relative_offset
        if date_field.type == 'date':
            # find records that have a date in past, but were not yet executed that day
            time_domain = Domain(date_field.name, '>', relative_last_run.date()) & Domain(date_field.name, '<=', relative_until.date())
            if is_date_automation_last:
                time_domain |= Domain(date_field.name, '=', False) & Domain('create_date', '>', relative_last_run.date()) & Domain('create_date', '<=', relative_until.today())
        else:  # datetime
            time_domain = Domain(date_field.name, '>=', relative_last_run) & Domain(date_field.name, '<', relative_until)
            if is_date_automation_last:
                time_domain |= Domain(date_field.name, '=', False) & Domain('create_date', '>=', relative_last_run) & Domain('create_date', '<', relative_until)

        if (date_field.store or date_field.search):
            return Model.search(time_domain & domain)
        else:
            return Model.search(domain).filtered_domain(time_domain)

    @api.model
    def _cron_process_time_based_actions(self):
        """ Execute the time-based automations. """
        if '__action_done' not in self.env.context:
            self = self.with_context(__action_done={})

        # retrieve all the automation rules to run based on a timed condition
        final_exception = None
        automations = self.with_context(active_test=True).search([('trigger', 'in', TIME_TRIGGERS)])
        self.env['ir.cron']._commit_progress(remaining=len(automations))

        for automation in automations:
            # is automation deactivated or disappeared between commits?
            try:
                if not automation.active:
                    continue
            except MissingError:
                continue
            _logger.info("Starting time-based automation rule `%s`.", automation.name)
            now = self.env.cr.now()
            records = automation._search_time_based_automation_records(until=now)
            # run the automation on the records
            try:
                for record in records:
                    automation._process(record)
                self.env.flush_all()
            except Exception as e:
                self.env.cr.rollback()
                _logger.exception("Error in time-based automation rule `%s`.", automation.name)
                final_exception = e
                continue

            automation.write({'last_run': now})
            _logger.info("Time-based automation rule `%s` done.", automation.name)
            if not self.env['ir.cron']._commit_progress(1):
                break
        if final_exception is not None:
            # raise the last found exception to mark the cron job as failing
            raise final_exception
