// 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 (
	"errors"
	"flag"
	"fmt"
	"log"
	"os"
	"os/exec"
	"slices"
	"strings"
	"syscall"
)

var (
	allPkgs      = flag.Bool("all", false, "check all installed packages")
	selectedTags = flag.String("tags", "", "comma-separated list of tags to emit; "+
		"prefix with a - to specify tags not to emit; defaults to emitting all tags")
	root           = flag.String("root", "", "switch root directory")
	user           = flag.String("user", "", "--user <user>[:<group>]  switch user and group")
	help           = flag.Bool("help", false, "display help and exit")
	version        = flag.Bool("version", false, "display version and exit")
	listTags       = flag.Bool("list-tags", false, "list all tag names and exit")
	aptPreinstMode = flag.Bool("apt-preinst", false, "(used internally by the APT hook)")
	pendingMode    = flag.Bool("pending", false, "(used internally by the APT hook)")
	debconf        = flag.Bool("debconf", false, "report issues via debconf")
	failMode       = flag.Bool("fail", false, "exit with non-zero code if at least "+
		"one package failed at least one check; ignored when --debconf is set")
	verbose = flag.Bool("verbose", false, "print basic progress indicators")
)

func main() {
	flag.Usage = func() { printUsage(); os.Exit(1) } // called on invalid invocation
	flag.Parse()
	if err := checkArgs(flag.Args()); err != nil {
		fmt.Println(err.Error())
		flag.Usage()
	}

	pkgs := flag.Args()
	var ignoreMissingPackages bool
	switch {
	case *help:
		printUsage()
		os.Exit(0)
	case *version:
		fmt.Printf("adequate %s\n", adequateVersion)
		return
	case *listTags:
		fmt.Println(strings.Join(knownTags(), "\n"))
		return
	case *debconf && !*pendingMode:
		log.Fatal("--debconf is only supported with --pending")
	case *aptPreinstMode:
		stdinReader := aptHookReader()

		switch {
		case *root != "":
			log.Fatal("--apt-preinst and --root cannot be used together")
		case *allPkgs:
			log.Fatal("--apt-preinst and --all cannot be used together")
		case *pendingMode:
			log.Fatal("--apt-preinst and --pending cannot be used together")
		case len(flag.Args()) > 0:
			log.Fatalf("Too many arguments")
		case !aptHookIsEnabled(stdinReader):
			return
		}
		ph, err := newPendingHandler()
		if err != nil {
			log.Fatal(err.Error())
		}

		pkgs = readPkgs(stdinReader)
		ph.writePendingOrDie(pkgs)
		ph.cleanupOrDie()
		return
	case *pendingMode:
		if *debconf && !slices.Contains(os.Environ(), "DEBIAN_HAS_FRONTEND=1") {

			args := []string{"-oadequate", "--", "/usr/bin/adequate",
				"--pending"}
			if *debconf {
				args = append(args, "--debconf")
			}
			if *verbose {
				args = append(args, "--verbose")
			}
			if *user != "" {
				args = append(args, []string{"--user", *user}...)
			}

			cmd := &exec.Cmd{
				Path:   "/usr/bin/debconf",
				Args:   args,
				Stdin:  os.Stdin,
				Stdout: os.Stdout,
				Stderr: os.Stderr,
				SysProcAttr: &syscall.SysProcAttr{
					Setsid: true,
				},
			}
			if err := cmd.Run(); err != nil {
				// This is typically due to stdout not being an
				// interactive terminal.
				fmt.Fprintf(os.Stderr, "adequate: failed to run debconf frontend: %v\n", err)
			}
			os.Exit(0)
		}

		ph, err := newPendingHandler()
		if err != nil {
			log.Fatalf("Failed to open: %v", err)
		}

		pkgs, err = ph.read()
		if err != nil {
			log.Fatalf("Failed to read: %v", err)
		}
		ph.truncatePendingOrDie()
		ph.cleanupOrDie()

		if len(pkgs) == 0 {
			return
		}
		ignoreMissingPackages = true
	case len(pkgs) == 0 && !*allPkgs:
		log.Fatal("No packages to check.")
	case len(pkgs) > 0 && *allPkgs:
		log.Fatal("Too many arguments.")
	case len(invalidPkgs(pkgs)) > 0:
		log.Fatal("Invalid package name(s): ", strings.Join(invalidPkgs(pkgs), " "))
	}

	ids, err := newLDDids(*user)
	if err != nil {
		log.Fatalf(err.Error())
	}

	chroot(*root)

	tags, err := newTagFilter(*selectedTags)
	if err != nil {
		log.Fatalf("Invalid --tags value(s): %s", err)
	}

	// Augment pkgs with provider packages (unclear whether this actually
	// makes a difference), and eliminate duplicates and ambiguous package
	// names (see uniqArchFreePkgs() on what the latter means) before dpkg
	// invocations.
	pkgs = uniqArchFreePkgs(pkgs)
	pkgProvides := packageProvides(ignoreMissingPackages, pkgs)
	for p := range pkgProvides {
		pkgs = append(pkgs, p)
	}
	pkgs = uniqArchFreePkgs(pkgs)

	pkgFiles, totalFiles, err := packageFiles(pkgs)
	if err != nil {
		log.Fatal("Failed to get package files: ", err)
	}

	if err := os.Setenv("LC_ALL", "C"); err != nil {
		log.Fatal("Failed to set LC_ALL: ", err)
	}

	s := sink{seen: make(map[string]bool)}
	maybeLogProgress(BROKEN_SYMLINK_TAG)
	s.add(mapTags(newBrokenSymlinkChecker(tags).check(pkgFiles)))

	maybeLogProgress(NAME_COLLISION_TAG)
	s.add(mapTags(newCollisionChecker(tags).check(pkgFiles)))

	maybeLogProgress(MISSING_COPYRIGHT_FILE_TAG)
	s.add(mapTags(newCopyrightChecker(tags).check(pkgs)))

	maybeLogProgress(OBSOLETE_CONFFILE_TAG)
	s.add(mapTags(newPackageConffileChecker(tags).check(pkgs)))

	maybeLogProgress(MISSING_PKGCONF_DEPENDENCY_TAG)
	s.add(mapTags(newPkgconfDepChecker(tags).check(pkgFiles)))

	maybeLogProgress(PYFILE_NOT_BYTECOMPILED_TAG)
	s.add(mapTags(newPyfileChecker(tags).check(pkgFiles)))

	maybeLogProgress(MISSING_ALTERNATIVE_TAG)
	s.add(mapTags(newAltChecker(tags).check(pkgProvides, pkgFiles)))

	maybeLogProgress("binfmt")
	s.add(mapTags(newBinfmtChecker(tags).check(pkgFiles)))

	maybeLogProgress("system ids")
	s.add(mapTags(newSystemIDChecker(tags).check(pkgFiles)))

	maybeLogProgress(fmt.Sprintf("%d files for elf-related", totalFiles))
	s.add(mapTags(newElfChecker(tags, ids).check(pkgFiles)))

	if s.empty() {
		os.Exit(0)
	}

	if *debconf {
		tellDebconf(s.tags)
	} else {
		for _, t := range s.tags {
			fmt.Println(t)
		}
		if *failMode {
			os.Exit(2)
		}
	}
}

func mapTags[T error](errs []T) []string {
	var strs []string
	for _, e := range errs {
		strs = append(strs, e.Error())
	}
	return strs
}

type sink struct {
	seen map[string]bool
	tags []string
}

func (s *sink) add(tags []string) {
	for _, t := range tags {
		if s.seen[t] {
			continue
		}
		s.tags = append(s.tags, t)
		s.seen[t] = true
	}
}

func (s *sink) empty() bool {
	return len(s.tags) == 0
}

func chroot(dir string) {
	if dir == "" {
		return
	}
	if err := syscall.Chroot(dir); err != nil {
		log.Fatalf("chroot failed: %v", err)
	}
}

func printUsage() {
	fmt.Println(`Usage:

  adequate [options] <package-name>...
  adequate [options] --all
  adequate [options] --apt-preinst
  adequate [options] --pending
  adequate --help

Options:`)
	// Make PrintDefaults() print to stdout and not return with
	// non-zero status (which the adequate apt hook relies on).
	flag.CommandLine.SetOutput(os.Stdout)
	flag.PrintDefaults()
}

func invalidPkgs(pkgs []string) []string {
	var inv []string
	for _, p := range pkgs {
		if strings.HasPrefix(p, "--") {
			inv = append(inv, p)
		}
	}
	return inv
}

func checkArgs(args []string) error {
	flagMode := true
	for _, a := range args {
		if flagMode && a[0] != '-' {
			flagMode = false
		} else if !flagMode && a[0] == '-' {
			return errors.New("command line options must appear before packages")
		}
	}
	return nil
}

// uniqArchFreePkgs eliminates from the passed package slice, any duplicates and
// any arch-free packages for which an arch-specific one exists.
func uniqArchFreePkgs(pkgs []string) []string {
	existsWithArch := make(map[string]bool)
	for _, p := range pkgs {
		if i := strings.Index(p, ":"); i != -1 {
			existsWithArch[p[:i]] = true
		}
	}

	var newPkgs []string
	seen := make(map[string]bool)
	for _, p := range pkgs {
		if !existsWithArch[p] && !seen[p] {
			newPkgs = append(newPkgs, p)
			seen[p] = true
		}
	}

	return newPkgs
}

func maybeLogProgress(msg string) {
	if *verbose {
		log.Printf("Checking %s tags ...", msg)
	}
}

const adequateVersion = "dummy-adequate-version"
