package cache

import (
	"bytes"
	"context"
	"errors"
	"io"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/lukaszraczylo/gohoarder/pkg/metadata"
	"github.com/lukaszraczylo/gohoarder/pkg/storage"
	"github.com/lukaszraczylo/gohoarder/pkg/websocket"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
)

// fakeBroadcaster records BroadcastEvent calls for assertions.
type fakeBroadcaster struct {
	events []fakeBroadcastEvent
	mu     sync.Mutex
}

type fakeBroadcastEvent struct {
	Payload any
	Type    string
}

func (f *fakeBroadcaster) BroadcastEvent(eventType string, payload any) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.events = append(f.events, fakeBroadcastEvent{Type: eventType, Payload: payload})
}

func (f *fakeBroadcaster) snapshot() []fakeBroadcastEvent {
	f.mu.Lock()
	defer f.mu.Unlock()
	out := make([]fakeBroadcastEvent, len(f.events))
	copy(out, f.events)
	return out
}

func (f *fakeBroadcaster) typesOnly() []string {
	snap := f.snapshot()
	out := make([]string, len(snap))
	for i, e := range snap {
		out[i] = e.Type
	}
	return out
}

// MockStorageBackend is a mock for storage.StorageBackend
type MockStorageBackend struct {
	mock.Mock
}

func (m *MockStorageBackend) Get(ctx context.Context, key string) (io.ReadCloser, error) {
	args := m.Called(ctx, key)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(io.ReadCloser), args.Error(1)
}

func (m *MockStorageBackend) Put(ctx context.Context, key string, data io.Reader, opts *storage.PutOptions) error {
	args := m.Called(ctx, key, data, opts)
	return args.Error(0)
}

func (m *MockStorageBackend) Delete(ctx context.Context, key string) error {
	args := m.Called(ctx, key)
	return args.Error(0)
}

func (m *MockStorageBackend) Exists(ctx context.Context, key string) (bool, error) {
	args := m.Called(ctx, key)
	return args.Bool(0), args.Error(1)
}

func (m *MockStorageBackend) List(ctx context.Context, prefix string, opts *storage.ListOptions) ([]storage.StorageObject, error) {
	args := m.Called(ctx, prefix, opts)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).([]storage.StorageObject), args.Error(1)
}

func (m *MockStorageBackend) Stat(ctx context.Context, key string) (*storage.StorageInfo, error) {
	args := m.Called(ctx, key)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*storage.StorageInfo), args.Error(1)
}

func (m *MockStorageBackend) GetQuota(ctx context.Context) (*storage.QuotaInfo, error) {
	args := m.Called(ctx)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*storage.QuotaInfo), args.Error(1)
}

func (m *MockStorageBackend) Health(ctx context.Context) error {
	args := m.Called(ctx)
	return args.Error(0)
}

func (m *MockStorageBackend) Close() error {
	args := m.Called()
	return args.Error(0)
}

// MockMetadataStore is a mock for metadata.MetadataStore
type MockMetadataStore struct {
	mock.Mock
}

func (m *MockMetadataStore) SavePackage(ctx context.Context, pkg *metadata.Package) error {
	args := m.Called(ctx, pkg)
	return args.Error(0)
}

func (m *MockMetadataStore) GetPackage(ctx context.Context, registry, name, version string) (*metadata.Package, error) {
	args := m.Called(ctx, registry, name, version)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*metadata.Package), args.Error(1)
}

func (m *MockMetadataStore) DeletePackage(ctx context.Context, registry, name, version string) error {
	args := m.Called(ctx, registry, name, version)
	return args.Error(0)
}

func (m *MockMetadataStore) ListPackages(ctx context.Context, opts *metadata.ListOptions) ([]*metadata.Package, error) {
	args := m.Called(ctx, opts)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).([]*metadata.Package), args.Error(1)
}

func (m *MockMetadataStore) UpdateDownloadCount(ctx context.Context, registry, name, version string) error {
	args := m.Called(ctx, registry, name, version)
	return args.Error(0)
}

func (m *MockMetadataStore) GetStats(ctx context.Context, registry string) (*metadata.Stats, error) {
	args := m.Called(ctx, registry)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*metadata.Stats), args.Error(1)
}

func (m *MockMetadataStore) SaveScanResult(ctx context.Context, result *metadata.ScanResult) error {
	args := m.Called(ctx, result)
	return args.Error(0)
}

func (m *MockMetadataStore) GetScanResult(ctx context.Context, registry, name, version string) (*metadata.ScanResult, error) {
	args := m.Called(ctx, registry, name, version)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*metadata.ScanResult), args.Error(1)
}

func (m *MockMetadataStore) Count(ctx context.Context) (int, error) {
	args := m.Called(ctx)
	return args.Int(0), args.Error(1)
}

func (m *MockMetadataStore) Health(ctx context.Context) error {
	args := m.Called(ctx)
	return args.Error(0)
}

func (m *MockMetadataStore) Close() error {
	args := m.Called()
	return args.Error(0)
}

func (m *MockMetadataStore) SaveCVEBypass(ctx context.Context, bypass *metadata.CVEBypass) error {
	args := m.Called(ctx, bypass)
	return args.Error(0)
}

func (m *MockMetadataStore) GetActiveCVEBypasses(ctx context.Context) ([]*metadata.CVEBypass, error) {
	args := m.Called(ctx)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).([]*metadata.CVEBypass), args.Error(1)
}

func (m *MockMetadataStore) ListCVEBypasses(ctx context.Context, opts *metadata.BypassListOptions) ([]*metadata.CVEBypass, error) {
	args := m.Called(ctx, opts)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).([]*metadata.CVEBypass), args.Error(1)
}

func (m *MockMetadataStore) DeleteCVEBypass(ctx context.Context, id string) error {
	args := m.Called(ctx, id)
	return args.Error(0)
}

func (m *MockMetadataStore) CleanupExpiredBypasses(ctx context.Context) (int, error) {
	args := m.Called(ctx)
	return args.Int(0), args.Error(1)
}

func (m *MockMetadataStore) GetTimeSeriesStats(ctx context.Context, period string, registry string) (*metadata.TimeSeriesStats, error) {
	args := m.Called(ctx, period, registry)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*metadata.TimeSeriesStats), args.Error(1)
}

func (m *MockMetadataStore) AggregateDownloadData(ctx context.Context) error {
	args := m.Called(ctx)
	return args.Error(0)
}

// API key methods are not used by the cache package; stub them out so the
// mock continues to satisfy metadata.MetadataStore after the interface gained
// API key persistence methods.
func (m *MockMetadataStore) SaveAPIKey(ctx context.Context, key *metadata.APIKey) error {
	return metadata.ErrNotImplemented
}

func (m *MockMetadataStore) GetAPIKey(ctx context.Context, id string) (*metadata.APIKey, error) {
	return nil, metadata.ErrNotImplemented
}

func (m *MockMetadataStore) ListAPIKeys(ctx context.Context) ([]*metadata.APIKey, error) {
	return nil, metadata.ErrNotImplemented
}

func (m *MockMetadataStore) DeleteAPIKey(ctx context.Context, id string) error {
	return metadata.ErrNotImplemented
}

func (m *MockMetadataStore) UpdateAPIKeyLastUsed(ctx context.Context, id string, t time.Time) error {
	return metadata.ErrNotImplemented
}

// TestNew tests cache manager creation
func TestNew(t *testing.T) {
	tests := []struct {
		storage     storage.StorageBackend
		metadata    metadata.MetadataStore
		name        string
		errContains string
		config      Config
		wantErr     bool
	}{
		// GOOD: Valid configuration
		{
			name:     "valid config with defaults",
			storage:  &MockStorageBackend{},
			metadata: &MockMetadataStore{},
			config:   Config{},
			wantErr:  false,
		},
		{
			name:     "valid config with custom settings",
			storage:  &MockStorageBackend{},
			metadata: &MockMetadataStore{},
			config: Config{
				DefaultTTL:        24 * time.Hour,
				CleanupInterval:   30 * time.Minute,
				EvictionThreshold: 0.8,
				MaxConcurrent:     50,
			},
			wantErr: false,
		},
		// WRONG: Missing required components
		{
			name:        "nil storage",
			storage:     nil,
			metadata:    &MockMetadataStore{},
			config:      Config{},
			wantErr:     true,
			errContains: "storage backend is required",
		},
		{
			name:        "nil metadata",
			storage:     &MockStorageBackend{},
			metadata:    nil,
			config:      Config{},
			wantErr:     true,
			errContains: "metadata store is required",
		},
		// EDGE: Both nil
		{
			name:        "both nil",
			storage:     nil,
			metadata:    nil,
			config:      Config{},
			wantErr:     true,
			errContains: "storage backend is required",
		},
		// EDGE: Zero values get defaults
		{
			name:     "zero config gets defaults",
			storage:  &MockStorageBackend{},
			metadata: &MockMetadataStore{},
			config:   Config{},
			wantErr:  false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			manager, err := New(tt.storage, tt.metadata, nil, nil, tt.config)

			if tt.wantErr {
				require.Error(t, err)
				if tt.errContains != "" {
					assert.Contains(t, err.Error(), tt.errContains)
				}
				assert.Nil(t, manager)
			} else {
				require.NoError(t, err)
				require.NotNil(t, manager)

				// Verify defaults were set
				if tt.config.DefaultTTL == 0 {
					assert.Equal(t, 7*24*time.Hour, manager.config.DefaultTTL)
				}
				if tt.config.CleanupInterval == 0 {
					assert.Equal(t, 1*time.Hour, manager.config.CleanupInterval)
				}
				if tt.config.EvictionThreshold == 0 {
					assert.Equal(t, 0.9, manager.config.EvictionThreshold)
				}
				if tt.config.MaxConcurrent == 0 {
					assert.Equal(t, 100, manager.config.MaxConcurrent)
				}
			}
		})
	}
}

// TestGet tests cache retrieval with various scenarios
func TestGet(t *testing.T) {
	tests := []struct {
		setupMock     func(*MockStorageBackend, *MockMetadataStore)
		fetchFunc     func(context.Context) (io.ReadCloser, string, error)
		name          string
		registry      string
		packageName   string
		version       string
		errContains   string
		wantFromCache bool
		wantErr       bool
	}{
		// GOOD: Cache hit
		{
			name:        "cache hit - package exists and valid",
			registry:    "npm",
			packageName: "react",
			version:     "18.2.0",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				now := time.Now()
				expiresAt := now.Add(24 * time.Hour)
				pkg := &metadata.Package{
					ID:           "test-id",
					Registry:     "npm",
					Name:         "react",
					Version:      "18.2.0",
					StorageKey:   "npm/react/18.2.0",
					CachedAt:     now,
					LastAccessed: now,
					ExpiresAt:    &expiresAt,
				}
				m.On("GetPackage", mock.Anything, "npm", "react", "18.2.0").Return(pkg, nil)
				s.On("Get", mock.Anything, "npm/react/18.2.0").Return(io.NopCloser(strings.NewReader("cached data")), nil)
				m.On("UpdateDownloadCount", mock.Anything, "npm", "react", "18.2.0").Return(nil)
			},
			wantFromCache: true,
			wantErr:       false,
		},
		// GOOD: Cache miss - fetch from upstream
		{
			name:        "cache miss - fetch from upstream",
			registry:    "npm",
			packageName: "lodash",
			version:     "4.17.21",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				m.On("GetPackage", mock.Anything, "npm", "lodash", "4.17.21").Return(nil, errors.New("not found"))
				s.On("GetQuota", mock.Anything).Return(&storage.QuotaInfo{Used: 100, Available: 900, Limit: 1000}, nil)
				s.On("Put", mock.Anything, "npm/lodash/4.17.21", mock.Anything, mock.Anything).Return(nil)
				m.On("SavePackage", mock.Anything, mock.Anything).Return(nil)
				s.On("Get", mock.Anything, "npm/lodash/4.17.21").Return(io.NopCloser(strings.NewReader("upstream data")), nil)
				m.On("UpdateDownloadCount", mock.Anything, "npm", "lodash", "4.17.21").Return(nil)
			},
			fetchFunc: func(ctx context.Context) (io.ReadCloser, string, error) {
				return io.NopCloser(strings.NewReader("upstream data")), "https://registry.npmjs.org/lodash", nil
			},
			wantFromCache: false,
			wantErr:       false,
		},
		// WRONG: Expired package
		{
			name:        "expired package - re-fetch",
			registry:    "npm",
			packageName: "expired-pkg",
			version:     "1.0.0",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				now := time.Now()
				expiresAt := now.Add(-1 * time.Hour) // Expired 1 hour ago
				pkg := &metadata.Package{
					ID:         "test-id",
					Registry:   "npm",
					Name:       "expired-pkg",
					Version:    "1.0.0",
					StorageKey: "npm/expired-pkg/1.0.0",
					ExpiresAt:  &expiresAt,
				}
				m.On("GetPackage", mock.Anything, "npm", "expired-pkg", "1.0.0").Return(pkg, nil)
				m.On("DeletePackage", mock.Anything, "npm", "expired-pkg", "1.0.0").Return(nil)
				s.On("Delete", mock.Anything, "npm/expired-pkg/1.0.0").Return(nil)
				s.On("GetQuota", mock.Anything).Return(&storage.QuotaInfo{Used: 100, Available: 900, Limit: 1000}, nil)
				s.On("Put", mock.Anything, "npm/expired-pkg/1.0.0", mock.Anything, mock.Anything).Return(nil)
				m.On("SavePackage", mock.Anything, mock.Anything).Return(nil)
				s.On("Get", mock.Anything, "npm/expired-pkg/1.0.0").Return(io.NopCloser(strings.NewReader("refreshed data")), nil)
				m.On("UpdateDownloadCount", mock.Anything, "npm", "expired-pkg", "1.0.0").Return(nil)
			},
			fetchFunc: func(ctx context.Context) (io.ReadCloser, string, error) {
				return io.NopCloser(strings.NewReader("refreshed data")), "https://registry.npmjs.org/expired-pkg", nil
			},
			wantFromCache: false,
			wantErr:       false,
		},
		// BAD: Fetch function is nil and package not cached
		{
			name:        "nil fetch function and not cached",
			registry:    "npm",
			packageName: "missing",
			version:     "1.0.0",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				m.On("GetPackage", mock.Anything, "npm", "missing", "1.0.0").Return(nil, errors.New("not found"))
			},
			fetchFunc:   nil,
			wantErr:     true,
			errContains: "package not found and no fetch function provided",
		},
		// BAD: Upstream fetch fails
		{
			name:        "upstream fetch error",
			registry:    "npm",
			packageName: "fail-pkg",
			version:     "1.0.0",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				m.On("GetPackage", mock.Anything, "npm", "fail-pkg", "1.0.0").Return(nil, errors.New("not found"))
			},
			fetchFunc: func(ctx context.Context) (io.ReadCloser, string, error) {
				return nil, "", errors.New("upstream error")
			},
			wantErr:     true,
			errContains: "failed to fetch from upstream",
		},
		// EDGE: Metadata exists but storage missing
		{
			name:        "metadata exists but storage missing - inconsistency",
			registry:    "npm",
			packageName: "inconsistent",
			version:     "1.0.0",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				now := time.Now()
				expiresAt := now.Add(24 * time.Hour)
				pkg := &metadata.Package{
					ID:         "test-id",
					Registry:   "npm",
					Name:       "inconsistent",
					Version:    "1.0.0",
					StorageKey: "npm/inconsistent/1.0.0",
					ExpiresAt:  &expiresAt,
				}
				m.On("GetPackage", mock.Anything, "npm", "inconsistent", "1.0.0").Return(pkg, nil)
				// First Get fails (storage missing)
				s.On("Get", mock.Anything, "npm/inconsistent/1.0.0").Return(nil, errors.New("not found")).Once()
				m.On("DeletePackage", mock.Anything, "npm", "inconsistent", "1.0.0").Return(nil)
				s.On("GetQuota", mock.Anything).Return(&storage.QuotaInfo{Used: 100, Available: 900, Limit: 1000}, nil)
				s.On("Put", mock.Anything, "npm/inconsistent/1.0.0", mock.Anything, mock.Anything).Return(nil)
				m.On("SavePackage", mock.Anything, mock.Anything).Return(nil)
				// Second Get succeeds (after re-storing)
				s.On("Get", mock.Anything, "npm/inconsistent/1.0.0").Return(io.NopCloser(strings.NewReader("recovered data")), nil).Once()
				m.On("UpdateDownloadCount", mock.Anything, "npm", "inconsistent", "1.0.0").Return(nil)
			},
			fetchFunc: func(ctx context.Context) (io.ReadCloser, string, error) {
				return io.NopCloser(strings.NewReader("recovered data")), "https://registry.npmjs.org/inconsistent", nil
			},
			wantFromCache: false,
			wantErr:       false,
		},
		// EDGE: Storage save fails
		{
			name:        "storage save fails",
			registry:    "npm",
			packageName: "save-fail",
			version:     "1.0.0",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				m.On("GetPackage", mock.Anything, "npm", "save-fail", "1.0.0").Return(nil, errors.New("not found"))
				s.On("GetQuota", mock.Anything).Return(&storage.QuotaInfo{Used: 100, Available: 900, Limit: 1000}, nil)
				s.On("Put", mock.Anything, "npm/save-fail/1.0.0", mock.Anything, mock.Anything).Return(errors.New("storage error"))
			},
			fetchFunc: func(ctx context.Context) (io.ReadCloser, string, error) {
				return io.NopCloser(strings.NewReader("data")), "https://registry.npmjs.org/save-fail", nil
			},
			wantErr:     true,
			errContains: "storage error",
		},
		// EDGE: Metadata save fails (should cleanup storage)
		{
			name:        "metadata save fails - storage cleanup",
			registry:    "npm",
			packageName: "meta-fail",
			version:     "1.0.0",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				m.On("GetPackage", mock.Anything, "npm", "meta-fail", "1.0.0").Return(nil, errors.New("not found"))
				s.On("GetQuota", mock.Anything).Return(&storage.QuotaInfo{Used: 100, Available: 900, Limit: 1000}, nil)
				s.On("Put", mock.Anything, "npm/meta-fail/1.0.0", mock.Anything, mock.Anything).Return(nil)
				m.On("SavePackage", mock.Anything, mock.Anything).Return(errors.New("metadata error"))
				s.On("Delete", mock.Anything, "npm/meta-fail/1.0.0").Return(nil)
			},
			fetchFunc: func(ctx context.Context) (io.ReadCloser, string, error) {
				return io.NopCloser(strings.NewReader("data")), "https://registry.npmjs.org/meta-fail", nil
			},
			wantErr:     true,
			errContains: "metadata error",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockStorage := &MockStorageBackend{}
			mockMetadata := &MockMetadataStore{}

			if tt.setupMock != nil {
				tt.setupMock(mockStorage, mockMetadata)
			}

			manager, err := New(mockStorage, mockMetadata, nil, nil, Config{
				DefaultTTL:      24 * time.Hour,
				CleanupInterval: 1 * time.Hour,
			})
			require.NoError(t, err)

			ctx := context.Background()
			entry, err := manager.Get(ctx, tt.registry, tt.packageName, tt.version, tt.fetchFunc)

			if tt.wantErr {
				require.Error(t, err)
				if tt.errContains != "" {
					assert.Contains(t, err.Error(), tt.errContains)
				}
				assert.Nil(t, entry)
			} else {
				require.NoError(t, err)
				require.NotNil(t, entry)
				assert.Equal(t, tt.wantFromCache, entry.FromCache)
				assert.NotNil(t, entry.Data)
				// Read and verify data exists
				data, _ := io.ReadAll(entry.Data)
				assert.NotEmpty(t, data)
			}

			mockStorage.AssertExpectations(t)
			mockMetadata.AssertExpectations(t)
		})
	}
}

// TestDelete tests package deletion
func TestDelete(t *testing.T) {
	tests := []struct {
		setupMock   func(*MockStorageBackend, *MockMetadataStore)
		name        string
		registry    string
		packageName string
		version     string
		errContains string
		wantErr     bool
	}{
		// GOOD: Successful deletion
		{
			name:        "successful deletion",
			registry:    "npm",
			packageName: "react",
			version:     "18.2.0",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				pkg := &metadata.Package{
					ID:         "test-id",
					Registry:   "npm",
					Name:       "react",
					Version:    "18.2.0",
					StorageKey: "npm/react/18.2.0",
				}
				m.On("GetPackage", mock.Anything, "npm", "react", "18.2.0").Return(pkg, nil)
				s.On("Delete", mock.Anything, "npm/react/18.2.0").Return(nil)
				m.On("DeletePackage", mock.Anything, "npm", "react", "18.2.0").Return(nil)
			},
			wantErr: false,
		},
		// WRONG: Package not found
		{
			name:        "package not found",
			registry:    "npm",
			packageName: "missing",
			version:     "1.0.0",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				m.On("GetPackage", mock.Anything, "npm", "missing", "1.0.0").Return(nil, errors.New("not found"))
			},
			wantErr:     true,
			errContains: "not found",
		},
		// EDGE: Storage delete fails but metadata succeeds
		{
			name:        "storage delete fails",
			registry:    "npm",
			packageName: "storage-fail",
			version:     "1.0.0",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				pkg := &metadata.Package{
					ID:         "test-id",
					Registry:   "npm",
					Name:       "storage-fail",
					Version:    "1.0.0",
					StorageKey: "npm/storage-fail/1.0.0",
				}
				m.On("GetPackage", mock.Anything, "npm", "storage-fail", "1.0.0").Return(pkg, nil)
				s.On("Delete", mock.Anything, "npm/storage-fail/1.0.0").Return(errors.New("storage error"))
				m.On("DeletePackage", mock.Anything, "npm", "storage-fail", "1.0.0").Return(nil)
			},
			wantErr: false, // Metadata delete still succeeds
		},
		// EDGE: Metadata delete fails
		{
			name:        "metadata delete fails",
			registry:    "npm",
			packageName: "meta-fail",
			version:     "1.0.0",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				pkg := &metadata.Package{
					ID:         "test-id",
					Registry:   "npm",
					Name:       "meta-fail",
					Version:    "1.0.0",
					StorageKey: "npm/meta-fail/1.0.0",
				}
				m.On("GetPackage", mock.Anything, "npm", "meta-fail", "1.0.0").Return(pkg, nil)
				s.On("Delete", mock.Anything, "npm/meta-fail/1.0.0").Return(nil)
				m.On("DeletePackage", mock.Anything, "npm", "meta-fail", "1.0.0").Return(errors.New("metadata error"))
			},
			wantErr:     true,
			errContains: "metadata error",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockStorage := &MockStorageBackend{}
			mockMetadata := &MockMetadataStore{}

			if tt.setupMock != nil {
				tt.setupMock(mockStorage, mockMetadata)
			}

			manager, err := New(mockStorage, mockMetadata, nil, nil, Config{})
			require.NoError(t, err)

			ctx := context.Background()
			err = manager.Delete(ctx, tt.registry, tt.packageName, tt.version)

			if tt.wantErr {
				require.Error(t, err)
				if tt.errContains != "" {
					assert.Contains(t, err.Error(), tt.errContains)
				}
			} else {
				require.NoError(t, err)
			}

			mockStorage.AssertExpectations(t)
			mockMetadata.AssertExpectations(t)
		})
	}
}

// TestHealth tests health check functionality
func TestHealth(t *testing.T) {
	tests := []struct {
		setupMock   func(*MockStorageBackend, *MockMetadataStore)
		name        string
		errContains string
		wantErr     bool
	}{
		// GOOD: Both healthy
		{
			name: "both storage and metadata healthy",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				s.On("Health", mock.Anything).Return(nil)
				m.On("Health", mock.Anything).Return(nil)
			},
			wantErr: false,
		},
		// WRONG: Storage unhealthy
		{
			name: "storage unhealthy",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				s.On("Health", mock.Anything).Return(errors.New("storage error"))
			},
			wantErr:     true,
			errContains: "storage health check failed",
		},
		// WRONG: Metadata unhealthy
		{
			name: "metadata unhealthy",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				s.On("Health", mock.Anything).Return(nil)
				m.On("Health", mock.Anything).Return(errors.New("metadata error"))
			},
			wantErr:     true,
			errContains: "metadata health check failed",
		},
		// BAD: Both unhealthy
		{
			name: "both unhealthy",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				s.On("Health", mock.Anything).Return(errors.New("storage error"))
			},
			wantErr:     true,
			errContains: "storage health check failed",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockStorage := &MockStorageBackend{}
			mockMetadata := &MockMetadataStore{}

			if tt.setupMock != nil {
				tt.setupMock(mockStorage, mockMetadata)
			}

			manager, err := New(mockStorage, mockMetadata, nil, nil, Config{})
			require.NoError(t, err)

			ctx := context.Background()
			err = manager.Health(ctx)

			if tt.wantErr {
				require.Error(t, err)
				if tt.errContains != "" {
					assert.Contains(t, err.Error(), tt.errContains)
				}
			} else {
				require.NoError(t, err)
			}

			mockStorage.AssertExpectations(t)
			mockMetadata.AssertExpectations(t)
		})
	}
}

// TestGetStats tests statistics retrieval
func TestGetStats(t *testing.T) {
	mockStorage := &MockStorageBackend{}
	mockMetadata := &MockMetadataStore{}

	expectedStats := &metadata.Stats{
		Registry:       "npm",
		TotalPackages:  100,
		TotalSize:      1024 * 1024 * 100,
		TotalDownloads: 5000,
	}

	mockMetadata.On("GetStats", mock.Anything, "npm").Return(expectedStats, nil)

	manager, err := New(mockStorage, mockMetadata, nil, nil, Config{})
	require.NoError(t, err)

	ctx := context.Background()
	stats, err := manager.GetStats(ctx, "npm")

	require.NoError(t, err)
	assert.Equal(t, expectedStats, stats)
	mockMetadata.AssertExpectations(t)
}

// TestClose tests manager cleanup
func TestClose(t *testing.T) {
	tests := []struct {
		setupMock func(*MockStorageBackend, *MockMetadataStore)
		name      string
		wantErr   bool
	}{
		// GOOD: Clean close
		{
			name: "both close successfully",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				s.On("Close").Return(nil)
				m.On("Close").Return(nil)
			},
			wantErr: false,
		},
		// WRONG: Storage close fails
		{
			name: "storage close fails",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				s.On("Close").Return(errors.New("storage error"))
				m.On("Close").Return(nil)
			},
			wantErr: true,
		},
		// WRONG: Metadata close fails
		{
			name: "metadata close fails",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				s.On("Close").Return(nil)
				m.On("Close").Return(errors.New("metadata error"))
			},
			wantErr: true,
		},
		// BAD: Both close fail
		{
			name: "both close fail",
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				s.On("Close").Return(errors.New("storage error"))
				m.On("Close").Return(errors.New("metadata error"))
			},
			wantErr: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockStorage := &MockStorageBackend{}
			mockMetadata := &MockMetadataStore{}

			if tt.setupMock != nil {
				tt.setupMock(mockStorage, mockMetadata)
			}

			manager, err := New(mockStorage, mockMetadata, nil, nil, Config{})
			require.NoError(t, err)

			err = manager.Close() // #nosec G104 -- Cleanup, error not critical

			if tt.wantErr {
				require.Error(t, err)
			} else {
				require.NoError(t, err)
			}

			mockStorage.AssertExpectations(t)
			mockMetadata.AssertExpectations(t)
		})
	}
}

// TestEvict tests LRU eviction
func TestEvict(t *testing.T) {
	tests := []struct {
		setupMock   func(*MockStorageBackend, *MockMetadataStore)
		name        string
		errContains string
		needed      int64
		wantErr     bool
	}{
		// GOOD: Successful eviction
		{
			name:   "evict enough to free space",
			needed: 200,
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				packages := []*metadata.Package{
					{
						ID:         "1",
						Name:       "old-pkg-1",
						Version:    "1.0.0",
						Registry:   "npm",
						StorageKey: "npm/old-pkg-1/1.0.0",
						Size:       100,
					},
					{
						ID:         "2",
						Name:       "old-pkg-2",
						Version:    "1.0.0",
						Registry:   "npm",
						StorageKey: "npm/old-pkg-2/1.0.0",
						Size:       150,
					},
				}
				m.On("ListPackages", mock.Anything, mock.MatchedBy(func(opts *metadata.ListOptions) bool {
					return opts.SortBy == "last_accessed" && !opts.SortDesc
				})).Return(packages, nil).Once()

				s.On("Delete", mock.Anything, "npm/old-pkg-1/1.0.0").Return(nil)
				m.On("DeletePackage", mock.Anything, "npm", "old-pkg-1", "1.0.0").Return(nil)
				s.On("Delete", mock.Anything, "npm/old-pkg-2/1.0.0").Return(nil)
				m.On("DeletePackage", mock.Anything, "npm", "old-pkg-2", "1.0.0").Return(nil)
			},
			wantErr: false,
		},
		// EDGE: No packages to evict
		{
			name:   "no packages available to evict",
			needed: 100,
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				m.On("ListPackages", mock.Anything, mock.Anything).Return([]*metadata.Package{}, nil)
			},
			wantErr: false, // Doesn't error, just can't free enough
		},
		// EDGE: Eviction list error
		{
			name:   "list packages fails",
			needed: 100,
			setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
				m.On("ListPackages", mock.Anything, mock.Anything).Return(nil, errors.New("list error"))
			},
			wantErr: false, // Doesn't error, just can't complete
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockStorage := &MockStorageBackend{}
			mockMetadata := &MockMetadataStore{}

			if tt.setupMock != nil {
				tt.setupMock(mockStorage, mockMetadata)
			}

			manager, err := New(mockStorage, mockMetadata, nil, nil, Config{})
			require.NoError(t, err)

			ctx := context.Background()
			err = manager.evict(ctx, tt.needed)

			if tt.wantErr {
				require.Error(t, err)
				if tt.errContains != "" {
					assert.Contains(t, err.Error(), tt.errContains)
				}
			} else {
				require.NoError(t, err)
			}

			mockStorage.AssertExpectations(t)
			mockMetadata.AssertExpectations(t)
		})
	}
}

// TestGenerateStorageKey tests storage key generation
func TestGenerateStorageKey(t *testing.T) {
	mockStorage := &MockStorageBackend{}
	mockMetadata := &MockMetadataStore{}

	manager, err := New(mockStorage, mockMetadata, nil, nil, Config{})
	require.NoError(t, err)

	tests := []struct {
		registry string
		name     string
		version  string
		expected string
	}{
		{"npm", "react", "18.2.0", "npm/react/18.2.0"},
		{"pypi", "requests", "2.28.0", "pypi/requests/2.28.0"},
		{"go", "github.com/gin-gonic/gin", "v1.9.0", "go/github.com/gin-gonic/gin/v1.9.0"},
	}

	for _, tt := range tests {
		t.Run(tt.expected, func(t *testing.T) {
			key := manager.generateStorageKey(tt.registry, tt.name, tt.version)
			assert.Equal(t, tt.expected, key)
		})
	}
}

// TestConcurrentGet tests concurrent access doesn't cause data races
func TestConcurrentGet(t *testing.T) {
	mockStorage := &MockStorageBackend{}
	mockMetadata := &MockMetadataStore{}

	// Setup mocks for concurrent access
	now := time.Now()
	expiresAt := now.Add(24 * time.Hour)
	pkg := &metadata.Package{
		ID:           "test-id",
		Registry:     "npm",
		Name:         "concurrent",
		Version:      "1.0.0",
		StorageKey:   "npm/concurrent/1.0.0",
		CachedAt:     now,
		LastAccessed: now,
		ExpiresAt:    &expiresAt,
	}

	// Use Maybe() to allow variable number of calls due to singleflight deduplication
	mockMetadata.On("GetPackage", mock.Anything, "npm", "concurrent", "1.0.0").Return(pkg, nil).Maybe()
	mockStorage.On("Get", mock.Anything, "npm/concurrent/1.0.0").Return(
		io.NopCloser(bytes.NewReader([]byte("test data"))), nil).Maybe()
	mockMetadata.On("UpdateDownloadCount", mock.Anything, "npm", "concurrent", "1.0.0").Return(nil).Maybe()

	manager, err := New(mockStorage, mockMetadata, nil, nil, Config{})
	require.NoError(t, err)

	ctx := context.Background()
	const numGoroutines = 10

	// Run concurrent gets
	errs := make(chan error, numGoroutines)
	for i := 0; i < numGoroutines; i++ {
		go func() {
			_, err := manager.Get(ctx, "npm", "concurrent", "1.0.0", nil)
			errs <- err
		}()
	}

	// Collect results
	for i := 0; i < numGoroutines; i++ {
		err := <-errs
		assert.NoError(t, err)
	}

	// Verify at least one call was made (singleflight may deduplicate others)
	mockMetadata.AssertCalled(t, "GetPackage", mock.Anything, "npm", "concurrent", "1.0.0")
}

// TestBroadcaster_CacheHit verifies EventPackageDownloaded fires on a
// cache-hit serve and no other events are emitted.
func TestBroadcaster_CacheHit(t *testing.T) {
	mockStorage := &MockStorageBackend{}
	mockMetadata := &MockMetadataStore{}

	now := time.Now()
	expiresAt := now.Add(24 * time.Hour)
	pkg := &metadata.Package{
		ID:           "test-id",
		Registry:     "npm",
		Name:         "react",
		Version:      "18.2.0",
		StorageKey:   "npm/react/18.2.0",
		CachedAt:     now,
		LastAccessed: now,
		ExpiresAt:    &expiresAt,
	}
	mockMetadata.On("GetPackage", mock.Anything, "npm", "react", "18.2.0").Return(pkg, nil)
	mockStorage.On("Get", mock.Anything, "npm/react/18.2.0").Return(io.NopCloser(strings.NewReader("cached data")), nil)
	mockMetadata.On("UpdateDownloadCount", mock.Anything, "npm", "react", "18.2.0").Return(nil)

	manager, err := New(mockStorage, mockMetadata, nil, nil, Config{})
	require.NoError(t, err)

	bc := &fakeBroadcaster{}
	manager.SetBroadcaster(bc)

	entry, err := manager.Get(context.Background(), "npm", "react", "18.2.0", nil)
	require.NoError(t, err)
	require.NotNil(t, entry)

	events := bc.snapshot()
	require.Len(t, events, 1, "expected exactly one event on cache hit")
	assert.Equal(t, string(websocket.EventPackageDownloaded), events[0].Type)

	payload, ok := events[0].Payload.(map[string]interface{})
	require.True(t, ok, "payload should be map[string]interface{}")
	assert.Equal(t, "npm", payload["registry"])
	assert.Equal(t, "react", payload["name"])
	assert.Equal(t, "18.2.0", payload["version"])
	assert.Equal(t, true, payload["cache_hit"])
}

// TestBroadcaster_CacheMissStore verifies EventPackageCached fires on a
// cache-miss-then-store path and EventPackageDownloaded does NOT fire
// on that path (avoid double counting).
func TestBroadcaster_CacheMissStore(t *testing.T) {
	mockStorage := &MockStorageBackend{}
	mockMetadata := &MockMetadataStore{}

	mockMetadata.On("GetPackage", mock.Anything, "npm", "lodash", "4.17.21").Return(nil, errors.New("not found"))
	mockStorage.On("GetQuota", mock.Anything).Return(&storage.QuotaInfo{Used: 100, Available: 900, Limit: 1000}, nil)
	mockStorage.On("Put", mock.Anything, "npm/lodash/4.17.21", mock.Anything, mock.Anything).Return(nil)
	mockMetadata.On("SavePackage", mock.Anything, mock.Anything).Return(nil)
	mockStorage.On("Get", mock.Anything, "npm/lodash/4.17.21").Return(io.NopCloser(strings.NewReader("upstream data")), nil)
	mockMetadata.On("UpdateDownloadCount", mock.Anything, "npm", "lodash", "4.17.21").Return(nil)

	manager, err := New(mockStorage, mockMetadata, nil, nil, Config{})
	require.NoError(t, err)

	bc := &fakeBroadcaster{}
	manager.SetBroadcaster(bc)

	fetch := func(ctx context.Context) (io.ReadCloser, string, error) {
		return io.NopCloser(strings.NewReader("upstream data")), "https://registry.npmjs.org/lodash", nil
	}
	entry, err := manager.Get(context.Background(), "npm", "lodash", "4.17.21", fetch)
	require.NoError(t, err)
	require.NotNil(t, entry)

	types := bc.typesOnly()
	require.Contains(t, types, string(websocket.EventPackageCached))
	require.NotContains(t, types, string(websocket.EventPackageDownloaded),
		"miss-then-fetch path should not emit EventPackageDownloaded")

	// Inspect EventPackageCached payload.
	var cachedEv *fakeBroadcastEvent
	for i := range bc.events {
		if bc.events[i].Type == string(websocket.EventPackageCached) {
			cachedEv = &bc.events[i]
			break
		}
	}
	require.NotNil(t, cachedEv)
	payload, ok := cachedEv.Payload.(map[string]interface{})
	require.True(t, ok)
	assert.Equal(t, "npm", payload["registry"])
	assert.Equal(t, "lodash", payload["name"])
	assert.Equal(t, "4.17.21", payload["version"])
	assert.Equal(t, int64(len("upstream data")), payload["size"])
	// Scanner is nil in this test → scan_status should be "disabled".
	assert.Equal(t, "disabled", payload["scan_status"])
}

// TestBroadcaster_NoEmitOnError ensures error paths (storage Put fail,
// metadata Save fail) do NOT emit EventPackageCached.
func TestBroadcaster_NoEmitOnError(t *testing.T) {
	t.Run("storage put fails", func(t *testing.T) {
		mockStorage := &MockStorageBackend{}
		mockMetadata := &MockMetadataStore{}

		mockMetadata.On("GetPackage", mock.Anything, "npm", "fail", "1.0.0").Return(nil, errors.New("not found"))
		mockStorage.On("GetQuota", mock.Anything).Return(&storage.QuotaInfo{Used: 100, Available: 900, Limit: 1000}, nil)
		mockStorage.On("Put", mock.Anything, "npm/fail/1.0.0", mock.Anything, mock.Anything).Return(errors.New("storage error"))

		manager, err := New(mockStorage, mockMetadata, nil, nil, Config{})
		require.NoError(t, err)

		bc := &fakeBroadcaster{}
		manager.SetBroadcaster(bc)

		fetch := func(ctx context.Context) (io.ReadCloser, string, error) {
			return io.NopCloser(strings.NewReader("data")), "https://registry.npmjs.org/fail", nil
		}
		_, err = manager.Get(context.Background(), "npm", "fail", "1.0.0", fetch)
		require.Error(t, err)

		assert.Empty(t, bc.snapshot(), "no events should be emitted when storage Put fails")
	})

	t.Run("metadata save fails", func(t *testing.T) {
		mockStorage := &MockStorageBackend{}
		mockMetadata := &MockMetadataStore{}

		mockMetadata.On("GetPackage", mock.Anything, "npm", "meta-fail", "1.0.0").Return(nil, errors.New("not found"))
		mockStorage.On("GetQuota", mock.Anything).Return(&storage.QuotaInfo{Used: 100, Available: 900, Limit: 1000}, nil)
		mockStorage.On("Put", mock.Anything, "npm/meta-fail/1.0.0", mock.Anything, mock.Anything).Return(nil)
		mockMetadata.On("SavePackage", mock.Anything, mock.Anything).Return(errors.New("metadata error"))
		mockStorage.On("Delete", mock.Anything, "npm/meta-fail/1.0.0").Return(nil)

		manager, err := New(mockStorage, mockMetadata, nil, nil, Config{})
		require.NoError(t, err)

		bc := &fakeBroadcaster{}
		manager.SetBroadcaster(bc)

		fetch := func(ctx context.Context) (io.ReadCloser, string, error) {
			return io.NopCloser(strings.NewReader("data")), "https://registry.npmjs.org/meta-fail", nil
		}
		_, err = manager.Get(context.Background(), "npm", "meta-fail", "1.0.0", fetch)
		require.Error(t, err)

		assert.Empty(t, bc.snapshot(), "no events should be emitted when metadata SavePackage fails")
	})

	t.Run("nil broadcaster is safe", func(t *testing.T) {
		mockStorage := &MockStorageBackend{}
		mockMetadata := &MockMetadataStore{}

		now := time.Now()
		expiresAt := now.Add(24 * time.Hour)
		pkg := &metadata.Package{
			ID:           "id",
			Registry:     "npm",
			Name:         "x",
			Version:      "1",
			StorageKey:   "npm/x/1",
			CachedAt:     now,
			LastAccessed: now,
			ExpiresAt:    &expiresAt,
		}
		mockMetadata.On("GetPackage", mock.Anything, "npm", "x", "1").Return(pkg, nil)
		mockStorage.On("Get", mock.Anything, "npm/x/1").Return(io.NopCloser(strings.NewReader("d")), nil)
		mockMetadata.On("UpdateDownloadCount", mock.Anything, "npm", "x", "1").Return(nil)

		manager, err := New(mockStorage, mockMetadata, nil, nil, Config{})
		require.NoError(t, err)
		// No SetBroadcaster — manager.broadcaster is nil.
		_, err = manager.Get(context.Background(), "npm", "x", "1", nil)
		require.NoError(t, err)
	})
}
