// Copyright 2017 The OPA Authors.  All rights reserved.
// Use of this source code is governed by an Apache2
// license that can be found in the LICENSE file.

package cmd

import (
	"bytes"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"slices"

	"github.com/sergi/go-diff/diffmatchpatch"
	"github.com/spf13/cobra"

	"github.com/open-policy-agent/opa/cmd/internal/env"
	fileurl "github.com/open-policy-agent/opa/internal/file/url"
	"github.com/open-policy-agent/opa/v1/ast"
	"github.com/open-policy-agent/opa/v1/format"
)

type fmtCommandParams struct {
	overwrite        bool
	list             bool
	diff             bool
	fail             bool
	regoV1           bool
	v0Compatible     bool
	v1Compatible     bool
	checkResult      bool
	dropV0Imports    bool
	capabilitiesFlag *capabilitiesFlag
}

func newFmtCommandParams() *fmtCommandParams {
	return &fmtCommandParams{
		capabilitiesFlag: newCapabilitiesFlag(),
	}
}

func (p *fmtCommandParams) capabilities() *ast.Capabilities {
	if p.capabilitiesFlag != nil && p.capabilitiesFlag.C != nil {
		return p.capabilitiesFlag.C
	}
	return ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(p.regoVersion()))
}

func (p *fmtCommandParams) regoVersion() ast.RegoVersion {
	// The '--rego-v1' flag takes precedence over the '--v1-compatible' flag.
	if p.regoV1 {
		return ast.RegoV0CompatV1
	}
	// The '--v0-compatible' flag takes precedence over the '--v1-compatible' flag.
	if p.v0Compatible {
		return ast.RegoV0
	}
	if p.v1Compatible {
		return ast.RegoV1
	}
	return ast.DefaultRegoVersion
}

// parserOptions returns the options for parsing the source to format.
func (p *fmtCommandParams) parserOptions() *ast.ParserOptions {
	popts := ast.ParserOptions{
		RegoVersion:  ast.DefaultRegoVersion,
		Capabilities: p.parserCapabilities(),
	}

	switch {
	case p.v0Compatible:
		popts.RegoVersion = ast.RegoV0
	case p.v1Compatible:
		popts.RegoVersion = ast.RegoV1
	case p.regoV1:
		// '--rego-v1' formats a v0 module for v1 compatibility, so it is read as v0.
		popts.RegoVersion = ast.RegoV0
	}

	return &popts
}

func (p *fmtCommandParams) parserCapabilities() *ast.Capabilities {
	caps := *p.capabilities()
	current := ast.CapabilitiesForThisVersion(ast.CapabilitiesRegoVersion(p.regoVersion()))

	// parsing caps must span current version and target
	caps.FutureKeywords = union(caps.FutureKeywords, current.FutureKeywords)
	caps.Features = union(caps.Features, current.Features)

	return &caps
}

func union(a, b []string) []string {
	out := slices.Clone(a)
	for _, s := range b {
		if !slices.Contains(out, s) {
			out = append(out, s)
		}
	}
	slices.Sort(out)

	return out
}

func opaFmt(args []string, fmtParams *fmtCommandParams) int {
	if len(args) == 0 {
		if err := formatStdin(fmtParams, os.Stdin, os.Stdout); err != nil {
			fmt.Fprintln(os.Stderr, err)
			return 1
		}
		return 0
	}

	for _, filename := range args {

		var err error
		filename, err = fileurl.Clean(filename)
		if err != nil {
			fmt.Fprintln(os.Stderr, err)
			return 1
		}
		err = filepath.Walk(filename, func(path string, info os.FileInfo, err error) error {
			return formatFile(fmtParams, os.Stdout, path, info, err)
		})
		if err != nil {
			switch err := err.(type) {
			case fmtError:
				fmt.Fprintln(os.Stderr, err.msg)
				return err.code
			default:
				fmt.Fprintln(os.Stderr, err.Error())
				return 1
			}
		}
	}

	return 0
}

func formatFile(params *fmtCommandParams, out io.Writer, filename string, info os.FileInfo, err error) error {
	if err != nil {
		return err
	}

	if info.IsDir() {
		return nil
	}

	if filepath.Ext(filename) != ".rego" {
		return nil
	}

	contents, err := os.ReadFile(filename)
	if err != nil {
		return newError("failed to open file: %v", err)
	}

	opts := format.Opts{
		RegoVersion:   params.regoVersion(),
		DropV0Imports: params.dropV0Imports,
		Capabilities:  params.capabilities(),
		ParserOptions: params.parserOptions(),
	}

	formatted, err := format.SourceWithOpts(filename, contents, opts)
	if err != nil {
		return newError("failed to format Rego source file: %v", err)
	}

	if params.checkResult {
		popts := ast.ParserOptions{
			RegoVersion:  params.regoVersion(),
			Capabilities: params.parserCapabilities(),
		}
		_, err := ast.ParseModuleWithOpts("formatted", string(formatted), popts)
		if err != nil {
			return newError("%s was successfully formatted, but the result is invalid: %v\n\nTo inspect the formatted Rego, you can turn off this check with --check-result=false.", filename, err)
		}
	}

	changed := !bytes.Equal(contents, formatted)

	if params.fail && !params.list && !params.diff {
		if changed {
			return newError("unexpected diff")
		}
	}

	if params.list {
		if changed {
			fmt.Fprintln(out, filename)

			if params.fail {
				return newError("unexpected diff")
			}
		}
		return nil
	}

	if params.diff {
		if changed {
			diffString := doDiff(contents, formatted)
			if _, err := fmt.Fprintln(out, diffString); err != nil {
				return newError("failed to print contents: %v", err)
			}
			if params.fail {
				return newError("unexpected diff")
			}
		}
		return nil
	}

	if params.overwrite {
		if !changed {
			return nil
		}
		outfile, err := os.OpenFile(filename, os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
		if err != nil {
			return newError("failed to open file for writing: %v", err)
		}
		defer outfile.Close()
		out = outfile
	}

	_, err = out.Write(formatted)
	if err != nil {
		return newError("failed writing formatted contents: %v", err)
	}

	return nil
}

func formatStdin(params *fmtCommandParams, r io.Reader, w io.Writer) error {
	contents, err := io.ReadAll(r)
	if err != nil {
		return err
	}

	opts := format.Opts{
		RegoVersion:   params.regoVersion(),
		Capabilities:  params.capabilities(),
		ParserOptions: params.parserOptions(),
	}

	formatted, err := format.SourceWithOpts("stdin", contents, opts)
	if err != nil {
		return err
	}

	_, err = w.Write(formatted)
	return err
}

func doDiff(a, b []byte) (diffString string) { // "a" is old, "b" is new
	dmp := diffmatchpatch.New()
	diffs := dmp.DiffMain(string(a), string(b), false)
	return dmp.DiffPrettyText(diffs)
}

type fmtError struct {
	msg  string
	code int
}

func (e fmtError) Error() string {
	return fmt.Sprintf("%s (%d)", e.msg, e.code)
}

func newError(msg string, a ...any) fmtError {
	return fmtError{
		msg:  fmt.Sprintf(msg, a...),
		code: 2,
	}
}

func initFmt(root *cobra.Command, _ string) {
	cmd := root.Name()
	fmtParams := newFmtCommandParams()
	formatCommand := &cobra.Command{
		Use:   "fmt [path [...]]",
		Short: "Format Rego source files",
		Long: `Format Rego source files.

The 'fmt' command takes a Rego source file and outputs a reformatted version. If no file path
is provided - this tool will use stdin.
The format of the output is not defined specifically; whatever this tool outputs
is considered correct format (with the exception of bugs).

If the '-w' option is supplied, the 'fmt' command will overwrite the source file
instead of printing to stdout.

If the '-d' option is supplied, the 'fmt' command will output a diff between the
original and formatted source.

If the '-l' option is supplied, the 'fmt' command will output the names of files
that would change if formatted. The '-l' option will suppress any other output
to stdout from the 'fmt' command.

If the '--fail' option is supplied, the 'fmt' command will return a non zero exit
code if a file would be reformatted.

The 'fmt' command can be run in several compatibility modes for consuming and outputting
different Rego versions:

* ` + "`" + cmd + ` fmt` + "`" + `:
  * v1 Rego is formatted to v1
  * ` + "`" + `rego.v1` + "`" + `/` + "`" + `future.keywords` + "`" + ` imports are NOT removed
  * ` + "`" + `rego.v1` + "`" + `/` + "`" + `future.keywords` + "`" + ` imports are NOT added if missing
  * v0 rego is rejected
* ` + "`" + cmd + ` fmt --v0-compatible` + "`" + `:
  * v0 Rego is formatted to v0
  * v1 Rego is rejected
* ` + "`" + cmd + ` fmt --v0-v1` + "`" + `:
  * v0 Rego is formatted to be compatible with v0 AND v1
  * v1 Rego is rejected
* ` + "`" + cmd + ` fmt --v0-v1 --v1-compatible` + "`" + `:
  * v1 Rego is formatted to be compatible with v0 AND v1
  * v0 Rego is rejected
`,
		PreRunE: func(cmd *cobra.Command, _ []string) error {
			return env.CmdFlags.CheckEnvironmentVariables(cmd)
		},
		RunE: func(cmd *cobra.Command, args []string) error {
			cmd.SilenceErrors = true
			cmd.SilenceUsage = true
			exit := opaFmt(args, fmtParams)
			if exit != 0 {
				return newExitError(exit)
			}
			return nil
		},
	}

	formatCommand.Flags().BoolVarP(&fmtParams.overwrite, "write", "w", false, "overwrite the original source file")
	formatCommand.Flags().BoolVarP(&fmtParams.list, "list", "l", false, "list all files who would change when formatted")
	formatCommand.Flags().BoolVarP(&fmtParams.diff, "diff", "d", false, "only display a diff of the changes")
	formatCommand.Flags().BoolVar(&fmtParams.fail, "fail", false, "non zero exit code on reformat")
	addRegoV0V1FlagWithDescription(formatCommand.Flags(), &fmtParams.regoV1, false, "format module(s) to be compatible with both Rego v0 and v1")
	addV0CompatibleFlag(formatCommand.Flags(), &fmtParams.v0Compatible)
	addV1CompatibleFlag(formatCommand.Flags(), &fmtParams.v1Compatible)
	formatCommand.Flags().BoolVar(&fmtParams.checkResult, "check-result", true, "assert that the formatted code is valid and can be successfully parsed")
	formatCommand.Flags().BoolVar(&fmtParams.dropV0Imports, "drop-v0-imports", false, "drop v0 imports from the formatted code, such as 'rego.v1' and 'future.keywords'")
	addCapabilitiesFlag(formatCommand.Flags(), fmtParams.capabilitiesFlag)

	root.AddCommand(formatCommand)
}
