# Fusion Accounting - Cash Flow Statement Report Handler

from odoo import models, _
from odoo.tools import SQL, Query


class CashFlowReportCustomHandler(models.AbstractModel):
    """Generates the cash flow statement using the direct method.

    Reference: https://www.investopedia.com/terms/d/direct_method.asp

    The handler fetches liquidity journal entries, splits them into
    operating / investing / financing buckets based on account tags,
    and renders both section totals and per-account detail rows.
    """

    _name = 'account.cash.flow.report.handler'
    _inherit = 'account.report.custom.handler'
    _description = 'Cash Flow Report Custom Handler'

    # ------------------------------------------------------------------
    # Public entry points
    # ------------------------------------------------------------------

    def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None):
        """Build every line of the cash flow statement.

        Returns a list of ``(sequence, line_dict)`` tuples ready for the
        report engine.
        """
        output_lines = []

        section_structure = self._build_section_structure()
        computed_data = self._compute_report_data(report, options, section_structure)

        # Render each section header
        for section_key, section_meta in section_structure.items():
            output_lines.append(
                (0, self._render_section_line(report, options, section_key, section_meta, computed_data))
            )

            # Render detail rows grouped by account under this section
            if section_key in computed_data and 'aml_groupby_account' in computed_data[section_key]:
                detail_entries = computed_data[section_key]['aml_groupby_account'].values()

                # Separate entries with / without an account code for sorting
                coded_entries = [e for e in detail_entries if e['account_code'] is not None]
                uncoded_entries = [e for e in detail_entries if e['account_code'] is None]

                sorted_details = sorted(coded_entries, key=lambda r: r['account_code']) + uncoded_entries
                for detail in sorted_details:
                    output_lines.append((0, self._render_detail_line(report, options, detail)))

        # Append an unexplained-difference line when the numbers don't tie
        diff_line = self._render_unexplained_difference(report, options, computed_data)
        if diff_line:
            output_lines.append((0, diff_line))

        return output_lines

    def _custom_options_initializer(self, report, options, previous_options):
        """Restrict selectable journals to bank, cash, and general types."""
        super()._custom_options_initializer(report, options, previous_options=previous_options)
        report._init_options_journals(
            options,
            previous_options=previous_options,
            additional_journals_domain=[('type', 'in', ('bank', 'cash', 'general'))],
        )

    # ------------------------------------------------------------------
    # Data computation
    # ------------------------------------------------------------------

    def _compute_report_data(self, report, options, section_structure):
        """Aggregate all cash-flow numbers into *report_data*.

        The returned dictionary maps section keys (from
        ``_build_section_structure``) to balance and per-account detail
        dictionaries.
        """
        report_data = {}

        liquidity_acct_ids = self._fetch_liquidity_account_ids(report, options)
        if not liquidity_acct_ids:
            return report_data

        # Beginning-of-period balances
        for row in self._query_liquidity_balances(report, options, liquidity_acct_ids, 'to_beginning_of_period'):
            self._merge_into_report_data('opening_balance', row, section_structure, report_data)
            self._merge_into_report_data('closing_balance', row, section_structure, report_data)

        # Period movements
        for row in self._query_liquidity_balances(report, options, liquidity_acct_ids, 'strict_range'):
            self._merge_into_report_data('closing_balance', row, section_structure, report_data)

        tag_map = self._resolve_cashflow_tags()
        cf_tag_ids = self._list_cashflow_tag_ids()

        # Liquidity-side entries
        for grouped_rows in self._fetch_liquidity_side_entries(report, options, liquidity_acct_ids, cf_tag_ids):
            for row_data in grouped_rows.values():
                self._route_entry_to_section(tag_map, row_data, section_structure, report_data)

        # Reconciled counterpart entries
        for grouped_rows in self._fetch_reconciled_counterparts(report, options, liquidity_acct_ids, cf_tag_ids):
            for row_data in grouped_rows.values():
                self._route_entry_to_section(tag_map, row_data, section_structure, report_data)

        return report_data

    def _merge_into_report_data(self, section_key, row, section_structure, report_data):
        """Insert or accumulate *row* into *report_data* under *section_key*.

        Also propagates the balance upward through parent sections so that
        all ancestor totals stay correct.

        The *report_data* dictionary uses two sub-keys per section:
          * ``balance`` – a ``{column_group_key: float}`` mapping
          * ``aml_groupby_account`` – per-account detail rows
        """

        def _propagate_to_parent(sec_key, col_grp, amount, structure, data):
            """Walk the parent chain and add *amount* to every ancestor."""
            parent_ref = structure[sec_key].get('parent_line_id')
            if parent_ref:
                data.setdefault(parent_ref, {'balance': {}})
                data[parent_ref]['balance'].setdefault(col_grp, 0.0)
                data[parent_ref]['balance'][col_grp] += amount
                _propagate_to_parent(parent_ref, col_grp, amount, structure, data)

        col_grp = row['column_group_key']
        acct_id = row['account_id']
        acct_code = row['account_code']
        acct_label = row['account_name']
        amt = row['balance']
        tag_ref = row.get('account_tag_id')

        if self.env.company.currency_id.is_zero(amt):
            return

        report_data.setdefault(section_key, {
            'balance': {},
            'aml_groupby_account': {},
        })

        report_data[section_key]['aml_groupby_account'].setdefault(acct_id, {
            'parent_line_id': section_key,
            'account_id': acct_id,
            'account_code': acct_code,
            'account_name': acct_label,
            'account_tag_id': tag_ref,
            'level': section_structure[section_key]['level'] + 1,
            'balance': {},
        })

        report_data[section_key]['balance'].setdefault(col_grp, 0.0)
        report_data[section_key]['balance'][col_grp] += amt

        acct_entry = report_data[section_key]['aml_groupby_account'][acct_id]
        acct_entry['balance'].setdefault(col_grp, 0.0)
        acct_entry['balance'][col_grp] += amt

        _propagate_to_parent(section_key, col_grp, amt, section_structure, report_data)

    # ------------------------------------------------------------------
    # Tag helpers
    # ------------------------------------------------------------------

    def _resolve_cashflow_tags(self):
        """Return a mapping of activity type to account.account.tag ID."""
        return {
            'operating': self.env.ref('account.account_tag_operating').id,
            'investing': self.env.ref('account.account_tag_investing').id,
            'financing': self.env.ref('account.account_tag_financing').id,
        }

    def _list_cashflow_tag_ids(self):
        """Return an iterable of all cash-flow-relevant tag IDs."""
        return self._resolve_cashflow_tags().values()

    def _route_entry_to_section(self, tag_map, entry, section_structure, report_data):
        """Determine the correct report section for a single entry and
        merge it into *report_data*.

        Receivable / payable lines go to advance-payment sections.
        Other lines are classified by tag + sign (cash in vs cash out).
        """
        acct_type = entry['account_account_type']
        amt = entry['balance']

        if acct_type == 'asset_receivable':
            target = 'advance_payments_customer'
        elif acct_type == 'liability_payable':
            target = 'advance_payments_suppliers'
        elif amt < 0:
            tag_id = entry.get('account_tag_id')
            if tag_id == tag_map['operating']:
                target = 'paid_operating_activities'
            elif tag_id == tag_map['investing']:
                target = 'investing_activities_cash_out'
            elif tag_id == tag_map['financing']:
                target = 'financing_activities_cash_out'
            else:
                target = 'unclassified_activities_cash_out'
        elif amt > 0:
            tag_id = entry.get('account_tag_id')
            if tag_id == tag_map['operating']:
                target = 'received_operating_activities'
            elif tag_id == tag_map['investing']:
                target = 'investing_activities_cash_in'
            elif tag_id == tag_map['financing']:
                target = 'financing_activities_cash_in'
            else:
                target = 'unclassified_activities_cash_in'
        else:
            return

        self._merge_into_report_data(target, entry, section_structure, report_data)

    # ------------------------------------------------------------------
    # SQL queries
    # ------------------------------------------------------------------

    def _fetch_liquidity_account_ids(self, report, options):
        """Return a tuple of account IDs used by liquidity journals.

        Includes default accounts of bank/cash journals as well as any
        payment-method-specific accounts.
        """
        chosen_journal_ids = [j['id'] for j in report._get_options_journals(options)]

        if chosen_journal_ids:
            where_fragment = "aj.id IN %s"
            where_args = [tuple(chosen_journal_ids)]
        else:
            where_fragment = "aj.type IN ('bank', 'cash', 'general')"
            where_args = []

        self.env.cr.execute(f'''
            SELECT
                array_remove(ARRAY_AGG(DISTINCT aa.id), NULL),
                array_remove(ARRAY_AGG(DISTINCT apml.payment_account_id), NULL)
            FROM account_journal aj
            JOIN res_company rc ON aj.company_id = rc.id
            LEFT JOIN account_payment_method_line apml
                ON aj.id = apml.journal_id
            LEFT JOIN account_account aa
                ON aj.default_account_id = aa.id
                AND aa.account_type IN ('asset_cash', 'liability_credit_card')
            WHERE {where_fragment}
        ''', where_args)

        fetched = self.env.cr.fetchone()
        combined = set((fetched[0] or []) + (fetched[1] or []))
        return tuple(combined) if combined else ()

    def _build_move_ids_subquery(self, report, liquidity_acct_ids, col_group_opts) -> SQL:
        """Build a sub-select that returns move IDs touching liquidity accounts."""
        base_query = report._get_report_query(
            col_group_opts, 'strict_range',
            [('account_id', 'in', list(liquidity_acct_ids))],
        )
        return SQL(
            '''
            SELECT array_agg(DISTINCT account_move_line.move_id) AS move_id
            FROM %(tbl_refs)s
            WHERE %(conditions)s
            ''',
            tbl_refs=base_query.from_clause,
            conditions=base_query.where_clause,
        )

    def _query_liquidity_balances(self, report, options, liquidity_acct_ids, scope):
        """Compute per-account balances for liquidity accounts.

        *scope* is either ``'to_beginning_of_period'`` (opening) or
        ``'strict_range'`` (period movement).
        """
        sql_parts = []

        for col_key, col_opts in report._split_options_per_column_group(options).items():
            qry = report._get_report_query(col_opts, scope, domain=[('account_id', 'in', liquidity_acct_ids)])
            acct_alias = qry.join(
                lhs_alias='account_move_line', lhs_column='account_id',
                rhs_table='account_account', rhs_column='id', link='account_id',
            )
            code_sql = self.env['account.account']._field_to_sql(acct_alias, 'code', qry)
            name_sql = self.env['account.account']._field_to_sql(acct_alias, 'name')

            sql_parts.append(SQL(
                '''
                SELECT
                    %(col_key)s                             AS column_group_key,
                    account_move_line.account_id,
                    %(code_sql)s                            AS account_code,
                    %(name_sql)s                            AS account_name,
                    SUM(%(bal_expr)s)                       AS balance
                FROM %(tbl_refs)s
                %(fx_join)s
                WHERE %(conditions)s
                GROUP BY account_move_line.account_id, account_code, account_name
                ''',
                col_key=col_key,
                code_sql=code_sql,
                name_sql=name_sql,
                tbl_refs=qry.from_clause,
                bal_expr=report._currency_table_apply_rate(SQL("account_move_line.balance")),
                fx_join=report._currency_table_aml_join(col_opts),
                conditions=qry.where_clause,
            ))

        self.env.cr.execute(SQL(' UNION ALL ').join(sql_parts))
        return self.env.cr.dictfetchall()

    def _fetch_liquidity_side_entries(self, report, options, liquidity_acct_ids, cf_tag_ids):
        """Retrieve the non-liquidity side of moves that touch liquidity accounts.

        Three sub-queries per column group capture:
        1. Credit-side partial reconciliation amounts
        2. Debit-side partial reconciliation amounts
        3. Full line balances (for unreconciled portions)

        Returns a list of dicts keyed by ``(account_id, column_group_key)``.
        """
        aggregated = {}
        sql_parts = []

        for col_key, col_opts in report._split_options_per_column_group(options).items():
            move_sub = self._build_move_ids_subquery(report, liquidity_acct_ids, col_opts)
            q = Query(self.env, 'account_move_line')
            acct_alias = q.join(
                lhs_alias='account_move_line', lhs_column='account_id',
                rhs_table='account_account', rhs_column='id', link='account_id',
            )
            code_sql = self.env['account.account']._field_to_sql(acct_alias, 'code', q)
            name_sql = self.env['account.account']._field_to_sql(acct_alias, 'name')
            type_sql = SQL.identifier(acct_alias, 'account_type')

            sql_parts.append(SQL(
                '''
                (WITH liq_moves AS (%(move_sub)s)

                -- 1) Credit-side partial amounts
                SELECT
                    %(col_key)s AS column_group_key,
                    account_move_line.account_id,
                    %(code_sql)s AS account_code,
                    %(name_sql)s AS account_name,
                    %(type_sql)s AS account_account_type,
                    aat.account_account_tag_id AS account_tag_id,
                    SUM(%(partial_bal)s) AS balance
                FROM %(from_cl)s
                %(fx_join)s
                LEFT JOIN account_partial_reconcile
                    ON account_partial_reconcile.credit_move_id = account_move_line.id
                LEFT JOIN account_account_account_tag aat
                    ON aat.account_account_id = account_move_line.account_id
                    AND aat.account_account_tag_id IN %(cf_tags)s
                WHERE account_move_line.move_id IN (SELECT unnest(liq_moves.move_id) FROM liq_moves)
                    AND account_move_line.account_id NOT IN %(liq_accts)s
                    AND account_partial_reconcile.max_date BETWEEN %(dt_from)s AND %(dt_to)s
                GROUP BY account_move_line.company_id, account_move_line.account_id,
                         account_code, account_name, account_account_type,
                         aat.account_account_tag_id

                UNION ALL

                -- 2) Debit-side partial amounts (negated)
                SELECT
                    %(col_key)s AS column_group_key,
                    account_move_line.account_id,
                    %(code_sql)s AS account_code,
                    %(name_sql)s AS account_name,
                    %(type_sql)s AS account_account_type,
                    aat.account_account_tag_id AS account_tag_id,
                    -SUM(%(partial_bal)s) AS balance
                FROM %(from_cl)s
                %(fx_join)s
                LEFT JOIN account_partial_reconcile
                    ON account_partial_reconcile.debit_move_id = account_move_line.id
                LEFT JOIN account_account_account_tag aat
                    ON aat.account_account_id = account_move_line.account_id
                    AND aat.account_account_tag_id IN %(cf_tags)s
                WHERE account_move_line.move_id IN (SELECT unnest(liq_moves.move_id) FROM liq_moves)
                    AND account_move_line.account_id NOT IN %(liq_accts)s
                    AND account_partial_reconcile.max_date BETWEEN %(dt_from)s AND %(dt_to)s
                GROUP BY account_move_line.company_id, account_move_line.account_id,
                         account_code, account_name, account_account_type,
                         aat.account_account_tag_id

                UNION ALL

                -- 3) Full line balances
                SELECT
                    %(col_key)s AS column_group_key,
                    account_move_line.account_id,
                    %(code_sql)s AS account_code,
                    %(name_sql)s AS account_name,
                    %(type_sql)s AS account_account_type,
                    aat.account_account_tag_id AS account_tag_id,
                    SUM(%(line_bal)s) AS balance
                FROM %(from_cl)s
                %(fx_join)s
                LEFT JOIN account_account_account_tag aat
                    ON aat.account_account_id = account_move_line.account_id
                    AND aat.account_account_tag_id IN %(cf_tags)s
                WHERE account_move_line.move_id IN (SELECT unnest(liq_moves.move_id) FROM liq_moves)
                    AND account_move_line.account_id NOT IN %(liq_accts)s
                GROUP BY account_move_line.account_id, account_code, account_name,
                         account_account_type, aat.account_account_tag_id)
                ''',
                col_key=col_key,
                move_sub=move_sub,
                code_sql=code_sql,
                name_sql=name_sql,
                type_sql=type_sql,
                from_cl=q.from_clause,
                fx_join=report._currency_table_aml_join(col_opts),
                partial_bal=report._currency_table_apply_rate(SQL("account_partial_reconcile.amount")),
                line_bal=report._currency_table_apply_rate(SQL("account_move_line.balance")),
                cf_tags=tuple(cf_tag_ids),
                liq_accts=liquidity_acct_ids,
                dt_from=col_opts['date']['date_from'],
                dt_to=col_opts['date']['date_to'],
            ))

        self.env.cr.execute(SQL(' UNION ALL ').join(sql_parts))

        for rec in self.env.cr.dictfetchall():
            acct_id = rec['account_id']
            aggregated.setdefault(acct_id, {})
            aggregated[acct_id].setdefault(rec['column_group_key'], {
                'column_group_key': rec['column_group_key'],
                'account_id': acct_id,
                'account_code': rec['account_code'],
                'account_name': rec['account_name'],
                'account_account_type': rec['account_account_type'],
                'account_tag_id': rec['account_tag_id'],
                'balance': 0.0,
            })
            aggregated[acct_id][rec['column_group_key']]['balance'] -= rec['balance']

        return list(aggregated.values())

    def _fetch_reconciled_counterparts(self, report, options, liquidity_acct_ids, cf_tag_ids):
        """Retrieve moves reconciled with liquidity moves but that are not
        themselves liquidity moves.

        Each amount is valued proportionally to what has actually been paid,
        so a partially-paid invoice appears at the paid percentage.
        """
        reconciled_acct_ids_by_col = {cg: set() for cg in options['column_groups']}
        pct_map = {cg: {} for cg in options['column_groups']}
        fx_table = report._get_currency_table(options)

        # Step 1 – gather reconciliation amounts per move / account
        step1_parts = []
        for col_key, col_opts in report._split_options_per_column_group(options).items():
            move_sub = self._build_move_ids_subquery(report, liquidity_acct_ids, col_opts)
            step1_parts.append(SQL(
                '''
                (WITH liq_moves AS (%(move_sub)s)

                SELECT
                    %(col_key)s AS column_group_key,
                    dr.move_id, dr.account_id,
                    SUM(%(partial_amt)s) AS balance
                FROM account_move_line AS cr
                LEFT JOIN account_partial_reconcile
                    ON account_partial_reconcile.credit_move_id = cr.id
                JOIN %(fx_tbl)s
                    ON account_currency_table.company_id = account_partial_reconcile.company_id
                    AND account_currency_table.rate_type = 'current'
                INNER JOIN account_move_line AS dr
                    ON dr.id = account_partial_reconcile.debit_move_id
                WHERE cr.move_id IN (SELECT unnest(liq_moves.move_id) FROM liq_moves)
                    AND cr.account_id NOT IN %(liq_accts)s
                    AND cr.credit > 0.0
                    AND dr.move_id NOT IN (SELECT unnest(liq_moves.move_id) FROM liq_moves)
                    AND account_partial_reconcile.max_date BETWEEN %(dt_from)s AND %(dt_to)s
                GROUP BY dr.move_id, dr.account_id

                UNION ALL

                SELECT
                    %(col_key)s AS column_group_key,
                    cr2.move_id, cr2.account_id,
                    -SUM(%(partial_amt)s) AS balance
                FROM account_move_line AS dr2
                LEFT JOIN account_partial_reconcile
                    ON account_partial_reconcile.debit_move_id = dr2.id
                JOIN %(fx_tbl)s
                    ON account_currency_table.company_id = account_partial_reconcile.company_id
                    AND account_currency_table.rate_type = 'current'
                INNER JOIN account_move_line AS cr2
                    ON cr2.id = account_partial_reconcile.credit_move_id
                WHERE dr2.move_id IN (SELECT unnest(liq_moves.move_id) FROM liq_moves)
                    AND dr2.account_id NOT IN %(liq_accts)s
                    AND dr2.debit > 0.0
                    AND cr2.move_id NOT IN (SELECT unnest(liq_moves.move_id) FROM liq_moves)
                    AND account_partial_reconcile.max_date BETWEEN %(dt_from)s AND %(dt_to)s
                GROUP BY cr2.move_id, cr2.account_id)
                ''',
                move_sub=move_sub,
                col_key=col_key,
                liq_accts=liquidity_acct_ids,
                dt_from=col_opts['date']['date_from'],
                dt_to=col_opts['date']['date_to'],
                fx_tbl=fx_table,
                partial_amt=report._currency_table_apply_rate(SQL("account_partial_reconcile.amount")),
            ))

        self.env.cr.execute(SQL(' UNION ALL ').join(step1_parts))

        for rec in self.env.cr.dictfetchall():
            cg = rec['column_group_key']
            pct_map[cg].setdefault(rec['move_id'], {})
            pct_map[cg][rec['move_id']].setdefault(rec['account_id'], [0.0, 0.0])
            pct_map[cg][rec['move_id']][rec['account_id']][0] += rec['balance']
            reconciled_acct_ids_by_col[cg].add(rec['account_id'])

        if not any(pct_map.values()):
            return []

        # Step 2 – total balance per move / reconciled account
        step2_parts = []
        for col in options['columns']:
            cg = col['column_group_key']
            mv_ids = tuple(pct_map[cg].keys()) or (None,)
            ac_ids = tuple(reconciled_acct_ids_by_col[cg]) or (None,)
            step2_parts.append(SQL(
                '''
                SELECT
                    %(col_key)s AS column_group_key,
                    account_move_line.move_id,
                    account_move_line.account_id,
                    SUM(%(bal_expr)s) AS balance
                FROM account_move_line
                JOIN %(fx_tbl)s
                    ON account_currency_table.company_id = account_move_line.company_id
                    AND account_currency_table.rate_type = 'current'
                WHERE account_move_line.move_id IN %(mv_ids)s
                    AND account_move_line.account_id IN %(ac_ids)s
                GROUP BY account_move_line.move_id, account_move_line.account_id
                ''',
                col_key=cg,
                fx_tbl=fx_table,
                bal_expr=report._currency_table_apply_rate(SQL("account_move_line.balance")),
                mv_ids=mv_ids,
                ac_ids=ac_ids,
            ))

        self.env.cr.execute(SQL(' UNION ALL ').join(step2_parts))
        for rec in self.env.cr.dictfetchall():
            cg = rec['column_group_key']
            mv = rec['move_id']
            ac = rec['account_id']
            if ac in pct_map[cg].get(mv, {}):
                pct_map[cg][mv][ac][1] += rec['balance']

        # Step 3 – fetch full detail with account type & tag, then apply pct
        result_map = {}

        detail_q = Query(self.env, 'account_move_line')
        acct_a = detail_q.join(
            lhs_alias='account_move_line', lhs_column='account_id',
            rhs_table='account_account', rhs_column='id', link='account_id',
        )
        code_fld = self.env['account.account']._field_to_sql(acct_a, 'code', detail_q)
        name_fld = self.env['account.account']._field_to_sql(acct_a, 'name')
        type_fld = SQL.identifier(acct_a, 'account_type')

        step3_parts = []
        for col in options['columns']:
            cg = col['column_group_key']
            step3_parts.append(SQL(
                '''
                SELECT
                    %(col_key)s AS column_group_key,
                    account_move_line.move_id,
                    account_move_line.account_id,
                    %(code_fld)s AS account_code,
                    %(name_fld)s AS account_name,
                    %(type_fld)s AS account_account_type,
                    aat.account_account_tag_id AS account_tag_id,
                    SUM(%(bal_expr)s) AS balance
                FROM %(from_cl)s
                %(fx_join)s
                LEFT JOIN account_account_account_tag aat
                    ON aat.account_account_id = account_move_line.account_id
                    AND aat.account_account_tag_id IN %(cf_tags)s
                WHERE account_move_line.move_id IN %(mv_ids)s
                GROUP BY account_move_line.move_id, account_move_line.account_id,
                         account_code, account_name, account_account_type,
                         aat.account_account_tag_id
                ''',
                col_key=cg,
                code_fld=code_fld,
                name_fld=name_fld,
                type_fld=type_fld,
                from_cl=detail_q.from_clause,
                fx_join=report._currency_table_aml_join(options),
                bal_expr=report._currency_table_apply_rate(SQL("account_move_line.balance")),
                cf_tags=tuple(cf_tag_ids),
                mv_ids=tuple(pct_map[cg].keys()) or (None,),
            ))

        self.env.cr.execute(SQL(' UNION ALL ').join(step3_parts))

        for rec in self.env.cr.dictfetchall():
            cg = rec['column_group_key']
            mv = rec['move_id']
            ac = rec['account_id']
            line_bal = rec['balance']

            # Sum reconciled & total for the whole move
            sum_reconciled = 0.0
            sum_total = 0.0
            for r_amt, t_amt in pct_map[cg][mv].values():
                sum_reconciled += r_amt
                sum_total += t_amt

            # Compute the applicable portion
            if sum_total and ac not in pct_map[cg][mv]:
                ratio = sum_reconciled / sum_total
                line_bal *= ratio
            elif not sum_total and ac in pct_map[cg][mv]:
                line_bal = -pct_map[cg][mv][ac][0]
            else:
                continue

            result_map.setdefault(ac, {})
            result_map[ac].setdefault(cg, {
                'column_group_key': cg,
                'account_id': ac,
                'account_code': rec['account_code'],
                'account_name': rec['account_name'],
                'account_account_type': rec['account_account_type'],
                'account_tag_id': rec['account_tag_id'],
                'balance': 0.0,
            })
            result_map[ac][cg]['balance'] -= line_bal

        return list(result_map.values())

    # ------------------------------------------------------------------
    # Line rendering
    # ------------------------------------------------------------------

    def _build_section_structure(self):
        """Define the hierarchical layout of the cash flow statement.

        Returns an ordered dictionary whose keys identify each section and
        whose values carry the display name, nesting level, parent reference,
        and optional CSS class.
        """
        return {
            'opening_balance': {
                'name': _('Cash and cash equivalents, beginning of period'),
                'level': 0,
            },
            'net_increase': {
                'name': _('Net increase in cash and cash equivalents'),
                'level': 0,
                'unfolded': True,
            },
            'operating_activities': {
                'name': _('Cash flows from operating activities'),
                'level': 2,
                'parent_line_id': 'net_increase',
                'class': 'fw-bold',
                'unfolded': True,
            },
            'advance_payments_customer': {
                'name': _('Advance Payments received from customers'),
                'level': 4,
                'parent_line_id': 'operating_activities',
            },
            'received_operating_activities': {
                'name': _('Cash received from operating activities'),
                'level': 4,
                'parent_line_id': 'operating_activities',
            },
            'advance_payments_suppliers': {
                'name': _('Advance payments made to suppliers'),
                'level': 4,
                'parent_line_id': 'operating_activities',
            },
            'paid_operating_activities': {
                'name': _('Cash paid for operating activities'),
                'level': 4,
                'parent_line_id': 'operating_activities',
            },
            'investing_activities': {
                'name': _('Cash flows from investing & extraordinary activities'),
                'level': 2,
                'parent_line_id': 'net_increase',
                'class': 'fw-bold',
                'unfolded': True,
            },
            'investing_activities_cash_in': {
                'name': _('Cash in'),
                'level': 4,
                'parent_line_id': 'investing_activities',
            },
            'investing_activities_cash_out': {
                'name': _('Cash out'),
                'level': 4,
                'parent_line_id': 'investing_activities',
            },
            'financing_activities': {
                'name': _('Cash flows from financing activities'),
                'level': 2,
                'parent_line_id': 'net_increase',
                'class': 'fw-bold',
                'unfolded': True,
            },
            'financing_activities_cash_in': {
                'name': _('Cash in'),
                'level': 4,
                'parent_line_id': 'financing_activities',
            },
            'financing_activities_cash_out': {
                'name': _('Cash out'),
                'level': 4,
                'parent_line_id': 'financing_activities',
            },
            'unclassified_activities': {
                'name': _('Cash flows from unclassified activities'),
                'level': 2,
                'parent_line_id': 'net_increase',
                'class': 'fw-bold',
                'unfolded': True,
            },
            'unclassified_activities_cash_in': {
                'name': _('Cash in'),
                'level': 4,
                'parent_line_id': 'unclassified_activities',
            },
            'unclassified_activities_cash_out': {
                'name': _('Cash out'),
                'level': 4,
                'parent_line_id': 'unclassified_activities',
            },
            'closing_balance': {
                'name': _('Cash and cash equivalents, closing balance'),
                'level': 0,
            },
        }

    def _render_section_line(self, report, options, section_key, section_meta, report_data):
        """Produce a single section / header line dictionary."""
        line_id = report._get_generic_line_id(None, None, markup=section_key)
        has_detail = (
            section_key in report_data
            and 'aml_groupby_account' in report_data[section_key]
        )

        col_vals = []
        for col in options['columns']:
            expr = col['expression_label']
            cg = col['column_group_key']
            raw = (
                report_data[section_key][expr].get(cg, 0.0)
                if section_key in report_data
                else 0.0
            )
            col_vals.append(report._build_column_dict(raw, col, options=options))

        return {
            'id': line_id,
            'name': section_meta['name'],
            'level': section_meta['level'],
            'class': section_meta.get('class', ''),
            'columns': col_vals,
            'unfoldable': has_detail,
            'unfolded': (
                line_id in options['unfolded_lines']
                or section_meta.get('unfolded')
                or (options.get('unfold_all') and has_detail)
            ),
        }

    def _render_detail_line(self, report, options, detail):
        """Produce a per-account detail line under a section."""
        parent_id = report._get_generic_line_id(None, None, detail['parent_line_id'])
        line_id = report._get_generic_line_id(
            'account.account', detail['account_id'], parent_line_id=parent_id,
        )

        col_vals = []
        for col in options['columns']:
            expr = col['expression_label']
            cg = col['column_group_key']
            raw = detail[expr].get(cg, 0.0)
            col_vals.append(report._build_column_dict(raw, col, options=options))

        display_name = (
            f"{detail['account_code']} {detail['account_name']}"
            if detail['account_code']
            else detail['account_name']
        )

        return {
            'id': line_id,
            'name': display_name,
            'caret_options': 'account.account',
            'level': detail['level'],
            'parent_id': parent_id,
            'columns': col_vals,
        }

    def _render_unexplained_difference(self, report, options, report_data):
        """If closing != opening + net_increase, emit an extra line showing
        the gap so the user can investigate."""
        found_gap = False
        col_vals = []

        for col in options['columns']:
            expr = col['expression_label']
            cg = col['column_group_key']

            opening = (
                report_data['opening_balance'][expr].get(cg, 0.0)
                if 'opening_balance' in report_data else 0.0
            )
            closing = (
                report_data['closing_balance'][expr].get(cg, 0.0)
                if 'closing_balance' in report_data else 0.0
            )
            net_chg = (
                report_data['net_increase'][expr].get(cg, 0.0)
                if 'net_increase' in report_data else 0.0
            )

            gap = closing - opening - net_chg

            if not self.env.company.currency_id.is_zero(gap):
                found_gap = True

            col_vals.append(report._build_column_dict(
                gap,
                {'figure_type': 'monetary', 'expression_label': 'balance'},
                options=options,
            ))

        if found_gap:
            return {
                'id': report._get_generic_line_id(None, None, markup='unexplained_difference'),
                'name': _('Unexplained Difference'),
                'level': 1,
                'columns': col_vals,
            }
        return None
