// Package webauthn implements the server side of the WebAuthn (FIDO2) protocol
// for passkey-based user verification. It handles both registration (creating
// a new credential) and assertion (verifying the user), using standard library
// crypto and a minimal CBOR decoder.
//
// Only ECDSA P-256 (ES256, COSE algorithm -7) credentials are supported.
// Attestation is read but not verified — we trust the authenticator attestation
// is honest for this deployment (single-user, self-hosted).
package webauthn

import (
	"bytes"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base64"
	"encoding/binary"
	"encoding/json"
	"fmt"
	"math/big"
	"sync"
	"time"
)

// Config — the WebAuthn Relying Party parameters. Must match what the browser
// sees (origin = the page's origin, rpID = the effective domain).
type Config struct {
	Origin string // e.g. "https://maven.kvmx.ru"
	RPID   string // e.g. "maven.kvmx.ru"
	RPName string // e.g. "maven"
}

// CredentialLookup is the function signature the RP needs to load a stored
// credential for assertion verification. Returns the COSE public key bytes
// and the current sign count.
type CredentialLookup func(id string) (publicKey []byte, signCount int64, err error)

// CredentialSaver stores a newly registered credential.
type CredentialSaver func(id string, publicKey []byte, userID []byte, userDisplayName string) error

// SignCountUpdater persists an updated sign counter after a successful assertion.
type SignCountUpdater func(id string, count int64) error

// credentialRegistration is the in-memory state for an in-flight registration.
type credentialRegistration struct {
	Challenge string
	UserID    []byte
	CreatedAt time.Time
}

// credentialAssertion is the in-memory state for an in-flight assertion.
type credentialAssertion struct {
	Challenge string
	CreatedAt time.Time
}

// RP — the relying party instance. Holds config and transient challenge state.
// A single-user daemon has one RP.
//
// mavweb serves the four passkey endpoints from its HTTP handlers, so the two
// challenge maps are reached concurrently even on a single-user box: a browser
// retrying an assertion while another tab begins one is enough. A concurrent
// map write is a fatal runtime error, not a recovered panic, so it would take
// the whole daemon down from an endpoint that answers before any credential is
// proven. Every read and write of regs and asserts is under mu.
type RP struct {
	cfg          Config
	mu           sync.Mutex
	regs         map[string]*credentialRegistration
	asserts      map[string]*credentialAssertion
	challengeTTL time.Duration
}

// NewRP creates a relying party with the given WebAuthn configuration.
func NewRP(cfg Config) *RP {
	return &RP{
		cfg:          cfg,
		regs:         make(map[string]*credentialRegistration),
		asserts:      make(map[string]*credentialAssertion),
		challengeTTL: 5 * time.Minute,
	}
}

// CleanExpired removes challenges older than the TTL.
func (rp *RP) CleanExpired() {
	rp.mu.Lock()
	defer rp.mu.Unlock()
	rp.cleanExpired()
}

// cleanExpired is CleanExpired for a caller that already holds mu.
func (rp *RP) cleanExpired() {
	now := time.Now()
	for k, r := range rp.regs {
		if now.Sub(r.CreatedAt) > rp.challengeTTL {
			delete(rp.regs, k)
		}
	}
	for k, a := range rp.asserts {
		if now.Sub(a.CreatedAt) > rp.challengeTTL {
			delete(rp.asserts, k)
		}
	}
}

// CreationOptions returns the PublicKeyCredentialCreationOptions as a
// JSON-serializable map for the browser to create a credential.
func (rp *RP) CreationOptions(userID []byte, userName string) (map[string]any, string, error) {
	challenge := make([]byte, 32)
	if _, err := rand.Read(challenge); err != nil {
		return nil, "", fmt.Errorf("webauthn: challenge: %w", err)
	}
	challengeB64 := base64.RawURLEncoding.EncodeToString(challenge)

	rp.mu.Lock()
	rp.cleanExpired()
	rp.regs[challengeB64] = &credentialRegistration{
		Challenge: challengeB64,
		UserID:    userID,
		CreatedAt: time.Now(),
	}
	rp.mu.Unlock()

	return map[string]any{
		"rp": map[string]string{
			"name": rp.cfg.RPName,
			"id":   rp.cfg.RPID,
		},
		"user": map[string]any{
			"id":          base64.RawURLEncoding.EncodeToString(userID),
			"name":        userName,
			"displayName": userName,
		},
		"challenge": challengeB64,
		// ES256 only — parseCOSEKey verifies P-256/ES256 exclusively. Offering
		// RS256 here would let an authenticator register a key we can never
		// verify at assertion time (register-ok, assert-fail forever).
		"pubKeyCredParams": []map[string]any{
			{"type": "public-key", "alg": -7}, // ES256
		},
		"timeout":            60000,
		"attestation":        "none",
		"excludeCredentials": []any{},
		// PRF: ask the authenticator at enrollment time whether it can
		// produce a per-credential secret. Nothing is wrapped here — the
		// browser reports support back and mavweb decides whether cold-start
		// unlock is available for this credential. See internal/webauthn/prf.go.
		"extensions": map[string]any{
			"prf": map[string]any{},
		},
	}, challengeB64, nil
}

// FinishRegistration parses the browser's response and stores the credential.
func (rp *RP) FinishRegistration(save CredentialSaver, challengeB64 string, resp map[string]any) (string, error) {
	reg, ok := rp.takeReg(challengeB64)
	if !ok {
		return "", fmt.Errorf("webauthn: unknown or expired challenge")
	}

	credID := rawString(resp, "id")
	if credID == "" {
		return "", fmt.Errorf("webauthn: missing credential id")
	}

	rawResponse, ok := resp["response"].(map[string]any)
	if !ok {
		return "", fmt.Errorf("webauthn: missing response")
	}

	cdjB64 := rawString(rawResponse, "clientDataJSON")
	cdjRaw, err := base64.RawURLEncoding.DecodeString(cdjB64)
	if err != nil {
		return "", fmt.Errorf("webauthn: clientDataJSON: %w", err)
	}
	if err := verifyClientDataBytes(cdjRaw, "webauthn.create", challengeB64, rp.cfg.Origin); err != nil {
		return "", err
	}

	attObjB64 := rawString(rawResponse, "attestationObject")
	attObj, err := base64.RawURLEncoding.DecodeString(attObjB64)
	if err != nil {
		return "", fmt.Errorf("webauthn: attestationObject: %w", err)
	}

	publicKey, err := extractPublicKey(attObj)
	if err != nil {
		return "", fmt.Errorf("webauthn: extract key: %w", err)
	}

	if err := save(credID, publicKey, reg.UserID, "maven user"); err != nil {
		return "", fmt.Errorf("webauthn: store credential: %w", err)
	}

	return credID, nil
}

// AssertionOptions returns a JSON-serializable map for browser authentication.
func (rp *RP) AssertionOptions() (map[string]any, string, error) {
	challenge := make([]byte, 32)
	if _, err := rand.Read(challenge); err != nil {
		return nil, "", fmt.Errorf("webauthn: challenge: %w", err)
	}
	challengeB64 := base64.RawURLEncoding.EncodeToString(challenge)

	rp.mu.Lock()
	rp.cleanExpired()
	rp.asserts[challengeB64] = &credentialAssertion{
		Challenge: challengeB64,
		CreatedAt: time.Now(),
	}
	rp.mu.Unlock()

	return map[string]any{
		"challenge":        challengeB64,
		"timeout":          60000,
		"rpId":             rp.cfg.RPID,
		"allowCredentials": []any{},
		"userVerification": "required",
		// PRF evaluation over the fixed cold-start salt. The 32 bytes that
		// come back are the ONLY thing that can unwrap the database key.
		"extensions": map[string]any{
			"prf": map[string]any{
				"eval": map[string]any{
					"first": base64.RawURLEncoding.EncodeToString(PRFSalt()),
				},
			},
		},
	}, challengeB64, nil
}

// FinishAssertion verifies the browser's assertion response and returns the
// verified credential ID.
func (rp *RP) FinishAssertion(lookup CredentialLookup, updateSignCount SignCountUpdater, challengeB64 string, resp map[string]any) (string, error) {
	if !rp.takeAssert(challengeB64) {
		return "", fmt.Errorf("webauthn: unknown or expired challenge")
	}

	credID := rawString(resp, "id")
	if credID == "" {
		return "", fmt.Errorf("webauthn: missing credential id")
	}

	rawResponse, ok := resp["response"].(map[string]any)
	if !ok {
		return "", fmt.Errorf("webauthn: missing response")
	}

	cdjRaw, err := base64.RawURLEncoding.DecodeString(rawString(rawResponse, "clientDataJSON"))
	if err != nil {
		return "", fmt.Errorf("webauthn: clientDataJSON: %w", err)
	}
	if err := verifyClientDataBytes(cdjRaw, "webauthn.get", challengeB64, rp.cfg.Origin); err != nil {
		return "", err
	}

	authenticatorData, err := base64.RawURLEncoding.DecodeString(rawString(rawResponse, "authenticatorData"))
	if err != nil {
		return "", fmt.Errorf("webauthn: authenticatorData: %w", err)
	}
	// Bind the assertion to this RP and require a verified user gesture. Origin
	// is checked via clientDataJSON above; rpIdHash + flags bind the
	// authenticator half. We requested userVerification:required, so UV must be
	// set — that IS the step-up gesture (biometric/PIN).
	if len(authenticatorData) < 37 {
		return "", fmt.Errorf("webauthn: authenticatorData too short (%d)", len(authenticatorData))
	}
	rpIDHash := sha256.Sum256([]byte(rp.cfg.RPID))
	if !bytes.Equal(authenticatorData[:32], rpIDHash[:]) {
		return "", fmt.Errorf("webauthn: rpIdHash mismatch")
	}
	const flagUP, flagUV = 1 << 0, 1 << 2
	if authenticatorData[32]&flagUP == 0 {
		return "", fmt.Errorf("webauthn: user-present flag not set")
	}
	if authenticatorData[32]&flagUV == 0 {
		return "", fmt.Errorf("webauthn: user-verification flag not set")
	}

	sig, err := base64.RawURLEncoding.DecodeString(rawString(rawResponse, "signature"))
	if err != nil {
		return "", fmt.Errorf("webauthn: signature: %w", err)
	}

	pubKeyBytes, signCount, err := lookup(credID)
	if err != nil {
		return "", fmt.Errorf("webauthn: credential not found: %w", err)
	}

	clientDataHash := sha256.Sum256(cdjRaw)
	sigData := append(authenticatorData, clientDataHash[:]...)

	pubKey, err := parseCOSEKey(pubKeyBytes)
	if err != nil {
		return "", fmt.Errorf("webauthn: parse key: %w", err)
	}
	if !ecdsa.VerifyASN1(pubKey, sigData, sig) {
		return "", fmt.Errorf("webauthn: signature verification failed")
	}

	if len(authenticatorData) >= 37 {
		counter := int64(binary.BigEndian.Uint32(authenticatorData[33:37]))
		if counter > 0 && counter <= signCount {
			return "", fmt.Errorf("webauthn: sign count not greater (old=%d, new=%d)", signCount, counter)
		}
		if counter > 0 {
			if err := updateSignCount(credID, counter); err != nil {
				return "", fmt.Errorf("webauthn: update sign count: %w", err)
			}
		}
	}

	return credID, nil
}

// takeReg removes and returns the in-flight registration for challengeB64.
// Taking under one lock is what makes a challenge single-use: looking it up
// and deleting it separately lets two replays of the same response both find
// it before either deletes.
func (rp *RP) takeReg(challengeB64 string) (*credentialRegistration, bool) {
	rp.mu.Lock()
	defer rp.mu.Unlock()
	rp.cleanExpired()
	reg, ok := rp.regs[challengeB64]
	if ok {
		delete(rp.regs, challengeB64)
	}
	return reg, ok
}

// takeAssert removes the in-flight assertion for challengeB64 and reports
// whether it was there. Single-use for the same reason takeReg is.
func (rp *RP) takeAssert(challengeB64 string) bool {
	rp.mu.Lock()
	defer rp.mu.Unlock()
	rp.cleanExpired()
	if _, ok := rp.asserts[challengeB64]; !ok {
		return false
	}
	delete(rp.asserts, challengeB64)
	return true
}

func verifyClientDataBytes(clientDataJSON []byte, expectedType, expectedChallenge, expectedOrigin string) error {
	var cdj struct {
		Type      string `json:"type"`
		Challenge string `json:"challenge"`
		Origin    string `json:"origin"`
	}
	if err := json.Unmarshal(clientDataJSON, &cdj); err != nil {
		return fmt.Errorf("webauthn: parse clientDataJSON: %w", err)
	}
	if cdj.Type != expectedType {
		return fmt.Errorf("webauthn: unexpected type %q", cdj.Type)
	}
	// Constant time, because the challenge is the one secret in clientDataJSON:
	// it is 32 bytes of crypto/rand the browser has to echo back, and a
	// byte-at-a-time compare is the shape that leaks a guessed prefix.
	if subtle.ConstantTimeCompare([]byte(cdj.Challenge), []byte(expectedChallenge)) != 1 {
		return fmt.Errorf("webauthn: challenge mismatch")
	}
	if cdj.Origin != expectedOrigin {
		return fmt.Errorf("webauthn: origin mismatch: %q != %q", cdj.Origin, expectedOrigin)
	}
	return nil
}

func extractPublicKey(attObj []byte) ([]byte, error) {
	v, err := decodeCBOR(attObj)
	if err != nil {
		return nil, fmt.Errorf("webauthn: decode attestation: %w", err)
	}
	m, err := v.MapText()
	if err != nil {
		return nil, fmt.Errorf("webauthn: attestation is not a map: %w", err)
	}

	authDataV, ok := m["authData"]
	if !ok {
		return nil, fmt.Errorf("webauthn: attestation missing authData")
	}
	authData, err := authDataV.Bytes()
	if err != nil {
		return nil, fmt.Errorf("webauthn: authData not bytes: %w", err)
	}
	return extractCOSEKeyFromAuthData(authData)
}

func extractCOSEKeyFromAuthData(authData []byte) ([]byte, error) {
	if len(authData) < 37 {
		return nil, fmt.Errorf("webauthn: authData too short (%d)", len(authData))
	}
	flags := authData[32]
	if flags&(1<<6) == 0 {
		return nil, fmt.Errorf("webauthn: AT flag not set in authData")
	}
	acd := authData[37:]
	if len(acd) < 18 {
		return nil, fmt.Errorf("webauthn: attested credential data too short (%d)", len(acd))
	}
	credIDLen := int(binary.BigEndian.Uint16(acd[16:18]))
	coseKeyOff := 18 + credIDLen
	if coseKeyOff > len(acd) {
		return nil, fmt.Errorf("webauthn: credential ID length %d exceeds data (%d)", credIDLen, len(acd))
	}
	return acd[coseKeyOff:], nil
}

func parseCOSEKey(raw []byte) (*ecdsa.PublicKey, error) {
	v, err := decodeCBOR(raw)
	if err != nil {
		return nil, fmt.Errorf("cose: decode: %w", err)
	}
	m, err := v.Map()
	if err != nil {
		return nil, fmt.Errorf("cose: not a map: %w", err)
	}

	kty, ok := m[1]
	if !ok {
		return nil, fmt.Errorf("cose: missing kty")
	}
	ktyV, err := kty.Int()
	if err != nil {
		return nil, fmt.Errorf("cose: kty: %w", err)
	}
	if ktyV != 2 {
		return nil, fmt.Errorf("cose: unsupported kty %d (expected 2=EC2)", ktyV)
	}

	crv, ok := m[-1]
	if !ok {
		return nil, fmt.Errorf("cose: missing crv")
	}
	crvV, err := crv.Int()
	if err != nil {
		return nil, fmt.Errorf("cose: crv: %w", err)
	}
	if crvV != 1 {
		return nil, fmt.Errorf("cose: unsupported crv %d (expected 1=P-256)", crvV)
	}

	xV, ok := m[-2]
	if !ok {
		return nil, fmt.Errorf("cose: missing x coordinate")
	}
	x, err := xV.Bytes()
	if err != nil {
		return nil, fmt.Errorf("cose: x: %w", err)
	}

	yV, ok := m[-3]
	if !ok {
		return nil, fmt.Errorf("cose: missing y coordinate")
	}
	y, err := yV.Bytes()
	if err != nil {
		return nil, fmt.Errorf("cose: y: %w", err)
	}

	return &ecdsa.PublicKey{
		Curve: elliptic.P256(),
		X:     new(big.Int).SetBytes(x),
		Y:     new(big.Int).SetBytes(y),
	}, nil
}

func rawString(m map[string]any, key string) string {
	v, ok := m[key]
	if !ok {
		return ""
	}
	s, _ := v.(string)
	return s
}
