package lint

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"sort"

	"github.com/moby/buildkit/client/llb"
	"github.com/moby/buildkit/frontend/dockerfile/parser"
	"github.com/moby/buildkit/frontend/gateway/client"
	"github.com/moby/buildkit/frontend/subrequests"
	"github.com/moby/buildkit/solver/errdefs"
	"github.com/moby/buildkit/solver/pb"
	"github.com/pkg/errors"
)

const RequestLint = "frontend.lint"

var SubrequestLintDefinition = subrequests.Request{
	Name:        RequestLint,
	Version:     "1.0.0",
	Type:        subrequests.TypeRPC,
	Description: "Lint a Dockerfile",
	Opts:        []subrequests.Named{},
	Metadata: []subrequests.Named{
		{Name: "result.json"},
		{Name: "result.txt"},
		{Name: "result.statuscode"},
	},
}

type Warning struct {
	RuleName    string      `json:"ruleName"`
	Description string      `json:"description,omitempty"`
	URL         string      `json:"url,omitempty"`
	Detail      string      `json:"detail,omitempty"`
	Location    pb.Location `json:"location,omitempty"`
}

type BuildError struct {
	Message  string      `json:"message"`
	Location pb.Location `json:"location"`
}

type LintResults struct {
	Warnings []Warning        `json:"warnings"`
	Sources  []*pb.SourceInfo `json:"sources"`
	Error    *BuildError      `json:"buildError,omitempty"`
}

func (results *LintResults) AddSource(sourceMap *llb.SourceMap) int {
	newSource := &pb.SourceInfo{
		Filename:   sourceMap.Filename,
		Language:   sourceMap.Language,
		Definition: sourceMap.Definition.ToPB(),
		Data:       sourceMap.Data,
	}
	for i, source := range results.Sources {
		if sourceInfoEqual(source, newSource) {
			return i
		}
	}
	results.Sources = append(results.Sources, newSource)
	return len(results.Sources) - 1
}

func (results *LintResults) AddWarning(rulename, description, url, fmtmsg string, sourceIndex int, location []parser.Range) {
	sourceLocation := []*pb.Range{}
	for _, loc := range location {
		sourceLocation = append(sourceLocation, &pb.Range{
			Start: pb.Position{
				Line:      int32(loc.Start.Line),
				Character: int32(loc.Start.Character),
			},
			End: pb.Position{
				Line:      int32(loc.End.Line),
				Character: int32(loc.End.Character),
			},
		})
	}
	pbLocation := pb.Location{
		SourceIndex: int32(sourceIndex),
		Ranges:      sourceLocation,
	}
	results.Warnings = append(results.Warnings, Warning{
		RuleName:    rulename,
		Description: description,
		URL:         url,
		Detail:      fmtmsg,
		Location:    pbLocation,
	})
}

func (results *LintResults) ToResult() (*client.Result, error) {
	res := client.NewResult()
	dt, err := json.MarshalIndent(results, "", "  ")
	if err != nil {
		return nil, err
	}
	res.AddMeta("result.json", dt)

	b := bytes.NewBuffer(nil)
	if err := PrintLintViolations(dt, b); err != nil {
		return nil, err
	}
	res.AddMeta("result.txt", b.Bytes())

	status := 0
	if len(results.Warnings) > 0 || results.Error != nil {
		status = 1
	}
	res.AddMeta("result.statuscode", []byte(fmt.Sprintf("%d", status)))

	res.AddMeta("version", []byte(SubrequestLintDefinition.Version))
	return res, nil
}

func (results *LintResults) validateWarnings() error {
	for _, warning := range results.Warnings {
		if int(warning.Location.SourceIndex) >= len(results.Sources) {
			return errors.Errorf("sourceIndex is out of range")
		}
		if warning.Location.SourceIndex > 0 {
			warningSource := results.Sources[warning.Location.SourceIndex]
			if warningSource == nil {
				return errors.Errorf("sourceIndex points to nil source")
			}
		}
	}
	return nil
}

func PrintLintViolations(dt []byte, w io.Writer) error {
	var results LintResults

	if err := json.Unmarshal(dt, &results); err != nil {
		return err
	}

	if err := results.validateWarnings(); err != nil {
		return err
	}

	sort.Slice(results.Warnings, func(i, j int) bool {
		warningI := results.Warnings[i]
		warningJ := results.Warnings[j]
		sourceIndexI := warningI.Location.SourceIndex
		sourceIndexJ := warningJ.Location.SourceIndex
		if sourceIndexI < 0 && sourceIndexJ < 0 {
			return warningI.RuleName < warningJ.RuleName
		} else if sourceIndexI < 0 || sourceIndexJ < 0 {
			return sourceIndexI < 0
		}

		sourceInfoI := results.Sources[warningI.Location.SourceIndex]
		sourceInfoJ := results.Sources[warningJ.Location.SourceIndex]
		if sourceInfoI.Filename != sourceInfoJ.Filename {
			return sourceInfoI.Filename < sourceInfoJ.Filename
		}
		if len(warningI.Location.Ranges) == 0 && len(warningJ.Location.Ranges) == 0 {
			return warningI.RuleName < warningJ.RuleName
		} else if len(warningI.Location.Ranges) == 0 || len(warningJ.Location.Ranges) == 0 {
			return len(warningI.Location.Ranges) == 0
		}

		return warningI.Location.Ranges[0].Start.Line < warningJ.Location.Ranges[0].Start.Line
	})

	for _, warning := range results.Warnings {
		fmt.Fprintf(w, "\nWARNING: %s", warning.RuleName)
		if warning.URL != "" {
			fmt.Fprintf(w, " - %s", warning.URL)
		}
		fmt.Fprintf(w, "\n%s\n", warning.Detail)

		if warning.Location.SourceIndex < 0 {
			continue
		}
		sourceInfo := results.Sources[warning.Location.SourceIndex]
		source := errdefs.Source{
			Info:   sourceInfo,
			Ranges: warning.Location.Ranges,
		}
		err := source.Print(w)
		if err != nil {
			return err
		}
	}

	return nil
}

func sourceInfoEqual(a, b *pb.SourceInfo) bool {
	if a.Filename != b.Filename || a.Language != b.Language {
		return false
	}
	return bytes.Equal(a.Data, b.Data)
}
