package report

import (
	"bytes"
	"fmt"
	"go/ast"
	"go/format"
	"go/token"
	"go/version"
	"path/filepath"
	"strconv"
	"strings"

	"honnef.co/go/tools/analysis/code"
	"honnef.co/go/tools/analysis/facts/generated"
	"honnef.co/go/tools/go/ast/astutil"

	"golang.org/x/tools/go/analysis"
)

type Options struct {
	ShortRange             bool
	FilterGenerated        bool
	Fixes                  []analysis.SuggestedFix
	Related                []analysis.RelatedInformation
	MinimumLanguageVersion string
	MaximumLanguageVersion string
	MinimumStdlibVersion   string
	MaximumStdlibVersion   string
}

type Option func(*Options)

func ShortRange() Option {
	return func(opts *Options) {
		opts.ShortRange = true
	}
}

func FilterGenerated() Option {
	return func(opts *Options) {
		opts.FilterGenerated = true
	}
}

func Fixes(fixes ...analysis.SuggestedFix) Option {
	return func(opts *Options) {
		opts.Fixes = append(opts.Fixes, fixes...)
	}
}

func Related(node Positioner, message string) Option {
	return func(opts *Options) {
		pos, end, ok := getRange(node, opts.ShortRange)
		if !ok {
			return
		}
		r := analysis.RelatedInformation{
			Pos:     pos,
			End:     end,
			Message: message,
		}
		opts.Related = append(opts.Related, r)
	}
}

func MinimumLanguageVersion(vers string) Option {
	return func(opts *Options) { opts.MinimumLanguageVersion = vers }
}
func MaximumLanguageVersion(vers string) Option {
	return func(opts *Options) { opts.MinimumLanguageVersion = vers }
}
func MinimumStdlibVersion(vers string) Option {
	return func(opts *Options) { opts.MinimumStdlibVersion = vers }
}
func MaximumStdlibVersion(vers string) Option {
	return func(opts *Options) { opts.MaximumStdlibVersion = vers }
}

type Positioner interface {
	Pos() token.Pos
}

type fullPositioner interface {
	Pos() token.Pos
	End() token.Pos
}

type sourcer interface {
	Source() ast.Node
}

// shortRange returns the position and end of the main component of an
// AST node. For nodes that have no body, the short range is identical
// to the node's Pos and End. For nodes that do have a body, the short
// range excludes the body.
func shortRange(node ast.Node) (pos, end token.Pos) {
	switch node := node.(type) {
	case *ast.File:
		return node.Pos(), node.Name.End()
	case *ast.CaseClause:
		return node.Pos(), node.Colon + 1
	case *ast.CommClause:
		return node.Pos(), node.Colon + 1
	case *ast.DeferStmt:
		return node.Pos(), node.Defer + token.Pos(len("defer"))
	case *ast.ExprStmt:
		return shortRange(node.X)
	case *ast.ForStmt:
		if node.Post != nil {
			return node.For, node.Post.End()
		} else if node.Cond != nil {
			return node.For, node.Cond.End()
		} else if node.Init != nil {
			// +1 to catch the semicolon, for gofmt'ed code
			return node.Pos(), node.Init.End() + 1
		} else {
			return node.Pos(), node.For + token.Pos(len("for"))
		}
	case *ast.FuncDecl:
		return node.Pos(), node.Type.End()
	case *ast.FuncLit:
		return node.Pos(), node.Type.End()
	case *ast.GoStmt:
		if _, ok := astutil.Unparen(node.Call.Fun).(*ast.FuncLit); ok {
			return node.Pos(), node.Go + token.Pos(len("go"))
		} else {
			return node.Pos(), node.End()
		}
	case *ast.IfStmt:
		return node.Pos(), node.Cond.End()
	case *ast.RangeStmt:
		return node.Pos(), node.X.End()
	case *ast.SelectStmt:
		return node.Pos(), node.Pos() + token.Pos(len("select"))
	case *ast.SwitchStmt:
		if node.Tag != nil {
			return node.Pos(), node.Tag.End()
		} else if node.Init != nil {
			// +1 to catch the semicolon, for gofmt'ed code
			return node.Pos(), node.Init.End() + 1
		} else {
			return node.Pos(), node.Pos() + token.Pos(len("switch"))
		}
	case *ast.TypeSwitchStmt:
		return node.Pos(), node.Assign.End()
	default:
		return node.Pos(), node.End()
	}
}

func HasRange(node Positioner) bool {
	// we don't know if getRange will be called with shortRange set to
	// true, so make sure that both work.
	_, _, ok := getRange(node, false)
	if !ok {
		return false
	}
	_, _, ok = getRange(node, true)
	return ok
}

func getRange(node Positioner, short bool) (pos, end token.Pos, ok bool) {
	switch n := node.(type) {
	case sourcer:
		s := n.Source()
		if s == nil {
			return 0, 0, false
		}
		if short {
			p, e := shortRange(s)
			return p, e, true
		}
		return s.Pos(), s.End(), true
	case fullPositioner:
		if short {
			p, e := shortRange(n)
			return p, e, true
		}
		return n.Pos(), n.End(), true
	default:
		return n.Pos(), token.NoPos, true
	}
}

func Report(pass *analysis.Pass, node Positioner, message string, opts ...Option) {
	cfg := &Options{}
	for _, opt := range opts {
		opt(cfg)
	}

	langVersion := code.LanguageVersion(pass, node)
	stdlibVersion := code.StdlibVersion(pass, node)
	if n := cfg.MaximumLanguageVersion; n != "" && version.Compare(n, langVersion) == -1 {
		return
	}
	if n := cfg.MaximumStdlibVersion; n != "" && version.Compare(n, stdlibVersion) == -1 {
		return
	}
	if n := cfg.MinimumLanguageVersion; n != "" && version.Compare(n, langVersion) == 1 {
		return
	}
	if n := cfg.MinimumStdlibVersion; n != "" && version.Compare(n, stdlibVersion) == 1 {
		return
	}

	file := DisplayPosition(pass.Fset, node.Pos()).Filename
	if cfg.FilterGenerated {
		m := pass.ResultOf[generated.Analyzer].(map[string]generated.Generator)
		if _, ok := m[file]; ok {
			return
		}
	}

	pos, end, ok := getRange(node, cfg.ShortRange)
	if !ok {
		panic(fmt.Sprintf("no valid position for reporting node %v", node))
	}
	d := analysis.Diagnostic{
		Pos:            pos,
		End:            end,
		Message:        message,
		SuggestedFixes: cfg.Fixes,
		Related:        cfg.Related,
	}
	pass.Report(d)
}

func Render(pass *analysis.Pass, x any) string {
	var buf bytes.Buffer
	if err := format.Node(&buf, pass.Fset, x); err != nil {
		panic(err)
	}
	return buf.String()
}

func RenderArgs(pass *analysis.Pass, args []ast.Expr) string {
	var ss []string
	for _, arg := range args {
		ss = append(ss, Render(pass, arg))
	}
	return strings.Join(ss, ", ")
}

func DisplayPosition(fset *token.FileSet, p token.Pos) token.Position {
	if p == token.NoPos {
		return token.Position{}
	}

	// Only use the adjusted position if it points to another Go file.
	// This means we'll point to the original file for cgo files, but
	// we won't point to a YACC grammar file.
	pos := fset.PositionFor(p, false)
	adjPos := fset.PositionFor(p, true)

	if filepath.Ext(adjPos.Filename) == ".go" {
		return adjPos
	}

	return pos
}

func Ordinal(n int) string {
	suffix := "th"
	if n < 10 || n > 20 {
		switch n % 10 {
		case 0:
			suffix = "th"
		case 1:
			suffix = "st"
		case 2:
			suffix = "nd"
		case 3:
			suffix = "rd"
		default:
			suffix = "th"
		}
	}

	return strconv.Itoa(n) + suffix
}
