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

import uuid
import markupsafe

from odoo import _, api, fields, models, Command
from odoo.osv import expression
from odoo.tools.misc import formatLang, frozendict


# Flags that derive their values directly from the source journal entry
_SOURCE_LINKED_FLAGS = frozenset({'aml', 'new_aml', 'liquidity', 'exchange_diff'})
# Flags where the statement line date should be used
_STMT_DATE_FLAGS = frozenset({'liquidity', 'auto_balance', 'manual', 'early_payment', 'tax_line'})
# Flags that derive partner from the source journal entry
_PARTNER_FROM_SOURCE_FLAGS = frozenset({'aml', 'new_aml'})
# Flags that use the widget's partner
_PARTNER_FROM_WIDGET_FLAGS = frozenset({'liquidity', 'auto_balance', 'manual', 'early_payment', 'tax_line'})
# Flags that derive currency from the transaction
_TRANSACTION_CURRENCY_FLAGS = frozenset({'auto_balance', 'manual', 'early_payment'})


class FusionBankRecLine(models.Model):
    """Represents a single entry within the bank reconciliation widget.

    Each entry has a 'flag' indicating its role in the reconciliation process:
    - liquidity: The bank/cash journal item from the statement line
    - new_aml: A journal item being matched against the statement line
    - aml: An already-reconciled journal item (read-only display)
    - exchange_diff: Automatically generated foreign exchange adjustment
    - tax_line: Tax amount computed from manual entries
    - manual: A user-created write-off or adjustment entry
    - early_payment: Discount entry for early payment terms
    - auto_balance: System-generated balancing entry

    This model exists only in memory; no database table is created.
    """

    _name = "bank.rec.widget.line"
    _inherit = "analytic.mixin"
    _description = "Fusion bank reconciliation entry"

    _auto = False
    _table_query = "0"

    # --- Relationship to parent widget ---
    wizard_id = fields.Many2one(comodel_name='bank.rec.widget')
    index = fields.Char(compute='_compute_index')
    flag = fields.Selection(
        selection=[
            ('liquidity', 'liquidity'),
            ('new_aml', 'new_aml'),
            ('aml', 'aml'),
            ('exchange_diff', 'exchange_diff'),
            ('tax_line', 'tax_line'),
            ('manual', 'manual'),
            ('early_payment', 'early_payment'),
            ('auto_balance', 'auto_balance'),
        ],
    )

    # --- Core accounting fields ---
    journal_default_account_id = fields.Many2one(
        related='wizard_id.st_line_id.journal_id.default_account_id',
        depends=['wizard_id'],
    )
    account_id = fields.Many2one(
        comodel_name='account.account',
        compute='_compute_account_id',
        store=True,
        readonly=False,
        check_company=True,
        domain="""[
            ('id', '!=', journal_default_account_id),
            ('account_type', 'not in', ('asset_cash', 'off_balance')),
        ]""",
    )
    date = fields.Date(
        compute='_compute_date',
        store=True,
        readonly=False,
    )
    name = fields.Char(
        compute='_compute_name',
        store=True,
        readonly=False,
    )
    partner_id = fields.Many2one(
        comodel_name='res.partner',
        compute='_compute_partner_id',
        store=True,
        readonly=False,
    )
    currency_id = fields.Many2one(
        comodel_name='res.currency',
        compute='_compute_currency_id',
        store=True,
        readonly=False,
    )
    company_id = fields.Many2one(related='wizard_id.company_id')
    country_code = fields.Char(related='company_id.country_id.code', depends=['company_id'])
    company_currency_id = fields.Many2one(related='wizard_id.company_currency_id')

    amount_currency = fields.Monetary(
        currency_field='currency_id',
        compute='_compute_amount_currency',
        store=True,
        readonly=False,
    )
    balance = fields.Monetary(
        currency_field='company_currency_id',
        compute='_compute_balance',
        store=True,
        readonly=False,
    )

    # --- Transaction currency fields (from statement line) ---
    transaction_currency_id = fields.Many2one(
        related='wizard_id.st_line_id.foreign_currency_id',
        depends=['wizard_id'],
    )
    amount_transaction_currency = fields.Monetary(
        currency_field='transaction_currency_id',
        related='wizard_id.st_line_id.amount_currency',
        depends=['wizard_id'],
    )

    # --- Debit/Credit split ---
    debit = fields.Monetary(
        currency_field='company_currency_id',
        compute='_compute_from_balance',
    )
    credit = fields.Monetary(
        currency_field='company_currency_id',
        compute='_compute_from_balance',
    )

    # --- Tax handling ---
    force_price_included_taxes = fields.Boolean()
    tax_base_amount_currency = fields.Monetary(currency_field='currency_id')

    # --- Source journal entry reference ---
    source_aml_id = fields.Many2one(comodel_name='account.move.line')
    source_aml_move_id = fields.Many2one(
        comodel_name='account.move',
        compute='_compute_source_aml_fields',
        store=True,
        readonly=False,
    )
    source_aml_move_name = fields.Char(
        compute='_compute_source_aml_fields',
        store=True,
        readonly=False,
    )

    # --- Tax detail fields ---
    tax_repartition_line_id = fields.Many2one(
        comodel_name='account.tax.repartition.line',
        compute='_compute_tax_repartition_line_id',
        store=True,
        readonly=False,
    )
    tax_ids = fields.Many2many(
        comodel_name='account.tax',
        compute='_compute_tax_ids',
        store=True,
        readonly=False,
        check_company=True,
    )
    tax_tag_ids = fields.Many2many(
        comodel_name='account.account.tag',
        compute='_compute_tax_tag_ids',
        store=True,
        readonly=False,
    )
    group_tax_id = fields.Many2one(
        comodel_name='account.tax',
        compute='_compute_group_tax_id',
        store=True,
        readonly=False,
    )

    # --- Reconcile model tracking ---
    reconcile_model_id = fields.Many2one(comodel_name='account.reconcile.model')

    # --- Original (pre-partial) amounts for comparison ---
    source_amount_currency = fields.Monetary(currency_field='currency_id')
    source_balance = fields.Monetary(currency_field='company_currency_id')
    source_debit = fields.Monetary(
        currency_field='company_currency_id',
        compute='_compute_from_source_balance',
    )
    source_credit = fields.Monetary(
        currency_field='company_currency_id',
        compute='_compute_from_source_balance',
    )

    # --- Visual indicators for partial amounts ---
    display_stroked_amount_currency = fields.Boolean(compute='_compute_display_stroked_amount_currency')
    display_stroked_balance = fields.Boolean(compute='_compute_display_stroked_balance')

    # --- Partner account info for UI suggestions ---
    partner_currency_id = fields.Many2one(
        comodel_name='res.currency',
        compute='_compute_partner_info',
    )
    partner_receivable_account_id = fields.Many2one(
        comodel_name='account.account',
        compute='_compute_partner_info',
    )
    partner_payable_account_id = fields.Many2one(
        comodel_name='account.account',
        compute='_compute_partner_info',
    )
    partner_receivable_amount = fields.Monetary(
        currency_field='partner_currency_id',
        compute='_compute_partner_info',
    )
    partner_payable_amount = fields.Monetary(
        currency_field='partner_currency_id',
        compute='_compute_partner_info',
    )

    # --- Display fields ---
    bank_account = fields.Char(compute='_compute_bank_account')
    suggestion_html = fields.Html(
        compute='_compute_suggestion',
        sanitize=False,
    )
    suggestion_amount_currency = fields.Monetary(
        currency_field='currency_id',
        compute='_compute_suggestion',
    )
    suggestion_balance = fields.Monetary(
        currency_field='company_currency_id',
        compute='_compute_suggestion',
    )
    ref = fields.Char(
        compute='_compute_ref_narration',
        store=True,
        readonly=False,
    )
    narration = fields.Html(
        compute='_compute_ref_narration',
        store=True,
        readonly=False,
    )

    manually_modified = fields.Boolean()

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

    def _compute_index(self):
        """Assign a unique identifier to each entry for JS-side tracking."""
        for entry in self:
            entry.index = uuid.uuid4()

    @api.depends('source_aml_id')
    def _compute_account_id(self):
        """Derive the account from the source journal item for linked entries.

        Entries tied to actual journal items (aml, new_aml, liquidity, exchange_diff)
        inherit the account directly. Other entry types retain their current account.
        """
        for entry in self:
            if entry.flag in _SOURCE_LINKED_FLAGS:
                entry.account_id = entry.source_aml_id.account_id
            else:
                entry.account_id = entry.account_id

    @api.depends('source_aml_id')
    def _compute_date(self):
        """Set the date based on the entry type.

        Source-linked entries (aml, new_aml, exchange_diff) use the original journal
        item date. Statement-based entries use the statement line date.
        """
        for entry in self:
            if entry.flag in _STMT_DATE_FLAGS:
                entry.date = entry.wizard_id.st_line_id.date
            elif entry.flag in ('aml', 'new_aml', 'exchange_diff'):
                entry.date = entry.source_aml_id.date
            else:
                entry.date = entry.date

    @api.depends('source_aml_id')
    def _compute_name(self):
        """Set the description/label from the source journal item when applicable.

        For entries derived from journal items, the label is taken from the
        original item. If the source has no name (e.g. credit notes), the
        move name is used as fallback.
        """
        for entry in self:
            if entry.flag in ('aml', 'new_aml', 'liquidity'):
                entry.name = entry.source_aml_id.name or entry.source_aml_move_name
            else:
                entry.name = entry.name

    @api.depends('source_aml_id')
    def _compute_partner_id(self):
        """Determine the partner for each entry based on its type.

        Matched journal items carry their own partner. Statement-derived
        entries use the partner set on the reconciliation widget.
        """
        for entry in self:
            if entry.flag in _PARTNER_FROM_SOURCE_FLAGS:
                entry.partner_id = entry.source_aml_id.partner_id
            elif entry.flag in _PARTNER_FROM_WIDGET_FLAGS:
                entry.partner_id = entry.wizard_id.partner_id
            else:
                entry.partner_id = entry.partner_id

    @api.depends('source_aml_id')
    def _compute_currency_id(self):
        """Set the currency based on entry type.

        Source-linked entries use the currency from the original journal item.
        Transaction-related entries (auto_balance, manual, early_payment) use
        the transaction currency from the bank statement.
        """
        for entry in self:
            if entry.flag in _SOURCE_LINKED_FLAGS:
                entry.currency_id = entry.source_aml_id.currency_id
            elif entry.flag in _TRANSACTION_CURRENCY_FLAGS:
                entry.currency_id = entry.wizard_id.transaction_currency_id
            else:
                entry.currency_id = entry.currency_id

    @api.depends('source_aml_id')
    def _compute_balance(self):
        """Set the company-currency balance from the source when applicable.

        Only 'aml' and 'liquidity' entries copy the balance directly from the
        source journal item. All other types preserve their computed/manual balance.
        """
        for entry in self:
            if entry.flag in ('aml', 'liquidity'):
                entry.balance = entry.source_aml_id.balance
            else:
                entry.balance = entry.balance

    @api.depends('source_aml_id')
    def _compute_amount_currency(self):
        """Set the foreign currency amount from the source when applicable.

        Only 'aml' and 'liquidity' entries copy directly from the source.
        """
        for entry in self:
            if entry.flag in ('aml', 'liquidity'):
                entry.amount_currency = entry.source_aml_id.amount_currency
            else:
                entry.amount_currency = entry.amount_currency

    @api.depends('balance')
    def _compute_from_balance(self):
        """Split the balance into separate debit and credit components."""
        for entry in self:
            entry.debit = max(entry.balance, 0.0)
            entry.credit = max(-entry.balance, 0.0)

    @api.depends('source_balance')
    def _compute_from_source_balance(self):
        """Split the original source balance into debit and credit."""
        for entry in self:
            entry.source_debit = max(entry.source_balance, 0.0)
            entry.source_credit = max(-entry.source_balance, 0.0)

    @api.depends('source_aml_id', 'account_id', 'partner_id')
    def _compute_analytic_distribution(self):
        """Compute analytic distribution based on entry type.

        Source-linked entries (liquidity, aml) inherit from the source item.
        Tax/early-payment entries keep their current distribution. Other entries
        look up the default distribution from analytic distribution models.
        """
        distribution_cache = {}
        for entry in self:
            if entry.flag in ('liquidity', 'aml'):
                entry.analytic_distribution = entry.source_aml_id.analytic_distribution
            elif entry.flag in ('tax_line', 'early_payment'):
                entry.analytic_distribution = entry.analytic_distribution
            else:
                lookup_params = frozendict({
                    "partner_id": entry.partner_id.id,
                    "partner_category_id": entry.partner_id.category_id.ids,
                    "account_prefix": entry.account_id.code,
                    "company_id": entry.company_id.id,
                })
                if lookup_params not in distribution_cache:
                    distribution_cache[lookup_params] = (
                        self.env['account.analytic.distribution.model']._get_distribution(lookup_params)
                    )
                entry.analytic_distribution = distribution_cache[lookup_params] or entry.analytic_distribution

    @api.depends('source_aml_id')
    def _compute_tax_repartition_line_id(self):
        """Inherit tax repartition line from the source for 'aml' entries only."""
        for entry in self:
            if entry.flag == 'aml':
                entry.tax_repartition_line_id = entry.source_aml_id.tax_repartition_line_id
            else:
                entry.tax_repartition_line_id = entry.tax_repartition_line_id

    @api.depends('source_aml_id')
    def _compute_tax_ids(self):
        """Copy applied tax references from the source for 'aml' entries."""
        for entry in self:
            if entry.flag == 'aml':
                entry.tax_ids = [Command.set(entry.source_aml_id.tax_ids.ids)]
            else:
                entry.tax_ids = entry.tax_ids

    @api.depends('source_aml_id')
    def _compute_tax_tag_ids(self):
        """Copy tax tags from the source for 'aml' entries."""
        for entry in self:
            if entry.flag == 'aml':
                entry.tax_tag_ids = [Command.set(entry.source_aml_id.tax_tag_ids.ids)]
            else:
                entry.tax_tag_ids = entry.tax_tag_ids

    @api.depends('source_aml_id')
    def _compute_group_tax_id(self):
        """Copy the group tax reference from the source for 'aml' entries."""
        for entry in self:
            if entry.flag == 'aml':
                entry.group_tax_id = entry.source_aml_id.group_tax_id
            else:
                entry.group_tax_id = entry.group_tax_id

    @api.depends('currency_id', 'amount_currency', 'source_amount_currency')
    def _compute_display_stroked_amount_currency(self):
        """Determine whether to show a strikethrough on the foreign currency amount.

        This visual indicator appears when a 'new_aml' entry has been partially
        matched (its current amount differs from the original source amount).
        """
        for entry in self:
            is_modified = entry.currency_id.compare_amounts(
                entry.amount_currency, entry.source_amount_currency
            ) != 0
            entry.display_stroked_amount_currency = entry.flag == 'new_aml' and is_modified

    @api.depends('currency_id', 'balance', 'source_balance')
    def _compute_display_stroked_balance(self):
        """Determine whether to show a strikethrough on the balance.

        Applies to 'new_aml' and 'exchange_diff' entries whose balance
        has been adjusted from the original source value.
        """
        for entry in self:
            balance_changed = entry.currency_id.compare_amounts(
                entry.balance, entry.source_balance
            ) != 0
            entry.display_stroked_balance = (
                entry.flag in ('new_aml', 'exchange_diff') and balance_changed
            )

    @api.depends('flag')
    def _compute_source_aml_fields(self):
        """Resolve the originating move for display and navigation purposes.

        For 'new_aml' and 'liquidity' entries, this is simply the move containing
        the source journal item. For 'aml' (already reconciled) entries, we trace
        through partial reconciliation records to find the counterpart document.
        """
        for entry in self:
            entry.source_aml_move_id = None
            entry.source_aml_move_name = None

            if entry.flag in ('new_aml', 'liquidity'):
                originating_move = entry.source_aml_id.move_id
                entry.source_aml_move_id = originating_move
                entry.source_aml_move_name = originating_move.name
            elif entry.flag == 'aml':
                # Trace through reconciliation partials to find the counterpart
                partial_records = (
                    entry.source_aml_id.matched_debit_ids
                    + entry.source_aml_id.matched_credit_ids
                )
                linked_items = partial_records.debit_move_id + partial_records.credit_move_id
                # Exclude the source itself and any exchange difference entries
                fx_move_items = partial_records.exchange_move_id.line_ids
                counterpart_items = linked_items - entry.source_aml_id - fx_move_items
                if len(counterpart_items) == 1:
                    entry.source_aml_move_id = counterpart_items.move_id
                    entry.source_aml_move_name = counterpart_items.move_id.name

    @api.depends('wizard_id.form_index', 'partner_id')
    def _compute_partner_info(self):
        """Load receivable/payable account info for the selected partner.

        This data is used by the UI to offer account switching suggestions
        when a partner is set on a manual entry. Only computed for the
        entry currently being edited (matching the form_index).
        """
        for entry in self:
            # Set defaults
            entry.partner_receivable_amount = 0.0
            entry.partner_payable_amount = 0.0
            entry.partner_currency_id = None
            entry.partner_receivable_account_id = None
            entry.partner_payable_account_id = None

            # Only compute for the actively edited entry with a partner
            if not entry.partner_id or entry.index != entry.wizard_id.form_index:
                continue

            entry.partner_currency_id = entry.company_currency_id
            scoped_partner = entry.partner_id.with_company(entry.wizard_id.company_id)
            posted_filter = [('parent_state', '=', 'posted'), ('partner_id', '=', scoped_partner.id)]

            # Receivable info
            recv_account = scoped_partner.property_account_receivable_id
            entry.partner_receivable_account_id = recv_account
            if recv_account:
                recv_domain = expression.AND([posted_filter, [('account_id', '=', recv_account.id)]])
                recv_data = self.env['account.move.line']._read_group(
                    domain=recv_domain,
                    aggregates=['amount_residual:sum'],
                )
                entry.partner_receivable_amount = recv_data[0][0]

            # Payable info
            pay_account = scoped_partner.property_account_payable_id
            entry.partner_payable_account_id = pay_account
            if pay_account:
                pay_domain = expression.AND([posted_filter, [('account_id', '=', pay_account.id)]])
                pay_data = self.env['account.move.line']._read_group(
                    domain=pay_domain,
                    aggregates=['amount_residual:sum'],
                )
                entry.partner_payable_amount = pay_data[0][0]

    @api.depends('flag')
    def _compute_bank_account(self):
        """Show the bank account number on the liquidity entry only."""
        for entry in self:
            if entry.flag == 'liquidity':
                stmt_line = entry.wizard_id.st_line_id
                displayed_account = stmt_line.partner_bank_id.display_name or stmt_line.account_number
                entry.bank_account = displayed_account or None
            else:
                entry.bank_account = None

    @api.depends('wizard_id.form_index', 'amount_currency', 'balance')
    def _compute_suggestion(self):
        """Build contextual suggestion text for matched journal items.

        When a 'new_aml' entry is being edited, this generates guidance text
        explaining the reconciliation impact and offering a quick action button
        for full or partial matching.
        """
        for entry in self:
            entry.suggestion_html = None
            entry.suggestion_amount_currency = None
            entry.suggestion_balance = None

            # Only generate suggestions for actively edited matched entries
            if entry.flag != 'new_aml' or entry.index != entry.wizard_id.form_index:
                continue

            source_item = entry.source_aml_id
            parent_widget = entry.wizard_id
            original_residual = abs(source_item.amount_residual_currency)
            post_match_residual = abs(source_item.amount_residual_currency + entry.amount_currency)
            matched_portion = original_residual - post_match_residual
            fully_consumed = source_item.currency_id.is_zero(post_match_residual)
            belongs_to_invoice = source_item.move_id.is_invoice(include_receipts=True)

            # Build the clickable document reference
            doc_link_html = markupsafe.Markup(
                '<button name="action_redirect_to_move"'
                ' class="btn btn-link p-0 align-baseline fst-italic">'
                '%(doc_name)s</button>'
            ) % {'doc_name': source_item.move_id.display_name}

            # Shared template parameters
            tpl_params = {
                'amount': formatLang(self.env, matched_portion, currency_obj=source_item.currency_id),
                'open_amount': formatLang(self.env, original_residual, currency_obj=source_item.currency_id),
                'display_name_html': doc_link_html,
                'btn_start': markupsafe.Markup(
                    '<button name="action_apply_line_suggestion"'
                    ' class="btn btn-link p-0 align-baseline fst-italic">'
                ),
                'btn_end': markupsafe.Markup('</button>'),
            }

            if fully_consumed:
                # Full match scenario
                if belongs_to_invoice:
                    status_msg = _(
                        "The invoice %(display_name_html)s with an open amount of"
                        " %(open_amount)s will be entirely paid by the transaction."
                    )
                else:
                    status_msg = _(
                        "%(display_name_html)s with an open amount of %(open_amount)s"
                        " will be fully reconciled by the transaction."
                    )
                suggestion_lines = [status_msg]

                # Check if a partial would be more appropriate
                partial_data = parent_widget._lines_check_partial_amount(entry)
                if partial_data:
                    if belongs_to_invoice:
                        partial_msg = _(
                            "You might want to record a"
                            " %(btn_start)spartial payment%(btn_end)s."
                        )
                    else:
                        partial_msg = _(
                            "You might want to make a"
                            " %(btn_start)spartial reconciliation%(btn_end)s instead."
                        )
                    suggestion_lines.append(partial_msg)
                    entry.suggestion_amount_currency = partial_data['amount_currency']
                    entry.suggestion_balance = partial_data['balance']
            else:
                # Partial match scenario - suggest full reconciliation
                if belongs_to_invoice:
                    suggestion_lines = [
                        _(
                            "The invoice %(display_name_html)s with an open amount of"
                            " %(open_amount)s will be reduced by %(amount)s."
                        ),
                        _(
                            "You might want to set the invoice as"
                            " %(btn_start)sfully paid%(btn_end)s."
                        ),
                    ]
                else:
                    suggestion_lines = [
                        _(
                            "%(display_name_html)s with an open amount of"
                            " %(open_amount)s will be reduced by %(amount)s."
                        ),
                        _(
                            "You might want to %(btn_start)sfully reconcile%(btn_end)s"
                            " the document."
                        ),
                    ]
                entry.suggestion_amount_currency = entry.source_amount_currency
                entry.suggestion_balance = entry.source_balance

            rendered_lines = markupsafe.Markup('<br/>').join(
                msg % tpl_params for msg in suggestion_lines
            )
            entry.suggestion_html = (
                markupsafe.Markup('<div class="text-muted">%s</div>') % rendered_lines
            )

    @api.depends('flag')
    def _compute_ref_narration(self):
        """Populate ref and narration from the statement line for liquidity entries."""
        for entry in self:
            if entry.flag == 'liquidity':
                entry.ref = entry.wizard_id.st_line_id.ref
                entry.narration = entry.wizard_id.st_line_id.narration
            else:
                entry.ref = None
                entry.narration = None

    # =========================================================================
    # HELPERS
    # =========================================================================

    def _get_aml_values(self, **kwargs):
        """Convert this widget entry into values suitable for creating journal items.

        Returns a dictionary of field values that can be passed to
        Command.create() for account.move.line records during validation.
        """
        self.ensure_one()
        vals = {
            'name': self.name,
            'account_id': self.account_id.id,
            'currency_id': self.currency_id.id,
            'amount_currency': self.amount_currency,
            'balance': self.debit - self.credit,
            'reconcile_model_id': self.reconcile_model_id.id,
            'analytic_distribution': self.analytic_distribution,
            'tax_repartition_line_id': self.tax_repartition_line_id.id,
            'tax_ids': [Command.set(self.tax_ids.ids)],
            'tax_tag_ids': [Command.set(self.tax_tag_ids.ids)],
            'group_tax_id': self.group_tax_id.id,
        }
        vals.update(kwargs)
        if self.flag == 'early_payment':
            vals['display_type'] = 'epd'
        return vals
