package externaltest

import (
	"bytes"
	stdjson "encoding/json"
	"fmt"
	"iter"
	"os"
	"path/filepath"
	"strings"

	"cuelang.org/go/cue"
	"cuelang.org/go/cue/cuecontext"
	"cuelang.org/go/cue/load"
	"cuelang.org/go/cue/token"
)

type Schema struct {
	location
	Description string             `json:"description"`
	Comment     string             `json:"comment,omitempty"`
	Schema      stdjson.RawMessage `json:"schema"`
	Skip        Skip               `json:"skip,omitempty"`
	Tests       []*Test            `json:"tests"`
}

type Test struct {
	location
	Description string             `json:"description"`
	Comment     string             `json:"comment,omitempty"`
	Data        stdjson.RawMessage `json:"data"`
	Valid       bool               `json:"valid"`
	Skip        Skip               `json:"skip,omitempty"`
}

// Skip records information about whether a given schema
// or test will be skipped when testing. If not present,
// the test will be expected to pass.
//
// Each key in the map represents the name of a point
// in the cuetdtest matrix, optionally followed
// by a "-$variant" suffix to indicate a test variant
// such as round-trip testing.
type Skip map[string]string

type location struct {
	root cue.Value
	path cue.Path
}

func (loc location) Pos() token.Pos {
	var v cue.Value
	for v = range conjuncts(loc.root.LookupPath(loc.path)) {
		p := v.Pos()
		if strings.HasSuffix(p.Filename(), ".json") {
			return p
		}
	}
	return v.Pos()
}

func conjuncts(v cue.Value) iter.Seq[cue.Value] {
	return func(yield func(cue.Value) bool) {
		yieldConjuncts(v, yield)
	}
}

func yieldConjuncts(v cue.Value, yield func(cue.Value) bool) bool {
	op, args := v.Expr()
	if op != cue.AndOp {
		return yield(v)
	}
	for _, v := range args {
		if !yieldConjuncts(v, yield) {
			return false
		}
	}
	return true
}

// WriteTestDir writes test data files as read by ReadTestDir
// to the given directory. The keys of tests are filenames relative
// to dir.
func WriteTestDir(dir string, tests map[string][]*Schema) error {
	for filename, schemas := range tests {
		filename = filepath.Join(dir, filename)
		data, err := stdjson.MarshalIndent(schemas, "", "\t")
		if err != nil {
			return err
		}
		data = append(data, '\n')
		oldData, err := os.ReadFile(filename)
		if err != nil {
			return err
		}
		if bytes.Equal(oldData, data) {
			continue
		}
		err = os.WriteFile(filename, data, 0o666)
		if err != nil {
			return err
		}
	}
	return nil
}

var ErrNotFound = fmt.Errorf("no external JSON schema tests found")

// ReadTestDir reads all the external tests from the given directory.
func ReadTestDir(dir string) (tests map[string][]*Schema, err error) {
	if _, err := os.Stat(dir); err != nil {
		if os.IsNotExist(err) {
			return nil, ErrNotFound
		}
		return nil, err
	}
	inst := load.Instances([]string{"."}, &load.Config{
		Dir: dir,
		// Just like in the cue/load tests, prevent Go tests from walking up to the root
		// directory of the git repository, as that almost always causes test cache misses.
		ModuleRoot: ".",
	})[0]
	if err := inst.Err; err != nil {
		return nil, err
	}
	ctx := cuecontext.New()
	instVal := ctx.BuildInstance(inst)
	if err := instVal.Err(); err != nil {
		return nil, err
	}
	val := instVal.LookupPath(cue.MakePath(cue.Str("allTests")))
	if err := val.Err(); err != nil {
		return nil, err
	}
	if err := val.Decode(&tests); err != nil {
		return nil, err
	}
	// Fix up the raw JSON data to avoid running into some decode issues.
	for filename, schemas := range tests {
		for i, schema := range schemas {
			schema.location = location{
				root: val,
				path: cue.MakePath(cue.Str(filename), cue.Index(i)),
			}
			for j, test := range schema.Tests {
				test.location = location{
					root: val,
					path: cue.MakePath(cue.Str(filename), cue.Index(i), cue.Str("tests"), cue.Index(j)),
				}
				if len(test.Data) == 0 {
					// See https://github.com/cue-lang/cue/issues/3397
					test.Data = []byte("null")
					continue
				}
				// See https://github.com/cue-lang/cue/issues/3398
				test.Data = bytes.ReplaceAll(test.Data, []byte("\ufeff"), []byte(`\ufeff`))
			}
		}
	}
	return tests, nil
}
