import logging

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

from .laundry_order import POS_SYNC_CTX

_logger = logging.getLogger(__name__)


LINE_STATES = [
    ('received', 'Received'),
    ('processing', 'Processing'),
    ('ready', 'Ready'),
    ('delivered', 'Delivered'),
]

# Line fields the lock protects. NOT included on purpose:
#   - state (per-item workflow advance is always allowed)
#   - customer_note (operator commentary)
#   - tracking_code (auto-assigned; cannot change after create either way)
LOCKED_LINE_FIELDS = frozenset({
    'product_id', 'description', 'qty', 'price_unit',
})


class LaundryOrderLine(models.Model):
    """Line item on a laundry order — maps from pos.order.line.

    Each line carries a unique scannable tracking_code (barcode) and its
    own per-item workflow state. The order-level state on laundry.order
    remains the source of truth for financial gates; the per-line state
    is an operational overlay that supports items moving through the
    workflow at different speeds.
    """
    _name = 'laundry.order.line'
    _description = 'Laundry Order Line'
    _order = 'order_id, id'

    order_id = fields.Many2one(
        'laundry.order', string='Order',
        required=True, ondelete='cascade', index=True,
    )
    # Mirror order-level partner/state so list + kanban views can filter/group
    # without costly cross-model joins.
    order_partner_id = fields.Many2one(
        related='order_id.partner_id', store=True, index=True, readonly=True,
    )
    order_state = fields.Selection(
        related='order_id.state', store=True, index=True, readonly=True,
    )
    product_id = fields.Many2one(
        'product.product', string='Product',
    )
    description = fields.Char(
        string='Description',
    )
    qty = fields.Float(
        string='Quantity',
        default=1.0, digits=(10, 2),
    )
    price_unit = fields.Float(
        string='Unit Price',
        digits=(10, 2), readonly=True,
    )
    customer_note = fields.Char(
        string='Customer Note',
    )
    subtotal = fields.Float(
        string='Subtotal',
        compute='_compute_subtotal',
        store=True, digits=(10, 2),
    )

    # -- Per-item tracking --
    tracking_code = fields.Char(
        string='Tracking Code',
        copy=False, readonly=True, index=True,
        help='Unique scannable barcode for this item.',
    )
    state = fields.Selection(
        LINE_STATES,
        string='Item Status',
        default='received',
        required=True, copy=False, index=True,
    )

    _tracking_code_uniq = models.Constraint(
        'UNIQUE(tracking_code)',
        'Tracking code must be unique across all laundry items.',
    )

    @api.depends('qty', 'price_unit')
    def _compute_subtotal(self):
        for line in self:
            line.subtotal = line.qty * line.price_unit

    # Sequence code for the auto-generated tracking_code (barcode).
    _TRACKING_SEQ_CODE = 'laundry.order.line.tracking'

    @api.model
    def _next_tracking_code(self):
        """Allocate a tracking_code that is GUARANTEED unique across the
        existing laundry_order_line table.

        Why this exists
        ───────────────
        Postgres sequences are NON-transactional: a `nextval()` advances
        the sequence even when the surrounding ORM transaction rolls
        back. Repeated POS validates that fail (lock, missing partner,
        anything) eat sequence values without consuming them in real
        rows. Conversely, a partial reseed / data import that inserts
        rows with manual tracking_codes leaves the sequence BEHIND the
        table's MAX. Either way, `next_by_code()` can return a code
        that already exists → UniqueViolation → POS sale silently
        misses its laundry-order link (the savepoint in pos_order.py
        catches the SQL error to protect the POS commit).

        How this fixes it
        ─────────────────
        On collision, repair the sequence to (max_tracking_num + 1)
        using a direct SQL nextval-skip, then ask the sequence again.
        Capped at a few attempts so a real bug (e.g. malformed schema)
        still surfaces instead of looping forever.
        """
        seq = self.env['ir.sequence']
        for attempt in range(5):
            code = seq.next_by_code(self._TRACKING_SEQ_CODE) or False
            if not code:
                return False
            # Cheap collision check; the underlying UNIQUE constraint is
            # the real safety net — this just avoids paying the round-trip.
            existing = self.sudo().search_count([('tracking_code', '=', code)])
            if not existing:
                return code
            # Collision — repair sequence past the current MAX, then retry.
            self._repair_tracking_sequence()
            _logger.warning(
                "laundry.order.line tracking sequence collided on %s "
                "(attempt %d); repaired and retrying.", code, attempt + 1,
            )
        # If we still can't get a unique code after 5 tries, surface the
        # problem instead of writing an empty code.
        raise UserError(_(
            'Could not allocate a unique tracking code after 5 attempts. '
            'Check the laundry_management sequence configuration.'
        ))

    @api.model
    def _repair_tracking_sequence(self):
        """Advance the tracking-code sequence past the actual MAX in the
        table. Idempotent — safe to call repeatedly. SQL-level so it
        works even when the ORM env context is unusual (sudo, sync hook).
        """
        self.env.cr.execute("""
            SELECT COALESCE(MAX(
                CAST(NULLIF(REGEXP_REPLACE(tracking_code, '[^0-9]', '', 'g'), '')
                     AS INTEGER)
            ), 0)
            FROM laundry_order_line
            WHERE tracking_code IS NOT NULL;
        """)
        max_existing = self.env.cr.fetchone()[0] or 0
        # `ir.sequence` writes update number_next; we use the API for
        # safety (handles ranges, prefixes, padding).
        seq = self.env['ir.sequence'].sudo().search(
            [('code', '=', self._TRACKING_SEQ_CODE)], limit=1,
        )
        if seq:
            seq.write({'number_next': max_existing + 1})

    @api.model_create_multi
    def create(self, vals_list):
        # POS-sync bypass FIRST — POS creates the order and its lines in a
        # single create_vals payload; both must sail through the guard.
        pos_sync = bool(self.env.context.get(POS_SYNC_CTX))
        if not pos_sync:
            order_ids = {v.get('order_id') for v in vals_list if v.get('order_id')}
            if order_ids:
                orders = self.env['laundry.order'].browse(list(order_ids))
                for order in orders:
                    if order.locked:
                        raise UserError(_(
                            'Cannot add a line to locked order "%s".',
                            order.name,
                        ))
        for vals in vals_list:
            if not vals.get('tracking_code'):
                vals['tracking_code'] = self._next_tracking_code()
        if _logger.isEnabledFor(logging.DEBUG):
            _logger.debug(
                'laundry.order.line.create pos_sync=%s count=%d',
                pos_sync, len(vals_list),
            )
        return super().create(vals_list)

    def write(self, vals):
        if self.env.context.get(POS_SYNC_CTX):
            if _logger.isEnabledFor(logging.DEBUG):
                _logger.debug(
                    'laundry.order.line.write BYPASS ids=%s keys=%s',
                    self.ids, list(vals.keys()),
                )
            return super().write(vals)
        protected = LOCKED_LINE_FIELDS.intersection(vals.keys())
        if protected:
            for line in self:
                if line.order_id.locked:
                    raise UserError(_(
                        'Line on locked order "%(order)s" cannot edit '
                        '%(fields)s. Ask a manager to use "Unlock for '
                        'Editing" first.',
                        order=line.order_id.name,
                        fields=', '.join(sorted(protected)),
                    ))
        return super().write(vals)

    def unlink(self):
        # No bypass: deletion is never issued by the POS sync path.
        for line in self:
            if line.order_id.locked:
                raise UserError(_(
                    'Cannot delete a line from locked order "%s".',
                    line.order_id.name,
                ))
        return super().unlink()

    # -- Per-line workflow actions (1-click) --
    def action_line_process(self):
        for line in self:
            if line.state != 'received':
                raise UserError(_(
                    'Item %(code)s is not in Received state.',
                    code=line.tracking_code or line.id,
                ))
            line.state = 'processing'

    def action_line_ready(self):
        for line in self:
            if line.state != 'processing':
                raise UserError(_(
                    'Item %(code)s is not in Processing state.',
                    code=line.tracking_code or line.id,
                ))
            line.state = 'ready'

    def action_line_deliver(self):
        for line in self:
            if line.state != 'ready':
                raise UserError(_(
                    'Item %(code)s must be Ready before delivery.',
                    code=line.tracking_code or line.id,
                ))
            line.state = 'delivered'

    @api.model
    def action_scan_advance(self, tracking_code):
        """Advance an item one stage by its scanned tracking code.

        Intended for barcode scanner workflow: scanner types the code,
        this method finds the line and bumps its state to the next stage.
        Returns the new state or raises UserError if terminal / unknown.
        """
        if not tracking_code:
            raise UserError(_('Scan a tracking code.'))
        line = self.search([('tracking_code', '=', tracking_code.strip())], limit=1)
        if not line:
            raise UserError(_('No item with tracking code %s.', tracking_code))
        transitions = {
            'received': line.action_line_process,
            'processing': line.action_line_ready,
            'ready': line.action_line_deliver,
        }
        action = transitions.get(line.state)
        if not action:
            raise UserError(_(
                'Item %(code)s is already %(state)s.',
                code=line.tracking_code, state=line.state,
            ))
        action()
        return {
            'id': line.id,
            'tracking_code': line.tracking_code,
            'state': line.state,
            'order_name': line.order_id.name,
            'partner_name': line.order_partner_id.name,
            'product_name': line.product_id.display_name,
        }
