// Package home contains AdGuard Home's HTTP API methods.
package home

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"io/fs"
	"net"
	"net/http"
	"net/http/pprof"
	"net/url"
	"os"
	"os/signal"
	"path/filepath"
	"runtime"
	"sync"
	"syscall"
	"time"

	"github.com/AdguardTeam/AdGuardHome/internal/aghnet"
	"github.com/AdguardTeam/AdGuardHome/internal/aghos"
	"github.com/AdguardTeam/AdGuardHome/internal/dhcpd"
	"github.com/AdguardTeam/AdGuardHome/internal/dnsforward"
	"github.com/AdguardTeam/AdGuardHome/internal/filtering"
	"github.com/AdguardTeam/AdGuardHome/internal/querylog"
	"github.com/AdguardTeam/AdGuardHome/internal/stats"
	"github.com/AdguardTeam/AdGuardHome/internal/updater"
	"github.com/AdguardTeam/AdGuardHome/internal/version"
	"github.com/AdguardTeam/golibs/errors"
	"github.com/AdguardTeam/golibs/log"
	"gopkg.in/natefinch/lumberjack.v2"
)

const (
	// Used in config to indicate that syslog or eventlog (win) should be used for logger output
	configSyslog = "syslog"
)

// Global context
type homeContext struct {
	// Modules
	// --

	clients    clientsContainer          // per-client-settings module
	stats      stats.Stats               // statistics module
	queryLog   querylog.QueryLog         // query log module
	dnsServer  *dnsforward.Server        // DNS module
	rdns       *RDNS                     // rDNS module
	whois      *WHOIS                    // WHOIS module
	dnsFilter  *filtering.DNSFilter      // DNS filtering module
	dhcpServer *dhcpd.Server             // DHCP module
	auth       *Auth                     // HTTP authentication module
	filters    Filtering                 // DNS filtering module
	web        *Web                      // Web (HTTP, HTTPS) module
	tls        *TLSMod                   // TLS module
	etcHosts   *aghnet.EtcHostsContainer // IP-hostname pairs taken from system configuration (e.g. /etc/hosts) files
	updater    *updater.Updater

	subnetDetector *aghnet.SubnetDetector

	// mux is our custom http.ServeMux.
	mux *http.ServeMux

	// Runtime properties
	// --

	configFilename   string // Config filename (can be overridden via the command line arguments)
	workDir          string // Location of our directory, used to protect against CWD being somewhere else
	firstRun         bool   // if set to true, don't run any services except HTTP web inteface, and serve only first-run html
	pidFileName      string // PID file name.  Empty if no PID file was created.
	disableUpdate    bool   // If set, don't check for updates
	controlLock      sync.Mutex
	tlsRoots         *x509.CertPool // list of root CAs for TLSv1.2
	tlsCiphers       []uint16       // list of TLS ciphers to use
	transport        *http.Transport
	client           *http.Client
	appSignalChannel chan os.Signal // Channel for receiving OS signals by the console app
	// runningAsService flag is set to true when options are passed from the service runner
	runningAsService bool
}

// getDataDir returns path to the directory where we store databases and filters
func (c *homeContext) getDataDir() string {
	return filepath.Join(c.workDir, dataDir)
}

// Context - a global context object
var Context homeContext

// Main is the entry point
func Main(clientBuildFS fs.FS) {
	// config can be specified, which reads options from there, but other command line flags have to override config values
	// therefore, we must do it manually instead of using a lib
	args := loadOptions()

	Context.appSignalChannel = make(chan os.Signal)
	signal.Notify(Context.appSignalChannel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT)
	go func() {
		for {
			sig := <-Context.appSignalChannel
			log.Info("Received signal %q", sig)
			switch sig {
			case syscall.SIGHUP:
				Context.clients.Reload()
				Context.tls.Reload()

			default:
				cleanup(context.Background())
				cleanupAlways()
				os.Exit(0)
			}
		}
	}()

	if args.serviceControlAction != "" {
		// TODO(a.garipov): github.com/kardianos/service doesn't seem to
		// support OpenBSD currently.  Either patch it to do so or make
		// our own implementation of the service.System interface.
		if runtime.GOOS == "openbsd" {
			log.Fatal("service actions are not supported on openbsd, see issue 3226")
		}

		handleServiceControlAction(args, clientBuildFS)

		return
	}

	// run the protection
	run(args, clientBuildFS)
}

func setupContext(args options) {
	Context.runningAsService = args.runningAsService
	Context.disableUpdate = args.disableUpdate ||
		version.Channel() == version.ChannelDevelopment

	Context.firstRun = detectFirstRun()
	if Context.firstRun {
		log.Info("This is the first time AdGuard Home is launched")
		checkPermissions()
	}

	initConfig()

	Context.tlsRoots = LoadSystemRootCAs()
	Context.tlsCiphers = InitTLSCiphers()
	Context.transport = &http.Transport{
		DialContext: customDialContext,
		Proxy:       getHTTPProxy,
		TLSClientConfig: &tls.Config{
			RootCAs:    Context.tlsRoots,
			MinVersion: tls.VersionTLS12,
		},
	}
	Context.client = &http.Client{
		Timeout:   time.Minute * 5,
		Transport: Context.transport,
	}

	if !Context.firstRun {
		// Do the upgrade if necessary
		err := upgradeConfig()
		if err != nil {
			log.Fatal(err)
		}

		err = parseConfig()
		if err != nil {
			log.Error("parsing configuration file: %s", err)

			os.Exit(1)
		}

		if args.checkConfig {
			log.Info("configuration file is ok")

			os.Exit(0)
		}
	}

	Context.mux = http.NewServeMux()
}

// logIfUnsupported logs a formatted warning if the error is one of the
// unsupported errors and returns nil.  If err is nil, logIfUnsupported returns
// nil.  Otherise, it returns err.
func logIfUnsupported(msg string, err error) (outErr error) {
	if unsupErr := (&aghos.UnsupportedError{}); errors.As(err, &unsupErr) {
		log.Debug(msg, err)
	} else if err != nil {
		return err
	}

	return nil
}

// configureOS sets the OS-related configuration.
func configureOS(conf *configuration) (err error) {
	osConf := conf.OSConfig
	if osConf == nil {
		return nil
	}

	if osConf.Group != "" {
		err = aghos.SetGroup(osConf.Group)
		err = logIfUnsupported("warning: setting group", err)
		if err != nil {
			return fmt.Errorf("setting group: %w", err)
		}

		log.Info("group set to %s", osConf.Group)
	}

	if osConf.User != "" {
		err = aghos.SetUser(osConf.User)
		err = logIfUnsupported("warning: setting user", err)
		if err != nil {
			return fmt.Errorf("setting user: %w", err)
		}

		log.Info("user set to %s", osConf.User)
	}

	if osConf.RlimitNoFile != 0 {
		err = aghos.SetRlimit(osConf.RlimitNoFile)
		err = logIfUnsupported("warning: setting rlimit", err)
		if err != nil {
			return fmt.Errorf("setting rlimit: %w", err)
		}

		log.Info("rlimit_nofile set to %d", osConf.RlimitNoFile)
	}

	return nil
}

func setupConfig(args options) (err error) {
	config.DHCP.WorkDir = Context.workDir
	config.DHCP.HTTPRegister = httpRegister
	config.DHCP.ConfigModified = onConfigModified

	Context.dhcpServer, err = dhcpd.Create(config.DHCP)
	if Context.dhcpServer == nil || err != nil {
		// TODO(a.garipov): There are a lot of places in the code right
		// now which assume that the DHCP server can be nil despite this
		// condition.  Inspect them and perhaps rewrite them to use
		// Enabled() instead.
		return fmt.Errorf("initing dhcp: %w", err)
	}

	Context.updater = updater.NewUpdater(&updater.Config{
		Client:   Context.client,
		Version:  version.Version(),
		Channel:  version.Channel(),
		GOARCH:   runtime.GOARCH,
		GOOS:     runtime.GOOS,
		GOARM:    version.GOARM(),
		GOMIPS:   version.GOMIPS(),
		WorkDir:  Context.workDir,
		ConfName: config.getConfigFilename(),
	})

	if !args.noEtcHosts {
		Context.etcHosts = &aghnet.EtcHostsContainer{}
		Context.etcHosts.Init("")
	}
	Context.clients.Init(config.Clients, Context.dhcpServer, Context.etcHosts)
	config.Clients = nil

	// override bind host/port from the console
	if args.bindHost != nil {
		config.BindHost = args.bindHost
	}
	if args.bindPort != 0 {
		config.BindPort = args.bindPort
	}
	if len(args.pidFile) != 0 && writePIDFile(args.pidFile) {
		Context.pidFileName = args.pidFile
	}

	return nil
}

func initWeb(args options, clientBuildFS fs.FS) (web *Web, err error) {
	var clientFS, clientBetaFS fs.FS
	if args.localFrontend {
		log.Info("warning: using local frontend files")

		clientFS = os.DirFS("build/static")
		clientBetaFS = os.DirFS("build2/static")
	} else {
		clientFS, err = fs.Sub(clientBuildFS, "build/static")
		if err != nil {
			return nil, fmt.Errorf("getting embedded client subdir: %w", err)
		}

		clientBetaFS, err = fs.Sub(clientBuildFS, "build2/static")
		if err != nil {
			return nil, fmt.Errorf("getting embedded beta client subdir: %w", err)
		}
	}

	webConf := webConfig{
		firstRun:     Context.firstRun,
		BindHost:     config.BindHost,
		BindPort:     config.BindPort,
		BetaBindPort: config.BetaBindPort,

		ReadTimeout:       readTimeout,
		ReadHeaderTimeout: readHdrTimeout,
		WriteTimeout:      writeTimeout,

		clientFS:     clientFS,
		clientBetaFS: clientBetaFS,
	}

	web = CreateWeb(&webConf)
	if web == nil {
		return nil, fmt.Errorf("initializing web: %w", err)
	}

	return web, nil
}

func fatalOnError(err error) {
	if err != nil {
		log.Fatal(err)
	}
}

// run performs configurating and starts AdGuard Home.
func run(args options, clientBuildFS fs.FS) {
	var err error

	// configure config filename
	initConfigFilename(args)

	// configure working dir and config path
	initWorkingDir(args)

	// configure log level and output
	configureLogger(args)

	// Go memory hacks
	memoryUsage(args)

	// print the first message after logger is configured
	log.Println(version.Full())
	log.Debug("Current working directory is %s", Context.workDir)
	if args.runningAsService {
		log.Info("AdGuard Home is running as a service")
	}

	setupContext(args)

	err = configureOS(&config)
	fatalOnError(err)

	// clients package uses filtering package's static data (filtering.BlockedSvcKnown()),
	//  so we have to initialize filtering's static data first,
	//  but also avoid relying on automatic Go init() function
	filtering.InitModule()

	err = setupConfig(args)
	fatalOnError(err)

	if !Context.firstRun {
		// Save the updated config
		err = config.write()
		fatalOnError(err)

		if config.DebugPProf {
			mux := http.NewServeMux()
			mux.HandleFunc("/debug/pprof/", pprof.Index)
			mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
			mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
			mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
			mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
			go func() {
				log.Info("pprof: listening on localhost:6060")
				lerr := http.ListenAndServe("localhost:6060", mux)
				log.Error("Error while running the pprof server: %s", lerr)
			}()
		}
	}

	err = os.MkdirAll(Context.getDataDir(), 0o755)
	if err != nil {
		log.Fatalf("Cannot create DNS data dir at %s: %s", Context.getDataDir(), err)
	}

	sessFilename := filepath.Join(Context.getDataDir(), "sessions.db")
	GLMode = args.glinetMode
	var arl *authRateLimiter
	if config.AuthAttempts > 0 && config.AuthBlockMin > 0 {
		arl = newAuthRateLimiter(
			time.Duration(config.AuthBlockMin)*time.Minute,
			config.AuthAttempts,
		)
	} else {
		log.Info("authratelimiter is disabled")
	}

	Context.auth = InitAuth(
		sessFilename,
		config.Users,
		config.WebSessionTTLHours*60*60,
		arl,
	)
	if Context.auth == nil {
		log.Fatalf("Couldn't initialize Auth module")
	}
	config.Users = nil

	Context.tls = tlsCreate(config.TLS)
	if Context.tls == nil {
		log.Fatalf("Can't initialize TLS module")
	}

	Context.web, err = initWeb(args, clientBuildFS)
	fatalOnError(err)

	Context.subnetDetector, err = aghnet.NewSubnetDetector()
	fatalOnError(err)

	if !Context.firstRun {
		err = initDNSServer()
		fatalOnError(err)

		Context.tls.Start()
		Context.etcHosts.Start()

		go func() {
			serr := startDNSServer()
			if serr != nil {
				closeDNSServer()
				fatalOnError(serr)
			}
		}()

		if Context.dhcpServer != nil {
			err = Context.dhcpServer.Start()
			if err != nil {
				log.Error("starting dhcp server: %s", err)
			}
		}
	}

	Context.web.Start()

	// wait indefinitely for other go-routines to complete their job
	select {}
}

// StartMods initializes and starts the DNS server after installation.
func StartMods() error {
	err := initDNSServer()
	if err != nil {
		return err
	}

	Context.tls.Start()

	err = startDNSServer()
	if err != nil {
		closeDNSServer()
		return err
	}
	return nil
}

// Check if the current user permissions are enough to run AdGuard Home
func checkPermissions() {
	log.Info("Checking if AdGuard Home has necessary permissions")

	if runtime.GOOS == "windows" {
		// On Windows we need to have admin rights to run properly

		admin, _ := aghos.HaveAdminRights()
		if admin {
			return
		}

		log.Fatal("This is the first launch of AdGuard Home. You must run it as Administrator.")
	}

	// We should check if AdGuard Home is able to bind to port 53
	ok, err := aghnet.CanBindPort(53)

	if ok {
		log.Info("AdGuard Home can bind to port 53")
		return
	}

	if errors.Is(err, os.ErrPermission) {
		msg := `Permission check failed.

AdGuard Home is not allowed to bind to privileged ports (for instance, port 53).
Please note, that this is crucial for a server to be able to use privileged ports.

You have two options:
1. Run AdGuard Home with root privileges
2. On Linux you can grant the CAP_NET_BIND_SERVICE capability:
https://github.com/AdguardTeam/AdGuardHome/wiki/Getting-Started#running-without-superuser`

		log.Fatal(msg)
	}

	msg := fmt.Sprintf(`AdGuard failed to bind to port 53 due to %v

Please note, that this is crucial for a DNS server to be able to use that port.`, err)

	log.Info(msg)
}

// Write PID to a file
func writePIDFile(fn string) bool {
	data := fmt.Sprintf("%d", os.Getpid())
	err := os.WriteFile(fn, []byte(data), 0o644)
	if err != nil {
		log.Error("Couldn't write PID to file %s: %v", fn, err)
		return false
	}
	return true
}

func initConfigFilename(args options) {
	// config file path can be overridden by command-line arguments:
	if args.configFilename != "" {
		Context.configFilename = args.configFilename
	} else {
		// Default config file name
		Context.configFilename = "AdGuardHome.yaml"
	}
}

// initWorkingDir initializes the workDir
// if no command-line arguments specified, we use the directory where our binary file is located
func initWorkingDir(args options) {
	execPath, err := os.Executable()
	if err != nil {
		panic(err)
	}

	if args.workDir != "" {
		// If there is a custom config file, use it's directory as our working dir
		Context.workDir = args.workDir
	} else {
		Context.workDir = filepath.Dir(execPath)
	}

	workDir, err := filepath.EvalSymlinks(Context.workDir)
	if err != nil {
		panic(err)
	}

	Context.workDir = workDir
}

// configureLogger configures logger level and output
func configureLogger(args options) {
	ls := getLogSettings()

	// command-line arguments can override config settings
	if args.verbose || config.Verbose {
		ls.Verbose = true
	}
	if args.logFile != "" {
		ls.LogFile = args.logFile
	} else if config.LogFile != "" {
		ls.LogFile = config.LogFile
	}

	// Handle default log settings overrides
	ls.LogCompress = config.LogCompress
	ls.LogLocalTime = config.LogLocalTime
	ls.LogMaxBackups = config.LogMaxBackups
	ls.LogMaxSize = config.LogMaxSize
	ls.LogMaxAge = config.LogMaxAge

	// log.SetLevel(log.INFO) - default
	if ls.Verbose {
		log.SetLevel(log.DEBUG)
	}

	// Make sure that we see the microseconds in logs, as networking stuff
	// can happen pretty quickly.
	log.SetFlags(log.LstdFlags | log.Lmicroseconds)

	if args.runningAsService && ls.LogFile == "" && runtime.GOOS == "windows" {
		// When running as a Windows service, use eventlog by default if nothing else is configured
		// Otherwise, we'll simply loose the log output
		ls.LogFile = configSyslog
	}

	// logs are written to stdout (default)
	if ls.LogFile == "" {
		return
	}

	if ls.LogFile == configSyslog {
		// Use syslog where it is possible and eventlog on Windows
		err := aghos.ConfigureSyslog(serviceName)
		if err != nil {
			log.Fatalf("cannot initialize syslog: %s", err)
		}
	} else {
		logFilePath := filepath.Join(Context.workDir, ls.LogFile)
		if filepath.IsAbs(ls.LogFile) {
			logFilePath = ls.LogFile
		}

		_, err := os.OpenFile(logFilePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
		if err != nil {
			log.Fatalf("cannot create a log file: %s", err)
		}

		log.SetOutput(&lumberjack.Logger{
			Filename:   logFilePath,
			Compress:   ls.LogCompress, // disabled by default
			LocalTime:  ls.LogLocalTime,
			MaxBackups: ls.LogMaxBackups,
			MaxSize:    ls.LogMaxSize, // megabytes
			MaxAge:     ls.LogMaxAge,  // days
		})
	}
}

// cleanup stops and resets all the modules.
func cleanup(ctx context.Context) {
	log.Info("stopping AdGuard Home")

	if Context.web != nil {
		Context.web.Close(ctx)
		Context.web = nil
	}
	if Context.auth != nil {
		Context.auth.Close()
		Context.auth = nil
	}

	err := stopDNSServer()
	if err != nil {
		log.Error("stopping dns server: %s", err)
	}

	if Context.dhcpServer != nil {
		err = Context.dhcpServer.Stop()
		if err != nil {
			log.Error("stopping dhcp server: %s", err)
		}
	}

	Context.etcHosts.Close()

	if Context.tls != nil {
		Context.tls.Close()
		Context.tls = nil
	}
}

// This function is called before application exits
func cleanupAlways() {
	if len(Context.pidFileName) != 0 {
		_ = os.Remove(Context.pidFileName)
	}
	log.Info("Stopped")
}

func exitWithError() {
	os.Exit(64)
}

// loadOptions reads command line arguments and initializes configuration
func loadOptions() options {
	o, f, err := parse(os.Args[0], os.Args[1:])

	if err != nil {
		log.Error(err.Error())
		_ = printHelp(os.Args[0])
		exitWithError()
	} else if f != nil {
		err = f()
		if err != nil {
			log.Error(err.Error())
			exitWithError()
		} else {
			os.Exit(0)
		}
	}

	return o
}

// printWebAddrs prints addresses built from proto, addr, and an appropriate
// port.  At least one address is printed with the value of port.  If the value
// of betaPort is 0, the second address is not printed.  The output example:
//
//   Go to http://127.0.0.1:80
//   Go to http://127.0.0.1:3000 (BETA)
//
func printWebAddrs(proto, addr string, port, betaPort int) {
	const (
		hostMsg     = "Go to %s://%s"
		hostBetaMsg = hostMsg + " (BETA)"
	)

	log.Printf(hostMsg, proto, aghnet.JoinHostPort(addr, port))
	if betaPort == 0 {
		return
	}

	log.Printf(hostBetaMsg, proto, aghnet.JoinHostPort(addr, config.BetaBindPort))
}

// printHTTPAddresses prints the IP addresses which user can use to access the
// admin interface.  proto is either schemeHTTP or schemeHTTPS.
func printHTTPAddresses(proto string) {
	tlsConf := tlsConfigSettings{}
	if Context.tls != nil {
		Context.tls.WriteDiskConfig(&tlsConf)
	}

	port := config.BindPort
	if proto == schemeHTTPS {
		port = tlsConf.PortHTTPS
	}

	// TODO(e.burkov): Inspect and perhaps merge with the previous
	// condition.
	if proto == schemeHTTPS && tlsConf.ServerName != "" {
		printWebAddrs(proto, tlsConf.ServerName, tlsConf.PortHTTPS, 0)

		return
	}

	bindhost := config.BindHost
	if !bindhost.IsUnspecified() {
		printWebAddrs(proto, bindhost.String(), port, config.BetaBindPort)

		return
	}

	ifaces, err := aghnet.GetValidNetInterfacesForWeb()
	if err != nil {
		log.Error("web: getting iface ips: %s", err)
		// That's weird, but we'll ignore it.
		//
		// TODO(e.burkov): Find out when it happens.
		printWebAddrs(proto, bindhost.String(), port, config.BetaBindPort)

		return
	}

	for _, iface := range ifaces {
		for _, addr := range iface.Addresses {
			printWebAddrs(proto, addr.String(), config.BindPort, config.BetaBindPort)
		}
	}
}

// -------------------
// first run / install
// -------------------
func detectFirstRun() bool {
	configfile := Context.configFilename
	if !filepath.IsAbs(configfile) {
		configfile = filepath.Join(Context.workDir, Context.configFilename)
	}
	_, err := os.Stat(configfile)
	return errors.Is(err, os.ErrNotExist)
}

// Connect to a remote server resolving hostname using our own DNS server.
//
// TODO(e.burkov): This messy logic should be decomposed and clarified.
func customDialContext(ctx context.Context, network, addr string) (conn net.Conn, err error) {
	log.Tracef("network:%v  addr:%v", network, addr)

	host, port, err := net.SplitHostPort(addr)
	if err != nil {
		return nil, err
	}

	dialer := &net.Dialer{
		Timeout: time.Minute * 5,
	}

	if net.ParseIP(host) != nil || config.DNS.Port == 0 {
		return dialer.DialContext(ctx, network, addr)
	}

	addrs, err := Context.dnsServer.Resolve(host)
	log.Debug("dnsServer.Resolve: %s: %v", host, addrs)
	if err != nil {
		return nil, err
	}

	if len(addrs) == 0 {
		return nil, fmt.Errorf("couldn't lookup host: %s", host)
	}

	var dialErrs []error
	for _, a := range addrs {
		addr = net.JoinHostPort(a.String(), port)
		conn, err = dialer.DialContext(ctx, network, addr)
		if err != nil {
			dialErrs = append(dialErrs, err)

			continue
		}

		return conn, err
	}

	return nil, errors.List(fmt.Sprintf("couldn't dial to %s", addr), dialErrs...)
}

func getHTTPProxy(_ *http.Request) (*url.URL, error) {
	if config.ProxyURL == "" {
		return nil, nil
	}

	return url.Parse(config.ProxyURL)
}

// jsonError is a generic JSON error response.
//
// TODO(a.garipov): Merge together with the implementations in .../dhcpd and
// other packages after refactoring the web handler registering.
type jsonError struct {
	// Message is the error message, an opaque string.
	Message string `json:"message"`
}