// Package smb provides an interface to SMB servers
package smb

import (
	"context"
	"fmt"
	"io"
	"os"
	"path"
	"strings"
	"sync"
	"time"

	"github.com/rclone/rclone/fs"
	"github.com/rclone/rclone/fs/config"
	"github.com/rclone/rclone/fs/config/configmap"
	"github.com/rclone/rclone/fs/config/configstruct"
	"github.com/rclone/rclone/fs/hash"
	"github.com/rclone/rclone/lib/bucket"
	"github.com/rclone/rclone/lib/encoder"
	"github.com/rclone/rclone/lib/env"
	"github.com/rclone/rclone/lib/pacer"
	"github.com/rclone/rclone/lib/readers"
)

const (
	minSleep      = 100 * time.Millisecond
	maxSleep      = 2 * time.Second
	decayConstant = 2 // bigger for slower decay, exponential
)

var (
	currentUser = env.CurrentUser()
)

// Register with Fs
func init() {
	fs.Register(&fs.RegInfo{
		Name:        "smb",
		Description: "SMB / CIFS",
		NewFs:       NewFs,

		Options: []fs.Option{{
			Name:     "host",
			Help:     "SMB server hostname to connect to.\n\nE.g. \"example.com\".",
			Required: true,
		}, {
			Name:    "user",
			Help:    "SMB username.",
			Default: currentUser,
		}, {
			Name:    "port",
			Help:    "SMB port number.",
			Default: 445,
		}, {
			Name:       "pass",
			Help:       "SMB password.",
			IsPassword: true,
		}, {
			Name:    "domain",
			Help:    "Domain name for NTLM authentication.",
			Default: "WORKGROUP",
		}, {
			Name:    "idle_timeout",
			Default: fs.Duration(60 * time.Second),
			Help: `Max time before closing idle connections.

If no connections have been returned to the connection pool in the time
given, rclone will empty the connection pool.

Set to 0 to keep connections indefinitely.
`,
			Advanced: true,
		}, {
			Name:     "hide_special_share",
			Help:     "Hide special shares (e.g. print$) which users aren't supposed to access.",
			Default:  true,
			Advanced: true,
		}, {
			Name:     "case_insensitive",
			Help:     "Whether the server is configured to be case-insensitive.\n\nAlways true on Windows shares.",
			Default:  true,
			Advanced: true,
		}, {
			Name:     config.ConfigEncoding,
			Help:     config.ConfigEncodingHelp,
			Advanced: true,
			Default: encoder.EncodeZero |
				// path separator
				encoder.EncodeSlash |
				encoder.EncodeBackSlash |
				// windows
				encoder.EncodeWin |
				encoder.EncodeCtl |
				encoder.EncodeDot |
				// the file turns into 8.3 names (and cannot be converted back)
				encoder.EncodeRightSpace |
				encoder.EncodeRightPeriod |
				//
				encoder.EncodeInvalidUtf8,
		},
		}})
}

// Options defines the configuration for this backend
type Options struct {
	Host            string      `config:"host"`
	Port            string      `config:"port"`
	User            string      `config:"user"`
	Pass            string      `config:"pass"`
	Domain          string      `config:"domain"`
	HideSpecial     bool        `config:"hide_special_share"`
	CaseInsensitive bool        `config:"case_insensitive"`
	IdleTimeout     fs.Duration `config:"idle_timeout"`

	Enc encoder.MultiEncoder `config:"encoding"`
}

// Fs represents a SMB remote
type Fs struct {
	name     string       // name of this remote
	root     string       // the path we are working on if any
	opt      Options      // parsed config options
	features *fs.Features // optional features
	pacer    *fs.Pacer    // pacer for operations

	sessions int32
	poolMu   sync.Mutex
	pool     []*conn
	drain    *time.Timer // used to drain the pool when we stop using the connections

	ctx context.Context
}

// Object describes a file at the server
type Object struct {
	fs         *Fs    // reference to Fs
	remote     string // the remote path
	statResult os.FileInfo
}

// NewFs constructs an Fs from the path
func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) {
	// Parse config into Options struct
	opt := new(Options)
	err := configstruct.Set(m, opt)
	if err != nil {
		return nil, err
	}

	root = strings.Trim(root, "/")

	f := &Fs{
		name: name,
		opt:  *opt,
		ctx:  ctx,
		root: root,
	}
	f.features = (&fs.Features{
		CaseInsensitive:         opt.CaseInsensitive,
		CanHaveEmptyDirectories: true,
		BucketBased:             true,
	}).Fill(ctx, f)

	f.pacer = fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant)))
	// set the pool drainer timer going
	if opt.IdleTimeout > 0 {
		f.drain = time.AfterFunc(time.Duration(opt.IdleTimeout), func() { _ = f.drainPool(ctx) })
	}

	// test if the root exists as a file
	share, dir := f.split("")
	if share == "" || dir == "" {
		return f, nil
	}
	cn, err := f.getConnection(ctx, share)
	if err != nil {
		return nil, err
	}
	stat, err := cn.smbShare.Stat(f.toSambaPath(dir))
	f.putConnection(&cn)
	if err != nil {
		// ignore stat error here
		return f, nil
	}
	if !stat.IsDir() {
		f.root, err = path.Dir(root), fs.ErrorIsFile
	}
	fs.Debugf(f, "Using root directory %q", f.root)
	return f, err
}

// Name of the remote (as passed into NewFs)
func (f *Fs) Name() string {
	return f.name
}

// Root of the remote (as passed into NewFs)
func (f *Fs) Root() string {
	return f.root
}

// String converts this Fs to a string
func (f *Fs) String() string {
	bucket, file := f.split("")
	if bucket == "" {
		return fmt.Sprintf("smb://%s@%s:%s/", f.opt.User, f.opt.Host, f.opt.Port)
	}
	return fmt.Sprintf("smb://%s@%s:%s/%s/%s", f.opt.User, f.opt.Host, f.opt.Port, bucket, file)
}

// Features returns the optional features of this Fs
func (f *Fs) Features() *fs.Features {
	return f.features
}

// Hashes returns nothing as SMB itself doesn't have a way to tell checksums
func (f *Fs) Hashes() hash.Set {
	return hash.NewHashSet()
}

// Precision returns the precision of mtime
func (f *Fs) Precision() time.Duration {
	return time.Millisecond
}

// NewObject creates a new file object
func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
	share, path := f.split(remote)
	return f.findObjectSeparate(ctx, share, path)
}

func (f *Fs) findObjectSeparate(ctx context.Context, share, path string) (fs.Object, error) {
	if share == "" || path == "" {
		return nil, fs.ErrorIsDir
	}
	cn, err := f.getConnection(ctx, share)
	if err != nil {
		return nil, err
	}
	stat, err := cn.smbShare.Stat(f.toSambaPath(path))
	f.putConnection(&cn)
	if err != nil {
		return nil, translateError(err, false)
	}
	if stat.IsDir() {
		return nil, fs.ErrorIsDir
	}

	return f.makeEntry(share, path, stat), nil
}

// Mkdir creates a directory on the server
func (f *Fs) Mkdir(ctx context.Context, dir string) (err error) {
	share, path := f.split(dir)
	if share == "" || path == "" {
		return nil
	}
	cn, err := f.getConnection(ctx, share)
	if err != nil {
		return err
	}
	err = cn.smbShare.MkdirAll(f.toSambaPath(path), 0o755)
	f.putConnection(&cn)
	return err
}

// Rmdir removes an empty directory on the server
func (f *Fs) Rmdir(ctx context.Context, dir string) error {
	share, path := f.split(dir)
	if share == "" || path == "" {
		return nil
	}
	cn, err := f.getConnection(ctx, share)
	if err != nil {
		return err
	}
	err = cn.smbShare.Remove(f.toSambaPath(path))
	f.putConnection(&cn)
	return err
}

// Put uploads a file
func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
	o := &Object{
		fs:     f,
		remote: src.Remote(),
	}

	err := o.Update(ctx, in, src, options...)
	if err == nil {
		return o, nil
	}

	return nil, err
}

// PutStream uploads to the remote path with the modTime given of indeterminate size
//
// May create the object even if it returns an error - if so
// will return the object and the error, otherwise will return
// nil and the error
func (f *Fs) PutStream(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
	o := &Object{
		fs:     f,
		remote: src.Remote(),
	}

	err := o.Update(ctx, in, src, options...)
	if err == nil {
		return o, nil
	}

	return nil, err
}

// Move src to this remote using server-side move operations.
//
// This is stored with the remote path given.
//
// It returns the destination Object and a possible error.
//
// Will only be called if src.Fs().Name() == f.Name()
//
// If it isn't possible then return fs.ErrorCantMove
func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (_ fs.Object, err error) {
	dstShare, dstPath := f.split(remote)
	srcObj, ok := src.(*Object)
	if !ok {
		fs.Debugf(src, "Can't move - not same remote type")
		return nil, fs.ErrorCantMove
	}
	srcShare, srcPath := srcObj.split()
	if dstShare != srcShare {
		fs.Debugf(src, "Can't move - must be on the same share")
		return nil, fs.ErrorCantMove
	}

	err = f.ensureDirectory(ctx, dstShare, dstPath)
	if err != nil {
		return nil, fmt.Errorf("failed to make parent directories: %w", err)
	}

	cn, err := f.getConnection(ctx, dstShare)
	if err != nil {
		return nil, err
	}
	err = cn.smbShare.Rename(f.toSambaPath(srcPath), f.toSambaPath(dstPath))
	f.putConnection(&cn)
	if err != nil {
		return nil, translateError(err, false)
	}
	return f.findObjectSeparate(ctx, dstShare, dstPath)
}

// DirMove moves src, srcRemote to this remote at dstRemote
// using server-side move operations.
//
// Will only be called if src.Fs().Name() == f.Name()
//
// If it isn't possible then return fs.ErrorCantDirMove
//
// If destination exists then return fs.ErrorDirExists
func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string) (err error) {
	dstShare, dstPath := f.split(dstRemote)
	srcFs, ok := src.(*Fs)
	if !ok {
		fs.Debugf(src, "Can't move - not same remote type")
		return fs.ErrorCantDirMove
	}
	srcShare, srcPath := srcFs.split(srcRemote)
	if dstShare != srcShare {
		fs.Debugf(src, "Can't move - must be on the same share")
		return fs.ErrorCantDirMove
	}

	err = f.ensureDirectory(ctx, dstShare, dstPath)
	if err != nil {
		return fmt.Errorf("failed to make parent directories: %w", err)
	}

	cn, err := f.getConnection(ctx, dstShare)
	if err != nil {
		return err
	}
	defer f.putConnection(&cn)

	_, err = cn.smbShare.Stat(dstPath)
	if os.IsNotExist(err) {
		err = cn.smbShare.Rename(f.toSambaPath(srcPath), f.toSambaPath(dstPath))
		return translateError(err, true)
	}
	return fs.ErrorDirExists
}

// List files and directories in a directory
func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err error) {
	share, _path := f.split(dir)

	cn, err := f.getConnection(ctx, share)
	if err != nil {
		return nil, err
	}
	defer f.putConnection(&cn)

	if share == "" {
		shares, err := cn.smbSession.ListSharenames()
		for _, shh := range shares {
			shh = f.toNativePath(shh)
			if strings.HasSuffix(shh, "$") && f.opt.HideSpecial {
				continue
			}
			entries = append(entries, fs.NewDir(shh, time.Time{}))
		}
		return entries, err
	}

	dirents, err := cn.smbShare.ReadDir(f.toSambaPath(_path))
	if err != nil {
		return entries, translateError(err, true)
	}
	for _, file := range dirents {
		nfn := f.toNativePath(file.Name())
		if file.IsDir() {
			entries = append(entries, fs.NewDir(path.Join(dir, nfn), file.ModTime()))
		} else {
			entries = append(entries, f.makeEntryRelative(share, _path, nfn, file))
		}
	}

	return entries, nil
}

// About returns things about remaining and used spaces
func (f *Fs) About(ctx context.Context) (_ *fs.Usage, err error) {
	share, dir := f.split("/")
	if share == "" {
		return nil, fs.ErrorListBucketRequired
	}
	dir = f.toSambaPath(dir)

	cn, err := f.getConnection(ctx, share)
	if err != nil {
		return nil, err
	}
	stat, err := cn.smbShare.Statfs(dir)
	f.putConnection(&cn)
	if err != nil {
		return nil, err
	}

	bs := int64(stat.BlockSize())
	usage := &fs.Usage{
		Total: fs.NewUsageValue(bs * int64(stat.TotalBlockCount())),
		Used:  fs.NewUsageValue(bs * int64(stat.TotalBlockCount()-stat.FreeBlockCount())),
		Free:  fs.NewUsageValue(bs * int64(stat.AvailableBlockCount())),
	}
	return usage, nil
}

// Shutdown the backend, closing any background tasks and any
// cached connections.
func (f *Fs) Shutdown(ctx context.Context) error {
	return f.drainPool(ctx)
}

func (f *Fs) makeEntry(share, _path string, stat os.FileInfo) *Object {
	remote := path.Join(share, _path)
	return &Object{
		fs:         f,
		remote:     trimPathPrefix(remote, f.root),
		statResult: stat,
	}
}

func (f *Fs) makeEntryRelative(share, _path, relative string, stat os.FileInfo) *Object {
	return f.makeEntry(share, path.Join(_path, relative), stat)
}

func (f *Fs) ensureDirectory(ctx context.Context, share, _path string) error {
	cn, err := f.getConnection(ctx, share)
	if err != nil {
		return err
	}
	err = cn.smbShare.MkdirAll(f.toSambaPath(path.Dir(_path)), 0o755)
	f.putConnection(&cn)
	return err
}

/// Object

// Remote returns the remote path
func (o *Object) Remote() string {
	return o.remote
}

// ModTime is the last modified time (read-only)
func (o *Object) ModTime(ctx context.Context) time.Time {
	return o.statResult.ModTime()
}

// Size is the file length
func (o *Object) Size() int64 {
	return o.statResult.Size()
}

// Fs returns the parent Fs
func (o *Object) Fs() fs.Info {
	return o.fs
}

// Hash always returns empty value
func (o *Object) Hash(ctx context.Context, ty hash.Type) (string, error) {
	return "", hash.ErrUnsupported
}

// Storable returns if this object is storable
func (o *Object) Storable() bool {
	return true
}

// SetModTime sets modTime on a particular file
func (o *Object) SetModTime(ctx context.Context, t time.Time) (err error) {
	share, reqDir := o.split()
	if share == "" || reqDir == "" {
		return fs.ErrorCantSetModTime
	}
	reqDir = o.fs.toSambaPath(reqDir)

	cn, err := o.fs.getConnection(ctx, share)
	if err != nil {
		return err
	}
	defer o.fs.putConnection(&cn)

	err = cn.smbShare.Chtimes(reqDir, t, t)
	if err != nil {
		return err
	}

	fi, err := cn.smbShare.Stat(reqDir)
	if err == nil {
		o.statResult = fi
	}
	return err
}

// Open an object for read
func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (in io.ReadCloser, err error) {
	share, filename := o.split()
	if share == "" || filename == "" {
		return nil, fs.ErrorIsDir
	}
	filename = o.fs.toSambaPath(filename)

	var offset, limit int64 = 0, -1
	for _, option := range options {
		switch x := option.(type) {
		case *fs.SeekOption:
			offset = x.Offset
		case *fs.RangeOption:
			offset, limit = x.Decode(o.Size())
		default:
			if option.Mandatory() {
				fs.Logf(o, "Unsupported mandatory option: %v", option)
			}
		}
	}

	o.fs.addSession() // Show session in use
	defer o.fs.removeSession()

	cn, err := o.fs.getConnection(ctx, share)
	if err != nil {
		return nil, err
	}
	fl, err := cn.smbShare.OpenFile(filename, os.O_RDONLY, 0)
	if err != nil {
		o.fs.putConnection(&cn)
		return nil, fmt.Errorf("failed to open: %w", err)
	}
	pos, err := fl.Seek(offset, io.SeekStart)
	if err != nil {
		o.fs.putConnection(&cn)
		return nil, fmt.Errorf("failed to seek: %w", err)
	}
	if pos != offset {
		o.fs.putConnection(&cn)
		return nil, fmt.Errorf("failed to seek: wrong position (expected=%d, reported=%d)", offset, pos)
	}

	in = readers.NewLimitedReadCloser(fl, limit)
	in = &boundReadCloser{
		rc: in,
		close: func() error {
			o.fs.putConnection(&cn)
			return nil
		},
	}

	return in, nil
}

// Update the Object from in with modTime and size
func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (err error) {
	share, filename := o.split()
	if share == "" || filename == "" {
		return fs.ErrorIsDir
	}

	err = o.fs.ensureDirectory(ctx, share, filename)
	if err != nil {
		return fmt.Errorf("failed to make parent directories: %w", err)
	}

	filename = o.fs.toSambaPath(filename)

	o.fs.addSession() // Show session in use
	defer o.fs.removeSession()

	cn, err := o.fs.getConnection(ctx, share)
	if err != nil {
		return err
	}
	defer func() {
		o.statResult, _ = cn.smbShare.Stat(filename)
		o.fs.putConnection(&cn)
	}()

	fl, err := cn.smbShare.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
	if err != nil {
		return fmt.Errorf("failed to open: %w", err)
	}

	// remove the file if upload failed
	remove := func() {
		// Windows doesn't allow removal of files without closing file
		removeErr := fl.Close()
		if removeErr != nil {
			fs.Debugf(src, "failed to close the file for delete: %v", removeErr)
			// try to remove the file anyway; the file may be already closed
		}

		removeErr = cn.smbShare.Remove(filename)
		if removeErr != nil {
			fs.Debugf(src, "failed to remove: %v", removeErr)
		} else {
			fs.Debugf(src, "removed after failed upload: %v", err)
		}
	}

	_, err = fl.ReadFrom(in)
	if err != nil {
		remove()
		return fmt.Errorf("Update ReadFrom failed: %w", err)
	}

	err = fl.Close()
	if err != nil {
		remove()
		return fmt.Errorf("Update Close failed: %w", err)
	}

	// Set the modified time
	err = o.SetModTime(ctx, src.ModTime(ctx))
	if err != nil {
		return fmt.Errorf("Update SetModTime failed: %w", err)
	}

	return nil
}

// Remove an object
func (o *Object) Remove(ctx context.Context) (err error) {
	share, filename := o.split()
	if share == "" || filename == "" {
		return fs.ErrorIsDir
	}
	filename = o.fs.toSambaPath(filename)

	cn, err := o.fs.getConnection(ctx, share)
	if err != nil {
		return err
	}

	err = cn.smbShare.Remove(filename)
	o.fs.putConnection(&cn)

	return err
}

// String converts this Object to a string
func (o *Object) String() string {
	if o == nil {
		return "<nil>"
	}
	return o.remote
}

/// Misc

// split returns share name and path in the share from the rootRelativePath
// relative to f.root
func (f *Fs) split(rootRelativePath string) (shareName, filepath string) {
	return bucket.Split(path.Join(f.root, rootRelativePath))
}

// split returns share name and path in the share from the object
func (o *Object) split() (shareName, filepath string) {
	return o.fs.split(o.remote)
}

func (f *Fs) toSambaPath(path string) string {
	// 1. encode via Rclone's escaping system
	// 2. convert to backslash-separated path
	return strings.ReplaceAll(f.opt.Enc.FromStandardPath(path), "/", "\\")
}

func (f *Fs) toNativePath(path string) string {
	// 1. convert *back* to slash-separated path
	// 2. encode via Rclone's escaping system
	return f.opt.Enc.ToStandardPath(strings.ReplaceAll(path, "\\", "/"))
}

func ensureSuffix(s, suffix string) string {
	if strings.HasSuffix(s, suffix) {
		return s
	}
	return s + suffix
}

func trimPathPrefix(s, prefix string) string {
	// we need to clean the paths to make tests pass!
	s = betterPathClean(s)
	prefix = betterPathClean(prefix)
	if s == prefix || s == prefix+"/" {
		return ""
	}
	prefix = ensureSuffix(prefix, "/")
	return strings.TrimPrefix(s, prefix)
}

func betterPathClean(p string) string {
	d := path.Clean(p)
	if d == "." {
		return ""
	}
	return d
}

type boundReadCloser struct {
	rc    io.ReadCloser
	close func() error
}

func (r *boundReadCloser) Read(p []byte) (n int, err error) {
	return r.rc.Read(p)
}

func (r *boundReadCloser) Close() error {
	err1 := r.rc.Close()
	err2 := r.close()
	if err1 != nil {
		return err1
	}
	return err2
}

func translateError(e error, dir bool) error {
	if os.IsNotExist(e) {
		if dir {
			return fs.ErrorDirNotFound
		}
		return fs.ErrorObjectNotFound
	}

	return e
}

var (
	_ fs.Fs          = &Fs{}
	_ fs.PutStreamer = &Fs{}
	_ fs.Mover       = &Fs{}
	_ fs.DirMover    = &Fs{}
	_ fs.Abouter     = &Fs{}
	_ fs.Shutdowner  = &Fs{}
	_ fs.Object      = &Object{}
	_ io.ReadCloser  = &boundReadCloser{}
)
