package outbox

import (
	"context"
	"errors"
	"log/slog"
	"math"
	"time"
)

type WorkerConfig struct {
	WorkerID     string
	BatchSize    int
	PollInterval time.Duration
	Lease        time.Duration
	MaxAttempts  int
	MaxBackoff   time.Duration
}

type Worker struct {
	store     *Store
	publisher *RedisPublisher
	config    WorkerConfig
	logger    *slog.Logger
}

func NewWorker(store *Store, publisher *RedisPublisher, config WorkerConfig, logger *slog.Logger) *Worker {
	return &Worker{store: store, publisher: publisher, config: config, logger: logger}
}

func (w *Worker) Run(ctx context.Context) error {
	for {
		processed, err := w.runBatch(ctx)
		if err != nil && ctx.Err() == nil && w.logger != nil {
			w.logger.Error("outbox batch failed", "error", err)
		}
		if ctx.Err() != nil {
			return nil
		}
		if err == nil && processed > 0 {
			continue
		}
		timer := time.NewTimer(w.config.PollInterval)
		select {
		case <-ctx.Done():
			timer.Stop()
			return nil
		case <-timer.C:
		}
	}
}

func (w *Worker) runBatch(ctx context.Context) (int, error) {
	// Gate on the downstream publisher before claiming anything. Claiming
	// increments each event's attempt counter, so claiming during a Redis
	// outage would burn every queued event's delivery budget and dead-letter
	// the whole queue the moment the budget ran out — even though the events
	// themselves were never at fault. When Redis is unreachable we back off
	// instead, leaving events untouched in PostgreSQL until it recovers.
	if err := w.publisher.Ping(ctx); err != nil {
		return 0, err
	}
	events, err := w.store.Claim(ctx, w.config.WorkerID, w.config.BatchSize, w.config.Lease)
	if err != nil {
		return 0, err
	}
	var batchErr error
	for _, event := range events {
		result, publishErr := w.publisher.Publish(ctx, event)
		if publishErr == nil {
			if err := w.store.MarkProcessed(ctx, event.EventID, w.config.WorkerID, result.StreamID); err != nil {
				batchErr = errors.Join(batchErr, err)
			}
			continue
		}
		delay := retryDelay(event.Attempts, w.config.MaxBackoff)
		if err := w.store.MarkFailed(ctx, event, w.config.WorkerID, publishErr, w.config.MaxAttempts, delay); err != nil {
			batchErr = errors.Join(batchErr, err)
		}
	}
	return len(events), batchErr
}

func retryDelay(attempt int, maximum time.Duration) time.Duration {
	seconds := math.Pow(2, float64(max(attempt-1, 0)))
	delay := time.Duration(seconds * float64(time.Second))
	if delay > maximum {
		return maximum
	}
	return delay
}
