package service

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"net/http"
	"net/netip"
	"regexp"
	"slices"
	"strconv"
	"strings"
	"time"

	scannerclient "security-center-backend/internal/modules/scan/client"
)

const (
	defaultMaxTargets                = 1000
	defaultMaxCampaignTargets        = 50000
	defaultCampaignSubmitConcurrency = 2
	defaultDispatcherInterval        = 5 * time.Second
)

var (
	ErrInvalidRequest      = errors.New("invalid scan request")
	ErrMissingDependency   = errors.New("scan dependency is not configured")
	ErrUnknownBatch        = errors.New("scan batch not found")
	ErrUnknownCampaign     = errors.New("scan campaign not found")
	ErrDuplicateCallback   = errors.New("duplicate callback")
	ErrCallbackConflict    = errors.New("callback idempotency conflict")
	ErrPersistenceFailure  = errors.New("scan persistence failed")
	ErrNoAcceptedTargets   = errors.New("no accepted scan targets")
	ErrScannerConflict     = errors.New("scanner batch conflict")
	ErrScannerUnavailable  = errors.New("scanner unavailable")
	clientRequestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$`)
)

func New(repo Repository, resolver TargetResolver, scanner ScannerClient, cfg Config) *Service {
	now := cfg.Now
	if now == nil {
		now = time.Now
	}
	maxTargets := cfg.MaxTargetsPerBatch
	if maxTargets <= 0 {
		maxTargets = defaultMaxTargets
	}
	maxCampaignTargets := cfg.MaxCampaignTargets
	if maxCampaignTargets <= 0 {
		maxCampaignTargets = defaultMaxCampaignTargets
	}
	concurrency := cfg.CampaignSubmitConcurrency
	if concurrency <= 0 {
		concurrency = defaultCampaignSubmitConcurrency
	}
	interval := cfg.CampaignDispatcherInterval
	if interval <= 0 {
		interval = defaultDispatcherInterval
	}
	return &Service{
		repo:                      repo,
		resolver:                  resolver,
		scanner:                   scanner,
		callbackToken:             strings.TrimSpace(cfg.CallbackToken),
		maxTargets:                maxTargets,
		maxCampaignTargets:        maxCampaignTargets,
		campaignSubmitConcurrency: concurrency,
		dispatcherInterval:        interval,
		allowNonPublicTargets:     cfg.AllowNonPublicTargets,
		now:                       now,
	}
}

func (s *Service) CallbackToken() string {
	if s == nil {
		return ""
	}
	return s.callbackToken
}

func (s *Service) CreateBatch(ctx context.Context, req CreateBatchRequest) (*Batch, error) {
	if s == nil || s.repo == nil || s.resolver == nil || s.scanner == nil {
		return nil, ErrMissingDependency
	}
	req.Source = strings.TrimSpace(req.Source)
	if req.Source != SourceCAASMPorts && req.Source != SourceCAASMIPAssets {
		return nil, fmt.Errorf("%w: unsupported source", ErrInvalidRequest)
	}
	targetScope := normalizeTargetScope(req.TargetScope)
	switch targetScope {
	case TargetScopeSelectedPorts:
		if len(req.TargetIDs) == 0 {
			return nil, fmt.Errorf("%w: target_ids are required", ErrInvalidRequest)
		}
		if len(req.TargetIDs) > s.maxTargets {
			return nil, fmt.Errorf("%w: target_ids exceeds max batch size", ErrInvalidRequest)
		}
	case TargetScopeAllPublicPorts:
		if req.Source != SourceCAASMPorts {
			return nil, fmt.Errorf("%w: all_public_ports target_scope requires caasm_ports source", ErrInvalidRequest)
		}
	default:
		return nil, fmt.Errorf("%w: unsupported target_scope", ErrInvalidRequest)
	}
	requestID, err := DeriveRequestID(req.ClientRequestID)
	if err != nil {
		return nil, err
	}
	candidates, err := s.resolver.ResolveTargets(ctx, ResolveTargetsRequest{
		Source:      req.Source,
		TargetScope: targetScope,
		TargetIDs:   req.TargetIDs,
		Limit:       s.maxTargets + 1,
	})
	if err != nil {
		return nil, err
	}
	if len(candidates) > s.maxTargets {
		return nil, fmt.Errorf("%w: resolved targets exceeds max batch size", ErrInvalidRequest)
	}
	accepted, rejected := normalizeTargets(candidates, normalizeTargetOptions{AllowNonPublicTargets: s.allowNonPublicTargets})
	if len(accepted) == 0 {
		return nil, ErrNoAcceptedTargets
	}
	batch, err := s.repo.CreateBatch(ctx, CreateBatchRecordInput{
		RequestID:       requestID,
		ClientRequestID: strings.TrimSpace(req.ClientRequestID),
		Source:          req.Source,
		TargetCount:     len(candidates),
		AcceptedTargets: accepted,
		RejectedTargets: rejected,
		Metadata:        req.Metadata,
	})
	if err != nil {
		return nil, fmt.Errorf("%w: create local scan batch: %v", ErrPersistenceFailure, err)
	}
	scannerReq := scannerclient.CreateBatchRequest{
		RequestID: requestID,
		Source:    scannerclient.DefaultSource,
		Targets:   make([]scannerclient.CreateBatchTarget, 0, len(accepted)),
		Metadata:  req.Metadata,
	}
	for _, target := range accepted {
		scannerReq.Targets = append(scannerReq.Targets, scannerTarget(target))
	}
	scannerResp, err := s.scanner.CreateBatch(ctx, scannerReq)
	if err != nil {
		_ = s.repo.MarkBatchSubmitFailed(ctx, batch.ID, err.Error())
		return nil, err
	}
	submitted, err := s.repo.MarkBatchSubmitted(ctx, batch.ID, BatchSubmittedInput{
		ScannerBatchID:   scannerResp.BatchID,
		Status:           scannerResp.Status,
		AcceptedTargets:  scannerResp.AcceptedTargets,
		RejectedTargets:  len(rejected) + scannerResp.RejectedTargets,
		QueuedJobs:       scannerResp.QueuedJobs,
		RunningJobs:      scannerResp.RunningJobs,
		SucceededJobs:    scannerResp.SucceededJobs,
		FailedJobs:       scannerResp.FailedJobs,
		SkippedJobs:      scannerResp.SkippedJobs,
		RawCreatePayload: scannerResp,
		ScannerTargets:   scannerResp.Targets,
	})
	if err != nil {
		return nil, fmt.Errorf("%w: mark scanner batch submitted: %v", ErrPersistenceFailure, err)
	}
	return submitted, nil
}

func (s *Service) CreateCampaign(ctx context.Context, req CreateCampaignRequest) (*Campaign, error) {
	if s == nil || s.repo == nil || s.resolver == nil || s.scanner == nil {
		return nil, ErrMissingDependency
	}
	req.Source = strings.TrimSpace(req.Source)
	if req.Source != SourceCAASMPorts {
		return nil, fmt.Errorf("%w: campaign source must be caasm_ports", ErrInvalidRequest)
	}
	targetScope := normalizeTargetScope(req.TargetScope)
	if targetScope != TargetScopeAllPublicPorts {
		return nil, fmt.Errorf("%w: campaign target_scope must be all_public_ports", ErrInvalidRequest)
	}
	if len(req.TargetIDs) > 0 {
		return nil, fmt.Errorf("%w: campaign all_public_ports does not accept target_ids", ErrInvalidRequest)
	}
	requestID, err := DeriveRequestID(req.ClientRequestID)
	if err != nil {
		return nil, err
	}
	return s.repo.CreateCampaign(ctx, CreateCampaignRecordInput{
		RequestID:       requestID,
		ClientRequestID: strings.TrimSpace(req.ClientRequestID),
		Source:          req.Source,
		TargetScope:     targetScope,
		Metadata:        req.Metadata,
	})
}

func (s *Service) ListCampaigns(ctx context.Context, filter ListCampaignsFilter) (*PaginatedResult[Campaign], error) {
	if s == nil || s.repo == nil {
		return nil, ErrMissingDependency
	}
	filter.Pagination = normalizePagination(filter.Pagination)
	return s.repo.ListCampaigns(ctx, filter)
}

func (s *Service) GetCampaign(ctx context.Context, id uint64) (*Campaign, error) {
	if s == nil || s.repo == nil {
		return nil, ErrMissingDependency
	}
	if id == 0 {
		return nil, fmt.Errorf("%w: invalid campaign id", ErrInvalidRequest)
	}
	return s.repo.GetCampaign(ctx, id)
}

func (s *Service) ListCampaignBatches(ctx context.Context, campaignID uint64, filter ListBatchesFilter) (*PaginatedResult[Batch], error) {
	if s == nil || s.repo == nil {
		return nil, ErrMissingDependency
	}
	if campaignID == 0 {
		return nil, fmt.Errorf("%w: invalid campaign id", ErrInvalidRequest)
	}
	filter.Pagination = normalizePagination(filter.Pagination)
	return s.repo.ListCampaignBatches(ctx, campaignID, filter)
}

func (s *Service) SyncCampaign(ctx context.Context, campaignID uint64) (*Campaign, error) {
	if s == nil || s.repo == nil || s.scanner == nil {
		return nil, ErrMissingDependency
	}
	if campaignID == 0 {
		return nil, fmt.Errorf("%w: invalid campaign id", ErrInvalidRequest)
	}
	if _, err := s.repo.GetCampaign(ctx, campaignID); err != nil {
		return nil, err
	}
	page := Pagination{Page: 1, PageSize: 200}
	for {
		children, err := s.repo.ListCampaignBatches(ctx, campaignID, ListBatchesFilter{Pagination: page})
		if err != nil {
			return nil, err
		}
		for _, child := range children.Items {
			if strings.TrimSpace(child.ScannerBatchID) == "" {
				continue
			}
			if _, err := s.SyncBatch(ctx, child.ID); err != nil {
				message := err.Error()
				status := "sync_failed"
				_ = s.repo.UpdateCampaignFields(ctx, campaignID, CampaignUpdateFields{
					Status:       &status,
					ScannerError: &message,
				})
				return nil, err
			}
		}
		if len(children.Items) < page.PageSize {
			break
		}
		page.Page++
	}
	return s.repo.AggregateCampaign(ctx, campaignID)
}

func (s *Service) CancelBatch(ctx context.Context, batchID uint64) (*Batch, error) {
	if s == nil || s.repo == nil || s.scanner == nil {
		return nil, ErrMissingDependency
	}
	if batchID == 0 {
		return nil, fmt.Errorf("%w: invalid batch id", ErrInvalidRequest)
	}
	batch, err := s.repo.GetBatch(ctx, batchID)
	if err != nil {
		return nil, err
	}
	if batch == nil {
		return nil, ErrUnknownBatch
	}
	if strings.TrimSpace(batch.ScannerBatchID) == "" {
		return nil, fmt.Errorf("%w: scanner_batch_id is required to cancel scan batch", ErrInvalidRequest)
	}
	status := normalizeStatus(batch.Status)
	if status == "canceled" {
		return batch, nil
	}
	if isTerminalBatchStatus(status) {
		return nil, fmt.Errorf("%w: scan batch is already %s", ErrScannerConflict, status)
	}

	scannerBatch, err := s.scanner.CancelBatch(ctx, batch.ScannerBatchID, cancelBatchIdempotencyKey(batch))
	if err != nil {
		return nil, scannerCancelError(err)
	}
	if scannerBatch == nil {
		scannerBatch = &scannerclient.BatchResponse{BatchID: batch.ScannerBatchID, Status: "canceled"}
	}
	if strings.TrimSpace(scannerBatch.BatchID) == "" {
		scannerBatch.BatchID = batch.ScannerBatchID
	}
	if strings.TrimSpace(scannerBatch.Status) == "" {
		scannerBatch.Status = "canceled"
	}
	if scannerBatch.CompletedAt == nil && isTerminalBatchStatus(scannerBatch.Status) && scannerBatch.QueuedJobs == 0 && scannerBatch.RunningJobs == 0 {
		now := s.now().UTC()
		scannerBatch.CompletedAt = &now
	}
	if err := s.repo.UpdateBatchFromSync(ctx, batch.ID, scannerBatch, &scannerclient.ResultsResponse{
		BatchID:     scannerBatch.BatchID,
		RequestID:   scannerBatch.RequestID,
		Status:      scannerBatch.Status,
		CompletedAt: scannerBatch.CompletedAt,
	}); err != nil {
		return nil, fmt.Errorf("%w: update batch after cancel: %v", ErrPersistenceFailure, err)
	}
	updated, err := s.repo.GetBatch(ctx, batch.ID)
	if err != nil {
		return nil, err
	}
	if updated.CampaignID != nil {
		if _, err := s.repo.AggregateCampaign(ctx, *updated.CampaignID); err != nil {
			return nil, fmt.Errorf("%w: aggregate campaign after cancel: %v", ErrPersistenceFailure, err)
		}
	}
	return updated, nil
}

func (s *Service) CancelCampaign(ctx context.Context, campaignID uint64) (*Campaign, error) {
	if s == nil || s.repo == nil || s.scanner == nil {
		return nil, ErrMissingDependency
	}
	if campaignID == 0 {
		return nil, fmt.Errorf("%w: invalid campaign id", ErrInvalidRequest)
	}
	campaign, err := s.repo.GetCampaign(ctx, campaignID)
	if err != nil {
		return nil, err
	}
	if campaign == nil {
		return nil, ErrUnknownCampaign
	}
	status := normalizeStatus(campaign.Status)
	if status == "canceled" {
		return campaign, nil
	}
	if isTerminalCampaignStatus(status) {
		return nil, fmt.Errorf("%w: scan campaign is already %s", ErrScannerConflict, status)
	}

	page := Pagination{Page: 1, PageSize: 200}
	for {
		children, err := s.repo.ListCampaignBatches(ctx, campaignID, ListBatchesFilter{Pagination: page})
		if err != nil {
			return nil, err
		}
		for _, child := range children.Items {
			if isTerminalBatchStatus(child.Status) || strings.TrimSpace(child.ScannerBatchID) == "" {
				continue
			}
			if _, err := s.CancelBatch(ctx, child.ID); err != nil {
				message := err.Error()
				status := "sync_failed"
				_ = s.repo.UpdateCampaignFields(ctx, campaignID, CampaignUpdateFields{
					Status:       &status,
					ScannerError: &message,
				})
				return nil, err
			}
		}
		if len(children.Items) < page.PageSize {
			break
		}
		page.Page++
	}
	aggregated, err := s.repo.AggregateCampaign(ctx, campaignID)
	if err != nil {
		return nil, fmt.Errorf("%w: aggregate campaign after cancel: %v", ErrPersistenceFailure, err)
	}
	now := s.now().UTC()
	canceled := "canceled"
	update := CampaignUpdateFields{
		Status:            &canceled,
		LastSyncedAt:      &now,
		ClearScannerError: true,
	}
	if aggregated == nil || (aggregated.QueuedJobs == 0 && aggregated.RunningJobs == 0) {
		update.CompletedAt = &now
	}
	if err := s.repo.UpdateCampaignFields(ctx, campaignID, update); err != nil {
		return nil, fmt.Errorf("%w: mark campaign canceled: %v", ErrPersistenceFailure, err)
	}
	return s.repo.GetCampaign(ctx, campaignID)
}

func (s *Service) RunCampaignDispatcher(ctx context.Context) {
	if s == nil || s.repo == nil || s.resolver == nil || s.scanner == nil {
		return
	}
	ticker := time.NewTicker(s.dispatcherInterval)
	defer ticker.Stop()
	for {
		s.dispatchAvailableCampaigns(ctx)
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
		}
	}
}

func (s *Service) dispatchAvailableCampaigns(ctx context.Context) {
	limit := s.campaignSubmitConcurrency
	if limit <= 0 {
		limit = 1
	}
	for i := 0; i < limit; i++ {
		if ctx.Err() != nil {
			return
		}
		campaign, err := s.repo.ClaimNextCampaign(ctx)
		if err != nil || campaign == nil {
			return
		}
		if err := s.DispatchCampaign(ctx, campaign.ID); err != nil {
			message := err.Error()
			status := "failed"
			now := s.now().UTC()
			_ = s.repo.UpdateCampaignFields(ctx, campaign.ID, CampaignUpdateFields{
				Status:       &status,
				CompletedAt:  &now,
				ScannerError: &message,
			})
		}
	}
}

func (s *Service) DispatchCampaign(ctx context.Context, campaignID uint64) error {
	if s == nil || s.repo == nil || s.resolver == nil || s.scanner == nil {
		return ErrMissingDependency
	}
	campaign, err := s.repo.GetCampaign(ctx, campaignID)
	if err != nil {
		return err
	}
	if campaign == nil {
		return ErrUnknownCampaign
	}
	if campaign.TargetScope != TargetScopeAllPublicPorts || campaign.Source != SourceCAASMPorts {
		return fmt.Errorf("%w: unsupported campaign contract", ErrInvalidRequest)
	}
	acceptedTargets := make([]NormalizedTarget, 0, minInt(s.maxCampaignTargets, 4096))
	seen := make(map[string]struct{})
	cursor := campaign.NextCursor
	processed := 0
	rejectedTotal := 0
	pageSize := s.maxTargets
	if pageSize <= 0 {
		pageSize = defaultMaxTargets
	}

	for {
		page, err := s.resolver.ResolveTargetPage(ctx, ResolveTargetPageRequest{
			Source:      campaign.Source,
			TargetScope: campaign.TargetScope,
			AfterID:     cursor,
			Limit:       pageSize,
		})
		if err != nil {
			return err
		}
		if page == nil || len(page.Items) == 0 {
			break
		}
		for _, candidate := range page.Items {
			processed++
			if processed > s.maxCampaignTargets {
				return fmt.Errorf("%w: all_public_ports resolved targets exceeds max campaign size", ErrInvalidRequest)
			}
			normalized, rejected := normalizeTarget(candidate, normalizeTargetOptions{AllowNonPublicTargets: s.allowNonPublicTargets})
			if rejected != nil {
				rejectedTotal++
				continue
			}
			key := endpointIdentity(normalized.PublicIP, normalized.Port, normalized.Transport)
			if _, ok := seen[key]; ok {
				rejectedTotal++
				continue
			}
			seen[key] = struct{}{}
			acceptedTargets = append(acceptedTargets, normalized)
		}
		cursor = page.NextCursor
		if err := s.repo.UpdateCampaignFields(ctx, campaign.ID, CampaignUpdateFields{
			TargetCount:     intPtr(processed),
			AcceptedTargets: intPtr(len(acceptedTargets)),
			RejectedTargets: intPtr(rejectedTotal),
			NextCursor:      uint64Ptr(cursor),
		}); err != nil {
			return err
		}
		if !page.HasMore {
			break
		}
	}
	if len(acceptedTargets) == 0 {
		return ErrNoAcceptedTargets
	}
	partCount := (len(acceptedTargets) + s.maxTargets - 1) / s.maxTargets
	for start, partIndex := 0, 1; start < len(acceptedTargets); start, partIndex = start+s.maxTargets, partIndex+1 {
		end := start + s.maxTargets
		if end > len(acceptedTargets) {
			end = len(acceptedTargets)
		}
		requestID := campaignPartRequestID(campaign.RequestID, partIndex)
		if _, err := s.createAndSubmitChildBatch(ctx, campaign, requestID, partIndex, acceptedTargets[start:end]); err != nil {
			return err
		}
	}
	if err := s.repo.UpdateCampaignBatchPartCount(ctx, campaign.ID, partCount); err != nil {
		return err
	}
	running := "running"
	if err := s.repo.UpdateCampaignFields(ctx, campaign.ID, CampaignUpdateFields{
		Status:            &running,
		TargetCount:       intPtr(processed),
		AcceptedTargets:   intPtr(len(acceptedTargets)),
		RejectedTargets:   intPtr(rejectedTotal),
		BatchCount:        intPtr(partCount),
		CompletedBatches:  intPtr(0),
		FailedBatches:     intPtr(0),
		NextCursor:        uint64Ptr(cursor),
		ClearScannerError: true,
	}); err != nil {
		return err
	}
	_, err = s.repo.AggregateCampaign(ctx, campaign.ID)
	return err
}

func (s *Service) createAndSubmitChildBatch(ctx context.Context, campaign *Campaign, requestID string, partIndex int, accepted []NormalizedTarget) (*Batch, error) {
	if len(accepted) == 0 {
		return nil, ErrNoAcceptedTargets
	}
	metadata := map[string]any{
		"campaign_id":         campaign.ID,
		"campaign_request_id": campaign.RequestID,
		"part_index":          partIndex,
	}
	localCampaignID := campaign.ID
	batch, err := s.repo.CreateBatch(ctx, CreateBatchRecordInput{
		RequestID:       requestID,
		ClientRequestID: requestID,
		CampaignID:      &localCampaignID,
		Source:          campaign.Source,
		PartIndex:       partIndex,
		PartCount:       1,
		TargetCount:     len(accepted),
		AcceptedTargets: accepted,
		Metadata:        metadata,
	})
	if err != nil {
		return nil, fmt.Errorf("%w: create campaign child batch: %v", ErrPersistenceFailure, err)
	}
	scannerReq := scannerclient.CreateBatchRequest{
		RequestID: requestID,
		Source:    scannerclient.DefaultSource,
		Targets:   make([]scannerclient.CreateBatchTarget, 0, len(accepted)),
		Metadata:  metadata,
	}
	for _, target := range accepted {
		scannerReq.Targets = append(scannerReq.Targets, scannerTarget(target))
	}
	scannerResp, err := s.scanner.CreateBatch(ctx, scannerReq)
	if err != nil {
		_ = s.repo.MarkBatchSubmitFailed(ctx, batch.ID, err.Error())
		return nil, err
	}
	return s.repo.MarkBatchSubmitted(ctx, batch.ID, BatchSubmittedInput{
		ScannerBatchID:   scannerResp.BatchID,
		Status:           scannerResp.Status,
		AcceptedTargets:  scannerResp.AcceptedTargets,
		RejectedTargets:  scannerResp.RejectedTargets,
		QueuedJobs:       scannerResp.QueuedJobs,
		RunningJobs:      scannerResp.RunningJobs,
		SucceededJobs:    scannerResp.SucceededJobs,
		FailedJobs:       scannerResp.FailedJobs,
		SkippedJobs:      scannerResp.SkippedJobs,
		RawCreatePayload: scannerResp,
		ScannerTargets:   scannerResp.Targets,
	})
}

func normalizeTargetScope(scope string) string {
	scope = strings.TrimSpace(scope)
	if scope == "" {
		return TargetScopeSelectedPorts
	}
	return scope
}

func (s *Service) ListBatches(ctx context.Context, filter ListBatchesFilter) (*PaginatedResult[Batch], error) {
	if s == nil || s.repo == nil {
		return nil, ErrMissingDependency
	}
	filter.Pagination = normalizePagination(filter.Pagination)
	return s.repo.ListBatches(ctx, filter)
}

func (s *Service) GetBatch(ctx context.Context, id uint64) (*Batch, error) {
	if s == nil || s.repo == nil {
		return nil, ErrMissingDependency
	}
	if id == 0 {
		return nil, fmt.Errorf("%w: invalid batch id", ErrInvalidRequest)
	}
	return s.repo.GetBatch(ctx, id)
}

func (s *Service) ListTargets(ctx context.Context, batchID uint64, filter ListTargetsFilter) (*PaginatedResult[Target], error) {
	if s == nil || s.repo == nil {
		return nil, ErrMissingDependency
	}
	if batchID == 0 {
		return nil, fmt.Errorf("%w: invalid batch id", ErrInvalidRequest)
	}
	filter.Pagination = normalizePagination(filter.Pagination)
	return s.repo.ListTargets(ctx, batchID, filter)
}

func (s *Service) ListObservations(ctx context.Context, batchID uint64, page Pagination) (*PaginatedResult[Observation], error) {
	if s == nil || s.repo == nil {
		return nil, ErrMissingDependency
	}
	if batchID == 0 {
		return nil, fmt.Errorf("%w: invalid batch id", ErrInvalidRequest)
	}
	return s.repo.ListObservations(ctx, batchID, normalizePagination(page))
}

func (s *Service) ListCallbackReceipts(ctx context.Context, batchID uint64, page Pagination) (*PaginatedResult[CallbackReceipt], error) {
	if s == nil || s.repo == nil {
		return nil, ErrMissingDependency
	}
	if batchID == 0 {
		return nil, fmt.Errorf("%w: invalid batch id", ErrInvalidRequest)
	}
	return s.repo.ListCallbackReceipts(ctx, batchID, normalizePagination(page))
}

func (s *Service) ListPortExposures(ctx context.Context, filter ListPortExposuresFilter) (*PaginatedResult[PortExposure], error) {
	if s == nil || s.repo == nil {
		return nil, ErrMissingDependency
	}
	filter.Pagination = normalizePagination(filter.Pagination)
	return s.repo.ListPortExposures(ctx, filter)
}

func (s *Service) GetStats(ctx context.Context) (*Stats, error) {
	if s == nil || s.repo == nil {
		return nil, ErrMissingDependency
	}
	return s.repo.GetStats(ctx)
}

func (s *Service) ApplyCallback(ctx context.Context, callback CallbackEnvelope) (*CallbackResult, error) {
	if s == nil || s.repo == nil {
		return nil, ErrMissingDependency
	}
	key := strings.TrimSpace(callback.IdempotencyKey)
	if key == "" {
		return nil, fmt.Errorf("%w: missing idempotency key", ErrInvalidRequest)
	}
	payloadHash := strings.ToLower(strings.TrimSpace(callback.PayloadSHA256))
	if payloadHash == "" {
		return nil, fmt.Errorf("%w: missing payload hash", ErrInvalidRequest)
	}
	existing, err := s.repo.FindCallbackReceipt(ctx, key)
	if err != nil {
		return nil, fmt.Errorf("%w: lookup callback receipt: %v", ErrPersistenceFailure, err)
	}
	if existing != nil {
		if strings.EqualFold(existing.PayloadSHA256, payloadHash) {
			return &CallbackResult{Status: CallbackStatusDuplicate, BatchID: derefUint64(existing.LocalBatchID)}, ErrDuplicateCallback
		}
		return nil, ErrCallbackConflict
	}

	batch, err := s.repo.FindBatchForCallback(ctx, callback.Payload.RequestID, callback.Payload.BatchID)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrPersistenceFailure, err)
	}
	if batch == nil {
		return nil, ErrUnknownBatch
	}
	receipt, err := s.repo.CreateCallbackReceipt(ctx, CallbackReceiptInput{
		IdempotencyKey:      key,
		RequestID:           callback.Payload.RequestID,
		ScannerBatchID:      callback.Payload.BatchID,
		LocalBatchID:        &batch.ID,
		PayloadSHA256:       payloadHash,
		HeaderPayloadSHA256: callback.HeaderPayloadSHA256,
		RequestHeaderID:     callback.RequestHeaderID,
		Status:              "received",
		HTTPStatus:          200,
		RawPayload:          callback.RawPayload,
		ReceivedAt:          s.now().UTC(),
	})
	if err != nil {
		return nil, fmt.Errorf("%w: create callback receipt: %v", ErrPersistenceFailure, err)
	}
	input := ApplyObservationsInput{
		Batch:        *batch,
		CompletedAt:  callback.Payload.CompletedAt,
		Status:       callback.Payload.Status,
		Observations: buildCallbackObservations(callback.Payload.Observations),
	}
	if err := s.repo.ApplyObservations(ctx, input); err != nil {
		_ = s.repo.MarkCallbackReceiptFailed(ctx, receipt.ID, batch.ID, "failed", 500, err.Error())
		return nil, fmt.Errorf("%w: apply callback observations: %v", ErrPersistenceFailure, err)
	}
	if err := s.repo.MarkCallbackReceiptApplied(ctx, receipt.ID, batch.ID); err != nil {
		return nil, fmt.Errorf("%w: mark callback receipt applied: %v", ErrPersistenceFailure, err)
	}
	if batch.CampaignID != nil {
		if _, err := s.repo.AggregateCampaign(ctx, *batch.CampaignID); err != nil {
			return nil, fmt.Errorf("%w: aggregate campaign after callback: %v", ErrPersistenceFailure, err)
		}
	}
	return &CallbackResult{Status: CallbackStatusAccepted, BatchID: batch.ID}, nil
}

func (s *Service) SyncBatch(ctx context.Context, batchID uint64) (*Batch, error) {
	if s == nil || s.repo == nil || s.scanner == nil {
		return nil, ErrMissingDependency
	}
	batch, err := s.repo.GetBatch(ctx, batchID)
	if err != nil {
		return nil, err
	}
	if batch == nil || strings.TrimSpace(batch.ScannerBatchID) == "" {
		return nil, ErrUnknownBatch
	}
	scannerBatch, err := s.scanner.GetBatch(ctx, batch.ScannerBatchID)
	if err != nil {
		return nil, err
	}
	results, err := s.scanner.GetResults(ctx, batch.ScannerBatchID)
	if err != nil {
		return nil, err
	}
	if err := s.repo.ApplyObservations(ctx, ApplyObservationsInput{
		Batch:        *batch,
		CompletedAt:  results.CompletedAt,
		Status:       results.Status,
		Observations: buildResultObservations(results.Observations),
	}); err != nil {
		return nil, fmt.Errorf("%w: apply sync observations: %v", ErrPersistenceFailure, err)
	}
	if err := s.repo.UpdateBatchFromSync(ctx, batch.ID, scannerBatch, results); err != nil {
		return nil, fmt.Errorf("%w: update batch from sync: %v", ErrPersistenceFailure, err)
	}
	updated, err := s.repo.GetBatch(ctx, batch.ID)
	if err != nil {
		return nil, err
	}
	if updated.CampaignID != nil {
		if _, err := s.repo.AggregateCampaign(ctx, *updated.CampaignID); err != nil {
			return nil, fmt.Errorf("%w: aggregate campaign after sync: %v", ErrPersistenceFailure, err)
		}
	}
	return updated, nil
}

func DeriveRequestID(clientRequestID string) (string, error) {
	clientRequestID = strings.TrimSpace(clientRequestID)
	if !clientRequestIDPattern.MatchString(clientRequestID) {
		return "", fmt.Errorf("%w: client_request_id must be 8-128 characters and contain only letters, numbers, dot, underscore, colon, or dash", ErrInvalidRequest)
	}
	if len(clientRequestID) <= 64 {
		return clientRequestID, nil
	}
	sum := sha256.Sum256([]byte(clientRequestID))
	return "scan-" + hex.EncodeToString(sum[:])[:59], nil
}

func campaignPartRequestID(campaignRequestID string, partIndex int) string {
	campaignRequestID = strings.TrimSpace(campaignRequestID)
	suffix := fmt.Sprintf("-part-%06d", partIndex)
	if len(campaignRequestID)+len(suffix) <= 64 {
		return campaignRequestID + suffix
	}
	sum := sha256.Sum256([]byte(campaignRequestID))
	prefixLength := 64 - len(suffix)
	if prefixLength < 9 {
		prefixLength = 9
	}
	return "scan-" + hex.EncodeToString(sum[:])[:prefixLength-5] + suffix
}

type normalizeTargetOptions struct {
	AllowNonPublicTargets bool
}

func NormalizeTargets(candidates []TargetCandidate) ([]NormalizedTarget, []RejectedTarget) {
	return normalizeTargets(candidates, normalizeTargetOptions{})
}

func normalizeTargets(candidates []TargetCandidate, opts normalizeTargetOptions) ([]NormalizedTarget, []RejectedTarget) {
	accepted := make([]NormalizedTarget, 0, len(candidates))
	rejected := make([]RejectedTarget, 0)
	seen := make(map[string]struct{})
	for _, candidate := range candidates {
		normalized, reject := normalizeTarget(candidate, opts)
		if reject != nil {
			rejected = append(rejected, *reject)
			continue
		}
		key := normalized.PublicIP + ":" + strconv.Itoa(normalized.Port) + "/" + normalized.Transport
		if _, ok := seen[key]; ok {
			rejected = append(rejected, RejectedTarget{
				TargetCandidate: candidate,
				RejectReason:    "duplicate_endpoint",
				RejectDetail:    "duplicate public IP, port, and transport within scan batch",
			})
			continue
		}
		seen[key] = struct{}{}
		accepted = append(accepted, normalized)
	}
	return accepted, rejected
}

func normalizeTarget(candidate TargetCandidate, opts normalizeTargetOptions) (NormalizedTarget, *RejectedTarget) {
	ipText := strings.TrimSpace(candidate.PublicIP)
	transport := strings.ToLower(strings.TrimSpace(candidate.Transport))
	if transport == "" {
		transport = "tcp"
	}
	if candidate.Port < 1 || candidate.Port > 65535 {
		return NormalizedTarget{}, reject(candidate, "invalid_port", "port must be between 1 and 65535")
	}
	addr, err := netip.ParseAddr(ipText)
	if err != nil {
		return NormalizedTarget{}, reject(candidate, "invalid_ip", "public_ip must be a valid IP address")
	}
	if !addr.Is4() {
		return NormalizedTarget{}, reject(candidate, "unsupported_ip_version", "scanner supports only IPv4 targets")
	}
	if !opts.AllowNonPublicTargets && !isPublicIPv4(addr) {
		return NormalizedTarget{}, reject(candidate, "non_public_ip", "scanner target must be a public IPv4 address")
	}
	return NormalizedTarget{
		TargetCandidate: candidate,
		PublicIP:        addr.String(),
		Transport:       transport,
	}, nil
}

func isPublicIPv4(addr netip.Addr) bool {
	if !addr.Is4() || addr.IsUnspecified() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsMulticast() {
		return false
	}
	privatePrefixes := []string{
		"0.0.0.0/8",
		"10.0.0.0/8",
		"100.64.0.0/10",
		"127.0.0.0/8",
		"169.254.0.0/16",
		"172.16.0.0/12",
		"192.0.0.0/24",
		"192.0.2.0/24",
		"192.168.0.0/16",
		"198.18.0.0/15",
		"198.51.100.0/24",
		"203.0.113.0/24",
		"224.0.0.0/4",
		"240.0.0.0/4",
	}
	for _, prefixText := range privatePrefixes {
		prefix := netip.MustParsePrefix(prefixText)
		if prefix.Contains(addr) {
			return false
		}
	}
	return true
}

func reject(candidate TargetCandidate, reason, detail string) *RejectedTarget {
	return &RejectedTarget{TargetCandidate: candidate, RejectReason: reason, RejectDetail: detail}
}

func scannerTarget(target NormalizedTarget) scannerclient.CreateBatchTarget {
	return scannerclient.CreateBatchTarget{
		AssetID:        target.ScannerAssetID,
		PublicIP:       target.PublicIP,
		Port:           target.Port,
		Transport:      target.Transport,
		Domain:         target.Domain,
		CloudProvider:  target.CloudProvider,
		CloudAccountID: target.CloudAccountID,
		Region:         target.Region,
		ResourceType:   target.ResourceType,
		ResourceID:     target.ResourceID,
		Owner:          target.Owner,
		Environment:    target.Environment,
		Metadata:       target.Metadata,
	}
}

func normalizePagination(p Pagination) Pagination {
	if p.Page <= 0 {
		p.Page = 1
	}
	if p.PageSize <= 0 {
		p.PageSize = 20
	}
	if p.PageSize > 200 {
		p.PageSize = 200
	}
	return p
}

func buildCallbackObservations(items []CallbackObservation) []AppliedObservation {
	out := make([]AppliedObservation, 0, len(items))
	for _, item := range items {
		out = append(out, appliedObservationFromFields(
			item.ObservationID,
			item.JobID,
			item.TargetID,
			item.AssetID,
			item.PublicIP,
			item.Port,
			item.Transport,
			item.Result,
			item.ReasonCode,
			item.ConnectMS,
			item.ScannerID,
			item.ScannerVersion,
			item.ScannerSourceIP,
			item.EvidenceSHA256,
			item.EvidencePath,
			item.StartedAt,
			item.FinishedAt,
			item,
		))
	}
	return out
}

func buildResultObservations(items []scannerclient.ObservationResult) []AppliedObservation {
	out := make([]AppliedObservation, 0, len(items))
	for _, item := range items {
		out = append(out, appliedObservationFromFields(
			item.ObservationID,
			item.JobID,
			item.TargetID,
			item.AssetID,
			item.PublicIP,
			item.Port,
			item.Transport,
			item.Result,
			item.ReasonCode,
			item.ConnectMS,
			item.ScannerID,
			item.ScannerVersion,
			item.ScannerSourceIP,
			item.EvidenceSHA256,
			item.EvidencePath,
			item.StartedAt,
			item.FinishedAt,
			item,
		))
	}
	return out
}

func appliedObservationFromFields(observationID, jobID, targetID, assetID, publicIP string, port int, transport, result, reasonCode string, connectMS int64, scannerID, scannerVersion, scannerSourceIP, evidenceSHA256, evidencePath string, startedAt, finishedAt *time.Time, raw any) AppliedObservation {
	transport = strings.ToLower(strings.TrimSpace(transport))
	if transport == "" {
		transport = "tcp"
	}
	publicIP = strings.TrimSpace(publicIP)
	kind, value := observationIdentity(observationID, jobID, targetID, publicIP, port, transport)
	return AppliedObservation{
		IdentityKind:         kind,
		IdentityValue:        value,
		ScannerObservationID: strings.TrimSpace(observationID),
		ScannerJobID:         strings.TrimSpace(jobID),
		ScannerTargetID:      strings.TrimSpace(targetID),
		ScannerAssetID:       strings.TrimSpace(assetID),
		PublicIP:             publicIP,
		Port:                 port,
		Transport:            transport,
		Result:               strings.TrimSpace(result),
		ReasonCode:           strings.TrimSpace(reasonCode),
		ConnectMS:            connectMS,
		ScannerID:            strings.TrimSpace(scannerID),
		ScannerVersion:       strings.TrimSpace(scannerVersion),
		ScannerSourceIP:      strings.TrimSpace(scannerSourceIP),
		EvidenceSHA256:       strings.TrimSpace(evidenceSHA256),
		EvidencePath:         strings.TrimSpace(evidencePath),
		StartedAt:            startedAt,
		FinishedAt:           finishedAt,
		RawPayload:           raw,
	}
}

func observationIdentity(observationID, jobID, targetID, publicIP string, port int, transport string) (string, string) {
	candidates := []struct {
		kind  string
		value string
	}{
		{ObservationIdentityScannerObservation, strings.TrimSpace(observationID)},
		{ObservationIdentityScannerJob, strings.TrimSpace(jobID)},
		{ObservationIdentityScannerTarget, strings.TrimSpace(targetID)},
	}
	for _, candidate := range candidates {
		if candidate.value != "" {
			return candidate.kind, candidate.value
		}
	}
	return ObservationIdentityEndpoint, endpointIdentity(publicIP, port, transport)
}

func endpointIdentity(publicIP string, port int, transport string) string {
	parts := []string{strings.TrimSpace(publicIP), strconv.Itoa(port), strings.ToLower(strings.TrimSpace(transport))}
	return strings.Join(parts, "|")
}

func derefUint64(v *uint64) uint64 {
	if v == nil {
		return 0
	}
	return *v
}

func intPtr(v int) *int {
	return &v
}

func uint64Ptr(v uint64) *uint64 {
	return &v
}

func minInt(a, b int) int {
	if a < b {
		return a
	}
	return b
}

func IsCallbackDuplicate(err error) bool {
	return errors.Is(err, ErrDuplicateCallback)
}

func IsCallbackConflict(err error) bool {
	return errors.Is(err, ErrCallbackConflict)
}

func IsUnknownBatch(err error) bool {
	return errors.Is(err, ErrUnknownBatch)
}

func IsUnknownCampaign(err error) bool {
	return errors.Is(err, ErrUnknownCampaign)
}

func IsInvalidRequest(err error) bool {
	return errors.Is(err, ErrInvalidRequest)
}

func IsPersistenceFailure(err error) bool {
	return errors.Is(err, ErrPersistenceFailure)
}

func IsScannerConflict(err error) bool {
	return errors.Is(err, ErrScannerConflict)
}

func IsScannerUnavailable(err error) bool {
	return errors.Is(err, ErrScannerUnavailable)
}

func normalizeStatus(status string) string {
	return strings.ToLower(strings.TrimSpace(status))
}

func isTerminalBatchStatus(status string) bool {
	switch normalizeStatus(status) {
	case "completed", "failed", "canceled":
		return true
	default:
		return false
	}
}

func isTerminalCampaignStatus(status string) bool {
	switch normalizeStatus(status) {
	case "completed", "failed", "canceled":
		return true
	default:
		return false
	}
}

func scannerCancelError(err error) error {
	var httpErr *scannerclient.HTTPError
	if errors.As(err, &httpErr) {
		if httpErr.StatusCode == http.StatusConflict {
			return fmt.Errorf("%w: %v", ErrScannerConflict, err)
		}
		if httpErr.StatusCode >= http.StatusInternalServerError {
			return fmt.Errorf("%w: %v", ErrScannerUnavailable, err)
		}
		return err
	}
	return fmt.Errorf("%w: %v", ErrScannerUnavailable, err)
}

func cancelBatchIdempotencyKey(batch *Batch) string {
	if batch == nil {
		return ""
	}
	base := strings.TrimSpace(batch.RequestID)
	if base == "" {
		base = strings.TrimSpace(batch.ScannerBatchID)
	}
	if base == "" {
		return ""
	}
	return base + "-cancel"
}

func supportedStatuses() []string {
	return []string{"queued", "running", "completed", "failed", "canceled"}
}

func IsSupportedCallbackStatus(status string) bool {
	return slices.Contains(supportedStatuses(), strings.ToLower(strings.TrimSpace(status)))
}
