package jira

import (
	"bytes"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"text/template"

	"github.com/andygrunwald/go-jira"
	"github.com/pkg/errors"
	"github.com/trivago/tgo/tcontainer"
	"golang.org/x/text/cases"
	"golang.org/x/text/language"

	"github.com/projectdiscovery/gologger"
	"github.com/projectdiscovery/nuclei/v3/pkg/output"
	"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown/util"
	"github.com/projectdiscovery/nuclei/v3/pkg/reporting/format"
	"github.com/projectdiscovery/nuclei/v3/pkg/reporting/trackers/filters"
	"github.com/projectdiscovery/retryablehttp-go"
	"github.com/projectdiscovery/utils/ptr"
)

type Formatter struct {
	util.MarkdownFormatter
}

// TemplateContext holds the data available for template evaluation
type TemplateContext struct {
	Severity    string
	Name        string
	Host        string
	CVSSScore   string
	CVEID       string
	CWEID       string
	CVSSMetrics string
	Tags        []string
}

// buildTemplateContext creates a template context from a ResultEvent
func buildTemplateContext(event *output.ResultEvent) *TemplateContext {
	ctx := &TemplateContext{
		Host: event.Host,
		Name: event.Info.Name,
		Tags: event.Info.Tags.ToSlice(),
	}

	// Set severity string
	ctx.Severity = event.Info.SeverityHolder.Severity.String()

	if event.Info.Classification != nil {
		ctx.CVSSScore = fmt.Sprintf("%.2f", ptr.Safe(event.Info.Classification).CVSSScore)
		ctx.CVEID = strings.Join(ptr.Safe(event.Info.Classification).CVEID.ToSlice(), ", ")
		ctx.CWEID = strings.Join(ptr.Safe(event.Info.Classification).CWEID.ToSlice(), ", ")
		ctx.CVSSMetrics = ptr.Safe(event.Info.Classification).CVSSMetrics
	}

	return ctx
}

// evaluateTemplate executes a template string with the given context
func evaluateTemplate(templateStr string, ctx *TemplateContext) (string, error) {
	// If no template markers found, return as-is for backward compatibility
	if !strings.Contains(templateStr, "{{") {
		return templateStr, nil
	}

	// Create template with useful functions for JIRA custom fields
	funcMap := template.FuncMap{
		"upper":     strings.ToUpper,
		"lower":     strings.ToLower,
		"title":     cases.Title(language.English).String,
		"contains":  strings.Contains,
		"hasPrefix": strings.HasPrefix,
		"hasSuffix": strings.HasSuffix,
		"trim":      strings.Trim,
		"trimSpace": strings.TrimSpace,
		"replace":   strings.ReplaceAll,
		"split":     strings.Split,
		"join":      strings.Join,
	}

	tmpl, err := template.New("field").Funcs(funcMap).Parse(templateStr)
	if err != nil {
		return templateStr, fmt.Errorf("failed to parse template: %w", err)
	}

	var buf bytes.Buffer
	if err := tmpl.Execute(&buf, ctx); err != nil {
		return templateStr, fmt.Errorf("failed to execute template: %w", err)
	}

	return buf.String(), nil
}

// evaluateCustomFieldValue evaluates a custom field value, supporting both new template syntax and legacy $variable syntax
func (i *Integration) evaluateCustomFieldValue(value string, templateCtx *TemplateContext, event *output.ResultEvent) (interface{}, error) {
	// Try template evaluation first (supports {{...}} syntax)
	if strings.Contains(value, "{{") {
		return evaluateTemplate(value, templateCtx)
	}

	// Handle legacy $variable syntax for backward compatibility
	if strings.HasPrefix(value, "$") {
		variableName := strings.TrimPrefix(value, "$")
		switch variableName {
		case "CVSSMetrics":
			if event.Info.Classification != nil {
				return ptr.Safe(event.Info.Classification).CVSSMetrics, nil
			}
			return "", nil
		case "CVEID":
			if event.Info.Classification != nil {
				return strings.Join(ptr.Safe(event.Info.Classification).CVEID.ToSlice(), ", "), nil
			}
			return "", nil
		case "CWEID":
			if event.Info.Classification != nil {
				return strings.Join(ptr.Safe(event.Info.Classification).CWEID.ToSlice(), ", "), nil
			}
			return "", nil
		case "CVSSScore":
			if event.Info.Classification != nil {
				return fmt.Sprintf("%.2f", ptr.Safe(event.Info.Classification).CVSSScore), nil
			}
			return "", nil
		case "Host":
			return event.Host, nil
		case "Severity":
			return event.Info.SeverityHolder.Severity.String(), nil
		case "Name":
			return event.Info.Name, nil
		default:
			return value, nil // return as-is if variable not found
		}
	}

	// Return as-is if no template or variable syntax found
	return value, nil
}

func (jiraFormatter *Formatter) MakeBold(text string) string {
	return "*" + text + "*"
}

func (jiraFormatter *Formatter) CreateCodeBlock(title string, content string, _ string) string {
	escapedContent := strings.ReplaceAll(content, "{code}", "")
	return fmt.Sprintf("\n%s\n{code}\n%s\n{code}\n", jiraFormatter.MakeBold(title), escapedContent)
}

func (jiraFormatter *Formatter) CreateTable(headers []string, rows [][]string) (string, error) {
	table, err := jiraFormatter.MarkdownFormatter.CreateTable(headers, rows)
	if err != nil {
		return "", err
	}
	tableRows := strings.Split(table, "\n")
	tableRowsWithoutHeaderSeparator := append(tableRows[:1], tableRows[2:]...)
	return strings.Join(tableRowsWithoutHeaderSeparator, "\n"), nil
}

func (jiraFormatter *Formatter) CreateLink(title string, url string) string {
	return fmt.Sprintf("[%s|%s]", title, url)
}

// Integration is a client for an issue tracker integration
type Integration struct {
	Formatter
	jira    *jira.Client
	options *Options

	once         *sync.Once
	transitionID string
}

// Options contains the configuration options for jira client
type Options struct {
	// Cloud value (optional) is set to true when Jira cloud is used
	Cloud bool `yaml:"cloud" json:"cloud"`
	// UpdateExisting value (optional) if true, the existing opened issue is updated
	UpdateExisting bool `yaml:"update-existing" json:"update_existing"`
	// URL is the URL of the jira server
	URL string `yaml:"url" json:"url" validate:"required"`
	// SiteURL is the browsable URL for the Jira instance (optional)
	// If not provided, issue.Self will be used. Useful for OAuth where issue.Self contains api.atlassian.com
	SiteURL string `yaml:"site-url" json:"site_url"`
	// AccountID is the accountID of the jira user.
	AccountID string `yaml:"account-id" json:"account_id" validate:"required"`
	// Email is the email of the user for jira instance
	Email string `yaml:"email" json:"email"`
	// PersonalAccessToken is the personal access token for jira instance.
	// If this is set, Bearer Auth is used instead of Basic Auth.
	PersonalAccessToken string `yaml:"personal-access-token" json:"personal_access_token"`
	// Token is the token for jira instance.
	Token string `yaml:"token" json:"token"`
	// ProjectName is the name of the project.
	ProjectName string `yaml:"project-name" json:"project_name"`
	// ProjectID is the ID of the project (optional)
	ProjectID string `yaml:"project-id" json:"project_id"`
	// IssueType (optional) is the name of the created issue type
	IssueType string `yaml:"issue-type" json:"issue_type"`
	// IssueTypeID (optional) is the ID of the created issue type
	IssueTypeID string `yaml:"issue-type-id" json:"issue_type_id"`
	// SeverityAsLabel (optional) sends the severity as the label of the created
	// issue.
	SeverityAsLabel bool `yaml:"severity-as-label" json:"severity_as_label"`
	// AllowList contains a list of allowed events for this tracker
	AllowList *filters.Filter `yaml:"allow-list"`
	// DenyList contains a list of denied events for this tracker
	DenyList *filters.Filter `yaml:"deny-list"`
	// Severity (optional) is the severity of the issue.
	Severity   []string              `yaml:"severity" json:"severity"`
	HttpClient *retryablehttp.Client `yaml:"-" json:"-"`
	// for each customfield specified in the configuration options
	// we will create a map of customfield name to the value
	// that will be used to create the issue
	CustomFields map[string]interface{} `yaml:"custom-fields" json:"custom_fields"`
	StatusNot    string                 `yaml:"status-not" json:"status_not"`
	OmitRaw      bool                   `yaml:"-"`
}

// New creates a new issue tracker integration client based on options.
func New(options *Options) (*Integration, error) {
	username := options.Email
	if !options.Cloud {
		username = options.AccountID
	}

	var httpclient *http.Client
	if options.PersonalAccessToken != "" {
		bearerTp := jira.BearerAuthTransport{
			Token: options.PersonalAccessToken,
		}
		if options.HttpClient != nil {
			bearerTp.Transport = options.HttpClient.HTTPClient.Transport
		}
		httpclient = bearerTp.Client()
	} else {
		basicTp := jira.BasicAuthTransport{
			Username: username,
			Password: options.Token,
		}
		if options.HttpClient != nil {
			basicTp.Transport = options.HttpClient.HTTPClient.Transport
		}
		httpclient = basicTp.Client()
	}

	jiraClient, err := jira.NewClient(httpclient, options.URL)
	if err != nil {
		return nil, err
	}
	integration := &Integration{
		jira:    jiraClient,
		options: options,
		once:    &sync.Once{},
	}
	return integration, nil
}

func (i *Integration) Name() string {
	return "jira"
}

// CreateNewIssue creates a new issue in the tracker
func (i *Integration) CreateNewIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) {
	summary := format.Summary(event)
	labels := []string{}
	severityLabel := fmt.Sprintf("Severity:%s", event.Info.SeverityHolder.Severity.String())
	if i.options.SeverityAsLabel && severityLabel != "" {
		labels = append(labels, severityLabel)
	}
	if label := i.options.IssueType; label != "" {
		labels = append(labels, label)
	}
	// Build template context for evaluating custom field templates
	templateCtx := buildTemplateContext(event)

	// Process custom fields with template evaluation support
	customFields := tcontainer.NewMarshalMap()
	for name, value := range i.options.CustomFields {
		if valueMap, ok := value.(map[interface{}]interface{}); ok {
			// Iterate over nested map
			for nestedName, nestedValue := range valueMap {
				fmtNestedValue, ok := nestedValue.(string)
				if !ok {
					return nil, fmt.Errorf(`couldn't iterate on nested item "%s": %s`, nestedName, nestedValue)
				}

				// Evaluate template or handle legacy $variable syntax
				evaluatedValue, err := i.evaluateCustomFieldValue(fmtNestedValue, templateCtx, event)
				if err != nil {
					gologger.Warning().Msgf("Failed to evaluate template for field %s.%s: %v", name, nestedName, err)
					evaluatedValue = fmtNestedValue // fallback to original value
				}

				switch nestedName {
				case "id":
					customFields[name] = map[string]interface{}{"id": evaluatedValue}
				case "name":
					customFields[name] = map[string]interface{}{"value": evaluatedValue}
				case "freeform":
					customFields[name] = evaluatedValue
				}
			}
		}
	}
	fields := &jira.IssueFields{
		Assignee:    &jira.User{Name: i.options.AccountID},
		Description: format.CreateReportDescription(event, i, i.options.OmitRaw),
		Unknowns:    customFields,
		Labels:      labels,
		Type:        jira.IssueType{Name: i.options.IssueType},
		Project:     jira.Project{Key: i.options.ProjectName},
		Summary:     summary,
	}

	// On-prem version of Jira server does not use AccountID
	if !i.options.Cloud {
		fields = &jira.IssueFields{
			Assignee:    &jira.User{Name: i.options.AccountID},
			Description: format.CreateReportDescription(event, i, i.options.OmitRaw),
			Type:        jira.IssueType{Name: i.options.IssueType},
			Project:     jira.Project{Key: i.options.ProjectName},
			Summary:     summary,
			Labels:      labels,
			Unknowns:    customFields,
		}
	}
	if i.options.IssueTypeID != "" {
		fields.Type = jira.IssueType{ID: i.options.IssueTypeID}
	}
	if i.options.ProjectID != "" {
		fields.Project = jira.Project{ID: i.options.ProjectID}
	}

	issueData := &jira.Issue{
		Fields: fields,
	}
	createdIssue, resp, err := i.jira.Issue.Create(issueData)
	if err != nil {
		var data string
		if resp != nil && resp.Body != nil {
			d, _ := io.ReadAll(resp.Body)
			data = string(d)
		}
		return nil, fmt.Errorf("%w => %s", err, data)
	}
	return i.getIssueResponseFromJira(createdIssue)
}

func (i *Integration) getIssueResponseFromJira(issue *jira.Issue) (*filters.CreateIssueResponse, error) {
	var issueURL string

	// Use SiteURL if provided, otherwise fall back to original issue.Self logic
	if i.options.SiteURL != "" {
		// Use the configured site URL for browsable links (useful for OAuth)
		baseURL := strings.TrimRight(i.options.SiteURL, "/")
		issueURL = fmt.Sprintf("%s/browse/%s", baseURL, issue.Key)
	} else {
		// Fall back to original logic using issue.Self
		parsed, err := url.Parse(issue.Self)
		if err != nil {
			return nil, err
		}
		parsed.Path = fmt.Sprintf("/browse/%s", issue.Key)
		issueURL = parsed.String()
	}

	return &filters.CreateIssueResponse{
		IssueID:  issue.ID,
		IssueURL: issueURL,
	}, nil
}

// CreateIssue creates an issue in the tracker or updates the existing one
func (i *Integration) CreateIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) {
	if i.options.UpdateExisting {
		issue, err := i.FindExistingIssue(event, true)
		if err != nil {
			return nil, errors.Wrap(err, "could not find existing issue")
		} else if issue.ID != "" {
			_, _, err = i.jira.Issue.AddComment(issue.ID, &jira.Comment{
				Body: format.CreateReportDescription(event, i, i.options.OmitRaw),
			})
			if err != nil {
				return nil, errors.Wrap(err, "could not add comment to existing issue")
			}
			return i.getIssueResponseFromJira(&issue)
		}
	}
	resp, err := i.CreateNewIssue(event)
	if err != nil {
		return nil, errors.Wrap(err, "could not create new issue")
	}
	return resp, nil
}

func (i *Integration) CloseIssue(event *output.ResultEvent) error {
	if i.options.StatusNot == "" {
		return nil
	}

	issue, err := i.FindExistingIssue(event, false)
	if err != nil {
		return err
	} else if issue.ID != "" {
		// Lazy load the transitions ID in case it's not set.
		// StatusNot is a target status name (matches JQL usage below and the
		// field's semantics); find the transition that moves to that status.
		i.once.Do(func() {
			transitions, _, err := i.jira.Issue.GetTransitions(issue.ID)
			if err != nil {
				return
			}
			for _, transition := range transitions {
				if transition.To.Name == i.options.StatusNot {
					i.transitionID = transition.ID
					break
				}
			}
		})
		if i.transitionID == "" {
			return nil
		}
		transition := jira.CreateTransitionPayload{
			Transition: jira.TransitionPayload{
				ID: i.transitionID,
			},
		}

		_, err = i.jira.Issue.DoTransitionWithPayload(issue.ID, transition)
		if err != nil {
			return err
		}
	}
	return nil
}

// FindExistingIssue checks if the issue already exists and returns its ID
func (i *Integration) FindExistingIssue(event *output.ResultEvent, useStatus bool) (jira.Issue, error) {
	template := format.GetMatchedTemplateName(event)
	project := i.options.ProjectName
	if i.options.ProjectID != "" {
		project = i.options.ProjectID
	}
	jql := fmt.Sprintf("summary ~ \"%s\" AND summary ~ \"%s\" AND project = \"%s\"", template, event.Host, project)
	if useStatus {
		jql = fmt.Sprintf("%s AND status != \"%s\"", jql, i.options.StatusNot)
	}

	// Hotfix for Jira Cloud: use Enhanced Search API (v3) to avoid deprecated v2 path
	if i.options.Cloud {
		params := url.Values{}
		params.Set("jql", jql)
		params.Set("maxResults", "1")
		params.Set("fields", "id,key")

		req, err := i.jira.NewRequest("GET", "/rest/api/3/search/jql"+"?"+params.Encode(), nil)
		if err != nil {
			return jira.Issue{}, err
		}

		var searchResult struct {
			Issues []struct {
				ID  string `json:"id"`
				Key string `json:"key"`
			} `json:"issues"`
			IsLast        bool   `json:"isLast"`
			NextPageToken string `json:"nextPageToken"`
		}

		resp, err := i.jira.Do(req, &searchResult)
		if err != nil {
			var data string
			if resp != nil && resp.Body != nil {
				d, _ := io.ReadAll(resp.Body)
				data = string(d)
			}
			return jira.Issue{}, fmt.Errorf("%w => %s", err, data)
		}

		if len(searchResult.Issues) == 0 {
			return jira.Issue{}, nil
		}
		first := searchResult.Issues[0]
		base := strings.TrimRight(i.options.URL, "/")
		return jira.Issue{
			ID:   first.ID,
			Key:  first.Key,
			Self: fmt.Sprintf("%s/rest/api/3/issue/%s", base, first.ID),
		}, nil
	}

	searchOptions := &jira.SearchOptionsV2{
		MaxResults: 1, // if any issue exists, then we won't create a new one
		Fields:     []string{"summary", "description", "issuetype", "status", "priority", "project"},
	}

	issues, resp, err := i.jira.Issue.SearchV2JQL(jql, searchOptions)
	if err != nil {
		var data string
		if resp != nil && resp.Body != nil {
			d, _ := io.ReadAll(resp.Body)
			data = string(d)
		}
		return jira.Issue{}, fmt.Errorf("%w => %s", err, data)
	}

	switch resp.Total {
	case 0:
		return jira.Issue{}, nil
	case 1:
		return issues[0], nil
	default:
		gologger.Warning().Msgf("Discovered multiple opened issues %s for the host %s: The issue [%s] will be updated.", template, event.Host, issues[0].ID)
		return issues[0], nil
	}
}

// ShouldFilter determines if an issue should be logged to this tracker
func (i *Integration) ShouldFilter(event *output.ResultEvent) bool {
	if i.options.AllowList != nil && !i.options.AllowList.GetMatch(event) {
		return false
	}

	if i.options.DenyList != nil && i.options.DenyList.GetMatch(event) {
		return false
	}

	return true
}
