# Fusion Accounting - Bank Reconciliation Widget
# Original implementation for Fusion Accounting module

import json
import markupsafe
from collections import defaultdict
from contextlib import contextmanager

from odoo import _, api, fields, models, Command
from odoo.addons.web.controllers.utils import clean_action
from odoo.exceptions import UserError, RedirectWarning
from odoo.tools.misc import formatLang


class FusionBankRecWidget(models.Model):
    """Manages the reconciliation process for a single bank statement line.

    This transient-like model orchestrates the matching of a bank statement
    entry against existing journal items, write-off entries, and other
    counterparts. It exists only in memory and is never persisted.

    The widget maintains a collection of 'bank.rec.widget.line' entries
    representing the reconciliation breakdown: the original bank entry,
    matched journal items, manual adjustments, tax entries, exchange
    differences, and a system-generated balancing entry.
    """

    _name = "bank.rec.widget"
    _description = "Fusion bank reconciliation widget"

    _auto = False
    _table_query = "0"

    # =========================================================================
    # FIELDS: Statement Line Reference
    # =========================================================================

    st_line_id = fields.Many2one(comodel_name='account.bank.statement.line')
    move_id = fields.Many2one(
        related='st_line_id.move_id',
        depends=['st_line_id'],
    )
    st_line_checked = fields.Boolean(
        related='st_line_id.move_id.checked',
        depends=['st_line_id'],
    )
    st_line_is_reconciled = fields.Boolean(
        related='st_line_id.is_reconciled',
        depends=['st_line_id'],
    )
    st_line_journal_id = fields.Many2one(
        related='st_line_id.journal_id',
        depends=['st_line_id'],
    )
    st_line_transaction_details = fields.Html(
        compute='_compute_st_line_transaction_details',
    )
    partner_name = fields.Char(related='st_line_id.partner_name')

    # =========================================================================
    # FIELDS: Currency & Company
    # =========================================================================

    transaction_currency_id = fields.Many2one(
        comodel_name='res.currency',
        compute='_compute_transaction_currency_id',
    )
    journal_currency_id = fields.Many2one(
        comodel_name='res.currency',
        compute='_compute_journal_currency_id',
    )
    company_id = fields.Many2one(
        comodel_name='res.company',
        related='st_line_id.company_id',
        depends=['st_line_id'],
    )
    country_code = fields.Char(
        related='company_id.country_id.code',
        depends=['company_id'],
    )
    company_currency_id = fields.Many2one(
        string="Wizard Company Currency",
        related='company_id.currency_id',
        depends=['st_line_id'],
    )

    # =========================================================================
    # FIELDS: Partner & Lines
    # =========================================================================

    partner_id = fields.Many2one(
        comodel_name='res.partner',
        string="Partner",
        compute='_compute_partner_id',
        store=True,
        readonly=False,
    )
    line_ids = fields.One2many(
        comodel_name='bank.rec.widget.line',
        inverse_name='wizard_id',
        compute='_compute_line_ids',
        compute_sudo=False,
        store=True,
        readonly=False,
    )

    # =========================================================================
    # FIELDS: Reconciliation Models
    # =========================================================================

    available_reco_model_ids = fields.Many2many(
        comodel_name='account.reconcile.model',
        compute='_compute_available_reco_model_ids',
        store=True,
        readonly=False,
    )
    selected_reco_model_id = fields.Many2one(
        comodel_name='account.reconcile.model',
        compute='_compute_selected_reco_model_id',
    )
    matching_rules_allow_auto_reconcile = fields.Boolean()

    # =========================================================================
    # FIELDS: State & Display
    # =========================================================================

    state = fields.Selection(
        selection=[
            ('invalid', "Invalid"),
            ('valid', "Valid"),
            ('reconciled', "Reconciled"),
        ],
        compute='_compute_state',
        store=True,
        help=(
            "Invalid: Cannot validate because the suspense account is still present.\n"
            "Valid: Ready for validation.\n"
            "Reconciled: Already processed, no action needed."
        ),
    )
    is_multi_currency = fields.Boolean(compute='_compute_is_multi_currency')

    # =========================================================================
    # FIELDS: JS Interface
    # =========================================================================

    selected_aml_ids = fields.Many2many(
        comodel_name='account.move.line',
        compute='_compute_selected_aml_ids',
    )
    todo_command = fields.Json(store=False)
    return_todo_command = fields.Json(store=False)
    form_index = fields.Char()

    # =========================================================================
    # COMPUTE METHODS
    # =========================================================================

    @api.depends('st_line_id')
    def _compute_line_ids(self):
        """Build the initial set of reconciliation entries from the statement line.

        Creates the liquidity entry (the bank-side journal item) and loads any
        existing reconciled counterparts. For already-reconciled lines, exchange
        difference entries are separated out for clear display.
        """
        for rec_widget in self:
            if not rec_widget.st_line_id:
                rec_widget.line_ids = [Command.clear()]
                continue

            # Start with the liquidity (bank account) entry
            orm_ops = [
                Command.clear(),
                Command.create(rec_widget._lines_prepare_liquidity_line()),
            ]

            # Load existing counterpart entries for reconciled lines
            _liq_items, _suspense_items, matched_items = rec_widget.st_line_id._seek_for_lines()
            for matched_item in matched_items:
                partial_links = matched_item.matched_debit_ids + matched_item.matched_credit_ids
                fx_correction_items = (
                    partial_links.exchange_move_id.line_ids
                    .filtered(lambda item: item.account_id != matched_item.account_id)
                )
                if rec_widget.state == 'reconciled' and fx_correction_items:
                    # Display the original amounts separately from exchange adjustments
                    adjusted_balance = matched_item.balance - sum(fx_correction_items.mapped('balance'))
                    adjusted_foreign = matched_item.amount_currency - sum(fx_correction_items.mapped('amount_currency'))
                    orm_ops.append(Command.create(
                        rec_widget._lines_prepare_aml_line(
                            matched_item,
                            balance=adjusted_balance,
                            amount_currency=adjusted_foreign,
                        )
                    ))
                    for fx_item in fx_correction_items:
                        orm_ops.append(Command.create(rec_widget._lines_prepare_aml_line(fx_item)))
                else:
                    orm_ops.append(Command.create(rec_widget._lines_prepare_aml_line(matched_item)))

            rec_widget.line_ids = orm_ops
            rec_widget._lines_add_auto_balance_line()

    @api.depends('st_line_id')
    def _compute_available_reco_model_ids(self):
        """Find reconciliation models applicable to the current journal and company."""
        for rec_widget in self:
            if not rec_widget.st_line_id:
                rec_widget.available_reco_model_ids = [Command.clear()]
                continue

            stmt_entry = rec_widget.st_line_id
            applicable_models = self.env['account.reconcile.model'].search([
                ('rule_type', '=', 'writeoff_button'),
                ('company_id', '=', stmt_entry.company_id.id),
                '|',
                ('match_journal_ids', '=', False),
                ('match_journal_ids', '=', stmt_entry.journal_id.id),
            ])
            # Keep models that are general-purpose or use at most one journal
            applicable_models = applicable_models.filtered(
                lambda model: (
                    model.counterpart_type == 'general'
                    or len(model.line_ids.journal_id) <= 1
                )
            )
            rec_widget.available_reco_model_ids = [Command.set(applicable_models.ids)]

    @api.depends('line_ids.reconcile_model_id')
    def _compute_selected_reco_model_id(self):
        """Track which write-off reconciliation model is currently applied."""
        for rec_widget in self:
            active_models = rec_widget.line_ids.reconcile_model_id.filtered(
                lambda model: model.rule_type == 'writeoff_button'
            )
            rec_widget.selected_reco_model_id = (
                active_models.id if len(active_models) == 1 else None
            )

    @api.depends('st_line_id', 'line_ids.account_id')
    def _compute_state(self):
        """Determine the reconciliation state of the widget.

        - 'reconciled': Statement line is already fully matched
        - 'invalid': Suspense account is still present (not fully allocated)
        - 'valid': All amounts allocated to real accounts, ready to validate
        """
        for rec_widget in self:
            if not rec_widget.st_line_id:
                rec_widget.state = 'invalid'
            elif rec_widget.st_line_id.is_reconciled:
                rec_widget.state = 'reconciled'
            else:
                holding_account = rec_widget.st_line_id.journal_id.suspense_account_id
                accounts_in_use = rec_widget.line_ids.account_id
                rec_widget.state = 'invalid' if holding_account in accounts_in_use else 'valid'

    @api.depends('st_line_id')
    def _compute_journal_currency_id(self):
        """Resolve the effective currency of the bank journal."""
        for rec_widget in self:
            journal = rec_widget.st_line_id.journal_id
            rec_widget.journal_currency_id = journal.currency_id or journal.company_id.currency_id

    @api.depends('st_line_id')
    def _compute_st_line_transaction_details(self):
        """Render the raw transaction details as formatted HTML."""
        for rec_widget in self:
            rec_widget.st_line_transaction_details = rec_widget._render_transaction_details()

    @api.depends('st_line_id')
    def _compute_transaction_currency_id(self):
        """Determine the transaction currency (foreign currency if set, else journal currency)."""
        for rec_widget in self:
            rec_widget.transaction_currency_id = (
                rec_widget.st_line_id.foreign_currency_id or rec_widget.journal_currency_id
            )

    @api.depends('st_line_id')
    def _compute_partner_id(self):
        """Auto-detect the partner from the statement line data."""
        for rec_widget in self:
            if rec_widget.st_line_id:
                rec_widget.partner_id = rec_widget.st_line_id._retrieve_partner()
            else:
                rec_widget.partner_id = None

    @api.depends('company_id')
    def _compute_is_multi_currency(self):
        """Check if the user has multi-currency access rights."""
        self.is_multi_currency = self.env.user.has_groups('base.group_multi_currency')

    @api.depends('company_id', 'line_ids.source_aml_id')
    def _compute_selected_aml_ids(self):
        """Expose the set of journal items currently matched in this widget."""
        for rec_widget in self:
            rec_widget.selected_aml_ids = [Command.set(rec_widget.line_ids.source_aml_id.ids)]

    # =========================================================================
    # TRANSACTION DETAILS RENDERING
    # =========================================================================

    def _render_transaction_details(self):
        """Convert structured transaction details into a readable HTML tree.

        Parses the JSON/dict transaction_details field from the statement line
        and renders it as a nested HTML list, filtering out empty values.
        """
        self.ensure_one()
        raw_details = self.st_line_id.transaction_details
        if not raw_details:
            return None

        parsed = json.loads(raw_details) if isinstance(raw_details, str) else raw_details

        def _build_html_node(label, data):
            """Recursively build HTML list items from a data structure."""
            if not data:
                return ""
            if isinstance(data, dict):
                children = markupsafe.Markup("").join(
                    _build_html_node(f"{key}: ", val) for key, val in data.items()
                )
                rendered_value = markupsafe.Markup('<ol>%s</ol>') % children if children else ""
            elif isinstance(data, (list, tuple)):
                children = markupsafe.Markup("").join(
                    _build_html_node(f"{pos}: ", val) for pos, val in enumerate(data, start=1)
                )
                rendered_value = markupsafe.Markup('<ol>%s</ol>') % children if children else ""
            else:
                rendered_value = data

            if not rendered_value:
                return ""

            return markupsafe.Markup(
                '<li style="list-style-type: none">'
                '<span><span class="fw-bolder">%(label)s</span>%(content)s</span>'
                '</li>'
            ) % {'label': label, 'content': rendered_value}

        root_html = _build_html_node('', parsed)
        return markupsafe.Markup("<ol>%s</ol>") % root_html

    # =========================================================================
    # ONCHANGE HANDLERS
    # =========================================================================

    @api.onchange('todo_command')
    def _onchange_todo_command(self):
        """Dispatch JS-triggered commands to the appropriate handler method.

        The JS frontend sends commands via the todo_command field. Each command
        specifies a method_name that maps to a _js_action_* method, along with
        optional args and kwargs.
        """
        self.ensure_one()
        pending_cmd = self.todo_command
        self.todo_command = None
        self.return_todo_command = None

        # Force-load line_ids to prevent stale cache issues during updates
        self._ensure_loaded_lines()

        action_method = getattr(self, f'_js_action_{pending_cmd["method_name"]}')
        action_method(*pending_cmd.get('args', []), **pending_cmd.get('kwargs', {}))

    # =========================================================================
    # LOW-LEVEL OVERRIDES
    # =========================================================================

    @api.model
    def new(self, values=None, origin=None, ref=None):
        """Override to ensure line_ids are loaded immediately after creation."""
        widget = super().new(values=values, origin=origin, ref=ref)
        # Trigger line_ids evaluation to prevent cache inconsistencies
        # when subsequent operations modify the One2many
        widget.line_ids
        return widget

    # =========================================================================
    # INITIALIZATION
    # =========================================================================

    @api.model
    def fetch_initial_data(self):
        """Prepare field metadata and default values for the JS frontend.

        Returns a dictionary with field definitions (including related fields
        for One2many and Many2many) and initial values for bootstrapping the
        reconciliation widget on the client side.
        """
        field_defs = self.fields_get()
        view_attrs = self.env['ir.ui.view']._get_view_field_attributes()

        for fname, field_obj in self._fields.items():
            if field_obj.type == 'one2many':
                child_fields = self[fname].fields_get(attributes=view_attrs)
                # Remove the back-reference field from child definitions
                child_fields.pop(field_obj.inverse_name, None)
                field_defs[fname]['relatedFields'] = child_fields
                # Resolve nested Many2many related fields
                for child_fname, child_field_obj in self[fname]._fields.items():
                    if child_field_obj.type == "many2many":
                        nested_model = self.env[child_field_obj.comodel_name]
                        field_defs[fname]['relatedFields'][child_fname]['relatedFields'] = (
                            nested_model.fields_get(
                                allfields=['id', 'display_name'],
                                attributes=view_attrs,
                            )
                        )
            elif field_obj.name == 'available_reco_model_ids':
                field_defs[fname]['relatedFields'] = self[fname].fields_get(
                    allfields=['id', 'display_name'],
                    attributes=view_attrs,
                )

        # Mark todo_command as triggering onChange
        field_defs['todo_command']['onChange'] = True

        # Build initial values
        defaults = {}
        for fname, field_obj in self._fields.items():
            if field_obj.type == 'one2many':
                defaults[fname] = []
            else:
                defaults[fname] = field_obj.convert_to_read(self[fname], self, {})

        return {
            'initial_values': defaults,
            'fields': field_defs,
        }

    # =========================================================================
    # LINE PREPARATION METHODS
    # =========================================================================

    def _ensure_loaded_lines(self):
        """Force evaluation of line_ids to prevent ORM cache inconsistencies.

        When a One2many field's value is replaced with new Command.create entries,
        accessing the field beforehand can cause stale records to persist alongside
        the new ones. Triggering evaluation here avoids that problem.
        """
        self.line_ids

    def _lines_turn_auto_balance_into_manual_line(self, entry):
        """Promote an auto-balance entry to a manual entry when the user edits it."""
        if entry.flag == 'auto_balance':
            entry.flag = 'manual'

    def _lines_get_line_in_edit_form(self):
        """Return the widget entry currently selected for editing, if any."""
        self.ensure_one()
        if not self.form_index:
            return None
        return self.line_ids.filtered(lambda rec: rec.index == self.form_index)

    def _lines_prepare_aml_line(self, move_line, **extra_vals):
        """Build creation values for a widget entry linked to a journal item."""
        self.ensure_one()
        return {
            'flag': 'aml',
            'source_aml_id': move_line.id,
            **extra_vals,
        }

    def _lines_prepare_liquidity_line(self):
        """Build creation values for the liquidity (bank account) entry.

        The liquidity entry represents the bank-side journal item. When the
        journal uses a different currency from the transaction, the amounts
        are sourced from the appropriate line of the statement's move.
        """
        self.ensure_one()
        liq_item, _suspense_items, _matched_items = self.st_line_id._seek_for_lines()
        return self._lines_prepare_aml_line(liq_item, flag='liquidity')

    def _lines_prepare_auto_balance_line(self):
        """Compute values for the automatic balancing entry.

        Calculates the remaining unallocated amount across all current entries
        and produces an auto-balance entry to close the gap. The target account
        is chosen based on the partner's receivable/payable configuration, or
        falls back to the journal's suspense account.
        """
        self.ensure_one()
        stmt_entry = self.st_line_id

        # Retrieve the statement line's accounting amounts
        txn_amount, txn_currency, jrnl_amount, _jrnl_currency, comp_amount, _comp_currency = (
            self.st_line_id._get_accounting_amounts_and_currencies()
        )

        # Calculate the remaining amounts to be balanced
        pending_foreign = -txn_amount
        pending_company = -comp_amount

        for entry in self.line_ids:
            if entry.flag in ('liquidity', 'auto_balance'):
                continue

            pending_company -= entry.balance

            # Convert to transaction currency using the appropriate rate
            txn_to_jrnl_rate = abs(txn_amount / jrnl_amount) if jrnl_amount else 0.0
            txn_to_comp_rate = abs(txn_amount / comp_amount) if comp_amount else 0.0

            if entry.currency_id == self.transaction_currency_id:
                pending_foreign -= entry.amount_currency
            elif entry.currency_id == self.journal_currency_id:
                pending_foreign -= txn_currency.round(entry.amount_currency * txn_to_jrnl_rate)
            else:
                pending_foreign -= txn_currency.round(entry.balance * txn_to_comp_rate)

        # Determine the target account based on partner configuration
        target_account = None
        current_partner = self.partner_id
        if current_partner:
            label = _("Open balance of %(amount)s", amount=formatLang(
                self.env, txn_amount, currency_obj=txn_currency,
            ))
            scoped_partner = current_partner.with_company(stmt_entry.company_id)

            has_customer_role = current_partner.customer_rank and not current_partner.supplier_rank
            has_vendor_role = current_partner.supplier_rank and not current_partner.customer_rank

            if has_customer_role:
                target_account = scoped_partner.property_account_receivable_id
            elif has_vendor_role:
                target_account = scoped_partner.property_account_payable_id
            elif stmt_entry.amount > 0:
                target_account = scoped_partner.property_account_receivable_id
            else:
                target_account = scoped_partner.property_account_payable_id

        if not target_account:
            label = stmt_entry.payment_ref
            target_account = stmt_entry.journal_id.suspense_account_id

        return {
            'flag': 'auto_balance',
            'account_id': target_account.id,
            'name': label,
            'amount_currency': pending_foreign,
            'balance': pending_company,
        }

    def _lines_add_auto_balance_line(self):
        """Refresh the auto-balance entry to keep the reconciliation balanced.

        Removes any existing auto-balance entry and creates a new one if the
        remaining balance is non-zero. The entry is always placed last.
        """
        # Remove existing auto-balance entries
        orm_ops = [
            Command.unlink(entry.id)
            for entry in self.line_ids
            if entry.flag == 'auto_balance'
        ]

        # Create a fresh auto-balance if needed
        balance_data = self._lines_prepare_auto_balance_line()
        if not self.company_currency_id.is_zero(balance_data['balance']):
            orm_ops.append(Command.create(balance_data))

        self.line_ids = orm_ops

    def _lines_prepare_new_aml_line(self, move_line, **extra_vals):
        """Build values for adding a new journal item as a reconciliation counterpart."""
        return self._lines_prepare_aml_line(
            move_line,
            flag='new_aml',
            currency_id=move_line.currency_id.id,
            amount_currency=-move_line.amount_residual_currency,
            balance=-move_line.amount_residual,
            source_amount_currency=-move_line.amount_residual_currency,
            source_balance=-move_line.amount_residual,
            **extra_vals,
        )

    def _lines_check_partial_amount(self, entry):
        """Check if a partial reconciliation should be applied to the given entry.

        Determines whether the matched journal item exceeds the remaining
        transaction amount and, if so, computes the adjusted amounts needed
        for a partial match. Returns None if no partial is needed.
        """
        if entry.flag != 'new_aml':
            return None

        fx_entry = self.line_ids.filtered(
            lambda rec: rec.flag == 'exchange_diff' and rec.source_aml_id == entry.source_aml_id
        )
        balance_data = self._lines_prepare_auto_balance_line()

        remaining_comp = balance_data['balance']
        current_comp = entry.balance + fx_entry.balance

        # Check if there's excess in company currency
        comp_cur = self.company_currency_id
        excess_debit_comp = (
            comp_cur.compare_amounts(remaining_comp, 0) < 0
            and comp_cur.compare_amounts(current_comp, 0) > 0
            and comp_cur.compare_amounts(current_comp, -remaining_comp) > 0
        )
        excess_credit_comp = (
            comp_cur.compare_amounts(remaining_comp, 0) > 0
            and comp_cur.compare_amounts(current_comp, 0) < 0
            and comp_cur.compare_amounts(-current_comp, remaining_comp) > 0
        )

        remaining_foreign = balance_data['amount_currency']
        current_foreign = entry.amount_currency
        entry_cur = entry.currency_id

        # Check if there's excess in the entry's currency
        excess_debit_foreign = (
            entry_cur.compare_amounts(remaining_foreign, 0) < 0
            and entry_cur.compare_amounts(current_foreign, 0) > 0
            and entry_cur.compare_amounts(current_foreign, -remaining_foreign) > 0
        )
        excess_credit_foreign = (
            entry_cur.compare_amounts(remaining_foreign, 0) > 0
            and entry_cur.compare_amounts(current_foreign, 0) < 0
            and entry_cur.compare_amounts(-current_foreign, remaining_foreign) > 0
        )

        if entry_cur == self.transaction_currency_id:
            if not (excess_debit_foreign or excess_credit_foreign):
                return None

            adjusted_foreign = current_foreign + remaining_foreign

            # Use the bank transaction rate for conversion
            txn_amount, _txn_cur, _jrnl_amt, _jrnl_cur, comp_amount, _comp_cur = (
                self.st_line_id._get_accounting_amounts_and_currencies()
            )
            conversion_rate = abs(comp_amount / txn_amount) if txn_amount else 0.0

            adjusted_comp_total = entry.company_currency_id.round(adjusted_foreign * conversion_rate)
            adjusted_entry_comp = entry.company_currency_id.round(
                adjusted_comp_total * abs(entry.balance) / abs(current_comp)
            )
            adjusted_fx_comp = adjusted_comp_total - adjusted_entry_comp

            return {
                'exchange_diff_line': fx_entry,
                'amount_currency': adjusted_foreign,
                'balance': adjusted_entry_comp,
                'exchange_balance': adjusted_fx_comp,
            }

        elif excess_debit_comp or excess_credit_comp:
            adjusted_comp_total = current_comp + remaining_comp

            # Use the original journal item's rate
            original_rate = abs(entry.source_amount_currency) / abs(entry.source_balance)

            adjusted_entry_comp = entry.company_currency_id.round(
                adjusted_comp_total * abs(entry.balance) / abs(current_comp)
            )
            adjusted_fx_comp = adjusted_comp_total - adjusted_entry_comp
            adjusted_foreign = entry_cur.round(adjusted_entry_comp * original_rate)

            return {
                'exchange_diff_line': fx_entry,
                'amount_currency': adjusted_foreign,
                'balance': adjusted_entry_comp,
                'exchange_balance': adjusted_fx_comp,
            }

        return None

    def _do_amounts_apply_for_early_payment(self, pending_foreign, discount_total):
        """Check if the remaining amount exactly matches the early payment discount."""
        return self.transaction_currency_id.compare_amounts(pending_foreign, discount_total) == 0

    def _lines_check_apply_early_payment_discount(self):
        """Attempt to apply early payment discount terms to matched journal items.

        Examines all currently matched journal items to see if their invoices
        offer early payment discounts. If the remaining balance equals the total
        discount amount, applies the discount by creating early_payment entries.

        Returns True if the discount was applied, False otherwise.
        """
        matched_entries = self.line_ids.filtered(lambda rec: rec.flag == 'new_aml')

        # Compute the remaining balance with and without matched entries
        balance_data = self._lines_prepare_auto_balance_line()
        residual_foreign_excl = (
            balance_data['amount_currency'] + sum(matched_entries.mapped('amount_currency'))
        )
        residual_comp_excl = (
            balance_data['balance'] + sum(matched_entries.mapped('balance'))
        )
        residual_foreign_incl = (
            residual_foreign_excl - sum(matched_entries.mapped('source_amount_currency'))
        )
        residual_foreign = residual_foreign_incl

        uniform_currency = matched_entries.currency_id == self.transaction_currency_id
        has_discount_eligible = False

        discount_entries = []
        discount_total = 0.0

        for matched_entry in matched_entries:
            source_item = matched_entry.source_aml_id
            if source_item.move_id._is_eligible_for_early_payment_discount(
                self.transaction_currency_id, self.st_line_id.date
            ):
                has_discount_eligible = True
                discount_total += source_item.amount_currency - source_item.discount_amount_currency
                discount_entries.append({
                    'aml': source_item,
                    'amount_currency': matched_entry.amount_currency,
                    'balance': matched_entry.balance,
                })

        # Remove existing early payment entries
        orm_ops = [
            Command.unlink(entry.id)
            for entry in self.line_ids
            if entry.flag == 'early_payment'
        ]
        discount_applied = False

        if (
            uniform_currency
            and has_discount_eligible
            and self._do_amounts_apply_for_early_payment(residual_foreign, discount_total)
        ):
            # Reset matched entries to their full original amounts
            for matched_entry in matched_entries:
                matched_entry.amount_currency = matched_entry.source_amount_currency
                matched_entry.balance = matched_entry.source_balance

            # Generate the early payment discount entries
            discount_breakdown = (
                self.env['account.move']._get_invoice_counterpart_amls_for_early_payment_discount(
                    discount_entries,
                    residual_comp_excl - sum(matched_entries.mapped('source_balance')),
                )
            )

            for category_entries in discount_breakdown.values():
                for entry_vals in category_entries:
                    orm_ops.append(Command.create({
                        'flag': 'early_payment',
                        'account_id': entry_vals['account_id'],
                        'date': self.st_line_id.date,
                        'name': entry_vals['name'],
                        'partner_id': entry_vals['partner_id'],
                        'currency_id': entry_vals['currency_id'],
                        'amount_currency': entry_vals['amount_currency'],
                        'balance': entry_vals['balance'],
                        'analytic_distribution': entry_vals.get('analytic_distribution'),
                        'tax_ids': entry_vals.get('tax_ids', []),
                        'tax_tag_ids': entry_vals.get('tax_tag_ids', []),
                        'tax_repartition_line_id': entry_vals.get('tax_repartition_line_id'),
                        'group_tax_id': entry_vals.get('group_tax_id'),
                    }))
                    discount_applied = True

        if orm_ops:
            self.line_ids = orm_ops

        return discount_applied

    def _lines_check_apply_partial_matching(self):
        """Attempt partial matching on the most recently added journal item.

        If multiple items are matched and the last one overshoots the remaining
        balance, reduce it to create a partial reconciliation. Also resets
        any previous partials except on manually modified entries.

        Returns True if a partial was applied, False otherwise.
        """
        matched_entries = self.line_ids.filtered(lambda rec: rec.flag == 'new_aml')
        if not matched_entries:
            return False

        final_entry = matched_entries[-1]

        # Reset prior partials on unmodified entries
        reset_ops = []
        affected_entries = self.env['bank.rec.widget.line']
        for matched_entry in matched_entries:
            has_partial = matched_entry.display_stroked_amount_currency or matched_entry.display_stroked_balance
            if has_partial and not matched_entry.manually_modified:
                reset_ops.append(Command.update(matched_entry.id, {
                    'amount_currency': matched_entry.source_amount_currency,
                    'balance': matched_entry.source_balance,
                }))
                affected_entries |= matched_entry

        if reset_ops:
            self.line_ids = reset_ops
            self._lines_recompute_exchange_diff(affected_entries)

        # Check if the last entry should be partially matched
        partial_data = self._lines_check_partial_amount(final_entry)
        if partial_data:
            final_entry.amount_currency = partial_data['amount_currency']
            final_entry.balance = partial_data['balance']
            fx_entry = partial_data['exchange_diff_line']
            if fx_entry:
                fx_entry.balance = partial_data['exchange_balance']
                if fx_entry.currency_id == self.company_currency_id:
                    fx_entry.amount_currency = fx_entry.balance
            return True

        return False

    def _lines_load_new_amls(self, move_lines, reco_model=None):
        """Create widget entries for a set of journal items to be reconciled."""
        orm_ops = []
        model_ref = {'reconcile_model_id': reco_model.id} if reco_model else {}
        for move_line in move_lines:
            entry_vals = self._lines_prepare_new_aml_line(move_line, **model_ref)
            orm_ops.append(Command.create(entry_vals))

        if orm_ops:
            self.line_ids = orm_ops

    # =========================================================================
    # TAX COMPUTATION
    # =========================================================================

    def _prepare_base_line_for_taxes_computation(self, entry):
        """Convert a widget entry into the format expected by account.tax computation.

        Handles both tax-exclusive and tax-inclusive modes based on
        the force_price_included_taxes flag.
        """
        self.ensure_one()
        applied_taxes = entry.tax_ids
        tax_usage = applied_taxes[0].type_tax_use if applied_taxes else None
        is_refund_context = (
            (tax_usage == 'sale' and entry.balance > 0.0)
            or (tax_usage == 'purchase' and entry.balance < 0.0)
        )

        if entry.force_price_included_taxes and applied_taxes:
            computation_mode = 'total_included'
            base_value = entry.tax_base_amount_currency
        else:
            computation_mode = 'total_excluded'
            base_value = entry.amount_currency

        return self.env['account.tax']._prepare_base_line_for_taxes_computation(
            entry,
            price_unit=base_value,
            quantity=1.0,
            is_refund=is_refund_context,
            special_mode=computation_mode,
        )

    def _prepare_tax_line_for_taxes_computation(self, entry):
        """Convert a tax widget entry for the tax computation engine."""
        self.ensure_one()
        return self.env['account.tax']._prepare_tax_line_for_taxes_computation(entry)

    def _lines_prepare_tax_line(self, tax_data):
        """Build creation values for a tax entry from computed tax data."""
        self.ensure_one()
        tax_rep = self.env['account.tax.repartition.line'].browse(tax_data['tax_repartition_line_id'])
        description = tax_rep.tax_id.name
        if self.st_line_id.payment_ref:
            description = f'{description} - {self.st_line_id.payment_ref}'

        entry_currency = self.env['res.currency'].browse(tax_data['currency_id'])
        foreign_amount = tax_data['amount_currency']
        comp_amounts = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate(
            entry_currency, None, foreign_amount,
        )

        return {
            'flag': 'tax_line',
            'account_id': tax_data['account_id'],
            'date': self.st_line_id.date,
            'name': description,
            'partner_id': tax_data['partner_id'],
            'currency_id': entry_currency.id,
            'amount_currency': foreign_amount,
            'balance': comp_amounts['balance'],
            'analytic_distribution': tax_data['analytic_distribution'],
            'tax_repartition_line_id': tax_rep.id,
            'tax_ids': tax_data['tax_ids'],
            'tax_tag_ids': tax_data['tax_tag_ids'],
            'group_tax_id': tax_data['group_tax_id'],
        }

    def _lines_recompute_taxes(self):
        """Recalculate all tax entries based on the current manual base entries.

        Uses Odoo's tax computation engine to determine the correct tax amounts,
        then updates/creates/deletes tax entries accordingly.
        """
        self.ensure_one()
        TaxEngine = self.env['account.tax']

        # Collect base and tax entries
        base_entries = self.line_ids.filtered(
            lambda rec: rec.flag == 'manual' and not rec.tax_repartition_line_id
        )
        tax_entries = self.line_ids.filtered(lambda rec: rec.flag == 'tax_line')

        base_data = [self._prepare_base_line_for_taxes_computation(rec) for rec in base_entries]
        tax_data = [self._prepare_tax_line_for_taxes_computation(rec) for rec in tax_entries]

        # Run the tax computation pipeline
        TaxEngine._add_tax_details_in_base_lines(base_data, self.company_id)
        TaxEngine._round_base_lines_tax_details(base_data, self.company_id)
        TaxEngine._add_accounting_data_in_base_lines_tax_details(
            base_data, self.company_id, include_caba_tags=True,
        )
        computed_taxes = TaxEngine._prepare_tax_lines(
            base_data, self.company_id, tax_lines=tax_data,
        )

        orm_ops = []

        # Update base entries with new tax tags and amounts
        for base_rec, updates in computed_taxes['base_lines_to_update']:
            rec = base_rec['record']
            new_foreign = updates['amount_currency']
            comp_amounts = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate(
                rec.currency_id, rec.source_balance, new_foreign,
            )
            orm_ops.append(Command.update(rec.id, {
                'balance': comp_amounts['balance'],
                'amount_currency': new_foreign,
                'tax_tag_ids': updates['tax_tag_ids'],
            }))

        # Remove obsolete tax entries
        for obsolete_tax in computed_taxes['tax_lines_to_delete']:
            orm_ops.append(Command.unlink(obsolete_tax['record'].id))

        # Add newly computed tax entries
        for new_tax_data in computed_taxes['tax_lines_to_add']:
            orm_ops.append(Command.create(self._lines_prepare_tax_line(new_tax_data)))

        # Update existing tax entries with new amounts
        for existing_tax, grouping, updates in computed_taxes['tax_lines_to_update']:
            refreshed_vals = self._lines_prepare_tax_line({**grouping, **updates})
            orm_ops.append(Command.update(existing_tax['record'].id, {
                'amount_currency': refreshed_vals['amount_currency'],
                'balance': refreshed_vals['balance'],
            }))

        self.line_ids = orm_ops

    # =========================================================================
    # EXCHANGE DIFFERENCE HANDLING
    # =========================================================================

    def _get_key_mapping_aml_and_exchange_diff(self, entry):
        """Return the key used to associate exchange difference entries with their source."""
        if entry.source_aml_id:
            return 'source_aml_id', entry.source_aml_id.id
        return None, None

    def _reorder_exchange_and_aml_lines(self):
        """Reorder entries so each exchange difference follows its corresponding match."""
        fx_entries = self.line_ids.filtered(lambda rec: rec.flag == 'exchange_diff')
        source_to_fx = defaultdict(lambda: self.env['bank.rec.widget.line'])
        for fx_entry in fx_entries:
            mapping_key = self._get_key_mapping_aml_and_exchange_diff(fx_entry)
            source_to_fx[mapping_key] |= fx_entry

        ordered_ids = []
        for entry in self.line_ids:
            if entry in fx_entries:
                continue
            ordered_ids.append(entry.id)
            entry_key = self._get_key_mapping_aml_and_exchange_diff(entry)
            if entry_key in source_to_fx:
                ordered_ids.extend(source_to_fx[entry_key].mapped('id'))

        self.line_ids = self.env['bank.rec.widget.line'].browse(ordered_ids)

    def _remove_related_exchange_diff_lines(self, target_entries):
        """Remove exchange difference entries that are linked to the specified entries."""
        unlink_ops = []
        for target in target_entries:
            if target.flag == 'exchange_diff':
                continue
            ref_field, ref_id = self._get_key_mapping_aml_and_exchange_diff(target)
            if not ref_field:
                continue
            for fx_entry in self.line_ids:
                if fx_entry[ref_field] and fx_entry[ref_field].id == ref_id:
                    unlink_ops.append(Command.unlink(fx_entry.id))

        if unlink_ops:
            self.line_ids = unlink_ops

    def _lines_get_account_balance_exchange_diff(self, entry_currency, comp_balance, foreign_amount):
        """Compute the exchange difference amount and determine the target account.

        Compares the balance at the bank transaction rate vs. the original
        journal item rate to determine the foreign exchange gain/loss.

        Returns (account, exchange_diff_balance) tuple.
        """
        # Compute balance using the bank transaction rate
        rate_adjusted = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate(
            entry_currency, comp_balance, foreign_amount,
        )
        adjusted_balance = rate_adjusted['balance']

        if entry_currency == self.company_currency_id and self.transaction_currency_id != self.company_currency_id:
            # Reconciliation uses the statement line rate; keep the original balance
            adjusted_balance = comp_balance
        elif entry_currency != self.company_currency_id and self.transaction_currency_id == self.company_currency_id:
            # Convert through the foreign currency to handle rate discrepancies
            adjusted_balance = entry_currency._convert(
                foreign_amount, self.transaction_currency_id,
                self.company_id, self.st_line_id.date,
            )

        fx_diff = adjusted_balance - comp_balance
        if self.company_currency_id.is_zero(fx_diff):
            return self.env['account.account'], 0.0

        # Select the appropriate exchange gain/loss account
        if fx_diff > 0.0:
            fx_account = self.company_id.expense_currency_exchange_account_id
        else:
            fx_account = self.company_id.income_currency_exchange_account_id

        return fx_account, fx_diff

    def _lines_get_exchange_diff_values(self, entry):
        """Compute exchange difference entry values for a matched journal item."""
        if entry.flag != 'new_aml':
            return []

        fx_account, fx_amount = self._lines_get_account_balance_exchange_diff(
            entry.currency_id, entry.balance, entry.amount_currency,
        )
        if entry.currency_id.is_zero(fx_amount):
            return []

        return [{
            'flag': 'exchange_diff',
            'source_aml_id': entry.source_aml_id.id,
            'account_id': fx_account.id,
            'date': entry.date,
            'name': _("Exchange Difference: %s", entry.name),
            'partner_id': entry.partner_id.id,
            'currency_id': entry.currency_id.id,
            'amount_currency': fx_amount if entry.currency_id == self.company_currency_id else 0.0,
            'balance': fx_amount,
            'source_amount_currency': entry.amount_currency,
            'source_balance': fx_amount,
        }]

    def _lines_recompute_exchange_diff(self, target_entries):
        """Recalculate exchange difference entries for the specified matched items.

        Creates new exchange difference entries or updates existing ones as needed.
        Also cleans up orphaned exchange differences for deleted entries.
        """
        self.ensure_one()
        # Clean up exchange diffs for entries that were removed
        removed_entries = target_entries - self.line_ids
        self._remove_related_exchange_diff_lines(removed_entries)
        target_entries = target_entries - removed_entries

        existing_fx = self.line_ids.filtered(
            lambda rec: rec.flag == 'exchange_diff'
        ).grouped('source_aml_id')

        orm_ops = []
        needs_reorder = False

        for entry in target_entries:
            fx_values_list = self._lines_get_exchange_diff_values(entry)
            if entry.source_aml_id and entry.source_aml_id in existing_fx:
                # Update existing exchange difference entry
                for fx_vals in fx_values_list:
                    orm_ops.append(Command.update(existing_fx[entry.source_aml_id].id, fx_vals))
            else:
                # Create new exchange difference entry
                for fx_vals in fx_values_list:
                    orm_ops.append(Command.create(fx_vals))
                needs_reorder = True

        if orm_ops:
            self.line_ids = orm_ops
            if needs_reorder:
                self._reorder_exchange_and_aml_lines()

    # =========================================================================
    # RECONCILE MODEL WRITE-OFF PREPARATION
    # =========================================================================

    def _lines_prepare_reco_model_write_off_vals(self, reco_model, writeoff_data):
        """Build widget entry values from a reconcile model's write-off specification."""
        self.ensure_one()
        comp_amounts = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate(
            self.transaction_currency_id, None, writeoff_data['amount_currency'],
        )
        return {
            'flag': 'manual',
            'account_id': writeoff_data['account_id'],
            'date': self.st_line_id.date,
            'name': writeoff_data['name'],
            'partner_id': writeoff_data['partner_id'],
            'currency_id': writeoff_data['currency_id'],
            'amount_currency': writeoff_data['amount_currency'],
            'balance': comp_amounts['balance'],
            'tax_base_amount_currency': writeoff_data['amount_currency'],
            'force_price_included_taxes': True,
            'reconcile_model_id': reco_model.id,
            'analytic_distribution': writeoff_data['analytic_distribution'],
            'tax_ids': writeoff_data['tax_ids'],
        }

    # =========================================================================
    # LINE VALUE CHANGE HANDLERS
    # =========================================================================

    def _line_value_changed_account_id(self, entry):
        """Handle account change on a widget entry."""
        self.ensure_one()
        self._lines_turn_auto_balance_into_manual_line(entry)
        if entry.flag not in ('tax_line', 'early_payment') and entry.tax_ids:
            self._lines_recompute_taxes()
            self._lines_add_auto_balance_line()

    def _line_value_changed_date(self, entry):
        """Handle date change - propagate to statement line if editing liquidity entry."""
        self.ensure_one()
        if entry.flag == 'liquidity' and entry.date:
            self.st_line_id.date = entry.date
            self._action_reload_liquidity_line()
            self.return_todo_command = {'reset_global_info': True, 'reset_record': True}

    def _line_value_changed_ref(self, entry):
        """Handle reference change on the liquidity entry."""
        self.ensure_one()
        if entry.flag == 'liquidity':
            self.st_line_id.move_id.ref = entry.ref
            self._action_reload_liquidity_line()
            self.return_todo_command = {'reset_record': True}

    def _line_value_changed_narration(self, entry):
        """Handle narration change on the liquidity entry."""
        self.ensure_one()
        if entry.flag == 'liquidity':
            self.st_line_id.move_id.narration = entry.narration
            self._action_reload_liquidity_line()
            self.return_todo_command = {'reset_record': True}

    def _line_value_changed_name(self, entry):
        """Handle label/name change - propagate to statement line if liquidity."""
        self.ensure_one()
        if entry.flag == 'liquidity':
            self.st_line_id.payment_ref = entry.name
            self._action_reload_liquidity_line()
            self.return_todo_command = {'reset_global_info': True, 'reset_record': True}
            return
        self._lines_turn_auto_balance_into_manual_line(entry)

    def _line_value_changed_amount_transaction_currency(self, entry):
        """Handle transaction currency amount change on the liquidity entry."""
        self.ensure_one()
        if entry.flag != 'liquidity':
            return
        if entry.transaction_currency_id != self.journal_currency_id:
            self.st_line_id.amount_currency = entry.amount_transaction_currency
            self.st_line_id.foreign_currency_id = entry.transaction_currency_id
        else:
            self.st_line_id.amount_currency = 0.0
            self.st_line_id.foreign_currency_id = None
        self._action_reload_liquidity_line()
        self.return_todo_command = {'reset_global_info': True, 'reset_record': True}

    def _line_value_changed_transaction_currency_id(self, entry):
        """Handle transaction currency change."""
        self._line_value_changed_amount_transaction_currency(entry)

    def _line_value_changed_amount_currency(self, entry):
        """Handle foreign currency amount change on any entry.

        For liquidity entries, propagates to the statement line. For matched
        entries (new_aml), enforces bounds and adjusts the company-currency
        balance using the appropriate rate. For manual entries, converts using
        the statement line or market rate.
        """
        self.ensure_one()
        if entry.flag == 'liquidity':
            self.st_line_id.amount = entry.amount_currency
            self._action_reload_liquidity_line()
            self.return_todo_command = {'reset_global_info': True, 'reset_record': True}
            return

        self._lines_turn_auto_balance_into_manual_line(entry)

        direction = -1 if entry.amount_currency < 0.0 else 1
        if entry.flag == 'new_aml':
            # Clamp to valid range: same sign as source, not exceeding source
            clamped = direction * max(0.0, min(abs(entry.amount_currency), abs(entry.source_amount_currency)))
            entry.amount_currency = clamped
            entry.manually_modified = True
            # Reset to full amount if user clears the field
            if not entry.amount_currency:
                entry.amount_currency = entry.source_amount_currency
        elif not entry.amount_currency:
            entry.amount_currency = 0.0

        # Compute the corresponding company-currency balance
        if entry.currency_id == entry.company_currency_id:
            entry.balance = entry.amount_currency
        elif entry.flag == 'new_aml':
            if entry.currency_id.compare_amounts(
                abs(entry.amount_currency), abs(entry.source_amount_currency)
            ) == 0.0:
                entry.balance = entry.source_balance
            elif entry.source_balance:
                source_rate = abs(entry.source_amount_currency / entry.source_balance)
                entry.balance = entry.company_currency_id.round(entry.amount_currency / source_rate)
            else:
                entry.balance = 0.0
        elif entry.flag in ('manual', 'early_payment', 'tax_line'):
            if entry.currency_id in (self.transaction_currency_id, self.journal_currency_id):
                entry.balance = self.st_line_id._prepare_counterpart_amounts_using_st_line_rate(
                    entry.currency_id, None, entry.amount_currency,
                )['balance']
            else:
                entry.balance = entry.currency_id._convert(
                    entry.amount_currency, self.company_currency_id,
                    self.company_id, self.st_line_id.date,
                )

        if entry.flag not in ('tax_line', 'early_payment'):
            if entry.tax_ids:
                entry.force_price_included_taxes = False
                self._lines_recompute_taxes()
            self._lines_recompute_exchange_diff(entry)

        self._lines_add_auto_balance_line()

    def _line_value_changed_balance(self, entry):
        """Handle company-currency balance change on any entry.

        Similar to amount_currency changes but operates in company currency.
        For matched entries, enforces the same clamping rules.
        """
        self.ensure_one()
        if entry.flag == 'liquidity':
            self.st_line_id.amount = entry.balance
            self._action_reload_liquidity_line()
            self.return_todo_command = {'reset_global_info': True, 'reset_record': True}
            return

        self._lines_turn_auto_balance_into_manual_line(entry)

        direction = -1 if entry.balance < 0.0 else 1
        if entry.flag == 'new_aml':
            clamped = direction * max(0.0, min(abs(entry.balance), abs(entry.source_balance)))
            entry.balance = clamped
            entry.manually_modified = True
            if not entry.balance:
                entry.balance = entry.source_balance
        elif not entry.balance:
            entry.balance = 0.0

        if entry.currency_id == entry.company_currency_id:
            entry.amount_currency = entry.balance
            self._line_value_changed_amount_currency(entry)
        elif entry.flag == 'exchange_diff':
            self._lines_add_auto_balance_line()
        else:
            self._lines_recompute_exchange_diff(entry)
            self._lines_add_auto_balance_line()

    def _line_value_changed_currency_id(self, entry):
        """Handle currency change - triggers amount recomputation."""
        self.ensure_one()
        self._line_value_changed_amount_currency(entry)

    def _line_value_changed_tax_ids(self, entry):
        """Handle tax selection change on a manual entry.

        When taxes are added, enables tax-inclusive mode. When taxes are
        removed, restores the original base amount if it was in inclusive mode.
        """
        self.ensure_one()
        self._lines_turn_auto_balance_into_manual_line(entry)

        if entry.tax_ids:
            if not entry.tax_base_amount_currency:
                entry.tax_base_amount_currency = entry.amount_currency
                entry.force_price_included_taxes = True
        else:
            if entry.force_price_included_taxes:
                entry.amount_currency = entry.tax_base_amount_currency
                self._line_value_changed_amount_currency(entry)
            entry.tax_base_amount_currency = False

        self._lines_recompute_taxes()
        self._lines_add_auto_balance_line()

    def _line_value_changed_partner_id(self, entry):
        """Handle partner change on an entry.

        For liquidity entries, propagates to the statement line. For other
        entries, attempts to set the appropriate receivable/payable account
        based on the partner's configuration and outstanding balances.
        """
        self.ensure_one()
        if entry.flag == 'liquidity':
            self.st_line_id.partner_id = entry.partner_id
            self._action_reload_liquidity_line()
            self.return_todo_command = {'reset_global_info': True, 'reset_record': True}
            return

        self._lines_turn_auto_balance_into_manual_line(entry)

        suggested_account = None
        if entry.partner_id:
            is_customer_only = entry.partner_id.customer_rank and not entry.partner_id.supplier_rank
            is_vendor_only = entry.partner_id.supplier_rank and not entry.partner_id.customer_rank
            recv_balance_zero = entry.partner_currency_id.is_zero(entry.partner_receivable_amount)
            pay_balance_zero = entry.partner_currency_id.is_zero(entry.partner_payable_amount)

            if is_customer_only or (not recv_balance_zero and pay_balance_zero):
                suggested_account = entry.partner_receivable_account_id
            elif is_vendor_only or (recv_balance_zero and not pay_balance_zero):
                suggested_account = entry.partner_payable_account_id
            elif self.st_line_id.amount < 0.0:
                suggested_account = entry.partner_payable_account_id or entry.partner_receivable_account_id
            else:
                suggested_account = entry.partner_receivable_account_id or entry.partner_payable_account_id

        if suggested_account:
            entry.account_id = suggested_account
            self._line_value_changed_account_id(entry)
        elif entry.flag not in ('tax_line', 'early_payment') and entry.tax_ids:
            self._lines_recompute_taxes()
            self._lines_add_auto_balance_line()

    def _line_value_changed_analytic_distribution(self, entry):
        """Handle analytic distribution change - recompute taxes if analytics affect them."""
        self.ensure_one()
        self._lines_turn_auto_balance_into_manual_line(entry)
        if entry.flag not in ('tax_line', 'early_payment') and any(t.analytic for t in entry.tax_ids):
            self._lines_recompute_taxes()
            self._lines_add_auto_balance_line()

    # =========================================================================
    # CORE ACTIONS
    # =========================================================================

    def _action_trigger_matching_rules(self):
        """Run automatic reconciliation rules against the current statement line.

        Searches for applicable reconcile models and applies the first match,
        which may add journal items, apply a write-off model, or flag for
        auto-reconciliation.
        """
        self.ensure_one()
        if self.st_line_id.is_reconciled:
            return

        applicable_rules = self.env['account.reconcile.model'].search([
            ('rule_type', '!=', 'writeoff_button'),
            ('company_id', '=', self.company_id.id),
            '|',
            ('match_journal_ids', '=', False),
            ('match_journal_ids', '=', self.st_line_id.journal_id.id),
        ])
        match_result = applicable_rules._apply_rules(self.st_line_id, self.partner_id)

        if match_result.get('amls'):
            matched_model = match_result['model']
            permit_partial = match_result.get('status') != 'write_off'
            self._action_add_new_amls(
                match_result['amls'],
                reco_model=matched_model,
                allow_partial=permit_partial,
            )
        if match_result.get('status') == 'write_off':
            self._action_select_reconcile_model(match_result['model'])
        if match_result.get('auto_reconcile'):
            self.matching_rules_allow_auto_reconcile = True

        return match_result

    def _prepare_embedded_views_data(self):
        """Build configuration for the embedded journal item list views.

        Returns domain, filters, and context for the JS frontend to render
        the list of available journal items for matching.
        """
        self.ensure_one()
        stmt_entry = self.st_line_id

        view_context = {
            'search_view_ref': 'fusion_accounting.view_account_move_line_search_bank_rec_widget',
            'list_view_ref': 'fusion_accounting.view_account_move_line_list_bank_rec_widget',
        }
        if self.partner_id:
            view_context['search_default_partner_id'] = self.partner_id.id

        # Build dynamic filter for Customer/Vendor vs Misc separation
        journal = stmt_entry.journal_id
        payment_account_ids = set()

        for acct in journal._get_journal_inbound_outstanding_payment_accounts() - journal.default_account_id:
            payment_account_ids.add(acct.id)
        for acct in journal._get_journal_outbound_outstanding_payment_accounts() - journal.default_account_id:
            payment_account_ids.add(acct.id)

        receivable_payable_domain = [
            '|',
            '&',
            ('account_id.account_type', 'in', ('asset_receivable', 'liability_payable')),
            ('payment_id', '=', False),
            '&',
            ('account_id', 'in', tuple(payment_account_ids)),
            ('payment_id', '!=', False),
        ]

        filter_specs = [
            {
                'name': 'receivable_payable_matching',
                'description': _("Customer/Vendor"),
                'domain': str(receivable_payable_domain),
                'no_separator': True,
                'is_default': False,
            },
            {
                'name': 'misc_matching',
                'description': _("Misc"),
                'domain': str(['!'] + receivable_payable_domain),
                'is_default': False,
            },
        ]

        return {
            'amls': {
                'domain': stmt_entry._get_default_amls_matching_domain(),
                'dynamic_filters': filter_specs,
                'context': view_context,
            },
        }

    def _action_mount_st_line(self, stmt_entry):
        """Load a statement line into the widget and trigger matching rules."""
        self.ensure_one()
        self.st_line_id = stmt_entry
        self.form_index = self.line_ids[0].index if self.state == 'reconciled' else None
        self._action_trigger_matching_rules()

    def _action_reload_liquidity_line(self):
        """Reload the widget after the liquidity entry (statement line) was modified."""
        self.ensure_one()
        self = self.with_context(default_st_line_id=self.st_line_id.id)
        self.invalidate_model()

        # Force-load lines to prevent cache issues
        self.line_ids
        self._action_trigger_matching_rules()

        # Restore focus to the liquidity entry
        liq_entry = self.line_ids.filtered(lambda rec: rec.flag == 'liquidity')
        self._js_action_mount_line_in_edit(liq_entry.index)

    def _action_clear_manual_operations_form(self):
        """Close the manual operations form panel."""
        self.form_index = None

    def _action_remove_lines(self, target_entries):
        """Remove the specified entries and rebalance.

        After removal, recomputes taxes if needed, checks for early payment
        discounts or partial matching opportunities, and refreshes the
        auto-balance entry.
        """
        self.ensure_one()
        if not target_entries:
            return

        taxes_affected = bool(target_entries.tax_ids)
        had_matched_items = any(entry.flag == 'new_aml' for entry in target_entries)

        self.line_ids = [Command.unlink(entry.id) for entry in target_entries]
        self._remove_related_exchange_diff_lines(target_entries)

        if taxes_affected:
            self._lines_recompute_taxes()
        if had_matched_items and not self._lines_check_apply_early_payment_discount():
            self._lines_check_apply_partial_matching()
        self._lines_add_auto_balance_line()
        self._action_clear_manual_operations_form()

    def _action_add_new_amls(self, move_lines, reco_model=None, allow_partial=True):
        """Add journal items as reconciliation counterparts.

        Filters out items that are already present, creates widget entries,
        computes exchange differences, checks for early payment discounts
        and partial matching, then rebalances.
        """
        self.ensure_one()
        already_loaded = set(
            self.line_ids
            .filtered(lambda rec: rec.flag in ('new_aml', 'aml', 'liquidity'))
            .source_aml_id
        )
        new_items = move_lines.filtered(lambda item: item not in already_loaded)
        if not new_items:
            return

        self._lines_load_new_amls(new_items, reco_model=reco_model)
        newly_added = self.line_ids.filtered(
            lambda rec: rec.flag == 'new_aml' and rec.source_aml_id in new_items
        )
        self._lines_recompute_exchange_diff(newly_added)

        if not self._lines_check_apply_early_payment_discount() and allow_partial:
            self._lines_check_apply_partial_matching()

        self._lines_add_auto_balance_line()
        self._action_clear_manual_operations_form()

    def _action_remove_new_amls(self, move_lines):
        """Remove specific matched journal items from the reconciliation."""
        self.ensure_one()
        entries_to_remove = self.line_ids.filtered(
            lambda rec: rec.flag == 'new_aml' and rec.source_aml_id in move_lines
        )
        self._action_remove_lines(entries_to_remove)

    def _action_select_reconcile_model(self, reco_model):
        """Apply a reconciliation model's write-off lines.

        Removes entries from any previously selected model, then creates
        new entries based on the selected model's configuration. For
        sale/purchase models, creates an invoice/bill instead.
        """
        self.ensure_one()

        # Remove entries from previously applied models
        self.line_ids = [
            Command.unlink(entry.id)
            for entry in self.line_ids
            if (
                entry.flag not in ('new_aml', 'liquidity')
                and entry.reconcile_model_id
                and entry.reconcile_model_id != reco_model
            )
        ]
        self._lines_recompute_taxes()

        if reco_model.to_check:
            self.st_line_id.move_id.checked = False
            self.invalidate_recordset(fnames=['st_line_checked'])

        # Compute available balance for the model's write-off lines
        balance_data = self._lines_prepare_auto_balance_line()
        available_amount = balance_data['amount_currency']

        writeoff_specs = reco_model._apply_lines_for_bank_widget(
            available_amount, self.partner_id, self.st_line_id,
        )

        if reco_model.rule_type == 'writeoff_button' and reco_model.counterpart_type in ('sale', 'purchase'):
            # Create an invoice/bill from the write-off specification
            created_doc = self._create_invoice_from_write_off_values(reco_model, writeoff_specs)
            action_data = {
                'type': 'ir.actions.act_window',
                'res_model': 'account.move',
                'context': {'create': False},
                'view_mode': 'form',
                'res_id': created_doc.id,
            }
            self.return_todo_command = clean_action(action_data, self.env)
        else:
            # Create write-off entries directly
            self.line_ids = [
                Command.create(self._lines_prepare_reco_model_write_off_vals(reco_model, spec))
                for spec in writeoff_specs
            ]
            self._lines_recompute_taxes()
            self._lines_add_auto_balance_line()

    def _create_invoice_from_write_off_values(self, reco_model, writeoff_specs):
        """Create an invoice or bill from reconcile model write-off data.

        Determines the move type based on the amount direction and model type,
        then creates the invoice with appropriate line items.
        """
        target_journal = reco_model.line_ids.journal_id[:1]
        invoice_items = []
        cumulative_amount = 0.0
        pct_of_statement = 0.0

        for spec in writeoff_specs:
            spec_copy = dict(spec)
            if 'percentage_st_line' not in spec_copy:
                cumulative_amount -= spec_copy['amount_currency']
            pct_of_statement += spec_copy.pop('percentage_st_line', 0)
            spec_copy.pop('currency_id', None)
            spec_copy.pop('partner_id', None)
            spec_copy.pop('reconcile_model_id', None)
            invoice_items.append(spec_copy)

        stmt_amount = (
            self.st_line_id.amount_currency
            if self.st_line_id.foreign_currency_id
            else self.st_line_id.amount
        )
        cumulative_amount += self.transaction_currency_id.round(stmt_amount * pct_of_statement)

        # Determine invoice type from amount direction and model type
        if reco_model.counterpart_type == 'sale':
            doc_type = 'out_invoice' if cumulative_amount > 0 else 'out_refund'
        else:
            doc_type = 'in_invoice' if cumulative_amount < 0 else 'in_refund'

        sign_for_price = 1 if cumulative_amount < 0.0 else -1
        item_commands = []
        for item_vals in invoice_items:
            raw_total = sign_for_price * item_vals.pop('amount_currency')
            applicable_taxes = self.env['account.tax'].browse(item_vals['tax_ids'][0][2])
            item_vals['price_unit'] = self._get_invoice_price_unit_from_price_total(
                raw_total, applicable_taxes,
            )
            item_commands.append(Command.create(item_vals))

        doc_vals = {
            'invoice_date': self.st_line_id.date,
            'move_type': doc_type,
            'partner_id': self.st_line_id.partner_id.id,
            'currency_id': self.transaction_currency_id.id,
            'payment_reference': self.st_line_id.payment_ref,
            'invoice_line_ids': item_commands,
        }
        if target_journal:
            doc_vals['journal_id'] = target_journal.id

        created_doc = self.env['account.move'].create(doc_vals)
        if not created_doc.currency_id.is_zero(created_doc.amount_total - cumulative_amount):
            created_doc._check_total_amount(abs(cumulative_amount))

        return created_doc

    def _get_invoice_price_unit_from_price_total(self, total_with_tax, applicable_taxes):
        """Reverse-compute the unit price from a tax-inclusive total."""
        self.ensure_one()
        tax_details = applicable_taxes._get_tax_details(
            total_with_tax,
            1.0,
            precision_rounding=self.transaction_currency_id.rounding,
            rounding_method=self.company_id.tax_calculation_rounding_method,
            special_mode='total_included',
        )
        included_tax_total = sum(
            detail['tax_amount']
            for detail in tax_details['taxes_data']
            if detail['tax'].price_include
        )
        return tax_details['total_excluded'] + included_tax_total

    # =========================================================================
    # VALIDATION
    # =========================================================================

    def _validation_lines_vals(self, orm_ops, fx_correction_data, reconciliation_pairs):
        """Build journal item creation commands from the current widget entries.

        Processes each widget entry into an account.move.line creation command,
        squashing exchange difference amounts into their corresponding matched
        items. Tracks which entries need reconciliation against counterparts.
        """
        non_liq_entries = self.line_ids.filtered(lambda rec: rec.flag != 'liquidity')
        unique_partners = non_liq_entries.partner_id
        partner_for_liq = unique_partners if len(unique_partners) == 1 else self.env['res.partner']

        fx_by_source = self.line_ids.filtered(
            lambda rec: rec.flag == 'exchange_diff'
        ).grouped('source_aml_id')

        for entry in self.line_ids:
            if entry.flag == 'exchange_diff':
                continue

            entry_foreign = entry.amount_currency
            entry_comp = entry.balance

            if entry.flag == 'new_aml':
                sequence_idx = len(orm_ops) + 1
                reconciliation_pairs.append((sequence_idx, entry.source_aml_id))

                related_fx = fx_by_source.get(entry.source_aml_id)
                if related_fx:
                    fx_correction_data[sequence_idx] = {
                        'amount_residual': related_fx.balance,
                        'amount_residual_currency': related_fx.amount_currency,
                        'analytic_distribution': related_fx.analytic_distribution,
                    }
                    entry_foreign += related_fx.amount_currency
                    entry_comp += related_fx.balance

            # Determine partner: use unified partner for liquidity/auto_balance
            assigned_partner = (
                partner_for_liq.id
                if entry.flag in ('liquidity', 'auto_balance')
                else entry.partner_id.id
            )

            orm_ops.append(Command.create(entry._get_aml_values(
                sequence=len(orm_ops) + 1,
                partner_id=assigned_partner,
                amount_currency=entry_foreign,
                balance=entry_comp,
            )))

    def _action_validate(self):
        """Finalize the reconciliation by writing journal items and reconciling.

        Creates the final set of journal items on the statement line's move,
        handles exchange difference moves, performs the reconciliation, and
        updates partner/bank account information.
        """
        self.ensure_one()
        non_liq_entries = self.line_ids.filtered(lambda rec: rec.flag != 'liquidity')
        unique_partners = non_liq_entries.partner_id
        partner_for_move = unique_partners if len(unique_partners) == 1 else self.env['res.partner']

        reconciliation_pairs = []
        orm_ops = []
        fx_correction_data = {}

        self._validation_lines_vals(orm_ops, fx_correction_data, reconciliation_pairs)

        stmt_entry = self.st_line_id
        target_move = stmt_entry.move_id

        # Write the finalized journal items to the move
        editable_move = target_move.with_context(force_delete=True, skip_readonly_check=True)
        editable_move.write({
            'partner_id': partner_for_move.id,
            'line_ids': [Command.clear()] + orm_ops,
        })

        # Map sequences to the created journal items
        MoveLine = self.env['account.move.line']
        items_by_seq = editable_move.line_ids.grouped('sequence')
        paired_items = [
            (items_by_seq[seq_idx], counterpart_item)
            for seq_idx, counterpart_item in reconciliation_pairs
        ]
        all_involved_ids = tuple({
            item_id
            for created_item, counterpart in paired_items
            for item_id in (created_item + counterpart).ids
        })

        # Handle exchange difference moves
        fx_moves = None
        items_with_fx = MoveLine
        if fx_correction_data:
            fx_move_specs = []
            for created_item, counterpart in paired_items:
                prefetched_item = created_item.with_prefetch(all_involved_ids)
                prefetched_counterpart = counterpart.with_prefetch(all_involved_ids)
                fx_amounts = fx_correction_data.get(prefetched_item.sequence, {})
                fx_analytics = fx_amounts.pop('analytic_distribution', False)
                if fx_amounts:
                    # Determine which side gets the exchange difference
                    if fx_amounts['amount_residual'] * prefetched_item.amount_residual > 0:
                        fx_target = prefetched_item
                    else:
                        fx_target = prefetched_counterpart
                    fx_move_specs.append(fx_target._prepare_exchange_difference_move_vals(
                        [fx_amounts],
                        exchange_date=max(prefetched_item.date, prefetched_counterpart.date),
                        exchange_analytic_distribution=fx_analytics,
                    ))
                    items_with_fx += prefetched_item
            fx_moves = MoveLine._create_exchange_difference_moves(fx_move_specs)

        # Execute the reconciliation plan
        self.env['account.move.line'].with_context(no_exchange_difference=True)._reconcile_plan([
            (created_item + counterpart).with_prefetch(all_involved_ids)
            for created_item, counterpart in paired_items
        ])

        # Link exchange moves to the appropriate partial records
        for idx, fx_item in enumerate(items_with_fx):
            fx_move = fx_moves[idx]
            for side in ('debit', 'credit'):
                partial_records = fx_item[f'matched_{side}_ids'].filtered(
                    lambda partial: partial[f'{side}_move_id'].move_id != fx_move
                )
                partial_records.exchange_move_id = fx_move

        # Update partner on the statement line
        editable_stmt = stmt_entry.with_context(
            skip_account_move_synchronization=True,
            skip_readonly_check=True,
        )
        editable_stmt.partner_id = partner_for_move

        # Create or link partner bank account if applicable
        if stmt_entry.account_number and stmt_entry.partner_id:
            editable_stmt.partner_bank_id = (
                stmt_entry._find_or_create_bank_account() or stmt_entry.partner_bank_id
            )

        # Refresh analytic tracking
        target_move.line_ids.analytic_line_ids.unlink()
        target_move.line_ids.with_context(validate_analytic=True)._create_analytic_lines()

    @contextmanager
    def _action_validate_method(self):
        """Context manager wrapping validation to handle post-validation cleanup.

        Saves a reference to the statement line before validation (which
        invalidates the current record), then reloads everything after.
        """
        self.ensure_one()
        preserved_stmt = self.st_line_id
        yield
        self.st_line_id = preserved_stmt
        self._ensure_loaded_lines()
        self.return_todo_command = {'done': True}

    def _action_to_check(self):
        """Validate and mark the transaction as needing review."""
        self.st_line_id.move_id.checked = False
        self.invalidate_recordset(fnames=['st_line_checked'])
        self._action_validate()

    # =========================================================================
    # JS ACTION HANDLERS
    # =========================================================================

    def _js_action_mount_st_line(self, st_line_id):
        """Load a statement line by ID and return embedded view configuration."""
        self.ensure_one()
        stmt_entry = self.env['account.bank.statement.line'].browse(st_line_id)
        self._action_mount_st_line(stmt_entry)
        self.return_todo_command = self._prepare_embedded_views_data()

    def _js_action_restore_st_line_data(self, initial_data):
        """Restore the widget to a previously saved state.

        Used when the user navigates back from an invoice form or other
        view. Checks if the liquidity entry was modified externally and
        re-triggers matching if so.
        """
        self.ensure_one()
        saved_values = initial_data['initial_values']

        self.st_line_id = self.env['account.bank.statement.line'].browse(saved_values['st_line_id'])
        saved_return_cmd = saved_values['return_todo_command']

        # Detect liquidity line modifications requiring a full reload
        current_liq = self.line_ids.filtered(lambda rec: rec.flag == 'liquidity')
        saved_liq_data = next(
            (cmd[2] for cmd in saved_values['line_ids'] if cmd[2]['flag'] == 'liquidity'),
            {},
        )
        reference_liq = self.env['bank.rec.widget.line'].new(saved_liq_data)
        check_fields = saved_liq_data.keys() - {'index', 'suggestion_html'}
        for field_name in check_fields:
            if reference_liq[field_name] != current_liq[field_name]:
                self._js_action_mount_st_line(self.st_line_id.id)
                return

        # Remove fields that should be recomputed fresh
        for transient_field in ('id', 'st_line_id', 'todo_command', 'return_todo_command', 'available_reco_model_ids'):
            saved_values.pop(transient_field, None)

        matching_domain = self.st_line_id._get_default_amls_matching_domain()
        saved_values['line_ids'] = self._process_restore_lines_ids(saved_values['line_ids'])
        self.update(saved_values)

        # Check if a newly created invoice should be auto-matched
        if (
            saved_return_cmd
            and saved_return_cmd.get('res_model') == 'account.move'
            and (new_doc := self.env['account.move'].browse(saved_return_cmd['res_id']))
            and new_doc.state == 'posted'
        ):
            matchable_items = new_doc.line_ids.filtered_domain(matching_domain)
            self._action_add_new_amls(matchable_items)
        else:
            self._lines_add_auto_balance_line()

        self.return_todo_command = self._prepare_embedded_views_data()

    def _process_restore_lines_ids(self, saved_commands):
        """Filter saved line commands to remove entries whose source items are no longer available."""
        matching_domain = self.st_line_id._get_default_amls_matching_domain()
        valid_source_ids = self.env['account.move.line'].browse(
            cmd[2]['source_aml_id']
            for cmd in saved_commands
            if cmd[0] == Command.CREATE and cmd[2].get('source_aml_id')
        ).filtered_domain(matching_domain).ids
        valid_source_ids += [None]  # Allow entries without a source

        restored_commands = [Command.clear()]
        for cmd in saved_commands:
            match cmd:
                case (Command.CREATE, _, vals) if vals.get('source_aml_id') in valid_source_ids:
                    restored_commands.append(Command.create(vals))
                case _:
                    restored_commands.append(cmd)
        return restored_commands

    def _js_action_validate(self):
        """JS entry point for validation."""
        with self._action_validate_method():
            self._action_validate()

    def _js_action_to_check(self):
        """JS entry point for validate-and-flag-for-review."""
        self.ensure_one()
        if self.state == 'valid':
            with self._action_validate_method():
                self._action_to_check()
        else:
            self.st_line_id.move_id.checked = False
            self.invalidate_recordset(fnames=['st_line_checked'])
            self.return_todo_command = {'done': True}

    def _js_action_reset(self):
        """Undo a completed reconciliation and return to matching mode.

        Validates that the transaction isn't locked by hash verification
        before proceeding with the un-reconciliation.
        """
        self.ensure_one()
        stmt_entry = self.st_line_id

        if stmt_entry.inalterable_hash:
            if not stmt_entry.has_reconciled_entries:
                raise UserError(_(
                    "You can't hit the reset button on a secured bank transaction."
                ))
            else:
                raise RedirectWarning(
                    message=_(
                        "This bank transaction is protected by an integrity hash and"
                        " cannot be reset directly. Would you like to unreconcile it instead?"
                    ),
                    action=stmt_entry.move_id.open_reconcile_view(),
                    button_text=_('View Reconciled Entries'),
                )

        stmt_entry.action_undo_reconciliation()
        self.st_line_id = stmt_entry
        self._ensure_loaded_lines()
        self._action_trigger_matching_rules()
        self.return_todo_command = {'done': True}

    def _js_action_set_as_checked(self):
        """Mark the transaction as reviewed/checked."""
        self.ensure_one()
        self.st_line_id.move_id.checked = True
        self.invalidate_recordset(fnames=['st_line_checked'])
        self.return_todo_command = {'done': True}

    def _js_action_remove_line(self, line_index):
        """Remove a specific widget entry by its index."""
        self.ensure_one()
        target = self.line_ids.filtered(lambda rec: rec.index == line_index)
        self._action_remove_lines(target)

    def _js_action_add_new_aml(self, aml_id):
        """Add a single journal item as a reconciliation counterpart."""
        self.ensure_one()
        move_line = self.env['account.move.line'].browse(aml_id)
        self._action_add_new_amls(move_line)

    def _js_action_remove_new_aml(self, aml_id):
        """Remove a specific matched journal item."""
        self.ensure_one()
        move_line = self.env['account.move.line'].browse(aml_id)
        self._action_remove_new_amls(move_line)

    def _js_action_select_reconcile_model(self, reco_model_id):
        """Apply a reconciliation model by its ID."""
        self.ensure_one()
        reco_model = self.env['account.reconcile.model'].browse(reco_model_id)
        self._action_select_reconcile_model(reco_model)

    def _js_action_mount_line_in_edit(self, line_index):
        """Select a widget entry for editing in the manual operations form."""
        self.ensure_one()
        self.form_index = line_index

    def _js_action_line_changed(self, form_index, field_name):
        """Handle a field value change on a widget entry from the JS frontend.

        Invalidates the field cache to trigger recomputation of dependent
        fields, then dispatches to the appropriate _line_value_changed_* handler.
        """
        self.ensure_one()
        target_entry = self.line_ids.filtered(lambda rec: rec.index == form_index)

        # Force recomputation by invalidating and re-setting the value
        current_value = target_entry[field_name]
        target_entry.invalidate_recordset(fnames=[field_name], flush=False)
        target_entry[field_name] = current_value

        handler = getattr(self, f'_line_value_changed_{field_name}')
        handler(target_entry)

    def _js_action_line_set_partner_receivable_account(self, form_index):
        """Switch the entry's account to the partner's receivable account."""
        self.ensure_one()
        target_entry = self.line_ids.filtered(lambda rec: rec.index == form_index)
        target_entry.account_id = target_entry.partner_receivable_account_id
        self._line_value_changed_account_id(target_entry)

    def _js_action_line_set_partner_payable_account(self, form_index):
        """Switch the entry's account to the partner's payable account."""
        self.ensure_one()
        target_entry = self.line_ids.filtered(lambda rec: rec.index == form_index)
        target_entry.account_id = target_entry.partner_payable_account_id
        self._line_value_changed_account_id(target_entry)

    def _js_action_redirect_to_move(self, form_index):
        """Open the source document (invoice, payment, or journal entry) in a new form."""
        self.ensure_one()
        target_entry = self.line_ids.filtered(lambda rec: rec.index == form_index)
        source_move = target_entry.source_aml_move_id

        redirect_action = {
            'type': 'ir.actions.act_window',
            'context': {'create': False},
            'view_mode': 'form',
        }

        if source_move.origin_payment_id:
            redirect_action['res_model'] = 'account.payment'
            redirect_action['res_id'] = source_move.origin_payment_id.id
        else:
            redirect_action['res_model'] = 'account.move'
            redirect_action['res_id'] = source_move.id

        self.return_todo_command = clean_action(redirect_action, self.env)

    def _js_action_apply_line_suggestion(self, form_index):
        """Apply the computed suggestion amounts to a matched entry.

        Reads the suggestion values first to avoid dependency conflicts,
        then applies them and triggers the appropriate change handler.
        """
        self.ensure_one()
        target_entry = self.line_ids.filtered(lambda rec: rec.index == form_index)

        # Capture suggestion values before modifying fields they depend on
        suggested_foreign = target_entry.suggestion_amount_currency
        suggested_comp = target_entry.suggestion_balance

        target_entry.amount_currency = suggested_foreign
        target_entry.balance = suggested_comp

        if target_entry.currency_id == target_entry.company_currency_id:
            self._line_value_changed_balance(target_entry)
        else:
            self._line_value_changed_amount_currency(target_entry)

    # =========================================================================
    # GLOBAL INFO
    # =========================================================================

    @api.model
    def collect_global_info_data(self, journal_id):
        """Retrieve the current statement balance for display in the widget header."""
        journal = self.env['account.journal'].browse(journal_id)
        formatted_balance = ''
        if (
            journal.exists()
            and any(
                company in journal.company_id._accessible_branches()
                for company in self.env.companies
            )
        ):
            display_currency = journal.currency_id or journal.company_id.sudo().currency_id
            formatted_balance = formatLang(
                self.env,
                journal.current_statement_balance,
                currency_obj=display_currency,
            )
        return {'balance_amount': formatted_balance}
