//go:generate ../../../tools/readme_config_includer/generator
package nfsclient

import (
	"bufio"
	_ "embed"
	"errors"
	"fmt"
	"os"
	"regexp"
	"strconv"
	"strings"

	"github.com/influxdata/telegraf"
	"github.com/influxdata/telegraf/internal/choice"
	"github.com/influxdata/telegraf/plugins/inputs"
)

//go:embed sample.conf
var sampleConfig string

type NFSClient struct {
	Fullstat          bool            `toml:"fullstat"`
	IncludeMounts     []string        `toml:"include_mounts"`
	ExcludeMounts     []string        `toml:"exclude_mounts"`
	IncludeOperations []string        `toml:"include_operations"`
	ExcludeOperations []string        `toml:"exclude_operations"`
	Log               telegraf.Logger `toml:"-"`
	nfs3Ops           map[string]bool
	nfs4Ops           map[string]bool
	mountstatsPath    string
	// Add compiled regex patterns
	includeMountRegex []*regexp.Regexp
	excludeMountRegex []*regexp.Regexp
}

func (*NFSClient) SampleConfig() string {
	return sampleConfig
}

func (n *NFSClient) Init() error {
	var nfs3Fields = []string{
		"NULL",
		"GETATTR",
		"SETATTR",
		"LOOKUP",
		"ACCESS",
		"READLINK",
		"READ",
		"WRITE",
		"CREATE",
		"MKDIR",
		"SYMLINK",
		"MKNOD",
		"REMOVE",
		"RMDIR",
		"RENAME",
		"LINK",
		"READDIR",
		"READDIRPLUS",
		"FSSTAT",
		"FSINFO",
		"PATHCONF",
		"COMMIT",
	}

	var nfs4Fields = []string{
		"NULL",
		"READ",
		"WRITE",
		"COMMIT",
		"OPEN",
		"OPEN_CONFIRM",
		"OPEN_NOATTR",
		"OPEN_DOWNGRADE",
		"CLOSE",
		"SETATTR",
		"FSINFO",
		"RENEW",
		"SETCLIENTID",
		"SETCLIENTID_CONFIRM",
		"LOCK",
		"LOCKT",
		"LOCKU",
		"ACCESS",
		"GETATTR",
		"LOOKUP",
		"LOOKUP_ROOT",
		"REMOVE",
		"RENAME",
		"LINK",
		"SYMLINK",
		"CREATE",
		"PATHCONF",
		"STATFS",
		"READLINK",
		"READDIR",
		"SERVER_CAPS",
		"DELEGRETURN",
		"GETACL",
		"SETACL",
		"FS_LOCATIONS",
		"RELEASE_LOCKOWNER",
		"SECINFO",
		"FSID_PRESENT",
		"EXCHANGE_ID",
		"CREATE_SESSION",
		"DESTROY_SESSION",
		"SEQUENCE",
		"GET_LEASE_TIME",
		"RECLAIM_COMPLETE",
		"LAYOUTGET",
		"GETDEVICEINFO",
		"LAYOUTCOMMIT",
		"LAYOUTRETURN",
		"SECINFO_NO_NAME",
		"TEST_STATEID",
		"FREE_STATEID",
		"GETDEVICELIST",
		"BIND_CONN_TO_SESSION",
		"DESTROY_CLIENTID",
		"SEEK",
		"ALLOCATE",
		"DEALLOCATE",
		"LAYOUTSTATS",
		"CLONE",
		"COPY",
		"OFFLOAD_CANCEL",
		"LOOKUPP",
		"LAYOUTERROR",
		"COPY_NOTIFY",
		"GETXATTR",
		"SETXATTR",
		"LISTXATTRS",
		"REMOVEXATTR",
	}

	nfs3Ops := make(map[string]bool)
	nfs4Ops := make(map[string]bool)

	n.mountstatsPath = n.getMountStatsPath()

	if len(n.IncludeOperations) == 0 {
		for _, Op := range nfs3Fields {
			nfs3Ops[Op] = true
		}
		for _, Op := range nfs4Fields {
			nfs4Ops[Op] = true
		}
	} else {
		for _, Op := range n.IncludeOperations {
			nfs3Ops[Op] = true
		}
		for _, Op := range n.IncludeOperations {
			nfs4Ops[Op] = true
		}
	}

	if len(n.ExcludeOperations) > 0 {
		for _, Op := range n.ExcludeOperations {
			if nfs3Ops[Op] {
				delete(nfs3Ops, Op)
			}
			if nfs4Ops[Op] {
				delete(nfs4Ops, Op)
			}
		}
	}

	n.nfs3Ops = nfs3Ops
	n.nfs4Ops = nfs4Ops

	if len(n.IncludeMounts) > 0 {
		n.Log.Debugf("Including these mount patterns: %v", n.IncludeMounts)
	} else {
		n.Log.Debugf("Including all mounts.")
	}

	if len(n.ExcludeMounts) > 0 {
		n.Log.Debugf("Excluding these mount patterns: %v", n.ExcludeMounts)
	} else {
		n.Log.Debugf("Not excluding any mounts.")
	}

	if len(n.IncludeOperations) > 0 {
		n.Log.Debugf("Including these operations: %v", n.IncludeOperations)
	} else {
		n.Log.Debugf("Including all operations.")
	}

	if len(n.ExcludeOperations) > 0 {
		n.Log.Debugf("Excluding these mount patterns: %v", n.ExcludeOperations)
	} else {
		n.Log.Debugf("Not excluding any operations.")
	}

	// Compile include mount patterns
	if len(n.IncludeMounts) > 0 {
		n.includeMountRegex = make([]*regexp.Regexp, 0, len(n.IncludeMounts))
		for _, pattern := range n.IncludeMounts {
			compiled, err := regexp.Compile(pattern)
			if err != nil {
				return fmt.Errorf("failed to compile include mount pattern %q: %w", pattern, err)
			}
			n.includeMountRegex = append(n.includeMountRegex, compiled)
		}
	}

	// Compile exclude mount patterns
	if len(n.ExcludeMounts) > 0 {
		n.excludeMountRegex = make([]*regexp.Regexp, 0, len(n.ExcludeMounts))
		for _, pattern := range n.ExcludeMounts {
			compiled, err := regexp.Compile(pattern)
			if err != nil {
				return fmt.Errorf("failed to compile exclude mount pattern %q: %w", pattern, err)
			}
			n.excludeMountRegex = append(n.excludeMountRegex, compiled)
		}
	}

	return nil
}

func (n *NFSClient) Gather(acc telegraf.Accumulator) error {
	if _, err := os.Stat(n.mountstatsPath); os.IsNotExist(err) {
		return err
	}

	// Attempt to read the file to see if we have permissions before opening
	// which can lead to a panic
	if _, err := os.ReadFile(n.mountstatsPath); err != nil {
		return err
	}

	file, err := os.Open(n.mountstatsPath)
	if err != nil {
		n.Log.Errorf("Failed opening the %q file: %v ", file.Name(), err)
		return err
	}
	defer file.Close()

	scanner := bufio.NewScanner(file)
	if err := n.processText(scanner, acc); err != nil {
		return err
	}

	return scanner.Err()
}

func (n *NFSClient) parseStat(mountpoint, export, version string, line []string, acc telegraf.Accumulator) error {
	tags := map[string]string{"mountpoint": mountpoint, "serverexport": export}
	nline, err := convertToUint64(line)
	if err != nil {
		return err
	}

	if len(nline) == 0 {
		n.Log.Warnf("Parsing Stat line with one field: %s\n", line)
		return nil
	}

	first := strings.Replace(line[0], ":", "", 1)

	var eventsFields = []string{
		"inoderevalidates",
		"dentryrevalidates",
		"datainvalidates",
		"attrinvalidates",
		"vfsopen",
		"vfslookup",
		"vfsaccess",
		"vfsupdatepage",
		"vfsreadpage",
		"vfsreadpages",
		"vfswritepage",
		"vfswritepages",
		"vfsgetdents",
		"vfssetattr",
		"vfsflush",
		"vfsfsync",
		"vfslock",
		"vfsrelease",
		"congestionwait",
		"setattrtrunc",
		"extendwrite",
		"sillyrenames",
		"shortreads",
		"shortwrites",
		"delay",
		"pnfsreads",
		"pnfswrites",
	}

	var bytesFields = []string{
		"normalreadbytes",
		"normalwritebytes",
		"directreadbytes",
		"directwritebytes",
		"serverreadbytes",
		"serverwritebytes",
		"readpages",
		"writepages",
	}

	var xprtudpFields = []string{
		"bind_count",
		"rpcsends",
		"rpcreceives",
		"badxids",
		"inflightsends",
		"backlogutil",
	}

	var xprttcpFields = []string{
		"bind_count",
		"connect_count",
		"connect_time",
		"idle_time",
		"rpcsends",
		"rpcreceives",
		"badxids",
		"inflightsends",
		"backlogutil",
	}

	var nfsopFields = []string{
		"ops",
		"trans",
		"timeouts",
		"bytes_sent",
		"bytes_recv",
		"queue_time",
		"response_time",
		"total_time",
		"errors",
	}

	var fields = make(map[string]interface{})

	switch first {
	case "READ", "WRITE":
		fields["ops"] = nline[0]
		fields["retrans"] = nline[1] - nline[0]
		fields["bytes"] = nline[3] + nline[4]
		fields["rtt"] = nline[6]
		fields["exe"] = nline[7]
		fields["rtt_per_op"] = 0.0
		if nline[0] > 0 {
			fields["rtt_per_op"] = float64(nline[6]) / float64(nline[0])
		}
		tags["operation"] = first
		acc.AddFields("nfsstat", fields, tags)
	}

	if n.Fullstat {
		switch first {
		case "events":
			if len(nline) >= len(eventsFields) {
				for i, t := range eventsFields {
					fields[t] = nline[i]
				}
				acc.AddFields("nfs_events", fields, tags)
			}

		case "bytes":
			if len(nline) >= len(bytesFields) {
				for i, t := range bytesFields {
					fields[t] = nline[i]
				}
				acc.AddFields("nfs_bytes", fields, tags)
			}

		case "xprt":
			if len(line) > 1 {
				switch line[1] {
				case "tcp":
					if len(nline)+2 >= len(xprttcpFields) {
						for i, t := range xprttcpFields {
							fields[t] = nline[i+2]
						}
						acc.AddFields("nfs_xprt_tcp", fields, tags)
					}
				case "udp":
					if len(nline)+2 >= len(xprtudpFields) {
						for i, t := range xprtudpFields {
							fields[t] = nline[i+2]
						}
						acc.AddFields("nfs_xprt_udp", fields, tags)
					}
				}
			}
		}

		if (version == "3" && n.nfs3Ops[first]) || (version == "4" && n.nfs4Ops[first]) {
			tags["operation"] = first
			if len(nline) <= len(nfsopFields) {
				for i, t := range nline {
					fields[nfsopFields[i]] = t
				}
				acc.AddFields("nfs_ops", fields, tags)
			}
		}
	}

	return nil
}

func (n *NFSClient) processText(scanner *bufio.Scanner, acc telegraf.Accumulator) error {
	var mount string
	var version string
	var export string
	var skip bool

	for scanner.Scan() {
		line := strings.Fields(scanner.Text())
		lineLength := len(line)

		if lineLength == 0 {
			continue
		}

		skip = false

		// This denotes a new mount has been found, so set
		// mount and export, and stop skipping (for now)
		if lineLength > 4 && choice.Contains("fstype", line) && (choice.Contains("nfs", line) || choice.Contains("nfs4", line)) {
			mount = line[4]
			export = line[1]
		} else if lineLength > 5 && (choice.Contains("(nfs)", line) || choice.Contains("(nfs4)", line)) {
			version = strings.Split(line[5], "/")[1]
		}

		if mount == "" {
			continue
		}

		// Check include patterns using compiled regex
		if len(n.includeMountRegex) > 0 {
			skip = true
			for _, regex := range n.includeMountRegex {
				if regex.MatchString(mount) {
					skip = false
					break
				}
			}
		}

		// Check exclude patterns using compiled regex
		if !skip && len(n.excludeMountRegex) > 0 {
			for _, regex := range n.excludeMountRegex {
				if regex.MatchString(mount) {
					skip = true
					break
				}
			}
		}

		if !skip {
			err := n.parseStat(mount, export, version, line, acc)
			if err != nil {
				return fmt.Errorf("could not parseStat: %w", err)
			}
		}
	}

	return nil
}

func (n *NFSClient) getMountStatsPath() string {
	path := "/proc/self/mountstats"
	if os.Getenv("MOUNT_PROC") != "" {
		path = os.Getenv("MOUNT_PROC")
	}
	n.Log.Debugf("using [%s] for mountstats", path)
	return path
}

func convertToUint64(line []string) ([]uint64, error) {
	/* A "line" of input data (a pre-split array of strings) is
	   processed one field at a time.  Each field is converted to
	   an uint64 value, and appended to an array of return values.
	   On an error, check for ErrRange, and returns an error
	   if found.  This situation indicates a pretty major issue in
	   the /proc/self/mountstats file, and returning faulty data
	   is worse than no data.  Other errors are ignored, and append
	   whatever we got in the first place (probably 0).
	   Yes, this is ugly. */

	if len(line) < 2 {
		return nil, nil
	}

	nline := make([]uint64, 0, len(line[1:]))
	// Skip the first field; it's handled specially as the "first" variable
	for _, l := range line[1:] {
		val, err := strconv.ParseUint(l, 10, 64)
		if err != nil {
			var numError *strconv.NumError
			if errors.As(err, &numError) {
				if errors.Is(numError.Err, strconv.ErrRange) {
					return nil, fmt.Errorf("errrange: line:[%v] raw:[%v] -> parsed:[%v]", line, l, val)
				}
			}
		}
		nline = append(nline, val)
	}
	return nline, nil
}

func init() {
	inputs.Add("nfsclient", func() telegraf.Input {
		return &NFSClient{}
	})
}
