// scripthaus completion
package comp

import (
	"bytes"
	"context"
	"fmt"
	"sort"
	"strconv"
	"strings"
	"unicode"
	"unicode/utf8"

	"github.com/scripthaus-dev/mshell/pkg/simpleexpand"
	"github.com/scripthaus-dev/sh2-server/pkg/shparse"
	"github.com/scripthaus-dev/sh2-server/pkg/sstore"
	"github.com/scripthaus-dev/sh2-server/pkg/utilfn"
	"mvdan.cc/sh/v3/syntax"
)

const MaxCompQuoteLen = 5000

const (
	// local to simplecomp
	CGTypeCommand  = "command"
	CGTypeFile     = "file"
	CGTypeDir      = "directory"
	CGTypeVariable = "variable"

	// implemented in cmdrunner
	CGTypeMeta        = "metacmd"
	CGTypeCommandMeta = "command+meta"

	CGTypeRemote         = "remote"
	CGTypeRemoteInstance = "remoteinstance"
	CGTypeGlobalCmd      = "globalcmd"
)

const (
	QuoteTypeLiteral = ""
	QuoteTypeDQ      = "\""
	QuoteTypeANSI    = "$'"
	QuoteTypeSQ      = "'"
)

type CompContext struct {
	RemotePtr  *sstore.RemotePtrType
	Cwd        string
	ForDisplay bool
}

type ParsedWord struct {
	Offset      int
	Word        *syntax.Word
	PartialWord string
	Prefix      string
}

type CompPoint struct {
	StmtStr     string
	Words       []ParsedWord
	CompWord    int
	CompWordPos int
	Prefix      string
	Suffix      string
}

// directories will have a trailing "/"
type CompEntry struct {
	Word      string
	IsMetaCmd bool
}

type CompReturn struct {
	CompType string
	Entries  []CompEntry
	HasMore  bool
}

var noEscChars []bool
var specialEsc []string

func init() {
	noEscChars = make([]bool, 256)
	for ch := 0; ch < 256; ch++ {
		if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') ||
			ch == '-' || ch == '.' || ch == '/' || ch == ':' || ch == '=' {
			noEscChars[byte(ch)] = true
		}
	}
	specialEsc = make([]string, 256)
	specialEsc[0x7] = "\\a"
	specialEsc[0x8] = "\\b"
	specialEsc[0x9] = "\\t"
	specialEsc[0xa] = "\\n"
	specialEsc[0xb] = "\\v"
	specialEsc[0xc] = "\\f"
	specialEsc[0xd] = "\\r"
	specialEsc[0x1b] = "\\E"
}

func compQuoteDQString(s string, close bool) string {
	var buf bytes.Buffer
	buf.WriteByte('"')
	for _, ch := range s {
		if ch == '"' || ch == '\\' || ch == '$' || ch == '`' {
			buf.WriteByte('\\')
			buf.WriteRune(ch)
			continue
		}
		buf.WriteRune(ch)
	}
	if close {
		buf.WriteByte('"')
	}
	return buf.String()
}

func hasGlob(s string) bool {
	var lastExtGlob bool
	for _, ch := range s {
		if ch == '*' || ch == '?' || ch == '[' || ch == '{' {
			return true
		}
		if ch == '+' || ch == '@' || ch == '!' {
			lastExtGlob = true
			continue
		}
		if lastExtGlob && ch == '(' {
			return true
		}
		lastExtGlob = false
	}
	return false
}

func writeUtf8Literal(buf *bytes.Buffer, ch rune) {
	var runeArr [utf8.UTFMax]byte
	buf.WriteString("$'")
	barr := runeArr[:]
	byteLen := utf8.EncodeRune(barr, ch)
	for i := 0; i < byteLen; i++ {
		buf.WriteString("\\x")
		buf.WriteByte(utilfn.HexDigits[barr[i]/16])
		buf.WriteByte(utilfn.HexDigits[barr[i]%16])
	}
	buf.WriteByte('\'')
}

func compQuoteLiteralString(s string) string {
	var buf bytes.Buffer
	for idx, ch := range s {
		if ch == 0 {
			break
		}
		if idx == 0 && ch == '~' {
			buf.WriteRune(ch)
			continue
		}
		if ch > unicode.MaxASCII {
			writeUtf8Literal(&buf, ch)
			continue
		}
		var bch = byte(ch)
		if noEscChars[bch] {
			buf.WriteRune(ch)
			continue
		}
		if specialEsc[bch] != "" {
			buf.WriteString(specialEsc[bch])
			continue
		}
		if !unicode.IsPrint(ch) {
			writeUtf8Literal(&buf, ch)
			continue
		}
		buf.WriteByte('\\')
		buf.WriteByte(bch)
	}
	return buf.String()
}

func compQuoteSQString(s string) string {
	var buf bytes.Buffer
	for _, ch := range s {
		if ch == 0 {
			break
		}
		if ch == '\'' {
			buf.WriteString("'\\''")
			continue
		}
		var bch byte
		if ch <= unicode.MaxASCII {
			bch = byte(ch)
		}
		if ch > unicode.MaxASCII || !unicode.IsPrint(ch) {
			buf.WriteByte('\'')
			if bch != 0 && specialEsc[bch] != "" {
				buf.WriteString(specialEsc[bch])
			} else {
				writeUtf8Literal(&buf, ch)
			}
			buf.WriteByte('\'')
			continue
		}
		buf.WriteByte(bch)
	}
	return buf.String()
}

func compQuoteString(s string, quoteType string, close bool) string {
	if quoteType != QuoteTypeANSI && quoteType != QuoteTypeLiteral {
		for _, ch := range s {
			if ch > unicode.MaxASCII || !unicode.IsPrint(ch) || ch == '!' {
				quoteType = QuoteTypeANSI
				break
			}
			if ch == '\'' {
				if quoteType == QuoteTypeSQ {
					quoteType = QuoteTypeANSI
					break
				}
			}
		}
	}
	if quoteType == QuoteTypeANSI {
		rtn := strconv.QuoteToASCII(s)
		rtn = "$'" + strings.ReplaceAll(rtn[1:len(rtn)-1], "'", "\\'")
		if close {
			rtn = rtn + "'"
		}
		return rtn
	}
	if quoteType == QuoteTypeLiteral {
		return compQuoteLiteralString(s)
	}
	if quoteType == QuoteTypeSQ {
		rtn := utilfn.ShellQuote(s, false, MaxCompQuoteLen)
		if len(rtn) > 0 && rtn[0] != '\'' {
			rtn = "'" + rtn + "'"
		}
		if !close {
			rtn = rtn[0 : len(rtn)-1]
		}
		return rtn
	}
	// QuoteTypeDQ
	return compQuoteDQString(s, close)
}

func (p *CompPoint) wordAsStr(w ParsedWord) string {
	if w.Word != nil {
		return p.StmtStr[w.Word.Pos().Offset():w.Word.End().Offset()]
	}
	return w.PartialWord
}

func (p *CompPoint) simpleExpandWord(w ParsedWord) (string, simpleexpand.SimpleExpandInfo) {
	ectx := simpleexpand.SimpleExpandContext{}
	if w.Word != nil {
		return simpleexpand.SimpleExpandWord(ectx, w.Word, p.StmtStr)
	}
	return simpleexpand.SimpleExpandPartialWord(ectx, w.PartialWord, false)
}

func getQuoteTypePref(str string) string {
	if strings.HasPrefix(str, QuoteTypeANSI) {
		return QuoteTypeANSI
	}
	if strings.HasPrefix(str, QuoteTypeDQ) {
		return QuoteTypeDQ
	}
	if strings.HasPrefix(str, QuoteTypeSQ) {
		return QuoteTypeSQ
	}
	return QuoteTypeLiteral
}

func (p *CompPoint) getCompPrefix() (string, simpleexpand.SimpleExpandInfo) {
	if p.CompWordPos == 0 {
		return "", simpleexpand.SimpleExpandInfo{}
	}
	pword := p.Words[p.CompWord]
	wordStr := p.wordAsStr(pword)
	if p.CompWordPos == len(wordStr) {
		return p.simpleExpandWord(pword)
	}
	// TODO we can do better, if p.Word is not nil, we can look for which WordPart
	//      our pos is in.  we can then do a normal word expand on the previous parts
	//      and a partial on just the current part.  this is an uncommon case though
	//      and has very little upside (even bash does not expand multipart words correctly)
	partialWordStr := wordStr[:p.CompWordPos]
	return simpleexpand.SimpleExpandPartialWord(simpleexpand.SimpleExpandContext{}, partialWordStr, false)
}

func (p *CompPoint) extendWord(newWord string, newWordComplete bool) utilfn.StrWithPos {
	pword := p.Words[p.CompWord]
	wordStr := p.wordAsStr(pword)
	quotePref := getQuoteTypePref(wordStr)
	needsClose := newWordComplete && (len(wordStr) == p.CompWordPos)
	wordSuffix := wordStr[p.CompWordPos:]
	newQuotedStr := compQuoteString(newWord, quotePref, needsClose)
	if needsClose && wordSuffix == "" && !strings.HasSuffix(newWord, "/") {
		newQuotedStr = newQuotedStr + " "
	}
	newPos := len(newQuotedStr)
	return utilfn.StrWithPos{Str: newQuotedStr + wordSuffix, Pos: newPos}
}

// returns (extension, complete)
func computeCompExtension(compPrefix string, crtn *CompReturn) (string, bool) {
	if crtn == nil || crtn.HasMore {
		return "", false
	}
	compStrs := crtn.GetCompStrs()
	lcp := utilfn.LongestPrefix(compPrefix, compStrs)
	if lcp == compPrefix || len(lcp) < len(compPrefix) || !strings.HasPrefix(lcp, compPrefix) {
		return "", false
	}
	return lcp[len(compPrefix):], (utilfn.ContainsStr(compStrs, lcp) && !utilfn.IsPrefix(compStrs, lcp))
}

func (p *CompPoint) FullyExtend(crtn *CompReturn) utilfn.StrWithPos {
	if crtn == nil || crtn.HasMore {
		return utilfn.StrWithPos{Str: p.getOrigStr(), Pos: p.getOrigPos()}
	}
	compStrs := crtn.GetCompStrs()
	compPrefix, _ := p.getCompPrefix()
	lcp := utilfn.LongestPrefix(compPrefix, compStrs)
	if lcp == compPrefix || len(lcp) < len(compPrefix) || !strings.HasPrefix(lcp, compPrefix) {
		return utilfn.StrWithPos{Str: p.getOrigStr(), Pos: p.getOrigPos()}
	}
	newStr := p.extendWord(lcp, utilfn.ContainsStr(compStrs, lcp))
	var buf bytes.Buffer
	buf.WriteString(p.Prefix)
	for idx, w := range p.Words {
		if idx == p.CompWord {
			buf.WriteString(w.Prefix)
			buf.WriteString(newStr.Str)
		} else {
			buf.WriteString(w.Prefix)
			buf.WriteString(p.wordAsStr(w))
		}
	}
	buf.WriteString(p.Suffix)
	compWord := p.Words[p.CompWord]
	newPos := len(p.Prefix) + compWord.Offset + len(compWord.Prefix) + newStr.Pos
	return utilfn.StrWithPos{Str: buf.String(), Pos: newPos}
}

func (p *CompPoint) dump() {
	if p.Prefix != "" {
		fmt.Printf("prefix: %s\n", p.Prefix)
	}
	fmt.Printf("cpos: %d %d\n", p.CompWord, p.CompWordPos)
	for idx, w := range p.Words {
		fmt.Printf("w[%d]: ", idx)
		if w.Prefix != "" {
			fmt.Printf("{%s}", w.Prefix)
		}
		if idx == p.CompWord {
			fmt.Printf("%s\n", utilfn.StrWithPos{Str: p.wordAsStr(w), Pos: p.CompWordPos})
		} else {
			fmt.Printf("%s\n", p.wordAsStr(w))
		}
	}
	if p.Suffix != "" {
		fmt.Printf("suffix: %s\n", p.Suffix)
	}
	fmt.Printf("\n")
}

var SimpleCompGenFns map[string]SimpleCompGenFnType

func isWhitespace(str string) bool {
	return strings.TrimSpace(str) == ""
}

func splitInitialWhitespace(str string) (string, string) {
	for pos, ch := range str { // rune iteration :/
		if !unicode.IsSpace(ch) {
			return str[:pos], str[pos:]
		}
	}
	return str, ""
}

func ParseCompPoint(cmdStr utilfn.StrWithPos) *CompPoint {
	fullCmdStr := cmdStr.Str
	pos := cmdStr.Pos
	// fmt.Printf("---\n")
	// fmt.Printf("cmd: %s\n", strWithCursor(fullCmdStr, pos))

	// first, find the stmt that the pos appears in
	cmdReader := strings.NewReader(fullCmdStr)
	parser := syntax.NewParser(syntax.Variant(syntax.LangBash))
	var foundStmt *syntax.Stmt
	var lastStmt *syntax.Stmt
	var restStartPos int
	parser.Stmts(cmdReader, func(stmt *syntax.Stmt) bool { // ignore parse errors (since stmtStr will be the unparsed part)
		restStartPos = int(stmt.End().Offset())
		lastStmt = stmt
		if uint(pos) >= stmt.Pos().Offset() && uint(pos) < stmt.End().Offset() {
			foundStmt = stmt
			return false
		}
		// fmt.Printf("stmt: [[%s]] %d:%d (%d)\n", fullCmdStr[stmt.Pos().Offset():stmt.End().Offset()], stmt.Pos().Offset(), stmt.End().Offset(), stmt.Semicolon.Offset())
		return true
	})
	restStr := fullCmdStr[restStartPos:]
	if foundStmt == nil && lastStmt != nil && isWhitespace(restStr) && lastStmt.Semicolon.Offset() == 0 {
		foundStmt = lastStmt
	}
	var rtnPoint CompPoint
	var stmtStr string
	var stmtPos int
	if foundStmt != nil {
		stmtPos = pos - int(foundStmt.Pos().Offset())
		rtnPoint.Prefix = fullCmdStr[:foundStmt.Pos().Offset()]
		if isWhitespace(fullCmdStr[foundStmt.End().Offset():]) {
			stmtStr = fullCmdStr[foundStmt.Pos().Offset():]
			rtnPoint.Suffix = ""
		} else {
			stmtStr = fullCmdStr[foundStmt.Pos().Offset():foundStmt.End().Offset()]
			rtnPoint.Suffix = fullCmdStr[foundStmt.End().Offset():]
		}
	} else {
		stmtStr = restStr
		stmtPos = pos - restStartPos
		rtnPoint.Prefix = fullCmdStr[:restStartPos]
		rtnPoint.Suffix = fullCmdStr[restStartPos+len(stmtStr):]
	}
	if stmtPos > len(stmtStr) {
		// this should not happen and will cause a jump in completed strings
		stmtPos = len(stmtStr)
	}
	// fmt.Printf("found: ((%s))%s((%s))\n", rtnPoint.Prefix, strWithCursor(stmtStr, stmtPos), rtnPoint.Suffix)

	// now, find the word that the pos appears in within the stmt above
	rtnPoint.StmtStr = stmtStr
	stmtReader := strings.NewReader(stmtStr)
	lastWordPos := 0
	parser.Words(stmtReader, func(w *syntax.Word) bool {
		var pword ParsedWord
		pword.Offset = lastWordPos
		if int(w.Pos().Offset()) > lastWordPos {
			pword.Prefix = stmtStr[lastWordPos:w.Pos().Offset()]
		}
		pword.Word = w
		rtnPoint.Words = append(rtnPoint.Words, pword)
		lastWordPos = int(w.End().Offset())
		return true
	})
	if lastWordPos < len(stmtStr) {
		pword := ParsedWord{Offset: lastWordPos}
		pword.Prefix, pword.PartialWord = splitInitialWhitespace(stmtStr[lastWordPos:])
		rtnPoint.Words = append(rtnPoint.Words, pword)
	}
	if len(rtnPoint.Words) == 0 {
		rtnPoint.Words = append(rtnPoint.Words, ParsedWord{})
	}
	for idx, w := range rtnPoint.Words {
		wordLen := len(rtnPoint.wordAsStr(w))
		if stmtPos > w.Offset && stmtPos <= w.Offset+len(w.Prefix)+wordLen {
			rtnPoint.CompWord = idx
			rtnPoint.CompWordPos = stmtPos - w.Offset - len(w.Prefix)
			if rtnPoint.CompWordPos < 0 {
				splitCompWord(&rtnPoint)
			}
		}
	}
	return &rtnPoint
}

func splitCompWord(p *CompPoint) {
	w := p.Words[p.CompWord]
	prefixPos := p.CompWordPos + len(w.Prefix)

	w1 := ParsedWord{Offset: w.Offset, Prefix: w.Prefix[:prefixPos]}
	w2 := ParsedWord{Offset: w.Offset + prefixPos, Prefix: w.Prefix[prefixPos:], Word: w.Word, PartialWord: w.PartialWord}
	p.CompWord = p.CompWord // the same (w1)
	p.CompWordPos = 0       // will be at 0 since w1 has a word length of 0
	var newWords []ParsedWord
	if p.CompWord > 0 {
		newWords = append(newWords, p.Words[0:p.CompWord]...)
	}
	newWords = append(newWords, w1, w2)
	newWords = append(newWords, p.Words[p.CompWord+1:]...)
	p.Words = newWords
}

func getCompType(compPos shparse.CompletionPos) string {
	switch compPos.CompType {
	case shparse.CompTypeCommandMeta:
		return CGTypeCommandMeta

	case shparse.CompTypeCommand:
		return CGTypeCommand

	case shparse.CompTypeVar:
		return CGTypeVariable

	case shparse.CompTypeArg, shparse.CompTypeBasic, shparse.CompTypeAssignment:
		return CGTypeFile

	default:
		return CGTypeFile
	}
}

func fixupVarPrefix(varPrefix string) string {
	if strings.HasPrefix(varPrefix, "${") {
		varPrefix = varPrefix[2:]
		if strings.HasSuffix(varPrefix, "}") {
			varPrefix = varPrefix[:len(varPrefix)-1]
		}
	} else if strings.HasPrefix(varPrefix, "$") {
		varPrefix = varPrefix[1:]
	}
	return varPrefix
}

func DoCompGen(ctx context.Context, cmdStr utilfn.StrWithPos, compCtx CompContext) (*CompReturn, *utilfn.StrWithPos, error) {
	words := shparse.Tokenize(cmdStr.Str)
	cmds := shparse.ParseCommands(words)
	compPos := shparse.FindCompletionPos(cmds, cmdStr.Pos)
	if compPos.CompType == shparse.CompTypeInvalid {
		return nil, nil, nil
	}
	var compPrefix string
	if compPos.CompWord != nil {
		var info shparse.ExpandInfo
		compPrefix, info = shparse.SimpleExpandPrefix(shparse.ExpandContext{}, compPos.CompWord, compPos.CompWordOffset)
		if info.HasGlob || info.HasExtGlob || info.HasHistory || info.HasSpecial {
			return nil, nil, nil
		}
		if compPos.CompType != shparse.CompTypeVar && info.HasVar {
			return nil, nil, nil
		}
		if compPos.CompType == shparse.CompTypeVar {
			compPrefix = fixupVarPrefix(compPrefix)
		}
	}
	scType := getCompType(compPos)
	crtn, err := DoSimpleComp(ctx, scType, compPrefix, compCtx, nil)
	if err != nil {
		return nil, nil, err
	}
	if compCtx.ForDisplay {
		return crtn, nil, nil
	}
	extensionStr, extensionComplete := computeCompExtension(compPrefix, crtn)
	if extensionStr == "" {
		return crtn, nil, nil
	}
	rtnSP := compPos.Extend(cmdStr, extensionStr, extensionComplete)
	return crtn, &rtnSP, nil
}

func DoCompGenOld(ctx context.Context, sp utilfn.StrWithPos, compCtx CompContext) (*CompReturn, *utilfn.StrWithPos, error) {
	compPoint := ParseCompPoint(sp)
	compType := CGTypeFile
	if compPoint.CompWord == 0 {
		compType = CGTypeCommandMeta
	}
	// TODO lookup special types
	compPrefix, info := compPoint.getCompPrefix()
	if info.HasVar || info.HasGlob || info.HasExtGlob || info.HasHistory || info.HasSpecial {
		return nil, nil, nil
	}
	crtn, err := DoSimpleComp(ctx, compType, compPrefix, compCtx, nil)
	if err != nil {
		return nil, nil, err
	}
	if compCtx.ForDisplay {
		return crtn, nil, nil
	}
	rtnSP := compPoint.FullyExtend(crtn)
	return crtn, &rtnSP, nil
}

func SortCompReturnEntries(c *CompReturn) {
	sort.Slice(c.Entries, func(i int, j int) bool {
		e1 := c.Entries[i]
		e2 := c.Entries[j]
		if e1.Word < e2.Word {
			return true
		}
		if e1.Word == e2.Word && e1.IsMetaCmd && !e2.IsMetaCmd {
			return true
		}
		return false
	})
}

func CombineCompReturn(compType string, c1 *CompReturn, c2 *CompReturn) *CompReturn {
	if c1 == nil {
		return c2
	}
	if c2 == nil {
		return c1
	}
	var rtn CompReturn
	rtn.CompType = compType
	rtn.HasMore = c1.HasMore || c2.HasMore
	rtn.Entries = append([]CompEntry{}, c1.Entries...)
	rtn.Entries = append(rtn.Entries, c2.Entries...)
	SortCompReturnEntries(&rtn)
	return &rtn
}

func (c *CompReturn) GetCompStrs() []string {
	rtn := make([]string, len(c.Entries))
	for idx, entry := range c.Entries {
		rtn[idx] = entry.Word
	}
	return rtn
}

func (c *CompReturn) GetCompDisplayStrs() []string {
	rtn := make([]string, len(c.Entries))
	for idx, entry := range c.Entries {
		if entry.IsMetaCmd {
			rtn[idx] = "^" + entry.Word
		} else {
			rtn[idx] = entry.Word
		}
	}
	return rtn
}

func (p CompPoint) getOrigPos() int {
	pword := p.Words[p.CompWord]
	return len(p.Prefix) + pword.Offset + len(pword.Prefix) + p.CompWordPos
}

func (p CompPoint) getOrigStr() string {
	return p.Prefix + p.StmtStr + p.Suffix
}