/*
Copyright 2022 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 ibmcloud

import (
	"context"
	"fmt"
	"os"
	"reflect"
	"strconv"
	"strings"

	"github.com/IBM-Cloud/ibm-cloud-cli-sdk/bluemix/crn"
	"github.com/IBM/go-sdk-core/v5/core"
	"github.com/IBM/networking-go-sdk/dnsrecordsv1"
	"github.com/IBM/networking-go-sdk/dnssvcsv1"
	"github.com/IBM/networking-go-sdk/zonesv1"
	"gopkg.in/yaml.v2"

	log "github.com/sirupsen/logrus"

	"sigs.k8s.io/external-dns/endpoint"
	"sigs.k8s.io/external-dns/plan"
	"sigs.k8s.io/external-dns/provider"
	"sigs.k8s.io/external-dns/source"
)

var proxyTypeNotSupported = map[string]bool{
	"LOC": true,
	"MX":  true,
	"NS":  true,
	"SPF": true,
	"TXT": true,
	"SRV": true,
}

var privateTypeSupported = map[string]bool{
	"A":     true,
	"CNAME": true,
	"TXT":   true,
}

const (
	// recordCreate is a ChangeAction enum value
	recordCreate = "CREATE"
	// recordDelete is a ChangeAction enum value
	recordDelete = "DELETE"
	// recordUpdate is a ChangeAction enum value
	recordUpdate = "UPDATE"
	// defaultPublicRecordTTL 1 = automatic
	defaultPublicRecordTTL = 1

	proxyFilter             = "ibmcloud-proxied"
	vpcFilter               = "ibmcloud-vpc"
	zoneStatePendingNetwork = "PENDING_NETWORK_ADD"
	zoneStateActive         = "ACTIVE"
)

// Source shadow the interface source.Source. used primarily for unit testing.
type Source interface {
	Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error)
	AddEventHandler(context.Context, func())
}

// ibmcloudClient is a minimal implementation of DNS API that we actually use, used primarily for unit testing.
type ibmcloudClient interface {
	ListAllDDNSRecordsWithContext(ctx context.Context, listAllDNSRecordsOptions *dnsrecordsv1.ListAllDnsRecordsOptions) (result *dnsrecordsv1.ListDnsrecordsResp, response *core.DetailedResponse, err error)
	CreateDNSRecordWithContext(ctx context.Context, createDNSRecordOptions *dnsrecordsv1.CreateDnsRecordOptions) (result *dnsrecordsv1.DnsrecordResp, response *core.DetailedResponse, err error)
	DeleteDNSRecordWithContext(ctx context.Context, deleteDNSRecordOptions *dnsrecordsv1.DeleteDnsRecordOptions) (result *dnsrecordsv1.DeleteDnsrecordResp, response *core.DetailedResponse, err error)
	UpdateDNSRecordWithContext(ctx context.Context, updateDNSRecordOptions *dnsrecordsv1.UpdateDnsRecordOptions) (result *dnsrecordsv1.DnsrecordResp, response *core.DetailedResponse, err error)
	ListDnszonesWithContext(ctx context.Context, listDnszonesOptions *dnssvcsv1.ListDnszonesOptions) (result *dnssvcsv1.ListDnszones, response *core.DetailedResponse, err error)
	GetDnszoneWithContext(ctx context.Context, getDnszoneOptions *dnssvcsv1.GetDnszoneOptions) (result *dnssvcsv1.Dnszone, response *core.DetailedResponse, err error)
	CreatePermittedNetworkWithContext(ctx context.Context, createPermittedNetworkOptions *dnssvcsv1.CreatePermittedNetworkOptions) (result *dnssvcsv1.PermittedNetwork, response *core.DetailedResponse, err error)
	ListResourceRecordsWithContext(ctx context.Context, listResourceRecordsOptions *dnssvcsv1.ListResourceRecordsOptions) (result *dnssvcsv1.ListResourceRecords, response *core.DetailedResponse, err error)
	CreateResourceRecordWithContext(ctx context.Context, createResourceRecordOptions *dnssvcsv1.CreateResourceRecordOptions) (result *dnssvcsv1.ResourceRecord, response *core.DetailedResponse, err error)
	DeleteResourceRecordWithContext(ctx context.Context, deleteResourceRecordOptions *dnssvcsv1.DeleteResourceRecordOptions) (response *core.DetailedResponse, err error)
	UpdateResourceRecordWithContext(ctx context.Context, updateResourceRecordOptions *dnssvcsv1.UpdateResourceRecordOptions) (result *dnssvcsv1.ResourceRecord, response *core.DetailedResponse, err error)
	NewResourceRecordInputRdataRdataARecord(ip string) (model *dnssvcsv1.ResourceRecordInputRdataRdataARecord, err error)
	NewResourceRecordInputRdataRdataCnameRecord(cname string) (model *dnssvcsv1.ResourceRecordInputRdataRdataCnameRecord, err error)
	NewResourceRecordInputRdataRdataTxtRecord(text string) (model *dnssvcsv1.ResourceRecordInputRdataRdataTxtRecord, err error)
	NewResourceRecordUpdateInputRdataRdataARecord(ip string) (model *dnssvcsv1.ResourceRecordUpdateInputRdataRdataARecord, err error)
	NewResourceRecordUpdateInputRdataRdataCnameRecord(cname string) (model *dnssvcsv1.ResourceRecordUpdateInputRdataRdataCnameRecord, err error)
	NewResourceRecordUpdateInputRdataRdataTxtRecord(text string) (model *dnssvcsv1.ResourceRecordUpdateInputRdataRdataTxtRecord, err error)
}

type ibmcloudService struct {
	publicZonesService   *zonesv1.ZonesV1
	publicRecordsService *dnsrecordsv1.DnsRecordsV1
	privateDNSService    *dnssvcsv1.DnsSvcsV1
}

func (i ibmcloudService) ListAllDDNSRecordsWithContext(ctx context.Context, listAllDNSRecordsOptions *dnsrecordsv1.ListAllDnsRecordsOptions) (result *dnsrecordsv1.ListDnsrecordsResp, response *core.DetailedResponse, err error) {
	return i.publicRecordsService.ListAllDnsRecordsWithContext(ctx, listAllDNSRecordsOptions)
}

func (i ibmcloudService) CreateDNSRecordWithContext(ctx context.Context, createDNSRecordOptions *dnsrecordsv1.CreateDnsRecordOptions) (result *dnsrecordsv1.DnsrecordResp, response *core.DetailedResponse, err error) {
	return i.publicRecordsService.CreateDnsRecordWithContext(ctx, createDNSRecordOptions)
}

func (i ibmcloudService) DeleteDNSRecordWithContext(ctx context.Context, deleteDNSRecordOptions *dnsrecordsv1.DeleteDnsRecordOptions) (result *dnsrecordsv1.DeleteDnsrecordResp, response *core.DetailedResponse, err error) {
	return i.publicRecordsService.DeleteDnsRecordWithContext(ctx, deleteDNSRecordOptions)
}

func (i ibmcloudService) UpdateDNSRecordWithContext(ctx context.Context, updateDNSRecordOptions *dnsrecordsv1.UpdateDnsRecordOptions) (result *dnsrecordsv1.DnsrecordResp, response *core.DetailedResponse, err error) {
	return i.publicRecordsService.UpdateDnsRecordWithContext(ctx, updateDNSRecordOptions)
}

func (i ibmcloudService) ListDnszonesWithContext(ctx context.Context, listDnszonesOptions *dnssvcsv1.ListDnszonesOptions) (result *dnssvcsv1.ListDnszones, response *core.DetailedResponse, err error) {
	return i.privateDNSService.ListDnszonesWithContext(ctx, listDnszonesOptions)
}

func (i ibmcloudService) GetDnszoneWithContext(ctx context.Context, getDnszoneOptions *dnssvcsv1.GetDnszoneOptions) (result *dnssvcsv1.Dnszone, response *core.DetailedResponse, err error) {
	return i.privateDNSService.GetDnszoneWithContext(ctx, getDnszoneOptions)
}

func (i ibmcloudService) CreatePermittedNetworkWithContext(ctx context.Context, createPermittedNetworkOptions *dnssvcsv1.CreatePermittedNetworkOptions) (result *dnssvcsv1.PermittedNetwork, response *core.DetailedResponse, err error) {
	return i.privateDNSService.CreatePermittedNetworkWithContext(ctx, createPermittedNetworkOptions)
}

func (i ibmcloudService) ListResourceRecordsWithContext(ctx context.Context, listResourceRecordsOptions *dnssvcsv1.ListResourceRecordsOptions) (result *dnssvcsv1.ListResourceRecords, response *core.DetailedResponse, err error) {
	return i.privateDNSService.ListResourceRecordsWithContext(ctx, listResourceRecordsOptions)
}

func (i ibmcloudService) CreateResourceRecordWithContext(ctx context.Context, createResourceRecordOptions *dnssvcsv1.CreateResourceRecordOptions) (result *dnssvcsv1.ResourceRecord, response *core.DetailedResponse, err error) {
	return i.privateDNSService.CreateResourceRecordWithContext(ctx, createResourceRecordOptions)
}

func (i ibmcloudService) DeleteResourceRecordWithContext(ctx context.Context, deleteResourceRecordOptions *dnssvcsv1.DeleteResourceRecordOptions) (response *core.DetailedResponse, err error) {
	return i.privateDNSService.DeleteResourceRecordWithContext(ctx, deleteResourceRecordOptions)
}

func (i ibmcloudService) UpdateResourceRecordWithContext(ctx context.Context, updateResourceRecordOptions *dnssvcsv1.UpdateResourceRecordOptions) (result *dnssvcsv1.ResourceRecord, response *core.DetailedResponse, err error) {
	return i.privateDNSService.UpdateResourceRecordWithContext(ctx, updateResourceRecordOptions)
}

func (i ibmcloudService) NewResourceRecordInputRdataRdataARecord(ip string) (model *dnssvcsv1.ResourceRecordInputRdataRdataARecord, err error) {
	return i.privateDNSService.NewResourceRecordInputRdataRdataARecord(ip)
}

func (i ibmcloudService) NewResourceRecordInputRdataRdataCnameRecord(cname string) (model *dnssvcsv1.ResourceRecordInputRdataRdataCnameRecord, err error) {
	return i.privateDNSService.NewResourceRecordInputRdataRdataCnameRecord(cname)
}

func (i ibmcloudService) NewResourceRecordInputRdataRdataTxtRecord(text string) (model *dnssvcsv1.ResourceRecordInputRdataRdataTxtRecord, err error) {
	return i.privateDNSService.NewResourceRecordInputRdataRdataTxtRecord(text)
}

func (i ibmcloudService) NewResourceRecordUpdateInputRdataRdataARecord(ip string) (model *dnssvcsv1.ResourceRecordUpdateInputRdataRdataARecord, err error) {
	return i.privateDNSService.NewResourceRecordUpdateInputRdataRdataARecord(ip)
}

func (i ibmcloudService) NewResourceRecordUpdateInputRdataRdataCnameRecord(cname string) (model *dnssvcsv1.ResourceRecordUpdateInputRdataRdataCnameRecord, err error) {
	return i.privateDNSService.NewResourceRecordUpdateInputRdataRdataCnameRecord(cname)
}

func (i ibmcloudService) NewResourceRecordUpdateInputRdataRdataTxtRecord(text string) (model *dnssvcsv1.ResourceRecordUpdateInputRdataRdataTxtRecord, err error) {
	return i.privateDNSService.NewResourceRecordUpdateInputRdataRdataTxtRecord(text)
}

// IBMCloudProvider is an implementation of Provider for IBM Cloud DNS.
type IBMCloudProvider struct {
	provider.BaseProvider
	source Source
	Client ibmcloudClient
	// only consider hosted zones managing domains ending in this suffix
	domainFilter     endpoint.DomainFilter
	zoneIDFilter     provider.ZoneIDFilter
	instanceID       string
	privateZone      bool
	proxiedByDefault bool
	DryRun           bool
}

type ibmcloudConfig struct {
	Endpoint   string `json:"endpoint" yaml:"endpoint"`
	APIKey     string `json:"apiKey" yaml:"apiKey"`
	CRN        string `json:"instanceCrn" yaml:"instanceCrn"`
	IAMURL     string `json:"iamUrl" yaml:"iamUrl"`
	InstanceID string `json:"-" yaml:"-"`
}

// ibmcloudChange differentiates between ChangActions
type ibmcloudChange struct {
	Action                string
	PublicResourceRecord  dnsrecordsv1.DnsrecordDetails
	PrivateResourceRecord dnssvcsv1.ResourceRecord
}

func getConfig(configFile string) (*ibmcloudConfig, error) {
	contents, err := os.ReadFile(configFile)
	if err != nil {
		return nil, fmt.Errorf("failed to read IBM Cloud config file '%s': %v", configFile, err)
	}
	cfg := &ibmcloudConfig{}
	err = yaml.Unmarshal(contents, &cfg)
	if err != nil {
		return nil, fmt.Errorf("failed to read IBM Cloud config file '%s': %v", configFile, err)
	}

	return cfg, nil
}

func (c *ibmcloudConfig) Validate(authenticator core.Authenticator, domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter) (ibmcloudService, bool, error) {
	var service ibmcloudService
	isPrivate := false
	log.Debugf("filters: %v, %v", domainFilter.Filters, zoneIDFilter.ZoneIDs)
	if (len(domainFilter.Filters) == 0 || domainFilter.Filters[0] == "") && zoneIDFilter.ZoneIDs[0] == "" {
		return service, isPrivate, fmt.Errorf("at lease one of filters: 'domain-filter', 'zone-id-filter' needed")
	}

	crn, err := crn.Parse(c.CRN)
	if err != nil {
		return service, isPrivate, err
	}
	log.Infof("IBM Cloud Service: %s", crn.ServiceName)
	c.InstanceID = crn.ServiceInstance

	switch {
	case strings.Contains(crn.ServiceName, "internet-svcs"):
		if len(domainFilter.Filters) > 1 || len(zoneIDFilter.ZoneIDs) > 1 {
			return service, isPrivate, fmt.Errorf("for public zone, only one domain id filter or domain name filter allowed")
		}
		var zoneID string
		// Public DNS service
		service.publicZonesService, err = zonesv1.NewZonesV1(&zonesv1.ZonesV1Options{
			Authenticator: authenticator,
			Crn:           core.StringPtr(c.CRN),
		})
		if err != nil {
			return service, isPrivate, fmt.Errorf("failed to initialize ibmcloud public zones client: %v", err)
		}
		if c.Endpoint != "" {
			service.publicZonesService.SetServiceURL(c.Endpoint)
		}

		zonesResp, _, err := service.publicZonesService.ListZones(&zonesv1.ListZonesOptions{})
		if err != nil {
			return service, isPrivate, fmt.Errorf("failed to list ibmcloud public zones: %v", err)
		}
		for _, zone := range zonesResp.Result {
			log.Debugf("zoneName: %s, zoneID: %s", *zone.Name, *zone.ID)
			if len(domainFilter.Filters) > 0 && domainFilter.Filters[0] != "" && domainFilter.Match(*zone.Name) {
				log.Debugf("zone %s found.", *zone.ID)
				zoneID = *zone.ID
				break
			}
			if len(zoneIDFilter.ZoneIDs[0]) != 0 && zoneIDFilter.Match(*zone.ID) {
				log.Debugf("zone %s found.", *zone.ID)
				zoneID = *zone.ID
				break
			}
		}
		if len(zoneID) == 0 {
			return service, isPrivate, fmt.Errorf("no matched zone found")
		}

		service.publicRecordsService, err = dnsrecordsv1.NewDnsRecordsV1(&dnsrecordsv1.DnsRecordsV1Options{
			Authenticator:  authenticator,
			Crn:            core.StringPtr(c.CRN),
			ZoneIdentifier: core.StringPtr(zoneID),
		})
		if err != nil {
			return service, isPrivate, fmt.Errorf("failed to initialize ibmcloud public records client: %v", err)
		}
		if c.Endpoint != "" {
			service.publicRecordsService.SetServiceURL(c.Endpoint)
		}
	case strings.Contains(crn.ServiceName, "dns-svcs"):
		isPrivate = true
		// Private DNS service
		service.privateDNSService, err = dnssvcsv1.NewDnsSvcsV1(&dnssvcsv1.DnsSvcsV1Options{
			Authenticator: authenticator,
		})
		if err != nil {
			return service, isPrivate, fmt.Errorf("failed to initialize ibmcloud private records client: %v", err)
		}
		if c.Endpoint != "" {
			service.privateDNSService.SetServiceURL(c.Endpoint)
		}
	default:
		return service, isPrivate, fmt.Errorf("IBM Cloud instance crn is not provided or invalid dns crn : %s", c.CRN)
	}

	return service, isPrivate, nil
}

// NewIBMCloudProvider creates a new IBMCloud provider.
//
// Returns the provider or an error if a provider could not be created.
func NewIBMCloudProvider(configFile string, domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, source source.Source, proxiedByDefault bool, dryRun bool) (*IBMCloudProvider, error) {
	cfg, err := getConfig(configFile)
	if err != nil {
		return nil, err
	}

	authenticator := &core.IamAuthenticator{
		ApiKey: cfg.APIKey,
	}
	if cfg.IAMURL != "" {
		authenticator = &core.IamAuthenticator{
			ApiKey: cfg.APIKey,
			URL:    cfg.IAMURL,
		}
	}

	client, isPrivate, err := cfg.Validate(authenticator, domainFilter, zoneIDFilter)
	if err != nil {
		return nil, err
	}

	provider := &IBMCloudProvider{
		Client:           client,
		source:           source,
		domainFilter:     domainFilter,
		zoneIDFilter:     zoneIDFilter,
		instanceID:       cfg.InstanceID,
		privateZone:      isPrivate,
		proxiedByDefault: proxiedByDefault,
		DryRun:           dryRun,
	}
	return provider, nil
}

// Records gets the current records.
//
// Returns the current records or an error if the operation failed.
func (p *IBMCloudProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, err error) {
	if p.privateZone {
		endpoints, err = p.privateRecords(ctx)
	} else {
		endpoints, err = p.publicRecords(ctx)
	}
	return endpoints, err
}

// ApplyChanges applies a given set of changes in a given zone.
func (p *IBMCloudProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
	log.Debugln("applying change...")
	ibmcloudChanges := []*ibmcloudChange{}
	for _, endpoint := range changes.Create {
		for _, target := range endpoint.Targets {
			ibmcloudChanges = append(ibmcloudChanges, p.newIBMCloudChange(recordCreate, endpoint, target))
		}
	}

	for i, desired := range changes.UpdateNew {
		current := changes.UpdateOld[i]

		add, remove, leave := provider.Difference(current.Targets, desired.Targets)

		log.Debugf("add: %v, remove: %v, leave: %v", add, remove, leave)
		for _, a := range add {
			ibmcloudChanges = append(ibmcloudChanges, p.newIBMCloudChange(recordCreate, desired, a))
		}

		for _, a := range leave {
			ibmcloudChanges = append(ibmcloudChanges, p.newIBMCloudChange(recordUpdate, desired, a))
		}

		for _, a := range remove {
			ibmcloudChanges = append(ibmcloudChanges, p.newIBMCloudChange(recordDelete, current, a))
		}
	}

	for _, endpoint := range changes.Delete {
		for _, target := range endpoint.Targets {
			ibmcloudChanges = append(ibmcloudChanges, p.newIBMCloudChange(recordDelete, endpoint, target))
		}
	}

	return p.submitChanges(ctx, ibmcloudChanges)
}

// AdjustEndpoints modifies the endpoints as needed by the specific provider
func (p *IBMCloudProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {
	adjustedEndpoints := []*endpoint.Endpoint{}
	for _, e := range endpoints {
		log.Debugf("adjusting endpont: %v", *e)
		proxied := shouldBeProxied(e, p.proxiedByDefault)
		if proxied {
			e.RecordTTL = 0
		}
		e.SetProviderSpecificProperty(proxyFilter, strconv.FormatBool(proxied))

		adjustedEndpoints = append(adjustedEndpoints, e)
	}
	return adjustedEndpoints, nil
}

// submitChanges takes a zone and a collection of Changes and sends them as a single transaction.
func (p *IBMCloudProvider) submitChanges(ctx context.Context, changes []*ibmcloudChange) error {
	// return early if there is nothing to change
	if len(changes) == 0 {
		return nil
	}

	log.Debugln("submmiting change...")
	if p.privateZone {
		return p.submitChangesForPrivateDNS(ctx, changes)
	}
	return p.submitChangesForPublicDNS(ctx, changes)
}

// submitChangesForPublicDNS takes a zone and a collection of Changes and sends them as a single transaction on public dns.
func (p *IBMCloudProvider) submitChangesForPublicDNS(ctx context.Context, changes []*ibmcloudChange) error {
	records, err := p.listAllPublicRecords(ctx)
	if err != nil {
		return err
	}

	for _, change := range changes {
		logFields := log.Fields{
			"record": *change.PublicResourceRecord.Name,
			"type":   *change.PublicResourceRecord.Type,
			"ttl":    *change.PublicResourceRecord.TTL,
			"action": change.Action,
		}

		if p.DryRun {
			continue
		}

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

		if change.Action == recordUpdate {
			recordID := p.getPublicRecordID(records, change.PublicResourceRecord)
			if recordID == "" {
				log.WithFields(logFields).Errorf("failed to find previous record: %v", *change.PublicResourceRecord.Name)
				continue
			}
			p.updateRecord(ctx, "", recordID, change)
		} else if change.Action == recordDelete {
			recordID := p.getPublicRecordID(records, change.PublicResourceRecord)
			if recordID == "" {
				log.WithFields(logFields).Errorf("failed to find previous record: %v", *change.PublicResourceRecord.Name)
				continue
			}
			p.deleteRecord(ctx, "", recordID)
		} else if change.Action == recordCreate {
			p.createRecord(ctx, "", change)
		}
	}

	return nil
}

// submitChangesForPrivateDNS takes a zone and a collection of Changes and sends them as a single transaction on private dns.
func (p *IBMCloudProvider) submitChangesForPrivateDNS(ctx context.Context, changes []*ibmcloudChange) error {
	zones, err := p.privateZones(ctx)
	if err != nil {
		return err
	}
	// separate into per-zone change sets to be passed to the API.
	changesByPrivateZone := p.changesByPrivateZone(ctx, zones, changes)

	for zoneID, changes := range changesByPrivateZone {
		records, err := p.listAllPrivateRecords(ctx, zoneID)
		if err != nil {
			return err
		}

		for _, change := range changes {
			logFields := log.Fields{
				"record": *change.PrivateResourceRecord.Name,
				"type":   *change.PrivateResourceRecord.Type,
				"ttl":    *change.PrivateResourceRecord.TTL,
				"action": change.Action,
			}

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

			if p.DryRun {
				continue
			}

			if change.Action == recordUpdate {
				recordID := p.getPrivateRecordID(records, change.PrivateResourceRecord)
				if recordID == "" {
					log.WithFields(logFields).Errorf("failed to find previous record: %v", change.PrivateResourceRecord)
					continue
				}
				p.updateRecord(ctx, zoneID, recordID, change)
			} else if change.Action == recordDelete {
				recordID := p.getPrivateRecordID(records, change.PrivateResourceRecord)
				if recordID == "" {
					log.WithFields(logFields).Errorf("failed to find previous record: %v", change.PrivateResourceRecord)
					continue
				}
				p.deleteRecord(ctx, zoneID, recordID)
			} else if change.Action == recordCreate {
				p.createRecord(ctx, zoneID, change)
			}
		}
	}

	return nil
}

// privateZones return zones in private dns
func (p *IBMCloudProvider) privateZones(ctx context.Context) ([]dnssvcsv1.Dnszone, error) {
	result := []dnssvcsv1.Dnszone{}
	// if there is a zoneIDfilter configured
	// && if the filter isn't just a blank string (used in tests)
	if len(p.zoneIDFilter.ZoneIDs) > 0 && p.zoneIDFilter.ZoneIDs[0] != "" {
		log.Debugln("zoneIDFilter configured. only looking up zone IDs defined")
		for _, zoneID := range p.zoneIDFilter.ZoneIDs {
			log.Debugf("looking up zone %s", zoneID)
			detailResponse, _, err := p.Client.GetDnszoneWithContext(ctx, &dnssvcsv1.GetDnszoneOptions{
				InstanceID: core.StringPtr(p.instanceID),
				DnszoneID:  core.StringPtr(zoneID),
			})
			if err != nil {
				log.Errorf("zone %s lookup failed, %v", zoneID, err)
				continue
			}
			log.WithFields(log.Fields{
				"zoneName": *detailResponse.Name,
				"zoneID":   *detailResponse.ID,
			}).Debugln("adding zone for consideration")
			result = append(result, *detailResponse)
		}
		return result, nil
	}

	log.Debugln("no zoneIDFilter configured, looking at all zones")

	zonesResponse, _, err := p.Client.ListDnszonesWithContext(ctx, &dnssvcsv1.ListDnszonesOptions{
		InstanceID: core.StringPtr(p.instanceID),
	})
	if err != nil {
		return nil, err
	}

	for _, zone := range zonesResponse.Dnszones {
		if !p.domainFilter.Match(*zone.Name) {
			log.Debugf("zone %s not in domain filter", *zone.Name)
			continue
		}
		result = append(result, zone)
	}

	return result, nil
}

// activePrivateZone active zone with new records add if not active
func (p *IBMCloudProvider) activePrivateZone(ctx context.Context, zoneID, vpc string) {
	permittedNetworkVpc := &dnssvcsv1.PermittedNetworkVpc{
		VpcCrn: core.StringPtr(vpc),
	}
	createPermittedNetworkOptions := &dnssvcsv1.CreatePermittedNetworkOptions{
		InstanceID:       core.StringPtr(p.instanceID),
		DnszoneID:        core.StringPtr(zoneID),
		PermittedNetwork: permittedNetworkVpc,
		Type:             core.StringPtr("vpc"),
	}
	_, _, err := p.Client.CreatePermittedNetworkWithContext(ctx, createPermittedNetworkOptions)
	if err != nil {
		log.Errorf("failed to active zone %s in VPC %s with error: %v", zoneID, vpc, err)
	}
}

// changesByPrivateZone separates a multi-zone change into a single change per zone.
func (p *IBMCloudProvider) changesByPrivateZone(ctx context.Context, zones []dnssvcsv1.Dnszone, changeSet []*ibmcloudChange) map[string][]*ibmcloudChange {
	changes := make(map[string][]*ibmcloudChange)
	zoneNameIDMapper := provider.ZoneIDName{}
	for _, z := range zones {
		zoneNameIDMapper.Add(*z.ID, *z.Name)
		changes[*z.ID] = []*ibmcloudChange{}
	}

	for _, c := range changeSet {
		zoneID, _ := zoneNameIDMapper.FindZone(*c.PrivateResourceRecord.Name)
		if zoneID == "" {
			log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", *c.PrivateResourceRecord.Name)
			continue
		}
		changes[zoneID] = append(changes[zoneID], c)
	}

	return changes
}

func (p *IBMCloudProvider) publicRecords(ctx context.Context) ([]*endpoint.Endpoint, error) {
	log.Debugf("Listing records on public zone")
	dnsRecords, err := p.listAllPublicRecords(ctx)
	if err != nil {
		return nil, err
	}
	return p.groupPublicRecords(dnsRecords), nil
}

func (p *IBMCloudProvider) listAllPublicRecords(ctx context.Context) ([]dnsrecordsv1.DnsrecordDetails, error) {
	var dnsRecords []dnsrecordsv1.DnsrecordDetails
	page := 1
GETRECORDS:
	listAllDNSRecordsOptions := &dnsrecordsv1.ListAllDnsRecordsOptions{
		Page: core.Int64Ptr(int64(page)),
	}
	records, _, err := p.Client.ListAllDDNSRecordsWithContext(ctx, listAllDNSRecordsOptions)
	if err != nil {
		return dnsRecords, err
	}
	dnsRecords = append(dnsRecords, records.Result...)
	// Loop if more records exist
	if *records.ResultInfo.TotalCount > int64(page*100) {
		page = page + 1
		log.Debugf("More than one pages records found, page: %d", page)
		goto GETRECORDS
	}
	return dnsRecords, nil
}

func (p *IBMCloudProvider) groupPublicRecords(records []dnsrecordsv1.DnsrecordDetails) []*endpoint.Endpoint {
	endpoints := []*endpoint.Endpoint{}

	// group supported records by name and type
	groups := map[string][]dnsrecordsv1.DnsrecordDetails{}

	for _, r := range records {
		if !provider.SupportedRecordType(*r.Type) {
			continue
		}

		groupBy := *r.Name + *r.Type
		if _, ok := groups[groupBy]; !ok {
			groups[groupBy] = []dnsrecordsv1.DnsrecordDetails{}
		}

		groups[groupBy] = append(groups[groupBy], r)
	}

	// create single endpoint with all the targets for each name/type
	for _, records := range groups {
		targets := make([]string, len(records))
		for i, record := range records {
			targets[i] = *record.Content
		}

		ep := endpoint.NewEndpointWithTTL(
			*records[0].Name,
			*records[0].Type,
			endpoint.TTL(*records[0].TTL),
			targets...).WithProviderSpecific(proxyFilter, strconv.FormatBool(*records[0].Proxied))

		log.Debugf(
			"Found %s record for '%s' with target '%s'.",
			ep.RecordType,
			ep.DNSName,
			ep.Targets,
		)

		endpoints = append(endpoints, ep)
	}
	return endpoints
}

func (p *IBMCloudProvider) privateRecords(ctx context.Context) ([]*endpoint.Endpoint, error) {
	log.Debugf("Listing records on private zone")
	var vpc string
	zones, err := p.privateZones(ctx)
	if err != nil {
		return nil, err
	}
	sources, err := p.source.Endpoints(ctx)
	if err != nil {
		return nil, err
	}
	// Filter VPC annoation for private zone active
	for _, source := range sources {
		vpc = checkVPCAnnotation(source)
		if len(vpc) > 0 {
			log.Debugf("VPC found: %s", vpc)
			break
		}
	}

	endpoints := []*endpoint.Endpoint{}
	for _, zone := range zones {
		if len(vpc) > 0 && *zone.State == zoneStatePendingNetwork {
			log.Debugf("active zone: %s", *zone.ID)
			p.activePrivateZone(ctx, *zone.ID, vpc)
		}

		dnsRecords, err := p.listAllPrivateRecords(ctx, *zone.ID)
		if err != nil {
			return nil, err
		}
		endpoints = append(endpoints, p.groupPrivateRecords(dnsRecords)...)
	}

	return endpoints, nil
}

func (p *IBMCloudProvider) listAllPrivateRecords(ctx context.Context, zoneID string) ([]dnssvcsv1.ResourceRecord, error) {
	var dnsRecords []dnssvcsv1.ResourceRecord
	offset := 0
GETRECORDS:
	listResourceRecordsOptions := &dnssvcsv1.ListResourceRecordsOptions{
		InstanceID: core.StringPtr(p.instanceID),
		DnszoneID:  core.StringPtr(zoneID),
		Offset:     core.Int64Ptr(int64(offset)),
	}
	records, _, err := p.Client.ListResourceRecordsWithContext(ctx, listResourceRecordsOptions)
	if err != nil {
		return dnsRecords, err
	}
	oRecords := records.ResourceRecords
	dnsRecords = append(dnsRecords, oRecords...)
	// Loop if more records exist
	if int64(offset+1) < *records.TotalCount && int64(offset+200) < *records.TotalCount {
		offset = offset + 200
		log.Debugf("More than one pages records found, page: %d", offset/200+1)
		goto GETRECORDS
	}
	return dnsRecords, nil
}

func (p *IBMCloudProvider) groupPrivateRecords(records []dnssvcsv1.ResourceRecord) []*endpoint.Endpoint {
	endpoints := []*endpoint.Endpoint{}
	// group supported records by name and type
	groups := map[string][]dnssvcsv1.ResourceRecord{}
	for _, r := range records {
		if !provider.SupportedRecordType(*r.Type) || !privateTypeSupported[*r.Type] {
			continue
		}
		rname := *r.Name
		rtype := *r.Type
		groupBy := rname + rtype
		if _, ok := groups[groupBy]; !ok {
			groups[groupBy] = []dnssvcsv1.ResourceRecord{}
		}

		groups[groupBy] = append(groups[groupBy], r)
	}

	// create single endpoint with all the targets for each name/type
	for _, records := range groups {
		targets := make([]string, len(records))
		for i, record := range records {
			data := record.Rdata
			log.Debugf("record data: %v", data)
			switch *record.Type {
			case "A":
				if !isNil(data["ip"]) {
					targets[i] = data["ip"].(string)
				}
			case "CNAME":
				if !isNil(data["cname"]) {
					targets[i] = data["cname"].(string)
				}
			case "TXT":
				if !isNil(data["text"]) {
					targets[i] = data["text"].(string)
				}
				log.Debugf("text record data: %v", targets[i])
			}
		}

		ep := endpoint.NewEndpointWithTTL(
			*records[0].Name,
			*records[0].Type,
			endpoint.TTL(*records[0].TTL), targets...)

		log.Debugf(
			"Found %s record for '%s' with target '%s'.",
			ep.RecordType,
			ep.DNSName,
			ep.Targets,
		)

		endpoints = append(endpoints, ep)
	}
	return endpoints
}

func (p *IBMCloudProvider) getPublicRecordID(records []dnsrecordsv1.DnsrecordDetails, record dnsrecordsv1.DnsrecordDetails) string {
	for _, zoneRecord := range records {
		if *zoneRecord.Name == *record.Name && *zoneRecord.Type == *record.Type && *zoneRecord.Content == *record.Content {
			return *zoneRecord.ID
		}
	}
	return ""
}

func (p *IBMCloudProvider) getPrivateRecordID(records []dnssvcsv1.ResourceRecord, record dnssvcsv1.ResourceRecord) string {
	for _, zoneRecord := range records {
		if *zoneRecord.Name == *record.Name && *zoneRecord.Type == *record.Type {
			return *zoneRecord.ID
		}
	}
	return ""
}

func (p *IBMCloudProvider) newIBMCloudChange(action string, endpoint *endpoint.Endpoint, target string) *ibmcloudChange {
	ttl := defaultPublicRecordTTL
	proxied := shouldBeProxied(endpoint, p.proxiedByDefault)

	if endpoint.RecordTTL.IsConfigured() {
		ttl = int(endpoint.RecordTTL)
	}

	if p.privateZone {
		rData := make(map[string]interface{})
		switch endpoint.RecordType {
		case "A":
			rData[dnssvcsv1.CreateResourceRecordOptions_Type_A] = &dnssvcsv1.ResourceRecordInputRdataRdataARecord{
				Ip: core.StringPtr(target),
			}
		case "CNAME":
			rData[dnssvcsv1.CreateResourceRecordOptions_Type_Cname] = &dnssvcsv1.ResourceRecordInputRdataRdataCnameRecord{
				Cname: core.StringPtr(target),
			}
		case "TXT":
			rData[dnssvcsv1.CreateResourceRecordOptions_Type_Txt] = &dnssvcsv1.ResourceRecordInputRdataRdataTxtRecord{
				Text: core.StringPtr(target),
			}
		}
		return &ibmcloudChange{
			Action: action,
			PrivateResourceRecord: dnssvcsv1.ResourceRecord{
				Name:  core.StringPtr(endpoint.DNSName),
				TTL:   core.Int64Ptr(int64(ttl)),
				Type:  core.StringPtr(endpoint.RecordType),
				Rdata: rData,
			},
		}
	}

	return &ibmcloudChange{
		Action: action,
		PublicResourceRecord: dnsrecordsv1.DnsrecordDetails{
			Name:    core.StringPtr(endpoint.DNSName),
			TTL:     core.Int64Ptr(int64(ttl)),
			Proxied: core.BoolPtr(proxied),
			Type:    core.StringPtr(endpoint.RecordType),
			Content: core.StringPtr(target),
		},
	}
}

func (p *IBMCloudProvider) createRecord(ctx context.Context, zoneID string, change *ibmcloudChange) {
	if p.privateZone {
		createResourceRecordOptions := &dnssvcsv1.CreateResourceRecordOptions{
			InstanceID: core.StringPtr(p.instanceID),
			DnszoneID:  core.StringPtr(zoneID),
			Name:       change.PrivateResourceRecord.Name,
			Type:       change.PrivateResourceRecord.Type,
			TTL:        change.PrivateResourceRecord.TTL,
		}
		switch *change.PrivateResourceRecord.Type {
		case "A":
			data, _ := change.PrivateResourceRecord.Rdata[dnssvcsv1.CreateResourceRecordOptions_Type_A].(*dnssvcsv1.ResourceRecordInputRdataRdataARecord)
			aData, _ := p.Client.NewResourceRecordInputRdataRdataARecord(*data.Ip)
			createResourceRecordOptions.SetRdata(aData)
		case "CNAME":
			data, _ := change.PrivateResourceRecord.Rdata[dnssvcsv1.CreateResourceRecordOptions_Type_Cname].(*dnssvcsv1.ResourceRecordInputRdataRdataCnameRecord)
			cnameData, _ := p.Client.NewResourceRecordInputRdataRdataCnameRecord(*data.Cname)
			createResourceRecordOptions.SetRdata(cnameData)
		case "TXT":
			data, _ := change.PrivateResourceRecord.Rdata[dnssvcsv1.CreateResourceRecordOptions_Type_Txt].(*dnssvcsv1.ResourceRecordInputRdataRdataTxtRecord)
			txtData, _ := p.Client.NewResourceRecordInputRdataRdataTxtRecord(*data.Text)
			createResourceRecordOptions.SetRdata(txtData)
		}
		_, _, err := p.Client.CreateResourceRecordWithContext(ctx, createResourceRecordOptions)
		if err != nil {
			log.Errorf("failed to create %s type record named %s: %v", *change.PrivateResourceRecord.Type, *change.PrivateResourceRecord.Name, err)
		}
	} else {
		createDNSRecordOptions := &dnsrecordsv1.CreateDnsRecordOptions{
			Name:    change.PublicResourceRecord.Name,
			Type:    change.PublicResourceRecord.Type,
			TTL:     change.PublicResourceRecord.TTL,
			Content: change.PublicResourceRecord.Content,
		}
		_, _, err := p.Client.CreateDNSRecordWithContext(ctx, createDNSRecordOptions)
		if err != nil {
			log.Errorf("failed to create %s type record named %s: %v", *change.PublicResourceRecord.Type, *change.PublicResourceRecord.Name, err)
		}
	}
}

func (p *IBMCloudProvider) updateRecord(ctx context.Context, zoneID, recordID string, change *ibmcloudChange) {
	if p.privateZone {
		updateResourceRecordOptions := &dnssvcsv1.UpdateResourceRecordOptions{
			InstanceID: core.StringPtr(p.instanceID),
			DnszoneID:  core.StringPtr(zoneID),
			RecordID:   core.StringPtr(recordID),
			Name:       change.PrivateResourceRecord.Name,
			TTL:        change.PrivateResourceRecord.TTL,
		}
		switch *change.PrivateResourceRecord.Type {
		case "A":
			data, _ := change.PrivateResourceRecord.Rdata[dnssvcsv1.CreateResourceRecordOptions_Type_A].(*dnssvcsv1.ResourceRecordInputRdataRdataARecord)
			aData, _ := p.Client.NewResourceRecordUpdateInputRdataRdataARecord(*data.Ip)
			updateResourceRecordOptions.SetRdata(aData)
		case "CNAME":
			data, _ := change.PrivateResourceRecord.Rdata[dnssvcsv1.CreateResourceRecordOptions_Type_Cname].(*dnssvcsv1.ResourceRecordInputRdataRdataCnameRecord)
			cnameData, _ := p.Client.NewResourceRecordUpdateInputRdataRdataCnameRecord(*data.Cname)
			updateResourceRecordOptions.SetRdata(cnameData)
		case "TXT":
			data, _ := change.PrivateResourceRecord.Rdata[dnssvcsv1.CreateResourceRecordOptions_Type_Txt].(*dnssvcsv1.ResourceRecordInputRdataRdataTxtRecord)
			txtData, _ := p.Client.NewResourceRecordUpdateInputRdataRdataTxtRecord(*data.Text)
			updateResourceRecordOptions.SetRdata(txtData)
		}
		_, _, err := p.Client.UpdateResourceRecordWithContext(ctx, updateResourceRecordOptions)
		if err != nil {
			log.Errorf("failed to update %s type record named %s: %v", *change.PublicResourceRecord.Type, *change.PublicResourceRecord.Name, err)
		}
	} else {
		updateDNSRecordOptions := &dnsrecordsv1.UpdateDnsRecordOptions{
			DnsrecordIdentifier: &recordID,
			Name:                change.PublicResourceRecord.Name,
			Type:                change.PublicResourceRecord.Type,
			TTL:                 change.PublicResourceRecord.TTL,
			Content:             change.PublicResourceRecord.Content,
			Proxied:             change.PublicResourceRecord.Proxied,
		}
		_, _, err := p.Client.UpdateDNSRecordWithContext(ctx, updateDNSRecordOptions)
		if err != nil {
			log.Errorf("failed to update %s type record named %s: %v", *change.PublicResourceRecord.Type, *change.PublicResourceRecord.Name, err)
		}
	}
}

func (p *IBMCloudProvider) deleteRecord(ctx context.Context, zoneID, recordID string) {
	if p.privateZone {
		deleteResourceRecordOptions := &dnssvcsv1.DeleteResourceRecordOptions{
			InstanceID: core.StringPtr(p.instanceID),
			DnszoneID:  core.StringPtr(zoneID),
			RecordID:   core.StringPtr(recordID),
		}
		_, err := p.Client.DeleteResourceRecordWithContext(ctx, deleteResourceRecordOptions)
		if err != nil {
			log.Errorf("failed to delete record %s: %v", recordID, err)
		}
	} else {
		deleteDNSRecordOptions := &dnsrecordsv1.DeleteDnsRecordOptions{
			DnsrecordIdentifier: &recordID,
		}
		_, _, err := p.Client.DeleteDNSRecordWithContext(ctx, deleteDNSRecordOptions)
		if err != nil {
			log.Errorf("failed to delete record %s: %v", recordID, err)
		}
	}
}

func shouldBeProxied(endpoint *endpoint.Endpoint, proxiedByDefault bool) bool {
	proxied := proxiedByDefault

	for _, v := range endpoint.ProviderSpecific {
		if v.Name == proxyFilter {
			b, err := strconv.ParseBool(v.Value)
			if err != nil {
				log.Errorf("Failed to parse annotation [%s]: %v", proxyFilter, err)
			} else {
				proxied = b
			}
			break
		}
	}

	if proxyTypeNotSupported[endpoint.RecordType] || strings.Contains(endpoint.DNSName, "*") {
		proxied = false
	}
	return proxied
}

func checkVPCAnnotation(endpoint *endpoint.Endpoint) string {
	var vpc string
	for _, v := range endpoint.ProviderSpecific {
		if v.Name == vpcFilter {
			vpcCrn, err := crn.Parse(v.Value)
			if vpcCrn.ResourceType != "vpc" || err != nil {
				log.Errorf("Failed to parse vpc [%s]: %v", v.Value, err)
			} else {
				vpc = v.Value
			}
			break
		}
	}
	return vpc
}

func isNil(i interface{}) bool {
	if i == nil {
		return true
	}
	switch reflect.TypeOf(i).Kind() {
	case reflect.Ptr, reflect.Map, reflect.Array, reflect.Chan, reflect.Slice:
		return reflect.ValueOf(i).IsNil()
	}
	return false
}
