package outbox

import (
	"context"
	"errors"
	"fmt"
	"strconv"
	"time"

	"github.com/redis/go-redis/v9"
)

var ErrPublisherUnavailable = errors.New("outbox publisher unavailable")

type PublishResult struct {
	Published bool
	StreamID  string
}

type RedisPublisher struct {
	client    *redis.Client
	stream    string
	maxLength int64
	markerTTL time.Duration
	script    *redis.Script
}

func NewRedisPublisher(client *redis.Client, stream string, maxLength int64, markerTTL time.Duration) *RedisPublisher {
	return &RedisPublisher{client: client, stream: stream, maxLength: maxLength, markerTTL: markerTTL, script: redis.NewScript(publishScript)}
}

// Ping reports whether the downstream Redis stream is reachable. The outbox
// worker calls it before claiming events so a Redis outage never consumes the
// events' delivery budget (claiming increments attempts; dead-lettering then
// burns the whole queue for a fault that was never the events').
func (p *RedisPublisher) Ping(ctx context.Context) error {
	if p == nil || p.client == nil {
		return ErrPublisherUnavailable
	}
	if err := p.client.Ping(ctx).Err(); err != nil {
		return fmt.Errorf("%w: %v", ErrPublisherUnavailable, err)
	}
	return nil
}

func (p *RedisPublisher) Publish(ctx context.Context, event Event) (PublishResult, error) {
	if p == nil || p.client == nil {
		return PublishResult{}, ErrPublisherUnavailable
	}
	marker := "gateway:{outbox}:published:" + event.EventID
	result, err := p.script.Run(ctx, p.client, []string{marker, p.stream},
		int64(p.markerTTL.Seconds()), p.maxLength, event.EventID, event.EventType, event.EventVersion,
		valueOrEmpty(event.TenantID), event.AggregateType, event.AggregateID, string(event.Payload), string(event.TraceContext), event.OccurredAt.UTC().Format(time.RFC3339Nano),
	).Slice()
	if err != nil || len(result) != 2 {
		return PublishResult{}, fmt.Errorf("%w: %v", ErrPublisherUnavailable, err)
	}
	published, err := redisInt(result[0])
	if err != nil {
		return PublishResult{}, fmt.Errorf("%w: %v", ErrPublisherUnavailable, err)
	}
	streamID := "duplicate"
	if result[1] != nil && fmt.Sprint(result[1]) != "" {
		streamID = fmt.Sprint(result[1])
	}
	return PublishResult{Published: published == 1, StreamID: streamID}, nil
}

func valueOrEmpty(value *string) string {
	if value == nil {
		return ""
	}
	return *value
}

func redisInt(value any) (int64, error) {
	switch typed := value.(type) {
	case int64:
		return typed, nil
	case string:
		return strconv.ParseInt(typed, 10, 64)
	case []byte:
		return strconv.ParseInt(string(typed), 10, 64)
	default:
		return 0, fmt.Errorf("unexpected redis integer %T", value)
	}
}

const publishScript = `
if redis.call('SET', KEYS[1], '1', 'NX', 'EX', tonumber(ARGV[1])) then
  local id = redis.call('XADD', KEYS[2], 'MAXLEN', '~', tonumber(ARGV[2]), '*',
    'event_id', ARGV[3], 'event_type', ARGV[4], 'event_version', ARGV[5],
    'tenant_id', ARGV[6], 'aggregate_type', ARGV[7], 'aggregate_id', ARGV[8],
    'payload', ARGV[9], 'trace_context', ARGV[10], 'occurred_at', ARGV[11])
  return {1, id}
end
return {0, ''}
`
