package main

import (
	"bufio"
	"encoding/json"
	"fmt"
	"io"
	"io/fs"
	"log"
	"os"
	"path/filepath"
	"strings"

	"github.com/fatih/color"
	cbornode "github.com/ipfs/go-ipld-cbor"
	"github.com/urfave/cli/v2"

	"github.com/filecoin-project/go-address"
	"github.com/filecoin-project/test-vectors/schema"

	"github.com/filecoin-project/lotus/blockstore"
	"github.com/filecoin-project/lotus/chain/state"
	"github.com/filecoin-project/lotus/chain/types"
	"github.com/filecoin-project/lotus/conformance"
)

var execFlags struct {
	file               string
	out                string
	driverOpts         cli.StringSlice
	fallbackBlockstore bool
}

const (
	optSaveBalances = "save-balances"
)

var execCmd = &cli.Command{
	Name:        "exec",
	Description: "execute one or many test vectors against Lotus; supplied as a single JSON file, a directory, or a ndjson stdin stream",
	Action:      runExec,
	Flags: []cli.Flag{
		&repoFlag,
		&cli.StringFlag{
			Name:        "file",
			Usage:       "input file or directory; if not supplied, the vector will be read from stdin",
			TakesFile:   true,
			Destination: &execFlags.file,
		},
		&cli.BoolFlag{
			Name:        "fallback-blockstore",
			Usage:       "sets the full node API as a fallback blockstore; use this if you're transplanting vectors and get block not found errors",
			Destination: &execFlags.fallbackBlockstore,
		},
		&cli.StringFlag{
			Name:        "out",
			Usage:       "output directory where to save the results, only used when the input is a directory",
			Destination: &execFlags.out,
		},
		&cli.StringSliceFlag{
			Name:        "driver-opt",
			Usage:       "comma-separated list of driver options (EXPERIMENTAL; will change), supported: 'save-balances=<dst>', 'pipeline-basefee' (unimplemented); only available in single-file mode",
			Destination: &execFlags.driverOpts,
		},
	},
}

func runExec(c *cli.Context) error {
	if execFlags.fallbackBlockstore {
		if err := initialize(c); err != nil {
			return fmt.Errorf("fallback blockstore was enabled, but could not resolve lotus API endpoint: %w", err)
		}
		defer destroy(c) //nolint:errcheck
		conformance.FallbackBlockstoreGetter = FullAPI
	}

	path := execFlags.file
	if path == "" {
		return execVectorsStdin()
	}

	fi, err := os.Stat(path)
	if err != nil {
		return err
	}

	if fi.IsDir() {
		// we're in directory mode; ensure the out directory exists.
		outdir := execFlags.out
		if outdir == "" {
			return fmt.Errorf("no output directory provided")
		}
		if err := ensureDir(outdir); err != nil {
			return err
		}
		return execVectorDir(path, outdir)
	}

	// process tipset vector options.
	if err := processTipsetOpts(); err != nil {
		return err
	}

	_, err = execVectorFile(new(conformance.LogReporter), path)
	return err
}

func processTipsetOpts() error {
	for _, opt := range execFlags.driverOpts.Value() {
		switch ss := strings.Split(opt, "="); {
		case ss[0] == optSaveBalances:
			filename := ss[1]
			log.Printf("saving balances after each tipset in: %s", filename)
			balancesFile, err := os.Create(filename)
			if err != nil {
				return err
			}
			w := bufio.NewWriter(balancesFile)
			cb := func(bs blockstore.Blockstore, params *conformance.ExecuteTipsetParams, res *conformance.ExecuteTipsetResult) {
				cst := cbornode.NewCborStore(bs)
				st, err := state.LoadStateTree(cst, res.PostStateRoot)
				if err != nil {
					return
				}
				_ = st.ForEach(func(addr address.Address, actor *types.Actor) error {
					_, err := fmt.Fprintln(w, params.ExecEpoch, addr, actor.Balance)
					return err
				})
				_ = w.Flush()
			}
			conformance.TipsetVectorOpts.OnTipsetApplied = append(conformance.TipsetVectorOpts.OnTipsetApplied, cb)

		}

	}
	return nil
}

func execVectorDir(path string, outdir string) error {
	return filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return fmt.Errorf("failed while visiting path %s: %w", path, err)
		}
		if d.IsDir() || !strings.HasSuffix(path, "json") {
			return nil
		}
		// Create an output file to capture the output from the run of the vector.
		outfile := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + ".out"
		outpath := filepath.Join(outdir, outfile)
		outw, err := os.Create(outpath)
		if err != nil {
			return fmt.Errorf("failed to create file %s: %w", outpath, err)
		}

		log.Printf("processing vector %s; sending output to %s", path, outpath)

		// Actually run the vector.
		log.SetOutput(io.MultiWriter(os.Stderr, outw)) // tee the output.
		_, _ = execVectorFile(new(conformance.LogReporter), path)
		log.SetOutput(os.Stderr)
		_ = outw.Close()

		return nil
	})
}

func execVectorsStdin() error {
	r := new(conformance.LogReporter)
	for dec := json.NewDecoder(os.Stdin); ; {
		var tv schema.TestVector
		switch err := dec.Decode(&tv); err {
		case nil:
			if _, err = executeTestVector(r, tv); err != nil {
				return err
			}
		case io.EOF:
			// we're done.
			return nil
		default:
			// something bad happened.
			return err
		}
	}
}

func execVectorFile(r conformance.Reporter, path string) (diffs []string, error error) {
	file, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("failed to open test vector: %w", err)
	}

	var tv schema.TestVector
	if err = json.NewDecoder(file).Decode(&tv); err != nil {
		return nil, fmt.Errorf("failed to decode test vector: %w", err)
	}
	return executeTestVector(r, tv)
}

func executeTestVector(r conformance.Reporter, tv schema.TestVector) (diffs []string, err error) {
	log.Println("executing test vector:", tv.Meta.ID)

	for _, v := range tv.Pre.Variants {
		switch class, v := tv.Class, v; class {
		case "message":
			diffs, err = conformance.ExecuteMessageVector(r, &tv, &v)
		case "tipset":
			diffs, err = conformance.ExecuteTipsetVector(r, &tv, &v)
		default:
			return nil, fmt.Errorf("test vector class %s not supported", class)
		}

		if r.Failed() {
			log.Println(color.HiRedString("❌ test vector failed for variant %s", v.ID))
		} else {
			log.Println(color.GreenString("✅ test vector succeeded for variant %s", v.ID))
		}
	}

	return diffs, err
}
