package service

import (
	"context"
	"errors"
	"net/http"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

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

func TestDeriveRequestIDUsesClientRequestIDWhenScannerSafe(t *testing.T) {
	got, err := DeriveRequestID("frontend-key-123")
	require.NoError(t, err)
	require.Equal(t, "frontend-key-123", got)
}

func TestDeriveRequestIDHashesLongClientRequestIDToScannerSafeLength(t *testing.T) {
	clientID := "frontend-" + strings.Repeat("a", 90)
	got, err := DeriveRequestID(clientID)
	require.NoError(t, err)
	require.Len(t, got, 64)
	require.True(t, strings.HasPrefix(got, "scan-"))

	gotAgain, err := DeriveRequestID(clientID)
	require.NoError(t, err)
	require.Equal(t, got, gotAgain)
}

func TestDeriveRequestIDRejectsInvalidClientRequestID(t *testing.T) {
	_, err := DeriveRequestID("bad id")
	require.ErrorIs(t, err, ErrInvalidRequest)
}

func TestNormalizeTargetsAcceptsPublicIPv4AnyTransport(t *testing.T) {
	accepted, rejected := NormalizeTargets([]TargetCandidate{
		{ScannerAssetID: "1", PublicIP: "8.8.8.8", Port: 443, Transport: "TCP"},
		{ScannerAssetID: "2", PublicIP: "10.0.0.1", Port: 443, Transport: "tcp"},
		{ScannerAssetID: "3", PublicIP: "2001:db8::1", Port: 443, Transport: "tcp"},
		{ScannerAssetID: "4", PublicIP: "1.1.1.1", Port: 53, Transport: "udp"},
		{ScannerAssetID: "5", PublicIP: "8.8.8.8", Port: 443, Transport: "tcp"},
	})

	require.Len(t, accepted, 2)
	require.Equal(t, "8.8.8.8", accepted[0].PublicIP)
	require.Equal(t, "tcp", accepted[0].Transport)
	require.Equal(t, "1.1.1.1", accepted[1].PublicIP)
	require.Equal(t, "udp", accepted[1].Transport)
	require.Len(t, rejected, 3)
	require.Equal(t, "non_public_ip", rejected[0].RejectReason)
	require.Equal(t, "unsupported_ip_version", rejected[1].RejectReason)
	require.Equal(t, "duplicate_endpoint", rejected[2].RejectReason)
}

func TestCreateBatchAllowsLoopbackTargetsOnlyWhenConfigured(t *testing.T) {
	req := CreateBatchRequest{
		Source:          SourceCAASMPorts,
		TargetScope:     TargetScopeSelectedPorts,
		TargetIDs:       []uint64{1},
		ClientRequestID: "local-dast-e2e-test",
	}
	candidate := TargetCandidate{ScannerAssetID: "dast-local-target", PublicIP: "127.0.0.1", Port: 18180, Transport: "tcp"}

	blockedResolver := &fakeTargetResolver{candidates: []TargetCandidate{candidate}}
	blockedRepo := &fakeCreateBatchRepo{}
	blockedScanner := &fakeScannerClient{}
	blockedScans := New(blockedRepo, blockedResolver, blockedScanner, Config{MaxTargetsPerBatch: 2})

	_, err := blockedScans.CreateBatch(context.Background(), req)
	require.ErrorIs(t, err, ErrNoAcceptedTargets)
	require.False(t, blockedRepo.createCalled)
	require.False(t, blockedScanner.createCalled)

	allowedResolver := &fakeTargetResolver{candidates: []TargetCandidate{candidate}}
	allowedRepo := &fakeCreateBatchRepo{}
	allowedScanner := &fakeScannerClient{}
	allowedScans := New(allowedRepo, allowedResolver, allowedScanner, Config{MaxTargetsPerBatch: 2, AllowNonPublicTargets: true})

	batch, err := allowedScans.CreateBatch(context.Background(), req)
	require.NoError(t, err)
	require.NotNil(t, batch)
	require.True(t, allowedScanner.createCalled)
	require.Len(t, allowedScanner.created.Targets, 1)
	require.Equal(t, "127.0.0.1", allowedScanner.created.Targets[0].PublicIP)
	require.Equal(t, 18180, allowedScanner.created.Targets[0].Port)
}

func TestCreateBatchAllPublicPortsAllowsEmptyTargetIDsAndUsesOverflowLimit(t *testing.T) {
	resolver := &fakeTargetResolver{
		candidates: []TargetCandidate{
			{ScannerAssetID: "asset-1", PublicIP: "8.8.8.8", Port: 443, Transport: "tcp"},
		},
	}
	repo := &fakeCreateBatchRepo{}
	scanner := &fakeScannerClient{}
	scans := New(repo, resolver, scanner, Config{MaxTargetsPerBatch: 2})

	batch, err := scans.CreateBatch(context.Background(), CreateBatchRequest{
		Source:          SourceCAASMPorts,
		TargetScope:     TargetScopeAllPublicPorts,
		ClientRequestID: "client-123",
	})

	require.NoError(t, err)
	require.NotNil(t, batch)
	require.Equal(t, SourceCAASMPorts, resolver.req.Source)
	require.Equal(t, TargetScopeAllPublicPorts, resolver.req.TargetScope)
	require.Empty(t, resolver.req.TargetIDs)
	require.Equal(t, 3, resolver.req.Limit)
	require.Equal(t, 1, repo.created.TargetCount)
	require.Len(t, scanner.created.Targets, 1)
	require.Equal(t, "8.8.8.8", scanner.created.Targets[0].PublicIP)
}

func TestCreateBatchAllPublicPortsSubmitsEveryPublicTransport(t *testing.T) {
	resolver := &fakeTargetResolver{
		candidates: []TargetCandidate{
			{ScannerAssetID: "asset-1", PublicIP: "8.8.8.8", Port: 443, Transport: "tcp"},
			{ScannerAssetID: "asset-2", PublicIP: "9.9.9.9", Port: 53, Transport: "udp"},
		},
	}
	repo := &fakeCreateBatchRepo{}
	scanner := &fakeScannerClient{}
	scans := New(repo, resolver, scanner, Config{MaxTargetsPerBatch: 3})

	batch, err := scans.CreateBatch(context.Background(), CreateBatchRequest{
		Source:          SourceCAASMPorts,
		TargetScope:     TargetScopeAllPublicPorts,
		ClientRequestID: "client-123",
	})

	require.NoError(t, err)
	require.Equal(t, 2, batch.TargetCount)
	require.Equal(t, 2, batch.AcceptedTargets)
	require.Equal(t, 0, batch.RejectedTargets)
	require.Equal(t, 2, repo.created.TargetCount)
	require.Len(t, repo.created.AcceptedTargets, 2)
	require.Empty(t, repo.created.RejectedTargets)
	require.Len(t, scanner.created.Targets, 2)
	require.Equal(t, "tcp", scanner.created.Targets[0].Transport)
	require.Equal(t, "udp", scanner.created.Targets[1].Transport)
}

func TestCreateBatchAllPublicPortsRejectsResolvedTargetsOverMax(t *testing.T) {
	resolver := &fakeTargetResolver{
		candidates: []TargetCandidate{
			{ScannerAssetID: "asset-1", PublicIP: "8.8.8.8", Port: 443, Transport: "tcp"},
			{ScannerAssetID: "asset-2", PublicIP: "1.1.1.1", Port: 443, Transport: "tcp"},
			{ScannerAssetID: "asset-3", PublicIP: "9.9.9.9", Port: 443, Transport: "tcp"},
		},
	}
	repo := &fakeCreateBatchRepo{}
	scanner := &fakeScannerClient{}
	scans := New(repo, resolver, scanner, Config{MaxTargetsPerBatch: 2})

	_, err := scans.CreateBatch(context.Background(), CreateBatchRequest{
		Source:          SourceCAASMPorts,
		TargetScope:     TargetScopeAllPublicPorts,
		ClientRequestID: "client-123",
	})

	require.ErrorIs(t, err, ErrInvalidRequest)
	require.Contains(t, err.Error(), "exceeds max batch size")
	require.False(t, repo.createCalled)
	require.False(t, scanner.createCalled)
}

func TestCreateBatchDefaultsTargetScopeToSelectedPorts(t *testing.T) {
	resolver := &fakeTargetResolver{
		candidates: []TargetCandidate{
			{ScannerAssetID: "asset-1", PublicIP: "8.8.8.8", Port: 443, Transport: "tcp"},
		},
	}
	scans := New(&fakeCreateBatchRepo{}, resolver, &fakeScannerClient{}, Config{MaxTargetsPerBatch: 2})

	_, err := scans.CreateBatch(context.Background(), CreateBatchRequest{
		Source:          SourceCAASMPorts,
		TargetIDs:       []uint64{10},
		ClientRequestID: "client-123",
	})

	require.NoError(t, err)
	require.Equal(t, TargetScopeSelectedPorts, resolver.req.TargetScope)
	require.Equal(t, []uint64{10}, resolver.req.TargetIDs)
}

func TestDispatchCampaignChunksAllPublicTargets(t *testing.T) {
	resolver := &fakeTargetResolver{
		pages: []*TargetPage{
			{
				Items: []TargetCandidate{
					{ScannerAssetID: "asset-1", PublicIP: "8.8.8.8", Port: 443, Transport: "tcp"},
					{ScannerAssetID: "asset-2", PublicIP: "1.1.1.1", Port: 53, Transport: "udp"},
				},
				NextCursor: 2,
				HasMore:    true,
			},
			{
				Items: []TargetCandidate{
					{ScannerAssetID: "asset-3", PublicIP: "9.9.9.9", Port: 8443, Transport: "tcp"},
				},
				NextCursor: 3,
				HasMore:    false,
			},
		},
	}
	repo := &fakeCreateBatchRepo{
		campaign: &Campaign{
			ID:          10,
			RequestID:   "campaign-1234567890",
			Source:      SourceCAASMPorts,
			TargetScope: TargetScopeAllPublicPorts,
			Status:      "dispatching",
		},
	}
	scanner := &fakeScannerClient{}
	scans := New(repo, resolver, scanner, Config{MaxTargetsPerBatch: 2, MaxCampaignTargets: 50})

	err := scans.DispatchCampaign(context.Background(), 10)

	require.NoError(t, err)
	require.Len(t, resolver.pageReqs, 2)
	require.Len(t, scanner.creates, 2)
	require.Len(t, scanner.creates[0].Targets, 2)
	require.Len(t, scanner.creates[1].Targets, 1)
	require.Equal(t, "campaign-1234567890-part-000001", scanner.creates[0].RequestID)
	require.Equal(t, "campaign-1234567890-part-000002", scanner.creates[1].RequestID)
	require.Len(t, repo.createdBatches, 2)
	require.Equal(t, 1, repo.createdBatches[0].PartIndex)
	require.Equal(t, 2, repo.createdBatches[1].PartIndex)
	require.Equal(t, 2, repo.partCount)
	require.True(t, repo.aggregated)
	require.Equal(t, 3, repo.campaign.TargetCount)
	require.Equal(t, 3, repo.campaign.AcceptedTargets)
}

func TestDispatchCampaignRejectsMaxCampaignTargetsBeforeSubmittingScanner(t *testing.T) {
	resolver := &fakeTargetResolver{
		pages: []*TargetPage{
			{
				Items: []TargetCandidate{
					{ScannerAssetID: "asset-1", PublicIP: "8.8.8.8", Port: 443, Transport: "tcp"},
					{ScannerAssetID: "asset-2", PublicIP: "1.1.1.1", Port: 53, Transport: "udp"},
					{ScannerAssetID: "asset-3", PublicIP: "9.9.9.9", Port: 8443, Transport: "tcp"},
				},
				NextCursor: 3,
				HasMore:    false,
			},
		},
	}
	repo := &fakeCreateBatchRepo{
		campaign: &Campaign{
			ID:          10,
			RequestID:   "campaign-1234567890",
			Source:      SourceCAASMPorts,
			TargetScope: TargetScopeAllPublicPorts,
			Status:      "dispatching",
		},
	}
	scanner := &fakeScannerClient{}
	scans := New(repo, resolver, scanner, Config{MaxTargetsPerBatch: 10, MaxCampaignTargets: 2})

	err := scans.DispatchCampaign(context.Background(), 10)

	require.ErrorIs(t, err, ErrInvalidRequest)
	require.Empty(t, scanner.creates)
	require.Empty(t, repo.createdBatches)
}

func TestSyncBatchAggregatesParentCampaign(t *testing.T) {
	campaignID := uint64(10)
	repo := &fakeCreateBatchRepo{
		campaign: &Campaign{ID: campaignID, RequestID: "campaign-1234567890", Source: SourceCAASMPorts, TargetScope: TargetScopeAllPublicPorts},
		batches: map[uint64]*Batch{
			1: {
				ID:             1,
				CampaignID:     &campaignID,
				RequestID:      "campaign-1234567890-part-000001",
				ScannerBatchID: "scanner-1",
				Source:         SourceCAASMPorts,
				Status:         "running",
			},
		},
	}
	scanner := &fakeScannerClient{
		batchResp: &scannerclient.BatchResponse{
			BatchID: "scanner-1",
			Status:  "completed",
		},
		resultsResp: &scannerclient.ResultsResponse{
			BatchID: "scanner-1",
			Status:  "completed",
			Observations: []scannerclient.ObservationResult{
				{PublicIP: "8.8.8.8", Port: 443, Transport: "tcp", Result: "confirmed_open", ReasonCode: "tcp_connected"},
			},
		},
	}
	scans := New(repo, &fakeTargetResolver{}, scanner, Config{})

	_, err := scans.SyncBatch(context.Background(), 1)

	require.NoError(t, err)
	require.True(t, repo.aggregated)
	require.Equal(t, campaignID, repo.aggregatedCampaignID)
}

func TestSyncCampaignSyncsChildBatchesAndAggregates(t *testing.T) {
	campaignID := uint64(10)
	repo := &fakeCreateBatchRepo{
		campaign: &Campaign{ID: campaignID, RequestID: "campaign-1234567890", Source: SourceCAASMPorts, TargetScope: TargetScopeAllPublicPorts},
		batches: map[uint64]*Batch{
			1: {
				ID:             1,
				CampaignID:     &campaignID,
				RequestID:      "campaign-1234567890-part-000001",
				ScannerBatchID: "scanner-1",
				Source:         SourceCAASMPorts,
				Status:         "running",
			},
			2: {
				ID:             2,
				CampaignID:     &campaignID,
				RequestID:      "campaign-1234567890-part-000002",
				ScannerBatchID: "scanner-2",
				Source:         SourceCAASMPorts,
				Status:         "running",
			},
		},
	}
	scanner := &fakeScannerClient{
		batchResp:   &scannerclient.BatchResponse{BatchID: "scanner", Status: "completed"},
		resultsResp: &scannerclient.ResultsResponse{BatchID: "scanner", Status: "completed"},
	}
	scans := New(repo, &fakeTargetResolver{}, scanner, Config{})

	_, err := scans.SyncCampaign(context.Background(), campaignID)

	require.NoError(t, err)
	require.True(t, repo.aggregated)
	require.Equal(t, campaignID, repo.aggregatedCampaignID)
	require.Equal(t, 2, repo.syncUpdates)
}

func TestCancelBatchCancelsActiveBatchAndUpdatesLocalState(t *testing.T) {
	repo := &fakeCreateBatchRepo{
		batches: map[uint64]*Batch{
			1: {
				ID:             1,
				RequestID:      "client-1",
				ScannerBatchID: "scanner-1",
				Status:         "running",
			},
		},
	}
	scanner := &fakeScannerClient{
		cancelResp: &scannerclient.BatchResponse{BatchID: "scanner-1", Status: "canceled"},
	}
	scans := New(repo, &fakeTargetResolver{}, scanner, Config{})

	batch, err := scans.CancelBatch(context.Background(), 1)

	require.NoError(t, err)
	require.True(t, scanner.cancelCalled)
	require.Equal(t, []string{"scanner-1"}, scanner.cancelIDs)
	require.Equal(t, []string{"client-1-cancel"}, scanner.cancelKeys)
	require.Equal(t, "canceled", batch.Status)
	require.Equal(t, 1, repo.syncUpdates)
}

func TestCancelBatchIsIdempotentForLocalCanceledBatch(t *testing.T) {
	repo := &fakeCreateBatchRepo{
		batches: map[uint64]*Batch{
			1: {
				ID:             1,
				RequestID:      "client-1",
				ScannerBatchID: "scanner-1",
				Status:         "canceled",
			},
		},
	}
	scanner := &fakeScannerClient{}
	scans := New(repo, &fakeTargetResolver{}, scanner, Config{})

	batch, err := scans.CancelBatch(context.Background(), 1)

	require.NoError(t, err)
	require.Equal(t, "canceled", batch.Status)
	require.False(t, scanner.cancelCalled)
	require.Zero(t, repo.syncUpdates)
}

func TestCancelBatchCompletedReturnsConflict(t *testing.T) {
	repo := &fakeCreateBatchRepo{
		batches: map[uint64]*Batch{
			1: {
				ID:             1,
				RequestID:      "client-1",
				ScannerBatchID: "scanner-1",
				Status:         "completed",
			},
		},
	}
	scanner := &fakeScannerClient{}
	scans := New(repo, &fakeTargetResolver{}, scanner, Config{})

	_, err := scans.CancelBatch(context.Background(), 1)

	require.ErrorIs(t, err, ErrScannerConflict)
	require.False(t, scanner.cancelCalled)
}

func TestCancelBatchRejectsMissingScannerBatchID(t *testing.T) {
	repo := &fakeCreateBatchRepo{
		batches: map[uint64]*Batch{
			1: {
				ID:        1,
				RequestID: "client-1",
				Status:    "running",
			},
		},
	}
	scanner := &fakeScannerClient{}
	scans := New(repo, &fakeTargetResolver{}, scanner, Config{})

	_, err := scans.CancelBatch(context.Background(), 1)

	require.ErrorIs(t, err, ErrInvalidRequest)
	require.Contains(t, err.Error(), "scanner_batch_id")
	require.False(t, scanner.cancelCalled)
}

func TestCancelBatchScannerConflictIsSurfaced(t *testing.T) {
	repo := &fakeCreateBatchRepo{
		batches: map[uint64]*Batch{
			1: {
				ID:             1,
				RequestID:      "client-1",
				ScannerBatchID: "scanner-1",
				Status:         "running",
			},
		},
	}
	scanner := &fakeScannerClient{cancelErr: &scannerclient.HTTPError{StatusCode: http.StatusConflict, Body: "batch completed"}}
	scans := New(repo, &fakeTargetResolver{}, scanner, Config{})

	_, err := scans.CancelBatch(context.Background(), 1)

	require.ErrorIs(t, err, ErrScannerConflict)
	require.True(t, scanner.cancelCalled)
	require.Zero(t, repo.syncUpdates)
}

func TestCancelBatchScannerUnavailableIsSurfaced(t *testing.T) {
	repo := &fakeCreateBatchRepo{
		batches: map[uint64]*Batch{
			1: {
				ID:             1,
				RequestID:      "client-1",
				ScannerBatchID: "scanner-1",
				Status:         "running",
			},
		},
	}
	scanner := &fakeScannerClient{cancelErr: errors.New("dial tcp refused")}
	scans := New(repo, &fakeTargetResolver{}, scanner, Config{})

	_, err := scans.CancelBatch(context.Background(), 1)

	require.ErrorIs(t, err, ErrScannerUnavailable)
	require.True(t, scanner.cancelCalled)
	require.Zero(t, repo.syncUpdates)
}

func TestCancelCampaignCancelsNonterminalChildBatchesAndMarksCampaignCanceled(t *testing.T) {
	campaignID := uint64(10)
	repo := &fakeCreateBatchRepo{
		campaign: &Campaign{ID: campaignID, RequestID: "campaign-1234567890", Source: SourceCAASMPorts, TargetScope: TargetScopeAllPublicPorts, Status: "running"},
		batches: map[uint64]*Batch{
			1: {
				ID:             1,
				CampaignID:     &campaignID,
				RequestID:      "campaign-1234567890-part-000001",
				ScannerBatchID: "scanner-1",
				Source:         SourceCAASMPorts,
				Status:         "running",
			},
			2: {
				ID:             2,
				CampaignID:     &campaignID,
				RequestID:      "campaign-1234567890-part-000002",
				ScannerBatchID: "scanner-2",
				Source:         SourceCAASMPorts,
				Status:         "completed",
			},
			3: {
				ID:         3,
				CampaignID: &campaignID,
				RequestID:  "campaign-1234567890-part-000003",
				Source:     SourceCAASMPorts,
				Status:     "queued",
			},
		},
	}
	scanner := &fakeScannerClient{
		cancelResp: &scannerclient.BatchResponse{Status: "canceled"},
	}
	scans := New(repo, &fakeTargetResolver{}, scanner, Config{})

	campaign, err := scans.CancelCampaign(context.Background(), campaignID)

	require.NoError(t, err)
	require.Equal(t, []string{"scanner-1"}, scanner.cancelIDs)
	require.Equal(t, []string{"campaign-1234567890-part-000001-cancel"}, scanner.cancelKeys)
	require.True(t, repo.aggregated)
	require.Equal(t, "canceled", campaign.Status)
	require.Equal(t, "canceled", repo.batches[1].Status)
	require.Equal(t, "completed", repo.batches[2].Status)
	require.Equal(t, "queued", repo.batches[3].Status)
}

type fakeTargetResolver struct {
	req        ResolveTargetsRequest
	pageReqs   []ResolveTargetPageRequest
	pages      []*TargetPage
	candidates []TargetCandidate
	err        error
}

func (f *fakeTargetResolver) ResolveTargets(_ context.Context, req ResolveTargetsRequest) ([]TargetCandidate, error) {
	f.req = req
	return f.candidates, f.err
}

func (f *fakeTargetResolver) ResolveTargetPage(_ context.Context, req ResolveTargetPageRequest) (*TargetPage, error) {
	f.pageReqs = append(f.pageReqs, req)
	if f.err != nil {
		return nil, f.err
	}
	if len(f.pages) == 0 {
		return &TargetPage{}, nil
	}
	page := f.pages[0]
	f.pages = f.pages[1:]
	return page, nil
}

type fakeScannerClient struct {
	createCalled bool
	created      scannerclient.CreateBatchRequest
	creates      []scannerclient.CreateBatchRequest
	batchResp    *scannerclient.BatchResponse
	resultsResp  *scannerclient.ResultsResponse
	cancelCalled bool
	cancelIDs    []string
	cancelKeys   []string
	cancelResp   *scannerclient.BatchResponse
	cancelErr    error
}

func (f *fakeScannerClient) CreateBatch(_ context.Context, req scannerclient.CreateBatchRequest) (*scannerclient.CreateBatchResponse, error) {
	f.createCalled = true
	f.created = req
	f.creates = append(f.creates, req)
	return &scannerclient.CreateBatchResponse{
		BatchID:         "scanner-" + strconv.Itoa(len(f.creates)),
		Status:          "queued",
		AcceptedTargets: len(req.Targets),
		QueuedJobs:      len(req.Targets),
		Targets:         scannerTargetsFromRequest(req.Targets),
	}, nil
}

func (f *fakeScannerClient) GetBatch(context.Context, string) (*scannerclient.BatchResponse, error) {
	if f.batchResp != nil {
		return f.batchResp, nil
	}
	return &scannerclient.BatchResponse{BatchID: "scanner-1", Status: "completed"}, nil
}

func (f *fakeScannerClient) GetResults(context.Context, string) (*scannerclient.ResultsResponse, error) {
	if f.resultsResp != nil {
		return f.resultsResp, nil
	}
	return &scannerclient.ResultsResponse{BatchID: "scanner-1", Status: "completed"}, nil
}

func (f *fakeScannerClient) CancelBatch(_ context.Context, scannerBatchID string, idempotencyKey string) (*scannerclient.BatchResponse, error) {
	f.cancelCalled = true
	f.cancelIDs = append(f.cancelIDs, scannerBatchID)
	f.cancelKeys = append(f.cancelKeys, idempotencyKey)
	if f.cancelErr != nil {
		return nil, f.cancelErr
	}
	if f.cancelResp != nil {
		resp := *f.cancelResp
		if resp.BatchID == "" {
			resp.BatchID = scannerBatchID
		}
		return &resp, nil
	}
	return &scannerclient.BatchResponse{BatchID: scannerBatchID, Status: "canceled"}, nil
}

type fakeCreateBatchRepo struct {
	createCalled         bool
	created              CreateBatchRecordInput
	createdBatches       []CreateBatchRecordInput
	campaign             *Campaign
	aggregated           bool
	aggregatedCampaignID uint64
	partCount            int
	batches              map[uint64]*Batch
	syncUpdates          int
	scannerError         string
}

func (f *fakeCreateBatchRepo) CreateCampaign(_ context.Context, input CreateCampaignRecordInput) (*Campaign, error) {
	f.campaign = &Campaign{
		ID:          10,
		RequestID:   input.RequestID,
		Source:      input.Source,
		TargetScope: input.TargetScope,
		Status:      "queued",
		CreatedAt:   time.Now(),
		UpdatedAt:   time.Now(),
	}
	return f.campaign, nil
}

func (f *fakeCreateBatchRepo) GetCampaign(context.Context, uint64) (*Campaign, error) {
	return f.campaign, nil
}

func (f *fakeCreateBatchRepo) FindCampaignByRequestID(context.Context, string) (*Campaign, error) {
	return f.campaign, nil
}

func (f *fakeCreateBatchRepo) ListCampaigns(context.Context, ListCampaignsFilter) (*PaginatedResult[Campaign], error) {
	return nil, nil
}

func (f *fakeCreateBatchRepo) ListCampaignBatches(context.Context, uint64, ListBatchesFilter) (*PaginatedResult[Batch], error) {
	if f.batches != nil {
		items := make([]Batch, 0, len(f.batches))
		for _, batch := range f.batches {
			items = append(items, *batch)
		}
		return &PaginatedResult[Batch]{Items: items, Page: 1, PageSize: 200}, nil
	}
	return &PaginatedResult[Batch]{Items: nil, Page: 1, PageSize: 200}, nil
}

func (f *fakeCreateBatchRepo) ClaimNextCampaign(context.Context) (*Campaign, error) {
	return f.campaign, nil
}

func (f *fakeCreateBatchRepo) UpdateCampaignFields(_ context.Context, _ uint64, fields CampaignUpdateFields) error {
	if f.campaign == nil {
		f.campaign = &Campaign{ID: 10, RequestID: "campaign-1234567890", Source: SourceCAASMPorts, TargetScope: TargetScopeAllPublicPorts}
	}
	if fields.Status != nil {
		f.campaign.Status = *fields.Status
	}
	if fields.TargetCount != nil {
		f.campaign.TargetCount = *fields.TargetCount
	}
	if fields.AcceptedTargets != nil {
		f.campaign.AcceptedTargets = *fields.AcceptedTargets
	}
	if fields.RejectedTargets != nil {
		f.campaign.RejectedTargets = *fields.RejectedTargets
	}
	if fields.BatchCount != nil {
		f.campaign.BatchCount = *fields.BatchCount
	}
	if fields.NextCursor != nil {
		f.campaign.NextCursor = *fields.NextCursor
	}
	if fields.CompletedAt != nil {
		f.campaign.CompletedAt = fields.CompletedAt
	}
	if fields.LastSyncedAt != nil {
		f.campaign.LastSyncedAt = fields.LastSyncedAt
	}
	if fields.ScannerError != nil {
		f.scannerError = *fields.ScannerError
		f.campaign.ScannerError = *fields.ScannerError
	}
	if fields.ClearScannerError {
		f.scannerError = ""
		f.campaign.ScannerError = ""
	}
	return nil
}

func (f *fakeCreateBatchRepo) UpdateCampaignBatchPartCount(_ context.Context, _ uint64, partCount int) error {
	f.partCount = partCount
	return nil
}

func (f *fakeCreateBatchRepo) AggregateCampaign(_ context.Context, campaignID uint64) (*Campaign, error) {
	f.aggregated = true
	f.aggregatedCampaignID = campaignID
	if f.campaign == nil {
		return nil, nil
	}
	return f.campaign, nil
}

func (f *fakeCreateBatchRepo) CreateBatch(_ context.Context, input CreateBatchRecordInput) (*Batch, error) {
	f.createCalled = true
	f.created = input
	f.createdBatches = append(f.createdBatches, input)
	return &Batch{
		ID:              uint64(len(f.createdBatches)),
		RequestID:       input.RequestID,
		ClientRequestID: input.ClientRequestID,
		CampaignID:      input.CampaignID,
		Source:          input.Source,
		Status:          "submitting",
		PartIndex:       input.PartIndex,
		PartCount:       input.PartCount,
		TargetCount:     input.TargetCount,
		AcceptedTargets: len(input.AcceptedTargets),
		RejectedTargets: len(input.RejectedTargets),
		CreatedAt:       time.Now(),
		UpdatedAt:       time.Now(),
	}, nil
}

func (f *fakeCreateBatchRepo) MarkBatchSubmitted(_ context.Context, localBatchID uint64, input BatchSubmittedInput) (*Batch, error) {
	return &Batch{
		ID:              localBatchID,
		RequestID:       f.created.RequestID,
		ClientRequestID: f.created.ClientRequestID,
		ScannerBatchID:  input.ScannerBatchID,
		Source:          f.created.Source,
		Status:          input.Status,
		TargetCount:     f.created.TargetCount,
		AcceptedTargets: input.AcceptedTargets,
		RejectedTargets: input.RejectedTargets,
		QueuedJobs:      input.QueuedJobs,
		CreatedAt:       time.Now(),
		UpdatedAt:       time.Now(),
	}, nil
}

func (f *fakeCreateBatchRepo) MarkBatchSubmitFailed(context.Context, uint64, string) error {
	return nil
}

func (f *fakeCreateBatchRepo) GetBatch(_ context.Context, id uint64) (*Batch, error) {
	if f.batches != nil {
		return f.batches[id], nil
	}
	return nil, nil
}

func (f *fakeCreateBatchRepo) FindBatchForCallback(context.Context, string, string) (*Batch, error) {
	return nil, nil
}

func (f *fakeCreateBatchRepo) ListBatches(context.Context, ListBatchesFilter) (*PaginatedResult[Batch], error) {
	return nil, nil
}

func (f *fakeCreateBatchRepo) ListTargets(context.Context, uint64, ListTargetsFilter) (*PaginatedResult[Target], error) {
	return nil, nil
}

func (f *fakeCreateBatchRepo) ListObservations(context.Context, uint64, Pagination) (*PaginatedResult[Observation], error) {
	return nil, nil
}

func (f *fakeCreateBatchRepo) ListCallbackReceipts(context.Context, uint64, Pagination) (*PaginatedResult[CallbackReceipt], error) {
	return nil, nil
}

func (f *fakeCreateBatchRepo) ListPortExposures(context.Context, ListPortExposuresFilter) (*PaginatedResult[PortExposure], error) {
	return nil, nil
}

func (f *fakeCreateBatchRepo) GetStats(context.Context) (*Stats, error) {
	return nil, nil
}

func (f *fakeCreateBatchRepo) FindCallbackReceipt(context.Context, string) (*CallbackReceipt, error) {
	return nil, nil
}

func (f *fakeCreateBatchRepo) CreateCallbackReceipt(context.Context, CallbackReceiptInput) (*CallbackReceipt, error) {
	return nil, nil
}

func (f *fakeCreateBatchRepo) MarkCallbackReceiptApplied(context.Context, uint64, uint64) error {
	return nil
}

func (f *fakeCreateBatchRepo) MarkCallbackReceiptFailed(context.Context, uint64, uint64, string, int, string) error {
	return nil
}

func (f *fakeCreateBatchRepo) ApplyObservations(context.Context, ApplyObservationsInput) error {
	return nil
}

func (f *fakeCreateBatchRepo) UpdateBatchFromSync(_ context.Context, localBatchID uint64, batch *scannerclient.BatchResponse, results *scannerclient.ResultsResponse) error {
	f.syncUpdates++
	if f.batches != nil && f.batches[localBatchID] != nil {
		if batch != nil && batch.Status != "" {
			f.batches[localBatchID].Status = batch.Status
		}
		if results != nil && results.CompletedAt != nil {
			f.batches[localBatchID].CompletedAt = results.CompletedAt
		}
		now := time.Now()
		f.batches[localBatchID].LastSyncedAt = &now
	}
	return nil
}

func scannerTargetsFromRequest(targets []scannerclient.CreateBatchTarget) []scannerclient.BatchTargetResponse {
	out := make([]scannerclient.BatchTargetResponse, 0, len(targets))
	for _, target := range targets {
		out = append(out, scannerclient.BatchTargetResponse{
			TargetID:  "scanner-target-" + target.AssetID,
			AssetID:   target.AssetID,
			PublicIP:  target.PublicIP,
			Port:      target.Port,
			Transport: target.Transport,
			Status:    "accepted",
		})
	}
	return out
}
