/*
* ZDNS Copyright 2024 Regents of the University of Michigan
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied. See the License for the specific language governing
* permissions and limitations under the License.
 */
package cli

import (
	"context"
	"encoding/csv"
	"encoding/json"
	"fmt"
	"io"
	"math/rand"
	"net"
	"os"
	"os/signal"
	"runtime"
	"slices"
	"strconv"
	"strings"
	"sync"
	"syscall"
	"time"

	"github.com/zmap/zcrypto/x509"
	"golang.org/x/time/rate"

	"github.com/hashicorp/go-version"
	"github.com/liip/sheriff"
	"github.com/pkg/errors"
	log "github.com/sirupsen/logrus"
	"github.com/zmap/dns"

	"github.com/zmap/zdns/v2/src/cli/iohandlers"
	blacklist "github.com/zmap/zdns/v2/src/internal/safeblacklist"
	"github.com/zmap/zdns/v2/src/internal/util"
	"github.com/zmap/zdns/v2/src/zdns"
)

type routineMetadata struct {
	Names   int // number of domain names processed
	Lookups int // number of lookups performed
	Status  map[zdns.Status]int
}

type Metadata struct {
	Names           int                           `json:"names"`
	Lookups         int                           `json:"lookups"`
	Status          map[string]int                `json:"statuses"`
	StartTime       string                        `json:"start_time"`
	EndTime         string                        `json:"end_time"`
	NameServers     []string                      `json:"name_servers"`
	Timeout         int                           `json:"timeout"`
	Retries         int                           `json:"retries"`
	Conf            *CLIConf                      `json:"conf"`
	ZDNSVersion     string                        `json:"zdns_version"`
	CacheStatistics *zdns.CacheStatisticsMetadata `json:"cache_statistics,omitempty"`
}

func populateCLIConfig(gc *CLIConf) *CLIConf {
	if gc.LogFilePath != "" && gc.LogFilePath != "-" {
		f, err := os.OpenFile(gc.LogFilePath, os.O_WRONLY|os.O_CREATE, util.DefaultFilePermissions)
		if err != nil {
			log.Fatalf("Unable to open log file (%s): %s", gc.LogFilePath, err.Error())
		}
		log.SetOutput(f)
	}

	// Translate the assigned verbosity level to a logrus log level.
	logLevel := log.InfoLevel
	switch gc.Verbosity {
	case 1: // Fatal
		logLevel = log.FatalLevel
	case 2: // Error
		logLevel = log.ErrorLevel
	case 3: // Warnings  (default)
		logLevel = log.WarnLevel
	case 4: // Information
		logLevel = log.WarnLevel
	case 5: // Debugging
		logLevel = log.DebugLevel
	default:
		log.Fatal("Unknown verbosity level specified. Must be between 1 (lowest)--5 (highest)")
	}
	log.SetLevel(logLevel)

	// complete post facto global initialization based on command line arguments

	// class initialization
	switch strings.ToUpper(gc.ClassString) {
	case "INET", "IN":
		gc.Class = dns.ClassINET
	case "CSNET", "CS":
		gc.Class = dns.ClassCSNET
	case "CHAOS", "CH":
		gc.Class = dns.ClassCHAOS
	case "HESIOD", "HS":
		gc.Class = dns.ClassHESIOD
	case "NONE":
		gc.Class = dns.ClassNONE
	case "ANY":
		gc.Class = dns.ClassANY
	default:
		log.Fatal("Unknown record class specified. Valid valued are INET (default), CSNET, CHAOS, HESIOD, NONE, ANY")
	}

	err := populateNetworkingConfig(gc)
	if err != nil {
		log.Fatalf("could not populate networking config: %v", err)
	}

	if gc.UseNanoseconds {
		gc.TimeFormat = time.RFC3339Nano
	} else {
		gc.TimeFormat = time.RFC3339
	}
	if gc.GoMaxProcs < 0 {
		log.Fatal("Invalid argument for --go-processes. Must be >1.")
	}

	if gc.GoMaxProcs != 0 {
		runtime.GOMAXPROCS(gc.GoMaxProcs)
	}

	// the margin for limit of open files covers different build platforms (linux/darwin), metadata files, or
	// input and output files etc.
	// check ulimit if value is high enough and if not, try to fix it
	ulimitCheck(uint64(gc.Threads + 100))

	if gc.UDPOnly && gc.TCPOnly {
		log.Fatal("TCP Only and UDP Only are conflicting")
	}
	if gc.NameServerMode && gc.AlexaFormat {
		log.Fatal("Alexa mode is incompatible with name server mode")
	}
	if gc.NameServerMode && gc.MetadataFormat {
		log.Fatal("Metadata mode is incompatible with name server mode")
	}
	if gc.NameServerMode && gc.ZoneFileFormat {
		log.Fatal("Zone file mode is incompatible with name server mode")
	}
	if gc.ZoneFileIncludeTargets && !gc.ZoneFileFormat {
		log.Fatal("--zone-file-include-targets can only be used with --zone-file")
	}
	if gc.ZoneFileFormat && gc.AlexaFormat {
		log.Fatal("Zone file mode is incompatible with Alexa format")
	}
	if gc.ZoneFileFormat && gc.MetadataFormat {
		log.Fatal("Zone file mode is incompatible with metadata format")
	}
	if gc.NameServerMode && gc.NameOverride == "" && gc.CLIModule != BINDVERSION {
		log.Fatal("Static Name must be defined with --override-name in --name-server-mode unless DNS module does not expect names (e.g., BINDVERSION).")
	}
	// Output Groups are defined by a base + any additional fields that the user wants
	groups := strings.Split(gc.IncludeInOutput, ",")
	if gc.ResultVerbosity != "short" && gc.ResultVerbosity != "normal" && gc.ResultVerbosity != "long" && gc.ResultVerbosity != "trace" {
		log.Fatal("Invalid result verbosity. Options: short, normal, long, trace")
	}

	gc.OutputGroups = append(gc.OutputGroups, gc.ResultVerbosity)
	gc.OutputGroups = append(gc.OutputGroups, groups...)

	// setup i/o if not specified
	if len(GC.Domains) > 0 {
		// using domains from command line
		gc.InputHandler = iohandlers.NewStringSliceInputHandler(GC.Domains)
	} else if gc.InputHandler == nil {
		if gc.ZoneFileFormat {
			gc.InputHandler = iohandlers.NewZoneFileInputHandler(gc.InputFilePath, gc.ZoneFileIncludeTargets)
		} else {
			gc.InputHandler = iohandlers.NewFileInputHandler(gc.InputFilePath)
		}
	}
	if gc.OutputHandler == nil {
		gc.OutputHandler = iohandlers.NewFileOutputHandler(gc.OutputFilePath)
	}
	if gc.StatusHandler == nil {
		gc.StatusHandler = iohandlers.NewStatusHandler(gc.StatusUpdatesFilePath)
	}
	return gc
}

func populateResolverConfig(gc *CLIConf) *zdns.ResolverConfig {
	config := zdns.NewResolverConfig()

	config.TransportMode = zdns.GetTransportMode(gc.UDPOnly, gc.TCPOnly)
	config.DNSOverHTTPS = gc.DNSOverHTTPS
	config.DNSOverTLS = gc.DNSOverTLS
	config.VerifyServerCert = gc.VerifyServerCert

	// Read in the CA file if it exists
	if gc.RootCAsFile != "" {
		fd, err := os.Open(gc.RootCAsFile)
		if err != nil {
			log.Fatalf("Could not open root CA file: %v", err)
		}
		caBytes, readErr := io.ReadAll(fd)
		if readErr != nil {
			log.Fatalf("Could not read root CA file: %v", readErr)
		}
		config.RootCAs = x509.NewCertPool()
		ok := config.RootCAs.AppendCertsFromPEM(caBytes)
		if !ok {
			log.Fatalf("Could not read certificates from PEM file. Invalid PEM?")
		}
	}

	config.Timeout = time.Second * time.Duration(gc.Timeout)
	config.NetworkTimeout = time.Second * time.Duration(gc.NetworkTimeout)
	config.IterativeTimeout = time.Second * time.Duration(gc.IterationTimeout)
	perIPRate := rate.Limit(gc.PerIPNSRateLimit)
	// In --help we say that rate = 0 means infinite rate
	if perIPRate == 0 {
		perIPRate = rate.Inf
	}
	perNameRate := rate.Limit(gc.PerNameNSRateLimit)
	if perNameRate == 0 {
		perNameRate = rate.Inf
	}
	config.RateLimiter = zdns.NewNameServerRateLimiter(perIPRate, perNameRate)
	config.LookupAllNameServers = gc.LookupAllNameServers || gc.AllNameServersAllIPs
	config.AllNameServersAllIPs = gc.AllNameServersAllIPs
	config.FollowCNAMEs = !gc.DisableFollowCNAMEs // ZFlags only allows default-false bool flags. We'll invert here.

	if gc.UseNSID {
		config.EdnsOptions = append(config.EdnsOptions, new(dns.EDNS0_NSID))
	}
	if gc.ClientSubnet != nil {
		config.EdnsOptions = append(config.EdnsOptions, gc.ClientSubnet)
	}
	config.Cache = new(zdns.Cache)
	config.Cache.Init(gc.CacheSize)
	if gc.Verbosity >= 5 {
		config.Cache.Stats.CaptureStatistics()
	}
	config.Retries = gc.Retries
	config.MaxDepth = gc.MaxDepth
	config.CheckingDisabledBit = gc.CheckingDisabled
	config.ShouldRecycleSockets = !gc.DisableRecycleSockets

	config.ShouldValidateDNSSEC = gc.ValidateDNSSEC
	if config.ShouldValidateDNSSEC {
		config.DNSSecEnabled = true
		if !gc.IterativeResolution {
			log.Fatal("DNSSEC validation is only supported with iterative resolution")
		}
	} else {
		config.DNSSecEnabled = gc.Dnssec
	}

	config.DNSConfigFilePath = gc.DNSConfigFilePath

	config.LogLevel = log.Level(gc.Verbosity)

	if gc.BlacklistFilePath != "" {
		config.Blacklist = blacklist.New()
		if err := config.Blacklist.ParseFromFile(gc.BlacklistFilePath); err != nil {
			log.Fatal("unable to parse blacklist file: ", err)
		}
	}
	// This must occur after setting the DNSConfigFilePath above, so that ZDNS knows where to fetch the DNS Config
	config, err := populateIPTransportMode(gc, config)
	if err != nil {
		log.Fatal("could not populate IP transport mode: ", err)
	}
	config, _, err = populateLocalAddresses(gc, config)
	if err != nil {
		log.Fatal("could not populate local addresses: ", err)
	}
	// This is used in extractAuthorities where we need to know whether to request A or AAAA records to continue iteration
	// Must be set after populating IPTransportMode
	if config.IPVersionMode == zdns.IPv4Only {
		config.IterationIPPreference = zdns.PreferIPv4
	} else if config.IPVersionMode == zdns.IPv6Only {
		config.IterationIPPreference = zdns.PreferIPv6
	} else if config.IPVersionMode == zdns.IPv4OrIPv6 && !gc.PreferIPv4Iteration && !gc.PreferIPv6Iteration {
		config.IterationIPPreference = zdns.NoPreference
	} else if config.IPVersionMode == zdns.IPv4OrIPv6 && gc.PreferIPv4Iteration && gc.PreferIPv6Iteration {
		log.Fatal("Cannot specify both --prefer-ipv4-iteration and --prefer-ipv6-iteration")
	} else {
		config.IterationIPPreference = zdns.GetIterationIPPreference(gc.PreferIPv4Iteration, gc.PreferIPv6Iteration)
	}
	// This must occur after setting IPTransportMode, so that ZDNS knows whether to use IPv4 or IPv6 nameservers
	config, err = populateNameServers(gc, config)
	if err != nil {
		log.Fatal("could not populate name servers: ", err)
	}
	// If --verify-server-cert is set, all nameservers must have a domain name
	if config.VerifyServerCert {
		for _, ns := range slices.Concat(config.ExternalNameServersV4, config.RootNameServersV4, config.ExternalNameServersV6, config.RootNameServersV6) {
			if len(ns.DomainName) == 0 {
				log.Fatal("All name servers must have domain names when using --verify-server-cert, specify --name-servers=domain1,domain2 and ZDNS will resolve the domain to a name server IP")
			}
		}
	}
	if config.IPVersionMode == zdns.IPv4Only {
		// Drop any IPv6 nameservers
		config.ExternalNameServersV6 = []zdns.NameServer{}
		config.RootNameServersV6 = []zdns.NameServer{}
	}
	if config.IPVersionMode == zdns.IPv6Only {
		// Drop any IPv4 nameservers
		config.ExternalNameServersV4 = []zdns.NameServer{}
		config.RootNameServersV4 = []zdns.NameServer{}
	}
	noV4NameServers := len(config.ExternalNameServersV4) == 0 && len(config.RootNameServersV4) == 0
	if gc.IPv4TransportOnly && noV4NameServers {
		log.Fatal("cannot use --4 since no IPv4 nameservers found, ensure you have IPv4 connectivity and provide --name-servers")
	}
	noV6NameServers := len(config.ExternalNameServersV6) == 0 && len(config.RootNameServersV6) == 0
	if gc.IPv6TransportOnly && noV6NameServers {
		log.Fatal("cannot use --6 since no IPv6 nameservers found, ensure you have IPv6 connectivity and provide --name-servers")
	}

	return config
}

// populateIPTransportMode populates the IPTransportMode field of the ResolverConfig
// If user sets --4 (IPv4 Only) or --6 (IPv6 Only), we'll set the IPVersionMode to IPv4Only or IPv6Only, respectively.
// If user does not set --4 or --6, we'll determine the IPVersionMode based on:
//  1. the provided name-servers (if any)
//  2. the IP mode of the user's provided local addresses (if any)
//  3. the OS' default resolvers (if no name-servers provided)
func populateIPTransportMode(gc *CLIConf, config *zdns.ResolverConfig) (*zdns.ResolverConfig, error) {
	if gc.IPv4TransportOnly && gc.IPv6TransportOnly {
		return nil, errors.New("only one of --4 and --6 allowed")
	}
	if gc.IPv4TransportOnly {
		config.IPVersionMode = zdns.IPv4Only
		return config, nil
	}
	if gc.IPv6TransportOnly {
		config.IPVersionMode = zdns.IPv6Only
		return config, nil
	}
	// User did not specify IPv4 or IPv6 only transport, so attempt to determine the IPVersionMode based on the nameservers
	log.Info("User didn't specify IPv4 or IPv6 support. In this case, ZDNS will attempt to determine what mode is desired based on the IP version mode of:")
	log.Info(" 1. the provided --name-servers (if any)")
	log.Info(" 2. the provided --local-addr (if any)")
	log.Info(" 3. the addresses in the OS' default resolver config file (typically /etc/resolv.conf)")
	var nses []zdns.NameServer
	var err error
	var ipv4NSStrings, ipv6NSStrings []string
	var nameServersSupportIPv4, nameServersSupportIPv6 bool
	ipOnlyNSes := removeDomainsFromNameServersString(gc.NameServersString)
	// User can provide name servers as either IPs, IP+Port, or domain name
	// For the purposes of determining what IP mode the user's host supports, we'll only consider IPs or IP+Port
	// Domains could have either A or AAAA and that tells us nothing about the host's IPv4/6 capabilities
	if gc.NameServersString != "" && len(ipOnlyNSes) > 0 {
		// User provided name servers, so we can determine the IPVersionMode based on the provided name servers
		nses, err = convertNameServerStringSliceToNameServers(ipOnlyNSes, zdns.IPv4OrIPv6, config.DNSOverTLS, config.DNSOverHTTPS)
		if err != nil {
			return nil, fmt.Errorf("could not parse name servers from --name-server: %v", err)
		}
		for _, ns := range nses {
			if ns.IP.To4() != nil {
				nameServersSupportIPv4 = true
			} else if util.IsIPv6(&ns.IP) {
				nameServersSupportIPv6 = true
			} else {
				log.Fatal("invalid name server: ", ns.String())
			}
		}
		if !nameServersSupportIPv4 && !nameServersSupportIPv6 {
			return nil, errors.New("no nameservers found. Please specify desired nameservers with --name-servers")
		}
		config.IPVersionMode = zdns.GetIPVersionMode(nameServersSupportIPv4, nameServersSupportIPv6)
		log.Infof("user didn't prescribe a specific IP version mode (IPv4/v6), but did provide nameservers. Inferred IP mode - %s", config.IPVersionMode.String())
		return config, nil
	}
	// Check local addresses if provided
	var didSet bool
	config, didSet, err = populateLocalAddresses(gc, config)
	if err != nil {
		log.Fatal("could not populate local addresses: ", err)
	}
	if didSet {
		haveLocalIPv4Addr, haveLocalIPv6Addr := len(config.LocalAddrsV4) > 0, len(config.LocalAddrsV6) > 0
		config.IPVersionMode = zdns.GetIPVersionMode(haveLocalIPv4Addr, haveLocalIPv6Addr)
		log.Infof("user provided local addresses: %v and therefore inferred using IP mode - %s", slices.Concat(config.LocalAddrsV4, config.LocalAddrsV6), config.IPVersionMode.String())
		return config, nil
	}
	// check OS' default resolver(s) to determine if we support IPv4 or IPv6
	ipv4NSStrings, ipv6NSStrings, err = zdns.GetDNSServers(config.DNSConfigFilePath)
	if err != nil {
		log.Fatalf("ZDNS is unable to parse resolvers file. ZDNS only supports IPv4 and IPv6 addresses with an optional port, "+
			" either 111.222.333.444:9953 or [1111:2222::3333]:9953. "+
			"Please either modify your %s file or use '--name-servers'. Error: %v", config.DNSConfigFilePath, err)
	}
	if len(ipv4NSStrings) == 0 && len(ipv6NSStrings) == 0 {
		return nil, errors.New("no nameservers found with OS defaults. Please specify desired nameservers with --name-servers")
	}
	if len(ipv4NSStrings) > 0 {
		nameServersSupportIPv4 = true
	}
	if len(ipv6NSStrings) > 0 {
		nameServersSupportIPv6 = true
	}
	config.IPVersionMode = zdns.GetIPVersionMode(nameServersSupportIPv4, nameServersSupportIPv6)
	log.Infof("inferred IP mode from OS default resolvers file: %s", config.IPVersionMode.String())
	return config, nil
}

func populateNameServers(gc *CLIConf, config *zdns.ResolverConfig) (*zdns.ResolverConfig, error) {
	// Nameservers are populated in this order:
	// 1. If user provided nameservers, use those
	// 2. (External Only and NOT --name-server-mode) If we can get the OS' default recursive resolver nameservers, use those
	// 3. Use ZDNS defaults

	// Additionally, both Root and External nameservers must be populated, since the Resolver doesn't know we'll only
	// be performing either iterative or recursive lookups, not both.

	// IPv4 Name Servers/Local Address only needs to be populated if we're doing IPv4 lookups, same for IPv6
	if len(gc.NameServers) != 0 {
		// User provided name servers, use them.
		var err error
		config, err = useNameServerStringToPopulateNameServers(gc.NameServers, config)
		if err != nil {
			return nil, fmt.Errorf("could not populate name servers: %v", err)
		}
		if config.DNSOverHTTPS {
			// double-check all external nameservers have domains, necessary for DoH
			for _, ns := range slices.Concat(config.ExternalNameServersV4, config.ExternalNameServersV6) {
				if len(ns.DomainName) == 0 {
					log.Fatal("DoH requires domain names for all name servers, ex. --name-servers=cloudflare-dns.com,dns.google")
				}
			}
		}
		return config, nil
	}
	// User did not provide nameservers
	if gc.DNSOverTLS {
		config.RootNameServersV4 = zdns.DefaultExternalDoTResolversV4
		config.ExternalNameServersV4 = zdns.DefaultExternalDoTResolversV4
		config.RootNameServersV6 = zdns.DefaultExternalDoTResolversV6
		config.ExternalNameServersV6 = zdns.DefaultExternalDoTResolversV6
		return config, nil
	}
	if gc.DNSOverHTTPS {
		defaultDoHNameServers := []string{zdns.CloudflareDoHDomainName, zdns.GoogleDoHDomainName}
		return useNameServerStringToPopulateNameServers(defaultDoHNameServers, config)
	}
	if !gc.IterativeResolution && !gc.NameServerMode {
		// Try to get the OS' default recursive resolver nameservers
		var v4NameServers, v6NameServers []zdns.NameServer
		v4NameServerStrings, v6NameServersStrings, err := zdns.GetDNSServers(config.DNSConfigFilePath)
		if err != nil {
			v4NameServers, v6NameServers = zdns.DefaultExternalResolversV4, zdns.DefaultExternalResolversV6
			log.Warn("Unable to parse resolvers file. Using ZDNS defaults")
		} else {
			// convert string slices to NameServers
			v4NameServers, err = convertNameServerStringSliceToNameServers(v4NameServerStrings, config.IPVersionMode, config.DNSOverTLS, config.DNSOverHTTPS)
			if err != nil {
				return nil, fmt.Errorf("could not convert IPv4 nameservers %s to NameServers: %v", strings.Join(v4NameServerStrings, ", "), err)
			}
			v6NameServers, err = convertNameServerStringSliceToNameServers(v6NameServersStrings, config.IPVersionMode, config.DNSOverTLS, config.DNSOverHTTPS)
			if err != nil {
				return nil, fmt.Errorf("could not convert IPv6 nameservers %s to NameServers: %v", strings.Join(v6NameServersStrings, ", "), err)
			}
		}
		// Check that we have the nameservers we need to support lookups
		if len(v4NameServers) == 0 && config.IPVersionMode.SupportsIPv4() {
			log.Infof("no IPv4 nameservers found with OS defaults and it was detected the user wanted IPv4 support. Using ZDNS defaults for IPv4")
			v4NameServers = zdns.DefaultExternalResolversV4
		}
		if len(v6NameServers) == 0 && config.IPVersionMode.SupportsIPv6() {
			log.Infof("no IPv6 nameservers found with OS defaults and it was detected the user wanted IPv6 support. Using ZDNS defaults for IPv6")
			v6NameServers = zdns.DefaultExternalResolversV6
		}
		// The resolver will ignore IPv6 nameservers if we're doing IPv4 only lookups, and vice versa so this is fine
		config.ExternalNameServersV4 = v4NameServers
		config.RootNameServersV4 = v4NameServers
		config.ExternalNameServersV6 = v6NameServers
		config.RootNameServersV6 = v6NameServers

		return config, nil
	}
	// User did not provide nameservers and we're doing iterative resolution, use ZDNS defaults
	config.ExternalNameServersV4 = zdns.RootServersV4[:]
	config.RootNameServersV4 = zdns.RootServersV4[:]
	config.ExternalNameServersV6 = zdns.RootServersV6[:]
	config.RootNameServersV6 = zdns.RootServersV6[:]
	return config, nil
}

func useNameServerStringToPopulateNameServers(nameServers []string, config *zdns.ResolverConfig) (*zdns.ResolverConfig, error) {
	var v4NameServers, v6NameServers []zdns.NameServer
	nses, err := convertNameServerStringSliceToNameServers(nameServers, config.IPVersionMode, config.DNSOverTLS, config.DNSOverHTTPS)
	if err != nil {
		return nil, fmt.Errorf("could not parse name server: %v. Correct IPv4 format: 1.1.1.1:53 or IPv6 format: [::1]:53\"", err)
	}
	for _, ns := range nses {
		if ns.IP.To4() != nil {
			v4NameServers = append(v4NameServers, ns)
		} else if util.IsIPv6(&ns.IP) {
			v6NameServers = append(v6NameServers, ns)
		} else {
			log.Fatal("Invalid name server: ", ns.String())
		}
	}
	// The resolver will ignore IPv6 nameservers if we're doing IPv4 only lookups, and vice versa so this is fine
	config.ExternalNameServersV4 = v4NameServers
	config.RootNameServersV4 = v4NameServers
	config.ExternalNameServersV6 = v6NameServers
	config.RootNameServersV6 = v6NameServers
	return config, nil
}

// populateLocalAddresses takes the local addr string from the CLI and splits it into IPv4 and v6 local addrs
// Idempotent - it is safe to call repeatedly
func populateLocalAddresses(gc *CLIConf, config *zdns.ResolverConfig) (*zdns.ResolverConfig, bool, error) {
	// Local Addresses are populated in this order:
	// 1. If user provided local addresses, use those
	// 2. If user does not provide local addresses, one will be used on-demand by Resolver. See resolver.go:getConnectionInfo for more info
	var didSet bool

	if len(gc.LocalAddrs) != 0 {
		// if user provided a local address(es), that takes precedent
		config.LocalAddrsV4, config.LocalAddrsV6 = []net.IP{}, []net.IP{}
		for _, addr := range gc.LocalAddrs {
			if addr == nil {
				return nil, false, errors.New("invalid nil local address")
			}
			if addr.To4() != nil {
				config.LocalAddrsV4 = append(config.LocalAddrsV4, addr)
			} else if util.IsIPv6(&addr) {
				config.LocalAddrsV6 = append(config.LocalAddrsV6, addr)
			} else {
				return nil, false, fmt.Errorf("invalid local address: %s", addr.String())
			}
			didSet = true
		}
	}
	return config, didSet, nil
}

func Run(gc CLIConf) {
	// Create a context that is cancelled on SIGINT or SIGTERM.
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	gc = *populateCLIConfig(&gc)
	resolverConfig := populateResolverConfig(&gc)
	// Log any information about the resolver configuration, according to log level
	resolverConfig.PrintInfo()
	err := resolverConfig.Validate()
	if err != nil {
		log.Fatalf("resolver config did not pass validation: %v", err)
	}
	for _, module := range gc.ActiveModules {
		// init all modules
		err = module.CLIInit(&gc, resolverConfig)
		if err != nil {
			log.Fatalf("could not initialize lookup module (type: %s): %v", gc.CLIModule, err)
		}
	}
	// DoLookup:
	//	- n threads that do processing from in and place results in out
	//	- process until inChan closes, then wg.done()
	// Once we processing threads have all finished, wait until the
	// output and metadata threads have completed
	inChan := make(chan string)                        // input handler feeds inChan with input
	outChan := make(chan string)                       // lookup workers write to outChan, output handler reads from outChan
	metaChan := make(chan routineMetadata, gc.Threads) // lookup workers write to metaChan, metadata is collected at end of scan
	statusChan := make(chan zdns.Status)               // lookup workers write to status chan after each lookup, status handler reads from statusChan
	statusAbortChan := make(chan struct{})             // used by this thread to signal to the status handler that we've aborted

	sigs := make(chan os.Signal, 1)
	signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)

	go func() {
		<-sigs                        // SIGINT or SIGTERM received
		cancel()                      // signal to all goroutines to clean up
		statusAbortChan <- struct{}{} // signal to status handler that we've aborted so it will print a suitable message
		// to the user vs. the 'Scan Complete' it normally prints
	}()
	var routineWG sync.WaitGroup

	inHandler := gc.InputHandler
	if inHandler == nil {
		log.Fatal("Input handler is nil")
	}

	outHandler := gc.OutputHandler
	if outHandler == nil {
		log.Fatal("Output handler is nil")
	}

	statusHandler := gc.StatusHandler
	if statusHandler == nil {
		log.Fatal("Status handler is nil")
	}

	// Use handlers to populate the input and output/results channel
	go func() {
		if inErr := inHandler.FeedChannel(ctx, inChan, &routineWG); inErr != nil {
			log.Fatal(fmt.Sprintf("could not feed input channel: %v", inErr))
		}
	}()

	go func() {
		if outErr := outHandler.WriteResults(outChan, &routineWG); outErr != nil {
			log.Fatal(fmt.Sprintf("could not write output results from output channel: %v", outErr))
		}
	}()
	routineWG.Add(2) // input and output handlers

	if !gc.QuietStatusUpdates {
		go func() {
			if statusErr := statusHandler.LogPeriodicUpdates(statusChan, statusAbortChan, &routineWG); statusErr != nil {
				log.Fatal(fmt.Sprintf("could not log periodic status updates: %v", statusErr))
			}
		}()
		routineWG.Add(1) // status handler
	}

	// create pool of worker goroutines
	var lookupWG sync.WaitGroup
	lookupWG.Add(gc.Threads)
	startTime := time.Now().Format(gc.TimeFormat)
	// create shared cache for all threads to share
	for i := 0; i < gc.Threads; i++ {
		i := i
		go func(threadID int) {
			initWorkerErr := doLookupWorker(ctx, &gc, resolverConfig, inChan, outChan, metaChan, statusChan, &lookupWG)
			if initWorkerErr != nil {
				log.Fatalf("could not start lookup worker #%d: %v", i, initWorkerErr)
			}
		}(i)
	}
	lookupWG.Wait()
	close(outChan)
	close(metaChan)
	close(statusChan)
	close(statusAbortChan)
	routineWG.Wait()
	if gc.MetadataFilePath != "" {
		// we're done processing data. aggregate all the data from individual routines
		metaData := aggregateMetadata(metaChan)
		if resolverConfig.Cache.Stats.ShouldCaptureStatistics() {
			// we only capture cache statistics in verbosity=5 to prevent unnecessary overhead
			metaData.CacheStatistics = resolverConfig.Cache.Stats.GetStatistics()
		}
		metaData.StartTime = startTime
		metaData.EndTime = time.Now().Format(gc.TimeFormat)
		metaData.NameServers = gc.NameServers
		metaData.Retries = gc.Retries
		// Seconds() returns a float. However, timeout is passed in as an integer
		// command line argument, so there should be no loss of data when casting
		// back to an integer here.
		metaData.Timeout = gc.Timeout
		metaData.Conf = &gc
		// add global lookup-related metadata
		// write out metadata
		var f *os.File
		if gc.MetadataFilePath == "-" {
			f = os.Stderr
		} else {
			f, err = os.OpenFile(gc.MetadataFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, util.DefaultFilePermissions)
			if err != nil {
				log.Fatalf("unable to open metadata file: %v", err)
			}
			defer func(f *os.File) {
				err = f.Close()
				if err != nil {
					log.Errorf("unable to close metadata file: %v", err)
				}
			}(f)
		}
		j, err := json.Marshal(metaData)
		if err != nil {
			log.Fatal("unable to JSON encode metadata:", err.Error())
		}
		_, err = f.WriteString(string(j))
		if err != nil {
			log.Errorf("unable to write metadata with error: %v", err)
		}
	}
}

// doLookupWorker is a single worker thread that processes lookups from the input channel. It calls wg.Done when it is finished.
func doLookupWorker(ctx context.Context, gc *CLIConf, rc *zdns.ResolverConfig, inputChan <-chan string, outputChan chan<- string, metaChan chan<- routineMetadata, statusChan chan<- zdns.Status, wg *sync.WaitGroup) error {
	defer wg.Done()
	resolver, err := zdns.InitResolver(rc)
	if err != nil {
		return fmt.Errorf("could not init resolver: %w", err)
	}
	defer resolver.Close() // close the resolver, freeing up resources
	var metadata routineMetadata
	metadata.Status = make(map[zdns.Status]int)

	for line := range inputChan {
		if util.HasCtxExpired(ctx) {
			break
		}
		handleWorkerInput(ctx, gc, rc, line, resolver, &metadata, outputChan, statusChan)
	}
	metaChan <- metadata
	return nil
}

func handleWorkerInput(ctx context.Context, gc *CLIConf, rc *zdns.ResolverConfig, line string, resolver *zdns.Resolver, metadata *routineMetadata, outputChan chan<- string, statusChan chan<- zdns.Status) {
	// we'll process each module sequentially, parallelism is per-domain
	res := zdns.Result{Results: make(map[string]zdns.SingleModuleResult, len(gc.ActiveModules))}
	// get the fields that won't change for each lookup module
	rawName := ""
	var nameServer *zdns.NameServer
	var nameServers []zdns.NameServer
	nameServerString := ""
	inputTriggers := make(map[string]struct{})
	var rank int
	var entryMetadata string
	var err error
	if gc.AlexaFormat {
		rawName, rank = parseAlexa(line)
		res.AlexaRank = rank
	} else if gc.MetadataFormat {
		rawName, entryMetadata = parseMetadataInputLine(line)
		res.Metadata = entryMetadata
	} else if gc.NameServerMode {
		nameServers, err = convertNameServerStringToNameServer(line, rc.IPVersionMode, rc.DNSOverTLS, rc.DNSOverHTTPS)
		if err != nil {
			log.Fatal("unable to parse name server: ", line)
		}
		if len(nameServers) == 0 {
			log.Fatal("no name servers found in line: ", line)
		}
		// if user provides a domain name for the name server (one.one.one.one) we'll pick one of the IPs at random
		nameServer = &nameServers[rand.Intn(len(nameServers))]
	} else {
		var inputTriggersSlice []string
		rawName, nameServerString, inputTriggersSlice = parseNormalInputLine(line)
		if len(nameServerString) != 0 {
			nameServers, err = convertNameServerStringToNameServer(nameServerString, rc.IPVersionMode, rc.DNSOverTLS, rc.DNSOverHTTPS)
			if err != nil {
				log.Fatal("unable to parse name server: ", line)
			}
			if len(nameServers) == 0 {
				log.Fatal("no name servers found in line: ", line)
			}
			// if user provides a domain name for the name server (one.one.one.one) we'll pick one of the IPs at random
			nameServer = &nameServers[rand.Intn(len(nameServers))]
		}
		if len(inputTriggersSlice) > 0 {
			for _, t := range inputTriggersSlice {
				// build set of input triggers for lookup
				inputTriggers[t] = struct{}{}
			}
		}
	}
	res.Name = rawName
	// handle per-module lookups
	for moduleName, module := range gc.ActiveModules {
		if len(inputTriggers) > 0 {
			if _, ok := inputTriggers[module.GetTrigger()]; !ok {
				// this module was not triggered for this input line, skip
				continue
			}
		}
		var innerRes any
		var trace zdns.Trace
		var status zdns.Status
		var err error
		var changed bool
		var lookupName string
		lookupName, changed = makeName(rawName, gc.NamePrefix, gc.NameOverride)
		if changed {
			res.AlteredName = lookupName
		}
		res.Class = dns.Class(gc.Class).String()

		startTime := time.Now()
		innerRes, trace, status, err = module.Lookup(ctx, resolver, lookupName, nameServer)

		lookupRes := zdns.SingleModuleResult{
			Timestamp: time.Now().Format(gc.TimeFormat),
			Duration:  time.Since(startTime).Seconds(),
		}
		if status != zdns.StatusNoOutput {
			lookupRes.Status = string(status)
			lookupRes.Data = innerRes
			lookupRes.Trace = trace
			if err != nil {
				lookupRes.Error = err.Error()
			}
			res.Results[moduleName] = lookupRes
			if !gc.QuietStatusUpdates {
				statusChan <- status
			}
		}
		metadata.Status[status]++
		metadata.Lookups++
	}
	if len(res.Results) > 0 {
		v, _ := version.NewVersion("0.0.0")
		o := &sheriff.Options{
			Groups:          gc.OutputGroups,
			ApiVersion:      v,
			IncludeEmptyTag: true,
		}
		data, err := sheriff.Marshal(o, res)
		if err != nil {
			log.Fatalf("unable to marshal result to JSON: %v", err)
		}
		cleansedData := replaceIntSliceInterface(data)
		jsonRes, err := json.Marshal(cleansedData)
		if err != nil {
			log.Fatalf("unable to marshal JSON result: %v", err)
		}
		outputChan <- string(jsonRes)
	}
	metadata.Names++
}

func parseAlexa(line string) (string, int) {
	s := strings.SplitN(line, ",", 2)
	rank, err := strconv.Atoi(s[0])
	if err != nil {
		log.Fatal("Malformed Alexa Top Million file")
	}
	return s[1], rank
}

func parseMetadataInputLine(line string) (string, string) {
	s := strings.SplitN(line, ",", 2)
	if len(s) == 1 {
		return s[0], ""
	}
	return s[0], s[1]
}

func parseNormalInputLine(line string) (name, nameserver string, triggers []string) {
	r := csv.NewReader(strings.NewReader(line))
	s, err := r.Read()
	if err != nil || len(s) == 0 {
		return line, "", []string{}
	}
	for i := range s {
		s[i] = strings.TrimSpace(s[i])
	}
	switch len(s) {
	case 1:
		return s[0], "", []string{}
	case 2:
		return s[0], s[1], []string{}
	default:
		return s[0], s[1], s[2:]
	}
}

func makeName(name, prefix, nameOverride string) (string, bool) {
	if nameOverride != "" {
		return nameOverride, true
	}
	trimmedName := strings.TrimSuffix(name, ".")
	if prefix == "" {
		return trimmedName, name != trimmedName
	} else {
		return strings.Join([]string{prefix, trimmedName}, ""), true
	}
}

func aggregateMetadata(c <-chan routineMetadata) Metadata {
	var meta Metadata
	meta.ZDNSVersion = zdns.ZDNSVersion
	meta.Status = make(map[string]int)
	for m := range c {
		meta.Names += m.Names
		meta.Lookups += m.Lookups
		for k, v := range m.Status {
			meta.Status[string(k)] += v
		}
	}
	return meta
}

func convertNameServerStringSliceToNameServers(nameServerStrings []string, mode zdns.IPVersionMode, usingDoT, usingDoH bool) ([]zdns.NameServer, error) {
	nameServers := make([]zdns.NameServer, 0, len(nameServerStrings))
	for _, ns := range nameServerStrings {
		parsedNSes, err := convertNameServerStringToNameServer(ns, mode, usingDoT, usingDoH)
		if err != nil {
			return nil, fmt.Errorf("could not parse name server %s: %v", ns, err)
		}
		nameServers = append(nameServers, parsedNSes...)
	}
	return nameServers, nil
}

func convertNameServerStringToNameServer(inaddr string, mode zdns.IPVersionMode, usingDoT, usingDoH bool) ([]zdns.NameServer, error) {
	host, port, err := util.SplitHostPort(inaddr)
	if err == nil && host != nil {
		return []zdns.NameServer{{IP: host, Port: uint16(port)}}, nil
	}

	// may be a port-less IP
	ip := net.ParseIP(inaddr)
	if ip != nil {
		ns := zdns.NameServer{IP: ip}
		ns.PopulateDefaultPort(usingDoT, usingDoH)
		return []zdns.NameServer{ns}, nil
	}

	// may be the domain name of a name server (one.one.one.one)
	// we'll add these prefixes back on later, stripping so we can detect ports
	inaddr = strings.TrimPrefix(inaddr, "https://")
	inaddr = strings.TrimPrefix(inaddr, "http://")
	domainAndPort := strings.Split(inaddr, ":")
	port = 0
	if len(domainAndPort) == 2 {
		// domain name with port (one.one.one.one:53)
		port, err = strconv.Atoi(domainAndPort[1])
		if err != nil {
			return nil, fmt.Errorf("invalid port: %s", inaddr)
		}
	}
	ips, err := net.LookupIP(domainAndPort[0])
	if err != nil {
		return nil, fmt.Errorf("could not resolve name server: %s", inaddr)
	}
	nses := make([]zdns.NameServer, 0, len(ips))
	for _, resolvedIP := range ips {
		isIPv6AndCanUseIPv6 := util.IsIPv6(&resolvedIP) && mode != zdns.IPv4Only
		isIPv4AndCanUseIPv4 := resolvedIP.To4() != nil && mode != zdns.IPv6Only
		if isIPv4AndCanUseIPv4 || isIPv6AndCanUseIPv6 {
			ns := zdns.NameServer{IP: resolvedIP, Port: uint16(port), DomainName: domainAndPort[0]}
			ns.PopulateDefaultPort(usingDoT, usingDoH)
			nses = append(nses, ns)
		}
	}
	return nses, nil
}

func removeDomainsFromNameServersString(nameServersString string) []string {
	// User can provide name servers as either IPs, IP+Port, or domain name
	// For the purposes of determining what IP mode the user's host supports, we'll only consider IPs or IP+Port
	// Domains could have either A or AAAA and that tells us nothing about the host's IPv4/6 capabilities
	// We'll remove any domains from the name servers string
	nses := strings.Split(nameServersString, ",")
	ipOnlyNSes := make([]string, 0, len(nses))
	for _, ns := range nses {
		if net.ParseIP(ns) != nil {
			ipOnlyNSes = append(ipOnlyNSes, ns)
		} else if ip, _, err := net.SplitHostPort(ns); err == nil && net.ParseIP(ip) != nil {
			ipOnlyNSes = append(ipOnlyNSes, ns)
		}
		// else this must be a domain name
	}
	return ipOnlyNSes
}
