<?php

declare(strict_types=1);

namespace Budget;

use DateTimeImmutable;
use PDO;
use RuntimeException;

final class BudgetService
{
    public function __construct(private PDO $pdo, private Settings $settings)
    {
    }

    public function ensureMonth(string $month): array
    {
        $month = Support::month($month);
        $plan = $this->plan($month);
        if ($plan) {
            $this->materializeRecurringItems($month);
            return $plan;
        }

        $previousMonth = (new DateTimeImmutable($month . '-01'))->modify('-1 month')->format('Y-m');
        $previous = $this->plan($previousMonth);
        $rollover = 0.0;
        if ($previous && (bool) $previous['rolling_enabled']) {
            $rollover = max(0, (float) $this->summary($previousMonth, false)['remaining']);
        }

        $statement = $this->pdo->prepare(
            'INSERT INTO monthly_plans (month, rollover_in, rolling_enabled, rollover_source_month)
             VALUES (:month, :rollover, 1, :source)'
        );
        $statement->execute([
            'month' => $month,
            'rollover' => $rollover,
            'source' => $previous ? $previousMonth : null,
        ]);
        $this->materializeRecurringItems($month);
        return $this->plan($month) ?: throw new RuntimeException('Unable to create monthly plan.');
    }

    public function plan(string $month): ?array
    {
        $statement = $this->pdo->prepare('SELECT * FROM monthly_plans WHERE month = :month');
        $statement->execute(['month' => Support::month($month)]);
        return $statement->fetch() ?: null;
    }

    public function savePlan(string $month, float $startingIncome, float $rollover, bool $rollingEnabled, string $notes): void
    {
        $plan = $this->ensureMonth($month);
        $withholding = $this->automaticAmount(
            $startingIncome,
            (string) $this->settings->get('dead.income_mode', 'percent'),
            (float) $this->settings->get('dead.income_value', 0)
        );

        $this->pdo->beginTransaction();
        try {
            $statement = $this->pdo->prepare(
                'UPDATE monthly_plans
                 SET starting_income = :starting_income, rollover_in = :rollover_in,
                     rolling_enabled = :rolling_enabled, notes = :notes, updated_at = CURRENT_TIMESTAMP
                 WHERE id = :id'
            );
            $statement->execute([
                'starting_income' => max(0, $startingIncome),
                'rollover_in' => max(0, $rollover),
                'rolling_enabled' => $rollingEnabled ? 1 : 0,
                'notes' => trim($notes) ?: null,
                'id' => $plan['id'],
            ]);
            $this->replaceDeadSource(
                $month,
                'income_withholding',
                $withholding,
                'monthly_plan',
                (int) $plan['id'],
                'Automatic withholding from the monthly starting point'
            );
            $this->pdo->commit();
        } catch (\Throwable $exception) {
            $this->pdo->rollBack();
            throw $exception;
        }
    }

    public function refreshRollover(string $month): float
    {
        $plan = $this->ensureMonth($month);
        if (!(bool) $plan['rolling_enabled']) {
            return (float) $plan['rollover_in'];
        }
        $previousMonth = (new DateTimeImmutable($month . '-01'))->modify('-1 month')->format('Y-m');
        $previous = $this->plan($previousMonth);
        $rollover = $previous ? max(0, (float) $this->summary($previousMonth, false)['remaining']) : 0.0;
        $statement = $this->pdo->prepare(
            'UPDATE monthly_plans SET rollover_in = :rollover, rollover_source_month = :source, updated_at = CURRENT_TIMESTAMP WHERE id = :id'
        );
        $statement->execute(['rollover' => $rollover, 'source' => $previous ? $previousMonth : null, 'id' => $plan['id']]);
        return $rollover;
    }

    public function addIncome(string $month, string $label, float $amount, string $receivedOn, string $notes): int
    {
        $month = Support::month($month);
        $this->ensureMonth($month);
        $this->assertDateInMonth($receivedOn, $month, 'Income date');
        $amount = max(0, $amount);
        if ($amount <= 0 || trim($label) === '') {
            throw new RuntimeException('Income needs a label and an amount greater than zero.');
        }
        $withholding = $this->automaticAmount(
            $amount,
            (string) $this->settings->get('dead.income_mode', 'percent'),
            (float) $this->settings->get('dead.income_value', 0)
        );
        $this->pdo->beginTransaction();
        try {
            $statement = $this->pdo->prepare(
                'INSERT INTO income_entries (month, label, amount, received_on, withholding_amount, notes)
                 VALUES (:month, :label, :amount, :received_on, :withholding, :notes)'
            );
            $statement->execute([
                'month' => $month,
                'label' => trim($label),
                'amount' => $amount,
                'received_on' => $receivedOn,
                'withholding' => $withholding,
                'notes' => trim($notes) ?: null,
            ]);
            $id = (int) $this->pdo->lastInsertId();
            if ($withholding > 0) {
                $this->insertDeadEntry(
                    $month,
                    'income_withholding',
                    $withholding,
                    'income',
                    $id,
                    'Automatic withholding from ' . trim($label)
                );
            }
            $this->pdo->commit();
            return $id;
        } catch (\Throwable $exception) {
            $this->pdo->rollBack();
            throw $exception;
        }
    }

    public function updateIncome(int $id, string $month, string $label, float $amount, string $receivedOn, string $notes): void
    {
        $month = Support::month($month);
        $this->assertDateInMonth($receivedOn, $month, 'Income date');
        $amount = max(0, $amount);
        if ($id <= 0 || $amount <= 0 || trim($label) === '') {
            throw new RuntimeException('Income needs a label and an amount greater than zero.');
        }
        $withholding = $this->automaticAmount(
            $amount,
            (string) $this->settings->get('dead.income_mode', 'percent'),
            (float) $this->settings->get('dead.income_value', 0)
        );
        $this->pdo->beginTransaction();
        try {
            $statement = $this->pdo->prepare(
                'UPDATE income_entries
                 SET month = :month, label = :label, amount = :amount, received_on = :received_on,
                     withholding_amount = :withholding, notes = :notes
                 WHERE id = :id'
            );
            $statement->execute([
                'month' => $month,
                'label' => trim($label),
                'amount' => $amount,
                'received_on' => $receivedOn,
                'withholding' => $withholding,
                'notes' => trim($notes) ?: null,
                'id' => $id,
            ]);
            if ($statement->rowCount() === 0) {
                $check = $this->pdo->prepare('SELECT COUNT(*) FROM income_entries WHERE id = :id');
                $check->execute(['id' => $id]);
                if ((int) $check->fetchColumn() === 0) {
                    throw new RuntimeException('Income entry was not found.');
                }
            }
            $this->replaceDeadSource(
                $month,
                'income_withholding',
                $withholding,
                'income',
                $id,
                'Automatic withholding from ' . trim($label)
            );
            $this->pdo->commit();
        } catch (\Throwable $exception) {
            $this->pdo->rollBack();
            throw $exception;
        }
    }

    public function deleteIncome(int $id): void
    {
        $this->pdo->beginTransaction();
        try {
            $statement = $this->pdo->prepare('DELETE FROM dead_account_entries WHERE source_type = :type AND source_id = :id');
            $statement->execute(['type' => 'income', 'id' => $id]);
            $statement = $this->pdo->prepare('DELETE FROM income_entries WHERE id = :id');
            $statement->execute(['id' => $id]);
            $this->pdo->commit();
        } catch (\Throwable $exception) {
            $this->pdo->rollBack();
            throw $exception;
        }
    }

    public function incomes(string $month): array
    {
        $statement = $this->pdo->prepare('SELECT * FROM income_entries WHERE month = :month ORDER BY received_on DESC, id DESC');
        $statement->execute(['month' => $month]);
        return $statement->fetchAll();
    }

    public function addCategory(string $name, string $color, float $monthlyLimit): int
    {
        if (trim($name) === '') {
            throw new RuntimeException('Category name is required.');
        }
        if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
            $color = '#7c5cff';
        }
        $statement = $this->pdo->prepare(
            'INSERT INTO categories (name, color, monthly_limit) VALUES (:name, :color, :monthly_limit)'
        );
        $statement->execute([
            'name' => trim($name),
            'color' => $color,
            'monthly_limit' => max(0, $monthlyLimit),
        ]);
        return (int) $this->pdo->lastInsertId();
    }

    public function categories(): array
    {
        return $this->pdo->query('SELECT * FROM categories WHERE active = 1 ORDER BY name')->fetchAll();
    }

    public function updateCategory(int $id, string $name, string $color, float $monthlyLimit): void
    {
        if ($id <= 0 || trim($name) === '') {
            throw new RuntimeException('Category name is required.');
        }
        if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
            $color = '#7c5cff';
        }
        $statement = $this->pdo->prepare(
            'UPDATE categories SET name = :name, color = :color, monthly_limit = :monthly_limit WHERE id = :id'
        );
        $statement->execute([
            'name' => trim($name),
            'color' => $color,
            'monthly_limit' => max(0, $monthlyLimit),
            'id' => $id,
        ]);
    }

    public function archiveCategory(int $id): void
    {
        $statement = $this->pdo->prepare('UPDATE categories SET active = 0 WHERE id = :id');
        $statement->execute(['id' => $id]);
    }

    public function addBudgetItem(
        string $month,
        ?int $categoryId,
        string $name,
        float $plannedAmount,
        ?string $dueDate,
        string $recurrence,
        string $notes
    ): int {
        $this->ensureMonth($month);
        if (trim($name) === '' || $plannedAmount < 0) {
            throw new RuntimeException('A budget item needs a name and a non-negative target.');
        }
        if (!in_array($recurrence, ['none', 'monthly', 'yearly'], true)) {
            $recurrence = 'none';
        }
        if ($dueDate !== null) {
            $this->assertDateInMonth($dueDate, Support::month($month), 'Due date');
        }
        $series = $recurrence === 'none' ? null : bin2hex(random_bytes(12));
        $statement = $this->pdo->prepare(
            'INSERT INTO budget_items (month, category_id, name, planned_amount, due_date, recurrence, series_key, notes)
             VALUES (:month, :category_id, :name, :planned_amount, :due_date, :recurrence, :series_key, :notes)'
        );
        $statement->execute([
            'month' => $month,
            'category_id' => $categoryId ?: null,
            'name' => trim($name),
            'planned_amount' => max(0, $plannedAmount),
            'due_date' => $dueDate ?: null,
            'recurrence' => $recurrence,
            'series_key' => $series,
            'notes' => trim($notes) ?: null,
        ]);
        return (int) $this->pdo->lastInsertId();
    }

    public function deleteBudgetItem(int $id): void
    {
        $statement = $this->pdo->prepare('DELETE FROM budget_items WHERE id = :id');
        $statement->execute(['id' => $id]);
    }

    public function updateBudgetItem(
        int $id,
        string $month,
        ?int $categoryId,
        string $name,
        float $plannedAmount,
        ?string $dueDate,
        string $recurrence,
        string $notes
    ): void {
        if ($id <= 0 || trim($name) === '' || $plannedAmount < 0) {
            throw new RuntimeException('A budget item needs a name and a non-negative target.');
        }
        if (!in_array($recurrence, ['none', 'monthly', 'yearly'], true)) {
            $recurrence = 'none';
        }
        if ($dueDate !== null) {
            $this->assertDateInMonth($dueDate, Support::month($month), 'Due date');
        }
        $existing = $this->pdo->prepare('SELECT series_key FROM budget_items WHERE id = :id AND month = :month');
        $existing->execute(['id' => $id, 'month' => Support::month($month)]);
        $series = $existing->fetchColumn();
        if ($series === false) {
            throw new RuntimeException('Budget item was not found.');
        }
        if ($recurrence !== 'none' && !$series) {
            $series = bin2hex(random_bytes(12));
        }
        if ($recurrence === 'none') {
            $series = null;
        }
        $statement = $this->pdo->prepare(
            'UPDATE budget_items
             SET category_id = :category_id, name = :name, planned_amount = :planned_amount,
                 due_date = :due_date, recurrence = :recurrence, series_key = :series_key, notes = :notes
             WHERE id = :id AND month = :month'
        );
        $statement->execute([
            'category_id' => $categoryId ?: null,
            'name' => trim($name),
            'planned_amount' => max(0, $plannedAmount),
            'due_date' => $dueDate ?: null,
            'recurrence' => $recurrence,
            'series_key' => $series ?: null,
            'notes' => trim($notes) ?: null,
            'id' => $id,
            'month' => Support::month($month),
        ]);
    }

    public function budgetItems(string $month): array
    {
        $statement = $this->pdo->prepare(
            'SELECT bi.*, c.name AS category_name, c.color AS category_color,
                    COALESCE(SUM(t.amount), 0) AS paid_amount
             FROM budget_items bi
             LEFT JOIN categories c ON c.id = bi.category_id
             LEFT JOIN transactions t ON t.budget_item_id = bi.id
             WHERE bi.month = :month
             GROUP BY bi.id, c.name, c.color
             ORDER BY CASE WHEN bi.due_date IS NULL THEN 1 ELSE 0 END, bi.due_date, bi.name'
        );
        $statement->execute(['month' => $month]);
        $items = $statement->fetchAll();
        foreach ($items as &$item) {
            $planned = (float) $item['planned_amount'];
            $paid = (float) $item['paid_amount'];
            $item['remaining_amount'] = max(0, $planned - $paid);
            $item['progress'] = $planned > 0 ? min(100, ($paid / $planned) * 100) : ($paid > 0 ? 100 : 0);
        }
        unset($item);
        return $items;
    }

    public function addTransaction(
        string $month,
        ?int $budgetItemId,
        ?int $categoryId,
        string $merchant,
        float $amount,
        string $date,
        string $notes
    ): int {
        $month = Support::month($month);
        $this->ensureMonth($month);
        $this->assertDateInMonth($date, $month, 'Expense date');
        if (trim($merchant) === '' || $amount <= 0) {
            throw new RuntimeException('An expense needs a description and an amount greater than zero.');
        }
        if ($budgetItemId) {
            $statement = $this->pdo->prepare('SELECT category_id FROM budget_items WHERE id = :id AND month = :month');
            $statement->execute(['id' => $budgetItemId, 'month' => $month]);
            $itemCategory = $statement->fetchColumn();
            if ($itemCategory === false) {
                throw new RuntimeException('The selected budget item is not part of this month.');
            }
            if (!$categoryId) {
                $categoryId = $itemCategory ? (int) $itemCategory : null;
            }
        }
        $deadPull = $this->automaticAmount(
            $amount,
            (string) $this->settings->get('dead.transaction_mode', 'fixed'),
            (float) $this->settings->get('dead.transaction_value', 0)
        );

        $this->pdo->beginTransaction();
        try {
            $statement = $this->pdo->prepare(
                'INSERT INTO transactions (month, budget_item_id, category_id, merchant, amount, transacted_on, dead_pull_amount, notes)
                 VALUES (:month, :budget_item_id, :category_id, :merchant, :amount, :transacted_on, :dead_pull, :notes)'
            );
            $statement->execute([
                'month' => $month,
                'budget_item_id' => $budgetItemId ?: null,
                'category_id' => $categoryId ?: null,
                'merchant' => trim($merchant),
                'amount' => $amount,
                'transacted_on' => $date,
                'dead_pull' => $deadPull,
                'notes' => trim($notes) ?: null,
            ]);
            $id = (int) $this->pdo->lastInsertId();
            if ($deadPull > 0) {
                $this->insertDeadEntry(
                    $month,
                    'transaction_pull',
                    $deadPull,
                    'transaction',
                    $id,
                    'Automatic dormant transfer for ' . trim($merchant)
                );
            }
            $this->pdo->commit();
            return $id;
        } catch (\Throwable $exception) {
            $this->pdo->rollBack();
            throw $exception;
        }
    }

    public function updateTransaction(
        int $id,
        string $month,
        ?int $budgetItemId,
        ?int $categoryId,
        string $merchant,
        float $amount,
        string $date,
        string $notes
    ): void {
        $month = Support::month($month);
        $this->assertDateInMonth($date, $month, 'Expense date');
        if ($id <= 0 || trim($merchant) === '' || $amount <= 0) {
            throw new RuntimeException('An expense needs a description and an amount greater than zero.');
        }
        $existing = $this->pdo->prepare('SELECT COUNT(*) FROM transactions WHERE id = :id');
        $existing->execute(['id' => $id]);
        if ((int) $existing->fetchColumn() === 0) {
            throw new RuntimeException('Expense was not found.');
        }
        if ($budgetItemId) {
            $statement = $this->pdo->prepare('SELECT category_id FROM budget_items WHERE id = :id AND month = :month');
            $statement->execute(['id' => $budgetItemId, 'month' => $month]);
            $itemCategory = $statement->fetchColumn();
            if ($itemCategory === false) {
                throw new RuntimeException('The selected budget item is not part of this month.');
            }
            if (!$categoryId) {
                $categoryId = $itemCategory ? (int) $itemCategory : null;
            }
        }
        $deadPull = $this->automaticAmount(
            $amount,
            (string) $this->settings->get('dead.transaction_mode', 'fixed'),
            (float) $this->settings->get('dead.transaction_value', 0)
        );
        $this->pdo->beginTransaction();
        try {
            $statement = $this->pdo->prepare(
                'UPDATE transactions
                 SET month = :month, budget_item_id = :budget_item_id, category_id = :category_id,
                     merchant = :merchant, amount = :amount, transacted_on = :transacted_on,
                     dead_pull_amount = :dead_pull, notes = :notes
                 WHERE id = :id'
            );
            $statement->execute([
                'month' => $month,
                'budget_item_id' => $budgetItemId ?: null,
                'category_id' => $categoryId ?: null,
                'merchant' => trim($merchant),
                'amount' => $amount,
                'transacted_on' => $date,
                'dead_pull' => $deadPull,
                'notes' => trim($notes) ?: null,
                'id' => $id,
            ]);
            $this->replaceDeadSource(
                $month,
                'transaction_pull',
                $deadPull,
                'transaction',
                $id,
                'Automatic dormant transfer for ' . trim($merchant)
            );
            $this->pdo->commit();
        } catch (\Throwable $exception) {
            $this->pdo->rollBack();
            throw $exception;
        }
    }

    public function deleteTransaction(int $id): void
    {
        $this->pdo->beginTransaction();
        try {
            $statement = $this->pdo->prepare('DELETE FROM dead_account_entries WHERE source_type = :type AND source_id = :id');
            $statement->execute(['type' => 'transaction', 'id' => $id]);
            $statement = $this->pdo->prepare('DELETE FROM transactions WHERE id = :id');
            $statement->execute(['id' => $id]);
            $this->pdo->commit();
        } catch (\Throwable $exception) {
            $this->pdo->rollBack();
            throw $exception;
        }
    }

    public function transactions(string $month): array
    {
        $statement = $this->pdo->prepare(
            'SELECT t.*, bi.name AS item_name, c.name AS category_name, c.color AS category_color
             FROM transactions t
             LEFT JOIN budget_items bi ON bi.id = t.budget_item_id
             LEFT JOIN categories c ON c.id = t.category_id
             WHERE t.month = :month
             ORDER BY t.transacted_on DESC, t.id DESC'
        );
        $statement->execute(['month' => $month]);
        return $statement->fetchAll();
    }

    public function moveDeadAccount(
        string $month,
        string $direction,
        float $amount,
        string $notes,
        string $destinationType = 'income',
        ?int $destinationId = null
    ): int
    {
        $month = Support::month($month);
        $this->ensureMonth($month);
        if ($amount <= 0) {
            throw new RuntimeException('Transfer amount must be greater than zero.');
        }
        if ($direction === 'restore') {
            if ($amount > $this->deadBalance() + 0.0001) {
                throw new RuntimeException('That restoration is larger than the dormant balance.');
            }
            $sourceType = 'restore_income';
            $sourceId = null;
            $destinationLabel = 'monthly income';
            if ($destinationType === 'item' && $destinationId) {
                $statement = $this->pdo->prepare('SELECT name FROM budget_items WHERE id = :id AND month = :month');
                $statement->execute(['id' => $destinationId, 'month' => $month]);
                $itemName = $statement->fetchColumn();
                if ($itemName === false) {
                    throw new RuntimeException('The selected restoration target was not found.');
                }
                $sourceType = 'restore_item';
                $sourceId = $destinationId;
                $destinationLabel = (string) $itemName;
            }
            $amount *= -1;
            $kind = 'restore';
            $notes = trim($notes) ?: 'Restored to ' . $destinationLabel;
        } else {
            $available = (float) $this->summary($month)['remaining'];
            if ($amount > max(0, $available) + 0.0001) {
                throw new RuntimeException('That deposit is larger than this month’s spendable balance.');
            }
            $kind = 'manual_deposit';
            $sourceType = 'manual';
            $sourceId = null;
            $notes = trim($notes) ?: 'Manual dormant deposit';
        }
        return $this->insertDeadEntry($month, $kind, $amount, $sourceType, $sourceId, $notes);
    }

    public function deadEntries(?string $month = null, int $limit = 100): array
    {
        if ($month) {
            $statement = $this->pdo->prepare(
                'SELECT dae.*, bi.name AS destination_name
                 FROM dead_account_entries dae
                 LEFT JOIN budget_items bi ON dae.source_type = "restore_item" AND bi.id = dae.source_id
                 WHERE dae.month = :month ORDER BY dae.created_at DESC, dae.id DESC LIMIT ' . (int) $limit
            );
            $statement->execute(['month' => $month]);
            return $statement->fetchAll();
        }
        return $this->pdo->query(
            'SELECT dae.*, bi.name AS destination_name
             FROM dead_account_entries dae
             LEFT JOIN budget_items bi ON dae.source_type = "restore_item" AND bi.id = dae.source_id
             ORDER BY dae.created_at DESC, dae.id DESC LIMIT ' . (int) $limit
        )->fetchAll();
    }

    public function deadBalance(): float
    {
        return (float) $this->pdo->query('SELECT COALESCE(SUM(amount), 0) FROM dead_account_entries')->fetchColumn();
    }

    public function addCalendarEvent(string $month, string $title, string $date, string $type, string $notes): int
    {
        $month = Support::month($month);
        $this->assertDateInMonth($date, $month, 'Event date');
        if (trim($title) === '') {
            throw new RuntimeException('Calendar event title is required.');
        }
        if (!in_array($type, ['note', 'payday', 'deadline'], true)) {
            $type = 'note';
        }
        $statement = $this->pdo->prepare(
            'INSERT INTO calendar_events (month, title, event_date, event_type, notes)
             VALUES (:month, :title, :event_date, :event_type, :notes)'
        );
        $statement->execute([
            'month' => $month,
            'title' => trim($title),
            'event_date' => $date,
            'event_type' => $type,
            'notes' => trim($notes) ?: null,
        ]);
        return (int) $this->pdo->lastInsertId();
    }

    public function deleteCalendarEvent(int $id): void
    {
        $statement = $this->pdo->prepare('DELETE FROM calendar_events WHERE id = :id');
        $statement->execute(['id' => $id]);
    }

    public function calendarEntries(string $month): array
    {
        $statement = $this->pdo->prepare(
            'SELECT id, name AS title, due_date AS event_date, "bill" AS event_type, planned_amount AS amount
             FROM budget_items WHERE month = :month1 AND due_date IS NOT NULL
             UNION ALL
             SELECT id, title, event_date, event_type, NULL AS amount
             FROM calendar_events WHERE month = :month2
             ORDER BY event_date'
        );
        $statement->execute(['month1' => $month, 'month2' => $month]);
        return $statement->fetchAll();
    }

    public function summary(string $month, bool $ensure = true): array
    {
        $month = Support::month($month);
        $plan = $ensure ? $this->ensureMonth($month) : $this->plan($month);
        if (!$plan) {
            return [
                'month' => $month, 'starting_income' => 0.0, 'rollover_in' => 0.0,
                'additional_income' => 0.0, 'fresh_income' => 0.0, 'gross_resources' => 0.0, 'expenses' => 0.0,
                'dead_movement' => 0.0, 'remaining' => 0.0, 'planned' => 0.0,
                'dead_deposits' => 0.0, 'dead_restorations' => 0.0, 'paid_to_items' => 0.0,
                'unallocated' => 0.0, 'savings_rate' => 0.0, 'budget_coverage' => 0.0,
            ];
        }
        $income = $this->sum('income_entries', 'amount', $month);
        $expenses = $this->sum('transactions', 'amount', $month);
        $deadMovement = $this->sum('dead_account_entries', 'amount', $month);
        $deadDeposits = $this->sumWhere('dead_account_entries', 'amount', $month, 'amount > 0');
        $deadRestorations = abs($this->sumWhere('dead_account_entries', 'amount', $month, 'amount < 0'));
        $planned = $this->sum('budget_items', 'planned_amount', $month);
        $freshIncome = (float) $plan['starting_income'] + $income;
        $gross = $freshIncome + (float) $plan['rollover_in'];
        $remaining = $gross - $expenses - $deadMovement;
        $usableBeforeExpenses = $gross - $deadMovement;
        return [
            'month' => $month,
            'starting_income' => (float) $plan['starting_income'],
            'rollover_in' => (float) $plan['rollover_in'],
            'additional_income' => $income,
            'fresh_income' => $freshIncome,
            'gross_resources' => $gross,
            'expenses' => $expenses,
            'dead_movement' => $deadMovement,
            'dead_deposits' => $deadDeposits,
            'dead_restorations' => $deadRestorations,
            'remaining' => $remaining,
            'planned' => $planned,
            'paid_to_items' => $this->sumWhere('transactions', 'amount', $month, 'budget_item_id IS NOT NULL'),
            'unallocated' => $usableBeforeExpenses - $planned,
            'savings_rate' => $freshIncome > 0 ? $deadDeposits / $freshIncome * 100 : 0.0,
            'budget_coverage' => $planned > 0 ? min(100, $usableBeforeExpenses / $planned * 100) : 100.0,
        ];
    }

    public function comparison(string $endingMonth, int $months = 12): array
    {
        $ending = new DateTimeImmutable(Support::month($endingMonth) . '-01');
        $rows = [];
        for ($offset = $months - 1; $offset >= 0; $offset--) {
            $month = $ending->modify('-' . $offset . ' months')->format('Y-m');
            $rows[] = $this->summary($month, false);
        }
        return $rows;
    }

    public function yearlySummary(int $year): array
    {
        $totals = [
            'fresh_income' => 0.0,
            'rollover_in' => 0.0,
            'gross_resources' => 0.0,
            'expenses' => 0.0,
            'dead_movement' => 0.0,
            'dead_deposits' => 0.0,
            'dead_restorations' => 0.0,
            'remaining' => 0.0,
            'planned' => 0.0,
        ];
        $months = [];
        for ($monthNumber = 1; $monthNumber <= 12; $monthNumber++) {
            $month = sprintf('%04d-%02d', $year, $monthNumber);
            $summary = $this->summary($month, false);
            $months[] = $summary;
            foreach ($totals as $key => $unused) {
                $totals[$key] += (float) $summary[$key];
            }
        }
        return ['year' => $year, 'totals' => $totals, 'months' => $months];
    }

    public function categoryStats(string $month): array
    {
        $statement = $this->pdo->prepare(
            'SELECT c.id, c.name AS category_name, c.color, c.monthly_limit,
                    COALESCE(SUM(t.amount), 0) AS spent,
                    COALESCE((SELECT SUM(bi.planned_amount) FROM budget_items bi WHERE bi.month = :item_month AND bi.category_id = c.id), 0) AS planned
             FROM categories c
             LEFT JOIN transactions t ON t.category_id = c.id AND t.month = :transaction_month
             WHERE c.active = 1
             GROUP BY c.id, c.name, c.color, c.monthly_limit
             UNION ALL
             SELECT NULL AS id, "Uncategorized" AS category_name, "#718096" AS color, 0 AS monthly_limit,
                    COALESCE(SUM(t.amount), 0) AS spent,
                    COALESCE((SELECT SUM(bi.planned_amount) FROM budget_items bi WHERE bi.month = :uncategorized_item_month AND bi.category_id IS NULL), 0) AS planned
             FROM transactions t
             WHERE t.month = :uncategorized_transaction_month AND t.category_id IS NULL
             HAVING COALESCE(SUM(t.amount), 0) > 0
             ORDER BY spent DESC'
        );
        $statement->execute([
            'item_month' => $month,
            'transaction_month' => $month,
            'uncategorized_item_month' => $month,
            'uncategorized_transaction_month' => $month,
        ]);
        return array_values(array_filter(
            $statement->fetchAll(),
            static fn (array $category): bool => (float) $category['spent'] > 0
                || (float) $category['planned'] > 0
                || (float) $category['monthly_limit'] > 0
        ));
    }

    public function upcomingBills(string $month, int $limit = 6): array
    {
        $items = array_values(array_filter(
            $this->budgetItems($month),
            static fn (array $item): bool => $item['due_date'] !== null && (float) $item['remaining_amount'] > 0
        ));
        usort($items, static fn (array $left, array $right): int => strcmp((string) $left['due_date'], (string) $right['due_date']));
        return array_slice($items, 0, max(1, $limit));
    }

    public function recentActivity(string $month, int $limit = 8): array
    {
        $activity = [];
        foreach ($this->transactions($month) as $transaction) {
            $activity[] = [
                'type' => 'expense',
                'title' => $transaction['merchant'],
                'detail' => $transaction['category_name'] ?: 'Uncategorized',
                'amount' => -(float) $transaction['amount'],
                'date' => $transaction['transacted_on'],
            ];
        }
        foreach ($this->incomes($month) as $income) {
            $activity[] = [
                'type' => 'income',
                'title' => $income['label'],
                'detail' => 'Income received',
                'amount' => (float) $income['amount'],
                'date' => $income['received_on'],
            ];
        }
        foreach ($this->deadEntries($month, 100) as $entry) {
            $activity[] = [
                'type' => (float) $entry['amount'] >= 0 ? 'saved' : 'restored',
                'title' => (float) $entry['amount'] >= 0 ? 'Moved to dormant' : 'Restored from dormant',
                'detail' => $entry['notes'] ?: ucwords(str_replace('_', ' ', $entry['kind'])),
                'amount' => -(float) $entry['amount'],
                'date' => substr((string) $entry['created_at'], 0, 10),
            ];
        }
        usort($activity, static fn (array $left, array $right): int => strcmp($right['date'], $left['date']));
        return array_slice($activity, 0, max(1, $limit));
    }

    public function visitorStats(): array
    {
        $today = (int) $this->pdo->query(
            "SELECT COUNT(DISTINCT visitor_hash) FROM visits WHERE visited_at >= '" . date('Y-m-d') . " 00:00:00'"
        )->fetchColumn();
        $thirtyDays = (int) $this->pdo->query(
            "SELECT COUNT(DISTINCT visitor_hash) FROM visits WHERE visited_at >= '" . date('Y-m-d H:i:s', strtotime('-30 days')) . "'"
        )->fetchColumn();
        $views = (int) $this->pdo->query(
            "SELECT COUNT(*) FROM visits WHERE visited_at >= '" . date('Y-m-d H:i:s', strtotime('-30 days')) . "'"
        )->fetchColumn();
        return ['today' => $today, 'thirty_days' => $thirtyDays, 'views' => $views];
    }

    private function automaticAmount(float $sourceAmount, string $mode, float $value): float
    {
        $amount = $mode === 'percent' ? $sourceAmount * max(0, $value) / 100 : max(0, $value);
        return round(min(max(0, $sourceAmount), $amount), 2);
    }

    private function insertDeadEntry(
        string $month,
        string $kind,
        float $amount,
        ?string $sourceType,
        ?int $sourceId,
        ?string $notes
    ): int {
        $statement = $this->pdo->prepare(
            'INSERT INTO dead_account_entries (month, kind, amount, source_type, source_id, notes)
             VALUES (:month, :kind, :amount, :source_type, :source_id, :notes)'
        );
        $statement->execute([
            'month' => $month,
            'kind' => $kind,
            'amount' => round($amount, 2),
            'source_type' => $sourceType,
            'source_id' => $sourceId,
            'notes' => $notes,
        ]);
        return (int) $this->pdo->lastInsertId();
    }

    private function replaceDeadSource(
        string $month,
        string $kind,
        float $amount,
        string $sourceType,
        int $sourceId,
        string $notes
    ): void {
        $statement = $this->pdo->prepare('DELETE FROM dead_account_entries WHERE source_type = :type AND source_id = :id');
        $statement->execute(['type' => $sourceType, 'id' => $sourceId]);
        if ($amount > 0) {
            $this->insertDeadEntry($month, $kind, $amount, $sourceType, $sourceId, $notes);
        }
    }

    private function sum(string $table, string $column, string $month): float
    {
        $statement = $this->pdo->prepare("SELECT COALESCE(SUM({$column}), 0) FROM {$table} WHERE month = :month");
        $statement->execute(['month' => $month]);
        return (float) $statement->fetchColumn();
    }

    private function sumWhere(string $table, string $column, string $month, string $where): float
    {
        $statement = $this->pdo->prepare("SELECT COALESCE(SUM({$column}), 0) FROM {$table} WHERE month = :month AND {$where}");
        $statement->execute(['month' => $month]);
        return (float) $statement->fetchColumn();
    }

    private function assertDateInMonth(string $date, string $month, string $label): void
    {
        $parts = preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $matches) ? $matches : null;
        if ($parts === null
            || !checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1])
            || substr($date, 0, 7) !== $month
        ) {
            throw new RuntimeException($label . ' must fall within ' . date('F Y', strtotime($month . '-01')) . '.');
        }
    }

    private function materializeRecurringItems(string $month): void
    {
        $targetDate = new DateTimeImmutable($month . '-01');
        $targetMonthNumber = $targetDate->format('m');
        $statements = [
            [
                'sql' => 'SELECT * FROM budget_items
                          WHERE month < :month AND recurrence = :recurrence AND series_key IS NOT NULL
                          ORDER BY month DESC, id DESC',
                'parameters' => ['month' => $month, 'recurrence' => 'monthly'],
            ],
            [
                'sql' => 'SELECT * FROM budget_items
                          WHERE month < :month AND SUBSTR(month, 6, 2) = :month_number
                            AND recurrence = :recurrence AND series_key IS NOT NULL
                          ORDER BY month DESC, id DESC',
                'parameters' => ['month' => $month, 'month_number' => $targetMonthNumber, 'recurrence' => 'yearly'],
            ],
        ];
        foreach ($statements as $source) {
            $statement = $this->pdo->prepare($source['sql']);
            $statement->execute($source['parameters']);
            $seen = [];
            foreach ($statement->fetchAll() as $item) {
                if (isset($seen[$item['series_key']])) {
                    continue;
                }
                $seen[$item['series_key']] = true;
                $exists = $this->pdo->prepare('SELECT COUNT(*) FROM budget_items WHERE month = :month AND series_key = :series_key');
                $exists->execute(['month' => $month, 'series_key' => $item['series_key']]);
                if ((int) $exists->fetchColumn() > 0) {
                    continue;
                }
                $dueDate = null;
                if ($item['due_date']) {
                    $day = (int) substr((string) $item['due_date'], 8, 2);
                    $lastDay = (int) $targetDate->format('t');
                    $dueDate = $month . '-' . str_pad((string) min($day, $lastDay), 2, '0', STR_PAD_LEFT);
                }
                $insert = $this->pdo->prepare(
                    'INSERT INTO budget_items (month, category_id, name, planned_amount, due_date, recurrence, series_key, status, notes)
                     VALUES (:month, :category_id, :name, :planned_amount, :due_date, :recurrence, :series_key, :status, :notes)'
                );
                $insert->execute([
                    'month' => $month,
                    'category_id' => $item['category_id'],
                    'name' => $item['name'],
                    'planned_amount' => $item['planned_amount'],
                    'due_date' => $dueDate,
                    'recurrence' => $item['recurrence'],
                    'series_key' => $item['series_key'],
                    'status' => 'active',
                    'notes' => $item['notes'],
                ]);
            }
        }
    }
}
