package linear

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"

	"github.com/shurcooL/graphql"

	"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
	"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/nuclei/v3/pkg/reporting/trackers/linear/jsonutil"
	"github.com/projectdiscovery/nuclei/v3/pkg/types"
	"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
	"github.com/projectdiscovery/retryablehttp-go"
)

// Integration is a client for linear issue tracker integration
type Integration struct {
	url        string
	httpclient *http.Client
	options    *Options
}

// Options contains the configuration options for linear issue tracker client
type Options struct {
	// APIKey is the API key for linear account.
	APIKey string `yaml:"api-key" validate:"required"`

	// 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"`

	// TeamID is the team id for the project
	TeamID string `yaml:"team-id"`
	// ProjectID is the project id for the project
	ProjectID string `yaml:"project-id"`
	// DuplicateIssueCheck is a bool to enable duplicate tracking issue check and update the newest
	DuplicateIssueCheck bool `yaml:"duplicate-issue-check" default:"false"`

	// OpenStateID is the id of the open state for the project
	OpenStateID string `yaml:"open-state-id"`

	HttpClient *retryablehttp.Client `yaml:"-"`
	OmitRaw    bool                  `yaml:"-"`
}

// New creates a new issue tracker integration client based on options.
func New(options *Options) (*Integration, error) {
	var transport http.RoundTripper = http.DefaultTransport
	if options.HttpClient != nil && options.HttpClient.HTTPClient.Transport != nil {
		transport = options.HttpClient.HTTPClient.Transport
	}

	httpClient := &http.Client{
		Transport: &addHeaderTransport{
			T:   transport,
			Key: options.APIKey,
		},
	}

	integration := &Integration{
		url:        "https://api.linear.app/graphql",
		options:    options,
		httpclient: httpClient,
	}

	return integration, nil
}

// CreateIssue creates an issue in the tracker
func (i *Integration) CreateIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) {
	summary := format.Summary(event)
	description := format.CreateReportDescription(event, util.MarkdownFormatter{}, i.options.OmitRaw)
	_ = description

	ctx := context.Background()

	var err error
	var existingIssue *linearIssue
	if i.options.DuplicateIssueCheck {
		existingIssue, err = i.findIssueByTitle(ctx, summary)
		if err != nil && !errors.Is(err, io.EOF) {
			return nil, err
		}
	}

	if existingIssue == nil {
		// Create a new issue
		createdIssue, err := i.createIssueLinear(ctx, summary, description, priorityFromSeverity(event.Info.SeverityHolder.Severity))
		if err != nil {
			return nil, err
		}
		return &filters.CreateIssueResponse{
			IssueID:  types.ToString(createdIssue.ID),
			IssueURL: types.ToString(createdIssue.URL),
		}, nil
	} else {
		if existingIssue.State.Name == "Done" {
			// Update the issue state to open
			var issueUpdateInput struct {
				StateID string `json:"stateId"`
			}
			issueUpdateInput.StateID = i.options.OpenStateID
			variables := map[string]interface{}{
				"issueUpdateInput": issueUpdateInput,
				"issueID":          types.ToString(existingIssue.ID),
			}
			var resp struct {
				IssueUpdate struct {
					LastSyncID int `json:"lastSyncId"`
				}
			}
			err := i.doGraphqlRequest(ctx, existingIssueUpdateStateMutation, &resp, variables, "IssueUpdate")
			if err != nil {
				return nil, fmt.Errorf("error reopening issue %s: %s", existingIssue.ID, err)
			}
		}

		commentInput := map[string]interface{}{
			"issueId": types.ToString(existingIssue.ID),
			"body":    description,
		}
		variables := map[string]interface{}{
			"commentCreateInput": commentInput,
		}
		var resp struct {
			CommentCreate struct {
				LastSyncID int `json:"lastSyncId"`
			}
		}
		err := i.doGraphqlRequest(ctx, commentCreateExistingTicketMutation, &resp, variables, "CommentCreate")
		if err != nil {
			return nil, fmt.Errorf("error commenting on issue %s: %s", existingIssue.ID, err)
		}
		return &filters.CreateIssueResponse{
			IssueID:  types.ToString(existingIssue.ID),
			IssueURL: types.ToString(existingIssue.URL),
		}, nil
	}
}

func priorityFromSeverity(sev severity.Severity) float64 {
	switch sev {
	case severity.Critical:
		return linearPriorityCritical
	case severity.High:
		return linearPriorityHigh
	case severity.Medium:
		return linearPriorityMedium
	case severity.Low:
		return linearPriorityLow
	default:
		return linearPriorityNone
	}
}

type createIssueMutation struct {
	IssueCreate struct {
		Issue struct {
			ID         graphql.ID
			Title      graphql.String
			Identifier graphql.String
			State      struct {
				Name graphql.String
			}
			URL graphql.String
		}
	}
}

const (
	createIssueGraphQLMutation = `mutation CreateIssue($input: IssueCreateInput!) {
    issueCreate(input: $input) {
        issue {
            id
            title
            identifier
            state {
                name
            }
            url
        }
    }
}`

	searchExistingTicketQuery = `query ($teamID: ID, $projectID: ID, $title: String!) {
  issues(filter: { 
  	title: { eq: $title }, 
	team: { id: { eq: $teamID } } 
	project: { id: { eq: $projectID } } 
  }) {
    nodes {
      id
      title
      identifier
      state {
        name
      }
      url
    }
  }
}
`

	existingIssueUpdateStateMutation = `mutation IssueUpdate($issueUpdateInput: IssueUpdateInput!, $issueID: String!) {
  issueUpdate(input: $issueUpdateInput, id: $issueID) {
	lastSyncId
  }
}
`

	commentCreateExistingTicketMutation = `mutation CommentCreate($commentCreateInput: CommentCreateInput!) {
  commentCreate(input: $commentCreateInput) {
    lastSyncId
  }
}
`
)

func (i *Integration) createIssueLinear(ctx context.Context, title, description string, priority float64) (*linearIssue, error) {
	var mutation createIssueMutation
	input := map[string]interface{}{
		"title":       title,
		"description": description,
		"priority":    priority,
	}
	if i.options.TeamID != "" {
		input["teamId"] = graphql.ID(i.options.TeamID)
	}
	if i.options.ProjectID != "" {
		input["projectId"] = i.options.ProjectID
	}

	variables := map[string]interface{}{
		"input": input,
	}

	err := i.doGraphqlRequest(ctx, createIssueGraphQLMutation, &mutation, variables, "CreateIssue")
	if err != nil {
		return nil, err
	}

	return &linearIssue{
		ID:         mutation.IssueCreate.Issue.ID,
		Title:      mutation.IssueCreate.Issue.Title,
		Identifier: mutation.IssueCreate.Issue.Identifier,
		State: struct {
			Name graphql.String
		}{
			Name: mutation.IssueCreate.Issue.State.Name,
		},
		URL: mutation.IssueCreate.Issue.URL,
	}, nil
}

func (i *Integration) findIssueByTitle(ctx context.Context, title string) (*linearIssue, error) {
	var query findExistingIssuesSearch
	variables := map[string]interface{}{
		"title": graphql.String(title),
	}
	if i.options.TeamID != "" {
		variables["teamId"] = graphql.ID(i.options.TeamID)
	}
	if i.options.ProjectID != "" {
		variables["projectID"] = graphql.ID(i.options.ProjectID)
	}

	err := i.doGraphqlRequest(ctx, searchExistingTicketQuery, &query, variables, "")
	if err != nil {
		return nil, err
	}

	if len(query.Issues.Nodes) > 0 {
		return &query.Issues.Nodes[0], nil
	}
	return nil, io.EOF
}

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

func (i *Integration) CloseIssue(event *output.ResultEvent) error {
	// TODO: Unimplemented for now as not used in many places
	// and overhead of maintaining our own API for this.
	// This is too much code as it is :(
	return 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
}

type linearIssue struct {
	ID         graphql.ID
	Title      graphql.String
	Identifier graphql.String
	State      struct {
		Name graphql.String
	}
	URL graphql.String
}

type findExistingIssuesSearch struct {
	Issues struct {
		Nodes []linearIssue
	}
}

// Custom transport to add the API key to the header
type addHeaderTransport struct {
	T   http.RoundTripper
	Key string
}

func (adt *addHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	req.Header.Add("Authorization", adt.Key)
	return adt.T.RoundTrip(req)
}

const (
	linearPriorityNone     = float64(0)
	linearPriorityCritical = float64(1)
	linearPriorityHigh     = float64(2)
	linearPriorityMedium   = float64(3)
	linearPriorityLow      = float64(4)
)

// errors represents the "errors" array in a response from a GraphQL server.
// If returned via error interface, the slice is expected to contain at least 1 element.
//
// Specification: https://spec.graphql.org/October2021/#sec-Errors.
type errorsGraphql []struct {
	Message   string
	Locations []struct {
		Line   int
		Column int
	}
}

// Error implements error interface.
func (e errorsGraphql) Error() string {
	return e[0].Message
}

// do executes a single GraphQL operation.
func (i *Integration) doGraphqlRequest(ctx context.Context, query string, v any, variables map[string]any, operationName string) error {
	in := struct {
		Query         string         `json:"query"`
		Variables     map[string]any `json:"variables,omitempty"`
		OperationName string         `json:"operationName,omitempty"`
	}{
		Query:         query,
		Variables:     variables,
		OperationName: operationName,
	}

	var buf bytes.Buffer
	err := json.NewEncoder(&buf).Encode(in)
	if err != nil {
		return err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, i.url, &buf)
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")
	resp, err := i.httpclient.Do(req)
	if err != nil {
		return err
	}
	defer func() {
		_ = resp.Body.Close()
	}()
	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		return fmt.Errorf("non-200 OK status code: %v body: %q", resp.Status, body)
	}
	var out struct {
		Data   *json.Message
		Errors errorsGraphql
		//Extensions any // Unused.
	}

	err = json.NewDecoder(resp.Body).Decode(&out)
	if err != nil {
		return err
	}
	if out.Data != nil {
		err := jsonutil.UnmarshalGraphQL(*out.Data, v)
		if err != nil {
			return err
		}
	}
	if len(out.Errors) > 0 {
		return out.Errors
	}
	return nil
}
