/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF 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
 *
 *   https://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 ads

import (
	"context"
	"encoding/binary"
	"fmt"
	"net"
	"net/url"
	"runtime/debug"
	"strconv"
	"sync"
	"time"

	"github.com/pkg/errors"
	"github.com/rs/zerolog"

	apiModel "github.com/apache/plc4x/plc4go/pkg/api/model"
	apiValues "github.com/apache/plc4x/plc4go/pkg/api/values"
	"github.com/apache/plc4x/plc4go/protocols/ads/discovery/readwrite/model"
	driverModel "github.com/apache/plc4x/plc4go/protocols/ads/readwrite/model"
	spiModel "github.com/apache/plc4x/plc4go/spi/model"
	"github.com/apache/plc4x/plc4go/spi/options"
	"github.com/apache/plc4x/plc4go/spi/utils"
	spiValues "github.com/apache/plc4x/plc4go/spi/values"
)

type discovery struct {
	interf           net.Interface
	localAddress     net.IP
	broadcastAddress net.IP
	socket           *net.UDPConn
}

type Discoverer struct {
	wg sync.WaitGroup // use to track spawned go routines

	passLogToModel bool
	log            zerolog.Logger
}

func NewDiscoverer(_options ...options.WithOption) *Discoverer {
	passLoggerToModel, _ := options.ExtractPassLoggerToModel(_options...)
	customLogger := options.ExtractCustomLoggerOrDefaultToGlobal(_options...)
	return &Discoverer{
		passLogToModel: passLoggerToModel,
		log:            customLogger,
	}
}

func (d *Discoverer) Discover(ctx context.Context, callback func(event apiModel.PlcDiscoveryItem), discoveryOptions ...options.WithDiscoveryOption) error {
	////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
	// Find out which interfaces to use for sending out search requests

	allInterfaces, err := net.Interfaces()
	if err != nil {
		return err
	}

	// If no device is explicitly selected via option, simply use all of them
	// However if a discovery option is present to select a device by name, only
	// add those devices matching any of the given names.
	var interfaces []net.Interface
	deviceNames := options.FilterDiscoveryOptionsDeviceName(discoveryOptions)
	if len(deviceNames) > 0 {
		for _, curInterface := range allInterfaces {
			if err := ctx.Err(); err != nil {
				return err
			}
			for _, deviceNameOption := range deviceNames {
				if err := ctx.Err(); err != nil {
					return err
				}
				if curInterface.Name == deviceNameOption.GetDeviceName() {
					interfaces = append(interfaces, curInterface)
					break
				}
			}
		}
	} else {
		interfaces = allInterfaces
	}

	// Iterate over all selected network devices and filter out all the devices with IPv4 configured
	var discoveryItems []*discovery
	for _, interf := range interfaces {
		if err := ctx.Err(); err != nil {
			return err
		}
		addrs, err := interf.Addrs()
		if err != nil {
			return err
		}
		// Iterate over all addresses the current interface has configured
		// For ADS we're only interested in IPv4 addresses, as it doesn't
		// seem to work with IPv6.
		for _, addr := range addrs {
			if err := ctx.Err(); err != nil {
				return err
			}
			var ipv4Addr net.IP
			switch addr.(type) {
			// If the device is configured to communicate with a subnet
			case *net.IPNet:
				ipv4Addr = addr.(*net.IPNet).IP.To4()

			// If the device is configured for a point-to-point connection
			case *net.IPAddr:
				ipv4Addr = addr.(*net.IPAddr).IP.To4()
			}

			// If we found an IPv4 address and this is not a loopback address,
			// add it to the list of devices we will open ports and send discovery
			// messages from.
			if ipv4Addr == nil || ipv4Addr.IsLoopback() {
				continue
			}

			// Calculate the broadcast address for this interface
			broadcastAddress := make(net.IP, len(ipv4Addr))
			binary.BigEndian.PutUint32(broadcastAddress, binary.BigEndian.Uint32(ipv4Addr)|^binary.BigEndian.Uint32(net.IP(addr.(*net.IPNet).Mask).To4()))

			// Add the item to the list.
			discoveryItems = append(discoveryItems, &discovery{
				interf:           interf,
				localAddress:     ipv4Addr,
				broadcastAddress: broadcastAddress,
				socket:           nil,
			})
		}
	}

	////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
	// Set up a listening socket on all devices for processing the responses to any search requests

	// Open a listening udp socket for each of the discoveryItems
	for _, discoveryItem := range discoveryItems {
		if err := ctx.Err(); err != nil {
			return err
		}
		responseAddr, err := net.ResolveUDPAddr("udp4", fmt.Sprintf("%s:%d", discoveryItem.localAddress, model.AdsDiscoveryConstants_ADSDISCOVERYUDPDEFAULTPORT))
		if err != nil {
			return errors.Wrap(err, "error resolving udp")
		}
		socket, err := net.ListenUDP("udp4", responseAddr)
		if err != nil {
			return errors.Wrap(err, "error listening udp")
		}
		discoveryItem.socket = socket

		// Start a worker to receive responses
		d.wg.Add(1)
		go func(discoveryItem *discovery) {
			defer d.wg.Done()
			defer func() {
				if err := recover(); err != nil {
					d.log.Error().
						Str("stack", string(debug.Stack())).
						Interface("err", err).
						Msg("panic-ed")
				}
			}()
			buf := make([]byte, 1024)
			for {
				if err := ctx.Err(); err != nil {
					d.log.Debug().Err(ctx.Err()).Msg("ending")
					return
				}
				length, fromAddr, err := socket.ReadFromUDP(buf)
				if length == 0 {
					continue
				}
				ctxForModel := options.GetLoggerContextForModel(ctx, d.log, options.WithPassLoggerToModel(d.passLogToModel))
				discoveryResponse, err := model.AdsDiscoveryParse(ctxForModel, buf[0:length])
				if err != nil {
					d.log.Error().Err(err).Str("src-ip", fromAddr.String()).Msg("error decoding response")
					continue
				}

				if discoveryResponse.GetRequestId() != 0 ||
					discoveryResponse.GetPortNumber() != model.AdsPortNumbers_SYSTEM_SERVICE ||
					discoveryResponse.GetOperation() != model.Operation_DISCOVERY_RESPONSE {
					continue
				}

				remoteAmsNetId := discoveryResponse.GetAmsNetId()
				var hostNameBlock model.AdsDiscoveryBlockHostName
				//var osDataBlock model.AdsDiscoveryBlockOsData
				var versionBlock model.AdsDiscoveryBlockVersion
				var fingerprintBlock model.AdsDiscoveryBlockFingerprint
				for _, block := range discoveryResponse.GetBlocks() {
					if err := ctx.Err(); err != nil {
						d.log.Debug().Err(err).Msg("ending")
						return
					}
					switch block.GetBlockType() {
					case model.AdsDiscoveryBlockType_HOST_NAME:
						hostNameBlock = block.(model.AdsDiscoveryBlockHostName)
						/*									case model.AdsDiscoveryBlockType_OS_DATA:
															osDataBlock = block.(model.AdsDiscoveryBlockOsData)*/
					case model.AdsDiscoveryBlockType_VERSION:
						versionBlock = block.(model.AdsDiscoveryBlockVersion)
					case model.AdsDiscoveryBlockType_FINGERPRINT:
						fingerprintBlock = block.(model.AdsDiscoveryBlockFingerprint)
					}
				}

				if hostNameBlock == nil {
					continue
				}

				opts := make(map[string][]string)
				opts["sourceAmsNetId"] = []string{discoveryItem.localAddress.String() + ".1.1"}
				opts["sourceAmsPort"] = []string{"65534"}
				opts["targetAmsNetId"] = []string{strconv.Itoa(int(remoteAmsNetId.GetOctet1())) + "." +
					strconv.Itoa(int(remoteAmsNetId.GetOctet2())) + "." +
					strconv.Itoa(int(remoteAmsNetId.GetOctet3())) + "." +
					strconv.Itoa(int(remoteAmsNetId.GetOctet4())) + "." +
					strconv.Itoa(int(remoteAmsNetId.GetOctet5())) + "." +
					strconv.Itoa(int(remoteAmsNetId.GetOctet6()))}
				// TODO: Check if this is legit, or if we can get the information from somewhere.
				opts["targetAmsPort"] = []string{"851"}

				attributes := make(map[string]apiValues.PlcValue)
				attributes["hostName"] = spiValues.NewPlcSTRING(hostNameBlock.GetHostName().GetText())
				if versionBlock != nil {
					versionData := versionBlock.GetVersionData()
					patchVersion := (int(versionData[3])&0xFF)<<8 | (int(versionData[2]) & 0xFF)
					attributes["twinCatVersion"] = spiValues.NewPlcSTRING(fmt.Sprintf("%d.%d.%d", int(versionData[0])&0xFF, int(versionData[1])&0xFF, patchVersion))
				}
				if fingerprintBlock != nil {
					attributes["fingerprint"] = spiValues.NewPlcSTRING(string(fingerprintBlock.GetData()))
				}
				// TODO: Find out how to handle the OS Data

				// Add an entry to the results.
				remoteAddress, err2 := url.Parse("udp://" + strconv.Itoa(int(remoteAmsNetId.GetOctet1())) + "." +
					strconv.Itoa(int(remoteAmsNetId.GetOctet2())) + "." +
					strconv.Itoa(int(remoteAmsNetId.GetOctet3())) + "." +
					strconv.Itoa(int(remoteAmsNetId.GetOctet4())) + ":" +
					strconv.Itoa(int(driverModel.AdsConstants_ADSTCPDEFAULTPORT)))
				if err2 == nil {
					plcDiscoveryItem := spiModel.NewDefaultPlcDiscoveryItem(
						"ads",
						"tcp",
						*remoteAddress,
						opts,
						hostNameBlock.GetHostName().GetText(),
						attributes,
					)

					// Pass the event back to the callback
					callback(plcDiscoveryItem)
				}
			}
		}(discoveryItem)
	}
	defer func() {
		for _, discoveryItem := range discoveryItems {
			if err := ctx.Err(); err != nil {
				d.log.Debug().Err(err).Msg("ending")
				return
			}
			if discoveryItem.socket != nil {
				if err := discoveryItem.socket.Close(); err != nil {
					d.log.Debug().Err(err).Msg("errored")
				}
			}
		}
	}()

	////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
	// Send out search requests on all selected interfaces

	// Iterate over all network devices of this system.
	for _, discoveryItem := range discoveryItems {
		if err := ctx.Err(); err != nil {
			return err
		}
		// Prepare the discovery packet data
		// Create the discovery request message for this device.
		amsNetId := model.NewAmsNetId(discoveryItem.localAddress[0], discoveryItem.localAddress[1], discoveryItem.localAddress[2], discoveryItem.localAddress[3], uint8(1), uint8(1))
		discoveryRequestMessage := model.NewAdsDiscovery(0, model.Operation_DISCOVERY_REQUEST, amsNetId, model.AdsPortNumbers_SYSTEM_SERVICE, []model.AdsDiscoveryBlock{})

		// Serialize the message
		bytes, err := discoveryRequestMessage.Serialize()
		if err != nil {
			d.log.Error().Err(err).Str("broadcast-ip", discoveryItem.broadcastAddress.String()).Msg("Error serialising broadcast search packet")
			continue
		}

		// Create a not-connected UDP connection to the broadcast address
		requestAddr, err := net.ResolveUDPAddr("udp4", fmt.Sprintf("%s:%d", discoveryItem.broadcastAddress.String(), model.AdsDiscoveryConstants_ADSDISCOVERYUDPDEFAULTPORT))
		if err != nil {
			d.log.Error().Err(err).Str("broadcast-ip", discoveryItem.broadcastAddress.String()).Msg("Error resolving target socket for broadcast search")
			continue
		}
		/*localAddr, err := net.ResolveUDPAddr("udp4", fmt.Sprintf("%s:%d", ipv4Addr.String(), model.AdsDiscoveryConstants_ADSDISCOVERYUDPDEFAULTPORT))
		if err != nil {
		m.log.Error().Err(err).Str("local-ip", ipv4Addr.String()).Msg("Error resolving local address for broadcast search")
			continue
		}
		udp, err := net.DialUDP("udp4", localAddr, requestAddr)
		if err != nil {
		m.log.Error().Err(err).Str("local-ip", ipv4Addr.String()).Str("broadcast-ip", broadcastAddress.String()).
				Msg("Error creating sending udp socket for broadcast search")
			continue
		}*/

		// Send out the message.
		_, err = discoveryItem.socket.WriteTo(bytes, requestAddr)
		if err != nil {
			d.log.Error().Err(err).Str("broadcast-ip", discoveryItem.broadcastAddress.String()).Msg("Error sending request for broadcast search")
			continue
		}
	}

	time.Sleep(time.Second * 10)
	return nil
}

func (d *Discoverer) Close() error {
	defer utils.StopWarn(d.log)()
	d.log.Trace().Msg("Waiting for goroutines to stop")
	d.wg.Wait()
	return nil
}
