package processors

import (
	"fmt"
	"go/ast"
	"go/token"
	"sort"
	"strings"

	"github.com/golangci/golangci-lint/pkg/lint/astcache"
	"github.com/golangci/golangci-lint/pkg/lint/lintersdb"
	"github.com/golangci/golangci-lint/pkg/logutils"
	"github.com/golangci/golangci-lint/pkg/result"
)

var nolintDebugf = logutils.Debug("nolint")

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

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 _, linterName := range i.linters {
		if linterName == issue.FromLinter {
			return true
		}
	}

	return false
}

type fileData struct {
	ignoredRanges []ignoredRange
}

type filesCache map[string]*fileData

type Nolint struct {
	cache     filesCache
	astCache  *astcache.Cache
	dbManager *lintersdb.Manager
	log       logutils.Log

	unknownLintersSet map[string]bool
}

func NewNolint(astCache *astcache.Cache, log logutils.Log) *Nolint {
	return &Nolint{
		cache:             filesCache{},
		astCache:          astCache,
		dbManager:         lintersdb.NewManager(), // TODO: get it in constructor
		log:               log,
		unknownLintersSet: map[string]bool{},
	}
}

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)
}

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

	file := p.astCache.GetOrParse(i.FilePath(), nil)
	if file.Err != nil {
		return nil, fmt.Errorf("can't parse file %s: %s", i.FilePath(), file.Err)
	}

	fd.ignoredRanges = p.buildIgnoredRangesForFile(file.F, file.Fset, i.FilePath())
	nolintDebugf("file %s: built nolint ranges are %+v", i.FilePath(), fd.ignoredRanges)
	return fd, nil
}

func (p *Nolint) buildIgnoredRangesForFile(f *ast.File, fset *token.FileSet, filePath string) []ignoredRange {
	inlineRanges := p.extractFileCommentsInlineRanges(fset, f.Comments...)
	nolintDebugf("file %s: inline nolint ranges are %+v", filePath, inlineRanges)

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

	e := rangeExpander{
		fset:         fset,
		inlineRanges: inlineRanges,
	}

	ast.Walk(&e, f)

	// TODO: merge all ranges: there are repeated ranges
	allRanges := append([]ignoredRange{}, inlineRanges...)
	allRanges = append(allRanges, e.expandedRanges...)

	return allRanges
}

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

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

	return true, nil
}

type rangeExpander struct {
	fset           *token.FileSet
	inlineRanges   []ignoredRange
	expandedRanges []ignoredRange
}

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

	nodeStartPos := e.fset.Position(node.Pos())
	nodeStartLine := nodeStartPos.Line
	nodeEndLine := e.fset.Position(node.End()).Line

	var foundRange *ignoredRange
	for _, r := range e.inlineRanges {
		if r.To == nodeStartLine-1 && nodeStartPos.Column == r.col {
			foundRange = &r
			break
		}
	}
	if foundRange == nil {
		return e
	}

	expandedRange := *foundRange
	if expandedRange.To < nodeEndLine {
		expandedRange.To = nodeEndLine
	}
	nolintDebugf("found range is %v for node %#v [%d;%d], expanded range is %v",
		*foundRange, node, nodeStartLine, nodeEndLine, expandedRange)
	e.expandedRanges = append(e.expandedRanges, expandedRange)

	return e
}

func (p *Nolint) 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.ToLower(strings.TrimSpace(linter))
					lc := p.dbManager.GetLinterConfig(linterName)
					if lc == nil {
						p.unknownLintersSet[linterName] = true
						continue
					}
					linters = append(linters, lc.Name()) // normalize name to work with aliases
				}
			} // else ignore all linters
			nolintDebugf("%d: linters are %s", fset.Position(g.Pos()).Line, 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() {
	if len(p.unknownLintersSet) == 0 {
		return
	}

	unknownLinters := []string{}
	for name := range p.unknownLintersSet {
		unknownLinters = append(unknownLinters, name)
	}
	sort.Strings(unknownLinters)

	p.log.Warnf("Found unknown linters in //nolint directives: %s", strings.Join(unknownLinters, ", "))
}
