// Copyright 2016 the Go-FUSE Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package unionfs

import (
	"fmt"
	"io/ioutil"
	"log"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"syscall"
	"time"

	"github.com/hanwen/go-fuse/v2/fuse"
	"github.com/hanwen/go-fuse/v2/fuse/nodefs"
	"github.com/hanwen/go-fuse/v2/fuse/pathfs"
)

type knownFs struct {
	unionFS pathfs.FileSystem
	nodeFS  *pathfs.PathNodeFs
}

// Creates unions for all files under a given directory,
// walking the tree and looking for directories D which have a
// D/READONLY symlink.
//
// A union for A/B/C will placed under directory A-B-C.
type autoUnionFs struct {
	pathfs.FileSystem
	debug bool

	lock             sync.RWMutex
	zombies          map[string]bool
	knownFileSystems map[string]knownFs
	nameRootMap      map[string]string
	root             string

	nodeFs  *pathfs.PathNodeFs
	options *AutoUnionFsOptions
}

type AutoUnionFsOptions struct {
	UnionFsOptions

	nodefs.Options
	pathfs.PathNodeFsOptions

	// If set, run updateKnownFses() after mounting.
	UpdateOnMount bool

	// If set hides the _READONLY file.
	HideReadonly bool

	// Expose this version in /status/gounionfs_version
	Version string
}

const (
	_READONLY    = "READONLY"
	_STATUS      = "status"
	_CONFIG      = "config"
	_DEBUG       = "debug"
	_ROOT        = "root"
	_VERSION     = "gounionfs_version"
	_SCAN_CONFIG = ".scan_config"
)

func NewAutoUnionFs(directory string, options AutoUnionFsOptions) pathfs.FileSystem {
	if options.HideReadonly {
		options.HiddenFiles = append(options.HiddenFiles, _READONLY)
	}
	a := &autoUnionFs{
		knownFileSystems: make(map[string]knownFs),
		nameRootMap:      make(map[string]string),
		zombies:          make(map[string]bool),
		options:          &options,
		FileSystem:       pathfs.NewDefaultFileSystem(),
	}

	directory, err := filepath.Abs(directory)
	if err != nil {
		panic("filepath.Abs returned err")
	}
	a.root = directory
	return a
}

func (fs *autoUnionFs) String() string {
	return fmt.Sprintf("autoUnionFs(%s)", fs.root)
}

func (fs *autoUnionFs) OnMount(nodeFs *pathfs.PathNodeFs) {
	fs.nodeFs = nodeFs
	if fs.options.UpdateOnMount {
		time.AfterFunc(100*time.Millisecond, func() { fs.updateKnownFses() })
	}
}

func (fs *autoUnionFs) addAutomaticFs(roots []string) {
	relative := strings.TrimLeft(strings.Replace(roots[0], fs.root, "", -1), "/")
	name := strings.Replace(relative, "/", "-", -1)

	if fs.getUnionFs(name) == nil {
		fs.addFs(name, roots)
	}
}

func (fs *autoUnionFs) createFs(name string, roots []string) fuse.Status {
	fs.lock.Lock()
	defer fs.lock.Unlock()

	if fs.zombies[name] {
		log.Printf("filesystem named %q is being removed", name)
		return fuse.EBUSY
	}

	for workspace, root := range fs.nameRootMap {
		if root == roots[0] && workspace != name {
			log.Printf("Already have a union FS for directory %s in workspace %s",
				roots[0], workspace)
			return fuse.EBUSY
		}
	}

	known := fs.knownFileSystems[name]
	if known.unionFS != nil {
		log.Println("Already have a workspace:", name)
		return fuse.EBUSY
	}

	ufs, err := NewUnionFsFromRoots(roots, &fs.options.UnionFsOptions, true)
	if err != nil {
		log.Println("Could not create UnionFs:", err)
		return fuse.EPERM
	}

	log.Printf("Adding workspace %v for roots %v", name, ufs.String())
	nfs := pathfs.NewPathNodeFs(ufs, &fs.options.PathNodeFsOptions)
	code := fs.nodeFs.Mount(name, nfs.Root(), &fs.options.Options)
	if code.Ok() {
		fs.knownFileSystems[name] = knownFs{
			ufs,
			nfs,
		}
		fs.nameRootMap[name] = roots[0]
	}
	return code
}

func (fs *autoUnionFs) rmFs(name string) (code fuse.Status) {
	fs.lock.Lock()
	defer fs.lock.Unlock()

	if fs.zombies[name] {
		return fuse.ENOENT
	}

	known := fs.knownFileSystems[name]
	if known.unionFS == nil {
		return fuse.ENOENT
	}

	root := fs.nameRootMap[name]
	delete(fs.knownFileSystems, name)
	delete(fs.nameRootMap, name)
	fs.zombies[name] = true
	fs.lock.Unlock()
	code = fs.nodeFs.Unmount(name)

	fs.lock.Lock()
	delete(fs.zombies, name)
	if !code.Ok() {
		// Reinstate.
		log.Printf("Unmount failed for %s.  Code %v", name, code)
		fs.knownFileSystems[name] = known
		fs.nameRootMap[name] = root
	}
	return code
}

func (fs *autoUnionFs) addFs(name string, roots []string) (code fuse.Status) {
	if name == _CONFIG || name == _STATUS || name == _SCAN_CONFIG {
		return fuse.EINVAL
	}
	return fs.createFs(name, roots)
}

func (fs *autoUnionFs) getRoots(path string) []string {
	ro := filepath.Join(path, _READONLY)
	fi, err := os.Lstat(ro)
	fiDir, errDir := os.Stat(ro)
	if err != nil || errDir != nil {
		return nil
	}

	if fi.Mode()&os.ModeSymlink != 0 && fiDir.IsDir() {
		// TODO - should recurse and chain all READONLYs
		// together.
		return []string{path, ro}
	}
	return nil
}

func (fs *autoUnionFs) visit(path string, fi os.FileInfo, err error) error {
	if fi != nil && fi.IsDir() {
		roots := fs.getRoots(path)
		if roots != nil {
			fs.addAutomaticFs(roots)
		}
	}
	return nil
}

func (fs *autoUnionFs) updateKnownFses() {
	// We unroll the first level of entries in the root manually in order
	// to allow symbolic links on that level.
	directoryEntries, err := ioutil.ReadDir(fs.root)
	if err == nil {
		for _, dir := range directoryEntries {
			if dir.IsDir() || dir.Mode()&os.ModeSymlink != 0 {
				path := filepath.Join(fs.root, dir.Name())
				dir, _ = os.Stat(path)
				fs.visit(path, dir, nil)
				filepath.Walk(path,
					func(path string, fi os.FileInfo, err error) error {
						return fs.visit(path, fi, err)
					})
			}
		}
	}
}

func (fs *autoUnionFs) Readlink(path string, context *fuse.Context) (out string, code fuse.Status) {
	comps := strings.Split(path, string(filepath.Separator))
	if comps[0] == _STATUS && comps[1] == _ROOT {
		return fs.root, fuse.OK
	}

	if comps[0] != _CONFIG {
		return "", fuse.ENOENT
	}

	name := comps[1]

	fs.lock.RLock()
	defer fs.lock.RUnlock()

	root, ok := fs.nameRootMap[name]
	if ok {
		return root, fuse.OK
	}
	return "", fuse.ENOENT
}

func (fs *autoUnionFs) getUnionFs(name string) pathfs.FileSystem {
	fs.lock.RLock()
	defer fs.lock.RUnlock()
	return fs.knownFileSystems[name].unionFS
}

func (fs *autoUnionFs) Symlink(pointedTo string, linkName string, context *fuse.Context) (code fuse.Status) {
	comps := strings.Split(linkName, "/")
	if len(comps) != 2 {
		return fuse.EPERM
	}

	if comps[0] == _CONFIG {
		roots := fs.getRoots(pointedTo)
		if roots == nil {
			return fuse.Status(syscall.ENOTDIR)
		}

		name := comps[1]
		return fs.addFs(name, roots)
	}
	return fuse.EPERM
}

func (fs *autoUnionFs) Unlink(path string, context *fuse.Context) (code fuse.Status) {
	comps := strings.Split(path, "/")
	if len(comps) != 2 {
		return fuse.EPERM
	}

	if comps[0] == _CONFIG && comps[1] != _SCAN_CONFIG {
		code = fs.rmFs(comps[1])
	} else {
		code = fuse.ENOENT
	}
	return code
}

// Must define this, because ENOSYS will suspend all GetXAttr calls.
func (fs *autoUnionFs) GetXAttr(name string, attr string, context *fuse.Context) ([]byte, fuse.Status) {
	return nil, fuse.ENOATTR
}

func (fs *autoUnionFs) GetAttr(path string, context *fuse.Context) (*fuse.Attr, fuse.Status) {
	a := &fuse.Attr{
		Owner: *fuse.CurrentOwner(),
	}
	if path == "" || path == _CONFIG || path == _STATUS {
		a.Mode = fuse.S_IFDIR | 0755
		return a, fuse.OK
	}

	if path == filepath.Join(_STATUS, _VERSION) {
		a.Mode = fuse.S_IFREG | 0644
		a.Size = uint64(len(fs.options.Version))
		return a, fuse.OK
	}

	if path == filepath.Join(_STATUS, _DEBUG) {
		a.Mode = fuse.S_IFREG | 0644
		a.Size = uint64(len(fs.DebugData()))
		return a, fuse.OK
	}

	if path == filepath.Join(_STATUS, _ROOT) {
		a.Mode = syscall.S_IFLNK | 0644
		return a, fuse.OK
	}

	if path == filepath.Join(_CONFIG, _SCAN_CONFIG) {
		a.Mode = fuse.S_IFREG | 0644
		return a, fuse.OK
	}
	comps := strings.Split(path, string(filepath.Separator))

	if len(comps) > 1 && comps[0] == _CONFIG {
		fs := fs.getUnionFs(comps[1])

		if fs == nil {
			return nil, fuse.ENOENT
		}

		a.Mode = syscall.S_IFLNK | 0644
		return a, fuse.OK
	}

	return nil, fuse.ENOENT
}

func (fs *autoUnionFs) StatusDir() (stream []fuse.DirEntry, status fuse.Status) {
	stream = make([]fuse.DirEntry, 0, 10)
	stream = []fuse.DirEntry{
		{Name: _VERSION, Mode: fuse.S_IFREG | 0644},
		{Name: _DEBUG, Mode: fuse.S_IFREG | 0644},
		{Name: _ROOT, Mode: syscall.S_IFLNK | 0644},
	}
	return stream, fuse.OK
}

func (fs *autoUnionFs) DebugData() string {
	conn := fs.nodeFs.Connector()
	if conn.Server() == nil {
		return "autoUnionFs.mountState not set"
	}
	setting := conn.Server().KernelSettings()
	msg := fmt.Sprintf(
		"Version: %v\n"+
			"Bufferpool: %v\n"+
			"Kernel: %v\n",
		fs.options.Version,
		conn.Server().DebugData(),
		&setting)

	if conn != nil {
		msg += fmt.Sprintf("Live inodes: %d\n", conn.InodeHandleCount())
	}

	return msg
}

func (fs *autoUnionFs) Open(path string, flags uint32, context *fuse.Context) (nodefs.File, fuse.Status) {
	if path == filepath.Join(_STATUS, _DEBUG) {
		if flags&fuse.O_ANYWRITE != 0 {
			return nil, fuse.EPERM
		}

		return nodefs.NewDataFile([]byte(fs.DebugData())), fuse.OK
	}
	if path == filepath.Join(_STATUS, _VERSION) {
		if flags&fuse.O_ANYWRITE != 0 {
			return nil, fuse.EPERM
		}
		return nodefs.NewDataFile([]byte(fs.options.Version)), fuse.OK
	}
	if path == filepath.Join(_CONFIG, _SCAN_CONFIG) {
		if flags&fuse.O_ANYWRITE != 0 {
			fs.updateKnownFses()
		}
		return nodefs.NewDevNullFile(), fuse.OK
	}
	return nil, fuse.ENOENT
}

func (fs *autoUnionFs) Truncate(name string, offset uint64, context *fuse.Context) (code fuse.Status) {
	if name != filepath.Join(_CONFIG, _SCAN_CONFIG) {
		log.Println("Huh? Truncating unsupported write file", name)
		return fuse.EPERM
	}
	return fuse.OK
}

func (fs *autoUnionFs) OpenDir(name string, context *fuse.Context) (stream []fuse.DirEntry, status fuse.Status) {
	switch name {
	case _STATUS:
		return fs.StatusDir()
	case _CONFIG:
	case "/":
		name = ""
	case "":
	default:
		log.Printf("Argh! Don't know how to list dir %v", name)
		return nil, fuse.ENOSYS
	}

	fs.lock.RLock()
	defer fs.lock.RUnlock()

	stream = make([]fuse.DirEntry, 0, len(fs.knownFileSystems)+5)
	if name == _CONFIG {
		for k := range fs.knownFileSystems {
			stream = append(stream, fuse.DirEntry{
				Name: k,
				Mode: syscall.S_IFLNK | 0644,
			})
		}
	}

	if name == "" {
		stream = append(stream, fuse.DirEntry{
			Name: _CONFIG,
			Mode: uint32(fuse.S_IFDIR | 0755),
		},
			fuse.DirEntry{
				Name: _STATUS,
				Mode: uint32(fuse.S_IFDIR | 0755),
			})
	}
	return stream, status
}

func (fs *autoUnionFs) StatFs(name string) *fuse.StatfsOut {
	return &fuse.StatfsOut{}
}
