package interactsh

import (
	"bytes"
	"fmt"
	"os"
	"regexp"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"errors"

	"github.com/projectdiscovery/gcache"
	"github.com/projectdiscovery/gologger"
	"github.com/projectdiscovery/interactsh/pkg/client"
	"github.com/projectdiscovery/interactsh/pkg/server"
	"github.com/projectdiscovery/nuclei/v3/pkg/operators"
	"github.com/projectdiscovery/nuclei/v3/pkg/output"
	"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/helpers/responsehighlighter"
	"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/helpers/writer"
	"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/marker"
	"github.com/projectdiscovery/retryablehttp-go"
	"github.com/projectdiscovery/utils/errkit"
	stringsutil "github.com/projectdiscovery/utils/strings"
)

// Client is a wrapped client for interactsh server.
type Client struct {
	sync.Once
	sync.RWMutex

	options *Options

	// interactsh is a client for interactsh server.
	interactsh *client.Client
	// requests is a stored cache for interactsh-url->request-event data.
	requests gcache.Cache[string, *RequestData]
	// interactions is a stored cache for interactsh-interaction->interactsh-url data
	interactions gcache.Cache[string, []*server.Interaction]
	// matchedTemplates is a stored cache to track matched templates
	matchedTemplates gcache.Cache[string, bool]
	// interactshURLs is a stored cache to track multiple interactsh markers
	interactshURLs gcache.Cache[string, string]

	eviction         time.Duration
	pollDuration     time.Duration
	cooldownDuration time.Duration

	hostname string

	// determines if wait the cooldown period in case of generated URL
	generated atomic.Bool
	matched   atomic.Bool
}

// New returns a new interactsh server client
func New(options *Options) (*Client, error) {
	requestsCache := gcache.New[string, *RequestData](options.CacheSize).LRU().Build()
	interactionsCache := gcache.New[string, []*server.Interaction](defaultMaxInteractionsCount).LRU().Build()
	matchedTemplateCache := gcache.New[string, bool](defaultMaxInteractionsCount).LRU().Build()
	interactshURLCache := gcache.New[string, string](defaultMaxInteractionsCount).LRU().Build()

	interactClient := &Client{
		eviction:         options.Eviction,
		interactions:     interactionsCache,
		matchedTemplates: matchedTemplateCache,
		interactshURLs:   interactshURLCache,
		options:          options,
		requests:         requestsCache,
		pollDuration:     options.PollDuration,
		cooldownDuration: options.CooldownPeriod,
	}
	return interactClient, nil
}

func (c *Client) poll() error {
	if c.options.NoInteractsh {
		// do not init if disabled
		return ErrInteractshClientNotInitialized
	}
	interactsh, err := client.New(&client.Options{
		ServerURL:           c.options.ServerURL,
		Token:               c.options.Authorization,
		DisableHTTPFallback: c.options.DisableHttpFallback,
		HTTPClient:          c.options.HTTPClient,
		KeepAliveInterval:   time.Minute,
	})
	if err != nil {
		return errkit.Wrap(err, "could not create client")
	}

	c.interactsh = interactsh

	interactURL := interactsh.URL()
	interactDomain := interactURL[strings.Index(interactURL, ".")+1:]
	gologger.Info().Msgf("Using Interactsh Server: %s", interactDomain)

	c.setHostname(interactDomain)

	err = interactsh.StartPolling(c.pollDuration, func(interaction *server.Interaction) {
		request, err := c.requests.Get(interaction.UniqueID)
		// for more context in github actions
		if strings.EqualFold(os.Getenv("GITHUB_ACTIONS"), "true") && c.options.Debug {
			gologger.DefaultLogger.Print().Msgf("[Interactsh]: got interaction of %v for request %v and error %v", interaction, request, err)
		}
		if errors.Is(err, gcache.KeyNotFoundError) || request == nil {
			// If we don't have any request for this ID, add it to temporary
			// lru cache, so we can correlate when we get an add request.
			items, err := c.interactions.Get(interaction.UniqueID)
			if errkit.Is(err, gcache.KeyNotFoundError) || items == nil {
				_ = c.interactions.SetWithExpire(interaction.UniqueID, []*server.Interaction{interaction}, defaultInteractionDuration)
			} else {
				items = append(items, interaction)
				_ = c.interactions.SetWithExpire(interaction.UniqueID, items, defaultInteractionDuration)
			}
			return
		}

		if requestShouldStopAtFirstMatch(request) || c.options.StopAtFirstMatch {
			if gotItem, err := c.matchedTemplates.Get(eventHash(request.Event)); gotItem && err == nil {
				return
			}
		}

		_ = c.processInteractionForRequest(interaction, request)
	})

	if err != nil {
		return errkit.Wrap(err, "could not perform interactsh polling")
	}
	return nil
}

// requestShouldStopAtFirstmatch checks if further interactions should be stopped
// note: extra care should be taken while using this function since internalEvent is
// synchronized all the time and if caller functions has already acquired lock its best to explicitly specify that
// we could use `TryLock()` but that may over complicate things and need to differentiate
// situations whether to block or skip
func requestShouldStopAtFirstMatch(request *RequestData) bool {
	request.Event.RLock()
	defer request.Event.RUnlock()

	if stop, ok := request.Event.InternalEvent[stopAtFirstMatchAttribute]; ok {
		if v, ok := stop.(bool); ok {
			return v
		}
	}
	return false
}

// processInteractionForRequest processes an interaction for a request
func (c *Client) processInteractionForRequest(interaction *server.Interaction, data *RequestData) bool {
	var result *operators.Result
	var matched bool
	var templateID string
	data.Event.Lock()
	data.Event.InternalEvent["interactsh_protocol"] = interaction.Protocol
	if strings.EqualFold(interaction.Protocol, "dns") {
		data.Event.InternalEvent["interactsh_request"] = strings.ToLower(interaction.RawRequest)
	} else {
		data.Event.InternalEvent["interactsh_request"] = interaction.RawRequest
	}
	data.Event.InternalEvent["interactsh_response"] = interaction.RawResponse
	data.Event.InternalEvent["interactsh_ip"] = interaction.RemoteAddress
	if data.Operators != nil {
		result, matched = data.Operators.Execute(data.Event.InternalEvent, data.MatchFunc, data.ExtractFunc, c.options.Debug || c.options.DebugRequest || c.options.DebugResponse)
	} else {
		// this is most likely a bug so error instead of warning
		if data.Event.InternalEvent != nil {
			templateID = fmt.Sprint(data.Event.InternalEvent[templateIdAttribute])
		}
	}
	data.Event.Unlock()
	if data.Operators == nil {
		gologger.Error().Msgf("missing compiled operators for '%v' template", templateID)
	}

	// for more context in github actions
	if strings.EqualFold(os.Getenv("GITHUB_ACTIONS"), "true") && c.options.Debug {
		gologger.DefaultLogger.Print().Msgf("[Interactsh]: got result %v and status %v after processing interaction", result, matched)
	}

	if c.options.FuzzParamsFrequency != nil {
		if !matched {
			c.options.FuzzParamsFrequency.MarkParameter(data.Parameter, data.Request.String(), data.Operators.TemplateID)
		} else {
			c.options.FuzzParamsFrequency.UnmarkParameter(data.Parameter, data.Request.String(), data.Operators.TemplateID)
		}
	}

	// if we don't match, return
	if !matched || result == nil {
		return false
	}
	c.requests.Remove(interaction.UniqueID)

	if data.Event.OperatorsResult != nil {
		data.Event.OperatorsResult.Merge(result)
	} else {
		data.Event.SetOperatorResult(result)
	}
	// ensure payload values are preserved for interactsh-only matches
	data.Event.Lock()
	if data.Event.OperatorsResult != nil && len(data.Event.OperatorsResult.PayloadValues) == 0 {
		if payloads, ok := data.Event.InternalEvent["payloads"].(map[string]interface{}); ok {
			data.Event.OperatorsResult.PayloadValues = payloads
		}
	}
	data.Event.Unlock()

	data.Event.Lock()
	data.Event.Results = data.MakeResultFunc(data.Event)
	for _, event := range data.Event.Results {
		event.Interaction = interaction
	}
	data.Event.Unlock()

	if c.options.Debug || c.options.DebugRequest || c.options.DebugResponse {
		c.debugPrintInteraction(interaction, data.Event.OperatorsResult)
	}

	// if event is not already matched, write it to output
	if !data.Event.InteractshMatched.Load() && writer.WriteResult(data.Event, c.options.Output, c.options.Progress, c.options.IssuesClient) {
		data.Event.InteractshMatched.Store(true)
		c.matched.Store(true)
		if requestShouldStopAtFirstMatch(data) || c.options.StopAtFirstMatch {
			_ = c.matchedTemplates.SetWithExpire(eventHash(data.Event), true, defaultInteractionDuration)
		}
	}

	return true
}

func (c *Client) AlreadyMatched(data *RequestData) bool {
	return c.matchedTemplates.Has(eventHash(data.Event))
}

// URL returns a new URL that can be interacted with
func (c *Client) URL() (string, error) {
	// first time initialization
	var err error
	c.Do(func() {
		err = c.poll()
	})
	if err != nil {
		return "", errkit.Wrap(ErrInteractshClientNotInitialized, err.Error())
	}

	if c.interactsh == nil {
		return "", ErrInteractshClientNotInitialized
	}

	c.generated.Store(true)
	return c.interactsh.URL(), nil
}

// Close the interactsh clients after waiting for cooldown period.
func (c *Client) Close() bool {
	if c.cooldownDuration > 0 && c.generated.Load() {
		time.Sleep(c.cooldownDuration)
	}
	if c.interactsh != nil {
		_ = c.interactsh.StopPolling()
		_ = c.interactsh.Close()
	}

	c.requests.Purge()
	c.interactions.Purge()
	c.matchedTemplates.Purge()
	c.interactshURLs.Purge()

	return c.matched.Load()
}

// Replace replaces the default Interactsh URL placeholders with interactsh URLs.
func (c *Client) Replace(data string, interactshURLs []string) (string, []string) {
	for _, interactshURLMarker := range marker.FindInteractshURLMarkers(data) {
		if url, err := c.NewURLWithData(interactshURLMarker); err == nil {
			interactshURLs = append(interactshURLs, url)
			data = strings.Replace(data, interactshURLMarker, url, 1)
		}
	}
	return data, interactshURLs
}

// ReplaceWithMarker replaces custom regex matches with Interactsh URLs and appends them to interactshURLs.
func (c *Client) ReplaceWithMarker(data string, regex *regexp.Regexp, interactshURLs []string) (string, []string) {
	for _, interactshURLMarker := range regex.FindAllString(data, -1) {
		if url, err := c.NewURLWithData(interactshURLMarker); err == nil {
			interactshURLs = append(interactshURLs, url)
			data = strings.Replace(data, interactshURLMarker, url, 1)
		}
	}
	return data, interactshURLs
}

func (c *Client) NewURL() (string, error) {
	return c.NewURLWithData("")
}

func (c *Client) NewURLWithData(data string) (string, error) {
	url, err := c.URL()
	if err != nil {
		return "", err
	}
	if url == "" {
		return "", errors.New("empty interactsh url")
	}
	_ = c.interactshURLs.SetWithExpire(url, data, defaultInteractionDuration)
	return url, nil
}

// MakePlaceholders does placeholders for interact URLs and other data to a map
func (c *Client) MakePlaceholders(urls []string, data map[string]interface{}) {
	data["interactsh-server"] = c.getHostname()
	for _, url := range urls {
		if interactshURLMarker, err := c.interactshURLs.Get(url); interactshURLMarker != "" && err == nil {
			interactshMarker := strings.TrimSuffix(strings.TrimPrefix(interactshURLMarker, "{{"), "}}")

			c.interactshURLs.Remove(url)

			data[interactshMarker] = url
			urlIndex := strings.Index(url, ".")
			if urlIndex == -1 {
				continue
			}
			data[strings.Replace(interactshMarker, "url", "id", 1)] = url[:urlIndex]
		}
	}
}

// MakeResultEventFunc is a result making function for nuclei
type MakeResultEventFunc func(wrapped *output.InternalWrappedEvent) []*output.ResultEvent

// RequestData contains data for a request event
type RequestData struct {
	MakeResultFunc MakeResultEventFunc
	Event          *output.InternalWrappedEvent
	Operators      *operators.Operators
	MatchFunc      operators.MatchFunc
	ExtractFunc    operators.ExtractFunc

	Parameter string
	Request   *retryablehttp.Request
}

// RequestEvent is the event for a network request sent by nuclei.
func (c *Client) RequestEvent(interactshURLs []string, data *RequestData) {
	for _, interactshURL := range interactshURLs {
		id := strings.TrimRight(strings.TrimSuffix(interactshURL, c.getHostname()), ".")

		if requestShouldStopAtFirstMatch(data) || c.options.StopAtFirstMatch {
			gotItem, err := c.matchedTemplates.Get(eventHash(data.Event))
			if gotItem && err == nil {
				break
			}
		}

		interactions, err := c.interactions.Get(id)
		if interactions != nil && err == nil {
			for _, interaction := range interactions {
				if c.processInteractionForRequest(interaction, data) {
					c.interactions.Remove(id)
					break
				}
			}
		} else {
			_ = c.requests.SetWithExpire(id, data, c.eviction)
		}
	}
}

// HasMatchers returns true if an operator has interactsh part
// matchers or extractors.
//
// Used by requests to show result or not depending on presence of interact.sh
// data part matchers.
func HasMatchers(op *operators.Operators) bool {
	if op == nil {
		return false
	}

	for _, matcher := range op.Matchers {
		for _, dsl := range matcher.DSL {
			if stringsutil.ContainsAnyI(dsl, "interactsh") {
				return true
			}
		}
		if stringsutil.HasPrefixI(matcher.Part, "interactsh") {
			return true
		}
	}
	for _, matcher := range op.Extractors {
		if stringsutil.HasPrefixI(matcher.Part, "interactsh") {
			return true
		}
	}
	return false
}

// HasMarkers checks if the text contains interactsh markers
func HasMarkers(data string) bool {
	return marker.HasInteractshURLMarker(data)
}

func (c *Client) debugPrintInteraction(interaction *server.Interaction, event *operators.Result) {
	builder := &bytes.Buffer{}

	switch interaction.Protocol {
	case "dns":
		builder.WriteString(formatInteractionHeader("DNS", interaction.FullId, interaction.RemoteAddress, interaction.Timestamp))
		if c.options.DebugRequest || c.options.Debug {
			builder.WriteString(formatInteractionMessage("DNS Request", interaction.RawRequest, event, c.options.NoColor))
		}
		if c.options.DebugResponse || c.options.Debug {
			builder.WriteString(formatInteractionMessage("DNS Response", interaction.RawResponse, event, c.options.NoColor))
		}
	case "http":
		builder.WriteString(formatInteractionHeader("HTTP", interaction.FullId, interaction.RemoteAddress, interaction.Timestamp))
		if c.options.DebugRequest || c.options.Debug {
			builder.WriteString(formatInteractionMessage("HTTP Request", interaction.RawRequest, event, c.options.NoColor))
		}
		if c.options.DebugResponse || c.options.Debug {
			builder.WriteString(formatInteractionMessage("HTTP Response", interaction.RawResponse, event, c.options.NoColor))
		}
	case "smtp":
		builder.WriteString(formatInteractionHeader("SMTP", interaction.FullId, interaction.RemoteAddress, interaction.Timestamp))
		if c.options.DebugRequest || c.options.Debug || c.options.DebugResponse {
			builder.WriteString(formatInteractionMessage("SMTP Interaction", interaction.RawRequest, event, c.options.NoColor))
		}
	case "ldap":
		builder.WriteString(formatInteractionHeader("LDAP", interaction.FullId, interaction.RemoteAddress, interaction.Timestamp))
		if c.options.DebugRequest || c.options.Debug || c.options.DebugResponse {
			builder.WriteString(formatInteractionMessage("LDAP Interaction", interaction.RawRequest, event, c.options.NoColor))
		}
	}
	_, _ = fmt.Fprint(os.Stderr, builder.String())
}

func formatInteractionHeader(protocol, ID, address string, at time.Time) string {
	return fmt.Sprintf("[%s] Received %s interaction from %s at %s", ID, protocol, address, at.Format("2006-01-02 15:04:05"))
}

func formatInteractionMessage(key, value string, event *operators.Result, noColor bool) string {
	value = responsehighlighter.Highlight(event, value, noColor, false)
	return fmt.Sprintf("\n------------\n%s\n------------\n\n%s\n\n", key, value)
}

func hash(internalEvent output.InternalEvent) string {
	templateId := internalEvent[templateIdAttribute].(string)
	host := internalEvent["host"].(string)
	return fmt.Sprintf("%s:%s", templateId, host)
}

func eventHash(event *output.InternalWrappedEvent) string {
	event.RLock()
	defer event.RUnlock()

	return hash(event.InternalEvent)
}

func (c *Client) getHostname() string {
	c.RLock()
	defer c.RUnlock()

	return c.hostname
}

func (c *Client) setHostname(hostname string) {
	c.Lock()
	defer c.Unlock()

	c.hostname = hostname
}

// GetHostname returns the configured interactsh server hostname.
func (c *Client) GetHostname() string {
	return c.getHostname()
}
