package codes

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
	"github.com/wangjia/pangolin/server/internal/apierr"
)

// RedeemRequest carries the inputs to a single redemption attempt.
type RedeemRequest struct {
	UserID int64  // authenticated user
	Code   string // raw code string from the client (will be canonicalized)
}

// RedeemResult carries the outcome of a successful redemption.
type RedeemResult struct {
	// Idempotent indicates this user already redeemed this code; the result is
	// from the original redemption.
	Idempotent   bool
	PlanCode     PlanCode
	DurationDays int
	// ExpiresAt is the updated/new subscription expiry (UTC).
	ExpiresAt time.Time
	// SubscriptionID is the subscription that was extended or newly created.
	SubscriptionID int64
}

// Service handles activation-code redemption.
type Service struct {
	store *Store
	rdb   *redis.Client
	// redeemFailMax is the number of consecutive failures before a 1-hour lock.
	redeemFailMax int
	// redeemLockDur is how long the lock lasts.
	redeemLockDur time.Duration
}

// NewService creates a Service.
func NewService(store *Store, rdb *redis.Client, failMax int, lockDur time.Duration) *Service {
	if failMax <= 0 {
		failMax = 5
	}
	if lockDur <= 0 {
		lockDur = time.Hour
	}
	return &Service{
		store:         store,
		rdb:           rdb,
		redeemFailMax: failMax,
		redeemLockDur: lockDur,
	}
}

// redisKeyFail returns the Redis key for the per-user failure counter.
func redisKeyFail(userID int64) string {
	return fmt.Sprintf("redeem:fail:%d", userID)
}

// isLocked returns true if the user has hit the failure cap.
// Returns false (not locked) if Redis is not configured.
func (svc *Service) isLocked(ctx context.Context, userID int64) (bool, error) {
	if svc.rdb == nil {
		return false, nil
	}
	val, err := svc.rdb.Get(ctx, redisKeyFail(userID)).Int()
	if err == redis.Nil {
		return false, nil
	}
	if err != nil {
		return false, err
	}
	return val >= svc.redeemFailMax, nil
}

// recordFail increments the failure counter, setting a 1-hour TTL on first
// increment so the counter resets automatically after the lock window.
// No-ops if Redis is not configured.
func (svc *Service) recordFail(ctx context.Context, userID int64) error {
	if svc.rdb == nil {
		return nil
	}
	key := redisKeyFail(userID)
	pipe := svc.rdb.Pipeline()
	pipe.Incr(ctx, key)
	pipe.Expire(ctx, key, svc.redeemLockDur)
	_, err := pipe.Exec(ctx)
	return err
}

// clearFail removes the failure counter after a successful redemption.
// No-ops if Redis is not configured.
func (svc *Service) clearFail(ctx context.Context, userID int64) {
	if svc.rdb == nil {
		return
	}
	_ = svc.rdb.Del(ctx, redisKeyFail(userID)).Err()
}

// Redeem processes a redemption request inside a serialisable transaction.
//
// Flow:
//  1. Check rate-limit lock.
//  2. Canonicalize and hash the code.
//  3. BEGIN TRANSACTION (Serializable isolation).
//  4. SELECT … FOR UPDATE the codes row.
//  5. Idempotency: if already redeemed by this user, return cached success.
//  6. Fail if redeemed by someone else, or code is void.
//  7. MarkRedeemed, extend/create subscription, write audit_log.
//  8. COMMIT.
//  9. Clear the failure counter on success.
func (svc *Service) Redeem(ctx context.Context, req RedeemRequest) (*RedeemResult, *apierr.Error) {
	// 1. Check lock.
	locked, err := svc.isLocked(ctx, req.UserID)
	if err != nil {
		return nil, apierr.ErrInternal
	}
	if locked {
		return nil, apierr.ErrLocked
	}

	// 2. Canonicalize and hash.
	canonical, cerr := Canonicalize(req.Code)
	if cerr != nil {
		_ = svc.recordFail(ctx, req.UserID)
		return nil, apierr.ErrInvalidCode
	}
	hash := Hash(canonical)

	// 3. Begin transaction.
	tx, err := svc.store.BeginTx(ctx)
	if err != nil {
		return nil, apierr.ErrInternal
	}
	// Roll back on any unhandled path.
	committed := false
	defer func() {
		if !committed {
			_ = tx.Rollback()
		}
	}()

	// 4. SELECT … FOR UPDATE.
	cr, err := svc.store.FindCodeByHashForUpdate(ctx, tx, hash)
	if err != nil {
		return nil, apierr.ErrInternal
	}
	if cr == nil {
		_ = tx.Rollback()
		committed = true // prevent double rollback
		_ = svc.recordFail(ctx, req.UserID)
		return nil, apierr.ErrCodeNotFound
	}

	// 5. Idempotency check.
	// Same user has already redeemed this code → return a success result without
	// re-applying any changes.  Roll back the (read-only) transaction first.
	if cr.Status == "redeemed" && cr.RedeemedBy.Valid && cr.RedeemedBy.Int64 == req.UserID {
		_ = tx.Rollback()
		committed = true
		return &RedeemResult{
			Idempotent:   true,
			PlanCode:     cr.PlanCode,
			DurationDays: cr.DurationDays,
			// ExpiresAt is omitted; the caller can query /v1/me if needed.
		}, nil
	}

	// 6. Fail if already taken or voided.
	switch cr.Status {
	case "redeemed":
		_ = tx.Rollback()
		committed = true
		_ = svc.recordFail(ctx, req.UserID)
		return nil, apierr.ErrCodeRedeemed
	case "void":
		_ = tx.Rollback()
		committed = true
		_ = svc.recordFail(ctx, req.UserID)
		return nil, apierr.ErrCodeVoid
	}

	// 7a. Mark the code as redeemed.
	if err := svc.store.MarkRedeemed(ctx, tx, cr.ID, req.UserID); err != nil {
		return nil, apierr.ErrInternal
	}

	// 7b. Extend or create subscription.
	subID, expiresAt, apiErr := svc.applySubscription(ctx, tx, req.UserID, cr)
	if apiErr != nil {
		return nil, apiErr
	}

	// 7c. Write audit log.
	meta := auditMeta(req.UserID, cr, subID)
	if err := svc.store.WriteAuditLog(ctx, tx,
		fmt.Sprintf("user:%d", req.UserID), "redeem",
		"code_hash:"+cr.CodeHash[:16]+"...",
		meta,
	); err != nil {
		// Audit log failure must not abort the business transaction.
		// Log the error but continue.
		_ = err
	}

	// 8. Commit.
	if err := tx.Commit(); err != nil {
		return nil, apierr.ErrInternal
	}
	committed = true

	// 9. Clear failure counter on success.
	svc.clearFail(ctx, req.UserID)

	return &RedeemResult{
		Idempotent:     false,
		PlanCode:       cr.PlanCode,
		DurationDays:   cr.DurationDays,
		ExpiresAt:      expiresAt,
		SubscriptionID: subID,
	}, nil
}

// applySubscription implements the subscription-extension rules:
//
//   - Same plan as code → extend the most-recently-expiring subscription of
//     that plan: expires_at = max(expires_at, now) + duration_days
//   - Different plan (or no existing sub for the code's plan) → create a new
//     subscription row:
//     expires_at = max(now, latest_expiry_for_code_plan) + duration_days
func (svc *Service) applySubscription(
	ctx context.Context,
	tx *sql.Tx,
	userID int64,
	cr *CodeRow,
) (subID int64, expiresAt time.Time, apiErr *apierr.Error) {

	subs, err := svc.store.GetActiveSubscriptions(ctx, tx, userID)
	if err != nil {
		return 0, time.Time{}, apierr.ErrInternal
	}

	// Find any existing subscription with the same plan as the code.
	var samePlanSub *SubscriptionRow
	var latestSamePlan time.Time
	for i := range subs {
		if subs[i].PlanID == cr.PlanID {
			if samePlanSub == nil || subs[i].ExpiresAt.After(samePlanSub.ExpiresAt) {
				samePlanSub = &subs[i]
			}
			if subs[i].ExpiresAt.After(latestSamePlan) {
				latestSamePlan = subs[i].ExpiresAt
			}
		}
	}

	now := time.Now().UTC()

	if samePlanSub != nil {
		// Extend existing subscription: max(expires_at, now) + duration_days.
		if err := svc.store.ExtendSubscription(ctx, tx, samePlanSub.ID, cr.DurationDays); err != nil {
			return 0, time.Time{}, apierr.ErrInternal
		}
		base := samePlanSub.ExpiresAt
		if now.After(base) {
			base = now
		}
		expiresAt = base.AddDate(0, 0, cr.DurationDays)
		return samePlanSub.ID, expiresAt, nil
	}

	// Create a new subscription.
	newSubID, err := svc.store.CreateSubscription(ctx, tx, userID, cr.PlanID, cr.DurationDays, latestSamePlan)
	if err != nil {
		return 0, time.Time{}, apierr.ErrInternal
	}
	base := now
	if latestSamePlan.After(now) {
		base = latestSamePlan
	}
	expiresAt = base.AddDate(0, 0, cr.DurationDays)
	return newSubID, expiresAt, nil
}

// auditMeta serialises a compact JSON string for the audit log meta field.
func auditMeta(userID int64, cr *CodeRow, subID int64) string {
	m := map[string]interface{}{
		"plan":          string(cr.PlanCode),
		"duration_days": cr.DurationDays,
		"batch_id":      cr.BatchID,
		"sub_id":        subID,
	}
	b, _ := json.Marshal(m)
	return string(b)
}

// --------------------------------------------------------------------------
// Batch generation service
// --------------------------------------------------------------------------

// BatchRequest holds the parameters for a bulk code-generation job.
type BatchRequest struct {
	PlanCode     PlanCode
	DurationDays int
	Count        int
	Channel      BatchChannel
	Note         string
	CreatedBy    string // e.g. "admin:1" or "cli"
}

// BatchResult contains the generated plaintext codes (only occurrence ever)
// and the batch metadata.
type BatchResult struct {
	BatchID      int64
	Codes        []string // plaintext canonical codes – NOT stored in DB
	PlanCode     PlanCode
	DurationDays int
	Channel      BatchChannel
}

// CreateBatch generates Count activation codes, writes the batch + code hashes
// to MySQL, and returns the plaintext codes.  The plaintext codes are the ONLY
// time they ever appear; the caller is responsible for delivering them securely
// (e.g., streaming to CSV without logging).
//
// Duplicate-hash retries (birthday collision, astronomically unlikely) are
// handled automatically up to maxRetries per slot.
func (svc *Service) CreateBatch(ctx context.Context, req BatchRequest) (*BatchResult, error) {
	const maxRetries = 10

	// Look up plan ID.
	planID, err := svc.store.GetPlanID(ctx, req.PlanCode)
	if err != nil {
		return nil, fmt.Errorf("CreateBatch: unknown plan %s: %w", req.PlanCode, err)
	}

	// Insert the batch record.
	batchID, err := svc.store.CreateBatch(ctx, req.Channel, req.CreatedBy, req.Note)
	if err != nil {
		return nil, fmt.Errorf("CreateBatch: %w", err)
	}

	plaintexts := make([]string, 0, req.Count)

	for i := 0; i < req.Count; i++ {
		var code string
		var h string
		ok := false
		for attempt := 0; attempt < maxRetries; attempt++ {
			c, err := GenerateCode()
			if err != nil {
				return nil, fmt.Errorf("CreateBatch: GenerateCode: %w", err)
			}
			h = Hash(c)
			err = svc.store.CreateCode(ctx, h, planID, req.DurationDays, batchID)
			if err == ErrDuplicate {
				continue // collision – generate a fresh code
			}
			if err != nil {
				return nil, fmt.Errorf("CreateBatch: CreateCode: %w", err)
			}
			code = c
			ok = true
			break
		}
		if !ok {
			return nil, fmt.Errorf("CreateBatch: exceeded %d retry attempts for slot %d", maxRetries, i)
		}
		plaintexts = append(plaintexts, code)
	}

	return &BatchResult{
		BatchID:      batchID,
		Codes:        plaintexts,
		PlanCode:     req.PlanCode,
		DurationDays: req.DurationDays,
		Channel:      req.Channel,
	}, nil
}
