package auth

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"strings"

	"github.com/google/uuid"
	"go.uber.org/zap"
)

// SendEmailCode issues an email login challenge for email and returns
// its challenge_id. The wire shape is intentionally identical for new
// users, existing users, and throttled requesters; the only path that
// returns ErrEmailPermanentlyBlocked is when email maps to an account
// whose `permanent_block` column is true (handler maps that sentinel to
// 400 invalid_request).
//
// Throttle behaviour: when the count of un-consumed, non-expired
// challenges for email created within ChallengeThrottle.Window already
// equals or exceeds ChallengeThrottle.Max, SendEmailCode reuses the
// most recent existing challenge_id and skips the mail enqueue. This
// avoids a leak where an attacker who controls their own SMTP server
// could otherwise correlate "row created without mail" with
// throttle-state on the platform.
//
// locale (request body, BCP 47) takes precedence over acceptLanguage
// (the standard HTTP header forwarded by gateway) when both are
// supplied. When neither is supplied SendEmailCode falls back to the
// platform default ("en"). The resolved value is persisted on the
// challenge row as `preferred_language` and used by confirm-email-code
// only for newly-registered accounts; existing accounts keep their
// stored language.
func (s *Service) SendEmailCode(
	ctx context.Context,
	email, locale, acceptLanguage, sourceIP string,
) (uuid.UUID, error) {
	normalised := normaliseEmail(email)
	if normalised == "" {
		return uuid.Nil, fmt.Errorf("auth: email is empty")
	}

	permanent, err := s.deps.Store.IsEmailPermanentlyBlocked(ctx, normalised)
	if err != nil {
		return uuid.Nil, err
	}
	if permanent {
		return uuid.Nil, ErrEmailPermanentlyBlocked
	}

	captured := pickCapturedLocale(locale, acceptLanguage)
	if captured == "" {
		captured = defaultLanguage
	}

	now := s.deps.Now()
	windowStart := now.Add(-s.deps.Config.ChallengeThrottle.Window)
	count, err := s.deps.Store.CountRecentChallenges(ctx, normalised, windowStart)
	if err != nil {
		return uuid.Nil, err
	}
	if count >= s.deps.Config.ChallengeThrottle.Max {
		existing, lerr := s.deps.Store.LatestUnconsumedChallenge(ctx, normalised, windowStart)
		if lerr == nil {
			s.deps.Logger.Info("auth challenge reused (throttled)",
				zap.String("email_hash", s.hashEmail(normalised)),
				zap.String("challenge_id", existing.ChallengeID.String()),
				zap.Int("recent_count", count),
			)
			return existing.ChallengeID, nil
		}
		if !errors.Is(lerr, sql.ErrNoRows) {
			return uuid.Nil, lerr
		}
		// sql.ErrNoRows here is a race (a concurrent confirm consumed
		// the row between count and select); fall through and issue a
		// fresh challenge.
	}

	code, err := generateCode()
	if err != nil {
		return uuid.Nil, err
	}
	hash, err := hashCode(code)
	if err != nil {
		return uuid.Nil, fmt.Errorf("auth: hash code: %w", err)
	}

	challenge := Challenge{
		ChallengeID: uuid.New(),
		Email: normalised,
		CodeHash: hash,
		ExpiresAt: now.Add(s.deps.Config.ChallengeTTL),
		PreferredLanguage: captured,
	}
	if err := s.deps.Store.InsertChallenge(ctx, challenge); err != nil {
		return uuid.Nil, err
	}

	if err := s.deps.Mail.EnqueueLoginCode(ctx, normalised, code, s.deps.Config.ChallengeTTL); err != nil {
		// A mail-enqueue failure is logged but not surfaced — the user
		// can issue another challenge. The implementation will surface a
		// transient error path; for The implementation the no-op publisher never
		// returns an error.
		s.deps.Logger.Warn("auth: enqueue login code failed",
			zap.String("email_hash", s.hashEmail(normalised)),
			zap.String("challenge_id", challenge.ChallengeID.String()),
			zap.Error(err),
		)
	}

	s.deps.Logger.Info("auth challenge issued",
		zap.String("email_hash", s.hashEmail(normalised)),
		zap.String("challenge_id", challenge.ChallengeID.String()),
	)

	return challenge.ChallengeID, nil
}

// ConfirmInputs is the parsed-and-validated input to ConfirmEmailCode.
// Wire-format validation (base64 decode, 32-byte length, IANA time-zone
// parse, source-IP extraction) happens at the handler boundary so the
// service operates on already-typed values.
type ConfirmInputs struct {
	ChallengeID uuid.UUID
	Code string
	ClientPublicKey []byte
	TimeZone string
	SourceIP string
}

// ConfirmEmailCode redeems a challenge_id, ensures the corresponding
// `accounts` row exists, and creates an active `device_sessions` row.
// The returned Session is identical to the row stored in the database
// (including server-assigned timestamps).
//
// The flow runs in two transactions:
//
// 1. LoadAndIncrementChallenge increments the attempts counter under
// SELECT FOR UPDATE so concurrent attempts cannot bypass the ceiling.
// 2. Out-of-band: ceiling check, bcrypt verify, EnsureByEmail.
// 3. MarkConsumedAndInsertSession atomically marks the challenge
// consumed and inserts the device_session row, satisfying the
// "single challenge → at most one session" invariant.
//
// Post-commit work (cache write-through, declared_country backfill) is
// best-effort: a failure does not roll the registration back.
func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Session, error) {
	if in.ChallengeID == uuid.Nil {
		return Session{}, ErrChallengeNotFound
	}
	if len(in.ClientPublicKey) != 32 {
		return Session{}, fmt.Errorf("auth: client public key must be 32 bytes, got %d", len(in.ClientPublicKey))
	}
	if strings.TrimSpace(in.TimeZone) == "" {
		return Session{}, fmt.Errorf("auth: time_zone must not be empty")
	}

	loaded, err := s.deps.Store.LoadAndIncrementChallenge(ctx, in.ChallengeID)
	if err != nil {
		return Session{}, err
	}

	if int(loaded.Attempts) > s.deps.Config.ChallengeMaxAttempts {
		s.deps.Logger.Info("auth challenge attempts exhausted",
			zap.String("challenge_id", in.ChallengeID.String()),
			zap.Int32("attempts", loaded.Attempts),
		)
		return Session{}, ErrTooManyAttempts
	}

	if !s.devFixedCodeMatches(in.Code) {
		if err := verifyCode(loaded.CodeHash, in.Code); err != nil {
			if errors.Is(err, ErrCodeMismatch) {
				s.deps.Logger.Info("auth challenge code mismatch",
					zap.String("challenge_id", in.ChallengeID.String()),
					zap.Int32("attempts", loaded.Attempts),
				)
				return Session{}, ErrCodeMismatch
			}
			return Session{}, err
		}
	} else {
		s.deps.Logger.Warn("auth challenge accepted via dev-mode fixed code override",
			zap.String("challenge_id", in.ChallengeID.String()),
		)
	}

	// Re-check permanent_block after verifying the code. SendEmailCode
	// guards against fresh challenges for already-blocked addresses;
	// this guard catches the case where an admin applied
	// permanent_block in the window between send and confirm.
	permanent, err := s.deps.Store.IsEmailPermanentlyBlocked(ctx, loaded.Email)
	if err != nil {
		return Session{}, fmt.Errorf("auth: check permanent block at confirm: %w", err)
	}
	if permanent {
		return Session{}, ErrEmailPermanentlyBlocked
	}

	preferredLang := loaded.PreferredLanguage
	if preferredLang == "" {
		// Defensive fallback: SendEmailCode now always persists a
		// non-empty preferred_language, but a row written by an older
		// build could still be empty.
		preferredLang = defaultLanguage
	}

	declaredCountry := s.deps.Geo.LookupCountry(in.SourceIP)

	userID, err := s.deps.User.EnsureByEmail(ctx, loaded.Email, preferredLang, in.TimeZone, declaredCountry)
	if err != nil {
		return Session{}, fmt.Errorf("auth: ensure account by email: %w", err)
	}

	deviceSessionID := uuid.New()
	pending := Session{
		DeviceSessionID: deviceSessionID,
		UserID: userID,
		Status: SessionStatusActive,
		ClientPublicKey: cloneBytes(in.ClientPublicKey),
	}
	if err := s.deps.Store.MarkConsumedAndInsertSession(ctx, in.ChallengeID, pending); err != nil {
		return Session{}, err
	}

	persisted, err := s.deps.Store.LoadSession(ctx, deviceSessionID)
	if err != nil {
		return Session{}, fmt.Errorf("auth: reload created session: %w", err)
	}
	s.deps.Cache.Add(persisted)

	if err := s.deps.Geo.SetDeclaredCountryAtRegistration(ctx, userID, in.SourceIP); err != nil {
		s.deps.Logger.Warn("auth: declared country backfill failed",
			zap.String("user_id", userID.String()),
			zap.Error(err),
		)
	}

	s.deps.Logger.Info("auth session created",
		zap.String("user_id", userID.String()),
		zap.String("device_session_id", deviceSessionID.String()),
	)

	return persisted, nil
}

// defaultLanguage is the fallback locale written when neither the body
// nor the Accept-Language header nor the geoip-derived language produce
// a value.
const defaultLanguage = "en"

func normaliseEmail(email string) string {
	return strings.ToLower(strings.TrimSpace(email))
}

// pickCapturedLocale picks the locale to persist on the challenge row.
// The body field wins over the header. The header parsing is
// intentionally minimal — auth only stores the value, so a richer parse
// would be wasted; user.Service treats the captured string as opaque.
func pickCapturedLocale(locale, acceptLanguage string) string {
	if v := strings.TrimSpace(locale); v != "" {
		return v
	}
	if acceptLanguage == "" {
		return ""
	}
	first := acceptLanguage
	if idx := strings.IndexAny(first, ",;"); idx >= 0 {
		first = first[:idx]
	}
	return strings.TrimSpace(first)
}

func cloneBytes(b []byte) []byte {
	if b == nil {
		return nil
	}
	out := make([]byte, len(b))
	copy(out, b)
	return out
}
