package snmp

import (
	"bufio"
	"bytes"
	"errors"
	"fmt"
	"os/exec"
	"strings"
	"sync"

	"github.com/influxdata/telegraf"
)

// struct that implements the translator interface. This calls existing
// code to exec netsnmp's snmptranslate program
type netsnmpTranslator struct {
	log telegraf.Logger
}

func NewNetsnmpTranslator(log telegraf.Logger) *netsnmpTranslator {
	return &netsnmpTranslator{log: log}
}

type snmpTableCache struct {
	mibName string
	oidNum  string
	oidText string
	fields  []Field
	err     error
}

// execCommand is so tests can mock out exec.Command usage.
var execCommand = exec.Command

// execCmd executes the specified command, returning the STDOUT content.
// If command exits with error status, the output is captured into the returned error.
func (n *netsnmpTranslator) execCmd(arg0 string, args ...string) ([]byte, error) {
	quoted := make([]string, 0, len(args))
	for _, arg := range args {
		quoted = append(quoted, fmt.Sprintf("%q", arg))
	}
	n.log.Debugf("executing %q %s", arg0, strings.Join(quoted, " "))

	out, err := execCommand(arg0, args...).Output()
	if err != nil {
		var exitErr *exec.ExitError
		if errors.As(err, &exitErr) {
			return nil, fmt.Errorf("%s: %w", bytes.TrimRight(exitErr.Stderr, "\r\n"), err)
		}
		return nil, err
	}
	return out, nil
}

var snmpTableCaches map[string]snmpTableCache
var snmpTableCachesLock sync.Mutex

// snmpTable resolves the given OID as a table, providing information about the
// table and fields within.
//
//nolint:revive //function-result-limit conditionally 5 return results allowed
func (n *netsnmpTranslator) SnmpTable(oid string) (
	mibName string, oidNum string, oidText string,
	fields []Field,
	err error) {
	snmpTableCachesLock.Lock()
	if snmpTableCaches == nil {
		snmpTableCaches = map[string]snmpTableCache{}
	}

	var stc snmpTableCache
	var ok bool
	if stc, ok = snmpTableCaches[oid]; !ok {
		stc.mibName, stc.oidNum, stc.oidText, stc.fields, stc.err = n.snmpTableCall(oid)
		snmpTableCaches[oid] = stc
	}

	snmpTableCachesLock.Unlock()
	return stc.mibName, stc.oidNum, stc.oidText, stc.fields, stc.err
}

//nolint:revive //function-result-limit conditionally 5 return results allowed
func (n *netsnmpTranslator) snmpTableCall(oid string) (
	mibName string, oidNum string, oidText string,
	fields []Field,
	err error) {
	mibName, oidNum, oidText, _, err = n.SnmpTranslate(oid)
	if err != nil {
		return "", "", "", nil, fmt.Errorf("translating: %w", err)
	}

	mibPrefix := mibName + "::"
	oidFullName := mibPrefix + oidText

	// first attempt to get the table's tags
	tagOids := map[string]struct{}{}
	// We have to guess that the "entry" oid is `oid+".1"`. snmptable and snmptranslate don't seem to have a way to provide the info.
	if out, err := n.execCmd("snmptranslate", "-Td", oidFullName+".1"); err == nil {
		scanner := bufio.NewScanner(bytes.NewBuffer(out))
		for scanner.Scan() {
			line := scanner.Text()

			if !strings.HasPrefix(line, "  INDEX") {
				continue
			}

			i := strings.Index(line, "{ ")
			if i == -1 { // parse error
				continue
			}
			line = line[i+2:]
			i = strings.Index(line, " }")
			if i == -1 { // parse error
				continue
			}
			line = line[:i]
			for _, col := range strings.Split(line, ", ") {
				tagOids[mibPrefix+col] = struct{}{}
			}
		}
	}

	// this won't actually try to run a query. The `-Ch` will just cause it to dump headers.
	out, err := n.execCmd("snmptable", "-Ch", "-Cl", "-c", "public", "127.0.0.1", oidFullName)
	if err != nil {
		return "", "", "", nil, fmt.Errorf("getting table columns: %w", err)
	}
	scanner := bufio.NewScanner(bytes.NewBuffer(out))
	scanner.Scan()
	cols := scanner.Text()
	if len(cols) == 0 {
		return "", "", "", nil, errors.New("could not find any columns in table")
	}
	for _, col := range strings.Split(cols, " ") {
		if len(col) == 0 {
			continue
		}
		_, isTag := tagOids[mibPrefix+col]
		fields = append(fields, Field{Name: col, Oid: mibPrefix + col, IsTag: isTag})
	}

	return mibName, oidNum, oidText, fields, err
}

type snmpTranslateCache struct {
	mibName    string
	oidNum     string
	oidText    string
	conversion string
	err        error
}

var snmpTranslateCachesLock sync.Mutex
var snmpTranslateCaches map[string]snmpTranslateCache

// snmpTranslate resolves the given OID.
//
//nolint:revive //function-result-limit conditionally 5 return results allowed
func (n *netsnmpTranslator) SnmpTranslate(oid string) (
	mibName string, oidNum string, oidText string,
	conversion string,
	err error) {
	snmpTranslateCachesLock.Lock()
	if snmpTranslateCaches == nil {
		snmpTranslateCaches = map[string]snmpTranslateCache{}
	}

	var stc snmpTranslateCache
	var ok bool
	if stc, ok = snmpTranslateCaches[oid]; !ok {
		// This will result in only one call to snmptranslate running at a time.
		// We could speed it up by putting a lock in snmpTranslateCache and then
		// returning it immediately, and multiple callers would then release the
		// snmpTranslateCachesLock and instead wait on the individual
		// snmpTranslation.Lock to release. But I don't know that the extra complexity
		// is worth it. Especially when it would slam the system pretty hard if lots
		// of lookups are being performed.

		stc.mibName, stc.oidNum, stc.oidText, stc.conversion, stc.err = n.snmpTranslateCall(oid)
		snmpTranslateCaches[oid] = stc
	}

	snmpTranslateCachesLock.Unlock()

	return stc.mibName, stc.oidNum, stc.oidText, stc.conversion, stc.err
}

//nolint:revive //function-result-limit conditionally 5 return results allowed
func (n *netsnmpTranslator) snmpTranslateCall(oid string) (mibName string, oidNum string, oidText string, conversion string, err error) {
	var out []byte
	if strings.ContainsAny(oid, ":abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") {
		out, err = n.execCmd("snmptranslate", "-Td", "-Ob", oid)
	} else {
		out, err = n.execCmd("snmptranslate", "-Td", "-Ob", "-m", "all", oid)
		var execErr *exec.Error
		if errors.As(err, &execErr) && errors.Is(execErr, exec.ErrNotFound) {
			// Silently discard error if snmptranslate not found and we have a numeric OID.
			// Meaning we can get by without the lookup.
			return "", oid, oid, "", nil
		}
	}
	if err != nil {
		return "", "", "", "", err
	}

	scanner := bufio.NewScanner(bytes.NewBuffer(out))
	ok := scanner.Scan()
	if !ok && scanner.Err() != nil {
		return "", "", "", "", fmt.Errorf("getting OID text: %w", scanner.Err())
	}

	oidText = scanner.Text()

	i := strings.Index(oidText, "::")
	if i == -1 {
		// was not found in MIB.
		if bytes.Contains(out, []byte("[TRUNCATED]")) {
			return "", oid, oid, "", nil
		}
		// not truncated, but not fully found. We still need to parse out numeric OID, so keep going
		oidText = oid
	} else {
		mibName = oidText[:i]
		oidText = oidText[i+2:]
	}

	for scanner.Scan() {
		line := scanner.Text()

		if strings.HasPrefix(line, "  -- TEXTUAL CONVENTION ") {
			tc := strings.TrimPrefix(line, "  -- TEXTUAL CONVENTION ")
			switch tc {
			case "MacAddress", "PhysAddress":
				conversion = "hwaddr"
			case "InetAddressIPv4", "InetAddressIPv6", "InetAddress", "IPSIpAddress":
				conversion = "ipaddr"
			}
		} else if strings.HasPrefix(line, "::= { ") {
			objs := strings.TrimPrefix(line, "::= { ")
			objs = strings.TrimSuffix(objs, " }")

			for _, obj := range strings.Split(objs, " ") {
				if len(obj) == 0 {
					continue
				}
				if i := strings.Index(obj, "("); i != -1 {
					obj = obj[i+1:]
					if j := strings.Index(obj, ")"); j != -1 {
						oidNum += "." + obj[:j]
					} else {
						return "", "", "", "", fmt.Errorf("getting OID number from: %s", obj)
					}

				} else {
					oidNum += "." + obj
				}
			}
			break
		}
	}

	return mibName, oidNum, oidText, conversion, nil
}

func (*netsnmpTranslator) SnmpFormatEnum(string, interface{}, bool) (string, error) {
	return "", errors.New("not implemented in netsnmp translator")
}

func (*netsnmpTranslator) SnmpFormatDisplayHint(string, interface{}) (string, error) {
	return "", errors.New("not implemented in netsnmp translator")
}
