<?php

namespace classes;

use PDO;

class order_reference_suggestions_service
{
    private const DEFAULT_LIMIT = 10;
    private const MAX_LIMIT = 25;
    private const MAX_SOURCE_ROWS = 500;

    /**
     * @var array<string, bool>
     */
    private array $columnExistsCache = [];

    /**
     * @param array{
     *   search?: mixed,
     *   department_id?: mixed,
     *   customer_id?: mixed,
     *   reg_1?: mixed,
     *   reg_2?: mixed,
     *   reg_3?: mixed,
     *   limit?: mixed
     * } $criteria
     * @return array<int, array<string, mixed>>
     */
    public function suggest(array $criteria): array
    {
        $departmentId = $this->toPositiveInt($criteria['department_id'] ?? null);
        if ($departmentId === null) {
            return [];
        }

        $search = $this->normalizeText($criteria['search'] ?? '');
        $customerId = $this->toPositiveInt($criteria['customer_id'] ?? null);
        $plates = $this->normalizePlates([
            $criteria['reg_1'] ?? '',
            $criteria['reg_2'] ?? '',
            $criteria['reg_3'] ?? '',
        ]);
        $limit = $this->clampLimit($criteria['limit'] ?? self::DEFAULT_LIMIT);

        $rows = [
            ...$this->fetchBookingRows($departmentId, $search),
            ...$this->fetchOrderRows($departmentId, $search),
            ...$this->fetchVehicleRows($departmentId, $customerId, $plates, $search),
        ];

        $suggestions = $this->aggregateRows($rows, $search, $customerId, $plates);
        usort($suggestions, [$this, 'sortSuggestions']);

        return array_slice($suggestions, 0, $limit);
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function fetchBookingRows(int $departmentId, string $search): array
    {
        $where = [
            'department = :department_id',
            'reference IS NOT NULL',
            "TRIM(reference) <> ''",
        ];
        if ($this->tableHasColumn('order_bookings', 'deleted_at')) {
            array_unshift($where, 'deleted_at IS NULL');
        }
        $params = ['department_id' => $departmentId];

        if ($search !== '') {
            $where[] = 'LOWER(reference) LIKE :search';
            $params['search'] = '%' . $this->lower($search) . '%';
        }

        $sql = "SELECT
                    'booking' AS source,
                    id AS origin_id,
                    TRIM(reference) AS reference,
                    datetime AS source_created_at,
                    datetime AS used_at,
                    customer_number AS customer_id,
                    department AS department_id,
                    reg_1,
                    reg_2,
                    reg_3
                FROM order_bookings
                WHERE " . implode(' AND ', $where) . "
                ORDER BY datetime DESC, id DESC
                LIMIT :source_limit";

        return $this->fetchRows($sql, $params);
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function fetchOrderRows(int $departmentId, string $search): array
    {
        $where = [
            'department_id = :department_id',
            'reference IS NOT NULL',
            "TRIM(reference) <> ''",
        ];
        if ($this->tableHasColumn('orders', 'deleted_at')) {
            array_unshift($where, 'deleted_at IS NULL');
        }
        $params = ['department_id' => $departmentId];

        if ($search !== '') {
            $where[] = 'LOWER(reference) LIKE :search';
            $params['search'] = '%' . $this->lower($search) . '%';
        }

        $sql = "SELECT
                    'order' AS source,
                    id AS origin_id,
                    TRIM(reference) AS reference,
                    created_at AS source_created_at,
                    created_at AS used_at,
                    customer_id,
                    department_id,
                    reg_1,
                    reg_2,
                    reg_3
                FROM orders
                WHERE " . implode(' AND ', $where) . "
                ORDER BY created_at DESC, id DESC
                LIMIT :source_limit";

        return $this->fetchRows($sql, $params);
    }

    /**
     * @param array<int, string> $plates
     * @return array<int, array<string, mixed>>
     */
    private function fetchVehicleRows(int $departmentId, ?int $customerId, array $plates, string $search): array
    {
        $contextWhere = [];
        $params = [];

        if ($customerId !== null) {
            $contextWhere[] = 'customer_id = :customer_id';
            $params['customer_id'] = $customerId;
        }

        foreach ($plates as $index => $plate) {
            $key = 'plate_' . $index;
            $contextWhere[] = "UPPER(REPLACE(reg, ' ', '')) = :$key";
            $params[$key] = $plate;
        }

        if ($contextWhere === []) {
            return [];
        }

        $where = [
            'reference IS NOT NULL',
            "TRIM(reference) <> ''",
            '(' . implode(' OR ', $contextWhere) . ')',
        ];
        if ($this->tableHasColumn('customer_vehicles', 'deleted_at')) {
            array_unshift($where, 'deleted_at IS NULL');
        }

        if ($search !== '') {
            $where[] = 'LOWER(reference) LIKE :search';
            $params['search'] = '%' . $this->lower($search) . '%';
        }

        $where[] = $this->vehicleDepartmentAccessPredicate();
        $params['orders_department_id'] = $departmentId;
        $params['bookings_department_id'] = $departmentId;

        $sql = "SELECT
                    'vehicle' AS source,
                    id AS origin_id,
                    TRIM(reference) AS reference,
                    created_at AS source_created_at,
                    created_at AS used_at,
                    customer_id,
                    NULL AS department_id,
                    reg AS reg_1,
                    '' AS reg_2,
                    '' AS reg_3
                FROM customer_vehicles
                WHERE " . implode(' AND ', $where) . "
                ORDER BY created_at DESC, id DESC
                LIMIT :source_limit";

        return $this->fetchRows($sql, $params);
    }

    private function vehicleDepartmentAccessPredicate(): string
    {
        $ordersWhere = [
            'authorized_orders.department_id = :orders_department_id',
            '(authorized_orders.customer_id = customer_vehicles.customer_id'
                . " OR UPPER(REPLACE(authorized_orders.reg_1, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
                . " OR UPPER(REPLACE(authorized_orders.reg_2, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
                . " OR UPPER(REPLACE(authorized_orders.reg_3, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', '')))"
        ];
        if ($this->tableHasColumn('orders', 'deleted_at')) {
            $ordersWhere[] = 'authorized_orders.deleted_at IS NULL';
        }

        $bookingsWhere = [
            'authorized_bookings.department = :bookings_department_id',
            '(authorized_bookings.customer_number = customer_vehicles.customer_id'
                . " OR UPPER(REPLACE(authorized_bookings.reg_1, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
                . " OR UPPER(REPLACE(authorized_bookings.reg_2, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
                . " OR UPPER(REPLACE(authorized_bookings.reg_3, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', '')))"
        ];
        if ($this->tableHasColumn('order_bookings', 'deleted_at')) {
            $bookingsWhere[] = 'authorized_bookings.deleted_at IS NULL';
        }

        return '(EXISTS (
                    SELECT 1
                    FROM orders authorized_orders
                    WHERE ' . implode(' AND ', $ordersWhere) . '
                ) OR EXISTS (
                    SELECT 1
                    FROM order_bookings authorized_bookings
                    WHERE ' . implode(' AND ', $bookingsWhere) . '
                ))';
    }

    /**
     * @param array<string, mixed> $params
     * @return array<int, array<string, mixed>>
     */
    private function fetchRows(string $sql, array $params): array
    {
        $pdo = db::getPDO();
        $statement = $pdo->prepare($sql);

        foreach ($params as $key => $value) {
            $statement->bindValue(':' . $key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
        }
        $statement->bindValue(':source_limit', self::MAX_SOURCE_ROWS, PDO::PARAM_INT);
        $statement->execute();

        $rows = $statement->fetchAll(PDO::FETCH_ASSOC);
        return is_array($rows) ? $rows : [];
    }

    private function tableHasColumn(string $table, string $column): bool
    {
        $cacheKey = $table . '.' . $column;
        if (array_key_exists($cacheKey, $this->columnExistsCache)) {
            return $this->columnExistsCache[$cacheKey];
        }

        $pdo = db::getPDO();
        $statement = $pdo->prepare(
            'SELECT COUNT(*) AS total
             FROM INFORMATION_SCHEMA.COLUMNS
             WHERE TABLE_SCHEMA = DATABASE()
               AND TABLE_NAME = :table_name
               AND COLUMN_NAME = :column_name'
        );
        $statement->bindValue(':table_name', $table, PDO::PARAM_STR);
        $statement->bindValue(':column_name', $column, PDO::PARAM_STR);
        $statement->execute();

        $this->columnExistsCache[$cacheKey] = ((int)$statement->fetchColumn()) > 0;
        return $this->columnExistsCache[$cacheKey];
    }

    /**
     * @param array<int, array<string, mixed>> $rows
     * @param array<int, string> $plates
     * @return array<int, array<string, mixed>>
     */
    private function aggregateRows(array $rows, string $search, ?int $customerId, array $plates): array
    {
        $groups = [];

        foreach ($rows as $row) {
            $reference = $this->normalizeText($row['reference'] ?? '');
            if ($reference === '') {
                continue;
            }

            $key = $this->lower($reference);
            if (!isset($groups[$key])) {
                $groups[$key] = [
                    'reference' => $reference,
                    'rows' => [],
                    'usage_count' => 0,
                    'last_used_at' => null,
                    'context_boost' => 0,
                    'section' => 'other',
                ];
            }

            $section = $this->contextSection($row, $customerId, $plates);
            $groups[$key]['usage_count']++;
            $groups[$key]['rows'][] = $row;
            $groups[$key]['last_used_at'] = $this->maxDate(
                $groups[$key]['last_used_at'],
                $this->normalizeDate($row['used_at'] ?? null)
            );
            $groups[$key]['context_boost'] = max(
                $groups[$key]['context_boost'],
                $this->contextBoost($row, $customerId, $plates)
            );
            $groups[$key]['section'] = $this->bestSection(
                (string)$groups[$key]['section'],
                $section
            );
        }

        $suggestions = [];
        foreach ($groups as $group) {
            $bestRow = $this->bestOriginRow($group['rows']);
            if ($bestRow === null) {
                continue;
            }

            $source = (string)($bestRow['source'] ?? 'order');
            $usageCount = (int)$group['usage_count'];
            $score = $this->matchScore((string)$group['reference'], $search)
                + (int)$group['context_boost']
                + $this->sectionScore((string)$group['section'])
                + $this->sourceScore($source)
                + min($usageCount, 20) * 5;

            $suggestions[] = [
                'source' => $source,
                'section' => (string)$group['section'],
                'reference' => (string)$group['reference'],
                'source_created_at' => $this->normalizeDate($bestRow['source_created_at'] ?? null),
                'last_used_at' => $group['last_used_at'],
                'usage_count' => $usageCount,
                'origin_id' => (int)($bestRow['origin_id'] ?? 0),
                'score' => $score,
            ];
        }

        return $suggestions;
    }

    /**
     * @param array<int, array<string, mixed>> $rows
     */
    private function bestOriginRow(array $rows): ?array
    {
        usort($rows, function (array $left, array $right): int {
            $sourceCompare = $this->sourceScore((string)($right['source'] ?? ''))
                <=> $this->sourceScore((string)($left['source'] ?? ''));
            if ($sourceCompare !== 0) {
                return $sourceCompare;
            }

            $dateCompare = strcmp(
                (string)$this->normalizeDate($right['source_created_at'] ?? null),
                (string)$this->normalizeDate($left['source_created_at'] ?? null)
            );
            if ($dateCompare !== 0) {
                return $dateCompare;
            }

            return ((int)($right['origin_id'] ?? 0)) <=> ((int)($left['origin_id'] ?? 0));
        });

        return $rows[0] ?? null;
    }

    private function sortSuggestions(array $left, array $right): int
    {
        $scoreCompare = ((int)($right['score'] ?? 0)) <=> ((int)($left['score'] ?? 0));
        if ($scoreCompare !== 0) {
            return $scoreCompare;
        }

        $usageCompare = ((int)($right['usage_count'] ?? 0)) <=> ((int)($left['usage_count'] ?? 0));
        if ($usageCompare !== 0) {
            return $usageCompare;
        }

        $sectionCompare = $this->sectionScore((string)($right['section'] ?? ''))
            <=> $this->sectionScore((string)($left['section'] ?? ''));
        if ($sectionCompare !== 0) {
            return $sectionCompare;
        }

        $dateCompare = strcmp((string)($right['last_used_at'] ?? ''), (string)($left['last_used_at'] ?? ''));
        if ($dateCompare !== 0) {
            return $dateCompare;
        }

        $referenceCompare = strcmp((string)($left['reference'] ?? ''), (string)($right['reference'] ?? ''));
        if ($referenceCompare !== 0) {
            return $referenceCompare;
        }

        return $this->sourceScore((string)($right['source'] ?? '')) <=> $this->sourceScore((string)($left['source'] ?? ''));
    }

    /**
     * @param array<int, string> $plates
     */
    private function contextBoost(array $row, ?int $customerId, array $plates): int
    {
        $score = 0;
        if ($customerId !== null && (int)($row['customer_id'] ?? 0) === $customerId) {
            $score += 80;
        }

        if ($this->rowMatchesAnyPlate($row, $plates)) {
            $score += 90;
        }

        return $score;
    }

    /**
     * @param array<int, string> $plates
     */
    private function contextSection(array $row, ?int $customerId, array $plates): string
    {
        if ($this->rowMatchesAnyPlate($row, $plates)) {
            return 'this_vehicle';
        }

        if ($customerId !== null && (int)($row['customer_id'] ?? 0) === $customerId) {
            return 'other_customer_vehicle';
        }

        return 'other';
    }

    /**
     * @param array<int, string> $plates
     */
    private function rowMatchesAnyPlate(array $row, array $plates): bool
    {
        $rowPlates = $this->normalizePlates([
            $row['reg_1'] ?? '',
            $row['reg_2'] ?? '',
            $row['reg_3'] ?? '',
        ]);

        return $plates !== [] && array_intersect($plates, $rowPlates) !== [];
    }

    private function matchScore(string $reference, string $search): int
    {
        if ($search === '') {
            return 0;
        }

        $referenceKey = $this->lower($reference);
        $searchKey = $this->lower($search);

        if ($referenceKey === $searchKey) {
            return 1000;
        }

        if (str_starts_with($referenceKey, $searchKey)) {
            return 600;
        }

        if (str_contains($referenceKey, $searchKey)) {
            return 300;
        }

        return 0;
    }

    private function sourceScore(string $source): int
    {
        return match ($source) {
            'booking' => 30,
            'order' => 20,
            'vehicle' => 10,
            default => 0,
        };
    }

    private function sectionScore(string $section): int
    {
        return match ($section) {
            'this_vehicle' => 40,
            'other_customer_vehicle' => 20,
            default => 0,
        };
    }

    private function bestSection(string $left, string $right): string
    {
        return $this->sectionScore($right) > $this->sectionScore($left) ? $right : $left;
    }

    private function clampLimit(mixed $value): int
    {
        $limit = $this->toPositiveInt($value) ?? self::DEFAULT_LIMIT;
        return max(1, min($limit, self::MAX_LIMIT));
    }

    private function toPositiveInt(mixed $value): ?int
    {
        $parsed = filter_var($value, FILTER_VALIDATE_INT);
        return is_int($parsed) && $parsed > 0 ? $parsed : null;
    }

    private function normalizeText(mixed $value): string
    {
        return trim((string)($value ?? ''));
    }

    private function lower(string $value): string
    {
        return function_exists('mb_strtolower') ? mb_strtolower($value) : strtolower($value);
    }

    /**
     * @param array<int, mixed> $values
     * @return array<int, string>
     */
    private function normalizePlates(array $values): array
    {
        $plates = [];
        foreach ($values as $value) {
            $plate = strtoupper(preg_replace('/\s+/', '', (string)($value ?? '')));
            if ($plate !== '') {
                $plates[] = $plate;
            }
        }

        return array_values(array_unique($plates));
    }

    private function normalizeDate(mixed $value): ?string
    {
        $date = trim((string)($value ?? ''));
        return $date === '' || $date === '0000-00-00 00:00:00' ? null : $date;
    }

    private function maxDate(?string $left, ?string $right): ?string
    {
        if ($left === null) {
            return $right;
        }
        if ($right === null) {
            return $left;
        }

        return strcmp($right, $left) > 0 ? $right : $left;
    }
}
