package usage

import (
	"context"
	"sync"
	"time"

	"github.com/redis/go-redis/v9"
)

// Report is one usage delta pushed by the data plane (#5's ReportUsage path).
// It identifies the user only by data-plane UUID; the aggregator resolves it to
// a user_id internally.  No destination, domain, or DNS data is ever carried.
type Report struct {
	// DPUUID is the data-plane credential UUID the node observed.
	DPUUID string
	// BytesUp / BytesDown are the incremental byte counts since the last report.
	BytesUp   uint64
	BytesDown uint64
	// Minutes is the incremental session minutes since the last report.
	Minutes int
	// At is the event time used for UTC day bucketing.  Zero = time.Now().
	At time.Time
	// BatchID is #5's upload batch identifier, used for replay-idempotent
	// de-duplication.  Empty disables dedup for this report.
	BatchID string
}

// UsageReporter is the callback interface #5 invokes when a node reports usage.
// Aggregator satisfies it, so #5 can depend on the interface and be wired to a
// real or mock implementation.
type UsageReporter interface {
	ReportUsage(ctx context.Context, r Report) error
}

// Aggregator implements UsageReporter: it resolves dp_uuid → user_id (with a
// short in-memory + Redis cache), de-duplicates replayed batches, and folds the
// delta into usage_daily by UTC date.
type Aggregator struct {
	store    *Store
	rdb      *redis.Client
	cache    *dpCache
	batchTTL time.Duration
}

// NewAggregator builds an Aggregator.  rdb may be nil (batch dedup and the
// Redis tier of the dp_uuid cache are then disabled; the in-memory cache and
// DB lookups still work).  dpCacheTTL <= 0 defaults to 5 minutes; batchTTL <= 0
// defaults to 10 minutes.
func NewAggregator(store *Store, rdb *redis.Client, dpCacheTTL, batchTTL time.Duration) *Aggregator {
	if dpCacheTTL <= 0 {
		dpCacheTTL = 5 * time.Minute
	}
	if batchTTL <= 0 {
		batchTTL = 10 * time.Minute
	}
	return &Aggregator{
		store:    store,
		rdb:      rdb,
		cache:    newDPCache(dpCacheTTL),
		batchTTL: batchTTL,
	}
}

// redisKeyBatch returns the Redis key marking a processed upload batch.
func redisKeyBatch(batchID string) string { return "usage:batch:" + batchID }

// redisKeyDP returns the Redis key caching a dp_uuid → user_id resolution.
func redisKeyDP(dpUUID string) string { return "usage:dp:" + dpUUID }

// ReportUsage folds one report into usage_daily.  It is safe for concurrent
// use and idempotent across replays of the same BatchID.
func (a *Aggregator) ReportUsage(ctx context.Context, r Report) error {
	if r.DPUUID == "" {
		return nil
	}
	if r.BytesUp == 0 && r.BytesDown == 0 && r.Minutes == 0 {
		return nil
	}

	// Replay guard: a batch already seen is a no-op.
	if dup, err := a.batchSeen(ctx, r.BatchID); err != nil {
		return err
	} else if dup {
		return nil
	}

	userID, err := a.resolveUser(ctx, r.DPUUID)
	if err != nil {
		return err
	}
	if userID == 0 {
		// Unknown/rotated dp_uuid: drop silently (no log of the mapping).
		return nil
	}

	at := r.At
	if at.IsZero() {
		at = time.Now()
	}
	return a.store.AggregateUsage(ctx, userID, at.UTC(), r.BytesUp, r.BytesDown, r.Minutes)
}

// batchSeen marks the batch as processed and reports whether it was already
// seen.  Returns false when batchID is empty or Redis is unavailable (dedup is
// best-effort; the DB accumulation itself is additive, not idempotent, so #5
// must supply a BatchID + Redis to get exactly-once semantics).
func (a *Aggregator) batchSeen(ctx context.Context, batchID string) (bool, error) {
	if batchID == "" || a.rdb == nil {
		return false, nil
	}
	set, err := a.rdb.SetNX(ctx, redisKeyBatch(batchID), "1", a.batchTTL).Result()
	if err != nil {
		return false, err
	}
	// SetNX returns true when the key was newly set (i.e. not a duplicate).
	return !set, nil
}

// resolveUser maps a dp_uuid to a user_id via the in-memory cache, then Redis,
// then the database (populating both caches on a DB hit).
func (a *Aggregator) resolveUser(ctx context.Context, dpUUID string) (int64, error) {
	if id, ok := a.cache.get(dpUUID); ok {
		return id, nil
	}

	if a.rdb != nil {
		if id, err := a.rdb.Get(ctx, redisKeyDP(dpUUID)).Int64(); err == nil {
			a.cache.set(dpUUID, id)
			return id, nil
		} else if err != redis.Nil {
			return 0, err
		}
	}

	id, err := a.store.LookupUserIDByDPUUID(ctx, dpUUID)
	if err != nil {
		return 0, err
	}
	if id == 0 {
		return 0, nil
	}

	a.cache.set(dpUUID, id)
	if a.rdb != nil {
		_ = a.rdb.Set(ctx, redisKeyDP(dpUUID), id, a.cache.ttl).Err()
	}
	return id, nil
}

// --------------------------------------------------------------------------
// in-memory dp_uuid → user_id cache (short TTL)
// --------------------------------------------------------------------------

type dpCacheEntry struct {
	userID  int64
	expires time.Time
}

type dpCache struct {
	mu  sync.RWMutex
	m   map[string]dpCacheEntry
	ttl time.Duration
}

func newDPCache(ttl time.Duration) *dpCache {
	return &dpCache{m: make(map[string]dpCacheEntry), ttl: ttl}
}

func (c *dpCache) get(dpUUID string) (int64, bool) {
	c.mu.RLock()
	e, ok := c.m[dpUUID]
	c.mu.RUnlock()
	if !ok || time.Now().After(e.expires) {
		return 0, false
	}
	return e.userID, true
}

func (c *dpCache) set(dpUUID string, userID int64) {
	c.mu.Lock()
	c.m[dpUUID] = dpCacheEntry{userID: userID, expires: time.Now().Add(c.ttl)}
	c.mu.Unlock()
}
