# Fusion Accounting - Aged Partner Balance Report Handler

import datetime

from odoo import models, fields, _
from odoo.tools import SQL
from odoo.tools.misc import format_date

from dateutil.relativedelta import relativedelta
from itertools import chain


class AgedPartnerBalanceCustomHandler(models.AbstractModel):
    """Base handler for aged receivable / payable reports.

    Groups outstanding amounts into configurable aging buckets so the user
    can visualise how long balances have been open.
    """

    _name = 'account.aged.partner.balance.report.handler'
    _inherit = 'account.report.custom.handler'
    _description = 'Aged Partner Balance Custom Handler'

    # ------------------------------------------------------------------
    # Display & options
    # ------------------------------------------------------------------

    def _get_custom_display_config(self):
        return {
            'css_custom_class': 'aged_partner_balance',
            'templates': {
                'AccountReportLineName': 'fusion_accounting.AgedPartnerBalanceLineName',
            },
            'components': {
                'AccountReportFilters': 'fusion_accounting.AgedPartnerBalanceFilters',
            },
        }

    def _custom_options_initializer(self, report, options, previous_options):
        """Configure multi-currency, aging interval, and column labels."""
        super()._custom_options_initializer(report, options, previous_options=previous_options)

        cols_to_hide = set()

        options['multi_currency'] = report.env.user.has_group('base.group_multi_currency')
        options['show_currency'] = (
            options['multi_currency']
            and (previous_options or {}).get('show_currency', False)
        )
        if not options['show_currency']:
            cols_to_hide.update(['amount_currency', 'currency'])

        options['show_account'] = (previous_options or {}).get('show_account', False)
        if not options['show_account']:
            cols_to_hide.add('account_name')

        options['columns'] = [
            c for c in options['columns']
            if c['expression_label'] not in cols_to_hide
        ]

        options['order_column'] = previous_options.get('order_column') or {
            'expression_label': 'invoice_date',
            'direction': 'ASC',
        }
        options['aging_based_on'] = previous_options.get('aging_based_on') or 'base_on_maturity_date'
        options['aging_interval'] = previous_options.get('aging_interval') or 30

        # Relabel period columns to reflect the chosen interval
        bucket_size = options['aging_interval']
        for col in options['columns']:
            label = col['expression_label']
            if label.startswith('period'):
                bucket_idx = int(label.replace('period', '')) - 1
                if 0 <= bucket_idx < 4:
                    lo = bucket_size * bucket_idx + 1
                    hi = bucket_size * (bucket_idx + 1)
                    col['name'] = f'{lo}-{hi}'

    # ------------------------------------------------------------------
    # Post-processing
    # ------------------------------------------------------------------

    def _custom_line_postprocessor(self, report, options, lines):
        """Inject the partner trust level into each partner line."""
        partner_line_map = {}
        for ln in lines:
            mdl, mid = report._get_model_info_from_id(ln['id'])
            if mdl == 'res.partner':
                partner_line_map[mid] = ln

        if partner_line_map:
            partners = self.env['res.partner'].browse(partner_line_map)
            for partner, ln_dict in zip(partners, partner_line_map.values()):
                ln_dict['trust'] = partner.with_company(
                    partner.company_id or self.env.company
                ).trust

        return lines

    # ------------------------------------------------------------------
    # Report engines
    # ------------------------------------------------------------------

    def _report_custom_engine_aged_receivable(
        self, expressions, options, date_scope,
        current_groupby, next_groupby,
        offset=0, limit=None, warnings=None,
    ):
        return self._aged_partner_report_custom_engine_common(
            options, 'asset_receivable', current_groupby, next_groupby,
            offset=offset, limit=limit,
        )

    def _report_custom_engine_aged_payable(
        self, expressions, options, date_scope,
        current_groupby, next_groupby,
        offset=0, limit=None, warnings=None,
    ):
        return self._aged_partner_report_custom_engine_common(
            options, 'liability_payable', current_groupby, next_groupby,
            offset=offset, limit=limit,
        )

    def _aged_partner_report_custom_engine_common(
        self, options, account_type, current_groupby, next_groupby,
        offset=0, limit=None,
    ):
        """Core query and aggregation logic shared by receivable and payable.

        Builds aging periods dynamically from the chosen interval, runs a
        single SQL query that joins against a period table, and returns
        either a flat result or a list of ``(grouping_key, result)`` pairs.
        """
        report = self.env['account.report'].browse(options['report_id'])
        report._check_groupby_fields(
            (next_groupby.split(',') if next_groupby else [])
            + ([current_groupby] if current_groupby else [])
        )

        def _subtract_days(dt, n):
            return fields.Date.to_string(dt - relativedelta(days=n))

        # Determine the aging date field
        if options['aging_based_on'] == 'base_on_invoice_date':
            age_field = SQL.identifier('invoice_date')
        else:
            age_field = SQL.identifier('date_maturity')

        report_end = fields.Date.from_string(options['date']['date_to'])
        interval = options['aging_interval']

        # Build period boundaries: [(start_or_None, stop_or_None), ...]
        period_list = [(False, fields.Date.to_string(report_end))]
        period_col_count = len([
            c for c in options['columns'] if c['expression_label'].startswith('period')
        ]) - 1

        for p in range(period_col_count):
            p_start = _subtract_days(report_end, interval * p + 1)
            p_stop = _subtract_days(report_end, interval * (p + 1)) if p < period_col_count - 1 else False
            period_list.append((p_start, p_stop))

        # Helper: aggregate query rows into the result dictionary
        def _aggregate_rows(rpt, rows):
            agg = {f'period{k}': 0 for k in range(len(period_list))}
            for r in rows:
                for k in range(len(period_list)):
                    agg[f'period{k}'] += r[f'period{k}']

            if current_groupby == 'id':
                single = rows[0]
                cur_obj = (
                    self.env['res.currency'].browse(single['currency_id'][0])
                    if len(single['currency_id']) == 1 else None
                )
                agg.update({
                    'invoice_date': single['invoice_date'][0] if len(single['invoice_date']) == 1 else None,
                    'due_date': single['due_date'][0] if len(single['due_date']) == 1 else None,
                    'amount_currency': single['amount_currency'],
                    'currency_id': single['currency_id'][0] if len(single['currency_id']) == 1 else None,
                    'currency': cur_obj.display_name if cur_obj else None,
                    'account_name': single['account_name'][0] if len(single['account_name']) == 1 else None,
                    'total': None,
                    'has_sublines': single['aml_count'] > 0,
                    'partner_id': single['partner_id'][0] if single['partner_id'] else None,
                })
            else:
                agg.update({
                    'invoice_date': None,
                    'due_date': None,
                    'amount_currency': None,
                    'currency_id': None,
                    'currency': None,
                    'account_name': None,
                    'total': sum(agg[f'period{k}'] for k in range(len(period_list))),
                    'has_sublines': False,
                })
            return agg

        # Build the VALUES clause for the period table
        period_vals_fmt = '(VALUES %s)' % ','.join('(%s, %s, %s)' for _ in period_list)
        flat_params = list(chain.from_iterable(
            (p[0] or None, p[1] or None, idx)
            for idx, p in enumerate(period_list)
        ))
        period_tbl = SQL(period_vals_fmt, *flat_params)

        # Build the main report query
        base_qry = report._get_report_query(
            options, 'strict_range',
            domain=[('account_id.account_type', '=', account_type)],
        )
        acct_alias = base_qry.left_join(
            lhs_alias='account_move_line', lhs_column='account_id',
            rhs_table='account_account', rhs_column='id', link='account_id',
        )
        acct_code_sql = self.env['account.account']._field_to_sql(acct_alias, 'code', base_qry)

        fixed_groupby = SQL("period_table.period_index")
        if current_groupby:
            select_grp = SQL("%s AS grouping_key,", SQL.identifier("account_move_line", current_groupby))
            full_groupby = SQL("%s, %s", SQL.identifier("account_move_line", current_groupby), fixed_groupby)
        else:
            select_grp = SQL()
            full_groupby = fixed_groupby

        sign = -1 if account_type == 'liability_payable' else 1

        period_case_sql = SQL(',').join(
            SQL("""
                CASE WHEN period_table.period_index = %(idx)s
                THEN %(sign)s * SUM(%(bal)s)
                ELSE 0 END AS %(col_id)s
                """,
                idx=n,
                sign=sign,
                col_id=SQL.identifier(f"period{n}"),
                bal=report._currency_table_apply_rate(SQL(
                    "account_move_line.balance"
                    " - COALESCE(part_debit.amount, 0)"
                    " + COALESCE(part_credit.amount, 0)"
                )),
            )
            for n in range(len(period_list))
        )

        tail_sql = report._get_engine_query_tail(offset, limit)

        full_sql = SQL(
            """
            WITH period_table(date_start, date_stop, period_index) AS (%(period_tbl)s)

            SELECT
                %(select_grp)s
                %(sign)s * (
                    SUM(account_move_line.amount_currency)
                    - COALESCE(SUM(part_debit.debit_amount_currency), 0)
                    + COALESCE(SUM(part_credit.credit_amount_currency), 0)
                ) AS amount_currency,
                ARRAY_AGG(DISTINCT account_move_line.partner_id) AS partner_id,
                ARRAY_AGG(account_move_line.payment_id) AS payment_id,
                ARRAY_AGG(DISTINCT move.invoice_date) AS invoice_date,
                ARRAY_AGG(DISTINCT COALESCE(account_move_line.%(age_field)s, account_move_line.date)) AS report_date,
                ARRAY_AGG(DISTINCT %(acct_code)s) AS account_name,
                ARRAY_AGG(DISTINCT COALESCE(account_move_line.%(age_field)s, account_move_line.date)) AS due_date,
                ARRAY_AGG(DISTINCT account_move_line.currency_id) AS currency_id,
                COUNT(account_move_line.id) AS aml_count,
                ARRAY_AGG(%(acct_code)s) AS account_code,
                %(period_case_sql)s

            FROM %(tbl_refs)s
            JOIN account_journal jnl ON jnl.id = account_move_line.journal_id
            JOIN account_move move ON move.id = account_move_line.move_id
            %(fx_join)s

            LEFT JOIN LATERAL (
                SELECT
                    SUM(pr.amount) AS amount,
                    SUM(pr.debit_amount_currency) AS debit_amount_currency,
                    pr.debit_move_id
                FROM account_partial_reconcile pr
                WHERE pr.max_date <= %(cutoff)s AND pr.debit_move_id = account_move_line.id
                GROUP BY pr.debit_move_id
            ) part_debit ON TRUE

            LEFT JOIN LATERAL (
                SELECT
                    SUM(pr.amount) AS amount,
                    SUM(pr.credit_amount_currency) AS credit_amount_currency,
                    pr.credit_move_id
                FROM account_partial_reconcile pr
                WHERE pr.max_date <= %(cutoff)s AND pr.credit_move_id = account_move_line.id
                GROUP BY pr.credit_move_id
            ) part_credit ON TRUE

            JOIN period_table ON
                (
                    period_table.date_start IS NULL
                    OR COALESCE(account_move_line.%(age_field)s, account_move_line.date) <= DATE(period_table.date_start)
                )
                AND
                (
                    period_table.date_stop IS NULL
                    OR COALESCE(account_move_line.%(age_field)s, account_move_line.date) >= DATE(period_table.date_stop)
                )

            WHERE %(where_cond)s

            GROUP BY %(full_groupby)s

            HAVING
                ROUND(SUM(%(having_dr)s), %(precision)s) != 0
                OR ROUND(SUM(%(having_cr)s), %(precision)s) != 0

            ORDER BY %(full_groupby)s

            %(tail)s
            """,
            acct_code=acct_code_sql,
            period_tbl=period_tbl,
            select_grp=select_grp,
            period_case_sql=period_case_sql,
            sign=sign,
            age_field=age_field,
            tbl_refs=base_qry.from_clause,
            fx_join=report._currency_table_aml_join(options),
            cutoff=report_end,
            where_cond=base_qry.where_clause,
            full_groupby=full_groupby,
            having_dr=report._currency_table_apply_rate(SQL(
                "CASE WHEN account_move_line.balance > 0 THEN account_move_line.balance ELSE 0 END"
                " - COALESCE(part_debit.amount, 0)"
            )),
            having_cr=report._currency_table_apply_rate(SQL(
                "CASE WHEN account_move_line.balance < 0 THEN -account_move_line.balance ELSE 0 END"
                " - COALESCE(part_credit.amount, 0)"
            )),
            precision=self.env.company.currency_id.decimal_places,
            tail=tail_sql,
        )

        self.env.cr.execute(full_sql)
        fetched_rows = self.env.cr.dictfetchall()

        if not current_groupby:
            return _aggregate_rows(report, fetched_rows)

        # Group rows by their grouping key
        grouped = {}
        for row in fetched_rows:
            gk = row['grouping_key']
            grouped.setdefault(gk, []).append(row)

        return [(gk, _aggregate_rows(report, rows)) for gk, rows in grouped.items()]

    # ------------------------------------------------------------------
    # Actions
    # ------------------------------------------------------------------

    def open_journal_items(self, options, params):
        params['view_ref'] = 'account.view_move_line_tree_grouped_partner'
        audit_opts = {**options, 'date': {**options['date'], 'date_from': None}}
        report = self.env['account.report'].browse(options['report_id'])
        action = report.open_journal_items(options=audit_opts, params=params)
        action.get('context', {}).update({
            'search_default_group_by_account': 0,
            'search_default_group_by_partner': 1,
        })
        return action

    def open_partner_ledger(self, options, params):
        report = self.env['account.report'].browse(options['report_id'])
        rec_model, rec_id = report._get_model_info_from_id(params.get('line_id'))
        return self.env[rec_model].browse(rec_id).open_partner_ledger()

    # ------------------------------------------------------------------
    # Batch unfold
    # ------------------------------------------------------------------

    def _common_custom_unfold_all_batch_data_generator(
        self, account_type, report, options, lines_to_expand_by_function,
    ):
        """Pre-load all data needed to unfold every partner in one pass."""
        output = {}
        num_periods = 6

        for fn_name, expand_lines in lines_to_expand_by_function.items():
            for target_line in expand_lines:
                if fn_name != '_report_expand_unfoldable_line_with_groupby':
                    continue

                report_line_id = report._get_res_id_from_line_id(target_line['id'], 'account.report.line')
                custom_exprs = report.line_ids.expression_ids.filtered(
                    lambda x: x.report_line_id.id == report_line_id and x.engine == 'custom'
                )
                if not custom_exprs:
                    continue

                for cg_key, cg_opts in report._split_options_per_column_group(options).items():
                    by_partner = {}
                    for aml_id, aml_vals in self._aged_partner_report_custom_engine_common(
                        cg_opts, account_type, 'id', None,
                    ):
                        aml_vals['aml_id'] = aml_id
                        by_partner.setdefault(aml_vals['partner_id'], []).append(aml_vals)

                    partner_expr_totals = (
                        output
                        .setdefault(f"[{report_line_id}]=>partner_id", {})
                        .setdefault(cg_key, {expr: {'value': []} for expr in custom_exprs})
                    )

                    for pid, aml_list in by_partner.items():
                        pv = self._prepare_partner_values()
                        for k in range(num_periods):
                            pv[f'period{k}'] = 0

                        aml_expr_totals = (
                            output
                            .setdefault(f"[{report_line_id}]partner_id:{pid}=>id", {})
                            .setdefault(cg_key, {expr: {'value': []} for expr in custom_exprs})
                        )

                        for aml_data in aml_list:
                            for k in range(num_periods):
                                period_val = aml_data[f'period{k}']
                                pv[f'period{k}'] += period_val
                                pv['total'] += period_val

                            for expr in custom_exprs:
                                aml_expr_totals[expr]['value'].append(
                                    (aml_data['aml_id'], aml_data[expr.subformula])
                                )

                        for expr in custom_exprs:
                            partner_expr_totals[expr]['value'].append(
                                (pid, pv[expr.subformula])
                            )

        return output

    def _prepare_partner_values(self):
        return {
            'invoice_date': None,
            'due_date': None,
            'amount_currency': None,
            'currency_id': None,
            'currency': None,
            'account_name': None,
            'total': 0,
        }

    # ------------------------------------------------------------------
    # Audit action
    # ------------------------------------------------------------------

    def aged_partner_balance_audit(self, options, params, journal_type):
        """Open a filtered list of invoices / bills for the clicked cell."""
        report = self.env['account.report'].browse(options['report_id'])
        action = self.env['ir.actions.actions']._for_xml_id('account.action_amounts_to_settle')

        excluded_type = {'purchase': 'sale', 'sale': 'purchase'}
        if options:
            action['domain'] = [
                ('account_id.reconcile', '=', True),
                ('journal_id.type', '!=', excluded_type.get(journal_type)),
                *self._build_domain_from_period(options, params['expression_label']),
                *report._get_options_domain(options, 'from_beginning'),
                *report._get_audit_line_groupby_domain(params['calling_line_dict_id']),
            ]
        return action

    def _build_domain_from_period(self, options, period_label):
        """Translate a period column label into a date-maturity domain."""
        if period_label == 'total' or not period_label[-1].isdigit():
            return []

        bucket_num = int(period_label[-1])
        end_date = datetime.datetime.strptime(options['date']['date_to'], '%Y-%m-%d')

        if bucket_num == 0:
            return [('date_maturity', '>=', options['date']['date_to'])]

        upper_bound = end_date - datetime.timedelta(30 * (bucket_num - 1) + 1)
        lower_bound = end_date - datetime.timedelta(30 * bucket_num)

        if bucket_num == 5:
            return [('date_maturity', '<=', lower_bound)]

        return [
            ('date_maturity', '>=', lower_bound),
            ('date_maturity', '<=', upper_bound),
        ]


# ======================================================================
# Payable sub-handler
# ======================================================================

class AgedPayableCustomHandler(models.AbstractModel):
    """Specialised handler for aged payable balances."""

    _name = 'account.aged.payable.report.handler'
    _inherit = 'account.aged.partner.balance.report.handler'
    _description = 'Aged Payable Custom Handler'

    def open_journal_items(self, options, params):
        payable_filter = {'id': 'trade_payable', 'name': _("Payable"), 'selected': True}
        options.setdefault('account_type', []).append(payable_filter)
        return super().open_journal_items(options, params)

    def _custom_unfold_all_batch_data_generator(self, report, options, lines_to_expand_by_function):
        ref_line = self.env.ref('fusion_accounting.aged_payable_line')
        if ref_line.groupby.replace(' ', '') == 'partner_id,id':
            return self._common_custom_unfold_all_batch_data_generator(
                'liability_payable', report, options, lines_to_expand_by_function,
            )
        return {}

    def action_audit_cell(self, options, params):
        return super().aged_partner_balance_audit(options, params, 'purchase')


# ======================================================================
# Receivable sub-handler
# ======================================================================

class AgedReceivableCustomHandler(models.AbstractModel):
    """Specialised handler for aged receivable balances."""

    _name = 'account.aged.receivable.report.handler'
    _inherit = 'account.aged.partner.balance.report.handler'
    _description = 'Aged Receivable Custom Handler'

    def open_journal_items(self, options, params):
        receivable_filter = {'id': 'trade_receivable', 'name': _("Receivable"), 'selected': True}
        options.setdefault('account_type', []).append(receivable_filter)
        return super().open_journal_items(options, params)

    def _custom_unfold_all_batch_data_generator(self, report, options, lines_to_expand_by_function):
        ref_line = self.env.ref('fusion_accounting.aged_receivable_line')
        if ref_line.groupby.replace(' ', '') == 'partner_id,id':
            return self._common_custom_unfold_all_batch_data_generator(
                'asset_receivable', report, options, lines_to_expand_by_function,
            )
        return {}

    def action_audit_cell(self, options, params):
        return super().aged_partner_balance_audit(options, params, 'sale')
