package plugins

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"path"
	"regexp"
	"strings"
	"time"

	"github.com/mitchellh/hashstructure/v2"
	"github.com/pkg/errors"
	"github.com/sirupsen/logrus"
	"gopkg.in/irc.v4"
	"gopkg.in/yaml.v3"

	"github.com/Luzifer/go_helpers/v2/fieldcollection"
	"github.com/Luzifer/go_helpers/v2/str"
	"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
)

const (
	contentTypeJSON = "json"
	contentTypeYAML = "yaml"

	remoteRuleFetchTimeout = 5 * time.Second
)

// ErrStopRuleExecution is a way for actions to terminate execution
// of the current rule gracefully. No actions after this has been
// returned will be executed and no error state will be set
var ErrStopRuleExecution = errors.New("stop rule execution now")

type (
	// Rule represents a rule in the bot configuration
	Rule struct {
		UUID          string  `hash:"-" json:"uuid,omitempty" yaml:"uuid,omitempty"`
		Description   string  `json:"description,omitempty" yaml:"description,omitempty"`
		SubscribeFrom *string `json:"subscribe_from,omitempty" yaml:"subscribe_from,omitempty"`

		Actions []*RuleAction `json:"actions,omitempty" yaml:"actions,omitempty"`

		Cooldown        *time.Duration `json:"cooldown,omitempty" yaml:"cooldown,omitempty"`
		ChannelCooldown *time.Duration `json:"channel_cooldown,omitempty" yaml:"channel_cooldown,omitempty"`
		UserCooldown    *time.Duration `json:"user_cooldown,omitempty" yaml:"user_cooldown,omitempty"`
		SkipCooldownFor []string       `json:"skip_cooldown_for,omitempty" yaml:"skip_cooldown_for,omitempty"`

		MatchChannels []string `json:"match_channels,omitempty" yaml:"match_channels,omitempty"`
		MatchEvent    *string  `json:"match_event,omitempty" yaml:"match_event,omitempty"`
		MatchMessage  *string  `json:"match_message,omitempty" yaml:"match_message,omitempty"`
		MatchUsers    []string `json:"match_users,omitempty" yaml:"match_users,omitempty" `

		DisableOnMatchMessages []string `json:"disable_on_match_messages,omitempty" yaml:"disable_on_match_messages,omitempty"`

		Disable           *bool    `json:"disable,omitempty" yaml:"disable,omitempty"`
		DisableOnOffline  *bool    `json:"disable_on_offline,omitempty" yaml:"disable_on_offline,omitempty"`
		DisableOnPermit   *bool    `json:"disable_on_permit,omitempty" yaml:"disable_on_permit,omitempty"`
		DisableOnTemplate *string  `json:"disable_on_template,omitempty" yaml:"disable_on_template,omitempty"`
		DisableOn         []string `json:"disable_on,omitempty" yaml:"disable_on,omitempty"`
		EnableOn          []string `json:"enable_on,omitempty" yaml:"enable_on,omitempty"`

		//revive:disable-next-line:confusing-naming // only used internally as parsed regexp
		matchMessage *regexp.Regexp
		//revive:disable-next-line:confusing-naming // only used internally as parsed regexp
		disableOnMatchMessages []*regexp.Regexp

		msgFormatter MsgFormatter
		timerStore   TimerStore
		twitchClient *twitch.Client
	}

	// RuleAction represents an action to be executed when running a Rule
	RuleAction struct {
		Type       string                           `json:"type" yaml:"type,omitempty"`
		Attributes *fieldcollection.FieldCollection `json:"attributes" yaml:"attributes,omitempty"`
	}
)

// MatcherID returns the rule UUID or a hash for the rule if no UUID
// is available
func (r Rule) MatcherID() string {
	if r.UUID != "" {
		return r.UUID
	}

	return r.hash()
}

// Matches checks whether the Rule should be executed for the given parameters
func (r *Rule) Matches(m *irc.Message, event *string, timerStore TimerStore, msgFormatter MsgFormatter, twitchClient *twitch.Client, eventData *fieldcollection.FieldCollection) bool {
	r.msgFormatter = msgFormatter
	r.timerStore = timerStore
	r.twitchClient = twitchClient

	var (
		badges = twitch.ParseBadgeLevels(m)
		logger = logrus.WithFields(logrus.Fields{
			"msg":  m,
			"rule": r,
		})
	)

	for _, matcher := range []func(*logrus.Entry, *irc.Message, *string, twitch.BadgeCollection, *fieldcollection.FieldCollection) bool{
		r.allowExecuteDisable,
		r.allowExecuteChannelWhitelist,
		r.allowExecuteUserWhitelist,
		r.allowExecuteEventMatch,
		r.allowExecuteMessageMatcherWhitelist,
		r.allowExecuteMessageMatcherBlacklist,
		r.allowExecuteBadgeBlacklist,
		r.allowExecuteBadgeWhitelist,
		r.allowExecuteDisableOnPermit,
		r.allowExecuteRuleCooldown,
		r.allowExecuteChannelCooldown,
		r.allowExecuteUserCooldown,
		r.allowExecuteDisableOnTemplate,
		r.allowExecuteDisableOnOffline,
	} {
		if !matcher(logger, m, event, badges, eventData) {
			return false
		}
	}

	// Nothing objected: Matches!
	return true
}

// GetMatchMessage returns the cached Regexp if available or compiles
// the given match string into a Regexp
func (r *Rule) GetMatchMessage() *regexp.Regexp {
	var err error

	if r.matchMessage == nil {
		if r.matchMessage, err = regexp.Compile(*r.MatchMessage); err != nil {
			logrus.WithError(err).Error("Unable to compile expression")
			return nil
		}
	}

	return r.matchMessage
}

// SetCooldown uses the given TimerStore to set the cooldowns for the
// Rule after execution
func (r *Rule) SetCooldown(timerStore TimerStore, m *irc.Message, evtData *fieldcollection.FieldCollection) {
	var err error

	if r.Cooldown != nil {
		if err = timerStore.AddCooldown(TimerTypeCooldown, "", r.MatcherID(), time.Now().Add(*r.Cooldown)); err != nil {
			logrus.WithError(err).Error("setting general rule cooldown")
		}
	}

	if r.ChannelCooldown != nil && DeriveChannel(m, evtData) != "" {
		if err = timerStore.AddCooldown(TimerTypeCooldown, DeriveChannel(m, evtData), r.MatcherID(), time.Now().Add(*r.ChannelCooldown)); err != nil {
			logrus.WithError(err).Error("setting channel rule cooldown")
		}
	}

	if r.UserCooldown != nil && DeriveUser(m, evtData) != "" {
		if err = timerStore.AddCooldown(TimerTypeCooldown, DeriveUser(m, evtData), r.MatcherID(), time.Now().Add(*r.UserCooldown)); err != nil {
			logrus.WithError(err).Error("setting user rule cooldown")
		}
	}
}

// UpdateFromSubscription fetches the remote Rule source if one is
// defined and updates the rule with its content
func (r *Rule) UpdateFromSubscription(ctx context.Context) (bool, error) {
	if r.SubscribeFrom == nil || len(*r.SubscribeFrom) == 0 {
		return false, nil
	}

	prevHash := r.hash()

	remoteURL, err := url.Parse(*r.SubscribeFrom)
	if err != nil {
		return false, errors.Wrap(err, "parsing remote subscription url")
	}

	reqCtx, cancel := context.WithTimeout(ctx, remoteRuleFetchTimeout)
	defer cancel()

	req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, remoteURL.String(), nil)
	if err != nil {
		return false, errors.Wrap(err, "assembling request")
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return false, errors.Wrap(err, "executing request")
	}
	defer func() {
		if err := resp.Body.Close(); err != nil {
			logrus.WithError(err).Error("closing request body (leaked fd)")
		}
	}()

	if resp.StatusCode != http.StatusOK {
		return false, errors.Errorf("unxpected HTTP status %d", resp.StatusCode)
	}

	inputType, err := r.fileTypeFromRequest(remoteURL, resp)
	if err != nil {
		return false, errors.Wrap(err, "detecting content type")
	}

	var newRule Rule
	switch inputType {
	case contentTypeJSON:
		err = json.NewDecoder(resp.Body).Decode(&newRule)

	case contentTypeYAML:
		err = yaml.NewDecoder(resp.Body).Decode(&newRule)

	default:
		return false, errors.New("unexpected format")
	}

	if err != nil {
		return false, errors.Wrap(err, "decoding remote rule")
	}

	if newRule.hash() == prevHash {
		// No update, exit now
		return false, nil
	}

	*r = newRule

	return true, nil
}

// Validate executes some basic checks on the validity of the Rule
func (r Rule) Validate(tplValidate TemplateValidatorFunc) error {
	if r.MatchMessage != nil {
		if _, err := regexp.Compile(*r.MatchMessage); err != nil {
			return errors.Wrap(err, "compiling match_message field regex")
		}
	}

	if r.DisableOnTemplate != nil {
		if err := tplValidate(*r.DisableOnTemplate); err != nil {
			return errors.Wrap(err, "parsing disable_on_template template")
		}
	}

	return nil
}

func (r *Rule) allowExecuteBadgeBlacklist(logger *logrus.Entry, _ *irc.Message, _ *string, badges twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
	for _, b := range r.DisableOn {
		if badges.Has(b) {
			logger.Tracef("Non-Match: Disable-Badge %s", b)
			return false
		}
	}

	return true
}

func (r *Rule) allowExecuteBadgeWhitelist(_ *logrus.Entry, _ *irc.Message, _ *string, badges twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
	if len(r.EnableOn) == 0 {
		// No match criteria set, does not speak against matching
		return true
	}

	for _, b := range r.EnableOn {
		if badges.Has(b) {
			return true
		}
	}

	return false
}

func (r *Rule) allowExecuteChannelCooldown(logger *logrus.Entry, m *irc.Message, _ *string, badges twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
	if r.ChannelCooldown == nil || DeriveChannel(m, evtData) == "" {
		// No match criteria set, does not speak against matching
		return true
	}

	inCooldown, err := r.timerStore.InCooldown(TimerTypeCooldown, DeriveChannel(m, evtData), r.MatcherID())
	if err != nil {
		logger.WithError(err).Error("checking channel cooldown")
		return false
	}

	if !inCooldown {
		return true
	}

	for _, b := range r.SkipCooldownFor {
		if badges.Has(b) {
			return true
		}
	}

	return false
}

func (r *Rule) allowExecuteChannelWhitelist(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
	if len(r.MatchChannels) == 0 {
		// No match criteria set, does not speak against matching
		return true
	}

	if DeriveChannel(m, evtData) == "" || (!str.StringInSlice(DeriveChannel(m, evtData), r.MatchChannels) && !str.StringInSlice(strings.TrimPrefix(DeriveChannel(m, evtData), "#"), r.MatchChannels)) {
		logger.Trace("Non-Match: Channel")
		return false
	}

	return true
}

func (r *Rule) allowExecuteDisable(logger *logrus.Entry, _ *irc.Message, _ *string, _ twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
	if r.Disable == nil {
		// No match criteria set, does not speak against matching
		return true
	}

	if *r.Disable {
		logger.Trace("Non-Match: Disable")
		return false
	}

	return true
}

func (r *Rule) allowExecuteDisableOnOffline(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
	if r.DisableOnOffline == nil || !*r.DisableOnOffline || DeriveChannel(m, evtData) == "" {
		// No match criteria set, does not speak against matching
		return true
	}

	streamLive, err := r.twitchClient.HasLiveStream(context.Background(), strings.TrimLeft(DeriveChannel(m, evtData), "#"))
	if err != nil {
		logger.WithError(err).Error("Unable to determine live status")
		return false
	}
	if !streamLive {
		logger.Trace("Non-Match: Stream offline")
		return false
	}

	return true
}

func (r *Rule) allowExecuteDisableOnPermit(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
	hasPermit, err := r.timerStore.HasPermit(DeriveChannel(m, evtData), DeriveUser(m, evtData))
	if err != nil {
		logger.WithError(err).Error("checking permit")
		return false
	}

	if r.DisableOnPermit != nil && *r.DisableOnPermit && DeriveChannel(m, evtData) != "" && hasPermit {
		logger.Trace("Non-Match: Permit")
		return false
	}

	return true
}

func (r *Rule) allowExecuteDisableOnTemplate(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
	if r.DisableOnTemplate == nil || *r.DisableOnTemplate == "" {
		// No match criteria set, does not speak against matching
		return true
	}

	res, err := r.msgFormatter(*r.DisableOnTemplate, m, r, evtData)
	if err != nil {
		logger.WithError(err).Error("Unable to check DisableOnTemplate field")
		// Caused an error, forbid execution
		return false
	}

	if res == "true" {
		logger.Trace("Non-Match: Template")
		return false
	}

	return true
}

func (r *Rule) allowExecuteEventMatch(logger *logrus.Entry, _ *irc.Message, event *string, _ twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
	// The user defines either no event to match or they define an
	// event to match. We now need to ensure this match is valid for
	// the current execution:
	//
	// - If the user gave no event we MUST NOT have an event defined
	// - If the user gave an event we MUST have the same event defined
	//
	// To aid fighting spam we do define some excemption from these
	// rules:
	//
	// - Bits are sent using IRC messages and might contains spam
	//   therefore we additionally match them through a message
	//   matcher
	// - Resubs are also IRC messages and might be abused to spam
	//   though this is quite unlikely. Even though it's unlikely
	//   we also allow a match for message matchers to aid mods
	//
	// As all set events are always pointer to non-empty strings we
	// assume an empty string in case either is not set and then
	// compare the string contents.

	var mE, gE string

	if r.MatchEvent != nil {
		mE = *r.MatchEvent
	}

	if event != nil {
		gE = *event
	}

	if mE == gE {
		// Event does exactly match
		return true
	}

	if mE == "" && str.StringInSlice(gE, []string{"bits", "resub"}) {
		// Additional message matchers - see explanation above
		return true
	}

	logger.Trace("Non-Match: Event")
	return false
}

func (r *Rule) allowExecuteMessageMatcherBlacklist(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
	if len(r.DisableOnMatchMessages) == 0 {
		// No match criteria set, does not speak against matching
		return true
	}

	// If the regexps were not pre-compiled, do it now
	if len(r.disableOnMatchMessages) != len(r.DisableOnMatchMessages) {
		r.disableOnMatchMessages = nil
		for _, dm := range r.DisableOnMatchMessages {
			dmr, err := regexp.Compile(dm)
			if err != nil {
				logger.WithError(err).Error("Unable to compile expression")
				return false
			}
			r.disableOnMatchMessages = append(r.disableOnMatchMessages, dmr)
		}
	}

	for _, rex := range r.disableOnMatchMessages {
		if m != nil && rex.MatchString(m.Trailing()) {
			logger.Trace("Non-Match: Disable-On-Message")
			return false
		}
	}

	return true
}

func (r *Rule) allowExecuteMessageMatcherWhitelist(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
	if r.MatchMessage == nil {
		// No match criteria set, does not speak against matching
		return true
	}

	var err error

	// If the regexp was not yet compiled, cache it
	if r.matchMessage == nil {
		if r.matchMessage, err = regexp.Compile(*r.MatchMessage); err != nil {
			logger.WithError(err).Error("Unable to compile expression")
			return false
		}
	}

	// Check whether the message matches
	if m == nil || !r.matchMessage.MatchString(m.Trailing()) {
		logger.Trace("Non-Match: Message")
		return false
	}

	return true
}

func (r *Rule) allowExecuteRuleCooldown(logger *logrus.Entry, _ *irc.Message, _ *string, badges twitch.BadgeCollection, _ *fieldcollection.FieldCollection) bool {
	if r.Cooldown == nil {
		// No match criteria set, does not speak against matching
		return true
	}

	inCooldown, err := r.timerStore.InCooldown(TimerTypeCooldown, "", r.MatcherID())
	if err != nil {
		logger.WithError(err).Error("checking rule cooldown")
		return false
	}

	if !inCooldown {
		return true
	}

	for _, b := range r.SkipCooldownFor {
		if badges.Has(b) {
			return true
		}
	}

	return false
}

func (r *Rule) allowExecuteUserCooldown(logger *logrus.Entry, m *irc.Message, _ *string, badges twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
	if r.UserCooldown == nil {
		// No match criteria set, does not speak against matching
		return true
	}

	inCooldown, err := r.timerStore.InCooldown(TimerTypeCooldown, DeriveUser(m, evtData), r.MatcherID())
	if err != nil {
		logger.WithError(err).Error("checking user cooldown")
		return false
	}

	if DeriveUser(m, evtData) == "" || !inCooldown {
		return true
	}

	for _, b := range r.SkipCooldownFor {
		if badges.Has(b) {
			return true
		}
	}

	return false
}

func (r *Rule) allowExecuteUserWhitelist(logger *logrus.Entry, m *irc.Message, _ *string, _ twitch.BadgeCollection, evtData *fieldcollection.FieldCollection) bool {
	if len(r.MatchUsers) == 0 {
		// No match criteria set, does not speak against matching
		return true
	}

	if DeriveUser(m, evtData) == "" || !str.StringInSlice(strings.ToLower(DeriveUser(m, evtData)), r.MatchUsers) {
		logger.Trace("Non-Match: Users")
		return false
	}

	return true
}

func (Rule) fileTypeFromRequest(remoteURL *url.URL, resp *http.Response) (string, error) {
	switch path.Ext(remoteURL.Path) {
	case ".json":
		return contentTypeJSON, nil

	case ".yaml", ".yml":
		return contentTypeYAML, nil
	}

	switch strings.Split(resp.Header.Get("Content-Type"), ";")[0] {
	case "application/json":
		return contentTypeJSON, nil

	case "application/yaml", "application/x-yaml", "text/x-yaml":
		return contentTypeYAML, nil
	}

	return "", errors.New("no valid file type detected")
}

func (r Rule) hash() string {
	h, err := hashstructure.Hash(r, hashstructure.FormatV2, nil)
	if err != nil {
		panic(errors.Wrap(err, "hashing rule"))
	}
	return fmt.Sprintf("hashstructure:%x", h)
}