package system

import (
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"

	"go.uber.org/zap"
)

var (
	cpuDirRegExp = regexp.MustCompile(`/cpu(\d+)`)
	nodeDir      = "/sys/devices/system/node"
	cpusPath     = "/sys/devices/system/cpu"
)

type Resources interface {
	GetMilliNumCores() (int, error)
	GetOnlineMemory() (int64, error)
}

// Real implementation of Resources interface that reads values from sysfs.
type SysfsResources struct{}

const (
	sysFsCPUTopology = "/topology"
	cpuDirPattern    = "cpu*[0-9]"
	nodeDirPattern   = "node*[0-9]"

	coreIDFilePath    = sysFsCPUTopology + "/core_id"
	packageIDFilePath = sysFsCPUTopology + "/physical_package_id"

	memoryPath = "/sys/devices/system/memory"
)

type core struct {
	Id       int      `json:"core_id"`
	Threads  []uint64 `json:"thread_ids"`
	SocketID int      `json:"socket_id"`
}

func init() {
	//cannot copy file to /sys for doc build, use this as a hack for testing
	cpuDirEnv := os.Getenv("CPU_DIR")
	if cpuDirEnv != "" {
		cpusPath = cpuDirEnv
	}
	nodeDirEnv := os.Getenv("NODE_DIR")
	if nodeDirEnv != "" {
		nodeDir = nodeDirEnv
	}
}

// GetMilliNumCores this is a stripped version of GetNodesInfo that only get information for NumCores
// https://github.com/google/cadvisor/blob/master/utils/sysinfo/sysinfo.go#L203
func (pr SysfsResources) GetMilliNumCores() (int, error) {
	allLogicalCoresCount := 0

	nodesDirs, err := getNodesPaths()
	if err != nil {
		return 0, err
	}
	if len(nodesDirs) == 0 {
		zap.L().Error("Nodes topology is not available, providing CPU topology")
		cpuCount, err := getCPUCount()
		if err != nil {
			return 0, err
		}
		return cpuCount * 1000, nil
	}

	for _, dir := range nodesDirs {
		cpuDirs, err := getCPUsPaths(dir)
		if len(cpuDirs) == 0 {
			zap.L().Error("Found node without any CPU", zap.String("dir", dir), zap.Error(err))
		} else {
			cores, err := getCoresInfo(cpuDirs)
			if err != nil {
				return 0, err
			}
			for _, core := range cores {
				allLogicalCoresCount += len(core.Threads)
			}
		}

	}
	return allLogicalCoresCount * 1000, err

}

func getCPUCount() (int, error) {
	cpusPaths, err := getCPUsPaths(cpusPath)
	if err != nil {
		return 0, err
	}
	cpusCount := len(cpusPaths)

	if cpusCount == 0 {
		return 0, fmt.Errorf("Any CPU is not available, cpusPath: %s", cpusPath)
	}
	return cpusCount, nil
}

func getNodesPaths() ([]string, error) {
	pathPattern := fmt.Sprintf("%s/%s", nodeDir, nodeDirPattern)
	return filepath.Glob(pathPattern)
}

func getCPUsPaths(cpusPath string) ([]string, error) {
	pathPattern := fmt.Sprintf("%s/%s", cpusPath, cpuDirPattern)
	return filepath.Glob(pathPattern)
}

func getCPUPhysicalPackageID(cpuPath string) (string, error) {
	packageIDFilePath := fmt.Sprintf("%s%s", cpuPath, packageIDFilePath)
	// #nosec G304 // This cpuPath essentially come from getCPUsPaths and it should be a system path
	packageID, err := os.ReadFile(packageIDFilePath)
	if err != nil {
		return "", err
	}
	return strings.TrimSpace(string(packageID)), err
}

func getCoresInfo(cpuDirs []string) ([]core, error) {
	cores := make([]core, 0, len(cpuDirs))
	for _, cpuDir := range cpuDirs {
		cpuID, err := getCPUID(cpuDir)
		if err != nil {
			return nil, fmt.Errorf("unexpected format of CPU directory, cpuDirRegExp %s, cpuDir: %s", cpuDirRegExp, cpuDir)
		}
		if !isCPUOnline(cpuID) {
			continue
		}

		rawPhysicalID, err := getCoreID(cpuDir)
		if os.IsNotExist(err) {
			zap.L().Warn("Cannot read core id for input cpuDir, core_id file does not exist",
				zap.String("cpuDir", cpuDir), zap.Error(err))
			continue
		} else if err != nil {
			return nil, err
		}
		physicalID, err := strconv.Atoi(rawPhysicalID)
		if err != nil {
			return nil, err
		}

		rawPhysicalPackageID, err := getCPUPhysicalPackageID(cpuDir)
		if os.IsNotExist(err) {
			zap.L().Warn("Cannot read physical package id for input cpuDir, physical_package_id file does not exist",
				zap.String("cpuDir", cpuDir), zap.Error(err))
			continue
		} else if err != nil {
			return nil, err
		}

		physicalPackageID, err := strconv.Atoi(rawPhysicalPackageID)
		if err != nil {
			return nil, err
		}

		coreIDx := -1
		for id, core := range cores {
			if core.Id == physicalID && core.SocketID == physicalPackageID {
				coreIDx = id
			}
		}
		if coreIDx == -1 {
			cores = append(cores, core{})
			coreIDx = len(cores) - 1
		}
		desiredCore := &cores[coreIDx]

		desiredCore.Id = physicalID
		desiredCore.SocketID = physicalPackageID

		if len(desiredCore.Threads) == 0 {
			desiredCore.Threads = []uint64{cpuID}
		} else {
			desiredCore.Threads = append(desiredCore.Threads, cpuID)
		}

	}
	return cores, nil
}

func getCPUID(str string) (uint64, error) {
	matches := cpuDirRegExp.FindStringSubmatch(str)
	if len(matches) != 2 {
		return 0, fmt.Errorf("failed to match regexp, str: %s", str)
	}
	valInt, err := strconv.ParseUint(matches[1], 10, 16)
	if err != nil {
		return 0, err
	}
	return valInt, nil
}

func getCoreID(cpuPath string) (string, error) {
	coreIDFilePath := fmt.Sprintf("%s%s", cpuPath, coreIDFilePath)
	// #nosec G304 // This cpuPath essentially come from getCPUsPaths and it should be a system path
	coreID, err := os.ReadFile(coreIDFilePath)
	if err != nil {
		return "", err
	}
	return strings.TrimSpace(string(coreID)), err
}

func isCPUOnline(cpuID uint64) bool {
	cpuOnlinePath, err := filepath.Abs(cpusPath + "/online")
	if err != nil {
		zap.L().Info("Unable to get absolute path", zap.String("absolutPath", cpusPath+"/online"))
		return false
	}

	// Quick check to determine if file exists: if it does not then kernel CPU hotplug is disabled and all CPUs are online.
	_, err = os.Stat(cpuOnlinePath)
	if err != nil && os.IsNotExist(err) {
		return true
	}
	if err != nil {
		zap.L().Warn("Unable to stat cpuOnlinePath",
			zap.String("cpuOnlinePath", cpuOnlinePath),
			zap.Error(err))
	}

	isOnline, err := isCpuOnlineAtPath(cpuOnlinePath, cpuID)
	if err != nil {
		zap.L().Error("Unable to get online CPUs list", zap.Error(err))
		return false
	}
	return isOnline
}

func isCpuOnlineAtPath(path string, cpuID uint64) (bool, error) {
	// #nosec G304 // This path is cpuOnlinePath from isCPUOnline
	fileContent, err := os.ReadFile(path)
	if err != nil {
		return false, err
	}
	if len(fileContent) == 0 {
		return false, fmt.Errorf("%s found to be empty", path)
	}

	cpuList := strings.TrimSpace(string(fileContent))
	for _, s := range strings.Split(cpuList, ",") {
		splitted := strings.SplitN(s, "-", 3)
		switch len(splitted) {
		case 3:
			return false, fmt.Errorf("invalid values in %s", path)
		case 2:
			min, err := strconv.ParseUint(splitted[0], 10, 16)
			if err != nil {
				return false, err
			}
			max, err := strconv.ParseUint(splitted[1], 10, 16)
			if err != nil {
				return false, err
			}
			if min > max {
				return false, fmt.Errorf("invalid values in %s", path)
			}
			// Return true, if the CPU under consideration is in the range of online CPUs.
			if cpuID >= min && cpuID <= max {
				return true, nil
			}
		case 1:
			value, err := strconv.ParseUint(s, 10, 16)
			if err != nil {
				return false, err
			}
			if value == cpuID {
				return true, nil
			}
		}
	}

	return false, nil
}

// Gets the total amount of online memory on the node in bytes.
func (pr SysfsResources) GetOnlineMemory() (int64, error) {
	blockSizePath := filepath.Join(memoryPath, "block_size_bytes")
	// #nosec G304 // This path is a constant sysfs path.
	blockSizeContents, err := os.ReadFile(blockSizePath)
	if err != nil {
		return 0, fmt.Errorf("failed to read block size path '%s': %w", blockSizePath, err)
	}

	blockSize, err := strconv.ParseInt(strings.TrimSpace(string(blockSizeContents)), 16, 64)
	if err != nil {
		return 0, fmt.Errorf("failed to parse block size '%s': %w", blockSizeContents, err)
	}

	files, err := os.ReadDir(memoryPath)
	if err != nil {
		return 0, fmt.Errorf("failed to read memory dir '%s': %w", memoryPath, err)
	}

	onlineMemory := int64(0)
	pattern := regexp.MustCompile(`memory[0-9]+`)
	for _, file := range files {
		if !pattern.MatchString(file.Name()) {
			continue
		}

		onlinePath := filepath.Join(memoryPath, file.Name(), "online")
		// #nosec G304 // This path will be a sysfs subpath.
		onlineContents, err := os.ReadFile(onlinePath)
		if err != nil {
			return 0, fmt.Errorf("failed to read online path for memory '%s': %w", onlinePath, err)
		}

		if strings.TrimSpace(string(onlineContents)) == "1" {
			onlineMemory += 1
		}
	}

	return blockSize * onlineMemory, nil
}

type FakeResources struct {
	Cpu    int
	Memory int64
}

func (fr FakeResources) GetMilliNumCores() (int, error) {
	return fr.Cpu * 1000, nil
}

func (fr FakeResources) GetOnlineMemory() (int64, error) {
	return fr.Memory, nil
}
