package config

import (
	"fmt"
	"io/ioutil"
	"net"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"runtime"
	"strings"
	"time"

	log "github.com/Sirupsen/logrus"
	"github.com/alphasoc/nfr/elastic"
	"github.com/alphasoc/nfr/utils"
	"github.com/pkg/errors"
	"gopkg.in/yaml.v3"
)

// Monitor is a config for monitoring files
type Monitor struct {
	Format string `yaml:"format"`
	Type   string `yaml:"type"`
	File   string `yaml:"file"`
}

type group struct {
	Label          string   `yaml:"label"`
	InScope        []string `yaml:"in_scope"`
	OutScope       []string `yaml:"out_scope"`
	TrustedDomains []string `yaml:"trusted_domains"`
	TrustedIps     []string `yaml:"trusted_ips"`
}

// Config for nfr
type Config struct {
	// AlphaSOC server configuration
	Engine struct {
		// AlphaSOC host server.
		// Default: https://api.alphasoc.net
		Host string `yaml:"host,omitempty"`

		// AlphaSOC api key. Required for start sending dns queries.
		APIKey string `yaml:"api_key,omitempty"`

		// events to analize by AlphaSOC Engine.
		Analyze struct {
			// Enable (true) or disable (false) DNS event processing
			// Default: true
			DNS bool `yaml:"dns"`
			// Enable (true) or disable (false) IP event processing
			// Default: true
			IP bool `yaml:"ip"`
			// Enable (true) or disable (false) HTTP event processing
			// Default: true
			HTTP bool `yaml:"http"`
		} `yaml:"analyze"`

		// Alerts configuration (generated by Engine).
		Alerts struct {
			// Interval for polling alerts from AlphaSOC Engine.
			// Default: 5m
			PollInterval time.Duration `yaml:"poll_interval,omitempty"`
		} `yaml:"alerts,omitempty"`
	} `yaml:"engine"`

	// Inputs describes where collects network traffic to score from
	Inputs struct {
		// Sniffer configuration.
		Sniffer struct {
			// Enabled if set to true nfr will run sniffer.
			Enabled bool `yaml:"enabled"`
			// Interface on which nfr should listen.
			// Default: (none)
			Interface string `yaml:"interface,omitempty"`

			// Interface physical hardware address.
			HardwareAddr net.HardwareAddr `yaml:"-"`
		} `yaml:"sniffer,omitempty"`

		// Monitors keeps list of log files to monitor.
		Monitors []Monitor `yaml:"monitor"`

		// Elasticsearch configuration.
		Elastic elastic.Config `yaml:"elastic"`

		// MSDNSTimeFormat defines time format as expcted by time.Parse
		MSDNSTimeFormat string `yaml:"msdns_time_format"`

		// UseInotify uses inotify for detecting file changes.
		// File polling will be used otherwise.
		UseInotify bool `yaml:"use_inotify"`
	} `yaml:"inputs"`

	// Outputs describes where should send the alerts generated by the Analytics Engine.
	Outputs struct {
		// Enabled if set to true nfr will gather alerts.
		Enabled bool `yaml:"enabled"`

		Graylog struct {
			URI   string `yaml:"uri"`
			Level int    `yaml:"level"`
		} `yaml:"graylog"`

		// Syslog server
		Syslog struct {
			// Default: (none)
			IP string `yaml:"ip"`
			// Default: 514
			Port int `yaml:"port"`
			// Default: tcp
			Proto string `yaml:"proto,omitempty"`
			// Default: json
			Format string `yaml:"format,omitempty"`
		} `yaml:"syslog"`

		// File where to store alerts. If not set then no alerts will be retrieved.
		// To print alerts to console use two special outputs: stderr or stdout
		// Default: "stderr"
		File string `yaml:"file,omitempty"`

		// Format for the file output; can be json or cef (default is json).
		Format string `yaml:"format,omitempty"`
	} `yaml:"outputs"`

	// Log configuration.
	Log struct {
		// File to which nfr should log.
		// To print log to console use two special outputs: stderr or stdout
		// Default: stdout
		File string `yaml:"file,omitempty"`

		// Log level. Possibles values are: debug, info, warn, error
		// Default: info
		Level string `yaml:"level,omitempty"`
	} `yaml:"log,omitempty"`

	// Internal nfr data.
	Data struct {
		// File for internal data.
		// Default:
		// - linux /run/nfr.data
		// - win %AppData%/nfr.data
		File string `yaml:"file,omitempty"`
		// Dir for internal data.
		// Default:
		// - linux /run/nfr
		// - win %AppData%/nfr
		Dir string `yaml:"dir,omitempty"`
	} `yaml:"data,omitempty"`

	// Scope groups file.
	// The IP exclusion list is used to prune 'noisy' hosts, such as mail servers
	// or workstations within the IP ranges provided.
	// Finally, the domain scope is used to specify internal and trusted domains and
	// hostnames (supporting wildcards, e.g. *.google.com) to ignore.
	// If you do not scope domains, local DNS traffic will be forwarded to the AlphaSOC Engine for scoring.
	Scope struct {
		// File with scope groups . See ScopeConfig for more info.
		// Default: (none)
		File string `yaml:"file,omitempty"`
	} `yaml:"scope,omitempty"`

	// ScopeConfig is loaded when Scope.File is not empty or the default one is used.
	ScopeConfig struct {
		Groups map[string]group `yaml:"groups,omitempty"`
	} `yaml:"-"`

	// DNS queries configuration.
	DNSEvents struct {
		// Buffer size for dns queries queue. If the size will be exceded then
		// nfr send quries to AlphaSOC Engine. Default: 65535
		BufferSize int `yaml:"buffer_size,omitempty"`
		// Interval for flushing dns queries to AlphaSOC Engine. Default: 30s
		FlushInterval time.Duration `yaml:"flush_interval,omitempty"`

		// Queries that were unable to send to AlphaSOC Engine.
		// If file is set, then unsent queries will be saved on disk and send again.
		// Pcap format is used to store queries. You can view it in
		// programs like tcpdump or whireshark.
		Failed struct {
			// File to store DNS Queries. Default: (none)
			File string `yaml:"file,omitempty"`
		} `yaml:"failed,omitempty"`
	} `yaml:"dns_events,omitempty"`

	// IP events configuration.
	IPEvents struct {
		// Buffer size for ip events queue. If the size will be exceded then
		// nfr send quries to AlphaSOC Engine. Default: 65535
		BufferSize int `yaml:"buffer_size,omitempty"`
		// Interval for flushing ip events to AlphaSOC Engine. Default: 30s
		FlushInterval time.Duration `yaml:"flush_interval,omitempty"`

		// Events that were unable to send to AlphaSOC Engine.
		// If file is set, then unsent events will be saved on disk and send again.
		// Pcap format is used to store events. You can view it in
		// programs like tcpdump or whireshark.
		Failed struct {
			// File to store ip events. Default: (none)
			File string `yaml:"file,omitempty"`
		} `yaml:"failed,omitempty"`
	} `yaml:"ip_events,omitempty"`

	// HTTP events configuration.
	HTTPEvents struct {
		// Buffer size for ip events queue. If the size will be exceded then
		// nfr send quries to AlphaSOC Engine. Default: 65535
		BufferSize int `yaml:"buffer_size,omitempty"`
		// Interval for flushing ip events to AlphaSOC Engine. Default: 30s
		FlushInterval time.Duration `yaml:"flush_interval,omitempty"`
	} `yaml:"http_events,omitempty"`
}

// New reads the config from file location. If file is not set
// then it tries to read from default location, if this fails, then
// default config is returned.
func New(file ...string) (*Config, error) {
	cfg := NewDefault()
	if len(file) > 1 {
		panic("config: too many files")
	}

	filename := ""
	if len(file) == 1 {
		filename = file[0]
	}

	if filename != "" {
		if err := cfg.load(filename); err != nil {
			return nil, fmt.Errorf("config: can't load file: %w", err)
		}
	}

	if err := cfg.loadScopeConfig(); err != nil {
		return nil, err
	}

	if filename == "" {
		// do not validate default config
		return cfg, nil
	}

	if err := cfg.validate(); err != nil {
		return nil, fmt.Errorf("config: %s", err)
	}

	return cfg, nil
}

// NewDefault returns config with set defaults.
func NewDefault() *Config {
	cfg := &Config{}
	cfg.Engine.Host = "https://api.alphasoc.net"
	cfg.Engine.Analyze.DNS = true
	cfg.Engine.Analyze.IP = true
	cfg.Engine.Analyze.HTTP = true
	cfg.Engine.Alerts.PollInterval = 5 * time.Minute

	cfg.Inputs.Sniffer.Enabled = false
	// Use inotify by default on non-windows OS
	cfg.Inputs.UseInotify = (runtime.GOOS != "windows")

	cfg.Outputs.Enabled = true
	cfg.Outputs.File = "stderr"
	cfg.Outputs.Format = "json"
	cfg.Outputs.Graylog.Level = 1
	cfg.Outputs.Syslog.Port = 514
	cfg.Outputs.Syslog.Proto = "tcp"
	cfg.Outputs.Syslog.Format = "json"

	cfg.Log.File = "stdout"
	cfg.Log.Level = "info"

	cfg.Data.File = "/run/nfr.data"
	if runtime.GOOS == "windows" {
		cfg.Data.File = path.Join(os.Getenv("AppData"), "nfr.data")
	}

	cfg.Data.Dir = "/run/nfr"
	if runtime.GOOS == "windows" {
		cfg.Data.File = path.Join(os.Getenv("AppData"), "nfr")
	}

	cfg.DNSEvents.BufferSize = 65535
	cfg.DNSEvents.FlushInterval = 30 * time.Second
	cfg.IPEvents.BufferSize = 65535
	cfg.IPEvents.FlushInterval = 30 * time.Second
	cfg.HTTPEvents.BufferSize = 65535
	cfg.HTTPEvents.FlushInterval = 30 * time.Second
	return cfg
}

// Save saves config to file.
func (cfg *Config) Save(file string) error {
	if err := os.MkdirAll(filepath.Dir(file), os.ModeDir); err != nil {
		return err
	}

	content, err := yaml.Marshal(cfg)
	if err != nil {
		return err
	}

	return ioutil.WriteFile(file, content, 0666)
}

// HasOutputs returns true if at least one output is configured and enabled.
func (cfg *Config) HasOutputs() bool {
	return cfg.Outputs.Enabled && (cfg.Outputs.File != "" || cfg.Outputs.Graylog.URI != "")
}

// HasInputs returns true if at least one input is configured and enabled.
func (cfg *Config) HasInputs() bool {
	return cfg.Inputs.Sniffer.Enabled || len(cfg.Inputs.Monitors) > 0 || cfg.Inputs.Elastic.Enabled
}

// load config from content.
func (cfg *Config) load(filename string) error {
	f, err := os.Open(filename)
	if err != nil {
		return fmt.Errorf("opening config: %w", err)
	}
	defer f.Close()

	dec := yaml.NewDecoder(f)
	dec.KnownFields(true)
	if err := dec.Decode(cfg); err != nil {
		return fmt.Errorf("parsing config: %w", err)
	}

	return nil
}

func (cfg *Config) validate() error {
	if !(cfg.HasInputs() || cfg.HasOutputs()) {
		return fmt.Errorf("at least one input or output must be enable")
	}

	// special case if there are only inputs and analyze set to false.
	if !cfg.HasOutputs() && cfg.HasInputs() && !(cfg.Engine.Analyze.DNS || cfg.Engine.Analyze.IP) {
		return fmt.Errorf("inputs are configured but analysis of dns and ip are set to false")
	}

	if cfg.Inputs.Sniffer.Enabled {
		if cfg.Inputs.Sniffer.Interface != "" {
			iface, err := net.InterfaceByName(cfg.Inputs.Sniffer.Interface)
			if err != nil {
				return fmt.Errorf("can't open interface %s: %s", cfg.Inputs.Sniffer.Interface, err)
			}
			cfg.Inputs.Sniffer.HardwareAddr = iface.HardwareAddr
		} else {
			iface, err := utils.InterfaceWithPublicIP()
			if err != nil {
				return fmt.Errorf("can't find an interface for sniffing: %s", err)
			}
			cfg.Inputs.Sniffer.Interface = iface.Name
			cfg.Inputs.Sniffer.HardwareAddr = iface.HardwareAddr
		}
	}

	if err := validateFilename(cfg.Log.File, true); err != nil {
		return err
	}
	if cfg.Log.Level != "debug" &&
		cfg.Log.Level != "info" &&
		cfg.Log.Level != "warn" &&
		cfg.Log.Level != "error" {
		return fmt.Errorf("invalid %s log level", cfg.Log.Level)
	}

	if err := validateFilename(cfg.Data.File, false); err != nil {
		return err
	}

	// Elastic is the only output that requires data directory
	if cfg.Inputs.Elastic.Enabled {
		if err := validateDirectory(cfg.Data.Dir); err != nil {
			return err
		}
	}

	if cfg.Outputs.Graylog.URI != "" {
		parsedURI, err := url.Parse(cfg.Outputs.Graylog.URI)
		if err != nil {
			return fmt.Errorf("invalid graylog uri %s", err)
		}

		if _, _, err := net.SplitHostPort(parsedURI.Host); err != nil {
			return fmt.Errorf("missing port in graylog uri %s", cfg.Outputs.Graylog.URI)
		}
	}

	if cfg.Outputs.Graylog.Level < 0 || cfg.Outputs.Graylog.Level > 7 {
		return fmt.Errorf("invalid graylog alert level %d", cfg.Outputs.Graylog.Level)
	}

	if cfg.Outputs.Syslog.Port <= 0 && cfg.Outputs.Syslog.Port > 65535 {
		return fmt.Errorf("config: invalid qradar port number %d", cfg.Outputs.Syslog.Port)
	}

	if cfg.Outputs.File != "" {
		if err := validateFilename(cfg.Outputs.File, true); err != nil {
			return err
		}
	}

	if cfg.Engine.Alerts.PollInterval < 5*time.Second {
		return fmt.Errorf("events poll interval must be at least 5s")
	}

	if cfg.DNSEvents.BufferSize < 64 {
		return fmt.Errorf("queries buffer size must be at least 64")
	}

	if cfg.DNSEvents.FlushInterval < 5*time.Second {
		return fmt.Errorf("queries flush interval must be at least 5s")
	}

	if cfg.DNSEvents.Failed.File != "" {
		if err := validateFilename(cfg.DNSEvents.Failed.File, false); err != nil {
			return err
		}
	}

	if cfg.IPEvents.BufferSize < 64 {
		return fmt.Errorf("queries buffer size must be at least 64")
	}

	if cfg.IPEvents.FlushInterval < 5*time.Second {
		return fmt.Errorf("queries flush interval must be at least 5s")
	}

	if cfg.IPEvents.Failed.File != "" {
		if err := validateFilename(cfg.IPEvents.Failed.File, false); err != nil {
			return err
		}
	}

	for _, monitor := range cfg.Inputs.Monitors {
		// skip empty items
		if monitor.File == "" && monitor.Format == "" && monitor.Type == "" {
			continue
		}
		if monitor.Format == "" {
			return fmt.Errorf("empty format for monitoring")
		}
		if monitor.Type == "" {
			return fmt.Errorf("empty type for monitoring")
		}
		if monitor.File == "" {
			return fmt.Errorf("empty file for monitoring")
		}

		switch monitor.Format {
		case "bro", "suricata", "msdns", "syslog-named":
			// ok
		default:
			return fmt.Errorf("unknown format %s for monitoring", monitor.Format)
		}

		var invalidTypeFormat bool

		switch monitor.Type {
		case "dns":
		case "ip":
			if monitor.Format != "bro" {
				invalidTypeFormat = true
			}
		case "http":
			switch monitor.Format {
			case "suricate", "bro":
				// ok
			default:
				invalidTypeFormat = true
			}
		default:
			return fmt.Errorf("unknown type %s for monitoring", monitor.Type)
		}

		if invalidTypeFormat {
			return fmt.Errorf("unsupported type %s for %s format", monitor.Type, monitor.Format)
		}
	}

	if err := cfg.Inputs.Elastic.Validate(); err != nil {
		return errors.Wrap(err, "elastic configuration")
	}

	return nil
}

// validateFilename checks if file can be created.
func validateFilename(file string, noFileOutput bool) error {
	if noFileOutput && (file == "stdout" || file == "stderr") {
		return nil
	}

	dir := path.Dir(file)
	stat, err := os.Stat(dir)
	if err != nil {
		return fmt.Errorf("can't stat %s directory: %s", dir, err)
	}
	if !stat.IsDir() {
		return fmt.Errorf("%s is not directory", dir)
	}

	stat, err = os.Stat(file)
	if err == nil && !stat.Mode().IsRegular() {
		return fmt.Errorf("%s is not regular file", file)
	}
	if err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("can't stat %s file: %s", file, err)
	}
	return nil
}

// validateDirectory checks if nfr has necessary permissions to the directory.
func validateDirectory(dir string) error {
	if err := os.MkdirAll(dir, 0755); err != nil {
		return err
	}

	f, err := os.Create(path.Join(dir, "datadir-permission-test"))
	if err != nil {
		return err
	}
	f.Close()
	os.Remove(f.Name())

	return nil
}

// load scope config from yaml file, or use default one.
func (cfg *Config) loadScopeConfig() (err error) {
	if cfg.Scope.File == "" {
		cfg.ScopeConfig.Groups = map[string]group{
			"default": {
				Label:          "Default",
				InScope:        []string{"10.0.0.0/8", "192.168.0.0/16", "172.16.0.0/12", "fc00::/7"},
				TrustedDomains: []string{"*.arpa", "*.lan", "*.local", "*.internal"},
				TrustedIps: []string{
					"10.0.0.0/8",
					"127.0.0.0/8",
					"169.254.0.0/16",
					"172.16.0.0/12",
					"192.168.0.0/16",
					"224.0.0.0/8",
					"255.255.255.255/32",
					"fc00::/7",
					"fe80::/10",
					"ff00::/8",
				},
			},
		}
	} else {
		scopeFile, err := os.Open(cfg.Scope.File)
		if err != nil {
			return fmt.Errorf("opening scope config: %w", err)
		}
		defer scopeFile.Close()

		cfgDecoder := yaml.NewDecoder(scopeFile)
		cfgDecoder.KnownFields(true)
		if err := cfgDecoder.Decode(&cfg.ScopeConfig); err != nil {
			return fmt.Errorf("parsing scope config: %w", err)
		}
	}

	return cfg.validateScopeConfig()
}

func (cfg *Config) validateScopeConfig() error {
	testCidr := func(cidrs []string) error {
		for _, cidr := range cidrs {
			if _, _, err := net.ParseCIDR(cidr); err != nil {
				return fmt.Errorf("parse scope config: %s is not a cidr", cidr)
			}
		}
		return nil
	}

	for _, group := range cfg.ScopeConfig.Groups {
		if err := testCidr(group.InScope); err != nil {
			return err
		}
		if err := testCidr(group.OutScope); err != nil {
			return err
		}
		if err := testCidr(group.TrustedIps); err != nil {
			return err
		}

		for _, domain := range group.TrustedDomains {
			// TrimPrefix *. for multimatch domain
			if !utils.IsDomainName(domain) &&
				!utils.IsDomainName(strings.TrimPrefix(domain, "*.")) {
				return fmt.Errorf("parse scope config: %s is not valid domain name", domain)
			}
		}
	}

	return nil
}

func (cfg *Config) WriteData(fname string, data []byte) error {
	fullname := path.Join(cfg.Data.Dir, fname)
	f, err := os.Create(fullname)
	if err != nil {
		return err
	}

	f.Write(data)
	return f.Close()
}

func (cfg *Config) ReadData(fname string) ([]byte, error) {
	fullname := path.Join(cfg.Data.Dir, fname)
	f, err := os.Open(fullname)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, err
	}
	defer f.Close()

	return ioutil.ReadAll(f)
}

// LoadTimestamp loads a timestamp from fname located in the data dir. If maxAge is not zero,
// and the loaded timestamp is older than maxAge, now()-maxAge is returned. If the file
// doesn't exist or there's an error, zero time is returned.
func (cfg *Config) LoadTimestamp(fname string, maxAge time.Duration) time.Time {

	data, err := cfg.ReadData(fname)
	if err != nil {
		log.Warnf("error reading last checkpoint: %v", err)
		return time.Time{}
	}

	if data == nil {
		log.Debugf("datafile %v not found, returning zero time", fname)
		return time.Time{}
	}

	t, err := time.Parse(time.RFC3339Nano, string(data))
	if err != nil {
		log.Warnf("corrupted checkpoint data: %v", err)
	} else {
		log.Debugf("resuming reading from: %v", t)
	}

	yesterday := time.Now().Add(-maxAge)
	if t.Before(yesterday) {
		t = yesterday
		log.Debugf("last checkpoint is too far in the future, setting to -24h")
	}

	return t
}

// SaveTimestamp saves a timestamp to a fname located in the data dir.
func (cfg *Config) SaveTimestamp(fname string, t time.Time) error {
	return cfg.WriteData(fname, []byte(t.Format(time.RFC3339Nano)))
}
