package coderd

import (
	"context"
	"crypto/sha256"
	"database/sql"
	"encoding/json"
	"net/http/httptest"
	"net/url"
	"sync"
	"testing"
	"time"

	"github.com/google/uuid"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"cdr.dev/slog"
	"cdr.dev/slog/sloggers/slogtest"
	"github.com/coder/coder/coderd/database"
	"github.com/coder/coder/coderd/database/databasefake"
	"github.com/coder/coder/coderd/rbac"
	"github.com/coder/coder/codersdk"
	"github.com/coder/coder/testutil"
)

func TestProvisionerJobLogs_Unit(t *testing.T) {
	t.Parallel()

	t.Run("QueryPubSubDupes", func(t *testing.T) {
		t.Parallel()
		logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
		// mDB := mocks.NewStore(t)
		fDB := databasefake.New()
		fPubsub := &fakePubSub{t: t, cond: sync.NewCond(&sync.Mutex{})}
		opts := Options{
			Logger:   logger,
			Database: fDB,
			Pubsub:   fPubsub,
		}
		api := New(&opts)
		server := httptest.NewServer(api.Handler)
		defer server.Close()
		userID := uuid.New()
		keyID, keySecret, err := generateAPIKeyIDSecret()
		require.NoError(t, err)
		hashed := sha256.Sum256([]byte(keySecret))

		u, err := url.Parse(server.URL)
		require.NoError(t, err)
		client := codersdk.Client{
			HTTPClient:   server.Client(),
			SessionToken: keyID + "-" + keySecret,
			URL:          u,
		}

		buildID := uuid.New()
		workspaceID := uuid.New()
		jobID := uuid.New()

		expectedLogs := []database.ProvisionerJobLog{
			{ID: uuid.New(), JobID: jobID, Stage: "Stage0"},
			{ID: uuid.New(), JobID: jobID, Stage: "Stage1"},
			{ID: uuid.New(), JobID: jobID, Stage: "Stage2"},
			{ID: uuid.New(), JobID: jobID, Stage: "Stage3"},
		}

		ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
		defer cancel()

		// wow there are a lot of DB rows we touch...
		_, err = fDB.InsertAPIKey(ctx, database.InsertAPIKeyParams{
			ID:           keyID,
			HashedSecret: hashed[:],
			UserID:       userID,
			ExpiresAt:    time.Now().Add(5 * time.Hour),
			LoginType:    database.LoginTypePassword,
		})
		require.NoError(t, err)
		_, err = fDB.InsertUser(ctx, database.InsertUserParams{
			ID:        userID,
			RBACRoles: []string{rbac.RoleOwner()},
		})
		require.NoError(t, err)
		_, err = fDB.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{
			ID:          buildID,
			WorkspaceID: workspaceID,
			JobID:       jobID,
		})
		require.NoError(t, err)
		_, err = fDB.InsertWorkspace(ctx, database.InsertWorkspaceParams{
			ID: workspaceID,
		})
		require.NoError(t, err)
		_, err = fDB.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{
			ID: jobID,
		})
		require.NoError(t, err)
		for _, l := range expectedLogs[:2] {
			_, err := fDB.InsertProvisionerJobLogs(ctx, database.InsertProvisionerJobLogsParams{
				ID:    []uuid.UUID{l.ID},
				JobID: jobID,
				Stage: []string{l.Stage},
			})
			require.NoError(t, err)
		}

		logs, err := client.WorkspaceBuildLogsAfter(ctx, buildID, time.Now())
		require.NoError(t, err)

		// when the endpoint calls subscribe, we get the listener here.
		fPubsub.cond.L.Lock()
		for fPubsub.listener == nil {
			fPubsub.cond.Wait()
		}

		// endpoint should now be listening
		assert.False(t, fPubsub.canceled)
		assert.False(t, fPubsub.closed)

		// send all the logs in two batches, duplicating what we already returned on the DB query.
		msg := provisionerJobLogsMessage{}
		msg.Logs = expectedLogs[:2]
		data, err := json.Marshal(msg)
		require.NoError(t, err)
		fPubsub.listener(ctx, data)
		msg.Logs = expectedLogs[2:]
		data, err = json.Marshal(msg)
		require.NoError(t, err)
		fPubsub.listener(ctx, data)

		// send end of logs
		msg.Logs = nil
		msg.EndOfLogs = true
		data, err = json.Marshal(msg)
		require.NoError(t, err)
		fPubsub.listener(ctx, data)

		var stages []string
		for l := range logs {
			logger.Info(ctx, "got log",
				slog.F("id", l.ID),
				slog.F("stage", l.Stage))
			stages = append(stages, l.Stage)
		}
		assert.Equal(t, []string{"Stage0", "Stage1", "Stage2", "Stage3"}, stages)
		for !fPubsub.canceled {
			fPubsub.cond.Wait()
		}
		assert.False(t, fPubsub.closed)
	})
}

func TestConvertProvisionerJob_Unit(t *testing.T) {
	t.Parallel()
	validNullTimeMock := sql.NullTime{
		Time:  database.Now(),
		Valid: true,
	}
	invalidNullTimeMock := sql.NullTime{}
	errorMock := sql.NullString{
		String: "error",
		Valid:  true,
	}
	testCases := []struct {
		name     string
		input    database.ProvisionerJob
		expected codersdk.ProvisionerJob
	}{
		{
			name:  "empty",
			input: database.ProvisionerJob{},
			expected: codersdk.ProvisionerJob{
				Status: codersdk.ProvisionerJobPending,
			},
		},
		{
			name: "cancellation pending",
			input: database.ProvisionerJob{
				CanceledAt:  validNullTimeMock,
				CompletedAt: invalidNullTimeMock,
			},
			expected: codersdk.ProvisionerJob{
				Status: codersdk.ProvisionerJobCanceling,
			},
		},
		{
			name: "cancellation failed",
			input: database.ProvisionerJob{
				CanceledAt:  validNullTimeMock,
				CompletedAt: validNullTimeMock,
				Error:       errorMock,
			},
			expected: codersdk.ProvisionerJob{
				CompletedAt: &validNullTimeMock.Time,
				Status:      codersdk.ProvisionerJobFailed,
				Error:       errorMock.String,
			},
		},
		{
			name: "cancellation succeeded",
			input: database.ProvisionerJob{
				CanceledAt:  validNullTimeMock,
				CompletedAt: validNullTimeMock,
			},
			expected: codersdk.ProvisionerJob{
				CompletedAt: &validNullTimeMock.Time,
				Status:      codersdk.ProvisionerJobCanceled,
			},
		},
		{
			name: "job pending",
			input: database.ProvisionerJob{
				StartedAt: invalidNullTimeMock,
			},
			expected: codersdk.ProvisionerJob{
				Status: codersdk.ProvisionerJobPending,
			},
		},
		{
			name: "job failed",
			input: database.ProvisionerJob{
				CompletedAt: validNullTimeMock,
				StartedAt:   validNullTimeMock,
				Error:       errorMock,
			},
			expected: codersdk.ProvisionerJob{
				CompletedAt: &validNullTimeMock.Time,
				StartedAt:   &validNullTimeMock.Time,
				Error:       errorMock.String,
				Status:      codersdk.ProvisionerJobFailed,
			},
		},
		{
			name: "job succeeded",
			input: database.ProvisionerJob{
				CompletedAt: validNullTimeMock,
				StartedAt:   validNullTimeMock,
			},
			expected: codersdk.ProvisionerJob{
				CompletedAt: &validNullTimeMock.Time,
				StartedAt:   &validNullTimeMock.Time,
				Status:      codersdk.ProvisionerJobSucceeded,
			},
		},
	}
	for _, testCase := range testCases {
		testCase := testCase
		t.Run(testCase.name, func(t *testing.T) {
			t.Parallel()
			actual := convertProvisionerJob(testCase.input)
			assert.Equal(t, testCase.expected, actual)
		})
	}
}

type fakePubSub struct {
	t        *testing.T
	cond     *sync.Cond
	listener database.Listener
	canceled bool
	closed   bool
}

func (f *fakePubSub) Subscribe(_ string, listener database.Listener) (cancel func(), err error) {
	f.cond.L.Lock()
	defer f.cond.L.Unlock()
	f.listener = listener
	f.cond.Signal()
	return f.cancel, nil
}

func (f *fakePubSub) Publish(_ string, _ []byte) error {
	f.t.Fail()
	return nil
}

func (f *fakePubSub) Close() error {
	f.cond.L.Lock()
	defer f.cond.L.Unlock()
	f.closed = true
	f.cond.Signal()
	return nil
}

func (f *fakePubSub) cancel() {
	f.cond.L.Lock()
	defer f.cond.L.Unlock()
	f.canceled = true
	f.cond.Signal()
}
