package runtime

import (
	"context"
	"errors"
	"fmt"
	"io"
	"io/fs"
	httplib "net/http"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strconv"
	"strings"
	"time"

	"github.com/jandedobbeleer/oh-my-posh/src/cache"
	"github.com/jandedobbeleer/oh-my-posh/src/log"
	"github.com/jandedobbeleer/oh-my-posh/src/maps"
	"github.com/jandedobbeleer/oh-my-posh/src/regex"
	"github.com/jandedobbeleer/oh-my-posh/src/runtime/cmd"
	"github.com/jandedobbeleer/oh-my-posh/src/runtime/http"
	"github.com/jandedobbeleer/oh-my-posh/src/runtime/path"
)

type Terminal struct {
	CmdFlags *Flags
	cmdCache *cache.Command
	lsDirMap *maps.Concurrent[[]fs.DirEntry]
	cwd      string
	host     string
	networks []*Connection
}

func (term *Terminal) Init(flags *Flags) {
	defer log.Trace(time.Now())

	term.CmdFlags = flags

	if term.CmdFlags == nil {
		term.CmdFlags = &Flags{}
	}

	term.lsDirMap = maps.NewConcurrent[[]fs.DirEntry]()

	term.setPromptCount()

	term.setPwd()

	term.cmdCache = &cache.Command{
		Commands: maps.NewConcurrent[string](),
	}
}

func (term *Terminal) Getenv(key string) string {
	defer log.Trace(time.Now(), key)

	// The data file's env section carries template values (UserName, PWD, ...),
	// not OS variables, so there is nothing to substitute here - and a browser
	// has no environment either. Answering empty is what makes the CLI under
	// DataOnly and the wasm build agree; reading the real environment would
	// leave a segment keyed on, say, TERM_PROGRAM rendering one thing here and
	// another there, from the same config and the same data.
	if term.CmdFlags != nil && term.CmdFlags.DataOnly {
		return ""
	}

	val := os.Getenv(key)
	log.Debug(val)
	return val
}

func (term *Terminal) Pwd() string {
	return term.cwd
}

func (term *Terminal) setPwd() {
	defer log.Trace(time.Now())

	correctPath := func(pwd string) string {
		if term.GOOS() != WINDOWS {
			return pwd
		}

		// on Windows, and being case sensitive and not consistent and all, this gives silly issues
		driveLetter, err := regex.GetCompiledRegex(`^[a-z]:`)
		if err == nil {
			return driveLetter.ReplaceAllStringFunc(pwd, strings.ToUpper)
		}

		return pwd
	}

	if term.CmdFlags != nil && term.CmdFlags.PWD != "" {
		term.cwd = path.Clean(term.CmdFlags.PWD)
		log.Debug(term.cwd)
		return
	}

	dir, err := os.Getwd()
	if err != nil {
		log.Error(err)
		return
	}

	term.cwd = correctPath(dir)
	log.Debug(term.cwd)
}

// errDataOnly is what every environment probe answers with when
// Flags.DataOnly is set. DataOnly started life in config.Segment.restoreData,
// suppressing a segment the recorded data does not cover - but that only
// governs the segment's own Enabled(). A writer field computed lazily by a
// method the template calls still reached the machine long afterwards:
// segments/git.go's StashCount() reads logs/refs/stash off disk, and
// stashCount is unexported so no recorded data ever restores it. Rendering
// jandedobbeleer under wasm, where there is no filesystem, failed that
// template outright while the CLI happily read the real repository.
//
// Gating the environment itself rather than each such method is what makes
// the guarantee hold for segments nobody has audited: there is no way to
// write one that probes, because the probe primitives themselves refuse.
var errDataOnly = errors.New("environment access is disabled: rendering from recorded data only")

func (term *Terminal) HasFiles(pattern string) bool {
	return term.HasFilesInDir(term.Pwd(), pattern)
}

func (term *Terminal) HasFilesInDir(dir, pattern string) bool {
	if term.CmdFlags != nil && term.CmdFlags.DataOnly {
		return false
	}
	defer log.Trace(time.Now(), pattern)

	fileSystem := os.DirFS(dir)
	var dirEntries []fs.DirEntry

	if files, OK := term.lsDirMap.Get(dir); OK {
		dirEntries = files
	}

	if len(dirEntries) == 0 {
		var err error
		dirEntries, err = fs.ReadDir(fileSystem, ".")
		if err != nil {
			log.Error(err)
			log.Debug("false")
			return false
		}

		term.lsDirMap.Set(dir, dirEntries)
	}

	pattern = strings.ToLower(pattern)

	for _, match := range dirEntries {
		if match.IsDir() {
			continue
		}

		matchFileName, err := filepath.Match(pattern, strings.ToLower(match.Name()))
		if err != nil {
			log.Error(err)
			log.Debug("false")
			return false
		}

		if matchFileName {
			log.Debug("true")
			return true
		}
	}

	log.Debug("false")
	return false
}

func (term *Terminal) HasFileInParentDirs(pattern string, depth uint) bool {
	if term.CmdFlags != nil && term.CmdFlags.DataOnly {
		return false
	}
	defer log.Trace(time.Now(), pattern, fmt.Sprint(depth))
	currentFolder := term.Pwd()

	for c := 0; c < int(depth); c++ {
		if term.HasFilesInDir(currentFolder, pattern) {
			log.Debug("true")
			return true
		}

		if dir := filepath.Dir(currentFolder); dir != currentFolder {
			currentFolder = dir
		} else {
			log.Debug("false")
			return false
		}
	}
	log.Debug("false")
	return false
}

func (term *Terminal) HasFolder(folder string) bool {
	if term.CmdFlags != nil && term.CmdFlags.DataOnly {
		return false
	}
	defer log.Trace(time.Now(), folder)
	f, err := os.Stat(folder)
	if err != nil {
		log.Debug("false")
		return false
	}
	isDir := f.IsDir()
	log.Debugf("%t", isDir)
	return isDir
}

func (term *Terminal) ResolveSymlink(input string) (string, error) {
	if term.CmdFlags != nil && term.CmdFlags.DataOnly {
		return "", errDataOnly
	}
	defer log.Trace(time.Now(), input)
	link, err := filepath.EvalSymlinks(input)
	if err != nil {
		log.Error(err)
		return "", err
	}
	log.Debug(link)
	return link, nil
}

func (term *Terminal) FileContent(file string) string {
	if term.CmdFlags != nil && term.CmdFlags.DataOnly {
		return ""
	}
	defer log.Trace(time.Now(), file)
	if !filepath.IsAbs(file) {
		file = filepath.Join(term.Pwd(), file)
	}

	content, err := os.ReadFile(file)
	if err != nil {
		log.Error(err)
		return ""
	}

	fileContent := string(content)
	log.Debug(fileContent)

	return fileContent
}

func (term *Terminal) LsDir(input string) []fs.DirEntry {
	if term.CmdFlags != nil && term.CmdFlags.DataOnly {
		return nil
	}
	defer log.Trace(time.Now(), input)

	entries, err := os.ReadDir(input)
	if err != nil {
		log.Error(err)
		return nil
	}

	log.Debugf("%v", entries)
	return entries
}

func (term *Terminal) User() string {
	defer log.Trace(time.Now())
	user := os.Getenv("USER")
	if user == "" {
		user = os.Getenv("USERNAME")
	}
	log.Debug(user)
	return user
}

func (term *Terminal) Host() (string, error) {
	defer log.Trace(time.Now())
	if len(term.host) != 0 {
		return term.host, nil
	}

	hostName, err := os.Hostname()
	if err != nil {
		log.Error(err)
		return "", err
	}

	hostName = cleanHostName(hostName)
	log.Debug(hostName)
	term.host = hostName

	return hostName, nil
}

func (term *Terminal) GOOS() string {
	defer log.Trace(time.Now())
	return runtime.GOOS
}

func (term *Terminal) Home() string {
	return path.Home()
}

func (term *Terminal) RunCommand(command string, args ...string) (string, error) {
	return term.RunCommandWithEnv(command, nil, args...)
}

func (term *Terminal) RunCommandWithEnv(command string, envs []string, args ...string) (string, error) {
	if term.CmdFlags != nil && term.CmdFlags.DataOnly {
		return "", errDataOnly
	}
	defer log.Trace(time.Now(), append([]string{command}, args...)...)

	if cacheCommand, ok := term.cmdCache.Get(command); ok {
		command = cacheCommand
	}

	output, err := cmd.RunWithEnv(command, envs, args...)
	if err != nil {
		log.Error(err)
	}

	log.Debug(output)
	return output, err
}

func (term *Terminal) RunShellCommand(shell, command string) string {
	if term.CmdFlags != nil && term.CmdFlags.DataOnly {
		return ""
	}
	defer log.Trace(time.Now())

	if out, err := term.RunCommand(shell, "-c", command); err == nil {
		return out
	}

	return ""
}

func (term *Terminal) CommandPath(command string) string {
	if term.CmdFlags != nil && term.CmdFlags.DataOnly {
		return ""
	}
	defer log.Trace(time.Now(), command)

	// L1: in-memory, unbounded for the lifetime of this process.
	if cmdPath, ok := term.cmdCache.Get(command); ok {
		log.Debug(cmdPath)
		return cmdPath
	}

	// L2: session-persisted lookups, shared across prompt invocations within
	// the same shell session. Avoids re-running exec.LookPath (a PATH x
	// PATHEXT stat storm on Windows, worst for missing commands) on every
	// prompt render.
	if cachedPath, found, ok := cache.GetPersistedCommandPath(command); ok {
		if !found {
			log.Debug("command not found (cached)")
			return ""
		}

		// Revalidate cheaply; a stale/moved binary should fall through to a
		// fresh LookPath rather than returning a dead path.
		if _, err := os.Stat(cachedPath); err == nil {
			term.cmdCache.Set(command, cachedPath)
			log.Debug(cachedPath)
			return cachedPath
		}

		log.Debugf("cached command path no longer valid: %s", cachedPath)
	}

	cmdPath, err := exec.LookPath(command)
	if err == nil {
		term.cmdCache.Set(command, cmdPath)
		cache.PersistCommandPath(command, cmdPath, true)
		log.Debug(cmdPath)
		return cmdPath
	}

	cache.PersistCommandPath(command, "", false)

	log.Error(err)
	return ""
}

func (term *Terminal) HasCommand(command string) bool {
	if term.CmdFlags != nil && term.CmdFlags.DataOnly {
		return false
	}
	defer log.Trace(time.Now(), command)

	if cmdPath := term.CommandPath(command); cmdPath != "" {
		return true
	}

	return false
}

func (term *Terminal) StatusCodes() (int, string) {
	defer log.Trace(time.Now())

	if term.CmdFlags.Shell != CMD || !term.CmdFlags.NoExitCode {
		return term.CmdFlags.ErrorCode, term.CmdFlags.PipeStatus
	}

	errorCode := term.Getenv("=ExitCode")
	log.Debug(errorCode)
	term.CmdFlags.ErrorCode, _ = strconv.Atoi(errorCode)

	return term.CmdFlags.ErrorCode, term.CmdFlags.PipeStatus
}

func (term *Terminal) ExecutionTime() float64 {
	defer log.Trace(time.Now())
	if term.CmdFlags.ExecutionTime < 0 {
		return 0
	}
	return term.CmdFlags.ExecutionTime
}

func (term *Terminal) Flags() *Flags {
	defer log.Trace(time.Now())
	return term.CmdFlags
}

func (term *Terminal) Shell() string {
	defer log.Trace(time.Now())
	if len(term.CmdFlags.Shell) != 0 {
		return term.CmdFlags.Shell
	}

	log.Debug("no shell name provided in flags, trying to detect it")

	name := term.shellProcessName()
	if len(name) == 0 {
		return UNKNOWN
	}

	log.Debug("process name: " + name)

	// Cache the shell value to speed things up.
	term.CmdFlags.Shell = strings.Trim(strings.TrimSuffix(name, ".exe"), " ")
	return term.CmdFlags.Shell
}

func (term *Terminal) unWrapError(err error) error {
	cause := err
	for {
		type nested interface{ Unwrap() error }
		unwrap, ok := cause.(nested)
		if !ok {
			break
		}
		cause = unwrap.Unwrap()
	}
	return cause
}

func (term *Terminal) HTTPRequest(targetURL string, body io.Reader, timeout int, requestModifiers ...http.RequestModifier) ([]byte, error) {
	if term.CmdFlags != nil && term.CmdFlags.DataOnly {
		return nil, errDataOnly
	}
	defer log.Trace(time.Now(), targetURL)

	ctx, cncl := context.WithTimeout(context.Background(), time.Millisecond*time.Duration(timeout))
	defer cncl()

	request, err := httplib.NewRequestWithContext(ctx, httplib.MethodGet, targetURL, body)
	if err != nil {
		return nil, err
	}

	for _, modifier := range requestModifiers {
		modifier(request)
	}

	if term.CmdFlags.Debug {
		log.Debug(dumpRequest(request))
	}

	response, err := http.HTTPClient.Do(request)
	if err != nil {
		log.Error(err)
		return nil, term.unWrapError(err)
	}

	// anything inside the range [200, 299] is considered a success
	if response.StatusCode < 200 || response.StatusCode >= 300 {
		err := &http.Error{
			StatusCode: response.StatusCode,
		}
		log.Error(err)
		return nil, err
	}

	defer response.Body.Close()

	responseBody, err := io.ReadAll(response.Body)
	if err != nil {
		log.Error(err)
		return nil, err
	}

	log.Debug(string(responseBody))

	return responseBody, nil
}

func (term *Terminal) HasParentFilePath(parent string, followSymlinks bool) (*FileInfo, error) {
	if term.CmdFlags != nil && term.CmdFlags.DataOnly {
		return nil, errDataOnly
	}
	defer log.Trace(time.Now(), parent)

	pwd := term.Pwd()
	if followSymlinks {
		if actual, err := term.ResolveSymlink(pwd); err == nil {
			pwd = actual
		}
	}

	for {
		fileSystem := os.DirFS(pwd)
		info, err := fs.Stat(fileSystem, parent)
		if err == nil {
			return &FileInfo{
				ParentFolder: pwd,
				Path:         filepath.Join(pwd, parent),
				IsDir:        info.IsDir(),
			}, nil
		}

		if !os.IsNotExist(err) {
			return nil, err
		}

		if dir := filepath.Dir(pwd); dir != pwd {
			pwd = dir
			continue
		}

		log.Error(err)
		return nil, errors.New("no match at root level")
	}
}

func (term *Terminal) StackCount() int {
	defer log.Trace(time.Now())

	if term.CmdFlags.StackCount < 0 {
		return 0
	}

	return term.CmdFlags.StackCount
}

func (term *Terminal) Logs() string {
	return log.String()
}

func (term *Terminal) DirMatchesOneOf(dir string, regexes []string) (match bool) {
	// sometimes the function panics inside golang, we want to silence that error
	// and assume that there's no match. Not perfect, but better than crashing
	// for the time being until we figure out what the actual root cause is
	defer func() {
		if err := recover(); err != nil {
			log.Error(errors.New("panic"))
			match = false
		}
	}()
	match = dirMatchesOneOf(dir, term.Home(), term.GOOS(), regexes)
	return
}

func dirMatchesOneOf(dir, home, goos string, regexes []string) bool {
	if len(regexes) == 0 {
		return false
	}

	if goos == WINDOWS {
		dir = strings.ReplaceAll(dir, "\\", "/")
		home = strings.ReplaceAll(home, "\\", "/")
	}

	for _, element := range regexes {
		normalized := strings.ReplaceAll(element, "\\\\", "/")
		if strings.HasPrefix(normalized, "~") {
			rem := normalized[1:]
			if rem == "" || rem[0] == '/' {
				normalized = home + rem
			}
		}
		pattern := fmt.Sprintf("^%s$", normalized)
		if goos == WINDOWS || goos == DARWIN {
			pattern = "(?i)" + pattern
		}
		matched := regex.MatchString(pattern, dir)
		if matched {
			return true
		}
	}
	return false
}

func (term *Terminal) setPromptCount() {
	defer log.Trace(time.Now())

	var count int
	if val, found := cache.Get[int](cache.Session, cache.PROMPTCOUNTCACHE); found {
		count = val
	}

	// Only update the count if we're generating a primary prompt.
	if term.CmdFlags.Type == PRIMARY {
		count++
		cache.Set(cache.Session, cache.PROMPTCOUNTCACHE, count, cache.ONEDAY)
	}

	term.CmdFlags.PromptCount = count
}

func (term *Terminal) CursorPosition() (row, col int) {
	if number, err := strconv.Atoi(term.Getenv("POSH_CURSOR_LINE")); err == nil {
		row = number
	}

	if number, err := strconv.Atoi(term.Getenv("POSH_CURSOR_COLUMN")); err != nil {
		col = number
	}

	return
}

func cleanHostName(hostName string) string {
	garbage := []string{
		".lan",
		".local",
		".localdomain",
	}
	for _, g := range garbage {
		if strings.HasSuffix(hostName, g) {
			hostName = strings.Replace(hostName, g, "", 1)
		}
	}
	return hostName
}
