// Copyright (c) 2014-present, b3log.org
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package file includes file related manipulations.
package file

import (
	"encoding/json"
	"io/ioutil"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"github.com/88250/gulu"
	"github.com/88250/wide/conf"
	"github.com/88250/wide/event"
	"github.com/88250/wide/session"
)

// Logger.
var logger = gulu.Log.NewLogger(os.Stdout)

// Node represents a file node in file tree.
type Node struct {
	Id        string  `json:"id"`
	Name      string  `json:"name"`
	Path      string  `json:"path"`
	IconSkin  string  `json:"iconSkin"` // Value should be end with a space
	IsParent  bool    `json:"isParent"`
	Type      string  `json:"type"`      // "f": file, "d": directory
	Creatable bool    `json:"creatable"` // whether can create file in this file node
	Removable bool    `json:"removable"` // whether can remove this file node
	IsGoAPI   bool    `json:"isGOAPI"`
	Mode      string  `json:"mode"`
	Children  []*Node `json:"children"`
}

// Snippet represents a source code snippet, used to as the result of "Find Usages", "Search".
type Snippet struct {
	Path     string   `json:"path"`     // file path
	Line     int      `json:"line"`     // line number
	Ch       int      `json:"ch"`       // column number
	Contents []string `json:"contents"` // lines nearby
}

var apiNode *Node

// initAPINode builds the Go API file node.
func initAPINode() {
	apiPath := gulu.Go.GetAPIPath()

	apiNode = &Node{Name: "Go API", Path: apiPath, IconSkin: "ico-ztree-dir-api ", Type: "d",
		Creatable: false, Removable: false, IsGoAPI: true, Children: []*Node{}}

	walk(apiPath, apiNode, false, false, true)
}

// GetFilesHandler handles request of constructing user workspace file tree.
//
// The Go API source code package also as a child node,
// so that users can easily view the Go API source code in file tree.
func GetFilesHandler(w http.ResponseWriter, r *http.Request) {
	httpSession, _ := session.HTTPSession.Get(r, session.CookieName)
	if httpSession.IsNew {
		http.Error(w, "Forbidden", http.StatusForbidden)

		return
	}
	uid := httpSession.Values["uid"].(string)

	result := gulu.Ret.NewResult()
	defer gulu.Ret.RetGzResult(w, r, result)

	userWorkspace := conf.GetUserWorkspace(uid)
	workspaces := filepath.SplitList(userWorkspace)

	root := Node{Name: "root", Path: "", IconSkin: "ico-ztree-dir ", Type: "d", IsParent: true, Children: []*Node{}}

	if nil == apiNode { // lazy init
		initAPINode()
	}

	// workspace node process
	for _, workspace := range workspaces {
		workspacePath := workspace + conf.PathSeparator + "src"

		workspaceNode := Node{
			Id:        filepath.ToSlash(workspacePath), // jQuery API can't accept "\", so we convert it to "/"
			Name:      workspace[strings.LastIndex(workspace, conf.PathSeparator)+1:],
			Path:      filepath.ToSlash(workspacePath),
			IconSkin:  "ico-ztree-dir-workspace ",
			Type:      "d",
			Creatable: true,
			Removable: false,
			IsGoAPI:   false,
			Children:  []*Node{}}

		walk(workspacePath, &workspaceNode, true, true, false)

		// add workspace node
		root.Children = append(root.Children, &workspaceNode)
	}

	// add Go API node
	root.Children = append(root.Children, apiNode)

	result.Data = root
}

// RefreshDirectoryHandler handles request of refresh a directory of file tree.
func RefreshDirectoryHandler(w http.ResponseWriter, r *http.Request) {
	httpSession, _ := session.HTTPSession.Get(r, session.CookieName)
	if httpSession.IsNew {
		http.Error(w, "Forbidden", http.StatusForbidden)

		return
	}
	uid := httpSession.Values["uid"].(string)

	r.ParseForm()
	path := r.FormValue("path")

	if !gulu.Go.IsAPI(path) && !session.CanAccess(uid, path) {
		http.Error(w, "Forbidden", http.StatusForbidden)

		return
	}

	node := Node{Name: "root", Path: path, IconSkin: "ico-ztree-dir ", Type: "d", Children: []*Node{}}

	walk(path, &node, true, true, false)

	w.Header().Set("Content-Type", "application/json")
	data, err := json.Marshal(node.Children)
	if err != nil {
		logger.Error(err)
		return
	}

	w.Write(data)
}

// GetFileHandler handles request of opening file by editor.
func GetFileHandler(w http.ResponseWriter, r *http.Request) {
	httpSession, _ := session.HTTPSession.Get(r, session.CookieName)
	if httpSession.IsNew {
		http.Error(w, "Forbidden", http.StatusForbidden)

		return
	}
	uid := httpSession.Values["uid"].(string)

	result := gulu.Ret.NewResult()
	defer gulu.Ret.RetResult(w, r, result)

	var args map[string]interface{}

	if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
		logger.Error(err)
		result.Code = -1

		return
	}

	path := args["path"].(string)

	if !gulu.Go.IsAPI(path) && !session.CanAccess(uid, path) {
		http.Error(w, "Forbidden", http.StatusForbidden)

		return
	}

	size := gulu.File.GetFileSize(path)
	if size > 5242880 { // 5M
		result.Code = -1
		result.Msg = "This file is too large to open :("

		return
	}

	data := map[string]interface{}{}
	result.Data = &data

	buf, _ := ioutil.ReadFile(path)

	extension := filepath.Ext(path)

	if gulu.File.IsImg(extension) {
		// image file will be open in a browser tab

		data["mode"] = "img"

		userId := conf.GetOwner(path)
		if "" == userId {
			logger.Warnf("The path [%s] has no owner", path)
			data["path"] = ""

			return
		}

		user := conf.GetUser(uid)

		data["path"] = "/workspace/" + user.Name + "/" + strings.Replace(path, user.WorkspacePath(), "", 1)

		return
	}

	content := string(buf)

	if gulu.File.IsBinary(content) {
		result.Code = -1
		result.Msg = "Can't open a binary file :("
	} else {
		data["content"] = content
		data["path"] = path
	}
}

// SaveFileHandler handles request of saving file.
func SaveFileHandler(w http.ResponseWriter, r *http.Request) {
	httpSession, _ := session.HTTPSession.Get(r, session.CookieName)
	if httpSession.IsNew {
		http.Error(w, "Forbidden", http.StatusForbidden)

		return
	}
	uid := httpSession.Values["uid"].(string)

	result := gulu.Ret.NewResult()
	defer gulu.Ret.RetResult(w, r, result)

	if conf.Wide.ReadOnly {
		result.Code = -1
		result.Msg = "readonly mode"
		return
	}

	var args map[string]interface{}

	if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
		logger.Error(err)
		result.Code = -1

		return
	}

	filePath := args["file"].(string)
	sid := args["sid"].(string)

	if gulu.Go.IsAPI(filePath) || !session.CanAccess(uid, filePath) {
		http.Error(w, "Forbidden", http.StatusForbidden)

		return
	}

	fout, err := os.Create(filePath)

	if nil != err {
		logger.Error(err)
		result.Code = -1

		return
	}

	code := args["code"].(string)

	fout.WriteString(code)

	if err := fout.Close(); nil != err {
		logger.Error(err)
		result.Code = -1

		wSession := session.WideSessions.Get(sid)
		wSession.EventQueue.Queue <- &event.Event{Code: event.EvtCodeServerInternalError, Sid: sid,
			Data: "can't save file " + filePath}

		return
	}
}

// NewFileHandler handles request of creating file or directory.
func NewFileHandler(w http.ResponseWriter, r *http.Request) {
	httpSession, _ := session.HTTPSession.Get(r, session.CookieName)
	if httpSession.IsNew {
		http.Error(w, "Forbidden", http.StatusForbidden)

		return
	}
	uid := httpSession.Values["uid"].(string)

	result := gulu.Ret.NewResult()
	defer gulu.Ret.RetResult(w, r, result)

	var args map[string]interface{}

	if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
		logger.Error(err)
		result.Code = -1

		return
	}

	path := args["path"].(string)

	if gulu.Go.IsAPI(path) || !session.CanAccess(uid, path) {
		http.Error(w, "Forbidden", http.StatusForbidden)

		return
	}

	fileType := args["fileType"].(string)
	sid := args["sid"].(string)

	wSession := session.WideSessions.Get(sid)

	if !createFile(path, fileType) {
		result.Code = -1

		wSession.EventQueue.Queue <- &event.Event{Code: event.EvtCodeServerInternalError, Sid: sid,
			Data: "can't create file " + path}

		return
	}

	if "f" == fileType {
		logger.Debugf("Created a file [%s] by user [%s]", path, wSession.UserId)
	} else {
		logger.Debugf("Created a dir [%s] by user [%s]", path, wSession.UserId)
	}

}

// RemoveFileHandler handles request of removing file or directory.
func RemoveFileHandler(w http.ResponseWriter, r *http.Request) {
	httpSession, _ := session.HTTPSession.Get(r, session.CookieName)
	if httpSession.IsNew {
		http.Error(w, "Forbidden", http.StatusForbidden)

		return
	}
	uid := httpSession.Values["uid"].(string)

	result := gulu.Ret.NewResult()
	defer gulu.Ret.RetResult(w, r, result)

	var args map[string]interface{}

	if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
		logger.Error(err)
		result.Code = -1

		return
	}

	path := args["path"].(string)

	if gulu.Go.IsAPI(path) || !session.CanAccess(uid, path) {
		http.Error(w, "Forbidden", http.StatusForbidden)

		return
	}

	sid := args["sid"].(string)

	wSession := session.WideSessions.Get(sid)

	if !removeFile(path) {
		result.Code = -1

		wSession.EventQueue.Queue <- &event.Event{Code: event.EvtCodeServerInternalError, Sid: sid,
			Data: "can't remove file " + path}

		return
	}

	logger.Debugf("Removed a file [%s] by user [%s]", path, wSession.UserId)
}

// RenameFileHandler handles request of renaming file or directory.
func RenameFileHandler(w http.ResponseWriter, r *http.Request) {
	httpSession, _ := session.HTTPSession.Get(r, session.CookieName)
	if httpSession.IsNew {
		http.Error(w, "Forbidden", http.StatusForbidden)

		return
	}
	uid := httpSession.Values["uid"].(string)

	result := gulu.Ret.NewResult()
	defer gulu.Ret.RetResult(w, r, result)

	var args map[string]interface{}

	if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
		logger.Error(err)
		result.Code = -1

		return
	}

	oldPath := args["oldPath"].(string)
	if gulu.Go.IsAPI(oldPath) ||
		!session.CanAccess(uid, oldPath) {
		http.Error(w, "Forbidden", http.StatusForbidden)

		return
	}

	newPath := args["newPath"].(string)
	if gulu.Go.IsAPI(newPath) || !session.CanAccess(uid, newPath) {
		http.Error(w, "Forbidden", http.StatusForbidden)

		return
	}

	sid := args["sid"].(string)

	wSession := session.WideSessions.Get(sid)

	if !renameFile(oldPath, newPath) {
		result.Code = -1

		wSession.EventQueue.Queue <- &event.Event{Code: event.EvtCodeServerInternalError, Sid: sid,
			Data: "can't rename file " + oldPath}

		return
	}

	logger.Debugf("Renamed a file [%s] to [%s] by user [%s]", oldPath, newPath, wSession.UserId)
}

// Use to find results sorting.
type foundPath struct {
	Path  string `json:"path"`
	score int
}

type foundPaths []*foundPath

func (f foundPaths) Len() int           { return len(f) }
func (f foundPaths) Swap(i, j int)      { f[i], f[j] = f[j], f[i] }
func (f foundPaths) Less(i, j int) bool { return f[i].score > f[j].score }

// FindHandler handles request of find files under the specified directory with the specified filename pattern.
func FindHandler(w http.ResponseWriter, r *http.Request) {
	httpSession, _ := session.HTTPSession.Get(r, session.CookieName)
	if httpSession.IsNew {
		http.Error(w, "Forbidden", http.StatusForbidden)

		return
	}
	uid := httpSession.Values["uid"].(string)

	result := gulu.Ret.NewResult()
	defer gulu.Ret.RetResult(w, r, result)

	var args map[string]interface{}
	if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
		logger.Error(err)
		result.Code = -1

		return
	}

	path := args["path"].(string) // path of selected file in file tree
	if !gulu.Go.IsAPI(path) && !session.CanAccess(uid, path) {
		http.Error(w, "Forbidden", http.StatusForbidden)

		return
	}

	name := args["name"].(string)

	userWorkspace := conf.GetUserWorkspace(uid)
	workspaces := filepath.SplitList(userWorkspace)

	if "" != path && !gulu.File.IsDir(path) {
		path = filepath.Dir(path)
	}

	founds := foundPaths{}

	for _, workspace := range workspaces {
		rs := find(workspace+conf.PathSeparator+"src", name, []*string{})

		for _, r := range rs {
			substr := gulu.Str.LCS(path, *r)

			founds = append(founds, &foundPath{Path: filepath.ToSlash(*r), score: len(substr)})
		}
	}

	sort.Sort(founds)

	result.Data = founds
}

// SearchTextHandler handles request of searching files under the specified directory with the specified keyword.
func SearchTextHandler(w http.ResponseWriter, r *http.Request) {
	httpSession, _ := session.HTTPSession.Get(r, session.CookieName)
	if httpSession.IsNew {
		http.Error(w, "Forbidden", http.StatusForbidden)

		return
	}

	result := gulu.Ret.NewResult()
	defer gulu.Ret.RetResult(w, r, result)

	var args map[string]interface{}

	if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
		logger.Error(err)
		result.Code = -1

		return
	}

	sid := args["sid"].(string)
	wSession := session.WideSessions.Get(sid)
	if nil == wSession {
		result.Code = -1

		return
	}

	// XXX: just one directory

	dir := args["dir"].(string)
	if "" == dir {
		userWorkspace := conf.GetUserWorkspace(wSession.UserId)
		workspaces := filepath.SplitList(userWorkspace)
		dir = workspaces[0]
	}

	extension := args["extension"].(string)
	text := args["text"].(string)

	founds := []*Snippet{}
	if gulu.File.IsDir(dir) {
		founds = search(dir, extension, text, []*Snippet{})
	} else {
		founds = searchInFile(dir, text)
	}

	result.Data = founds
}

// walk traverses the specified path to build a file tree.
func walk(path string, node *Node, creatable, removable, isGOAPI bool) {
	files := listFiles(path)

	for _, filename := range files {
		fpath := filepath.Join(path, filename)

		fio, _ := os.Lstat(fpath)

		child := Node{
			Id:        filepath.ToSlash(fpath), // jQuery API can't accept "\", so we convert it to "/"
			Name:      filename,
			Path:      filepath.ToSlash(fpath),
			Removable: removable,
			IsGoAPI:   isGOAPI,
			Children:  []*Node{}}
		node.Children = append(node.Children, &child)

		if nil == fio {
			logger.Warnf("Path [%s] is nil", fpath)

			continue
		}

		if fio.IsDir() {
			child.Type = "d"
			child.Creatable = creatable
			child.IconSkin = "ico-ztree-dir "
			child.IsParent = true

			walk(fpath, &child, creatable, removable, isGOAPI)
		} else {
			child.Type = "f"
			child.Creatable = creatable
			ext := filepath.Ext(fpath)

			child.IconSkin = getIconSkin(ext)
		}
	}

	return
}

// listFiles lists names of files under the specified dirname.
func listFiles(dirname string) []string {
	f, _ := os.Open(dirname)

	names, _ := f.Readdirnames(-1)
	f.Close()

	sort.Strings(names)

	dirs := []string{}
	files := []string{}

	// sort: directories in front of files
	for _, name := range names {
		path := filepath.Join(dirname, name)
		fio, err := os.Lstat(path)

		if nil != err {
			logger.Warnf("Can't read file info [%s]", path)

			continue
		}

		if fio.IsDir() {
			// exclude the .git, .svn, .hg direcitory
			if ".git" == fio.Name() || ".svn" == fio.Name() || ".hg" == fio.Name() {
				continue
			}

			dirs = append(dirs, name)
		} else {
			// exclude the .DS_Store directory on Mac OS X
			if ".DS_Store" == fio.Name() {
				continue
			}

			files = append(files, name)
		}
	}

	return append(dirs, files...)
}

// getIconSkin gets CSS class name of icon with the specified filename extension.
//
// Refers to the zTree document for CSS class names.
func getIconSkin(filenameExtension string) string {
	if gulu.File.IsImg(filenameExtension) {
		return "ico-ztree-img "
	}

	switch filenameExtension {
	case ".html", ".htm":
		return "ico-ztree-html "
	case ".go":
		return "ico-ztree-go "
	case ".css":
		return "ico-ztree-css "
	case ".txt":
		return "ico-ztree-text "
	case ".sql":
		return "ico-ztree-sql "
	case ".properties":
		return "ico-ztree-pro "
	case ".md":
		return "ico-ztree-md "
	case ".js", ".json":
		return "ico-ztree-js "
	case ".xml":
		return "ico-ztree-xml "
	default:
		return "ico-ztree-other "
	}
}

// createFile creates file on the specified path.
//
// fileType:
//
//  "f": file
//  "d": directory
func createFile(path, fileType string) bool {
	switch fileType {
	case "f":
		file, err := os.OpenFile(path, os.O_CREATE, 0775)
		if nil != err {
			logger.Error(err)

			return false
		}

		defer file.Close()

		logger.Tracef("Created file [%s]", path)

		return true
	case "d":
		err := os.Mkdir(path, 0775)

		if nil != err {
			logger.Error(err)

			return false
		}

		logger.Tracef("Created directory [%s]", path)

		return true
	default:
		logger.Errorf("Unsupported file type [%s]", fileType)

		return false
	}
}

// removeFile removes file on the specified path.
func removeFile(path string) bool {
	if err := os.RemoveAll(path); nil != err {
		logger.Errorf("Removes [%s] failed: [%s]", path, err.Error())

		return false
	}

	logger.Tracef("Removed [%s]", path)

	return true
}

// renameFile renames (moves) a file from the specified old path to the specified new path.
func renameFile(oldPath, newPath string) bool {
	if err := os.Rename(oldPath, newPath); nil != err {
		logger.Errorf("Renames [%s] failed: [%s]", oldPath, err.Error())

		return false
	}

	logger.Tracef("Renamed [%s] to [%s]", oldPath, newPath)

	return true
}

// Default exclude file name patterns when find.
var defaultExcludesFind = []string{".git", ".svn", ".repository", "CVS", "RCS", "SCCS", ".bzr", ".metadata", ".hg"}

// find finds files under the specified dir and its sub-directoryies with the specified name,
// likes the command 'find dir -name name'.
func find(dir, name string, results []*string) []*string {
	if !strings.HasSuffix(dir, conf.PathSeparator) {
		dir += conf.PathSeparator
	}

	f, _ := os.Open(dir)
	fileInfos, err := f.Readdir(-1)
	f.Close()

	if nil != err {
		logger.Errorf("Read dir [%s] failed: [%s]", dir, err.Error())

		return results
	}

	for _, fileInfo := range fileInfos {
		fname := fileInfo.Name()
		path := dir + fname

		if fileInfo.IsDir() {
			if gulu.Str.Contains(fname, defaultExcludesFind) {
				continue
			}

			// enter the directory recursively
			results = find(path, name, results)
		} else {
			// match filename
			pattern := filepath.Dir(path) + conf.PathSeparator + name

			match, err := filepath.Match(strings.ToLower(pattern), strings.ToLower(path))

			if nil != err {
				logger.Errorf("Find match filename failed: [%s]", err.Error())

				continue
			}

			if match {
				results = append(results, &path)
			}
		}
	}

	return results
}

// search finds file under the specified dir and its sub-directories with the specified text, likes the command 'grep'
// or 'findstr'.
func search(dir, extension, text string, snippets []*Snippet) []*Snippet {
	if !strings.HasSuffix(dir, conf.PathSeparator) {
		dir += conf.PathSeparator
	}

	f, _ := os.Open(dir)
	fileInfos, err := f.Readdir(-1)
	f.Close()

	if nil != err {
		logger.Errorf("Read dir [%s] failed: [%s]", dir, err.Error())

		return snippets
	}

	for _, fileInfo := range fileInfos {
		path := dir + fileInfo.Name()

		if fileInfo.IsDir() {
			// enter the directory recursively
			snippets = search(path, extension, text, snippets)
		} else if strings.HasSuffix(path, extension) {
			// grep in file
			ss := searchInFile(path, text)

			snippets = append(snippets, ss...)
		}
	}

	return snippets
}

// searchInFile finds file with the specified path and text.
func searchInFile(path string, text string) []*Snippet {
	ret := []*Snippet{}

	bytes, err := ioutil.ReadFile(path)
	if nil != err {
		logger.Errorf("Read file [%s] failed: [%s]", path, err.Error())

		return ret
	}

	content := string(bytes)
	if gulu.File.IsBinary(content) {
		return ret
	}

	lines := strings.Split(content, "\n")

	for idx, line := range lines {
		ch := strings.Index(strings.ToLower(line), strings.ToLower(text))

		if -1 != ch {
			snippet := &Snippet{Path: filepath.ToSlash(path),
				Line: idx + 1, Ch: ch + 1, Contents: []string{line}}

			ret = append(ret, snippet)
		}
	}

	return ret
}
