package com.leisingha.ledger.transaction;

import com.leisingha.ledger.common.MissingIdempotencyKeyException;
import com.leisingha.ledger.common.TransactionNotFoundException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;
import java.util.UUID;

/**
 * The public face of posting and reversing.
 *
 * <p>This class owns idempotency; {@link TransactionWriter} owns the database
 * transaction. The split is deliberate — see below, and see
 * {@code TransactionWriter}'s own note.
 */
@Service
public class TransactionService {

    private static final Logger log = LoggerFactory.getLogger(TransactionService.class);

    private final TransactionWriter writer;
    private final LedgerTransactionRepository transactions;

    public TransactionService(TransactionWriter writer, LedgerTransactionRepository transactions) {
        this.writer = writer;
        this.transactions = transactions;
    }

    /**
     * Posts a transaction, or returns the original if this key has been used.
     *
     * <p><strong>What happens when two identical requests arrive at once.</strong>
     * Both pass the lookup below, because neither has committed yet. Both proceed
     * to insert. The unique index on {@code idempotency_key} lets exactly one
     * win; the loser's insert raises a constraint violation, its transaction
     * rolls back having written nothing, and it lands in the catch here — outside
     * that dead transaction — where it re-reads the winner's row and returns it.
     *
     * <p>So the lookup is an optimisation and the constraint is the correctness
     * mechanism. A check-then-act in application code cannot make this safe on its
     * own, no matter where the check is placed; only the database can arbitrate,
     * because only the database sees both writes.
     */
    public PostResult post(String idempotencyKey, CreateTransactionRequest request) {
        requireIdempotencyKey(idempotencyKey);

        Optional<LedgerTransaction> alreadyPosted = transactions.findByIdempotencyKey(idempotencyKey);
        if (alreadyPosted.isPresent()) {
            return PostResult.replayed(TransactionResponse.of(alreadyPosted.get()));
        }

        try {
            return PostResult.created(writer.write(idempotencyKey, request));
        } catch (DataIntegrityViolationException lostTheRace) {
            return PostResult.replayed(reReadWinner(idempotencyKey, lostTheRace));
        }
    }

    /**
     * Reverses a transaction by posting its mirror image.
     *
     * <p>Idempotent on the same terms as {@link #post}: replaying a reversal's key
     * returns the reversal that was already written rather than making a second one.
     */
    public TransactionResponse reverse(UUID originalId, String idempotencyKey) {
        requireIdempotencyKey(idempotencyKey);

        Optional<LedgerTransaction> alreadyReversed = transactions.findByIdempotencyKey(idempotencyKey);
        if (alreadyReversed.isPresent()) {
            return TransactionResponse.of(alreadyReversed.get());
        }

        try {
            return writer.writeReversal(originalId, idempotencyKey);
        } catch (DataIntegrityViolationException lostTheRace) {
            return reReadWinner(idempotencyKey, lostTheRace);
        }
    }

    @Transactional(readOnly = true)
    public TransactionResponse findById(UUID id) {
        return transactions.findById(id)
            .map(TransactionResponse::of)
            .orElseThrow(() -> new TransactionNotFoundException(id));
    }

    /**
     * Reads back the transaction that won a duplicate-key race.
     *
     * <p>Runs in its own transaction, because the one that raised the violation is
     * gone. If nothing is found, the collision was on some other constraint and the
     * original exception is the honest thing to surface.
     */
    private TransactionResponse reReadWinner(String idempotencyKey, DataIntegrityViolationException cause) {
        // Worth a log line: this is a genuine concurrent duplicate, and knowing how
        // often it happens tells you whether clients are retrying more than expected.
        log.info("Lost a duplicate-key race on Idempotency-Key {}; returning the committed transaction",
            idempotencyKey);

        return transactions.findByIdempotencyKey(idempotencyKey)
            .map(TransactionResponse::of)
            .orElseThrow(() -> cause);
    }

    private static void requireIdempotencyKey(String key) {
        if (key == null || key.isBlank()) {
            throw new MissingIdempotencyKeyException();
        }
    }

    /**
     * A posted transaction plus whether it was newly created, so the controller can
     * answer 201 for a new post and 200 for a replayed key.
     */
    public record PostResult(TransactionResponse transaction, boolean created) {

        static PostResult created(TransactionResponse transaction) {
            return new PostResult(transaction, true);
        }

        static PostResult replayed(TransactionResponse transaction) {
            return new PostResult(transaction, false);
        }
    }
}
