package raffle

import (
	"strings"
	"sync"
	"time"

	"github.com/go-irc/irc"
	"github.com/pkg/errors"

	"github.com/Luzifer/twitch-bot/v3/pkg/database"
	"github.com/Luzifer/twitch-bot/v3/plugins"
)

const (
	frontendNotifyEventRaffleChange      = "raffleChanged"
	frontendNotifyEventRaffleEntryChange = "raffleEntryChanged"
)

type (
	dbClient struct {
		activeRaffles map[string]uint64
		speakUp       map[string]*speakUpWait
		db            database.Connector
		lock          sync.RWMutex
	}

	raffle struct {
		ID uint64 `gorm:"primaryKey" json:"id"`

		Channel string       `json:"channel"`
		Keyword string       `json:"keyword"`
		Title   string       `json:"title"`
		Status  raffleStatus `gorm:"default:planned" json:"status"`

		AllowEveryone   bool          `json:"allowEveryone"`
		AllowFollower   bool          `json:"allowFollower"`
		AllowSubscriber bool          `json:"allowSubscriber"`
		AllowVIP        bool          `gorm:"column:allow_vip" json:"allowVIP"`
		MinFollowAge    time.Duration `json:"minFollowAge"`

		MultiFollower   float64 `json:"multiFollower"`
		MultiSubscriber float64 `json:"multiSubscriber"`
		MultiVIP        float64 `gorm:"column:multi_vip" json:"multiVIP"`

		AutoStartAt     *time.Time    `json:"autoStartAt"`
		CloseAfter      time.Duration `json:"closeAfter"`
		CloseAt         *time.Time    `json:"closeAt"`
		WaitForResponse time.Duration `json:"waitForResponse"`

		TextClose            string        `json:"textClose"`
		TextClosePost        bool          `json:"textClosePost"`
		TextEntry            string        `json:"textEntry"`
		TextEntryPost        bool          `json:"textEntryPost"`
		TextEntryFail        string        `json:"textEntryFail"`
		TextEntryFailPost    bool          `json:"textEntryFailPost"`
		TextReminder         string        `json:"textReminder"`
		TextReminderInterval time.Duration `json:"textReminderInterval"`
		TextReminderNextSend time.Time     `json:"-"`
		TextReminderPost     bool          `json:"textReminderPost"`
		TextWin              string        `json:"textWin"`
		TextWinPost          bool          `json:"textWinPost"`

		Entries []raffleEntry `gorm:"foreignKey:RaffleID" json:"entries,omitempty"`
	}

	raffleEntry struct {
		ID       uint64 `gorm:"primaryKey" json:"id"`
		RaffleID uint64 `gorm:"uniqueIndex:user_per_raffle" json:"-"`

		UserID          string `gorm:"size:128;uniqueIndex:user_per_raffle" json:"userID"`
		UserLogin       string `json:"userLogin"`
		UserDisplayName string `json:"userDisplayName"`

		EnteredAt  time.Time `json:"enteredAt"`
		EnteredAs  string    `json:"enteredAs"`
		Multiplier float64   `json:"multiplier"`

		WasPicked  bool `json:"wasPicked"`
		WasRedrawn bool `json:"wasRedrawn"`

		DrawResponse string     `json:"drawResponse,omitempty"`
		SpeakUpUntil *time.Time `json:"speakUpUntil,omitempty"`
	}

	raffleMessageEvent uint8
	raffleStatus       string

	speakUpWait struct {
		RaffleEntryID uint64
		Until         time.Time
	}
)

const (
	raffleMessageEventEntryFailed raffleMessageEvent = iota
	raffleMessageEventEntry
	raffleMessageEventReminder
	raffleMessageEventWin
	raffleMessageEventClose
)

const (
	raffleStatusPlanned raffleStatus = "planned"
	raffleStatusActive  raffleStatus = "active"
	raffleStatusEnded   raffleStatus = "ended"
)

var errRaffleNotFound = errors.New("raffle not found")

func newDBClient(db database.Connector) *dbClient {
	return &dbClient{
		activeRaffles: make(map[string]uint64),
		speakUp:       make(map[string]*speakUpWait),
		db:            db,
	}
}

// AutoCloseExpired collects all active raffles which have overdue
// close_at dates and closes them
func (d *dbClient) AutoCloseExpired() (err error) {
	var rr []raffle

	if err = d.db.DB().
		Where("status = ? AND close_at IS NOT NULL AND close_at < ?", raffleStatusActive, time.Now().UTC()).
		Find(&rr).
		Error; err != nil {
		return errors.Wrap(err, "fetching raffles to close")
	}

	for _, r := range rr {
		if err = d.Close(r.ID); err != nil {
			return errors.Wrapf(err, "closing raffle %d", r.ID)
		}
	}

	return nil
}

// AutoSendReminders collects all active raffles which have enabled
// reminders which are overdue and posts the reminder for them
func (d *dbClient) AutoSendReminders() (err error) {
	var rr []raffle

	if err = d.db.DB().
		Where("status = ? AND text_reminder_post = ? AND text_reminder_next_send < ?", raffleStatusActive, true, time.Now().UTC()).
		Find(&rr).
		Error; err != nil {
		return errors.Wrap(err, "fetching raffles to send reminders")
	}

	for _, r := range rr {
		if err = r.SendEvent(raffleMessageEventReminder, nil); err != nil {
			return errors.Wrapf(err, "sending reminder for raffle %d", r.ID)
		}
	}

	return nil
}

// AutoStart collects planned and overdue raffles and starts them
func (d *dbClient) AutoStart() (err error) {
	var rr []raffle

	if err = d.db.DB().
		Where("status = ? AND auto_start_at IS NOT NULL AND auto_start_at < ?", raffleStatusPlanned, time.Now().UTC()).
		Find(&rr).
		Error; err != nil {
		return errors.Wrap(err, "fetching raffles to start")
	}

	for _, r := range rr {
		if err = d.Start(r.ID); err != nil {
			return errors.Wrapf(err, "starting raffle %d", r.ID)
		}
	}

	return nil
}

// Clone duplicates a raffle into a new draft resetting some
// parameters into their default state
func (d *dbClient) Clone(raffleID uint64) error {
	raffle, err := d.Get(raffleID)
	if err != nil {
		return errors.Wrap(err, "getting raffle")
	}

	raffle.CloseAt = nil
	raffle.Entries = nil
	raffle.ID = 0
	raffle.Status = raffleStatusPlanned
	raffle.Title = strings.Join([]string{"Copy of", raffle.Title}, " ")

	if err = d.Create(raffle); err != nil {
		return errors.Wrap(err, "creating copy")
	}

	frontendNotify(frontendNotifyEventRaffleChange)
	return nil
}

// Close marks the raffle as closed and removes it from the active
// raffle cache
func (d *dbClient) Close(raffleID uint64) error {
	r, err := d.Get(raffleID)
	if err != nil {
		return errors.Wrap(err, "getting raffle")
	}

	if err = d.db.DB().Model(&raffle{}).
		Where("id = ?", raffleID).
		Update("status", raffleStatusEnded).
		Error; err != nil {
		return errors.Wrap(err, "setting status closed")
	}

	d.lock.Lock()
	defer d.lock.Unlock()
	delete(d.activeRaffles, strings.Join([]string{r.Channel, r.Keyword}, "::"))

	frontendNotify(frontendNotifyEventRaffleChange)

	return errors.Wrap(
		r.SendEvent(raffleMessageEventClose, nil),
		"sending close-message",
	)
}

// Create creates a new raffle. The record will be written to
// the database without modification and therefore need to be filled
// before calling this function
func (d *dbClient) Create(r raffle) error {
	if err := d.db.DB().Create(&r).Error; err != nil {
		return errors.Wrap(err, "creating database record")
	}

	frontendNotify(frontendNotifyEventRaffleChange)
	return nil
}

// Delete removes all entries for the given raffle and afterwards
// deletes the raffle itself
func (d *dbClient) Delete(raffleID uint64) (err error) {
	if err = d.db.DB().
		Where("raffle_id = ?", raffleID).
		Delete(&raffleEntry{}).
		Error; err != nil {
		return errors.Wrap(err, "deleting raffle entries")
	}

	if err = d.db.DB().
		Where("id = ?", raffleID).
		Delete(&raffle{}).Error; err != nil {
		return errors.Wrap(err, "creating database record")
	}

	frontendNotify(frontendNotifyEventRaffleChange)
	return nil
}

// Enter creates a new raffle entry. The entry will be written to
// the database without modification and therefore need to be filled
// before calling this function
func (d *dbClient) Enter(re raffleEntry) error {
	if err := d.db.DB().Create(&re).Error; err != nil {
		return errors.Wrap(err, "creating database record")
	}

	frontendNotify(frontendNotifyEventRaffleEntryChange)
	return nil
}

// Get retrieves a raffle from the database
func (d *dbClient) Get(raffleID uint64) (out raffle, err error) {
	return out, errors.Wrap(
		d.db.DB().
			Where("raffles.id = ?", raffleID).
			Preload("Entries").
			First(&out).
			Error,
		"getting raffle from database",
	)
}

// GetByChannelAndKeyword resolves an active raffle through channel
// and keyword given in the raffle and returns it through the Get
// function. If the combination is not known errRaffleNotFound is
// returned.
func (d *dbClient) GetByChannelAndKeyword(channel, keyword string) (raffle, error) {
	d.lock.RLock()
	id := d.activeRaffles[strings.Join([]string{channel, keyword}, "::")]
	d.lock.RUnlock()

	if id == 0 {
		return raffle{}, errRaffleNotFound
	}

	return d.Get(id)
}

// List returns a list of all known raffles
func (d *dbClient) List() (raffles []raffle, _ error) {
	return raffles, errors.Wrap(
		d.db.DB().Model(&raffle{}).
			Order("id DESC").
			Find(&raffles).
			Error,
		"updating column",
	)
}

// PatchNextReminderSend updates the time another reminder shall be
// sent for the given raffle ID. No other fields are modified
func (d *dbClient) PatchNextReminderSend(raffleID uint64, next time.Time) error {
	return errors.Wrap(
		d.db.DB().Model(&raffle{}).
			Where("id = ?", raffleID).
			Update("text_reminder_next_send", next).
			Error,
		"updating column",
	)
}

// PickWinner fetches the given raffle and picks a random winner
// based on entries and their multiplier
func (d *dbClient) PickWinner(raffleID uint64) error {
	r, err := d.Get(raffleID)
	if err != nil {
		return errors.Wrap(err, "getting raffle")
	}

	winner, err := pickWinnerFromRaffle(r)
	if err != nil {
		return errors.Wrap(err, "picking winner")
	}

	speakUpUntil := time.Now().UTC().Add(r.WaitForResponse)
	if err = d.db.DB().Model(&raffleEntry{}).
		Where("id = ?", winner.ID).
		Updates(map[string]any{"was_picked": true, "speak_up_until": speakUpUntil}).
		Error; err != nil {
		return errors.Wrap(err, "updating winner")
	}

	d.lock.Lock()
	d.speakUp[strings.Join([]string{r.Channel, winner.UserLogin}, ":")] = &speakUpWait{RaffleEntryID: winner.ID, Until: speakUpUntil}
	d.lock.Unlock()

	fields := plugins.FieldCollectionFromData(map[string]any{
		"user_id": winner.UserID,
		"user":    winner.UserLogin,
		"winner":  winner,
	})

	frontendNotify(frontendNotifyEventRaffleEntryChange)

	return errors.Wrap(
		r.SendEvent(raffleMessageEventWin, fields),
		"sending win-message",
	)
}

// RedrawWinner marks the previous winner as redrawn (and therefore
// crossed out as winner in the interface) and picks a new one
func (d *dbClient) RedrawWinner(raffleID, winnerID uint64) error {
	if err := d.db.DB().Model(&raffleEntry{}).
		Where("id = ?", winnerID).
		Update("was_redrawn", true).
		Error; err != nil {
		return errors.Wrap(err, "updating previous winner")
	}

	return d.PickWinner(raffleID)
}

// RefreshActiveRaffles loads all active raffles and populates the
// activeRaffles cache
func (d *dbClient) RefreshActiveRaffles() error {
	d.lock.Lock()
	defer d.lock.Unlock()

	var (
		actives []raffle
		tmp     = map[string]uint64{}
	)

	if err := d.db.DB().
		Where("status = ?", raffleStatusActive).
		Find(&actives).
		Error; err != nil {
		return errors.Wrap(err, "fetching active raffles")
	}

	for _, r := range actives {
		tmp[strings.Join([]string{r.Channel, r.Keyword}, "::")] = r.ID
	}

	d.activeRaffles = tmp
	return nil
}

// RefreshSpeakUp seeks all still active speak-up entries and
// populates the speak-up cache with them
func (d *dbClient) RefreshSpeakUp() error {
	d.lock.Lock()
	defer d.lock.Unlock()

	var (
		res []raffleEntry
		tmp = map[string]*speakUpWait{}
	)

	if err := d.db.DB().Debug().
		Where("speak_up_until IS NOT NULL AND speak_up_until > ?", time.Now().UTC()).
		Find(&res).
		Error; err != nil {
		return errors.Wrap(err, "querying active entries")
	}

	for _, e := range res {
		var r raffle
		if err := d.db.DB().
			Where("id = ?", e.RaffleID).
			First(&r).
			Error; err != nil {
			return errors.Wrap(err, "fetching raffle for entry")
		}
		tmp[strings.Join([]string{r.Channel, e.UserLogin}, ":")] = &speakUpWait{RaffleEntryID: e.ID, Until: *e.SpeakUpUntil}
	}

	d.speakUp = tmp
	return nil
}

// RegisterSpeakUp sets the speak-up message if there was a
// speakUpWait for that user and channel
func (d *dbClient) RegisterSpeakUp(channel, user, message string) error {
	d.lock.RLock()
	w := d.speakUp[strings.Join([]string{channel, user}, ":")]
	d.lock.RUnlock()

	if w == nil || w.Until.Before(time.Now()) {
		// No speak-up-request for that user or expired
		return nil
	}

	if err := d.db.DB().
		Model(&raffleEntry{}).
		Where("id = ?", w.RaffleEntryID).
		Updates(map[string]any{
			"DrawResponse": message,
			"SpeakUpUntil": nil,
		}).
		Error; err != nil {
		return errors.Wrap(err, "registering speak-up")
	}

	d.lock.Lock()
	defer d.lock.Unlock()
	delete(d.speakUp, strings.Join([]string{channel, user}, ":"))

	frontendNotify(frontendNotifyEventRaffleEntryChange)
	return nil
}

// Reopen updates the CloseAt attribute and status to active to
// prolong the raffle
func (d *dbClient) Reopen(raffleID uint64, duration time.Duration) error {
	r, err := d.Get(raffleID)
	if err != nil {
		return errors.Wrap(err, "getting specified raffle")
	}

	if err = d.db.DB().
		Model(&raffle{}).
		Where("id = ?", raffleID).
		Updates(map[string]any{
			"CloseAt": time.Now().UTC().Add(duration),
			"status":  raffleStatusActive,
		}).
		Error; err != nil {
		return errors.Wrap(err, "updating raffle")
	}

	// Store ID to active-raffle cache
	d.lock.Lock()
	defer d.lock.Unlock()
	d.activeRaffles[strings.Join([]string{r.Channel, r.Keyword}, "::")] = r.ID

	frontendNotify(frontendNotifyEventRaffleChange)
	return nil
}

// Start fetches the given raffle, updates its CloseAt attribute
// in case it is not already set, sets the raffle to active, updates
// the raffle in the database and notes its channel/keyword combo
// into the activeRaffles cache for use with irc handling
func (d *dbClient) Start(raffleID uint64) error {
	r, err := d.Get(raffleID)
	if err != nil {
		return errors.Wrap(err, "getting specified raffle")
	}

	if r.CloseAt == nil {
		end := time.Now().UTC().Add(r.CloseAfter)
		r.CloseAt = &end
	}

	r.Status = raffleStatusActive
	if err = d.Update(r); err != nil {
		return errors.Wrap(err, "updating raffle")
	}

	// Store ID to active-raffle cache
	d.lock.Lock()
	defer d.lock.Unlock()
	d.activeRaffles[strings.Join([]string{r.Channel, r.Keyword}, "::")] = r.ID

	frontendNotify(frontendNotifyEventRaffleChange)

	return errors.Wrap(
		r.SendEvent(raffleMessageEventReminder, nil),
		"sending first reminder",
	)
}

// Update stores the given raffle to the database. The ID within the
// raffle object must be set in order to update it. The object must
// be completely filled.
func (d *dbClient) Update(r raffle) error {
	old, err := d.Get(r.ID)
	if err != nil {
		return errors.Wrap(err, "getting previous version")
	}

	// These information must not be changed after raffle has been started
	if old.Status != raffleStatusPlanned {
		r.Channel = old.Channel
		r.Keyword = old.Keyword
		r.Status = old.Status

		r.AllowEveryone = old.AllowEveryone
		r.AllowFollower = old.AllowFollower
		r.AllowSubscriber = old.AllowSubscriber
		r.AllowVIP = old.AllowVIP
		r.MinFollowAge = old.MinFollowAge

		r.MultiFollower = old.MultiFollower
		r.MultiSubscriber = old.MultiSubscriber
		r.MultiVIP = old.MultiVIP

		r.AutoStartAt = old.AutoStartAt
	}

	// This info must be preserved
	r.Entries = nil
	r.TextReminderNextSend = old.TextReminderNextSend

	if err := d.db.DB().
		Model(&raffle{}).
		Where("id = ?", r.ID).
		Updates(&r).
		Error; err != nil {
		return errors.Wrap(err, "updating raffle")
	}

	frontendNotify(frontendNotifyEventRaffleChange)
	return nil
}

// SendEvent processes the text template and sends the message if
// enabled given through the event
func (r raffle) SendEvent(evt raffleMessageEvent, fields *plugins.FieldCollection) (err error) {
	if fields == nil {
		fields = plugins.NewFieldCollection()
	}

	fields.Set("raffle", r) // Make raffle available to templating

	var sendTextTpl string

	switch evt {
	case raffleMessageEventClose:
		if !r.TextClosePost {
			return nil
		}
		sendTextTpl = r.TextClose

	case raffleMessageEventEntryFailed:
		if !r.TextEntryFailPost {
			return nil
		}
		sendTextTpl = r.TextEntryFail

	case raffleMessageEventEntry:
		if !r.TextEntryPost {
			return nil
		}
		sendTextTpl = r.TextEntry

	case raffleMessageEventReminder:
		if !r.TextReminderPost {
			return nil
		}
		sendTextTpl = r.TextReminder
		if err = dbc.PatchNextReminderSend(r.ID, time.Now().UTC().Add(r.TextReminderInterval)); err != nil {
			return errors.Wrap(err, "updating next reminder for raffle")
		}

	case raffleMessageEventWin:
		if !r.TextWinPost {
			return nil
		}
		sendTextTpl = r.TextWin

	default:
		// How?
		return errors.New("unexpected event")
	}

	msg, err := formatMessage(sendTextTpl, nil, nil, fields)
	if err != nil {
		return errors.Wrap(err, "formatting message to send")
	}

	return errors.Wrap(
		send(&irc.Message{
			Command: "PRIVMSG",
			Params: []string{
				"#" + strings.TrimLeft(r.Channel, "#"),
				msg,
			},
		}),
		"sending message",
	)
}