<?php

namespace routes;

use classes\authentication;
use classes\email;
use classes\order_bookings_counts_cache;
use classes\order_bookings_list_cache;
use classes\redis;
use classes\security_policy_service;
use Exception;
use modules\subusers\helpers\subusers_permission_node_key;
use objects\departments_o;
use objects\logs_o;
use objects\order_bookings_o;
use objects\order_items_o;
use objects\orders_o;
use objects\products_o;
use objects\users_o;
use traits\route_t;

require_once WD . '/classes/security_policy_service.php';

class orderBookingRoute
{
    use route_t;

    public function run(): void
    {
        $this->post('/order-bookings', function () {
            // Require the user to be logged in
            global $response;
            /**
             * Parameters
             */
            $customer_number = self::getTargetCustomer(); // users_o object
            $department = self::getTargetDepartment(); // Int
            $reg_1 = self::getTargetReg(1); // String
            $reg_2 = self::getTargetReg(2); // String | Null
            $reg_3 = self::getTargetReg(3); // String | Null
            $datetime = self::getTargetDateTime(); // DateTime
            $note = self::getTargetNote(); // String | Null
            $reference = self::getTargetReference(); // String | Null
            $po = self::getTargetPo(); // String | Null
            $pickup = self::getTargetPickup(); // Bool | Null
            $this->requireOrderBookingCreateAccess(
                (int)$customer_number->customer_number->value(),
                (int)$department->id
            );
            $items = self::getTargetItems(
                true,
                $customer_number,
                (int)$department->id
            ); // Array of order_items_o objects
            /**
             * Input data
             */
            $data = [
                'customer_number' => (int)$customer_number->customer_number->value(),
                'department' => $department->id,
                'reg_1' => $reg_1,
                'reg_2' => $reg_2,
                'reg_3' => $reg_3,
                'datetime' => $datetime->format('Y-m-d H:i:s'),
                'note' => $note,
                'reference' => $reference,
                'po' => $po,
                'pickup' => $pickup,
                'items' => $items,
            ];
            $fingerprint = $this->buildBookingCreationFingerprint($data);
            $cachedBooking = $this->getBookingFromIdempotencyCache($fingerprint);
            if ($cachedBooking !== null) {
                $response->success($cachedBooking->asArray());
            }
            if (!$this->reserveBookingCreationSlot($fingerprint)) {
                $cachedBooking = $this->getBookingFromIdempotencyCache($fingerprint);
                if ($cachedBooking !== null) {
                    $response->success($cachedBooking->asArray());
                }
                $response->error('A similar booking request is already being processed. Please wait a moment and retry.', 429);
            }
            /**
             * Create the object
             */
            $order_bookings_o = new order_bookings_o();
            try {
                $order_bookings_o->add($data);
                $this->storeBookingIdempotencyResult($fingerprint, (int)$order_bookings_o->id);
                (new security_policy_service())->observeBookingCreated((int)$customer_number->customer_number->value(), [
                    'booking_id' => (int)$order_bookings_o->id,
                    'route' => '/order-bookings',
                ]);
            } catch (\Throwable $e) {
                $this->clearBookingCreationSlot($fingerprint);
                throw $e;
            }
            $response->success($order_bookings_o->asArray());
        },
            [
                'add_bookings' => 'Permission to create order bookings for another customer or department scope.',
                'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD.'
            ]
        );

        $this->get('/order-bookings', function () {
            // Require the user to be logged in
            global $response;
            /**
             * Parameters
             */
            $object = self::getTargetObject();
            /** Authentication */
            $auth = new authentication();
            $user = $auth->get_user();
            /**
             * Permissions
             */
            $permission_own = self::definePermission('list_own_bookings', subusers_permission_node_key::BOOKINGS_LIST);
            $permission_other = self::definePermission('list_bookings');
            $has_permission_other = self::hasPermission($permission_other);
            $targetCustomer = !empty($object) ? (int)$object->customer_number->value() : null;
            $deptId = !empty($object) ? (int)$object->department->value() : null;
            self::allowOwnOrDepartmentAccess(
                $permission_own,
                $permission_other,
                $targetCustomer,
                $deptId,
                null,
                'You do not have permission to view this order booking.'
            );
            /**
             * Return the object
             */
            if (!empty($object)) $response->success($object->asArray());
            /**
             * Return the filtered list of objects
             */
            $object = new order_bookings_o(); // New object for listing
            $effectiveCustomer = self::resolveEffectiveCustomerNumber();
            $forcedFilters = $object->forceRestrictFilters([
                ...($has_permission_other && $user !== false ? [
                    'department' => $user->getGroup()->getDepartments()
                ] : []),
                ...(!$has_permission_other && $effectiveCustomer !== null ? [
                    'customer_number' => [(int)$effectiveCustomer]
                ] : [])
            ]);

            $cacheTtl = order_bookings_list_cache::getTtl();
            $cacheKey = $cacheTtl > 0 ? $this->getOrderBookingsListCacheKey($object, $forcedFilters) : null;
            if ($cacheKey !== null) {
                $cachedPayload = order_bookings_list_cache::getPayload($cacheKey);
                if ($cachedPayload !== null) {
                    $response->rawJson($cachedPayload);
                }
            }

            $result = $object->listObjectsWithPaginationIfSet(
                function ($booking) { return (new order_bookings_o())->select((int)$booking['id'])->asArray(); },
                $forcedFilters
            );

            $payload = [
                'success' => true,
                'data' => $result,
                'meta' => $response->get_meta(),
                'includes' => $response->get_includes(),
            ];

            if ($cacheKey !== null) {
                order_bookings_list_cache::storePayload($cacheKey, $payload, $cacheTtl);
            }

            $response->rawJson($payload);
        },
            [
                'list_own_bookings' => 'Permission to list own order bookings.',
                'list_bookings' => 'Permission to list department order bookings.',
            ]
        );

        $this->get('/order-bookings/counts', function () {
            global $response;

            $auth = new authentication();
            $user = $auth->get_user();

            $permission_own = self::definePermission('list_own_bookings', subusers_permission_node_key::BOOKINGS_LIST);
            $permission_other = self::definePermission('list_bookings');
            $hasPermissionOwn = self::hasPermission($permission_own);
            $hasPermissionOther = self::hasPermission($permission_other);

            if (!$hasPermissionOwn && !$hasPermissionOther) {
                $this->emitForbidden([$permission_own, $permission_other]);
            }

            $requestedDepartmentId = $this->getTargetCountDepartmentId();
            if ($hasPermissionOther && $requestedDepartmentId !== null) {
                self::requireDepartmentAccess((string)$requestedDepartmentId);
            }

            $effectiveCustomer = null;
            if (!$hasPermissionOther) {
                $effectiveCustomer = self::resolveEffectiveCustomerNumber();
                if ($effectiveCustomer === null || $effectiveCustomer < 1) {
                    $this->emitForbidden([$permission_other]);
                }
            }

            $departmentIds = null;
            if ($requestedDepartmentId !== null) {
                $departmentIds = [$requestedDepartmentId];
            } elseif ($hasPermissionOther && $user !== false) {
                $departmentIds = array_map('intval', $user->getGroup()->getDepartments());
                if ($departmentIds === []) {
                    $response->success([
                        'past' => 0,
                        'current' => 0,
                        'future' => 0,
                    ]);
                }
            }

            $cacheTtl = order_bookings_counts_cache::getTtl();
            $cacheKey = $cacheTtl > 0
                ? $this->getOrderBookingsCountsCacheKey($departmentIds, $effectiveCustomer)
                : null;

            if ($cacheKey !== null) {
                $cachedCounts = order_bookings_counts_cache::getCounts($cacheKey);
                if ($cachedCounts !== null) {
                    $response->success($cachedCounts);
                }
            }

            $counts = (new order_bookings_o())->getPendingBookingCounts(
                $departmentIds,
                $effectiveCustomer
            );

            if ($cacheKey !== null) {
                order_bookings_counts_cache::storeCounts($cacheKey, $counts, $cacheTtl);
            }

            $response->success($counts);
        },
            [
                'list_own_bookings' => 'Permission to list own order booking counts.',
                'list_bookings' => 'Permission to list department order booking counts.',
            ]
        );

        $this->put('/order-bookings', function () {
            // Require the user to be logged in
            global $response;
            /**
             * Parameters
             */
            $object = self::getTargetObject();
            $customer_number = self::getTargetCustomer(false); // users_o object (Can only be changed by admins)
            $department = self::getTargetDepartment(false); // Int
            $reg_1 = self::getTargetReg(1, false); // String
            $reg_2 = self::getTargetReg(2, false); // String | Null
            $reg_3 = self::getTargetReg(3, false); // String | Null
            $datetime = self::getTargetDateTime(false); // DateTime
            $note = self::getTargetNote(false); // String | Null
            $reference = self::getTargetReference(false); // String | Null
            $po = self::getTargetPo(false); // String | Null
            $pickup = self::getTargetPickup(false); // Bool | Null
            $order_id_was_set = self::isParametersSet(['order_id']);
            $order_id = self::getTargetOrderId(false); // Int | Null
            /** Authentication */
            $auth = new authentication();
            $user = $auth->get_user();
            /**
             * Permissions
             */
            if (!$object || !$object->exists()) {
                $response->error('Order booking does not exist.', 400);
            }
            $permission_own = self::definePermission('edit_own_bookings', subusers_permission_node_key::BOOKINGS_EDIT);
            $permission_other = self::definePermission('edit_bookings');
            $has_permission_other = self::hasPermission($permission_other);
            $ownGuard = function () use ($has_permission_other, $object) {
                // Block own edits when a transaction exists, unless admin/department permission is present
                return $has_permission_other || !$object->hasTransaction();
            };
            self::allowOwnOrDepartmentAccess(
                $permission_own,
                $permission_other,
                (int)$object->customer_number->value(),
                (int)$object->department->value(),
                $ownGuard,
                'You do not have permission to edit this order booking.'
            );
            $items = self::getTargetItems(
                false,
                $customer_number ?: (new users_o())->getUserByCustomerNumber((int)$object->customer_number->value()),
                $department !== null ? (int)$department->id : (int)$object->department->value()
            ); // Array of order_items_o objects
            /**
             * Update the object
             */
            $previous_order_id = (int)($object->order_id->value() ?? 0);
            $data = [
                ...(self::hasPermission($permission_other) && !empty($customer_number) ? [
                    'customer_number' => (int)$customer_number->customer_number->value(),
                ] : []),
                ...(isset($department) ? ['department' => $department->id] : []),
                ...(isset($reg_1) ? ['reg_1' => $reg_1] : []),
                ...(isset($reg_2) ? ['reg_2' => $reg_2] : []),
                ...(isset($reg_3) ? ['reg_3' => $reg_3] : []),
                ...(isset($datetime) ? ['datetime' => $datetime->format('Y-m-d H:i:s')] : []),
                ...(isset($note) ? ['note' => $note] : []),
                ...(isset($reference) ? ['reference' => $reference] : []),
                ...(isset($po) ? ['po' => $po] : []),
                ...(isset($pickup) ? ['pickup' => $pickup] : []),
                ...(isset($items) ? ['items' => $items] : []),
                ...($order_id_was_set ? ['order_id' => $order_id] : []),
            ];
            $object->update($data);
            if ($order_id_was_set && $order_id === null) {
                self::clearMatchingOrderBookingLink($object, $previous_order_id);
            }
            /**
             * Return the object
             */
            $response->success($object->asArray());
        },
            [
                'edit_bookings' => 'Permission to edit order bookings. Only applies to bookings already permitted.',
                'edit_own_bookings' => 'Permission to edit own order bookings.'
            ]
        );

        $this->delete('/order-bookings', function () {
            // Require the user to be logged in
            global $response;
            /**
             * Parameters
             */
            $object = self::getTargetObject();
            /** Authentication */
            $auth = new authentication();
            $user = $auth->get_user();
            /**
             * Permissions
             */
            if (!$object || !$object->exists()) {
                $response->error('Order booking does not exist.', 400);
            }
            $permission_own = self::definePermission('delete_own_bookings', subusers_permission_node_key::BOOKINGS_DELETE);
            $permission_other = self::definePermission('delete_bookings');
            self::allowOwnOrDepartmentAccess(
                $permission_own,
                $permission_other,
                (int)$object->customer_number->value(),
                (int)$object->department->value(),
                null,
                'You do not have permission to delete this order booking.'
            );
            /**
             * Delete the object
             */
            $object->delete();
            /**
             * Return success
             */
            $response->success(['message' => 'Order booking deleted successfully.']);
        },
            [
                'delete_bookings' => 'Permission to delete order bookings. Only applies to bookings already permitted.',
                'delete_own_bookings' => 'Permission to delete own order bookings. Only applies to uncompleted bookings.'
            ]
        );

        $this->post('/order-bookings/booking-confirmation/resend', function () {
            global $response;

            $object = self::getTargetObject();
            if (!$object || !$object->exists()) {
                $response->error('Order booking does not exist.', 400);
            }

            self::requirePermission('resend_booking_confirmations');
            self::requireDepartmentAccess((int)$object->department->value());

            (new email())->sendOrderBookingConfirmationEmail($object);

            $response->success([
                'message' => 'Booking confirmation resent successfully.',
                'booking' => $object->asArray(),
            ]);
        },
            [
                'resend_booking_confirmations' => 'Permission for department admins to resend order booking confirmation emails.'
            ]
        );

        $this->post('/order-bookings/completion-confirmation/resend', function () {
            global $response;

            $object = self::getTargetObject();
            if (!$object || !$object->exists()) {
                $response->error('Order booking does not exist.', 400);
            }

            self::requirePermission('complete_bookings');
            self::requireDepartmentAccess((int)$object->department->value());

            if (!$object->hasTransaction()) {
                $response->error('Order booking has not been completed yet.', 409);
            }

            if (!$object->getOrder()->hasWashCertificateAttached()) {
                $response->error('Order booking completion confirmation is not available yet.', 409);
            }

            (new email())->sendWashCertificateEmailToCustomer($object);

            $response->success([
                'message' => 'Completion confirmation resent successfully.',
                'booking' => $object->asArray(),
            ]);
        },
            [
                'complete_bookings' => 'Permission for department admins to resend order booking completion confirmation emails.'
            ]
        );

        $this->post('/order-bookings/complete', function () {
            // Require the user to be logged in
            global $response;
            /**
             * Parameters
             */
            $object = self::getTargetObject();
            $safetySeal = self::getSafetySeal(false);
            /**
             * Authentication
             */
            $user = (new authentication())->get_user();
            /**
             * Permissions
             */
            self::requireDepartmentAccess((int)$object->department->value());
            if (!$object || !$object->exists()) {
                $response->error('Order booking does not exist.', 400);
            }
            if (!(int)$object->order_id->value()) {
                $response->error('Booking completion must be completed through POS desktop or mobile steps.', 409);
            }
            /**
             * Complete the booking
             */
            $object->completeBooking((int)$user->id, $safetySeal);
            /**
             * Return the object
             */
            $response->success($object->asArray());
        },
            [
                'complete_bookings' => 'Permission to complete order bookings.'
            ]
        );

    }

    private function getOrderBookingsListCacheKey(order_bookings_o $object, string $forcedFilters): string
    {
        global $response;

        $page = (int)($response->getRequestParameter('page') ?? 0);
        if ($page < 1) {
            $page = 1;
        }

        $limit = (int)($response->getRequestParameter('limit') ?? 0);
        if ($limit < 1) {
            $limit = 1000;
        }

        return order_bookings_list_cache::buildKey([
            'route' => '/order-bookings',
            'page' => $page,
            'limit' => $limit,
            'search' => (string)($response->getRequestParameter('search') ?? ''),
            'order' => $this->normalizeOrderBookingsListCacheOrder((string)($response->getRequestParameter('order') ?? 'id:ASC')),
            'filters' => $object->filter_string_to_array($forcedFilters),
        ]);
    }

    private function getOrderBookingsCountsCacheKey(?array $departmentIds, ?int $customerNumber): string
    {
        return order_bookings_counts_cache::buildKey([
            'route' => '/order-bookings/counts',
            'day' => (new \DateTimeImmutable('now'))->format('Y-m-d'),
            'department_ids' => $departmentIds ?? [],
            'customer_number' => $customerNumber,
        ]);
    }

    private function normalizeOrderBookingsListCacheOrder(string $order): array
    {
        $normalized = trim($order);
        if ($normalized === '') {
            $normalized = 'id:ASC';
        }

        $parts = array_pad(explode(':', $normalized, 2), 2, 'ASC');
        $field = trim((string)$parts[0]);
        $direction = strtoupper(trim((string)$parts[1]));

        if ($field === '') {
            $field = 'id';
        }
        if ($direction === '') {
            $direction = 'ASC';
        }

        return [$field => $direction];
    }

    private function getTargetCountDepartmentId(): ?int
    {
        global $response;

        $parameter = 'department';
        $rawValue = $this->fromRequest($parameter);
        if ($rawValue === null || $rawValue === '') {
            return null;
        }

        if (!is_numeric($rawValue)) {
            $response->error('Invalid department', 400);
        }

        $value = (int)$rawValue;
        if ($value < 1 || $value > 999999999) {
            $response->error('Invalid department', 400);
        }

        return $value;
    }

    /**
     * @throws Exception
     */
    private function getTargetObject(): order_bookings_o|null
    {
        global $response;
        $parameter = 'id';
        $error = 'Invalid ' . $parameter;
        if (!$this->isParametersSet([$parameter])) return null;
        self::requireParameters(['id']);
        self::requireType((int)self::getParameter($parameter), self::type_int());
        self::requireMinLength($parameter, 1);
        self::requireMaxLength($parameter, 9);
        $value = (int)self::getParameter($parameter);
        self::requireMinValue($value, 1);
        self::requireMaxValue($value, 999999999);
        $object = (new order_bookings_o())->select($value);
        if (!$object->exists()) $response->error($error, 400);
        return $object;
    }

    private function getSafetySeal(bool $required = true): int|null
    {
        global $response;
        $parameter = 'safety_seal';
        $error = 'Invalid safety seal';
        if (!$required && !$this->isParametersSet([$parameter])) return null;
        self::requireParameters([$parameter]);
        $rawValue = self::getParameter($parameter);
        if (!$required && $rawValue === null) return null;
        if (is_string($rawValue)) {
            $rawValue = trim($rawValue);
            if (!$required && $rawValue === '') return null;
            if (!ctype_digit($rawValue)) $response->error($error, 400);
        } elseif (!is_int($rawValue)) {
            if ($required) {
                self::requireType($rawValue, self::type_int());
            } else {
                self::requireTypeIn($rawValue, [self::type_int(), self::type_null()]);
            }
        }
        if ($required || $rawValue !== null) {
            $valueLength = strlen((string)$rawValue);
            if ($valueLength < 1) $response->error('Parameter ' . $parameter . ' must be at least 1 characters long', 400);
            if ($valueLength > 9) $response->error('Parameter ' . $parameter . ' must be at most 9 characters long', 400);
        }
        $value = (int)$rawValue;
        self::requireMinValue($value, 1);
        self::requireMaxValue($value, 999999999);
        return $value;
    }

    private function getTargetOrderId(bool $required = true): int|null
    {
        global $response;
        $parameter = 'order_id';
        $error = 'Invalid order ID';
        if (!$required && !$this->isParametersSet([$parameter])) return null;
        self::requireParameters([$parameter]);
        if ($required) {
            self::requireType(self::getParameter($parameter), self::type_int());
        } else {
            self::requireTypeIn(self::getParameter($parameter), [self::type_int(), self::type_null()]);
            // Check if the value is null
            if ($this->getParameter($parameter) === null) return null;
        }
        self::requireMinLength($parameter, 1);
        self::requireMaxLength($parameter, 9);
        $value = (int)self::getParameter($parameter);
        self::requireMinValue($value, 1);
        self::requireMaxValue($value, 999999999);
        return $value;
    }

    /**
     * @throws Exception
     */
    private function clearMatchingOrderBookingLink(order_bookings_o $booking, int $previous_order_id): void
    {
        if ($previous_order_id <= 0) {
            return;
        }

        $previous_order = (new orders_o())->select($previous_order_id);
        if (!$previous_order->exists()) {
            return;
        }

        if ((int)($previous_order->booking_id->value() ?? 0) !== (int)$booking->id) {
            return;
        }

        $previous_order->booking_id->set(null);
        $previous_order->objectChanged();
    }

    private function getTargetCustomer(bool $required = true): users_o|null
    {
        global $response;
        $parameter = 'customer_number';
        $error = 'Invalid customer number';
        if (!$required && !$this->isParametersSet([$parameter])) return null;
        self::requireParameters([$parameter]);
        self::requireType(self::getParameter($parameter), self::type_int());
        self::requireMinLength($parameter, 2);
        self::requireMaxLength($parameter, 9);
        $value = (int)self::getParameter($parameter);
        self::requireMinValue($value, 1);
        self::requireMaxValue($value, 999999999);
        $object = (new users_o())->getUserByCustomerNumber($value);
        if (!$object->exists()) $response->error($error, 400);
        // If a subuser is making the request, expose the target customer number in meta
        try {
            $auth = new authentication();
            if ($auth->get_subuser() !== false) {
                $response->add_meta('target_customer_number', $value);
            }
        } catch (\Throwable $e) { /* ignore meta set errors */ }
        return $object;
    }

    private function requireOrderBookingCreateAccess(int $targetCustomerNumber, int $departmentId): void
    {
        $auth = new authentication();

        if ($auth->get_subuser() !== false && $this->isOwnCustomerContext($targetCustomerNumber)) {
            $permissionOwn = self::definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD);
            if (!self::hasPermission($permissionOwn, $targetCustomerNumber)) {
                $this->emitForbidden([$permissionOwn]);
            }
            return;
        }

        if (
            $auth->get_user() !== false
            && self::hasPermission('user')
            && $this->isOwnCustomerContext($targetCustomerNumber)
        ) {
            return;
        }

        $permissionOther = self::definePermission('add_bookings');
        if (!self::hasPermission($permissionOther)) {
            $this->emitForbidden([$permissionOther]);
        }

        self::requireDepartmentAccess((string)$departmentId);
    }

    private function isOrderBookingCustomerSession(): bool
    {
        try {
            $auth = new authentication();
            if ($auth->get_subuser() !== false) {
                return true;
            }

            return $auth->get_user() !== false && self::hasPermission('user');
        } catch (Exception) {
            return false;
        }
    }

    /**
     * @throws Exception If the Department is invalid.
     */
    private function getTargetDepartment(bool $required = true): departments_o|null
    {
        global $response;
        $parameter = 'department';
        $error = 'Invalid department';
        if (!$required && !$this->isParametersSet([$parameter])) return null;
        self::requireParameters([$parameter]);
        self::requireType(self::getParameter($parameter), self::type_int());
        self::requireMinLength($parameter, 1);
        self::requireMaxLength($parameter, 9);
        $value = (int)self::getParameter($parameter);
        self::requireMinValue($value, 1);
        self::requireMaxValue($value, 999999999);
        $object = (new departments_o())->select($value);
        if (!$object->exists()) $response->error($error, 400);
        return $object;
    }

    private function getTargetReg(int $index = 1, bool $required = true): string|null
    {
        global $response;
        $parameter = 'reg_' . $index;
        $error = 'Invalid ' . $parameter;
        $requiredParam = $required && $index === 1;
        if (!$requiredParam && !$this->isParametersSet([$parameter])) return null;
        self::requireParameters([$parameter]);
        if ($requiredParam) {
            self::requireType(self::getParameter($parameter), self::type_string());
        } else {
            self::requireTypeIn(self::getParameter($parameter), [self::type_string(), self::type_null()]);
            // Check if the value is null
            if ($this->getParameter($parameter) === null) return "null";
        }
        self::requireMinLength($parameter, 0);
        self::requireMaxLength($parameter, 12);
        $value = self::getParameter($parameter);
        return self::cleanReg($value);
    }
    private function getTargetDateTime(bool $required = true): \DateTime|null
    {
        global $response;
        $parameter = 'datetime';
        $error = 'Invalid datetime';
        if (!$required && !$this->isParametersSet([$parameter])) return null;
        self::requireParameters([$parameter]);
        self::requireType(self::getParameter($parameter), self::type_string());
        self::requireMinLength($parameter, 10);
        self::requireMaxLength($parameter, 25);
        $value = (string)self::getParameter($parameter);
        $fromFormat = strlen($value) === 10 ? 'Y-m-d' : 'Y-m-d H:i:s';
        $datetime = \DateTime::createFromFormat($fromFormat, $value);
        if (!$datetime) {
            $response->error($error, 400);
        }
        return $datetime;
    }
    private function getTargetNote(bool $required = false): string|null
    {
        $parameter = 'note';
        if (!$required && !$this->isParametersSet([$parameter])) return null;
        self::requireParameters([$parameter]);
        self::requireTypeIn(self::getParameter($parameter), [self::type_string(), self::type_null()]);
        if (self::getParameter($parameter) === null) return null;
        self::requireMinLength($parameter, 0);
        self::requireMaxLength($parameter, 1000);
        return (string)self::getParameter($parameter);
    }
    private function getTargetReference(bool $required = false): string|null
    {
        $parameter = 'reference';
        if (!$required && !$this->isParametersSet([$parameter])) return null;
        self::requireParameters([$parameter]);
        self::requireTypeIn(self::getParameter($parameter), [self::type_string(), self::type_null()]);
        if (self::getParameter($parameter) === null) return null;
        self::requireMinLength($parameter, 0);
        self::requireMaxLength($parameter, 100);
        return (string)self::getParameter($parameter);
    }
    private function getTargetPo(bool $required = false): string|null
    {
        $parameter = 'po';
        if (!$required && !$this->isParametersSet([$parameter])) return null;
        self::requireParameters([$parameter]);
        self::requireTypeIn(self::getParameter($parameter), [self::type_string(), self::type_null()]);
        if (self::getParameter($parameter) === null) return null;
        self::requireMinLength($parameter, 0);
        self::requireMaxLength($parameter, 100);
        return (string)self::getParameter($parameter);
    }
    private function getTargetPickup(bool $required = false): bool|null
    {
        $parameter = 'pickup';
        if (!$required && !$this->isParametersSet([$parameter])) return null;
        self::requireParameters([$parameter]);
        self::requireType(self::getParameter($parameter), self::type_bool());
        return (bool)self::getParameter($parameter);
    }

    /**
     * @throws Exception
     */
    private function getTargetItems(
        bool $required = true,
        ?users_o $customer = null,
        ?int $department_id = null
    ): array|null {
        global $response;
        $parameter = 'items';
        $error = 'Invalid items';
        if (!$required && !$this->isParametersSet([$parameter])) return null;
        self::requireParameters([$parameter]);
        self::requireType(self::getParameter($parameter), self::type_array());
        $items = (array)self::getParameter($parameter);
        if (count($items) === 0) {
            $response->error($error, 400);
        }
        // Verify items
        foreach ($items as $key => $item) {
            self::requireType($item, self::type_array());
            self::requireValidItem((array)$item, $key);
            $items[$key] = $this->normalizeBookingItem((array)$item, $customer, $department_id);
        }
        return $items;
    }

    /**
     * @throws Exception
     */
    private function normalizeBookingItem(array $item, ?users_o $customer, ?int $department_id): array
    {
        global $response;

        $product = (new products_o())->select((int)$item['id']);
        if (!$product->exists()) {
            $response->error('Invalid item id', 400);
        }

        $price = (int)$product->price->value();
        if ($department_id !== null) {
            $priceResolution = $product->getDepartmentPriceResolution($department_id);
            $price = (int)$priceResolution['price'];
            if (
                $customer !== null
                && $customer->exists()
                && !products_o::priceResolutionIsCustomMissing($priceResolution)
            ) {
                $price = $customer->applyProductCustomerPricing(
                    (int)$product->id,
                    $price,
                    true,
                    $department_id
                );
            }
        } elseif ($customer !== null && $customer->exists()) {
            $price = $customer->applyProductCustomerPricing((int)$product->id, $price);
        }

        return [
            ...$item,
            'id' => (int)$product->id,
            'name' => (string)$product->name->value(),
            'quantity' => (int)$item['quantity'],
            'price' => $price,
        ];
    }

    private function cleanReg(string $reg): string
    {
        // Remove all non-alphanumeric characters
        return strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $reg));
    }

    private function requireValidItem(array $item, int $index): void
    {
        global $response;
        /**
         * {
         * "id": 22,
         * "name": "Double Duty - Kemi",
         * "description": " ",
         * "price": 239,
         * "subscription_allowed": 0,
         * "category": "4",
         * "piktogram": "",
         * "economic_product_id": "24",
         * "apply_category_discount": false,
         * "requires_note": true,
         * "is_wash": false,
         * "display_in_booking_form": true,
         * "order_priority": 1021,
         * "created_at": "2024-12-09 14:31:49",
         * "updated_at": "2025-10-28 13:55:58",
         * "quantity": 4
         * }
         */
        // Require id field
        if (!isset($item['id']) || !is_int($item['id']) || $item['id'] < 1) {
            $response->error('Invalid item id at index ' . $index, 400);
        }
        // Require quantity field
        if (!isset($item['quantity']) || !is_int($item['quantity']) || $item['quantity'] < 1) {
            $response->error('Invalid item quantity at index ' . $index, 400);
        }
    }

    private function buildBookingCreationFingerprint(array $data): string
    {
        $payload = [
            'customer_number' => (int)($data['customer_number'] ?? 0),
            'department' => (int)($data['department'] ?? 0),
            'reg_1' => (string)($data['reg_1'] ?? ''),
            'reg_2' => $this->normalizeNullableString($data['reg_2'] ?? null),
            'reg_3' => $this->normalizeNullableString($data['reg_3'] ?? null),
            'datetime' => $this->normalizeDatetimeForFingerprint((string)($data['datetime'] ?? '')),
            'note' => $this->normalizeNullableString($data['note'] ?? null),
            'reference' => $this->normalizeNullableString($data['reference'] ?? null),
            'po' => $this->normalizeNullableString($data['po'] ?? null),
            'pickup' => (bool)($data['pickup'] ?? false),
            'items' => $this->normalizeItemsForFingerprint((array)($data['items'] ?? [])),
        ];

        $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        if ($encoded === false) {
            return md5((string)microtime(true));
        }

        return hash('sha256', $encoded);
    }

    private function normalizeNullableString(mixed $value): ?string
    {
        if ($value === null) {
            return null;
        }

        $normalized = trim((string)$value);
        if (strtolower($normalized) === 'null') {
            return null;
        }

        return $normalized;
    }

    private function normalizeItemsForFingerprint(array $items): array
    {
        $normalized = [];
        foreach ($items as $item) {
            if (!is_array($item)) {
                continue;
            }
            $id = (int)($item['id'] ?? 0);
            $quantity = (int)($item['quantity'] ?? 0);
            if ($id < 1 || $quantity < 1) {
                continue;
            }
            $normalized[] = [
                'id' => $id,
                'quantity' => $quantity,
            ];
        }

        usort($normalized, static function (array $a, array $b): int {
            if ($a['id'] === $b['id']) {
                return $a['quantity'] <=> $b['quantity'];
            }
            return $a['id'] <=> $b['id'];
        });

        return $normalized;
    }

    private function normalizeDatetimeForFingerprint(string $datetime): string
    {
        $value = trim($datetime);
        if ($value === '') {
            return '';
        }

        $formats = ['Y-m-d H:i:s', 'Y-m-d H:i', 'Y-m-d'];
        foreach ($formats as $format) {
            $parsed = \DateTime::createFromFormat($format, $value);
            if ($parsed !== false) {
                // Use minute precision so rapid retries do not bypass idempotency because of second-level drift.
                return $parsed->format('Y-m-d H:i');
            }
        }

        return $value;
    }

    private function getBookingFromIdempotencyCache(string $fingerprint): ?order_bookings_o
    {
        try {
            $redis = $this->resolveRedisClient();
            if ($redis === null) {
                return null;
            }
            $id = (int)($redis->get($this->bookingIdempotencyResultKey($fingerprint)) ?? 0);
            if ($id < 1) {
                return null;
            }
            $booking = (new order_bookings_o())->select($id);
            if (!$booking->exists()) {
                return null;
            }
            return $booking;
        } catch (\Throwable) {
            return null;
        }
    }

    private function reserveBookingCreationSlot(string $fingerprint): bool
    {
        try {
            $redis = $this->resolveRedisClient();
            if ($redis === null) {
                return true;
            }
            return $redis->set_if_absent_with_expiration(
                $this->bookingIdempotencyLockKey($fingerprint),
                '1',
                $this->bookingIdempotencyLockTtlSeconds()
            );
        } catch (\Throwable) {
            // Fail open when Redis is unavailable to avoid blocking all bookings.
            return true;
        }
    }

    private function storeBookingIdempotencyResult(string $fingerprint, int $bookingId): void
    {
        try {
            $redis = $this->resolveRedisClient();
            if ($redis === null) {
                return;
            }
            $redis->setEx(
                $this->bookingIdempotencyResultKey($fingerprint),
                (string)$bookingId,
                $this->bookingIdempotencyResultTtlSeconds()
            );
            $redis->delete($this->bookingIdempotencyLockKey($fingerprint));
        } catch (\Throwable) {
            // Best effort only.
        }
    }

    private function clearBookingCreationSlot(string $fingerprint): void
    {
        try {
            $redis = $this->resolveRedisClient();
            if ($redis === null) {
                return;
            }
            $redis->delete($this->bookingIdempotencyLockKey($fingerprint));
        } catch (\Throwable) {
            // Best effort only.
        }
    }

    private function resolveRedisClient(): ?redis
    {
        try {
            if (defined('redis')) {
                $instance = constant('redis');
                if ($instance instanceof redis) {
                    return $instance;
                }
            }
            return (new redis())->connect();
        } catch (\Throwable) {
            return null;
        }
    }

    private function bookingIdempotencyLockKey(string $fingerprint): string
    {
        return 'order_booking:idempotency:lock:' . $fingerprint;
    }

    private function bookingIdempotencyResultKey(string $fingerprint): string
    {
        return 'order_booking:idempotency:result:' . $fingerprint;
    }

    private function bookingIdempotencyLockTtlSeconds(): int
    {
        return 30;
    }

    private function bookingIdempotencyResultTtlSeconds(): int
    {
        return 300;
    }

}
