package delivery

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/alertstore/pkg/alert"
)

func testSender(t *testing.T, holdRate float64) *AgentSender {
	t.Helper()
	return NewAgentSender(nil, nil, &http.Client{Timeout: 2 * time.Second},
		"http://alertstore.svc/", holdRate, "", slog.Default())
}

func testAlert(t *testing.T, attrs alert.AlertAttrs) *alert.Alert {
	t.Helper()
	raw, err := json.Marshal(attrs)
	require.NoError(t, err)
	return &alert.Alert{ID: "ALT-2607-42", State: alert.StateOpen, Attrs: raw}
}

func TestAgentSenderEnabled(t *testing.T) {
	t.Parallel()

	require.True(t, testSender(t, 0).Enabled())
	require.False(t, NewAgentSender(nil, nil, nil, "", 0, "", slog.Default()).Enabled())
	// Unconfigured is the state alertstore ran in before agents existed, so a
	// nil sender must answer rather than panic.
	require.False(t, (*AgentSender)(nil).Enabled())
}

// A dispatch that is retried must land in the same population it started in.
// Flipping between shown and held would put one run in both samples and make
// the comparison the held sample exists for meaningless.
func TestShouldHoldIsStableAcrossRetries(t *testing.T) {
	t.Parallel()

	s := testSender(t, 0.5)
	first := s.shouldHold("ALT-2607-42", "runbook-triage")
	for i := 0; i < 50; i++ {
		require.Equal(t, first, s.shouldHold("ALT-2607-42", "runbook-triage"))
	}
	// Same alert, different agent: an independent draw, so one agent being held
	// says nothing about another dispatched for the same alert.
	require.NotPanics(t, func() { s.shouldHold("ALT-2607-42", "tikv-inspector") })
}

func TestShouldHoldRateBounds(t *testing.T) {
	t.Parallel()

	require.False(t, testSender(t, 0).shouldHold("ALT-1", "a"))
	require.False(t, testSender(t, -1).shouldHold("ALT-1", "a"))
	require.True(t, testSender(t, 1).shouldHold("ALT-1", "a"))

	// A rate somewhere in the middle holds roughly that share. Loose bounds:
	// this asserts the knob is connected, not that fnv is uniform.
	s := testSender(t, 0.3)
	held := 0
	for i := 0; i < 1000; i++ {
		if s.shouldHold("ALT-"+string(rune('a'+i%26))+string(rune('a'+i/26)), "triage") {
			held++
		}
	}
	require.Greater(t, held, 150)
	require.Less(t, held, 450)
}

// The endpoint check is safeHTTPURL, shared with the links the cards render, so
// that hardening it later reaches the one path that makes an outbound request.
func TestEndpointCheck(t *testing.T) {
	t.Parallel()

	require.NotEmpty(t, safeHTTPURL("http://triage.svc.cluster.local:8080/dispatch"))
	require.NotEmpty(t, safeHTTPURL("https://agents.example.com/alertstore"))
	require.Empty(t, safeHTTPURL(""))
	require.Empty(t, safeHTTPURL("triage.svc/dispatch"))
	require.Empty(t, safeHTTPURL("file:///etc/passwd"))
}

// The failure taxonomy is the whole reason dispatch goes through the outbox: a
// refusal must stop, a rate limit and an outage must come back.
func TestAgentPostFailureClassification(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name      string
		status    int
		wantErr   bool
		permanent bool
	}{
		{name: "accepted", status: http.StatusAccepted},
		{name: "ok", status: http.StatusOK},
		{name: "refused", status: http.StatusBadRequest, wantErr: true, permanent: true},
		{name: "unauthorized", status: http.StatusUnauthorized, wantErr: true, permanent: true},
		{name: "rate limited", status: http.StatusTooManyRequests, wantErr: true},
		{name: "agent down", status: http.StatusBadGateway, wantErr: true},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				require.Equal(t, http.MethodPost, r.Method)
				require.Equal(t, "application/json", r.Header.Get("Content-Type"))
				w.WriteHeader(tc.status)
				_, _ = w.Write([]byte("detail"))
			}))
			defer srv.Close()

			err := testSender(t, 0).post(context.Background(), srv.URL, dispatchBody{Agent: "triage"})
			if !tc.wantErr {
				require.NoError(t, err)
				return
			}
			require.Error(t, err)
			var perm *permanentError
			require.Equal(t, tc.permanent, errors.As(err, &perm),
				"permanent classification for %d", tc.status)
		})
	}
}

func TestAgentPostSendsTheContract(t *testing.T) {
	t.Parallel()

	var got dispatchBody
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.NoError(t, json.NewDecoder(r.Body).Decode(&got))
		w.WriteHeader(http.StatusAccepted)
	}))
	defer srv.Close()

	body := dispatchBody{
		AlertID:        "ALT-2607-42",
		RunID:          7,
		Agent:          "runbook-triage",
		TriggerKind:    "alert_created",
		Alertname:      "UnhealthyPod",
		Classification: "page",
		ClusterID:      "cluster-abc",
		TenantID:       "tenant-123",
		Description:    "Pod is unhealthy",
		Disclosure:     "held",
		Callback: dispatchCallback{
			URL: "http://alertstore.svc/api/v1/alertstore/alerts/ALT-2607-42/agent-runs/7",
		},
	}
	require.NoError(t, testSender(t, 0).post(context.Background(), srv.URL, body))
	require.Equal(t, body.AlertID, got.AlertID)
	require.Equal(t, body.RunID, got.RunID)
	require.Equal(t, body.Alertname, got.Alertname)
	require.Equal(t, body.ClusterID, got.ClusterID)
	require.Equal(t, body.TenantID, got.TenantID)
	require.Equal(t, body.Disclosure, got.Disclosure)
	require.Equal(t, body.Callback, got.Callback)
}

func TestAgentPostSignsWhenSecretSet(t *testing.T) {
	t.Parallel()

	const secret = "s3cr3t"
	var gotHeader string
	var gotBody []byte
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotHeader = r.Header.Get(dispatchSignatureHeader)
		gotBody, _ = io.ReadAll(r.Body)
		w.WriteHeader(http.StatusAccepted)
	}))
	defer srv.Close()

	sender := NewAgentSender(nil, nil, &http.Client{Timeout: 2 * time.Second},
		"http://alertstore.svc/", 0, secret, slog.Default())
	require.NoError(t, sender.post(context.Background(), srv.URL,
		dispatchBody{AlertID: "ALT-2607-42", RunID: 7, Agent: "triage",
			Callback: dispatchCallback{URL: "http://alertstore.svc/x"}}))

	require.NotEmpty(t, gotHeader, "a sender with a secret must sign the dispatch")

	// The header the receiver gets must verify against the body it received.
	var tPart, v1 string
	for _, f := range strings.Split(gotHeader, ",") {
		if k, v, ok := strings.Cut(f, "="); ok {
			switch k {
			case "t":
				tPart = v
			case "v1":
				v1 = v
			}
		}
	}
	require.NotEmpty(t, tPart)
	require.NotEmpty(t, v1)
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(tPart))
	mac.Write([]byte("."))
	mac.Write(gotBody)
	require.Equal(t, hex.EncodeToString(mac.Sum(nil)), v1)
}

func TestAgentPostUnsignedWithoutSecret(t *testing.T) {
	t.Parallel()

	var gotHeader string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotHeader = r.Header.Get(dispatchSignatureHeader)
		w.WriteHeader(http.StatusAccepted)
	}))
	defer srv.Close()

	require.NoError(t, testSender(t, 0).post(context.Background(), srv.URL,
		dispatchBody{Agent: "triage", Callback: dispatchCallback{URL: "http://x/y"}}))
	require.Empty(t, gotHeader, "no secret means no signature header")
}

func TestAgentPostRejectsUnusableEndpoint(t *testing.T) {
	t.Parallel()

	err := testSender(t, 0).post(context.Background(), "not-a-url", dispatchBody{Agent: "triage"})
	var perm *permanentError
	require.ErrorAs(t, err, &perm)
}
