<?php

namespace App\Livewire;

use App\Models\User;
use App\Services\FinanceRepository;
use App\Services\NotificationService;
use App\Services\PreferencesStore;
use App\Support\AppLocale;
use App\Support\CategoryCatalog;
use App\Support\CsvStatementParser;
use App\Support\DescriptionAutocomplete;
use App\Support\Money;
use App\Support\RelativeDayLabel;
use App\Support\TransactionInference;
use Carbon\Carbon;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;

#[Layout('layouts.app')]
class TransactionsPage extends Component
{
    use WithFileUploads;

    /** @var list<array<string,mixed>>|null */
    private ?array $inferenceHistoryCache = null;

    /** @var list<string>|null */
    private ?array $transactionDescriptionHistoryCache = null;

    #[Url]
    public ?string $create = null;

    #[Url(as: 'search')]
    public ?string $openSheet = null;

    public string $search = '';

    public bool $mobileSearchOpen = false;

    public bool $showAdd = false;

    public bool $showImport = false;

    /** @var 'idle'|'preview' */
    public string $importStage = 'idle';

    /** @var array{name: string, format: string, size: int}|null */
    public ?array $importFileMeta = null;

    /** @var list<array<string, mixed>> */
    public array $importRows = [];

    public ?TemporaryUploadedFile $importUpload = null;

    public ?string $selectedTxnId = null;

    public bool $detailEditing = false;

    public string $addName = '';

    public ?float $addAmount = null;

    public string $addDate = '';

    public string $addCategoryId = '';

    public string $addType = 'expense';

    public bool $addIsRecurring = false;

    public bool $addRecurringOpen = false;

    public string $addRecurringFrequency = 'monthly';

    public string $addRecurringStartDate = '';

    /** @var list<array{id: string, label: string, detail?: string, tone?: string}> */
    public array $addSmartSuggestions = [];

    /** @var list<array{value: string, hint?: string}> */
    public array $addDescriptionHints = [];

    public bool $addManualCategory = false;

    public bool $addManualType = false;

    public bool $addManualRecurring = false;

    /** True when the user changed "Repeats from" away from tracking the main transaction date. */
    public bool $addManualRecurringStartDate = false;

    public string $dName = '';

    public ?float $dAmount = null;

    public string $dDate = '';

    public string $dType = 'expense';

    public string $dCategoryId = '';

    public string $dNotes = '';

    public bool $dIsRecurring = false;

    public bool $detailRecurringOpen = false;

    public string $dRecurringFrequency = 'monthly';

    public string $dRecurringStartDate = '';

    /** @var list<array{id: string, label: string, detail?: string, tone?: string}> */
    public array $dSmartSuggestions = [];

    /** @var list<array{value: string, hint?: string}> */
    public array $dDescriptionHints = [];

    public bool $dManualCategory = false;

    public bool $dManualRecurring = false;

    public function mount(): void
    {
        if ($this->create === 'transaction') {
            $this->showAdd = true;
            $this->create = null;
            $this->resetAddForm();
        }
        if ($this->openSheet === '1') {
            $this->mobileSearchOpen = true;
            $this->openSheet = null;
        }
        if (! $this->showAdd) {
            $this->addDate = date('Y-m-d');
        }
    }

    public function openAdd(): void
    {
        $this->showAdd = true;
        $this->resetAddForm();
    }

    public function closeAdd(): void
    {
        $this->showAdd = false;
        $this->addRecurringOpen = false;
    }

    public function openImportModal(): void
    {
        $this->showImport = true;
        $this->resetImportFlow();
    }

    public function closeImportModal(): void
    {
        $this->showImport = false;
        $this->resetImportFlow();
    }

    protected function resetImportFlow(): void
    {
        $this->importStage = 'idle';
        $this->importRows = [];
        $this->importFileMeta = null;
        $this->importUpload = null;
    }

    /** @return array<string, true> */
    protected function existingFingerprints(): array
    {
        $prefs = PreferencesStore::all();
        $rowsForFp = [];
        foreach (app(FinanceRepository::class)->transactions() as $t) {
            $rowsForFp[] = [
                'date' => (string) ($t['date'] ?? ''),
                'amount' => (float) ($t['amount'] ?? 0),
                'name' => (string) ($t['name'] ?? ''),
            ];
        }
        foreach ($prefs['importedTransactions'] ?? [] as $t) {
            if (! is_array($t)) {
                continue;
            }
            $rowsForFp[] = [
                'date' => (string) ($t['date'] ?? ''),
                'amount' => (float) ($t['amount'] ?? 0),
                'name' => (string) ($t['name'] ?? ''),
            ];
        }

        return CsvStatementParser::buildFingerprintsFromRows($rowsForFp);
    }

    public function updatedImportUpload(?TemporaryUploadedFile $upload): void
    {
        if (! $upload) {
            return;
        }

        $this->resetValidation();
        try {
            $this->validate([
                'importUpload' => 'required|file|max:10240',
            ]);
        } catch (ValidationException) {
            $this->importUpload = null;
            session()->flash('toast', (string) __('budgets.import_page.toast_unsupported_file_10mb'));

            return;
        }

        $this->runImportParse($upload);
        $this->importUpload = null;
    }

    protected function runImportParse(TemporaryUploadedFile $file): void
    {
        $name = $file->getClientOriginalName();
        $format = $this->detectImportFormat($name, (string) $file->getMimeType());
        if ($format === 'unknown') {
            session()->flash('toast', (string) __('budgets.import_page.toast_unsupported_file'));

            return;
        }

        $this->importFileMeta = [
            'name' => $name,
            'format' => $format,
            'size' => (int) $file->getSize(),
        ];

        $fingerprints = $this->existingFingerprints();
        if ($format !== 'csv') {
            session()->flash('toast', (string) __('budgets.import_page.toast_unsupported_file'));

            return;
        }

        $parsed = CsvStatementParser::parseCsv($file->get(), $fingerprints);
        if ($parsed === []) {
            session()->flash('toast', (string) __('budgets.import_page.toast_no_transactions'));

            return;
        }
        $this->importRows = $parsed;

        $this->importStage = 'preview';
    }

    private function detectImportFormat(string $filename, string $mime): string
    {
        $lower = mb_strtolower($filename);
        if (str_ends_with($lower, '.csv') || $mime === 'text/csv' || $mime === 'application/csv' || $mime === 'text/plain') {
            return 'csv';
        }
        return 'unknown';
    }

    public function toggleImportRow(string $id): void
    {
        $this->importRows = array_map(function (array $r) use ($id) {
            if (($r['id'] ?? '') === $id) {
                $r['selected'] = ! (bool) ($r['selected'] ?? false);
            }

            return $r;
        }, $this->importRows);
    }

    public function updateImportRowCategory(string $id, string $categoryId): void
    {
        $this->importRows = array_map(function (array $r) use ($id, $categoryId) {
            if (($r['id'] ?? '') === $id) {
                $r['categoryId'] = $categoryId;
                $r['confidence'] = 100;
            }

            return $r;
        }, $this->importRows);
    }

    public function removeImportRow(string $id): void
    {
        $this->importRows = array_values(array_filter($this->importRows, fn (array $r) => ($r['id'] ?? '') !== $id));
    }

    public function resetImportSelection(): void
    {
        $this->importRows = array_map(function (array $r) {
            $r['selected'] = empty($r['duplicate']);

            return $r;
        }, $this->importRows);
    }

    /** @return array{count: int, duplicates: int} */
    public function importStats(): array
    {
        return [
            'count' => count(array_filter($this->importRows, fn (array $r) => ! empty($r['selected']))),
            'duplicates' => count(array_filter($this->importRows, fn (array $r) => ! empty($r['duplicate']))),
        ];
    }

    public function importSelected(FinanceRepository $finance, NotificationService $notifications): void
    {
        $toImport = array_values(array_filter($this->importRows, fn (array $r) => ! empty($r['selected'])));
        if ($toImport === []) {
            session()->flash('toast', (string) __('budgets.import_page.toast_nothing_selected'));

            return;
        }

        $source = $this->importFileMeta['format'] ?? 'csv';
        $importedAt = (new \DateTimeImmutable('now'))->format(\DateTimeInterface::ATOM);

        $newTransactions = [];
        $importSeq = (int) round(microtime(true) * 1000);
        foreach ($toImport as $i => $r) {
            $newTransactions[] = [
                'id' => (string) ($r['id'] ?? uniqid('imp-', true)),
                'name' => (string) ($r['name'] ?? ''),
                'amount' => (float) ($r['amount'] ?? 0),
                'date' => (string) ($r['date'] ?? ''),
                'type' => (string) ($r['type'] ?? 'expense'),
                'categoryId' => (string) ($r['categoryId'] ?? 'other'),
                'isRecurring' => false,
                '_createdTs' => $importSeq + $i,
            ];
        }

        PreferencesStore::update(function (array $p) use ($toImport, $source, $importedAt): array {
            $extra = [];
            foreach ($toImport as $r) {
                $extra[] = [
                    'id' => (string) ($r['id'] ?? uniqid('imp-', true)),
                    'name' => (string) ($r['name'] ?? ''),
                    'amount' => (float) ($r['amount'] ?? 0),
                    'date' => (string) ($r['date'] ?? ''),
                    'type' => (string) ($r['type'] ?? 'expense'),
                    'categoryId' => (string) ($r['categoryId'] ?? 'other'),
                    'source' => $source,
                    'importedAt' => $importedAt,
                ];
            }
            $p['importedTransactions'] = array_merge($p['importedTransactions'] ?? [], $extra);

            return $p;
        });

        $beforePct = $this->budgetPctByExternalId($finance->budgets());
        $existing = $finance->transactions();
        $finance->setTransactions(array_merge($newTransactions, $existing));

        $user = auth()->user();
        if ($user !== null) {
            $afterPct = $this->budgetPctByExternalId($finance->budgets());
            $this->emitBudgetPctCrossings($notifications, $user, $beforePct, $afterPct, 'txn_import_sheet');

            $notifications->emit($user, 'transaction.imported', [
                'title' => __('notifications.types.transaction_imported_title'),
                'message' => __('notifications.types.transaction_imported_message', ['count' => count($toImport)]),
                'entity_type' => 'transaction',
                'entity_id' => null,
                'dedupe_key' => 'txn:import:sheet:'.count($toImport).':'.now()->format('Y-m-d-H-i'),
            ]);
        }

        session()->flash('toast', (string) __('budgets.import_page.toast_imported_count', ['count' => count($toImport)]));
        $this->invalidateInferenceCaches();
        $this->closeImportModal();
    }

    public function closeMobileSearch(): void
    {
        $this->mobileSearchOpen = false;
    }

    public function openDetail(string $id, FinanceRepository $finance): void
    {
        $txn = collect($finance->transactions())->firstWhere('id', $id);
        if (! $txn) {
            return;
        }
        $this->selectedTxnId = $id;
        $this->detailEditing = false;
        $this->hydrateDetailFromTxn($txn);
    }

    public function closeDetail(): void
    {
        $this->selectedTxnId = null;
        $this->detailEditing = false;
        $this->detailRecurringOpen = false;
    }

    public function startDetailEdit(): void
    {
        $this->detailEditing = true;
        $this->updatedDName();
    }

    public function cancelDetailEdit(FinanceRepository $finance): void
    {
        if ($this->selectedTxnId) {
            $txn = collect($finance->transactions())->firstWhere('id', $this->selectedTxnId);
            if ($txn) {
                $this->hydrateDetailFromTxn($txn);
            }
        }
        $this->detailEditing = false;
    }

    protected function hydrateDetailFromTxn(array $txn): void
    {
        $this->dName = (string) ($txn['name'] ?? '');
        $this->dAmount = abs((float) ($txn['amount'] ?? 0));
        $this->dDate = (string) ($txn['date'] ?? date('Y-m-d'));
        $this->dType = (string) ($txn['type'] ?? 'expense');
        $this->dCategoryId = (string) ($txn['categoryId'] ?? 'other');
        $this->dNotes = (string) ($txn['notes'] ?? '');
        $this->dIsRecurring = (bool) ($txn['isRecurring'] ?? false);
        $this->dRecurringFrequency = (string) ($txn['recurringConfig']['frequency'] ?? ($txn['recurring_frequency'] ?? 'monthly'));
        $this->dRecurringStartDate = (string) ($txn['recurringConfig']['startDate'] ?? ($txn['recurring_start_date'] ?? (string) ($txn['date'] ?? date('Y-m-d'))));
        $this->dManualCategory = false;
        $this->dManualRecurring = false;
        $this->dSmartSuggestions = [];
        $this->dDescriptionHints = [];
    }

    protected function resetAddForm(): void
    {
        $this->addName = '';
        $this->addAmount = null;
        $this->addDate = date('Y-m-d');
        $this->addCategoryId = '';
        $this->addType = 'expense';
        $this->addIsRecurring = false;
        $this->addRecurringOpen = false;
        $this->addRecurringFrequency = 'monthly';
        $this->addManualRecurringStartDate = false;
        $this->addRecurringStartDate = $this->addDate;
        $this->addSmartSuggestions = [];
        $this->addManualCategory = false;
        $this->addManualType = false;
        $this->addManualRecurring = false;
        $this->addDescriptionHints = [];
    }

    protected function syncAddRecurringStartFromTransactionDate(): void
    {
        if ($this->addManualRecurringStartDate || $this->addDate === '') {
            return;
        }
        $this->addRecurringStartDate = $this->addDate;
    }

    public function updatedAddDate(): void
    {
        $this->syncAddRecurringStartFromTransactionDate();
    }

    public function updatedAddRecurringStartDate(): void
    {
        if ($this->addRecurringStartDate === '' || $this->addRecurringStartDate === $this->addDate) {
            $this->addManualRecurringStartDate = false;

            return;
        }
        $this->addManualRecurringStartDate = true;
    }

    protected function syncAddInference(): void
    {
        $inf = TransactionInference::infer($this->addName, $this->addAmount, $this->inferenceHistory());
        $this->addSmartSuggestions = $inf['suggestions'] ?? [];
        $this->addDescriptionHints = DescriptionAutocomplete::suggest(
            $this->addName,
            DescriptionAutocomplete::CONTEXT_TRANSACTION,
            $this->transactionDescriptionHistory(),
            12,
        );
        if (! empty($inf['categoryId']) && ! $this->addManualCategory) {
            $this->addCategoryId = $inf['categoryId'];
        }
        if (! empty($inf['type']) && $inf['type'] !== 'transfer' && ! $this->addManualType) {
            $this->addType = $inf['type'];
        }
        if (isset($inf['isRecurring']) && ! $this->addManualRecurring) {
            $this->addIsRecurring = (bool) $inf['isRecurring'];
        }
        if ($this->addIsRecurring) {
            $this->syncAddRecurringStartFromTransactionDate();
        }
    }

    public function updatedAddName(): void
    {
        $this->syncAddInference();
    }

    public function updatedAddAmount(): void
    {
        $this->syncAddInference();
    }

    public function setAddType(string $t): void
    {
        $this->addManualType = true;
        $this->addType = $t;
    }

    public function setAddCategory(string $id): void
    {
        $this->addManualCategory = true;
        $this->addCategoryId = $id;
    }

    public function toggleAddRecurring(): void
    {
        $this->addManualRecurring = true;
        $this->addIsRecurring = ! $this->addIsRecurring;
        if ($this->addIsRecurring) {
            $this->syncAddRecurringStartFromTransactionDate();
            $this->addRecurringOpen = true;
        } else {
            $this->addManualRecurringStartDate = false;
        }
    }

    public function openAddRecurring(): void
    {
        $this->syncAddRecurringStartFromTransactionDate();
        $this->addRecurringOpen = true;
    }

    public function closeAddRecurring(): void
    {
        $this->addRecurringOpen = false;
    }

    public function saveAddRecurring(): void
    {
        $this->addManualRecurring = true;
        $this->addIsRecurring = true;
        $this->addRecurringOpen = false;
    }

    public function updatedDName(): void
    {
        if (! $this->selectedTxnId) {
            return;
        }
        $inf = TransactionInference::infer($this->dName, $this->dAmount, $this->inferenceHistory());
        $this->dSmartSuggestions = $inf['suggestions'] ?? [];
        $this->refreshDetailDescriptionHints();
        if (! empty($inf['categoryId']) && ! $this->dManualCategory) {
            $this->dCategoryId = $inf['categoryId'];
        }
        if (isset($inf['isRecurring']) && ! $this->dManualRecurring) {
            $this->dIsRecurring = (bool) $inf['isRecurring'];
        }
    }

    public function pickAddDescriptionHint(int $index): void
    {
        $row = $this->addDescriptionHints[$index] ?? null;
        if (! is_array($row) || ($row['value'] ?? '') === '') {
            return;
        }
        $this->addName = (string) $row['value'];
        $this->syncAddInference();
    }

    public function pickDetailDescriptionHint(int $index): void
    {
        $row = $this->dDescriptionHints[$index] ?? null;
        if (! is_array($row) || ($row['value'] ?? '') === '') {
            return;
        }
        $this->dName = (string) $row['value'];
        $this->updatedDName();
    }

    protected function refreshDetailDescriptionHints(): void
    {
        $this->dDescriptionHints = DescriptionAutocomplete::suggest(
            $this->dName,
            DescriptionAutocomplete::CONTEXT_TRANSACTION,
            $this->transactionDescriptionHistory(),
            12,
        );
    }

    /**
     * @return list<string>
     */
    protected function transactionDescriptionHistory(): array
    {
        if ($this->transactionDescriptionHistoryCache !== null) {
            return $this->transactionDescriptionHistoryCache;
        }

        $finance = app(FinanceRepository::class);
        $this->transactionDescriptionHistoryCache = collect($this->inferenceHistory())
            ->pluck('name')
            ->merge(collect($finance->subscriptions())->pluck('name'))
            ->map(fn ($n) => trim((string) $n))
            ->filter()
            ->unique(fn (string $n) => mb_strtolower($n))
            ->values()
            ->all();

        return $this->transactionDescriptionHistoryCache;
    }

    public function updatedDAmount(): void
    {
        if (! $this->selectedTxnId) {
            return;
        }
        $this->updatedDName();
    }

    public function setDetailCategory(string $id): void
    {
        $this->dManualCategory = true;
        $this->dCategoryId = $id;
    }

    public function setDetailType(string $type): void
    {
        if (! in_array($type, ['expense', 'income'], true)) {
            return;
        }

        $this->dType = $type;
    }

    public function toggleDetailRecurring(): void
    {
        $this->dManualRecurring = true;
        $wasRecurring = $this->dIsRecurring;
        $this->dIsRecurring = ! $this->dIsRecurring;
        if ($this->dIsRecurring) {
            if (! $wasRecurring && $this->dDate !== '') {
                $this->dRecurringStartDate = $this->dDate;
            }
            $this->detailRecurringOpen = true;
        }
    }

    public function openDetailRecurring(): void
    {
        if (! $this->detailEditing) {
            return;
        }
        $this->detailRecurringOpen = true;
    }

    public function closeDetailRecurring(): void
    {
        $this->detailRecurringOpen = false;
    }

    public function saveDetailRecurring(): void
    {
        if (! $this->detailEditing) {
            return;
        }
        $this->dManualRecurring = true;
        $this->dIsRecurring = true;
        $this->detailRecurringOpen = false;
    }

    public function addTransaction(FinanceRepository $finance, NotificationService $notifications): void
    {
        if ($this->addName === '' || $this->addAmount === null) {
            return;
        }
        $beforePct = $this->budgetPctByExternalId($finance->budgets());
        $rows = $finance->transactions();
        $id = 't'.(string) (int) round(microtime(true) * 1000);
        $amt = $this->addType === 'expense' ? -abs($this->addAmount) : abs($this->addAmount);
        $rows = array_merge([
            [
                'id' => $id,
                'name' => $this->addName,
                'categoryId' => $this->addCategoryId !== '' ? $this->addCategoryId : 'other',
                'amount' => $amt,
                'date' => $this->addDate,
                'type' => $this->addType,
                'isRecurring' => $this->addIsRecurring,
                'recurringConfig' => $this->addIsRecurring ? [
                    'frequency' => $this->addRecurringFrequency,
                    'startDate' => $this->addRecurringStartDate !== '' ? $this->addRecurringStartDate : $this->addDate,
                ] : null,
                '_createdTs' => (int) round(microtime(true) * 1000),
            ],
        ], $rows);
        $finance->setTransactions($rows);

        $user = auth()->user();
        if ($user !== null) {
            $afterPct = $this->budgetPctByExternalId($finance->budgets());
            $this->emitBudgetPctCrossings($notifications, $user, $beforePct, $afterPct, 'txn_add');

            $currency = (string) ((PreferencesStore::all()['regional']['currency'] ?? 'EUR'));
            $notifications->emit($user, 'transaction.created', [
                'title' => __('notifications.types.transaction_created_title'),
                'message' => __('notifications.types.transaction_created_message', [
                    'name' => $this->addName,
                    'amount' => number_format(abs((float) $amt), 2, '.', ','),
                    'currency' => $currency,
                    'type' => (string) $this->addType,
                ]),
                'entity_type' => 'transaction',
                'entity_id' => $id,
                'dedupe_key' => 'txn:created:'.$id,
            ]);

            if ($this->addType === 'expense' && abs((float) $amt) >= $this->largeExpenseThreshold()) {
                $notifications->emit($user, 'transaction.large_expense', [
                    'title' => __('notifications.types.transaction_large_expense_title'),
                    'message' => __('notifications.types.transaction_large_expense_message', [
                        'name' => $this->addName,
                        'amount' => number_format(abs((float) $amt), 2, '.', ','),
                        'currency' => $currency,
                    ]),
                    'entity_type' => 'transaction',
                    'entity_id' => $id,
                    'dedupe_key' => 'txn:large:'.$id,
                ]);
            }
        }

        if ($this->syncSubscriptionFromTransaction($finance, $rows[0])) {
            session()->flash('toast', (string) __('budgets.transactions_page.toast_added_to_subscriptions'));
        }
        $this->invalidateInferenceCaches();
        $this->closeAdd();
    }

    public function deleteTransaction(NotificationService $notifications): void
    {
        if (! $this->selectedTxnId) {
            return;
        }
        $finance = app(FinanceRepository::class);
        $beforePct = $this->budgetPctByExternalId($finance->budgets());
        $txn = collect($finance->transactions())->firstWhere('id', $this->selectedTxnId);
        $deleted = $finance->deleteTransaction($this->selectedTxnId);
        if (! $deleted) {
            $msg = (string) __('budgets.transactions_page.toast_delete_failed');
            session()->flash('toast', $msg);
            $this->dispatch('app-toast', message: $msg);

            return;
        }

        $user = auth()->user();
        if ($user !== null) {
            $afterPct = $this->budgetPctByExternalId($finance->budgets());
            $this->emitBudgetPctCrossings($notifications, $user, $beforePct, $afterPct, 'txn_delete');

            $currency = (string) ((PreferencesStore::all()['regional']['currency'] ?? 'EUR'));
            $name = is_array($txn) ? (string) ($txn['name'] ?? '') : '';
            if ($name !== '') {
                $amt = is_array($txn) ? abs((float) ($txn['amount'] ?? 0)) : 0.0;
                $notifications->emit($user, 'transaction.deleted', [
                    'title' => __('notifications.types.transaction_deleted_title'),
                    'message' => __('notifications.types.transaction_deleted_message', [
                        'name' => $name,
                        'amount' => number_format($amt, 2, '.', ','),
                        'currency' => $currency,
                    ]),
                    'entity_type' => 'transaction',
                    'entity_id' => (string) $this->selectedTxnId,
                    'dedupe_key' => 'txn:deleted:'.(string) $this->selectedTxnId,
                ]);
            }
        }

        $this->closeDetail();
        $this->invalidateInferenceCaches();
        $msg = (string) __('budgets.transactions_page.toast_deleted');
        session()->flash('toast', $msg);
        $this->dispatch('app-toast', message: $msg);
    }

    public function saveDetail(FinanceRepository $finance, NotificationService $notifications): void
    {
        if (! $this->selectedTxnId) {
            return;
        }
        $txn = collect($finance->transactions())->firstWhere('id', $this->selectedTxnId);
        if (! $txn) {
            return;
        }
        $beforePct = $this->budgetPctByExternalId($finance->budgets());
        $type = in_array($this->dType, ['expense', 'income'], true) ? $this->dType : 'expense';
        $amt = $type === 'expense' ? -abs((float) ($this->dAmount ?? 0)) : abs((float) ($this->dAmount ?? 0));
        $updated = array_merge($txn, [
            'name' => $this->dName,
            'amount' => $amt,
            'date' => $this->dDate !== '' ? $this->dDate : (string) ($txn['date'] ?? date('Y-m-d')),
            'type' => $type,
            'categoryId' => $this->dCategoryId,
            'notes' => $this->dNotes,
            'isRecurring' => $this->dIsRecurring,
            'recurringConfig' => $this->dIsRecurring ? [
                'frequency' => $this->dRecurringFrequency,
                'startDate' => $this->dRecurringStartDate !== '' ? $this->dRecurringStartDate : ($this->dDate !== '' ? $this->dDate : (string) ($txn['date'] ?? date('Y-m-d'))),
            ] : null,
        ]);
        $rows = collect($finance->transactions())->map(fn ($t) => $t['id'] === $this->selectedTxnId ? $updated : $t)->values()->all();
        $finance->setTransactions($rows);

        $user = auth()->user();
        if ($user !== null) {
            $afterPct = $this->budgetPctByExternalId($finance->budgets());
            $this->emitBudgetPctCrossings($notifications, $user, $beforePct, $afterPct, 'txn_update');

            $currency = (string) ((PreferencesStore::all()['regional']['currency'] ?? 'EUR'));
            $notifications->emit($user, 'transaction.updated', [
                'title' => __('notifications.types.transaction_updated_title'),
                'message' => __('notifications.types.transaction_updated_message', [
                    'name' => $this->dName,
                    'amount' => number_format(abs((float) $amt), 2, '.', ','),
                    'currency' => $currency,
                ]),
                'entity_type' => 'transaction',
                'entity_id' => (string) $this->selectedTxnId,
                'dedupe_key' => 'txn:updated:'.$this->selectedTxnId.':'.now()->format('Y-m-d-H-i'),
            ]);

            if ($type === 'expense' && abs((float) $amt) >= $this->largeExpenseThreshold()) {
                $notifications->emit($user, 'transaction.large_expense', [
                    'title' => __('notifications.types.transaction_large_expense_title'),
                    'message' => __('notifications.types.transaction_large_expense_message', [
                        'name' => $this->dName,
                        'amount' => number_format(abs((float) $amt), 2, '.', ','),
                        'currency' => $currency,
                    ]),
                    'entity_type' => 'transaction',
                    'entity_id' => (string) $this->selectedTxnId,
                    'dedupe_key' => 'txn:large:'.$this->selectedTxnId.':'.number_format(abs((float) $amt), 2, '.', ''),
                ]);
            }
        }

        if ($this->syncSubscriptionFromTransaction($finance, $updated)) {
            session()->flash('toast', (string) __('budgets.transactions_page.toast_added_to_subscriptions'));
        }
        $this->invalidateInferenceCaches();
        $this->detailEditing = false;
        $this->closeDetail();
    }

    /** @return list<array<string,mixed>> */
    protected function inferenceHistory(): array
    {
        if ($this->inferenceHistoryCache !== null) {
            return $this->inferenceHistoryCache;
        }

        $this->inferenceHistoryCache = app(FinanceRepository::class)->transactions();

        return $this->inferenceHistoryCache;
    }

    protected function invalidateInferenceCaches(): void
    {
        $this->inferenceHistoryCache = null;
        $this->transactionDescriptionHistoryCache = null;
    }

    /**
     * @param  list<array<string,mixed>>  $budgets
     * @return array<string,float>
     */
    protected function budgetPctByExternalId(array $budgets): array
    {
        $map = [];
        foreach ($budgets as $b) {
            $id = (string) ($b['id'] ?? '');
            if ($id === '') {
                continue;
            }
            $limit = (float) ($b['limit'] ?? 0);
            $spent = (float) ($b['spent'] ?? 0);
            $map[$id] = $limit > 0 ? ($spent / $limit) * 100.0 : 0.0;
        }

        return $map;
    }

    /**
     * @param  array<string,float>  $beforePctById
     * @param  array<string,float>  $afterPctById
     */
    protected function emitBudgetPctCrossings(NotificationService $notifications, User $user, array $beforePctById, array $afterPctById, string $reason): void
    {
        $monthKey = now()->format('Y-m');
        foreach ($afterPctById as $budgetId => $afterPct) {
            $beforePct = (float) ($beforePctById[$budgetId] ?? 0.0);
            $budget = collect(app(FinanceRepository::class)->budgets())->firstWhere('id', $budgetId);
            if (! is_array($budget)) {
                continue;
            }
            $name = (string) ($budget['name'] ?? 'Budget');
            $rounded = (int) round($afterPct);

            if ($beforePct < 100.0 && $afterPct >= 100.0) {
                $notifications->emit($user, 'budget.over_limit', [
                    'title' => __('notifications.types.budget_over_limit_title'),
                    'message' => __('notifications.types.budget_over_limit_message', ['name' => $name, 'pct' => $rounded]),
                    'entity_type' => 'budget',
                    'entity_id' => $budgetId,
                    'dedupe_key' => 'budget:'.$budgetId.':cross:100:'.$monthKey.':'.$reason,
                ]);

                continue;
            }

            if ($beforePct < 90.0 && $afterPct >= 90.0 && $afterPct < 100.0) {
                $notifications->emit($user, 'budget.warning', [
                    'title' => __('notifications.types.budget_warning_title'),
                    'message' => __('notifications.types.budget_warning_message', ['name' => $name, 'pct' => $rounded]),
                    'entity_type' => 'budget',
                    'entity_id' => $budgetId,
                    'dedupe_key' => 'budget:'.$budgetId.':cross:90:'.$monthKey.':'.$reason,
                ]);

                continue;
            }

            if ($beforePct < 85.0 && $afterPct >= 85.0 && $afterPct < 90.0) {
                $notifications->emit($user, 'budget.near_limit', [
                    'title' => __('notifications.types.budget_near_limit_title'),
                    'message' => __('notifications.types.budget_near_limit_message', ['name' => $name, 'pct' => $rounded]),
                    'entity_type' => 'budget',
                    'entity_id' => $budgetId,
                    'dedupe_key' => 'budget:'.$budgetId.':cross:85:'.$monthKey.':'.$reason,
                ]);
            }
        }
    }

    protected function largeExpenseThreshold(): float
    {
        $income = (float) ((PreferencesStore::all()['profile']['monthlyIncome'] ?? 0));
        $pct = $income > 0 ? $income * 0.15 : 250.0;

        return max(50.0, $pct);
    }

    /** @param array<string, mixed> $transaction */
    protected function syncSubscriptionFromTransaction(FinanceRepository $finance, array $transaction): bool
    {
        $type = (string) ($transaction['type'] ?? 'expense');
        $categoryId = (string) ($transaction['categoryId'] ?? 'other');
        $isRecurring = (bool) ($transaction['isRecurring'] ?? false);
        if ($type !== 'expense' || ($categoryId !== 'subscriptions' && ! $isRecurring)) {
            return false;
        }

        $name = trim((string) ($transaction['name'] ?? ''));
        $cost = abs((float) ($transaction['amount'] ?? 0));
        if ($name === '' || $cost <= 0) {
            return false;
        }

        $allSubscriptions = collect($finance->subscriptions());
        $ownedSubscriptions = $allSubscriptions
            ->filter(fn (array $s): bool => (bool) ($s['isOwner'] ?? false))
            ->keyBy('id');

        $existing = $ownedSubscriptions
            ->first(fn (array $s): bool => mb_strtolower((string) ($s['name'] ?? '')) === mb_strtolower($name));

        $id = is_array($existing)
            ? (string) ($existing['id'] ?? '')
            : 's'.(string) (int) round(microtime(true) * 1000);
        if ($id === '') {
            return false;
        }

        $transactionDate = (string) ($transaction['date'] ?? date('Y-m-d'));
        $ownedSubscriptions->put($id, [
            'id' => $id,
            'name' => $name,
            'categoryId' => is_array($existing) ? (string) ($existing['categoryId'] ?? 'subscriptions') : 'subscriptions',
            'cost' => $cost,
            'billingCycle' => is_array($existing) ? (string) ($existing['billingCycle'] ?? 'monthly') : 'monthly',
            'nextRenewal' => is_array($existing) ? (string) ($existing['nextRenewal'] ?? $transactionDate) : $transactionDate,
            'lastCharge' => $transactionDate,
            'status' => is_array($existing) ? (string) ($existing['status'] ?? 'active') : 'active',
            'confidence' => is_array($existing) ? (int) ($existing['confidence'] ?? 100) : 100,
            'shared' => is_array($existing) ? (bool) ($existing['shared'] ?? false) : false,
            'sharedWith' => is_array($existing) ? (array) ($existing['sharedWith'] ?? []) : [],
            'usageScore' => is_array($existing) ? ($existing['usageScore'] ?? null) : null,
            'notes' => is_array($existing) ? (string) ($existing['notes'] ?? '') : '',
        ]);

        $finance->setSubscriptions($ownedSubscriptions->values()->all());

        return true;
    }

    public function render(FinanceRepository $finance)
    {
        $locale = AppLocale::carbon();
        $tx = $finance->transactions();
        $q = mb_strtolower(trim($this->search));
        $filtered = $q === ''
            ? $tx
            : array_values(array_filter($tx, function ($t) use ($q) {
                $name = mb_strtolower((string) ($t['name'] ?? ''));
                $cat = CategoryCatalog::get((string) ($t['categoryId'] ?? 'other'));

                return str_contains($name, $q) || str_contains(mb_strtolower($cat['name']), $q);
            }));

        $monthStart = now()->copy()->startOfMonth()->startOfDay();
        $monthEnd = now()->copy()->endOfMonth()->endOfDay();
        $txThisMonth = collect($tx)->filter(function (array $t) use ($monthStart, $monthEnd): bool {
            $raw = $t['date'] ?? null;
            if (! is_string($raw) || trim($raw) === '') {
                return false;
            }
            try {
                $d = Carbon::parse($raw)->startOfDay();

                return $d->betweenIncluded($monthStart, $monthEnd);
            } catch (\Throwable) {
                return false;
            }
        });

        $income = (float) $txThisMonth->where('type', 'income')->sum('amount');
        $expenses = (float) $txThisMonth->where('type', 'expense')->sum(fn ($t) => abs((float) ($t['amount'] ?? 0)));
        $net = $income - $expenses;
        $incomeCount = $txThisMonth->where('type', 'income')->count();
        $expenseCount = $txThisMonth->where('type', 'expense')->count();
        $txCount = $txThisMonth->count();
        $statsPeriod = now()->locale($locale)->translatedFormat('F Y');
        $statsNetSavingsPct = $income > 0 ? (int) round(($net / $income) * 100) : null;
        $statsMonthHasActivity = $txThisMonth->isNotEmpty();

        $listPresentation = RelativeDayLabel::groupedForTransactionsList($filtered, $locale);
        $grouped = $listPresentation['history'];
        $upcomingGrouped = $listPresentation['upcoming'];
        $upcomingTxnCount = 0;
        foreach ($upcomingGrouped as $upSection) {
            $upcomingTxnCount += count($upSection['items']);
        }
        $selectedTxn = $this->selectedTxnId
            ? collect($tx)->firstWhere('id', $this->selectedTxnId)
            : null;

        return view('livewire.transactions-page', [
            'locale' => $locale,
            'filtered' => $filtered,
            'grouped' => $grouped,
            'upcomingGrouped' => $upcomingGrouped,
            'upcomingTxnCount' => $upcomingTxnCount,
            'income' => $income,
            'expenses' => $expenses,
            'net' => $net,
            'txCount' => $txCount,
            'statsPeriod' => $statsPeriod,
            'statsIncomeCount' => $incomeCount,
            'statsExpenseCount' => $expenseCount,
            'statsNetSavingsPct' => $statsNetSavingsPct,
            'statsMonthHasActivity' => $statsMonthHasActivity,
            'statsNetHint' => self::transactionsNetStatHint($net, $income, $expenses, $statsPeriod, $statsMonthHasActivity),
            'statsNetHintExtra' => self::transactionsNetStatHintExtra($net, $income, $statsNetSavingsPct),
            'selectedTxn' => $selectedTxn,
            'categories' => CategoryCatalog::all(),
        ])->title((string) __('budgets.transactions_page.title'));
    }

    protected static function transactionsNetStatHint(
        float $net,
        float $income,
        float $expenses,
        string $statsPeriod,
        bool $monthHasActivity,
    ): string {
        if (! $monthHasActivity || ($income <= 0 && $expenses <= 0)) {
            return (string) __('budgets.transactions_page.stat_net_empty', ['month' => $statsPeriod]);
        }
        if (abs($net) < 0.005) {
            return (string) __('budgets.transactions_page.stat_net_balanced');
        }
        if ($net > 0) {
            return (string) __('budgets.transactions_page.stat_net_positive', ['amount' => Money::formatEur($net)]);
        }

        return (string) __('budgets.transactions_page.stat_net_negative', ['amount' => Money::formatEur(abs($net))]);
    }

    protected static function transactionsNetStatHintExtra(float $net, float $income, ?int $statsNetSavingsPct): ?string
    {
        if ($income <= 0 || $net <= 0 || $statsNetSavingsPct === null) {
            return null;
        }

        return (string) __('budgets.transactions_page.stat_net_saved_pct', ['pct' => $statsNetSavingsPct]);
    }
}
