package fs

import (
	"errors"
	"os"
	"sort"
	"strings"
	"sync"
	"syscall"
)

type EntryKind uint8

const (
	DirEntry  EntryKind = 1
	FileEntry EntryKind = 2
)

type Entry struct {
	symlink  string
	dir      string
	base     string
	mutex    sync.Mutex
	kind     EntryKind
	needStat bool
}

func (e *Entry) Kind(fs FS) EntryKind {
	e.mutex.Lock()
	defer e.mutex.Unlock()
	if e.needStat {
		e.needStat = false
		e.symlink, e.kind = fs.kind(e.dir, e.base)
	}
	return e.kind
}

func (e *Entry) Symlink(fs FS) string {
	e.mutex.Lock()
	defer e.mutex.Unlock()
	if e.needStat {
		e.needStat = false
		e.symlink, e.kind = fs.kind(e.dir, e.base)
	}
	return e.symlink
}

type accessedEntries struct {
	mutex      sync.Mutex
	wasPresent map[string]bool

	// If this is nil, "SortedKeys()" was not accessed. This means we should
	// check for whether this directory has changed or not by seeing if any of
	// the entries in the "wasPresent" map have changed in "present or not"
	// status, since the only access was to individual entries via "Get()".
	//
	// If this is non-nil, "SortedKeys()" was accessed. This means we should
	// check for whether this directory has changed or not by checking the
	// "allEntries" array for equality with the existing entries list, since the
	// code asked for all entries and may have used the presence or absence of
	// entries in that list.
	//
	// The goal of having these two checks is to be as narrow as possible to
	// avoid unnecessary rebuilds. If only "Get()" is called on a few entries,
	// then we won't invalidate the build if random unrelated entries are added
	// or removed. But if "SortedKeys()" is called, we need to invalidate the
	// build if anything about the set of entries in this directory is changed.
	allEntries []string
}

type DirEntries struct {
	dir             string
	data            map[string]*Entry
	accessedEntries *accessedEntries
}

func MakeEmptyDirEntries(dir string) DirEntries {
	return DirEntries{dir, make(map[string]*Entry), nil}
}

type DifferentCase struct {
	Dir    string
	Query  string
	Actual string
}

func (entries DirEntries) Get(query string) (*Entry, *DifferentCase) {
	if entries.data != nil {
		key := strings.ToLower(query)
		entry := entries.data[key]

		// Track whether this specific entry was present or absent for watch mode
		if accessed := entries.accessedEntries; accessed != nil {
			accessed.mutex.Lock()
			accessed.wasPresent[key] = entry != nil
			accessed.mutex.Unlock()
		}

		if entry != nil {
			if entry.base != query {
				return entry, &DifferentCase{
					Dir:    entries.dir,
					Query:  query,
					Actual: entry.base,
				}
			}
			return entry, nil
		}
	}

	return nil, nil
}

func (entries DirEntries) SortedKeys() (keys []string) {
	if entries.data != nil {
		keys = make([]string, 0, len(entries.data))
		for _, entry := range entries.data {
			keys = append(keys, entry.base)
		}
		sort.Strings(keys)

		// Track the exact set of all entries for watch mode
		if entries.accessedEntries != nil {
			entries.accessedEntries.mutex.Lock()
			entries.accessedEntries.allEntries = keys
			entries.accessedEntries.mutex.Unlock()
		}

		return keys
	}

	return
}

type OpenedFile interface {
	Len() int
	Read(start int, end int) ([]byte, error)
	Close() error
}

type InMemoryOpenedFile struct {
	Contents []byte
}

func (f *InMemoryOpenedFile) Len() int {
	return len(f.Contents)
}

func (f *InMemoryOpenedFile) Read(start int, end int) ([]byte, error) {
	return []byte(f.Contents[start:end]), nil
}

func (f *InMemoryOpenedFile) Close() error {
	return nil
}

type FS interface {
	// The returned map is immutable and is cached across invocations. Do not
	// mutate it.
	ReadDirectory(path string) (entries DirEntries, canonicalError error, originalError error)
	ReadFile(path string) (contents string, canonicalError error, originalError error)
	OpenFile(path string) (result OpenedFile, canonicalError error, originalError error)

	// This is a key made from the information returned by "stat". It is intended
	// to be different if the file has been edited, and to otherwise be equal if
	// the file has not been edited. It should usually work, but no guarantees.
	//
	// See https://apenwarr.ca/log/20181113 for more information about why this
	// can be broken. For example, writing to a file with mmap on WSL on Windows
	// won't change this key. Hopefully this isn't too much of an issue.
	//
	// Additional reading:
	// - https://github.com/npm/npm/pull/20027
	// - https://github.com/golang/go/commit/7dea509703eb5ad66a35628b12a678110fbb1f72
	ModKey(path string) (ModKey, error)

	// This is part of the interface because the mock interface used for tests
	// should not depend on file system behavior (i.e. different slashes for
	// Windows) while the real interface should.
	IsAbs(path string) bool
	Abs(path string) (string, bool)
	Dir(path string) string
	Base(path string) string
	Ext(path string) string
	Join(parts ...string) string
	Cwd() string
	Rel(base string, target string) (string, bool)

	// This is used in the implementation of "Entry"
	kind(dir string, base string) (symlink string, kind EntryKind)

	// This is a set of all files used and all directories checked. The build
	// must be invalidated if any of these watched files change.
	WatchData() WatchData
}

type WatchData struct {
	// These functions return a non-empty path as a string if the file system
	// entry has been modified. For files, the returned path is the same as the
	// file path. For directories, the returned path is either the directory
	// itself or a file in the directory that was changed.
	Paths map[string]func() string
}

type ModKey struct {
	// What gets filled in here is OS-dependent
	inode      uint64
	size       int64
	mtime_sec  int64
	mtime_nsec int64
	mode       uint32
	uid        uint32
}

// Some file systems have a time resolution of only a few seconds. If a mtime
// value is too new, we won't be able to tell if it has been recently modified
// or not. So we only use mtimes for comparison if they are sufficiently old.
// Apparently the FAT file system has a resolution of two seconds according to
// this article: https://en.wikipedia.org/wiki/Stat_(system_call).
const modKeySafetyGap = 3 // In seconds
var modKeyUnusable = errors.New("The modification key is unusable")

// Limit the number of files open simultaneously to avoid ulimit issues
var fileOpenLimit = make(chan bool, 32)

func BeforeFileOpen() {
	// This will block if the number of open files is already at the limit
	fileOpenLimit <- false
}

func AfterFileClose() {
	<-fileOpenLimit
}

// This is a fork of "os.MkdirAll" to work around bugs with the WebAssembly
// build target. More information here: https://github.com/golang/go/issues/43768.
func MkdirAll(fs FS, path string, perm os.FileMode) error {
	// Run "Join" once to run "Clean" on the path, which removes trailing slashes
	return mkdirAll(fs, fs.Join(path), perm)
}

func mkdirAll(fs FS, path string, perm os.FileMode) error {
	// Fast path: if we can tell whether path is a directory or file, stop with success or error.
	if dir, err := os.Stat(path); err == nil {
		if dir.IsDir() {
			return nil
		}
		return &os.PathError{Op: "mkdir", Path: path, Err: syscall.ENOTDIR}
	}

	// Slow path: make sure parent exists and then call Mkdir for path.
	if parent := fs.Dir(path); parent != path {
		// Create parent.
		if err := mkdirAll(fs, parent, perm); err != nil {
			return err
		}
	}

	// Parent now exists; invoke Mkdir and use its result.
	if err := os.Mkdir(path, perm); err != nil {
		// Handle arguments like "foo/." by
		// double-checking that directory doesn't exist.
		dir, err1 := os.Lstat(path)
		if err1 == nil && dir.IsDir() {
			return nil
		}
		return err
	}
	return nil
}
