package com.leisingha.ledger.transaction;

import com.leisingha.ledger.account.Account;
import com.leisingha.ledger.account.AccountRepository;
import com.leisingha.ledger.common.AccountNotFoundException;
import com.leisingha.ledger.common.AlreadyReversedException;
import com.leisingha.ledger.common.TransactionNotFoundException;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.OffsetDateTime;
import java.util.*;

/**
 * Everything that happens inside a database transaction.
 *
 * <p>Separate from {@link TransactionService} for one specific reason: recovering
 * from a duplicate-key collision has to happen <em>outside</em> the transaction
 * that collided. Once a constraint fires, that transaction is doomed — it is
 * marked rollback-only, and any further query on it fails at commit. So the write
 * lives here behind its own {@code @Transactional} boundary, and the service that
 * calls it handles the collision after that boundary has closed.
 *
 * <p>These are two beans rather than two methods on one because Spring's
 * {@code @Transactional} works through a proxy: a method calling another method
 * on {@code this} goes straight to the target and never crosses the proxy, so the
 * inner annotation would be silently ignored.
 */
@Component
class TransactionWriter {

    private final LedgerTransactionRepository transactions;
    private final AccountRepository accounts;

    TransactionWriter(LedgerTransactionRepository transactions, AccountRepository accounts) {
        this.transactions = transactions;
        this.accounts = accounts;
    }

    /**
     * Validates and writes a transaction with its entries.
     *
     * <p>If any rule throws — unbalanced, unknown account, mixed currency — the
     * whole transaction rolls back and not one row survives. That is the entire
     * reason the check and the write share a boundary.
     */
    @Transactional
    TransactionResponse write(String idempotencyKey, CreateTransactionRequest request) {
        List<CreateEntryRequest> entries = request.entries();

        // Order matters and is fixed by the spec: shape first, then existence,
        // then the rules that need the accounts in hand.
        TransactionValidator.validateShape(entries);
        Map<UUID, Account> referenced = loadAccounts(entries);
        TransactionValidator.validateAgainstAccounts(entries, referenced);

        LedgerTransaction transaction = new LedgerTransaction(
            UUID.randomUUID(), idempotencyKey, request.description(), OffsetDateTime.now());

        for (CreateEntryRequest entry : entries) {
            transaction.addEntry(new Entry(
                UUID.randomUUID(), transaction, entry.accountId(), entry.direction(), entry.amountMinor()));
        }

        return TransactionResponse.of(transactions.saveAndFlush(transaction));
    }

    /**
     * Checks the reversal rules and writes the mirror image.
     *
     * <p>Two rules that are easy to conflate. A transaction that is <em>itself</em>
     * a reversal cannot be reversed — undoing an undo is just the original again.
     * And a transaction something else already reverses cannot be reversed twice.
     * The database enforces the second one too, via a unique constraint on
     * {@code reverses_transaction_id}, so a race between two reversal requests
     * cannot slip past the check and produce two.
     */
    @Transactional
    TransactionResponse writeReversal(UUID originalId, String idempotencyKey) {
        LedgerTransaction original = transactions.findById(originalId)
            .orElseThrow(() -> new TransactionNotFoundException(originalId));

        if (original.isReversal()) {
            throw new AlreadyReversedException(
                "Transaction " + originalId + " is itself a reversal of "
                    + original.getReversesTransactionId() + " and cannot be reversed");
        }

        transactions.findByReversesTransactionId(originalId).ifPresent(existing -> {
            throw new AlreadyReversedException(
                "Transaction " + originalId + " was already reversed by " + existing.getId());
        });

        LedgerTransaction reversal =
            original.reverseWith(UUID.randomUUID(), idempotencyKey, OffsetDateTime.now());

        return TransactionResponse.of(transactions.saveAndFlush(reversal));
    }

    /** Loads every referenced account in one query, failing on the first unknown id. */
    private Map<UUID, Account> loadAccounts(List<CreateEntryRequest> entries) {
        Set<UUID> ids = new LinkedHashSet<>();
        entries.forEach(entry -> ids.add(entry.accountId()));

        Map<UUID, Account> found = new HashMap<>();
        accounts.findAllById(ids).forEach(account -> found.put(account.getId(), account));

        for (UUID id : ids) {
            if (!found.containsKey(id)) {
                throw new AccountNotFoundException(id);
            }
        }
        return found;
    }
}
