// Copyright 2024 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 embed provides capabilities to CUE to embed any file that resides
// within a CUE module into CUE either verbatim or decoded.
//
// This package is EXPERIMENTAL and subject to change.
//
// # Overview
//
// To enable file embedding, a file must include the file-level @extern(embed)
// attribute. This allows a quick glance to see if a file embeds any files at
// all. This allows the @embed attribute to be used to load a file within a CUE
// module into a field.
//
// References to files are always relative to the directory in which the
// referring file resides. Only files in the same module containing the CUE
// file can be embedded, and parent directory references are not allowed.
//
// # The @embed attribute
//
// There are two main ways to embed files which are distinguished by the file
// and glob arguments. The @embed attribute supports the following arguments:
//
// file=$filename
//
// The use of the file argument tells embed to load a single file into the
// field. This argument many not be used in conjunction with the glob argument.
//
// glob=$pattern
//
// The use of the glob argument tells embed to load multiple files into the
// field as a map of file paths to the decoded values. The paths are normalized
// to use forward slashes. This argument may not be used in conjunction with the
// file argument.
//
// type=$type
//
// By default, the file type is interpreted based on the file extension. This
// behavior can be overridden by the type argument. See cue help filetypes for
// the list of supported types. This field is required if a file extension is
// unknown, or if a wildcard is used for the file extension in the glob pattern.
//
// allowEmptyGlob
//
// By default, a glob pattern that matches no files results in an error. When
// allowEmptyGlob is present, a glob pattern with no matches will return an
// empty struct instead of an error. This option is only supported with glob patterns.
//
// # Limitations
//
// The embed injection currently does not support:
// - stream values, such as .ndjson or YAML streams.
// - schema-based decoding, such as needed for textproto
//
// # Example
//
//	@extern(embed)
//
//	package foo
//
//	// interpreted as JSON
//	a: _ @embed(file="file1.json") // the quotes are optional here
//
//	// interpreted the same file as JSON schema
//	#A: _ @embed(file=file1.json, type=jsonschema)
//
//	// interpret a proprietary extension as OpenAPI represented as YAML
//	b: _ @embed(file="file2.crd", type=openapi+yaml)
//
//	// include all YAML files in the x directory interpreted as YAML
//	// The result is a map of file paths to the decoded YAML values.
//	files: _ @embed(glob=x/*.yaml)
//
//	// include all files in the y directory as a map of file paths to binary
//	// data. The entries are unified into the same map as above.
//	files: _ @embed(glob=y/*.*, type=binary)
//
//	// include all YAML files in the z directory, but allow empty result
//	// if no files match (returns empty struct instead of error)
//	optionalFiles: _ @embed(glob=z/*.yaml, allowEmptyGlob)
package embed

import (
	iofs "io/fs"
	"path"
	"strings"

	"cuelang.org/go/cue"
	"cuelang.org/go/cue/ast"
	"cuelang.org/go/cue/build"
	"cuelang.org/go/cue/errors"
	"cuelang.org/go/cue/token"
	"cuelang.org/go/internal"
	"cuelang.org/go/internal/core/adt"
	"cuelang.org/go/internal/core/runtime"
	"cuelang.org/go/internal/encoding"
	"cuelang.org/go/internal/filetypes"
	"cuelang.org/go/internal/value"
	pkgpath "cuelang.org/go/pkg/path"
)

// TODO: record files in build.Instance

// injection is a [runtime.Injection] for embedded files.
type injection struct{}

// New returns a new injection for embedded files as a
// [runtime.Injection] suitable for passing to
// [cuelang.org/go/cue/cuecontext.WithInjection].
func New() runtime.Injection {
	return injection{}
}

func (i injection) Kind() string {
	return EmbedKind
}

const EmbedKind = "embed"

// InjectorForInstance returns an injector that can decode and embed files
// that exist within a CUE module.
func (i injection) InjectorForInstance(b *build.Instance, r *runtime.Runtime) (runtime.Injector, errors.Error) {
	if b.Module == "" {
		return nil, errors.Newf(token.Pos{}, "cannot embed files when not in a module")
	}
	if b.Root == "" {
		return nil, errors.Newf(token.Pos{}, "cannot embed files: no module root found")
	}
	return &injector{
		b:       b,
		runtime: (*cue.Context)(r),
	}, nil
}

// An injector is a [runtime.Injector] that allows embedding files into CUE
// values.
type injector struct {
	b       *build.Instance
	runtime *cue.Context
	opCtx   *adt.OpContext

	// file system cache
	dir string
	fs  iofs.FS
	pos token.Pos
}

// validateAttr performs logical validation of the attr. It does not
// perform any checks against a filesystem.
func validateAttr(a *internal.Attr) (file, glob, typ string, allowEmptyGlob bool, errs errors.Error) {
	if a.Err != nil {
		return "", "", "", false, a.Err
	}

	pos := a.Pos

	file, _, err := a.Lookup(0, "file")
	if err != nil {
		return "", "", "", false, errors.Promote(err, "invalid attribute")
	}

	glob, _, err = a.Lookup(0, "glob")
	if err != nil {
		return "", "", "", false, errors.Promote(err, "invalid attribute")
	}

	typ, _, err = a.Lookup(0, "type")
	if err != nil {
		return "", "", "", false, errors.Promote(err, "invalid type argument")
	}

	allowEmptyGlob, err = a.Flag(0, "allowEmptyGlob")
	if err != nil {
		return "", "", "", false, errors.Promote(err, "invalid allowEmptyGlob argument")
	}

	switch {
	case file == "" && glob == "":
		return "", "", "", false, errors.Newf(pos, "attribute must have file or glob field")

	case file != "" && glob != "":
		return "", "", "", false, errors.Newf(pos, "attribute cannot have both file and glob field")
	case allowEmptyGlob && glob == "":
		return "", "", "", false, errors.Newf(pos, "allowEmptyGlob must be specified with a glob field")

	case file != "":
		file, err := clean(pos, file)
		if err != nil {
			return "", "", "", false, err
		}
		return file, "", typ, allowEmptyGlob, nil

	default:
		glob, err := clean(pos, glob)
		if err != nil {
			return "", "", "", false, err
		}

		// Validate that the glob pattern is valid per [pkgpath.Match].
		// Note that we use Unix match semantics because all embed paths are Unix-like.
		if _, err := pkgpath.Match(glob, "", pkgpath.Unix); err != nil {
			return "", "", "", false, errors.Wrapf(err, pos, "invalid glob pattern %q", glob)
		}

		// If we do not have a type, ensure the extension of the base is fully
		// specified, i.e. does not contain any meta characters as specified by
		// path.Match.
		if typ == "" {
			ext := path.Ext(path.Base(glob))
			if ext == "" || strings.ContainsAny(ext, "*?[\\") {
				return "", "", "", false, errors.Newf(pos, "extension not fully specified; type argument required")
			}
		}
		return "", glob, typ, allowEmptyGlob, nil
	}
}

// InjectedValue interprets an embed attribute to either load a file
// (@embed(file=...)) or a glob of files (@embed(glob=...))
// and decodes the given files.
func (c *injector) InjectedValue(attr *runtime.ExternAttr, scope *adt.Vertex) (adt.Expr, errors.Error) {
	a := attr.Attr
	c.opCtx = adt.NewContext((*runtime.Runtime)(c.runtime), nil)

	pos := a.Pos
	c.pos = pos

	// Resolve the FS from the file's recorded FSLoc, which is populated
	// by cue/load for both [load.Config.FS] and [load.Config.Overlay].
	loc := pos.File().FSLoc()
	dir := path.Dir(loc.Path)
	if c.dir != dir {
		fsys, err := iofs.Sub(loc.FS, dir)
		if err != nil {
			return nil, errors.Newf(pos, "cannot embed: %v", err)
		}
		c.fs = fsys
		c.dir = dir
	}

	file, glob, typ, allowEmptyGlob, err := validateAttr(a)
	if err != nil {
		return nil, err
	}

	if file != "" {
		for dir := path.Dir(file); dir != "."; dir = path.Dir(dir) {
			if _, err := iofs.Stat(c.fs, path.Join(dir, "cue.mod")); err == nil {
				return nil, errors.Newf(pos, "cannot embed file %q: in different module", file)
			}
		}
		return c.decodeFile(file, typ)
	}
	return c.processGlob(glob, typ, allowEmptyGlob)
}

func (c *injector) processGlob(glob, scope string, allowEmptyGlob bool) (adt.Expr, errors.Error) {
	m := &adt.StructLit{}

	matches, err := fsGlob(c.fs, glob)
	if err != nil {
		return nil, errors.Promote(err, "failed to match glob")
	}
	if len(matches) == 0 && !allowEmptyGlob {
		return nil, errors.Newf(c.pos, "no matches for glob pattern %q", glob)
	}

	dirs := make(map[string]string)
	for _, f := range matches {
		// TODO: lots of stat calls happening in this MVP so another won't hurt.
		// We don't support '**' initially, and '*' only matches files, so skip
		// any directories.
		if fi, err := iofs.Stat(c.fs, f); err != nil {
			return nil, errors.Newf(c.pos, "failed to stat %s: %v", f, err)
		} else if fi.IsDir() {
			continue
		}
		// Add all parents of the embedded file that
		// aren't the current directory (if there's a cue.mod
		// in the current directory, that's the current module
		// not nested).
		for dir := path.Dir(f); dir != "."; dir = path.Dir(dir) {
			dirs[dir] = f
		}

		expr, err := c.decodeFile(f, scope)
		if err != nil {
			return nil, err
		}

		m.Decls = append(m.Decls, &adt.Field{
			Label: c.opCtx.StringLabel(f),
			Value: expr,
		})
	}
	// Check that none of the matches were in a nested module
	// directory.
	for dir, f := range dirs {
		if _, err := iofs.Stat(c.fs, path.Join(dir, "cue.mod")); err == nil {
			return nil, errors.Newf(c.pos, "cannot embed file %q: in different module", f)
		}
	}
	return m, nil
}

func clean(pos token.Pos, s string) (string, errors.Error) {
	file := path.Clean(s)
	if file != s {
		return file, errors.Newf(pos, "path not normalized, use %q instead", file)
	}
	if path.IsAbs(file) {
		return "", errors.Newf(pos, "only relative files are allowed")
	}
	if file == ".." || strings.HasPrefix(file, "../") {
		return "", errors.Newf(pos, "cannot refer to parent directory")
	}
	return file, nil
}

// fsGlob is like [iofs.Glob] but only includes dot-prefixed files
// when the dot is explictly present in an element.
// TODO: add option for including dot files?
func fsGlob(fsys iofs.FS, pattern string) ([]string, error) {
	pattern = path.Clean(pattern)
	matches, err := iofs.Glob(fsys, pattern)
	if err != nil {
		return nil, err
	}
	return filterFsGlobResults(pattern, matches...), nil
}

// filterFsGlobResults applies additional filtering on the given
// matches to only include dot-prefixed files when the dot is
// explictly present in the corresponding pattern element.
func filterFsGlobResults(pattern string, matches ...string) []string {
	patElems := strings.Split(pattern, "/")
	included := func(m string) bool {
		i := 0
		for elem := range strings.SplitSeq(m, "/") {
			// Technically there should never be more elements in m than
			// there are in patElems, but be defensive and check bounds just in case.
			if strings.HasPrefix(elem, ".") && (i >= len(patElems) || !strings.HasPrefix(patElems[i], ".")) {
				return false
			}
			i++
		}
		return true
	}

	i := 0
	for _, m := range matches {
		if included(m) {
			matches[i] = m
			i++
		}
	}
	return matches[:i]
}

func (c *injector) decodeFile(file, scope string) (adt.Expr, errors.Error) {
	// Do not use the most obvious filetypes.Input in order to disable "auto"
	// mode.
	f, err := filetypes.ParseFileAndType(file, scope, filetypes.Def)
	if err != nil {
		return nil, errors.Promote(err, "invalid file type")
	}

	// Open and pre-load the file system using iofs.FS.
	r, err := c.fs.Open(file)
	if err != nil {
		return nil, errors.Newf(c.pos, "open %v: no such file or directory", file)
	}
	defer r.Close()

	info, err := r.Stat()
	if err != nil {
		return nil, errors.Promote(err, "failed to decode file")
	}
	if info.IsDir() {
		return nil, errors.Newf(c.pos, "cannot embed directories")
	}
	f.Source = r

	// TODO: this really should be done at the start of the build process.
	// c.b.ExternFiles = append(c.b.ExternFiles, f)

	config := &encoding.Config{
		// TODO: schema is currently the wrong schema, which is a bug in
		// internal/core/runtime. There is also an outstanding design choice:
		// do we imply the schema from the schema of the current field, or do
		// we explicitly enable schema-based encoding with a "schema" argument.
		// In the case of YAML it seems to be better to be explicit. In the case
		// of textproto it seems to be more convenient to do it implicitly.
		// Schema: value.Make(c.opCtx, schema),
	}

	d := encoding.NewDecoder(c.runtime, f, config)
	if err := d.Err(); err != nil {
		return nil, errors.Promote(err, "failed to decode file")
	}

	defer d.Close()

	n := d.File()

	if d.Next(); !d.Done() {
		// TODO: support streaming values
		return nil, errors.Newf(c.pos, "streaming not implemented: found more than one value in file")
	}

	// TODO: each of these encodings should probably be supported in the future
	switch f.Encoding {
	case build.CUE:
		return nil, errors.Newf(c.pos, "encoding %q not (yet) supported", f.Encoding)
	case build.JSONL:
		return nil, errors.Newf(c.pos, "encoding %q not (yet) supported: requires support for streaming", f.Encoding)
	case build.BinaryProto, build.TextProto:
		return nil, errors.Newf(c.pos, "encoding %q not (yet) supported: requires support for schema-guided decoding", f.Encoding)
	}

	val := c.runtime.BuildFile(n)
	if err := val.Err(); err != nil {
		return nil, errors.Promote(err, "failed to build file")
	}

	_, v := value.ToInternal(val)
	return v, nil
}

// EmbeddedPaths walks all the embed attributes in the given file,
// returning a slice of [Embed] structs for attributes
// that were successfully validated, and errors for those which were
// not. The filepath should be the filepath of the file from which
// these attributes were extracted, and relative to whatever root is
// going to be used in calls to [Embed.Matches] and [Embed.FindAll].
func EmbeddedPaths(filepath string, syntax *ast.File) ([]*Embed, errors.Error) {
	extAttrs, err := runtime.ExternAttrsForFile(syntax)
	if err != nil {
		return nil, err
	}
	if extAttrs.TopLevel[EmbedKind] == nil {
		return nil, nil
	}
	var errs errors.Error
	var embeds []*Embed
	for attr := range extAttrs.Body {
		if attr.Attr.Name != EmbedKind {
			continue
		}
		if err := attr.Attr.Err; err != nil {
			errs = errors.Append(errs, err)
			continue
		}
		file, glob, typ, allowEmptyGlob, err := validateAttr(attr.Attr)
		if err != nil {
			errs = errors.Append(errs, err)
			continue
		}
		embed := &Embed{
			Node:      attr.Parent,
			Attribute: attr.Attr,
			FilePath:  filepath,
			Type:      typ,
		}
		if file != "" {
			embed.interpreter = &embeddedFile{
				filepath: file,
			}
			embeds = append(embeds, embed)
		} else if glob != "" {
			embed.interpreter = &embeddedGlob{
				glob:           glob,
				allowEmptyGlob: allowEmptyGlob,
			}
			embeds = append(embeds, embed)
		}
	}
	return embeds, errs
}

type Embed struct {
	Node        ast.Node
	Attribute   *internal.Attr
	FilePath    string
	Type        string
	interpreter embedInterpreter
}

// Matches reports whether the provided filepath is matched by this
// [Embed] attribute. The filepath should be relative to the same root
// as the filepath provided to [EmbeddedPaths]. E.g. if in
// `/wibble/foo/bar.cue` you have `@embed(filename=a/b.json)`, and
// `foo/bar.cue` is the filepath passed to [EmbeddedPaths], then
// [Embed.Matches] will return true if called with `foo/a/b.json`.
func (e *Embed) Matches(filepath string) bool {
	return e.interpreter.matches(e, filepath)
}

// FindAll uses the provided fs to report all the filepaths that
// match this [Embed] attribute. The fs must be relative to the same
// root as the filepath provided to [EmbeddedPaths]. I.e. for the
// filepath provided to [EmbeddedPaths], iofs.Stat(fs, filepath)
// should be accessing the same file which contained this [Embed]
// attribute.
func (e *Embed) FindAll(fs iofs.FS) ([]string, error) {
	return e.interpreter.findAll(e, fs)
}

// IsGlob reports whether this [Embed] attribute represents a glob
// embedding.
func (e *Embed) IsGlob() bool {
	_, isGlob := e.interpreter.(*embeddedGlob)
	return isGlob
}

type embedInterpreter interface {
	// NB: All filepaths (including any within the Embed) are
	// considered relative to the same root.

	matches(e *Embed, filepath string) bool
	findAll(e *Embed, fs iofs.FS) ([]string, error)
}

type embeddedFile struct {
	filepath string
}

func (ef *embeddedFile) matches(e *Embed, filepath string) bool {
	dir := path.Dir(e.FilePath)
	return filepath == path.Join(dir, ef.filepath)
}

func (ef *embeddedFile) findAll(e *Embed, fs iofs.FS) ([]string, error) {
	dir := path.Dir(e.FilePath)
	filepath := path.Join(dir, ef.filepath)
	info, err := iofs.Stat(fs, filepath)
	if err != nil {
		return nil, errors.Wrapf(err, e.Attribute.Pos, "failed to stat %s: %v", filepath, err)
	}
	if info.IsDir() {
		return nil, errors.Newf(e.Attribute.Pos, "%v is a directory", filepath)
	}
	return []string{filepath}, nil
}

type embeddedGlob struct {
	glob           string
	allowEmptyGlob bool
}

func (eg *embeddedGlob) matches(e *Embed, filepath string) bool {
	dir := path.Dir(e.FilePath)
	if dir != "." {
		wasCut := false
		filepath, wasCut = strings.CutPrefix(filepath, dir+"/")
		if !wasCut {
			return false
		}
	}
	result, err := pkgpath.Match(eg.glob, filepath, pkgpath.Unix)
	if !result || err != nil {
		return false
	}
	return len(filterFsGlobResults(eg.glob, filepath)) == 1
}

func (eg *embeddedGlob) findAll(e *Embed, fs iofs.FS) ([]string, error) {
	dir := path.Dir(e.FilePath)
	fs, err := iofs.Sub(fs, dir)
	if err != nil {
		return nil, errors.Wrapf(err, e.Attribute.Pos, "%v", err)
	}
	filepaths, err := fsGlob(fs, eg.glob)
	if err != nil {
		return nil, errors.Wrapf(err, e.Attribute.Pos, "%v", err)
	}
	if !eg.allowEmptyGlob && len(filepaths) == 0 {
		return nil, errors.Newf(e.Attribute.Pos, "no matches for glob pattern %q", eg.glob)
	}
	for i, filepath := range filepaths {
		filepaths[i] = path.Join(dir, filepath)
	}
	return filepaths, nil
}
