// This file is part of the adequate Debian-native package, and is available
// under the Expat license. For the full terms please see debian/copyright.

package main

import (
	"fmt"
	"log"
	"os"
	osUser "os/user"
	"path"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"syscall"
)

const (
	BIN_OR_SBIN_BINARY_REQUIRES_USR_LIB_LIBRARY_TAG = "bin-or-sbin-binary-requires-usr-lib-library" // obsolete given usrmerge
	UNDEFINED_SYMBOL_TAG                            = "undefined-symbol"
	SYMBOL_SIZE_MISMATCH_TAG                        = "symbol-size-mismatch"
	MISSING_SYMBOL_VERSION_INFORMATION_TAG          = "missing-symbol-version-information"
	LIBRARY_NOT_FOUND_TAG                           = "library-not-found"
	INCOMPATIBLE_LICENSES_TAG                       = "incompatible-licenses"
	LDD_FAILURE_TAG                                 = "ldd-failure"
)

var elfTags = []string{
	BIN_OR_SBIN_BINARY_REQUIRES_USR_LIB_LIBRARY_TAG,
	UNDEFINED_SYMBOL_TAG,
	SYMBOL_SIZE_MISMATCH_TAG,
	MISSING_SYMBOL_VERSION_INFORMATION_TAG,
	LIBRARY_NOT_FOUND_TAG,
	INCOMPATIBLE_LICENSES_TAG,
	LDD_FAILURE_TAG,
}

type elfErr struct {
	tag              string
	pkg              string
	path             string
	symbol           string
	lib              string
	licenseConflicts string
	err              error
}

func (c elfErr) Error() string {
	switch c.tag {
	case LDD_FAILURE_TAG:
		return fmt.Sprintf("%s: %s => %s (%s)", c.pkg, c.tag, c.symbol, c.err)
	case UNDEFINED_SYMBOL_TAG:
		// libparted-fs-resize0:amd64: undefined-symbol /lib/x86_64-linux-gnu/libparted-fs-resize.so.0.0.4 => ptt_geom_clear_sectors
		var optionalLib string
		if c.lib != "" {
			optionalLib = " (" + c.lib + ")"
		}
		return fmt.Sprintf("%s: %s %s => %s%s", c.pkg, c.tag, c.path, c.symbol, optionalLib)
	case SYMBOL_SIZE_MISMATCH_TAG:
		return fmt.Sprintf("%s: %s %s => %s", c.pkg, c.tag, c.path, c.symbol)
	case MISSING_SYMBOL_VERSION_INFORMATION_TAG:
		return fmt.Sprintf("%s: %s %s => %s", c.pkg, c.tag, c.path, c.lib)
	case LIBRARY_NOT_FOUND_TAG:
		return fmt.Sprintf("%s: %s %s => %s", c.pkg, c.tag, c.path, c.lib)
	case INCOMPATIBLE_LICENSES_TAG:
		return fmt.Sprintf("%s: %s %s %s", c.pkg, c.tag, c.path, c.licenseConflicts)
	default:
		log.Fatal("Invalid elf error tag: ", c.tag)
		return "" // will never get here
	}
}

type elfChecker struct {
	tagsToEmit      map[string]bool
	doNotEmitAnyTag bool
	ids             lddIDs
}

func newElfChecker(tags tagFilter, ids lddIDs) elfChecker {
	emit := make(map[string]bool)
	var emitAny bool
	for _, t := range elfTags {
		emit[t] = tags.shouldEmit(t)
		if tags.shouldEmit(t) {
			emitAny = true
		}
	}
	return elfChecker{
		tagsToEmit:      emit,
		doNotEmitAnyTag: !emitAny,
		ids:             ids,
	}
}

func (cc elfChecker) check(pkg2files map[string][]string) []elfErr {
	if cc.doNotEmitAnyTag {
		return nil
	}

	clearLDDenvOrDie()
	applyIDsOrDie(cc.ids)

	dirsToCheck := map[string]bool{
		"/bin":       true,
		"/sbin":      true,
		"/usr/bin":   true,
		"/usr/games": true,
		"/usr/sbin":  true,
	}
	out, err := runCommand([]string{"/sbin/ldconfig", "-p"})
	if err != nil {
		log.Fatal("ldconfig: ", err)
	}

	// sample input:
	// 	libxml2.so.2 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxml2.so.2
	// sample output:
	//	/lib/x86_64-linux-gnu
	ldcRE := regexp.MustCompile(spaceToken + "[(]libc[^)]+[)]" + spaceToken + "=>" + spaceToken + "(" + nonSpaceToken + ")[/][^/]+$")
	for _, line := range out {
		if m := ldcRE.FindStringSubmatch(line); len(m) == 2 {
			dirsToCheck[m[1]] = true
		}
	}

	// sample input:
	//	/lib64/ld-linux-x86-64.so.2
	dynLinkerRE := regexp.MustCompile("/lib[0-9]*/.*ld(?:-.*)[.]so(?:$|[.])")
	path2pkg := make(map[string]string)
	for pkg, files := range pkg2files {
		for _, path := range files {
			dir := filepath.Dir(path)

			if !dirsToCheck[dir] ||
				dynLinkerRE.MatchString(path) {
				continue
			}

			// Skip unless path is a readable file.
			fi, err := os.OpenFile(path, os.O_RDONLY, 0)
			if err != nil {
				continue
			}
			fi.Close()

			// If it's a symlink, does it point to an interesting directory?
			if isPathSymlink(path) {
				destPath, err := os.Readlink(path)
				if err != nil {
					continue
				}
				if !dirsToCheck[filepath.Dir(destPath)] {
					continue
				}

			}
			path2pkg[path] = pkg
		}
	}

	var tags []elfErr
	undefSymRE := regexp.MustCompile("^undefined symbol:" + spaceToken +
		"(" + nonSpaceToken + ")(?:," + spaceToken + "version" + spaceToken +
		"(" + nonSpaceToken + "))?" + spaceToken + "[(](" + nonSpaceToken + ")[)]$")

	linkTimeRefRE := regexp.MustCompile("^symbol (" + nonSpaceToken + "), version (" +
		nonSpaceToken + ") not defined in file (" + nonSpaceToken +
		") with link time reference" + spaceToken + "[(](" + nonSpaceToken + ")[)]")

	symSizeMismatchRE := regexp.MustCompile("^(" + nonSpaceToken + "): Symbol `(" + nonSpaceToken +
		")' has different size in shared object, consider re-linking$")

	// sample input:
	//	/usr/bin/adequate-test-msvi: /lib/libadequate-test-versionless.so.0: no version information available (required by /usr/bin/adequate-test-msvi)
	missingSymbolVerRE := regexp.MustCompile("^(" + nonSpaceToken + "): (" + nonSpaceToken +
		"): no version information available [(]required by (" + nonSpaceToken + ")[)]$")
	missingLibRE := regexp.MustCompile("^[\t ]*(" + nonSpaceToken + ") => not found$")
	libthreadRE := regexp.MustCompile(`libthread_db\.so(\.[0-9.]+)?$`)
	soNamePathRE := regexp.MustCompile("(" + nonSpaceToken + ") => (" + nonSpaceToken + ") [(]0x[0-9a-f]+[)]$")

	dep5Licenses := loadPkgLicenses(mapStrKeys(pkg2files))
	type conflictKey struct {
		pkg            string
		depends        map[string]bool
		soNameLicenses string
	}
	licenseConflicts := make(map[string]conflictKey)
	for path, pkg := range path2pkg {
		if is, err := isDir(path); err == nil && is {
			continue
		} else if err != nil {
			log.Printf("W: failed to stat %q: %s", path, err)
			continue
		}
		output, err := runCommand([]string{"ldd", "-r", path})
		if e := strings.TrimSpace(strings.Join(output, " ")); e == "statically linked" || e == "not a dynamic executable" {
			continue
		}
		depends := make(map[string]bool)
		var soNameLicenses []soNameLicense
		licenseIDproduct := licenseID(-1)
		license, ok := dep5Licenses[pkg]
		if ok {
			licenseIDproduct = license.id()
			soNameLicenses = []soNameLicense{
				{soName: "", license: license},
			}
		}

		if err != nil {
			if !cc.tagsToEmit[LDD_FAILURE_TAG] {
				continue
			}

			tags = append(tags, elfErr{
				tag:  LDD_FAILURE_TAG,
				pkg:  pkg,
				path: path,
				err:  err,
			})
			continue
		}

		for _, line := range output {
			line = strings.TrimSpace(line)
			if m := undefSymRE.FindStringSubmatch(line); len(m) == 4 {
				if !cc.tagsToEmit[UNDEFINED_SYMBOL_TAG] {
					continue
				}
				symbol := m[1]
				if m[2] != "" {
					symbol = m[1] + "@" + m[2]
				}
				triggeringPath := m[3]
				switch {
				case (strings.Contains(path, "python3") || strings.Contains(path, "py3")) &&
					(strings.HasPrefix(symbol, "_Py") || strings.HasPrefix(symbol, "Py")):
					continue
				case strings.Contains(path, "perl") &&
					strings.HasPrefix(symbol, "Perl_") || strings.HasPrefix(symbol, "PL_"):
					continue
				case strings.Contains(path, "liblua") &&
					(strings.HasPrefix(symbol, "luaL_") || strings.HasPrefix(symbol, "lua_")):
					continue
				case libthreadRE.MatchString(path) && strings.HasPrefix(symbol, "ps_"):
					continue
				}

				augmentedPath := augmentPath(path, triggeringPath, dirsToCheck)
				if augmentedPath == "" {
					continue
				}

				tags = append(tags, elfErr{
					tag:    UNDEFINED_SYMBOL_TAG,
					pkg:    pkg,
					path:   augmentedPath,
					symbol: symbol,
				})
				continue
			}
			if m := linkTimeRefRE.FindStringSubmatch(line); len(m) == 4 {
				if !cc.tagsToEmit[UNDEFINED_SYMBOL_TAG] {
					continue
				}
				symbol := m[1] + "@" + m[2]
				lib := m[3]
				triggeringPath := m[4]
				augmentedPath := augmentPath(path, triggeringPath, dirsToCheck)
				if augmentedPath == "" {
					continue
				}

				tags = append(tags, elfErr{
					tag:    UNDEFINED_SYMBOL_TAG,
					pkg:    pkg,
					path:   augmentedPath,
					symbol: symbol,
					lib:    lib,
				})
				continue
			}
			if m := symSizeMismatchRE.FindStringSubmatch(line); len(m) == 3 {
				if !cc.tagsToEmit[SYMBOL_SIZE_MISMATCH_TAG] {
					continue
				}
				if m[1] != path {
					continue
				}
				symbol := m[2]
				tags = append(tags, elfErr{
					tag:    SYMBOL_SIZE_MISMATCH_TAG,
					pkg:    pkg,
					path:   path,
					symbol: symbol,
				})
				continue
			}
			if m := missingSymbolVerRE.FindStringSubmatch(line); len(m) == 4 {
				if !cc.tagsToEmit[MISSING_SYMBOL_VERSION_INFORMATION_TAG] {
					continue
				}
				path = m[1]
				lib := m[2]
				triggeringPath := m[3]
				augmentedPath := augmentPath(path, triggeringPath, dirsToCheck)
				if augmentedPath == "" {
					continue
				}

				tags = append(tags, elfErr{
					tag:  MISSING_SYMBOL_VERSION_INFORMATION_TAG,
					pkg:  pkg,
					path: augmentedPath,
					lib:  lib,
				})
				continue
			}
			if m := missingLibRE.FindStringSubmatch(line); len(m) == 2 {
				if !cc.tagsToEmit[LIBRARY_NOT_FOUND_TAG] {
					continue
				}
				lib := m[1]
				tags = append(tags, elfErr{
					tag:  LIBRARY_NOT_FOUND_TAG,
					pkg:  pkg,
					path: path,
					lib:  lib,
				})
				continue
			}
			if m := soNamePathRE.FindStringSubmatch(line); len(m) == 3 {
				if !cc.tagsToEmit[INCOMPATIBLE_LICENSES_TAG] {
					continue
				}
				soName, soPath := m[1], m[2]
				realSoPath := realPath(soPath)
				if dirsToCheck[filepath.Dir(realSoPath)] {
					depends[realSoPath] = true
				}
				license := soname2license[soName]
				if license.undefined() {
					// This may yield false positives as
					// soname2license rots.
					// TODO: add a tunable to optionally log
					// unknown licenses.
					continue
				}
				newLincenseIDproduct := licenseIDproduct & license.id()
				if licenseIDproduct != newLincenseIDproduct {
					soNameLicenses = append(soNameLicenses,
						soNameLicense{
							soName:  soName,
							license: license,
						},
					)
				}
				licenseIDproduct = newLincenseIDproduct
				if licenseIDproduct == 0 {
					// Don't emit incompatible-licenses tag yet, because
					// the conflict might have been caused by one of the
					// dependencies.
					var ss []string
					for _, lic := range soNameLicenses {
						ss = append(ss, lic.String())
					}
					licenseConflicts[path] = conflictKey{
						pkg,
						depends,
						strings.Join(ss, " + "),
					}

				}

			}
		}
	}

	dependencyLicenses := make(map[string]licenseID)
	for _, soPaths := range licenseConflicts {
		for soPath := range soPaths.depends {
			dependencyLicenses[soPath] = -1
		}
	}
	for path := range dependencyLicenses {
		lines, err := runCommand([]string{"/bin/ldd", path})
		if err != nil {
			log.Fatal("ldd: ", err)
		}

		for _, line := range lines {
			if m := soNamePathRE.FindStringSubmatch(line); len(m) == 3 {
				soName := m[1]
				license := soname2license[soName]
				if license == unknownLicense {
					continue
				}
				dependencyLicenses[path] &= license.id()
			}

		}
	}
file:
	for path, conflictKey := range licenseConflicts {
		for soPath := range conflictKey.depends {
			if dependencyLicenses[soPath] == 0 {
				continue file
			}
		}
		tags = append(tags, elfErr{
			tag:              INCOMPATIBLE_LICENSES_TAG,
			pkg:              conflictKey.pkg,
			path:             path,
			licenseConflicts: conflictKey.soNameLicenses,
		})
	}

	return tags
}

type lddIDs struct {
	uid int
	gid int
}

type idSpecErr struct {
	user  string
	group string
}

func (u idSpecErr) Error() string {
	if u.user != "" {
		return fmt.Sprintf("%q: no such user", u.user)
	}
	if u.group != "" {
		return fmt.Sprintf("%q: no such group", u.group)
	}
	return "invalid user/group specification"
}

func newLDDids(spec string) (lddIDs, error) {
	var ids lddIDs
	if spec == "" {
		return ids, nil
	}

	var user, group string
	tokens := strings.Split(spec, ":")
	switch n := len(tokens); {
	case n == 1:
		user = tokens[0]
	case n == 2 && spec != ":":
		user = tokens[0]
		group = tokens[1]
	default:
		return ids, idSpecErr{}
	}

	if user != "" {
		u, err := osUser.Lookup(user)
		if err != nil {
			return ids, idSpecErr{user: user}
		}
		n, err := strconv.Atoi(u.Uid)
		if err != nil {
			return lddIDs{}, fmt.Errorf("failed to parse uid %q: %w", u, err)
		}
		ids.uid = n
	}
	if group != "" {
		g, err := osUser.LookupGroup(group)
		if err != nil {
			return ids, idSpecErr{group: group}
		}
		n, err := strconv.Atoi(g.Gid)
		if err != nil {
			return lddIDs{}, fmt.Errorf("failed to parse gid %q: %w", g, err)
		}
		ids.gid = n
	}

	return ids, nil
}

func clearLDDenvOrDie() {
	for _, s := range os.Environ() {
		k := strings.Split(s, "=")[0]
		if !strings.HasPrefix(k, "LD_") {
			continue
		}
		if err := os.Unsetenv(k); err != nil {
			log.Fatal("Failed to clear environment variable ", k)
		}
	}
}

// parseCopyrightFiles returns a map of pkg to license.
func parseCopyrightFiles(pkgs []string) map[string]string {
	pkg2license := make(map[string]string)

	return pkg2license
}

func augmentPath(origPath, trigPath string, dirs map[string]bool) string {
	if origPath == trigPath {
		return trigPath
	}

	// Verify that file can be stat'ed ...
	fi, err := os.Lstat(trigPath)
	if os.IsPermission(err) || err != nil {
		log.Fatal("stat: ", err)
	}
	dstAbs := trigPath
	// ... and resolve if it's a symlink.
	if isSymlink(fi) {
		dst, err := os.Readlink(trigPath)
		if os.IsPermission(err) || err != nil {
			log.Fatal("readlink: ", err)
		}

		if filepath.IsAbs(dst) {
			dstAbs = dst
		} else {
			// Symlink targets are relative to the directory containing the link.
			dstAbs = filepath.Join(path.Dir(trigPath), dst)
		}
		// If the symlink target is still in an “interesting” directory,
		// then any issue hopefully will be reported against another
		// package.
		if dirs[path.Dir(dstAbs)] {
			return ""
		}
	}
	return fmt.Sprintf("%s => %s", origPath, dstAbs)
}

func applyIDsOrDie(want lddIDs) {
	if want.uid == 0 && want.gid == 0 {
		return
	}

	if gotUid := os.Geteuid(); gotUid != want.uid {
		if err := syscall.Setuid(want.uid); err != nil {
			log.Fatal("Could not set uid to ", want.uid)
		}
		if id := os.Geteuid(); id != want.uid {
			log.Fatal("Setuid() returned no error but also had no effect!")
		}
	}

	if gotGid := os.Getegid(); gotGid != want.gid {
		if err := syscall.Setuid(want.gid); err != nil {
			log.Fatal("Could not set gid to ", want.gid)
		}
		if id := os.Getegid(); id != want.gid {
			log.Fatal("Setgid() returned no error but also had no effect!")
		}
	}
}

// TODO: Drop once maps.Keys() moves to the stdlib.
func mapStrKeys(m map[string][]string) []string {
	var keys []string
	for k := range m {
		keys = append(keys, k)
	}
	return sort.StringSlice(keys)
}
