// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package project_events

import (
	"context"
	"sync"
	"testing"
	"time"

	project_model "code.gitea.io/gitea/models/project"
	"code.gitea.io/gitea/modules/eventsource"
	"code.gitea.io/gitea/modules/json"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// capturedCall is one observed broadcast: the recipient uid set plus the
// constructed Event.
type capturedCall struct {
	uids  []int64
	event *eventsource.Event
}

// installFakes swaps every package-level seam used by publishEvent for
// test doubles: a fake uid lister, a stubbed project lookup that
// returns a synthetic project (no DB hit), an "everyone passes" access
// checker, and a broadcaster that pushes calls onto a buffered channel.
//
// The returned restore func reverts every seam; defer it in the test.
func installFakes(t *testing.T, uids []int64) (<-chan capturedCall, func()) {
	t.Helper()

	calls := make(chan capturedCall, 16)

	origBroadcast := broadcastFn
	origLister := connectedUIDsLister
	origChecker := projectAccessChecker
	origLookup := projectLookup

	broadcastFn = func(uids []int64, event *eventsource.Event) {
		calls <- capturedCall{uids: append([]int64(nil), uids...), event: event}
	}
	connectedUIDsLister = func() []int64 {
		return append([]int64(nil), uids...)
	}
	projectLookup = func(_ context.Context, id int64) (*project_model.Project, error) {
		return &project_model.Project{ID: id}, nil
	}
	projectAccessChecker = func(_ context.Context, _ int64, _ *project_model.Project) (bool, error) {
		return true, nil
	}

	return calls, func() {
		broadcastFn = origBroadcast
		connectedUIDsLister = origLister
		projectAccessChecker = origChecker
		projectLookup = origLookup
	}
}

// awaitCall blocks until one capturedCall arrives or the test deadline
// elapses. It fails the test on timeout.
func awaitCall(t *testing.T, ch <-chan capturedCall) capturedCall {
	t.Helper()
	select {
	case c := <-ch:
		return c
	case <-time.After(2 * time.Second):
		t.Fatal("timed out waiting for broadcast")
		return capturedCall{}
	}
}

func TestEventNameFormat(t *testing.T) {
	assert.Equal(t, "project-board.42", eventName(42))
	assert.Equal(t, "project-board.0", eventName(0))
}

func TestPublishHelpers_NameAndPayload(t *testing.T) {
	cases := []struct {
		name     string
		invoke   func(ctx context.Context)
		wantName string
		wantData any
	}{
		{
			name:     "card.moved",
			wantName: "project-board.10",
			invoke: func(ctx context.Context) {
				PublishCardMoved(ctx, CardMoved{
					ProjectID: 10, IssueID: 7, FromColumnID: 1, ToColumnID: 2, Sorting: 3,
				})
			},
			wantData: CardMoved{ProjectID: 10, IssueID: 7, FromColumnID: 1, ToColumnID: 2, Sorting: 3},
		},
		{
			name:     "card.linked",
			wantName: "project-board.11",
			invoke: func(ctx context.Context) {
				PublishCardLinked(ctx, CardLinked{ProjectID: 11, IssueID: 8, ColumnID: 9})
			},
			wantData: CardLinked{ProjectID: 11, IssueID: 8, ColumnID: 9},
		},
		{
			name:     "card.unlinked",
			wantName: "project-board.12",
			invoke: func(ctx context.Context) {
				PublishCardUnlinked(ctx, CardUnlinked{ProjectID: 12, IssueID: 8})
			},
			wantData: CardUnlinked{ProjectID: 12, IssueID: 8},
		},
		{
			name:     "column.created",
			wantName: "project-board.13",
			invoke: func(ctx context.Context) {
				PublishColumnCreated(ctx, ColumnCreated{
					ProjectID: 13, ColumnID: 5, Title: "Backlog", Color: "#ff0000", Sorting: 0, IsDefault: true,
				})
			},
			wantData: ColumnCreated{
				ProjectID: 13, ColumnID: 5, Title: "Backlog", Color: "#ff0000", Sorting: 0, IsDefault: true,
			},
		},
		{
			name:     "column.updated",
			wantName: "project-board.14",
			invoke: func(ctx context.Context) {
				PublishColumnUpdated(ctx, ColumnUpdated{ProjectID: 14, ColumnID: 5, Title: "Done"})
			},
			wantData: ColumnUpdated{ProjectID: 14, ColumnID: 5, Title: "Done"},
		},
		{
			name:     "column.deleted",
			wantName: "project-board.15",
			invoke: func(ctx context.Context) {
				PublishColumnDeleted(ctx, ColumnDeleted{ProjectID: 15, ColumnID: 5})
			},
			wantData: ColumnDeleted{ProjectID: 15, ColumnID: 5},
		},
		{
			name:     "column.reordered",
			wantName: "project-board.16",
			invoke: func(ctx context.Context) {
				PublishColumnReordered(ctx, ColumnReordered{
					ProjectID: 16,
					Columns: []ColumnSort{
						{ColumnID: 1, Sorting: 0},
						{ColumnID: 2, Sorting: 1},
					},
				})
			},
			wantData: ColumnReordered{
				ProjectID: 16,
				Columns: []ColumnSort{
					{ColumnID: 1, Sorting: 0},
					{ColumnID: 2, Sorting: 1},
				},
			},
		},
		{
			name:     "project.updated",
			wantName: "project-board.17",
			invoke: func(ctx context.Context) {
				PublishProjectUpdated(ctx, ProjectUpdated{
					ProjectID: 17, Title: "T", Description: "D", CardType: "text_only", IsClosed: false,
				})
			},
			wantData: ProjectUpdated{
				ProjectID: 17, Title: "T", Description: "D", CardType: "text_only", IsClosed: false,
			},
		},
		{
			name:     "project.deleted",
			wantName: "project-board.18",
			invoke: func(ctx context.Context) {
				PublishProjectDeleted(ctx, ProjectDeleted{ProjectID: 18})
			},
			wantData: ProjectDeleted{ProjectID: 18},
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			ch, restore := installFakes(t, []int64{1})
			defer restore()

			tc.invoke(context.Background())

			c := awaitCall(t, ch)
			assert.Equal(t, tc.wantName, c.event.Name)
			gotJSON, ok := c.event.Data.([]byte)
			require.True(t, ok, "Event.Data should be []byte")

			wantJSON, err := json.Marshal(tc.wantData)
			require.NoError(t, err)
			assert.JSONEq(t, string(wantJSON), string(gotJSON))
		})
	}
}

// TestSessionTagPropagation verifies that when a publish is invoked
// inside a context decorated by WithSessionTag, the emitted JSON
// payload carries the tag.
func TestSessionTagPropagation(t *testing.T) {
	ch, restore := installFakes(t, []int64{1})
	defer restore()

	ctx := WithSessionTag(context.Background(), "abc-123")
	PublishCardMoved(ctx, CardMoved{
		ProjectID: 99, IssueID: 1, FromColumnID: 1, ToColumnID: 2, Sorting: 0,
	})

	c := awaitCall(t, ch)
	var payload CardMoved
	require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload))
	assert.Equal(t, "abc-123", payload.SessionTag)
}

// TestSessionTagExplicitOverridesContext verifies that an explicit
// SessionTag set on the payload struct is preserved.
func TestSessionTagExplicitOverridesContext(t *testing.T) {
	ch, restore := installFakes(t, []int64{1})
	defer restore()

	ctx := WithSessionTag(context.Background(), "from-ctx")
	PublishCardMoved(ctx, CardMoved{
		ProjectID: 1, IssueID: 1, FromColumnID: 1, ToColumnID: 2, Sorting: 0,
		SessionTag: "explicit",
	})

	c := awaitCall(t, ch)
	var payload CardMoved
	require.NoError(t, json.Unmarshal(c.event.Data.([]byte), &payload))
	assert.Equal(t, "explicit", payload.SessionTag)
}

// TestConnectedUIDsWithProjectAccess_FiltersByPermission ensures the
// helper drops uids the access checker rejects.
func TestConnectedUIDsWithProjectAccess_FiltersByPermission(t *testing.T) {
	origLister := connectedUIDsLister
	origChecker := projectAccessChecker
	origLookup := projectLookup
	defer func() {
		connectedUIDsLister = origLister
		projectAccessChecker = origChecker
		projectLookup = origLookup
	}()

	connectedUIDsLister = func() []int64 { return []int64{1, 2, 3, 4} }
	projectLookup = func(_ context.Context, id int64) (*project_model.Project, error) {
		return &project_model.Project{ID: id}, nil
	}
	allowed := map[int64]bool{1: true, 3: true}
	projectAccessChecker = func(_ context.Context, uid int64, _ *project_model.Project) (bool, error) {
		return allowed[uid], nil
	}

	got := connectedUIDsWithProjectAccess(context.Background(), 42)
	assert.ElementsMatch(t, []int64{1, 3}, got)
}

// TestConnectedUIDsWithProjectAccess_NoConnections shortcuts when no
// users are connected; the project lookup must not be called.
func TestConnectedUIDsWithProjectAccess_NoConnections(t *testing.T) {
	origLister := connectedUIDsLister
	origLookup := projectLookup
	defer func() {
		connectedUIDsLister = origLister
		projectLookup = origLookup
	}()

	connectedUIDsLister = func() []int64 { return nil }
	called := false
	projectLookup = func(_ context.Context, _ int64) (*project_model.Project, error) {
		called = true
		return &project_model.Project{}, nil
	}

	got := connectedUIDsWithProjectAccess(context.Background(), 42)
	assert.Empty(t, got)
	assert.False(t, called, "project lookup should be skipped when no uids are connected")
}

// TestPublishEvent_BroadcastsToAllowedUIDs exercises publishEvent
// directly to verify the uid set computed by the access filter is
// what gets handed to broadcastFn.
func TestPublishEvent_BroadcastsToAllowedUIDs(t *testing.T) {
	origBroadcast := broadcastFn
	origLister := connectedUIDsLister
	origChecker := projectAccessChecker
	origLookup := projectLookup
	defer func() {
		broadcastFn = origBroadcast
		connectedUIDsLister = origLister
		projectAccessChecker = origChecker
		projectLookup = origLookup
	}()

	var mu sync.Mutex
	var got []int64
	broadcastFn = func(uids []int64, _ *eventsource.Event) {
		mu.Lock()
		got = append([]int64(nil), uids...)
		mu.Unlock()
	}
	connectedUIDsLister = func() []int64 { return []int64{10, 20, 30} }
	projectLookup = func(_ context.Context, id int64) (*project_model.Project, error) {
		return &project_model.Project{ID: id}, nil
	}
	projectAccessChecker = func(_ context.Context, uid int64, _ *project_model.Project) (bool, error) {
		return uid != 20, nil
	}

	publishEvent(context.Background(), 1, ColumnDeleted{ProjectID: 1, ColumnID: 5})

	mu.Lock()
	defer mu.Unlock()
	assert.ElementsMatch(t, []int64{10, 30}, got)
}
