package auth

import (
	"context"
	"crypto/rand"
	"database/sql"
	"encoding/base32"
	"errors"
	"strings"
	"time"

	"golang.org/x/crypto/bcrypt"
)

// ErrInvalidRecoveryCode is returned by Redeem when the code is unknown,
// already used, or otherwise unredeemable. The caller maps this to 401 + a
// rate-limit counter increment.
var ErrInvalidRecoveryCode = errors.New("auth: invalid recovery code")

// RecoveryCodeCount is the number of codes generated per registration. Ten
// is the operator-facing default in CLAUDE-phase2.md.
const RecoveryCodeCount = 10

// recoveryCodeBytes determines the entropy of each code. 10 bytes →
// base32-encoded → 16 character codes (without padding), grouped as XXXX-XXXX-XXXX-XXXX
// for human handling. 80 bits of entropy is more than sufficient against
// online guessing given the rate limit on /api/auth/recovery.
const recoveryCodeBytes = 10

// bcryptCost is intentionally moderate (10): the codes are high-entropy random,
// so hashing cost is a minor defense layer; high cost would slow down recovery
// without meaningful security benefit. Operator-facing endpoint is rate-limited.
const bcryptCost = 10

// RecoveryStore wraps the recovery_codes table.
type RecoveryStore struct {
	db          *sql.DB
	rngOverride func([]byte) (int, error) // for tests
}

// NewRecoveryStore constructs the store.
func NewRecoveryStore(db *sql.DB) *RecoveryStore {
	return &RecoveryStore{db: db}
}

// GenerateBatch creates RecoveryCodeCount fresh codes, persists their bcrypt
// hashes, and returns the plaintext codes ONCE. The caller is responsible
// for surfacing them to the operator (and confirming the operator saved
// them before the response leaves memory).
//
// Any previously-stored unused recovery codes are invalidated in the same
// transaction: regenerating means starting clean.
func (s *RecoveryStore) GenerateBatch(ctx context.Context) ([]string, error) {
	codes := make([]string, RecoveryCodeCount)
	hashes := make([][]byte, RecoveryCodeCount)
	for i := range codes {
		code, err := s.randomCode()
		if err != nil {
			return nil, err
		}
		h, err := bcrypt.GenerateFromPassword([]byte(normalizeCode(code)), bcryptCost)
		if err != nil {
			return nil, err
		}
		codes[i] = code
		hashes[i] = h
	}

	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer func() { _ = tx.Rollback() }()

	if _, err := tx.ExecContext(ctx, `DELETE FROM recovery_codes WHERE used_at IS NULL`); err != nil {
		return nil, err
	}
	now := time.Now().Unix()
	for _, h := range hashes {
		if _, err := tx.ExecContext(ctx,
			`INSERT INTO recovery_codes(code_hash, created_at) VALUES (?, ?)`, h, now,
		); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return codes, nil
}

// Redeem checks code against every unused stored hash; on match it marks
// that row used_at = now and returns nil. The check is constant-time across
// the candidate set in the sense that we always iterate the full set (no
// early return) — though bcrypt comparison itself is per-row and not
// constant-time across rows. At single-operator scale this is fine.
func (s *RecoveryStore) Redeem(ctx context.Context, code string) error {
	if strings.TrimSpace(code) == "" {
		return ErrInvalidRecoveryCode
	}
	normalized := []byte(normalizeCode(code))

	rows, err := s.db.QueryContext(ctx,
		`SELECT id, code_hash FROM recovery_codes WHERE used_at IS NULL`)
	if err != nil {
		return err
	}
	defer rows.Close()

	var matchedID int64
	matched := false
	for rows.Next() {
		var (
			id   int64
			hash []byte
		)
		if err := rows.Scan(&id, &hash); err != nil {
			return err
		}
		if err := bcrypt.CompareHashAndPassword(hash, normalized); err == nil {
			if !matched {
				matchedID = id
				matched = true
				// Iterate through the rest to keep timing characteristics
				// closer to "always traverse all candidates".
			}
		}
	}
	if err := rows.Err(); err != nil {
		return err
	}
	if !matched {
		return ErrInvalidRecoveryCode
	}

	res, err := s.db.ExecContext(ctx,
		`UPDATE recovery_codes SET used_at = ? WHERE id = ? AND used_at IS NULL`,
		time.Now().Unix(), matchedID)
	if err != nil {
		return err
	}
	n, _ := res.RowsAffected()
	if n == 0 {
		// Race: someone else used it between SELECT and UPDATE.
		return ErrInvalidRecoveryCode
	}
	return nil
}

// CountUnused returns how many recovery codes remain redeemable. Used by the
// settings UI to surface "you have N unused recovery codes".
func (s *RecoveryStore) CountUnused(ctx context.Context) (int, error) {
	var n int
	err := s.db.QueryRowContext(ctx,
		`SELECT COUNT(*) FROM recovery_codes WHERE used_at IS NULL`).Scan(&n)
	return n, err
}

func (s *RecoveryStore) randomCode() (string, error) {
	buf := make([]byte, recoveryCodeBytes)
	if s.rngOverride != nil {
		if _, err := s.rngOverride(buf); err != nil {
			return "", err
		}
	} else {
		if _, err := rand.Read(buf); err != nil {
			return "", err
		}
	}
	raw := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf))
	// Group as 4x4 for readability: xxxx-xxxx-xxxx-xxxx
	var b strings.Builder
	for i, r := range raw {
		if i > 0 && i%4 == 0 {
			b.WriteRune('-')
		}
		b.WriteRune(r)
	}
	return b.String(), nil
}

// normalizeCode is applied to both the generated plaintext (before hashing)
// and the redemption attempt (before comparison). It tolerates operator
// transcription quirks: case, dashes, whitespace.
func normalizeCode(s string) string {
	s = strings.ToLower(strings.TrimSpace(s))
	out := make([]byte, 0, len(s))
	for i := 0; i < len(s); i++ {
		c := s[i]
		switch {
		case c >= 'a' && c <= 'z', c >= '2' && c <= '7':
			// base32 alphabet (lowercase) and digits 2-7
			out = append(out, c)
		}
	}
	return string(out)
}
