// Package webhooks provides server-internal helpers for inbound webhook handling.
// The verifier is the single read path for connector-pack secret bytes; it is
// never exposed through an HTTP handler.
package webhooks

import (
	"context"
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"errors"
	"fmt"
	"log"
	"strings"

	"github.com/pingcap-inc/nexuscrm-connectors/features/connector/packstate"
	"github.com/pingcap-inc/nexuscrm-connectors/features/namedcredential"
)

// resolveWebhookSecretByPackInstallID performs a single atomic 3-way JOIN that
// binds the secret lookup to the specific connector-pack incarnation identified
// by packInstallID. This eliminates the race window that existed when the
// incarnation check (ResolveConnectorPackByNamespace) and the secret reads
// (_System_WebhookSecret + _System_WebhookSecretValue) were separate statements:
// an admin reinstall between any two of those statements would have caused the
// dispatcher to verify a delivery against the new incarnation's secret rows or
// a now-missing secret, returning terminal 401 instead of retriable 503.
//
// Join logic:
//   - _System_InstalledPack ip: constrained by Name=namespace, PackInstallId=packInstallID,
//     Category='connector-pack', Status='active', IsDeleted=0.
//   - _System_WebhookSecret ws: OwnerPack=namespace, Name=secretName, IsDeleted=0.
//   - _System_WebhookSecretValue wsv: WebhookSecretId=ws.Id, IsActive=1.
//
// Returns (algorithm, ciphertext, nil) on success.
// Returns ("", "", ErrWebhookSecretIncarnationChanged) on 0 rows when the pack
// incarnation has changed (PackInstallId no longer matches) — caller surfaces 503
// (retriable) so the dispatcher retries with the new incarnation.
// Returns ("", "", ErrWebhookSecretNotAvailable) on 0 rows when the pack is still
// active under the same PackInstallId but the secret row is genuinely missing or
// inactive — caller surfaces 401 (terminal).
// Returns ("", "", ErrWebhookSecretLookupFailed) on DB error.
func resolveWebhookSecretByPackInstallID(ctx context.Context, db *sql.DB, namespace, secretName, packInstallID string) (algorithm, ciphertext string, err error) {
	queryErr := db.QueryRowContext(ctx, `
SELECT ws.Algorithm, wsv.SecretValue
  FROM _System_WebhookSecret ws
  JOIN _System_InstalledPack ip
    ON ip.Name = ?
   AND ip.PackInstallId = ?
   AND ip.Category = 'connector-pack'
   AND ip.Status = 'active'
   AND COALESCE(ip.IsDeleted, 0) = 0
  JOIN _System_WebhookSecretValue wsv
    ON wsv.WebhookSecretId = ws.Id
   AND wsv.IsActive = 1
 WHERE ws.OwnerPack = ?
   AND ws.Name = ?
   AND COALESCE(ws.IsDeleted, 0) = 0
 LIMIT 1`,
		namespace, packInstallID,
		namespace, secretName,
	).Scan(&algorithm, &ciphertext)
	if queryErr == nil {
		return algorithm, ciphertext, nil
	}
	if queryErr != sql.ErrNoRows {
		log.Printf("webhooks: atomic secret resolve %s/%s (packInstallID=%s): %v", namespace, secretName, packInstallID, queryErr)
		return "", "", ErrWebhookSecretLookupFailed
	}

	// B23: 0 rows — could be (a) pack incarnation changed (PackInstallId stale) or
	// (b) pack still active but secret genuinely missing/inactive. Distinguish with a
	// follow-up query: if the pack is still active under this PackInstallId, the cause
	// is a missing secret (terminal 401). If not, the incarnation changed (retriable 503).
	var packActive bool
	disambiguateErr := db.QueryRowContext(ctx, `
SELECT EXISTS(
  SELECT 1 FROM _System_InstalledPack
   WHERE Name = ?
     AND PackInstallId = ?
     AND Category = 'connector-pack'
     AND Status = 'active'
     AND COALESCE(IsDeleted, 0) = 0
)`, namespace, packInstallID).Scan(&packActive)
	if disambiguateErr != nil {
		log.Printf("webhooks: disambiguate after secret JOIN miss %s/%s (packInstallID=%s): %v", namespace, secretName, packInstallID, disambiguateErr)
		// Follow-up fault: fall back to the transient sentinel so we don't
		// permanently reject what may have been a DB hiccup.
		return "", "", ErrWebhookSecretIncarnationChanged
	}
	if !packActive {
		// Pack is no longer active under this PackInstallId — incarnation changed.
		// Return retriable 503: the dispatcher should retry with the new incarnation.
		return "", "", ErrWebhookSecretIncarnationChanged
	}
	// Pack still active but secret missing/inactive — genuine secret absence.
	// Return terminal 401: retrying will not help until the secret is provisioned.
	return "", "", ErrWebhookSecretNotAvailable
}

// ErrWebhookSecretNotAvailable is returned by ResolveWebhookSecretForVerification
// whenever the secret is missing, inactive, soft-deleted, or the owning connector
// pack is not active. The error text is intentionally opaque to callers (no
// leaking of which lookup step failed).
var ErrWebhookSecretNotAvailable = errors.New("webhook secret not available")

// ErrWebhookSecretLookupFailed signals a server-side / transient failure while
// resolving the secret — cipher not configured, a DB query error, or a decrypt
// failure — as opposed to ErrWebhookSecretNotAvailable, which means the secret
// genuinely does not exist or is inactive. Callers map this to a retriable 5xx
// rather than a terminal 4xx. The distinction depends only on infrastructure
// health (which is global to the server), never on whether a specific secret
// exists, so it leaks no secret-existence information to callers.
var ErrWebhookSecretLookupFailed = errors.New("webhook secret lookup failed")

// ErrWebhookSecretIncarnationChanged is returned by
// ResolveWebhookSecretForVerification when the active connector pack's
// PackInstallId no longer matches the packInstallID that was resolved at
// dispatcher Step 2. This indicates an admin reinstall occurred between the
// Step-2 pack resolution and the Step-3 secret lookup, so the delivery should
// be retried (provider gets 503) rather than permanently dropped (terminal 401).
var ErrWebhookSecretIncarnationChanged = errors.New("webhook secret: pack incarnation changed since route resolution")

// ResolvedWebhookSecret holds the algorithm and raw secret bytes needed by an
// HMAC verifier. Secret bytes must never be logged or serialised to an API response.
type ResolvedWebhookSecret struct {
	Algorithm       string
	Secret          []byte
	SecretValueHash string
}

// ResolveWebhookSecretForVerification is the server-internal-only read path for
// connector-pack webhook secret bytes. It is consumed by the generic inbound
// webhook dispatcher (NCRM-INT-04/#338); no HTTP handler exposes this directly.
//
// packInstallID is the per-incarnation identity resolved at dispatcher Step 2.
// When non-empty, steps 3–5 are collapsed into ONE atomic SQL JOIN that binds
// the secret lookup to the specific pack incarnation. 0 rows → always returns
// ErrWebhookSecretIncarnationChanged (transient 503), regardless of whether
// the cause is incarnation mismatch or a missing secret row. This eliminates
// the R14-BLOCKed race: the old 3-step sequence (pack check + 2 separate SELECTs)
// could observe a mid-reinstall state if an admin reinstall occurred between any
// two of those statements.
//
// When packInstallID is "" (admin/test callers), the legacy 3-step path runs
// with no incarnation check.
//
// Lookup order (fail closed at every step):
//  1. Trim namespace + name; either empty → ErrWebhookSecretNotAvailable.
//  2. cipher == nil → ErrWebhookSecretLookupFailed (encryption not configured).
//  3. packInstallID != "": ONE atomic JOIN (_System_InstalledPack ON PackInstallId
//     + _System_WebhookSecret + _System_WebhookSecretValue); 0 rows →
//     disambiguate (B23): pack inactive → ErrWebhookSecretIncarnationChanged (503);
//     pack active + secret missing → ErrWebhookSecretNotAvailable (401).
//  3. packInstallID == "": legacy 3-step path (no incarnation check).
//  6. cipher.Decrypt(storedCiphertext) — failure → ErrWebhookSecretLookupFailed.
//  7. Return ResolvedWebhookSecret{Algorithm, Secret}.
//
// The returned Secret slice must not be stored in logs.
func ResolveWebhookSecretForVerification(ctx context.Context, db *sql.DB, cipher *namedcredential.SecretCipher, namespace, name, packInstallID string) (*ResolvedWebhookSecret, error) {
	// Normalize at entry so callers can pass any case — stored Names are always
	// lowercase (intake-time normalization in webhook_secret_component.go).
	namespace = strings.ToLower(strings.TrimSpace(namespace))
	name = strings.ToLower(strings.TrimSpace(name))
	if namespace == "" || name == "" {
		return nil, ErrWebhookSecretNotAvailable
	}

	// Step 2: cipher must be configured; without it we cannot decrypt the stored value.
	if cipher == nil {
		log.Printf("webhooks: cipher not configured; cannot resolve webhook secret for %s/%s", namespace, name)
		return nil, ErrWebhookSecretLookupFailed
	}

	// Steps 3–5 (packInstallID-bound atomic path): when the caller has supplied
	// the incarnation identity from dispatcher Step 2, collapse the pack-active
	// check and both secret reads into ONE SQL with a JOIN on PackInstallId.
	// This closes the race window that existed when those three statements ran
	// sequentially: an admin reinstall between any two would cause verification
	// against the wrong incarnation's secret or a now-missing secret row.
	//
	// B23: 0 rows are disambiguated by a follow-up query inside
	// resolveWebhookSecretByPackInstallID: pack incarnation changed →
	// ErrWebhookSecretIncarnationChanged (retriable 503); pack still active but
	// secret missing → ErrWebhookSecretNotAvailable (terminal 401).
	//
	// When packInstallID is "" (admin/test callers), fall through to the legacy
	// three-statement path below (incarnation check skipped, namespace-scoped reads).
	var storedCiphertext, algorithm string
	if packInstallID != "" {
		var resolveErr error
		algorithm, storedCiphertext, resolveErr = resolveWebhookSecretByPackInstallID(ctx, db, namespace, name, packInstallID)
		if resolveErr != nil {
			// ErrWebhookSecretIncarnationChanged or ErrWebhookSecretLookupFailed —
			// propagate as-is; callers map them to 503 vs infra-fault 5xx respectively.
			return nil, resolveErr
		}
	} else {
		// Legacy three-statement path (packInstallID not supplied).

		// Step 3: verify the owning connector pack is active.
		packRow, ok, packErr := packstate.ResolveConnectorPackByNamespace(ctx, db, namespace)
		if packErr != nil {
			log.Printf("webhooks: resolve connector pack %q: %v", namespace, packErr)
			return nil, ErrWebhookSecretLookupFailed
		}
		if !ok {
			return nil, ErrWebhookSecretNotAvailable
		}
		_ = packRow // incarnation check skipped when packInstallID is "".

		// Step 4: find the definition row.
		var defID string
		err := db.QueryRowContext(ctx, `
SELECT Id, Algorithm
FROM _System_WebhookSecret
WHERE OwnerPack = ? AND Name = ? AND COALESCE(IsDeleted, 0) = 0
LIMIT 1`, namespace, name).Scan(&defID, &algorithm)
		if err == sql.ErrNoRows {
			return nil, ErrWebhookSecretNotAvailable
		}
		if err != nil {
			log.Printf("webhooks: query _System_WebhookSecret %s/%s: %v", namespace, name, err)
			return nil, ErrWebhookSecretLookupFailed
		}

		// Step 5: find the active value row.
		err = db.QueryRowContext(ctx, `
SELECT SecretValue
FROM _System_WebhookSecretValue
WHERE WebhookSecretId = ? AND IsActive = 1
LIMIT 1`, defID).Scan(&storedCiphertext)
		if err == sql.ErrNoRows {
			return nil, ErrWebhookSecretNotAvailable
		}
		if err != nil {
			log.Printf("webhooks: query _System_WebhookSecretValue for def %s: %v", defID, err)
			return nil, ErrWebhookSecretLookupFailed
		}

		if len(storedCiphertext) == 0 {
			// Treat an empty persisted value as missing — fail closed.
			log.Printf("webhooks: empty SecretValue for def %s; treating as unavailable", defID)
			return nil, ErrWebhookSecretNotAvailable
		}
	}

	// Step 6: decrypt the stored ciphertext.
	plaintext, err := cipher.Decrypt(storedCiphertext)
	if err != nil {
		log.Printf("webhooks: decrypt SecretValue for %s/%s: %v", namespace, name, err)
		return nil, ErrWebhookSecretLookupFailed
	}

	// Step 7: fail closed on an empty/whitespace decrypted secret. The
	// empty-ciphertext guard above only catches a blank stored value; a
	// corrupt/migrated row whose ciphertext decrypts to "" would otherwise hand
	// the dispatcher a zero-length HMAC key and let the verifier "authenticate"
	// against a public empty key. Treat it as missing (mirrors the admin
	// write-time EMPTY_SECRET_VALUE guard) so it never reaches the verifier.
	if strings.TrimSpace(plaintext) == "" {
		log.Printf("webhooks: decrypted SecretValue for %s/%s is empty; treating as unavailable", namespace, name)
		return nil, ErrWebhookSecretNotAvailable
	}

	return &ResolvedWebhookSecret{
		Algorithm:       algorithm,
		Secret:          []byte(plaintext),
		SecretValueHash: StoredSecretValueHash(storedCiphertext),
	}, nil
}

// StoredSecretValueHash returns a non-secret fingerprint of the encrypted
// SecretValue persisted in _System_WebhookSecretValue. It lets callers guard a
// later write against secret rotation without carrying plaintext outside the
// verifier read path.
func StoredSecretValueHash(ciphertext string) string {
	sum := sha256.Sum256([]byte(ciphertext))
	return hex.EncodeToString(sum[:])
}

// MaskSecretValue returns a display-safe masked representation of a raw secret.
// For secrets of 8 or more runes, returns "whsec_•••" + last 4 runes.
// For secrets shorter than 8 runes (including empty), returns just "whsec_•••"
// with no suffix — revealing any suffix of a short secret would disclose most
// or all of the value.
func MaskSecretValue(secret string) string {
	const prefix = "whsec_•••"
	runes := []rune(secret)
	n := len(runes)
	if n < 8 {
		return prefix
	}
	return fmt.Sprintf("%s%s", prefix, string(runes[n-4:]))
}
