package processors

import (
	"bufio"
	"bytes"
	"fmt"
	"go/ast"
	"go/parser"
	"go/token"
	"io/ioutil"
	"sort"
	"strings"

	"github.com/golangci/golangci-lint/pkg/result"
)

type ignoredRange struct {
	linters []string
	result.Range
	col int
}

func (i *ignoredRange) isAdjacent(col, start int) bool {
	return col == i.col && i.To == start-1
}

func (i *ignoredRange) doesMatch(issue *result.Issue) bool {
	if issue.Line() < i.From || issue.Line() > i.To {
		return false
	}

	if len(i.linters) == 0 {
		return true
	}

	for _, l := range i.linters {
		if l == issue.FromLinter {
			return true
		}
	}

	return false
}

type fileData struct {
	ignoredRanges []ignoredRange
	isGenerated   bool
}

type filesCache map[string]*fileData

type Nolint struct {
	fset  *token.FileSet
	cache filesCache
}

func NewNolint(fset *token.FileSet) *Nolint {
	return &Nolint{
		fset:  fset,
		cache: filesCache{},
	}
}

var _ Processor = &Nolint{}

func (p Nolint) Name() string {
	return "nolint"
}

func (p *Nolint) Process(issues []result.Issue) ([]result.Issue, error) {
	return filterIssuesErr(issues, p.shouldPassIssue)
}

var (
	genHdr = []byte("// Code generated")
	genFtr = []byte("DO NOT EDIT")
)

// isGenerated reports whether the source file is generated code.
// Using a bit laxer rules than https://golang.org/s/generatedcode to
// match more generated code.
func isGenerated(src []byte) bool {
	sc := bufio.NewScanner(bytes.NewReader(src))
	var hdr, ftr bool
	for sc.Scan() {
		b := sc.Bytes()
		if bytes.HasPrefix(b, genHdr) {
			hdr = true
		}
		if bytes.Contains(b, genFtr) {
			ftr = true
		}
	}
	return hdr && ftr
}

func (p *Nolint) getOrCreateFileData(i *result.Issue) (*fileData, error) {
	fd := p.cache[i.FilePath()]
	if fd != nil {
		return fd, nil
	}

	fd = &fileData{}
	p.cache[i.FilePath()] = fd

	src, err := ioutil.ReadFile(i.FilePath())
	if err != nil {
		return nil, fmt.Errorf("can't read file %s: %s", i.FilePath(), err)
	}

	fd.isGenerated = isGenerated(src)
	if fd.isGenerated { // don't report issues for autogenerated files
		return fd, nil
	}

	file, err := parser.ParseFile(p.fset, i.FilePath(), src, parser.ParseComments)
	if err != nil {
		return nil, fmt.Errorf("can't parse file %s", i.FilePath())
	}

	fd.ignoredRanges = buildIgnoredRangesForFile(file, p.fset)
	return fd, nil
}

func buildIgnoredRangesForFile(f *ast.File, fset *token.FileSet) []ignoredRange {
	inlineRanges := extractFileCommentsInlineRanges(fset, f.Comments...)

	if len(inlineRanges) == 0 {
		return nil
	}

	e := rangeExpander{
		fset:   fset,
		ranges: ignoredRanges(inlineRanges),
	}

	ast.Walk(&e, f)

	return e.ranges
}

func (p *Nolint) shouldPassIssue(i *result.Issue) (bool, error) {
	fd, err := p.getOrCreateFileData(i)
	if err != nil {
		return false, err
	}

	if fd.isGenerated { // don't report issues for autogenerated files
		return false, nil
	}

	for _, ir := range fd.ignoredRanges {
		if ir.doesMatch(i) {
			return false, nil
		}
	}

	return true, nil
}

type ignoredRanges []ignoredRange

func (ir ignoredRanges) Len() int           { return len(ir) }
func (ir ignoredRanges) Swap(i, j int)      { ir[i], ir[j] = ir[j], ir[i] }
func (ir ignoredRanges) Less(i, j int) bool { return ir[i].To < ir[j].To }

type rangeExpander struct {
	fset   *token.FileSet
	ranges ignoredRanges
}

func (e *rangeExpander) Visit(node ast.Node) ast.Visitor {
	if node == nil {
		return e
	}

	startPos := e.fset.Position(node.Pos())
	start := startPos.Line
	end := e.fset.Position(node.End()).Line
	found := sort.Search(len(e.ranges), func(i int) bool {
		return e.ranges[i].To+1 >= start
	})

	if found < len(e.ranges) && e.ranges[found].isAdjacent(startPos.Column, start) {
		r := &e.ranges[found]
		if r.From > start {
			r.From = start
		}
		if r.To < end {
			r.To = end
		}
	}

	return e
}

func extractFileCommentsInlineRanges(fset *token.FileSet, comments ...*ast.CommentGroup) []ignoredRange {
	var ret []ignoredRange
	for _, g := range comments {
		for _, c := range g.List {
			text := strings.TrimLeft(c.Text, "/ ")
			if !strings.HasPrefix(text, "nolint") {
				continue
			}

			var linters []string
			if strings.HasPrefix(text, "nolint:") {
				// ignore specific linters
				text = strings.Split(text, "//")[0] // allow another comment after this comment
				linterItems := strings.Split(strings.TrimPrefix(text, "nolint:"), ",")
				for _, linter := range linterItems {
					linterName := strings.TrimSpace(linter) // TODO: validate it here
					linters = append(linters, linterName)
				}
			} // else ignore all linters

			pos := fset.Position(g.Pos())
			ret = append(ret, ignoredRange{
				Range: result.Range{
					From: pos.Line,
					To:   fset.Position(g.End()).Line,
				},
				col:     pos.Column,
				linters: linters,
			})
		}
	}

	return ret
}

func (p Nolint) Finish() {}