package golang

import (
	"debug/buildinfo"
	"fmt"
	"io"
	"runtime/debug"
	"strings"

	"github.com/kastenhq/goversion/version"

	"github.com/anchore/syft/internal/log"
	"github.com/anchore/syft/internal/unknown"
	"github.com/anchore/syft/syft/file"
	"github.com/anchore/syft/syft/internal/elfutil"
	"github.com/anchore/syft/syft/internal/unionreader"
)

type extendedBuildInfo struct {
	*debug.BuildInfo
	cryptoSettings []string
	arch           string
	symbols        []binarySymbol
}

// scanFile scans file to try to report the Go and module versions.
func scanFile(location file.Location, reader unionreader.UnionReader, captureSymbols bool) ([]*extendedBuildInfo, error) {
	// NOTE: multiple readers are returned to cover universal binaries, which are files
	// with more than one binary
	readers, errs := unionreader.GetReaders(reader)
	if errs != nil {
		log.WithFields("error", errs).Debug("failed to open a golang binary")
		return nil, fmt.Errorf("failed to open a golang binary: %w", errs)
	}

	var builds []*extendedBuildInfo
	for _, r := range readers {
		bi, err := getBuildInfo(r, location)
		if err != nil {
			log.WithFields("file", location.RealPath, "error", err).Trace("unable to read golang buildinfo")

			continue
		}
		// it's possible the reader just isn't a go binary, in which case just skip it
		if bi == nil {
			continue
		}

		v, err := getCryptoInformation(r)
		if err != nil {
			log.WithFields("file", location.RealPath, "error", err).Trace("unable to read golang version info")
			// don't skip this build info.
			// we can still catalog packages, even if we can't get the crypto information
			errs = unknown.Appendf(errs, location, "unable to read golang version info: %w", err)
		}
		v = append(v, getNativeFIPSSettings(bi.Settings)...)
		arch := getGOARCH(bi.Settings)
		if arch == "" {
			arch, err = getGOARCHFromBin(r)
			if err != nil {
				log.WithFields("file", location.RealPath, "error", err).Trace("unable to read golang arch info")
				// don't skip this build info.
				// we can still catalog packages, even if we can't get the arch information
				errs = unknown.Appendf(errs, location, "unable to read golang arch info: %w", err)
			}
		}

		var symbols []binarySymbol
		if captureSymbols {
			symbols, err = getSymbols(r)
			if err != nil {
				log.WithFields("file", location.RealPath, "error", err).Trace("unable to read golang symbol info")
				// don't skip this build info.
				// we can still catalog packages, even if we can't get the symbol information
				errs = unknown.Appendf(errs, location, "unable to read golang symbol info: %w", err)
			}
		}

		builds = append(builds, &extendedBuildInfo{BuildInfo: bi, cryptoSettings: v, arch: arch, symbols: symbols})
	}
	return builds, errs
}

func getCryptoInformation(reader io.ReaderAt) ([]string, error) {
	v, err := version.ReadExeFromReader(reader)
	if err != nil {
		return nil, err
	}

	return getCryptoSettingsFromVersion(v), nil
}

func getCryptoSettingsFromVersion(v version.Version) []string {
	cryptoSettings := []string{}
	if v.StandardCrypto {
		cryptoSettings = append(cryptoSettings, "standard-crypto")
	}
	if v.BoringCrypto {
		cryptoSettings = append(cryptoSettings, "boring-crypto")
	}
	if v.FIPSOnly {
		cryptoSettings = append(cryptoSettings, "crypto/tls/fipsonly")
	}
	return cryptoSettings
}

func getNativeFIPSSettings(settings []debug.BuildSetting) []string {
	var cryptoSettings []string
	for _, s := range settings {
		switch s.Key {
		case "GOFIPS140":
			if s.Value != "" {
				cryptoSettings = append(cryptoSettings, "GOFIPS140="+s.Value)
			}
		case "DefaultGODEBUG":
			for _, kv := range strings.Split(s.Value, ",") {
				if setting, val, ok := strings.Cut(kv, "="); ok && setting == "fips140" {
					cryptoSettings = append(cryptoSettings, "GODEBUG=fips140="+val)
				}
			}
		}
	}
	return cryptoSettings
}

// readBuildInfo bounds the reader before handing it to debug/buildinfo, which opens ELF files with
// debug/elf itself rather than through elfutil. elf.NewFile expands the section-name string table as it
// parses, so an unbounded read here is reachable no matter how little of the file buildinfo goes on to
// look at.
func readBuildInfo(r io.ReaderAt) (*debug.BuildInfo, error) {
	if err := elfutil.CheckSectionNameTable(r); err != nil {
		return nil, err
	}
	return buildinfo.Read(r)
}

func getBuildInfo(r io.ReaderAt, location file.Location) (bi *debug.BuildInfo, err error) {
	defer func() {
		if r := recover(); r != nil {
			// this can happen in cases where a malformed binary is passed in can be initially parsed, but not
			// used without error later down the line. This is the case with :
			// https://github.com/llvm/llvm-project/blob/llvmorg-15.0.6/llvm/test/Object/Inputs/macho-invalid-dysymtab-bad-size
			err = fmt.Errorf("recovered from panic: %v", r)
		}
	}()

	// try to read buildinfo from the binary directly
	bi, err = readBuildInfo(r)
	if err == nil {
		return bi, nil
	}

	// if direct read fails and this looks like a UPX-compressed binary,
	// try to decompress and read the buildinfo from the decompressed data
	if isUPXCompressed(r) {
		log.WithFields("path", location.RealPath).Trace("detected UPX-compressed Go binary, attempting decompression to read the build info")
		decompressed, decompErr := decompressUPX(r)
		if decompErr == nil {
			bi, err = readBuildInfo(decompressed)
			if err == nil {
				return bi, nil
			}
		}
	}

	// note: the stdlib does not export the error we need to check for
	if err != nil {
		if err.Error() == "not a Go executable" {
			// since the cataloger can only select executables and not distinguish if they are a go-compiled
			// binary, we should not show warnings/logs in this case. For this reason we nil-out err here.
			err = nil
			return bi, err
		}
		// in this case we could not read the or parse the file, but not explicitly because it is not a
		// go-compiled binary (though it still might be).
		return bi, err
	}
	return bi, err
}
