package luaplugin

import (
	"io"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"sync"

	lua "github.com/yuin/gopher-lua"

	"github.com/bjarneo/cliamp/internal/appdir"
)

var (
	allowDirsOnce sync.Once
	allowDirs     []string
)

// writeAllowDirs returns the directories where plugins can write files, with
// symlinks resolved so the prefix check in isWriteAllowed cannot be bypassed
// by a symlinked allow dir (e.g. /tmp -> /private/tmp on macOS). Entries are
// normalized via normalizeWritePath so isWriteAllowed can compare directly.
// The result is cached since these paths never change at runtime.
func writeAllowDirs() []string {
	allowDirsOnce.Do(func() {
		raw := []string{"/tmp", os.TempDir()}
		if configDir, err := appdir.Dir(); err == nil {
			raw = append(raw, configDir)
		}
		if home, err := os.UserHomeDir(); err == nil {
			raw = append(raw, filepath.Join(home, ".local", "share", "cliamp"))
			raw = append(raw, filepath.Join(home, "Music", "cliamp"))
		}
		for _, d := range raw {
			abs, err := filepath.Abs(d)
			if err != nil {
				continue
			}
			if resolved, err := filepath.EvalSymlinks(abs); err == nil {
				abs = resolved
			}
			allowDirs = append(allowDirs, normalizeWritePath(abs))
		}
	})
	return allowDirs
}

// canonicalExistingPath resolves symlinks on the deepest existing ancestor of
// path, re-appending any non-existent tail (e.g. a file about to be created).
// This prevents a symlink planted inside an allowed dir from redirecting a
// write to a target outside it; a purely lexical check cannot catch that.
func canonicalExistingPath(path string) (string, bool) {
	abs, err := filepath.Abs(path)
	if err != nil {
		return "", false
	}
	suffix := ""
	cur := abs
	for {
		if resolved, err := filepath.EvalSymlinks(cur); err == nil {
			if suffix != "" {
				resolved = filepath.Join(resolved, suffix)
			}
			return resolved, true
		}
		parent := filepath.Dir(cur)
		if parent == cur {
			return abs, true // nothing along the path exists yet
		}
		suffix = filepath.Join(filepath.Base(cur), suffix)
		cur = parent
	}
}

// isWriteAllowed checks if a path is within one of the allowed write
// directories, resolving symlinks on both sides first.
func isWriteAllowed(path string) bool {
	abs, ok := canonicalExistingPath(path)
	if !ok {
		return false
	}
	abs = normalizeWritePath(abs)
	for _, dir := range writeAllowDirs() {
		if abs == dir || strings.HasPrefix(abs, dir+string(os.PathSeparator)) {
			return true
		}
	}
	return false
}

// normalizeWritePath canonicalizes an absolute path for prefix comparison:
// cleaned and, on Windows, case-folded (Windows paths are case-insensitive).
func normalizeWritePath(path string) string {
	path = filepath.Clean(path)
	if runtime.GOOS == "windows" {
		path = strings.ToLower(path)
	}
	return path
}

// registerFSAPI adds cliamp.fs.{write,append,read,remove,exists} to the cliamp table.
func registerFSAPI(L *lua.LState, cliamp *lua.LTable) {
	tbl := L.NewTable()

	// cliamp.fs.write(path, content)
	L.SetField(tbl, "write", L.NewFunction(func(L *lua.LState) int {
		path := L.CheckString(1)
		content := L.CheckString(2)
		if !isWriteAllowed(path) {
			L.ArgError(1, "write not allowed to this path")
			return 0
		}
		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
			L.Push(lua.LNil)
			L.Push(lua.LString(err.Error()))
			return 2
		}
		if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
			L.Push(lua.LNil)
			L.Push(lua.LString(err.Error()))
			return 2
		}
		L.Push(lua.LTrue)
		return 1
	}))

	// cliamp.fs.append(path, content)
	L.SetField(tbl, "append", L.NewFunction(func(L *lua.LState) int {
		path := L.CheckString(1)
		content := L.CheckString(2)
		if !isWriteAllowed(path) {
			L.ArgError(1, "write not allowed to this path")
			return 0
		}
		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
			L.Push(lua.LNil)
			L.Push(lua.LString(err.Error()))
			return 2
		}
		f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
		if err != nil {
			L.Push(lua.LNil)
			L.Push(lua.LString(err.Error()))
			return 2
		}
		_, err = f.WriteString(content)
		f.Close()
		if err != nil {
			L.Push(lua.LNil)
			L.Push(lua.LString(err.Error()))
			return 2
		}
		L.Push(lua.LTrue)
		return 1
	}))

	// cliamp.fs.read(path) -> string (max 1MB)
	L.SetField(tbl, "read", L.NewFunction(func(L *lua.LState) int {
		path := L.CheckString(1)
		f, err := os.Open(path)
		if err != nil {
			L.Push(lua.LNil)
			L.Push(lua.LString(err.Error()))
			return 2
		}
		defer f.Close()
		const maxSize = 1 << 20 // 1MB
		// Read one byte past the cap so an oversized file is detected without
		// pulling the whole thing into memory, then reject it explicitly
		// rather than returning a silently truncated value.
		data, err := io.ReadAll(io.LimitReader(f, maxSize+1))
		if err != nil {
			L.Push(lua.LNil)
			L.Push(lua.LString(err.Error()))
			return 2
		}
		if len(data) > maxSize {
			L.Push(lua.LNil)
			L.Push(lua.LString("file exceeds 1MB read limit"))
			return 2
		}
		L.Push(lua.LString(string(data)))
		return 1
	}))

	// cliamp.fs.remove(path)
	L.SetField(tbl, "remove", L.NewFunction(func(L *lua.LState) int {
		path := L.CheckString(1)
		if !isWriteAllowed(path) {
			L.ArgError(1, "remove not allowed for this path")
			return 0
		}
		if err := os.Remove(path); err != nil {
			L.Push(lua.LNil)
			L.Push(lua.LString(err.Error()))
			return 2
		}
		L.Push(lua.LTrue)
		return 1
	}))

	// cliamp.fs.exists(path) -> boolean
	L.SetField(tbl, "exists", L.NewFunction(func(L *lua.LState) int {
		path := L.CheckString(1)
		_, err := os.Stat(path)
		L.Push(lua.LBool(err == nil))
		return 1
	}))

	// cliamp.fs.mkdir(path) — recursive; path must be in write allowlist.
	L.SetField(tbl, "mkdir", L.NewFunction(func(L *lua.LState) int {
		path := L.CheckString(1)
		if !isWriteAllowed(path) {
			L.ArgError(1, "mkdir not allowed for this path")
			return 0
		}
		if err := os.MkdirAll(path, 0o755); err != nil {
			L.Push(lua.LNil)
			L.Push(lua.LString(err.Error()))
			return 2
		}
		L.Push(lua.LTrue)
		return 1
	}))

	// cliamp.fs.listdir(path) -> {names}, err
	// Reading is unrestricted (matches cliamp.fs.read); returns entry names only.
	L.SetField(tbl, "listdir", L.NewFunction(func(L *lua.LState) int {
		path := L.CheckString(1)
		entries, err := os.ReadDir(path)
		if err != nil {
			L.Push(lua.LNil)
			L.Push(lua.LString(err.Error()))
			return 2
		}
		result := L.NewTable()
		for i, e := range entries {
			result.RawSetInt(i+1, lua.LString(e.Name()))
		}
		L.Push(result)
		return 1
	}))

	L.SetField(cliamp, "fs", tbl)
}
