// Copyright (C) The Arvados Authors. All rights reserved.
//
// SPDX-License-Identifier: Apache-2.0

package arvados

import (
	"fmt"
	"io"
	"io/ioutil"
	"net/http"
	"os"
	"strings"
	"sync"
	"syscall"
	"time"

	check "gopkg.in/check.v1"
)

const (
	// Importing arvadostest would be an import cycle, so these
	// fixtures are duplicated here [until fs moves to a separate
	// package].
	fixtureActiveUserUUID               = "zzzzz-tpzed-xurymjxw79nv3jz"
	fixtureActiveToken                  = "3kg6k6lzmp9kj5cpkcoxie963cmvjahbt2fod9zru30k1jqdmi"
	fixtureAProjectUUID                 = "zzzzz-j7d0g-v955i6s2oi1cbso"
	fixtureThisFilterGroupUUID          = "zzzzz-j7d0g-thisfiltergroup"
	fixtureAFilterGroupTwoUUID          = "zzzzz-j7d0g-afiltergrouptwo"
	fixtureAFilterGroupThreeUUID        = "zzzzz-j7d0g-filtergroupthre"
	fixtureAFilterGroupFourUUID         = "zzzzz-j7d0g-filtergroupfour"
	fixtureAFilterGroupFiveUUID         = "zzzzz-j7d0g-filtergroupfive"
	fixtureFooAndBarFilesInDirUUID      = "zzzzz-4zz18-foonbarfilesdir"
	fixtureFooCollectionName            = "zzzzz-4zz18-fy296fx3hot09f7 added sometime"
	fixtureFooCollectionPDH             = "1f4b0bc7583c2a7f9102c395f4ffc5e3+45"
	fixtureFooCollection                = "zzzzz-4zz18-fy296fx3hot09f7"
	fixtureNonexistentCollection        = "zzzzz-4zz18-totallynotexist"
	fixtureStorageClassesDesiredArchive = "zzzzz-4zz18-3t236wr12769qqa"
	fixtureBlobSigningKey               = "zfhgfenhffzltr9dixws36j1yhksjoll2grmku38mi7yxd66h5j4q9w4jzanezacp8s6q0ro3hxakfye02152hncy6zml2ed0uc"
	fixtureBlobSigningTTL               = 336 * time.Hour
)

var _ = check.Suite(&SiteFSSuite{})

func init() {
	// Enable DebugLocksPanicMode sometimes. Don't enable it all
	// the time, though -- it adds many calls to time.Sleep(),
	// which could hide different bugs.
	if time.Now().Second()&1 == 0 {
		DebugLocksPanicMode = true
	}
}

type SiteFSSuite struct {
	client *Client
	fs     CustomFileSystem
	kc     keepClient
}

func (s *SiteFSSuite) SetUpTest(c *check.C) {
	s.client = &Client{
		APIHost:   os.Getenv("ARVADOS_API_HOST"),
		AuthToken: fixtureActiveToken,
		Insecure:  true,
	}
	s.kc = &keepClientStub{
		blocks: map[string][]byte{
			"3858f62230ac3c915f300c664312c63f": []byte("foobar"),
		},
		sigkey:    fixtureBlobSigningKey,
		sigttl:    fixtureBlobSigningTTL,
		authToken: fixtureActiveToken,
	}
	s.fs = s.client.SiteFileSystem(s.kc)
}

func (s *SiteFSSuite) TestHttpFileSystemInterface(c *check.C) {
	_, ok := s.fs.(http.FileSystem)
	c.Check(ok, check.Equals, true)
}

func (s *SiteFSSuite) TestByIDEmpty(c *check.C) {
	f, err := s.fs.Open("/by_id")
	c.Assert(err, check.IsNil)
	fis, err := f.Readdir(-1)
	c.Check(err, check.IsNil)
	c.Check(len(fis), check.Equals, 0)
}

func (s *SiteFSSuite) TestUpdateStorageClasses(c *check.C) {
	f, err := s.fs.OpenFile("/by_id/"+fixtureStorageClassesDesiredArchive+"/newfile", os.O_CREATE|os.O_RDWR, 0777)
	c.Assert(err, check.IsNil)
	_, err = f.Write([]byte("nope"))
	c.Assert(err, check.IsNil)
	err = f.Close()
	c.Assert(err, check.IsNil)
	err = s.fs.Sync()
	c.Assert(err, check.ErrorMatches, `.*stub does not write storage class "archive"`)
}

func (s *SiteFSSuite) TestSameCollectionDifferentPaths(c *check.C) {
	s.fs.MountProject("home", "")
	var coll Collection
	err := s.client.RequestAndDecode(&coll, "POST", "arvados/v1/collections", nil, map[string]interface{}{
		"collection": map[string]interface{}{
			"owner_uuid": fixtureAProjectUUID,
			"name":       fmt.Sprintf("test collection %d", time.Now().UnixNano()),
		},
	})
	c.Assert(err, check.IsNil)

	viaProjID := "by_id/" + fixtureAProjectUUID + "/" + coll.Name
	viaProjName := "home/A Project/" + coll.Name
	viaCollID := "by_id/" + coll.UUID
	for n, dirs := range [][]string{
		{viaCollID, viaProjID, viaProjName},
		{viaCollID, viaProjName, viaProjID},
		{viaProjID, viaProjName, viaCollID},
		{viaProjID, viaCollID, viaProjName},
		{viaProjName, viaCollID, viaProjID},
		{viaProjName, viaProjID, viaCollID},
	} {
		filename := fmt.Sprintf("file %d", n)
		f := make([]File, 3)
		for i, dir := range dirs {
			path := dir + "/" + filename
			mode := os.O_RDWR
			if i == 0 {
				mode |= os.O_CREATE
				c.Logf("create %s", path)
			} else {
				c.Logf("open %s", path)
			}
			f[i], err = s.fs.OpenFile(path, mode, 0777)
			c.Assert(err, check.IsNil, check.Commentf("n=%d i=%d path=%s", n, i, path))
			defer f[i].Close()
		}
		_, err = io.WriteString(f[0], filename)
		c.Assert(err, check.IsNil)
		_, err = f[1].Seek(0, io.SeekEnd)
		c.Assert(err, check.IsNil)
		_, err = io.WriteString(f[1], filename)
		c.Assert(err, check.IsNil)
		buf, err := io.ReadAll(f[2])
		c.Assert(err, check.IsNil)
		c.Check(string(buf), check.Equals, filename+filename)
	}
}

func (s *SiteFSSuite) TestByUUIDAndPDH(c *check.C) {
	f, err := s.fs.Open("/by_id")
	c.Assert(err, check.IsNil)
	fis, err := f.Readdir(-1)
	c.Check(err, check.IsNil)
	c.Check(len(fis), check.Equals, 0)

	err = s.fs.Mkdir("/by_id/"+fixtureFooCollection, 0755)
	c.Check(err, check.Equals, os.ErrExist)

	f, err = s.fs.Open("/by_id/" + fixtureNonexistentCollection)
	c.Assert(err, check.Equals, os.ErrNotExist)

	for _, path := range []string{
		fixtureFooCollection,
		fixtureFooCollectionPDH,
		fixtureAProjectUUID + "/" + fixtureFooCollectionName,
	} {
		f, err = s.fs.Open("/by_id/" + path)
		c.Assert(err, check.IsNil)
		fis, err = f.Readdir(-1)
		c.Assert(err, check.IsNil)
		var names []string
		for _, fi := range fis {
			names = append(names, fi.Name())
		}
		c.Check(names, check.DeepEquals, []string{"foo"})
	}

	f, err = s.fs.Open("/by_id/" + fixtureAProjectUUID + "/A Subproject/baz_file")
	c.Assert(err, check.IsNil)
	fis, err = f.Readdir(-1)
	c.Assert(err, check.IsNil)
	var names []string
	for _, fi := range fis {
		names = append(names, fi.Name())
	}
	c.Check(names, check.DeepEquals, []string{"baz"})
	f, err = s.fs.Open("/by_id/" + fixtureAProjectUUID + "/A Subproject/baz_file/baz")
	c.Assert(err, check.IsNil)
	err = f.Close()
	c.Assert(err, check.IsNil)
	_, err = s.fs.Open("/by_id/" + fixtureAProjectUUID + "/A Subproject/baz_file/baz/")
	c.Assert(err, check.Equals, ErrNotADirectory)
	_, err = s.fs.Open("/by_id/" + fixtureAProjectUUID + "/A Subproject/baz_file/baz/z")
	c.Assert(err, check.Equals, ErrNotADirectory)
	_, err = s.fs.Open("/by_id/" + fixtureAProjectUUID + "/A Subproject/baz_file/baz/..")
	c.Assert(err, check.Equals, ErrNotADirectory)

	_, err = s.fs.OpenFile("/by_id/"+fixtureNonexistentCollection, os.O_RDWR|os.O_CREATE, 0755)
	c.Check(err, ErrorIs, ErrInvalidOperation)
	err = s.fs.Rename("/by_id/"+fixtureFooCollection, "/by_id/beep")
	c.Check(err, ErrorIs, ErrInvalidOperation)
	err = s.fs.Rename("/by_id/"+fixtureFooCollection+"/foo", "/by_id/beep")
	c.Check(err, ErrorIs, ErrInvalidOperation)
	_, err = s.fs.Stat("/by_id/beep")
	c.Check(err, check.Equals, os.ErrNotExist)
	err = s.fs.Rename("/by_id/"+fixtureFooCollection+"/foo", "/by_id/"+fixtureFooCollection+"/bar")
	c.Check(err, check.IsNil)

	err = s.fs.Rename("/by_id", "/beep")
	c.Check(err, ErrorIs, ErrInvalidOperation)
}

// Copy subtree from OS src to dst path inside fs. If src is a
// directory, dst must exist and be a directory.
func copyFromOS(fs FileSystem, dst, src string) error {
	inf, err := os.Open(src)
	if err != nil {
		return err
	}
	defer inf.Close()
	dirents, err := inf.Readdir(-1)
	if e, ok := err.(*os.PathError); ok {
		if e, ok := e.Err.(syscall.Errno); ok {
			if e == syscall.ENOTDIR {
				err = syscall.ENOTDIR
			}
		}
	}
	if err == syscall.ENOTDIR {
		outf, err := fs.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_TRUNC|os.O_WRONLY, 0700)
		if err != nil {
			return fmt.Errorf("open %s: %s", dst, err)
		}
		defer outf.Close()
		_, err = io.Copy(outf, inf)
		if err != nil {
			return fmt.Errorf("%s: copying data from %s: %s", dst, src, err)
		}
		err = outf.Close()
		if err != nil {
			return err
		}
	} else if err != nil {
		return fmt.Errorf("%s: readdir: %T %s", src, err, err)
	} else {
		{
			d, err := fs.Open(dst)
			if err != nil {
				return fmt.Errorf("opendir(%s): %s", dst, err)
			}
			d.Close()
		}
		for _, ent := range dirents {
			if ent.Name() == "." || ent.Name() == ".." {
				continue
			}
			dstname := dst + "/" + ent.Name()
			if ent.IsDir() {
				err = fs.Mkdir(dstname, 0700)
				if err != nil {
					return fmt.Errorf("mkdir %s: %s", dstname, err)
				}
			}
			err = copyFromOS(fs, dstname, src+"/"+ent.Name())
			if err != nil {
				return err
			}
		}
	}
	return nil
}

func (s *SiteFSSuite) TestSnapshotSplice(c *check.C) {
	s.fs.MountProject("home", "")
	thisfile, err := ioutil.ReadFile("fs_site_test.go")
	c.Assert(err, check.IsNil)

	var src1 Collection
	err = s.client.RequestAndDecode(&src1, "POST", "arvados/v1/collections", nil, map[string]interface{}{
		"collection": map[string]string{
			"name":       "TestSnapshotSplice src1",
			"owner_uuid": fixtureAProjectUUID,
		},
	})
	c.Assert(err, check.IsNil)
	defer s.client.RequestAndDecode(nil, "DELETE", "arvados/v1/collections/"+src1.UUID, nil, nil)
	err = s.fs.Sync()
	c.Assert(err, check.IsNil)
	err = copyFromOS(s.fs, "/home/A Project/TestSnapshotSplice src1", "..") // arvados.git/sdk/go
	c.Assert(err, check.IsNil)

	var src2 Collection
	err = s.client.RequestAndDecode(&src2, "POST", "arvados/v1/collections", nil, map[string]interface{}{
		"collection": map[string]string{
			"name":       "TestSnapshotSplice src2",
			"owner_uuid": fixtureAProjectUUID,
		},
	})
	c.Assert(err, check.IsNil)
	defer s.client.RequestAndDecode(nil, "DELETE", "arvados/v1/collections/"+src2.UUID, nil, nil)
	err = s.fs.Sync()
	c.Assert(err, check.IsNil)
	err = copyFromOS(s.fs, "/home/A Project/TestSnapshotSplice src2", "..") // arvados.git/sdk/go
	c.Assert(err, check.IsNil)

	var dst Collection
	err = s.client.RequestAndDecode(&dst, "POST", "arvados/v1/collections", nil, map[string]interface{}{
		"collection": map[string]string{
			"name":       "TestSnapshotSplice dst",
			"owner_uuid": fixtureAProjectUUID,
		},
	})
	c.Assert(err, check.IsNil)
	defer s.client.RequestAndDecode(nil, "DELETE", "arvados/v1/collections/"+dst.UUID, nil, nil)
	err = s.fs.Sync()
	c.Assert(err, check.IsNil)

	dstPath := "/home/A Project/TestSnapshotSplice dst"
	err = copyFromOS(s.fs, dstPath, "..") // arvados.git/sdk/go
	c.Assert(err, check.IsNil)

	// Snapshot directory
	snap1, err := Snapshot(s.fs, "/home/A Project/TestSnapshotSplice src1/ctxlog")
	c.Check(err, check.IsNil)
	// Attach same snapshot twice, at paths that didn't exist before
	err = Splice(s.fs, dstPath+"/ctxlog-copy", snap1)
	c.Check(err, check.IsNil)
	err = Splice(s.fs, dstPath+"/ctxlog-copy2", snap1)
	c.Check(err, check.IsNil)
	// Splicing a snapshot twice results in two independent copies
	err = s.fs.Rename(dstPath+"/ctxlog-copy2/log.go", dstPath+"/ctxlog-copy/log2.go")
	c.Check(err, check.IsNil)
	_, err = s.fs.Open(dstPath + "/ctxlog-copy2/log.go")
	c.Check(err, check.Equals, os.ErrNotExist)
	f, err := s.fs.Open(dstPath + "/ctxlog-copy/log.go")
	if c.Check(err, check.IsNil) {
		buf, err := ioutil.ReadAll(f)
		c.Check(err, check.IsNil)
		c.Check(string(buf), check.Not(check.Equals), "")
		f.Close()
	}

	// Snapshot regular file
	snapFile, err := Snapshot(s.fs, "/home/A Project/TestSnapshotSplice src1/arvados/fs_site_test.go")
	c.Check(err, check.IsNil)
	// Replace dir with file
	err = Splice(s.fs, dstPath+"/ctxlog-copy2", snapFile)
	c.Check(err, check.IsNil)
	if f, err := s.fs.Open(dstPath + "/ctxlog-copy2"); c.Check(err, check.IsNil) {
		buf, err := ioutil.ReadAll(f)
		c.Check(err, check.IsNil)
		c.Check(string(buf), check.Equals, string(thisfile))
	}

	// Cannot splice a file onto a collection root; cannot splice
	// anything to a target outside a collection.
	for _, badpath := range []string{
		dstPath + "/",
		dstPath,
		"/home/A Project/newnodename/",
		"/home/A Project/newnodename",
		"/home/A Project/",
		"/home/A Project",
		"/home/newnodename/",
		"/home/newnodename",
		"/home/",
		"/home",
		"/newnodename/",
		"/newnodename",
		"/",
	} {
		err = Splice(s.fs, badpath, snapFile)
		c.Check(err, check.NotNil)
		if strings.Contains(badpath, "newnodename") && strings.HasSuffix(badpath, "/") {
			c.Check(err, ErrorIs, os.ErrNotExist, check.Commentf("badpath %q", badpath))
		} else {
			c.Check(err, ErrorIs, ErrInvalidOperation, check.Commentf("badpath %q", badpath))
		}
		if strings.TrimSuffix(badpath, "/") == dstPath {
			c.Check(err, check.ErrorMatches, `cannot use Splice to attach a file at top level of \*arvados.collectionFileSystem: invalid operation`, check.Commentf("badpath: %q", badpath))
			continue
		}

		err = Splice(s.fs, badpath, snap1)
		if strings.Contains(badpath, "newnodename") && strings.HasSuffix(badpath, "/") {
			c.Check(err, ErrorIs, os.ErrNotExist, check.Commentf("badpath %q", badpath))
		} else {
			c.Check(err, ErrorIs, ErrInvalidOperation, check.Commentf("badpath %q", badpath))
		}
	}

	// Destination's parent must already exist
	for _, badpath := range []string{
		dstPath + "/newdirname/",
		dstPath + "/newdirname/foobar",
		"/foo/bar",
	} {
		err = Splice(s.fs, badpath, snap1)
		c.Check(err, ErrorIs, os.ErrNotExist, check.Commentf("badpath %s", badpath))
		err = Splice(s.fs, badpath, snapFile)
		c.Check(err, ErrorIs, os.ErrNotExist, check.Commentf("badpath %s", badpath))
	}

	snap2, err := Snapshot(s.fs, dstPath+"/ctxlog-copy")
	if c.Check(err, check.IsNil) {
		err = Splice(s.fs, dstPath+"/ctxlog-copy-copy", snap2)
		c.Check(err, check.IsNil)
	}

	// Snapshot entire collection, splice into same collection at
	// a new path, remove file from original location, verify
	// spliced content survives
	snapDst, err := Snapshot(s.fs, dstPath+"")
	c.Check(err, check.IsNil)
	err = Splice(s.fs, dstPath+"", snapDst)
	c.Check(err, check.IsNil)
	err = Splice(s.fs, dstPath+"/copy1", snapDst)
	c.Check(err, check.IsNil)
	err = Splice(s.fs, dstPath+"/copy2", snapDst)
	c.Check(err, check.IsNil)
	err = s.fs.RemoveAll(dstPath + "/arvados/fs_site_test.go")
	c.Check(err, check.IsNil)
	err = s.fs.RemoveAll(dstPath + "/arvados")
	c.Check(err, check.IsNil)
	_, err = s.fs.Open(dstPath + "/arvados/fs_site_test.go")
	c.Check(err, check.Equals, os.ErrNotExist)
	f, err = s.fs.Open(dstPath + "/copy2/arvados/fs_site_test.go")
	if c.Check(err, check.IsNil) {
		defer f.Close()
		buf, err := ioutil.ReadAll(f)
		c.Check(err, check.IsNil)
		c.Check(string(buf), check.Equals, string(thisfile))
	}
}

func (s *SiteFSSuite) TestLocks(c *check.C) {
	DebugLocksPanicMode = false
	done := make(chan struct{})
	defer close(done)
	ticker := time.NewTicker(2 * time.Second)
	go func() {
		for {
			timeout := time.AfterFunc(5*time.Second, func() {
				// c.FailNow() doesn't break deadlock, but this sure does
				panic("timed out -- deadlock?")
			})
			select {
			case <-done:
				timeout.Stop()
				return
			case <-ticker.C:
				c.Logf("MemorySize == %d", s.fs.MemorySize())
			}
			timeout.Stop()
		}
	}()
	ncolls := 5
	ndirs := 3
	nfiles := 5
	projects := make([]Group, 5)
	for pnum := range projects {
		c.Logf("make project %d", pnum)
		err := s.client.RequestAndDecode(&projects[pnum], "POST", "arvados/v1/groups", nil, map[string]interface{}{
			"group": map[string]string{
				"name":        fmt.Sprintf("TestLocks project %d", pnum),
				"owner_uuid":  fixtureAProjectUUID,
				"group_class": "project",
			},
			"ensure_unique_name": true,
		})
		c.Assert(err, check.IsNil)
		for cnum := 0; cnum < ncolls; cnum++ {
			c.Logf("make project %d collection %d", pnum, cnum)
			var coll Collection
			err = s.client.RequestAndDecode(&coll, "POST", "arvados/v1/collections", nil, map[string]interface{}{
				"collection": map[string]string{
					"name":       fmt.Sprintf("TestLocks collection %d", cnum),
					"owner_uuid": projects[pnum].UUID,
				},
			})
			c.Assert(err, check.IsNil)
			for d1num := 0; d1num < ndirs; d1num++ {
				s.fs.Mkdir(fmt.Sprintf("/by_id/%s/dir1-%d", coll.UUID, d1num), 0777)
				for d2num := 0; d2num < ndirs; d2num++ {
					s.fs.Mkdir(fmt.Sprintf("/by_id/%s/dir1-%d/dir2-%d", coll.UUID, d1num, d2num), 0777)
					for fnum := 0; fnum < nfiles; fnum++ {
						f, err := s.fs.OpenFile(fmt.Sprintf("/by_id/%s/dir1-%d/dir2-%d/file-%d", coll.UUID, d1num, d2num, fnum), os.O_CREATE|os.O_RDWR, 0755)
						c.Assert(err, check.IsNil)
						f.Close()
						f, err = s.fs.OpenFile(fmt.Sprintf("/by_id/%s/dir1-%d/file-%d", coll.UUID, d1num, fnum), os.O_CREATE|os.O_RDWR, 0755)
						c.Assert(err, check.IsNil)
						f.Close()
					}
				}
			}
		}
	}
	c.Log("sync")
	s.fs.Sync()
	var wg sync.WaitGroup
	for n := 0; n < 100; n++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for pnum, project := range projects {
				c.Logf("read project %d", pnum)
				if pnum%2 == 0 {
					f, err := s.fs.Open(fmt.Sprintf("/by_id/%s", project.UUID))
					c.Assert(err, check.IsNil)
					f.Readdir(-1)
					f.Close()
				}
				for cnum := 0; cnum < ncolls; cnum++ {
					c.Logf("read project %d collection %d", pnum, cnum)
					if pnum%2 == 0 {
						f, err := s.fs.Open(fmt.Sprintf("/by_id/%s/TestLocks collection %d", project.UUID, cnum))
						c.Assert(err, check.IsNil)
						_, err = f.Readdir(-1)
						c.Assert(err, check.IsNil)
						f.Close()
					}
					if pnum%3 == 0 {
						for d1num := 0; d1num < ndirs; d1num++ {
							f, err := s.fs.Open(fmt.Sprintf("/by_id/%s/TestLocks collection %d/dir1-%d", project.UUID, cnum, d1num))
							c.Assert(err, check.IsNil)
							fis, err := f.Readdir(-1)
							c.Assert(err, check.IsNil)
							c.Assert(fis, check.HasLen, ndirs+nfiles)
							f.Close()
						}
					}
					for d1num := 0; d1num < ndirs; d1num++ {
						for d2num := 0; d2num < ndirs; d2num++ {
							f, err := s.fs.Open(fmt.Sprintf("/by_id/%s/TestLocks collection %d/dir1-%d/dir2-%d", project.UUID, cnum, d1num, d2num))
							c.Assert(err, check.IsNil)
							fis, err := f.Readdir(-1)
							c.Assert(err, check.IsNil)
							c.Assert(fis, check.HasLen, nfiles)
							f.Close()
						}
					}
				}
			}
		}()
	}
	wg.Wait()
	c.Logf("MemorySize == %d", s.fs.MemorySize())
}
