package admin

import (
	"context"
	"errors"
	"strings"

	"go.uber.org/zap"
	"golang.org/x/crypto/bcrypt"
)

// Verify implements `basicauth.Verifier`. The middleware in
// `internal/server/middleware/basicauth/basicauth.go:84` invokes this
// method on every admin request.
//
// Behaviour:
//
//  1. Empty username rejects fast.
//  2. Cache lookup; on miss, fall back to a direct Postgres read and
//     populate the cache. Lookup misses return (false, nil) — no
//     account-existence leak.
//  3. Disabled accounts (`disabled_at IS NOT NULL`) reject without
//     hitting bcrypt.
//  4. `bcrypt.CompareHashAndPassword` runs constant-time on the
//     matching path; a mismatch returns (false, nil) so the
//     middleware emits 401 with the standard envelope.
//  5. On match a fire-and-forget goroutine bumps `last_used_at` and
//     refreshes the cached entry. Errors on the bump are logged but
//     never block the request.
//  6. Any other error returned by the lookup path surfaces to the
//     middleware which maps it to 500.
func (s *Service) Verify(ctx context.Context, username, password string) (bool, error) {
	username = strings.TrimSpace(username)
	if username == "" {
		return false, nil
	}

	admin, hash, err := s.lookupForVerify(ctx, username)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return false, nil
		}
		return false, err
	}
	if admin.DisabledAt != nil {
		return false, nil
	}

	switch err := bcrypt.CompareHashAndPassword(hash, []byte(password)); {
	case err == nil:
		s.touchLastUsedAsync(username, hash)
		return true, nil
	case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword):
		return false, nil
	default:
		return false, err
	}
}

// lookupForVerify reads the cache first and falls back to Postgres on
// miss, populating the cache so subsequent requests skip the round
// trip. The returned hash slice is the cached entry's reference;
// callers must not mutate it.
func (s *Service) lookupForVerify(ctx context.Context, username string) (Admin, []byte, error) {
	if admin, hash, ok := s.deps.Cache.Get(username); ok {
		return admin, hash, nil
	}
	admin, hash, err := s.deps.Store.Lookup(ctx, username)
	if err != nil {
		return Admin{}, nil, err
	}
	s.deps.Cache.Put(admin, hash)
	return admin, hash, nil
}

// touchLastUsedAsync schedules a fire-and-forget UPDATE on
// `last_used_at`. The update uses a fresh background context so the
// write survives the request lifecycle even when the caller
// disconnects mid-response. On success the cached entry is refreshed
// in place so subsequent reads see the new timestamp; failures are
// logged at warn level and the cache stays at the old value.
//
// `last_used_at` is observability-only: it never gates authentication.
// The fire-and-forget pattern keeps the request path single-digit
// milliseconds even under transient Postgres latency.
func (s *Service) touchLastUsedAsync(username string, hash []byte) {
	now := s.deps.Now().UTC()
	go func() {
		// Background context — the request may complete before the
		// goroutine reaches Postgres. The store query carries no
		// timeout of its own; the pool's default operation timeout
		// applies instead.
		ctx := context.Background()
		if err := s.deps.Store.TouchLastUsed(ctx, username, now); err != nil {
			s.deps.Logger.Warn("touch last_used_at failed",
				zap.String("admin_username", username),
				zap.Error(err),
			)
			return
		}
		// Refresh the cached entry. We re-read so the cache reflects
		// any concurrent disable/enable that happened between the
		// successful Verify and the bump.
		if admin, freshHash, ok := s.deps.Cache.Get(username); ok {
			admin.LastUsedAt = &now
			// Prefer the slice that was just verified; if the cache
			// rotated to a different hash (concurrent password
			// reset), keep the cached one to avoid clobbering it.
			if hashesEqual(freshHash, hash) {
				s.deps.Cache.Put(admin, hash)
			} else {
				s.deps.Cache.Put(admin, freshHash)
			}
		}
	}()
}

// hashesEqual reports whether two bcrypt hashes are byte-identical.
// The caller cares only about staleness detection — bcrypt hashes are
// not secret in the cache (the cache lives in process memory), so a
// timing-leaking comparison is acceptable.
func hashesEqual(a, b []byte) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}
