import logging
from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError

_logger = logging.getLogger(__name__)


class ResPartnerLaundry(models.Model):
    _inherit = 'res.partner'

    laundry_unpaid_count = fields.Integer(
        string='Unpaid Laundry Orders',
        compute='_compute_laundry_unpaid_count',
    )

    # Customer defaults — seed values only; cashier can override in POS.
    default_laundry_order_type_id = fields.Many2one(
        'laundry.order.type', string='Default Laundry Order Type',
    )
    default_laundry_attribute_ids = fields.Many2many(
        'laundry.order.attribute',
        'res_partner_laundry_default_attr_rel',
        'partner_id', 'attribute_id',
        string='Default Laundry Attributes',
    )

    def _compute_laundry_unpaid_count(self):
        if not self.ids:
            self.laundry_unpaid_count = 0
            return
        self.env.cr.execute("""
            SELECT partner_id, COUNT(*)
            FROM laundry_order
            WHERE partner_id IN %s
              AND amount_due > 0
              AND state != 'delivered'
            GROUP BY partner_id
        """, [tuple(self.ids)])
        counts = dict(self.env.cr.fetchall())
        for partner in self:
            partner.laundry_unpaid_count = counts.get(partner.id, 0)

    @api.model
    def _load_pos_data_fields(self, config):
        fields = super()._load_pos_data_fields(config)
        fields.append('laundry_unpaid_count')
        fields.append('default_laundry_order_type_id')
        fields.append('default_laundry_attribute_ids')
        return fields

    @api.model
    def laundry_find_by_phone(self, phone):
        """Search for existing partner by phone. Returns dict or False."""
        phone_clean = phone.strip().replace(' ', '')
        if not phone_clean:
            return False
        domain = [('phone', 'ilike', phone_clean)]
        if 'mobile' in self._fields:
            domain = ['|', ('phone', 'ilike', phone_clean), ('mobile', 'ilike', phone_clean)]
        partner = self.search(domain, limit=1)
        if partner:
            phone_val = partner.phone or ''
            if 'mobile' in self._fields and partner.mobile:
                phone_val = phone_val or partner.mobile
            return {'id': partner.id, 'name': partner.name, 'phone': phone_val}
        return False

    @api.model
    def laundry_quick_create(self, vals):
        """Create partner from POS quick-create popup. Returns partner id.

        UX contract:
          - phone is required (the JS popup enforces this; we also guard).
          - name is optional. If empty, the partner name defaults to the
            phone number — keeps lists scannable when a cashier doesn't
            have time to type a name.
          - mobile (when the field exists in this Odoo install) is mirrored
            from phone so duplicate-search by phone OR mobile keeps working.
        """
        phone = (vals.get('phone') or '').strip()
        if not phone:
            raise UserError(_('Phone is required to create a customer.'))
        name = (vals.get('name') or '').strip() or phone
        street = (vals.get('street') or '').strip() or False
        create_vals = {'name': name, 'phone': phone, 'street': street}
        # `mobile` is provided by the optional phone add-on. Mirror only
        # when the field exists, so this stays portable across Odoo
        # distributions without bringing in extra deps.
        if 'mobile' in self._fields:
            create_vals['mobile'] = phone
        partner = self.create(create_vals)
        return partner.id

    @api.model
    def get_laundry_dues(self, partner_id):
        """Return outstanding laundry dues for a partner (read-only).

        `amount_due` is now `amount_deferred - amount_settled` — the real
        money still owed — not `total - paid`.
        """
        orders = self.env['laundry.order'].search([
            ('partner_id', '=', partner_id),
            ('amount_due', '>', 0),
            ('state', '!=', 'delivered'),
        ], order='create_date asc, id asc')
        order_details = []
        total_due = 0.0
        for o in orders:
            total_due += o.amount_due
            services = o.line_ids.mapped('product_id.name')
            service_summary = ', '.join(dict.fromkeys(services))[:80]
            order_details.append({
                'id': o.id,
                'name': o.name,
                'amount_due': o.amount_due,
                'amount_total': o.amount_total,
                'amount_paid_cash': o.amount_paid_cash,
                'amount_deferred': o.amount_deferred,
                'amount_settled': o.amount_settled,
                'state': o.state,
                'intake_date': fields.Datetime.to_string(o.create_date) if o.create_date else False,
                'item_count': o.item_count,
                'service_summary': service_summary or '',
            })
        return {
            'total_due': total_due,
            'order_count': len(orders),
            'orders': order_details,
        }

    # ─────────────────────────────────────────────────────────────────────
    # Settlement engine
    #   CASH      → account.bank.statement.line (Dr Cash / Cr Receivable)
    #               + immediate reconcile vs open AR debits (FIFO)
    #               → POS expected cash auto-includes it via statement_line_ids
    #   NON-CASH  → account.payment (Dr Bank / Cr Receivable) + reconcile
    #               → drawer untouched, no duplication risk
    # ─────────────────────────────────────────────────────────────────────
    @api.model
    def settle_laundry_dues_rpc(self, partner_id, payment_lines, pos_session_id=None):
        """Collect a laundry receivable via one or more journal entries.

        Per-line routing by journal type — see module docstring above.

        Args:
            partner_id: res.partner id.
            payment_lines: list of dicts, each with:
                - pos_payment_method_id: pos.payment.method id
                - amount: float > 0
            pos_session_id: optional pos.session id. For CASH it tags the
                statement line so POS includes the amount in expected cash.
                For NON-CASH it's stamped on account.payment for audit.

        Returns:
            dict with: settled_total, remaining_due, payments[], settled_orders[].
        """
        partner = self.browse(partner_id)
        if not partner.exists():
            raise UserError(_('Customer not found.'))

        if not payment_lines or not isinstance(payment_lines, list):
            raise UserError(_('At least one payment line is required.'))

        normalized = []
        total = 0.0
        for idx, line in enumerate(payment_lines, start=1):
            if not isinstance(line, dict):
                raise UserError(_('Payment line %s is malformed.', idx))
            method_id = line.get('pos_payment_method_id')
            if not method_id:
                raise UserError(_('Payment line %s has no payment method.', idx))
            try:
                amt = float(line.get('amount') or 0.0)
            except (TypeError, ValueError):
                raise UserError(_('Payment line %s has an invalid amount.', idx))
            if amt <= 0:
                raise UserError(_('Payment line %s must have a positive amount.', idx))
            journal = self._resolve_settlement_journal(method_id)
            normalized.append({
                'method_id': int(method_id),
                'amount': amt,
                'journal': journal,
            })
            total += amt

        session = None
        if pos_session_id:
            session = self.env['pos.session'].browse(int(pos_session_id))
            if not session.exists():
                session = None

        receipt_entries = []
        for line in normalized:
            journal = line['journal']
            method = self.env['pos.payment.method'].browse(line['method_id'])
            if journal.type == 'cash':
                stmt_line = self._create_cash_settlement_statement_line(
                    partner, journal, line['amount'], session,
                )
                self._reconcile_statement_line_to_receivable(stmt_line, partner)
                receipt_entries.append({
                    'id': stmt_line.id,
                    'name': stmt_line.move_id.name or stmt_line.payment_ref,
                    'state': stmt_line.move_id.state,
                    'amount': stmt_line.amount,
                    'journal_name': journal.name,
                    'journal_type': 'cash',
                    'method_name': method.name or journal.name,
                })
            else:
                pmt = self._create_settlement_account_payment(
                    partner, journal, line['amount'], line['method_id'], session,
                )
                receipt_entries.append({
                    'id': pmt.id,
                    'name': pmt.name,
                    'state': pmt.state,
                    'amount': pmt.amount,
                    'journal_name': pmt.journal_id.name,
                    'journal_type': pmt.journal_id.type,
                    'method_name': pmt.settlement_pos_pm_id.name or pmt.journal_id.name,
                })

        settled_orders, remaining_due = self._distribute_settlement_fifo(partner, total)

        _logger.info(
            'Settlement RPC: partner=%s total=%.2f lines=%d remaining=%.2f',
            partner.name, total, len(receipt_entries), remaining_due,
        )

        return {
            'settled_total': total,
            'remaining_due': remaining_due,
            'payments': receipt_entries,
            'settled_orders': settled_orders,
        }

    @api.model
    def _create_cash_settlement_statement_line(self, partner, journal, amount, session):
        """Create a POS-tagged cash-in statement line that posts directly
        Dr Cash / Cr Receivable.

        `counterpart_account_id` is consumed by account.bank.statement.line.create
        to override the journal's default suspense account — so the credit
        leg lands on the partner's receivable account, ready for reconcile.
        The statement line auto-posts its move (Odoo handles action_post).

        Tagging `pos_session_id` makes the amount flow into POS's native
        expected-cash formula (statement_line_ids sum) → no closing diff.
        """
        receivable = partner.property_account_receivable_id
        if not receivable:
            raise UserError(_(
                'Customer "%s" has no receivable account configured.',
                partner.name,
            ))
        vals = {
            'journal_id': journal.id,
            'amount': amount,
            'partner_id': partner.id,
            'payment_ref': _('Customer Settlement (POS) — %s', partner.name),
            'counterpart_account_id': receivable.id,
            'date': fields.Date.context_today(self),
        }
        if session:
            vals['pos_session_id'] = session.id
        try:
            return self.env['account.bank.statement.line'].sudo().create(vals)
        except Exception as e:
            _logger.exception(
                'Failed to create cash settlement statement line for partner %s',
                partner.id,
            )
            raise UserError(_('Failed to record cash settlement: %s', e))

    @api.model
    def _reconcile_statement_line_to_receivable(self, stmt_line, partner):
        """Reconcile the statement line's receivable credit against the
        partner's open receivable debits (FIFO).

        Both sides live on the partner's receivable account, so a direct
        AML.reconcile() fully settles the customer due in one step.
        """
        receivable = partner.property_account_receivable_id
        if not receivable:
            return
        counterpart_credits = stmt_line.move_id.line_ids.filtered(
            lambda l: l.account_id == receivable
            and l.credit > 0
            and not l.reconciled
        )
        if not counterpart_credits:
            return
        AML = self.env['account.move.line']
        open_debits = AML.search([
            ('partner_id', '=', partner.id),
            ('account_id', '=', receivable.id),
            ('reconciled', '=', False),
            ('parent_state', '=', 'posted'),
            ('debit', '>', 0),
        ], order='date asc, id asc')
        if not open_debits:
            return
        try:
            (counterpart_credits | open_debits).reconcile()
        except Exception:
            _logger.exception(
                'Reconciliation failed for cash settlement statement line %s',
                stmt_line.id,
            )

    @api.model
    def _create_settlement_account_payment(
        self, partner, journal, amount, method_id, session,
    ):
        """Non-cash settlement: standard account.payment + reconcile."""
        Payment = self.env['account.payment']
        pmt_vals = {
            'payment_type': 'inbound',
            'partner_type': 'customer',
            'partner_id': partner.id,
            'amount': amount,
            'journal_id': journal.id,
            'currency_id': (journal.currency_id or self.env.company.currency_id).id,
            'date': fields.Date.context_today(self),
            'memo': _('Laundry dues settlement — %s', partner.name),
            'settlement_pos_pm_id': method_id,
        }
        if session:
            pmt_vals['pos_session_id'] = session.id
        pmt = Payment.sudo().create(pmt_vals)
        try:
            pmt.action_post()
        except Exception as e:
            _logger.exception(
                'Failed to post settlement payment for partner %s', partner.id,
            )
            raise UserError(_('Failed to post payment: %s', e))
        self._reconcile_settlement_payment(pmt, partner)
        return pmt

    @api.model
    def _resolve_settlement_journal(self, pos_payment_method_id):
        """Pick the account.journal for a settlement payment.

        1. If a pos.payment.method id is given, enforce split_transactions=False
           and return its journal.
        2. Else return the company's default cash journal, then bank journal.
        Raises UserError if no valid journal can be found.
        """
        if pos_payment_method_id:
            method = self.env['pos.payment.method'].browse(int(pos_payment_method_id))
            if not method.exists():
                raise UserError(_('Selected payment method not found.'))
            if method.split_transactions:
                raise ValidationError(_(
                    'Customer Account / pay-later methods cannot be used to '
                    'settle laundry dues — they would create new receivable '
                    'instead of reducing it.'
                ))
            if not method.journal_id:
                raise UserError(_(
                    'Payment method "%s" has no journal configured.',
                    method.name,
                ))
            return method.journal_id

        Journal = self.env['account.journal']
        j = Journal.search([
            ('company_id', '=', self.env.company.id),
            ('type', '=', 'cash'),
        ], limit=1)
        if not j:
            j = Journal.search([
                ('company_id', '=', self.env.company.id),
                ('type', '=', 'bank'),
            ], limit=1)
        if not j:
            raise UserError(_('No cash or bank journal available for settlement.'))
        return j

    @api.model
    def _reconcile_settlement_payment(self, payment, partner):
        """Reconcile the payment's partner-receivable line against open
        receivable AMLs from prior customer-account POS sales.

        Journals without an outstanding-receipts account post directly to the
        partner receivable (Dr Bank / Cr Receivable, state=paid). Journals
        configured with an outstanding-receipts account post to it first
        (Dr Bank / Cr Outstanding Receipts, state=in_process) — no partner
        receivable line yet, so there's nothing to reconcile here until the
        bank statement clears. This method safely no-ops in that case.
        """
        receivable_account = partner.property_account_receivable_id
        if not receivable_account:
            _logger.warning('Partner %s has no receivable account', partner.id)
            return

        AML = self.env['account.move.line']
        payment_lines = payment.move_id.line_ids.filtered(
            lambda l: l.account_id == receivable_account and not l.reconciled
        )
        if not payment_lines:
            return  # state=in_process — reconciliation happens at bank clearing

        open_debits = AML.search([
            ('partner_id', '=', partner.id),
            ('account_id', '=', receivable_account.id),
            ('reconciled', '=', False),
            ('parent_state', '=', 'posted'),
            ('debit', '>', 0),
        ], order='date asc, id asc')
        if not open_debits:
            return

        try:
            (payment_lines | open_debits).reconcile()
        except Exception:
            _logger.exception(
                'Reconciliation failed for settlement payment %s', payment.name,
            )

    @api.model
    def _distribute_settlement_fifo(self, partner, amount):
        """Spread an already-collected amount across the partner's open
        laundry orders, oldest first. Returns (settled_orders, remaining_due).
        """
        LaundryOrder = self.env['laundry.order']
        open_orders = LaundryOrder.search([
            ('partner_id', '=', partner.id),
            ('amount_due', '>', 0),
            ('state', '!=', 'delivered'),
        ], order='create_date asc, id asc')

        remaining = amount
        settled_orders = []
        for lo in open_orders:
            if remaining <= 0:
                break
            apply_amount = min(remaining, lo.amount_due)
            # Defense-in-depth: `amount_settled` is not in
            # LOCKED_HEADER_FIELDS so this write already sails through,
            # but we pass laundry_pos_sync=True to guarantee the bypass
            # even if the locked-field whitelist changes later.
            lo.sudo().with_context(laundry_pos_sync=True).write({
                'amount_settled': (lo.amount_settled or 0.0) + apply_amount,
            })
            remaining -= apply_amount
            settled_orders.append({
                'id': lo.id,
                'name': lo.name,
                'applied': apply_amount,
                'remaining_on_order': lo.amount_due,
            })

        total_due = sum(
            LaundryOrder.search([
                ('partner_id', '=', partner.id),
                ('amount_due', '>', 0),
                ('state', '!=', 'delivered'),
            ]).mapped('amount_due')
        )
        return settled_orders, total_due

    @api.model
    def get_session_settlements(self, pos_session_id):
        """Return laundry settlement payments stamped on a POS session.

        Used by the closing-screen extension to show a read-only summary
        of settlement collections made during this session. Does NOT inject
        into POS cash-control totals.
        """
        Payment = self.env['account.payment']
        payments = Payment.sudo().search([
            ('pos_session_id', '=', int(pos_session_id)),
            ('payment_type', '=', 'inbound'),
            ('partner_type', '=', 'customer'),
        ], order='date asc, id asc')

        by_journal = {}
        for p in payments:
            jname = p.journal_id.name
            jtype = p.journal_id.type  # 'cash' or 'bank'
            key = jname
            if key not in by_journal:
                by_journal[key] = {'name': jname, 'type': jtype, 'total': 0.0}
            by_journal[key]['total'] += p.amount

        return {
            'total': sum(p.amount for p in payments),
            'count': len(payments),
            'by_journal': list(by_journal.values()),
            'payments': [{
                'id': p.id,
                'name': p.name,
                'partner_name': p.partner_id.name,
                'amount': p.amount,
                'journal_name': p.journal_id.name,
                'date': fields.Date.to_string(p.date),
            } for p in payments],
        }

    @api.model
    def get_laundry_orders_for_pos(self, partner_id, limit=20):
        """Legacy shim — delegates to the canonical method on laundry.order.
        Kept so older POS bundles that still call this RPC keep working.
        New code should call `laundry.order.pos_search_customer_orders`
        directly; it supports a `search_query` parameter and returns a
        richer payload (payment_state, allowed_actions, …).
        """
        return self.env['laundry.order'].pos_search_customer_orders(
            partner_id=partner_id, limit=limit,
        )
