<?php
require_once __DIR__ . '/../../includes/bootstrap.php';
require_login();

header('Content-Type: application/json; charset=utf-8');

$orderId       = (int)($_POST['order_id'] ?? 0);
$roomingId     = (int)($_POST['rooming_id'] ?? 0);
$roomId        = (int)($_POST['room_id'] ?? 0); // 0 means Temporary Holding Area
$checkIn       = trim((string)($_POST['check_in'] ?? ''));
$checkOut      = trim((string)($_POST['check_out'] ?? ''));
$holdingReason = trim((string)($_POST['holding_reason'] ?? 'Temporarily moved to holding area'));
$adminPin      = trim((string)($_POST['admin_pin'] ?? ''));
// When a move changes the stay dates, how nightly prices should update:
//   'reprice'  - rebuild every night from the rate plan
//   'preserve' - keep existing nights' prices, price only new nights
// Empty/absent = do not touch pricing (legacy behaviour for a pure room move).
$repriceMode   = strtolower(trim((string)($_POST['reprice_mode'] ?? '')));

if ($orderId <= 0 || $roomingId <= 0 || $checkIn === '' || $checkOut === '') {
    echo json_encode([
        'ok' => false,
        'message' => 'Missing rooming information.'
    ]);
    exit;
}

/*
   Validate the dates before anything touches the database.

   This used to be unnecessary: the only caller was calendar drag-and-drop,
   which always re-posts the booking's own unchanged dates. The Extend Stay
   feature now posts a USER-CHOSEN check_out, so a malformed or inverted range
   can reach here.

   An inverted range is the dangerous one. room_is_available_for_rooming()
   tests `rmg.check_in < :check_out AND rmg.check_out > :check_in`, which can
   never match when check_out <= check_in - so the room would be reported FREE
   and the booking written with a negative stay.
*/
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $checkIn) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $checkOut)) {
    echo json_encode(['ok' => false, 'message' => 'Check-in and check-out must be valid dates (YYYY-MM-DD).']);
    exit;
}

$checkInTs  = strtotime($checkIn);
$checkOutTs = strtotime($checkOut);

if ($checkInTs === false || $checkOutTs === false) {
    echo json_encode(['ok' => false, 'message' => 'Check-in or check-out is not a real date.']);
    exit;
}

/* Reject 2026-02-31 and friends, which strtotime() silently rolls forward. */
if (date('Y-m-d', $checkInTs) !== $checkIn || date('Y-m-d', $checkOutTs) !== $checkOut) {
    echo json_encode(['ok' => false, 'message' => 'Check-in or check-out is not a real calendar date.']);
    exit;
}

if ($checkOutTs <= $checkInTs) {
    echo json_encode(['ok' => false, 'message' => 'Check-out must be after check-in.']);
    exit;
}

if (!function_exists('current_user_id_safe')) {
    function current_user_id_safe(): int
    {
        if (function_exists('current_user_id')) {
            return (int)current_user_id();
        }

        return (int)($_SESSION['user_id'] ?? $_SESSION['id'] ?? 0);
    }
}

if (!function_exists('room_is_available_for_rooming')) {
    function room_is_available_for_rooming(PDO $pdo, int $roomId, string $checkIn, string $checkOut, int $excludeRoomingId = 0): bool
    {
        $sql = "
            SELECT COUNT(*)
            FROM order_rooming rmg
            INNER JOIN orders o ON o.id = rmg.order_id
            WHERE rmg.room_id = :room_id
              AND rmg.room_id > 0
              AND COALESCE(o.status, 1) <> 0
              AND rmg.check_in < :check_out
              AND rmg.check_out > :check_in
        ";

        $params = [
            ':room_id'   => $roomId,
            ':check_in'  => $checkIn,
            ':check_out' => $checkOut,
        ];

        if ($excludeRoomingId > 0) {
            $sql .= " AND rmg.id <> :exclude_rooming_id ";
            $params[':exclude_rooming_id'] = $excludeRoomingId;
        }

        $stmt = $pdo->prepare($sql);
        $stmt->execute($params);

        return ((int)$stmt->fetchColumn() === 0);
    }
}

if (!function_exists('mb_role_is_supervisor')) {
    function mb_role_is_supervisor(string $role): bool
    {
        $role = strtolower(trim($role));
        if ($role === '') return false;
        foreach (['super_admin', 'superadmin', 'administrator', 'admin', 'master', 'owner', 'director', 'manager', 'supervisor'] as $ok) {
            if ($role === $ok || strpos($role, $ok) !== false) return true;
        }
        return false;
    }
}

if (!function_exists('mb_admin_pin_valid')) {
    /*
       Validates the Admin PIN used to authorise moving a booking to a room of
       a DIFFERENT room type. Same multi-table candidate search + multiple
       hash strategies pattern as the restaurant planner's supervisor PIN, so
       the same admin credentials work across both screens. Only users whose
       role is supervisor-grade can authorise.
    */
    function mb_admin_pin_valid(PDO $pdo, string $pin): bool
    {
        $pin = trim($pin);
        if ($pin === '') return false;

        $tables = ['tblconsultant', 'consultants', 'users', 'system_users'];
        $pinCols = ['supervisor_pin', 'manager_pin', 'admin_pin', 'void_pin', 'pin', 'auth_pin', 'password', 'Password', 'user_password', 'password_hash'];
        $roleCols = ['role_key', 'user_role_key', 'ss_role_key', 'role', 'user_role', 'type', 'Role'];
        $activeCols = ['is_active', 'active', 'status'];

        foreach ($tables as $table) {
            try {
                $tStmt = $pdo->prepare("SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t");
                $tStmt->execute([':t' => $table]);
                if ((int)$tStmt->fetchColumn() === 0) continue;
            } catch (Throwable $e) { continue; }

            $existingPinCols = [];
            foreach ($pinCols as $pc) {
                if (column_exists_mb($pdo, $table, $pc)) $existingPinCols[] = $pc;
            }
            if (!$existingPinCols) continue;

            $sql = "SELECT * FROM `{$table}` WHERE 1=1";
            foreach ($activeCols as $ac) {
                if (column_exists_mb($pdo, $table, $ac)) {
                    if ($ac === 'status') $sql .= " AND LOWER(CAST(`{$ac}` AS CHAR)) NOT IN ('0','inactive','disabled','deleted')";
                    else $sql .= " AND COALESCE(`{$ac}`,1)=1";
                    break;
                }
            }
            $sql .= " LIMIT 500";

            try {
                foreach ($pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC) as $row) {
                    $roleOk = false;
                    foreach ($roleCols as $rc) {
                        if (array_key_exists($rc, $row) && mb_role_is_supervisor((string)$row[$rc])) { $roleOk = true; break; }
                    }
                    if (!$roleOk && isset($row['type']) && in_array((string)$row['type'], ['1', 'master'], true)) $roleOk = true;
                    if (!$roleOk) continue;

                    foreach ($existingPinCols as $pc) {
                        $stored = (string)($row[$pc] ?? '');
                        if ($stored === '') continue;
                        if (strlen($stored) >= 55 && password_verify($pin, $stored)) return true;
                        if (strlen($stored) === 32 && hash_equals(strtolower($stored), md5($pin))) return true;
                        if (strlen($stored) === 40 && hash_equals(strtolower($stored), sha1($pin))) return true;
                        if (hash_equals($stored, $pin)) return true;
                    }
                }
            } catch (Throwable $ignore) { continue; }
        }

        return false;
    }
}

if (!function_exists('mb_room_type_name')) {
    function mb_room_type_name(PDO $pdo, int $roomTypeId): string
    {
        if ($roomTypeId <= 0) return 'Unknown type';
        try {
            $stmt = $pdo->prepare("SELECT * FROM room_types WHERE id = :id LIMIT 1");
            $stmt->execute([':id' => $roomTypeId]);
            $row = $stmt->fetch(PDO::FETCH_ASSOC);
            if ($row) {
                foreach (['room_type_name', 'type_name', 'name', 'title', 'room_type'] as $col) {
                    if (!empty($row[$col])) return (string)$row[$col];
                }
            }
        } catch (Throwable $e) {}
        return 'Type #' . $roomTypeId;
    }
}

if (!function_exists('mb_nightsbridge_availability_push')) {
    /*
       Real-time availability push to Nightsbridge after a room move, so a
       cross-type move (which shifts availability between TWO room types) is
       reflected on the channel within seconds instead of waiting for the
       02:00 full flush. Same pattern as reservation_list.php:
         - called strictly AFTER the move transaction has committed
         - only fires for the Nightsbridge-connected property
         - all failures swallowed - a slow/dead push can never break the
           move itself; outcomes are recorded in nightsbridge_inventory_push_log
    */
    function mb_nightsbridge_availability_push(PDO $pdo, int $propertyId, array $roomTypeIds): void
    {
        try {
            if (!function_exists('nb_run_inventory_push')) {
                $candidates = [
                    __DIR__ . '/../../api/nightsbridge_inventory_push.php',
                    __DIR__ . '/../api/nightsbridge_inventory_push.php',
                    rtrim((string)($_SERVER['DOCUMENT_ROOT'] ?? ''), '/') . '/rooms/api/nightsbridge_inventory_push.php',
                ];
                foreach ($candidates as $lib) {
                    if (is_file($lib)) {
                        if (!defined('NB_INVENTORY_PUSH_LIB')) {
                            define('NB_INVENTORY_PUSH_LIB', true);
                        }
                        require_once $lib;
                        break;
                    }
                }
            }
            if (!function_exists('nb_run_inventory_push')) {
                return; // library not deployed - the nightly flush covers it
            }
            if (defined('NIGHTSBRIDGE_PROPERTY_ID') && $propertyId !== (int)NIGHTSBRIDGE_PROPERTY_ID) {
                return;
            }

            $ids = array_values(array_unique(array_filter(array_map('intval', $roomTypeIds))));
            nb_run_inventory_push($pdo, $ids ?: null, 'room_move', false);
        } catch (Throwable $e) {
            // Intentionally silent - see docblock.
        }
    }
}

if (!function_exists('column_exists_mb')) {
    function column_exists_mb(PDO $pdo, string $table, string $column): bool
    {
        try {
            $stmt = $pdo->prepare("
                SELECT COUNT(*)
                FROM INFORMATION_SCHEMA.COLUMNS
                WHERE TABLE_SCHEMA = DATABASE()
                  AND TABLE_NAME = :table_name
                  AND COLUMN_NAME = :column_name
            ");
            $stmt->execute([
                ':table_name'  => $table,
                ':column_name' => $column,
            ]);

            return (int)$stmt->fetchColumn() > 0;
        } catch (Throwable $e) {
            return false;
        }
    }
}


/*
   Rebuild nightly pricing from the CURRENT order_rooming rows.

   This deliberately has a move_booking-specific name so an older helper loaded
   by bootstrap.php cannot silently win through function_exists(). One row is
   written for each physical room and each occupied night. This is essential
   when only one room in a group arrives earlier or departs later.
*/

if (!function_exists('mb_recalc_folio_totals')) {
    /**
     * Recalculate a folio directly from its live, non-voided transactions.
     *
     * move_booking.php is an AJAX endpoint and does not load calendar.php,
     * therefore planner_recalc_folio() is not guaranteed to exist here.
     */
    function mb_recalc_folio_totals(PDO $pdo, int $folioId): void
    {
        if ($folioId <= 0) {
            return;
        }

        $voidExpr = column_exists_mb($pdo, 'folio_transactions', 'is_voided')
            ? 'COALESCE(is_voided,0)=0'
            : '1=1';

        $stmt = $pdo->prepare("
            SELECT
                COALESCE(SUM(CASE
                    WHEN {$voidExpr}
                     AND LOWER(COALESCE(tx_type,'')) NOT IN (
                        'payment','payments','receipt','deposit',
                        'payment_received','folio_payment','guest_payment',
                        'payment_void','void_payment','payment_reversal',
                        'reversal','refund','payment_refund'
                     )
                    THEN COALESCE(debit,0)
                    ELSE 0
                END),0) AS charges,
                COALESCE(SUM(CASE
                    WHEN {$voidExpr}
                     AND LOWER(COALESCE(tx_type,'')) IN (
                        'payment','payments','receipt','deposit',
                        'payment_received','folio_payment','guest_payment'
                     )
                    THEN COALESCE(credit,0)
                    ELSE 0
                END),0) AS payments
            FROM folio_transactions
            WHERE folio_id = :folio_id
        ");
        $stmt->execute([':folio_id' => $folioId]);
        $totals = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];

        $charges  = round((float)($totals['charges'] ?? 0), 2);
        $payments = round((float)($totals['payments'] ?? 0), 2);
        $balance  = round($charges - $payments, 2);

        $sets = [];
        $params = [':folio_id' => $folioId];

        foreach (['total_charges', 'charges'] as $column) {
            if (column_exists_mb($pdo, 'folios', $column)) {
                $sets[] = "`{$column}` = :charges";
                $params[':charges'] = $charges;
            }
        }

        foreach (['total_payments', 'payments'] as $column) {
            if (column_exists_mb($pdo, 'folios', $column)) {
                $sets[] = "`{$column}` = :payments";
                $params[':payments'] = $payments;
            }
        }

        if (column_exists_mb($pdo, 'folios', 'balance')) {
            $sets[] = "`balance` = :balance";
            $params[':balance'] = $balance;
        }

        if (column_exists_mb($pdo, 'folios', 'updated_at')) {
            $sets[] = "`updated_at` = NOW()";
        }

        if ($sets) {
            $pdo->prepare(
                "UPDATE folios
                 SET " . implode(', ', $sets) . "
                 WHERE id = :folio_id"
            )->execute($params);
        }
    }
}

if (!function_exists('mb_recalc_all_order_folios')) {
    function mb_recalc_all_order_folios(PDO $pdo, int $orderId): void
    {
        if ($orderId <= 0) {
            return;
        }

        $stmt = $pdo->prepare("
            SELECT id
            FROM folios
            WHERE order_id = :order_id
        ");
        $stmt->execute([':order_id' => $orderId]);

        foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) ?: [] as $folioId) {
            mb_recalc_folio_totals($pdo, (int)$folioId);
        }

        /*
         * Keep orders.payment_status in step when the column is available:
         * 0 unpaid, 1 partly paid, 2 fully paid/overpaid.
         */
        if (column_exists_mb($pdo, 'orders', 'payment_status')) {
            $chargeColumn = column_exists_mb($pdo, 'folios', 'total_charges')
                ? 'total_charges'
                : (column_exists_mb($pdo, 'folios', 'charges') ? 'charges' : null);

            $paymentColumn = column_exists_mb($pdo, 'folios', 'total_payments')
                ? 'total_payments'
                : (column_exists_mb($pdo, 'folios', 'payments') ? 'payments' : null);

            if ($chargeColumn && $paymentColumn) {
                $sum = $pdo->prepare("
                    SELECT
                        COALESCE(SUM(`{$chargeColumn}`),0) AS charges,
                        COALESCE(SUM(`{$paymentColumn}`),0) AS payments
                    FROM folios
                    WHERE order_id = :order_id
                ");
                $sum->execute([':order_id' => $orderId]);
                $row = $sum->fetch(PDO::FETCH_ASSOC) ?: [];

                $charges = (float)($row['charges'] ?? 0);
                $payments = (float)($row['payments'] ?? 0);

                $status = 0;
                if ($charges > 0.00001 && $payments > 0.00001) {
                    $status = ($payments + 0.00001 >= $charges) ? 2 : 1;
                }

                $pdo->prepare("
                    UPDATE orders
                    SET payment_status = :payment_status
                    WHERE id = :order_id
                ")->execute([
                    ':payment_status' => $status,
                    ':order_id' => $orderId,
                ]);
            }
        }
    }
}

if (!function_exists('mb_reprice_room_nights_for_date_change')) {
    function mb_reprice_room_nights_for_date_change(PDO $pdo, int $orderId, string $mode = 'reprice'): array
    {
        if ($orderId <= 0) {
            throw new RuntimeException('Invalid booking for repricing.');
        }

        if (!function_exists('fetch_rate_plan') || !function_exists('calculate_room_night_total')) {
            foreach ([
                __DIR__ . '/rate_support.php',
                __DIR__ . '/../../includes/rate_support.php',
                __DIR__ . '/../reservations/rate_support.php',
            ] as $rateFile) {
                if (is_file($rateFile)) {
                    require_once $rateFile;
                }
                if (function_exists('fetch_rate_plan') && function_exists('calculate_room_night_total')) {
                    break;
                }
            }
        }

        if (!function_exists('fetch_rate_plan') || !function_exists('calculate_room_night_total')) {
            throw new RuntimeException('Shared rate engine is unavailable.');
        }

        $orderStmt = $pdo->prepare("SELECT * FROM orders WHERE id = :id LIMIT 1");
        $orderStmt->execute([':id' => $orderId]);
        $order = $orderStmt->fetch(PDO::FETCH_ASSOC);
        if (!$order) {
            throw new RuntimeException('Booking not found while repricing.');
        }

        $roomStmt = $pdo->prepare("\n            SELECT rmg.*, rm.room_no AS actual_room_no\n            FROM order_rooming rmg\n            LEFT JOIN rooms rm ON rm.id = rmg.room_id\n            WHERE rmg.order_id = :id\n            ORDER BY rmg.room_no ASC, rmg.id ASC\n        ");
        $roomStmt->execute([':id' => $orderId]);
        $rooming = $roomStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
        if (!$rooming) {
            throw new RuntimeException('No rooming rows were found to price.');
        }

        $propertyId = (int)($order['property_id'] ?? 0);
        $agentId = !empty($order['agent_id']) ? (int)$order['agent_id'] : null;
        $mealPlan = function_exists('normalize_meal_plan_for_rates')
            ? normalize_meal_plan_for_rates((string)($order['meal_plan'] ?? 'HB'))
            : (string)($order['meal_plan'] ?? 'HB');
        $totalRooms = count($rooming);

        $has = function (string $column) use ($pdo): bool {
            return column_exists_mb($pdo, 'rel_orders_prices', $column);
        };

        // Preserve only genuine manual overrides, keyed by exact date + physical room.
        $overrides = [];
        if ($has('rate_overridden')) {
            $oldStmt = $pdo->prepare("\n                SELECT *\n                FROM rel_orders_prices\n                WHERE order_id = :id\n                  AND COALESCE(rate_overridden,0) = 1\n            ");
            $oldStmt->execute([':id' => $orderId]);
            foreach ($oldStmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $old) {
                $key = substr((string)$old['date'], 0, 10) . '|' . (int)($old['room_id'] ?? 0);
                $overrides[$key] = $old;
            }
        }

        $columns = ['order_id', 'date', 'total'];
        foreach ([
            'property_id','price','additional_person','additional_person_price','original_total',
            'override_total','override_reason','rate_overridden','extra_bed_amount',
            'single_supplement_amount','child_amount','room_id','room_type_id','rate_plan_id',
            'adults_count','kids_count','sharing_rate','single_rate','single_supplement',
            'child_rate_percent','adult_total','child_total','applied_rate_type','rate_plan_title',
            'rate_plan_currency','rate_plan_season_label','rate_plan_season_color','override_by',
            'override_at','override_type','override_value','extra_bed_override','override_components'
        ] as $column) {
            if ($has($column)) {
                $columns[] = $column;
            }
        }

        $quotedColumns = array_map(static function ($c) { return '`' . $c . '`'; }, $columns);
        $placeholders = array_map(static function ($c) { return ':' . $c; }, $columns);
        $insert = $pdo->prepare(
            'INSERT INTO rel_orders_prices (' . implode(',', $quotedColumns) . ') VALUES (' . implode(',', $placeholders) . ')'
        );

        $pdo->prepare("DELETE FROM rel_orders_prices WHERE order_id = :id")
            ->execute([':id' => $orderId]);

        $inserted = 0;
        $grandTotal = 0.0;
        $minIn = null;
        $maxOut = null;

        foreach ($rooming as $slot) {
            $checkIn = substr((string)($slot['check_in'] ?? ''), 0, 10);
            $checkOut = substr((string)($slot['check_out'] ?? ''), 0, 10);
            $start = strtotime($checkIn);
            $end = strtotime($checkOut);
            if (!$start || !$end || $end <= $start) {
                continue;
            }

            if ($minIn === null || $checkIn < $minIn) $minIn = $checkIn;
            if ($maxOut === null || $checkOut > $maxOut) $maxOut = $checkOut;

            $roomTypeId = (int)($slot['room_type_id'] ?? 0);
            $roomId = (int)($slot['room_id'] ?? 0);
            $adults = max(0, (int)($slot['adults'] ?? 0));
            $children = max(0, (int)($slot['children'] ?? 0));

            for ($ts = $start; $ts < $end; $ts = strtotime('+1 day', $ts)) {
                $day = date('Y-m-d', $ts);

                $ref = new ReflectionFunction('fetch_rate_plan');
                if ($ref->getNumberOfParameters() >= 6) {
                    $plan = fetch_rate_plan($pdo, $roomTypeId, $agentId, $day, $mealPlan, $totalRooms);
                } else {
                    $plan = fetch_rate_plan($pdo, $roomTypeId, $agentId, $day, $mealPlan);
                }
                if (!$plan) {
                    $plan = [
                        'id' => 0, 'title' => 'No rate plan (held at 0)', 'currency' => '',
                        'season_label' => '', 'season_color' => 'secondary',
                        'sharing_rate' => 0, 'single_rate' => 0, 'single_supplement' => 0,
                        'child_rate_percent' => 0,
                    ];
                }

                $sharing = (float)($plan['sharing_rate'] ?? 0);
                $single = (float)($plan['single_rate'] ?? 0);
                $supplement = (float)($plan['single_supplement'] ?? 0);
                $childPct = (float)($plan['child_rate_percent'] ?? 0);
                $calc = calculate_room_night_total($adults, $children, $sharing, $single, $supplement, $childPct);

                $total = round((float)($calc['room_total'] ?? 0), 2);
                $originalTotal = $total;
                $key = $day . '|' . $roomId;
                $oldOverride = $overrides[$key] ?? null;

                if ($oldOverride) {
                    if ($oldOverride['override_total'] !== null && $oldOverride['override_total'] !== '') {
                        $total = round((float)$oldOverride['override_total'], 2);
                    } else {
                        $total = round((float)$oldOverride['total'], 2);
                    }
                }

                $appliedType = (string)($calc['applied_rate_type'] ?? '');
                $price = $appliedType === 'single_rate' ? $single : $sharing;

                $data = [
                    'order_id' => $orderId,
                    'date' => $day,
                    'total' => $total,
                    'property_id' => $propertyId,
                    'price' => $price,
                    'additional_person' => 0,
                    'additional_person_price' => 0,
                    'original_total' => $oldOverride ? ($oldOverride['original_total'] ?? $originalTotal) : $originalTotal,
                    'override_total' => $oldOverride ? $total : null,
                    'override_reason' => $oldOverride['override_reason'] ?? null,
                    'rate_overridden' => $oldOverride ? 1 : 0,
                    'extra_bed_amount' => (float)($calc['extra_bed_amount'] ?? 0),
                    'single_supplement_amount' => (float)($calc['single_supplement_amount'] ?? 0),
                    'child_amount' => (float)($calc['child_total'] ?? 0),
                    'room_id' => $roomId,
                    'room_type_id' => $roomTypeId,
                    'rate_plan_id' => (int)($plan['id'] ?? $plan['rate_plan_id'] ?? 0),
                    'adults_count' => $adults,
                    'kids_count' => $children,
                    'sharing_rate' => $sharing,
                    'single_rate' => $single,
                    'single_supplement' => $supplement,
                    'child_rate_percent' => $childPct,
                    'adult_total' => (float)($calc['adult_total'] ?? 0),
                    'child_total' => (float)($calc['child_total'] ?? 0),
                    'applied_rate_type' => $appliedType,
                    'rate_plan_title' => (string)($plan['title'] ?? ''),
                    'rate_plan_currency' => (string)($plan['currency'] ?? ''),
                    'rate_plan_season_label' => (string)($plan['season_label'] ?? ''),
                    'rate_plan_season_color' => (string)($plan['season_color'] ?? 'secondary'),
                    'override_by' => $oldOverride['override_by'] ?? null,
                    'override_at' => $oldOverride['override_at'] ?? null,
                    'override_type' => $oldOverride['override_type'] ?? null,
                    'override_value' => $oldOverride['override_value'] ?? null,
                    'extra_bed_override' => $oldOverride['extra_bed_override'] ?? null,
                    'override_components' => $oldOverride['override_components'] ?? null,
                ];

                $params = [];
                foreach ($columns as $column) {
                    $params[':' . $column] = $data[$column] ?? null;
                }
                $insert->execute($params);
                $grandTotal += $total;
                $inserted++;
            }
        }

        $grandTotal = round($grandTotal, 2);
        $orderSets = [];
        $orderParams = [':id' => $orderId];
        foreach (['base_price','amount','totalamount'] as $column) {
            if (column_exists_mb($pdo, 'orders', $column)) {
                $orderSets[] = '`' . $column . '` = :grand_' . $column;
                $orderParams[':grand_' . $column] = $grandTotal;
            }
        }
        if ($minIn !== null && column_exists_mb($pdo, 'orders', 'check_in')) {
            $orderSets[] = 'check_in = :span_in';
            $orderParams[':span_in'] = $minIn;
        }
        if ($maxOut !== null && column_exists_mb($pdo, 'orders', 'check_out')) {
            $orderSets[] = 'check_out = :span_out';
            $orderParams[':span_out'] = $maxOut;
        }
        if ($minIn !== null && $maxOut !== null && column_exists_mb($pdo, 'orders', 'nights')) {
            $orderSets[] = 'nights = :span_nights';
            $orderParams[':span_nights'] = max(0, (int)((strtotime($maxOut) - strtotime($minIn)) / 86400));
        }
        if ($orderSets) {
            $pdo->prepare('UPDATE orders SET ' . implode(', ', $orderSets) . ' WHERE id = :id')->execute($orderParams);
        }

        // Rebuild accommodation folio transactions from the exact room-night rows.
        if (column_exists_mb($pdo, 'folio_transactions', 'source_table')) {
            $pdo->prepare("\n                DELETE FROM folio_transactions\n                WHERE order_id = :id\n                  AND LOWER(COALESCE(source_table,'')) = 'rel_orders_prices'\n            ")->execute([':id' => $orderId]);

            $folioStmt = $pdo->prepare("SELECT id FROM folios WHERE order_id = :id ORDER BY (folio_type = 'master') DESC, id ASC LIMIT 1");
            $folioStmt->execute([':id' => $orderId]);
            $folioId = (int)($folioStmt->fetchColumn() ?: 0);

            if ($folioId > 0) {
                $priceRows = $pdo->prepare("\n                    SELECT rop.*, rm.room_no AS actual_room_no, rt.title AS room_type_title\n                    FROM rel_orders_prices rop\n                    LEFT JOIN rooms rm ON rm.id = rop.room_id\n                    LEFT JOIN room_types rt ON rt.id = rm.room_type_id\n                    WHERE rop.order_id = :id\n                    ORDER BY rop.date ASC, rop.id ASC\n                ");
                $priceRows->execute([':id' => $orderId]);

                $txColumns = ['folio_id','order_id','tx_date','tx_type','description','debit','credit','source_table','source_id'];
                if (column_exists_mb($pdo, 'folio_transactions', 'property_id')) array_unshift($txColumns, 'property_id');
                if (column_exists_mb($pdo, 'folio_transactions', 'room_id')) $txColumns[] = 'room_id';
                $txPlaceholders = array_map(static function ($c) { return ':' . $c; }, $txColumns);
                $txInsert = $pdo->prepare('INSERT INTO folio_transactions (`' . implode('`,`', $txColumns) . '`) VALUES (' . implode(',', $txPlaceholders) . ')');

                foreach ($priceRows->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
                    $amount = round((float)$row['total'], 2);
                    $roomLabel = trim((string)($row['actual_room_no'] ?? ''));
                    $desc = 'Accommodation charge for ' . substr((string)$row['date'], 0, 10);
                    if ($roomLabel !== '') $desc .= ' | Room ' . $roomLabel;
                    if (!empty($row['room_type_title'])) $desc .= ' ' . $row['room_type_title'];
                    if (!empty($row['rate_plan_title'])) $desc .= ' | ' . $row['rate_plan_title'];

                    $tx = [
                        'folio_id' => $folioId,
                        'order_id' => $orderId,
                        'tx_date' => substr((string)$row['date'], 0, 10) . ' 00:00:00',
                        'tx_type' => 'room_charge',
                        'description' => $desc,
                        'debit' => $amount,
                        'credit' => 0,
                        'source_table' => 'rel_orders_prices',
                        'source_id' => (int)$row['id'],
                        'property_id' => $propertyId,
                        'room_id' => (int)($row['room_id'] ?? 0),
                    ];
                    $txParams = [];
                    foreach ($txColumns as $column) $txParams[':' . $column] = $tx[$column] ?? null;
                    $txInsert->execute($txParams);
                }

                /*
                 * Recalculate every folio belonging to the booking. The old
                 * accommodation transactions may have existed on a different
                 * folio, while the rebuilt room charges are posted to master.
                 */
                mb_recalc_all_order_folios($pdo, $orderId);
            }

            /*
             * This also covers the case where the selected master folio did
             * not exist. Folios affected by the DELETE still need their cached
             * totals refreshed to remove the old accommodation charges.
             */
            mb_recalc_all_order_folios($pdo, $orderId);
        }

        $payments = 0.0;
        if (column_exists_mb($pdo, 'folio_transactions', 'credit')) {
            $payStmt = $pdo->prepare("SELECT COALESCE(SUM(credit),0) FROM folio_transactions WHERE order_id = :id AND COALESCE(is_voided,0)=0");
            $payStmt->execute([':id' => $orderId]);
            $payments = round((float)$payStmt->fetchColumn(), 2);
        }

        return [
            'nights' => $inserted,
            'total' => $grandTotal,
            'payments' => $payments,
            'is_overpaid' => $payments > $grandTotal,
            'overpaid' => max(0, round($payments - $grandTotal, 2)),
        ];
    }
}

try {
    $stmt = $pdo->prepare("
        SELECT
            rmg.id,
            rmg.order_id,
            rmg.property_id AS rooming_property_id,
            rmg.room_no,
            rmg.room_id AS current_room_id,
            rmg.adults,
            rmg.children,
            rmg.check_in,
            rmg.check_out,
            rmg.room_type_id,
            o.property_id AS order_property_id,
            o.status AS order_status
        FROM order_rooming rmg
        INNER JOIN orders o ON o.id = rmg.order_id
        WHERE rmg.id = :id
          AND rmg.order_id = :order_id
        LIMIT 1
    ");
    $stmt->execute([
        ':id'       => $roomingId,
        ':order_id' => $orderId,
    ]);

    $rooming = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$rooming) {
        echo json_encode([
            'ok' => false,
            'message' => 'Rooming row not found.'
        ]);
        exit;
    }

    $bookingPropertyId = (int)($rooming['order_property_id'] ?? 0);

    if ($bookingPropertyId <= 0) {
        echo json_encode([
            'ok' => false,
            'message' => 'This booking does not have a valid property.'
        ]);
        exit;
    }

    $status = strtolower(trim((string)($rooming['order_status'] ?? '')));

    if (in_array($status, ['0', 'cancelled', 'canceled'], true)) {
        echo json_encode([
            'ok' => false,
            'message' => 'Cancelled bookings cannot be moved.'
        ]);
        exit;
    }

    if (in_array($status, ['4', 'checked_out', 'checkout', 'completed'], true)) {
        echo json_encode([
            'ok' => false,
            'message' => 'Checked-out bookings cannot be moved.'
        ]);
        exit;
    }

    $movingToHolding = ($roomId <= 0);
    $crossTypeMove = false;
    $bookedTypeName = '';
    $targetTypeName = '';
    $targetRoom = null;

    if (!$movingToHolding) {
        $stmtRoom = $pdo->prepare("
            SELECT
                id,
                property_id,
                room_type_id,
                room_no,
                status
            FROM rooms
            WHERE id = :id
            LIMIT 1
        ");
        $stmtRoom->execute([':id' => $roomId]);
        $targetRoom = $stmtRoom->fetch(PDO::FETCH_ASSOC);

        if (!$targetRoom) {
            echo json_encode([
                'ok' => false,
                'message' => 'Selected room was not found.'
            ]);
            exit;
        }

        if ((int)$targetRoom['property_id'] !== $bookingPropertyId) {
            echo json_encode([
                'ok' => false,
                'message' => 'You cannot move this booking to a room in another property.'
            ]);
            exit;
        }

        $crossTypeMove = ((int)$targetRoom['room_type_id'] !== (int)$rooming['room_type_id']);

        if ($crossTypeMove) {
            /*
               Moving a guest into a room of a DIFFERENT type is allowed, but
               only with a valid Admin PIN - e.g. only a Family Room is free
               and a guest booked on another type needs a room. Any user can
               still move freely between rooms of the SAME type without a PIN.
            */
            $bookedTypeName = mb_room_type_name($pdo, (int)$rooming['room_type_id']);
            $targetTypeName = mb_room_type_name($pdo, (int)$targetRoom['room_type_id']);

            if ($adminPin === '') {
                echo json_encode([
                    'ok' => false,
                    'requires_pin' => true,
                    'message' => 'Room ' . (string)$targetRoom['room_no'] . ' is a different room type (' . $targetTypeName . ') to what this booking was sold as (' . $bookedTypeName . '). An Admin PIN is required to authorise moving between non-similar room types.'
                ]);
                exit;
            }

            if (!mb_admin_pin_valid($pdo, $adminPin)) {
                echo json_encode([
                    'ok' => false,
                    'requires_pin' => true,
                    'message' => 'Invalid Admin PIN. Moving between non-similar room types (' . $bookedTypeName . ' → ' . $targetTypeName . ') requires a valid Admin PIN.'
                ]);
                exit;
            }
        }

        if (!room_is_available_for_rooming($pdo, $roomId, $checkIn, $checkOut, $roomingId)) {
            echo json_encode([
                'ok' => false,
                'message' => 'That room is already occupied in the selected date range.'
            ]);
            exit;
        }
    }

    $hasHoldingReason = column_exists_mb($pdo, 'order_rooming', 'holding_reason');
    $hasHoldingAt     = column_exists_mb($pdo, 'order_rooming', 'moved_to_holding_at');
    $hasHoldingBy     = column_exists_mb($pdo, 'order_rooming', 'moved_to_holding_by');

    $pdo->beginTransaction();

    if ($movingToHolding) {
        $sql = "
            UPDATE order_rooming
            SET room_id = 0,
                property_id = :property_id,
                check_in = :check_in,
                check_out = :check_out,
                updated_at = NOW()
        ";

        $params = [
            ':property_id' => $bookingPropertyId,
            ':check_in'    => $checkIn,
            ':check_out'   => $checkOut,
            ':id'          => $roomingId,
        ];

        /* Clear the denormalized room_no copy too - a booking sitting in the
           holding area occupies no room, and leaving the old number behind
           made the calendar search still report it as being in that room. */
        if (column_exists_mb($pdo, 'order_rooming', 'room_no')) {
            $sql .= ", room_no = 0 ";
        }

        if ($hasHoldingReason) {
            $sql .= ", holding_reason = :holding_reason ";
            $params[':holding_reason'] = $holdingReason !== '' ? $holdingReason : 'Temporarily moved to holding area';
        }

        if ($hasHoldingAt) {
            $sql .= ", moved_to_holding_at = NOW() ";
        }

        if ($hasHoldingBy) {
            $sql .= ", moved_to_holding_by = :moved_to_holding_by ";
            $params[':moved_to_holding_by'] = current_user_id_safe();
        }

        $sql .= " WHERE id = :id ";

        $pdo->prepare($sql)->execute($params);
    } else {
        $sql = "
            UPDATE order_rooming
            SET room_id = :room_id,
                property_id = :property_id,
                check_in = :check_in,
                check_out = :check_out,
                updated_at = NOW()
        ";

        $params = [
            ':room_id'     => $roomId,
            ':property_id' => $bookingPropertyId,
            ':check_in'    => $checkIn,
            ':check_out'   => $checkOut,
            ':id'          => $roomingId,
        ];

        /*
           order_rooming.room_no is a DENORMALIZED copy of the room number,
           written at insert. The calendar grid, availability checks and this
           file all work off room_id (the truth), but the calendar's search
           results display room_no - so leaving it stale here made a moved
           booking show its OLD room number in search while the grid showed
           the new one. Keep the copy in step on every move.
        */
        if (column_exists_mb($pdo, 'order_rooming', 'room_no')) {
            $sql .= ", room_no = :new_room_no ";
            $params[':new_room_no'] = (string)$targetRoom['room_no'];
        }

        if ($crossTypeMove && $targetRoom) {
            /*
               The rooming row's room_type_id drives ALL availability counting
               (calendar, reports, and the Nightsbridge inventory push). The
               guest now physically occupies a room of the new type, so the
               row must count against THAT type - otherwise the originally
               booked type stays blocked while physically free, and the new
               type still shows as available while occupied (an oversell
               waiting to happen on the channel). What the guest is CHARGED is
               unaffected - billing lives on the order/folio, which keeps the
               originally sold room type.
            */
            $sql .= ", room_type_id = :new_room_type_id ";
            $params[':new_room_type_id'] = (int)$targetRoom['room_type_id'];
        }

        if ($hasHoldingReason) {
            $sql .= ", holding_reason = NULL ";
        }

        if ($hasHoldingAt) {
            $sql .= ", moved_to_holding_at = NULL ";
        }

        if ($hasHoldingBy) {
            $sql .= ", moved_to_holding_by = NULL ";
        }

        $sql .= " WHERE id = :id ";

        $pdo->prepare($sql)->execute($params);
    }

    $firstRoom = $pdo->prepare("
        SELECT room_id
        FROM order_rooming
        WHERE order_id = :order_id
          AND room_id > 0
        ORDER BY room_no ASC, id ASC
        LIMIT 1
    ");
    $firstRoom->execute([':order_id' => $orderId]);
    $headerRoomId = (int)($firstRoom->fetchColumn() ?: 0);

    /*
       Keep the orders HEADER dates in step with the rooming rows.

       A plain move never changes dates, so this used to be unnecessary. An
       EXTEND STAY is committed as a move to the same room with a later
       check_out - without this, order_rooming would say 14 Aug while
       orders.check_out still said 12 Aug, and every consumer of the header
       (folio night count, front office reports, invoices, availability
       exclusions on the header) would be a night short.

       The header spans the WIDEST extent of its rooming rows, which is the
       correct semantics for a multi-room booking with staggered arrivals.
    */
    $extent = $pdo->prepare("
        SELECT MIN(check_in) AS min_in, MAX(check_out) AS max_out
        FROM order_rooming
        WHERE order_id = :order_id
    ");
    $extent->execute([':order_id' => $orderId]);
    $span = $extent->fetch(PDO::FETCH_ASSOC) ?: [];

    if (!empty($span['min_in']) && !empty($span['max_out'])) {
        $nights = (int)max(1, round((strtotime($span['max_out']) - strtotime($span['min_in'])) / 86400));
        $pdo->prepare("
            UPDATE orders
            SET room_id = :room_id,
                check_in = :check_in,
                check_out = :check_out,
                nights = :nights,
                updated_on = NOW()
            WHERE id = :id
        ")->execute([
            ':room_id'   => $headerRoomId,
            ':check_in'  => $span['min_in'],
            ':check_out' => $span['max_out'],
            ':nights'    => $nights,
            ':id'        => $orderId,
        ]);
    } else {
        $pdo->prepare("
            UPDATE orders
            SET room_id = :room_id,
                updated_on = NOW()
            WHERE id = :id
        ")->execute([
            ':room_id' => $headerRoomId,
            ':id'      => $orderId,
        ]);
    }

    if ($crossTypeMove && $targetRoom) {
        /* Permanent audit trail of the override on the booking itself. */
        $auditNote = '[' . date('Y-m-d H:i') . '] ROOM TYPE OVERRIDE: moved to Room '
            . (string)$targetRoom['room_no'] . ' (' . $targetTypeName . ') although sold as '
            . $bookedTypeName . ' - authorised by Admin PIN, actioned by user #' . current_user_id_safe() . '.';
        foreach (['reservation_notes', 'notes'] as $noteCol) {
            if (column_exists_mb($pdo, 'orders', $noteCol)) {
                /*
                   The notes column on this install is latin1 while the PDO
                   connection is utf8mb4 - CONCAT across the two throws
                   "Illegal mix of collations". Detect the column's actual
                   character set and CONVERT the bound note into it so the
                   CONCAT happens in one charset.
                */
                $noteExpr = ':note';
                try {
                    $csStmt = $pdo->prepare("SELECT CHARACTER_SET_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'orders' AND COLUMN_NAME = :c LIMIT 1");
                    $csStmt->execute([':c' => $noteCol]);
                    $charset = (string)$csStmt->fetchColumn();
                    if ($charset !== '' && preg_match('/^[a-z0-9_]+$/i', $charset)) {
                        $noteExpr = "CONVERT(:note USING {$charset})";
                    }
                } catch (Throwable $e) {}

                $pdo->prepare("UPDATE orders SET `{$noteCol}` = TRIM(CONCAT(COALESCE(`{$noteCol}`,''), {$noteExpr})) WHERE id = :id")
                    ->execute([':note' => "\n" . $auditNote, ':id' => $orderId]);
                break;
            }
        }
    }

    /*
       If this move changed the stay dates (earlier arrival, later checkout,
       shift, or reduction), the nightly prices and master folio must follow.
       We only reprice when the caller asked for it (reprice_mode set) AND the
       dates actually changed - a pure room move leaves pricing untouched.

       Runs INSIDE the transaction so a pricing failure rolls the whole date
       change back, never leaving rooming dates and prices out of step.
    */
    $oldIn  = substr((string)($rooming['check_in'] ?? ''), 0, 10);
    $oldOut = substr((string)($rooming['check_out'] ?? ''), 0, 10);
    $datesChanged = ($checkIn !== $oldIn) || ($checkOut !== $oldOut);
    $repriceSummary = null;

    if ($datesChanged && !$movingToHolding && in_array($repriceMode, ['reprice', 'preserve'], true)) {
        $repriceSummary = mb_reprice_room_nights_for_date_change($pdo, $orderId, $repriceMode);
    }

    /*
       Keep room_id in step on records that snapshot it at check-in time.
       order_rooming is the truth, but folios and order_room_occupants store a
       room_id captured when the guest checked in. Without this, a moved guest
       still shows their OLD room on the Folio Control Centre even though the
       rooming row (and restaurant posting) are correct. $roomId is the new
       physical room (0 when moved to holding).
    */
    try {
        if (column_exists_mb($pdo, 'folios', 'room_id') && column_exists_mb($pdo, 'folios', 'rooming_id')) {
            $pdo->prepare("
                UPDATE folios
                SET room_id = :room_id
                WHERE order_id = :order_id
                  AND rooming_id = :rooming_id
            ")->execute([
                ':room_id'    => $roomId,
                ':order_id'   => $orderId,
                ':rooming_id' => $roomingId,
            ]);
        }
        if (column_exists_mb($pdo, 'order_room_occupants', 'room_id')
            && column_exists_mb($pdo, 'order_room_occupants', 'rooming_id')) {
            $pdo->prepare("
                UPDATE order_room_occupants
                SET room_id = :room_id
                WHERE order_id = :order_id
                  AND rooming_id = :rooming_id
            ")->execute([
                ':room_id'    => $roomId,
                ':order_id'   => $orderId,
                ':rooming_id' => $roomingId,
            ]);
        }
    } catch (Throwable $e) {
        // Best-effort sync; never fail the move over a snapshot update.
    }

    $pdo->commit();

    /* Push updated availability for every room type this move touched:
       the ORIGINAL type (dates may have shifted, and on a cross-type move it
       gains a unit back) and the NEW type (which loses one). Runs after
       commit and never blocks or fails the move. */
    $affectedTypeIds = [(int)$rooming['room_type_id']];
    if ($crossTypeMove && $targetRoom) {
        $affectedTypeIds[] = (int)$targetRoom['room_type_id'];
    }
    mb_nightsbridge_availability_push($pdo, $bookingPropertyId, $affectedTypeIds);

    $responseMessage = $movingToHolding
        ? 'Room slot moved to Temporary Holding Area.'
        : ($crossTypeMove
            ? 'Room slot moved to a different room type (' . $bookedTypeName . ' → ' . $targetTypeName . ') with Admin authorisation.'
            : 'Room slot moved successfully.');

    if ($repriceSummary) {
        $n = (int)$repriceSummary['nights'];
        $responseMessage = 'Dates updated. ' . $n . ' night(s), new total '
            . number_format((float)$repriceSummary['total'], 2) . '.';
        if (!empty($repriceSummary['is_overpaid'])) {
            $responseMessage .= ' NOTE: payments exceed the new total by '
                . number_format((float)$repriceSummary['overpaid'], 2)
                . ' - a refund may be due.';
        }
    }

    echo json_encode([
        'ok' => true,
        'message' => $responseMessage,
        'reprice' => $repriceSummary,
    ]);
} catch (Throwable $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }

    echo json_encode([
        'ok' => false,
        'message' => $e->getMessage()
    ]);
}