/*
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 dns

import (
	"fmt"
	"strings"
	"sync"
	"time"

	"k8s.io/klog/v2"
	"k8s.io/kops/protokube/pkg/gossip"
)

// We don't really support multiple zone ids, but we could
// Also, not supporting multiple zone ids makes implementing dnsprovider painful
const DefaultZoneName = "local"

type DNSTarget interface {
	Update(snapshot *DNSViewSnapshot) error
}

func RunDNSUpdates(target DNSTarget, src *DNSView) {
	var lastSnapshot *DNSViewSnapshot
	for {
		time.Sleep(5 * time.Second)

		// Consider replace with a watch?  But debouncing is also nice

		// Snapshot is very cheap if we are in-sync
		snapshot := src.Snapshot()
		if lastSnapshot != nil && lastSnapshot.version == snapshot.version {
			klog.V(4).Infof("DNSView unchanged: %v", lastSnapshot.version)
			continue
		}

		// TODO: We might want to keep old records alive for a bit

		klog.V(2).Infof("DNSView changed: %v", snapshot.version)

		err := target.Update(snapshot)
		if err != nil {
			klog.Warningf("error applying DNS changes to target: %v", err)
			continue
		}

		lastSnapshot = snapshot
	}

}

type DNSView struct {
	gossipState gossip.GossipState

	mutex        sync.Mutex
	lastSnapshot *DNSViewSnapshot
}

type DNSViewSnapshot struct {
	version uint64
	zoneMap map[string]*dnsViewSnapshotZone
}

type dnsViewSnapshotZone struct {
	Name    string
	Records map[string]DNSRecord
}

// RecordsForZone returns records matching the specified zone
func (s *DNSViewSnapshot) RecordsForZone(zoneInfo DNSZoneInfo) []DNSRecord {
	var records []DNSRecord

	zone := s.zoneMap[zoneInfo.Name]
	if zone != nil {
		for k := range zone.Records {
			records = append(records, zone.Records[k])
		}
	}

	return records
}

// RecordsForZoneAndName returns records matching the specified zone and name
func (s *DNSViewSnapshot) RecordsForZoneAndName(zoneInfo DNSZoneInfo, name string) []DNSRecord {
	var records []DNSRecord

	zone := s.zoneMap[zoneInfo.Name]
	if zone != nil {
		for k := range zone.Records {
			if zone.Records[k].Name != name {
				continue
			}

			records = append(records, zone.Records[k])
		}
	}

	return records
}

// ListZones returns all zones
func (s *DNSViewSnapshot) ListZones() []DNSZoneInfo {
	var zones []DNSZoneInfo
	for _, z := range s.zoneMap {
		zones = append(zones, DNSZoneInfo{
			Name: z.Name,
		})
	}
	return zones
}

// RemoveZone removes the specified zone, though this is currently not supported and returns an error.
func (v *DNSView) RemoveZone(info DNSZoneInfo) error {
	return fmt.Errorf("zone deletion is implicit")
}

// AddZone adds the specified zone; this creates a fake NS record just so that the zone has records
func (v *DNSView) AddZone(info DNSZoneInfo) (*DNSZoneInfo, error) {
	createRecords := []*DNSRecord{
		{
			RrsType: "NS",
			Name:    info.Name,
			Rrdatas: []string{"gossip"},
		},
	}

	err := v.ApplyChangeset(info, nil, createRecords)
	if err != nil {
		return nil, err
	}

	return &DNSZoneInfo{
		Name: info.Name,
	}, nil
}

// ApplyChangeset applies a DNS changeset to the records.
func (v *DNSView) ApplyChangeset(zone DNSZoneInfo, removeRecords []*DNSRecord, createRecords []*DNSRecord) error {
	var removeTags []string
	for _, record := range removeRecords {
		tagKey, err := buildTagKey(zone, record)
		if err != nil {
			return err
		}
		removeTags = append(removeTags, tagKey)
	}

	createTags := make(map[string]string)
	for _, record := range createRecords {
		tagKey, err := buildTagKey(zone, record)
		if err != nil {
			return err
		}
		if createTags[tagKey] != "" {
			return fmt.Errorf("duplicate record %q being created", tagKey)
		}
		createTags[tagKey] = strings.Join(record.Rrdatas, ",")
	}

	return v.gossipState.UpdateValues(removeTags, createTags)
}

func buildTagKey(zone DNSZoneInfo, record *DNSRecord) (string, error) {
	fqdn := strings.TrimSuffix(record.Name, ".")
	if fqdn != zone.Name && !strings.HasSuffix(fqdn, "."+zone.Name) {
		return "", fmt.Errorf("record %q not in zone %q", record.Name, zone.Name)
	}

	tokens := []string{
		"dns",
		zone.Name,
		record.RrsType,
		fqdn,
	}
	return strings.Join(tokens, "/"), nil
}

type DNSRecord struct {
	Name    string
	Rrdatas []string
	RrsType string
}

type DNSZoneInfo struct {
	Name string
}

func NewDNSView(gossipState gossip.GossipState) *DNSView {
	return &DNSView{
		gossipState: gossipState,
	}
}

// Snapshot returns a copy of the current desired DNS state-of-the-world
func (v *DNSView) Snapshot() *DNSViewSnapshot {
	v.mutex.Lock()
	defer v.mutex.Unlock()

	gossipSnapshot := v.gossipState.Snapshot()
	// Snapshot must be cheap if nothing has changed
	if v.lastSnapshot != nil && gossipSnapshot.Version == v.lastSnapshot.version {
		return v.lastSnapshot
	}

	snapshot := &DNSViewSnapshot{
		version: gossipSnapshot.Version,
	}

	zoneMap := make(map[string]*dnsViewSnapshotZone)
	for k, v := range gossipSnapshot.Values {
		if strings.HasPrefix(k, "dns/") {
			tokens := strings.Split(k, "/")
			if len(tokens) != 4 {
				klog.Warningf("key had invalid format: %q", k)
				continue
			}

			zoneID := tokens[1]
			recordType := tokens[2]
			name := tokens[3]

			zone := zoneMap[zoneID]
			if zone == nil {
				zone = &dnsViewSnapshotZone{
					Name:    zoneID,
					Records: make(map[string]DNSRecord),
				}
				zoneMap[zoneID] = zone
			}

			key := recordType + "::" + name

			record, found := zone.Records[key]
			if !found {
				record.Name = name
				record.RrsType = recordType
			}

			addresses := strings.Split(v, ",")
			record.Rrdatas = append(record.Rrdatas, addresses...)
			zone.Records[key] = record
		} else {
			klog.Warningf("unknown tag %q=%q", k, v)
		}
	}

	snapshot.zoneMap = zoneMap
	v.lastSnapshot = snapshot

	return snapshot
}
