/*
 * Copyright 2021-2024 JetBrains s.r.o.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package platform

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"github.com/JetBrains/qodana-cli/internal/platform/msg"
	"github.com/JetBrains/qodana-cli/internal/platform/qdenv"
	"github.com/JetBrains/qodana-cli/internal/platform/thirdpartyscan"
	"github.com/JetBrains/qodana-cli/internal/sarif"
	"github.com/google/uuid"
	bbapi "github.com/reviewdog/go-bitbucket"
	log "github.com/sirupsen/logrus"
)

// https://www.jetbrains.com/help/qodana/qodana-sarif-output.html
const (
	baselineStateEmpty     = ""          // baselineStateEmpty default baseline state (not set)
	baselineStateNew       = "new"       // baselineStateNew new baseline state
	baselineStateUnchanged = "unchanged" // baselineStateUnchanged unchanged baseline state
	extension              = ".sarif.json"
	qodanaCritical         = "Critical"
	qodanaHigh             = "High"
	qodanaModerate         = "Moderate"
	qodanaLow              = "Low"
	qodanaInfo             = "Info"
	sarifError             = "error"
	sarifWarning           = "warning"
	sarifNote              = "note"
)

func MergeSarifReports(c thirdpartyscan.Context, deviceId string) (int, error) {
	tmpResultsDir := GetTmpResultsDir(c.ResultsDir())
	files, err := findSarifFiles(tmpResultsDir)
	sort.Strings(files)
	if err != nil {
		return 0, fmt.Errorf("error locating SARIF files: %s", err)
	}

	if len(files) == 0 {
		return 0, fmt.Errorf("no SARIF files (file names ending with .sarif.json) found in %s", tmpResultsDir)
	}

	ch := make(chan *sarif.Report)
	go collectReports(files, ch)
	finalReport, err := mergeReports(ch)
	if err != nil || finalReport == nil {
		return 0, fmt.Errorf("error merging SARIF files: %s", err)
	}

	for _, result := range finalReport.Runs[0].Results {
		// update locations[].physicalLocation.artifactLocation.uri by removing the projectDir prefix
		for _, location := range result.Locations {
			if (location.PhysicalLocation == nil) || (location.PhysicalLocation.ArtifactLocation == nil) {
				continue
			}
			toReplace := c.ProjectDir()
			if !strings.HasSuffix(toReplace, string(os.PathSeparator)) {
				toReplace += string(os.PathSeparator)
			}
			location.PhysicalLocation.ArtifactLocation.Uri = strings.TrimPrefix(
				location.PhysicalLocation.ArtifactLocation.Uri,
				toReplace,
			)
		}
	}
	finalReport.Runs[0].Results = removeDuplicates(finalReport.Runs[0].Results)

	SetVersionControlParams(c, deviceId, finalReport)

	totalProblems := len(finalReport.Runs[0].Results)

	err = WriteReport(GetSarifPath(c.ResultsDir()), finalReport)
	if err != nil {
		return 0, err
	}
	return totalProblems, nil
}

func removeDuplicates(results []sarif.Result) []sarif.Result {
	if len(results) == 0 {
		return results
	}
	seen := make(map[string]struct{}, len(results))
	writeIndex := 0

	for _, result := range results {
		if result.PartialFingerprints != nil {
			fingerPrint := getFingerprint(&result)
			if fingerPrint != "" {
				if _, exists := seen[fingerPrint]; exists {
					continue
				}
				seen[fingerPrint] = struct{}{}
			}
		}
		results[writeIndex] = result
		writeIndex++
	}

	if len(results) != writeIndex {
		log.Warnf("Removed duplicates: %d", len(results)-writeIndex)
	}

	return results[:writeIndex]
}

func WriteReport(path string, finalReport *sarif.Report) error {
	// serialize object skipping empty fields
	fatBytes, err := json.MarshalIndent(finalReport, "", " ")
	if err != nil {
		return fmt.Errorf("error marshalling report: %s", err)
	}

	f, err := os.Create(path)
	if err != nil {
		return fmt.Errorf("error creating resulting SARIF file: %s", err)
	}

	defer func(f *os.File) {
		err := f.Close()
		if err != nil {
			fmt.Printf("error closing resulting SARIF file: %s\n", err)
		}
	}(f)

	_, err = f.Write(fatBytes)
	if err != nil {
		return fmt.Errorf("error writing resulting SARIF file: %s", err)
	}
	return nil
}

func MakeShortSarif(sarifPath string, shortSarifPath string) error {
	report, err := ReadReport(sarifPath)
	if err != nil {
		return err
	}

	if len(report.Runs) == 0 {
		return fmt.Errorf("error reading SARIF %s: no runs found", sarifPath)
	}
	report.Runs[0].Tool.Extensions = []sarif.ToolComponent{}
	report.Runs[0].Tool.Driver.Taxa = []sarif.ReportingDescriptor{}
	report.Runs[0].Tool.Driver.Rules = []sarif.ReportingDescriptor{}
	report.Runs[0].Results = []sarif.Result{}
	report.Runs[0].Artifacts = []sarif.Artifact{}
	return WriteReport(shortSarifPath, report)
}

func SetVersionControlParams(c thirdpartyscan.Context, deviceId string, finalReport *sarif.Report) {
	linterInfo := c.LinterInfo()
	vcd, err := GetVersionDetails(c.ProjectDir())
	if err != nil {
		log.Errorf("Error getting version control details: %s. Project is probably outside of the Git VCS.", err)
	} else {
		finalReport.Runs[0].VersionControlProvenance = make([]sarif.VersionControlDetails, 0)
		finalReport.Runs[0].VersionControlProvenance = append(finalReport.Runs[0].VersionControlProvenance, vcd)
	}

	if deviceId != "" {
		finalReport.Runs[0].Properties = &sarif.PropertyBag{}
		finalReport.Runs[0].Properties.AdditionalProperties = map[string]interface{}{
			"deviceId": deviceId,
		}
	}

	if linterInfo.ProductCode != "" {
		finalReport.Runs[0].Tool.Driver.Name = linterInfo.ProductCode
	}
	if linterInfo.LinterPresentableName != "" {
		finalReport.Runs[0].Tool.Driver.FullName = linterInfo.LinterPresentableName
	}
	if linterInfo.LinterVersion != "" {
		finalReport.Runs[0].Tool.Driver.Version = linterInfo.LinterVersion
	}

	finalReport.Runs[0].AutomationDetails = &sarif.RunAutomationDetails{
		Guid: RunGUID(),
		Id:   ReportId(linterInfo.ProductCode),
		Properties: &sarif.PropertyBag{
			AdditionalProperties: map[string]interface{}{
				"jobUrl": JobUrl(),
			},
		},
	}
}

func findSarifFiles(root string) ([]string, error) {
	var files []string
	err := filepath.Walk(
		root, func(path string, info os.FileInfo, err error) error {
			if err != nil {
				return err
			}
			if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), extension) {
				files = append(files, path)
			}
			return nil
		},
	)
	if err != nil {
		return nil, err
	}
	return files, nil
}

func collectReports(files []string, ch chan<- *sarif.Report) {
	for _, file := range files {
		r, err := ReadReport(file)
		if err != nil {
			fmt.Printf("Error reading SARIF %s: %s\n", file, err)
			continue
		}
		ch <- r
	}
	close(ch)
}

func ReadReport(file string) (*sarif.Report, error) {
	f, err := os.Open(file)
	if err != nil {
		return nil, err
	}
	defer func(f *os.File) {
		err := f.Close()
		if err != nil {
			fmt.Printf("Error closing SARIF file %s: %s\n", file, err)
		}
	}(f)

	dec := json.NewDecoder(f)
	var r sarif.Report
	if err := dec.Decode(&r); err != nil {
		return nil, err
	}

	return &r, nil
}

func ReadReportFromString(sarifStr string) (*sarif.Report, error) {
	var r sarif.Report
	if err := json.Unmarshal([]byte(sarifStr), &r); err != nil {
		return nil, err
	}

	return &r, nil
}

func mergeReports(ch <-chan *sarif.Report) (*sarif.Report, error) {
	var finalReport *sarif.Report

	for r := range ch {
		if finalReport == nil {
			// For the first file, keep the toolDesc configuration and initialize the 'Runs' slice
			finalReport = &sarif.Report{
				Schema:  r.Schema,
				Version: r.Version,
				Runs:    make([]sarif.Run, 0, len(r.Runs)),
			}
			finalReport.Runs = append(finalReport.Runs, r.Runs[0])
			finalReport.Runs[0].Results = r.Runs[0].Results
			finalReport.Runs[0].Tool = r.Runs[0].Tool
			continue
		}

		// Append results from each report into the 'Results' slice of the first run of the final report
		for _, run := range r.Runs {
			finalReport.Runs[0].Results = append(finalReport.Runs[0].Results, run.Results...)
			finalReport.Runs[0].Artifacts = append(finalReport.Runs[0].Artifacts, run.Artifacts...)
		}
	}

	return finalReport, nil
}

func RunGUID() string {
	runGUID := os.Getenv("QODANA_AUTOMATION_GUID")
	if runGUID == "" {
		runGUID = uuid.New().String()
	}
	return runGUID
}

func ReportId(projectName string) string {
	reportId := os.Getenv("QODANA_REPORT_ID")
	if reportId != "" {
		return reportId
	}

	projectId := os.Getenv("QODANA_PROJECT_ID")
	if projectId == "" {
		projectId = projectName
	}

	date := time.Now().Format("2006-01-02")
	tool := "qodana"

	return projectId + "/" + tool + "/" + date
}

func JobUrl() string {
	return os.Getenv("QODANA_JOB_URL")
}

func getRuleDescription(report *sarif.Report, ruleId string) string {
	for _, run := range report.Runs {
		for _, extension := range run.Tool.Extensions {
			for _, rule := range extension.Rules {
				if rule.Id == ruleId {
					return rule.ShortDescription.Text
				}
			}
		}
	}
	return ""
}

// ProcessSarif concludes the result of analysis based on provided SARIF file
// - can print problems to the output
// - can create GitLab CodeQuality issues report
// - can submit problems to BitBucket Code Insights
func ProcessSarif(sarifPath, analysisId, reportUrl string, printProblems, codeClimate, codeInsights bool) {
	newProblems := 0
	s, err := ReadReport(sarifPath)
	if err != nil {
		log.Fatal(err)
	}
	var codeClimateIssues = make([]CCIssue, 0)
	var codeInsightIssues = make([]bbapi.ReportAnnotation, 0)
	rulesDescriptions := make(map[string]string)
	if printProblems {
		msg.EmptyMessage()
	}
	for _, run := range s.Runs {
		for _, r := range run.Results {
			ruleId := r.RuleId
			message := r.Message.Text
			baselineState := baselineStateEmpty
			if r.BaselineState != nil {
				baselineState = r.BaselineState.(string)
			}
			if baselineState == baselineStateNew || baselineState == baselineStateEmpty {
				newProblems++
			}
			if len(r.Locations) > 0 && baselineState != baselineStateUnchanged {
				if codeClimate {
					codeClimateIssues = append(codeClimateIssues, sarifResultToCodeClimate(&r))
				}
				if codeInsights {
					ruleDescription, ok := rulesDescriptions[ruleId]
					if !ok {
						ruleDescription = getRuleDescription(s, ruleId)
						rulesDescriptions[ruleId] = ruleDescription
					}
					codeInsightIssues = append(codeInsightIssues, buildAnnotation(&r, ruleDescription, reportUrl))
				}
				if printProblems {
					err = printSarifProblem(&r, ruleId, message)
					if err != nil {
						log.Debugf("Failed to print result: %s", err)
					}
				}
			}
		}
	}
	if codeClimate {
		err = writeGlCodeQualityReport(codeClimateIssues, sarifPath)
		if err != nil {
			log.Warnf("Problems writing GitLab CodeQuality report: %v", err)
		}
	}
	if codeInsights {
		err = sendBitBucketReport(codeInsightIssues, s.Runs[0].Tool.Driver.FullName, reportUrl, "qodana-"+analysisId)
		if err != nil {
			log.Warnf("Problems sending BitBucket Code Insights report: %v", err)
		}
	}
	if !qdenv.IsContainer() {
		if newProblems == 0 {
			msg.SuccessMessage(msg.GetProblemsFoundMessage(0))
		} else {
			msg.ErrorMessage(msg.GetProblemsFoundMessage(newProblems))
		}
	}
}

func printSarifProblem(r *sarif.Result, ruleId, message string) error {
	if r == nil {
		return fmt.Errorf("r must not be nil")
	}

	path := ""
	line := 0
	column := 0
	contextLine := 0
	contextText := ""

	if len(r.Locations) > 0 && r.Locations[0].PhysicalLocation != nil {
		location := r.Locations[0].PhysicalLocation

		if location.ArtifactLocation != nil {
			path = location.ArtifactLocation.Uri
		}

		if location.Region != nil {
			line = int(location.Region.StartLine)
			column = int(location.Region.StartColumn)
		}

		if location.ContextRegion != nil {
			contextLine = int(location.ContextRegion.StartLine)

			if location.ContextRegion.Snippet != nil {
				contextText = location.ContextRegion.Snippet.Text
			}
		}
	}

	msg.PrintProblem(ruleId, getSeverity(r), message, path, line, column, contextLine, contextText)
	return nil
}

// getFingerprint returns the fingerprint of the Qodana (or not) SARIF result.
func getFingerprint(r *sarif.Result) string {
	if r != nil && r.PartialFingerprints != nil {
		fingerprint, ok := r.PartialFingerprints["equalIndicator/v2"]
		if ok {
			return fingerprint
		}
		fingerprint, ok = r.PartialFingerprints["equalIndicator/v1"]
		if ok {
			return fingerprint
		}
	}
	log.Fatalf("failed to get fingerprint from result: %v", r)
	return ""
}

// getSeverity returns the severity of the Qodana (or not) SARIF result.
func getSeverity(r *sarif.Result) string {
	if r.Properties != nil && r.Properties.AdditionalProperties != nil {
		severity, ok := r.Properties.AdditionalProperties["qodanaSeverity"].(string)
		if ok {
			return severity
		}
	}
	if r.Level != nil {
		log.Debug("failed to get severity from properties, using sarif level")
		return r.Level.(string)
	}
	return sarifNote
}
