package yubikey

import (
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base32"
	"encoding/hex"
	"strings"

	"gitea.apointless.space/bsncubed/dial/internal/store"
)

// Recovery code format: 6 dash-separated groups of 5 base32 characters
// (XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX), i.e. 30 chars ≈ 150 bits of entropy.
// It is shown to the user exactly once at enrollment and stored only as a
// salted hash. It is single-use: after a successful recovery unlock the caller
// invalidates it and issues a fresh one.
const (
	recoveryGroups   = 6
	recoveryGroupLen = 5
	recoveryChars    = recoveryGroups * recoveryGroupLen // 30
	recoveryRawBytes = 19                                // 19 bytes -> 31 base32 chars, sliced to 30
)

// base32NoPad uses the standard RFC 4648 alphabet without padding.
var base32NoPad = base32.StdEncoding.WithPadding(base32.NoPadding)

// GenerateRecoveryCode returns a fresh human-formatted recovery code together
// with its salted hash for storage. The plaintext is returned only here and is
// expected to be displayed once and then discarded by the caller.
func GenerateRecoveryCode() (code string, h store.RecoveryHash, err error) {
	raw := make([]byte, recoveryRawBytes)
	if _, err = rand.Read(raw); err != nil {
		return "", store.RecoveryHash{}, err
	}
	enc := base32NoPad.EncodeToString(raw)[:recoveryChars]

	var b strings.Builder
	for i := 0; i < recoveryGroups; i++ {
		if i > 0 {
			b.WriteByte('-')
		}
		b.WriteString(enc[i*recoveryGroupLen : (i+1)*recoveryGroupLen])
	}
	code = b.String()

	salt := make([]byte, 16)
	if _, err = rand.Read(salt); err != nil {
		return "", store.RecoveryHash{}, err
	}
	h = store.RecoveryHash{
		Salt: hex.EncodeToString(salt),
		Hash: hex.EncodeToString(hashCode(salt, normalizeCode(code))),
	}
	return code, h, nil
}

// VerifyRecoveryCode reports whether input matches the stored recovery hash.
// Input is normalised (uppercased, separators/whitespace removed) before
// comparison so the user can type it with or without dashes.
func VerifyRecoveryCode(input string, h store.RecoveryHash) bool {
	if !h.IsSet() {
		return false
	}
	salt, err := hex.DecodeString(h.Salt)
	if err != nil {
		return false
	}
	want, err := hex.DecodeString(h.Hash)
	if err != nil {
		return false
	}
	got := hashCode(salt, normalizeCode(input))
	return subtle.ConstantTimeCompare(got, want) == 1
}

// normalizeCode strips separators/whitespace and uppercases so that "abc de"
// and "ABCDE" compare equal.
func normalizeCode(s string) string {
	var b strings.Builder
	for _, r := range strings.ToUpper(s) {
		if (r >= 'A' && r <= 'Z') || (r >= '2' && r <= '7') {
			b.WriteRune(r)
		}
	}
	return b.String()
}

func hashCode(salt []byte, normalized string) []byte {
	h := sha256.New()
	h.Write(salt)
	h.Write([]byte(normalized))
	return h.Sum(nil)
}
