// 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 jsonschema_test

import (
	"bytes"
	"fmt"
	"io/fs"
	"maps"
	"net/url"
	"path"
	"slices"
	"strings"
	"testing"

	"github.com/go-quicktest/qt"
	"golang.org/x/tools/txtar"

	"cuelang.org/go/cue"
	"cuelang.org/go/cue/ast"
	"cuelang.org/go/cue/cuecontext"
	"cuelang.org/go/cue/errors"
	"cuelang.org/go/cue/format"
	"cuelang.org/go/cue/token"
	"cuelang.org/go/encoding/json"
	"cuelang.org/go/encoding/jsonschema"
	"cuelang.org/go/encoding/yaml"
	"cuelang.org/go/internal/cuetdtest"
	"cuelang.org/go/internal/cuetxtar"
	_ "cuelang.org/go/pkg"
)

// TestDecode reads the testdata/*.txtar files, converts the contained
// JSON schema to CUE and compares it against the output.
//
// Set CUE_UPDATE=1 to update test files with the corresponding output.
//
// Each test extracts the JSON Schema from a schema file (either
// schema.json or schema.yaml) and writes the result to
// out/decode/extract.
//
// If there are any files in the "test" directory in the txtar, each one
// is extracted and validated against the extracted schema. If the file
// name starts with "err-" it is expected to fail, otherwise it is
// expected to succeed.
//
// If the first line of a test file starts with a "#" character,
// it should start with `#schema` followed by a CUE path
// of the schema to test within the extracted schema.
//
// The #noverify tag in the txtar header causes verification and
// instance tests to be skipped.
//
// The #version: <version> tag selects the default schema version URI to use.
// As a special case, when this is "openapi", OpenAPI extraction
// mode is enabled.
func TestDecode(t *testing.T) {
	t.Parallel()
	test := cuetxtar.TxTarTest{
		Root:   "./testdata/txtar",
		Name:   "decode",
		Matrix: cuetdtest.FullMatrix,
	}
	test.Run(t, func(t *cuetxtar.Test) {
		t.Parallel()
		ctx := t.CueContext()

		fsys, err := txtar.FS(t.Archive)
		if err != nil {
			t.Fatal(err)
		}
		v, err := readSchema(ctx, fsys)
		if err != nil {
			t.Fatal(err)
		}

		cfg := &jsonschema.Config{}

		if versStr, ok := t.Value("version"); ok {
			// TODO most schemas have neither an explicit $schema or a #version
			// tag, so when we update the default version, they could break.
			// We should probably change most of the tests to use an explicit $schema
			// field apart from when we're explicitly testing the default version logic.
			switch versStr {
			case "openapi":
				cfg.DefaultVersion = jsonschema.VersionOpenAPI
				cfg.Map = func(p token.Pos, a []string) ([]ast.Label, error) {
					// Just for testing: does not validate the path.
					return []ast.Label{ast.NewIdent("#" + a[len(a)-1])}, nil
				}
				cfg.Root = "#/components/schemas/"
				cfg.StrictKeywords = true // encoding/openapi always uses strict keywords
			case "k8sAPI":
				cfg.DefaultVersion = jsonschema.VersionKubernetesAPI
				cfg.Root = "#/components/schemas/"
				cfg.StrictKeywords = true
			case "k8sCRD":
				if v.IncompleteKind() == cue.ListKind {
					t.Skip("regular Extract does not cope with extracting CRDs from arrays")
				}
				cfg.DefaultVersion = jsonschema.VersionKubernetesCRD
				// Default to the first version; can be overridden with #root.
				cfg.Root = "#/spec/versions/0/schema/openAPIV3Schema"
				cfg.StrictKeywords = true // CRDs always use strict keywords
				cfg.SingleRoot = true
			default:
				vers, err := jsonschema.ParseVersion(versStr)
				qt.Assert(t, qt.IsNil(err))
				cfg.DefaultVersion = vers
			}
		}
		if root, ok := t.Value("root"); ok {
			cfg.Root = root
		}
		cfg.Strict = t.HasTag("strict")
		cfg.StrictKeywords = cfg.StrictKeywords || t.HasTag("strictKeywords")
		cfg.AllowNonExistentRoot = t.HasTag("allowNonExistentRoot")
		cfg.StrictFeatures = t.HasTag("strictFeatures")
		if t.HasTag("singleRoot") {
			cfg.SingleRoot = true
		}
		cfg.PkgName, _ = t.Value("pkgName")

		w := t.Writer("extract")
		expr, err := jsonschema.Extract(v, cfg)
		if err != nil {
			got := "ERROR:\n" + errors.Details(err, nil)
			w.Write([]byte(got))
			return
		}
		if expr == nil {
			t.Fatal("no expression was extracted")
		}

		b, err := format.Node(expr, format.Simplify())
		if err != nil {
			t.Fatal(errors.Details(err, nil))
		}
		b = append(bytes.TrimSpace(b), '\n')
		w.Write(b)
		if t.HasTag("noverify") {
			return
		}
		// Verify that the generated CUE compiles.
		schemav := ctx.CompileBytes(b, cue.Filename("generated.cue"))
		if err := schemav.Err(); err != nil {
			t.Fatal(errors.Details(err, nil), qt.Commentf("generated code: %q", b))
		}
		testEntries, err := fs.ReadDir(fsys, "test")
		if err != nil {
			return
		}
		for _, e := range testEntries {
			file := path.Join("test", e.Name())
			var v cue.Value
			base := ""
			testData, err := fs.ReadFile(fsys, file)
			if err != nil {
				t.Fatal(err)
			}
			var schemaPath cue.Path
			if bytes.HasPrefix(testData, []byte("#")) {
				directiveBytes, rest, _ := bytes.Cut(testData, []byte("\n"))
				// Replace the directive with a newline so the line numbers
				// are correct in any error messages.
				testData = append([]byte("\n"), rest...)
				directive := string(directiveBytes)
				verb, arg, ok := strings.Cut(directive, " ")
				if verb != "#schema" {
					t.Fatalf("unknown directive %q in test file %v", directiveBytes, file)
				}
				if !ok {
					t.Fatalf("no schema path argument to #schema directive in %s", file)
				}
				schemaPath = cue.ParsePath(arg)
				qt.Assert(t, qt.IsNil(schemaPath.Err()))
			}

			switch {
			case strings.HasSuffix(file, ".json"):
				expr, err := json.Extract(file, testData)
				if err != nil {
					t.Fatal(err)
				}
				v = ctx.BuildExpr(expr)
				base = strings.TrimSuffix(e.Name(), ".json")

			case strings.HasSuffix(file, ".yaml"):
				file, err := yaml.Extract(file, testData)
				if err != nil {
					t.Fatal(err)
				}
				v = ctx.BuildFile(file)
				base = strings.TrimSuffix(e.Name(), ".yaml")
			default:
				t.Fatalf("unknown file encoding for test file %v", file)
			}
			if err := v.Err(); err != nil {
				t.Fatalf("error building expression for test %v: %v", file, err)
			}
			subSchema := schemav.LookupPath(schemaPath)
			if !subSchema.Exists() {
				t.Fatalf("path %q does not exist within schema", schemaPath)
			}
			rv := subSchema.Unify(v)
			if strings.HasPrefix(e.Name(), "err-") {
				err := rv.Err()
				if err == nil {
					t.Fatalf("test %v unexpectedly passes", file)
				}
				if t.M.IsDefault() {
					// The error results of the different evaluators can vary,
					// so only test the exact results for the default evaluator.
					t.Writer(path.Join("testerr", base)).Write([]byte(errors.Details(err, nil)))
				}
			} else {
				if err := rv.Err(); err != nil {
					t.Fatalf("test %v unexpectedly fails: %v", file, errors.Details(err, nil))
				}
			}
		}
	})
}

func TestDecodeCRD(t *testing.T) {
	t.Parallel()
	test := cuetxtar.TxTarTest{
		Root:   "./testdata/txtar",
		Name:   "decodeCRD",
		Matrix: cuetdtest.FullMatrix,
	}
	test.Run(t, func(t *cuetxtar.Test) {
		if versStr, ok := t.Value("version"); !ok || versStr != "k8sCRD" {
			t.Skip("test not relevant to CRDs")
		}
		t.Parallel()

		ctx := t.CueContext()

		fsys, err := txtar.FS(t.Archive)
		if err != nil {
			t.Fatal(err)
		}
		v, err := readSchema(ctx, fsys)
		if err != nil {
			t.Fatal(err)
		}
		crds, err := jsonschema.ExtractCRDs(v, &jsonschema.CRDConfig{})
		if err != nil {
			w := t.Writer("extractCRD/error")
			fmt.Fprintf(w, "%v\n", err)
			return
		}
		for i, crd := range crds {
			for _, version := range slices.Sorted(maps.Keys(crd.Versions)) {
				w := t.Writer(fmt.Sprintf("extractCRD/%d/%s", i, version))
				schemaPath := crd.VersionToPath[version]
				// Sanity check that the path does actually resolve and looks plausible.
				qt.Check(t, qt.Matches(schemaPath.String(), `spec\.versions\[\d+\]\.schema\.openAPIV3Schema`))
				schemav := crd.Source.LookupPath(schemaPath)
				qt.Check(t, qt.IsTrue(schemav.Exists()))
				f := crd.Versions[version]
				b, err := format.Node(f, format.Simplify())
				if err != nil {
					t.Fatal(errors.Details(err, nil))
				}
				b = append(bytes.TrimSpace(b), '\n')
				w.Write(b)
				// TODO test that schema actually works.
			}
		}
	})
}

func readSchema(ctx *cue.Context, fsys fs.FS) (cue.Value, error) {
	jsonData, jsonErr := fs.ReadFile(fsys, "schema.json")
	yamlData, yamlErr := fs.ReadFile(fsys, "schema.yaml")
	var v cue.Value
	switch {
	case jsonErr == nil && yamlErr == nil:
		return cue.Value{}, fmt.Errorf("cannot define both schema.json and schema.yaml")
	case jsonErr == nil:
		expr, err := json.Extract("schema.json", jsonData)
		if err != nil {
			return cue.Value{}, err
		}
		v = ctx.BuildExpr(expr)
	case yamlErr == nil:
		file, err := yaml.Extract("schema.yaml", yamlData)
		if err != nil {
			return cue.Value{}, err
		}
		v = ctx.BuildFile(file)
	default:
		return cue.Value{}, fmt.Errorf("no schema.yaml or schema.json file found for test")
	}
	if err := v.Err(); err != nil {
		return cue.Value{}, err
	}
	return v, nil
}

func TestMapURL(t *testing.T) {
	t.Parallel()
	v := cuecontext.New().CompileString(`
type: "object"
properties: x: $ref: "https://something.test/foo#/definitions/blah"
`)
	var calls []string
	expr, err := jsonschema.Extract(v, &jsonschema.Config{
		MapURL: func(u *url.URL) (string, cue.Path, error) {
			calls = append(calls, u.String())
			return "other.test/something:blah", cue.ParsePath("#Foo.bar"), nil
		},
	})
	qt.Assert(t, qt.IsNil(err))
	b, err := format.Node(expr, format.Simplify())
	if err != nil {
		t.Fatal(errors.Details(err, nil))
	}
	qt.Assert(t, qt.DeepEquals(calls, []string{"https://something.test/foo"}))
	qt.Assert(t, qt.Equals(string(b), `
import "other.test/something:blah"

x?: blah.#Foo.bar.#blah
...
`[1:]))
}

func TestMapURLErrors(t *testing.T) {
	v := cuecontext.New().CompileString(`
type: "object"
properties: {
	x: $ref: "https://something.test/foo#/definitions/x"
	y: $ref: "https://something.test/foo#/definitions/y"
}
`, cue.Filename("foo.cue"))
	_, err := jsonschema.Extract(v, &jsonschema.Config{
		MapURL: func(u *url.URL) (string, cue.Path, error) {
			return "", cue.Path{}, fmt.Errorf("some error")
		},
	})
	qt.Assert(t, qt.Equals(errors.Details(err, nil), `
cannot determine CUE location for JSON Schema location id=https://something.test/foo#/definitions/x: some error:
    foo.cue:4:5
cannot determine CUE location for JSON Schema location id=https://something.test/foo#/definitions/y: some error:
    foo.cue:5:5
`[1:]))
}

func TestMapRef(t *testing.T) {
	t.Parallel()
	v := cuecontext.New().CompileString(`
type: "object"
$id: "https://this.test"
$defs: foo: type: "string"
properties: x: $ref: "https://something.test/foo#/$defs/blah"
`)
	var calls []string
	expr, err := jsonschema.Extract(v, &jsonschema.Config{
		MapRef: func(loc jsonschema.SchemaLoc) (string, cue.Path, error) {
			calls = append(calls, loc.String())
			switch loc.ID.String() {
			case "https://this.test#/$defs/foo":
				return "", cue.ParsePath("#x.#def.#foo"), nil
			case "https://something.test/foo#/$defs/blah":
				return "other.test/something:blah", cue.ParsePath("#Foo.bar"), nil
			case "https://this.test":
				return "", cue.Path{}, nil
			}
			t.Errorf("unexpected ID")
			return "", cue.Path{}, fmt.Errorf("unexpected ID %q passed to MapRef", loc.ID)
		},
	})
	qt.Assert(t, qt.IsNil(err))
	b, err := format.Node(expr, format.Simplify())
	if err != nil {
		t.Fatal(errors.Details(err, nil))
	}
	qt.Assert(t, qt.DeepEquals(calls, []string{
		"id=https://this.test#/$defs/foo localPath=$defs.foo",
		"id=https://something.test/foo#/$defs/blah",
		"id=https://this.test localPath=",
		"id=https://something.test/foo#/$defs/blah",
	}))
	qt.Assert(t, qt.Equals(string(b), `
import "other.test/something:blah"

@jsonschema(id="https://this.test")
x?: blah.#Foo.bar

#x: #def: #foo: string
...
`[1:]))
}

func TestMapRefExternalRefForInternalSchema(t *testing.T) {
	t.Parallel()
	v := cuecontext.New().CompileString(`
type: "object"
$id: "https://this.test"
$defs: foo: {
	description: "foo can be a number or a string"
	type: ["number", "string"]
}
$defs: bar: type: "boolean"
$ref: "#/$defs/foo"
`)
	var calls []string
	defines := make(map[string]string)
	expr, err := jsonschema.Extract(v, &jsonschema.Config{
		MapRef: func(loc jsonschema.SchemaLoc) (string, cue.Path, error) {
			calls = append(calls, loc.String())
			switch loc.ID.String() {
			case "https://this.test#/$defs/foo":
				return "otherpkg.example/foo", cue.ParsePath("#x"), nil
			case "https://this.test#/$defs/bar":
				return "otherpkg.example/bar", cue.ParsePath("#x"), nil
			case "https://this.test":
				return "", cue.Path{}, nil
			}
			t.Errorf("unexpected ID")
			return "", cue.Path{}, fmt.Errorf("unexpected ID %q passed to MapRef", loc.ID)
		},
		DefineSchema: func(importPath string, path cue.Path, e ast.Expr, c *ast.CommentGroup) {
			if c != nil {
				ast.AddComment(e, c)
			}
			data, err := format.Node(e)
			if err != nil {
				t.Errorf("cannot format: %v", err)
				return
			}
			defines[fmt.Sprintf("%s.%v", importPath, path)] = string(data)
		},
	})
	qt.Assert(t, qt.IsNil(err))
	b, err := format.Node(expr, format.Simplify())
	if err != nil {
		t.Fatal(errors.Details(err, nil))
	}
	qt.Check(t, qt.DeepEquals(calls, []string{
		"id=https://this.test#/$defs/foo localPath=$defs.foo",
		"id=https://this.test#/$defs/bar localPath=$defs.bar",
		"id=https://this.test localPath=",
	}))
	qt.Check(t, qt.Equals(string(b), `
import "otherpkg.example/foo"

@jsonschema(id="https://this.test")
foo.#x & {
	...
}
`[1:]))
	qt.Check(t, qt.DeepEquals(defines, map[string]string{
		"otherpkg.example/bar.#x": "bool",
		"otherpkg.example/foo.#x": "// foo can be a number or a string\nnumber | string",
	}))
}
