package adapter

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"
)

// The bug this closes: alertstore's outbox retries a dispatch whose response
// was lost or timed out, carrying the same run_id. The adapter used to start a
// session every time, so a slow success produced two agents investigating one
// alert — and since alertstore's report merge takes the last non-empty value
// per field, whichever finished second silently overwrote the other's verdict.
func TestRedeliveredDispatchReusesTheSession(t *testing.T) {
	var sessions atomic.Int32
	usherSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		n := sessions.Add(1)
		w.WriteHeader(http.StatusAccepted)
		fmt.Fprintf(w, `{"id":"sess-%d"}`, n)
	}))
	defer usherSrv.Close()

	reports := make(chan string, 8)
	callback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		body, _ := io.ReadAll(r.Body)
		reports <- string(body)
	}))
	defer callback.Close()

	a := newTestAdapter(t, usherSrv.URL, "http://proxy:8990")
	body := strings.Replace(sampleDispatch, "%s", callback.URL, 1)

	first := post(t, a.Handler(), body)
	second := post(t, a.Handler(), body)

	if first.Code != http.StatusAccepted || second.Code != http.StatusAccepted {
		t.Fatalf("codes = %d, %d; want 202 both", first.Code, second.Code)
	}
	if got := sessions.Load(); got != 1 {
		t.Errorf("started %d usher sessions for one run, want 1", got)
	}

	idOf := func(rec *httptest.ResponseRecorder) string {
		var body struct {
			SessionID string `json:"session_id"`
		}
		if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
			t.Fatal(err)
		}
		return body.SessionID
	}
	if idOf(first) != idOf(second) {
		t.Errorf("session ids differ: %q vs %q", idOf(first), idOf(second))
	}

	// The running report belongs to the first delivery only: repeating it would
	// overwrite a session_ref that is already right.
	time.Sleep(200 * time.Millisecond)
	if n := len(reports); n != 1 {
		t.Errorf("callback got %d running reports, want 1", n)
	}
}

// Two retries arriving together must not race past each other into two
// sessions, which is what a plain check-then-create would do.
func TestConcurrentRedeliveriesStartOneSession(t *testing.T) {
	var sessions atomic.Int32
	usherSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		n := sessions.Add(1)
		// Slow enough that a second caller is inside the handler before the
		// first has answered — the exact window the bug lived in.
		time.Sleep(150 * time.Millisecond)
		w.WriteHeader(http.StatusAccepted)
		fmt.Fprintf(w, `{"id":"sess-%d"}`, n)
	}))
	defer usherSrv.Close()

	callback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
	}))
	defer callback.Close()

	a := newTestAdapter(t, usherSrv.URL, "http://proxy:8990")
	body := strings.Replace(sampleDispatch, "%s", callback.URL, 1)

	var wg sync.WaitGroup
	for i := 0; i < 4; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			post(t, a.Handler(), body)
		}()
	}
	wg.Wait()

	if got := sessions.Load(); got != 1 {
		t.Errorf("started %d usher sessions concurrently, want 1", got)
	}
}

// A failure must not be remembered. Caching it would turn a transient usher
// outage into a run that can never be dispatched — the opposite of what
// alertstore's retry is for.
func TestFailedStartIsRetryable(t *testing.T) {
	var attempts atomic.Int32
	usherSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if attempts.Add(1) == 1 {
			w.WriteHeader(http.StatusInternalServerError)
			return
		}
		w.WriteHeader(http.StatusAccepted)
		_, _ = w.Write([]byte(`{"id":"sess-ok"}`))
	}))
	defer usherSrv.Close()

	callback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
	}))
	defer callback.Close()

	a := newTestAdapter(t, usherSrv.URL, "http://proxy:8990")
	body := strings.Replace(sampleDispatch, "%s", callback.URL, 1)

	if rec := post(t, a.Handler(), body); rec.Code != http.StatusBadGateway {
		t.Fatalf("first attempt = %d, want 502", rec.Code)
	}
	rec := post(t, a.Handler(), body)
	if rec.Code != http.StatusAccepted {
		t.Fatalf("retry after recovery = %d, want 202; body %s", rec.Code, rec.Body.String())
	}
	if got := attempts.Load(); got != 2 {
		t.Errorf("usher saw %d attempts, want 2", got)
	}
}

// Different runs are independent, including two runs on the same alert.
func TestDistinctRunsGetDistinctSessions(t *testing.T) {
	tracker := newTestTracker()
	create := func(id string) func(context.Context) (string, error) {
		return func(context.Context) (string, error) { return id, nil }
	}

	a, _, _ := tracker.start(context.Background(), "ALT-1", 1, create("s1"))
	b, _, _ := tracker.start(context.Background(), "ALT-1", 2, create("s2"))
	c, _, _ := tracker.start(context.Background(), "ALT-2", 1, create("s3"))

	if a == b || a == c || b == c {
		t.Errorf("sessions collided: %q %q %q", a, b, c)
	}
}

func TestTrackerEvictsOldestBeyondCap(t *testing.T) {
	tracker := newTestTracker()
	create := func(context.Context) (string, error) { return "s", nil }

	for i := 0; i < maxTrackedRuns+10; i++ {
		if _, _, err := tracker.start(context.Background(), "ALT-1", int64(i), create); err != nil {
			t.Fatal(err)
		}
	}

	tracker.mu.Lock()
	defer tracker.mu.Unlock()
	if len(tracker.entries) > maxTrackedRuns {
		t.Errorf("tracker holds %d entries, cap is %d", len(tracker.entries), maxTrackedRuns)
	}
	if len(tracker.entries) != len(tracker.order) {
		t.Errorf("map and order disagree: %d vs %d", len(tracker.entries), len(tracker.order))
	}
	// The oldest is gone; the newest is not.
	if _, ok := tracker.entries[runKey("ALT-1", 0)]; ok {
		t.Error("oldest entry survived eviction")
	}
	if _, ok := tracker.entries[runKey("ALT-1", maxTrackedRuns+9)]; !ok {
		t.Error("newest entry was evicted")
	}
}

func TestTrackerForgetsFailuresWithoutLeaking(t *testing.T) {
	tracker := newTestTracker()
	fail := func(context.Context) (string, error) { return "", errors.New("usher down") }

	if _, _, err := tracker.start(context.Background(), "ALT-1", 1, fail); err == nil {
		t.Fatal("want error")
	}

	tracker.mu.Lock()
	defer tracker.mu.Unlock()
	if len(tracker.entries) != 0 || len(tracker.order) != 0 {
		t.Errorf("failed entry left behind: %d entries, %d order", len(tracker.entries), len(tracker.order))
	}
}

func newTestTracker() *sessionTracker { return newSessionTracker() }
