package aws

import (
	"context"
	"errors"
	"fmt"
	"net"
	"sort"
	"strings"
	"sync"
	"time"

	coremodel "security-center-backend/internal/model"
	"security-center-backend/internal/modules/caasm/collector"
	model "security-center-backend/internal/modules/caasm/model"

	"github.com/aws/aws-sdk-go-v2/service/route53"
	"github.com/aws/aws-sdk-go-v2/service/route53/types"
	"github.com/aws/aws-sdk-go-v2/service/route53domains"
)

type route53AggregateSink struct {
	mu     sync.Mutex
	result *collector.AssetResult
}

type route53RecordGroup struct {
	Name    string
	Records []map[string]interface{}
}

type route53ListPosition struct {
	Name       string
	Type       string
	Identifier string
}

type route53RecordWindow struct {
	Groups      []route53RecordGroup
	DefaultTTL  uint
	PageCount   int
	Next        *route53ListPosition
	WindowIndex int
}

func (s *route53AggregateSink) PersistChunk(ctx context.Context, chunk *collector.AssetChunk) error {
	if s == nil || s.result == nil || chunk == nil {
		return nil
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	if s.result.CollectedAt.IsZero() && !chunk.Result.CollectedAt.IsZero() {
		s.result.CollectedAt = chunk.Result.CollectedAt
	}
	s.result.Assets = append(s.result.Assets, chunk.Result.Assets...)
	s.result.IPAssets = append(s.result.IPAssets, chunk.Result.IPAssets...)
	s.result.DomainAssets = append(s.result.DomainAssets, chunk.Result.DomainAssets...)
	s.result.Ports = append(s.result.Ports, chunk.Result.Ports...)
	s.result.PartialFailures = append(s.result.PartialFailures, chunk.Result.PartialFailures...)
	return nil
}

func (c *AWSCollector) collectRoute53(ctx context.Context, result *collector.AssetResult) error {
	if result == nil {
		return errors.New("route53 collection result is nil")
	}

	sink := &route53AggregateSink{result: result}
	_, err := c.collectRoute53Chunks(ctx, sink)
	return err
}

func (c *AWSCollector) collectRoute53Chunks(ctx context.Context, sink collector.ChunkSink) (*collector.CollectionSummary, error) {
	if sink == nil {
		return nil, errors.New("route53 chunk sink is nil")
	}

	svc := route53.NewFromConfig(c.cfg)
	summary := &collector.CollectionSummary{CollectedAt: time.Now()}

	// Route53 Domains API is available in us-east-1 for the standard 'aws' partition.
	// It is NOT available in 'aws-us-gov' or 'aws-cn' partitions.
	domainsRegion := "us-east-1"
	if strings.HasPrefix(c.cfg.Region, "us-gov") || strings.HasPrefix(c.cfg.Region, "cn-") {
		domainsRegion = ""
	}

	domainRegistrations := make(map[string]struct {
		ExpiresAt *time.Time
		AutoRenew bool
		Registrar string
	})
	zoneCheckpointEligible := true

	if domainsRegion != "" {
		usEast1Cfg := c.cfg.Copy()
		usEast1Cfg.Region = domainsRegion
		domainsSvc := route53domains.NewFromConfig(usEast1Cfg)
		registrations, err := c.collectRoute53Domains(ctx, domainsSvc)
		if err != nil {
			zoneCheckpointEligible = false
			summary.PartialFailures = append(summary.PartialFailures, collector.CollectionIssue{
				Scope:   "global",
				Target:  "route53domains",
				Message: err.Error(),
			})
			c.report("warning", fmt.Sprintf("Failed to collect Route53 domain registrations: %v", err), map[string]interface{}{
				"region": domainsRegion,
				"error":  err.Error(),
			})
		} else {
			domainRegistrations = registrations
		}
	}

	paginator := route53.NewListHostedZonesPaginator(svc, &route53.ListHostedZonesInput{})

	// Concurrency control
	zoneWorkerLimit := c.currentConcurrencyLimit(c.rateLimiterKey("route53", c.cfg.Region))
	if zoneWorkerLimit <= 0 {
		zoneWorkerLimit = 1
	}
	sem := make(chan struct{}, zoneWorkerLimit)

	var (
		wg              sync.WaitGroup
		mu              sync.Mutex
		collectedErrors []error
		sequence        int
	)
	nextSequence := func() int {
		mu.Lock()
		defer mu.Unlock()
		current := sequence
		sequence++
		return current
	}

	recordError := func(err error, issue collector.CollectionIssue) {
		if err == nil {
			return
		}
		mu.Lock()
		collectedErrors = append(collectedErrors, err)
		if issue.Message != "" {
			summary.PartialFailures = append(summary.PartialFailures, issue)
		}
		mu.Unlock()
	}

	for paginator.HasMorePages() {
		page, err := paginator.NextPage(ctx)
		if err != nil {
			return summary, err
		}

		for _, zone := range page.HostedZones {
			if c.domainScope == model.CollectionScopePublicOnly && zone.Config != nil && zone.Config.PrivateZone {
				c.report("info", "Skipping private Route53 hosted zone due to domain collection scope", map[string]interface{}{
					"zone_id":   safeZoneID(zone),
					"zone_name": safeZoneName(zone),
					"zone_type": "private",
					"scope":     "public_only",
				})
				continue
			}
			if c.hasCompletedRoute53Zone(safeZoneID(zone)) {
				c.report("info", "Skipping Route53 zone due to resume checkpoint", map[string]interface{}{
					"zone_id":   safeZoneID(zone),
					"zone_name": safeZoneName(zone),
				})
				continue
			}
			select {
			case sem <- struct{}{}:
			case <-ctx.Done():
				recordError(fmt.Errorf("route53 zone scheduling canceled: %w", ctx.Err()), collector.CollectionIssue{
					Scope:   "global",
					Target:  "route53",
					Message: ctx.Err().Error(),
				})
				wg.Wait()
				return summary, errors.Join(collectedErrors...)
			}

			zoneSeq := nextSequence()
			wg.Add(1)

			go func(z types.HostedZone, seq int) {
				defer wg.Done()
				defer func() { <-sem }()

				chunkCount, err := c.collectRoute53ZoneChunks(ctx, svc, z, domainRegistrations, sink, seq, nextSequence, zoneCheckpointEligible)
				if err != nil {
					domainName := safeZoneName(z)
					recordError(fmt.Errorf("route53 zone %s chunk build failed: %w", domainName, err), collector.CollectionIssue{
						Scope:   "zone",
						Target:  domainName,
						Message: err.Error(),
					})
					c.report("warning", fmt.Sprintf("Skipping Route53 zone %s due to chunk build failure", domainName), map[string]interface{}{
						"zone_id": safeZoneID(z),
						"domain":  domainName,
						"error":   err.Error(),
					})
					return
				}

				mu.Lock()
				summary.ChunkCount += chunkCount
				if summary.CollectedAt.IsZero() {
					summary.CollectedAt = time.Now()
				}
				mu.Unlock()
			}(zone, zoneSeq)
		}
	}

	wg.Wait()
	if len(collectedErrors) > 0 {
		return summary, errors.Join(collectedErrors...)
	}
	return summary, nil
}

const route53RecordGroupWindowSize = 100

func (c *AWSCollector) collectRoute53ZoneChunks(ctx context.Context, svc *route53.Client, z types.HostedZone, domainRegistrations map[string]struct {
	ExpiresAt *time.Time
	AutoRenew bool
	Registrar string
}, sink collector.ChunkSink, initialSequence int, nextSequence func() int, checkpointEligible bool) (int, error) {
	if z.Id == nil || z.Name == nil {
		return 0, fmt.Errorf("hosted zone missing id or name")
	}

	collectedAt := time.Now()
	domainName := normalizeRoute53DNSName(*z.Name)
	zoneID := *z.Id

	// 1. Collect Tags for Environment
	tags := c.getZoneTags(ctx, svc, *z.Id)
	environment := c.extractEnvironment(tags)

	// Fetch nameservers and linked VPCs
	nameServers, linkedVPCs, err := c.getNameServers(ctx, svc, *z.Id)
	if err != nil {
		return 0, fmt.Errorf("hosted-zone lookup failed: %w", err)
	}

	// 3. Check DNS Health
	dnsHealth := c.checkDNSHealth(ctx, domainName, nameServers)

	zoneType := "public"
	privateZone := false
	if z.Config != nil {
		privateZone = z.Config.PrivateZone
	}
	if privateZone {
		zoneType = "private"
	}

	// Merge with registration data
	var expiresAt *time.Time
	autoRenew := false
	registrar := "route53"

	if reg, ok := domainRegistrations[domainName]; ok {
		expiresAt = reg.ExpiresAt
		autoRenew = reg.AutoRenew
		registrar = reg.Registrar
	}

	// Base Asset
	asset := c.newBaseAsset(zoneID, domainName, string(model.AssetTypeNetwork), "active", "global")
	c.setService(&asset, "route53")
	asset.Environment = environment

	// Prepare PeeringCfg with Linked VPCs
	var peeringCfg coremodel.JSONB
	if len(linkedVPCs) > 0 {
		peeringCfg = coremodel.JSONB{"linked_vpcs": linkedVPCs}
	}

	domainAsset := model.DomainAsset{
		Asset:         asset,
		DomainName:    domainName,
		DomainClass:   model.DomainClassAuthoritativeZone,
		Registrar:     registrar,
		ZoneID:        *z.Id,
		ZoneType:      zoneType,
		Status:        "active",
		NameServers:   nameServers,
		LastSyncedAt:  &collectedAt,
		FirstSeenAt:   &collectedAt,
		LastSeenAt:    &collectedAt,
		ExpiresAt:     expiresAt,
		AutoRenew:     autoRenew,
		PeeringCfg:    peeringCfg,
		RoutingPolicy: c.extractRoutingPolicy(z.Config),
		DNSHealth:     dnsHealth,
	}

	baseMeta := coremodel.JSONB{
		"zone_id":      zoneID,
		"private_zone": privateZone,
		"record_count": z.ResourceRecordSetCount,
		"tags":         tags,
	}
	asset.Metadata = baseMeta

	startPosition := c.route53ZoneStartPosition(zoneID)
	rootEmitted := startPosition != nil
	chunkCount := 0
	recordGroupCount := 0
	totalPageCount := 0
	err = c.visitRecordSetGroupWindows(ctx, svc, zoneID, startPosition, route53RecordGroupWindowSize, func(window route53RecordWindow) error {
		recordGroupCount += len(window.Groups)
		totalPageCount = window.PageCount
		derived := make([]model.DomainAsset, 0, len(window.Groups))
		for _, group := range window.Groups {
			derived = append(derived, newRoute53RecordBackedDomain(domainAsset, group, collectedAt))
		}
		for i := range derived {
			derived[i].TTLDefault = window.DefaultTTL
		}

		chunkSequence := initialSequence
		if chunkCount > 0 && nextSequence != nil {
			chunkSequence = nextSequence()
		}
		zoneDomainAsset := domainAsset
		zoneDomainAsset.TTLDefault = window.DefaultTTL
		zoneAssetsMeta := coremodel.JSONB{
			"zone_id":            zoneID,
			"private_zone":       privateZone,
			"record_count":       z.ResourceRecordSetCount,
			"record_page_count":  window.PageCount,
			"record_group_count": recordGroupCount,
			"window_group_count": len(window.Groups),
			"window_index":       window.WindowIndex,
			"tags":               tags,
		}
		zoneAssets := make([]model.Asset, 0, 1)
		zoneDomainAssets := make([]model.DomainAsset, 0, len(derived)+1)
		if !rootEmitted {
			zoneAsset := asset
			zoneAsset.Metadata = zoneAssetsMeta
			zoneAssets = append(zoneAssets, zoneAsset)
			zoneDomainAssets = append(zoneDomainAssets, zoneDomainAsset)
			rootEmitted = true
		}
		zoneDomainAssets = append(zoneDomainAssets, derived...)

		checkpoint := collector.ChunkCheckpoint{
			Route53ZoneID: zoneID,
			Route53Zone:   domainName,
			Eligible:      checkpointEligible,
		}
		if window.Next == nil {
			checkpoint.Route53ZoneComplete = true
		} else {
			checkpoint.Route53NextRecordName = window.Next.Name
			checkpoint.Route53NextRecordType = window.Next.Type
			checkpoint.Route53NextRecordIdentifier = window.Next.Identifier
		}

		chunk := &collector.AssetChunk{
			Result: collector.AssetResult{
				Assets:       zoneAssets,
				DomainAssets: zoneDomainAssets,
				CollectedAt:  collectedAt,
			},
			Scope:    "route53_window",
			Target:   fmt.Sprintf("%s/window-%d", domainName, window.WindowIndex),
			Sequence: chunkSequence,
			Metadata: map[string]interface{}{
				"zone_id":            zoneID,
				"zone_name":          domainName,
				"private_zone":       privateZone,
				"record_count":       z.ResourceRecordSetCount,
				"record_page_count":  window.PageCount,
				"record_group_count": recordGroupCount,
				"window_group_count": len(window.Groups),
				"window_index":       window.WindowIndex,
				"dns_health_status":  dnsHealth["status"],
			},
			Checkpoint: checkpoint,
		}
		if err := sink.PersistChunk(ctx, chunk); err != nil {
			return err
		}
		chunkCount++
		return nil
	})
	if err != nil {
		return chunkCount, fmt.Errorf("record-set collection failed: %w", err)
	}
	c.report("info", "Finished Route53 zone windowed collection", map[string]interface{}{
		"zone_id":            zoneID,
		"zone_name":          domainName,
		"record_page_count":  totalPageCount,
		"record_group_count": recordGroupCount,
		"chunk_count":        chunkCount,
	})
	return chunkCount, nil
}

// collectRoute53Domains fetches domain registration details
func (c *AWSCollector) collectRoute53Domains(ctx context.Context, svc *route53domains.Client) (map[string]struct {
	ExpiresAt *time.Time
	AutoRenew bool
	Registrar string
}, error) {
	results := make(map[string]struct {
		ExpiresAt *time.Time
		AutoRenew bool
		Registrar string
	})

	paginator := route53domains.NewListDomainsPaginator(svc, &route53domains.ListDomainsInput{})

	for paginator.HasMorePages() {
		page, err := paginator.NextPage(ctx)
		if err != nil {
			return results, err
		}

		for _, d := range page.Domains {
			var autoRenew bool
			if d.AutoRenew != nil {
				autoRenew = *d.AutoRenew
			}
			results[*d.DomainName] = struct {
				ExpiresAt *time.Time
				AutoRenew bool
				Registrar string
			}{
				ExpiresAt: d.Expiry,
				AutoRenew: autoRenew,
				Registrar: "Amazon Route 53",
			}
		}
	}
	return results, nil
}

func (c *AWSCollector) getZoneTags(ctx context.Context, svc *route53.Client, zoneID string) map[string]string {
	tags := make(map[string]string)
	cleanID := strings.TrimPrefix(zoneID, "/hostedzone/")

	out, err := svc.ListTagsForResource(ctx, &route53.ListTagsForResourceInput{
		ResourceType: types.TagResourceTypeHostedzone,
		ResourceId:   &cleanID,
	})
	if err != nil {
		eventType := "warning"
		message := fmt.Sprintf("Failed to retrieve tags for Route53 zone: %v", err)
		if downgradedType, downgradedMessage, ok := classifyAWSHelperEvent("route53", "ListTagsForResource", err); ok {
			eventType = downgradedType
			message = downgradedMessage
		}
		c.report(eventType, message, map[string]interface{}{
			"zone_id": zoneID,
			"error":   err.Error(),
		})
		return tags
	}

	for _, t := range out.ResourceTagSet.Tags {
		if t.Key != nil && t.Value != nil {
			tags[*t.Key] = *t.Value
		}
	}
	return tags
}

func (c *AWSCollector) checkDNSHealth(ctx context.Context, domain string, nameServers []string) coremodel.JSONB {
	if len(nameServers) == 0 {
		return buildResolverLevelDNSHealth(nameServers, 0, fmt.Errorf("no_nameservers"))
	}

	resolver := &net.Resolver{
		PreferGo: true,
		Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
			d := net.Dialer{
				Timeout: 2 * time.Second,
			}
			return d.DialContext(ctx, network, address)
		},
	}

	start := time.Now()
	_, err := resolver.LookupHost(ctx, domain)
	latency := time.Since(start)
	return buildResolverLevelDNSHealth(nameServers, latency, err)
}

func buildResolverLevelDNSHealth(nameServers []string, latency time.Duration, err error) coremodel.JSONB {
	health := coremodel.JSONB{
		"status":                "healthy",
		"check_scope":           "resolver",
		"check_method":          "system_resolver_lookup",
		"authoritative_probing": false,
		"nameservers_observed":  nameServers,
	}

	if latency > 0 {
		health["latency_ms"] = latency.Milliseconds()
	}

	if err != nil {
		if err.Error() == "no_nameservers" {
			health["status"] = "unknown"
			health["reason"] = "no_nameservers"
			return health
		}
		health["status"] = "unhealthy"
		health["error"] = err.Error()
	}

	return health
}

func buildRoute53RecordBackedDomainsFromGrouped(zone model.DomainAsset, groupedRecords map[string][]map[string]interface{}, collectedAt time.Time) []model.DomainAsset {
	if len(groupedRecords) == 0 {
		return nil
	}

	names := make([]string, 0, len(groupedRecords))
	for name := range groupedRecords {
		names = append(names, name)
	}
	sort.Strings(names)

	derived := make([]model.DomainAsset, 0, len(names))
	for _, name := range names {
		derived = append(derived, newRoute53RecordBackedDomain(zone, route53RecordGroup{
			Name:    name,
			Records: groupedRecords[name],
		}, collectedAt))
	}

	return derived
}

func newRoute53RecordBackedDomain(zone model.DomainAsset, group route53RecordGroup, collectedAt time.Time) model.DomainAsset {
	return model.DomainAsset{
		Asset:        model.Asset{NativeID: zone.Asset.NativeID},
		DomainName:   group.Name,
		DomainClass:  model.DomainClassDNSRecord,
		Registrar:    zone.Registrar,
		ZoneID:       zone.ZoneID,
		ZoneType:     zone.ZoneType,
		Status:       zone.Status,
		TTLDefault:   zone.TTLDefault,
		AutoRenew:    zone.AutoRenew,
		ExpiresAt:    zone.ExpiresAt,
		LastSyncedAt: &collectedAt,
		FirstSeenAt:  &collectedAt,
		LastSeenAt:   &collectedAt,
		DNSRecords: coremodel.JSONB{
			"records": group.Records,
			"fetched": collectedAt,
		},
	}
}

func buildRoute53RecordBackedDomains(zone model.DomainAsset, records coremodel.JSONB, collectedAt time.Time) []model.DomainAsset {
	groupedRecords, err := normalizeRoute53RecordEntries(records)
	if err != nil || len(groupedRecords) == 0 {
		return nil
	}
	return buildRoute53RecordBackedDomainsFromGrouped(zone, groupedRecords, collectedAt)
}

func normalizeRoute53RecordEntries(records coremodel.JSONB) (map[string][]map[string]interface{}, error) {
	if records == nil {
		return nil, nil
	}

	grouped := make(map[string][]map[string]interface{})

	if direct, ok := records["records"].([]map[string]interface{}); ok {
		for _, record := range direct {
			rawName, _ := record["name"].(string)
			name := normalizeRoute53DNSName(rawName)
			if name == "" {
				continue
			}
			grouped[name] = append(grouped[name], record)
		}
		return grouped, nil
	}

	if generic, ok := records["records"].([]interface{}); ok {
		for _, item := range generic {
			rec, ok := item.(map[string]interface{})
			if !ok {
				continue
			}
			rawName, _ := rec["name"].(string)
			name := normalizeRoute53DNSName(rawName)
			if name == "" {
				continue
			}
			grouped[name] = append(grouped[name], rec)
		}
		return grouped, nil
	}

	if direct, ok := records["grouped_records"].(map[string][]map[string]interface{}); ok {
		return direct, nil
	}

	return nil, nil
}

func (c *AWSCollector) listRecordSets(ctx context.Context, svc *route53.Client, zoneID string) (coremodel.JSONB, uint, error) {
	grouped, defaultTTL, _, err := c.listRecordSetsGrouped(ctx, svc, zoneID)
	if err != nil {
		return coremodel.JSONB{}, 0, err
	}
	names := make([]string, 0, len(grouped))
	for name := range grouped {
		names = append(names, name)
	}
	sort.Strings(names)
	records := make([]map[string]interface{}, 0)
	for _, name := range names {
		records = append(records, grouped[name]...)
	}
	return coremodel.JSONB{
		"records":         records,
		"grouped_records": grouped,
		"fetched":         time.Now(),
	}, defaultTTL, nil
}

func (c *AWSCollector) visitRecordSetGroups(ctx context.Context, svc *route53.Client, zoneID string, visit func(route53RecordGroup) error) (uint, int, error) {
	var defaultTTL uint
	pageCount := 0

	p := route53.NewListResourceRecordSetsPaginator(svc, &route53.ListResourceRecordSetsInput{
		HostedZoneId: &zoneID,
	})

	var currentName string
	currentRecords := make([]map[string]interface{}, 0)
	flushCurrent := func() error {
		if currentName == "" || len(currentRecords) == 0 {
			return nil
		}
		group := route53RecordGroup{
			Name:    currentName,
			Records: append([]map[string]interface{}(nil), currentRecords...),
		}
		currentRecords = currentRecords[:0]
		return visit(group)
	}

	for p.HasMorePages() {
		page, err := p.NextPage(ctx)
		if err != nil {
			return 0, pageCount, err
		}
		pageCount++
		for _, rr := range page.ResourceRecordSets {
			if rr.Name == nil {
				continue
			}
			if rr.Type == types.RRTypeSoa && rr.TTL != nil {
				defaultTTL = uint(*rr.TTL)
			}
			rec, name := normalizeRoute53RecordSet(rr)
			if name == "" {
				continue
			}
			if currentName != "" && name != currentName {
				if err := flushCurrent(); err != nil {
					return 0, pageCount, err
				}
			}
			currentName = name
			currentRecords = append(currentRecords, rec)
		}
	}
	if err := flushCurrent(); err != nil {
		return 0, pageCount, err
	}
	return defaultTTL, pageCount, nil
}

func (c *AWSCollector) route53ZoneStartPosition(zoneID string) *route53ListPosition {
	progress, ok := c.route53ZoneProgress(zoneID)
	if !ok || progress.NextRecordName == "" || progress.NextRecordType == "" {
		return nil
	}
	return &route53ListPosition{
		Name:       progress.NextRecordName,
		Type:       progress.NextRecordType,
		Identifier: progress.NextRecordIdentifier,
	}
}

func (c *AWSCollector) visitRecordSetGroupWindows(ctx context.Context, svc *route53.Client, zoneID string, start *route53ListPosition, maxGroups int, visit func(route53RecordWindow) error) error {
	if maxGroups <= 0 {
		maxGroups = 1
	}

	input := &route53.ListResourceRecordSetsInput{
		HostedZoneId: &zoneID,
	}
	if start != nil {
		if start.Name != "" {
			input.StartRecordName = &start.Name
		}
		if start.Type != "" {
			rrType := types.RRType(start.Type)
			input.StartRecordType = rrType
		}
		if start.Identifier != "" {
			input.StartRecordIdentifier = &start.Identifier
		}
	}

	p := route53.NewListResourceRecordSetsPaginator(svc, input)

	var (
		defaultTTL     uint
		pageCount      int
		windowIndex    int
		currentName    string
		currentRecords []map[string]interface{}
		pendingGroups  []route53RecordGroup
	)

	flushWindow := func(next *route53ListPosition) error {
		if len(pendingGroups) == 0 {
			return nil
		}
		windowIndex++
		window := route53RecordWindow{
			Groups:      append([]route53RecordGroup(nil), pendingGroups...),
			DefaultTTL:  defaultTTL,
			PageCount:   pageCount,
			Next:        next,
			WindowIndex: windowIndex,
		}
		pendingGroups = pendingGroups[:0]
		return visit(window)
	}

	flushCurrentGroup := func(next *route53ListPosition) error {
		if currentName == "" || len(currentRecords) == 0 {
			return nil
		}
		group := route53RecordGroup{
			Name:    currentName,
			Records: append([]map[string]interface{}(nil), currentRecords...),
		}
		currentName = ""
		currentRecords = currentRecords[:0]
		pendingGroups = append(pendingGroups, group)
		if len(pendingGroups) >= maxGroups {
			return flushWindow(next)
		}
		return nil
	}

	for p.HasMorePages() {
		page, err := p.NextPage(ctx)
		if err != nil {
			return err
		}
		pageCount++
		for _, rr := range page.ResourceRecordSets {
			if rr.Name == nil {
				continue
			}
			if rr.Type == types.RRTypeSoa && rr.TTL != nil {
				defaultTTL = uint(*rr.TTL)
			}
			rec, name := normalizeRoute53RecordSet(rr)
			if name == "" {
				continue
			}
			if currentName != "" && name != currentName {
				next := &route53ListPosition{
					Name:       awsStringValue(rr.Name),
					Type:       string(rr.Type),
					Identifier: awsStringValue(rr.SetIdentifier),
				}
				if err := flushCurrentGroup(next); err != nil {
					return err
				}
			}
			currentName = name
			currentRecords = append(currentRecords, rec)
		}
	}

	if err := flushCurrentGroup(nil); err != nil {
		return err
	}
	return flushWindow(nil)
}

// listRecordSetsGrouped returns records grouped by record name and the default TTL (from SOA).
// Grouping by name preserves cross-page semantics without materializing all records for the account.
func (c *AWSCollector) listRecordSetsGrouped(ctx context.Context, svc *route53.Client, zoneID string) (map[string][]map[string]interface{}, uint, int, error) {
	grouped := make(map[string][]map[string]interface{})
	defaultTTL, pageCount, err := c.visitRecordSetGroups(ctx, svc, zoneID, func(group route53RecordGroup) error {
		grouped[group.Name] = append(grouped[group.Name], group.Records...)
		return nil
	})
	if err != nil {
		return nil, 0, pageCount, err
	}
	return grouped, defaultTTL, pageCount, nil
}

func normalizeRoute53RecordSet(rr types.ResourceRecordSet) (map[string]interface{}, string) {
	if rr.Name == nil {
		return nil, ""
	}
	name := normalizeRoute53DNSName(*rr.Name)
	rec := map[string]interface{}{
		"name": name,
		"type": rr.Type,
	}
	if rr.TTL != nil {
		rec["ttl"] = int(*rr.TTL)
	}
	if rr.SetIdentifier != nil {
		rec["set_identifier"] = *rr.SetIdentifier
	}
	if rr.Weight != nil {
		rec["weight"] = *rr.Weight
	}
	if rr.Region != "" {
		rec["region"] = rr.Region
	}
	if rr.Failover != "" {
		rec["failover"] = rr.Failover
	}
	if rr.AliasTarget != nil {
		rec["alias_target"] = map[string]interface{}{
			"dns_name":               normalizeRoute53DNSName(*rr.AliasTarget.DNSName),
			"evaluate_target_health": rr.AliasTarget.EvaluateTargetHealth,
		}
	}
	if rr.HealthCheckId != nil {
		rec["health_check_id"] = *rr.HealthCheckId
	}
	values := make([]string, 0, len(rr.ResourceRecords))
	for _, v := range rr.ResourceRecords {
		if v.Value != nil {
			values = append(values, normalizeRoute53RecordValue(rr.Type, *v.Value))
		}
	}
	if len(values) > 0 {
		rec["values"] = values
	}
	if evidence := route53DNSEvidence(rr.Type, values, rec["alias_target"]); evidence != nil {
		rec["dns_evidence"] = evidence
		rec["ownership_status"] = "unverified"
	}
	return rec, name
}

func route53DNSEvidence(recordType types.RRType, values []string, aliasTarget interface{}) map[string]interface{} {
	switch recordType {
	case types.RRTypeA, types.RRTypeAaaa, types.RRTypeCname:
	default:
		if aliasTarget == nil {
			return nil
		}
	}
	evidence := map[string]interface{}{
		"evidence_source":  "route53_dns_record",
		"ownership_status": "unverified",
		"record_type":      string(recordType),
	}
	resolutionIPs := make([]string, 0, len(values))
	for _, value := range values {
		if net.ParseIP(value) != nil {
			resolutionIPs = append(resolutionIPs, value)
		}
	}
	if len(resolutionIPs) > 0 {
		evidence["resolution_ips"] = resolutionIPs
	}
	if aliasTarget != nil {
		evidence["alias_target"] = aliasTarget
	}
	if len(values) > 0 {
		evidence["record_values"] = values
	}
	return evidence
}

func normalizeRoute53DNSName(value string) string {
	name := strings.TrimSpace(value)
	name = strings.TrimSuffix(name, ".")
	name = strings.NewReplacer(
		"\\\\052", "*",
		"\\052", "*",
	).Replace(name)
	return strings.ToLower(name)
}

func normalizeRoute53RecordValue(recordType types.RRType, value string) string {
	trimmed := strings.TrimSpace(value)
	switch recordType {
	case types.RRTypeCname, types.RRTypeNs, types.RRTypePtr:
		return normalizeRoute53DNSName(trimmed)
	case types.RRTypeMx:
		parts := strings.Fields(trimmed)
		if len(parts) == 2 {
			return parts[0] + " " + normalizeRoute53DNSName(parts[1])
		}
	case types.RRTypeSrv:
		parts := strings.Fields(trimmed)
		if len(parts) == 4 {
			return strings.Join([]string{parts[0], parts[1], parts[2], normalizeRoute53DNSName(parts[3])}, " ")
		}
	case types.RRTypeSoa:
		parts := strings.Fields(trimmed)
		if len(parts) >= 7 {
			parts[0] = normalizeRoute53DNSName(parts[0])
			parts[1] = normalizeRoute53DNSName(parts[1])
			return strings.Join(parts, " ")
		}
	}
	return trimmed
}

func awsStringValue(value *string) string {
	if value == nil {
		return ""
	}
	return *value
}

func safeZoneName(z types.HostedZone) string {
	if z.Name == nil {
		return ""
	}
	return normalizeRoute53DNSName(*z.Name)
}

func safeZoneID(z types.HostedZone) string {
	if z.Id == nil {
		return ""
	}
	return *z.Id
}

// getNameServers returns nameservers list and linked VPCs
func (c *AWSCollector) getNameServers(ctx context.Context, svc *route53.Client, zoneID string) ([]string, []map[string]string, error) {
	out, err := svc.GetHostedZone(ctx, &route53.GetHostedZoneInput{Id: &zoneID})
	if err != nil {
		return []string{}, nil, err
	}

	var ns []string
	if out.DelegationSet != nil {
		ns = out.DelegationSet.NameServers
	}

	var vpcs []map[string]string
	for _, v := range out.VPCs {
		if v.VPCId != nil {
			vpcs = append(vpcs, map[string]string{
				"vpc_id":     *v.VPCId,
				"vpc_region": string(v.VPCRegion),
			})
		}
	}

	return ns, vpcs, nil
}

func (c *AWSCollector) extractRoutingPolicy(cfg *types.HostedZoneConfig) coremodel.JSONB {
	if cfg == nil {
		return coremodel.JSONB{}
	}
	return coremodel.JSONB{
		"comment":      cfg.Comment,
		"private_zone": cfg.PrivateZone,
	}
}
