<?php

namespace WMS\Services;

use WMS\Core\Database;
use WMS\Repositories\BinRepository;
use WMS\Repositories\StockRepository;
use WMS\Repositories\StockMovementRepository;
use WMS\Repositories\WarehouseTaskRepository;

/**
 * Servicio de Putaway.
 *
 * Flujo:
 * 1. Se genera tarea PUTAWAY (manual o automática post-recepción)
 * 2. Se sugiere bin destino (estrategia configurable)
 * 3. El operador confirma el putaway desde handheld
 * 4. Se mueve stock de RECEIVING a bin definitivo
 */
class PutawayService
{
    private BinRepository $binRepo;
    private StockRepository $stockRepo;
    private StockMovementRepository $movementRepo;
    private WarehouseTaskRepository $taskRepo;

    public function __construct()
    {
        $this->binRepo      = new BinRepository();
        $this->stockRepo    = new StockRepository();
        $this->movementRepo = new StockMovementRepository();
        $this->taskRepo     = new WarehouseTaskRepository();
    }

    /**
     * Crea una tarea de putaway.
     */
    public function createPutawayTask(
        int $warehouseId,
        int $itemId,
        ?int $batchId,
        int $fromBinId,
        float $quantity,
        ?int $referenceId = null,
        ?int $userId = null
    ): int {
        $suggestedBin = $this->binRepo->suggestStorageBin($warehouseId, $itemId);
        $toBinId = $suggestedBin ? $suggestedBin['id'] : null;

        // Auto-asignar a op_almacen ONLINE con menos carga
        $assignee = $this->pickOnlineOpAlmacen($warehouseId);

        return $this->taskRepo->insert([
            'warehouse_id'   => $warehouseId,
            'task_type'      => 'PUTAWAY',
            'priority'       => 3,
            'status'         => $assignee ? 'ASSIGNED' : 'PENDING',
            'item_id'        => $itemId,
            'batch_id'       => $batchId,
            'from_bin_id'    => $fromBinId,
            'to_bin_id'      => $toBinId,
            'quantity'       => $quantity,
            'assigned_to'    => $assignee,
            'reference_type' => 'GOODS_RECEIPT',
            'reference_id'   => $referenceId,
            'created_by'     => $userId,
        ]);
    }

    /**
     * Selecciona un usuario op_almacen "en línea" (last_activity_at <= 5 min)
     * con la menor carga de tareas activas. Fallback al de menos carga si nadie online.
     */
    private function pickOnlineOpAlmacen(int $warehouseId): ?int
    {
        $db = Database::getConnection();
        $sql = "SELECT u.id, COALESCE(SUM(CASE WHEN t.status IN ('PENDING','ASSIGNED','IN_PROGRESS') THEN 1 ELSE 0 END), 0) AS load_active
                FROM users u
                LEFT JOIN warehouse_tasks t ON t.assigned_to = u.id
                WHERE u.role = 'op_almacen' AND u.is_active = 1
                  AND u.last_activity_at >= (NOW() - INTERVAL 5 MINUTE)
                  AND (u.warehouse_id = ? OR u.warehouse_id IS NULL)
                GROUP BY u.id
                ORDER BY load_active ASC, u.last_activity_at DESC, u.id ASC LIMIT 1";
        $stmt = $db->prepare($sql);
        $stmt->execute([$warehouseId]);
        $row = $stmt->fetch();
        if ($row) return (int) $row['id'];

        $stmt = $db->prepare(
            "SELECT u.id FROM users u
             WHERE u.role = 'op_almacen' AND u.is_active = 1
               AND (u.warehouse_id = ? OR u.warehouse_id IS NULL)
             ORDER BY u.id ASC LIMIT 1"
        );
        $stmt->execute([$warehouseId]);
        $row = $stmt->fetch();
        return $row ? (int) $row['id'] : null;
    }

    /**
     * Putaway agrupado: mueve TODOS los items de una etiqueta de transferencia a un solo bin destino.
     */
    public function groupedPutaway(int $labelId, int $toBinId, int $userId): array
    {
        $db = Database::getConnection();

        // Obtener etiqueta con líneas
        $stmt = $db->prepare("SELECT * FROM transfer_labels WHERE id = ?");
        $stmt->execute([$labelId]);
        $label = $stmt->fetch();
        if (!$label) {
            throw new \RuntimeException('Etiqueta de transferencia no encontrada');
        }

        $stmt = $db->prepare(
            "SELECT * FROM transfer_label_lines WHERE label_id = ? AND quantity > 0"
        );
        $stmt->execute([$labelId]);
        $lines = $stmt->fetchAll();
        if (empty($lines)) {
            throw new \RuntimeException('La etiqueta no tiene líneas con stock');
        }

        // Validación de exclusividad por grupo (zonificación)
        foreach ($lines as $line) {
            $this->assertBinAcceptsItem($toBinId, (int) $line['item_id']);
        }

        $db->beginTransaction();
        try {
            $movedItems = [];
            foreach ($lines as $line) {
                $qty = (float) $line['quantity'];

                // Descontar del bin origen (línea de la etiqueta), preservando el status real
                $fromBinId = $line['from_bin_id'] ?? $line['current_bin_id'] ?? null;
                $status = $line['stock_status'] ?? null;
                if (!$status && $fromBinId) {
                    // Detectar status real del stock origen (puede ser AVAILABLE, QA, etc.)
                    $stmt = $db->prepare(
                        "SELECT stock_status FROM stock
                         WHERE warehouse_id=? AND bin_id=? AND item_id=? AND (batch_id <=> ?) AND quantity > 0
                         ORDER BY FIELD(stock_status,'AVAILABLE','QA','BLOCKED'), id ASC LIMIT 1"
                    );
                    $stmt->execute([$label['warehouse_id'], (int)$fromBinId, $line['item_id'], $line['batch_id']]);
                    $status = $stmt->fetchColumn() ?: 'AVAILABLE';
                }
                $status = $status ?: 'AVAILABLE';

                if ($fromBinId) {
                    $fromStockId = $this->stockRepo->findOrCreatePosition(
                        $label['warehouse_id'], (int) $fromBinId, $line['item_id'], $line['batch_id'], $status
                    );
                    $this->stockRepo->subtractQuantity($fromStockId, $qty);
                }

                // Agregar al bin destino MANTENIENDO el mismo status (cuarentena se preserva)
                $toStockId = $this->stockRepo->findOrCreatePosition(
                    $label['warehouse_id'], $toBinId, $line['item_id'], $line['batch_id'], $status
                );
                $this->stockRepo->addQuantity($toStockId, $qty);

                // Registrar movimiento
                $this->movementRepo->logMovement([
                    'warehouse_id'   => $label['warehouse_id'],
                    'movement_type'  => 'PUTAWAY_GROUPED',
                    'item_id'        => $line['item_id'],
                    'batch_id'       => $line['batch_id'],
                    'from_bin_id'    => $fromBinId,
                    'to_bin_id'      => $toBinId,
                    'from_status'    => $status,
                    'to_status'      => $status,
                    'quantity'       => $qty,
                    'reference_type' => 'TRANSFER_LABEL',
                    'reference_id'   => $labelId,
                    'created_by'     => $userId,
                ]);

                $movedItems[] = [
                    'item_id'  => $line['item_id'],
                    'batch_id' => $line['batch_id'],
                    'quantity' => $qty,
                ];
            }

            $db->commit();

            return [
                'label_id'    => $labelId,
                'to_bin_id'   => $toBinId,
                'moved_items' => $movedItems,
                'total_lines' => count($movedItems),
            ];
        } catch (\Throwable $e) {
            $db->rollBack();
            throw $e;
        }
    }

    /**
     * Sugiere el mejor bin para guardar un artículo.
     * Prioridad: 1) Mismo item ya almacenado, 2) Misma zona, 3) FEFO compatible,
     * 4) Bin vacío, 5) Más cercano.
     */
    /**
     * Putaway por CONTENEDOR (LPN): mueve todo el stock asociado a un contenedor
     * desde su bin actual a un bin destino. Una sola operación, todos los items.
     */
    public function byContainer(int $containerId, int $toBinId, int $userId): array
    {
        $db = Database::getConnection();
        $stmt = $db->prepare("SELECT * FROM containers WHERE id = ?");
        $stmt->execute([$containerId]);
        $container = $stmt->fetch();
        if (!$container) {
            throw new \RuntimeException('Contenedor no encontrado');
        }
        if (!in_array($container['status'], ['OPEN','CLOSED'])) {
            throw new \RuntimeException('El contenedor no está disponible para putaway (ya fue procesado: ' . $container['status'] . ')');
        }

        // Stock asociado: vía container_lines.
        // Si la línea del contenedor tiene batch_id => match exacto.
        // Si tiene batch_id NULL => agarra todo el stock del item en bins RECEIVING.
        $stmt = $db->prepare(
            "SELECT s.id AS stock_id, s.warehouse_id, s.bin_id, s.item_id, s.batch_id,
                    s.stock_status, s.quantity, s.uom,
                    bn.code AS from_bin_code,
                    i.item_code, b.batch_number
             FROM container_lines cl
             JOIN stock s ON s.warehouse_id = ? AND s.item_id = cl.item_id
                          AND s.quantity > 0
                          AND (
                                cl.batch_id IS NOT NULL AND s.batch_id <=> cl.batch_id
                                OR cl.batch_id IS NULL
                          )
             JOIN bins bn ON bn.id = s.bin_id AND (cl.batch_id IS NOT NULL OR bn.bin_type = 'RECEIVING')
             LEFT JOIN items i ON i.id = s.item_id
             LEFT JOIN batches b ON b.id = s.batch_id
             WHERE cl.container_id = ?
             GROUP BY s.id"
        );
        $stmt->execute([$container['warehouse_id'], $containerId]);
        $positions = $stmt->fetchAll();
        if (empty($positions)) {
            throw new \RuntimeException('No hay stock asociado a este contenedor para mover');
        }

        // Validación de exclusividad por grupo (zonificación)
        foreach ($positions as $sp) {
            $this->assertBinAcceptsItem($toBinId, (int) $sp['item_id']);
        }

        $db->beginTransaction();
        $movedItems = 0;
        $movedQty   = 0;
        try {
            foreach ($positions as $sp) {
                $qty = (float)$sp['quantity'];
                if ($qty <= 0) continue;

                $this->stockRepo->subtractQuantity((int)$sp['stock_id'], $qty);
                $newId = $this->stockRepo->findOrCreatePosition(
                    (int)$sp['warehouse_id'], $toBinId, (int)$sp['item_id'],
                    $sp['batch_id'] ? (int)$sp['batch_id'] : null,
                    $sp['stock_status'], $sp['uom']
                );
                $this->stockRepo->addQuantity($newId, $qty);

                $this->movementRepo->logMovement([
                    'warehouse_id'  => $sp['warehouse_id'],
                    'movement_type' => 'PUTAWAY',
                    'item_id'       => $sp['item_id'],
                    'batch_id'      => $sp['batch_id'],
                    'from_bin_id'   => $sp['bin_id'],
                    'to_bin_id'     => $toBinId,
                    'from_status'   => $sp['stock_status'],
                    'to_status'     => $sp['stock_status'],
                    'quantity'      => $qty,
                    'uom'           => $sp['uom'],
                    'reference_type'=> 'CONTAINER',
                    'reference_id'  => $containerId,
                    'created_by'    => $userId,
                ]);
                $movedItems++;
                $movedQty += $qty;

                // Completar tareas PUT pendientes asociadas (stock ya movido por LPN).
                // En lugar de cancelar, registrar el bin destino real y marcar COMPLETED.
                $db->prepare(
                    "UPDATE warehouse_tasks
                     SET status = 'COMPLETED',
                         to_bin_id = ?,
                         confirmed_qty = confirmed_qty + ?,
                         putaway_mode = COALESCE(putaway_mode, 'GROUPED'),
                         putaway_strategy = COALESCE(putaway_strategy, 'BY_LPN'),
                         completed_at = NOW(),
                         notes = CONCAT(COALESCE(notes,''), ' [Completada vía LPN ', ?, ']')
                     WHERE task_type = 'PUTAWAY'
                       AND status IN ('PENDING','ASSIGNED','IN_PROGRESS')
                       AND warehouse_id = ?
                       AND item_id = ?
                       AND (batch_id <=> ?)
                       AND from_bin_id = ?"
                )->execute([
                    $toBinId,
                    $qty,
                    $container['container_code'] ?? $containerId,
                    $sp['warehouse_id'], $sp['item_id'],
                    $sp['batch_id'] ?? null, $sp['bin_id']
                ]);
            }
            // Cerrar el contenedor para que no se pueda volver a procesar
            $db->prepare("UPDATE containers SET status = 'SHIPPED', closed_at = COALESCE(closed_at, NOW()) WHERE id = ?")
               ->execute([$containerId]);
            $db->commit();
        } catch (\Throwable $e) {
            $db->rollBack();
            throw new \RuntimeException('Error en putaway por contenedor: ' . $e->getMessage());
        }

        \WMS\Services\AuditService::log('containers', $containerId, 'CONTAINER_PUTAWAY', null, [
            'container_code' => $container['container_code'] ?? null,
            'to_bin_id'      => $toBinId,
            'positions'      => $movedItems,
            'total_qty'      => $movedQty,
        ], $userId);

        return [
            'container_id'   => $containerId,
            'container_code' => $container['container_code'] ?? null,
            'to_bin_id'      => $toBinId,
            'positions'      => $movedItems,
            'total_qty'      => $movedQty,
        ];
    }

    /**
     * Valida que un bin con regla EXCLUSIVA solo reciba artículos de su grupo.
     * Lanza excepción si el bin está reservado a otro grupo.
     */
    private function assertBinAcceptsItem(int $binId, int $itemId): void
    {
        $db = Database::getConnection();
        $stmt = $db->prepare(
            "SELECT r.item_group_code, r.item_group_name
             FROM bin_group_rules r
             WHERE r.bin_id = ? AND r.is_exclusive = 1"
        );
        $stmt->execute([$binId]);
        $rules = $stmt->fetchAll();
        if (!$rules) return;

        $stmt = $db->prepare("SELECT item_code, item_group FROM items WHERE id = ?");
        $stmt->execute([$itemId]);
        $item = $stmt->fetch();
        $itemGroup = $item['item_group'] ?? null;

        foreach ($rules as $r) {
            if ((string)$r['item_group_code'] === (string)$itemGroup) return;
        }
        $allowed = implode(', ', array_map(fn($r) => $r['item_group_name'] ?: $r['item_group_code'], $rules));
        throw new \RuntimeException(
            'El bin destino está reservado exclusivamente para el grupo: ' . $allowed .
            '. El artículo ' . ($item['item_code'] ?? $itemId) . ' pertenece al grupo ' . ($itemGroup ?? 'sin grupo') . '.'
        );
    }

    public function suggestBin(int $itemId, int $warehouseId): array
    {
        $db = Database::getConnection();
        $suggestions = [];

        $stmt = $db->prepare("SELECT item_group FROM items WHERE id = ?");
        $stmt->execute([$itemId]);
        $itemGroup = $stmt->fetchColumn() ?: null;

        // -1) Preferencia por item (item_zone_preferences): zone + level
        //     respetando capacidad física (max_level) y prefiriendo nivel ergonómico.
        $stmt = $db->prepare(
            "SELECT b.id, b.code, b.zone_id AS zone, b.level AS bin_level,
                    'PREFERRED_ZONE' AS strategy,
                    izp.preferred_level, izp.max_level, izp.pallet_weight_kg,
                    COALESCE(SUM(s.quantity), 0) AS current_qty
             FROM item_zone_preferences izp
             JOIN bins b ON b.zone_id = izp.zone_id
             LEFT JOIN stock s ON s.bin_id = b.id AND s.quantity > 0
             WHERE izp.item_id = ?
               AND b.warehouse_id = ?
               AND b.bin_type = 'STORAGE'
               AND b.is_active = 1
               AND (izp.max_level IS NULL OR CAST(b.level AS UNSIGNED) <= izp.max_level)
             GROUP BY b.id, b.code, b.zone_id, b.level,
                      izp.preferred_level, izp.max_level, izp.pallet_weight_kg
             ORDER BY
                 (COALESCE(SUM(s.quantity), 0) > 0) ASC,
                 ABS(CAST(b.level AS UNSIGNED) - COALESCE(izp.preferred_level, CAST(b.level AS UNSIGNED))) ASC,
                 RAND()
             LIMIT 5"
        );
        $stmt->execute([$itemId, $warehouseId]);
        foreach ($stmt->fetchAll() as $row) {
            $suggestions[] = $row;
        }

        // -0.5) Regla por U_subgrupo (subgrupo_zone_rules):
        //   Si el item tiene u_subgrupo y existe una regla activa para ese subgrupo,
        //   sugiere bins en la zona destino (capacidad sin restricción, sólo prioriza
        //   bins vacíos y ordena por nivel ascendente para colocar bajo por defecto).
        $stmt = $db->prepare(
            "SELECT b.id, b.code, b.zone_id AS zone, b.level AS bin_level,
                    'SUBGRUPO_RULE' AS strategy,
                    sgr.u_subgrupo, sgr.zone_id AS rule_zone_id,
                    COALESCE(SUM(s.quantity), 0) AS current_qty
             FROM items i
             JOIN subgrupo_zone_rules sgr ON sgr.u_subgrupo = i.u_subgrupo AND sgr.is_active = 1
             JOIN bins b ON b.zone_id = sgr.zone_id
             LEFT JOIN stock s ON s.bin_id = b.id AND s.quantity > 0
             WHERE i.id = ?
               AND b.warehouse_id = ?
               AND b.bin_type = 'STORAGE'
               AND b.is_active = 1
             GROUP BY b.id, b.code, b.zone_id, b.level, sgr.u_subgrupo, sgr.zone_id
             ORDER BY
                 (COALESCE(SUM(s.quantity), 0) > 0) ASC,
                 CAST(b.level AS UNSIGNED) ASC,
                 RAND()
             LIMIT 5"
        );
        $stmt->execute([$itemId, $warehouseId]);
        foreach ($stmt->fetchAll() as $row) {
            $suggestions[] = $row;
        }

        // -0.25) Regla por Familia (family_zone_rules):
        //   Match path: items.item_group → item_groups.name → family_zone_rules.familia
        //   La familia maestra es el NOMBRE del SAP ItemGroup.
        $stmt = $db->prepare(
            "SELECT b.id, b.code, b.zone_id AS zone, b.level AS bin_level,
                    'FAMILY_RULE' AS strategy,
                    fzr.familia, fzr.zone_id AS rule_zone_id,
                    COALESCE(SUM(s.quantity), 0) AS current_qty
             FROM items i
             JOIN item_groups g ON g.code = i.item_group
             JOIN family_zone_rules fzr ON fzr.familia = g.name AND fzr.is_active = 1
             JOIN bins b ON b.zone_id = fzr.zone_id
             LEFT JOIN stock s ON s.bin_id = b.id AND s.quantity > 0
             WHERE i.id = ?
               AND b.warehouse_id = ?
               AND b.bin_type = 'STORAGE'
               AND b.is_active = 1
             GROUP BY b.id, b.code, b.zone_id, b.level, fzr.familia, fzr.zone_id
             ORDER BY
                 (COALESCE(SUM(s.quantity), 0) > 0) ASC,
                 CAST(b.level AS UNSIGNED) ASC,
                 RAND()
             LIMIT 5"
        );
        $stmt->execute([$itemId, $warehouseId]);
        foreach ($stmt->fetchAll() as $row) {
            $suggestions[] = $row;
        }

        // 0) Posiciones FIJAS por grupo de artículo (zonificación)
        if ($itemGroup) {
            $stmt = $db->prepare(
                "SELECT b.id, b.code, b.zone_id AS zone, 'FIXED_GROUP' AS strategy,
                        r.priority, r.is_exclusive, r.item_group_name,
                        COALESCE(SUM(s.quantity), 0) AS current_qty
                 FROM bin_group_rules r
                 JOIN bins b ON b.id = r.bin_id
                 LEFT JOIN stock s ON s.bin_id = b.id AND s.quantity > 0
                 WHERE r.warehouse_id = ? AND r.item_group_code = ?
                   AND b.bin_type = 'STORAGE' AND b.is_active = 1
                 GROUP BY b.id, b.code, b.zone_id, r.priority, r.is_exclusive, r.item_group_name
                 ORDER BY r.priority ASC, current_qty ASC
                 LIMIT 5"
            );
            $stmt->execute([$warehouseId, $itemGroup]);
            foreach ($stmt->fetchAll() as $row) {
                $suggestions[] = $row;
            }
        }

        // 1) Bin donde ya existe el mismo item con stock AVAILABLE,
        //    excluyendo bins exclusivos asignados a OTRO grupo.
        $stmt = $db->prepare(
            "SELECT b.id, b.code, b.zone_id AS zone, 'SAME_ITEM' AS strategy,
                    SUM(s.quantity) AS current_qty
             FROM stock s
             JOIN bins b ON s.bin_id = b.id
             WHERE s.item_id = ? AND s.warehouse_id = ?
               AND s.stock_status = 'AVAILABLE' AND s.quantity > 0
               AND b.bin_type = 'STORAGE' AND b.is_active = 1
               AND NOT EXISTS (
                   SELECT 1 FROM bin_group_rules r2
                   WHERE r2.bin_id = b.id AND r2.is_exclusive = 1
                     AND r2.item_group_code <> COALESCE(?, '')
               )
             GROUP BY b.id, b.code, b.zone_id
             ORDER BY current_qty ASC
             LIMIT 3"
        );
        $stmt->execute([$itemId, $warehouseId, $itemGroup]);
        $sameItem = $stmt->fetchAll();
        foreach ($sameItem as $row) {
            $suggestions[] = $row;
        }

        // 2) Bin vacío en la misma zona del item (si ya existe una zona asignada)
        $stmt = $db->prepare(
            "SELECT DISTINCT b2.zone_id AS zone FROM stock s2
             JOIN bins b2 ON s2.bin_id = b2.id
             WHERE s2.item_id = ? AND s2.warehouse_id = ? AND b2.zone_id IS NOT NULL
             LIMIT 1"
        );
        $stmt->execute([$itemId, $warehouseId]);
        $zoneRow = $stmt->fetch();

        if ($zoneRow && $zoneRow['zone']) {
            $stmt = $db->prepare(
                "SELECT b.id, b.code, b.zone_id AS zone, 'SAME_ZONE' AS strategy
                 FROM bins b
                 LEFT JOIN stock s ON s.bin_id = b.id AND s.quantity > 0
                 WHERE b.warehouse_id = ? AND b.zone_id = ? AND b.bin_type = 'STORAGE'
                   AND b.is_active = 1
                 GROUP BY b.id, b.code, b.zone_id
                 HAVING COALESCE(SUM(s.quantity), 0) = 0
                 ORDER BY RAND()
                 LIMIT 2"
            );
            $stmt->execute([$warehouseId, $zoneRow['zone']]);
            foreach ($stmt->fetchAll() as $row) {
                $suggestions[] = $row;
            }
        }

        // 3) Almacenamiento CAÓTICO: cualquier bin vacío al azar,
        //    excluyendo bins exclusivos asignados a OTRO grupo.
        $stmt = $db->prepare(
            "SELECT b.id, b.code, b.zone_id AS zone, 'CHAOTIC' AS strategy
             FROM bins b
             LEFT JOIN stock s ON s.bin_id = b.id AND s.quantity > 0
             WHERE b.warehouse_id = ? AND b.bin_type = 'STORAGE' AND b.is_active = 1
               AND NOT EXISTS (
                   SELECT 1 FROM bin_group_rules r3
                   WHERE r3.bin_id = b.id AND r3.is_exclusive = 1
                     AND r3.item_group_code <> COALESCE(?, '')
               )
             GROUP BY b.id, b.code, b.zone_id
             HAVING COALESCE(SUM(s.quantity), 0) = 0
             ORDER BY RAND()
             LIMIT 3"
        );
        $stmt->execute([$warehouseId, $itemGroup]);
        foreach ($stmt->fetchAll() as $row) {
            $suggestions[] = $row;
        }

        // Deduplicar manteniendo el primer hit (FIXED_GROUP gana)
        $seen = [];
        $deduped = [];
        foreach ($suggestions as $s) {
            if (isset($seen[$s['id']])) continue;
            $seen[$s['id']] = true;
            $deduped[] = $s;
        }

        $best = !empty($deduped) ? $deduped[0] : null;

        return [
            'best_suggestion' => $best,
            'alternatives'    => array_slice($deduped, 1, 4),
        ];
    }

    /**
     * Mueve TODO el stock de un bin a otro.
     */
    public function putawayByLocation(int $fromBinId, int $toBinId, int $userId): array
    {
        $db = Database::getConnection();

        $stocks = $this->stockRepo->getStockByBin($fromBinId);
        if (empty($stocks)) {
            throw new \RuntimeException('No hay stock en el bin origen');
        }

        // Obtener warehouse_id del bin
        $stmt = $db->prepare("SELECT warehouse_id FROM bins WHERE id = ?");
        $stmt->execute([$fromBinId]);
        $bin = $stmt->fetch();
        if (!$bin) {
            throw new \RuntimeException('Bin origen no encontrado');
        }
        $warehouseId = (int) $bin['warehouse_id'];

        // Validación de exclusividad por grupo (zonificación)
        foreach ($stocks as $stock) {
            $this->assertBinAcceptsItem($toBinId, (int) $stock['item_id']);
        }

        $db->beginTransaction();
        try {
            $movedItems = [];
            foreach ($stocks as $stock) {
                $qty = (float) $stock['quantity'];
                if ($qty <= 0) continue;

                $status = $stock['stock_status'] ?? 'AVAILABLE';

                // Descontar del origen
                $this->stockRepo->subtractQuantity($stock['id'], $qty);

                // Agregar al destino
                $toStockId = $this->stockRepo->findOrCreatePosition(
                    $warehouseId, $toBinId, $stock['item_id'], $stock['batch_id'], $status
                );
                $this->stockRepo->addQuantity($toStockId, $qty);

                // Registrar movimiento
                $this->movementRepo->logMovement([
                    'warehouse_id'   => $warehouseId,
                    'movement_type'  => 'PUTAWAY_BY_LOCATION',
                    'item_id'        => $stock['item_id'],
                    'batch_id'       => $stock['batch_id'],
                    'from_bin_id'    => $fromBinId,
                    'to_bin_id'      => $toBinId,
                    'quantity'       => $qty,
                    'reference_type' => 'PUTAWAY',
                    'created_by'     => $userId,
                ]);

                $movedItems[] = [
                    'item_id'  => $stock['item_id'],
                    'batch_id' => $stock['batch_id'],
                    'quantity' => $qty,
                ];
            }

            $db->commit();

            return [
                'from_bin_id' => $fromBinId,
                'to_bin_id'   => $toBinId,
                'moved_items' => $movedItems,
                'total_lines' => count($movedItems),
            ];
        } catch (\Throwable $e) {
            $db->rollBack();
            throw $e;
        }
    }

    /**
     * Modo libre: mueve una cantidad específica de un item entre bins.
     */
    public function freePutaway(array $data, int $userId): array
    {
        $db = Database::getConnection();

        $itemId    = (int) $data['item_id'];
        $batchId   = isset($data['batch_id']) ? (int) $data['batch_id'] : null;
        $fromBinId = (int) $data['from_bin_id'];
        $toBinId   = (int) $data['to_bin_id'];
        $qty       = (float) $data['quantity'];

        // Obtener warehouse_id del bin origen
        $stmt = $db->prepare("SELECT warehouse_id FROM bins WHERE id = ?");
        $stmt->execute([$fromBinId]);
        $bin = $stmt->fetch();
        if (!$bin) {
            throw new \RuntimeException('Bin origen no encontrado');
        }
        $warehouseId = (int) $bin['warehouse_id'];

        // Detectar status real del stock origen (preserva QA/BLOCKED en el destino)
        $stmt = $db->prepare(
            "SELECT stock_status FROM stock
             WHERE warehouse_id=? AND bin_id=? AND item_id=? AND (batch_id <=> ?) AND quantity > 0
             ORDER BY FIELD(stock_status,'AVAILABLE','QA','BLOCKED'), id ASC LIMIT 1"
        );
        $stmt->execute([$warehouseId, $fromBinId, $itemId, $batchId]);
        $status = $stmt->fetchColumn() ?: 'AVAILABLE';
        if (!empty($data['stock_status'])) {
            $status = (string) $data['stock_status'];
        }

        $db->beginTransaction();
        try {
            // Descontar del origen (mismo status)
            $fromStockId = $this->stockRepo->findOrCreatePosition(
                $warehouseId, $fromBinId, $itemId, $batchId, $status
            );
            $this->stockRepo->subtractQuantity($fromStockId, $qty);

            // Agregar al destino MANTENIENDO el status (la cuarentena se preserva)
            $toStockId = $this->stockRepo->findOrCreatePosition(
                $warehouseId, $toBinId, $itemId, $batchId, $status
            );
            $this->stockRepo->addQuantity($toStockId, $qty);

            // Registrar movimiento
            $this->movementRepo->logMovement([
                'warehouse_id'   => $warehouseId,
                'movement_type'  => 'PUTAWAY_FREE',
                'item_id'        => $itemId,
                'batch_id'       => $batchId,
                'from_bin_id'    => $fromBinId,
                'to_bin_id'      => $toBinId,
                'from_status'    => $status,
                'to_status'      => $status,
                'quantity'       => $qty,
                'reference_type' => 'PUTAWAY',
                'created_by'     => $userId,
            ]);

            $db->commit();

            return [
                'item_id'     => $itemId,
                'batch_id'    => $batchId,
                'from_bin_id' => $fromBinId,
                'to_bin_id'   => $toBinId,
                'quantity'    => $qty,
            ];
        } catch (\Throwable $e) {
            $db->rollBack();
            throw $e;
        }
    }

    /**
     * Confirma un putaway: mueve stock del bin origen al bin destino.
     */
    public function confirmPutaway(int $taskId, array $data, int $userId): array
    {
        $task = $this->taskRepo->findById($taskId);
        if (!$task || $task['task_type'] !== 'PUTAWAY') {
            throw new \RuntimeException('Tarea de putaway no válida');
        }
        if ($task['status'] === 'COMPLETED' || $task['status'] === 'CANCELLED') {
            throw new \RuntimeException('Esta tarea ya fue procesada');
        }

        $confirmedQty = (float) ($data['confirmed_qty'] ?? $task['quantity']);
        $toBinId = (int) ($data['to_bin_id'] ?? $task['to_bin_id']);
        $strategy = isset($data['strategy']) ? substr((string)$data['strategy'], 0, 20) : null;

        if (!$toBinId) {
            throw new \RuntimeException('Debe indicar el bin destino');
        }

        $db = Database::getConnection();
        $db->beginTransaction();

        try {
            // Detectar el status real del stock origen (puede ser QA o AVAILABLE
            // según haya pasado o no por liberación de QA)
            $stmtSrc = $db->prepare(
                "SELECT id, stock_status, quantity FROM stock
                 WHERE warehouse_id = ? AND bin_id = ? AND item_id = ?
                   AND batch_id <=> ? AND quantity > 0
                 ORDER BY FIELD(stock_status,'AVAILABLE','QA','BLOCKED'), id ASC
                 LIMIT 1"
            );
            $stmtSrc->execute([
                $task['warehouse_id'], $task['from_bin_id'], $task['item_id'], $task['batch_id']
            ]);
            $sourcePos = $stmtSrc->fetch();
            if (!$sourcePos) {
                throw new \RuntimeException('No hay stock disponible en el bin de origen');
            }
            $sourceStatus = $sourcePos['stock_status'];

            // Descontar del bin origen con el status real
            $this->stockRepo->subtractQuantity($sourcePos['id'], $confirmedQty);

            // Agregar al bin destino conservando el mismo status
            $toStockId = $this->stockRepo->findOrCreatePosition(
                $task['warehouse_id'],
                $toBinId,
                $task['item_id'],
                $task['batch_id'],
                $sourceStatus
            );
            $this->stockRepo->addQuantity($toStockId, $confirmedQty);

            // Registrar movimiento
            $this->movementRepo->logMovement([
                'warehouse_id'  => $task['warehouse_id'],
                'movement_type' => 'PUTAWAY',
                'item_id'       => $task['item_id'],
                'batch_id'      => $task['batch_id'],
                'from_bin_id'   => $task['from_bin_id'],
                'to_bin_id'     => $toBinId,
                'from_status'   => $sourceStatus,
                'to_status'     => $sourceStatus,
                'quantity'      => $confirmedQty,
                'reference_type'=> 'TASK',
                'reference_id'  => $taskId,
                'created_by'    => $userId,
            ]);

            // Completar tarea + guardar estrategia + bin destino real
            $this->taskRepo->completeTask($taskId, $confirmedQty);
            // Auto-asignar al operador que confirma si la tarea no tenía dueño
            $autoAssign = empty($task['assigned_to']);
            $db->prepare(
                "UPDATE warehouse_tasks
                 SET to_bin_id = ?,
                     putaway_strategy = COALESCE(?, putaway_strategy),
                     assigned_to = COALESCE(assigned_to, ?)
                 WHERE id = ?"
            )->execute([$toBinId, $strategy, $userId, $taskId]);

            $db->commit();

            // Audit
            \WMS\Services\AuditService::log('warehouse_tasks', $taskId, 'PUT_CONFIRM', [
                'task_status_prev' => $task['status'],
            ], [
                'item_id'        => $task['item_id'],
                'batch_id'       => $task['batch_id'],
                'from_bin_id'    => $task['from_bin_id'],
                'to_bin_id'      => $toBinId,
                'confirmed_qty'  => $confirmedQty,
                'source_status'  => $sourceStatus ?? null,
                'strategy'       => $strategy,
            ], $userId);

            return $this->taskRepo->findById($taskId);
        } catch (\Throwable $e) {
            $db->rollBack();
            throw $e;
        }
    }
}
