// Copyright 2019 CUE Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package fix contains functionality for writing CUE files with legacy
// syntax to newer ones.
//
// Note: the transformations that are supported in this package will change
// over time.
package fix

import (
	"fmt"
	"slices"

	"cuelang.org/go/cue/ast"
	"cuelang.org/go/cue/ast/astutil"
	"cuelang.org/go/cue/errors"
	"cuelang.org/go/cue/token"
	"cuelang.org/go/internal/cueexperiment"
	"cuelang.org/go/internal/mod/semver"
)

type Option func(*options)

type options struct {
	simplify         bool
	exps             []string
	upgradeVersion   string
	removeListCommas bool
}

// Simplify enables fixes that simplify the code, but are not strictly
// necessary.
func Simplify() Option {
	return func(o *options) { o.simplify = true }
}

// Experiments enables fixes for specific experiments. Depending on the version
// of the module, this may result in adding an @experiment attribute to the
// top of the file for the given experiment.
func Experiments(experiment ...string) Option {
	return func(o *options) {
		// Deduplicate experiments to avoid running the same fix twice
		for _, exp := range experiment {
			if !slices.Contains(o.exps, exp) {
				o.exps = append(o.exps, exp)
			}
		}
	}
}

// RemoveListCommas removes explicit commas from multiline list elements,
// converting to the v0.17.0+ comma-free style.
func RemoveListCommas() Option {
	return func(o *options) { o.removeListCommas = true }
}

// UpgradeVersion enables upgrade fixes to the specified language version,
// applying all accepted experiments up to that version.
func UpgradeVersion(version string) Option {
	return func(o *options) {
		o.upgradeVersion = version
	}
}

// File applies fixes to f and returns it. It alters the original f.
func File(f *ast.File, o ...Option) *ast.File {
	f, err := file(f, "", o...)
	// TODO: this File method is public, and its signature was fixed
	// before we started calling Sanitize. Ideally, we want to return
	// this error, but that would require deprecating this File method,
	// and creating a new one, which might happen in due course if we
	// also discover that we need to be a bit more flexible than just
	// accepting a File.
	if err != nil {
		panic(err)
	}
	if err := astutil.Sanitize(f); err != nil {
		panic(err)
	}
	return f
}

func file(f *ast.File, version string, o ...Option) (*ast.File, errors.Error) {
	existingExps := f.Pos().Experiment()
	if version == "" {
		version = existingExps.LanguageVersion()
	}

	options := options{}
	for _, f := range o {
		f(&options)
	}

	// Handle upgrade version logic
	targetVersion := version
	if options.upgradeVersion != "" {
		targetVersion = options.upgradeVersion

		// Add accepted experiments for the target version to the experiment list
		acceptedExps := cueexperiment.GetUpgradable(version, targetVersion)
		for _, exp := range acceptedExps {
			// Only add if not already in the list and if it would make changes
			if !slices.Contains(options.exps, exp) {
				options.exps = append(options.exps, exp)
			}
		}
	}

	// Handle --exp=all (only valid as a lone argument)
	if slices.Equal(options.exps, []string{"all"}) {
		activeExps := cueexperiment.GetActive(version, targetVersion)
		options.exps = activeExps
	}

	for _, exp := range options.exps {
		if err := cueexperiment.CanApplyFix(exp, version, targetVersion); err != nil {
			return nil, errors.Newf(token.NoPos, "fix: %v", err)
		}
	}

	// TODO: should this error be wrapped better to not panic?
	wantExps, err := cueexperiment.NewFile(targetVersion, options.exps...)
	if err != nil {
		return nil, errors.Wrapf(err, token.NoPos, "fix: invalid experiment")
	}

	if wantExps.ExplicitOpen && !existingExps.ExplicitOpen {
		f = fixExperiment(fixExplicitOpen, f, "explicitopen", targetVersion)
	}

	if wantExps.AliasV2 && !existingExps.AliasV2 {
		f = fixExperiment(fixAliasV2, f, "aliasv2", targetVersion)
	}

	// Make sure we use the "after" function, and not the "before",
	// because "before" will stop recursion early which creates
	// problems with nested expressions.
	f = astutil.Apply(f, nil, func(c astutil.Cursor) bool {
		n := c.Node()
		switch n := n.(type) {
		case *ast.BinaryExpr:
			switch n.Op {
			case token.ADD, token.MUL:
				// The fix here only works when at least one argument is a
				// literal list. It would be better to be able to use CUE
				// to infer type information, and then apply the fix to
				// all places where we infer a list argument.
				x, y := n.X, n.Y
				_, xIsList := x.(*ast.ListLit)
				_, yIsList := y.(*ast.ListLit)
				_, xIsConcat := concatCallArgs(x)
				_, yIsConcat := concatCallArgs(y)

				if n.Op == token.ADD {
					if !(xIsList || xIsConcat || yIsList || yIsConcat) {
						break
					}
					// Rewrite list addition to use list.Concat
					exprs := expandConcats(x, y)
					ast.SetRelPos(x, token.NoSpace)
					c.Replace(ast.NewCall(
						ast.NewSel(&ast.Ident{
							Name: "list",
							Node: ast.NewImport(nil, "list"),
						}, "Concat"), ast.NewList(exprs...)),
					)

				} else {
					if !(xIsList || yIsList) {
						break
					}
					// Rewrite list multiplication to use list.Repeat
					if !xIsList {
						x, y = y, x
					}
					ast.SetRelPos(x, token.NoSpace)
					c.Replace(ast.NewCall(
						ast.NewSel(&ast.Ident{
							Name: "list",
							Node: ast.NewImport(nil, "list"),
						}, "Repeat"), x, y),
					)
				}
			}
		}
		return true
	}).(*ast.File)

	if options.removeListCommas && (version == "" || semver.Compare(version, "v0.17.0") >= 0) {
		fixListCommas(f)
	}

	if options.simplify {
		f = simplify(f)
	}

	return f, nil
}

// fixListCommas removes explicit commas from list elements by clearing the
// HasComma bit on element positions. The formatter then uses the comma-free
// style for multiline lists. Single-line lists are unaffected because the
// formatter always prints commas between inline elements regardless of
// HasComma bits.
func fixListCommas(f *ast.File) {
	ast.Walk(f, func(n ast.Node) bool {
		list, ok := n.(*ast.ListLit)
		if !ok {
			return true
		}
		for _, elt := range list.Elts {
			p := elt.Pos()
			if p.HasComma() {
				ast.SetPos(elt, p.WithComma(false))
			}
		}
		if list.Rbrack.HasComma() {
			list.Rbrack = list.Rbrack.WithComma(false)
		}
		return true
	}, nil)
}

func expandConcats(exprs ...ast.Expr) (result []ast.Expr) {
	for _, expr := range exprs {
		list, ok := concatCallArgs(expr)
		if ok {
			result = append(result, expandConcats(list.Elts...)...)
		} else {
			result = append(result, expr)
		}
	}
	return result
}

func concatCallArgs(expr ast.Expr) (*ast.ListLit, bool) {
	call, ok := expr.(*ast.CallExpr)
	if !ok {
		return nil, false
	}
	sel, ok := call.Fun.(*ast.SelectorExpr)
	if !ok {
		return nil, false
	}
	name, ok := sel.X.(*ast.Ident)
	if !ok || name.Name != "list" {
		return nil, false
	}
	name, ok = sel.Sel.(*ast.Ident)
	if !ok || name.Name != "Concat" {
		return nil, false
	}
	if len(call.Args) != 1 {
		return nil, false
	}
	list, ok := call.Args[0].(*ast.ListLit)
	if !ok {
		return nil, false
	}
	return list, true
}

func fixExperiment(fn func(*ast.File) (*ast.File, bool), f *ast.File, exp, version string) *ast.File {
	result, hasChanges := fn(f)

	// If we made any changes, add the @experiment(explicitopen) attribute at the top
	if hasChanges {
		// there must be at least one decl for there to be any changes.
		ast.SetRelPos(result.Decls[0], token.NewSection)

		if !cueexperiment.IsStable(exp, version) {
			expAttr := &ast.Attribute{Text: fmt.Sprintf("@experiment(%s)", exp)}

			// Insert the attribute as the first declaration
			result.Decls = append([]ast.Decl{expAttr}, result.Decls...)
		}
	}

	return result
}

func fixAliasV2(f *ast.File) (result *ast.File, hasChanges bool) {
	// Run multiple passes until no more changes are made, to handle nested aliases
	for {
		var changed bool
		result, changed = fixAliasV2Pass(f)
		if changed {
			hasChanges = true
			f = result
		} else {
			break
		}
	}
	return result, hasChanges
}

func fixAliasV2Pass(f *ast.File) (result *ast.File, hasChanges bool) {
	result = astutil.Apply(f, func(c astutil.Cursor) bool {
		n, ok := c.Node().(*ast.Field)
		if !ok {
			return true
		}

		// Check if this field has an old-style alias in the label
		if alias, ok := n.Label.(*ast.Alias); ok {
			hasChanges = true

			// Convert old-style alias (X=label) to new postfix alias (label~X)
			// The alias.Expr should be a Label (e.g., Ident)
			n.Alias = &ast.PostfixAlias{
				Field: alias.Ident,
			}
			ast.SetRelPos(alias.Ident, token.NoSpace)

			if label, ok := alias.Expr.(ast.Label); ok {
				// Skip if the label is not a valid Label type
				n.Label = label
				ast.SetRelPos(label, token.NoRelPos)
			}
		}

		if list, ok := n.Label.(*ast.ListLit); ok && len(list.Elts) == 1 {
			if alias, ok := list.Elts[0].(*ast.Alias); ok {
				hasChanges = true
				if n.Alias == nil {
					n.Alias = &ast.PostfixAlias{
						Field: ast.NewIdent("_"),
					}
				}
				n.Alias.Label = alias.Ident
				list.Elts[0] = alias.Expr
				ast.SetRelPos(alias.Ident, token.NoSpace)
				ast.SetRelPos(alias.Expr, token.NoRelPos)
			}
		}

		// Check if this field has an old-style value alias in the value
		// e.g., foo: X={x: X.a} should become foo: {let X = self; x: X.a}
		if alias, ok := n.Value.(*ast.Alias); ok {
			hasChanges = true

			// The value alias binds the alias identifier to self
			// Convert: foo: X={x: X.a, y: 1}
			// To: foo: {let X = self; x: X.a; y: 1}

			// The alias.Expr should be a StructLit
			s, ok := alias.Expr.(*ast.StructLit)
			if !ok {
				// This is possible V=[{a?: V}]. Replace with
				// {let V = self, [{a?: V}]} in that case.
				s = &ast.StructLit{Elts: []ast.Decl{alias.Expr}}
			}

			// Create a let clause: let X = self
			// Mark the "self" identifier as a predeclared reference so that
			// Sanitize will rename it to "__self" if shadowed in scope.
			selfIdent := ast.NewPredeclared("self")
			letClause := &ast.LetClause{
				Ident: alias.Ident,
				Expr:  selfIdent,
			}

			// Insert the let clause at the beginning of the struct
			s.Elts = slices.Insert(s.Elts, 0, ast.Decl(letClause))

			n.Value = s

			// Relink referring nodes to prevent rewriting by Sanitize.
			astutil.Apply(s, func(c astutil.Cursor) bool {
				if id, ok := c.Node().(*ast.Ident); ok && id.Node == alias {
					id.Node = letClause
				}
				return true
			}, nil)
		}
		return true
	}, nil).(*ast.File)

	return result, hasChanges
}
