/*
** 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 (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"jobarranger2/src/libs/golibs/common"
	"jobarranger2/src/libs/golibs/config_reader/server"
	"jobarranger2/src/libs/golibs/database"
	"jobarranger2/src/libs/golibs/event"
	"jobarranger2/src/libs/golibs/logger/logger"
	"jobarranger2/src/libs/golibs/uds"
	"jobarranger2/src/libs/golibs/utils"
)

// getSortedRunningJobnetFiles returns a list of files in the run directory matching the given jobnetId.
// Files must be in the format: <someid>_<jobnetId>_0_<timestamp>.json
// jobnetId can be "NORMAL_JOB", "FOO_BAR_BAZ", etc.
// getSortedRunningJobnetFilesRegex finds files matching the pattern with jobnetId using regex
func getSortedRunningJobnetFile(jobnetId string, dir string) (string, error) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return "", fmt.Errorf("error reading directory: %v", err)
	}

	// var matchedFiles []string

	// Build regex:
	// Example pattern: <innerjobnetid>_<jobnetId>_<jobid>_<timestamp>_<nano>.json
	// Regex breakdown:
	// \d+      => starts with innerjobnetid (digits)
	// jobnetId  => match jobnetId literally
	// \d+      => jobid (can be 0 or number) (digits)
	// \d+       => timestamp (digits)
	// \d+       => nano(digits)
	// \.json$   => ends with .json
	pattern := fmt.Sprintf(`\d+_%s_\d+_\d+_\d+\.json$`, regexp.QuoteMeta(jobnetId))
	re := regexp.MustCompile(pattern)

	type fileInfo struct {
		name string
		time time.Time
	}

	var files []fileInfo

	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		if !re.MatchString(e.Name()) {
			continue
		}

		info, err := e.Info()
		if err != nil {
			continue
		}

		files = append(files, fileInfo{
			name: e.Name(),
			time: info.ModTime(),
		})
	}

	if len(files) == 0 {
		return "", nil
	}

	// Sort same as `ls -ltr`
	sort.Slice(files, func(i, j int) bool {
		return files[i].time.Before(files[j].time)
	})

	// Oldest first (same as ls -ltr)
	return files[0].name, nil
}

// findFileByInnerJobnetID scans the specified folder and returns the first file
// matching the format: <innerJobnetId>_<jobnetId>_<jobId>_<timestamp>_<nano>.json
// Example: 1234_MY_JOB_0_1731476123_987654321.json
func findFileByInnerJobnetID(folder string, innerJobnetId uint64) (string, error) {
	files, err := os.ReadDir(folder)
	if err != nil {
		return "", fmt.Errorf("failed to read directory %s: %w", folder, err)
	}

	// Convert innerJobnetId to string for regex pattern
	idStr := strconv.FormatUint(innerJobnetId, 10)
	// Build regex pattern
	// Example: ^1234_[A-Z0-9_]+_\d+_\d+_\d+\.json$
	pattern := fmt.Sprintf(`^%s_[A-Za-z0-9_-]+_\d+_\d+_\d+\.json$`, regexp.QuoteMeta(idStr))
	re := regexp.MustCompile(pattern)

	for _, file := range files {
		if file.IsDir() {
			continue
		}
		name := file.Name()
		if re.MatchString(name) {
			return filepath.Join(folder, name), nil
		}
	}

	return "", nil
}

func getInnerJobnetIDFromFilename(filename string) (uint64, error) {
	parts := strings.Split(filename, "_")
	if len(parts) < 2 {
		return 0, fmt.Errorf("invalid jobnet filename: %s", filename)
	}
	return strconv.ParseUint(parts[0], 10, 64)
}

func findJobnetFileInDirs(
	dirs []string,
	innerJobnetId uint64,
) (string, error) {
	for _, dir := range dirs {
		fileName, err := findFileByInnerJobnetID(dir, innerJobnetId)
		if err != nil {
			return "", fmt.Errorf("find file in %s failed: %w", dir, err)
		}
		if fileName != "" {
			return fileName, nil
		}
	}
	return "", nil
}

// moveFilesToEndForInnerJobnet finds files for the given innerJobnetId in the provided directories
// and moves them to the END subfolder. If no dirs are provided, it checks the RUN and IN dirs.
// It returns on the first error encountered.
func moveFilesToEndForInnerJobnet(innerJobnetId uint64, dirs ...string) error {
	// Default directories to check when none are specified
	if len(dirs) == 0 {
		dirs = []string{
			JOBNETMANAGER_RUN_DIR,
			JOBNETMANAGER_IN_DIR,
		}
	}

	for _, dir := range dirs {
		fileName, err := findFileByInnerJobnetID(dir, innerJobnetId)
		if err != nil {
			return fmt.Errorf("find file in %s failed: %w", dir, err)
		}
		if fileName == "" {
			// nothing to move for this directory
			continue
		}

		if err := utils.MoveToSubFolder(fileName, END); err != nil {
			return fmt.Errorf("move file %s to %s failed: %w", fileName, END, err)
		}
	}

	return nil
}

// Call this function after server.Options.WorkingDir is initialized
func initJobnetManagerDirs(baseDir string) {
	JOBNETMANAGER_IN_DIR = filepath.Join(baseDir, "jobnetmanager", "in")
	JOBNETMANAGER_RUN_DIR = filepath.Join(baseDir, "jobnetmanager", "run")
	JOBNETMANAGER_WAIT_DIR = filepath.Join(baseDir, "jobnetmanager", "wait")
	JOBNETMANAGER_END_DIR = filepath.Join(baseDir, "jobnetmanager", "end")
}

func getJobnetRunData(data interface{}) (common.JobnetRunData, error) {
	// If already the right type, just return
	if jobnetRunData, ok := data.(common.JobnetRunData); ok {
		return jobnetRunData, nil
	}
	// If it's a map, marshal and unmarshal for conversion
	bytes, err := json.Marshal(data)
	if err != nil {
		return common.JobnetRunData{}, fmt.Errorf("marshal failed: %w", err)
	}
	var jobnetRunData common.JobnetRunData
	if err := json.Unmarshal(bytes, &jobnetRunData); err != nil {
		return common.JobnetRunData{}, fmt.Errorf("unmarshal failed: %w", err)
	}
	return jobnetRunData, nil
}

func prepareNextEventData(
	eventName common.EventName,
	nextProcessName common.ProcessType,
	nextProcessData any,
	queries []common.ParameterizedQuery,
) common.EventData {
	return common.EventData{
		Event: common.Event{
			Name:      eventName,
			UniqueKey: common.GetUniqueKey(common.JobnetManagerProcess),
		},
		NextProcess: common.NextProcess{
			Name: nextProcessName,
			Data: nextProcessData,
		},
		ParameterizedQueries: queries,
	}
}

// Helper to update jobnet status based on context
func updateJobnetStatus(
	jobnetRunData common.JobnetRunData,
	isSubJobnet bool,
	status common.StatusType,
	queries []common.ParameterizedQuery,
) ([]common.ParameterizedQuery, error) {
	if isSubJobnet {
		queries = jaSetStatusJobnet(jobnetRunData.InnerJobnetId, status, -1, 1, queries...)
	} else {
		queries = jaSetStatusJobnetSummary(jobnetRunData.InnerJobnetId, status, -1, 1, queries...)
		queries = jaSetStatusJobnet(jobnetRunData.InnerJobnetId, status, -1, 1, queries...)
	}
	return queries, nil
}

// Helper to create icon stop events in parallel
func createIconStopEvents(
	jobnetRunData common.JobnetRunData,
	runningJobIdList []uint64,
) error {
	const maxConcurrent = 16
	sem := make(chan struct{}, maxConcurrent)
	var wg sync.WaitGroup
	errCh := make(chan error, len(runningJobIdList))

	for _, jobId := range runningJobIdList {
		wg.Add(1)
		sem <- struct{}{}
		go func(jobId uint64) {
			defer wg.Done()
			defer func() { <-sem }()
			eventIconStop := prepareNextEventData(
				common.EventIconStop,
				common.FlowManagerProcess,
				common.FlowProcessData{
					InnerJobnetId: jobnetRunData.InnerJobnetId,
					JobnetId:      jobnetRunData.JobnetID,
					InnerJobId:    jobId,
				},
				[]common.ParameterizedQuery{},
			)
			err := event.CreateNextEvent(eventIconStop, jobnetRunData.InnerJobnetId, jobnetRunData.JobnetID, jobnetRunData.InnerJobId)
			if err != nil {
				errCh <- fmt.Errorf("%s", err.Error())
			}
		}(jobId)
	}

	wg.Wait()
	close(errCh)

	for err := range errCh {
		return fmt.Errorf("%s", err.Error())
	}
	return nil
}

// Wait for all running jobs to finish, then update status to END
func waitForJobsAndComplete(
	dbconn database.DBConnection,
	jobnetRunData common.JobnetRunData,
	isSubJobnet bool,
	timeout uint64,
	jobnetStatus common.StatusType,
	currentFilepath string,
) (bool, error) {
	queries := []common.ParameterizedQuery{}
	var nextEvent common.EventData
	//polling for run status only.
	runningJobIdList, err := utils.GetRunningJobIDList(dbconn, jobnetRunData.InnerJobnetId)
	if err != nil {
		return false, fmt.Errorf("%s", err.Error())
	}

	// If jobs are still running, optionally fire stop events each retry:
	now := uint64(time.Now().Unix())

	if jobnetRunData.IconStopInterval == 0 || (now-jobnetRunData.IconStopInterval) > timeout {
		fmt.Println("Condition met: creating stop events")

		if err := createIconStopEvents(jobnetRunData, runningJobIdList); err != nil {
			fmt.Println("Error creating stop events:", err)
			return false, fmt.Errorf("%s", err.Error())
		}
	}

	//polling for run and ready status.
	runningJobIdList, err = utils.GetRunningJobIDList(dbconn, jobnetRunData.InnerJobnetId)
	if err != nil {
		return false, fmt.Errorf("%s", err.Error())
	}

	if len(runningJobIdList) == 0 {
		if isSubJobnet {
			// Add Flag here
			queries = jaSetEndingFlagJobnet(jobnetRunData.InnerJobnetId, 0, queries...)
		} else {
			queries = jaSetEndingFlagSummary(jobnetRunData.InnerJobnetId, 0, queries...)
			queries = jaSetEndingFlagJobnet(jobnetRunData.InnerJobnetId, 0, queries...)
		}

		queries, err = updateJobnetStatus(jobnetRunData, isSubJobnet, jobnetStatus, queries)
		if err != nil {
			return false, fmt.Errorf("%s", err.Error())
		}
		if isSubJobnet && jobnetStatus == common.StatusEndErr {
			nextEvent = prepareNextEventData(
				common.EventSubJobnetStatusChange,
				common.FlowManagerProcess,
				common.FlowProcessData{
					InnerJobnetId: jobnetRunData.InnerJobnetId,
					Status:        jobnetStatus,
					JobnetId:      jobnetRunData.JobnetID,
				},
				queries,
			)
		} else {
			nextEvent = prepareNextEventData(
				common.EventJobnetEnd,
				common.DBSyncerManagerProcess,
				map[string]any{},
				queries,
			)
		}

		err := event.CreateNextEvent(nextEvent, jobnetRunData.InnerJobnetId, jobnetRunData.JobnetID, jobnetRunData.InnerJobId)
		if err != nil {
			return false, fmt.Errorf("%s", err.Error())
		}

		return true, nil // finished
	} else {
		//retrigger event file
		sockFilePathFull := filepath.Join(server.Options.UnixSockParentDir, common.JobnetManagerSockFile)
		return false, updateTransactionFile(
			jobnetRunData.InnerJobnetId,
			jobnetRunData.InnerJobId,
			string(common.JobnetManagerProcess),
			sockFilePathFull,
			currentFilepath,
			func(data *common.JobnetRunData) {
				data.IconStopInterval = uint64(time.Now().Unix())
			},
		)

	}
}

// FormatTime returns a formatted time string.
func FormatTime(t time.Time, layout string) string {
	return t.Format(layout)
}

// FormatTimeToUint64 returns a formatted time string (in layout) as uint64.
func FormatTimeToUint64(t time.Time, layout string) uint64 {
	val, err := strconv.ParseUint(t.Format(layout), 10, 64)
	if err != nil {
		return 0
	}
	return val
}

// MustLoadLocationOrDefault tries to load a location, falls back to server timezone if not found.
func MustLoadLocationOrDefault(loc string, fallback string) *time.Location {
	l, err := time.LoadLocation(loc)
	if err == nil {
		return l
	}
	fallbackLoc, err := time.LoadLocation(fallback)
	if err == nil {
		return fallbackLoc
	}
	return time.Local
}

// ConvertUnixToTZ converts a Unix timestamp into YYYYMMDDhhmmss format adjusted to the given timezone.
func ConvertUnixToTZ(scheduledTime int64, tz string) uint64 {
	if scheduledTime == 0 {
		return 0
	}
	loc, err := time.LoadLocation(tz)
	if err != nil {
		loc = time.Local
	}
	t := time.Unix(scheduledTime, 0).In(loc)
	return FormatTimeToUint64(t, "20060102150405")
}

// GetLocalIanaTimezone returns the system's IANA timezone name (e.g., "Asia/Yangon") or "UTC" using timedatectl.
func GetLocalIanaTimezone() string {
	out, err := exec.Command("timedatectl", "show", "--property=Timezone", "--value").Output()
	if err != nil && !strings.Contains(err.Error(), "waitid: no child processes") {
		return "UTC"
	}
	tz := strings.TrimSpace(string(out))
	if tz == "" {
		return "UTC"
	}
	return tz
}

// UnmarshalMap unmarshals a JSON []byte to map[string]any safely.
func UnmarshalMap(data []byte) map[string]any {
	var m map[string]any
	if err := json.Unmarshal(data, &m); err != nil {
		return nil
	}
	return m
}

// ScheduleJobnets provides thread-safe storage for cancel functions
type ScheduleJobnets struct {
	mu        sync.RWMutex
	cancelMap map[uint64]context.CancelFunc
}

var (
	registryInstance *ScheduleJobnets
	once             sync.Once
)

// Singleton initializer for the registry
func GetScheduleJobnets() *ScheduleJobnets {
	once.Do(func() {
		registryInstance = &ScheduleJobnets{
			cancelMap: make(map[uint64]context.CancelFunc),
		}
	})
	return registryInstance
}

// Add stores a cancel function for a given ID
func (sj *ScheduleJobnets) Add(id uint64, cancel context.CancelFunc) {
	sj.mu.Lock()
	sj.cancelMap[id] = cancel
	sj.mu.Unlock()
}

// Get retrieves the cancel function for a given ID
func (sj *ScheduleJobnets) Get(id uint64) (context.CancelFunc, bool) {
	sj.mu.RLock()
	cancel, ok := sj.cancelMap[id]
	sj.mu.RUnlock()
	return cancel, ok
}

// Remove cancels the goroutine for the given ID (if present) and deletes the entry
func (sj *ScheduleJobnets) Remove(id uint64) {
	sj.mu.Lock()
	if cancel, ok := sj.cancelMap[id]; ok && cancel != nil {
		cancel()
	}
	delete(sj.cancelMap, id)
	sj.mu.Unlock()
}

func updateTransactionFile(
	innerJobnetId uint64,
	innerJobId uint64,
	manager string,
	udsPath string,
	targetFilePath string,
	modifier func(*common.JobnetRunData),
) error {
	fn := "updateTransactionFile"
	logData := logger.Logging{}
	var eventData common.EventData

	logger.JaLog("JAJOBNETRUN400022", logData, fn, innerJobnetId, innerJobId, manager)
	logger.JaLog("JAJOBNETRUN400023", logData, fn, targetFilePath)

	data, err := os.ReadFile(targetFilePath)
	if err != nil {
		return fmt.Errorf("failed to read file '%s': %v", targetFilePath, err)
	}
	if err := utils.UnmarshalEventData(data, &eventData); err != nil {
		return fmt.Errorf("failed to unmarshal event data: %v", err)
	}

	if err != nil {
		return fmt.Errorf("failed to find transaction file: %v", err)
	}

	if targetFilePath == "" {
		logger.JaLog("JAJOBNETRUN000012", logData, targetFilePath)
		return nil
	}

	eventData.Event.UniqueKey = common.GetUniqueKey(common.JobnetManagerProcess)

	jobnetRunData, err := getJobnetRunData(eventData.NextProcess.Data)
	if err != nil {
		return fmt.Errorf("%s", err.Error())
	}

	modifier(&jobnetRunData)
	eventData.NextProcess.Data = jobnetRunData

	// Step 3: Write updated data
	if err := writeJsonFileAtomic(targetFilePath, eventData); err != nil {
		return fmt.Errorf("failed to write new data to json file '%s': %v", targetFilePath, err)
	}
	logger.JaLog("JAJOBNETRUN400025", logData, fn, targetFilePath)

	if err := uds.SendViaUDS(udsPath, &eventData); err != nil {
		logger.JaLog("JAJOBNETRUN200009", logData, fn, eventData.Event.Name, targetFilePath, err.Error())
	}

	logger.JaLog("JAJOBNETRUN400026", logData, fn, eventData.Event.Name, targetFilePath)

	return nil
}

// Writes `data` to `filePath` atomically (via temporary file then rename)
func writeJsonFileAtomic(filePath string, data any) error {
	fn := "writeJsonFileAtomic"
	logData := logger.Logging{}

	// Create a temporary file in the same directory
	dir := filepath.Dir(filePath)
	tempFile, err := os.CreateTemp(dir, "*.tmp")
	if err != nil {
		return fmt.Errorf("failed to create temp file: %w", err)
	}
	logger.JaLog("JAJOBNETRUN400027", logData, fn, tempFile.Name())

	// Ensure it's removed on failure
	defer func() {
		tempFile.Close()
		os.Remove(tempFile.Name())
	}()

	// Encode data to JSON and write to the temp file
	encoder := json.NewEncoder(tempFile)
	encoder.SetIndent("", "  ") // optional: pretty-print
	if err := encoder.Encode(data); err != nil {
		return fmt.Errorf("failed to encode JSON: %w", err)
	}

	// Sync the data to disk
	if err := tempFile.Sync(); err != nil {
		return fmt.Errorf("failed to sync temp file: %w", err)
	}

	// Close the temp file before renaming
	if err := tempFile.Close(); err != nil {
		return fmt.Errorf("failed to close temp file: %w", err)
	}

	// Atomically replace the original file
	if err := os.Rename(tempFile.Name(), filePath); err != nil {
		return fmt.Errorf("failed to rename temp file: %w", err)
	}
	logger.JaLog("JAJOBNETRUN400028", logData, fn, filePath, tempFile.Name())

	return nil
}

func isNotScheduleJobnetId(id uint64) bool {
	s := strconv.FormatUint(id, 10)
	return strings.HasPrefix(s, "15")
}

func IsSubJobnet(dbconn database.DBConnection, innerJobnetId uint64) (bool, uint64, error) {
	mainId, err := utils.GetInnerJobnetMainIdFromRunJobnetTable(dbconn, innerJobnetId)
	if err != nil {
		return false, 0, err
	}
	return mainId != innerJobnetId, mainId, nil
}
