package protogetter

import (
	"bytes"
	"flag"
	"fmt"
	"go/ast"
	"go/format"
	"go/token"
	"log"
	"path/filepath"
	"strings"

	"github.com/gobwas/glob"
	"golang.org/x/tools/go/analysis"
	"golang.org/x/tools/go/ast/inspector"
)

const msgFormat = "avoid direct access to proto field %s, use %s instead"

func NewAnalyzer(cfg *Config) *analysis.Analyzer {
	if cfg == nil {
		cfg = &Config{}
	}

	return &analysis.Analyzer{
		Name:  "protogetter",
		Doc:   "Reports direct reads from proto message fields when getters should be used",
		Flags: flags(cfg),
		Run: func(pass *analysis.Pass) (any, error) {
			err := Run(pass, cfg)
			return nil, err
		},
	}
}

func flags(opts *Config) flag.FlagSet {
	fs := flag.NewFlagSet("protogetter", flag.ContinueOnError)

	fs.Func("skip-generated-by", "skip files generated with the given prefixes", func(s string) error {
		for _, prefix := range strings.Split(s, ",") {
			opts.SkipGeneratedBy = append(opts.SkipGeneratedBy, prefix)
		}
		return nil
	})
	fs.Func("skip-files", "skip files with the given glob patterns", func(s string) error {
		for _, pattern := range strings.Split(s, ",") {
			opts.SkipFiles = append(opts.SkipFiles, pattern)
		}
		return nil
	})
	fs.BoolVar(&opts.SkipAnyGenerated, "skip-any-generated", false, "skip any generated files")

	return *fs
}

type Config struct {
	SkipGeneratedBy         []string
	SkipFiles               []string
	SkipAnyGenerated        bool
	ReplaceFirstArgInAppend bool
}

func Run(pass *analysis.Pass, cfg *Config) error {
	skipGeneratedBy := make([]string, 0, len(cfg.SkipGeneratedBy)+3)
	// Always skip files generated by protoc-gen-go, protoc-gen-go-grpc and protoc-gen-grpc-gateway.
	skipGeneratedBy = append(skipGeneratedBy, "protoc-gen-go", "protoc-gen-go-grpc", "protoc-gen-grpc-gateway")
	for _, s := range cfg.SkipGeneratedBy {
		s = strings.TrimSpace(s)
		if s == "" {
			continue
		}
		skipGeneratedBy = append(skipGeneratedBy, s)
	}

	skipFilesGlobPatterns := make([]glob.Glob, 0, len(cfg.SkipFiles))
	for _, s := range cfg.SkipFiles {
		s = strings.TrimSpace(s)
		if s == "" {
			continue
		}

		compile, err := glob.Compile(s)
		if err != nil {
			return fmt.Errorf("invalid glob pattern: %w", err)
		}

		skipFilesGlobPatterns = append(skipFilesGlobPatterns, compile)
	}

	nodeTypes := []ast.Node{
		(*ast.AssignStmt)(nil),
		(*ast.BinaryExpr)(nil),
		(*ast.CallExpr)(nil),
		(*ast.SelectorExpr)(nil),
		(*ast.StarExpr)(nil),
		(*ast.IncDecStmt)(nil),
		(*ast.UnaryExpr)(nil),
		(*ast.KeyValueExpr)(nil),
		(*ast.DeclStmt)(nil),
		(*ast.ReturnStmt)(nil),
	}

	// Skip filtered files.
	var files []*ast.File
	for _, f := range pass.Files {
		if skipGeneratedFile(f, skipGeneratedBy, cfg.SkipAnyGenerated) {
			continue
		}

		if skipFilesByGlob(pass.Fset.File(f.Pos()).Name(), skipFilesGlobPatterns) {
			continue
		}

		files = append(files, f)

		// ast.Print(pass.Fset, f)
	}

	ins := inspector.New(files)

	filter := NewPosFilter()
	ins.Preorder(nodeTypes, func(node ast.Node) {
		report := analyse(pass, filter, node, cfg)
		if report == nil {
			return
		}
		pass.Report(report.ToDiagReport())
	})

	return nil
}

func analyse(pass *analysis.Pass, filter *PosFilter, n ast.Node, cfg *Config) *Report {
	// fmt.Printf("\n>>> check: %s\n", formatNode(n))
	// ast.Print(pass.Fset, n)
	if filter.IsFiltered(n.Pos()) {
		// fmt.Printf(">>> filtered\n")
		return nil
	}

	result, err := Process(pass.TypesInfo, filter, n, cfg)
	if err != nil {
		pass.Report(analysis.Diagnostic{
			Pos:     n.Pos(),
			End:     n.End(),
			Message: fmt.Sprintf("error: %v", err),
		})

		return nil
	}

	// If existing in filter, skip it.
	if filter.IsFiltered(n.Pos()) {
		return nil
	}

	if result.Skipped() {
		return nil
	}

	// If the expression has already been replaced, skip it.
	if filter.IsAlreadyReplaced(pass.Fset, n.Pos(), n.End()) {
		return nil
	}
	// Add the expression to the filter.
	filter.AddAlreadyReplaced(pass.Fset, n.Pos(), n.End())

	return &Report{
		node:   n,
		result: result,
	}
}

type Report struct {
	node   ast.Node
	result *Result
}

func (r *Report) ToDiagReport() analysis.Diagnostic {
	msg := fmt.Sprintf(msgFormat, r.result.From, r.result.To)

	return analysis.Diagnostic{
		Pos:     r.node.Pos(),
		End:     r.node.End(),
		Message: msg,
		SuggestedFixes: []analysis.SuggestedFix{
			{
				Message: msg,
				TextEdits: []analysis.TextEdit{
					{
						Pos:     r.node.Pos(),
						End:     r.node.End(),
						NewText: []byte(r.result.To),
					},
				},
			},
		},
	}
}

func skipGeneratedFile(f *ast.File, prefixes []string, skipAny bool) bool {
	if len(f.Comments) == 0 {
		return false
	}
	firstComment := f.Comments[0].Text()

	if skipAny && ast.IsGenerated(f) {
		return true
	}

	for _, prefix := range prefixes {
		if strings.HasPrefix(firstComment, "Code generated by "+prefix) {
			return true
		}
	}

	return false
}

func skipFilesByGlob(filename string, patterns []glob.Glob) bool {
	for _, pattern := range patterns {
		if pattern.Match(filename) || pattern.Match(filepath.Base(filename)) {
			return true
		}
	}

	return false
}

func formatNode(node ast.Node) string {
	buf := new(bytes.Buffer)
	if err := format.Node(buf, token.NewFileSet(), node); err != nil {
		log.Printf("Error formatting expression: %v", err)
		return ""
	}

	return buf.String()
}
