package spec

import (
	"bytes"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"path"
	"sort"
	"strings"

	"gopkg.in/yaml.v3"
)

// metaFile is the on-disk shape of meta.yaml. It mirrors Metadata plus the
// action reference list; Metadata is the clean public projection (no action
// refs), so metaFile stays load/encode-internal.
type metaFile struct {
	SchemaVersion int         `yaml:"schema_version"`
	Digest        string      `yaml:"digest,omitempty"`
	Domain        string      `yaml:"domain"`
	Version       int         `yaml:"version"`
	Name          string      `yaml:"name"`
	Description   string      `yaml:"description,omitempty"`
	Source        Source      `yaml:"source,omitempty"`
	Endpoint      Endpoint    `yaml:"endpoint"`
	Auth          Auth        `yaml:"auth,omitempty"`
	Actions       []ActionRef `yaml:"actions"`
}

// LoadDefinition reads and validates exactly one Definition package rooted at
// root within fsys. It returns a sorted, detached copy. Source provenance is
// display-only: this function never opens source.path or source.url.
func LoadDefinition(fsys fs.FS, root string) (Package, error) {
	root = strings.Trim(root, "/")
	if root == "." {
		root = ""
	}

	metaPath := joinPath(root, "meta.yaml")
	if err := assertNoSymlink(fsys, metaPath); err != nil {
		return Package{}, err
	}
	rawMeta, err := fs.ReadFile(fsys, metaPath)
	if err != nil {
		return Package{}, fmt.Errorf("read %q: %w", metaPath, err)
	}
	mf, err := decodeMeta(rawMeta)
	if err != nil {
		return Package{}, fmt.Errorf("meta.yaml: %w", err)
	}
	meta := Metadata{
		SchemaVersion: mf.SchemaVersion,
		Digest:        mf.Digest,
		Domain:        mf.Domain,
		Version:       mf.Version,
		Name:          mf.Name,
		Description:   mf.Description,
		Source:        mf.Source,
		Endpoint:      mf.Endpoint,
		Auth:          mf.Auth,
	}
	if err := validateMetadata(meta); err != nil {
		return Package{}, err
	}
	if err := validateActionRefs(mf.Actions); err != nil {
		return Package{}, err
	}

	listed := make(map[string]struct{}, len(mf.Actions))
	actions := make([]Action, 0, len(mf.Actions))
	for _, ref := range mf.Actions {
		full := joinPath(root, ref.File)
		if err := assertNoSymlink(fsys, full); err != nil {
			return Package{}, err
		}
		raw, err := fs.ReadFile(fsys, full)
		if err != nil {
			return Package{}, fmt.Errorf("read action %q: %w", ref.Name, err)
		}
		act, err := decodeAction(raw)
		if err != nil {
			return Package{}, fmt.Errorf("action %q: %w", ref.Name, err)
		}
		if act.Name != ref.Name {
			return Package{}, fmt.Errorf("action %q: file declares name %q (basename must equal action name and meta name)", ref.Name, act.Name)
		}
		// Compile/validate schemas (dialect + Draft 2020-12) before binding
		// validation so a malformed schema is reported as such rather than as a
		// pointer-existence error against an invalid schema.
		compiled, err := compileInputSchema(act.Name, act.Catalog.InputSchema)
		if err != nil {
			return Package{}, err
		}
		if act.Catalog.OutputSchema != nil {
			if _, err := compileInputSchema(act.Name, act.Catalog.OutputSchema); err != nil {
				return Package{}, err
			}
		}
		if err := validateAction(act, meta.Auth); err != nil {
			return Package{}, err
		}
		if err := validateExample(act.Name, compiled, act.Catalog.Example); err != nil {
			return Package{}, err
		}
		act.File = ref.File
		actions = append(actions, act)
		listed[ref.File] = struct{}{}
	}

	if err := detectUnlistedActions(fsys, root, listed); err != nil {
		return Package{}, err
	}

	sort.Slice(actions, func(i, j int) bool { return actions[i].Name < actions[j].Name })
	for i := range actions {
		actions[i].Catalog.InputSchema = copySchema(actions[i].Catalog.InputSchema)
		actions[i].Catalog.OutputSchema = copySchema(actions[i].Catalog.OutputSchema)
		if actions[i].Binding.StaticHeaders != nil {
			cp := make(map[string]string, len(actions[i].Binding.StaticHeaders))
			for k, v := range actions[i].Binding.StaticHeaders {
				cp[k] = v
			}
			actions[i].Binding.StaticHeaders = cp
		}
	}

	return Package{Metadata: meta, Actions: actions, Digest: meta.Digest}, nil
}

// LoadDefinitions loads every immediate subdirectory of root as a package and
// returns the packages sorted by domain. Each subdirectory must be a valid
// package.
func LoadDefinitions(fsys fs.FS, root string) ([]Package, error) {
	root = strings.Trim(root, "/")
	if root == "." {
		root = ""
	}
	listDir := root
	if listDir == "" {
		listDir = "."
	}
	entries, err := fs.ReadDir(fsys, listDir)
	if err != nil {
		return nil, fmt.Errorf("read packages root %q: %w", root, err)
	}
	var pkgs []Package
	for _, e := range entries {
		if !e.IsDir() {
			continue
		}
		name := e.Name()
		if strings.HasPrefix(name, ".") {
			continue
		}
		pkg, err := LoadDefinition(fsys, joinPath(root, name))
		if err != nil {
			return nil, fmt.Errorf("package %q: %w", name, err)
		}
		pkgs = append(pkgs, pkg)
	}
	sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].Metadata.Domain < pkgs[j].Metadata.Domain })
	return pkgs, nil
}

// decodeMeta decodes meta.yaml strictly: unknown fields are rejected and the
// stream must contain exactly one document.
func decodeMeta(data []byte) (metaFile, error) {
	var mf metaFile
	if err := decodeYAMLStrict(data, &mf); err != nil {
		return metaFile{}, err
	}
	return mf, nil
}

func decodeAction(data []byte) (Action, error) {
	var act Action
	if err := decodeYAMLStrict(data, &act); err != nil {
		return Action{}, err
	}
	return act, nil
}

// decodeYAMLStrict enforces KnownFields(true) and a single YAML document, the
// two guards that keep the on-disk format closed.
func decodeYAMLStrict(data []byte, out any) error {
	dec := yaml.NewDecoder(bytes.NewReader(data))
	dec.KnownFields(true)
	if err := dec.Decode(out); err != nil {
		if err == io.EOF {
			return fmt.Errorf("empty YAML document")
		}
		var typeErr *yaml.TypeError
		if errors.As(err, &typeErr) {
			return fmt.Errorf("unknown field(s): %w", err)
		}
		return err
	}
	// A second decode must hit EOF: exactly one document allowed.
	var extra any
	if err := dec.Decode(&extra); err != io.EOF {
		if err == nil {
			return fmt.Errorf("expected a single YAML document, found multiple")
		}
		return fmt.Errorf("trailing YAML content: %w", err)
	}
	return nil
}

// detectUnlistedActions rejects any *.yaml file under <root>/actions that is
// not referenced by meta.yaml.
func detectUnlistedActions(fsys fs.FS, root string, listed map[string]struct{}) error {
	actionsDir := joinPath(root, "actions")
	if _, err := fs.Stat(fsys, actionsDir); err != nil {
		if isNotExist(err) {
			return nil
		}
		return fmt.Errorf("stat actions dir: %w", err)
	}
	prefix := ""
	if root != "" {
		prefix = root + "/"
	}
	return fs.WalkDir(fsys, actionsDir, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			if isNotExist(err) {
				return nil
			}
			return err
		}
		if d.IsDir() || !strings.HasSuffix(p, ".yaml") {
			return nil
		}
		rel := strings.TrimPrefix(p, prefix)
		if _, ok := listed[rel]; !ok {
			return fmt.Errorf("unlisted action file %q is not referenced by meta.yaml", p)
		}
		return nil
	})
}

func isNotExist(err error) bool {
	return errors.Is(err, fs.ErrNotExist)
}

// confinePath rejects absolute, traversal, and unclean relative paths. It
// returns the cleaned path when the reference stays inside the package root.
func confinePath(rel string) (string, error) {
	if rel == "" {
		return "", fmt.Errorf("empty path")
	}
	if strings.ContainsAny(rel, "\\\x00") {
		return "", fmt.Errorf("path %q contains a forbidden separator", rel)
	}
	if path.IsAbs(rel) {
		return "", fmt.Errorf("absolute path %q is not allowed", rel)
	}
	cleaned := path.Clean(rel)
	for _, seg := range strings.Split(cleaned, "/") {
		if seg == ".." {
			return "", fmt.Errorf("path %q escapes the package directory (traversal)", rel)
		}
	}
	return cleaned, nil
}

// fileBase returns the action file's basename without the .yaml suffix; this is
// the identity that must equal the action name.
func fileBase(rel string) string {
	base := path.Base(rel)
	return strings.TrimSuffix(base, ".yaml")
}

func joinPath(root, rel string) string {
	if root == "" {
		return rel
	}
	return root + "/" + rel
}

// assertNoSymlink walks each component of p (an fsys-rooted path) and rejects
// any symlink, which could otherwise escape the package directory. Detection
// uses DirEntry.Type (lstat-based) so it works on os.DirFS. The walk starts at
// "." because fs.ReadDir(fsys, "") is rejected by os.DirFS.
func assertNoSymlink(fsys fs.FS, p string) error {
	segments := strings.Split(p, "/")
	dir := "."
	for _, seg := range segments {
		if seg == "" || seg == "." {
			continue
		}
		entries, err := fs.ReadDir(fsys, dir)
		if err != nil {
			// best-effort: a filesystem without directory listing cannot be
			// checked here; lexical confinement still applies.
			return nil
		}
		for _, e := range entries {
			if e.Name() != seg {
				continue
			}
			if e.Type()&fs.ModeSymlink != 0 {
				return fmt.Errorf("path %q contains symlink %q (escape forbidden)", p, joinPath(dir, seg))
			}
			break
		}
		if dir == "." {
			dir = seg
		} else {
			dir = dir + "/" + seg
		}
	}
	return nil
}
