package service_test

import (
	"context"
	"errors"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"

	"gorm.io/gorm"
	coremodel "security-center-backend/internal/model"
	"security-center-backend/internal/modules/caasm/mocks"
	caasmmodel "security-center-backend/internal/modules/caasm/model"
	"security-center-backend/internal/modules/caasm/queue"
	"security-center-backend/internal/modules/caasm/repository"
	"security-center-backend/internal/modules/caasm/service"
	corerepo "security-center-backend/internal/repository"
)

type stubConfigRepo struct {
	cfg *caasmmodel.Config
	err error
}

type recordingSyncJobEventRepo struct {
	listResult []caasmmodel.SyncJobEvent
	listOpts   repository.SyncJobEventListOptions
	created    []*caasmmodel.SyncJobEvent
}

type stubUserRepository struct {
	user *coremodel.User
	err  error
}

func (s *stubConfigRepo) Get(ctx context.Context) (*caasmmodel.Config, error) {
	return s.cfg, s.err
}

func (s *stubConfigRepo) Upsert(ctx context.Context, data caasmmodel.Config) error {
	s.cfg = &data
	return s.err
}

func (r *recordingSyncJobEventRepo) Create(ctx context.Context, event *caasmmodel.SyncJobEvent) error {
	r.created = append(r.created, event)
	return nil
}

func (r *recordingSyncJobEventRepo) List(ctx context.Context, syncJobID uint64, opts repository.SyncJobEventListOptions) ([]caasmmodel.SyncJobEvent, error) {
	r.listOpts = opts
	return append([]caasmmodel.SyncJobEvent(nil), r.listResult...), nil
}

func (s *stubUserRepository) FindByID(ctx context.Context, id uint64) (*coremodel.User, error) {
	if s.err != nil {
		return nil, s.err
	}
	if s.user == nil || s.user.ID != id {
		return nil, gorm.ErrRecordNotFound
	}
	return s.user, nil
}

func (s *stubUserRepository) FindByAuth0ID(ctx context.Context, auth0UserID string) (*coremodel.User, error) {
	return nil, errors.New("not implemented")
}

func (s *stubUserRepository) FindByExternalID(ctx context.Context, externalID string) (*coremodel.User, error) {
	return nil, errors.New("not implemented")
}

func (s *stubUserRepository) FindByEmail(ctx context.Context, email string) (*coremodel.User, error) {
	return nil, errors.New("not implemented")
}

func (s *stubUserRepository) FindByEmails(ctx context.Context, emails []string) ([]coremodel.User, error) {
	return nil, errors.New("not implemented")
}

func (s *stubUserRepository) CreateOrUpdate(ctx context.Context, user *coremodel.User) error {
	return errors.New("not implemented")
}

func (s *stubUserRepository) List(ctx context.Context, filter corerepo.UserFilter, page, pageSize int) ([]coremodel.User, int64, error) {
	return nil, 0, errors.New("not implemented")
}

func (s *stubUserRepository) AssignRoles(ctx context.Context, userID uint64, roleIDs []uint64) error {
	return errors.New("not implemented")
}

func (s *stubUserRepository) RemoveRole(ctx context.Context, userID uint64, roleID uint64) error {
	return errors.New("not implemented")
}

func (s *stubUserRepository) GetUserRoles(ctx context.Context, userID uint64) ([]coremodel.Role, error) {
	return nil, errors.New("not implemented")
}

func (s *stubUserRepository) GetUserPermissions(ctx context.Context, userID uint64) ([]coremodel.Permission, error) {
	return nil, errors.New("not implemented")
}

func (s *stubUserRepository) UpdateDefaultRole(ctx context.Context, userID uint64, roleID *uint64) error {
	return errors.New("not implemented")
}

func TestTriggerSync(t *testing.T) {
	ctx := context.Background()

	t.Run("Success", func(t *testing.T) {
		// Setup
		mockAcctRepo := new(mocks.MockCloudAccountRepository)
		mockJobRepo := new(mocks.MockSyncJobRepository)
		mockQueue := new(mocks.MockJobQueue)
		mockAssetRepo := new(mocks.MockAssetRepository) // Assuming we need this for constructor
		mockPortRepo := new(mocks.MockIPPortIndexRepository)

		svc := service.NewCloudAccountService(mockAcctRepo, mockJobRepo, mockAssetRepo)
		svc.SetPortRepository(mockPortRepo)
		svc.SetJobQueue(mockQueue)

		accountID := uint64(1)
		actorID := uint64(999)

		// Expectations
		mockJobRepo.On("FindActiveJobForAccount", ctx, accountID).Return((*caasmmodel.SyncJob)(nil), nil)
		mockAcctRepo.On("FindByID", ctx, accountID).Return(&caasmmodel.CloudAccount{
			Base:     coremodel.Base{ID: accountID},
			Name:     "Test Account",
			Provider: caasmmodel.CloudProviderAWS,
		}, nil)

		mockJobRepo.On("Create", ctx, mock.Anything).Return(nil)

		mockQueue.On("Enqueue", ctx, mock.MatchedBy(func(job *queue.SyncJobPayload) bool {
			return job.CloudAccountID == accountID && job.SyncJobID != nil
		})).Return(nil)

		// Execute
		job, err := svc.TriggerSync(ctx, accountID, &actorID)

		// Assert
		assert.NoError(t, err)
		assert.NotNil(t, job)
		assert.Equal(t, caasmmodel.SyncJobQueued, job.Status)

		mockAcctRepo.AssertExpectations(t)
		mockJobRepo.AssertExpectations(t)
		mockQueue.AssertExpectations(t)
	})

	t.Run("AccountNotFound", func(t *testing.T) {
		mockAcctRepo := new(mocks.MockCloudAccountRepository)
		mockJobRepo := new(mocks.MockSyncJobRepository)
		mockAssetRepo := new(mocks.MockAssetRepository)
		mockPortRepo := new(mocks.MockIPPortIndexRepository)

		svc := service.NewCloudAccountService(mockAcctRepo, mockJobRepo, mockAssetRepo)
		svc.SetPortRepository(mockPortRepo)

		mockJobRepo.On("FindActiveJobForAccount", ctx, uint64(1)).Return((*caasmmodel.SyncJob)(nil), nil)
		mockAcctRepo.On("FindByID", ctx, uint64(1)).Return(nil, errors.New("not found"))

		_, err := svc.TriggerSync(ctx, 1, nil)
		assert.Error(t, err)
	})

	t.Run("EnqueueFailure", func(t *testing.T) {
		mockAcctRepo := new(mocks.MockCloudAccountRepository)
		mockJobRepo := new(mocks.MockSyncJobRepository)
		mockQueue := new(mocks.MockJobQueue)
		mockAssetRepo := new(mocks.MockAssetRepository)
		mockPortRepo := new(mocks.MockIPPortIndexRepository)

		svc := service.NewCloudAccountService(mockAcctRepo, mockJobRepo, mockAssetRepo)
		svc.SetPortRepository(mockPortRepo)
		svc.SetJobQueue(mockQueue)

		mockJobRepo.On("FindActiveJobForAccount", ctx, uint64(1)).Return((*caasmmodel.SyncJob)(nil), nil)
		mockAcctRepo.On("FindByID", ctx, uint64(1)).Return(&caasmmodel.CloudAccount{Base: coremodel.Base{ID: 1}}, nil)
		mockAcctRepo.On("Update", ctx, mock.Anything).Return(nil)
		mockJobRepo.On("Create", ctx, mock.Anything).Return(nil)
		mockJobRepo.On("FindByID", mock.Anything, uint64(123)).Return(&caasmmodel.SyncJob{}, nil).Maybe()

		// Expect Update to be called when enqueue fails
		mockJobRepo.On("Update", ctx, mock.MatchedBy(func(job *caasmmodel.SyncJob) bool {
			return job.Status == caasmmodel.SyncJobFailed
		})).Return(nil)
		mockJobRepo.On("UpdateFields", mock.Anything, mock.Anything, mock.Anything).Return(nil)

		mockQueue.On("Enqueue", ctx, mock.Anything).Return(errors.New("queue error"))

		job, err := svc.TriggerSync(ctx, 1, nil)
		assert.NoError(t, err)
		assert.NotNil(t, job)
	})
}

func TestTriggerSyncRejectsWhenKillSwitchEnabled(t *testing.T) {
	ctx := context.Background()
	mockAcctRepo := new(mocks.MockCloudAccountRepository)
	mockJobRepo := new(mocks.MockSyncJobRepository)
	mockAssetRepo := new(mocks.MockAssetRepository)

	svc := service.NewCloudAccountService(mockAcctRepo, mockJobRepo, mockAssetRepo)
	svc.SetConfigService(service.NewConfigService(&stubConfigRepo{
		cfg: &caasmmodel.Config{
			Data: coremodel.JSONB{
				"sync_settings": map[string]interface{}{
					"mode":                "queue",
					"kill_switch_enabled": true,
				},
			},
		},
	}))

	job, err := svc.TriggerSync(ctx, 42, nil)
	assert.Nil(t, job)
	assert.Error(t, err)
	assert.Contains(t, err.Error(), "kill switch")
	mockJobRepo.AssertNotCalled(t, "FindActiveJobForAccount", mock.Anything, mock.Anything)
	mockAcctRepo.AssertNotCalled(t, "FindByID", mock.Anything, mock.Anything)
}

func TestExecuteSyncMarksJobFailedWhenKillSwitchEnabled(t *testing.T) {
	ctx := context.Background()
	mockAcctRepo := new(mocks.MockCloudAccountRepository)
	mockJobRepo := new(mocks.MockSyncJobRepository)
	mockAssetRepo := new(mocks.MockAssetRepository)

	svc := service.NewCloudAccountService(mockAcctRepo, mockJobRepo, mockAssetRepo)
	svc.SetConfigService(service.NewConfigService(&stubConfigRepo{
		cfg: &caasmmodel.Config{
			Data: coremodel.JSONB{
				"sync_settings": map[string]interface{}{
					"mode":                "queue",
					"kill_switch_enabled": true,
				},
			},
		},
	}))

	job := &caasmmodel.SyncJob{
		Base:           coremodel.Base{ID: 100},
		CloudAccountID: func() *uint64 { v := uint64(1); return &v }(),
		Status:         caasmmodel.SyncJobQueued,
	}
	mockJobRepo.On("FindByID", ctx, uint64(100)).Return(job, nil).Once()
	mockJobRepo.On("UpdateFields", ctx, uint64(100), mock.MatchedBy(func(fields map[string]interface{}) bool {
		status, ok := fields["status"].(caasmmodel.SyncJobStatus)
		message, hasMessage := fields["error_message"].(string)
		return ok && hasMessage && status == caasmmodel.SyncJobFailed && strings.Contains(message, "kill switch")
	})).Return(nil).Once()

	err := svc.ExecuteSync(ctx, 100, 1)
	assert.NoError(t, err)
	mockAcctRepo.AssertNotCalled(t, "FindByID", mock.Anything, mock.Anything)
	mockJobRepo.AssertExpectations(t)
}

func TestAssetServiceUpdateOwnerUsesFieldScopedRepositoryMethod(t *testing.T) {
	ctx := context.Background()
	actorID := uint64(1001)
	assetID := uint64(77)
	ownerID := uint64(2002)
	deptID := "dept-xyz"

	mockAssetRepo := new(mocks.MockAssetRepository)
	userRepo := &stubUserRepository{
		user: &coremodel.User{
			Base:  coremodel.Base{ID: actorID},
			Roles: []coremodel.Role{{Name: "admin"}},
		},
	}
	svc := service.NewAssetService(mockAssetRepo, userRepo)

	mockAssetRepo.On("FindByID", ctx, assetID).Return(&caasmmodel.Asset{
		Base: coremodel.Base{ID: assetID},
	}, nil).Once()
	mockAssetRepo.On("UpdateAssetOwner", ctx, assetID, &ownerID, &deptID).Return(nil).Once()

	err := svc.UpdateOwner(ctx, actorID, assetID, &ownerID, &deptID)
	assert.NoError(t, err)

	mockAssetRepo.AssertNotCalled(t, "Update", mock.Anything, mock.Anything)
	mockAssetRepo.AssertNotCalled(t, "Save", mock.Anything, mock.Anything)
	mockAssetRepo.AssertExpectations(t)
}

// TestDomainBasedPortPersistence verifies that ports associated with domain assets
// (RDS, ELB, CloudFront, etc.) are correctly persisted by resolving asset IDs
// through the domainAssetMap, not just the ipAssetMap.
func TestDomainBasedPortPersistence(t *testing.T) {
	t.Run("Domain-based ports should resolve asset ID correctly", func(t *testing.T) {
		// This test verifies the bug fix where ports from RDS, ELB, etc. were being
		// skipped because the code only checked ipAssetMap for IP addresses.
		// After the fix, ports should also check domainAssetMap for domain names.

		// The fix is in service.go:816-844 where we:
		// 1. Create domainAssetMap when saving domain assets
		// 2. Check both ipAssetMap and domainAssetMap when resolving port asset IDs

		// This test is intentionally simple as it documents the expected behavior.
		// The actual integration test would require:
		// - Mock collector returning domain assets with associated ports
		// - Verifying domain asset is saved with AssetID populated
		// - Verifying port record is saved with correct AssetID from domain asset
		// - Confirming port is NOT skipped with "unknown asset_id" message

		// For now, this serves as documentation that this scenario is tested
		// in the actual sync workflow and should be validated manually or via
		// integration tests.

		t.Skip("This test documents the domain-based port persistence feature. " +
			"Actual validation requires integration test with real collector or " +
			"more complex mocking of SaveDomainAsset to populate AssetID.")
	})
}

func TestSyncJobServiceListEventsUsesCursorOptionsAndKeepsOrder(t *testing.T) {
	ctx := context.Background()
	eventRepo := &recordingSyncJobEventRepo{
		listResult: []caasmmodel.SyncJobEvent{
			{Base: coremodel.Base{ID: 9}, Message: "latest"},
			{Base: coremodel.Base{ID: 8}, Message: "older"},
		},
	}
	svc := service.NewSyncJobService(nil, eventRepo)
	beforeID := uint64(12)

	events, err := svc.ListEvents(ctx, 55, repository.SyncJobEventListOptions{
		Limit:    2,
		BeforeID: &beforeID,
	})

	assert.NoError(t, err)
	assert.Len(t, events, 2)
	assert.Equal(t, uint64(9), events[0].ID)
	assert.Equal(t, uint64(8), events[1].ID)
	assert.Equal(t, repository.SyncJobEventListOptions{
		Limit:    2,
		BeforeID: &beforeID,
	}, eventRepo.listOpts)
	assert.Empty(t, eventRepo.created)
}

func TestSyncJobServiceListEventsSeedsLegacyJobOnlyForInitialLoad(t *testing.T) {
	ctx := context.Background()
	eventRepo := &recordingSyncJobEventRepo{}
	jobRepo := new(mocks.MockSyncJobRepository)
	jobRepo.On("FindByID", ctx, uint64(77)).Return(&caasmmodel.SyncJob{
		Base: coremodel.Base{ID: 77},
		Metadata: coremodel.JSONB{
			"execution_mode": "queue",
			"provider":       "aws",
		},
	}, nil)

	svc := service.NewSyncJobService(jobRepo, eventRepo)

	events, err := svc.ListEvents(ctx, 77, repository.SyncJobEventListOptions{})
	assert.NoError(t, err)
	assert.Len(t, events, 1)
	assert.Equal(t, "Job created (legacy record - no detailed events)", events[0].Message)
	assert.Len(t, eventRepo.created, 1)
	assert.Equal(t, uint64(77), eventRepo.created[0].SyncJobID)
	jobRepo.AssertExpectations(t)
}

func TestSyncJobServiceListEventsDoesNotSeedLegacyJobForCursorLoads(t *testing.T) {
	ctx := context.Background()
	eventRepo := &recordingSyncJobEventRepo{}
	jobRepo := new(mocks.MockSyncJobRepository)

	svc := service.NewSyncJobService(jobRepo, eventRepo)
	beforeID := uint64(88)

	events, err := svc.ListEvents(ctx, 77, repository.SyncJobEventListOptions{BeforeID: &beforeID})
	assert.NoError(t, err)
	assert.Empty(t, events)
	assert.Empty(t, eventRepo.created)
	jobRepo.AssertNotCalled(t, "FindByID", mock.Anything, mock.Anything)
}

func TestIngestSyncDataPersistsPortsUsingSharedContract(t *testing.T) {
	ctx := context.Background()

	mockAcctRepo := new(mocks.MockCloudAccountRepository)
	mockJobRepo := new(mocks.MockSyncJobRepository)
	mockAssetRepo := new(mocks.MockAssetRepository)
	mockPortRepo := new(mocks.MockIPPortIndexRepository)

	svc := service.NewCloudAccountService(mockAcctRepo, mockJobRepo, mockAssetRepo)
	svc.SetPortRepository(mockPortRepo)

	job := &caasmmodel.SyncJob{
		Base:   coremodel.Base{ID: 10},
		Status: caasmmodel.SyncJobRunning,
	}

	payload := &caasmmodel.IngestPayload{
		SyncJobID:      10,
		CloudAccountID: 1,
		Assets: []caasmmodel.Asset{
			{
				Base:     coremodel.Base{ID: 0},
				NativeID: "asset-1",
				Name:     "database",
			},
		},
		DomainAssets: []caasmmodel.DomainAsset{
			{
				DomainName: "db.example.com",
				Asset: caasmmodel.Asset{
					NativeID: "asset-1",
				},
			},
		},
		Ports: []caasmmodel.IPPortIndex{
			{
				IPAddress: "db.example.com",
				Port:      5432,
				Protocol:  "tcp",
			},
		},
	}

	mockJobRepo.On("FindByID", ctx, uint64(10)).Return(job, nil)
	mockJobRepo.On("UpdateFields", ctx, uint64(10), mock.MatchedBy(func(fields map[string]interface{}) bool {
		_, hasMetadata := fields["metadata"]
		return hasMetadata
	})).Return(nil).Maybe()
	mockJobRepo.On("UpdateFields", ctx, uint64(10), mock.MatchedBy(func(fields map[string]interface{}) bool {
		status, ok := fields["status"].(caasmmodel.SyncJobStatus)
		_, hasFinishedAt := fields["finished_at"]
		return ok && status == caasmmodel.SyncJobSuccess && hasFinishedAt
	})).Return(nil)

	mockAssetRepo.On("FindMinimalByNativeIDs", ctx, uint64(1), []string{"asset-1"}).Return([]repository.AssetMinimal{}, nil).Once()
	mockAssetRepo.On("SaveAssetSnapshot", ctx, mock.MatchedBy(func(asset *caasmmodel.Asset) bool {
		return asset.NativeID == "asset-1" && asset.Name == "database"
	})).Run(func(args mock.Arguments) {
		asset := args.Get(1).(*caasmmodel.Asset)
		asset.ID = 101
	}).Return(nil)

	mockAssetRepo.On("SaveDomainAssetSnapshot", ctx, mock.MatchedBy(func(domainAsset *caasmmodel.DomainAsset) bool {
		return domainAsset.DomainName == "db.example.com" &&
			domainAsset.AssetID == 101 &&
			domainAsset.Asset.ID == 0 &&
			domainAsset.Asset.NativeID == ""
	})).Run(func(args mock.Arguments) {
		domainAsset := args.Get(1).(*caasmmodel.DomainAsset)
		domainAsset.ID = 201
	}).Return(nil)

	mockPortRepo.On("Upsert", ctx, mock.MatchedBy(func(port *caasmmodel.IPPortIndex) bool {
		return port.AssetID == 101 &&
			port.Endpoint == "db.example.com" &&
			port.IPAddress == "" &&
			port.Port == 5432 &&
			port.Protocol == "tcp" &&
			port.ObservationSource == caasmmodel.PortObservationSourceUnknown &&
			port.Asset.ID == 0 &&
			port.Asset.NativeID == "" &&
			!port.LastSeenAt.IsZero()
	})).Return(nil)

	err := svc.IngestSyncData(ctx, payload)
	assert.NoError(t, err)

	mockJobRepo.AssertExpectations(t)
	mockAssetRepo.AssertExpectations(t)
	mockPortRepo.AssertExpectations(t)
}

func TestIngestSyncDataKeepsSharedEndpointsScopedPerAsset(t *testing.T) {
	ctx := context.Background()

	mockAcctRepo := new(mocks.MockCloudAccountRepository)
	mockJobRepo := new(mocks.MockSyncJobRepository)
	mockAssetRepo := new(mocks.MockAssetRepository)
	mockPortRepo := new(mocks.MockIPPortIndexRepository)

	svc := service.NewCloudAccountService(mockAcctRepo, mockJobRepo, mockAssetRepo)
	svc.SetPortRepository(mockPortRepo)

	job := &caasmmodel.SyncJob{
		Base:   coremodel.Base{ID: 11},
		Status: caasmmodel.SyncJobRunning,
	}

	payload := &caasmmodel.IngestPayload{
		SyncJobID:      11,
		CloudAccountID: 1,
		Assets: []caasmmodel.Asset{
			{NativeID: "asset-1", Name: "api-a"},
			{NativeID: "asset-2", Name: "api-b"},
		},
		DomainAssets: []caasmmodel.DomainAsset{
			{
				DomainName: "shared.example.com",
				Asset: caasmmodel.Asset{
					NativeID: "asset-1",
				},
			},
			{
				DomainName: "shared.example.com",
				Asset: caasmmodel.Asset{
					NativeID: "asset-2",
				},
			},
		},
		Ports: []caasmmodel.IPPortIndex{
			{
				Asset: caasmmodel.Asset{
					NativeID: "asset-1",
				},
				IPAddress:         "shared.example.com",
				Port:              443,
				Protocol:          "tcp",
				ObservationSource: caasmmodel.PortObservationSourceInferredWeb,
			},
			{
				Asset: caasmmodel.Asset{
					NativeID: "asset-2",
				},
				IPAddress:         "shared.example.com",
				Port:              443,
				Protocol:          "tcp",
				ObservationSource: caasmmodel.PortObservationSourceInferredWeb,
			},
		},
	}

	mockJobRepo.On("FindByID", ctx, uint64(11)).Return(job, nil)
	mockJobRepo.On("UpdateFields", ctx, uint64(11), mock.MatchedBy(func(fields map[string]interface{}) bool {
		_, hasMetadata := fields["metadata"]
		return hasMetadata
	})).Return(nil).Maybe()
	mockJobRepo.On("UpdateFields", ctx, uint64(11), mock.MatchedBy(func(fields map[string]interface{}) bool {
		status, ok := fields["status"].(caasmmodel.SyncJobStatus)
		_, hasFinishedAt := fields["finished_at"]
		return ok && status == caasmmodel.SyncJobSuccess && hasFinishedAt
	})).Return(nil)

	mockAssetRepo.On("FindMinimalByNativeIDs", ctx, uint64(1), []string{"asset-1", "asset-2"}).Return([]repository.AssetMinimal{}, nil).Once()
	mockAssetRepo.On("SaveAssetSnapshot", ctx, mock.MatchedBy(func(asset *caasmmodel.Asset) bool {
		return asset.NativeID == "asset-1"
	})).Run(func(args mock.Arguments) {
		asset := args.Get(1).(*caasmmodel.Asset)
		asset.ID = 101
	}).Return(nil).Once()

	mockAssetRepo.On("SaveAssetSnapshot", ctx, mock.MatchedBy(func(asset *caasmmodel.Asset) bool {
		return asset.NativeID == "asset-2"
	})).Run(func(args mock.Arguments) {
		asset := args.Get(1).(*caasmmodel.Asset)
		asset.ID = 202
	}).Return(nil).Once()

	mockAssetRepo.On("SaveDomainAssetSnapshot", ctx, mock.MatchedBy(func(domainAsset *caasmmodel.DomainAsset) bool {
		return domainAsset.DomainName == "shared.example.com" &&
			domainAsset.AssetID == 101 &&
			domainAsset.Asset.ID == 0 &&
			domainAsset.Asset.NativeID == ""
	})).Return(nil).Once()
	mockAssetRepo.On("SaveDomainAssetSnapshot", ctx, mock.MatchedBy(func(domainAsset *caasmmodel.DomainAsset) bool {
		return domainAsset.DomainName == "shared.example.com" &&
			domainAsset.AssetID == 202 &&
			domainAsset.Asset.ID == 0 &&
			domainAsset.Asset.NativeID == ""
	})).Return(nil).Once()

	mockPortRepo.On("Upsert", ctx, mock.MatchedBy(func(port *caasmmodel.IPPortIndex) bool {
		return port.AssetID == 101 &&
			port.Endpoint == "shared.example.com" &&
			port.IPAddress == "" &&
			port.Port == 443 &&
			port.Protocol == "tcp" &&
			port.ObservationSource == caasmmodel.PortObservationSourceInferredWeb &&
			port.Asset.ID == 0 &&
			port.Asset.NativeID == ""
	})).Return(nil).Once()
	mockPortRepo.On("Upsert", ctx, mock.MatchedBy(func(port *caasmmodel.IPPortIndex) bool {
		return port.AssetID == 202 &&
			port.Endpoint == "shared.example.com" &&
			port.IPAddress == "" &&
			port.Port == 443 &&
			port.Protocol == "tcp" &&
			port.ObservationSource == caasmmodel.PortObservationSourceInferredWeb &&
			port.Asset.ID == 0 &&
			port.Asset.NativeID == ""
	})).Return(nil).Once()

	err := svc.IngestSyncData(ctx, payload)
	assert.NoError(t, err)

	mockJobRepo.AssertExpectations(t)
	mockAssetRepo.AssertExpectations(t)
	mockPortRepo.AssertExpectations(t)
}

func TestIngestSyncDataFiltersPrivateIPsPortsAndDomains(t *testing.T) {
	ctx := context.Background()

	mockAcctRepo := new(mocks.MockCloudAccountRepository)
	mockJobRepo := new(mocks.MockSyncJobRepository)
	mockAssetRepo := new(mocks.MockAssetRepository)
	mockPortRepo := new(mocks.MockIPPortIndexRepository)

	svc := service.NewCloudAccountService(mockAcctRepo, mockJobRepo, mockAssetRepo)
	svc.SetPortRepository(mockPortRepo)
	svc.SetConfigService(service.NewConfigService(&stubConfigRepo{
		cfg: &caasmmodel.Config{
			Data: coremodel.JSONB{
				"collection_scope": map[string]interface{}{
					"ip_collection_scope":     "public_only",
					"port_collection_scope":   "public_only",
					"domain_collection_scope": "public_only",
				},
			},
		},
	}))

	job := &caasmmodel.SyncJob{
		Base:   coremodel.Base{ID: 12},
		Status: caasmmodel.SyncJobRunning,
	}

	payload := &caasmmodel.IngestPayload{
		SyncJobID:      12,
		CloudAccountID: 1,
		Assets: []caasmmodel.Asset{
			{NativeID: "asset-1", Name: "app"},
		},
		IPAssets: []caasmmodel.IPAsset{
			{
				Asset:     caasmmodel.Asset{NativeID: "asset-1"},
				IPAddress: "10.0.0.15",
				IsPublic:  false,
			},
			{
				Asset:     caasmmodel.Asset{NativeID: "asset-1"},
				IPAddress: "54.1.2.3",
				IsPublic:  true,
			},
		},
		Ports: []caasmmodel.IPPortIndex{
			{
				Asset:             caasmmodel.Asset{NativeID: "asset-1"},
				IPAddress:         "10.0.0.15",
				Port:              8080,
				Protocol:          "tcp",
				Exposure:          "private",
				ObservationSource: caasmmodel.PortObservationSourceSecurityGroup,
			},
			{
				Asset:             caasmmodel.Asset{NativeID: "asset-1"},
				IPAddress:         "54.1.2.3",
				Port:              443,
				Protocol:          "tcp",
				Exposure:          "public",
				ObservationSource: caasmmodel.PortObservationSourceListener,
			},
		},
		DomainAssets: []caasmmodel.DomainAsset{
			{
				Asset:       caasmmodel.Asset{NativeID: "asset-1"},
				DomainName:  "public.example.com",
				DomainClass: caasmmodel.DomainClassServiceHostname,
				ZoneType:    "public",
				Status:      "active",
			},
			{
				Asset:       caasmmodel.Asset{NativeID: "asset-1"},
				DomainName:  "private.example.internal",
				DomainClass: caasmmodel.DomainClassServiceHostname,
				ZoneType:    "private",
				Status:      "active",
			},
		},
	}

	mockJobRepo.On("FindByID", ctx, uint64(12)).Return(job, nil)
	mockJobRepo.On("UpdateFields", ctx, uint64(12), mock.MatchedBy(func(fields map[string]interface{}) bool {
		_, hasMetadata := fields["metadata"]
		return hasMetadata
	})).Return(nil).Maybe()
	mockJobRepo.On("UpdateFields", ctx, uint64(12), mock.MatchedBy(func(fields map[string]interface{}) bool {
		status, ok := fields["status"].(caasmmodel.SyncJobStatus)
		_, hasFinishedAt := fields["finished_at"]
		return ok && status == caasmmodel.SyncJobSuccess && hasFinishedAt
	})).Return(nil)
	mockAssetRepo.On("DeletePrivateIPAssetsForCloudAccount", ctx, uint64(1)).Return(nil).Once()
	mockAssetRepo.On("DeletePrivateDomainAssetsForCloudAccount", ctx, uint64(1)).Return(nil).Once()
	mockPortRepo.On("DeleteNonPublicForCloudAccount", ctx, uint64(1)).Return(nil).Once()

	mockAssetRepo.On("FindMinimalByNativeIDs", ctx, uint64(1), []string{"asset-1"}).Return([]repository.AssetMinimal{}, nil).Once()
	mockAssetRepo.On("SaveAssetSnapshot", ctx, mock.MatchedBy(func(asset *caasmmodel.Asset) bool {
		return asset.NativeID == "asset-1"
	})).Run(func(args mock.Arguments) {
		asset := args.Get(1).(*caasmmodel.Asset)
		asset.ID = 101
	}).Return(nil).Once()

	mockAssetRepo.On("SaveIPAsset", ctx, mock.MatchedBy(func(ipAsset *caasmmodel.IPAsset) bool {
		return ipAsset.AssetID == 101 && ipAsset.IPAddress == "54.1.2.3" && ipAsset.IsPublic
	})).Return(nil).Once()

	mockPortRepo.On("Upsert", ctx, mock.MatchedBy(func(port *caasmmodel.IPPortIndex) bool {
		return port.AssetID == 101 &&
			port.IPAddress == "54.1.2.3" &&
			port.Port == 443 &&
			port.Exposure == "public"
	})).Return(nil).Once()
	mockAssetRepo.On("SaveDomainAssetSnapshot", ctx, mock.MatchedBy(func(domainAsset *caasmmodel.DomainAsset) bool {
		return domainAsset.AssetID == 101 &&
			domainAsset.DomainName == "public.example.com" &&
			domainAsset.ZoneType == "public"
	})).Return(nil).Once()
	err := svc.IngestSyncData(ctx, payload)
	assert.NoError(t, err)

	mockJobRepo.AssertExpectations(t)
	mockAssetRepo.AssertExpectations(t)
	mockPortRepo.AssertExpectations(t)
	mockAssetRepo.AssertNotCalled(t, "SaveIPAsset", ctx, mock.MatchedBy(func(ipAsset *caasmmodel.IPAsset) bool {
		return ipAsset.IPAddress == "10.0.0.15"
	}))
	mockPortRepo.AssertNotCalled(t, "Upsert", ctx, mock.MatchedBy(func(port *caasmmodel.IPPortIndex) bool {
		return port.IPAddress == "10.0.0.15"
	}))
	mockAssetRepo.AssertNotCalled(t, "SaveDomainAssetSnapshot", ctx, mock.MatchedBy(func(domainAsset *caasmmodel.DomainAsset) bool {
		return domainAsset.DomainName == "private.example.internal"
	}))
}

func TestCloudAccountInputValidation(t *testing.T) {
	ctx := context.Background()

	t.Run("Create rejects AWS account without role ARN", func(t *testing.T) {
		mockAcctRepo := new(mocks.MockCloudAccountRepository)
		mockJobRepo := new(mocks.MockSyncJobRepository)
		mockAssetRepo := new(mocks.MockAssetRepository)

		svc := service.NewCloudAccountService(mockAcctRepo, mockJobRepo, mockAssetRepo)

		err := svc.Create(ctx, &caasmmodel.CloudAccount{
			Name:       "aws account",
			Provider:   caasmmodel.CloudProviderAWS,
			ExternalID: "311141548860",
			Metadata:   coremodel.JSONB{},
		})

		assert.ErrorContains(t, err, "metadata.role_arn is required")
		mockAcctRepo.AssertNotCalled(t, "Create", mock.Anything, mock.Anything)
	})

	t.Run("Create normalizes AWS region before persisting", func(t *testing.T) {
		mockAcctRepo := new(mocks.MockCloudAccountRepository)
		mockJobRepo := new(mocks.MockSyncJobRepository)
		mockAssetRepo := new(mocks.MockAssetRepository)

		svc := service.NewCloudAccountService(mockAcctRepo, mockJobRepo, mockAssetRepo)

		mockAcctRepo.On("Create", ctx, mock.MatchedBy(func(account *caasmmodel.CloudAccount) bool {
			roleARN, _ := account.Metadata["role_arn"].(string)
			region, _ := account.Metadata["region"].(string)
			return account.ExternalID == "311141548860" &&
				roleARN == "arn:aws:iam::311141548860:role/caasm-collector-role" &&
				region == "us-east-1"
		})).Return(nil).Once()

		err := svc.Create(ctx, &caasmmodel.CloudAccount{
			Name:       "aws account",
			Provider:   caasmmodel.CloudProviderAWS,
			ExternalID: "311141548860",
			Metadata: coremodel.JSONB{
				"role_arn": " arn:aws:iam::311141548860:role/caasm-collector-role ",
			},
		})

		assert.NoError(t, err)
		mockAcctRepo.AssertExpectations(t)
	})

	t.Run("Create normalizes allowed regions and account throttling overrides", func(t *testing.T) {
		mockAcctRepo := new(mocks.MockCloudAccountRepository)
		mockJobRepo := new(mocks.MockSyncJobRepository)
		mockAssetRepo := new(mocks.MockAssetRepository)

		svc := service.NewCloudAccountService(mockAcctRepo, mockJobRepo, mockAssetRepo)

		mockAcctRepo.On("Create", ctx, mock.MatchedBy(func(account *caasmmodel.CloudAccount) bool {
			allowedRegions, ok := account.Metadata["allowed_regions"].([]string)
			if !ok {
				return false
			}
			throttleTokens, ok := account.Metadata["throttle_tokens"].(int)
			if !ok {
				return false
			}
			throttleRefill, ok := account.Metadata["throttle_refill_per_sec"].(float64)
			if !ok {
				return false
			}
			throttleMax, ok := account.Metadata["throttle_max_concurrency"].(int)
			if !ok {
				return false
			}
			throttleMin, ok := account.Metadata["throttle_min_concurrency"].(int)
			if !ok {
				return false
			}
			return account.Metadata["region"] == "us-west-2" &&
				len(allowedRegions) == 2 &&
				allowedRegions[0] == "us-east-1" &&
				allowedRegions[1] == "eu-west-1" &&
				throttleTokens == 15 &&
				throttleRefill == 8.5 &&
				throttleMax == 4 &&
				throttleMin == 2
		})).Return(nil).Once()

		err := svc.Create(ctx, &caasmmodel.CloudAccount{
			Name:       "aws account",
			Provider:   caasmmodel.CloudProviderAWS,
			ExternalID: "311141548860",
			Metadata: coremodel.JSONB{
				"role_arn":                 "arn:aws:iam::311141548860:role/caasm-collector-role",
				"region":                   "us-west-2",
				"allowed_regions":          []interface{}{" us-east-1 ", "eu-west-1", "us-east-1"},
				"throttle_enabled":         true,
				"throttle_tokens":          float64(15),
				"throttle_refill_per_sec":  8.5,
				"throttle_max_concurrency": float64(4),
				"throttle_min_concurrency": float64(2),
			},
		})

		assert.NoError(t, err)
		mockAcctRepo.AssertExpectations(t)
	})

	t.Run("Create rejects invalid allowed regions and override ranges", func(t *testing.T) {
		mockAcctRepo := new(mocks.MockCloudAccountRepository)
		mockJobRepo := new(mocks.MockSyncJobRepository)
		mockAssetRepo := new(mocks.MockAssetRepository)

		svc := service.NewCloudAccountService(mockAcctRepo, mockJobRepo, mockAssetRepo)

		err := svc.Create(ctx, &caasmmodel.CloudAccount{
			Name:       "aws account",
			Provider:   caasmmodel.CloudProviderAWS,
			ExternalID: "311141548860",
			Metadata: coremodel.JSONB{
				"role_arn":                 "arn:aws:iam::311141548860:role/caasm-collector-role",
				"allowed_regions":          []interface{}{"not-a-region"},
				"throttle_max_concurrency": float64(2),
				"throttle_min_concurrency": float64(3),
			},
		})

		assert.Error(t, err)
		assert.True(t,
			strings.Contains(err.Error(), "metadata.allowed_regions") ||
				strings.Contains(err.Error(), "metadata.throttle_min_concurrency"),
		)
		mockAcctRepo.AssertNotCalled(t, "Create", mock.Anything, mock.Anything)
	})

	t.Run("Create rejects account throttle overrides above global defaults", func(t *testing.T) {
		mockAcctRepo := new(mocks.MockCloudAccountRepository)
		mockJobRepo := new(mocks.MockSyncJobRepository)
		mockAssetRepo := new(mocks.MockAssetRepository)

		svc := service.NewCloudAccountService(mockAcctRepo, mockJobRepo, mockAssetRepo)
		cfgRepo := &stubConfigRepo{}
		svc.SetConfigService(service.NewConfigService(cfgRepo))

		defaults := caasmmodel.ConfigDefaults()
		defaults.Throttling.TokenCapacity = 12
		defaults.Throttling.TokenRefillPerSec = 6
		defaults.Throttling.MaxConcurrency = 3
		defaults.Throttling.MinConcurrency = 2
		rawConfig := coremodel.JSONB{
			"throttling": defaults.Throttling,
		}
		cfgRepo.cfg = &caasmmodel.Config{Data: rawConfig}

		err := svc.Create(ctx, &caasmmodel.CloudAccount{
			Name:       "aws account",
			Provider:   caasmmodel.CloudProviderAWS,
			ExternalID: "311141548860",
			Metadata: coremodel.JSONB{
				"role_arn":                 "arn:aws:iam::311141548860:role/caasm-collector-role",
				"throttle_tokens":          float64(20),
				"throttle_refill_per_sec":  8.5,
				"throttle_max_concurrency": float64(4),
			},
		})

		assert.Error(t, err)
		assert.Contains(t, err.Error(), "global throttling")
		mockAcctRepo.AssertNotCalled(t, "Create", mock.Anything, mock.Anything)
	})
}
