package webhooks

import (
	"bytes"
	"context"
	"errors"
	"regexp"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
	"github.com/pingcap-inc/nexuscrm-connectors/features/namedcredential"
)

// testVerifierCipher returns a deterministic AES-256-GCM cipher for verifier tests.
func testVerifierCipher(t *testing.T) *namedcredential.SecretCipher {
	t.Helper()
	c, err := namedcredential.NewSecretCipherFromKey(bytes.Repeat([]byte{0x42}, 32))
	if err != nil {
		t.Fatalf("testVerifierCipher: %v", err)
	}
	return c
}

// encryptVerifier encrypts plaintext with testVerifierCipher for use in sqlmock rows.
func encryptVerifier(t *testing.T, plaintext string) string {
	t.Helper()
	ct, err := testVerifierCipher(t).Encrypt(plaintext)
	if err != nil {
		t.Fatalf("encryptVerifier(%q): %v", plaintext, err)
	}
	return ct
}

// ─── Verifier tests ───────────────────────────────────────────────────────────

// TestResolveWebhookSecretForVerification_HappyPath verifies that a fully-set-up
// connector pack + definition + active value resolves to the expected algorithm and bytes.
func TestResolveWebhookSecretForVerification_HappyPath(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	cipher := testVerifierCipher(t)
	namespace := "test-connector"
	name := "order-webhook"
	defID := "def-001"
	algorithm := "HMAC-SHA256"
	secretPlaintext := "supersecretvalue"
	storedCiphertext := encryptVerifier(t, secretPlaintext)

	// Step 2: connector pack lookup (active).
	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, PackInstallId, Name, Category, Status")).
		WithArgs(namespace, "connector-pack", "active").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "PackInstallId", "Name", "Category", "Status", "AppliedPayloadHash", "PackVersion"}).
			AddRow("pack-id", "install-id-v1", namespace, "connector-pack", "active", "payload-hash-1", "1.0.0"))

	// Step 3: definition lookup.
	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, Algorithm")).
		WithArgs(namespace, name).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Algorithm"}).
			AddRow(defID, algorithm))

	// Step 4: value lookup (returns ciphertext).
	mock.ExpectQuery(regexp.QuoteMeta("SELECT SecretValue")).
		WithArgs(defID).
		WillReturnRows(sqlmock.NewRows([]string{"SecretValue"}).
			AddRow(storedCiphertext))

	resolved, err := ResolveWebhookSecretForVerification(context.Background(), db, cipher, namespace, name, "")
	if err != nil {
		t.Fatalf("want nil error, got %v", err)
	}
	if resolved.Algorithm != algorithm {
		t.Errorf("Algorithm = %q, want %q", resolved.Algorithm, algorithm)
	}
	if string(resolved.Secret) != secretPlaintext {
		t.Errorf("Secret = %q, want %q", resolved.Secret, secretPlaintext)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet sql expectations: %v", err)
	}
}

// TestResolveWebhookSecretForVerification_DecryptedEmpty_Unavailable verifies
// that an active value row whose (non-empty) ciphertext decrypts to an empty
// plaintext is treated as unavailable (fail closed) and never yields a
// zero-length HMAC secret to the dispatcher. The empty-ciphertext guard does
// NOT catch this — AES-GCM of "" is still a non-empty ciphertext — so the
// post-decrypt guard must. Regression for the #1527 P1.
func TestResolveWebhookSecretForVerification_DecryptedEmpty_Unavailable(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	cipher := testVerifierCipher(t)
	namespace := "test-connector"
	name := "order-webhook"
	defID := "def-001"
	algorithm := "HMAC-SHA256"
	storedCiphertext := encryptVerifier(t, "") // non-empty ciphertext, empty plaintext
	if storedCiphertext == "" {
		t.Fatal("precondition: ciphertext of empty plaintext must be non-empty")
	}

	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, PackInstallId, Name, Category, Status")).
		WithArgs(namespace, "connector-pack", "active").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "PackInstallId", "Name", "Category", "Status", "AppliedPayloadHash", "PackVersion"}).
			AddRow("pack-id", "install-id-v1", namespace, "connector-pack", "active", "payload-hash-1", "1.0.0"))
	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, Algorithm")).
		WithArgs(namespace, name).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Algorithm"}).
			AddRow(defID, algorithm))
	mock.ExpectQuery(regexp.QuoteMeta("SELECT SecretValue")).
		WithArgs(defID).
		WillReturnRows(sqlmock.NewRows([]string{"SecretValue"}).
			AddRow(storedCiphertext))

	resolved, err := ResolveWebhookSecretForVerification(context.Background(), db, cipher, namespace, name, "")
	if !errors.Is(err, ErrWebhookSecretNotAvailable) {
		t.Fatalf("want ErrWebhookSecretNotAvailable, got err=%v resolved=%v", err, resolved)
	}
	if resolved != nil {
		t.Fatalf("resolved must be nil (no zero-length secret), got %v", resolved)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet sql expectations: %v", err)
	}
}

// TestResolveWebhookSecretForVerification_MissingDefinition verifies that
// ErrWebhookSecretNotAvailable is returned when no matching definition row exists.
func TestResolveWebhookSecretForVerification_MissingDefinition(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	cipher := testVerifierCipher(t)
	namespace := "test-connector"
	name := "missing-webhook"

	// connector pack exists.
	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, PackInstallId, Name, Category, Status")).
		WithArgs(namespace, "connector-pack", "active").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "PackInstallId", "Name", "Category", "Status", "AppliedPayloadHash", "PackVersion"}).
			AddRow("pack-id", "install-id-v1", namespace, "connector-pack", "active", "payload-hash-1", "1.0.0"))

	// definition missing → ErrNoRows.
	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, Algorithm")).
		WithArgs(namespace, name).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Algorithm"}))

	_, err = ResolveWebhookSecretForVerification(context.Background(), db, cipher, namespace, name, "")
	if !errors.Is(err, ErrWebhookSecretNotAvailable) {
		t.Fatalf("want ErrWebhookSecretNotAvailable, got %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet sql expectations: %v", err)
	}
}

// TestResolveWebhookSecretForVerification_MissingValue verifies that
// ErrWebhookSecretNotAvailable is returned when the definition exists but has no
// active value row.
func TestResolveWebhookSecretForVerification_MissingValue(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	cipher := testVerifierCipher(t)
	namespace := "test-connector"
	name := "order-webhook"
	defID := "def-002"

	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, PackInstallId, Name, Category, Status")).
		WithArgs(namespace, "connector-pack", "active").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "PackInstallId", "Name", "Category", "Status", "AppliedPayloadHash", "PackVersion"}).
			AddRow("pack-id", "install-id-v1", namespace, "connector-pack", "active", "payload-hash-1", "1.0.0"))

	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, Algorithm")).
		WithArgs(namespace, name).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Algorithm"}).
			AddRow(defID, "HMAC-SHA256"))

	// value row missing.
	mock.ExpectQuery(regexp.QuoteMeta("SELECT SecretValue")).
		WithArgs(defID).
		WillReturnRows(sqlmock.NewRows([]string{"SecretValue"}))

	_, err = ResolveWebhookSecretForVerification(context.Background(), db, cipher, namespace, name, "")
	if !errors.Is(err, ErrWebhookSecretNotAvailable) {
		t.Fatalf("want ErrWebhookSecretNotAvailable, got %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet sql expectations: %v", err)
	}
}

// TestResolveWebhookSecretForVerification_InactiveValue verifies that an IsActive=0
// value row is treated as missing.
func TestResolveWebhookSecretForVerification_InactiveValue(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	cipher := testVerifierCipher(t)
	namespace := "test-connector"
	name := "order-webhook"
	defID := "def-003"

	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, PackInstallId, Name, Category, Status")).
		WithArgs(namespace, "connector-pack", "active").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "PackInstallId", "Name", "Category", "Status", "AppliedPayloadHash", "PackVersion"}).
			AddRow("pack-id", "install-id-v1", namespace, "connector-pack", "active", "payload-hash-1", "1.0.0"))

	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, Algorithm")).
		WithArgs(namespace, name).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Algorithm"}).
			AddRow(defID, "HMAC-SHA256"))

	// The WHERE clause already filters IsActive=1, so no row is returned.
	mock.ExpectQuery(regexp.QuoteMeta("SELECT SecretValue")).
		WithArgs(defID).
		WillReturnRows(sqlmock.NewRows([]string{"SecretValue"}))

	_, err = ResolveWebhookSecretForVerification(context.Background(), db, cipher, namespace, name, "")
	if !errors.Is(err, ErrWebhookSecretNotAvailable) {
		t.Fatalf("want ErrWebhookSecretNotAvailable, got %v", err)
	}
}

// TestResolveWebhookSecretForVerification_SoftDeletedDefinition verifies that a
// soft-deleted definition (IsDeleted=1) is treated as missing.
func TestResolveWebhookSecretForVerification_SoftDeletedDefinition(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	cipher := testVerifierCipher(t)
	namespace := "test-connector"
	name := "order-webhook"

	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, PackInstallId, Name, Category, Status")).
		WithArgs(namespace, "connector-pack", "active").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "PackInstallId", "Name", "Category", "Status", "AppliedPayloadHash", "PackVersion"}).
			AddRow("pack-id", "install-id-v1", namespace, "connector-pack", "active", "payload-hash-1", "1.0.0"))

	// IsDeleted=1 rows are excluded by the WHERE clause, so no row returned.
	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, Algorithm")).
		WithArgs(namespace, name).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Algorithm"}))

	_, err = ResolveWebhookSecretForVerification(context.Background(), db, cipher, namespace, name, "")
	if !errors.Is(err, ErrWebhookSecretNotAvailable) {
		t.Fatalf("want ErrWebhookSecretNotAvailable, got %v", err)
	}
}

// TestResolveWebhookSecretForVerification_InactiveConnectorPack verifies that an
// inactive (status != "active") connector pack causes the verifier to fail closed.
func TestResolveWebhookSecretForVerification_InactiveConnectorPack(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	cipher := testVerifierCipher(t)
	namespace := "inactive-connector"
	name := "order-webhook"

	// ResolveConnectorPackByNamespace's WHERE clause requires Status='active', so
	// an inactive pack returns no rows → (nil, false, nil).
	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, PackInstallId, Name, Category, Status")).
		WithArgs(namespace, "connector-pack", "active").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "PackInstallId", "Name", "Category", "Status", "AppliedPayloadHash", "PackVersion"}))

	_, err = ResolveWebhookSecretForVerification(context.Background(), db, cipher, namespace, name, "")
	if !errors.Is(err, ErrWebhookSecretNotAvailable) {
		t.Fatalf("want ErrWebhookSecretNotAvailable, got %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet sql expectations: %v", err)
	}
}

// TestResolveWebhookSecretForVerification_CipherUnavailable verifies that
// ErrWebhookSecretLookupFailed (a retriable server-side fault, NOT a terminal
// "not available") is returned immediately when cipher is nil
// (NEXUSCRM_NC_MASTER_KEY not configured), without any DB queries firing.
func TestResolveWebhookSecretForVerification_CipherUnavailable(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	_, err = ResolveWebhookSecretForVerification(context.Background(), db, nil, "test-connector", "order-webhook", "")
	if !errors.Is(err, ErrWebhookSecretLookupFailed) {
		t.Fatalf("want ErrWebhookSecretLookupFailed, got %v", err)
	}
	// No DB queries should have fired.
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet sql expectations: %v", err)
	}
}

// TestResolveWebhookSecretForVerification_DefinitionQueryError verifies that a
// transient DB error on the definition lookup surfaces as the retriable
// ErrWebhookSecretLookupFailed, not the terminal ErrWebhookSecretNotAvailable —
// otherwise a transient outage would look like bad credentials to the caller.
func TestResolveWebhookSecretForVerification_DefinitionQueryError(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	cipher := testVerifierCipher(t)
	namespace := "test-connector"
	name := "order-webhook"

	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, PackInstallId, Name, Category, Status")).
		WithArgs(namespace, "connector-pack", "active").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "PackInstallId", "Name", "Category", "Status", "AppliedPayloadHash", "PackVersion"}).
			AddRow("pack-id", "install-id-v1", namespace, "connector-pack", "active", "payload-hash-1", "1.0.0"))

	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, Algorithm")).
		WithArgs(namespace, name).
		WillReturnError(errors.New("connection reset by peer"))

	_, err = ResolveWebhookSecretForVerification(context.Background(), db, cipher, namespace, name, "")
	if !errors.Is(err, ErrWebhookSecretLookupFailed) {
		t.Fatalf("want ErrWebhookSecretLookupFailed, got %v", err)
	}
}

// TestResolveWebhookSecretForVerification_DecryptFailure verifies that a stored
// value that fails to decrypt (e.g. wrong master key / corrupt row) surfaces as
// the retriable ErrWebhookSecretLookupFailed. The secret row exists, so this is
// a server-side fault, not a "secret does not exist" condition.
func TestResolveWebhookSecretForVerification_DecryptFailure(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	cipher := testVerifierCipher(t)
	namespace := "test-connector"
	name := "order-webhook"
	defID := "def-004"

	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, PackInstallId, Name, Category, Status")).
		WithArgs(namespace, "connector-pack", "active").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "PackInstallId", "Name", "Category", "Status", "AppliedPayloadHash", "PackVersion"}).
			AddRow("pack-id", "install-id-v1", namespace, "connector-pack", "active", "payload-hash-1", "1.0.0"))

	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, Algorithm")).
		WithArgs(namespace, name).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Algorithm"}).
			AddRow(defID, "HMAC-SHA256"))

	// Stored value is not valid ciphertext for this cipher — decryption fails.
	mock.ExpectQuery(regexp.QuoteMeta("SELECT SecretValue")).
		WithArgs(defID).
		WillReturnRows(sqlmock.NewRows([]string{"SecretValue"}).AddRow("not-valid-ciphertext"))

	_, err = ResolveWebhookSecretForVerification(context.Background(), db, cipher, namespace, name, "")
	if !errors.Is(err, ErrWebhookSecretLookupFailed) {
		t.Fatalf("want ErrWebhookSecretLookupFailed, got %v", err)
	}
}

// TestResolveWebhookSecretForVerification_NormalizesCaseInsensitiveInputs verifies
// that namespace and name are lowercased at entry so a caller that passes mixed-case
// identifiers still resolves against lowercase-stored rows.
func TestResolveWebhookSecretForVerification_NormalizesCaseInsensitiveInputs(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	cipher := testVerifierCipher(t)
	// Caller passes mixed-case; the verifier must normalize before querying.
	namespace := "Test-Connector"
	name := "Events"
	defID := "def-norm-001"
	algorithm := "HMAC-SHA256"
	secretPlaintext := "normtestsecret"
	storedCiphertext := encryptVerifier(t, secretPlaintext)

	// Step 3: connector pack lookup expects lowercase namespace.
	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, PackInstallId, Name, Category, Status")).
		WithArgs("test-connector", "connector-pack", "active").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "PackInstallId", "Name", "Category", "Status", "AppliedPayloadHash", "PackVersion"}).
			AddRow("pack-id", "install-id-v1", "test-connector", "connector-pack", "active", "payload-hash-1", "1.0.0"))

	// Step 4: definition lookup expects lowercase name.
	mock.ExpectQuery(regexp.QuoteMeta("SELECT Id, Algorithm")).
		WithArgs("test-connector", "events").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Algorithm"}).
			AddRow(defID, algorithm))

	// Step 5: value lookup.
	mock.ExpectQuery(regexp.QuoteMeta("SELECT SecretValue")).
		WithArgs(defID).
		WillReturnRows(sqlmock.NewRows([]string{"SecretValue"}).
			AddRow(storedCiphertext))

	resolved, err := ResolveWebhookSecretForVerification(context.Background(), db, cipher, namespace, name, "")
	if err != nil {
		t.Fatalf("want nil error, got %v", err)
	}
	if resolved.Algorithm != algorithm {
		t.Errorf("Algorithm = %q, want %q", resolved.Algorithm, algorithm)
	}
	if string(resolved.Secret) != secretPlaintext {
		t.Errorf("Secret = %q, want %q", string(resolved.Secret), secretPlaintext)
	}
	if resolved.SecretValueHash != StoredSecretValueHash(storedCiphertext) {
		t.Errorf("SecretValueHash = %q, want stored SecretValue hash", resolved.SecretValueHash)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet sql expectations: %v", err)
	}
}

// ─── R13/R15: packInstallID incarnation-change tests ─────────────────────────
//
// R15 note: ResolveWebhookSecretForVerification now takes the packInstallID-bound
// path when packInstallID != "". Steps 3+4+5 are collapsed into ONE atomic SQL
// JOIN (_System_InstalledPack ON PackInstallId + _System_WebhookSecret +
// _System_WebhookSecretValue). A separate pre-check query is no longer issued.
// Tests must match the single-JOIN mock expectation, not the old 3-query sequence.

// atomicSecretQuery is the regexp matching the single atomic 3-way JOIN issued by
// resolveWebhookSecretByPackInstallID when packInstallID is non-empty.
const atomicSecretQuery = `SELECT ws\.Algorithm, wsv\.SecretValue`

// TestResolveWebhookSecretForVerification_PackInstallIDMismatch_ReturnsIncarnationChanged
// verifies that when the atomic JOIN yields 0 rows because PackInstallId no longer
// matches (admin reinstall between dispatcher Step 2 and this call), the helper
// returns ErrWebhookSecretIncarnationChanged — retriable 503 — not terminal 401.
// B23: follow-up query confirms pack NOT active under this PackInstallId.
func TestResolveWebhookSecretForVerification_PackInstallIDMismatch_ReturnsIncarnationChanged(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	cipher := testVerifierCipher(t)
	namespace := "test-connector"
	name := "order-webhook"

	// Atomic JOIN returns 0 rows: the PackInstallId predicate eliminates the row
	// because the active pack was reinstalled (PackInstallId now "install-b").
	mock.ExpectQuery(atomicSecretQuery).
		WillReturnRows(sqlmock.NewRows([]string{"Algorithm", "SecretValue"}))

	// B23: follow-up disambiguate — pack NOT active under "install-a" (reinstalled).
	mock.ExpectQuery(`SELECT EXISTS`).
		WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))

	// The dispatcher cached "install-a" at Step 2 — 0-row JOIN + inactive pack → transient 503.
	_, err = ResolveWebhookSecretForVerification(context.Background(), db, cipher, namespace, name, "install-a")
	if !errors.Is(err, ErrWebhookSecretIncarnationChanged) {
		t.Fatalf("want ErrWebhookSecretIncarnationChanged, got %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet sql expectations: %v", err)
	}
}

// TestResolveWebhookSecretForVerification_PackInstallIDMatch_Proceeds verifies
// that when the atomic JOIN yields a row (pack incarnation unchanged, secret
// present), the helper proceeds to decrypt and returns the resolved secret.
func TestResolveWebhookSecretForVerification_PackInstallIDMatch_Proceeds(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	cipher := testVerifierCipher(t)
	namespace := "test-connector"
	name := "order-webhook"
	algorithm := "HMAC-SHA256"
	secretPlaintext := "matched-secret"
	storedCiphertext := encryptVerifier(t, secretPlaintext)

	// Atomic JOIN succeeds: pack incarnation matches + secret/value rows present.
	mock.ExpectQuery(atomicSecretQuery).
		WillReturnRows(sqlmock.NewRows([]string{"Algorithm", "SecretValue"}).
			AddRow(algorithm, storedCiphertext))

	resolved, err := ResolveWebhookSecretForVerification(context.Background(), db, cipher, namespace, name, "install-a")
	if err != nil {
		t.Fatalf("want nil error, got %v", err)
	}
	if string(resolved.Secret) != secretPlaintext {
		t.Errorf("Secret = %q, want %q", string(resolved.Secret), secretPlaintext)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet sql expectations: %v", err)
	}
}

// TestResolveWebhookSecretForVerification_PackReinstalledMidQuery_ReturnsTransient
// is the R15 atomic-race regression test: simulates an admin reinstall occurring
// between dispatcher Step 2 (packInstallID cached) and the secret read. The
// atomic JOIN binds both operations so the JOIN returns 0 rows; the B23 follow-up
// confirms pack no longer active → transient 503, not terminal 401.
func TestResolveWebhookSecretForVerification_PackReinstalledMidQuery_ReturnsTransient(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	cipher := testVerifierCipher(t)
	namespace := "acme-connector"
	name := "payments-webhook"
	step2InstallID := "install-old"

	// The JOIN is constrained by PackInstallId = step2InstallID. Because the pack
	// was reinstalled mid-flight (now "install-new"), the predicate eliminates
	// the InstalledPack row → 0 rows from the JOIN.
	mock.ExpectQuery(atomicSecretQuery).
		WillReturnRows(sqlmock.NewRows([]string{"Algorithm", "SecretValue"})) // 0 rows

	// B23: follow-up confirms pack no longer active under stale PackInstallId.
	mock.ExpectQuery(`SELECT EXISTS`).
		WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))

	_, err = ResolveWebhookSecretForVerification(context.Background(), db, cipher, namespace, name, step2InstallID)
	if !errors.Is(err, ErrWebhookSecretIncarnationChanged) {
		t.Fatalf("atomic race path: want ErrWebhookSecretIncarnationChanged (transient 503), got %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet sql expectations: %v", err)
	}
}

// TestResolveWebhookSecretForVerification_GenuineMissingSecret_ReturnsNotAvailable
// verifies B23: when the atomic JOIN returns 0 rows but the pack is still active
// under the same PackInstallId (the secret row is genuinely missing), the helper
// returns ErrWebhookSecretNotAvailable (terminal 401) — NOT ErrWebhookSecretIncarnationChanged.
// Without this fix, a provider would enter an infinite retry loop on a misconfigured pack.
func TestResolveWebhookSecretForVerification_GenuineMissingSecret_ReturnsNotAvailable(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	cipher := testVerifierCipher(t)
	namespace := "acme-connector"
	name := "payments-webhook"
	packInstallID := "install-v1"

	// Atomic JOIN returns 0 rows: pack active, secret row missing.
	mock.ExpectQuery(atomicSecretQuery).
		WillReturnRows(sqlmock.NewRows([]string{"Algorithm", "SecretValue"})) // 0 rows

	// B23: follow-up confirms pack IS still active under this PackInstallId.
	mock.ExpectQuery(`SELECT EXISTS`).
		WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))

	_, err = ResolveWebhookSecretForVerification(context.Background(), db, cipher, namespace, name, packInstallID)
	if !errors.Is(err, ErrWebhookSecretNotAvailable) {
		t.Fatalf("genuine secret miss: want ErrWebhookSecretNotAvailable (terminal 401), got %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet sql expectations: %v", err)
	}
}

// ─── MaskSecretValue tests ────────────────────────────────────────────────────

func TestMaskSecretValue_LastFour(t *testing.T) {
	cases := []struct {
		input string
		want  string
	}{
		// Long secrets: show last 4 runes.
		{"supersecretvalue", "whsec_•••alue"},
		{"1234567890", "whsec_•••7890"},
		// Threshold boundary: 8 runes → show suffix.
		{"12345678", "whsec_•••5678"},
		// Below threshold: never reveal suffix (short secret = high disclosure risk).
		{"1234567", "whsec_•••"}, // 7 runes < 8
		{"abcd", "whsec_•••"},    // was "whsec_•••abcd" — fixed
		{"ab", "whsec_•••"},      // was "whsec_•••ab"   — fixed
		{"", "whsec_•••"},
	}
	for _, tc := range cases {
		got := MaskSecretValue(tc.input)
		if got != tc.want {
			t.Errorf("MaskSecretValue(%q) = %q, want %q", tc.input, got, tc.want)
		}
	}
}
