package utils

/*
 * This file contains miscellaneous functions that are generally useful and
 * don't fit into any other file.
 */

import (
	"crypto/sha256"
	"fmt"
	"os"
	"os/exec"
	"os/signal"
	path "path/filepath"
	"regexp"
	"strings"
	"time"

	"github.com/apache/cloudberry-backup/filepath"
	"github.com/apache/cloudberry-go-libs/dbconn"
	"github.com/apache/cloudberry-go-libs/gplog"
	"github.com/apache/cloudberry-go-libs/operating"
	"github.com/pkg/errors"
	"golang.org/x/sys/unix"
)

const MINIMUM_GPDB5_VERSION = "5.1.0"

/*
 * General helper functions
 */

func CommandExists(cmd string) bool {
	_, err := exec.LookPath(cmd)
	return err == nil
}

func ClusterEnvScriptPath(gphome string) string {
	greenplumPath := path.Join(gphome, "greenplum_path.sh")
	if FileExists(greenplumPath) {
		return greenplumPath
	}

	cloudberryPath := path.Join(gphome, "cloudberry-env.sh")
	if FileExists(cloudberryPath) {
		return cloudberryPath
	}

	// Preserve previous behavior as fallback for clearer error messages upstream.
	return greenplumPath
}

func SourceClusterEnvCommand(gphome string) string {
	return fmt.Sprintf("source %s", ClusterEnvScriptPath(gphome))
}

func FileExists(filename string) bool {
	_, err := os.Stat(filename)
	return err == nil
}

func RemoveFileIfExists(filename string) error {
	baseFilename := path.Base(filename)
	if FileExists(filename) {
		gplog.Debug("File %s: Exists, Attempting Removal", baseFilename)
		err := os.Remove(filename)
		if err != nil {
			gplog.Error("File %s: Failed to remove. Error %s", baseFilename, err.Error())
			return err
		}
		gplog.Debug("File %s: Successfully removed", baseFilename)
	} else {
		gplog.Debug("File %s: Does not exist. No removal needed", baseFilename)
	}
	return nil
}

func OpenFileForWrite(filename string) (*os.File, error) {
	baseFilename := path.Base(filename)
	file, err := os.OpenFile(filename, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
	if err != nil {
		gplog.Error("File %s: Failed to open. Error %s", baseFilename, err.Error())
	}
	return file, err
}

func WriteToFileAndMakeReadOnly(filename string, contents []byte) error {
	baseFilename := path.Base(filename)
	file, err := os.OpenFile(filename, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
	if err != nil {
		gplog.Error("File %s: Failed to open. Error %s", baseFilename, err.Error())
		return err
	}

	_, err = file.Write(contents)
	if err != nil {
		gplog.Error("File %s: Could not write to file. Error %s", baseFilename, err.Error())
		return err
	}

	err = file.Chmod(0444)
	if err != nil {
		gplog.Error("File %s: Could not chmod. Error %s", baseFilename, err.Error())
		return err
	}

	err = file.Sync()
	if err != nil {
		gplog.Error("File %s: Could not sync. Error %s", baseFilename, err.Error())
		return err
	}

	return file.Close()
}

// Dollar-quoting logic is based on appendStringLiteralDQ() in pg_dump.
func DollarQuoteString(literal string) string {
	delimStr := "_XXXXXXX"
	quoteStr := ""
	for i := range delimStr {
		testStr := "$" + delimStr[0:i]
		if !strings.Contains(literal, testStr) {
			quoteStr = testStr + "$"
			break
		}
	}
	return quoteStr + literal + quoteStr
}

// This function assumes that all identifiers are already appropriately quoted
func MakeFQN(schema string, object string) string {
	return fmt.Sprintf("%s.%s", schema, object)
}

// We require users to include schema and table, separated by a dot.  So, check that there's at
// least one dot with stuff on either side of it.  The remainder of validation is done in the
// ValidateTablesExist routine
func ValidateFQNs(tableList []string) error {
	validFormat := regexp.MustCompile(`^.+\..+$`)
	for _, fqn := range tableList {
		if !validFormat.Match([]byte(fqn)) {
			return errors.Errorf(`Table "%s" is not correctly fully-qualified.  Please ensure table is in the format "schema.table".`, fqn)
		}
	}
	return nil
}

func ValidateFullPath(path string) error {
	if len(path) > 0 && !(strings.HasPrefix(path, "/") || strings.HasPrefix(path, "~")) {
		return errors.Errorf("%s is not an absolute path.", path)
	}
	return nil
}

// A description of compression levels for some compression type
type CompressionLevelsDescription struct {
	Min int
	Max int
}

func ValidateCompressionTypeAndLevel(compressionType string, compressionLevel int) error {
	compressionLevelsForType := map[string]CompressionLevelsDescription{
		"gzip": {Min: 1, Max: 9},
		"zstd": {Min: 1, Max: 19},
	}

	if levelsDescription, ok := compressionLevelsForType[compressionType]; ok {
		if compressionLevel < levelsDescription.Min || compressionLevel > levelsDescription.Max {
			return fmt.Errorf("compression type '%s' only allows compression levels between %d and %d, but the provided level is %d", compressionType, levelsDescription.Min, levelsDescription.Max, compressionLevel)
		}
	} else {
		return fmt.Errorf("unknown compression type '%s'", compressionType)
	}

	return nil
}

func InitializeSignalHandler(cleanupFunc func(bool), procDesc string, termFlag *bool) {
	signalChan := make(chan os.Signal, 1)
	signal.Notify(signalChan, unix.SIGINT, unix.SIGTERM)
	go func() {
		sig := <-signalChan
		fmt.Println() // Add newline after "^C" is printed
		switch sig {
		case unix.SIGINT:
			gplog.Warn("Received an interrupt signal, aborting %s", procDesc)
		case unix.SIGTERM:
			gplog.Warn("Received a termination signal, aborting %s", procDesc)
		}
		*termFlag = true
		cleanupFunc(true)
		os.Exit(2)
	}()
}

// TODO: Uniquely identify COPY commands in the multiple data file case to allow terminating sessions
func TerminateHangingCopySessions(fpInfo filepath.FilePathInfo, appName string, timeout time.Duration, interval time.Duration) {
	gplog.Verbose("Checking for leftover COPY sessions")
	var pidCol, queryCol string

	termConn := dbconn.NewDBConnFromEnvironment("postgres")
	termConn.MustConnect(1)
	defer termConn.Close()

	if termConn.Version.IsGPDB() && termConn.Version.Before("6") {
		pidCol = "procpid"
		queryCol = "current_query"
	} else {
		pidCol = "pid"
		queryCol = "query"
	}

	copyFileName := fpInfo.GetSegmentPipePathForCopyCommand()
	fromClause := fmt.Sprintf(`FROM pg_stat_activity
	WHERE application_name = '%s'
	AND %s LIKE '%%%s%%'
	AND %s <> pg_backend_pid()`, appName, queryCol, copyFileName, pidCol)

	tickerTerminate := time.NewTicker(interval)
	tickerCount := time.NewTicker(interval / 5)

	termQuery := fmt.Sprintf(`SELECT
		pg_terminate_backend(%s) %s`, pidCol, fromClause)

	countQuery := fmt.Sprintf(`SELECT
		count(*) %s`, fromClause)

	// Terminate any COPY commands that are still running.
	// Send a termination query every interval until no COPY commands are running, or timeout is reached.
	// Check for remaining COPY commands every 20% of the interval duration.
	for {
		count := dbconn.MustSelectString(termConn, countQuery)
		select {
		case <-tickerCount.C:
			if count == "0" {
				return
			}
		case <-tickerTerminate.C:
			termConn.MustExec(termQuery)
		case <-time.After(timeout):
			gplog.Warn("Timeout of %ds reached while waiting for %s COPY command(s) to complete.", timeout, count)
			return
		}
	}
}

func ValidateGPDBVersionCompatibility(connectionPool *dbconn.DBConn) {
	if connectionPool.Version.IsGPDB() && connectionPool.Version.Before(MINIMUM_GPDB5_VERSION) {
		gplog.Fatal(errors.Errorf(`GPDB version %s is not supported. Please upgrade to GPDB %s or later.`, connectionPool.Version.VersionString, MINIMUM_GPDB5_VERSION), "")
	}
}

func LogExecutionTime(start time.Time, name string) {
	elapsed := time.Since(start)
	gplog.Debug(fmt.Sprintf("%s took %s", name, elapsed))
}

func Exists(slice []string, val string) bool {
	for _, item := range slice {
		if item == val {
			return true
		}
	}
	return false
}

func SchemaIsExcludedByUser(inSchemasUserInput []string, exSchemasUserInput []string, schemaName string) bool {
	included := Exists(inSchemasUserInput, schemaName) || len(inSchemasUserInput) == 0
	excluded := Exists(exSchemasUserInput, schemaName)
	return excluded || !included
}

func RelationIsExcludedByUser(inRelationsUserInput []string, exRelationsUserInput []string, tableFQN string) bool {
	included := Exists(inRelationsUserInput, tableFQN) || len(inRelationsUserInput) == 0
	excluded := Exists(exRelationsUserInput, tableFQN)
	return excluded || !included
}

func UnquoteIdent(ident string) string {
	if len(ident) <= 1 {
		return ident
	}

	if ident[0] == '"' && ident[len(ident)-1] == '"' {
		ident = ident[1 : len(ident)-1]
		unescape := strings.NewReplacer(`""`, `"`)
		ident = unescape.Replace(ident)
	}

	return ident
}

func QuoteIdent(connectionPool *dbconn.DBConn, ident string) string {
	return dbconn.MustSelectString(connectionPool, fmt.Sprintf(`SELECT quote_ident('%s')`, EscapeSingleQuotes(ident)))
}

func SliceToQuotedString(slice []string) string {
	quotedStrings := make([]string, len(slice))
	for i, str := range slice {
		quotedStrings[i] = fmt.Sprintf("'%s'", EscapeSingleQuotes(str))
	}
	return strings.Join(quotedStrings, ",")
}

func EscapeSingleQuotes(str string) string {
	return strings.Replace(str, "'", "''", -1)
}

func UnEscapeDoubleQuotes(str string) string {
	return strings.Replace(str, "\"\"", "\"", -1)
}

func GetFileHash(filename string) ([32]byte, error) {
	contents, err := operating.System.ReadFile(filename)
	if err != nil {
		gplog.Error("Failed to read contents of file %s: %v", filename, err)
		return [32]byte{}, err
	}
	filehash := sha256.Sum256(contents)
	if err != nil {
		gplog.Error("Failed to hash contents of file %s: %v", filename, err)
		return [32]byte{}, err
	}
	return filehash, nil
}

/*
 * Parses a string of the form "key1=val1, key2=val2, ..." into a map.
 * This is useful for parsing options columns from PostgreSQL/GreenplumDB.
 */
func ParseOptions(optionsStr string) map[string]string {
	optionsMap := make(map[string]string)
	if optionsStr == "" {
		return optionsMap
	}

	pairs := strings.Split(optionsStr, ", ")
	for _, pair := range pairs {
		parts := strings.SplitN(pair, "=", 2)
		if len(parts) == 2 {
			optionsMap[parts[0]] = parts[1]
		}
	}
	return optionsMap
}
