// Copyright 2019 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0

package filesys

import (
	"bytes"
	"fmt"
	"io"
	"log"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"

	"github.com/pkg/errors"
)

var _ File = &fsNode{}
var _ FileSystem = &fsNode{}

// fsNode is either a file or a directory.
type fsNode struct {
	// What node owns me?
	parent *fsNode

	// Value to return as the Name() when the
	// parent is nil.
	nilParentName string

	// A directory mapping names to nodes.
	// If dir is nil, then self node is a file.
	// If dir is non-nil, then self node is a directory,
	// albeit possibly an empty directory.
	dir map[string]*fsNode

	// if this node is a file, this is the content.
	content []byte

	// if offset is not nil the file is open and it tracks
	// the current file offset.
	offset *int
}

// MakeEmptyDirInMemory returns an empty directory.
// The paths of nodes in this object will never
// report a leading Separator, meaning they
// aren't "absolute" in the sense defined by
// https://golang.org/pkg/path/filepath/#IsAbs.
func MakeEmptyDirInMemory() *fsNode {
	return &fsNode{
		dir: make(map[string]*fsNode),
	}
}

// MakeFsInMemory returns an empty 'file system'.
// The paths of nodes in this object will always
// report a leading Separator, meaning they
// are "absolute" in the sense defined by
// https://golang.org/pkg/path/filepath/#IsAbs.
// This is a relevant difference when using Walk,
// Glob, Match, etc.
func MakeFsInMemory() FileSystem {
	return &fsNode{
		nilParentName: Separator,
		dir:           make(map[string]*fsNode),
	}
}

// Name returns the name of the node.
func (n *fsNode) Name() string {
	if n.parent == nil {
		// Unable to lookup name in parent.
		return n.nilParentName
	}
	if !n.parent.isNodeADir() {
		log.Fatal("parent not a dir")
	}
	for key, value := range n.parent.dir {
		if value == n {
			return key
		}
	}
	log.Fatal("unable to find fsNode name")
	return ""
}

// Path returns the full path to the node.
func (n *fsNode) Path() string {
	if n.parent == nil {
		return n.nilParentName
	}
	if !n.parent.isNodeADir() {
		log.Fatal("parent not a dir, structural error")
	}
	return filepath.Join(n.parent.Path(), n.Name())
}

// mySplit trims trailing separators from the directory
// result of filepath.Split.
func mySplit(s string) (string, string) {
	dName, fName := filepath.Split(s)
	return StripTrailingSeps(dName), fName
}

func (n *fsNode) addFile(name string, c []byte) (result *fsNode, err error) {
	parent := n
	dName, fileName := mySplit(name)
	if dName != "" {
		parent, err = parent.addDir(dName)
		if err != nil {
			return nil, err
		}
	}
	if !isLegalFileNameForCreation(fileName) {
		return nil, fmt.Errorf(
			"illegal name '%s' in file creation", fileName)
	}
	result, ok := parent.dir[fileName]
	if ok {
		// File already exists; overwrite it.
		if result.offset != nil {
			return nil, fmt.Errorf("cannot add already opened file '%s'", n.Path())
		}
		result.content = append(result.content[:0], c...)
		return result, nil
	}
	result = &fsNode{
		content: append([]byte(nil), c...),
		parent:  parent,
	}
	parent.dir[fileName] = result
	return result, nil
}

// Create implements FileSystem.
// Create makes an empty file.
func (n *fsNode) Create(path string) (result File, err error) {
	f, err := n.AddFile(path, nil)
	if err != nil {
		return f, err
	}
	f.offset = new(int)
	return f, nil
}

// WriteFile implements FileSystem.
func (n *fsNode) WriteFile(path string, d []byte) error {
	_, err := n.AddFile(path, d)
	return err
}

// AddFile adds a file and any necessary containing
// directories to the node.
func (n *fsNode) AddFile(
	name string, c []byte) (result *fsNode, err error) {
	if n.dir == nil {
		return nil, fmt.Errorf(
			"cannot add a file to a non-directory '%s'", n.Name())
	}
	return n.addFile(cleanQueryPath(name), c)
}

func (n *fsNode) addDir(path string) (result *fsNode, err error) {

	parent := n
	dName, subDirName := mySplit(path)
	if dName != "" {
		parent, err = n.addDir(dName)
		if err != nil {
			return nil, err
		}
	}
	switch subDirName {
	case "", SelfDir:
		return n, nil
	case ParentDir:
		if n.parent == nil {
			return nil, fmt.Errorf(
				"cannot add a directory above '%s'", n.Path())
		}
		return n.parent, nil
	default:
		if !isLegalFileNameForCreation(subDirName) {
			return nil, fmt.Errorf(
				"illegal name '%s' in directory creation", subDirName)
		}
		result, ok := parent.dir[subDirName]
		if ok {
			if result.isNodeADir() {
				// it's already there.
				return result, nil
			}
			return nil, fmt.Errorf(
				"cannot make dir '%s'; a file of that name already exists in '%s'",
				subDirName, parent.Name())
		}
		result = &fsNode{
			dir:    make(map[string]*fsNode),
			parent: parent,
		}
		parent.dir[subDirName] = result
		return result, nil
	}
}

// Mkdir implements FileSystem.
// Mkdir creates a directory.
func (n *fsNode) Mkdir(path string) error {
	_, err := n.AddDir(path)
	return err
}

// MkdirAll implements FileSystem.
// MkdirAll creates a directory.
func (n *fsNode) MkdirAll(path string) error {
	_, err := n.AddDir(path)
	return err
}

// AddDir adds a directory to the node, not complaining
// if it is already there.
func (n *fsNode) AddDir(path string) (result *fsNode, err error) {
	if n.dir == nil {
		return nil, fmt.Errorf(
			"cannot add a directory to file node '%s'", n.Name())
	}
	return n.addDir(cleanQueryPath(path))
}

// CleanedAbs implements FileSystem.
func (n *fsNode) CleanedAbs(path string) (ConfirmedDir, string, error) {
	node, err := n.Find(path)
	if err != nil {
		return "", "", errors.Wrap(err, "unable to clean")
	}
	if node == nil {
		return "", "", notExistError(path)
	}
	if node.isNodeADir() {
		return ConfirmedDir(node.Path()), "", nil
	}
	return ConfirmedDir(node.parent.Path()), node.Name(), nil
}

// Exists implements FileSystem.
// Exists returns true if the path exists.
func (n *fsNode) Exists(path string) bool {
	if !n.isNodeADir() {
		return n.Name() == path
	}
	result, err := n.Find(path)
	if err != nil {
		return false
	}
	return result != nil
}

func cleanQueryPath(path string) string {
	// Always ignore leading separator?
	// Remember that filepath.Clean returns "." if
	// given an empty string argument.
	return filepath.Clean(StripLeadingSeps(path))
}

// Find finds the given node, else nil if not found.
// Return error on structural/argument errors.
func (n *fsNode) Find(path string) (*fsNode, error) {
	if !n.isNodeADir() {
		return nil, fmt.Errorf("can only find inside a dir")
	}
	if path == "" {
		// Special case; check *before* cleaning and *before*
		// comparison to nilParentName.
		return nil, nil
	}
	if (n.parent == nil && path == n.nilParentName) || path == SelfDir {
		// Special case
		return n, nil
	}
	return n.findIt(cleanQueryPath(path))
}

func (n *fsNode) findIt(path string) (result *fsNode, err error) {
	parent := n
	dName, item := mySplit(path)
	if dName != "" {
		parent, err = n.findIt(dName)
		if err != nil {
			return nil, err
		}
		if parent == nil {
			// all done, target doesn't exist.
			return nil, nil
		}
	}
	if !parent.isNodeADir() {
		return nil, fmt.Errorf("'%s' is not a directory", parent.Path())
	}
	return parent.dir[item], nil
}

// RemoveAll implements FileSystem.
// RemoveAll removes an item and everything it contains.
func (n *fsNode) RemoveAll(path string) error {
	result, err := n.Find(path)
	if err != nil {
		return err
	}
	if result == nil {
		// If the path doesn't exist, no need to remove anything.
		return nil
	}
	return result.Remove()
}

// Remove drop the node, and everything it contains, from its parent.
func (n *fsNode) Remove() error {
	if n.parent == nil {
		return fmt.Errorf("cannot remove a root node")
	}
	if !n.parent.isNodeADir() {
		log.Fatal("parent not a dir")
	}
	for key, value := range n.parent.dir {
		if value == n {
			delete(n.parent.dir, key)
			return nil
		}
	}
	log.Fatal("unable to find self in parent")
	return nil
}

// isNodeADir returns true if the node is a directory.
// Cannot collide with the poorly named "IsDir".
func (n *fsNode) isNodeADir() bool {
	return n.dir != nil
}

// IsDir implements FileSystem.
// IsDir returns true if the argument resolves
// to a directory rooted at the node.
func (n *fsNode) IsDir(path string) bool {
	result, err := n.Find(path)
	if err != nil || result == nil {
		return false
	}
	return result.isNodeADir()
}

// ReadDir implements FileSystem.
func (n *fsNode) ReadDir(path string) ([]string, error) {
	if !n.Exists(path) {
		return nil, notExistError(path)
	}
	if !n.IsDir(path) {
		return nil, fmt.Errorf("%s is not a directory", path)
	}

	dir, err := n.Find(path)
	if err != nil {
		return nil, err
	}
	if dir == nil {
		return nil, fmt.Errorf("could not find directory %s", path)
	}

	keys := make([]string, len(dir.dir))
	i := 0
	for k := range dir.dir {
		keys[i] = k
		i++
	}
	return keys, nil
}

// Size returns the size of the node.
func (n *fsNode) Size() int64 {
	if n.isNodeADir() {
		return int64(len(n.dir))
	}
	return int64(len(n.content))
}

// Open implements FileSystem.
// Open opens the node in read-write mode and sets the offset its start.
// Writing right after opening the file will replace the original content
// and move the offset forward, as with a file opened with O_RDWR | O_CREATE.
//
// As an example, let's consider a file with content "content":
// - open: sets offset to start, content is "content"
// - write "@": offset increases by one, the content is now "@ontent"
// - read the rest: since offset is 1, the read operation returns "ontent"
// - write "$": offset is at EOF, so "$" is appended and content is now "@ontent$"
// - read the rest: returns 0 bytes and EOF
// - close: the content is still "@ontent$"
func (n *fsNode) Open(path string) (File, error) {
	result, err := n.Find(path)
	if err != nil {
		return nil, err
	}
	if result == nil {
		return nil, notExistError(path)
	}
	if result.offset != nil {
		return nil, fmt.Errorf("cannot open previously opened file '%s'", path)
	}
	result.offset = new(int)
	return result, nil
}

// Close marks the node closed.
func (n *fsNode) Close() error {
	if n.offset == nil {
		return fmt.Errorf("cannot close already closed file '%s'", n.Path())
	}
	n.offset = nil
	return nil
}

// ReadFile implements FileSystem.
func (n *fsNode) ReadFile(path string) (c []byte, err error) {
	result, err := n.Find(path)
	if err != nil {
		return nil, err
	}
	if result == nil {
		return nil, notExistError(path)
	}
	if result.isNodeADir() {
		return nil, fmt.Errorf("cannot read content from non-file '%s'", n.Path())
	}
	c = make([]byte, len(result.content))
	copy(c, result.content)
	return c, nil
}

// Read returns the content of the file node.
func (n *fsNode) Read(d []byte) (c int, err error) {
	if n.isNodeADir() {
		return 0, fmt.Errorf(
			"cannot read content from non-file '%s'", n.Path())
	}
	if n.offset == nil {
		return 0, fmt.Errorf("cannot read from closed file '%s'", n.Path())
	}

	rest := n.content[*n.offset:]
	if len(d) < len(rest) {
		rest = rest[:len(d)]
	} else {
		err = io.EOF
	}
	copy(d, rest)
	*n.offset += len(rest)
	return len(rest), err
}

// Write saves the contents of the argument to the file node.
func (n *fsNode) Write(p []byte) (c int, err error) {
	if n.isNodeADir() {
		return 0, fmt.Errorf(
			"cannot write content to non-file '%s'", n.Path())
	}
	if n.offset == nil {
		return 0, fmt.Errorf("cannot write to closed file '%s'", n.Path())
	}
	n.content = append(n.content[:*n.offset], p...)
	*n.offset = len(n.content)
	return len(p), nil
}

// ContentMatches returns true if v matches fake file's content.
func (n *fsNode) ContentMatches(v []byte) bool {
	return bytes.Equal(v, n.content)
}

// GetContent the content of a fake file.
func (n *fsNode) GetContent() []byte {
	return n.content
}

// Stat returns an instance of FileInfo.
func (n *fsNode) Stat() (os.FileInfo, error) {
	return fileInfo{node: n}, nil
}

// Walk implements FileSystem.
func (n *fsNode) Walk(path string, walkFn filepath.WalkFunc) error {
	result, err := n.Find(path)
	if err != nil {
		return err
	}
	if result == nil {
		return notExistError(path)
	}
	return result.WalkMe(walkFn)
}

// Walk runs the given walkFn on each node.
func (n *fsNode) WalkMe(walkFn filepath.WalkFunc) error {
	fi, err := n.Stat()
	// always visit self first
	err = walkFn(n.Path(), fi, err)
	if !n.isNodeADir() {
		// it's a file, so nothing more to do
		return err
	}
	// process self as a directory
	if err == filepath.SkipDir {
		return nil
	}
	// Walk is supposed to visit in lexical order.
	for _, k := range n.sortedDirEntries() {
		if err := n.dir[k].WalkMe(walkFn); err != nil {
			if err == filepath.SkipDir {
				// stop processing this directory
				break
			}
			// bail out completely
			return err
		}
	}
	return nil
}

func (n *fsNode) sortedDirEntries() []string {
	keys := make([]string, len(n.dir))
	i := 0
	for k := range n.dir {
		keys[i] = k
		i++
	}
	sort.Strings(keys)
	return keys
}

// FileCount returns a count of files.
// Directories, empty or otherwise, not counted.
func (n *fsNode) FileCount() int {
	count := 0
	n.WalkMe(func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if !info.IsDir() {
			count++
		}
		return nil
	})
	return count
}

func (n *fsNode) DebugPrint() {
	n.WalkMe(func(path string, info os.FileInfo, err error) error {
		if err != nil {
			fmt.Printf("err '%v' at path %q\n", err, path)
			return nil
		}
		if info.IsDir() {
			if info.Size() == 0 {
				fmt.Println("empty dir: " + path)
			}
		} else {
			fmt.Println("     file: " + path)
		}
		return nil
	})
}

var legalFileNamePattern = regexp.MustCompile("^[a-zA-Z0-9-_.]+$")

// This rules enforced here should be simpler and tighter
// than what's allowed on a real OS.
// Should be fine for testing or in-memory purposes.
func isLegalFileNameForCreation(n string) bool {
	if n == "" || n == SelfDir || !legalFileNamePattern.MatchString(n) {
		return false
	}
	return !strings.Contains(n, ParentDir)
}

// RegExpGlob returns a list of file paths matching the regexp.
// Excludes directories.
func (n *fsNode) RegExpGlob(pattern string) ([]string, error) {
	var result []string
	var expression = regexp.MustCompile(pattern)
	err := n.WalkMe(func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if !info.IsDir() {
			if expression.MatchString(path) {
				result = append(result, path)
			}
		}
		return nil
	})
	if err != nil {
		return nil, err
	}
	sort.Strings(result)
	return result, nil
}

// Glob implements FileSystem.
// Glob returns the list of file paths matching
// per filepath.Match semantics, i.e. unlike RegExpGlob,
// Match("foo/a*") will not match sub-sub directories of foo.
// This is how /bin/ls behaves.
func (n *fsNode) Glob(pattern string) ([]string, error) {
	var result []string
	err := n.WalkMe(func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if !info.IsDir() {
			match, err := filepath.Match(pattern, path)
			if err != nil {
				return err
			}
			if match {
				result = append(result, path)
			}
		}
		return nil
	})
	if err != nil {
		return nil, err
	}
	sort.Strings(result)
	return result, nil
}

// notExistError indicates that a file or directory does not exist.
// Unwrapping returns os.ErrNotExist so errors.Is(err, os.ErrNotExist) works correctly.
type notExistError string

func (err notExistError) Error() string { return fmt.Sprintf("'%s' doesn't exist", string(err)) }
func (err notExistError) Unwrap() error { return os.ErrNotExist }
