package notify_test

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"sync"
	"testing"

	"github.com/alexei/tinyforge/internal/notify"
)

// TestSignedRoundTrip is the canonical "the receiver can verify what we
// sent" check. Sender signs the body with a secret; the test server reads
// the raw body and the X-Hub-Signature-256 header and verifies via
// VerifySignature. A regression here means receivers built against our
// docs would silently reject real notifications.
func TestSignedRoundTrip(t *testing.T) {
	const secret = "super-secret-test-key-not-used-in-prod"

	var receivedBody []byte
	var receivedSig string
	var receivedEvent string
	var receivedDelivery string
	var receivedTier string
	var receivedTimestamp string
	done := make(chan struct{})

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer close(done)
		body, _ := io.ReadAll(r.Body)
		receivedBody = body
		receivedSig = r.Header.Get(notify.HeaderSignature)
		receivedEvent = r.Header.Get(notify.HeaderEvent)
		receivedDelivery = r.Header.Get(notify.HeaderDelivery)
		receivedTier = r.Header.Get(notify.HeaderTier)
		receivedTimestamp = r.Header.Get(notify.HeaderTimestamp)
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte(`{"ok":true}`))
	}))
	defer srv.Close()

	n := notify.New()
	n.SendSigned(srv.URL, secret, notify.TierStage, notify.Event{
		Type:    "deploy_success",
		Project: "demo",
		Stage:   "prod",
	})
	n.Drain()
	<-done

	if !notify.VerifySignature(secret, receivedBody, receivedSig) {
		t.Fatalf("receiver could not verify signature: header=%q body=%q", receivedSig, receivedBody)
	}
	if receivedEvent != "deploy_success" {
		t.Errorf("event header = %q, want deploy_success", receivedEvent)
	}
	if receivedDelivery == "" {
		t.Errorf("delivery ID header missing")
	}
	if receivedTier != string(notify.TierStage) {
		t.Errorf("tier header = %q, want %q", receivedTier, notify.TierStage)
	}
	if receivedTimestamp == "" {
		t.Errorf("timestamp header missing")
	}

	// Sanity: payload roundtrips through JSON unchanged.
	var got notify.Event
	if err := json.Unmarshal(receivedBody, &got); err != nil {
		t.Fatalf("decode body: %v", err)
	}
	if got.Project != "demo" || got.Stage != "prod" {
		t.Errorf("body fields lost in transit: %+v", got)
	}
}

// TestUnsignedSendOmitsSignatureHeader covers the back-compat path: a
// caller that hasn't yet generated a signing secret should still be able to
// dispatch events, just without the signature header. Existing receivers
// must not break when a Tinyforge instance upgrades but hasn't enabled
// signing yet.
func TestUnsignedSendOmitsSignatureHeader(t *testing.T) {
	var sigHeader string
	done := make(chan struct{})

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer close(done)
		sigHeader = r.Header.Get(notify.HeaderSignature)
		w.WriteHeader(http.StatusOK)
	}))
	defer srv.Close()

	n := notify.New()
	n.SendSigned(srv.URL, "", notify.TierSettings, notify.Event{Type: "test"})
	n.Drain()
	<-done

	if sigHeader != "" {
		t.Errorf("expected no signature header on unsigned send, got %q", sigHeader)
	}
}

// TestVerifyRejectsTamperedBody is the negative half of the round-trip:
// flipping a single byte in the signed body must fail verification.
// Catches accidental MAC truncation / wrong hash family / non-constant-time
// compares (the last only weakly, but the round-trip already guards
// correctness; this just locks the contract).
func TestVerifyRejectsTamperedBody(t *testing.T) {
	const secret = "abc"
	body := []byte(`{"type":"deploy_success"}`)
	sig := "sha256=" + hexEncode(hmacSha256(secret, body))

	if !notify.VerifySignature(secret, body, sig) {
		t.Fatalf("control: legit signature failed to verify")
	}

	tampered := append([]byte(nil), body...)
	tampered[1] = 'X' // flip one byte
	if notify.VerifySignature(secret, tampered, sig) {
		t.Errorf("verifier accepted tampered body — signature scheme is broken")
	}

	if notify.VerifySignature("wrong-secret", body, sig) {
		t.Errorf("verifier accepted wrong secret")
	}
	if notify.VerifySignature(secret, body, "") {
		t.Errorf("verifier accepted empty signature header")
	}
	if notify.VerifySignature("", body, sig) {
		t.Errorf("verifier accepted empty secret")
	}
}

// TestSendSyncForTestReturnsReceiverStatus is the "send test" UI button
// contract: when the receiver returns a non-2xx status, we must surface
// both the status code and the body preview rather than swallowing them.
// Operators rely on this to debug mis-pointed receivers.
func TestSendSyncForTestReturnsReceiverStatus(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusForbidden)
		_, _ = w.Write([]byte("invalid signature"))
	}))
	defer srv.Close()

	n := notify.New()
	res := n.SendSyncForTest(context.Background(), srv.URL, "secret", notify.TierProject, notify.Event{Type: "test"})

	if res.StatusCode != http.StatusForbidden {
		t.Errorf("status_code = %d, want 403", res.StatusCode)
	}
	if res.ResponseSnippet != "invalid signature" {
		t.Errorf("response_snippet = %q, want 'invalid signature'", res.ResponseSnippet)
	}
	if !res.SignatureSent {
		t.Errorf("signature_sent should be true when secret is provided")
	}
	if res.Tier != notify.TierProject {
		t.Errorf("tier = %q, want project", res.Tier)
	}
	if res.Error == "" {
		t.Errorf("Error field should be set on 4xx response")
	}
}

// TestSendSyncForTestEmptyURL is the guard for the test endpoint when no
// URL is configured at any tier. The handler relies on Error being non-empty
// to render the "no URL configured" message, so this contract must hold.
func TestSendSyncForTestEmptyURL(t *testing.T) {
	n := notify.New()
	res := n.SendSyncForTest(context.Background(), "", "secret", notify.TierSettings, notify.Event{Type: "test"})
	if res.Error == "" {
		t.Errorf("Error field should be set when URL is empty")
	}
	if res.StatusCode != 0 {
		t.Errorf("StatusCode should remain 0 when no request was made, got %d", res.StatusCode)
	}
}

// TestConcurrentSendsAllArrive guards the WaitGroup contract on Drain — a
// regression where Drain returns before in-flight goroutines complete would
// drop notifications during graceful shutdown.
func TestConcurrentSendsAllArrive(t *testing.T) {
	const fanout = 20
	var (
		mu       sync.Mutex
		received int
	)

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		mu.Lock()
		received++
		mu.Unlock()
		w.WriteHeader(http.StatusOK)
	}))
	defer srv.Close()

	n := notify.New()
	for i := 0; i < fanout; i++ {
		n.SendSigned(srv.URL, "key", notify.TierSettings, notify.Event{Type: "test"})
	}
	n.Drain()

	mu.Lock()
	defer mu.Unlock()
	if received != fanout {
		t.Errorf("received %d sends, want %d", received, fanout)
	}
}

// --- helpers ----------------------------------------------------------

// hmacSha256 + hexEncode duplicate the production sign() body so the
// negative tests don't depend on the un-exported helper. If the exported
// VerifySignature contract changes, this is the canary.
func hmacSha256(secret string, body []byte) []byte {
	h := hmac.New(sha256.New, []byte(secret))
	h.Write(body)
	return h.Sum(nil)
}

func hexEncode(b []byte) string {
	const hexdigits = "0123456789abcdef"
	out := make([]byte, len(b)*2)
	for i, x := range b {
		out[i*2] = hexdigits[x>>4]
		out[i*2+1] = hexdigits[x&0x0f]
	}
	return string(out)
}
