// Licensed to Elasticsearch B.V. under one or more contributor
// license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright
// ownership. Elasticsearch B.V. licenses this file to you 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 monitors

import (
	"errors"
	"fmt"
	"net"
	"time"

	"github.com/elastic/beats/libbeat/beat"
	"github.com/elastic/beats/libbeat/common"

	"github.com/elastic/beats/heartbeat/look"
)

// TaskRunner describes a runnable task.
// Note that these tasks can decompose and produce continuations,
// along the line of a java fork join pool.
type TaskRunner interface {
	Run() (common.MapStr, []TaskRunner, error)
}

type funcJob struct {
	settings JobSettings
	run      jobRunner
}

type funcTask struct {
	run func() (common.MapStr, []TaskRunner, error)
}

// IPSettings provides common configuration settings for IP resolution and ping
// mode.
type IPSettings struct {
	IPv4 bool     `config:"ipv4"`
	IPv6 bool     `config:"ipv6"`
	Mode PingMode `config:"mode"`
}

// JobSettings configures a Job name and global fields to be added to every
// event.
type JobSettings struct {
	Name   string
	Fields common.MapStr
}

// HostJobSettings configures a Job including Host lookups and global fields to be added
// to every event.
type HostJobSettings struct {
	Name   string
	Host   string
	IP     IPSettings
	Fields common.MapStr
}

// PingMode enumeration for configuring `any` or `all` IPs pinging.
type PingMode uint8

const (
	PingModeUndefined PingMode = iota
	PingAny
	PingAll
)

// DefaultIPSettings provides an instance of default IPSettings to be copied
// when unpacking settings from a common.Config object.
var DefaultIPSettings = IPSettings{
	IPv4: true,
	IPv6: true,
	Mode: PingAny,
}

// Network determines the Network type used for IP name resolution, based on the
// provided settings.
func (s IPSettings) Network() string {
	switch {
	case s.IPv4 && !s.IPv6:
		return "ip4"
	case !s.IPv4 && s.IPv6:
		return "ip6"
	case s.IPv4 && s.IPv6:
		return "ip"
	}
	return ""
}

// MakeSimpleJob creates a new Job from a callback function. The callback should
// return an valid event and can not create any sub-tasks to be executed after
// completion.
func MakeSimpleJob(settings JobSettings, f func() (common.MapStr, error)) Job {
	return MakeJob(settings, func() (common.MapStr, []TaskRunner, error) {
		event, err := f()
		return event, nil, err
	})
}

// MakeJob create a new Job from a callback function. The callback can
// optionally return an event to be published and a set of derived sub-tasks to be
// scheduled. The sub-tasks will be run only once and removed from the scheduler
// after completion.
func MakeJob(settings JobSettings, f func() (common.MapStr, []TaskRunner, error)) Job {
	settings.AddFields(common.MapStr{
		"monitor": common.MapStr{
			"id": settings.Name,
		},
	})

	return &funcJob{settings, func() (beat.Event, []jobRunner, error) {
		// Create and run new annotated Job whenever the Jobs root is Task is executed.
		// This will set the jobs active start timestamp to the time.Now().
		return annotated(settings, time.Now(), f)()
	}}
}

// annotated lifts a TaskRunner into a job, annotating events with common fields and start timestamp.
func annotated(
	settings JobSettings,
	start time.Time,
	fn func() (common.MapStr, []TaskRunner, error),
) jobRunner {
	return func() (beat.Event, []jobRunner, error) {
		var event beat.Event

		fields, cont, err := fn()
		if err != nil {
			if fields == nil {
				fields = common.MapStr{}
			}
			fields["error"] = look.Reason(err)
		}

		if fields != nil {
			fields = fields.Clone()

			status := look.Status(err)
			fields.DeepUpdate(common.MapStr{
				"monitor": common.MapStr{
					"duration": look.RTT(time.Since(start)),
					"status":   status,
				},
			})
			if user := settings.Fields; user != nil {
				fields.DeepUpdate(user.Clone())
			}

			event.Timestamp = start
			event.Fields = fields
		}

		jobCont := make([]jobRunner, len(cont))
		for i, c := range cont {
			jobCont[i] = annotated(settings, start, c.Run)
		}
		return event, jobCont, nil
	}
}

// MakeCont wraps a function into an executable TaskRunner. The task being generated
// can optionally return an event and/or sub-tasks.
func MakeCont(f func() (common.MapStr, []TaskRunner, error)) TaskRunner {
	return funcTask{f}
}

// MakeSimpleCont wraps a function into an executable TaskRunner. The task bein generated
// should return an event to be reported.
func MakeSimpleCont(f func() (common.MapStr, error)) TaskRunner {
	return MakeCont(func() (common.MapStr, []TaskRunner, error) {
		event, err := f()
		return event, nil, err
	})
}

// MakePingIPFactory creates a jobFactory for building a Task from a new IP address.
func MakePingIPFactory(
	f func(*net.IPAddr) (common.MapStr, error),
) func(*net.IPAddr) TaskRunner {
	return func(ip *net.IPAddr) TaskRunner {
		return MakeSimpleCont(func() (common.MapStr, error) { return f(ip) })
	}
}

var emptyTask = MakeSimpleCont(func() (common.MapStr, error) { return nil, nil })

// MakePingAllIPFactory wraps a function for building a recursive Task Runner from function callbacks.
func MakePingAllIPFactory(
	f func(*net.IPAddr) []func() (common.MapStr, error),
) func(*net.IPAddr) TaskRunner {
	return func(ip *net.IPAddr) TaskRunner {
		cont := f(ip)
		switch len(cont) {
		case 0:
			return emptyTask
		case 1:
			return MakeSimpleCont(cont[0])
		}

		tasks := make([]TaskRunner, len(cont))
		for i, c := range cont {
			tasks[i] = MakeSimpleCont(c)
		}
		return MakeCont(func() (common.MapStr, []TaskRunner, error) {
			return nil, tasks, nil
		})
	}
}

// MakePingAllIPPortFactory builds a set of TaskRunner supporting a set of
// IP/port-pairs.
func MakePingAllIPPortFactory(
	ports []uint16,
	f func(*net.IPAddr, uint16) (common.MapStr, error),
) func(*net.IPAddr) TaskRunner {
	if len(ports) == 1 {
		port := ports[0]
		return MakePingIPFactory(func(ip *net.IPAddr) (common.MapStr, error) {
			return f(ip, port)
		})
	}

	return MakePingAllIPFactory(func(ip *net.IPAddr) []func() (common.MapStr, error) {
		funcs := make([]func() (common.MapStr, error), len(ports))
		for i := range ports {
			port := ports[i]
			funcs[i] = func() (common.MapStr, error) {
				return f(ip, port)
			}
		}
		return funcs
	})
}

// MakeByIPJob builds a new Job based on already known IP. Similar to
// MakeByHostJob, the pingFactory will be used to build the tasks run by the job.
//
// A pingFactory instance is normally build with MakePingIPFactory,
// MakePingAllIPFactory or MakePingAllIPPortFactory.
func MakeByIPJob(
	settings JobSettings,
	ip net.IP,
	pingFactory func(ip *net.IPAddr) TaskRunner,
) (Job, error) {
	// use ResolveIPAddr to parse the ip into net.IPAddr adding a zone info
	// if ipv6 is used.
	addr, err := net.ResolveIPAddr("ip", ip.String())
	if err != nil {
		return nil, err
	}

	fields := common.MapStr{
		"monitor": common.MapStr{"ip": addr.String()},
	}
	return MakeJob(settings, WithFields(fields, pingFactory(addr)).Run), nil
}

// MakeByHostJob creates a new Job including host lookup. The pingFactory will be used to
// build one or multiple Tasks after name lookup according to settings.
//
// A pingFactory instance is normally build with MakePingIPFactory,
// MakePingAllIPFactory or MakePingAllIPPortFactory.
func MakeByHostJob(
	settings HostJobSettings,
	pingFactory func(ip *net.IPAddr) TaskRunner,
) (Job, error) {
	host := settings.Host

	if ip := net.ParseIP(host); ip != nil {
		return MakeByIPJob(settings.jobSettings(), ip, pingFactory)
	}

	network := settings.IP.Network()
	if network == "" {
		return nil, errors.New("pinging hosts requires ipv4 or ipv6 mode enabled")
	}

	mode := settings.IP.Mode

	settings.AddFields(common.MapStr{
		"monitor": common.MapStr{
			"host": host,
		},
	})

	if mode == PingAny {
		return makeByHostAnyIPJob(settings, host, pingFactory), nil
	}
	return makeByHostAllIPJob(settings, host, pingFactory), nil
}

func makeByHostAnyIPJob(
	settings HostJobSettings,
	host string,
	pingFactory func(ip *net.IPAddr) TaskRunner,
) Job {
	network := settings.IP.Network()

	return MakeJob(settings.jobSettings(), func() (common.MapStr, []TaskRunner, error) {
		resolveStart := time.Now()
		ip, err := net.ResolveIPAddr(network, host)
		if err != nil {
			return resolveErr(host, err)
		}

		resolveEnd := time.Now()
		resolveRTT := resolveEnd.Sub(resolveStart)

		event := resolveIPEvent(host, ip.String(), resolveRTT)
		return WithFields(event, pingFactory(ip)).Run()
	})
}

func makeByHostAllIPJob(
	settings HostJobSettings,
	host string,
	pingFactory func(ip *net.IPAddr) TaskRunner,
) Job {
	network := settings.IP.Network()
	filter := makeIPFilter(network)

	return MakeJob(settings.jobSettings(), func() (common.MapStr, []TaskRunner, error) {
		// TODO: check for better DNS IP lookup support:
		//         - The net.LookupIP drops ipv6 zone index
		//
		resolveStart := time.Now()
		ips, err := net.LookupIP(host)
		if err != nil {
			return resolveErr(host, err)
		}

		resolveEnd := time.Now()
		resolveRTT := resolveEnd.Sub(resolveStart)

		if filter != nil {
			ips = filterIPs(ips, filter)
		}

		if len(ips) == 0 {
			err := fmt.Errorf("no %v address resolvable for host %v", network, host)
			return resolveErr(host, err)
		}

		// create ip ping tasks
		cont := make([]TaskRunner, len(ips))
		for i, ip := range ips {
			addr := &net.IPAddr{IP: ip}
			event := resolveIPEvent(host, ip.String(), resolveRTT)
			cont[i] = WithFields(event, pingFactory(addr))
		}
		return nil, cont, nil
	})
}

func resolveIPEvent(host, ip string, rtt time.Duration) common.MapStr {
	return common.MapStr{
		"monitor": common.MapStr{
			"host": host,
			"ip":   ip,
		},
		"resolve": common.MapStr{
			"host": host,
			"ip":   ip,
			"rtt":  look.RTT(rtt),
		},
	}
}

func resolveErr(host string, err error) (common.MapStr, []TaskRunner, error) {
	event := common.MapStr{
		"monitor": common.MapStr{
			"host": host,
		},
		"resolve": common.MapStr{
			"host": host,
		},
	}
	return event, nil, err
}

// WithFields wraps a TaskRunner, updating all events returned with the set of
// fields configured.
func WithFields(fields common.MapStr, r TaskRunner) TaskRunner {
	return MakeCont(func() (common.MapStr, []TaskRunner, error) {
		event, cont, err := r.Run()
		if event != nil {
			event = event.Clone()
			event.DeepUpdate(fields)
		} else if err != nil {
			event = common.MapStr{}
			event.DeepUpdate(fields)
		}

		for i := range cont {
			cont[i] = WithFields(fields, cont[i])
		}
		return event, cont, err
	})
}

// WithDuration wraps a TaskRunner, measuring the duration between creation and
// finish of the actual task and sub-tasks.
func WithDuration(field string, r TaskRunner) TaskRunner {
	return MakeCont(func() (common.MapStr, []TaskRunner, error) {
		return withStart(field, time.Now(), r).Run()
	})
}

func withStart(field string, start time.Time, r TaskRunner) TaskRunner {
	return MakeCont(func() (common.MapStr, []TaskRunner, error) {
		event, cont, err := r.Run()
		if event != nil {
			event.Put(field, look.RTT(time.Since(start)))
		}

		for i := range cont {
			cont[i] = withStart(field, start, cont[i])
		}
		return event, cont, err
	})
}

func (f *funcJob) Name() string { return f.settings.Name }

func (f *funcJob) Run() (beat.Event, []jobRunner, error) { return f.run() }

func (f funcTask) Run() (common.MapStr, []TaskRunner, error) { return f.run() }

// Unpack sets PingMode from a constant string. Unpack will be called by common.Unpack when
// unpacking into an IPSettings type.
func (p *PingMode) Unpack(s string) error {
	switch s {
	case "all":
		*p = PingAll
	case "any":
		*p = PingAny
	default:
		return fmt.Errorf("expecting 'any' or 'all', not '%v'", s)
	}
	return nil
}

func makeIPFilter(network string) func(net.IP) bool {
	switch network {
	case "ip4":
		return func(i net.IP) bool { return i.To4() != nil }
	case "ip6":
		return func(i net.IP) bool { return i.To4() == nil && i.To16() != nil }
	}
	return nil
}

func filterIPs(ips []net.IP, filt func(net.IP) bool) []net.IP {
	out := ips[:0]
	for _, ip := range ips {
		if filt(ip) {
			out = append(out, ip)
		}
	}
	return out
}

// MakeJobSetting creates a new JobSettings structure without any global event fields.
func MakeJobSetting(name string) JobSettings {
	return JobSettings{Name: name}
}

// WithFields adds new event fields to a Job. Existing fields will be
// overwritten.
// The fields map will be updated (no copy).
func (s JobSettings) WithFields(m common.MapStr) JobSettings {
	s.AddFields(m)
	return s
}

// AddFields adds new event fields to a Job. Existing fields will be
// overwritten.
func (s *JobSettings) AddFields(m common.MapStr) { addFields(&s.Fields, m) }

// MakeHostJobSettings creates a new HostJobSettings structure without any global
// event fields.
func MakeHostJobSettings(name, host string, ip IPSettings) HostJobSettings {
	return HostJobSettings{Name: name, Host: host, IP: ip}
}

// WithFields adds new event fields to a Job. Existing fields will be
// overwritten.
// The fields map will be updated (no copy).
func (s HostJobSettings) WithFields(m common.MapStr) HostJobSettings {
	s.AddFields(m)
	return s
}

// AddFields adds new event fields to a Job. Existing fields will be
// overwritten.
func (s *HostJobSettings) AddFields(m common.MapStr) { addFields(&s.Fields, m) }

func addFields(to *common.MapStr, m common.MapStr) {
	if m == nil {
		return
	}

	fields := *to
	if fields == nil {
		fields = common.MapStr{}
		*to = fields
	}
	fields.DeepUpdate(m)
}

func (s *HostJobSettings) jobSettings() JobSettings {
	return JobSettings{Name: s.Name, Fields: s.Fields}
}
