package telemetry

import (
	"context"
	"sync"
	"time"

	"github.com/getsentry/sentry-go/internal/debuglog"
	"github.com/getsentry/sentry-go/internal/protocol"
	"github.com/getsentry/sentry-go/internal/ratelimit"
	"github.com/getsentry/sentry-go/report"
)

// Scheduler implements a weighted round-robin scheduler for processing buffered events.
type Scheduler struct {
	buffers   map[ratelimit.Category]Buffer[protocol.TelemetryItem]
	transport protocol.TelemetryTransport
	dsn       *protocol.Dsn
	sdkInfo   func() *protocol.SdkInfo
	recorder  report.ClientReportRecorder

	currentCycle []ratelimit.Priority
	cyclePos     int

	ctx          context.Context
	cancel       context.CancelFunc
	processingWg sync.WaitGroup

	mu         sync.Mutex
	cond       *sync.Cond
	startOnce  sync.Once
	finishOnce sync.Once
}

func NewScheduler(
	buffers map[ratelimit.Category]Buffer[protocol.TelemetryItem],
	transport protocol.TelemetryTransport,
	dsn *protocol.Dsn,
	sdkInfo func() *protocol.SdkInfo,
	recorder report.ClientReportRecorder,
) *Scheduler {
	if recorder == nil {
		recorder = report.NoopRecorder()
	}

	ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel is stored in s.cancel and called in Shutdown()

	priorityWeights := map[ratelimit.Priority]int{
		ratelimit.PriorityCritical: 5,
		ratelimit.PriorityHigh:     4,
		ratelimit.PriorityMedium:   3,
		ratelimit.PriorityLow:      2,
		ratelimit.PriorityLowest:   1,
	}

	var currentCycle []ratelimit.Priority
	for priority, weight := range priorityWeights {
		hasBuffers := false
		for _, buffer := range buffers {
			if buffer.Priority() == priority {
				hasBuffers = true
				break
			}
		}

		if hasBuffers {
			for i := 0; i < weight; i++ {
				currentCycle = append(currentCycle, priority)
			}
		}
	}

	s := &Scheduler{
		buffers:      buffers,
		transport:    transport,
		dsn:          dsn,
		sdkInfo:      sdkInfo,
		recorder:     recorder,
		currentCycle: currentCycle,
		ctx:          ctx,
		cancel:       cancel,
	}
	s.cond = sync.NewCond(&s.mu)

	return s
}

func (s *Scheduler) resolveSdkInfo() *protocol.SdkInfo {
	if s.sdkInfo == nil {
		return &protocol.SdkInfo{}
	}
	return s.sdkInfo()
}

func (s *Scheduler) Start() {
	s.startOnce.Do(func() {
		s.processingWg.Add(1)
		go s.run()
	})
}

func (s *Scheduler) Stop(timeout time.Duration) {
	s.finishOnce.Do(func() {
		s.Flush(timeout)

		s.cancel()
		s.cond.Broadcast()

		done := make(chan struct{})
		go func() {
			defer close(done)
			s.processingWg.Wait()
		}()

		select {
		case <-done:
		case <-time.After(timeout):
			debuglog.Printf("scheduler stop timed out after %v", timeout)
		}
	})
}

func (s *Scheduler) Signal() {
	s.cond.Signal()
}

func (s *Scheduler) Add(item protocol.TelemetryItem) bool {
	category := item.GetCategory()
	buffer, exists := s.buffers[category]
	if !exists {
		return false
	}

	accepted := buffer.Offer(item)
	if accepted {
		s.Signal()
	}
	return accepted
}

func (s *Scheduler) Flush(timeout time.Duration) bool {
	ctx, cancel := context.WithTimeout(context.Background(), timeout)
	defer cancel()
	return s.FlushWithContext(ctx)
}

func (s *Scheduler) FlushWithContext(ctx context.Context) bool {
	s.flushBuffers()
	return s.transport.FlushWithContext(ctx)
}

func (s *Scheduler) run() {
	defer s.processingWg.Done()

	go func() {
		ticker := time.NewTicker(100 * time.Millisecond)
		defer ticker.Stop()

		for {
			select {
			case <-ticker.C:
				s.cond.Broadcast()
			case <-s.ctx.Done():
				return
			}
		}
	}()

	for {
		s.mu.Lock()

		for !s.hasWork() && s.ctx.Err() == nil {
			s.cond.Wait()
		}

		if s.ctx.Err() != nil {
			s.mu.Unlock()
			return
		}

		s.mu.Unlock()
		s.processNextBatch()
	}
}

func (s *Scheduler) hasWork() bool {
	for _, buffer := range s.buffers {
		if buffer.IsReadyToFlush() {
			return true
		}
	}
	return false
}

func (s *Scheduler) processNextBatch() {
	if len(s.currentCycle) == 0 {
		return
	}

	priority := s.currentCycle[s.cyclePos]
	s.cyclePos = (s.cyclePos + 1) % len(s.currentCycle)

	var bufferToProcess Buffer[protocol.TelemetryItem]
	var categoryToProcess ratelimit.Category
	for category, buffer := range s.buffers {
		if buffer.Priority() == priority && buffer.IsReadyToFlush() {
			bufferToProcess = buffer
			categoryToProcess = category
			break
		}
	}

	if bufferToProcess != nil {
		s.processItems(bufferToProcess, categoryToProcess, false)
	}
}

func (s *Scheduler) processItems(buffer Buffer[protocol.TelemetryItem], category ratelimit.Category, force bool) {
	var items []protocol.TelemetryItem

	if force {
		items = buffer.Drain()
	} else {
		items = buffer.PollIfReady()
	}

	if len(items) == 0 {
		return
	}

	if s.isRateLimited(category) {
		for _, item := range items {
			s.recorder.RecordItem(report.ReasonRateLimitBackoff, item)
		}
		return
	}
	if !s.transport.HasCapacity() {
		for _, item := range items {
			s.recorder.RecordItem(report.ReasonQueueOverflow, item)
		}
		return
	}

	switch category {
	case ratelimit.CategoryLog:
		logs := protocol.Logs(items)
		header := &protocol.EnvelopeHeader{EventID: protocol.GenerateEventID(), SentAt: time.Now(), Dsn: s.dsn, Sdk: s.resolveSdkInfo()}
		envelope := protocol.NewEnvelope(header)
		item, err := logs.ToEnvelopeItem()
		if err != nil {
			debuglog.Printf("error creating log batch envelope item: %v", err)
			return
		}
		envelope.AddItem(item)
		if err := s.transport.SendEnvelope(envelope); err != nil {
			debuglog.Printf("error sending envelope: %v", err)
		}
		return
	case ratelimit.CategoryTraceMetric:
		metrics := protocol.Metrics(items)
		header := &protocol.EnvelopeHeader{EventID: protocol.GenerateEventID(), SentAt: time.Now(), Dsn: s.dsn, Sdk: s.resolveSdkInfo()}
		envelope := protocol.NewEnvelope(header)
		item, err := metrics.ToEnvelopeItem()
		if err != nil {
			debuglog.Printf("error creating trace metric batch envelope item: %v", err)
			return
		}
		envelope.AddItem(item)
		if err := s.transport.SendEnvelope(envelope); err != nil {
			debuglog.Printf("error sending envelope: %v", err)
		}
		return
	default:
		// if the buffers are properly configured, buffer.PollIfReady should return a single item for every category
		// other than logs. We still iterate over the items just in case, because we don't want to send broken envelopes.
		for _, it := range items {
			convertible, ok := it.(protocol.EnvelopeItemConvertible)
			if !ok {
				debuglog.Printf("item does not implement EnvelopeItemConvertible: %T", it)
				continue
			}
			s.sendItem(convertible)
		}
	}
}

func (s *Scheduler) sendItem(item protocol.EnvelopeItemConvertible) {
	header := &protocol.EnvelopeHeader{
		EventID: item.GetEventID(),
		SentAt:  time.Now(),
		Dsn:     s.dsn,
		Trace:   item.GetDynamicSamplingContext(),
		Sdk:     s.resolveSdkInfo(),
	}
	if header.EventID == "" {
		header.EventID = protocol.GenerateEventID()
	}

	// TODO: need to restructure the abstraction layer to actually return envelopes instead of
	// envelope items. The problem with envelope items, is that for events and batches we might
	// need envelopes (eg. attachments need to be added separately and splitting the `Add` path
	// is problematic). Currently this is a workaround for adding attachments to events without
	// adding a circular dependency on telemetry/scheduler.
	var envelope *protocol.Envelope
	if convertible, ok := item.(protocol.EnvelopeConvertible); ok {
		var err error
		envelope, err = convertible.ToEnvelope(header)
		if err != nil {
			debuglog.Printf("error while converting to envelope: %v", err)
			s.recorder.RecordItem(report.ReasonInternalError, item)
			return
		}
	} else {
		envItem, err := item.ToEnvelopeItem()
		if err != nil {
			debuglog.Printf("error while converting to envelope item: %v", err)
			s.recorder.RecordItem(report.ReasonInternalError, item)
			return
		}
		envelope = protocol.NewEnvelope(header)
		envelope.AddItem(envItem)
	}

	if err := s.transport.SendEnvelope(envelope); err != nil {
		debuglog.Printf("error sending envelope: %v", err)
	}
}

func (s *Scheduler) flushBuffers() {
	for category, buffer := range s.buffers {
		if !buffer.IsEmpty() {
			s.processItems(buffer, category, true)
		}
	}
}

func (s *Scheduler) isRateLimited(category ratelimit.Category) bool {
	return s.transport.IsRateLimited(category)
}
