<?php
/**
 * nightsbridge_inventory_push.php
 *
 * Sends current room availability to Nightsbridge (BridgeConnect
 * InventoryUpdateRQ), so their booking engine knows what's actually free to
 * sell. This is the OUTBOUND direction - the counterpart to
 * new_api_booking.php, which handles the INBOUND direction (bookings they
 * push to us).
 *
 * TWO WAYS TO RUN IT:
 *
 * 1. STANDALONE (cron) - full inventory flush of every mapped room type:
 *      php nightsbridge_inventory_push.php
 *    Nightsbridge recommend a full flush between 01:00 and 03:00 daily:
 *      0 2 * * *  php /path/to/nightsbridge_inventory_push.php >> /path/to/nightsbridge_inventory.log 2>&1
 *
 * 2. AS A LIBRARY (real-time push) - new_api_booking.php includes this file
 *    and calls nb_run_inventory_push($pdo, [roomTypeIds]) immediately after a
 *    booking is created or cancelled, so only the affected room type(s) are
 *    recomputed and pushed as they occur. To include without triggering the
 *    standalone run, define NB_INVENTORY_PUSH_LIB before requiring:
 *        define('NB_INVENTORY_PUSH_LIB', true);
 *        require_once __DIR__ . '/nightsbridge_inventory_push.php';
 *
 * ---------------------------------------------------------------------
 * STILL NEEDED (marked TODO below):
 *   1. Confirm the full set of orders.status values that should NOT hold
 *      inventory. Currently excluded: 0 (cancelled via reservation_list)
 *      and 5 (cancelled via Nightsbridge / channel).
 * ---------------------------------------------------------------------
 */

declare(strict_types=1);

if (!defined('NB_INVENTORY_PUSH_LIB')) {
    error_reporting(E_ALL);
    ini_set('display_errors', '0');

    $bootstrapCandidates = [
        __DIR__ . '/../includes/bootstrap.php',     // /rooms/api/ -> /rooms/includes/ (confirmed actual layout)
        __DIR__ . '/../../includes/bootstrap.php',  // in case this ever moves a level deeper
        __DIR__ . '/bootstrap.php',                  // in case it's copied alongside this file
    ];
    $bootstrapFound = false;
    foreach ($bootstrapCandidates as $bootstrapPath) {
        if (is_file($bootstrapPath)) {
            require_once $bootstrapPath;
            $bootstrapFound = true;
            break;
        }
    }
    if (!$bootstrapFound) {
        http_response_code(500);
        header('Content-Type: text/plain; charset=utf-8');
        echo "Could not find boot.php. Tried:\n" . implode("\n", $bootstrapCandidates);
        exit;
    }
}

// Guarded with defined() so this file can be safely required by
// new_api_booking.php, which defines NIGHTSBRIDGE_LOGIN_KEY itself.
if (!defined('NIGHTSBRIDGE_LOGIN_KEY'))    define('NIGHTSBRIDGE_LOGIN_KEY', '266119649f911114c0bf80c2c3456c8c');
if (!defined('NIGHTSBRIDGE_BBID'))         define('NIGHTSBRIDGE_BBID', 30164); // RoomZ Pioneers Account - from https://book.nightsbridge.com/40042
if (!defined('NIGHTSBRIDGE_INVENTORY_URL')) define('NIGHTSBRIDGE_INVENTORY_URL', 'https://www.nightsbridge.co.za/bridge/bridgeconnect');
if (!defined('NIGHTSBRIDGE_PROPERTY_ID'))  define('NIGHTSBRIDGE_PROPERTY_ID', 2); // Pioneers Lodge (property id 2 within the Pioneers database)
if (!defined('INVENTORY_WINDOW_DAYS'))     define('INVENTORY_WINDOW_DAYS', 540); // how far ahead to push availability

if (!function_exists('ro_table_exists')) {
    function ro_table_exists(PDO $pdo, string $table): bool
    {
        static $cache = [];
        if (array_key_exists($table, $cache)) return $cache[$table];
        try {
            $stmt = $pdo->prepare("SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t");
            $stmt->execute([':t' => $table]);
            $cache[$table] = ((int)$stmt->fetchColumn() > 0);
        } catch (Throwable $e) {
            $cache[$table] = false;
        }
        return $cache[$table];
    }
}

if (!function_exists('ro_column_exists')) {
    function ro_column_exists(PDO $pdo, string $table, string $column): bool
    {
        static $cache = [];
        $key = $table . '.' . $column;
        if (array_key_exists($key, $cache)) return $cache[$key];
        try {
            $stmt = $pdo->prepare("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t AND COLUMN_NAME = :c");
            $stmt->execute([':t' => $table, ':c' => $column]);
            $cache[$key] = ((int)$stmt->fetchColumn() > 0);
        } catch (Throwable $e) {
            $cache[$key] = false;
        }
        return $cache[$key];
    }
}

if (!function_exists('ensureInventoryLogSchema')) {
    function ensureInventoryLogSchema(PDO $pdo): void
    {
        $pdo->exec("
            CREATE TABLE IF NOT EXISTS nightsbridge_inventory_push_log (
                id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
                pushed_at DATETIME NOT NULL,
                entry_count INT UNSIGNED NOT NULL DEFAULT 0,
                success TINYINT(1) NOT NULL DEFAULT 0,
                response_body TEXT NULL,
                error_message TEXT NULL,
                trigger_source VARCHAR(40) NULL
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
        ");
        if (!ro_column_exists($pdo, 'nightsbridge_inventory_push_log', 'trigger_source')) {
            try { $pdo->exec("ALTER TABLE nightsbridge_inventory_push_log ADD COLUMN trigger_source VARCHAR(40) NULL"); } catch (Throwable $e) {}
        }
    }
}

/*
   For each mapped room type, work out how many rooms are free on each day
   in the window, then merge consecutive days that share the same
   availability into a single date range - this keeps the payload compact
   instead of sending one entry per single day (Nightsbridge's own example
   sends a whole month as one range when nothing changes within it).
*/
if (!function_exists('computeRoomsFreeByDay')) {
    function computeRoomsFreeByDay(PDO $pdo, int $roomTypeId, int $propertyId, DateTime $start, int $days): array
    {
        /*
           Which rooms are SELLABLE.

           rooms.status is NOT sellability - it is housekeeping state. On
           Batonka it holds 'Occupied' and 'dirty'. The filter that used to live
           here excluded 'out_of_order','maintenance','inactive','blocked',
           none of which that column has ever contained, so it silently matched
           nothing - the same dead text-filter bug this file's own comments
           describe for orders.status.

           A dirty or occupied room is still a sellable room: it is free the
           moment housekeeping turns it and the guest leaves. Excluding it here
           would UNDER-sell the property and hide real inventory from the
           channel. So housekeeping state is deliberately ignored.

           If you later add a genuine out-of-service flag - is_active,
           is_sellable, out_of_order - name it below and it will be honoured
           automatically. Until then every room of the type counts toward the
           total, which is correct for this schema.
        */
        $roomStatusFilter = '';
        foreach (['is_active', 'is_sellable', 'sellable', 'is_bookable'] as $flagCol) {
            if (ro_column_exists($pdo, 'rooms', $flagCol)) {
                $roomStatusFilter = "AND COALESCE(`{$flagCol}`, 1) = 1";
                break;
            }
        }
        if ($roomStatusFilter === '') {
            foreach (['out_of_order', 'is_out_of_order', 'maintenance'] as $oooCol) {
                if (ro_column_exists($pdo, 'rooms', $oooCol)) {
                    $roomStatusFilter = "AND COALESCE(`{$oooCol}`, 0) = 0";
                    break;
                }
            }
        }

        $totalStmt = $pdo->prepare("
            SELECT COUNT(*) FROM rooms
            WHERE property_id = :pid AND room_type_id = :rtid
            {$roomStatusFilter}
        ");
        $totalStmt->execute([':pid' => $propertyId, ':rtid' => $roomTypeId]);
        $totalRooms = (int)$totalStmt->fetchColumn();

        $end = (clone $start)->add(new DateInterval('P' . $days . 'D'));

        // Count active reservations of this room type overlapping each day.
        // Counting by RESERVATION (order_rooming rows), not by physically
        // assigned room_id, since a booking can exist before a specific room
        // has been allocated (this is exactly the case for new Nightsbridge
        // bookings, which arrive with the room type set but room_id NULL until
        // staff assign one) - counting only assigned rooms would undercount
        // real usage and risk overselling.
        //
        // orders.status is NUMERIC in this schema - the previous text-based
        // filter ('cancelled','void',...) never matched anything, which meant
        // cancelled bookings were still counted as occupying rooms and
        // availability never reopened after a cancellation. Now excluding:
        //   0 = cancelled (reservation_list.php manual cancellation)
        //   5 = cancelled (Nightsbridge / channel cancellation)
        // TODO: add any other non-inventory-holding status values here.
        $activeStatusFilter = "1=1";
        if (ro_column_exists($pdo, 'orders', 'status')) {
            $activeStatusFilter = "COALESCE(o.status, 1) NOT IN (0, 5)";
        }

        // Released rooming rows (set by the cancellation flow) no longer hold
        // inventory even if the parent order status is something unexpected.
        $releasedFilter = '';
        if (ro_column_exists($pdo, 'order_rooming', 'released_status')) {
            $releasedFilter = "AND COALESCE(rmg.released_status, 0) = 0";
        }

        /*
           Count by the room type ACTUALLY OCCUPIED, not the one booked.

           BUG THIS FIXES - OVERSELLING:
           This used to filter on `rmg.room_type_id = :rtid`, i.e. the type the
           guest BOOKED. Availability is not about what was booked, it is about
           what is physically occupied. An upgrade breaks the two apart: a guest
           books a Double Room and is given a Family Room. The rooming row still
           says room_type_id = 14 (Double), but room 46 - a Family Room - is
           full for those nights.

           So when this asked "how many type-15 bookings on 7 Aug?" it found 3
           and reported 1 Family Room free, while all 4 were occupied. Room 46
           was on sale to the channel while a guest was in it. Double Rooms were
           simultaneously UNDER-sold, because the upgraded booking was still
           counted against Double inventory it was no longer using.

           On Batonka this was not an edge case: 30+ current and future bookings
           are upgrades, every one of them a Family Room the channel believed
           was free.

           COALESCE(r.room_type_id, rmg.room_type_id):
             - room assigned  -> the PHYSICAL room's type. What is really full.
             - not yet assigned (room_id 0/NULL, e.g. a fresh channel booking,
               or anything sitting in the holding tray) -> fall back to the
               requested type, so the booking still holds inventory somewhere
               rather than vanishing from availability entirely.
        */
        $bookingStmt = $pdo->prepare("
            SELECT rmg.check_in, rmg.check_out
            FROM order_rooming rmg
            INNER JOIN orders o ON o.id = rmg.order_id
            LEFT JOIN rooms r
                   ON r.id = rmg.room_id
                  AND r.property_id = o.property_id
            WHERE COALESCE(r.room_type_id, rmg.room_type_id) = :rtid
              AND o.property_id = :pid
              AND rmg.check_out > :start
              AND rmg.check_in < :end
              AND {$activeStatusFilter}
              {$releasedFilter}
        ");
        $bookingStmt->execute([
            ':rtid' => $roomTypeId,
            ':pid' => $propertyId,
            ':start' => $start->format('Y-m-d'),
            ':end' => $end->format('Y-m-d'),
        ]);
        $bookings = $bookingStmt->fetchAll(PDO::FETCH_ASSOC);

        /*
           CPU OPTIMISATION: build a difference array instead of scanning every
           booking again for every day. The previous implementation was
           O(days × bookings) per room type. This is O(days + bookings), while
           producing exactly the same check-in-inclusive/check-out-exclusive
           availability values.
        */
        $changes = array_fill(0, $days + 1, 0);
        $windowStart = new DateTimeImmutable($start->format('Y-m-d'));
        $windowEnd   = $windowStart->add(new DateInterval('P' . $days . 'D'));

        foreach ($bookings as $b) {
            $checkInRaw  = substr((string)($b['check_in'] ?? ''), 0, 10);
            $checkOutRaw = substr((string)($b['check_out'] ?? ''), 0, 10);

            $checkIn  = DateTimeImmutable::createFromFormat('!Y-m-d', $checkInRaw);
            $checkOut = DateTimeImmutable::createFromFormat('!Y-m-d', $checkOutRaw);
            if (!$checkIn || !$checkOut || $checkOut <= $checkIn) {
                continue;
            }

            // Clamp the reservation to the inventory window.
            if ($checkIn < $windowStart) $checkIn = $windowStart;
            if ($checkOut > $windowEnd)  $checkOut = $windowEnd;
            if ($checkOut <= $checkIn) continue;

            $startOffset = (int)$windowStart->diff($checkIn)->days;
            $endOffset   = (int)$windowStart->diff($checkOut)->days;

            if ($startOffset >= 0 && $startOffset < $days) {
                $changes[$startOffset]++;
            }
            if ($endOffset >= 0 && $endOffset <= $days) {
                $changes[$endOffset]--;
            }
        }

        $roomsFreeByDay = [];
        $cursor = clone $start;
        $booked = 0;
        for ($i = 0; $i < $days; $i++) {
            $booked += $changes[$i];
            $roomsFreeByDay[$cursor->format('Y-m-d')] = max(0, $totalRooms - $booked);
            $cursor->add(new DateInterval('P1D'));
        }

        return $roomsFreeByDay;
    }
}

/*
   Merge consecutive days with the same roomsfree value into single
   {startdate, enddate, roomsfree} ranges.
*/
if (!function_exists('mergeIntoRanges')) {
    function mergeIntoRanges(array $roomsFreeByDay, int $nbRoomTypeId): array
    {
        $ranges = [];
        $dates = array_keys($roomsFreeByDay);
        if (!$dates) return $ranges;

        $rangeStart = $dates[0];
        $rangeValue = $roomsFreeByDay[$dates[0]];
        $prevDate = $dates[0];

        for ($i = 1; $i < count($dates); $i++) {
            $date = $dates[$i];
            $value = $roomsFreeByDay[$date];

            if ($value !== $rangeValue) {
                $ranges[] = [
                    'roomtypeid' => $nbRoomTypeId,
                    'bbid' => NIGHTSBRIDGE_BBID,
                    'startdate' => $rangeStart,
                    'enddate' => $prevDate,
                    'roomsfree' => $rangeValue,
                ];
                $rangeStart = $date;
                $rangeValue = $value;
            }
            $prevDate = $date;
        }

        $ranges[] = [
            'roomtypeid' => $nbRoomTypeId,
            'bbid' => NIGHTSBRIDGE_BBID,
            'startdate' => $rangeStart,
            'enddate' => $prevDate,
            'roomsfree' => $rangeValue,
        ];

        return $ranges;
    }
}

if (!function_exists('pushInventoryToNightsbridge')) {
    function pushInventoryToNightsbridge(array $inventoryUpdates): array
    {
        $payload = [
            'messagename' => 'InventoryUpdateRQ',
            'credentials' => ['loginkey' => NIGHTSBRIDGE_LOGIN_KEY],
            'inventoryupdates' => $inventoryUpdates,
        ];

        $ch = curl_init(NIGHTSBRIDGE_INVENTORY_URL);
        $jsonPayload = json_encode($payload);
        if ($jsonPayload === false) {
            return ['ok' => false, 'message' => 'Could not encode inventory payload: ' . json_last_error_msg(), 'raw' => ''];
        }

        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $jsonPayload,
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_TIMEOUT => 30,
        ]);
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlError = curl_error($ch);
        curl_close($ch);

        if ($curlError !== '') {
            return ['ok' => false, 'message' => 'Connection error: ' . $curlError, 'raw' => ''];
        }

        $decoded = json_decode((string)$response, true);
        if (!is_array($decoded) || empty($decoded['success'])) {
            $errMsg = is_array($decoded) && isset($decoded['error']['message']) ? $decoded['error']['message'] : ('Unexpected response (HTTP ' . $httpCode . ')');
            return ['ok' => false, 'message' => $errMsg, 'raw' => (string)$response];
        }

        return ['ok' => true, 'message' => 'Success', 'raw' => (string)$response];
    }
}

/* =========================================================================
   Core runner - usable both from cron (full flush) and from
   new_api_booking.php (targeted real-time push for the affected room
   type(s) right after a booking is created or cancelled).

   $onlyLocalRoomTypeIds - null = ALL mapped room types (full flush);
                           array of local room_type_id values = only those.
   $triggerSource        - recorded in the push log so you can tell cron
                           flushes apart from real-time pushes.
   ========================================================================= */
if (!function_exists('nb_run_inventory_push')) {
    function nb_run_inventory_push(PDO $pdo, ?array $onlyLocalRoomTypeIds = null, string $triggerSource = 'cron', bool $verbose = false): array
    {
        ensureInventoryLogSchema($pdo);

        /*
           SERIALIZE ALL PUSHES FOR THIS PROPERTY.
           Both the cron sweep and the real-time booking-triggered push call
           this function. Previously only the standalone cron block held an
           advisory lock, so booking-triggered pushes (which run with
           NB_INVENTORY_PUSH_LIB defined, skipping that block) ran completely
           unlocked. A burst of channel bookings could therefore fire several
           full pushes at once, and a cron run could land on top of them -
           multiplying CPU and DB load.

           A per-property GET_LOCK with timeout 0 (non-blocking) fixes this: if
           a push for this property is already running, we SKIP rather than
           stack. Skipping is safe because every push reads live availability,
           so the in-flight run already reflects whatever triggered this call.
        */
        /*
           IMPORTANT: GET_LOCK is SERVER-WIDE, not per-database. Pioneers and
           Batonka run on the same MySQL server in separate databases, and BOTH
           legitimately number their own property as id 2. So a lock name based
           on NIGHTSBRIDGE_PROPERTY_ID alone would be identical ('..._2') across
           both properties and they would block each other server-wide - one
           property's push silently skipping whenever the two overlap.

           Including the current database name makes the lock name genuinely
           unique per property (the two databases are distinct), so each
           property serialises only against its own pushes.
        */
        $pushLockDb = '';
        try {
            $dbStmt = $pdo->query("SELECT DATABASE()");
            $pushLockDb = (string)$dbStmt->fetchColumn();
        } catch (Throwable $dbErr) {
            $pushLockDb = '';
        }
        $pushLockName = 'nb_inventory_push_' . $pushLockDb . '_' . NIGHTSBRIDGE_PROPERTY_ID;
        $pushLockHeld = false;
        try {
            $glStmt = $pdo->prepare("SELECT GET_LOCK(:ln, 0)");
            $glStmt->execute([':ln' => $pushLockName]);
            $pushLockHeld = ((int)$glStmt->fetchColumn() === 1);
        } catch (Throwable $lockErr) {
            // If locking itself fails, fall through and run unlocked rather
            // than blocking availability updates entirely.
            $pushLockHeld = false;
        }

        if (!$pushLockHeld) {
            if ($verbose) {
                echo "Inventory push skipped: another push for property " . NIGHTSBRIDGE_PROPERTY_ID . " is already running.\n";
            }
            return [
                'ok' => true,
                'skipped' => true,
                'message' => 'Skipped - another push for this property is already running.',
                'raw' => '',
                'entry_count' => 0,
                'room_type_count' => 0,
            ];
        }

        try {
            return nb_run_inventory_push_locked($pdo, $onlyLocalRoomTypeIds, $triggerSource, $verbose);
        } finally {
            try {
                $rlStmt = $pdo->prepare("SELECT RELEASE_LOCK(:ln)");
                $rlStmt->execute([':ln' => $pushLockName]);
            } catch (Throwable $ignore) {}
        }
    }
}

if (!function_exists('nb_run_inventory_push_locked')) {
    function nb_run_inventory_push_locked(PDO $pdo, ?array $onlyLocalRoomTypeIds = null, string $triggerSource = 'cron', bool $verbose = false): array
    {
        $roomTypeMapStmt = $pdo->prepare("SELECT nightsbridge_roomtypeid, room_type_id FROM nightsbridge_room_type_map WHERE property_id = :pid");
        $roomTypeMapStmt->execute([':pid' => NIGHTSBRIDGE_PROPERTY_ID]);
        $roomTypeMap = $roomTypeMapStmt->fetchAll(PDO::FETCH_ASSOC);


        if (!$roomTypeMap) {
            throw new RuntimeException('No room type mappings found for property_id ' . NIGHTSBRIDGE_PROPERTY_ID . ' - run nightsbridge_schema_setup.sql first.');
        }

        if ($onlyLocalRoomTypeIds !== null) {
            $wanted = array_map('intval', $onlyLocalRoomTypeIds);
            $filtered = [];
            foreach ($roomTypeMap as $map) {
                if (in_array((int)$map['room_type_id'], $wanted, true)) {
                    $filtered[] = $map;
                }
            }
            // If none of the requested room types are mapped, fall back to a
            // full push rather than silently pushing nothing.
            $roomTypeMap = $filtered ?: $roomTypeMap;
        }

        $today = new DateTime('today');
        $allUpdates = [];
        $diagnostics = [];

        foreach ($roomTypeMap as $map) {
            $nbRoomTypeId = (int)$map['nightsbridge_roomtypeid'];
            $localRoomTypeId = (int)$map['room_type_id'];

            $roomsFreeByDay = computeRoomsFreeByDay($pdo, $localRoomTypeId, NIGHTSBRIDGE_PROPERTY_ID, $today, INVENTORY_WINDOW_DAYS);
            $ranges = mergeIntoRanges($roomsFreeByDay, $nbRoomTypeId);
            // Append in place; array_merge repeatedly copies the accumulated
            // payload and becomes increasingly expensive as it grows.
            foreach ($ranges as $range) {
                $allUpdates[] = $range;
            }

            // Diagnostic: show today + next 6 days so the actual numbers can
            // be sanity-checked directly, instead of just trusting a range
            // count. (Plain foreach, not arrow functions - PHP 7.2 safe.)
            $next7 = array_slice($roomsFreeByDay, 0, 7, true);
            $pairs = [];
            foreach ($next7 as $d => $v) {
                $pairs[] = $d . '=' . $v;
            }
            $diagnostics[] = 'Nightsbridge roomtypeid ' . $nbRoomTypeId . ' (local room_type_id ' . $localRoomTypeId . '): ' . implode(', ', $pairs);
        }

        if ($verbose) {
            echo "Computed availability (next 7 days):\n" . implode("\n", $diagnostics) . "\n\n";
        }

        $result = pushInventoryToNightsbridge($allUpdates);

        $pdo->prepare("
            INSERT INTO nightsbridge_inventory_push_log (pushed_at, entry_count, success, response_body, error_message, trigger_source)
            VALUES (NOW(), :count, :success, :raw, :err, :src)
        ")->execute([
            ':count' => count($allUpdates),
            ':success' => $result['ok'] ? 1 : 0,
            ':raw' => $result['raw'],
            ':err' => $result['ok'] ? null : $result['message'],
            ':src' => $triggerSource,
        ]);

        $result['entry_count'] = count($allUpdates);
        $result['room_type_count'] = count($roomTypeMap);
        return $result;
    }
}

/* =========================================================================
   STANDALONE MAIN (cron / manual run) - skipped entirely when this file is
   included as a library by new_api_booking.php.

   A database advisory lock prevents overlapping cron executions. Without it,
   a slow API response or database query can allow several full 180-day pushes
   to run at the same time and multiply CPU usage.
   ========================================================================= */
if (!defined('NB_INVENTORY_PUSH_LIB')) {
    global $pdo;

    /*
       Locking now lives INSIDE nb_run_inventory_push() (per-property, shared by
       cron and booking-triggered pushes), so this standalone block no longer
       acquires its own lock - doing so would deadlock against the function's
       own lock of the same name. If the push is skipped because another run is
       active, nb_run_inventory_push() returns ['skipped' => true].
    */
    try {
        $result = nb_run_inventory_push($pdo, null, 'cron', true);

        if (!empty($result['skipped'])) {
            echo "Inventory push skipped: another run is still active.\n";
            exit(0);
        }

        if ($result['ok']) {
            echo "Inventory pushed successfully: " . $result['entry_count'] . " date range(s) across " . $result['room_type_count'] . " room type(s).\n";
        } else {
            echo "Inventory push FAILED: " . $result['message'] . "\n";
            exit(1);
        }
    } catch (Throwable $e) {
        echo "Inventory push FAILED: " . $e->getMessage() . "\n";
        try {
            ensureInventoryLogSchema($pdo);
            $pdo->prepare("INSERT INTO nightsbridge_inventory_push_log (pushed_at, entry_count, success, error_message, trigger_source) VALUES (NOW(), 0, 0, :err, 'cron')")
                ->execute([':err' => $e->getMessage()]);
        } catch (Throwable $ignore) {}
        exit(1);
    }
}
