<?php

namespace classes;

use Exception;
use objects\collected_order_invoices_o;
use objects\logs_o;
use objects\order_items_o;
use objects\orders_o;
use objects\products_o;
use objects\users_o;

class invoice_collection_bulk_action_service
{
    public const ACTION_CLEAN_CUSTOMER_RULES = 'remove_customer_rule_violations';
    public const ACTION_MERGE = 'merge_collections';
    public const ACTION_SPLIT_BY_MONTH = 'split_by_month';
    public const ACTION_RESET_HIDDEN_PRICES = 'reset_hidden_item_prices';
    public const ACTION_QUEUE_ECONOMIC = 'queue_economic';

    private const PREVIEW_TTL_SECONDS = 600;
    private const MAX_COLLECTIONS = 100;
    private const CONFIRMATION_PHRASES = [
        'da' => 'Bekræft',
        'en' => 'Confirm',
        'sv' => 'Bekräfta',
        'no' => 'Bekreft',
        'de' => 'Bestätigen',
    ];

    public function preview(string $action, array $invoiceCollectionIds, array $options = [], string $locale = 'da'): array
    {
        $action = $this->normalizeAction($action);
        $invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
        $options = $this->normalizeOptions($options);

        $preview = $this->buildPreview($action, $invoiceCollectionIds, $options, $locale);
        $previewId = $this->previewId();
        $preview['preview_id'] = $previewId;
        $preview['selection_hash'] = $this->selectionHash($action, $invoiceCollectionIds, $options);
        $preview['confirmation_phrase'] = $this->confirmationPhrase($locale);

        $this->cachePreview($previewId, [
            'action' => $action,
            'invoice_collection_ids' => $invoiceCollectionIds,
            'options' => $options,
            'locale' => $locale,
            'selection_hash' => $preview['selection_hash'],
            'preview' => $preview,
        ]);

        return $preview;
    }

    public function apply(
        string $previewId,
        string $action,
        array $invoiceCollectionIds,
        array $options,
        string $confirmationText,
        int $actorUserId,
        string $locale = 'da'
    ): array {
        global $db;

        $action = $this->normalizeAction($action);
        $invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
        $options = $this->normalizeOptions($options);
        $cached = $this->getCachedPreview($previewId);
        $selectionHash = $this->selectionHash($action, $invoiceCollectionIds, $options);

        if (!$cached || ($cached['selection_hash'] ?? '') !== $selectionHash) {
            throw new Exception('Preview is missing, expired, or no longer matches the selected invoice collections.');
        }

        $expectedConfirmation = (string)($cached['preview']['confirmation_phrase'] ?? $this->confirmationPhrase($locale));
        if (trim($confirmationText) !== $expectedConfirmation) {
            throw new Exception('Confirmation text does not match.');
        }

        $freshPreview = $this->buildPreview($action, $invoiceCollectionIds, $options, (string)($cached['locale'] ?? $locale));
        if (!empty($freshPreview['blockers'])) {
            throw new Exception('Action cannot be applied while blockers are present.');
        }

        $db->conn()->begin_transaction();
        try {
            $result = match ($action) {
                self::ACTION_CLEAN_CUSTOMER_RULES => $this->applyCleanCustomerRules($freshPreview),
                self::ACTION_MERGE => $this->applyMerge($freshPreview, $options),
                self::ACTION_SPLIT_BY_MONTH => $this->applySplitByMonth($freshPreview),
                self::ACTION_RESET_HIDDEN_PRICES => $this->applyResetHiddenPrices($freshPreview),
                self::ACTION_QUEUE_ECONOMIC => [
                    'queued_invoice_collection_ids' => $invoiceCollectionIds,
                    'changed_count' => count($invoiceCollectionIds),
                ],
                default => throw new Exception('Unsupported action'),
            };
            (new logs_o())->add(
                'orderInvoices',
                'global',
                1,
                $actorUserId,
                'APPLY_COLLECTED_INVOICE_BULK_ACTION',
                'Applied collected invoice bulk action ' . $action . ' to ' . count($invoiceCollectionIds) . ' invoice collections'
            );
            $db->conn()->commit();
        } catch (\Throwable $e) {
            $db->conn()->rollback();
            throw $e;
        }

        $this->deleteCachedPreview($previewId);

        return [
            ...$freshPreview,
            'preview' => false,
            'result' => $result,
        ];
    }

    private function buildPreview(string $action, array $invoiceCollectionIds, array $options, string $locale): array
    {
        $collections = $this->loadCollections($invoiceCollectionIds);
        $base = [
            'action' => $action,
            'preview' => true,
            'confirmation_phrase' => $this->confirmationPhrase($locale),
            'invoice_collection_ids' => $invoiceCollectionIds,
            'collections' => array_map(fn(collected_order_invoices_o $collection): array => $this->collectionSummary($collection), $collections),
            'warnings' => [],
            'blockers' => [],
        ];

        return match ($action) {
            self::ACTION_CLEAN_CUSTOMER_RULES => $this->previewCleanCustomerRules($base, $collections),
            self::ACTION_MERGE => $this->previewMerge($base, $collections, $options),
            self::ACTION_SPLIT_BY_MONTH => $this->previewSplitByMonth($base, $collections),
            self::ACTION_RESET_HIDDEN_PRICES => $this->previewResetHiddenPrices($base, $collections),
            self::ACTION_QUEUE_ECONOMIC => $this->previewQueueEconomic($base, $collections),
            default => throw new Exception('Unsupported action'),
        };
    }

    private function previewCleanCustomerRules(array $preview, array $collections): array
    {
        $items = [];
        $blockers = [];
        foreach ($collections as $collection) {
            $blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
            $rows = $this->orderItemRows((int)$collection->id);
            $violatingItemIds = [];
            $includedItemIds = [];
            foreach ($rows as $row) {
                $violation = (new customer_product_rule_service())->firstViolationForOrderItem(
                    (int)$row['order_id'],
                    (int)$row['product_id'],
                    empty($row['related_item_id']) ? null : (int)$row['related_item_id']
                );
                if ($violation === null) {
                    continue;
                }
                $violatingItemIds[] = (int)$row['order_item_id'];
                $includedItemIds[] = (int)$row['order_item_id'];
                $items[] = [
                    'invoice_collection_id' => (int)$collection->id,
                    'order_id' => (int)$row['order_id'],
                    'order_item_id' => (int)$row['order_item_id'],
                    'product_id' => (int)$row['product_id'],
                    'product_name' => (string)$row['product_name'],
                    'rule' => (string)$violation['rule'],
                    'price' => (int)$row['price'],
                    'quantity' => (int)$row['quantity'],
                    'will_soft_delete' => true,
                ];
            }

            foreach ($rows as $row) {
                $orderItemId = (int)$row['order_item_id'];
                $relatedItemId = empty($row['related_item_id']) ? null : (int)$row['related_item_id'];
                if ($relatedItemId === null || !in_array($relatedItemId, $violatingItemIds, true) || in_array($orderItemId, $includedItemIds, true)) {
                    continue;
                }
                $includedItemIds[] = $orderItemId;
                $items[] = [
                    'invoice_collection_id' => (int)$collection->id,
                    'order_id' => (int)$row['order_id'],
                    'order_item_id' => $orderItemId,
                    'product_id' => (int)$row['product_id'],
                    'product_name' => (string)$row['product_name'],
                    'rule' => 'related_to_removed_item',
                    'price' => (int)$row['price'],
                    'quantity' => (int)$row['quantity'],
                    'will_soft_delete' => true,
                ];
            }
        }

        return [
            ...$preview,
            'order_items' => $items,
            'summary' => [
                'collections' => count($collections),
                'order_items' => count($items),
                'changed_count' => count($items),
            ],
            'blockers' => $blockers,
        ];
    }

    private function previewMerge(array $preview, array $collections, array $options): array
    {
        $targetId = (int)($options['target_invoice_collection_id'] ?? 0);
        $blockers = [];
        if (count($collections) < 2) {
            $blockers[] = ['code' => 'merge_requires_multiple_collections', 'message' => 'Merge requires at least two invoice collections.'];
        }
        if ($targetId < 1 || !in_array($targetId, array_map(static fn($collection): int => (int)$collection->id, $collections), true)) {
            $blockers[] = ['code' => 'invalid_merge_target', 'message' => 'A selected invoice collection must be chosen as merge target.'];
        }
        $customerNumbers = array_values(array_unique(array_map(static fn($collection): int => (int)$collection->customer_number->value(), $collections)));
        if (count($customerNumbers) !== 1) {
            $blockers[] = ['code' => 'merge_cross_customer', 'message' => 'Only invoice collections for the same customer can be merged.'];
        }
        foreach ($collections as $collection) {
            $blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
        }

        $ordersToMove = [];
        foreach ($collections as $collection) {
            if ((int)$collection->id === $targetId) {
                continue;
            }
            foreach ($collection->getOrderIds() as $orderIdRow) {
                $ordersToMove[] = [
                    'order_id' => (int)$orderIdRow['id'],
                    'source_invoice_collection_id' => (int)$collection->id,
                    'target_invoice_collection_id' => $targetId,
                ];
            }
        }

        return [
            ...$preview,
            'target_invoice_collection_id' => $targetId,
            'orders' => $ordersToMove,
            'summary' => [
                'collections' => count($collections),
                'orders_to_move' => count($ordersToMove),
                'changed_count' => count($ordersToMove),
            ],
            'blockers' => $blockers,
        ];
    }

    private function previewSplitByMonth(array $preview, array $collections): array
    {
        $items = [];
        $changed = [];
        $skipped = [];
        foreach ($collections as $collection) {
            try {
                $item = [
                    'invoice_collection_id' => (int)$collection->id,
                    ...$collection->previewSplitByOrderMonth(),
                ];
                if (($item['status'] ?? '') === 'changed') {
                    $changed[] = $item;
                } else {
                    $skipped[] = $item;
                }
                $items[] = $item;
            } catch (\Throwable $e) {
                $item = [
                    'status' => 'skipped',
                    'invoice_collection_id' => (int)$collection->id,
                    'reason' => 'not_splittable',
                    'message' => $e->getMessage(),
                ];
                $skipped[] = $item;
                $items[] = $item;
            }
        }

        return [
            ...$preview,
            'items' => $items,
            'changed' => $changed,
            'skipped' => $skipped,
            'summary' => [
                'collections' => count($collections),
                'changed_count' => count($changed),
                'skipped_count' => count($skipped),
            ],
        ];
    }

    private function previewResetHiddenPrices(array $preview, array $collections): array
    {
        $items = [];
        $blockers = [];
        foreach ($collections as $collection) {
            $blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
            foreach ($this->orderItemRows((int)$collection->id, true) as $row) {
                if ((int)$row['include_in_invoice'] !== 0) {
                    continue;
                }
                $order = (new orders_o())->select((int)$row['order_id']);
                $product = (new products_o())->select((int)$row['product_id']);
                if (!$order->exists() || !$product->exists()) {
                    continue;
                }
                $newPrice = (int)$order->getCustomerProductPrice($product);
                if ((int)$row['price'] === $newPrice) {
                    continue;
                }
                $items[] = [
                    'invoice_collection_id' => (int)$collection->id,
                    'order_id' => (int)$row['order_id'],
                    'order_item_id' => (int)$row['order_item_id'],
                    'product_id' => (int)$row['product_id'],
                    'product_name' => (string)$row['product_name'],
                    'current_price' => (int)$row['price'],
                    'new_price' => $newPrice,
                ];
            }
        }

        return [
            ...$preview,
            'order_items' => $items,
            'summary' => [
                'collections' => count($collections),
                'order_items' => count($items),
                'changed_count' => count($items),
            ],
            'blockers' => $blockers,
        ];
    }

    private function previewQueueEconomic(array $preview, array $collections): array
    {
        $blockers = [];
        foreach ($collections as $collection) {
            if (!empty($collection->booked_invoice_id->value())) {
                $blockers[] = [
                    'code' => 'collection_booked',
                    'invoice_collection_id' => (int)$collection->id,
                    'message' => 'Invoice collection is already booked.',
                ];
            }
        }
        return [
            ...$preview,
            'summary' => [
                'collections' => count($collections),
                'changed_count' => count($collections),
            ],
            'blockers' => $blockers,
        ];
    }

    private function applyCleanCustomerRules(array $preview): array
    {
        global $db;
        $itemIds = array_values(array_unique(array_map(static fn(array $item): int => (int)$item['order_item_id'], $preview['order_items'] ?? [])));
        if ($itemIds === []) {
            return ['changed_count' => 0, 'order_item_ids' => []];
        }
        $ids = implode(',', array_map('intval', $itemIds));
        $now = date('Y-m-d H:i:s');
        $safeNow = $db->escape_string($now);
        $db->query("UPDATE order_items SET deleted_at = '$safeNow' WHERE deleted_at IS NULL AND id IN ($ids)");
        $this->touchOrdersForItems($itemIds);
        $this->touchCollections($preview['invoice_collection_ids'] ?? []);
        return ['changed_count' => count($itemIds), 'order_item_ids' => $itemIds];
    }

    private function applyMerge(array $preview, array $options): array
    {
        $targetId = (int)($options['target_invoice_collection_id'] ?? 0);
        $moved = [];
        foreach ($preview['orders'] ?? [] as $row) {
            $order = (new orders_o())->select((int)$row['order_id']);
            if (!$order->exists()) {
                continue;
            }
            $order->assignToInvoiceCollection($targetId);
            $moved[] = (int)$order->id;
        }
        $this->touchCollections($preview['invoice_collection_ids'] ?? []);
        return ['changed_count' => count($moved), 'moved_order_ids' => $moved, 'target_invoice_collection_id' => $targetId];
    }

    private function applySplitByMonth(array $preview): array
    {
        $changed = [];
        $skipped = [];
        foreach ($preview['items'] ?? [] as $item) {
            $invoiceCollectionId = (int)($item['invoice_collection_id'] ?? 0);
            if (($item['status'] ?? '') !== 'changed' || $invoiceCollectionId < 1) {
                $skipped[] = $item;
                continue;
            }
            try {
                $changed[] = (new collected_order_invoices_o())->select($invoiceCollectionId)->splitByOrderMonth();
            } catch (\Throwable $e) {
                $skipped[] = [
                    'invoice_collection_id' => $invoiceCollectionId,
                    'status' => 'skipped',
                    'message' => $e->getMessage(),
                ];
            }
        }
        return ['changed_count' => count($changed), 'skipped_count' => count($skipped), 'changed' => $changed, 'skipped' => $skipped];
    }

    private function applyResetHiddenPrices(array $preview): array
    {
        $changed = [];
        foreach ($preview['order_items'] ?? [] as $item) {
            $orderItem = (new order_items_o())->select((int)$item['order_item_id']);
            if (!$orderItem->exists()) {
                continue;
            }
            $orderItem->price->set((int)$item['new_price']);
            $orderItem->objectChanged();
            $changed[] = (int)$orderItem->id;
        }
        $this->touchCollections($preview['invoice_collection_ids'] ?? []);
        return ['changed_count' => count($changed), 'order_item_ids' => $changed];
    }

    private function contentMutationBlockers(collected_order_invoices_o $collection): array
    {
        $blockers = [];
        if (!empty($collection->booked_invoice_id->value())) {
            $blockers[] = ['code' => 'collection_booked', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection is already booked.'];
        }
        if (!empty($collection->external_id->value())) {
            $blockers[] = ['code' => 'collection_exported', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection already has an external invoice reference.'];
        }
        return $blockers;
    }

    private function orderItemRows(int $invoiceCollectionId, bool $includeHidden = false): array
    {
        global $db;
        $hiddenCondition = $includeHidden ? '' : 'AND oi.include_in_invoice = 1';
        $sql = "
            SELECT
                oi.id AS order_item_id,
                oi.order_id,
                oi.product_id,
                oi.related_item_id,
                oi.include_in_invoice,
                oi.price,
                oi.quantity,
                p.name AS product_name
            FROM order_items oi
            JOIN orders o ON o.id = oi.order_id
            JOIN products p ON p.id = oi.product_id
            WHERE o.invoice_collection_id = {$invoiceCollectionId}
              AND o.deleted_at IS NULL
              AND oi.deleted_at IS NULL
              {$hiddenCondition}
            ORDER BY o.id ASC, oi.id ASC
        ";
        $result = $db->query($sql);
        return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
    }

    private function touchOrdersForItems(array $itemIds): void
    {
        global $db;
        if ($itemIds === []) {
            return;
        }
        $ids = implode(',', array_map('intval', $itemIds));
        $result = $db->query("SELECT DISTINCT order_id FROM order_items WHERE id IN ($ids)");
        if (!$result) {
            return;
        }
        while ($row = $result->fetch_assoc()) {
            $order = (new orders_o())->select((int)$row['order_id']);
            if ($order->exists()) {
                $order->objectChanged();
            }
        }
    }

    private function touchCollections(array $invoiceCollectionIds): void
    {
        foreach ($invoiceCollectionIds as $invoiceCollectionId) {
            $collection = (new collected_order_invoices_o())->select((int)$invoiceCollectionId);
            if ($collection->exists()) {
                $collection->objectChanged();
            }
        }
    }

    private function loadCollections(array $invoiceCollectionIds): array
    {
        return array_map(static function (int $invoiceCollectionId): collected_order_invoices_o {
            $collection = (new collected_order_invoices_o())->select($invoiceCollectionId);
            $collection->requireSelected();
            return $collection;
        }, $invoiceCollectionIds);
    }

    private function collectionSummary(collected_order_invoices_o $collection): array
    {
        return [
            'id' => (int)$collection->id,
            'customer_number' => (int)$collection->customer_number->value(),
            'name' => (string)$collection->name->value(),
            'created_at' => (string)$collection->created_at->value(),
            'closed_at' => $collection->closed_at->value(),
            'booked_invoice_id' => $collection->booked_invoice_id->value(),
            'external_id' => $collection->external_id->value(),
            'order_count' => (int)$collection->getOrders(true),
        ];
    }

    private function normalizeAction(string $action): string
    {
        $action = trim($action);
        if (!in_array($action, [
            self::ACTION_CLEAN_CUSTOMER_RULES,
            self::ACTION_MERGE,
            self::ACTION_SPLIT_BY_MONTH,
            self::ACTION_RESET_HIDDEN_PRICES,
            self::ACTION_QUEUE_ECONOMIC,
        ], true)) {
            throw new Exception('Invalid invoice collection bulk action.');
        }
        return $action;
    }

    private function normalizeInvoiceCollectionIds(array $ids): array
    {
        $normalized = [];
        foreach ($ids as $id) {
            if (is_array($id) || is_object($id) || !is_numeric($id)) {
                throw new Exception('invoice_collection_ids must contain only positive integer ids.');
            }
            $parsed = (int)$id;
            if ($parsed < 1 || $parsed > 999999999) {
                throw new Exception('invoice_collection_ids must contain only positive integer ids.');
            }
            $normalized[$parsed] = $parsed;
        }
        $normalized = array_values($normalized);
        sort($normalized);
        if ($normalized === []) {
            throw new Exception('invoice_collection_ids must contain at least one id.');
        }
        if (count($normalized) > self::MAX_COLLECTIONS) {
            throw new Exception('Too many invoice collections selected.');
        }
        return $normalized;
    }

    private function normalizeOptions(array $options): array
    {
        if (isset($options['target_invoice_collection_id'])) {
            $options['target_invoice_collection_id'] = (int)$options['target_invoice_collection_id'];
        }
        ksort($options);
        return $options;
    }

    private function selectionHash(string $action, array $invoiceCollectionIds, array $options): string
    {
        return hash('sha256', json_encode([
            'action' => $action,
            'invoice_collection_ids' => $invoiceCollectionIds,
            'options' => $options,
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
    }

    private function confirmationPhrase(string $locale): string
    {
        $language = strtolower(substr(trim($locale), 0, 2));
        return self::CONFIRMATION_PHRASES[$language] ?? self::CONFIRMATION_PHRASES['en'];
    }

    private function previewId(): string
    {
        return bin2hex(random_bytes(16));
    }

    private function previewCacheKey(string $previewId): string
    {
        return 'collected_invoice_bulk_action_preview:' . preg_replace('/[^a-f0-9]/', '', strtolower($previewId));
    }

    private function cachePreview(string $previewId, array $payload): void
    {
        (new redis())->setEx($this->previewCacheKey($previewId), json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), self::PREVIEW_TTL_SECONDS);
    }

    private function getCachedPreview(string $previewId): ?array
    {
        $raw = (new redis())->get($this->previewCacheKey($previewId));
        if (!$raw) {
            return null;
        }
        $decoded = json_decode($raw, true);
        return is_array($decoded) ? $decoded : null;
    }

    private function deleteCachedPreview(string $previewId): void
    {
        (new redis())->delete($this->previewCacheKey($previewId));
    }
}
