# Copyright 2025-Today Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)

import logging
from datetime import timedelta

from odoo import api
from odoo import fields
from odoo import models
from odoo.exceptions import ValidationError

_logger = logging.getLogger(__name__)
# Pylint: explicit 'string' attributes are intentional for readable labels in views.
# Some linters flag these as redundant; disable that specific check here.
# pylint: disable=attribute-string-redundant


class GroupOrder(models.Model):
    _name = "group.order"
    _description = "Consumer Group Order"
    _inherit = ["mail.thread", "mail.activity.mixin"]
    _order = "sequence, start_date desc"

    def _get_order_type_selection(self):
        """Return order type selection options with translations."""
        return [
            ("regular", self.env._("Regular Order")),
            ("special", self.env._("Special Order")),
            ("promotional", self.env._("Promotional Order")),
        ]

    def _get_period_selection(self):
        """Return period selection options with translations."""
        return [
            ("once", self.env._("One-time")),
            ("weekly", self.env._("Weekly")),
            ("biweekly", self.env._("Biweekly")),
            ("monthly", self.env._("Monthly")),
        ]

    def _get_day_selection(self):
        """Return day of week selection options with translations."""
        return [
            ("0", self.env._("Monday")),
            ("1", self.env._("Tuesday")),
            ("2", self.env._("Wednesday")),
            ("3", self.env._("Thursday")),
            ("4", self.env._("Friday")),
            ("5", self.env._("Saturday")),
            ("6", self.env._("Sunday")),
        ]

    def _get_state_selection(self):
        """Return state selection options with translations."""
        return [
            ("draft", self.env._("Draft")),
            ("open", self.env._("Open")),
            ("closed", self.env._("Closed")),
            ("cancelled", self.env._("Cancelled")),
        ]

    # === Multicompañía ===
    company_id = fields.Many2one(
        "res.company",
        required=True,
        default=lambda self: self.env.company,
        tracking=True,
        help="Company that owns this consumer group order",
    )

    # === Secuencia ===
    sequence = fields.Integer(
        default=10,
        help="Sequence for ordering group orders in the website list",
    )

    # === Campos básicos ===
    name = fields.Char(
        required=True,
        tracking=True,
        translate=True,
        help="Display name of this consumer group order",
    )
    group_ids = fields.Many2many(
        "res.partner",
        "group_order_group_rel",
        "order_id",
        "group_id",
        required=True,
        domain=[("is_group", "=", True)],
        tracking=True,
        help="Consumer groups that can participate in this order",
    )
    type = fields.Selection(
        selection=_get_order_type_selection,
        required=True,
        default="regular",
        tracking=True,
        help="Type of consumer group order: Regular, Special (one-time), or Promotional",
    )

    # === Fechas ===
    start_date = fields.Date(
        required=False,
        tracking=True,
        help="Day when the consumer group order opens for purchases",
    )
    end_date = fields.Date(
        required=False,
        tracking=True,
        help="If empty, the consumer group order is permanent",
    )

    # === Período y días ===
    period = fields.Selection(
        selection=_get_period_selection,
        required=True,
        default="weekly",
        tracking=True,
        help="How often this consumer group order repeats",
    )
    pickup_day = fields.Selection(
        selection=_get_day_selection,
        required=False,
        tracking=True,
        help="Day of the week when members pick up their orders",
    )
    cutoff_day = fields.Selection(
        selection=_get_day_selection,
        required=False,
        tracking=True,
        help="Day when purchases stop and the consumer group order is locked for this week.",
    )

    # === Home delivery ===
    home_delivery = fields.Boolean(
        default=False,
        tracking=True,
        help="Whether this consumer group order includes home delivery service",
    )
    delivery_product_id = fields.Many2one(
        "product.product",
        domain=[("type", "=", "service")],
        tracking=True,
        help="Product to use for home delivery (service type)",
    )
    delivery_date = fields.Date(
        compute="_compute_delivery_date",
        store=True,
        readonly=True,
        help="Calculated delivery date (pickup date + 1 day)",
    )

    # === Computed date fields ===
    pickup_date = fields.Date(
        compute="_compute_pickup_date",
        store=True,
        readonly=True,
        help="Calculated next occurrence of pickup day",
    )
    cutoff_date = fields.Date(
        compute="_compute_cutoff_date",
        store=True,
        readonly=True,
        help="Calculated next occurrence of cutoff day",
    )

    # === Asociaciones ===
    supplier_ids = fields.Many2many(
        "res.partner",
        "group_order_supplier_rel",
        "order_id",
        "supplier_id",
        domain=[("supplier_rank", ">", 0)],
        tracking=True,
        help="Products from these suppliers will be available.",
    )
    product_ids = fields.Many2many(
        "product.product",
        "group_order_product_rel",
        "order_id",
        "product_id",
        tracking=True,
        help="Directly assigned products.",
    )
    category_ids = fields.Many2many(
        "product.category",
        "group_order_category_rel",
        "order_id",
        "category_id",
        tracking=True,
        help="Products in these categories will be available",
    )
    excluded_product_ids = fields.Many2many(
        "product.product",
        "group_order_excluded_product_rel",
        "order_id",
        "product_id",
        tracking=True,
        help="Products explicitly excluded from this order (blacklist has absolute priority)",
    )
    excluded_supplier_ids = fields.Many2many(
        "res.partner",
        "group_order_excluded_supplier_rel",
        "order_id",
        "supplier_id",
        domain=[("supplier_rank", ">", 0)],
        tracking=True,
        help="Suppliers excluded from this order. Products with these suppliers as main seller will not be available (blacklist has absolute priority)",
    )
    excluded_category_ids = fields.Many2many(
        "product.category",
        "group_order_excluded_category_rel",
        "order_id",
        "category_id",
        tracking=True,
        help="Categories excluded from this order. Products in these categories and all their subcategories will not be available (blacklist has absolute priority)",
    )

    # === Estado ===
    state = fields.Selection(
        selection=_get_state_selection,
        default="draft",
        tracking=True,
    )

    # === Descripción e imagen ===
    description = fields.Text(
        translate=True,
        help="Free text description for this consumer group order",
    )
    delivery_notice = fields.Text(
        translate=True,
        help="Notice about home delivery displayed to users (shown when home delivery is enabled)",
    )
    image = fields.Binary(
        help="Image displayed alongside the consumer group order name",
        attachment=True,
    )
    display_image = fields.Binary(
        compute="_compute_display_image",
        store=True,
        help="Image to display: uses consumer group order image if set, otherwise group image",
        attachment=True,
    )

    @api.depends("image", "group_ids")
    def _compute_display_image(self):
        """Use order image if set, otherwise use first group image."""
        for record in self:
            if record.image:
                record.display_image = record.image
            elif record.group_ids and record.group_ids[0].image_1920:
                record.display_image = record.group_ids[0].image_1920
            else:
                record.display_image = False

    available_products_count = fields.Integer(
        compute="_compute_available_products_count",
        store=False,
        help="Total count of available products from all sources",
    )

    @api.depends(
        "product_ids",
        "category_ids",
        "supplier_ids",
        "excluded_product_ids",
        "excluded_supplier_ids",
        "excluded_category_ids",
    )
    def _compute_available_products_count(self):
        """Count all available products from all sources."""
        for record in self:
            products = self._get_products_for_group_order(record.id)
            record.available_products_count = len(products)

    @api.constrains("company_id", "group_ids")
    def _check_company_groups(self):
        """Validate that groups belong to the same company."""
        for record in self:
            for group in record.group_ids:
                if group.company_id and group.company_id != record.company_id:
                    raise ValidationError(
                        self.env._(
                            "Group %(group)s belongs to company %(group_company)s, "
                            "not to %(record_company)s.",
                            group=group.name,
                            group_company=group.company_id.name,
                            record_company=record.company_id.name,
                        )
                    )

    @api.constrains("start_date", "end_date")
    def _check_dates(self):
        for record in self:
            if record.start_date and record.end_date:
                if record.start_date > record.end_date:
                    raise ValidationError(
                        self.env._("Start date cannot be greater than end date")
                    )

    def action_open(self):
        """Open order for purchases."""
        self.write({"state": "open"})

    def action_close(self):
        """Close order."""
        self.write({"state": "closed"})

    def action_cancel(self):
        """Cancel order."""
        self.write({"state": "cancelled"})

    def action_reset_to_draft(self):
        """Reset order back to draft state."""
        self.write({"state": "draft"})

    def get_active_orders_for_week(self):
        """Get active orders for the current week.

        Respects the allowed_company_ids context if defined.
        """
        today = fields.Date.today()
        week_start = today - timedelta(days=today.weekday())
        week_end = week_start + timedelta(days=6)

        domain = [
            ("state", "=", "open"),
            "|",
            ("start_date", "=", False),  # No start_date = always active
            ("start_date", "<=", week_end),
            "|",
            ("end_date", "=", False),
            ("end_date", ">=", week_start),
        ]

        # Apply company filter if allowed_company_ids in context
        if self.env.context.get("allowed_company_ids"):
            domain.append(
                ("company_id", "in", self.env.context.get("allowed_company_ids"))
            )

        return self.search(domain)

    @api.model
    def _get_products_for_group_order(self, order_id):
        """Model helper: return product.product recordset for a given order id.

        Discovery logic is owned by `group.order` so it stays close to the
        order configuration. IMPORTANT: the result is the UNION of all
        association sources (direct products, categories, suppliers), not a
        single-branch fallback. This prevents dropping products that are
        associated through multiple fields and avoids returning only one
        association.

        Sources included (union):
          - explicit `product_ids`
          - products in `category_ids` (all products whose `categ_id` matches)
          - products from `supplier_ids` via `product.template.seller_ids`

        Filter restrictions:
          - active = True (product is not archived)
          - is_published = True (product is published on website)
          - sale_ok = True (product can be sold)

        The returned recordset is a `product.product` set with duplicates
        removed by standard recordset union semantics.
        """
        order = self.browse(order_id)
        if not order.exists():
            return self.env["product.product"].browse()

        # Common domain for all searches: active, published, and sale_ok
        base_domain = [
            ("active", "=", True),
            ("product_tmpl_id.is_published", "=", True),
            ("product_tmpl_id.sale_ok", "=", True),
        ]

        products = self.env["product.product"].browse()

        # 1) Direct products assigned to order
        if order.product_ids:
            products |= order.product_ids.filtered(
                lambda p: p.active
                and p.product_tmpl_id.is_published
                and p.product_tmpl_id.sale_ok
            )

        # 2) Products in categories assigned to order (including all subcategories)
        if order.category_ids:
            # Collect all category IDs including descendants
            all_category_ids = []

            def get_all_descendants(categories):
                """Recursively collect all descendant category IDs."""
                for cat in categories:
                    all_category_ids.append(cat.id)
                    if cat.child_id:
                        get_all_descendants(cat.child_id)

            get_all_descendants(order.category_ids)

            # Search for products in all categories and their descendants
            cat_products = self.env["product.product"].search(
                [("categ_id", "in", all_category_ids)] + base_domain
            )
            products |= cat_products

        # 3) Products from suppliers (via product.template.seller_ids)
        if order.supplier_ids:
            product_templates = self.env["product.template"].search(
                [
                    ("seller_ids.partner_id", "in", order.supplier_ids.ids),
                    ("is_published", "=", True),
                    ("sale_ok", "=", True),
                ]
            )
            supplier_products = product_templates.mapped(
                "product_variant_ids"
            ).filtered("active")
            products |= supplier_products

        # 4) Apply product blacklist filter (absolute priority)
        if order.excluded_product_ids:
            excluded_count = len(products & order.excluded_product_ids)
            products = products - order.excluded_product_ids
            _logger.info(
                "Group order %d: Excluded %d products from product blacklist (total: %d)",
                order.id,
                excluded_count,
                len(products),
            )

        # 5) Apply supplier blacklist filter (absolute priority)
        # Exclude products whose main seller is in the excluded suppliers list
        if order.excluded_supplier_ids:
            # Filter products where main_seller_id is in excluded_supplier_ids
            excluded_by_supplier = products.filtered(
                lambda p: p.product_tmpl_id.main_seller_id
                and p.product_tmpl_id.main_seller_id in order.excluded_supplier_ids
            )
            if excluded_by_supplier:
                products = products - excluded_by_supplier
                _logger.info(
                    "Group order %d: Excluded %d products from supplier blacklist (main sellers: %s) (total: %d)",
                    order.id,
                    len(excluded_by_supplier),
                    ", ".join(order.excluded_supplier_ids.mapped("name")),
                    len(products),
                )

        # 6) Apply category blacklist filter (absolute priority)
        # Exclude products in excluded categories and all their subcategories (recursive)
        if order.excluded_category_ids:
            # Collect all excluded category IDs including descendants
            excluded_cat_ids = []

            def get_all_excluded_descendants(categories):
                """Recursively collect all excluded category IDs including children."""
                for cat in categories:
                    excluded_cat_ids.append(cat.id)
                    if cat.child_id:
                        get_all_excluded_descendants(cat.child_id)

            get_all_excluded_descendants(order.excluded_category_ids)

            # Filter products whose category is in the excluded list
            excluded_by_category = products.filtered(
                lambda p: p.categ_id.id in excluded_cat_ids
            )
            if excluded_by_category:
                products = products - excluded_by_category
                _logger.info(
                    "Group order %d: Excluded %d products from category blacklist (categories: %s, including subcategories) (total: %d)",
                    order.id,
                    len(excluded_by_category),
                    ", ".join(order.excluded_category_ids.mapped("name")),
                    len(products),
                )

        # Sort products: in-stock first, then out-of-stock, maintaining sequence+name within each group
        # is_out_of_stock is Boolean: False (in stock) comes first, True (out of stock) comes last
        return products.sorted(
            lambda p: (
                p.is_out_of_stock,  # Boolean: False < True, so in-stock products first
                p.product_tmpl_id.website_sequence,
                (p.name or "").lower(),
            )
        )

    def _get_products_paginated(self, order_id, page=1, per_page=20):
        """Get paginated products for a group order.

        Args:
            order_id: ID of the group order
            page: Page number (1-indexed)
            per_page: Number of products per page

        Returns:
            tuple: (products_page, total_count, has_next)
                - products_page: recordset of product.product for this page
                - total_count: total number of products in order
                - has_next: boolean indicating if there are more pages
        """
        all_products = self._get_products_for_group_order(order_id)
        total_count = len(all_products)

        # Calculate pagination
        offset = (page - 1) * per_page
        products_page = all_products[offset : offset + per_page]

        has_next = offset + per_page < total_count

        return products_page, total_count, has_next

    # === Pickup slots helpers ===
    pickup_slot_ids = fields.One2many(
        "group.order.slot",
        "group_order_id",
        string="Pickup slots",
        help="Different pickup time slots available for this order (weekday + time)",
        tracking=True,
    )

    pickup_slots_count = fields.Integer(
        compute="_compute_pickup_slots_count",
        store=False,
        help="Number of pickup slots configured for this order",
    )

    next_pickup_slot_id = fields.Many2one(
        "group.order.slot",
        string="Next Pickup Slot",
        compute="_compute_next_pickup_slot",
        store=True,
        help="The pickup slot assigned for the next cycle (computed)",
    )

    next_pickup_datetime = fields.Datetime(
        string="Next Pickup Datetime",
        compute="_compute_next_pickup_slot",
        store=True,
        help="Datetime of the next pickup occurrence for the selected slot",
    )

    @api.depends("pickup_slot_ids")
    def _compute_pickup_slots_count(self):
        """Simple count of configured slots for quick UI badges."""
        for record in self:
            record.pickup_slots_count = len(record.pickup_slot_ids or [])

    @api.depends(
        "pickup_slot_ids",
        "pickup_slot_ids.start_hour",
        "pickup_slot_ids.weekday",
        "cutoff_date",
        "start_date",
    )
    def _compute_next_pickup_slot(self):
        """Compute the next pickup slot and its concrete datetime.

        Rules:
        - If slots are configured, compute for each active slot the next
          occurrence (date + start_hour) strictly AFTER the reference date
          (cutoff_date if present, otherwise start_date or today).
        - Select the slot whose occurrence datetime is the soonest (minimum).
        - If no slots are configured, leave fields empty (fallback handled
          by existing pickup_day logic).
        """
        from datetime import datetime
        from datetime import time

        for record in self:
            record.next_pickup_slot_id = False
            record.next_pickup_datetime = False

            slots = record.pickup_slot_ids.filtered(lambda s: s.active)
            if not slots:
                continue

            # Determine reference date (use cutoff_date if present)
            if record.cutoff_date:
                reference_date = record.cutoff_date
            else:
                today = datetime.now().date()
                if record.start_date and record.start_date < today:
                    reference_date = today
                else:
                    reference_date = record.start_date or today

            candidate_datetimes = []
            for slot in slots:
                try:
                    slot_weekday = int(slot.weekday)
                except Exception:
                    # Skip malformed slot
                    continue

                current_weekday = reference_date.weekday()
                days_ahead = slot_weekday - current_weekday
                # Ensure NEXT occurrence AFTER reference (not same-day)
                if days_ahead <= 0:
                    days_ahead += 7

                target_date = reference_date + timedelta(days=days_ahead)

                # Convert start_hour float to time
                sh = float(slot.start_hour or 0.0)
                sh_h = int(sh)
                sh_m = int(round((sh - sh_h) * 60))
                try:
                    slot_dt = datetime.combine(target_date, time(sh_h, sh_m))
                except Exception:
                    # Fallback to date-only
                    slot_dt = datetime.combine(target_date, time(0, 0))

                candidate_datetimes.append((slot_dt, slot))

            if not candidate_datetimes:
                continue

            # Choose earliest datetime
            candidate_datetimes.sort(key=lambda x: x[0])
            chosen_dt, chosen_slot = candidate_datetimes[0]

            # Assign results (store datetime as timezone-naive; Odoo will convert)
            record.next_pickup_slot_id = chosen_slot
            record.next_pickup_datetime = chosen_dt

    @api.depends("cutoff_date", "pickup_day", "pickup_slot_ids", "next_pickup_datetime")
    def _compute_pickup_date(self):
        """Compute pickup date.

        If pickup slots are configured, derive `pickup_date` from the computed
        `next_pickup_datetime`. Otherwise, fall back to the previous
        single-day `pickup_day` behavior.
        """
        from datetime import datetime

        _logger.info("_compute_pickup_date called for %d records", len(self))
        for record in self:
            # If slots exist, prefer the computed next_pickup_datetime
            if record.pickup_slot_ids:
                if record.next_pickup_datetime:
                    try:
                        dt = (
                            fields.Datetime.to_datetime(record.next_pickup_datetime)
                            if isinstance(record.next_pickup_datetime, str)
                            else record.next_pickup_datetime
                        )
                        record.pickup_date = dt.date()
                    except Exception:
                        record.pickup_date = None
                else:
                    record.pickup_date = None
                continue

            # Fallback: original single pickup_day logic
            if not record.pickup_day:
                record.pickup_date = None
                continue

            target_weekday = int(record.pickup_day)

            # Start from cutoff_date if available, otherwise from today/start_date
            if record.cutoff_date:
                reference_date = record.cutoff_date
            else:
                today = datetime.now().date()
                if record.start_date and record.start_date < today:
                    reference_date = today
                else:
                    reference_date = record.start_date or today

            current_weekday = reference_date.weekday()

            # Calculate days to NEXT occurrence of pickup_day from reference
            days_ahead = target_weekday - current_weekday
            if days_ahead <= 0:
                days_ahead += 7

            pickup_date = reference_date + timedelta(days=days_ahead)

            record.pickup_date = pickup_date
            _logger.info(
                "Computed pickup_date for order %d: %s (pickup_day=%s, reference=%s)",
                record.id,
                record.pickup_date,
                record.pickup_day,
                reference_date,
            )

    @api.depends("cutoff_day", "start_date")
    def _compute_cutoff_date(self):
        """Compute the cutoff date (deadline to place orders before pickup).

        The cutoff date is the NEXT occurrence of cutoff_day from today.
        This is when members can no longer place orders.

        Example (as of Monday 2026-02-09):
        - cutoff_day = 6 (Sunday) → cutoff_date = 2026-02-15 (next Sunday)
        - pickup_day = 1 (Tuesday) → pickup_date = 2026-02-17 (Tuesday after cutoff)
        """
        from datetime import datetime

        _logger.info("_compute_cutoff_date called for %d records", len(self))
        for record in self:
            if record.cutoff_day:
                target_weekday = int(record.cutoff_day)
                today = datetime.now().date()

                # Use today as reference if start_date is in the past, otherwise use start_date
                if record.start_date and record.start_date < today:
                    reference_date = today
                else:
                    reference_date = record.start_date or today

                current_weekday = reference_date.weekday()

                # Calculate days to NEXT occurrence of cutoff_day
                days_ahead = target_weekday - current_weekday

                if days_ahead < 0:
                    # Target day already passed this week
                    # Jump to next week's occurrence
                    days_ahead += 7
                # If days_ahead == 0, cutoff is today (allowed)

                record.cutoff_date = reference_date + timedelta(days=days_ahead)
                _logger.info(
                    "Computed cutoff_date for order %d: %s (target_weekday=%d, current=%d, days=%d)",
                    record.id,
                    record.cutoff_date,
                    target_weekday,
                    current_weekday,
                    days_ahead,
                )
            else:
                record.cutoff_date = None

    @api.depends("pickup_date")
    def _compute_delivery_date(self):
        """Compute delivery date as pickup date + 1 day."""
        _logger.info("_compute_delivery_date called for %d records", len(self))
        for record in self:
            if record.pickup_date:
                record.delivery_date = record.pickup_date + timedelta(days=1)
                _logger.info(
                    "Computed delivery_date for order %d: %s",
                    record.id,
                    record.delivery_date,
                )
            else:
                record.delivery_date = None

    # === Constraints ===

    # Restricción eliminada: ahora se permite cualquier combinación de cutoff_day y pickup_day

    # === Onchange Methods ===

    @api.onchange("cutoff_day", "start_date")
    def _onchange_cutoff_day(self):
        """Force recompute cutoff_date on UI change for immediate feedback."""
        self._compute_cutoff_date()

    @api.onchange("pickup_day", "cutoff_day", "start_date")
    def _onchange_pickup_day(self):
        """Force recompute pickup_date on UI change for immediate feedback."""
        self._compute_pickup_date()

    # === Cron Methods ===

    @api.model
    def _cron_update_dates(self):
        """Cron job to recalculate dates for active orders daily.

        This ensures that computed dates stay up-to-date as time passes.
        Only updates orders in 'draft' or 'open' states.
        """
        orders = self.search([("state", "in", ["draft", "open"])])
        cron_started_at = fields.Datetime.now()
        _logger.info(
            "Cron: Starting group.order date update at %s for %d active orders (ids=%s)",
            cron_started_at,
            len(orders),
            orders.ids,
        )
        processed_orders = 0
        failed_orders = []
        for order in orders:
            before_values = {
                "state": order.state,
                "period": order.period,
                "start_date": order.start_date,
                "end_date": order.end_date,
                "cutoff_day": order.cutoff_day,
                "pickup_day": order.pickup_day,
                "cutoff_date": order.cutoff_date,
                "pickup_date": order.pickup_date,
                "delivery_date": order.delivery_date,
                "home_delivery": order.home_delivery,
            }
            _logger.info(
                "Cron: Processing group order %s (%s) with values before recompute: %s",
                order.id,
                order.name,
                before_values,
            )
            try:
                # Confirm BEFORE recomputing dates: cutoff_date still points to the
                # current cycle's cutoff (today or past), so the check works correctly.
                # After confirmation, recompute dates so they advance to the next cycle.
                order._confirm_linked_sale_orders()
                order._compute_cutoff_date()
                order._compute_pickup_date()
                order._compute_delivery_date()
                processed_orders += 1
                _logger.info(
                    "Cron: Finished group order %s (%s). Dates after recompute: cutoff=%s, pickup=%s, delivery=%s",
                    order.id,
                    order.name,
                    order.cutoff_date,
                    order.pickup_date,
                    order.delivery_date,
                )
            except Exception:
                failed_orders.append(order.id)
                _logger.exception(
                    "Cron: Error while processing group order %s (%s). Initial values were: %s",
                    order.id,
                    order.name,
                    before_values,
                )
        _logger.info(
            "Cron: Date update completed. processed=%d failed=%d failed_ids=%s",
            processed_orders,
            len(failed_orders),
            failed_orders,
        )

    def _confirm_linked_sale_orders(self):
        """Confirm draft/sent sale orders linked to this group order.

        This is triggered by the daily cron so that weekly orders generated
        from the website are confirmed automatically once dates are refreshed.
        After confirmation, creates picking batches grouped by consumer group.

        Only confirms orders if the cutoff date has already passed.
        """
        self.ensure_one()

        today = fields.Date.today()

        if not self.cutoff_date:
            _logger.warning(
                "Cron: Group order %s (%s) has no cutoff_date (state=%s, period=%s, cutoff_day=%s, start_date=%s). Skipping sale order confirmation for this cycle.",
                self.id,
                self.name,
                self.state,
                self.period,
                self.cutoff_day,
                self.start_date,
            )
            return

        # Skip if cutoff hasn't passed yet (the cycle is still open for orders)
        if self.cutoff_date >= today:
            _logger.info(
                "Cron: Skipping group order %s (%s) - cutoff date %s not yet passed (today=%s)",
                self.id,
                self.name,
                self.cutoff_date,
                today,
            )
            return

        SaleOrder = self.env["sale.order"].sudo()
        sale_orders = SaleOrder.search(
            [
                ("group_order_id", "=", self.id),
                ("state", "in", ["draft", "sent"]),
            ]
        )

        if not sale_orders:
            _logger.info(
                "Cron: No sale orders to confirm for group order %s (%s)",
                self.id,
                self.name,
            )
            return

        _logger.info(
            "Cron: Confirming %d sale orders for group order %s (%s)",
            len(sale_orders),
            self.id,
            self.name,
        )

        try:
            # Freeze cycle dates in sale orders BEFORE confirming and BEFORE
            # group order dates are recomputed by the cron caller.
            # This avoids displaying next cycle dates in already confirmed orders.
            pickup_orders = sale_orders.filtered(lambda so: not so.home_delivery)
            delivery_orders = sale_orders.filtered(lambda so: so.home_delivery)

            if pickup_orders:
                pickup_orders.write(
                    {
                        "pickup_day": self.pickup_day,
                        "pickup_date": self.pickup_date,
                        "commitment_date": self.pickup_date,
                    }
                )

            if delivery_orders:
                delivery_commitment_date = self.delivery_date or self.pickup_date
                delivery_orders.write(
                    {
                        "pickup_day": self.pickup_day,
                        "pickup_date": self.pickup_date,
                        "commitment_date": delivery_commitment_date,
                    }
                )

            _logger.info(
                "Cron: Snapshot dates applied to %d sale orders for group order %s (%s): pickup_date=%s, delivery_date=%s",
                len(sale_orders),
                self.id,
                self.name,
                self.pickup_date,
                self.delivery_date,
            )

            # Confirm each order in an isolated savepoint so one bad product
            # route configuration doesn't block all remaining orders.
            confirmed_sale_orders = SaleOrder.browse()
            failed_sale_orders = SaleOrder.browse()

            for sale_order in sale_orders:
                try:
                    with self.env.cr.savepoint():
                        # Do not block sales confirmation due to procurement
                        # route issues. This cron confirms business orders first
                        # and handles stock exceptions operationally.
                        sale_order.with_context(from_orderpoint=True).action_confirm()
                        confirmed_sale_orders |= sale_order
                except Exception:
                    failed_sale_orders |= sale_order
                    _logger.exception(
                        "Cron: Error confirming sale order %s (%s) for group order %s (%s). "
                        "Order skipped; remaining orders will continue.",
                        sale_order.id,
                        sale_order.name,
                        self.id,
                        self.name,
                    )

            if confirmed_sale_orders:
                # Create picking batches only for confirmed sale orders
                self._create_picking_batches_for_sale_orders(confirmed_sale_orders)
                self._log_missing_procurement_warnings(confirmed_sale_orders)

            if failed_sale_orders:
                _logger.warning(
                    "Cron: %d/%d sale orders failed during confirmation for group order %s (%s). "
                    "failed_sale_order_ids=%s",
                    len(failed_sale_orders),
                    len(sale_orders),
                    self.id,
                    self.name,
                    failed_sale_orders.ids,
                )
        except Exception:
            _logger.exception(
                "Cron: Error confirming sale orders for group order %s (%s)",
                self.id,
                self.name,
            )

    def _log_missing_procurement_warnings(self, sale_orders):
        """Log warnings for confirmed orders with stockable lines lacking moves.

        This helps operations detect products with missing route/procurement
        configuration while still allowing sale order confirmation.
        """
        self.ensure_one()

        for sale_order in sale_orders:
            problematic_lines = sale_order.order_line.filtered(
                lambda line: line.product_id
                and getattr(line.product_id, "is_storable", False)
                and line.product_uom_qty > 0
                and not line.move_ids
            )

            if not problematic_lines:
                continue

            product_labels = []
            for line in problematic_lines:
                code = line.product_id.default_code or "NO-CODE"
                name = line.product_id.display_name or line.product_id.name
                product_labels.append(f"[{code}] {name}")

            _logger.warning(
                "Cron: Sale order %s (%s) confirmed but %d stockable lines have no stock moves. "
                "Likely missing replenishment routes/rules. group_order=%s (%s), products=%s",
                sale_order.id,
                sale_order.name,
                len(problematic_lines),
                self.id,
                self.name,
                product_labels,
            )

    def _create_picking_batches_for_sale_orders(self, sale_orders):
        """Create stock.picking.batch grouped by picking type for this group order.

        Args:
            sale_orders: Recordset of confirmed sale.order
        """
        self.ensure_one()
        StockPickingBatch = self.env["stock.picking.batch"].sudo()

        # Create batches per group order, not per consumer group.
        # If multiple picking types exist, keep one batch per picking type.
        grouped_pickings = {}
        pickings = sale_orders.picking_ids.filtered(
            lambda p: p.state not in ("done", "cancel") and not p.batch_id
        )
        for picking in pickings:
            grouped_pickings.setdefault(
                picking.picking_type_id.id, self.env["stock.picking"]
            )
            grouped_pickings[picking.picking_type_id.id] |= picking

        scheduled_date = None
        if self.pickup_date:
            scheduled_date = fields.Datetime.to_datetime(self.pickup_date)
        elif self.delivery_date:
            scheduled_date = fields.Datetime.to_datetime(
                self.delivery_date - timedelta(days=1)
            )

        for picking_type_id, pickings in grouped_pickings.items():
            if not pickings:
                continue

            batch_desc = self.name
            batch = StockPickingBatch.create(
                {
                    "description": batch_desc,
                    "company_id": self.company_id.id,
                    "picking_type_id": picking_type_id,
                    "scheduled_date": scheduled_date,
                }
            )

            pickings.write({"batch_id": batch.id})

            _logger.info(
                "Cron: Created batch %s with %d pickings for group order %s",
                batch.name,
                len(pickings),
                self.name,
            )
