package apns

import (
	"context"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/x509"
	"encoding/json"
	"encoding/pem"
	"io"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/golang-jwt/jwt/v5"

	"commilitia.net/cdrop/internal/db"
	"commilitia.net/cdrop/internal/push"
)

// generateTestKey creates an ephemeral P-256 key and returns it as PEM-encoded
// PKCS8 bytes alongside the typed private key (for verification).
func generateTestKey(t *testing.T) (pemBytes []byte, priv *ecdsa.PrivateKey) {
	t.Helper()
	priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		t.Fatalf("generate key: %v", err)
	}
	der, err := x509.MarshalPKCS8PrivateKey(priv)
	if err != nil {
		t.Fatalf("marshal pkcs8: %v", err)
	}
	pemBytes = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
	return pemBytes, priv
}

// openTestDB creates a temporary SQLite database and runs Bootstrap. Returns a
// *db.Queries backed by that DB.
func openTestDB(t *testing.T) *db.Queries {
	t.Helper()
	conn, err := db.Open(filepath.Join(t.TempDir(), "apns_test.db"))
	if err != nil {
		t.Fatalf("open test db: %v", err)
	}
	t.Cleanup(func() { _ = conn.Close() })
	if err := db.Bootstrap(context.Background(), conn); err != nil {
		t.Fatalf("bootstrap: %v", err)
	}
	return db.New(conn)
}

// TestNewSenderInertWhenUnconfigured verifies that a Sender with any missing
// config field is inert: Enabled reports false and Notify is a safe no-op.
func TestNewSenderInertWhenUnconfigured(t *testing.T) {
	keyPEM, _ := generateTestKey(t)

	cases := []struct {
		name              string
		keyPEM            []byte
		keyID, teamID, topic string
	}{
		{"no key", nil, "KID", "TID", "topic"},
		{"no keyID", keyPEM, "", "TID", "topic"},
		{"no teamID", keyPEM, "KID", "", "topic"},
		{"no topic", keyPEM, "KID", "TID", ""},
		{"all empty", nil, "", "", ""},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			s := NewSender(nil, tc.keyPEM, tc.keyID, tc.teamID, tc.topic, "sandbox")
			if s.Enabled() {
				t.Fatal("inert Sender should report Enabled=false")
			}
			// Notify must be a safe no-op with nil queries.
			s.Notify(context.Background(), "u", "d", push.Notification{Type: push.KindMessage})
		})
	}
}

// TestNilSenderIsSafe verifies that a nil *Sender is safe to call.
func TestNilSenderIsSafe(t *testing.T) {
	var s *Sender
	if s.Enabled() {
		t.Fatal("nil sender should report disabled")
	}
	s.Notify(context.Background(), "u", "d", push.Notification{}) // must not panic
}

// TestProviderJWT verifies the JWT structure and ES256 signature.
func TestProviderJWT(t *testing.T) {
	const keyID = "TESTKEYID1"
	const teamID = "TEAMID1234"

	keyPEM, priv := generateTestKey(t)
	s := NewSender(nil, keyPEM, keyID, teamID, "com.example.app", "sandbox")
	if !s.Enabled() {
		t.Fatal("sender should be enabled with a valid key")
	}

	jwtStr, err := s.providerJWT()
	if err != nil {
		t.Fatalf("providerJWT: %v", err)
	}
	if jwtStr == "" {
		t.Fatal("providerJWT returned empty string")
	}

	// Parse without verification first to inspect headers.
	tok, err := jwt.NewParser().ParseWithClaims(jwtStr, jwt.MapClaims{}, func(t *jwt.Token) (any, error) {
		return &priv.PublicKey, nil
	})
	if err != nil {
		t.Fatalf("parse jwt: %v", err)
	}
	if !tok.Valid {
		t.Fatal("jwt should be valid")
	}

	// Header checks.
	alg, _ := tok.Header["alg"].(string)
	if alg != "ES256" {
		t.Errorf("header alg = %q, want ES256", alg)
	}
	kid, _ := tok.Header["kid"].(string)
	if kid != keyID {
		t.Errorf("header kid = %q, want %q", kid, keyID)
	}

	// Claims checks.
	claims, ok := tok.Claims.(jwt.MapClaims)
	if !ok {
		t.Fatal("claims not MapClaims")
	}
	iss, _ := claims["iss"].(string)
	if iss != teamID {
		t.Errorf("claim iss = %q, want %q", iss, teamID)
	}
	iat, ok := claims["iat"]
	if !ok {
		t.Error("claim iat missing")
	}
	// iat should be a recent unix timestamp (within the last minute).
	iatF, _ := iat.(float64)
	iatTime := time.Unix(int64(iatF), 0)
	if time.Since(iatTime) > time.Minute {
		t.Errorf("claim iat too old: %v", iatTime)
	}
}

// TestJWTIsCached verifies that repeated calls within jwtRefreshAge return the
// same token string, and that forcing the cache stale mints a new one.
func TestJWTIsCached(t *testing.T) {
	keyPEM, _ := generateTestKey(t)
	s := NewSender(nil, keyPEM, "KID", "TID", "topic", "sandbox")

	first, err := s.providerJWT()
	if err != nil {
		t.Fatalf("first jwt: %v", err)
	}
	second, err := s.providerJWT()
	if err != nil {
		t.Fatalf("second jwt: %v", err)
	}
	if first != second {
		t.Error("providerJWT should return the cached token within refresh window")
	}

	// Expire the cache.
	s.mu.Lock()
	s.jwtMintedAt = time.Now().Add(-(jwtRefreshAge + time.Second))
	s.mu.Unlock()

	third, err := s.providerJWT()
	if err != nil {
		t.Fatalf("third jwt: %v", err)
	}
	if third == first {
		t.Error("providerJWT should mint a new token after refresh window expires")
	}
}

// apnsServerRecorder is a minimal httptest server that records the last request
// and responds with a configurable status.
type apnsServerRecorder struct {
	srv        *httptest.Server
	lastPath   string
	lastAuth   string
	lastTopic  string
	lastPushType string
	lastBody   []byte
	respondWith int
}

func newAPNsTestServer(t *testing.T, respondWith int) *apnsServerRecorder {
	t.Helper()
	rec := &apnsServerRecorder{respondWith: respondWith}
	rec.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		rec.lastPath = r.URL.Path
		rec.lastAuth = r.Header.Get("Authorization")
		rec.lastTopic = r.Header.Get("apns-topic")
		rec.lastPushType = r.Header.Get("apns-push-type")
		rec.lastBody, _ = io.ReadAll(r.Body)
		w.WriteHeader(rec.respondWith)
	}))
	t.Cleanup(rec.srv.Close)
	return rec
}

// TestSendRequest asserts that Notify fires a POST with the correct path and
// headers against the APNs base URL, and that the JSON body has the expected shape.
func TestSendRequest(t *testing.T) {
	const deviceToken = "abc123deadbeef"
	const topic = "com.example.cdrop"

	q := openTestDB(t)
	keyPEM, _ := generateTestKey(t)
	rec := newAPNsTestServer(t, http.StatusOK)

	s := NewSender(q, keyPEM, "KID", "TID", topic, "sandbox")
	if !s.Enabled() {
		t.Fatal("sender must be enabled")
	}
	// Point the sender at the test server instead of the real APNs endpoint.
	s.base = rec.srv.URL

	// Insert an APNs subscription row for user "u", device "d".
	now := time.Now().Unix()
	if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{
		ID:         push.SubscriptionID(deviceToken),
		UserID:     "u",
		DeviceName: "d",
		Endpoint:   deviceToken,
		Locale:     "en-US",
		CreatedAt:  now,
		LastUsedAt: now,
	}); err != nil {
		t.Fatalf("seed subscription: %v", err)
	}

	s.Notify(context.Background(), "u", "d", push.Notification{
		Type:   push.KindTransferIncoming,
		Params: map[string]string{"sender": "Mac", "filename": "a.pdf"},
		Tag:    "transfer:1",
		URL:    "/transfers",
	})

	// Path must be /3/device/{token}.
	wantPath := "/3/device/" + deviceToken
	if rec.lastPath != wantPath {
		t.Errorf("path = %q, want %q", rec.lastPath, wantPath)
	}

	// Authorization header must start with "bearer ".
	if !strings.HasPrefix(rec.lastAuth, "bearer ") {
		t.Errorf("Authorization = %q, want bearer ...", rec.lastAuth)
	}

	// APNs headers.
	if rec.lastTopic != topic {
		t.Errorf("apns-topic = %q, want %q", rec.lastTopic, topic)
	}
	if rec.lastPushType != "alert" {
		t.Errorf("apns-push-type = %q, want alert", rec.lastPushType)
	}

	// JSON body shape.
	var body apsPayload
	if err := json.Unmarshal(rec.lastBody, &body); err != nil {
		t.Fatalf("parse body: %v (raw: %s)", err, rec.lastBody)
	}
	if body.Type != push.KindTransferIncoming {
		t.Errorf("body.type = %q, want %q", body.Type, push.KindTransferIncoming)
	}
	if body.APS.Alert.Title != "Incoming file" {
		t.Errorf("body.aps.alert.title = %q, want Incoming file", body.APS.Alert.Title)
	}
	if !strings.Contains(body.APS.Alert.Body, "a.pdf") {
		t.Errorf("body.aps.alert.body = %q, want mention of a.pdf", body.APS.Alert.Body)
	}
	if body.URL != "/transfers" {
		t.Errorf("body.url = %q, want /transfers", body.URL)
	}
	if body.APS.Sound != "default" {
		t.Errorf("body.aps.sound = %q, want default", body.APS.Sound)
	}
}

// TestSend410PrunesRow verifies that an HTTP 410 response from the APNs
// endpoint causes the subscription row to be deleted from the database.
func TestSend410PrunesRow(t *testing.T) {
	const deviceToken = "deadbeef410token"

	q := openTestDB(t)
	keyPEM, _ := generateTestKey(t)
	rec := newAPNsTestServer(t, http.StatusGone) // 410 = Unregistered

	s := NewSender(q, keyPEM, "KID", "TID", "com.example.app", "sandbox")
	s.base = rec.srv.URL

	now := time.Now().Unix()
	subID := push.SubscriptionID(deviceToken)
	if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{
		ID:         subID,
		UserID:     "u",
		DeviceName: "d",
		Endpoint:   deviceToken,
		Locale:     "zh-CN",
		CreatedAt:  now,
		LastUsedAt: now,
	}); err != nil {
		t.Fatalf("seed subscription: %v", err)
	}

	s.Notify(context.Background(), "u", "d", push.Notification{
		Type: push.KindMessage,
		Params: map[string]string{
			"sender": "Phone",
			"text":   "hello",
		},
	})

	// The row must be gone.
	subs, err := q.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
		UserID:     "u",
		DeviceName: "d",
	})
	if err != nil {
		t.Fatalf("list subs: %v", err)
	}
	if len(subs) != 0 {
		t.Errorf("subscription row should be pruned on 410, got %d rows", len(subs))
	}
}

// TestWebPushRowsNotReturnedByAPNsQuery asserts that WebPush-platform rows are
// invisible to ListAPNsSubscriptionsByUserDevice and vice versa.
func TestWebPushRowsNotReturnedByAPNsQuery(t *testing.T) {
	q := openTestDB(t)
	now := time.Now().Unix()

	// Insert a webpush row.
	const webEndpoint = "https://fcm.googleapis.com/fake"
	if err := q.UpsertPushSubscription(context.Background(), db.UpsertPushSubscriptionParams{
		ID:         push.SubscriptionID(webEndpoint),
		UserID:     "u",
		DeviceName: "d",
		Endpoint:   webEndpoint,
		P256dh:     "p",
		Auth:       "a",
		UserAgent:  "",
		Locale:     "en-US",
		CreatedAt:  now,
		LastUsedAt: now,
	}); err != nil {
		t.Fatalf("upsert webpush: %v", err)
	}

	// APNs query must not see the webpush row.
	apnsSubs, err := q.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
		UserID: "u", DeviceName: "d",
	})
	if err != nil {
		t.Fatalf("list apns: %v", err)
	}
	if len(apnsSubs) != 0 {
		t.Errorf("APNs query returned %d row(s), want 0 (webpush rows must be filtered)", len(apnsSubs))
	}

	// WebPush query must not see apns rows.
	const apnsToken = "aabbccdd1234"
	if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{
		ID:         push.SubscriptionID(apnsToken),
		UserID:     "u",
		DeviceName: "d",
		Endpoint:   apnsToken,
		Locale:     "zh-CN",
		CreatedAt:  now,
		LastUsedAt: now,
	}); err != nil {
		t.Fatalf("upsert apns: %v", err)
	}

	webSubs, err := q.ListPushSubscriptionsByUserDevice(context.Background(), db.ListPushSubscriptionsByUserDeviceParams{
		UserID: "u", DeviceName: "d",
	})
	if err != nil {
		t.Fatalf("list webpush: %v", err)
	}
	if len(webSubs) != 1 {
		t.Errorf("webpush query returned %d row(s), want 1 (apns rows must be filtered)", len(webSubs))
	}
}
