package fs

import (
	"fmt"
	"io"
	"io/ioutil"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"sync"
	"time"

	s2ierr "github.com/openshift/source-to-image/pkg/errors"
	utillog "github.com/openshift/source-to-image/pkg/util/log"
)

var log = utillog.StderrLog

// FileSystem allows STI to work with the file system and
// perform tasks such as creating and deleting directories
type FileSystem interface {
	Chmod(file string, mode os.FileMode) error
	Rename(from, to string) error
	MkdirAll(dirname string) error
	MkdirAllWithPermissions(dirname string, perm os.FileMode) error
	Mkdir(dirname string) error
	Exists(file string) bool
	Copy(sourcePath, targetPath string, isIgnored func(path string) bool) error
	CopyContents(sourcePath, targetPath string, isIgnored func(path string) bool) error
	RemoveDirectory(dir string) error
	CreateWorkingDirectory() (string, error)
	Open(file string) (io.ReadCloser, error)
	Create(file string) (io.WriteCloser, error)
	WriteFile(file string, data []byte) error
	ReadDir(string) ([]os.FileInfo, error)
	Stat(string) (os.FileInfo, error)
	Lstat(string) (os.FileInfo, error)
	Walk(string, filepath.WalkFunc) error
	Readlink(string) (string, error)
	Symlink(string, string) error
	KeepSymlinks(bool)
	ShouldKeepSymlinks() bool
}

// NewFileSystem creates a new instance of the default FileSystem
// implementation
func NewFileSystem() FileSystem {
	return &fs{
		fileModes:    make(map[string]os.FileMode),
		keepSymlinks: false,
	}
}

type fs struct {
	// on Windows, fileModes is used to track the UNIX file mode of every file we
	// work with; m is used to synchronize access to fileModes.
	fileModes    map[string]os.FileMode
	m            sync.Mutex
	keepSymlinks bool
}

// FileInfo is a struct which implements os.FileInfo.  We use it (a) for test
// purposes, and (b) because we enrich the FileMode on Windows systems
type FileInfo struct {
	FileName    string
	FileSize    int64
	FileMode    os.FileMode
	FileModTime time.Time
	FileIsDir   bool
	FileSys     interface{}
}

// Name retuns the filename of fi
func (fi *FileInfo) Name() string {
	return fi.FileName
}

// Size returns the file size of fi
func (fi *FileInfo) Size() int64 {
	return fi.FileSize
}

// Mode returns the file mode of fi
func (fi *FileInfo) Mode() os.FileMode {
	return fi.FileMode
}

// ModTime returns the file modification time of fi
func (fi *FileInfo) ModTime() time.Time {
	return fi.FileModTime
}

// IsDir returns true if fi refers to a directory
func (fi *FileInfo) IsDir() bool {
	return fi.FileIsDir
}

// Sys returns the sys interface of fi
func (fi *FileInfo) Sys() interface{} {
	return fi.FileSys
}

func copyFileInfo(src os.FileInfo) *FileInfo {
	return &FileInfo{
		FileName:    src.Name(),
		FileSize:    src.Size(),
		FileMode:    src.Mode(),
		FileModTime: src.ModTime(),
		FileIsDir:   src.IsDir(),
		FileSys:     src.Sys(),
	}
}

// Stat returns a FileInfo describing the named file.
func (h *fs) Stat(path string) (os.FileInfo, error) {
	fi, err := os.Stat(path)
	if runtime.GOOS == "windows" && err == nil {
		fi = h.enrichFileInfo(path, fi)
	}
	return fi, err
}

// Lstat returns a FileInfo describing the named file (not following symlinks).
func (h *fs) Lstat(path string) (os.FileInfo, error) {
	fi, err := os.Lstat(path)
	if runtime.GOOS == "windows" && err == nil {
		fi = h.enrichFileInfo(path, fi)
	}
	return fi, err
}

// ReadDir reads the directory named by dirname and returns a list of directory
// entries sorted by filename.
func (h *fs) ReadDir(path string) ([]os.FileInfo, error) {
	fis, err := ioutil.ReadDir(path)
	if runtime.GOOS == "windows" && err == nil {
		h.enrichFileInfos(path, fis)
	}
	return fis, err
}

// Chmod sets the file mode
func (h *fs) Chmod(file string, mode os.FileMode) error {
	err := os.Chmod(file, mode)
	if runtime.GOOS == "windows" && err == nil {
		h.m.Lock()
		h.fileModes[file] = mode
		h.m.Unlock()
		return nil
	}
	return err
}

// Rename renames or moves a file
func (h *fs) Rename(from, to string) error {
	return os.Rename(from, to)
}

// MkdirAll creates the directory and all its parents
func (h *fs) MkdirAll(dirname string) error {
	return os.MkdirAll(dirname, 0700)
}

// MkdirAllWithPermissions creates the directory and all its parents with the provided permissions
func (h *fs) MkdirAllWithPermissions(dirname string, perm os.FileMode) error {
	return os.MkdirAll(dirname, perm)
}

// Mkdir creates the specified directory
func (h *fs) Mkdir(dirname string) error {
	return os.Mkdir(dirname, 0700)
}

// Exists determines whether the given file exists
func (h *fs) Exists(file string) bool {
	_, err := h.Stat(file)
	return err == nil
}

// Copy copies the source to a destination.
// If the source is a file, then the destination has to be a file as well,
// otherwise you will get an error.
// If the source is a directory, then the destination has to be a directory and
// we copy the content of the source directory to destination directory
// recursively.
func (h *fs) Copy(source string, dest string, isIgnored func(path string) bool) (err error) {
	return doCopy(h, source, dest, isIgnored)
}

// KeepSymlinks configures fs to copy symlinks from src as symlinks to dst.
// Default behavior is to follow symlinks and copy files by content.
func (h *fs) KeepSymlinks(k bool) {
	h.keepSymlinks = k
}

// ShouldKeepSymlinks is exported only due to the design of fs util package
// and how the tests are structured. It indicates whether the implementation
// should copy symlinks as symlinks or follow symlinks and copy by content.
func (h *fs) ShouldKeepSymlinks() bool {
	return h.keepSymlinks
}

// If src is symlink and symlink copy has been enabled, copy as a symlink.
// Otherwise ignore symlink and let rest of the code follow the symlink
// and copy the content of the file
func handleSymlink(h FileSystem, source, dest string) (bool, error) {
	lstatinfo, lstaterr := h.Lstat(source)
	_, staterr := h.Stat(source)
	if lstaterr == nil &&
		lstatinfo.Mode()&os.ModeSymlink != 0 {
		if os.IsNotExist(staterr) {
			log.V(5).Infof("(broken) L %q -> %q", source, dest)
		} else if h.ShouldKeepSymlinks() {
			log.V(5).Infof("L %q -> %q", source, dest)
		} else {
			// symlink not handled here, will copy the file content
			return false, nil
		}
		linkdest, err := h.Readlink(source)
		if err != nil {
			return true, err
		}
		return true, h.Symlink(linkdest, dest)
	}
	// symlink not handled here, will copy the file content
	return false, nil
}

func doCopy(h FileSystem, source, dest string, isIgnored func(path string) bool) error {
	if handled, err := handleSymlink(h, source, dest); handled || err != nil {
		return err
	}
	sourcefile, err := h.Open(source)
	if err != nil {
		return err
	}
	defer sourcefile.Close()
	sourceinfo, err := h.Stat(source)
	if err != nil {
		return err
	}

	if sourceinfo.IsDir() {
		ok := isIgnored != nil && isIgnored(source)
		if ok {
			log.V(5).Infof("Directory %q ignored", source)
			return nil
		}
		log.V(5).Infof("D %q -> %q", source, dest)
		return h.CopyContents(source, dest, isIgnored)
	}

	destinfo, _ := h.Stat(dest)
	if destinfo != nil && destinfo.IsDir() {
		return fmt.Errorf("destination must be full path to a file, not directory")
	}
	destfile, err := h.Create(dest)
	if err != nil {
		return err
	}
	defer destfile.Close()
	ok := isIgnored != nil && isIgnored(source)
	if ok {
		log.V(5).Infof("File %q ignored", source)
		return nil
	}
	log.V(5).Infof("F %q -> %q", source, dest)
	if _, err := io.Copy(destfile, sourcefile); err != nil {
		return err
	}

	return h.Chmod(dest, sourceinfo.Mode())
}

// CopyContents copies the content of the source directory to a destination
// directory.
// If the destination directory does not exists, it will be created.
// The source directory itself will not be copied, only its content. If you
// want this behavior, the destination must include the source directory name.
// It will skip any files provided in filesToIgnore from being copied
func (h *fs) CopyContents(src, dest string, isIgnored func(path string) bool) (err error) {
	sourceinfo, err := h.Stat(src)
	if err != nil {
		return err
	}
	if err = os.MkdirAll(dest, sourceinfo.Mode()); err != nil {
		return err
	}
	objects, err := os.ReadDir(src)
	if err != nil {
		return err
	}

	for _, obj := range objects {
		source := filepath.Join(src, obj.Name())
		destination := filepath.Join(dest, obj.Name())
		if err := h.Copy(source, destination, isIgnored); err != nil {
			return err
		}
	}
	return
}

// RemoveDirectory removes the specified directory and all its contents
func (h *fs) RemoveDirectory(dir string) error {
	log.V(2).Infof("Removing directory '%s'", dir)

	// HACK: If deleting a directory in windows, call out to the system to do the deletion
	// TODO: Remove this workaround when we switch to go 1.7 -- os.RemoveAll should
	// be fixed for Windows in that release.  https://github.com/golang/go/issues/9606
	if runtime.GOOS == "windows" {
		command := exec.Command("cmd.exe", "/c", fmt.Sprintf("rd /s /q %s", dir))
		output, err := command.Output()
		if err != nil {
			log.Errorf("Error removing directory %q: %v %s", dir, err, string(output))
			return err
		}
		return nil
	}

	err := os.RemoveAll(dir)
	if err != nil {
		log.Errorf("Error removing directory '%s': %v", dir, err)
	}
	return err
}

// CreateWorkingDirectory creates a directory to be used for STI
func (h *fs) CreateWorkingDirectory() (directory string, err error) {
	directory, err = ioutil.TempDir("", "s2i")
	if err != nil {
		return "", s2ierr.NewWorkDirError(directory, err)
	}

	return directory, err
}

// Open opens a file and returns a ReadCloser interface to that file
func (h *fs) Open(filename string) (io.ReadCloser, error) {
	return os.Open(filename)
}

// Create creates a file and returns a WriteCloser interface to that file
func (h *fs) Create(filename string) (io.WriteCloser, error) {
	return os.Create(filename)
}

// WriteFile opens a file and writes data to it, returning error if such
// occurred
func (h *fs) WriteFile(filename string, data []byte) error {
	return ioutil.WriteFile(filename, data, 0700)
}

// Walk walks the file tree rooted at root, calling walkFn for each file or
// directory in the tree, including root.
func (h *fs) Walk(root string, walkFn filepath.WalkFunc) error {
	wrapper := func(path string, info os.FileInfo, err error) error {
		if runtime.GOOS == "windows" && err == nil {
			info = h.enrichFileInfo(path, info)
		}
		return walkFn(path, info, err)
	}
	return filepath.Walk(root, wrapper)
}

// enrichFileInfo is used on Windows.  It takes an os.FileInfo object, e.g. as
// returned by os.Stat, and enriches the OS-returned file mode with the "real"
// UNIX file mode, if we know what it is.
func (h *fs) enrichFileInfo(path string, fi os.FileInfo) os.FileInfo {
	h.m.Lock()
	if mode, ok := h.fileModes[path]; ok {
		fi = copyFileInfo(fi)
		fi.(*FileInfo).FileMode = mode
	}
	h.m.Unlock()
	return fi
}

// enrichFileInfos is used on Windows.  It takes an array of os.FileInfo
// objects, e.g. as returned by os.ReadDir, and for each file enriches the OS-
// returned file mode with the "real" UNIX file mode, if we know what it is.
func (h *fs) enrichFileInfos(root string, fis []os.FileInfo) {
	h.m.Lock()
	for i := range fis {
		if mode, ok := h.fileModes[filepath.Join(root, fis[i].Name())]; ok {
			fis[i] = copyFileInfo(fis[i])
			fis[i].(*FileInfo).FileMode = mode
		}
	}
	h.m.Unlock()
}

// Readlink reads the destination of a symlink
func (h *fs) Readlink(name string) (string, error) {
	return os.Readlink(name)
}

// Symlink creates a symlink at newname, pointing to oldname
func (h *fs) Symlink(oldname, newname string) error {
	return os.Symlink(oldname, newname)
}
