import logging
from odoo import models, fields, api

_logger = logging.getLogger(__name__)


class PosOrderLaundryExt(models.Model):
    """Extend pos.order to:
      1. Auto-create laundry.order for sales containing laundry products.
      2. Split POS payments into real cash vs deferred (Customer Account).

    Settlement is NO LONGER a POS order. It is a pure account.payment
    invoked via res.partner.settle_laundry_dues_rpc — see res_partner.py.

    The `is_laundry_settlement` field and the old settlement-product path
    are preserved in schema form for historical compatibility with DB rows
    created before this refactor, but the sync hook no longer creates or
    processes settlement POS orders.
    """
    _inherit = 'pos.order'

    laundry_order_id = fields.Many2one(
        'laundry.order', string='Laundry Order',
        readonly=True, copy=False, index=True,
    )
    is_laundry_settlement = fields.Boolean(
        string='Laundry Settlement (legacy)',
        readonly=True, copy=False, default=False,
        help='Legacy flag from the old settlement-product flow. '
             'New settlements go through account.payment directly and do '
             'not create POS orders.',
    )

    # -- Order type / attributes / delivery (set in POS UI) --
    laundry_order_type_id = fields.Many2one(
        'laundry.order.type', string='Laundry Order Type',
        index=True, copy=False,
    )
    laundry_order_attribute_ids = fields.Many2many(
        'laundry.order.attribute',
        'pos_order_laundry_attribute_rel',
        'pos_order_id', 'attribute_id',
        string='Laundry Attributes',
        copy=False,
    )
    laundry_is_delivery = fields.Boolean(
        string='Laundry Delivery', copy=False,
    )
    laundry_delivery_address = fields.Text(
        string='Laundry Delivery Address', copy=False,
    )
    laundry_delivery_scheduled_at = fields.Datetime(
        string='Laundry Delivery Scheduled At', copy=False,
    )

    # NOTE: pos.order._load_pos_data_fields is intentionally NOT overridden.
    # Bisection proved that adding ANY custom field to this loader breaks
    # POS order construction (`lines is undefined` in _computeAllPrices).
    # Custom values are sent by serializeForORM (see pos_order_patch.js)
    # and written directly by core's _process_order via create(**order),
    # since the columns already exist on this model.

    def action_open_laundry_order(self):
        self.ensure_one()
        if not self.laundry_order_id:
            return
        return {
            'type': 'ir.actions.act_window',
            'res_model': 'laundry.order',
            'res_id': self.laundry_order_id.id,
            'view_mode': 'form',
            'target': 'current',
        }

    # ─────────────────────────────────────────────────────────────────────
    # Sync hook
    # ─────────────────────────────────────────────────────────────────────
    @api.model
    def _extract_pos_order_ids(self, sync_result):
        """Defensive extraction of pos.order ids from the value returned
        by `super().sync_from_ui(...)`. Across Odoo 19 patch levels and
        edge paths this value can be:

          - a dict {'pos.order': [{'id': N, ...}, ...], ...}
          - a list of integer ids
          - a recordset of pos.order (if a downstream module already
            normalized it)
          - an empty container of any of the above

        Returns a list of integer pos.order ids. Logs unknown shapes
        instead of silently dropping them.
        """
        if not sync_result:
            return []
        # Recordset → use .ids
        if isinstance(sync_result, models.BaseModel):
            return list(sync_result.ids)
        # Dict payload (canonical in mainline Odoo 19)
        if isinstance(sync_result, dict):
            payload = sync_result.get('pos.order', [])
            ids = []
            for entry in payload or []:
                if isinstance(entry, dict) and entry.get('id'):
                    ids.append(entry['id'])
                elif isinstance(entry, int):
                    ids.append(entry)
            return ids
        # Plain list of ids (some patch levels / community forks)
        if isinstance(sync_result, (list, tuple)):
            ids = []
            for entry in sync_result:
                if isinstance(entry, int):
                    ids.append(entry)
                elif isinstance(entry, dict) and entry.get('id'):
                    ids.append(entry['id'])
            return ids
        _logger.warning(
            "[laundry] unknown sync_from_ui return shape: %s %r",
            type(sync_result).__name__, sync_result,
        )
        return []

    @api.model
    def sync_from_ui(self, orders):
        # CRITICAL: super() runs the entire POS payment commit. The
        # laundry hand-off MUST be additive and side-effect-free if it
        # fails — savepoint per pos.order so a SQL-level error cannot
        # poison the parent transaction.
        result = super().sync_from_ui(orders)

        order_ids = self._extract_pos_order_ids(result)
        _logger.info(
            "[laundry] sync_from_ui post-process: %d pos.order id(s) extracted",
            len(order_ids),
        )

        if not order_ids:
            return result

        # `.exists()` filters out any id the caller provided that isn't
        # actually in the DB anymore (deleted in a concurrent flow).
        # `sudo()` is bounded to this internal hand-off — POS users
        # may not hold full create/write rights on laundry.order, but
        # the hand-off itself is a server-controlled, validated path.
        for order in self.browse(order_ids).exists():
            try:
                with self.env.cr.savepoint():
                    order.sudo()._maybe_create_laundry_order()
            except Exception:
                _logger.exception(
                    "[laundry] Failed to create/sync laundry.order from "
                    "POS %s (POS sale committed; rolled back laundry-side "
                    "savepoint only)", order.id,
                )

        return result

    def write(self, vals):
        res = super().write(vals)
        # Same isolation as above. The amount-sync to laundry is a
        # SECONDARY effect of a POS payment write; it must never be the
        # reason a payment fails.
        if 'amount_paid' in vals or 'payment_ids' in vals or 'amount_total' in vals:
            for order in self:
                if not order.laundry_order_id:
                    continue
                try:
                    with self.env.cr.savepoint():
                        order._sync_laundry_amounts()
                except Exception:
                    _logger.exception(
                        'Failed to sync amounts to laundry order %s '
                        'from POS %s (POS write succeeded; rolled back '
                        'laundry-side savepoint only)',
                        order.laundry_order_id.id, order.id,
                    )
        return res

    # ─────────────────────────────────────────────────────────────────────
    # Payment classification
    # ─────────────────────────────────────────────────────────────────────
    def _classify_pos_payments(self):
        """Return (cash_total, deferred_total) by inspecting pos.payment rows.

        cash_total     = Σ amount where method.split_transactions is False
        deferred_total = Σ amount where method.split_transactions is True
        """
        self.ensure_one()
        cash_total = 0.0
        deferred_total = 0.0
        for pmt in self.payment_ids:
            method = pmt.payment_method_id
            if method and method.split_transactions:
                deferred_total += pmt.amount
            else:
                cash_total += pmt.amount
        return cash_total, deferred_total

    def _sync_laundry_amounts(self):
        """Push current financial split to the linked laundry.order.

        amount_total / amount_paid_cash / amount_deferred are all in
        LOCKED_HEADER_FIELDS — without the POS-sync context bypass the
        lock guard would block this write on every payment edit.
        """
        self.ensure_one()
        if not self.laundry_order_id:
            return
        cash, deferred = self._classify_pos_payments()
        self.laundry_order_id.sudo().with_context(
            laundry_pos_sync=True
        ).write({
            'amount_total': self.amount_total,
            'amount_paid_cash': cash,
            'amount_deferred': deferred,
        })

    # ─────────────────────────────────────────────────────────────────────
    # Laundry order creation
    # ─────────────────────────────────────────────────────────────────────
    def _maybe_create_laundry_order(self):
        """Create laundry.order if this POS order contains laundry products."""
        self.ensure_one()
        _logger.warning(
            "[laundry-trace] _maybe_create_laundry_order POS %s "
            "(ref=%s, lines=%d, existing_link=%s)",
            self.id, self.pos_reference or '-',
            len(self.lines), self.laundry_order_id.id or False,
        )

        if self.laundry_order_id:
            _logger.warning(
                "[laundry-trace] POS %s already linked to laundry.order %s "
                "→ syncing amounts only", self.id, self.laundry_order_id.id,
            )
            self._sync_laundry_amounts()
            return

        if not self.lines:
            _logger.warning("[laundry-trace] POS %s has no lines → skip", self.id)
            return

        partner = self.partner_id or self.env.company.partner_id

        laundry_lines = []
        skipped = []
        for line in self.lines:
            tmpl = line.product_id.product_tmpl_id if line.product_id else None
            if not tmpl:
                skipped.append((line.id, 'no template'))
                continue
            if not tmpl.is_laundry_service:
                skipped.append((line.id, f'not laundry ({tmpl.name})'))
                continue
            laundry_lines.append((0, 0, {
                'product_id': line.product_id.id,
                'description': line.full_product_name or line.product_id.name,
                'qty': line.qty,
                'price_unit': line.price_unit,
                'customer_note': line.customer_note or '',
            }))

        _logger.warning(
            "[laundry-trace] POS %s line scan: %d laundry, %d skipped %s",
            self.id, len(laundry_lines), len(skipped), skipped[:5],
        )

        if not laundry_lines:
            _logger.warning(
                "[laundry-trace] POS %s has no laundry-service lines → skip "
                "(this is the most common cause of 'no laundry order created')",
                self.id,
            )
            return

        LaundryOrder = self.env['laundry.order']
        existing = LaundryOrder.search(
            [('pos_order_id', '=', self.id)], limit=1,
        )
        if existing:
            self.laundry_order_id = existing.id
            self._sync_laundry_amounts()
            return

        cash, deferred = self._classify_pos_payments()
        create_vals = {
            'pos_order_id': self.id,
            'pos_reference': self.pos_reference or '',
            'partner_id': partner.id,
            'company_id': self.company_id.id,
            # Phase 3: every laundry.order born from POS is hard-locked
            # by `source_type`. Header lock + line lock both kick in
            # immediately. The POS-sync context bypass below lets the
            # initial create + line writes go through.
            'source_type': 'pos',
            'amount_total': self.amount_total,
            'amount_paid_cash': cash,
            'amount_deferred': deferred,
            'notes': self.general_customer_note or '',
            'line_ids': laundry_lines,
        }
        # Propagate order type / attributes / delivery — inference in
        # laundry.order.create fills priority_level and is_delivery from
        # these when not explicitly provided.
        if self.laundry_order_type_id:
            create_vals['order_type_id'] = self.laundry_order_type_id.id
        if self.laundry_order_attribute_ids:
            create_vals['attribute_ids'] = [(6, 0, self.laundry_order_attribute_ids.ids)]
        if self.laundry_is_delivery:
            create_vals['is_delivery'] = True
        if self.laundry_delivery_address:
            create_vals['delivery_address'] = self.laundry_delivery_address
        if self.laundry_delivery_scheduled_at:
            create_vals['delivery_scheduled_at'] = self.laundry_delivery_scheduled_at

        laundry_order = LaundryOrder.with_context(
            laundry_pos_sync=True
        ).create(create_vals)
        self.laundry_order_id = laundry_order.id

        _logger.info(
            'Created laundry order %s from POS %s: total=%.2f, cash=%.2f, deferred=%.2f',
            laundry_order.name, self.pos_reference or self.id,
            self.amount_total, cash, deferred,
        )

    # ─────────────────────────────────────────────────────────────────────
    #  Integrity audit — Step 4 of the stabilization brief
    # ─────────────────────────────────────────────────────────────────────
    @api.model
    def audit_laundry_links(self, heal=False):
        """Report (and optionally heal) POS orders that should have a
        linked laundry.order but don't.

        Returns a dict:
            {
              'checked': int,
              'missing': [{pos_order_id, pos_reference, partner_name,
                           amount_total}, ...],
              'duplicates': [pos_order_id, ...],   # pos.order pointing to
                                                   # a laundry.order that no
                                                   # longer exists
              'healed': int,                        # populated only if heal=True
            }

        A POS order is "should-link" when it has at least one line whose
        product_template is flagged is_laundry_service AND is NOT the
        settlement product. This is exactly the same condition
        _maybe_create_laundry_order applies, so the audit and the live
        hand-off agree.

        Heal mode re-runs `_maybe_create_laundry_order` for missing
        rows, each in its own savepoint — same isolation guarantee as
        the live hand-off. Safe to call repeatedly.
        """
        Order = self.env['pos.order']
        all_orders = Order.search([])
        missing = []
        duplicates = []
        for o in all_orders:
            has_laundry_line = any(
                line.product_id.product_tmpl_id.is_laundry_service
                and not line.product_id.product_tmpl_id.is_laundry_settlement
                for line in o.lines
                if line.product_id and line.product_id.product_tmpl_id
            )
            if not has_laundry_line:
                continue
            if o.laundry_order_id and not o.laundry_order_id.exists():
                # Stored fk to a deleted laundry.order — orphan link
                duplicates.append(o.id)
                continue
            if not o.laundry_order_id:
                missing.append({
                    'pos_order_id': o.id,
                    'pos_reference': o.pos_reference or '',
                    'partner_name': o.partner_id.name if o.partner_id else '',
                    'amount_total': o.amount_total,
                })

        healed = 0
        if heal:
            for entry in missing:
                pos_order = Order.browse(entry['pos_order_id'])
                try:
                    with self.env.cr.savepoint():
                        pos_order._maybe_create_laundry_order()
                    if pos_order.laundry_order_id:
                        healed += 1
                except Exception:
                    _logger.exception(
                        'audit_laundry_links: heal failed for POS %s',
                        pos_order.id,
                    )
            # Clear orphan fk pointers (the linked laundry.order was deleted)
            for pos_id in duplicates:
                try:
                    with self.env.cr.savepoint():
                        Order.browse(pos_id).laundry_order_id = False
                except Exception:
                    _logger.exception(
                        'audit_laundry_links: orphan-clear failed for POS %s',
                        pos_id,
                    )

        return {
            'checked': len(all_orders),
            'missing': missing,
            'duplicates': duplicates,
            'healed': healed,
        }
