/*
Copyright 2020 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 ovh

import (
	"context"
	"errors"
	"fmt"
	"net/url"
	"slices"
	"strconv"
	"strings"
	"time"

	"github.com/miekg/dns"
	"github.com/ovh/go-ovh/ovh"
	"github.com/patrickmn/go-cache"
	log "github.com/sirupsen/logrus"
	"golang.org/x/sync/errgroup"

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

	"go.uber.org/ratelimit"
)

const (
	defaultTTL = 0
	ovhCreate  = iota
	ovhDelete
	ovhUpdate
)

var (
	// ErrRecordToMutateNotFound when ApplyChange has to update/delete and didn't found the record in the existing zone (Change with no record ID)
	ErrRecordToMutateNotFound = errors.New("record to mutate not found in current zone")
)

// OVHProvider is an implementation of Provider for OVH DNS.
type OVHProvider struct {
	provider.BaseProvider

	client ovhClient

	apiRateLimiter ratelimit.Limiter

	domainFilter *endpoint.DomainFilter

	// DryRun enables dry-run mode
	DryRun bool

	// EnableCNAMERelativeTarget controls if CNAME target should be sent with relative format.
	// Previous implementations of the OVHProvider always added a final dot as for absolut format.
	// Default value is false, all CNAME are transformed into absolut format.
	// Setting this to true will allow relative format to be sent to DNS zone.
	EnableCNAMERelativeTarget bool

	// UseCache controls if the OVHProvider will cache records in memory, and serve them
	// without recontacting the OVHcloud API if the SOA of the domain zone hasn't changed.
	// Note that, when disabling cache, OVHcloud API has rate-limiting that will hit if
	// your refresh rate/number of records is too big, which might cause issue with the
	// provider.
	// Default value: true
	UseCache       bool
	lastRunRecords []ovhRecord
	lastRunZones   []string

	cacheInstance *cache.Cache
	dnsClient     dnsClient
}

type ovhClient interface {
	PostWithContext(context.Context, string, any, any) error
	PutWithContext(context.Context, string, any, any) error
	GetWithContext(context.Context, string, any) error
	DeleteWithContext(context.Context, string, any) error
}

type dnsClient interface {
	ExchangeContext(ctx context.Context, m *dns.Msg, a string) (*dns.Msg, time.Duration, error)
}

type ovhRecordFields struct {
	ovhRecordFieldUpdate
	FieldType string `json:"fieldType"`
}

type ovhRecordFieldUpdate struct {
	SubDomain string `json:"subDomain"`
	TTL       int64  `json:"ttl"`
	Target    string `json:"target"`
}

type ovhRecord struct {
	ovhRecordFields
	ID   uint64 `json:"id"`
	Zone string `json:"zone"`
}

func (r ovhRecord) String() string {
	return "record#" + strconv.Itoa(int(r.ID)) + ": " + r.FieldType + " | " + r.SubDomain + " => " + r.Target + " (" + strconv.Itoa(int(r.TTL)) + ")"
}

type ovhChange struct {
	ovhRecord
	Action int
}

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

// newProvider initializes a new OVH DNS based Provider.
func newProvider(
	domainFilter *endpoint.DomainFilter,
	endpoint string,
	apiRateLimit int,
	enableCNAMERelative,
	dryRun bool) (*OVHProvider, error) {
	client, err := ovh.NewEndpointClient(endpoint)
	if err != nil {
		return nil, err
	}

	client.UserAgent = externaldns.UserAgent()

	return &OVHProvider{
		client:                    client,
		domainFilter:              domainFilter,
		apiRateLimiter:            ratelimit.New(apiRateLimit),
		DryRun:                    dryRun,
		cacheInstance:             cache.New(cache.NoExpiration, cache.NoExpiration),
		dnsClient:                 new(dns.Client),
		UseCache:                  true,
		EnableCNAMERelativeTarget: enableCNAMERelative,
	}, nil
}

// Records returns the list of records in all relevant zones.
func (p *OVHProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
	zones, records, err := p.zonesRecords(ctx)
	if err != nil {
		return nil, err
	}
	p.lastRunRecords = records
	p.lastRunZones = zones
	endpoints := ovhGroupByNameAndType(records)
	log.Infof("OVH: %d endpoints have been found", len(endpoints))
	return endpoints, nil
}

func planChangesByZoneName(zones []string, changes *plan.Changes) (map[string]*plan.Changes, error) {
	zoneNameIDMapper := provider.ZoneIDName{}
	for _, zone := range zones {
		zoneNameIDMapper.Add(zone, zone)
	}

	output := map[string]*plan.Changes{}
	for _, endpt := range changes.Delete {
		zoneName, _ := zoneNameIDMapper.FindZone(endpt.DNSName)
		if zoneName == "" {
			return nil, provider.NewSoftErrorf("record %q have not found matching DNS zone in OVH provider", endpt.DNSName)
		}
		if _, ok := output[zoneName]; !ok {
			output[zoneName] = &plan.Changes{}
		}
		output[zoneName].Delete = append(output[zoneName].Delete, endpt)
	}
	for _, endpt := range changes.Create {
		zoneName, _ := zoneNameIDMapper.FindZone(endpt.DNSName)
		if zoneName == "" {
			return nil, provider.NewSoftErrorf("record %q have not found matching DNS zone in OVH provider", endpt.DNSName)
		}
		if _, ok := output[zoneName]; !ok {
			output[zoneName] = &plan.Changes{}
		}
		output[zoneName].Create = append(output[zoneName].Create, endpt)
	}
	for _, endpt := range changes.UpdateOld {
		zoneName, _ := zoneNameIDMapper.FindZone(endpt.DNSName)
		if zoneName == "" {
			return nil, provider.NewSoftErrorf("record %q have not found matching DNS zone in OVH provider", endpt.DNSName)
		}
		if _, ok := output[zoneName]; !ok {
			output[zoneName] = &plan.Changes{}
		}
		output[zoneName].UpdateOld = append(output[zoneName].UpdateOld, endpt)
	}
	for _, endpt := range changes.UpdateNew {
		zoneName, _ := zoneNameIDMapper.FindZone(endpt.DNSName)
		if zoneName == "" {
			return nil, provider.NewSoftErrorf("record %q have not found matching DNS zone in OVH provider", endpt.DNSName)
		}
		if _, ok := output[zoneName]; !ok {
			output[zoneName] = &plan.Changes{}
		}
		output[zoneName].UpdateNew = append(output[zoneName].UpdateNew, endpt)
	}

	return output, nil
}

func (p *OVHProvider) computeSingleZoneChanges(_ context.Context, zoneName string, existingRecords []ovhRecord, changes *plan.Changes) ([]ovhChange, error) {
	allChanges := []ovhChange{}
	var computedChanges []ovhChange

	computedChanges, existingRecords = p.newOvhChangeCreateDelete(ovhCreate, changes.Create, zoneName, existingRecords)
	allChanges = append(allChanges, computedChanges...)
	computedChanges, existingRecords = p.newOvhChangeCreateDelete(ovhDelete, changes.Delete, zoneName, existingRecords)
	allChanges = append(allChanges, computedChanges...)

	var err error
	computedChanges, err = p.newOvhChangeUpdate(changes.UpdateOld, changes.UpdateNew, zoneName, existingRecords)
	if err != nil {
		return nil, err
	}
	allChanges = append(allChanges, computedChanges...)

	return allChanges, nil
}

func (p *OVHProvider) handleSingleZoneUpdate(ctx context.Context, zoneName string, existingRecords []ovhRecord, changes *plan.Changes) error {
	allChanges, err := p.computeSingleZoneChanges(ctx, zoneName, existingRecords, changes)
	if err != nil {
		return err
	}
	log.Infof("OVH: %q: %d changes will be done", zoneName, len(allChanges))

	eg, ctxErrGroup := errgroup.WithContext(ctx)
	for _, change := range allChanges {
		eg.Go(func() error {
			return p.change(ctxErrGroup, change)
		})
	}

	err = eg.Wait()

	// do not refresh zone if errors: some records might haven't been processed yet, hence the zone will be in an inconsistent state
	// if modification of the zone was in error, invalidating the cache to make sure next run will start freshly
	if err == nil {
		err = p.refresh(ctx, zoneName)
	} else {
		p.invalidateCache(zoneName)
	}

	return err
}

// ApplyChanges applies a given set of changes in a given zone.
func (p *OVHProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
	zones, records := p.lastRunZones, p.lastRunRecords
	defer func() {
		p.lastRunRecords = []ovhRecord{}
		p.lastRunZones = []string{}
	}()

	if log.IsLevelEnabled(log.DebugLevel) {
		for _, change := range changes.Create {
			log.Debugf("OVH: changes CREATE dns:%q / targets:%v / type:%s", change.DNSName, change.Targets, change.RecordType)
		}
		for _, change := range changes.UpdateOld {
			log.Debugf("OVH: changes UPDATEOLD dns:%q / targets:%v / type:%s", change.DNSName, change.Targets, change.RecordType)
		}
		for _, change := range changes.UpdateNew {
			log.Debugf("OVH: changes UPDATENEW dns:%q / targets:%v / type:%s", change.DNSName, change.Targets, change.RecordType)
		}
		for _, change := range changes.Delete {
			log.Debugf("OVH: changes DELETE dns:%q / targets:%v / type:%s", change.DNSName, change.Targets, change.RecordType)
		}
	}

	changesByZoneName, err := planChangesByZoneName(zones, changes)
	if err != nil {
		return err
	}
	eg, ctx := errgroup.WithContext(ctx)

	for zoneName, changes := range changesByZoneName {
		eg.Go(func() error {
			return p.handleSingleZoneUpdate(ctx, zoneName, records, changes)
		})
	}

	if err := eg.Wait(); err != nil {
		return provider.NewSoftError(err)
	}

	return nil
}

func (p *OVHProvider) refresh(ctx context.Context, zone string) error {
	log.Debugf("OVH: Refresh %s zone", zone)

	// Zone has been altered so we invalidate the cache
	// so that the next run will reload it.
	p.invalidateCache(zone)

	p.apiRateLimiter.Take()
	if p.DryRun {
		log.Infof("OVH: Dry-run: Would have refresh DNS zone %q", zone)
		return nil
	}
	if err := p.client.PostWithContext(ctx, fmt.Sprintf("/domain/zone/%s/refresh", url.PathEscape(zone)), nil, nil); err != nil {
		return provider.NewSoftError(err)
	}

	return nil
}

func (p *OVHProvider) change(ctx context.Context, change ovhChange) error {
	p.apiRateLimiter.Take()

	switch change.Action {
	case ovhCreate:
		log.Debugf("OVH: Add an entry to %s", change.String())
		if p.DryRun {
			log.Infof("OVH: Dry-run: Would have created a DNS record for zone %s", change.Zone)
			return nil
		}
		return p.client.PostWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record", url.PathEscape(change.Zone)), change.ovhRecordFields, nil)
	case ovhDelete:
		if change.ID == 0 {
			return ErrRecordToMutateNotFound
		}
		log.Debugf("OVH: Delete an entry to %s", change.String())
		if p.DryRun {
			log.Infof("OVH: Dry-run: Would have deleted a DNS record for zone %s", change.Zone)
			return nil
		}
		return p.client.DeleteWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record/%d", url.PathEscape(change.Zone), change.ID), nil)
	case ovhUpdate:
		if change.ID == 0 {
			return ErrRecordToMutateNotFound
		}
		log.Debugf("OVH: Update an entry to %s", change.String())
		if p.DryRun {
			log.Infof("OVH: Dry-run: Would have updated a DNS record for zone %s", change.Zone)
			return nil
		}
		return p.client.PutWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record/%d", url.PathEscape(change.Zone), change.ID), change.ovhRecordFieldUpdate, nil)
	default:
		return nil
	}
}

func (p *OVHProvider) invalidateCache(zone string) {
	p.cacheInstance.Delete(zone + "#soa")
}

func (p *OVHProvider) zonesRecords(ctx context.Context) ([]string, []ovhRecord, error) {
	var allRecords []ovhRecord
	zones, err := p.zones(ctx)
	if err != nil {
		return nil, nil, provider.NewSoftError(err)
	}

	chRecords := make(chan []ovhRecord, len(zones))
	eg, ctx := errgroup.WithContext(ctx)
	for _, zone := range zones {
		eg.Go(func() error { return p.records(ctx, &zone, chRecords) })
	}
	if err := eg.Wait(); err != nil {
		return nil, nil, provider.NewSoftError(err)
	}
	close(chRecords)
	for records := range chRecords {
		allRecords = append(allRecords, records...)
	}
	return zones, allRecords, nil
}

func (p *OVHProvider) zones(ctx context.Context) ([]string, error) {
	var zones []string
	var filteredZones []string

	p.apiRateLimiter.Take()
	if err := p.client.GetWithContext(ctx, "/domain/zone", &zones); err != nil {
		return nil, err
	}

	for _, zoneName := range zones {
		if p.domainFilter == nil || p.domainFilter.Match(zoneName) {
			filteredZones = append(filteredZones, zoneName)
		}
	}
	log.Infof("OVH: %d zones found", len(filteredZones))
	return filteredZones, nil
}

type ovhSoa struct {
	Server  string `json:"server"`
	Serial  uint32 `json:"serial"`
	records []ovhRecord
}

func (p *OVHProvider) records(ctx context.Context, zone *string, records chan<- []ovhRecord) error {
	var recordsIds []uint64
	ovhRecords := make([]ovhRecord, len(recordsIds))
	eg, ctxErrGroup := errgroup.WithContext(ctx)

	if p.UseCache {
		if cachedSoaItf, ok := p.cacheInstance.Get(*zone + "#soa"); ok {
			cachedSoa := cachedSoaItf.(ovhSoa)

			log.Debugf("OVH: zone %s: Checking SOA against %v", *zone, cachedSoa.Serial)

			m := new(dns.Msg)
			m.SetQuestion(dns.Fqdn(*zone), dns.TypeSOA)
			in, _, err := p.dnsClient.ExchangeContext(ctx, m, strings.TrimSuffix(cachedSoa.Server, ".")+":53")
			if err == nil {
				if s, ok := in.Answer[0].(*dns.SOA); ok {
					if s.Serial == cachedSoa.Serial {
						log.Debugf("OVH: zone %s: SOA from cache is valid", *zone)
						records <- cachedSoa.records
						return nil
					}
				}
			}

			p.invalidateCache(*zone)
		}
	}

	log.Debugf("OVH: Getting records for %s from API", *zone)

	p.apiRateLimiter.Take()
	var soa ovhSoa
	if p.UseCache {
		if err := p.client.GetWithContext(ctx, "/domain/zone/"+url.PathEscape(*zone)+"/soa", &soa); err != nil {
			return err
		}
	}

	if err := p.client.GetWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record", url.PathEscape(*zone)), &recordsIds); err != nil {
		return err
	}
	chRecords := make(chan ovhRecord, len(recordsIds))
	for _, id := range recordsIds {
		eg.Go(func() error { return p.record(ctxErrGroup, zone, id, chRecords) })
	}
	if err := eg.Wait(); err != nil {
		return err
	}
	close(chRecords)
	for record := range chRecords {
		ovhRecords = append(ovhRecords, record)
	}

	if p.UseCache {
		soa.records = ovhRecords
		_ = p.cacheInstance.Add(*zone+"#soa", soa, cache.DefaultExpiration)
	}

	records <- ovhRecords
	return nil
}

func (p *OVHProvider) record(ctx context.Context, zone *string, id uint64, records chan<- ovhRecord) error {
	record := ovhRecord{}

	log.Debugf("OVH: Getting record %d for %s", id, *zone)

	p.apiRateLimiter.Take()
	if err := p.client.GetWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record/%d", url.PathEscape(*zone), id), &record); err != nil {
		return err
	}
	if provider.SupportedRecordType(record.FieldType) {
		log.Debugf("OVH: Record %d for %s is %+v", id, *zone, record)
		records <- record
	}
	return nil
}

func ovhGroupByNameAndType(records []ovhRecord) []*endpoint.Endpoint {
	endpoints := []*endpoint.Endpoint{}

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

	for _, r := range records {
		groupBy := r.Zone + "//" + r.SubDomain + "//" + r.FieldType
		if _, ok := groups[groupBy]; !ok {
			groups[groupBy] = []ovhRecord{}
		}

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

	// create single endpoint with all the targets for each name/type
	for _, records := range groups {
		var targets []string
		for _, record := range records {
			targets = append(targets, record.Target)
		}
		ep := endpoint.NewEndpointWithTTL(
			strings.TrimPrefix(records[0].SubDomain+"."+records[0].Zone, "."),
			records[0].FieldType,
			endpoint.TTL(records[0].TTL),
			targets...,
		)
		endpoints = append(endpoints, ep)
	}

	return endpoints
}

func (p *OVHProvider) newOvhChangeCreateDelete(action int, endpoints []*endpoint.Endpoint, zone string, existingRecords []ovhRecord) ([]ovhChange, []ovhRecord) {
	var ovhChanges []ovhChange
	var toDeleteIds []int

	for _, e := range endpoints {
		for _, target := range e.Targets {
			change := ovhChange{
				Action: action,
				ovhRecord: ovhRecord{
					Zone: zone,
					ovhRecordFields: ovhRecordFields{
						FieldType: e.RecordType,
						ovhRecordFieldUpdate: ovhRecordFieldUpdate{
							SubDomain: convertDNSNameIntoSubDomain(e.DNSName, zone),
							TTL:       defaultTTL,
							Target:    target,
						},
					},
				},
			}
			p.formatCNAMETarget(&change)
			if e.RecordTTL.IsConfigured() {
				change.TTL = int64(e.RecordTTL)
			}

			// The Zone might have multiple records with the same target. In order to avoid applying the action to the
			// same OVH record, we remove a record from the list when a match is found.
			if action == ovhDelete {
				for i, rec := range existingRecords {
					if rec.Zone == change.Zone && rec.SubDomain == change.SubDomain && rec.FieldType == change.FieldType && rec.Target == change.Target && !slices.Contains(toDeleteIds, i) {
						change.ID = rec.ID
						toDeleteIds = append(toDeleteIds, i)
						break
					}
				}
			}

			ovhChanges = append(ovhChanges, change)
		}
	}

	if len(toDeleteIds) > 0 {
		// Copy the records because we need to mutate the list.
		newExistingRecords := make([]ovhRecord, 0, len(existingRecords)-len(toDeleteIds))
		for id := range existingRecords {
			if slices.Contains(toDeleteIds, id) {
				continue
			}
			newExistingRecords = append(newExistingRecords, existingRecords[id])
		}
		existingRecords = newExistingRecords
	}

	return ovhChanges, existingRecords
}

func convertDNSNameIntoSubDomain(DNSName string, zoneName string) string { // nolint: gocritic // captLocal
	if DNSName == zoneName {
		return ""
	}

	if name, err := idna.Profile.ToUnicode(DNSName); err == nil {
		DNSName = name
	}
	if name, err := idna.Profile.ToUnicode(zoneName); err == nil {
		zoneName = name
	}

	return strings.TrimSuffix(DNSName, "."+zoneName)
}

func normalizeDNSName(dnsName string) string {
	return strings.TrimSpace(strings.ToLower(dnsName))
}

func (p *OVHProvider) newOvhChangeUpdate(endpointsOld []*endpoint.Endpoint, endpointsNew []*endpoint.Endpoint, zone string, existingRecords []ovhRecord) ([]ovhChange, error) {
	zoneNameIDMapper := provider.ZoneIDName{}
	zoneNameIDMapper.Add(zone, zone)

	oldEndpointByTypeAndName := map[string]*endpoint.Endpoint{}
	newEndpointByTypeAndName := map[string]*endpoint.Endpoint{}
	oldRecordsInZone := map[string][]ovhRecord{}

	for _, e := range endpointsOld {
		sub := convertDNSNameIntoSubDomain(e.DNSName, zone)
		oldEndpointByTypeAndName[normalizeDNSName(e.RecordType+"//"+sub)] = e
	}
	for _, e := range endpointsNew {
		sub := convertDNSNameIntoSubDomain(e.DNSName, zone)
		newEndpointByTypeAndName[normalizeDNSName(e.RecordType+"//"+sub)] = e
	}

	for id := range oldEndpointByTypeAndName {
		for _, record := range existingRecords {
			if id == normalizeDNSName(record.FieldType+"//"+record.SubDomain) {
				oldRecordsInZone[id] = append(oldRecordsInZone[id], record)
			}
		}
	}

	var changes []ovhChange

	for id := range oldEndpointByTypeAndName {
		oldRecords := slices.Clone(oldRecordsInZone[id])
		endpointsNew, ok := newEndpointByTypeAndName[id]
		if !ok {
			return nil, errors.New("unrecoverable error: couldn't find the matching record in the update.New")
		}

		var toInsertTarget []string

		for _, target := range endpointsNew.Targets {
			var toDelete = -1

			for i, record := range oldRecords {
				if target == record.Target {
					toDelete = i
					break
				}
			}

			if toDelete >= 0 {
				oldRecords = slices.Delete(oldRecords, toDelete, toDelete+1)
			} else {
				toInsertTarget = append(toInsertTarget, target)
			}
		}

		createChangeConvertedToUpdateChange := []int{}
		for i, target := range toInsertTarget {
			if len(oldRecords) == 0 {
				break
			}

			record := oldRecords[0]
			oldRecords = slices.Delete(oldRecords, 0, 1)
			record.Target = target

			if endpointsNew.RecordTTL.IsConfigured() {
				record.TTL = int64(endpointsNew.RecordTTL)
			} else {
				record.TTL = defaultTTL
			}

			change := ovhChange{
				Action:    ovhUpdate,
				ovhRecord: record,
			}
			p.formatCNAMETarget(&change)
			changes = append(changes, change)
			createChangeConvertedToUpdateChange = append(createChangeConvertedToUpdateChange, i)
		}

		newToInsertTarget := make([]string, 0, len(toInsertTarget)-len(createChangeConvertedToUpdateChange))
		for i := range toInsertTarget {
			if slices.Contains(createChangeConvertedToUpdateChange, i) {
				continue
			}
			newToInsertTarget = append(newToInsertTarget, toInsertTarget[i])
		}
		toInsertTarget = newToInsertTarget

		if len(toInsertTarget) > 0 {
			for _, target := range toInsertTarget {
				recordTTL := int64(defaultTTL)
				if endpointsNew.RecordTTL.IsConfigured() {
					recordTTL = int64(endpointsNew.RecordTTL)
				}

				change := ovhChange{
					Action: ovhCreate,
					ovhRecord: ovhRecord{
						Zone: zone,
						ovhRecordFields: ovhRecordFields{
							FieldType: endpointsNew.RecordType,
							ovhRecordFieldUpdate: ovhRecordFieldUpdate{
								SubDomain: convertDNSNameIntoSubDomain(endpointsNew.DNSName, zone),
								TTL:       recordTTL,
								Target:    target,
							},
						},
					},
				}
				p.formatCNAMETarget(&change)
				changes = append(changes, change)
			}
		}

		if len(oldRecords) > 0 {
			for i := range oldRecords {
				changes = append(changes, ovhChange{
					Action:    ovhDelete,
					ovhRecord: oldRecords[i],
				})
			}
		}
	}

	return changes, nil
}

func (c *ovhChange) String() string {
	var action string
	switch c.Action {
	case ovhCreate:
		action = "create"
	case ovhUpdate:
		action = "update"
	case ovhDelete:
		action = "delete"
	default:
		action = "unknown"
	}

	if c.ID != 0 {
		return fmt.Sprintf("%s zone (ID : %d) action(%s) : %s %d IN %s %s", c.Zone, c.ID, action, c.SubDomain, c.TTL, c.FieldType, c.Target)
	}
	return fmt.Sprintf("%s zone action(%s) : %s %d IN %s %s", c.Zone, action, c.SubDomain, c.TTL, c.FieldType, c.Target)
}

func (p *OVHProvider) formatCNAMETarget(change *ovhChange) {
	if change.FieldType != endpoint.RecordTypeCNAME {
		return
	}

	if p.EnableCNAMERelativeTarget {
		return
	}

	if strings.HasSuffix(change.Target, ".") {
		return
	}

	change.Target += "."
}
