package loyalty

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"strings"
	"time"

	"git.sr.ht/~ashkeel/strimertul/log"

	"git.sr.ht/~ashkeel/containers/sync"
	"git.sr.ht/~ashkeel/strimertul/database"
	twitchclient "git.sr.ht/~ashkeel/strimertul/twitch/client"
	"git.sr.ht/~ashkeel/strimertul/utils"
)

var (
	ErrRedeemNotFound     = errors.New("redeem not found")
	ErrRedeemInCooldown   = errors.New("redeem is on cooldown")
	ErrGoalNotFound       = errors.New("goal not found")
	ErrGoalAlreadyReached = errors.New("goal already reached")
)

type Manager struct {
	points               *sync.Map[string, PointsEntry]
	Config               *sync.RWSync[Config]
	Rewards              *sync.Slice[Reward]
	Goals                *sync.Slice[Goal]
	Queue                *sync.Slice[Redeem]
	db                   database.Database
	logger               *slog.Logger
	cooldowns            map[string]time.Time
	banlist              map[string]bool
	ctx                  context.Context
	cancelFn             context.CancelFunc
	cancelSub            database.CancelFunc
	restartTwitchHandler chan struct{}
	twitchManager        *twitchclient.Manager
	twitchIntegration    *twitchIntegration
}

func NewManager(ctx context.Context, db database.Database, twitchManager *twitchclient.Manager) (*Manager, error) {
	loyaltyContext, cancelFn := context.WithCancel(ctx)
	loyalty := &Manager{
		Config:               sync.NewRWSync(Config{Enabled: false}),
		Rewards:              sync.NewSlice[Reward](),
		Goals:                sync.NewSlice[Goal](),
		Queue:                sync.NewSlice[Redeem](),
		logger:               log.GetLogger(ctx),
		db:                   db,
		points:               sync.NewMap[string, PointsEntry](),
		cooldowns:            make(map[string]time.Time),
		banlist:              make(map[string]bool),
		ctx:                  loyaltyContext,
		cancelFn:             cancelFn,
		restartTwitchHandler: make(chan struct{}),
		twitchManager:        twitchManager,
	}
	// Get data from DB
	var config Config
	if err := db.GetJSON(ConfigKey, &config); err == nil {
		loyalty.Config.Set(config)
	} else {
		if !errors.Is(err, database.ErrEmptyKey) {
			return nil, fmt.Errorf("could not retrieve loyalty config: %w", err)
		}
	}

	// Retrieve configs
	var rewards []Reward
	if err := db.GetJSON(RewardsKey, &rewards); err == nil {
		loyalty.Rewards.Set(rewards)
	} else {
		if !errors.Is(err, database.ErrEmptyKey) {
			return nil, err
		}
	}

	var goals []Goal
	if err := db.GetJSON(GoalsKey, &goals); err == nil {
		loyalty.Goals.Set(goals)
	} else {
		if !errors.Is(err, database.ErrEmptyKey) {
			return nil, err
		}
	}

	var queue []Redeem
	if err := db.GetJSON(QueueKey, &queue); err == nil {
		loyalty.Queue.Set(queue)
	} else {
		if !errors.Is(err, database.ErrEmptyKey) {
			return nil, err
		}
	}

	// Retrieve user points
	points, err := db.GetAll(PointsPrefix)
	if err != nil {
		if !errors.Is(err, database.ErrEmptyKey) {
			return nil, err
		}
		points = make(map[string]string)
	}

	for k, v := range points {
		var entry PointsEntry
		err := json.Unmarshal([]byte(v), &entry)
		if err != nil {
			return nil, err
		}

		loyalty.points.SetKey(k[len(PointsPrefix):], entry)
	}

	// SubscribePrefix for changes
	loyalty.cancelSub, err = db.SubscribePrefix(loyalty.update, "loyalty/")
	if err != nil {
		loyalty.logger.Error("Could not setup loyalty reload subscription", log.Error(err))
	}

	loyalty.SetBanList(config.BanList)

	// Start twitch handler
	twitchClient := twitchManager.Client()
	if twitchClient != nil && twitchClient.Chat != nil {
		loyalty.twitchIntegration = setupTwitchIntegration(loyaltyContext, loyalty, twitchClient.Chat)
	}

	return loyalty, nil
}

func (m *Manager) Close() error {
	// Disable twitch integration
	if m.twitchIntegration != nil {
		m.twitchIntegration.Close()
	}

	// Stop subscription
	if m.cancelSub != nil {
		m.cancelSub()
	}

	// Send cancellation
	m.cancelFn()

	return nil
}

func (m *Manager) update(key, value string) {
	var err error
	// Check for config changes/RPC
	switch key {
	case ConfigKey:
		err = utils.LoadJSONToWrapped[Config](value, m.Config)
		if err == nil {
			m.SetBanList(m.Config.Get().BanList)
			m.restartTwitchHandler <- struct{}{}
		}
	case GoalsKey:
		err = utils.LoadJSONToWrapped[[]Goal](value, m.Goals)
	case RewardsKey:
		err = utils.LoadJSONToWrapped[[]Reward](value, m.Rewards)
	case QueueKey:
		err = utils.LoadJSONToWrapped[[]Redeem](value, m.Queue)
	case CreateRedeemRPC:
		var redeem Redeem
		err = json.Unmarshal([]byte(value), &redeem)
		if err == nil {
			err = m.AddRedeem(redeem)
		}
	case RemoveRedeemRPC:
		var redeem Redeem
		err = json.Unmarshal([]byte(value), &redeem)
		if err == nil {
			err = m.RemoveRedeem(redeem)
		}
	default:
		// Check for prefix changes
		switch {
		// User point changed
		case strings.HasPrefix(key, PointsPrefix):
			var entry PointsEntry
			err = json.Unmarshal([]byte(value), &entry)
			user := key[len(PointsPrefix):]
			m.points.SetKey(user, entry)
		}
	}
	if err != nil {
		slog.Error("Subscribe error: invalid JSON received on key", slog.String("key", key), log.Error(err))
	} else {
		slog.Debug("Updated key", slog.String("key", key))
	}
}

func (m *Manager) GetPoints(user string) int64 {
	points, ok := m.points.GetKey(user)
	if ok {
		return points.Points
	}
	return 0
}

func (m *Manager) setPoints(user string, points int64) error {
	entry := PointsEntry{
		Points: points,
	}
	m.points.SetKey(user, entry)
	return m.db.PutJSON(PointsPrefix+user, entry)
}

func (m *Manager) GivePoints(pointsToGive map[string]int64) error {
	// Add points to each user
	for user, points := range pointsToGive {
		balance := m.GetPoints(user)
		if err := m.setPoints(user, balance+points); err != nil {
			return err
		}
	}
	return nil
}

func (m *Manager) TakePoints(pointsToTake map[string]int64) error {
	// Add points to each user
	for user, points := range pointsToTake {
		balance := m.GetPoints(user)
		if err := m.setPoints(user, balance-points); err != nil {
			return err
		}
	}
	return nil
}

func (m *Manager) saveQueue() error {
	return m.db.PutJSON(QueueKey, m.Queue.Get())
}

func (m *Manager) GetRewardCooldown(rewardID string) time.Time {
	cooldown, ok := m.cooldowns[rewardID]
	if !ok {
		// Return zero time for a reward with no cooldown
		return time.Time{}
	}

	return cooldown
}

func (m *Manager) AddRedeem(redeem Redeem) error {
	// Add to local list
	m.Queue.Set(append(m.Queue.Get(), redeem))

	// Send redeem event
	if err := m.db.PutJSON(RedeemEvent, redeem); err != nil {
		return err
	}

	// Add cooldown if applicable
	if redeem.Reward.Cooldown > 0 {
		m.cooldowns[redeem.Reward.ID] = time.Now().Add(time.Second * time.Duration(redeem.Reward.Cooldown))
	}

	// Save points
	return m.saveQueue()
}

func (m *Manager) PerformRedeem(redeem Redeem) error {
	// Check cooldown
	if time.Now().Before(m.GetRewardCooldown(redeem.Reward.ID)) {
		return ErrRedeemInCooldown
	}

	// Add redeem
	err := m.AddRedeem(redeem)
	if err != nil {
		return err
	}

	// Remove points from user
	return m.TakePoints(map[string]int64{redeem.Username: redeem.Reward.Price})
}

func (m *Manager) RemoveRedeem(redeem Redeem) error {
	queue := m.Queue.Get()
	for index, queued := range queue {
		if queued.When == redeem.When && queued.Username == redeem.Username && queued.Reward.ID == redeem.Reward.ID {
			// Remove redemption from list
			m.Queue.Set(append(queue[:index], queue[index+1:]...))

			// Save points
			return m.saveQueue()
		}
	}

	return ErrRedeemNotFound
}

func (m *Manager) SaveGoals() error {
	return m.db.PutJSON(GoalsKey, m.Goals.Get())
}

func (m *Manager) ContributeGoal(goal Goal, user string, points int64) error {
	goals := m.Goals.Get()
	for i, savedGoal := range goals {
		if savedGoal.ID != goal.ID {
			continue
		}
		goals[i].Contributed += points
		goals[i].Contributors[user] += points
		m.Goals.Set(goals)
		return m.SaveGoals()
	}
	return ErrGoalNotFound
}

func (m *Manager) PerformContribution(goal Goal, user string, points int64) (int64, error) {
	// Get user balance
	balance := m.GetPoints(user)

	// If user specified more points than they have, pick the maximum possible
	if points > balance {
		points = balance
	}

	// Check if goal was reached already
	if goal.Contributed >= goal.TotalGoal {
		return 0, ErrGoalAlreadyReached
	}

	// If remaining points are lower than what user is contributing, only take what's needed
	remaining := goal.TotalGoal - goal.Contributed
	if points > remaining {
		points = remaining
	}

	// Remove points from user
	if err := m.TakePoints(map[string]int64{user: points}); err != nil {
		return 0, err
	}

	// Add points to goal
	return points, m.ContributeGoal(goal, user, points)
}

func (m *Manager) GetReward(id string) Reward {
	for _, reward := range m.Rewards.Get() {
		if reward.ID == id {
			return reward
		}
	}
	return Reward{}
}

func (m *Manager) GetGoal(id string) Goal {
	for _, goal := range m.Goals.Get() {
		if goal.ID == id {
			return goal
		}
	}
	return Goal{}
}

func (m *Manager) Equals(c utils.Comparable) bool {
	if manager, ok := c.(*Manager); ok {
		return m == manager
	}
	return false
}

func (m *Manager) SetBanList(banned []string) {
	m.banlist = make(map[string]bool)
	for _, usr := range banned {
		m.banlist[usr] = true
	}
}

func (m *Manager) IsBanned(user string) bool {
	banned, ok := m.banlist[user]
	return ok && banned
}
