// Copyright 2015 Google Inc. All rights reserved
//
// 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
//
//      http://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 kati

import (
	"bytes"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"syscall"

	"github.com/golang/glog"
)

type fileid struct {
	dev, ino uint64
}

var (
	unknownFileid = fileid{}
	invalidFileid = fileid{dev: 1<<64 - 1, ino: 1<<64 - 1}
)

type dirent struct {
	id    fileid
	name  string
	lmode os.FileMode
	mode  os.FileMode
	// add other fields to support more find commands?
}

type fsCacheT struct {
	mu      sync.Mutex
	ids     map[string]fileid
	dirents map[fileid][]dirent
}

var fsCache = &fsCacheT{
	ids: make(map[string]fileid),
	dirents: map[fileid][]dirent{
		invalidFileid: nil,
	},
}

func init() {
	fsCache.readdir(".", unknownFileid)
}

func (c *fsCacheT) dirs() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return len(c.dirents)
}

func (c *fsCacheT) files() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	n := 0
	for _, ents := range c.dirents {
		n += len(ents)
	}
	return n
}

func hasWildcardMeta(pat string) bool {
	return strings.IndexAny(pat, "*?[") >= 0
}

func hasWildcardMetaByte(pat []byte) bool {
	return bytes.IndexAny(pat, "*?[") >= 0
}

func wildcardUnescape(pat string) string {
	var buf bytes.Buffer
	for i := 0; i < len(pat); i++ {
		if pat[i] == '\\' && i+1 < len(pat) {
			switch pat[i+1] {
			case '*', '?', '[', '\\':
				buf.WriteByte(pat[i])
			}
			continue
		}
		buf.WriteByte(pat[i])
	}
	return buf.String()
}

func filepathJoin(names ...string) string {
	var dir string
	for i, n := range names {
		dir += n
		if i != len(names)-1 && n != "" && n[len(n)-1] != '/' {
			dir += "/"
		}
	}
	return dir
}

func filepathClean(path string) string {
	var names []string
	if filepath.IsAbs(path) {
		names = append(names, "")
	}
	paths := strings.Split(path, string(filepath.Separator))
Loop:
	for _, n := range paths {
		if n == "" || n == "." {
			continue Loop
		}
		if n == ".." && len(names) > 0 {
			dir, last := names[:len(names)-1], names[len(names)-1]
			parent := strings.Join(dir, string(filepath.Separator))
			if parent == "" {
				parent = "."
			}
			_, ents := fsCache.readdir(parent, unknownFileid)
			for _, e := range ents {
				if e.name != last {
					continue
				}
				if e.lmode&os.ModeSymlink == os.ModeSymlink && e.mode&os.ModeDir == os.ModeDir {
					// preserve .. if last is symlink dir.
					names = append(names, "..")
					continue Loop
				}
				// last is not symlink, maybe safe to clean.
				names = names[:len(names)-1]
				continue Loop
			}
			// parent doesn't exists? preserve ..
			names = append(names, "..")
			continue Loop
		}
		names = append(names, n)
	}
	if len(names) == 0 {
		return "."
	}
	return strings.Join(names, string(filepath.Separator))
}

func (c *fsCacheT) fileid(dir string) fileid {
	c.mu.Lock()
	id := c.ids[dir]
	c.mu.Unlock()
	return id
}

func (c *fsCacheT) readdir(dir string, id fileid) (fileid, []dirent) {
	glog.V(3).Infof("readdir: %s [%v]", dir, id)
	c.mu.Lock()
	if id == unknownFileid {
		id = c.ids[dir]
	}
	ents, ok := c.dirents[id]
	c.mu.Unlock()
	if ok {
		return id, ents
	}
	glog.V(3).Infof("opendir: %s", dir)
	d, err := os.Open(dir)
	if err != nil {
		c.mu.Lock()
		c.ids[dir] = invalidFileid
		c.mu.Unlock()
		return invalidFileid, nil
	}
	defer d.Close()
	fi, err := d.Stat()
	if err != nil {
		c.mu.Lock()
		c.ids[dir] = invalidFileid
		c.mu.Unlock()
		return invalidFileid, nil
	}
	if stat, ok := fi.Sys().(*syscall.Stat_t); ok {
		id = fileid{dev: uint64(stat.Dev), ino: stat.Ino}
	}
	names, _ := d.Readdirnames(-1)
	// need sort?
	ents = nil
	var path string
	for _, name := range names {
		path = filepath.Join(dir, name)
		fi, err := os.Lstat(path)
		if err != nil {
			glog.Warningf("readdir %s: %v", name, err)
			ents = append(ents, dirent{name: name})
			continue
		}
		lmode := fi.Mode()
		mode := lmode
		var id fileid
		if stat, ok := fi.Sys().(*syscall.Stat_t); ok {
			id = fileid{dev: uint64(stat.Dev), ino: stat.Ino}
		}
		if lmode&os.ModeSymlink == os.ModeSymlink {
			fi, err = os.Stat(path)
			if err != nil {
				glog.Warningf("readdir %s: %v", name, err)
			} else {
				mode = fi.Mode()
				if stat, ok := fi.Sys().(*syscall.Stat_t); ok {
					id = fileid{dev: uint64(stat.Dev), ino: stat.Ino}
				}
			}
		}
		ents = append(ents, dirent{id: id, name: name, lmode: lmode, mode: mode})
	}
	glog.V(3).Infof("readdir:%s => %v: %v", dir, id, ents)
	c.mu.Lock()
	c.ids[dir] = id
	c.dirents[id] = ents
	c.mu.Unlock()
	return id, ents
}

// glob searches for files matching pattern in the directory dir
// and appends them to matches. ignore I/O errors.
func (c *fsCacheT) glob(dir, pattern string, matches []string) ([]string, error) {
	_, ents := c.readdir(filepathClean(dir), unknownFileid)
	switch dir {
	case "", string(filepath.Separator):
		// nothing
	default:
		dir += string(filepath.Separator) // add trailing separator back
	}
	for _, ent := range ents {
		matched, err := filepath.Match(pattern, ent.name)
		if err != nil {
			return nil, err
		}
		if matched {
			matches = append(matches, dir+ent.name)
		}
	}
	return matches, nil
}

func (c *fsCacheT) Glob(pat string) ([]string, error) {
	// TODO(ukai): expand ~ to user's home directory.
	// TODO(ukai): use find cache for glob if exists
	// or use wildcardCache for find cache.
	pat = wildcardUnescape(pat)
	dir, file := filepath.Split(pat)
	switch dir {
	case "", string(filepath.Separator):
		// nothing
	default:
		dir = dir[:len(dir)-1] // chop off trailing separator
	}
	if !hasWildcardMeta(dir) {
		return c.glob(dir, file, nil)
	}

	m, err := c.Glob(dir)
	if err != nil {
		return nil, err
	}
	var matches []string
	for _, d := range m {
		matches, err = c.glob(d, file, matches)
		if err != nil {
			return nil, err
		}
	}
	return matches, nil
}

func wildcard(w evalWriter, pat string) error {
	files, err := fsCache.Glob(pat)
	if err != nil {
		return err
	}
	for _, file := range files {
		w.writeWordString(file)
	}
	return nil
}

type findOp interface {
	apply(evalWriter, string, dirent) (test bool, prune bool)
}

type findOpName string

func (op findOpName) apply(w evalWriter, path string, ent dirent) (bool, bool) {
	matched, err := filepath.Match(string(op), ent.name)
	if err != nil {
		glog.Warningf("find -name %q: %v", string(op), err)
		return false, false
	}
	return matched, false
}

type findOpType struct {
	mode           os.FileMode
	followSymlinks bool
}

func (op findOpType) apply(w evalWriter, path string, ent dirent) (bool, bool) {
	mode := ent.lmode
	if op.followSymlinks && ent.mode != 0 {
		mode = ent.mode
	}
	return op.mode&mode == op.mode, false
}

type findOpRegular struct {
	followSymlinks bool
}

func (op findOpRegular) apply(w evalWriter, path string, ent dirent) (bool, bool) {
	mode := ent.lmode
	if op.followSymlinks && ent.mode != 0 {
		mode = ent.mode
	}
	return mode.IsRegular(), false
}

type findOpNot struct {
	op findOp
}

func (op findOpNot) apply(w evalWriter, path string, ent dirent) (bool, bool) {
	test, prune := op.op.apply(w, path, ent)
	return !test, prune
}

type findOpAnd []findOp

func (op findOpAnd) apply(w evalWriter, path string, ent dirent) (bool, bool) {
	var prune bool
	for _, o := range op {
		test, p := o.apply(w, path, ent)
		if p {
			prune = true
		}
		if !test {
			return test, prune
		}
	}
	return true, prune
}

type findOpOr struct {
	op1, op2 findOp
}

func (op findOpOr) apply(w evalWriter, path string, ent dirent) (bool, bool) {
	test, prune := op.op1.apply(w, path, ent)
	if test {
		return test, prune
	}
	return op.op2.apply(w, path, ent)
}

type findOpPrune struct{}

func (op findOpPrune) apply(w evalWriter, path string, ent dirent) (bool, bool) {
	return true, true
}

type findOpPrint struct{}

func (op findOpPrint) apply(w evalWriter, path string, ent dirent) (bool, bool) {
	var name string
	if path == "" {
		name = ent.name
	} else if ent.name == "." {
		name = path
	} else {
		name = filepathJoin(path, ent.name)
	}
	glog.V(3).Infof("find print: %s", name)
	w.writeWordString(name)
	return true, false
}

func (c *fsCacheT) find(w evalWriter, fc findCommand, path string, id fileid, depth int, seen map[fileid]string) {
	glog.V(2).Infof("find: path:%s id:%v depth:%d", path, id, depth)
	id, ents := c.readdir(filepathClean(filepathJoin(fc.chdir, path)), id)
	if ents == nil {
		glog.V(1).Infof("find: %s %s not found", fc.chdir, path)
		return
	}
	for _, ent := range ents {
		glog.V(3).Infof("find: path:%s ent:%s depth:%d", path, ent.name, depth)
		_, prune := fc.apply(w, path, ent)
		mode := ent.lmode
		if fc.followSymlinks {
			if mode&os.ModeSymlink == os.ModeSymlink {
				lpath := filepathJoin(path, ent.name)
				if p, ok := seen[ent.id]; ok {
					// stderr?
					glog.Errorf("find: File system loop detected; `%s' is part of the same file system loop as `%s'.", lpath, p)
					return
				}
				seen[ent.id] = lpath
			}
			mode = ent.mode
		}
		if !mode.IsDir() {
			glog.V(3).Infof("find: not dir: %s/%s", path, ent.name)
			continue
		}
		if prune {
			glog.V(3).Infof("find: prune: %s", path)
			continue
		}
		if depth >= fc.depth {
			glog.V(3).Infof("find: depth: %d >= %d", depth, fc.depth)
			continue
		}
		c.find(w, fc, filepathJoin(path, ent.name), ent.id, depth+1, seen)
	}
}

type findCommand struct {
	testdir        string // before chdir
	chdir          string
	finddirs       []string // after chdir
	followSymlinks bool
	ops            []findOp
	depth          int
}

func parseFindCommand(cmd string) (findCommand, error) {
	if !strings.Contains(cmd, "find") {
		return findCommand{}, errNotFind
	}
	fcp := findCommandParser{
		shellParser: shellParser{
			cmd: cmd,
		},
	}
	err := fcp.parse()
	if err != nil {
		return fcp.fc, err
	}
	if len(fcp.fc.finddirs) == 0 {
		fcp.fc.finddirs = append(fcp.fc.finddirs, ".")
	}
	if fcp.fc.chdir != "" {
		fcp.fc.chdir = filepathClean(fcp.fc.chdir)
	}
	if filepath.IsAbs(fcp.fc.chdir) {
		return fcp.fc, errFindAbspath
	}
	for _, dir := range fcp.fc.finddirs {
		if filepath.IsAbs(dir) {
			return fcp.fc, errFindAbspath
		}
	}
	glog.V(3).Infof("find command: %#v", fcp.fc)

	// TODO(ukai): handle this in run() instead of fallback shell.
	_, ents := fsCache.readdir(filepathClean(fcp.fc.testdir), unknownFileid)
	if ents == nil {
		glog.V(1).Infof("find: testdir %s - not dir", fcp.fc.testdir)
		return fcp.fc, errFindNoSuchDir
	}
	_, ents = fsCache.readdir(filepathClean(fcp.fc.chdir), unknownFileid)
	if ents == nil {
		glog.V(1).Infof("find: cd %s: No such file or directory", fcp.fc.chdir)
		return fcp.fc, errFindNoSuchDir
	}

	return fcp.fc, nil
}

func (fc findCommand) run(w evalWriter) {
	glog.V(3).Infof("find: %#v", fc)
	for _, dir := range fc.finddirs {
		seen := make(map[fileid]string)
		id, _ := fsCache.readdir(filepathClean(filepathJoin(fc.chdir, dir)), unknownFileid)
		_, prune := fc.apply(w, dir, dirent{id: id, name: ".", mode: os.ModeDir, lmode: os.ModeDir})
		if prune {
			glog.V(3).Infof("find: prune: %s", dir)
			continue
		}
		if 0 >= fc.depth {
			glog.V(3).Infof("find: depth: 0 >= %d", fc.depth)
			continue
		}
		fsCache.find(w, fc, dir, id, 1, seen)
	}
}

func (fc findCommand) apply(w evalWriter, path string, ent dirent) (test, prune bool) {
	var p bool
	for _, op := range fc.ops {
		test, p = op.apply(w, path, ent)
		if p {
			prune = true
		}
		if !test {
			break
		}
	}
	glog.V(2).Infof("apply path:%s ent:%v => test=%t, prune=%t", path, ent, test, prune)
	return test, prune
}

var (
	errNotFind             = errors.New("not find command")
	errFindBackground      = errors.New("find command: background")
	errFindUnbalancedQuote = errors.New("find command: unbalanced quote")
	errFindDupChdir        = errors.New("find command: dup chdir")
	errFindDupTestdir      = errors.New("find command: dup testdir")
	errFindExtra           = errors.New("find command: extra")
	errFindUnexpectedEnd   = errors.New("find command: unexpected end")
	errFindAbspath         = errors.New("find command: abs path")
	errFindNoSuchDir       = errors.New("find command: no such dir")
)

type findCommandParser struct {
	fc findCommand
	shellParser
}

func (p *findCommandParser) parse() error {
	p.fc.depth = 1<<31 - 1 // max int32
	var hasIf bool
	var hasFind bool
	for {
		tok, err := p.token()
		if err == io.EOF || tok == "" {
			if !hasFind {
				return errNotFind
			}
			return nil
		}
		if err != nil {
			return err
		}
		switch tok {
		case "cd":
			if p.fc.chdir != "" {
				return errFindDupChdir
			}
			p.fc.chdir, err = p.token()
			if err != nil {
				return err
			}
			err = p.expect(";", "&&")
			if err != nil {
				return err
			}
		case "if":
			err = p.expect("[")
			if err != nil {
				return err
			}
			if hasIf {
				return errFindDupTestdir
			}
			err = p.parseTest()
			if err != nil {
				return err
			}
			err = p.expectSeq("]", ";", "then")
			if err != nil {
				return err
			}
			hasIf = true
		case "test":
			if hasIf {
				return errFindDupTestdir
			}
			err = p.parseTest()
			if err != nil {
				return err
			}
			err = p.expect("&&")
			if err != nil {
				return err
			}
		case "find":
			err = p.parseFind()
			if err != nil {
				return err
			}
			if hasIf {
				err = p.expect("fi")
				if err != nil {
					return err
				}
			}
			tok, err = p.token()
			if err != io.EOF || tok != "" {
				return errFindExtra
			}
			hasFind = true
			return nil
		}
	}
}

func (p *findCommandParser) parseTest() error {
	if p.fc.testdir != "" {
		return errFindDupTestdir
	}
	err := p.expect("-d")
	if err != nil {
		return err
	}
	p.fc.testdir, err = p.token()
	return err
}

func (p *findCommandParser) parseFind() error {
	for {
		tok, err := p.token()
		if err == io.EOF || tok == "" || tok == ";" {
			var print findOpPrint
			if len(p.fc.ops) == 0 || p.fc.ops[len(p.fc.ops)-1] != print {
				p.fc.ops = append(p.fc.ops, print)
			}
			return nil
		}
		if err != nil {
			return err
		}
		if tok != "" && (tok[0] == '-' || tok == "\\(") {
			p.unget(tok)
			op, err := p.parseFindCond()
			if err != nil {
				return err
			}
			if op != nil {
				p.fc.ops = append(p.fc.ops, op)
			}
			continue
		}
		p.fc.finddirs = append(p.fc.finddirs, tok)
	}
}

func (p *findCommandParser) parseFindCond() (findOp, error) {
	return p.parseExpr()
}

func (p *findCommandParser) parseExpr() (findOp, error) {
	op, err := p.parseTerm()
	if err != nil {
		return nil, err
	}
	if op == nil {
		return nil, nil
	}
	for {
		tok, err := p.token()
		if err == io.EOF || tok == "" {
			return op, nil
		}
		if err != nil {
			return nil, err
		}
		if tok != "-or" && tok != "-o" {
			p.unget(tok)
			return op, nil
		}
		op2, err := p.parseTerm()
		if err != nil {
			return nil, err
		}
		op = findOpOr{op, op2}
	}
}

func (p *findCommandParser) parseTerm() (findOp, error) {
	op, err := p.parseFact()
	if err != nil {
		return nil, err
	}
	if op == nil {
		return nil, nil
	}
	var ops []findOp
	ops = append(ops, op)
	for {
		tok, err := p.token()
		if err == io.EOF || tok == "" {
			if len(ops) == 1 {
				return ops[0], nil
			}
			return findOpAnd(ops), nil
		}
		if err != nil {
			return nil, err
		}
		if tok != "-and" && tok != "-a" {
			p.unget(tok)
		}
		op, err = p.parseFact()
		if err != nil {
			return nil, err
		}
		if op == nil {
			if len(ops) == 1 {
				return ops[0], nil
			}
			return findOpAnd(ops), nil
		}
		ops = append(ops, op) // findAndOp?
	}
}

func (p *findCommandParser) parseFact() (findOp, error) {
	tok, err := p.token()
	if err != nil {
		return nil, err
	}
	switch tok {
	case "-L":
		p.fc.followSymlinks = true
		return nil, nil
	case "-prune":
		return findOpPrune{}, nil
	case "-print":
		return findOpPrint{}, nil
	case "-maxdepth":
		tok, err = p.token()
		if err != nil {
			return nil, err
		}
		i, err := strconv.ParseInt(tok, 10, 32)
		if err != nil {
			return nil, err
		}
		if i < 0 {
			return nil, fmt.Errorf("find commnad: -maxdepth negative: %d", i)
		}
		p.fc.depth = int(i)
		return nil, nil
	case "-not", "\\!":
		op, err := p.parseFact()
		if err != nil {
			return nil, err
		}
		return findOpNot{op}, nil
	case "\\(":
		op, err := p.parseExpr()
		if err != nil {
			return nil, err
		}
		err = p.expect("\\)")
		if err != nil {
			return nil, err
		}
		return op, nil
	case "-name":
		tok, err = p.token()
		if err != nil {
			return nil, err
		}
		return findOpName(tok), nil
	case "-type":
		tok, err = p.token()
		if err != nil {
			return nil, err
		}
		var m os.FileMode
		switch tok {
		case "b":
			m = os.ModeDevice
		case "c":
			m = os.ModeDevice | os.ModeCharDevice
		case "d":
			m = os.ModeDir
		case "p":
			m = os.ModeNamedPipe
		case "l":
			m = os.ModeSymlink
		case "f":
			return findOpRegular{p.fc.followSymlinks}, nil
		case "s":
			m = os.ModeSocket
		default:
			return nil, fmt.Errorf("find command: unsupported -type %s", tok)
		}
		return findOpType{m, p.fc.followSymlinks}, nil
	case "-o", "-or", "-a", "-and":
		p.unget(tok)
		return nil, nil
	default:
		if tok != "" && tok[0] == '-' {
			return nil, fmt.Errorf("find command: unsupported %s", tok)
		}
		p.unget(tok)
		return nil, nil
	}
}

type findleavesCommand struct {
	name     string
	dirs     []string
	prunes   []string
	mindepth int
}

func parseFindleavesCommand(cmd string) (findleavesCommand, error) {
	if !strings.Contains(cmd, "build/tools/findleaves.py") {
		return findleavesCommand{}, errNotFindleaves
	}
	fcp := findleavesCommandParser{
		shellParser: shellParser{
			cmd: cmd,
		},
	}
	err := fcp.parse()
	if err != nil {
		return fcp.fc, err
	}
	glog.V(3).Infof("findleaves command: %#v", fcp.fc)
	return fcp.fc, nil
}

func (fc findleavesCommand) run(w evalWriter) {
	glog.V(3).Infof("findleaves: %#v", fc)
	for _, dir := range fc.dirs {
		seen := make(map[fileid]string)
		id, _ := fsCache.readdir(filepathClean(dir), unknownFileid)
		fc.walk(w, dir, id, 1, seen)
	}
}

func (fc findleavesCommand) walk(w evalWriter, dir string, id fileid, depth int, seen map[fileid]string) {
	glog.V(3).Infof("findleaves walk: dir:%d id:%v depth:%d", dir, id, depth)
	id, ents := fsCache.readdir(filepathClean(dir), id)
	var subdirs []dirent
	for _, ent := range ents {
		if ent.mode.IsDir() {
			if fc.isPrune(ent.name) {
				glog.V(3).Infof("findleaves prune %s in %s", ent.name, dir)
				continue
			}
			subdirs = append(subdirs, ent)
			continue
		}
		if depth < fc.mindepth {
			glog.V(3).Infof("findleaves depth=%d mindepth=%d", depth, fc.mindepth)
			continue
		}
		if ent.name == fc.name {
			glog.V(2).Infof("findleaves %s in %s", ent.name, dir)
			w.writeWordString(filepathJoin(dir, ent.name))
			// no recurse subdirs
			return
		}
	}
	for _, subdir := range subdirs {
		if subdir.lmode&os.ModeSymlink == os.ModeSymlink {
			lpath := filepathJoin(dir, subdir.name)
			if p, ok := seen[subdir.id]; ok {
				// symlink loop detected.
				glog.Errorf("findleaves: loop detected %q was %q", lpath, p)
				continue
			}
			seen[subdir.id] = lpath
		}
		fc.walk(w, filepathJoin(dir, subdir.name), subdir.id, depth+1, seen)
	}
}

func (fc findleavesCommand) isPrune(name string) bool {
	for _, p := range fc.prunes {
		if p == name {
			return true
		}
	}
	return false
}

var (
	errNotFindleaves        = errors.New("not findleaves command")
	errFindleavesEmptyPrune = errors.New("findleaves: empty prune")
	errFindleavesNoFilename = errors.New("findleaves: no filename")
)

type findleavesCommandParser struct {
	fc findleavesCommand
	shellParser
}

func (p *findleavesCommandParser) parse() error {
	var args []string
	p.fc.mindepth = -1
	tok, err := p.token()
	if err != nil {
		return err
	}
	if tok != "build/tools/findleaves.py" {
		return errNotFindleaves
	}
	for {
		tok, err := p.token()
		if err == io.EOF || tok == "" {
			break
		}
		if err != nil {
			return err
		}
		switch {
		case strings.HasPrefix(tok, "--prune="):
			prune := filepath.Base(strings.TrimPrefix(tok, "--prune="))
			if prune == "" {
				return errFindleavesEmptyPrune
			}
			p.fc.prunes = append(p.fc.prunes, prune)
		case strings.HasPrefix(tok, "--mindepth="):
			md := strings.TrimPrefix(tok, "--mindepth=")
			i, err := strconv.ParseInt(md, 10, 32)
			if err != nil {
				return err
			}
			p.fc.mindepth = int(i)
		default:
			args = append(args, tok)
		}
	}
	if len(args) < 2 {
		return errFindleavesNoFilename
	}
	p.fc.dirs, p.fc.name = args[:len(args)-1], args[len(args)-1]
	return nil
}
