<?php

namespace classes;

use RuntimeException;
use Throwable;

class customer_rule_product_restriction_exception extends RuntimeException
{
    public function __construct(string $message, private readonly int $httpStatus = 422, string $code = 'INVALID_CUSTOMER_RULE_CONFIGURATION')
    {
        parent::__construct($message);
        $this->restrictionCode = $code;
    }

    private string $restrictionCode;

    public function httpStatus(): int
    {
        return $this->httpStatus;
    }

    public function restrictionCode(): string
    {
        return $this->restrictionCode;
    }
}

/**
 * Source of truth for globally configured customer-rule product collections.
 */
class customer_rule_product_restriction_service
{
    /** @var list<string> */
    public const PRODUCT_IMPACT_ATTRIBUTES = [
        'restrictAdditionalServices',
        'restrictTankCleaning',
        'restrictSpotFree',
        'restrictInteriorCleaning',
        'onlyTankCleaning',
    ];

    /** @var list<string> */
    public const SUPPORTED_ATTRIBUTES = [
        'restrictAdditionalServices',
        'restrictTankCleaning',
        'restrictSpotFree',
        'restrictInteriorCleaning',
        'onlyTankCleaning',
        'requiresReferenceNumber',
        'requiresRegistrationNumbersInvoice',
        'invoiceAllOrdersIndividually',
        'invoiceWithStripe',
        'showPricesOnBookingPage',
        'usePONumbers',
        'exemptFromAdministrationFee',
    ];

    public function __construct()
    {
        customer_rule_product_restriction_schema_bootstrap::ensureSchema();
    }

    /** @return array{rules:list<array<string,mixed>>,products:list<array<string,mixed>>} */
    public function listConfiguration(): array
    {
        return [
            'rules' => array_map(fn(string $attribute): array => $this->ruleConfiguration($attribute), self::PRODUCT_IMPACT_ATTRIBUTES),
            'products' => $this->productCatalog(),
        ];
    }

    /** @return array<string,mixed> */
    public function ruleConfiguration(string $attribute): array
    {
        $this->assertSupportedAttribute($attribute);
        global $db;

        $safeAttribute = $this->escape($attribute);
        $versionResult = $db->query(
            "SELECT version FROM customer_rule_product_restrictions WHERE attribute = '{$safeAttribute}' LIMIT 1"
        );
        if (!$versionResult || $versionResult->num_rows < 1) {
            throw new RuntimeException("Unable to load customer-rule restriction version for {$attribute}");
        }
        $versionRow = $versionResult->fetch_assoc();

        $result = $db->query(
            "SELECT c.id AS collection_id, c.name, c.sort_order, cp.product_id
             FROM customer_rule_product_collections c
             LEFT JOIN customer_rule_product_collection_products cp ON cp.collection_id = c.id
             WHERE c.attribute = '{$safeAttribute}'
             ORDER BY c.sort_order ASC, c.id ASC, cp.product_id ASC"
        );

        if (!$result) {
            throw new RuntimeException("Unable to load customer-rule restriction collections for {$attribute}");
        }

        $collections = [];
        $disabled = [];
        while ($row = $result->fetch_assoc()) {
            $collectionId = (int)$row['collection_id'];
            if (!isset($collections[$collectionId])) {
                $collections[$collectionId] = [
                    'id' => $collectionId,
                    'name' => (string)$row['name'],
                    'sort_order' => (int)$row['sort_order'],
                    'product_ids' => [],
                ];
            }
            if ($row['product_id'] !== null) {
                $productId = (int)$row['product_id'];
                $collections[$collectionId]['product_ids'][] = $productId;
                $disabled[$productId] = true;
            }
        }

        return [
            'attribute' => $attribute,
            'version' => max(1, (int)($versionRow['version'] ?? 1)),
            'collections' => array_values($collections),
            'disabled_product_ids' => array_map('intval', array_keys($disabled)),
        ];
    }

    /**
     * @param array<string,mixed> $payload
     * @return array<string,mixed>
     */
    public function replaceRuleConfiguration(string $attribute, array $payload, int $actorUserId): array
    {
        $this->assertSupportedAttribute($attribute);
        $expectedVersion = $this->positiveInt($payload['version'] ?? null, 'version');
        $collections = $this->validateCollections($attribute, $payload['collections'] ?? null);

        global $db;
        $safeAttribute = $this->escape($attribute);
        if ($db->query('START TRANSACTION') === false) {
            throw new customer_rule_product_restriction_exception('Unable to start configuration transaction', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
        }
        try {
            $versionResult = $db->query(
                "SELECT version FROM customer_rule_product_restrictions
                 WHERE attribute = '{$safeAttribute}' FOR UPDATE"
            );
            if (!$versionResult || $versionResult->num_rows < 1) {
                throw new customer_rule_product_restriction_exception('Customer rule configuration was not found', 404, 'CUSTOMER_RULE_CONFIGURATION_NOT_FOUND');
            }
            $versionRow = $versionResult->fetch_assoc();
            $currentVersion = (int)$versionRow['version'];
            if ($currentVersion !== $expectedVersion) {
                throw new customer_rule_product_restriction_exception(
                    'Customer rule configuration has changed; reload before saving',
                    409,
                    'CUSTOMER_RULE_CONFIGURATION_CONFLICT'
                );
            }

            $old = $this->ruleConfiguration($attribute);
            $existingIds = $this->existingCollectionIds($attribute);
            foreach ($collections as $collection) {
                if ($collection['id'] !== null && !isset($existingIds[$collection['id']])) {
                    throw new customer_rule_product_restriction_exception('A collection does not belong to this customer rule');
                }
            }

            // Avoid temporary unique-name collisions while two collections swap names.
            foreach ($existingIds as $collectionId => $_) {
                $temporaryName = $this->escape('__pending_' . $collectionId . '_' . bin2hex(random_bytes(6)));
                if ($db->query(
                    "UPDATE customer_rule_product_collections
                     SET name = '{$temporaryName}'
                     WHERE id = {$collectionId} AND attribute = '{$safeAttribute}'"
                ) === false) {
                    throw new customer_rule_product_restriction_exception('Unable to prepare collection update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
                }
            }

            $keptIds = [];
            foreach ($collections as $collection) {
                $name = $this->escape($collection['name']);
                $sortOrder = (int)$collection['sort_order'];
                $collectionId = $collection['id'];
                if ($collectionId === null) {
                    if ($db->query(
                        "INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
                         VALUES ('{$safeAttribute}', '{$name}', {$sortOrder})"
                    ) === false) {
                        throw new customer_rule_product_restriction_exception('Unable to create collection');
                    }
                    $collectionId = (int)$db->insert_id();
                } else {
                    if ($db->query(
                        "UPDATE customer_rule_product_collections
                         SET name = '{$name}', sort_order = {$sortOrder}
                         WHERE id = {$collectionId} AND attribute = '{$safeAttribute}'"
                    ) === false) {
                        throw new customer_rule_product_restriction_exception('Unable to update collection');
                    }
                }

                $keptIds[$collectionId] = true;
                if ($db->query("DELETE FROM customer_rule_product_collection_products WHERE collection_id = {$collectionId}") === false) {
                    throw new customer_rule_product_restriction_exception('Unable to replace collection products', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
                }
                foreach ($collection['product_ids'] as $productId) {
                    if ($db->query(
                        "INSERT INTO customer_rule_product_collection_products (collection_id, product_id)
                         VALUES ({$collectionId}, {$productId})"
                    ) === false) {
                        throw new customer_rule_product_restriction_exception('Unable to save collection products');
                    }
                }
            }

            $removeIds = array_values(array_diff(array_keys($existingIds), array_keys($keptIds)));
            if ($removeIds !== []) {
                if ($db->query(
                    'DELETE FROM customer_rule_product_collections WHERE attribute = \'' . $safeAttribute . '\' AND id IN (' .
                    implode(',', array_map('intval', $removeIds)) . ')'
                ) === false) {
                    throw new customer_rule_product_restriction_exception('Unable to remove collections', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
                }
            }

            $newVersion = $currentVersion + 1;
            if ($db->query(
                "UPDATE customer_rule_product_restrictions
                 SET version = {$newVersion}, updated_at = NOW()
                 WHERE attribute = '{$safeAttribute}'"
            ) === false) {
                throw new customer_rule_product_restriction_exception('Unable to update configuration version', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
            }
            $new = $this->ruleConfiguration($attribute);
            $changes = $this->escape((string)json_encode([
                'before' => $old,
                'after' => $new,
            ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
            if ($db->query(
                "INSERT INTO customer_rule_product_audit_logs
                    (actor_user_id, attribute, old_version, new_version, changes_json)
                 VALUES ({$actorUserId}, '{$safeAttribute}', {$currentVersion}, {$newVersion}, '{$changes}')"
            ) === false) {
                throw new customer_rule_product_restriction_exception('Unable to audit configuration update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
            }
            if ($db->query('COMMIT') === false) {
                throw new customer_rule_product_restriction_exception('Unable to commit configuration update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
            }
            return $new;
        } catch (Throwable $throwable) {
            $db->query('ROLLBACK');
            throw $throwable;
        }
    }

    /**
     * Return configured product restrictions for all active product-impact
     * attributes belonging to any account with the customer number.
     *
     * @return list<array<string,mixed>>
     */
    public function restrictionsForCustomerNumber(int $customerNumber): array
    {
        if ($customerNumber < 1) {
            return [];
        }

        global $db;
        $result = $db->query(
            "SELECT DISTINCT ca.attribute
             FROM users u
             INNER JOIN customer_attributes ca ON ca.user_id = u.id
             WHERE u.customer_number = {$customerNumber}"
        );
        if (!$result) {
            throw new RuntimeException('Unable to load active customer-rule product restrictions');
        }
        $activeAttributes = [];
        while ($row = $result->fetch_assoc()) {
            $attribute = (string)$row['attribute'];
            if (in_array($attribute, self::PRODUCT_IMPACT_ATTRIBUTES, true)) {
                $activeAttributes[$attribute] = true;
            }
        }

        $active = [];
        foreach (self::PRODUCT_IMPACT_ATTRIBUTES as $attribute) {
            if (isset($activeAttributes[$attribute])) {
                $active[] = $this->ruleConfiguration($attribute);
            }
        }
        return $active;
    }

    /** @return array{rules:list<string>,collections:list<int>,message:string,code:string,product_id:int}|null */
    public function violationForCustomerProduct(int $customerNumber, int $productId): ?array
    {
        if ($productId < 1) {
            return null;
        }
        $rules = [];
        $collections = [];
        foreach ($this->restrictionsForCustomerNumber($customerNumber) as $restriction) {
            if (!in_array($productId, $restriction['disabled_product_ids'], true)) {
                continue;
            }
            $rules[] = (string)$restriction['attribute'];
            foreach ($restriction['collections'] as $collection) {
                if (in_array($productId, $collection['product_ids'], true)) {
                    $collections[] = (int)$collection['id'];
                }
            }
        }
        if ($rules === []) {
            return null;
        }
        return [
            'code' => 'CUSTOMER_RULE_PRODUCT_RESTRICTED',
            'message' => customer_product_rule_service::BLOCK_MESSAGE,
            'product_id' => $productId,
            'rules' => array_values(array_unique($rules)),
            'collections' => array_values(array_unique($collections)),
        ];
    }

    /**
     * @param list<array<string,mixed>> $attributes
     * @return list<array<string,mixed>>
     */
    public function enrichAttributes(int $customerNumber, array $attributes): array
    {
        $restrictions = [];
        foreach ($this->restrictionsForCustomerNumber($customerNumber) as $restriction) {
            $restrictions[(string)$restriction['attribute']] = [
                'attribute' => (string)$restriction['attribute'],
                'version' => (int)$restriction['version'],
                'collections' => $restriction['collections'],
                'disabled_product_ids' => $restriction['disabled_product_ids'],
            ];
        }

        foreach ($attributes as &$attribute) {
            $key = (string)($attribute['attribute'] ?? '');
            $attribute['product_restriction'] = $restrictions[$key] ?? null;
        }
        unset($attribute);
        return $attributes;
    }

    /** @return list<array<string,mixed>> */
    private function productCatalog(): array
    {
        global $db;
        $activeExpression = $this->columnExists('products', 'deleted_at')
            ? 'CASE WHEN p.deleted_at IS NULL THEN 1 ELSE 0 END'
            : '1';
        $result = $db->query(
            "SELECT p.id, p.name, p.category AS category_id, c.name AS category_name,
                    {$activeExpression} AS active
             FROM products p
             LEFT JOIN categories c ON c.id = p.category
             ORDER BY c.name ASC, p.name ASC, p.id ASC"
        );
        if (!$result) {
            throw new RuntimeException('Unable to load the customer-rule product catalog');
        }
        $products = [];
        while ($row = $result->fetch_assoc()) {
            $products[] = [
                'id' => (int)$row['id'],
                'name' => (string)$row['name'],
                'category_id' => (int)$row['category_id'],
                'category_name' => (string)($row['category_name'] ?? ''),
                'active' => (bool)$row['active'],
            ];
        }
        return $products;
    }

    /**
     * @return array<int,true>
     */
    private function existingCollectionIds(string $attribute): array
    {
        global $db;
        $safeAttribute = $this->escape($attribute);
        $result = $db->query("SELECT id FROM customer_rule_product_collections WHERE attribute = '{$safeAttribute}'");
        if (!$result) {
            throw new RuntimeException("Unable to load existing collections for {$attribute}");
        }
        $ids = [];
        while ($row = $result->fetch_assoc()) {
            $ids[(int)$row['id']] = true;
        }
        return $ids;
    }

    /** @return list<array{id: ?int, name: string, sort_order: int, product_ids: list<int>}> */
    private function validateCollections(string $attribute, mixed $value): array
    {
        if (!is_array($value)) {
            throw new customer_rule_product_restriction_exception('collections must be an array');
        }
        $normalized = [];
        $names = [];
        $collectionIds = [];
        $allProductIds = [];
        foreach (array_values($value) as $index => $collection) {
            if (!is_array($collection)) {
                throw new customer_rule_product_restriction_exception("Collection {$index} must be an object");
            }
            $name = trim((string)($collection['name'] ?? ''));
            if ($name === '' || mb_strlen($name) > 191) {
                throw new customer_rule_product_restriction_exception('Collection names must be between 1 and 191 characters');
            }
            $nameKey = mb_strtolower($name);
            if (isset($names[$nameKey])) {
                throw new customer_rule_product_restriction_exception('Collection names must be unique within a rule');
            }
            $names[$nameKey] = true;
            if (!isset($collection['product_ids']) || !is_array($collection['product_ids'])) {
                throw new customer_rule_product_restriction_exception('product_ids must be an array');
            }
            $productIds = [];
            foreach ($collection['product_ids'] as $productId) {
                $id = $this->positiveInt($productId, 'product_id');
                $productIds[$id] = true;
                $allProductIds[$id] = true;
            }
            $id = isset($collection['id']) && $collection['id'] !== null
                ? $this->positiveInt($collection['id'], 'collection id')
                : null;
            if ($id !== null && isset($collectionIds[$id])) {
                throw new customer_rule_product_restriction_exception('Collection IDs must be unique within a rule');
            }
            if ($id !== null) {
                $collectionIds[$id] = true;
            }
            $normalized[] = [
                'id' => $id,
                'name' => $name,
                'sort_order' => isset($collection['sort_order']) && is_numeric($collection['sort_order'])
                    ? (int)$collection['sort_order']
                    : $index,
                'product_ids' => array_map('intval', array_keys($productIds)),
            ];
        }

        $this->assertProductsExist(array_map('intval', array_keys($allProductIds)));
        return $normalized;
    }

    /** @param list<int> $productIds */
    private function assertProductsExist(array $productIds): void
    {
        if ($productIds === []) {
            return;
        }
        global $db;
        $result = $db->query('SELECT id FROM products WHERE id IN (' . implode(',', $productIds) . ')');
        if (!$result) {
            throw new customer_rule_product_restriction_exception(
                'Unable to validate collection products',
                500,
                'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED'
            );
        }
        $found = [];
        while ($row = $result->fetch_assoc()) {
            $found[(int)$row['id']] = true;
        }
        $missing = array_values(array_diff($productIds, array_keys($found)));
        if ($missing !== []) {
            throw new customer_rule_product_restriction_exception('Unknown product IDs: ' . implode(', ', $missing));
        }
    }

    private function assertSupportedAttribute(string $attribute): void
    {
        if (!in_array($attribute, self::PRODUCT_IMPACT_ATTRIBUTES, true)) {
            throw new customer_rule_product_restriction_exception('Unsupported product-impact customer rule');
        }
    }

    private function positiveInt(mixed $value, string $field): int
    {
        if (!is_numeric($value) || (int)$value < 1 || (string)(int)$value !== trim((string)$value)) {
            throw new customer_rule_product_restriction_exception("{$field} must be a positive integer");
        }
        return (int)$value;
    }

    private function escape(string $value): string
    {
        global $db;
        return method_exists($db, 'escape_string') ? $db->escape_string($value) : addslashes($value);
    }

    private function columnExists(string $table, string $column): bool
    {
        global $db;
        $safeTable = str_replace('`', '', $table);
        $safeColumn = $this->escape($column);
        $result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
        return $result && (int)$result->num_rows > 0;
    }
}
