package messaging

import (
	"sync"
	"sync/atomic"
	"testing"
	"time"

	mqtt "github.com/eclipse/paho.mqtt.golang"
)

func TestEveryJobRuns(t *testing.T) {
	pool := newPosPool("test", 4, 16)

	var done int64
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		pool.submit(func() {
			defer wg.Done()
			atomic.AddInt64(&done, 1)
		})
	}

	wg.Wait()
	pool.stop()

	if got := atomic.LoadInt64(&done); got != 100 {
		t.Errorf("ran %d jobs, want 100", got)
	}
}

func TestConcurrencyIsBounded(t *testing.T) {
	// The reason the pool exists. Unbounded concurrency would open a database
	// transaction per message and exhaust the connection pool under a storm,
	// stalling every one of them at once.
	const workers = 4
	pool := newPosPool("test", workers, 64)

	var inFlight, peak int64
	var wg sync.WaitGroup

	for i := 0; i < 200; i++ {
		wg.Add(1)
		pool.submit(func() {
			defer wg.Done()

			now := atomic.AddInt64(&inFlight, 1)
			for {
				was := atomic.LoadInt64(&peak)
				if now <= was || atomic.CompareAndSwapInt64(&peak, was, now) {
					break
				}
			}

			time.Sleep(time.Millisecond)
			atomic.AddInt64(&inFlight, -1)
		})
	}

	wg.Wait()
	pool.stop()

	if got := atomic.LoadInt64(&peak); got > workers {
		t.Errorf("peak concurrency %d exceeded the %d workers", got, workers)
	}
}

func TestSubmitBlocksRatherThanDroppingWork(t *testing.T) {
	// A full queue must slow the caller down, not discard a bill. Dropping
	// would throw away work already accepted from the broker, and the terminal
	// would only find out at its ack timeout.
	pool := newPosPool("test", 1, 1)

	release := make(chan struct{})
	var ran int64

	// Occupy the single worker.
	pool.submit(func() {
		<-release
		atomic.AddInt64(&ran, 1)
	})

	// Fill the queue, then a third submit must block until the worker frees up.
	pool.submit(func() { atomic.AddInt64(&ran, 1) })

	blocked := make(chan struct{})
	go func() {
		pool.submit(func() { atomic.AddInt64(&ran, 1) })
		close(blocked)
	}()

	select {
	case <-blocked:
		t.Fatal("submit returned while the queue was full; work would be dropped under load")
	case <-time.After(100 * time.Millisecond):
		// Correctly blocked.
	}

	close(release)

	select {
	case <-blocked:
	case <-time.After(3 * time.Second):
		t.Fatal("submit never unblocked after the worker freed up")
	}

	pool.stop()

	if got := atomic.LoadInt64(&ran); got != 3 {
		t.Errorf("ran %d jobs, want 3 — none may be lost", got)
	}
}

func TestStopDrainsAcceptedWork(t *testing.T) {
	// A bill mid-commit must still get its ack. Without one the terminal holds
	// it and sends it again on restart — harmless, but avoidable.
	pool := newPosPool("test", 2, 64)

	var done int64
	for i := 0; i < 50; i++ {
		pool.submit(func() {
			time.Sleep(time.Millisecond)
			atomic.AddInt64(&done, 1)
		})
	}

	pool.stop()

	if got := atomic.LoadInt64(&done); got != 50 {
		t.Errorf("only %d of 50 jobs completed before shutdown finished", got)
	}
}

func TestSubmitAfterStopStillRunsTheWork(t *testing.T) {
	// A message that arrived during shutdown has already been taken from the
	// broker. Discarding it would lose a bill we accepted responsibility for.
	pool := newPosPool("test", 2, 8)
	pool.stop()

	var ran int64
	pool.submit(func() { atomic.AddInt64(&ran, 1) })

	if got := atomic.LoadInt64(&ran); got != 1 {
		t.Error("work submitted during shutdown was discarded")
	}
}

func TestStopIsIdempotent(t *testing.T) {
	// Close() may be reached twice on a shutdown path; a second close of the
	// jobs channel would panic and take the process down mid-drain.
	pool := newPosPool("test", 2, 8)
	pool.stop()
	pool.stop()
	pool.stop()
}

func TestPoolSizeFallsBackAndClamps(t *testing.T) {
	t.Setenv("POS_TEST_WORKERS", "")
	if got := posPoolSize("POS_TEST_WORKERS", 8); got != 8 {
		t.Errorf("unset = %d, want the fallback 8", got)
	}

	t.Setenv("POS_TEST_WORKERS", "not a number")
	if got := posPoolSize("POS_TEST_WORKERS", 8); got != 8 {
		t.Errorf("garbage = %d, want the fallback 8", got)
	}

	t.Setenv("POS_TEST_WORKERS", "0")
	if got := posPoolSize("POS_TEST_WORKERS", 8); got != 8 {
		t.Errorf("zero = %d, want the fallback 8", got)
	}

	t.Setenv("POS_TEST_WORKERS", "-4")
	if got := posPoolSize("POS_TEST_WORKERS", 8); got != 8 {
		t.Errorf("negative = %d, want the fallback 8", got)
	}

	t.Setenv("POS_TEST_WORKERS", "24")
	if got := posPoolSize("POS_TEST_WORKERS", 8); got != 24 {
		t.Errorf("explicit = %d, want 24", got)
	}

	// Clamped: more workers than the database can serve just moves the queue
	// inside the driver, where there is no backpressure to feel.
	t.Setenv("POS_TEST_WORKERS", "100000")
	if got := posPoolSize("POS_TEST_WORKERS", 8); got != 128 {
		t.Errorf("absurd = %d, want the 128 clamp", got)
	}
}

func TestAWrappedHandlerCopiesThePayload(t *testing.T) {
	// paho reuses its buffer once a handler returns, and with a pool the work
	// now happens *after* that. Without a copy a queued bill would be read as
	// whatever message happened to arrive next — silently, and as valid JSON
	// often enough to commit the wrong sale.
	pool := newPosPool("test", 1, 4)

	seen := make(chan string, 1)
	wrapped := wrapHandler(pool, func(_ mqtt.Client, msg mqtt.Message) {
		seen <- string(msg.Payload())
	})

	// A buffer paho would reuse.
	buffer := []byte(`{"batch_id":"original"}`)
	wrapped(nil, fakeMessage{topic: "nearle/pos/12/T4A9/order", payload: buffer})

	// Overwrite it the instant the handler returns, exactly as paho would.
	for i := range buffer {
		buffer[i] = 'X'
	}

	select {
	case got := <-seen:
		if got != `{"batch_id":"original"}` {
			t.Errorf("handler saw %q — the payload was not copied before queueing", got)
		}
	case <-time.After(3 * time.Second):
		t.Fatal("the wrapped handler never ran")
	}

	pool.stop()
}

func TestAWrappedHandlerKeepsTheTopic(t *testing.T) {
	// Store and terminal are read from the topic, never the body. Losing it in
	// the hand-off would leave the ack with nowhere to go.
	pool := newPosPool("test", 1, 4)

	seen := make(chan string, 1)
	wrapped := wrapHandler(pool, func(_ mqtt.Client, msg mqtt.Message) {
		seen <- msg.Topic()
	})

	wrapped(nil, fakeMessage{topic: "nearle/pos/1135/T4A9/order", payload: []byte("{}")})

	select {
	case got := <-seen:
		if got != "nearle/pos/1135/T4A9/order" {
			t.Errorf("topic = %q, want nearle/pos/1135/T4A9/order", got)
		}
	case <-time.After(3 * time.Second):
		t.Fatal("the wrapped handler never ran")
	}

	pool.stop()
}
