# frozen_string_literal: true

# A serial retry model, not a Sidekiq/Rails/Stripe integration.
# The same in-memory store and gateway survive replacement job instances.
# No sockets, credentials, database, real payments, or background processes.
# Reproduce the recorded environment; install this version if unavailable.
gem "minitest", "6.0.6"
require "minitest/autorun"

module RetryWindowLab
  class DuplicateOperation < StandardError; end
  class ChangedRequest < StandardError; end
  class SimulatedCrash < StandardError; end

  class PaymentStore
    def initialize
      @rows = {}
    end

    def create!(operation_id, amount_cents)
      raise DuplicateOperation, operation_id if @rows.key?(operation_id)

      @rows[operation_id] = {
        operation_id: operation_id,
        amount_cents: amount_cents,
        provider_key: "charge:#{operation_id}",
        status: :pending,
        gateway_id: nil
      }
    end

    def fetch(operation_id)
      @rows.fetch(operation_id)
    end

    # This method only models sequential retries. It is not an atomic upsert.
    def resume_or_create!(operation_id, amount_cents)
      row = @rows[operation_id] || create!(operation_id, amount_cents)
      raise ChangedRequest, "operation amount changed" unless row[:amount_cents] == amount_cents

      row
    end

    def complete!(row, gateway_id)
      row[:gateway_id] = gateway_id
      row[:status] = :completed
    end

    def count
      @rows.length
    end
  end

  class FakeGateway
    attr_reader :requests, :charges

    def initialize
      @results = {}
      @requests = 0
      @charges = 0
    end

    # Deliberately simpler than any real provider: no expiry, errors or races.
    def charge(amount_cents:, idempotency_key:)
      @requests += 1
      result = @results[idempotency_key]
      if result
        raise ChangedRequest, "provider parameters changed" unless result[:amount_cents] == amount_cents

        return result[:id]
      end

      @charges += 1
      @results[idempotency_key] = { amount_cents: amount_cents, id: "fake_charge_#{@charges}" }
      @results[idempotency_key][:id]
    end
  end

  class PaymentJob
    def initialize(store, gateway)
      @store = store
      @gateway = gateway
    end

    private

    def charge_and_complete(row, crash_at)
      raise SimulatedCrash, "after row creation, before gateway" if crash_at == :before_gateway

      gateway_id = @gateway.charge(
        amount_cents: row[:amount_cents],
        idempotency_key: row[:provider_key]
      )
      raise SimulatedCrash, "after gateway effect, before local completion" if crash_at == :after_gateway

      @store.complete!(row, gateway_id)
      :completed
    end
  end

  # Reproduces the control flow of the live article's Payment.create! + rescue.
  class BrokenPaymentJob < PaymentJob
    def perform(operation_id, amount_cents, crash_at: nil)
      row = @store.create!(operation_id, amount_cents)
      charge_and_complete(row, crash_at)
    rescue DuplicateOperation
      :ignored
    end
  end

  # A repair for the two modeled interruption windows, not a payment SDK.
  class RetryablePaymentJob < PaymentJob
    def perform(operation_id, amount_cents, crash_at: nil)
      row = @store.resume_or_create!(operation_id, amount_cents)
      return :already_completed if row[:status] == :completed

      charge_and_complete(row, crash_at)
    end
  end
end

class RetryWindowTest < Minitest::Test
  include RetryWindowLab

  def setup
    @store = PaymentStore.new
    @gateway = FakeGateway.new
    @operation_id = "order-42-payment-1"
    @amount_cents = 2500
  end

  def job(type)
    type.new(@store, @gateway)
  end

  def row
    @store.fetch(@operation_id)
  end

  def test_broken_retry_leaves_pending_without_charging
    assert_raises(SimulatedCrash) do
      job(BrokenPaymentJob).perform(@operation_id, @amount_cents, crash_at: :before_gateway)
    end
    assert_equal :ignored, job(BrokenPaymentJob).perform(@operation_id, @amount_cents)
    assert_equal :pending, row[:status]
    assert_equal 0, @gateway.charges
    assert_equal 1, @store.count
  end

  def test_broken_retry_leaves_pending_after_gateway_charged
    assert_raises(SimulatedCrash) do
      job(BrokenPaymentJob).perform(@operation_id, @amount_cents, crash_at: :after_gateway)
    end
    assert_equal :ignored, job(BrokenPaymentJob).perform(@operation_id, @amount_cents)
    assert_equal :pending, row[:status]
    assert_nil row[:gateway_id]
    assert_equal 1, @gateway.charges
  end

  def test_fixed_retry_resumes_before_gateway
    assert_raises(SimulatedCrash) do
      job(RetryablePaymentJob).perform(@operation_id, @amount_cents, crash_at: :before_gateway)
    end
    assert_equal 0, @gateway.charges
    assert_equal :completed, job(RetryablePaymentJob).perform(@operation_id, @amount_cents)
    assert_equal :completed, row[:status]
    assert_equal "fake_charge_1", row[:gateway_id]
    assert_equal 1, @gateway.charges
    assert_equal 1, @store.count
  end

  def test_fixed_retry_reuses_provider_result_after_gateway
    assert_raises(SimulatedCrash) do
      job(RetryablePaymentJob).perform(@operation_id, @amount_cents, crash_at: :after_gateway)
    end
    assert_equal :pending, row[:status]
    assert_equal 1, @gateway.charges
    assert_equal :completed, job(RetryablePaymentJob).perform(@operation_id, @amount_cents)
    assert_equal 2, @gateway.requests
    assert_equal 1, @gateway.charges
    assert_equal "fake_charge_1", row[:gateway_id]
    assert_equal :completed, row[:status]
  end

  def test_completed_operation_skips_another_gateway_request
    job(RetryablePaymentJob).perform(@operation_id, @amount_cents)
    assert_equal :already_completed, job(RetryablePaymentJob).perform(@operation_id, @amount_cents)
    assert_equal 1, @gateway.requests
    assert_equal 1, @gateway.charges
  end

  def test_amount_cannot_change_while_resuming_an_operation
    assert_raises(SimulatedCrash) do
      job(RetryablePaymentJob).perform(@operation_id, @amount_cents, crash_at: :before_gateway)
    end
    assert_raises(ChangedRequest) do
      job(RetryablePaymentJob).perform(@operation_id, @amount_cents + 100)
    end
    assert_equal @amount_cents, row[:amount_cents]
    assert_equal 0, @gateway.requests
  end

  def test_different_operations_are_allowed_to_charge_separately
    job(RetryablePaymentJob).perform(@operation_id, @amount_cents)
    job(RetryablePaymentJob).perform("order-42-payment-2", @amount_cents)
    assert_equal 2, @store.count
    assert_equal 2, @gateway.charges
  end

  def test_gateway_rejects_changed_parameters_under_existing_key
    @gateway.charge(amount_cents: 2500, idempotency_key: "same-key")
    assert_raises(ChangedRequest) do
      @gateway.charge(amount_cents: 2600, idempotency_key: "same-key")
    end
    assert_equal 1, @gateway.charges
  end
end
