# Fusion Accounting - Bank Statement & Statement Line Extensions
# Reconciliation widget support, auto-reconciliation CRON, partner matching

import logging

from dateutil.relativedelta import relativedelta
from itertools import product
from markupsafe import Markup

from odoo import _, api, fields, models
from odoo.addons.base.models.res_bank import sanitize_account_number
from odoo.exceptions import UserError
from odoo.osv import expression
from odoo.tools import html2plaintext

_log = logging.getLogger(__name__)


class FusionBankStatement(models.Model):
    """Extends bank statements with reconciliation widget integration
    and PDF attachment generation."""

    _name = "account.bank.statement"
    _inherit = ['mail.thread.main.attachment', 'account.bank.statement']

    # ---- Actions ----
    def action_open_bank_reconcile_widget(self):
        """Launch the bank reconciliation widget scoped to this statement."""
        self.ensure_one()
        return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
            name=self.name,
            default_context={
                'search_default_statement_id': self.id,
                'search_default_journal_id': self.journal_id.id,
            },
            extra_domain=[('statement_id', '=', self.id)],
        )

    def action_generate_attachment(self):
        """Render statement as PDF and attach it to the record."""
        report_sudo = self.env['ir.actions.report'].sudo()
        stmt_report_action = self.env.ref('account.action_report_account_statement')
        for stmt in self:
            stmt_report = stmt_report_action.sudo()
            pdf_bytes, _mime = report_sudo._render_qweb_pdf(
                stmt_report, res_ids=stmt.ids,
            )
            filename = (
                _("Bank Statement %s.pdf", stmt.name)
                if stmt.name
                else _("Bank Statement.pdf")
            )
            stmt.attachment_ids |= self.env['ir.attachment'].create({
                'name': filename,
                'type': 'binary',
                'mimetype': 'application/pdf',
                'raw': pdf_bytes,
                'res_model': stmt._name,
                'res_id': stmt.id,
            })
        return stmt_report_action.report_action(docids=self)


class FusionBankStatementLine(models.Model):
    """Extends bank statement lines with reconciliation workflow,
    automated matching via CRON, and partner detection heuristics."""

    _inherit = 'account.bank.statement.line'

    # ---- Fields ----
    cron_last_check = fields.Datetime()

    # Ensure each imported transaction is unique
    unique_import_id = fields.Char(
        string='Import ID',
        readonly=True,
        copy=False,
    )

    _sql_constraints = [
        (
            'unique_import_id',
            'unique (unique_import_id)',
            'A bank account transaction can be imported only once!',
        ),
    ]

    # ---- Quick Actions ----
    def action_save_close(self):
        """Close the current form after saving."""
        return {'type': 'ir.actions.act_window_close'}

    def action_save_new(self):
        """Save and immediately open a fresh statement line form."""
        window_action = self.env['ir.actions.act_window']._for_xml_id(
            'fusion_accounting.action_bank_statement_line_form_bank_rec_widget'
        )
        window_action['context'] = {
            'default_journal_id': self.env.context['default_journal_id'],
        }
        return window_action

    # ---- Reconciliation Widget ----
    @api.model
    def _action_open_bank_reconciliation_widget(
        self, extra_domain=None, default_context=None, name=None, kanban_first=True,
    ):
        """Return an action dict that opens the bank reconciliation widget."""
        xml_suffix = '_kanban' if kanban_first else ''
        act_ref = f'fusion_accounting.action_bank_statement_line_transactions{xml_suffix}'
        widget_action = self.env['ir.actions.act_window']._for_xml_id(act_ref)
        widget_action.update({
            'name': name or _("Bank Reconciliation"),
            'context': default_context or {},
            'domain': [('state', '!=', 'cancel')] + (extra_domain or []),
        })
        # Provide a helpful empty-state message listing supported import formats
        available_fmts = self.env['account.journal']._get_bank_statements_available_import_formats()
        widget_action['help'] = Markup(
            "<p class='o_view_nocontent_smiling_face'>{heading}</p>"
            "<p>{detail}<br/>{hint}</p>"
        ).format(
            heading=_('Nothing to do here!'),
            detail=_('No transactions matching your filters were found.'),
            hint=_('Click "New" or upload a %s.', ", ".join(available_fmts)),
        )
        return widget_action

    def action_open_recon_st_line(self):
        """Open the reconciliation widget focused on a single statement line."""
        self.ensure_one()
        return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
            name=self.name,
            default_context={
                'default_statement_id': self.statement_id.id,
                'default_journal_id': self.journal_id.id,
                'default_st_line_id': self.id,
                'search_default_id': self.id,
            },
        )

    # ---- Auto-Reconciliation CRON ----
    def _cron_try_auto_reconcile_statement_lines(self, batch_size=None, limit_time=0):
        """Attempt to automatically reconcile statement lines using
        configured reconciliation models. Processes unreconciled lines
        prioritised by those never previously checked by the CRON."""

        def _fetch_candidates(eligible_companies):
            """Return a batch of unreconciled lines and a marker for the next batch."""
            leftover_id = None
            fetch_limit = (batch_size + 1) if batch_size else None
            search_domain = [
                ('is_reconciled', '=', False),
                ('create_date', '>', run_start.date() - relativedelta(months=3)),
                ('company_id', 'in', eligible_companies.ids),
            ]
            candidates = self.search(
                search_domain,
                limit=fetch_limit,
                order="cron_last_check ASC NULLS FIRST, id",
            )
            if batch_size and len(candidates) > batch_size:
                leftover_id = candidates[batch_size].id
                candidates = candidates[:batch_size]
            return candidates, leftover_id

        run_start = fields.Datetime.now()

        # Identify companies that have auto-reconcile models configured
        recon_companies = child_cos = (
            self.env['account.reconcile.model']
            .search_fetch(
                [
                    ('auto_reconcile', '=', True),
                    ('rule_type', 'in', ('writeoff_suggestion', 'invoice_matching')),
                ],
                ['company_id'],
            )
            .company_id
        )
        if not recon_companies:
            return
        while child_cos := child_cos.child_ids:
            recon_companies += child_cos

        target_lines, next_line_id = (
            (self, None) if self else _fetch_candidates(recon_companies)
        )

        auto_matched_count = 0
        for idx, st_line in enumerate(target_lines):
            if limit_time and (fields.Datetime.now().timestamp() - run_start.timestamp()) > limit_time:
                next_line_id = st_line.id
                target_lines = target_lines[:idx]
                break

            rec_widget = self.env['bank.rec.widget'].with_context(
                default_st_line_id=st_line.id,
            ).new({})
            rec_widget._action_trigger_matching_rules()

            if rec_widget.state == 'valid' and rec_widget.matching_rules_allow_auto_reconcile:
                try:
                    rec_widget._action_validate()
                    if st_line.is_reconciled:
                        model_names = ', '.join(
                            st_line.move_id.line_ids.reconcile_model_id.mapped('name')
                        )
                        st_line.move_id.message_post(
                            body=_(
                                "This transaction was auto-reconciled using model '%s'.",
                                model_names,
                            ),
                        )
                        auto_matched_count += 1
                except UserError as exc:
                    _log.info(
                        "Auto-reconciliation of statement line %s failed: %s",
                        st_line.id, str(exc),
                    )
                    continue

        target_lines.write({'cron_last_check': run_start})

        if next_line_id:
            pending_line = self.env['account.bank.statement.line'].browse(next_line_id)
            if auto_matched_count or not pending_line.cron_last_check:
                self.env.ref(
                    'fusion_accounting.auto_reconcile_bank_statement_line'
                )._trigger()

    # ---- Partner Detection ----
    def _retrieve_partner(self):
        """Heuristically determine the partner for this statement line
        by inspecting bank account numbers, partner names, and
        reconciliation model mappings."""
        self.ensure_one()

        # 1. Already assigned
        if self.partner_id:
            return self.partner_id

        # 2. Match by bank account number
        if self.account_number:
            normalised_number = sanitize_account_number(self.account_number)
            if normalised_number:
                bank_domain = [('sanitized_acc_number', 'ilike', normalised_number)]
                for company_filter in (
                    [('company_id', 'parent_of', self.company_id.id)],
                    [('company_id', '=', False)],
                ):
                    matched_banks = self.env['res.partner.bank'].search(
                        company_filter + bank_domain
                    )
                    if len(matched_banks.partner_id) == 1:
                        return matched_banks.partner_id
                    # Filter out archived partners when multiple matches
                    active_banks = matched_banks.filtered(lambda b: b.partner_id.active)
                    if len(active_banks) == 1:
                        return active_banks.partner_id

        # 3. Match by partner name
        if self.partner_name:
            name_match_strategies = product(
                [
                    ('complete_name', '=ilike', self.partner_name),
                    ('complete_name', 'ilike', self.partner_name),
                ],
                [
                    ('company_id', 'parent_of', self.company_id.id),
                    ('company_id', '=', False),
                ],
            )
            for combined_domain in name_match_strategies:
                found_partner = self.env['res.partner'].search(
                    list(combined_domain) + [('parent_id', '=', False)],
                    limit=2,
                )
                if len(found_partner) == 1:
                    return found_partner

        # 4. Match through reconcile model partner mappings
        applicable_models = self.env['account.reconcile.model'].search([
            *self.env['account.reconcile.model']._check_company_domain(self.company_id),
            ('rule_type', '!=', 'writeoff_button'),
        ])
        for recon_model in applicable_models:
            mapped_partner = recon_model._get_partner_from_mapping(self)
            if mapped_partner and recon_model._is_applicable_for(self, mapped_partner):
                return mapped_partner

        return self.env['res.partner']

    # ---- Text Extraction for Matching ----
    def _get_st_line_strings_for_matching(self, allowed_fields=None):
        """Collect textual values from the statement line for use in
        matching algorithms."""
        self.ensure_one()
        collected_strings = []
        if not allowed_fields or 'payment_ref' in allowed_fields:
            if self.payment_ref:
                collected_strings.append(self.payment_ref)
        if not allowed_fields or 'narration' in allowed_fields:
            plain_notes = html2plaintext(self.narration or "")
            if plain_notes:
                collected_strings.append(plain_notes)
        if not allowed_fields or 'ref' in allowed_fields:
            if self.ref:
                collected_strings.append(self.ref)
        return collected_strings

    # ---- Domain Helpers ----
    def _get_default_amls_matching_domain(self):
        """Exclude stock valuation accounts from the default matching domain."""
        base_domain = super()._get_default_amls_matching_domain()
        stock_categories = self.env['product.category'].search([
            '|',
            ('property_stock_account_input_categ_id', '!=', False),
            ('property_stock_account_output_categ_id', '!=', False),
        ])
        excluded_accounts = (
            stock_categories.mapped('property_stock_account_input_categ_id')
            + stock_categories.mapped('property_stock_account_output_categ_id')
        )
        if excluded_accounts:
            return expression.AND([
                base_domain,
                [('account_id', 'not in', tuple(set(excluded_accounts.ids)))],
            ])
        return base_domain
