/*
Copyright 2017 The Kubernetes Authors.

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 civo

import (
	"context"
	"fmt"
	"os"
	"strings"

	"github.com/civo/civogo"
	log "github.com/sirupsen/logrus"

	"sigs.k8s.io/external-dns/endpoint"
	"sigs.k8s.io/external-dns/pkg/apis/externaldns"
	"sigs.k8s.io/external-dns/plan"
	"sigs.k8s.io/external-dns/provider"
)

// CivoProvider is an implementation of Provider for Civo's DNS.
type CivoProvider struct {
	provider.BaseProvider
	Client       civogo.Client
	domainFilter *endpoint.DomainFilter
	DryRun       bool
}

// CivoChanges All API calls calculated from the plan
type CivoChanges struct {
	Creates []*CivoChangeCreate
	Deletes []*CivoChangeDelete
	Updates []*CivoChangeUpdate
}

// Empty returns true if there are no changes
func (c *CivoChanges) Empty() bool {
	return len(c.Creates) == 0 && len(c.Updates) == 0 && len(c.Deletes) == 0
}

// CivoChangeCreate Civo Domain Record Creates
type CivoChangeCreate struct {
	Domain  civogo.DNSDomain
	Options *civogo.DNSRecordConfig
}

// CivoChangeUpdate Civo Domain Record Updates
type CivoChangeUpdate struct {
	Domain       civogo.DNSDomain
	DomainRecord civogo.DNSRecord
	Options      civogo.DNSRecordConfig
}

// CivoChangeDelete Civo Domain Record Deletes
type CivoChangeDelete struct {
	Domain       civogo.DNSDomain
	DomainRecord civogo.DNSRecord
}

// New creates a Civo provider from the given configuration.
func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {
	return newProvider(domainFilter, cfg.DryRun)
}

// newProvider initializes a new Civo DNS based Provider.
func newProvider(domainFilter *endpoint.DomainFilter, dryRun bool) (*CivoProvider, error) {
	token, ok := os.LookupEnv("CIVO_TOKEN")
	if !ok {
		return nil, fmt.Errorf("no token found")
	}

	// Declare a default region just for the client is not used for anything else
	// as the DNS API is global and not region based
	region := "LON1"

	civoClient, err := civogo.NewClient(token, region)
	if err != nil {
		return nil, err
	}

	userAgent := &civogo.Component{
		Name:    externaldns.UserAgentProduct,
		Version: externaldns.Version,
	}
	civoClient.SetUserAgent(userAgent)

	provider := &CivoProvider{
		Client:       *civoClient,
		domainFilter: domainFilter,
		DryRun:       dryRun,
	}
	return provider, nil
}

// Zones returns the list of hosted zones.
func (p *CivoProvider) Zones(_ context.Context) ([]civogo.DNSDomain, error) {
	zones, err := p.fetchZones()
	if err != nil {
		return nil, err
	}

	return zones, nil
}

// Records returns the list of records in a given zone.
func (p *CivoProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
	zones, err := p.Zones(ctx)
	if err != nil {
		return nil, err
	}

	var endpoints []*endpoint.Endpoint

	for _, zone := range zones {
		records, err := p.fetchRecords(zone.ID)
		if err != nil {
			return nil, err
		}

		for _, r := range records {
			toUpper := strings.ToUpper(string(r.Type))
			if provider.SupportedRecordType(toUpper) {
				name := fmt.Sprintf("%s.%s", r.Name, zone.Name)

				// root name is identified by the empty string and should be
				// translated to zone name for the endpoint entry.
				if r.Name == "" {
					name = zone.Name
				}

				endpoints = append(endpoints, endpoint.NewEndpointWithTTL(name, toUpper, endpoint.TTL(r.TTL), r.Value))
			}
		}
	}

	return endpoints, nil
}

func (p *CivoProvider) fetchRecords(domainID string) ([]civogo.DNSRecord, error) {
	records, err := p.Client.ListDNSRecords(domainID)
	if err != nil {
		return nil, err
	}

	return records, nil
}

func (p *CivoProvider) fetchZones() ([]civogo.DNSDomain, error) {
	var zones []civogo.DNSDomain

	allZones, err := p.Client.ListDNSDomains()
	if err != nil {
		return nil, err
	}

	for _, zone := range allZones {
		if !p.domainFilter.Match(zone.Name) {
			continue
		}

		zones = append(zones, zone)
	}

	return zones, nil
}

// submitChanges takes a zone and a collection of Changes and sends them as a single transaction.
func (p *CivoProvider) submitChanges(changes CivoChanges) error {
	if changes.Empty() {
		log.Info("All records are already up to date")
		return nil
	}

	for _, change := range changes.Creates {
		logFields := log.Fields{
			"Type":     change.Options.Type,
			"Name":     change.Options.Name,
			"Value":    change.Options.Value,
			"Priority": change.Options.Priority,
			"TTL":      change.Options.TTL,
			"action":   "Create",
		}

		log.WithFields(logFields).Info("Creating record.")

		if p.DryRun {
			log.WithFields(logFields).Info("Would create record.")
		} else if _, err := p.Client.CreateDNSRecord(change.Domain.ID, change.Options); err != nil {
			log.WithFields(logFields).Errorf(
				"Failed to Create record: %v",
				err,
			)
		}
	}

	for _, change := range changes.Deletes {
		logFields := log.Fields{
			"Type":     change.DomainRecord.Type,
			"Name":     change.DomainRecord.Name,
			"Value":    change.DomainRecord.Value,
			"Priority": change.DomainRecord.Priority,
			"TTL":      change.DomainRecord.TTL,
			"action":   "Delete",
		}

		log.WithFields(logFields).Info("Deleting record.")

		if p.DryRun {
			log.WithFields(logFields).Info("Would delete record.")
		} else if _, err := p.Client.DeleteDNSRecord(&change.DomainRecord); err != nil {
			log.WithFields(logFields).Errorf(
				"Failed to Delete record: %v",
				err,
			)
		}
	}

	for _, change := range changes.Updates {
		logFields := log.Fields{
			"Type":     change.DomainRecord.Type,
			"Name":     change.DomainRecord.Name,
			"Value":    change.DomainRecord.Value,
			"Priority": change.DomainRecord.Priority,
			"TTL":      change.DomainRecord.TTL,
			"action":   "Update",
		}

		log.WithFields(logFields).Info("Updating record.")

		if p.DryRun {
			log.WithFields(logFields).Info("Would update record.")
		} else if _, err := p.Client.UpdateDNSRecord(&change.DomainRecord, &change.Options); err != nil {
			log.WithFields(logFields).Errorf(
				"Failed to Update record: %v",
				err,
			)
		}
	}

	return nil
}

// processCreateActions return a list of changes to create records.
func processCreateActions(zonesByID map[string]civogo.DNSDomain, recordsByZoneID map[string][]civogo.DNSRecord, createsByZone map[string][]*endpoint.Endpoint, civoChange *CivoChanges) error {
	for zoneID, creates := range createsByZone {
		zone := zonesByID[zoneID]

		if len(creates) == 0 {
			log.WithFields(log.Fields{
				"zoneID":   zoneID,
				"zoneName": zone.Name,
			}).Info("Skipping Zone, no creates found.")
			continue
		}

		records := recordsByZoneID[zoneID]

		// Generate Create
		for _, ep := range creates {
			matchedRecords := getRecordID(records, zone, *ep)

			if len(matchedRecords) != 0 {
				log.WithFields(log.Fields{
					"zoneID":     zoneID,
					"zoneName":   zone.Name,
					"dnsName":    ep.DNSName,
					"recordType": ep.RecordType,
				}).Warn("Records found which should not exist")
			}

			recordType, err := convertRecordType(ep.RecordType)
			if err != nil {
				return err
			}

			for _, target := range ep.Targets {
				civoChange.Creates = append(civoChange.Creates, &CivoChangeCreate{
					Domain: zone,
					Options: &civogo.DNSRecordConfig{
						Value:    target,
						Name:     getStrippedRecordName(zone, *ep),
						Type:     recordType,
						Priority: 0,
						TTL:      int(ep.RecordTTL),
					},
				})
			}
		}
	}

	return nil
}

// processUpdateActions return a list of changes to update records.
func processUpdateActions(zonesByID map[string]civogo.DNSDomain, recordsByZoneID map[string][]civogo.DNSRecord, updatesByZone map[string][]*endpoint.Endpoint, civoChange *CivoChanges) error {
	for zoneID, updates := range updatesByZone {
		zone := zonesByID[zoneID]

		if len(updates) == 0 {
			log.WithFields(log.Fields{
				"zoneID":   zoneID,
				"zoneName": zone.Name,
			}).Debug("Skipping Zone, no updates found.")
			continue
		}

		records := recordsByZoneID[zoneID]

		for _, ep := range updates {
			matchedRecords := getRecordID(records, zone, *ep)
			if len(matchedRecords) == 0 {
				log.WithFields(log.Fields{
					"zoneID":     zoneID,
					"dnsName":    ep.DNSName,
					"zoneName":   zone.Name,
					"recordType": ep.RecordType,
				}).Warn("Update Records not found.")
			}

			recordType, err := convertRecordType(ep.RecordType)
			if err != nil {
				return err
			}

			matchedRecordsByTarget := make(map[string]civogo.DNSRecord)
			for _, record := range matchedRecords {
				matchedRecordsByTarget[record.Value] = record
			}

			for _, target := range ep.Targets {
				if record, ok := matchedRecordsByTarget[target]; ok {
					log.WithFields(log.Fields{
						"zoneID":     zoneID,
						"dnsName":    ep.DNSName,
						"zoneName":   zone.Name,
						"recordType": ep.RecordType,
						"target":     target,
					}).Warn("Updating Existing Target")

					civoChange.Updates = append(civoChange.Updates, &CivoChangeUpdate{
						Domain:       zone,
						DomainRecord: record,
						Options: civogo.DNSRecordConfig{
							Value:    target,
							Name:     getStrippedRecordName(zone, *ep),
							Type:     recordType,
							Priority: 0,
							TTL:      int(ep.RecordTTL),
						},
					})

					delete(matchedRecordsByTarget, target)
				} else {
					// Record did not previously exist, create new 'target'
					log.WithFields(log.Fields{
						"zoneID":     zoneID,
						"dnsName":    ep.DNSName,
						"zoneName":   zone.Name,
						"recordType": ep.RecordType,
						"target":     target,
					}).Warn("Creating New Target")

					civoChange.Creates = append(civoChange.Creates, &CivoChangeCreate{
						Domain: zone,
						Options: &civogo.DNSRecordConfig{
							Value:    target,
							Name:     getStrippedRecordName(zone, *ep),
							Type:     recordType,
							Priority: 0,
							TTL:      int(ep.RecordTTL),
						},
					})
				}
			}

			// Any remaining records have been removed, delete them
			for _, record := range matchedRecordsByTarget {
				log.WithFields(log.Fields{
					"zoneID":     zoneID,
					"dnsName":    ep.DNSName,
					"recordType": ep.RecordType,
					"target":     record.Value,
				}).Warn("Deleting target")

				civoChange.Deletes = append(civoChange.Deletes, &CivoChangeDelete{
					Domain:       zone,
					DomainRecord: record,
				})
			}
		}
	}

	return nil
}

// processDeleteActions return a list of changes to delete records.
func processDeleteActions(zonesByID map[string]civogo.DNSDomain, recordsByZoneID map[string][]civogo.DNSRecord, deletesByZone map[string][]*endpoint.Endpoint, civoChange *CivoChanges) error {
	for zoneID, deletes := range deletesByZone {
		zone := zonesByID[zoneID]

		if len(deletes) == 0 {
			log.WithFields(log.Fields{
				"zoneID":   zoneID,
				"zoneName": zone.Name,
			}).Debug("Skipping Zone, no deletes found.")
			continue
		}

		records := recordsByZoneID[zoneID]

		for _, ep := range deletes {
			matchedRecords := getRecordID(records, zone, *ep)

			if len(matchedRecords) == 0 {
				log.WithFields(log.Fields{
					"zoneID":     zoneID,
					"dnsName":    ep.DNSName,
					"zoneName":   zone.Name,
					"recordType": ep.RecordType,
				}).Warn("Records to Delete not found.")
			}

			for _, record := range matchedRecords {
				civoChange.Deletes = append(civoChange.Deletes, &CivoChangeDelete{
					Domain:       zone,
					DomainRecord: record,
				})
			}
		}
	}
	return nil
}

// ApplyChanges applies a given set of changes in a given zone.
func (p *CivoProvider) ApplyChanges(_ context.Context, changes *plan.Changes) error {
	var civoChange CivoChanges
	recordsByZoneID := make(map[string][]civogo.DNSRecord)

	zones, err := p.fetchZones()

	if err != nil {
		return err
	}

	zonesByID := make(map[string]civogo.DNSDomain)

	zoneNameIDMapper := provider.ZoneIDName{}

	for _, z := range zones {
		zoneNameIDMapper.Add(z.ID, z.Name)
		zonesByID[z.ID] = z
	}

	// Fetch records for each zone
	for _, zone := range zones {
		records, err := p.fetchRecords(zone.ID)

		if err != nil {
			return err
		}

		recordsByZoneID[zone.ID] = append(recordsByZoneID[zone.ID], records...)
	}

	createsByZone := endpointsByZone(zoneNameIDMapper, changes.Create)
	updatesByZone := endpointsByZone(zoneNameIDMapper, changes.UpdateNew)
	deletesByZone := endpointsByZone(zoneNameIDMapper, changes.Delete)

	// Generate Creates
	err = processCreateActions(zonesByID, recordsByZoneID, createsByZone, &civoChange)
	if err != nil {
		return err
	}

	// Generate Updates
	err = processUpdateActions(zonesByID, recordsByZoneID, updatesByZone, &civoChange)
	if err != nil {
		return err
	}

	// Generate Deletes
	err = processDeleteActions(zonesByID, recordsByZoneID, deletesByZone, &civoChange)
	if err != nil {
		return err
	}

	return p.submitChanges(civoChange)
}

func endpointsByZone(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) map[string][]*endpoint.Endpoint {
	endpointsByZone := make(map[string][]*endpoint.Endpoint)

	for _, ep := range endpoints {
		zoneID, _ := zoneNameIDMapper.FindZone(ep.DNSName)
		if zoneID == "" {
			log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", ep.DNSName)
			continue
		}
		endpointsByZone[zoneID] = append(endpointsByZone[zoneID], ep)
	}

	return endpointsByZone
}

func convertRecordType(recordType string) (civogo.DNSRecordType, error) {
	switch recordType {
	case "A":
		return civogo.DNSRecordTypeA, nil
	case "CNAME":
		return civogo.DNSRecordTypeCName, nil
	case "TXT":
		return civogo.DNSRecordTypeTXT, nil
	case "SRV":
		return civogo.DNSRecordTypeSRV, nil
	default:
		return "", fmt.Errorf("invalid Record Type: %s", recordType)
	}
}

func getStrippedRecordName(zone civogo.DNSDomain, ep endpoint.Endpoint) string {
	if ep.DNSName == zone.Name {
		return ""
	}

	return strings.TrimSuffix(ep.DNSName, "."+zone.Name)
}

func getRecordID(records []civogo.DNSRecord, zone civogo.DNSDomain, ep endpoint.Endpoint) []civogo.DNSRecord {
	var matchedRecords []civogo.DNSRecord

	for _, record := range records {
		stripedName := getStrippedRecordName(zone, ep)
		toUpper := strings.ToUpper(string(record.Type))
		if record.Name == stripedName && toUpper == ep.RecordType {
			matchedRecords = append(matchedRecords, record)
		}
	}

	return matchedRecords
}
