# Fusion Accounting - Deferred Revenue / Expense Report Handlers
# Computes period-by-period deferral breakdowns, generates closing entries

import calendar
from collections import defaultdict
from dateutil.relativedelta import relativedelta

from odoo import models, fields, _, api, Command
from odoo.exceptions import UserError
from odoo.tools import groupby, SQL
from odoo.addons.fusion_accounting.models.account_move import DEFERRED_DATE_MIN, DEFERRED_DATE_MAX


class FusionDeferredReportHandler(models.AbstractModel):
    """Base handler for deferred expense / revenue reports. Provides
    shared domain construction, SQL queries, grouping logic, and
    deferral-entry generation. Concrete sub-handlers set the report
    type via ``_get_deferred_report_type``."""

    _name = 'account.deferred.report.handler'
    _inherit = 'account.report.custom.handler'
    _description = 'Deferred Expense Report Custom Handler'

    def _get_deferred_report_type(self):
        raise NotImplementedError(
            "Subclasses must return either 'expense' or 'revenue'."
        )

    # =====================================================================
    # DOMAIN & QUERY HELPERS
    # =====================================================================

    def _get_domain(self, report, options, filter_already_generated=False, filter_not_started=False):
        """Build the search domain for deferred journal items within
        the selected report period."""
        base_domain = report._get_options_domain(options, "from_beginning")
        if self._get_deferred_report_type() == 'expense':
            acct_types = ('expense', 'expense_depreciation', 'expense_direct_cost')
        else:
            acct_types = ('income', 'income_other')

        base_domain += [
            ('account_id.account_type', 'in', acct_types),
            ('deferred_start_date', '!=', False),
            ('deferred_end_date', '!=', False),
            ('deferred_end_date', '>=', options['date']['date_from']),
            ('move_id.date', '<=', options['date']['date_to']),
        ]
        # Exclude lines that fall entirely within the period
        base_domain += [
            '!', '&', '&', '&', '&', '&',
            ('deferred_start_date', '>=', options['date']['date_from']),
            ('deferred_start_date', '<=', options['date']['date_to']),
            ('deferred_end_date', '>=', options['date']['date_from']),
            ('deferred_end_date', '<=', options['date']['date_to']),
            ('move_id.date', '>=', options['date']['date_from']),
            ('move_id.date', '<=', options['date']['date_to']),
        ]
        if filter_already_generated:
            base_domain += [
                ('deferred_end_date', '>=', options['date']['date_from']),
                '!',
                    '&',
                    ('move_id.deferred_move_ids.date', '=', options['date']['date_to']),
                    ('move_id.deferred_move_ids.state', '=', 'posted'),
            ]
        if filter_not_started:
            base_domain += [('deferred_start_date', '>', options['date']['date_to'])]
        return base_domain

    @api.model
    def _get_select(self):
        """Column expressions for the deferred-lines query."""
        acct_name_expr = self.env['account.account']._field_to_sql(
            'account_move_line__account_id', 'name',
        )
        return [
            SQL("account_move_line.id AS line_id"),
            SQL("account_move_line.account_id AS account_id"),
            SQL("account_move_line.partner_id AS partner_id"),
            SQL("account_move_line.product_id AS product_id"),
            SQL("account_move_line__product_template_id.categ_id AS product_category_id"),
            SQL("account_move_line.name AS line_name"),
            SQL("account_move_line.deferred_start_date AS deferred_start_date"),
            SQL("account_move_line.deferred_end_date AS deferred_end_date"),
            SQL("account_move_line.deferred_end_date - account_move_line.deferred_start_date AS diff_days"),
            SQL("account_move_line.balance AS balance"),
            SQL("account_move_line.analytic_distribution AS analytic_distribution"),
            SQL("account_move_line__move_id.id as move_id"),
            SQL("account_move_line__move_id.name AS move_name"),
            SQL("%s AS account_name", acct_name_expr),
        ]

    def _get_lines(self, report, options, filter_already_generated=False):
        """Execute the deferred-lines query and return raw dicts."""
        search_domain = self._get_domain(report, options, filter_already_generated)
        qry = report._get_report_query(options, domain=search_domain, date_scope='from_beginning')
        cols = SQL(', ').join(self._get_select())

        full_query = SQL(
            """
            SELECT %(cols)s
            FROM %(from_clause)s
            LEFT JOIN product_product AS account_move_line__product_id
                ON account_move_line.product_id = account_move_line__product_id.id
            LEFT JOIN product_template AS account_move_line__product_template_id
                ON account_move_line__product_id.product_tmpl_id = account_move_line__product_template_id.id
            WHERE %(where_clause)s
            ORDER BY account_move_line.deferred_start_date, account_move_line.id
            """,
            cols=cols,
            from_clause=qry.from_clause,
            where_clause=qry.where_clause,
        )
        self.env.cr.execute(full_query)
        return self.env.cr.dictfetchall()

    # =====================================================================
    # GROUPING HELPERS
    # =====================================================================

    @api.model
    def _get_grouping_fields_deferred_lines(self, filter_already_generated=False, grouping_field='account_id'):
        return (grouping_field,)

    @api.model
    def _group_by_deferred_fields(self, line, filter_already_generated=False, grouping_field='account_id'):
        return tuple(
            line[k] for k in self._get_grouping_fields_deferred_lines(filter_already_generated, grouping_field)
        )

    @api.model
    def _get_grouping_fields_deferral_lines(self):
        return ()

    @api.model
    def _group_by_deferral_fields(self, line):
        return tuple(line[k] for k in self._get_grouping_fields_deferral_lines())

    @api.model
    def _group_deferred_amounts_by_grouping_field(
        self, deferred_amounts_by_line, periods, is_reverse,
        filter_already_generated=False, grouping_field='account_id',
    ):
        """Group deferred amounts per grouping field and compute period
        totals. Returns ``(per_key_totals, aggregate_totals)``."""
        grouped_iter = groupby(
            deferred_amounts_by_line,
            key=lambda row: self._group_by_deferred_fields(row, filter_already_generated, grouping_field),
        )
        per_key = {}
        aggregate = {p: 0 for p in periods + ['totals_aggregated']}
        multiplier = 1 if is_reverse else -1

        for key, key_lines in grouped_iter:
            key_lines = list(key_lines)
            key_totals = self._get_current_key_totals_dict(key_lines, multiplier)
            aggregate['totals_aggregated'] += key_totals['amount_total']
            for period in periods:
                period_val = multiplier * sum(ln[period] for ln in key_lines)
                key_totals[period] = period_val
                aggregate[period] += self.env.company.currency_id.round(period_val)
            per_key[key] = key_totals

        return per_key, aggregate

    @api.model
    def _get_current_key_totals_dict(self, key_lines, multiplier):
        return {
            'account_id': key_lines[0]['account_id'],
            'product_id': key_lines[0]['product_id'],
            'product_category_id': key_lines[0]['product_category_id'],
            'amount_total': multiplier * sum(ln['balance'] for ln in key_lines),
            'move_ids': {ln['move_id'] for ln in key_lines},
        }

    # =====================================================================
    # REPORT DISPLAY
    # =====================================================================

    def _get_custom_display_config(self):
        return {
            'templates': {
                'AccountReportFilters': 'fusion_accounting.DeferredFilters',
            },
        }

    def _custom_options_initializer(self, report, options, previous_options):
        super()._custom_options_initializer(report, options, previous_options=previous_options)

        per_col_group = report._split_options_per_column_group(options)
        for col_dict in options['columns']:
            col_opts = per_col_group[col_dict['column_group_key']]
            col_dict['name'] = col_opts['date']['string']
            col_dict['date_from'] = col_opts['date']['date_from']
            col_dict['date_to'] = col_opts['date']['date_to']

        options['columns'] = list(reversed(options['columns']))

        total_col = [{
            **options['columns'][0],
            'name': _('Total'),
            'expression_label': 'total',
            'date_from': DEFERRED_DATE_MIN,
            'date_to': DEFERRED_DATE_MAX,
        }]
        not_started_col = [{
            **options['columns'][0],
            'name': _('Not Started'),
            'expression_label': 'not_started',
            'date_from': options['columns'][-1]['date_to'],
            'date_to': DEFERRED_DATE_MAX,
        }]
        before_col = [{
            **options['columns'][0],
            'name': _('Before'),
            'expression_label': 'before',
            'date_from': DEFERRED_DATE_MIN,
            'date_to': options['columns'][0]['date_from'],
        }]
        later_col = [{
            **options['columns'][0],
            'name': _('Later'),
            'expression_label': 'later',
            'date_from': options['columns'][-1]['date_to'],
            'date_to': DEFERRED_DATE_MAX,
        }]

        options['columns'] = total_col + not_started_col + before_col + options['columns'] + later_col
        options['column_headers'] = []
        options['deferred_report_type'] = self._get_deferred_report_type()
        options['deferred_grouping_field'] = previous_options.get('deferred_grouping_field') or 'account_id'

        co = self.env.company
        report_type = self._get_deferred_report_type()
        is_manual = (
            (report_type == 'expense' and co.generate_deferred_expense_entries_method == 'manual')
            or (report_type == 'revenue' and co.generate_deferred_revenue_entries_method == 'manual')
        )
        if is_manual:
            options['buttons'].append({
                'name': _('Generate entry'),
                'action': 'action_generate_entry',
                'sequence': 80,
                'always_show': True,
            })

    def action_audit_cell(self, options, params):
        """Open a list of the invoices/bills and deferral entries
        that underlie the clicked cell in the deferred report."""
        report = self.env['account.report'].browse(options['report_id'])
        col_data = next(
            (c for c in options['columns']
             if c['column_group_key'] == params.get('column_group_key')
             and c['expression_label'] == params.get('expression_label')),
            None,
        )
        if not col_data:
            return

        col_from = fields.Date.to_date(col_data['date_from'])
        col_to = fields.Date.to_date(col_data['date_to'])
        rpt_from = fields.Date.to_date(options['date']['date_from'])
        rpt_to = fields.Date.to_date(options['date']['date_to'])

        if col_data['expression_label'] in ('not_started', 'later'):
            col_from = rpt_to + relativedelta(days=1)
        if col_data['expression_label'] == 'before':
            col_to = rpt_from - relativedelta(days=1)

        _grp_model, grp_record_id = report._get_model_info_from_id(
            params.get('calling_line_dict_id'),
        )

        source_domain = self._get_domain(
            report, options,
            filter_not_started=(col_data['expression_label'] == 'not_started'),
        )
        if grp_record_id:
            source_domain.append(
                (options['deferred_grouping_field'], '=', grp_record_id)
            )

        source_moves = self.env['account.move.line'].search(source_domain).move_id
        visible_line_ids = source_moves.line_ids.ids
        if col_data['expression_label'] != 'total':
            visible_line_ids += source_moves.deferred_move_ids.line_ids.ids

        return {
            'type': 'ir.actions.act_window',
            'name': _('Deferred Entries'),
            'res_model': 'account.move.line',
            'domain': [('id', 'in', visible_line_ids)],
            'views': [(self.env.ref('fusion_accounting.view_deferred_entries_tree').id, 'list')],
            'context': {
                'search_default_pl_accounts': True,
                f'search_default_{options["deferred_grouping_field"]}': grp_record_id,
                'date_from': col_from,
                'date_to': col_to,
                'search_default_date_between': True,
                'expand': True,
            },
        }

    def _caret_options_initializer(self):
        return {
            'deferred_caret': [
                {'name': _("Journal Items"), 'action': 'open_journal_items'},
            ],
        }

    def _customize_warnings(self, report, options, all_column_groups_expression_totals, warnings):
        rpt_type = self._get_deferred_report_type()
        co = self.env.company
        is_manual_and_generated = (
            (rpt_type == 'expense' and co.generate_deferred_expense_entries_method == 'manual'
             or rpt_type == 'revenue' and co.generate_deferred_revenue_entries_method == 'manual')
            and self.env['account.move'].search_count(
                report._get_generated_deferral_entries_domain(options),
            )
        )
        if is_manual_and_generated:
            warnings['fusion_accounting.deferred_report_warning_already_posted'] = {
                'alert_type': 'warning',
            }

    def open_journal_items(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'))
        item_domain = self._get_domain(report, options)
        if rec_model == 'account.account' and rec_id:
            item_domain += [('account_id', '=', rec_id)]
        elif rec_model == 'product.product' and rec_id:
            item_domain += [('product_id', '=', rec_id)]
        elif rec_model == 'product.category' and rec_id:
            item_domain += [('product_category_id', '=', rec_id)]
        return {
            'type': 'ir.actions.act_window',
            'name': _("Deferred Entries"),
            'res_model': 'account.move.line',
            'domain': item_domain,
            'views': [(self.env.ref('fusion_accounting.view_deferred_entries_tree').id, 'list')],
            'context': {
                'search_default_group_by_move': True,
                'expand': True,
            },
        }

    def _dynamic_lines_generator(self, report, options, all_column_groups_expression_totals, warnings=None):
        """Build the report lines by computing deferred amounts per
        period and grouping field."""

        def _format_columns(row_totals):
            return [
                {
                    **report._build_column_dict(
                        row_totals[(
                            fields.Date.to_date(col['date_from']),
                            fields.Date.to_date(col['date_to']),
                            col['expression_label'],
                        )],
                        col,
                        options=options,
                        currency=self.env.company.currency_id,
                    ),
                    'auditable': True,
                }
                for col in options['columns']
            ]

        raw_lines = self._get_lines(report, options)
        col_periods = [
            (
                fields.Date.from_string(c['date_from']),
                fields.Date.from_string(c['date_to']),
                c['expression_label'],
            )
            for c in options['columns']
        ]

        per_line_amounts = self.env['account.move']._get_deferred_amounts_by_line(
            raw_lines, col_periods, self._get_deferred_report_type(),
        )
        per_key, totals = self._group_deferred_amounts_by_grouping_field(
            deferred_amounts_by_line=per_line_amounts,
            periods=col_periods,
            is_reverse=(self._get_deferred_report_type() == 'expense'),
            filter_already_generated=False,
            grouping_field=options['deferred_grouping_field'],
        )

        output_lines = []
        grp_model_name = self.env['account.move.line'][options['deferred_grouping_field']]._name
        for key_totals in per_key.values():
            grp_record = self.env[grp_model_name].browse(
                key_totals[options['deferred_grouping_field']]
            )
            field_desc = self.env['account.move.line'][options['deferred_grouping_field']]._description
            if options['deferred_grouping_field'] == 'product_id':
                field_desc = _("Product")
            display_label = grp_record.display_name or _("(No %s)", field_desc)
            output_lines.append((0, {
                'id': report._get_generic_line_id(grp_model_name, grp_record.id),
                'name': display_label,
                'caret_options': 'deferred_caret',
                'level': 1,
                'columns': _format_columns(key_totals),
            }))

        if per_key:
            output_lines.append((0, {
                'id': report._get_generic_line_id(None, None, markup='total'),
                'name': 'Total',
                'level': 1,
                'columns': _format_columns(totals),
            }))

        return output_lines

    # =====================================================================
    # ENTRY GENERATION
    # =====================================================================

    def action_generate_entry(self, options):
        new_moves = self._generate_deferral_entry(options)
        return {
            'name': _('Deferred Entries'),
            'type': 'ir.actions.act_window',
            'views': [(False, "list"), (False, "form")],
            'domain': [('id', 'in', new_moves.ids)],
            'res_model': 'account.move',
            'context': {
                'search_default_group_by_move': True,
                'expand': True,
            },
            'target': 'current',
        }

    def _generate_deferral_entry(self, options):
        """Create the deferral move and its reversal for the selected period."""
        rpt_type = self._get_deferred_report_type()
        co = self.env.company
        target_journal = (
            co.deferred_expense_journal_id if rpt_type == "expense"
            else co.deferred_revenue_journal_id
        )
        if not target_journal:
            raise UserError(_("Please configure the deferred journal in accounting settings."))

        period_start = fields.Date.to_date(DEFERRED_DATE_MIN)
        period_end = fields.Date.from_string(options['date']['date_to'])
        last_day = calendar.monthrange(period_end.year, period_end.month)[1]
        if period_end.day != last_day:
            raise UserError(
                _("Entries can only be generated for periods ending on the last day of a month.")
            )
        if co._get_violated_lock_dates(period_end, False, target_journal):
            raise UserError(_("Entries cannot be generated for a locked period."))

        options['all_entries'] = False
        report = self.env["account.report"].browse(options["report_id"])
        self.env['account.move.line'].flush_model()

        raw_lines = self._get_lines(report, options, filter_already_generated=True)
        period_info = self.env['account.report']._get_dates_period(
            period_start, period_end, 'range', period_type='month',
        )
        entry_ref = _("Grouped Deferral Entry of %s", period_info['string'])
        reversal_ref = _("Reversal of Grouped Deferral Entry of %s", period_info['string'])

        deferral_account = (
            co.deferred_expense_account_id if rpt_type == 'expense'
            else co.deferred_revenue_account_id
        )
        move_cmds, orig_move_ids = self._get_deferred_lines(
            raw_lines, deferral_account,
            (period_start, period_end, 'current'),
            rpt_type == 'expense', entry_ref,
        )
        if not move_cmds:
            raise UserError(_("No entry to generate."))

        deferral_move = self.env['account.move'].with_context(
            skip_account_deprecation_check=True,
        ).create({
            'move_type': 'entry',
            'deferred_original_move_ids': [Command.set(orig_move_ids)],
            'journal_id': target_journal.id,
            'date': period_end,
            'auto_post': 'at_date',
            'ref': entry_ref,
        })
        deferral_move.write({'line_ids': move_cmds})

        reversal = deferral_move._reverse_moves()
        reversal.write({
            'date': deferral_move.date + relativedelta(days=1),
            'ref': reversal_ref,
        })
        reversal.line_ids.name = reversal_ref

        combined = deferral_move + reversal
        self.env.cr.execute_values("""
            INSERT INTO account_move_deferred_rel(original_move_id, deferred_move_id)
                 VALUES %s
            ON CONFLICT DO NOTHING
        """, [
            (orig_id, dm.id)
            for orig_id in orig_move_ids
            for dm in combined
        ])
        combined._post(soft=True)
        return combined

    @api.model
    def _get_deferred_lines(self, raw_lines, deferral_account, period, is_reverse, label):
        """Compute the journal-item commands for a deferral entry and
        return ``(line_commands, original_move_ids)``."""
        if not deferral_account:
            raise UserError(_("Please configure the deferred accounts in accounting settings."))

        per_line_amounts = self.env['account.move']._get_deferred_amounts_by_line(
            raw_lines, [period], is_reverse,
        )
        per_key, agg_totals = self._group_deferred_amounts_by_grouping_field(
            per_line_amounts, [period], is_reverse, filter_already_generated=True,
        )
        if agg_totals['totals_aggregated'] == agg_totals[period]:
            return [], set()

        # Build per-key analytic distributions
        dist_per_key = defaultdict(lambda: defaultdict(float))
        deferral_dist = defaultdict(lambda: defaultdict(float))
        for ln in raw_lines:
            if not ln['analytic_distribution']:
                continue
            total_ratio = (
                (ln['balance'] / agg_totals['totals_aggregated'])
                if agg_totals['totals_aggregated'] else 0
            )
            key_data = per_key.get(self._group_by_deferred_fields(ln, True))
            key_ratio = (
                (ln['balance'] / key_data['amount_total'])
                if key_data and key_data['amount_total'] else 0
            )
            for analytic_id, pct in ln['analytic_distribution'].items():
                dist_per_key[self._group_by_deferred_fields(ln, True)][analytic_id] += pct * key_ratio
                deferral_dist[self._group_by_deferral_fields(ln)][analytic_id] += pct * total_ratio

        currency = self.env.company.currency_id
        balance_remainder = 0
        entry_lines = []
        source_move_ids = set()
        sign = 1 if is_reverse else -1

        for key, kv in per_key.items():
            for amt in (-kv['amount_total'], kv[period]):
                if amt != 0 and kv[period] != kv['amount_total']:
                    source_move_ids |= kv['move_ids']
                    adjusted_balance = currency.round(sign * amt)
                    entry_lines.append(Command.create(
                        self.env['account.move.line']._get_deferred_lines_values(
                            account_id=kv['account_id'],
                            balance=adjusted_balance,
                            ref=label,
                            analytic_distribution=dist_per_key[key] or False,
                            line=kv,
                        )
                    ))
                    balance_remainder += adjusted_balance

        # Group deferral-account lines
        grouped_values = {
            k: list(v)
            for k, v in groupby(per_key.values(), key=self._group_by_deferral_fields)
        }
        deferral_lines = []
        for key, key_items in grouped_values.items():
            key_balance = 0
            for item in key_items:
                if item[period] != item['amount_total']:
                    key_balance += currency.round(
                        sign * (item['amount_total'] - item[period])
                    )
            deferral_lines.append(Command.create(
                self.env['account.move.line']._get_deferred_lines_values(
                    account_id=deferral_account.id,
                    balance=key_balance,
                    ref=label,
                    analytic_distribution=deferral_dist[key] or False,
                    line=key_items[0],
                )
            ))
            balance_remainder += key_balance

        if not currency.is_zero(balance_remainder):
            deferral_lines.append(Command.create({
                'account_id': deferral_account.id,
                'balance': -balance_remainder,
                'name': label,
            }))

        return entry_lines + deferral_lines, source_move_ids


class FusionDeferredExpenseHandler(models.AbstractModel):
    _name = 'account.deferred.expense.report.handler'
    _inherit = 'account.deferred.report.handler'
    _description = 'Deferred Expense Custom Handler'

    def _get_deferred_report_type(self):
        return 'expense'


class FusionDeferredRevenueHandler(models.AbstractModel):
    _name = 'account.deferred.revenue.report.handler'
    _inherit = 'account.deferred.report.handler'
    _description = 'Deferred Revenue Custom Handler'

    def _get_deferred_report_type(self):
        return 'revenue'
