/*
** Job Arranger for ZABBIX
** Copyright (C) 2025 Daiwa Institute of Research Ltd. All Rights Reserved.
**
** This program is free software; you can redistribute it and/or modify
** it under the terms of the GNU General Public License as published by
** the Free Software Foundation; either version 2 of the License, or
** (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
** GNU General Public License for more details.
**
** You should have received a copy of the GNU General Public License
** along with this program; if not, write to the Free Software
** Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
**/

package main

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"runtime/debug"
	"strconv"
	"strings"
	"syscall"
	"time"

	clientcommon "jobarranger2/src/jobarg_server/managers/icon_exec_manager/workers/common"
	"jobarranger2/src/libs/golibs/common"
	"jobarranger2/src/libs/golibs/config_reader/server"
	"jobarranger2/src/libs/golibs/event"
	"jobarranger2/src/libs/golibs/utils"
)

var (
	executionData       common.IconExecutionProcessData
	ExtendData          common.IconExtData
	RunJobData          common.RunJobTable
	VariableWrapper     common.RunJobVariableTable
	ParameterData       common.ParameterTable
	runStatusChangeFlag bool
	err                 error
)

type jaWeek struct {
	sun int
	mon int
	tue int
	wed int
	thu int
	fri int
	sat int
}

func extensionIconClient() (int, error) {
	var (
		eventData common.EventData

		innerJobnetMainId, innerJobnetId, innerJobId uint64
		jobnetId                                     string
		runCount, methodFlag, iconType               int
		flag                                         bool
	)
	executionData, err = clientcommon.GetIconExecData()
	if err != nil {
		return common.JA_JOBEXEC_FAIL, err
	}

	// Check necessary data
	innerJobnetMainId = executionData.RunJobData.InnerJobnetMainID
	if innerJobnetMainId <= 0 {
		return common.JA_JOBEXEC_FAIL, fmt.Errorf("invalid inner_jobnet_main_id")
	}
	innerJobId = executionData.RunJobData.InnerJobID
	if innerJobId <= 0 {
		return common.JA_JOBEXEC_FAIL, fmt.Errorf("invalid inner_job_id")
	}
	runCount = executionData.RunJobData.RunCount
	if runCount < 0 {
		return common.JA_JOBEXEC_FAIL, fmt.Errorf("invalid run_count")
	}
	methodFlag = int(executionData.RunJobData.MethodFlag)
	if methodFlag < 0 {
		return common.JA_JOBEXEC_FAIL, fmt.Errorf("invalid method_flag")
	}

	// check flag file exist or not
	flag, err = event.CheckRunCountFile(clientcommon.RunCountFolderPath, innerJobnetMainId, innerJobId, runCount, methodFlag)
	if err != nil {
		return common.JA_JOBEXEC_FAIL, fmt.Errorf("failed to create flag file : %w", err)
	} else if !flag { // flag == false (nil && false)
		return common.JA_JOBEXEC_IGNORE, nil
	}

	if err := utils.Convert(executionData.RunJobData.Data, &ExtendData); err != nil {
		return common.JA_JOBEXEC_FAIL, fmt.Errorf("failed to convert Data : %w", err)
	}

	if err := utils.Convert(executionData.RunJobData, &RunJobData); err != nil {
		return common.JA_JOBEXEC_FAIL, fmt.Errorf("failed to convert Data : %w", err)
	}

	if err := clientcommon.SetBeforeVariable(executionData); err != nil {
		return common.JA_JOBEXEC_FAIL, fmt.Errorf("failed to set before variable: %w", err)
	}

	if err := utils.Convert(executionData.ParameterData, &ParameterData); err != nil {
		return common.JA_JOBEXEC_FAIL, fmt.Errorf("failed to convert Data : %w", err)
	}

	jobnetId = executionData.RunJobnetData.JobnetID
	if jobnetId == "" {
		return common.JA_JOBEXEC_FAIL, fmt.Errorf("invalid jobnet_id")
	}
	innerJobnetId = executionData.RunJobData.InnerJobnetID
	if innerJobnetId <= 0 {
		return common.JA_JOBEXEC_FAIL, fmt.Errorf("invalid inner_jobnet_id")
	}

	iconType = int(executionData.RunJobData.IconType)
	if iconType != int(common.IconTypeExtJob) {
		return common.JA_JOBEXEC_FAIL, fmt.Errorf("invalid icon_type for extension icon")
	}

	commandID := ExtendData.CommandId
	value := ExtendData.Value
	testFlag := RunJobData.TestFlag
	Timezone := clientcommon.BeforeVariableMap["TIMEZONE"]
	// Replace variables like ${VAR} or $VAR
	processedVal, err := replaceVariables(value, clientcommon.BeforeVariableMap, common.MAX_DEST_LEN)
	if err != nil {
		return common.JA_JOBRESULT_PRE_EXEC_FAIL, fmt.Errorf("variable replacement failed")
	}

	// Trim leading/trailing whitespace
	processedVal = strings.TrimSpace(processedVal)

	// Test Mode shortcut
	if testFlag == common.FlagOn && (commandID == common.JA_CMD_SLEEP || commandID == common.JA_CMD_TIME) {

		ExtendData.WaitTime = time.Now().Unix()

		if err := clientcommon.WriteStructToFD3(ExtendData); err != nil {
			fmt.Fprintln(os.Stderr, "Warning: failed to write to fd3:", err)

		}
		eventData = clientcommon.IconRunDataPrep(string(clientcommon.IconClientExtJob), executionData)

		err = event.CreateNextEvent(eventData, innerJobnetId, jobnetId, innerJobId)
		if err != nil {
			return common.JA_JOBRESULT_PRE_EXEC_FAIL, err
		}
		runStatusChangeFlag = true

		// flag file creation
		err = event.CreateRunCountFile(clientcommon.RunCountFolderPath, innerJobnetMainId, innerJobId, runCount)
		if err != nil {
			return common.JA_JOBRESULT_FAIL, fmt.Errorf("failed to create flag file : %w", err)
		}

		return common.JA_JOBRESULT_SUCCEED, nil
	}

	// Handle supported commands
	switch commandID {
	case common.JA_CMD_SLEEP:
		out, err := handleSleep(processedVal)
		if err != nil {
			return common.JA_JOBRESULT_PRE_EXEC_FAIL, fmt.Errorf("sleep command failed: %w", err)

		}
		ExtendData.WaitTime = out

		if err := clientcommon.WriteStructToFD3(ExtendData); err != nil {
			fmt.Fprintln(os.Stderr, "Warning: failed to write to fd3:", err)

		}
		eventData = clientcommon.IconRunDataPrep(string(clientcommon.IconClientExtJob), executionData)

		err = event.CreateNextEvent(eventData, innerJobnetId, jobnetId, innerJobId)
		if err != nil {
			return common.JA_JOBRESULT_PRE_EXEC_FAIL, err
		}
		runStatusChangeFlag = true

		// flag file creation
		err = event.CreateRunCountFile(clientcommon.RunCountFolderPath, innerJobnetMainId, innerJobId, runCount)
		if err != nil {
			return common.JA_JOBRESULT_FAIL, fmt.Errorf("failed to create flag file : %w", err)
		}

	case common.JA_CMD_TIME: //wait_time
		out, err := handleTimeWait(processedVal, Timezone)
		if err != nil {
			return common.JA_JOBRESULT_PRE_EXEC_FAIL, fmt.Errorf("time command failed: %w", err)
		}
		ExtendData.WaitTime = out

		if err := clientcommon.WriteStructToFD3(ExtendData); err != nil {
			fmt.Fprintln(os.Stderr, "Warning: failed to write to fd3:", err)

		}
		eventData = clientcommon.IconRunDataPrep(string(clientcommon.IconClientExtJob), executionData)

		err = event.CreateNextEvent(eventData, innerJobnetId, jobnetId, innerJobId)
		if err != nil {
			return common.JA_JOBRESULT_PRE_EXEC_FAIL, err
		}
		runStatusChangeFlag = true

		// flag file creation
		err = event.CreateRunCountFile(clientcommon.RunCountFolderPath, innerJobnetMainId, innerJobId, runCount)
		if err != nil {
			return common.JA_JOBRESULT_FAIL, fmt.Errorf("failed to create flag file : %w", err)
		}

	case common.JA_CMD_WEEK: //job exit code
		eventData = clientcommon.IconRunDataPrep(string(clientcommon.IconClientExtJob), executionData)

		err = event.CreateNextEvent(eventData, innerJobnetId, jobnetId, innerJobId)
		if err != nil {
			return common.JA_JOBRESULT_PRE_EXEC_FAIL, err
		}
		runStatusChangeFlag = true

		// flag file creation
		err = event.CreateRunCountFile(clientcommon.RunCountFolderPath, innerJobnetMainId, innerJobId, runCount)
		if err != nil {
			return common.JA_JOBRESULT_FAIL, fmt.Errorf("failed to create flag file : %w", err)
		}

		out, err := handleWeekCheck(processedVal, Timezone)
		if err != nil {
			return common.JA_JOBRESULT_FAIL, fmt.Errorf("week command failed: %w", err)
		}
		ExtendData.JobExitCode = out

		if err := clientcommon.WriteStructToFD3(ExtendData); err != nil {
			fmt.Fprintln(os.Stderr, "Warning: failed to write to fd3:", err)

		}

	case common.ZABBIX_SENDER:
		eventData = clientcommon.IconRunDataPrep(string(clientcommon.IconClientExtJob), executionData)

		err = event.CreateNextEvent(eventData, innerJobnetId, jobnetId, innerJobId)
		if err != nil {
			return common.JA_JOBRESULT_PRE_EXEC_FAIL, err
		}
		runStatusChangeFlag = true

		// flag file creation
		err = event.CreateRunCountFile(clientcommon.RunCountFolderPath, innerJobnetMainId, innerJobId, runCount)
		if err != nil {
			return common.JA_JOBRESULT_FAIL, fmt.Errorf("failed to create flag file : %w", err)
		}

		_, err := handleZbxSender(processedVal)
		if err != nil {
			return common.JA_JOBRESULT_FAIL, fmt.Errorf("zabbix sender failed: %w", err)
		}

	default:
		return common.JA_JOBRESULT_FAIL, fmt.Errorf("invalid command: %s", commandID)

	}

	return common.JA_JOBRESULT_SUCCEED, nil
}

// replaceVariables simulates ja_replace_variable
func replaceVariables(valueSrc string, vars map[string]string, destLen int) (string, error) {
	var builder strings.Builder

	for i := 0; i < len(valueSrc); {
		if valueSrc[i] != '$' {
			if builder.Len() >= destLen-1 {
				return "", fmt.Errorf("output overflow")
			}
			builder.WriteByte(valueSrc[i])
			i++
			continue
		}

		// $$ escape
		if i+1 < len(valueSrc) && valueSrc[i+1] == '$' {
			if builder.Len() >= destLen-1 {
				return "", fmt.Errorf("output overflow on $$ escape")
			}
			builder.WriteByte('$')
			i += 2
			continue
		}

		// Start variable
		i++
		var varName string
		if i < len(valueSrc) && valueSrc[i] == '{' {
			i++
			start := i
			for i < len(valueSrc) && valueSrc[i] != '}' {
				i++
			}
			if i >= len(valueSrc) {
				return "", fmt.Errorf("unclosed brace for variable")
			}
			varName = valueSrc[start:i]
			i++ // Skip closing brace
		} else {
			start := i
			for i < len(valueSrc) && (valueSrc[i] == '_' || ('a' <= valueSrc[i] && valueSrc[i] <= 'z') || ('A' <= valueSrc[i] && valueSrc[i] <= 'Z') || ('0' <= valueSrc[i] && valueSrc[i] <= '9')) {
				i++
			}
			varName = valueSrc[start:i]
		}

		if varName == "" {
			return "", fmt.Errorf("empty variable name")
		}

		val, ok := vars[varName]
		if !ok {
			return "", fmt.Errorf("variable not found: %s", varName)
		}

		if builder.Len()+len(val) >= destLen {
			return "", fmt.Errorf("value too long when inserting %s", varName)
		}

		builder.WriteString(val)
	}

	return builder.String(), nil
}

func handleSleep(val string) (int64, error) {

	/* number of digits check */
	if len(val) < 1 || len(val) > 6 {
		return common.JA_JOBRESULT_PRE_EXEC_FAIL, fmt.Errorf("invalid sleep value length")
	}
	/* numeric check */
	if !isNumeric(val) {
		return common.JA_JOBRESULT_PRE_EXEC_FAIL, fmt.Errorf("sleep value is not numeric")
	}
	/* get sleep time */
	sleepTime := getSleepTime(val)

	// Print to stdout for icon_exec_manager to capture
	return sleepTime, nil

}

func handleTimeWait(val string, tz string) (int64, error) {
	fmt.Println("[handleTimeWait] executing time wait with:", val)

	/* numeric check */
	if !isNumeric(val) {
		return common.JA_JOBRESULT_PRE_EXEC_FAIL, fmt.Errorf("time value is not numeric")
	}

	/* wait time format check */
	if !isValidWaitTimeFormat(val) {
		return common.JA_JOBRESULT_PRE_EXEC_FAIL, fmt.Errorf("invalid wait time format")
	}

	// Parse wait_time string (expected format: YYYYMMDDhhmmss)
	wt, err := getWaitTime(val, executionData.JobnetStartTime, tz)
	if err != nil {
		return common.JA_JOBRESULT_PRE_EXEC_FAIL, fmt.Errorf("failed to convert wait_time: %w", err)
	}

	/* get start_time */
	st := time.Unix(executionData.JobnetStartTime, 0)
	if wt.Before(st) { // Accept if wait time is after jobnet start time
		return 0, fmt.Errorf("wait_time is before jobnet start_time")
	}

	return wt.Unix(), nil
}

func handleWeekCheck(val string, tz string) (int, error) {
	fmt.Println("[handleWeekCheck] executing week check with:", val)

	jw := jaWeek{}
	valCopy := strings.TrimSpace(val)
	valCopy = strings.ToUpper(valCopy)

	rc := checkWeek(valCopy, &jw)
	if rc != 0 {
		if rc == 2 {
			return 0, fmt.Errorf("duplicate day of week detected")
		}
		return 0, fmt.Errorf("invalid day of week format")
	}

	// Get current time in the given timezone
	var now time.Time
	if tz != "" {
		loc, err := time.LoadLocation(tz)
		if err != nil {
			return 0, fmt.Errorf("invalid timezone: %s", tz)
		}
		now = time.Now().In(loc)
	} else {
		return 0, fmt.Errorf("timezone is empty")
	}

	cWday := (int(now.Weekday()) + 1) % 7
	if cWday == 0 {
		cWday = 7
	}

	// Use slice to check if today is among the active weekdays
	flags := []int{jw.sun, jw.mon, jw.tue, jw.wed, jw.thu, jw.fri, jw.sat}
	if flags[cWday-1] == 1 {
		return cWday, nil
	}
	return 0, nil // Not today
}

func handleZbxSender(val string) (string, error) {
	cmdPath := getZabbixSenderPath()
	fullCmd := fmt.Sprintf("%s -vv %s", cmdPath, val)
	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(ExtendData.TimeOut)*time.Second)
	defer cancel()

	cmd := exec.CommandContext(ctx, "sh", "-c", fullCmd)

	var stdoutBuf, stderrBuf bytes.Buffer
	cmd.Stdout = &stdoutBuf
	cmd.Stderr = &stderrBuf

	err := cmd.Run()
	combinedOutput := stdoutBuf.String() + stderrBuf.String()

	exitCode := 0
	writeFD3 := func() {
		ExtendData.JobExitCode = exitCode
		if werr := clientcommon.WriteStructToFD3(ExtendData); werr != nil {
			fmt.Fprintln(os.Stderr, "Failed to write zabbix sender data to fd3:", werr)
		}
	}

	switch {
	case ctx.Err() == context.DeadlineExceeded || errors.Is(err, context.DeadlineExceeded):
		fmt.Println(combinedOutput)
		exitCode = 255
		writeFD3()
		return "", fmt.Errorf("command timed out")

	case err != nil:

		fmt.Println(combinedOutput)
		if exitErr, ok := err.(*exec.ExitError); ok {
			if status, ok := exitErr.Sys().(syscall.WaitStatus); ok {
				exitCode = status.ExitStatus()
			} else {
				exitCode = 255
			}
		} else {
			exitCode = 255
		}
		writeFD3()
		return "", fmt.Errorf("command failed")

	default:
		// Success
		if status, ok := cmd.ProcessState.Sys().(syscall.WaitStatus); ok {
			exitCode = status.ExitStatus()
		}

	}

	// Parse Zabbix sender output
	saveLine, resultCode := checkZabbixSenderResultFromString(combinedOutput)
	exitCD := 0
	switch resultCode {
	case 0:
		fmt.Println(saveLine)
		exitCD = 0
	case 1, 2:
		fmt.Println(combinedOutput)
		exitCD = 255
	default:
		fmt.Fprintf(os.Stderr, "unexpected result code: %d\n", resultCode)
		exitCD = 255
	}
	ExtendData.JobExitCode = exitCD

	if err := clientcommon.WriteStructToFD3(ExtendData); err != nil {

		fmt.Fprintln(os.Stderr, "Failed to write zabbix sender data to fd3:", err)
	}

	return "", nil
}

func checkZabbixSenderResultFromString(output string) (string, int) {
	lines := strings.Split(output, "\n")
	saveLine := ""
	resultCode := 2 // unknown by default

	for _, line := range lines {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		if saveLine == "" {
			saveLine = line
		}

		// failure patterns
		if strings.Contains(line, "usage: ") ||
			strings.Contains(line, "Sending failed.") ||
			strings.Contains(line, "sent: 0;") ||
			strings.Contains(line, "processed: 0;") ||
			strings.Contains(line, "sh: ") ||
			strings.Contains(line, ": option") ||
			strings.Contains(line, ": invalid") {
			saveLine = line
			return saveLine, 1
		}

		// success pattern
		if strings.Contains(line, "sent: ") {
			saveLine = line
			return saveLine, 0
		}
	}

	return saveLine, resultCode
}

func isNumeric(s string) bool {
	for _, r := range s {
		if r < '0' || r > '9' {
			return false
		}
	}
	return true
}

func getSleepTime(seconds string) int64 {
	sec, err := strconv.Atoi(seconds)
	if err != nil {
		return 0
	}
	t := time.Now().Add(time.Duration(sec) * time.Second).Unix()
	return t
}

func isValidWaitTimeFormat(val string) bool {
	size := len(val)
	if size < 3 || size > 6 {
		return false
	}
	if !isNumeric(val) {
		return false
	}
	var hhmmss string
	if size == 3 || size == 5 {
		hhmmss = "0" + val
	} else {
		hhmmss = val
	}

	hh, _ := strconv.Atoi(hhmmss[0:2])
	mm, _ := strconv.Atoi(hhmmss[2:4])
	ss := 0
	if len(hhmmss) == 6 {
		ss, _ = strconv.Atoi(hhmmss[4:6])
	}

	if hh < 0 || hh > 99 {
		return false
	}
	if mm < 0 || mm > 59 {
		return false
	}
	if ss < 0 || ss > 59 {
		return false
	}
	return true
}

func getWaitTime(desTime string, startUnix int64, tz string) (time.Time, error) {
	desTime = strings.TrimSpace(desTime)

	// Normalize all acceptable formats (HMM, HHMM, HMMSS, HHMMSS)
	switch len(desTime) {
	case 3: // HMM → HHMMSS
		desTime = "0" + desTime + "00"
	case 4: // HHMM → HHMMSS
		desTime += "00"
	case 5: // HMMSS → HHMMSS
		desTime = "0" + desTime
	case 6: // HHMMSS → OK
	}

	// Parse hour, minute, second
	hour, err := strconv.Atoi(desTime[:2])
	if err != nil {
		return time.Time{}, fmt.Errorf("fail to convert hour digit string to int: %w", err)
	}
	min, err := strconv.Atoi(desTime[2:4])
	if err != nil {
		return time.Time{}, fmt.Errorf("fail to convert minute digit string to int: %w", err)
	}
	sec, err := strconv.Atoi(desTime[4:])
	if err != nil {
		return time.Time{}, fmt.Errorf("fail to convert second digit string to int: %w", err)
	}

	// Validate ranges
	if hour < 0 || hour > 99 || min < 0 || min > 59 || sec < 0 || sec > 59 {
		return time.Time{}, fmt.Errorf("invalid time values: hour=%d, minute=%d, second=%d", hour, min, sec)
	}

	// Load timezone
	loc, err := time.LoadLocation(tz)
	if err != nil {
		return time.Time{}, fmt.Errorf("invalid timezone: %w", err)
	}

	// Determine base time
	var baseTime time.Time
	if startUnix > 0 {
		baseTime = time.Unix(startUnix, 0).In(loc)
	} else {
		baseTime = time.Now().In(loc)
	}

	// Get base day's midnight
	midnight := time.Date(baseTime.Year(), baseTime.Month(), baseTime.Day(), 0, 0, 0, 0, loc)

	// Convert HHMMSS → seconds
	totalSeconds := int64(hour*3600 + min*60 + sec)

	// Add to midnight UNIX time
	targetUnix := midnight.Unix() + totalSeconds
	targetTime := time.Unix(targetUnix, 0).In(loc)

	return targetTime, nil
}

func checkWeek(value string, jw *jaWeek) int {
	jw.sun, jw.mon, jw.tue, jw.wed, jw.thu, jw.fri, jw.sat = 0, 0, 0, 0, 0, 0, 0

	if len(value) == 0 {
		jw.sun = 1
		jw.mon = 1
		jw.tue = 1
		jw.wed = 1
		jw.thu = 1
		jw.fri = 1
		jw.sat = 1
		return 0
	}

	tokens := strings.Fields(value)

	for _, tp := range tokens {
		if len(tp) != 3 {
			return 1 // Invalid token length
		}

		wday := strings.ToUpper(tp)

		switch wday {
		case "SUN":
			if jw.sun != 0 {
				return 2
			}
			jw.sun = 1
		case "MON":
			if jw.mon != 0 {
				return 2
			}
			jw.mon = 1
		case "TUE":
			if jw.tue != 0 {
				return 2
			}
			jw.tue = 1
		case "WED":
			if jw.wed != 0 {
				return 2
			}
			jw.wed = 1
		case "THU":
			if jw.thu != 0 {
				return 2
			}
			jw.thu = 1
		case "FRI":
			if jw.fri != 0 {
				return 2
			}
			jw.fri = 1
		case "SAT":
			if jw.sat != 0 {
				return 2
			}
			jw.sat = 1
		default:
			return 1
		}
	}

	return 0
}

func getZabbixSenderPath() string {
	if strings.EqualFold(ParameterData.ParameterName, "ZBXSND_SENDER") {
		return ParameterData.Value
	}
	return "zabbix_sender"
}

func main() {

	//catch runtime panic errors
	defer func() {
		if r := recover(); r != nil {
			//output stacktrace
			fmt.Fprintf(os.Stderr, "[ExtensionIconClient] Runtime panic error occurs in client. error : %s", string(debug.Stack()))
			if runStatusChangeFlag {
				os.Exit(common.JA_JOBRESULT_FAIL)
			}
			os.Exit(common.JA_JOBRESULT_PRE_EXEC_FAIL)
		}
	}()

	// add client_pid in .clientPID file
	pid := os.Getpid()
	err := common.SetClientPid(pid)
	if err != nil {
		fmt.Fprintf(os.Stderr, "[ExtensionIconClient] %v", err)
		os.Exit(common.JA_JOBEXEC_FAIL)
	}

	err = clientcommon.ParseArgs()
	if err != nil {
		fmt.Fprintf(os.Stderr, "[ExtensionIconClient] %v", err)
		os.Exit(common.JA_JOBEXEC_FAIL)
	}

	server.Options.UnixSockParentDir = clientcommon.UdsDirpath
	server.Options.TmpDir = clientcommon.TmpDirPath

	exitCode, err := extensionIconClient()
	if err != nil {
		fmt.Fprintf(os.Stderr, "[ExtensionIconClient] %v", err)

	}
	os.Exit(exitCode)
}
