// Package hookget verifies HookGet webhook signatures.
//
// One file, standard library only. Copy it into your project; there is
// deliberately nothing to keep up to date.
//
//	func handler(w http.ResponseWriter, r *http.Request) {
//	    body, _ := io.ReadAll(r.Body)
//	    if _, err := hookget.Verify(os.Getenv("HOOKGET_SECRET"), r.Header, body, hookget.Options{}); err != nil {
//	        http.Error(w, err.Error(), http.StatusBadRequest)
//	        return
//	    }
//	    w.WriteHeader(http.StatusOK) // acknowledge first, work afterwards
//	}
//
// ── THE ONE MISTAKE EVERYONE MAKES ───────────────────────────────────────────
// Verify the RAW BODY — the exact bytes that arrived, which in Go means
// io.ReadAll(r.Body) before any json.Decode. Not a re-marshalled struct, which
// will differ from what was signed the first time a payload contains a
// non-ASCII character, a field your struct does not have, or keys the encoder
// reordered. Every "the signature does not match and I cannot see why" ends
// here.
//
// See https://www.standardwebhooks.com
// License: Apache-2.0
package hookget

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"fmt"
	"net/http"
	"strconv"
	"strings"
	"time"
)

const (
	secretPrefix             = "whsec_"
	DefaultToleranceSeconds  = 300
)

// Reasons a verification can fail. Compare with errors.Is.
var (
	ErrMissingHeaders   = errors.New("hookget: missing webhook-id, webhook-timestamp or webhook-signature")
	ErrBadTimestamp     = errors.New("hookget: webhook-timestamp is not an integer")
	ErrStaleTimestamp   = errors.New("hookget: timestamp outside the tolerance window")
	ErrNoMatchingSig    = errors.New("hookget: no presented signature matched")
	ErrBadSecret        = errors.New("hookget: the signing secret is not valid base64")
)

// Options tunes verification. The zero value is the right default.
type Options struct {
	// ToleranceSeconds defaults to 300 when zero.
	ToleranceSeconds int
	// Now defaults to time.Now when zero. Present so tests do not sleep.
	Now time.Time
}

// Verified is what a successful verification tells you about the delivery.
type Verified struct {
	ID        string
	Timestamp int64
}

// secretToKey decodes the secret.
//
// The secret is base64 and the HMAC key is its BYTES, not its text. Hashing the
// base64 string itself is the second most common bug here, and it produces a
// signature that is stable, plausible, and wrong.
func secretToKey(secret string) ([]byte, error) {
	body := strings.TrimPrefix(secret, secretPrefix)
	key, err := base64.StdEncoding.DecodeString(body)
	if err != nil {
		return nil, ErrBadSecret
	}
	return key, nil
}

// Sign produces the base64 signature for one secret. The signed content is
// always `{id}.{timestamp}.{body}`.
func Sign(secret, id string, timestamp int64, body []byte) (string, error) {
	key, err := secretToKey(secret)
	if err != nil {
		return "", err
	}
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(fmt.Sprintf("%s.%d.", id, timestamp)))
	mac.Write(body)
	return base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil
}

// Verify checks a delivery against one or more secrets.
//
// Pass several secrets during a rotation: both the old and the new one sign the
// same body, so a rotation never drops a delivery.
func Verify(secrets interface{}, headers http.Header, body []byte, opts Options) (Verified, error) {
	var list []string
	switch s := secrets.(type) {
	case string:
		list = []string{s}
	case []string:
		list = s
	default:
		return Verified{}, errors.New("hookget: secrets must be a string or []string")
	}

	// http.Header.Get is already case-insensitive, which is the whole reason
	// this takes an http.Header rather than a map.
	id := headers.Get("webhook-id")
	rawTimestamp := headers.Get("webhook-timestamp")
	signatureHeader := headers.Get("webhook-signature")
	if id == "" || rawTimestamp == "" || signatureHeader == "" {
		return Verified{}, ErrMissingHeaders
	}

	timestamp, err := strconv.ParseInt(rawTimestamp, 10, 64)
	if err != nil {
		return Verified{}, ErrBadTimestamp
	}

	tolerance := opts.ToleranceSeconds
	if tolerance == 0 {
		tolerance = DefaultToleranceSeconds
	}
	now := opts.Now
	if now.IsZero() {
		now = time.Now()
	}
	// A replay window. Without it a captured delivery stays valid forever, and
	// an attacker who ever saw one valid request can send it again at will.
	drift := now.Unix() - timestamp
	if drift < 0 {
		drift = -drift
	}
	if drift > int64(tolerance) {
		return Verified{}, ErrStaleTimestamp
	}

	presented := strings.Fields(signatureHeader)
	for _, secret := range list {
		expected, err := Sign(secret, id, timestamp, body)
		if err != nil {
			return Verified{}, err
		}
		for _, candidate := range presented {
			version, value, found := strings.Cut(candidate, ",")
			if !found || version != "v1" || value == "" {
				continue
			}
			// hmac.Equal and not ==: a comparison that returns early leaks the
			// signature one byte at a time to anyone willing to measure.
			if hmac.Equal([]byte(value), []byte(expected)) {
				return Verified{ID: id, Timestamp: timestamp}, nil
			}
		}
	}

	return Verified{}, ErrNoMatchingSig
}

// IsValid is the boolean form, for callers that prefer one.
func IsValid(secrets interface{}, headers http.Header, body []byte, opts Options) bool {
	_, err := Verify(secrets, headers, body, opts)
	return err == nil
}
