package copy

import (
	"context"
	"io"
	"io/fs"
	"io/ioutil"
	"os"
	"path/filepath"
	"time"

	"golang.org/x/sync/errgroup"
	"golang.org/x/sync/semaphore"
)

type timespec struct {
	Mtime time.Time
	Atime time.Time
	Ctime time.Time
}

// Copy copies src to dest, doesn't matter if src is a directory or a file.
func Copy(src, dest string, opts ...Options) error {
	opt := assureOptions(src, dest, opts...)
	if opt.NumOfWorkers > 1 {
		opt.intent.sem = semaphore.NewWeighted(opt.NumOfWorkers)
		opt.intent.ctx = context.Background()
	}
	if opt.FS != nil {
		info, err := fs.Stat(opt.FS, src)
		if err != nil {
			return onError(src, dest, err, opt)
		}
		return switchboard(src, dest, info, opt)
	}
	info, err := os.Lstat(src)
	if err != nil {
		return onError(src, dest, err, opt)
	}
	return switchboard(src, dest, info, opt)
}

// switchboard switches proper copy functions regarding file type, etc...
// If there would be anything else here, add a case to this switchboard.
func switchboard(src, dest string, info os.FileInfo, opt Options) (err error) {
	if info.Mode()&os.ModeDevice != 0 && !opt.Specials {
		return onError(src, dest, err, opt)
	}

	switch {
	case info.Mode()&os.ModeSymlink != 0:
		err = onsymlink(src, dest, opt)
	case info.IsDir():
		err = dcopy(src, dest, info, opt)
	case info.Mode()&os.ModeNamedPipe != 0:
		err = pcopy(dest, info)
	default:
		err = fcopy(src, dest, info, opt)
	}

	return onError(src, dest, err, opt)
}

// copyNextOrSkip decide if this src should be copied or not.
// Because this "copy" could be called recursively,
// "info" MUST be given here, NOT nil.
func copyNextOrSkip(src, dest string, info os.FileInfo, opt Options) error {
	if opt.Skip != nil {
		skip, err := opt.Skip(info, src, dest)
		if err != nil {
			return err
		}
		if skip {
			return nil
		}
	}
	return switchboard(src, dest, info, opt)
}

// fcopy is for just a file,
// with considering existence of parent directory
// and file permission.
func fcopy(src, dest string, info os.FileInfo, opt Options) (err error) {

	var readcloser io.ReadCloser
	if opt.FS != nil {
		readcloser, err = opt.FS.Open(src)
	} else {
		readcloser, err = os.Open(src)
	}
	if err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		return
	}
	defer fclose(readcloser, &err)

	if err = os.MkdirAll(filepath.Dir(dest), os.ModePerm); err != nil {
		return
	}

	f, err := os.Create(dest)
	if err != nil {
		return
	}
	defer fclose(f, &err)

	chmodfunc, err := opt.PermissionControl(info, dest)
	if err != nil {
		return err
	}
	chmodfunc(&err)

	var buf []byte = nil
	var w io.Writer = f
	var r io.Reader = readcloser

	if opt.WrapReader != nil {
		r = opt.WrapReader(r)
	}

	if opt.CopyBufferSize != 0 {
		buf = make([]byte, opt.CopyBufferSize)
		// Disable using `ReadFrom` by io.CopyBuffer.
		// See https://github.com/otiai10/copy/pull/60#discussion_r627320811 for more details.
		w = struct{ io.Writer }{f}
		// r = struct{ io.Reader }{s}
	}

	if _, err = io.CopyBuffer(w, r, buf); err != nil {
		return err
	}

	if opt.Sync {
		err = f.Sync()
	}

	if opt.PreserveOwner {
		if err := preserveOwner(src, dest, info); err != nil {
			return err
		}
	}
	if opt.PreserveTimes {
		if err := preserveTimes(info, dest); err != nil {
			return err
		}
	}

	return
}

// dcopy is for a directory,
// with scanning contents inside the directory
// and pass everything to "copy" recursively.
func dcopy(srcdir, destdir string, info os.FileInfo, opt Options) (err error) {
	if skip, err := onDirExists(opt, srcdir, destdir); err != nil {
		return err
	} else if skip {
		return nil
	}

	// Make dest dir with 0755 so that everything writable.
	chmodfunc, err := opt.PermissionControl(info, destdir)
	if err != nil {
		return err
	}
	defer chmodfunc(&err)

	var contents []os.FileInfo
	if opt.FS != nil {
		entries, err := fs.ReadDir(opt.FS, srcdir)
		if err != nil {
			return err
		}
		for _, e := range entries {
			info, err := e.Info()
			if err != nil {
				return err
			}
			contents = append(contents, info)
		}
	} else {
		contents, err = ioutil.ReadDir(srcdir)
	}

	if err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		return
	}

	if yes, err := shouldCopyDirectoryConcurrent(opt, srcdir, destdir); err != nil {
		return err
	} else if yes {
		if err := dcopyConcurrent(srcdir, destdir, contents, opt); err != nil {
			return err
		}
	} else {
		if err := dcopySequential(srcdir, destdir, contents, opt); err != nil {
			return err
		}
	}

	if opt.PreserveTimes {
		if err := preserveTimes(info, destdir); err != nil {
			return err
		}
	}

	if opt.PreserveOwner {
		if err := preserveOwner(srcdir, destdir, info); err != nil {
			return err
		}
	}

	return
}

func dcopySequential(srcdir, destdir string, contents []os.FileInfo, opt Options) error {
	for _, content := range contents {
		cs, cd := filepath.Join(srcdir, content.Name()), filepath.Join(destdir, content.Name())

		if err := copyNextOrSkip(cs, cd, content, opt); err != nil {
			// If any error, exit immediately
			return err
		}
	}
	return nil
}

// Copy this directory concurrently regarding semaphore of opt.intent
func dcopyConcurrent(srcdir, destdir string, contents []os.FileInfo, opt Options) error {
	group, ctx := errgroup.WithContext(opt.intent.ctx)
	getRoutine := func(cs, cd string, content os.FileInfo) func() error {
		return func() error {
			if content.IsDir() {
				return copyNextOrSkip(cs, cd, content, opt)
			}
			if err := opt.intent.sem.Acquire(ctx, 1); err != nil {
				return err
			}
			err := copyNextOrSkip(cs, cd, content, opt)
			opt.intent.sem.Release(1)
			return err
		}
	}
	for _, content := range contents {
		csd := filepath.Join(srcdir, content.Name())
		cdd := filepath.Join(destdir, content.Name())
		group.Go(getRoutine(csd, cdd, content))
	}
	return group.Wait()
}

func onDirExists(opt Options, srcdir, destdir string) (bool, error) {
	_, err := os.Stat(destdir)
	if err == nil && opt.OnDirExists != nil && destdir != opt.intent.dest {
		switch opt.OnDirExists(srcdir, destdir) {
		case Replace:
			if err := os.RemoveAll(destdir); err != nil {
				return false, err
			}
		case Untouchable:
			return true, nil
		} // case "Merge" is default behaviour. Go through.
	} else if err != nil && !os.IsNotExist(err) {
		return true, err // Unwelcome error type...!
	}
	return false, nil
}

func onsymlink(src, dest string, opt Options) error {
	switch opt.OnSymlink(src) {
	case Shallow:
		if err := lcopy(src, dest); err != nil {
			return err
		}
		if opt.PreserveTimes {
			return preserveLtimes(src, dest)
		}
		return nil
	case Deep:
		orig, err := os.Readlink(src)
		if err != nil {
			return err
		}
		info, err := os.Lstat(orig)
		if err != nil {
			return err
		}
		return copyNextOrSkip(orig, dest, info, opt)
	case Skip:
		fallthrough
	default:
		return nil // do nothing
	}
}

// lcopy is for a symlink,
// with just creating a new symlink by replicating src symlink.
func lcopy(src, dest string) error {
	src, err := os.Readlink(src)
	if err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		return err
	}
	return os.Symlink(src, dest)
}

// fclose ANYHOW closes file,
// with asiging error raised during Close,
// BUT respecting the error already reported.
func fclose(f io.Closer, reported *error) {
	if err := f.Close(); *reported == nil {
		*reported = err
	}
}

// onError lets caller to handle errors
// occured when copying a file.
func onError(src, dest string, err error, opt Options) error {
	if opt.OnError == nil {
		return err
	}

	return opt.OnError(src, dest, err)
}
