package processors

import (
	"errors"
	"fmt"
	"go/parser"
	"go/token"
	"path/filepath"
	"regexp"
	"strings"

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

const (
	genCodeGenerated = "code generated"
	genDoNotEdit     = "do not edit"
	genAutoFile      = "autogenerated file" // easyjson
)

var _ Processor = &AutogeneratedExclude{}

type fileSummary struct {
	generated bool
}

type AutogeneratedExclude struct {
	debugf logutils.DebugFunc

	strict        bool
	strictPattern *regexp.Regexp

	fileSummaryCache map[string]*fileSummary
}

func NewAutogeneratedExclude(strict bool) *AutogeneratedExclude {
	return &AutogeneratedExclude{
		debugf:           logutils.Debug(logutils.DebugKeyAutogenExclude),
		strict:           strict,
		strictPattern:    regexp.MustCompile(`^// Code generated .* DO NOT EDIT\.$`),
		fileSummaryCache: map[string]*fileSummary{},
	}
}

func (p *AutogeneratedExclude) Name() string {
	return "autogenerated_exclude"
}

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

func (p *AutogeneratedExclude) Finish() {}

func (p *AutogeneratedExclude) shouldPassIssue(issue *result.Issue) (bool, error) {
	if issue.FromLinter == "typecheck" {
		// don't hide typechecking errors in generated files: users expect to see why the project isn't compiling
		return true, nil
	}

	if filepath.Base(issue.FilePath()) == "go.mod" {
		return true, nil
	}

	if issue.FilePath() == "" {
		return false, errors.New("no file path for issue")
	}

	if !isGoFile(issue.FilePath()) {
		return false, nil
	}

	// The file is already known.
	fs := p.fileSummaryCache[issue.FilePath()]
	if fs != nil {
		return !fs.generated, nil
	}

	fs = &fileSummary{}
	p.fileSummaryCache[issue.FilePath()] = fs

	if p.strict {
		var err error
		fs.generated, err = p.isGeneratedFileStrict(issue.FilePath())
		if err != nil {
			return false, fmt.Errorf("failed to get doc (strict) of file %s: %w", issue.FilePath(), err)
		}
	} else {
		doc, err := getComments(issue.FilePath())
		if err != nil {
			return false, fmt.Errorf("failed to get doc (lax) of file %s: %w", issue.FilePath(), err)
		}

		fs.generated = p.isGeneratedFileLax(doc)
	}

	p.debugf("file %q is generated: %t", issue.FilePath(), fs.generated)

	// don't report issues for autogenerated files
	return !fs.generated, nil
}

// isGeneratedFileLax reports whether the source file is generated code.
// The function uses a bit laxer rules than isGeneratedFileStrict to match more generated code.
// See https://github.com/golangci/golangci-lint/issues/48 and https://github.com/golangci/golangci-lint/issues/72.
func (p *AutogeneratedExclude) isGeneratedFileLax(doc string) bool {
	markers := []string{genCodeGenerated, genDoNotEdit, genAutoFile}

	doc = strings.ToLower(doc)

	for _, marker := range markers {
		if strings.Contains(doc, marker) {
			p.debugf("doc contains marker %q: file is generated", marker)

			return true
		}
	}

	p.debugf("doc of len %d doesn't contain any of markers: %s", len(doc), markers)

	return false
}

// isGeneratedFileStrict returns true if the source file has a line that matches the regular expression:
//
//	^// Code generated .* DO NOT EDIT\.$
//
// This line must appear before the first non-comment, non-blank text in the file.
// Based on https://go.dev/s/generatedcode.
func (p *AutogeneratedExclude) isGeneratedFileStrict(filePath string) (bool, error) {
	file, err := parser.ParseFile(token.NewFileSet(), filePath, nil, parser.PackageClauseOnly|parser.ParseComments)
	if err != nil {
		return false, fmt.Errorf("failed to parse file: %w", err)
	}

	if file == nil || len(file.Comments) == 0 {
		return false, nil
	}

	for _, comment := range file.Comments {
		if comment.Pos() > file.Package {
			return false, nil
		}

		for _, line := range comment.List {
			generated := p.strictPattern.MatchString(line.Text)
			if generated {
				p.debugf("doc contains ignore expression: file is generated")

				return true, nil
			}
		}
	}

	return false, nil
}

func getComments(filePath string) (string, error) {
	fset := token.NewFileSet()
	syntax, err := parser.ParseFile(fset, filePath, nil, parser.PackageClauseOnly|parser.ParseComments)
	if err != nil {
		return "", fmt.Errorf("failed to parse file: %w", err)
	}

	var docLines []string
	for _, c := range syntax.Comments {
		docLines = append(docLines, strings.TrimSpace(c.Text()))
	}

	return strings.Join(docLines, "\n"), nil
}

func isGoFile(name string) bool {
	return filepath.Ext(name) == ".go"
}