package main

import (
	"bytes"
	"fmt"
	"go/ast"
	"go/format"
	"go/token"
)

// call wraps an testify assert ast.CallExpr and exposes properties of the
// expression to facilitate migrating the expression to a gotest.tools/assert
type call struct {
	fileset *token.FileSet
	expr    *ast.CallExpr
	xIdent  *ast.Ident
	selExpr *ast.SelectorExpr
	assert  string
}

func (c call) String() string {
	buf := new(bytes.Buffer)
	format.Node(buf, token.NewFileSet(), c.expr)
	return buf.String()
}

func (c call) StringWithFileInfo() string {
	if c.fileset.File(c.expr.Pos()) == nil {
		return fmt.Sprintf("%s at unknown file", c)
	}
	return fmt.Sprintf("%s at %s:%d", c,
		relativePath(c.fileset.File(c.expr.Pos()).Name()),
		c.fileset.Position(c.expr.Pos()).Line)
}

// testingT returns the first argument of the call, which is assumed to be a
// *ast.Ident of *testing.T or a compatible interface.
func (c call) testingT() ast.Expr {
	if len(c.expr.Args) == 0 {
		return nil
	}
	return c.expr.Args[0]
}

// extraArgs returns the arguments of the expression starting from index
func (c call) extraArgs(index int) []ast.Expr {
	if len(c.expr.Args) <= index {
		return nil
	}
	return c.expr.Args[index:]
}

// args returns a range of arguments from the expression
func (c call) args(from, to int) []ast.Expr {
	return c.expr.Args[from:to]
}

// arg returns a single argument from the expression
func (c call) arg(index int) ast.Expr {
	return c.expr.Args[index]
}

// newCallFromCallExpr returns a new call build from a ast.CallExpr. Returns false
// if the call expression does not have the expected ast nodes.
func newCallFromCallExpr(callExpr *ast.CallExpr, migration migration) (call, bool) {
	c := call{}
	selector, ok := callExpr.Fun.(*ast.SelectorExpr)
	if !ok {
		return c, false
	}
	ident, ok := selector.X.(*ast.Ident)
	if !ok {
		return c, false
	}

	return call{
		fileset: migration.fileset,
		xIdent:  ident,
		selExpr: selector,
		expr:    callExpr,
	}, true
}

// newTestifyCallFromNode returns a call that wraps a valid testify assertion.
// Returns false if the call expression is not a testify assertion.
func newTestifyCallFromNode(callExpr *ast.CallExpr, migration migration) (call, bool) {
	tcall, ok := newCallFromCallExpr(callExpr, migration)
	if !ok {
		return tcall, false
	}

	testifyNewAssignStmt := testifyAssertionsAssignment(tcall, migration)
	switch {
	case testifyNewAssignStmt != nil:
		return updateCallForTestifyNew(tcall, testifyNewAssignStmt, migration)
	case isTestifyPkgCall(tcall, migration):
		tcall.assert = migration.importNames.funcNameFromTestifyName(tcall.xIdent.Name)
		return tcall, true
	}
	return tcall, false
}

// isTestifyPkgCall returns true if the call is a testify package-level assertion
// (as apposed to an assertion method on the Assertions type)
//
// TODO: check if the xIdent.Obj.Decl is an import declaration instead of
// assuming that a name matching the import name is always an import. Some code
// may shadow import names, which could lead to incorrect results.
func isTestifyPkgCall(tcall call, migration migration) bool {
	return migration.importNames.matchesTestify(tcall.xIdent)
}

// testifyAssertionsAssignment returns an ast.AssignStmt if the call is a testify
// call from an Assertions object returned from assert.New(t) (not a package level
// assert). Otherwise returns nil.
func testifyAssertionsAssignment(tcall call, migration migration) *ast.AssignStmt {
	if tcall.xIdent.Obj == nil {
		return nil
	}

	assignStmt, ok := tcall.xIdent.Obj.Decl.(*ast.AssignStmt)
	if !ok {
		return nil
	}

	if isAssignmentFromAssertNew(assignStmt, migration) {
		return assignStmt
	}
	return nil
}

func updateCallForTestifyNew(
	tcall call,
	testifyNewAssignStmt *ast.AssignStmt,
	migration migration,
) (call, bool) {
	testifyNewCallExpr := callExprFromAssignment(testifyNewAssignStmt)
	if testifyNewCallExpr == nil {
		return tcall, false
	}
	testifyNewCall, ok := newCallFromCallExpr(testifyNewCallExpr, migration)
	if !ok {
		return tcall, false
	}

	tcall.assert = migration.importNames.funcNameFromTestifyName(testifyNewCall.xIdent.Name)
	tcall.expr = addMissingTestingTArgToCallExpr(tcall.expr, testifyNewCall.testingT())
	return tcall, true
}

// addMissingTestingTArgToCallExpr adds a testingT arg as the first arg of the
// ast.CallExpr and returns a copy of the ast.CallExpr
func addMissingTestingTArgToCallExpr(callExpr *ast.CallExpr, testingT ast.Expr) *ast.CallExpr {
	return &ast.CallExpr{
		Fun:  callExpr.Fun,
		Args: append([]ast.Expr{removePos(testingT)}, callExpr.Args...),
	}
}

func removePos(node ast.Expr) ast.Expr {
	switch typed := node.(type) {
	case *ast.Ident:
		return &ast.Ident{Name: typed.Name}
	}
	return node
}

// TODO: use pkgInfo and walkForType instead?
func isAssignmentFromAssertNew(assign *ast.AssignStmt, migration migration) bool {
	callExpr := callExprFromAssignment(assign)
	if callExpr == nil {
		return false
	}
	tcall, ok := newCallFromCallExpr(callExpr, migration)
	if !ok {
		return false
	}
	if !migration.importNames.matchesTestify(tcall.xIdent) {
		return false
	}

	if len(tcall.expr.Args) != 1 {
		return false
	}
	return tcall.selExpr.Sel.Name == "New"
}

func callExprFromAssignment(assign *ast.AssignStmt) *ast.CallExpr {
	if len(assign.Rhs) != 1 {
		return nil
	}

	callExpr, ok := assign.Rhs[0].(*ast.CallExpr)
	if !ok {
		return nil
	}
	return callExpr
}
