package cron

import (
	"context"
	"errors"
	"io"
	"log/slog"
	"sync/atomic"
	"testing"
	"time"
)

// Intervals here are tens of milliseconds: long enough that scheduler jitter
// cannot invert an assertion, short enough that the whole file runs in well
// under a second.

func discard() *slog.Logger {
	return slog.New(slog.NewTextHandler(io.Discard, nil))
}

// The first run must be one interval after start, never at startup — the
// deploy hook already warmed the cache, so a boot-time run is pure waste.
func TestFirstRunIsDeferredOneInterval(t *testing.T) {
	t.Parallel()
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	var runs atomic.Int32
	done := make(chan struct{})
	go func() {
		defer close(done)
		Run(ctx, discard(), Job{
			Name:     "test",
			Interval: 60 * time.Millisecond,
			Run: func(context.Context) error {
				runs.Add(1)
				return nil
			},
		})
	}()

	// Well inside the first interval: nothing may have run yet.
	time.Sleep(20 * time.Millisecond)
	if got := runs.Load(); got != 0 {
		t.Fatalf("job ran %d time(s) before the first interval elapsed; first run must be deferred", got)
	}

	// Well past the first interval: it must have run by now.
	deadline := time.After(500 * time.Millisecond)
	for runs.Load() == 0 {
		select {
		case <-deadline:
			t.Fatal("job never ran after the first interval elapsed")
		case <-time.After(5 * time.Millisecond):
		}
	}

	cancel()
	<-done
}

// A slow run must not overlap with itself, and ticks that fired mid-run must
// be skipped, not queued — an immediate back-to-back run would double-spend
// the archive-fetch quota just like a concurrent one.
func TestSlowJobNeverOverlapsAndSkipsMissedTicks(t *testing.T) {
	t.Parallel()
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	const interval = 30 * time.Millisecond

	var inFlight, maxInFlight, runs atomic.Int32
	release := make(chan struct{})
	started := make(chan struct{}, 16)

	done := make(chan struct{})
	go func() {
		defer close(done)
		Run(ctx, discard(), Job{
			Name:     "slow",
			Interval: interval,
			Run: func(context.Context) error {
				n := inFlight.Add(1)
				for {
					m := maxInFlight.Load()
					if n <= m || maxInFlight.CompareAndSwap(m, n) {
						break
					}
				}
				runs.Add(1)
				started <- struct{}{}
				<-release // block until the test lets each run finish
				inFlight.Add(-1)
				return nil
			},
		})
	}()

	// First run starts; hold it across several intervals.
	<-started
	time.Sleep(4 * interval)
	if got := maxInFlight.Load(); got != 1 {
		t.Fatalf("job overlapped with itself: max in-flight = %d", got)
	}
	if got := runs.Load(); got != 1 {
		t.Fatalf("expected exactly 1 run while the first is still blocked, got %d", got)
	}
	release <- struct{}{}

	// After release the schedule resumes, but the ticks missed during the
	// block must have been dropped: the next run arrives roughly one interval
	// later, and only one more within that window (not a burst of catch-ups).
	select {
	case <-started:
	case <-time.After(500 * time.Millisecond):
		t.Fatal("job never resumed after the blocked run finished")
	}
	if got := runs.Load(); got != 2 {
		t.Fatalf("missed ticks were replayed as a burst: %d runs total, want 2", got)
	}
	release <- struct{}{}

	cancel()
	<-done
	if got := maxInFlight.Load(); got != 1 {
		t.Fatalf("job overlapped with itself: max in-flight = %d", got)
	}
}

// One failed tick must never kill the ticker — nothing restarts the schedule
// short of a container restart.
func TestFailedTickDoesNotStopSchedule(t *testing.T) {
	t.Parallel()
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	var runs atomic.Int32
	done := make(chan struct{})
	go func() {
		defer close(done)
		Run(ctx, discard(), Job{
			Name:     "flaky",
			Interval: 20 * time.Millisecond,
			Run: func(context.Context) error {
				if runs.Add(1) == 1 {
					return errors.New("backend briefly down")
				}
				return nil
			},
		})
	}()

	deadline := time.After(1 * time.Second)
	for runs.Load() < 3 {
		select {
		case <-deadline:
			t.Fatalf("schedule stalled after a failure: only %d run(s)", runs.Load())
		case <-time.After(5 * time.Millisecond):
		}
	}

	cancel()
	<-done
}

// Cancellation must stop Run promptly even while a job is mid-call: the job
// callbacks are ctx-aware HTTP calls, so cancelling the context aborts the
// in-flight request and the loop must then exit instead of ticking again.
func TestCancelStopsRunWhileJobInFlight(t *testing.T) {
	t.Parallel()
	ctx, cancel := context.WithCancel(context.Background())

	started := make(chan struct{})
	done := make(chan struct{})
	go func() {
		defer close(done)
		Run(ctx, discard(),
			Job{
				Name:     "blocking",
				Interval: 10 * time.Millisecond,
				Run: func(jobCtx context.Context) error {
					close(started)
					<-jobCtx.Done() // behaves like an HTTP call aborted by cancellation
					return jobCtx.Err()
				},
			},
			// A second, idle job proves Run waits for ALL jobs to stop.
			Job{
				Name:     "idle",
				Interval: time.Hour,
				Run:      func(context.Context) error { return nil },
			},
		)
	}()

	<-started
	cancel()

	select {
	case <-done:
	case <-time.After(1 * time.Second):
		t.Fatal("Run did not return promptly after context cancellation")
	}
}
