// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

package e2etests

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"sync"
	"testing"
	"time"

	"code.vikunja.io/api/pkg/db"
	"code.vikunja.io/api/pkg/events"
	"code.vikunja.io/api/pkg/models"
	"code.vikunja.io/api/pkg/user"

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

// webhookCapture is a test helper that starts an HTTP server to capture webhook payloads.
type webhookCapture struct {
	server   *httptest.Server
	payloads chan webhookDelivery
	mu       sync.Mutex
	received []webhookDelivery
}

type webhookDelivery struct {
	Body    []byte
	Headers http.Header
}

func newWebhookCapture() *webhookCapture {
	wc := &webhookCapture{
		payloads: make(chan webhookDelivery, 10),
	}
	wc.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		body, _ := io.ReadAll(r.Body)
		delivery := webhookDelivery{
			Body:    body,
			Headers: r.Header.Clone(),
		}
		wc.mu.Lock()
		wc.received = append(wc.received, delivery)
		wc.mu.Unlock()
		select {
		case wc.payloads <- delivery:
		default:
		}
		w.WriteHeader(http.StatusOK)
	}))
	return wc
}

func (wc *webhookCapture) URL() string {
	return wc.server.URL
}

func (wc *webhookCapture) Close() {
	wc.server.Close()
}

// waitForPayload waits for a webhook payload to arrive within 10 seconds.
func (wc *webhookCapture) waitForPayload(t *testing.T) webhookDelivery {
	t.Helper()
	select {
	case d := <-wc.payloads:
		return d
	case <-time.After(10 * time.Second):
		t.Fatal("Webhook payload not received within timeout")
		return webhookDelivery{} // unreachable
	}
}

// assertNoPayload asserts that no webhook payload arrives within the given duration.
func (wc *webhookCapture) assertNoPayload(t *testing.T, wait time.Duration) {
	t.Helper()
	select {
	case d := <-wc.payloads:
		t.Fatalf("Expected no webhook payload but received one: %s", string(d.Body))
	case <-time.After(wait):
		// success — nothing arrived
	}
}

// insertUserWebhook creates a user-level webhook (no project_id) in the database.
func insertUserWebhook(t *testing.T, userID int64, targetURL string, evts []string) {
	t.Helper()
	s := db.NewSession()
	defer s.Close()
	_, err := s.Insert(&models.Webhook{
		TargetURL:   targetURL,
		Events:      evts,
		UserID:      userID,
		CreatedByID: userID,
	})
	require.NoError(t, err)
	require.NoError(t, s.Commit())
}

// insertUserWebhookWithSecret creates a user-level webhook with an HMAC secret.
func insertUserWebhookWithSecret(t *testing.T, userID int64, targetURL string, evts []string, secret string) {
	t.Helper()
	s := db.NewSession()
	defer s.Close()
	_, err := s.Insert(&models.Webhook{
		TargetURL:   targetURL,
		Events:      evts,
		UserID:      userID,
		CreatedByID: userID,
		Secret:      secret,
	})
	require.NoError(t, err)
	require.NoError(t, s.Commit())
}

// resetWebhooks reloads all fixtures to start each test from a clean database state.
func resetWebhooks(t *testing.T) {
	t.Helper()
	require.NoError(t, db.LoadFixtures())
}

// parseWebhookPayload parses a webhook delivery body into a structured map.
func parseWebhookPayload(t *testing.T, d webhookDelivery) map[string]interface{} {
	t.Helper()
	var payload map[string]interface{}
	require.NoError(t, json.Unmarshal(d.Body, &payload))
	return payload
}

func TestUserWebhookTaskOverdueE2E(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	_, err := setupE2ETestEnv(ctx)
	require.NoError(t, err)

	capture := newWebhookCapture()
	defer capture.Close()

	resetWebhooks(t)
	insertUserWebhook(t, testuser1.ID, capture.URL(), []string{"task.overdue"})

	// Dispatch a TaskOverdueEvent directly — this simulates the overdue cron job
	err = events.Dispatch(&models.TaskOverdueEvent{
		Task: &models.Task{
			ID:        1,
			Title:     "Overdue task",
			ProjectID: 1,
		},
		User:    &testuser1,
		Project: &models.Project{ID: 1, Title: "Test Project"},
	})
	require.NoError(t, err)

	delivery := capture.waitForPayload(t)
	payload := parseWebhookPayload(t, delivery)

	assert.Equal(t, "task.overdue", payload["event_name"])
	data, ok := payload["data"].(map[string]interface{})
	require.True(t, ok, "payload.data should be a map")
	task, ok := data["task"].(map[string]interface{})
	require.True(t, ok, "payload.data.task should be a map")
	assert.Equal(t, "Overdue task", task["title"])
}

func TestUserWebhookTaskReminderFiredE2E(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	_, err := setupE2ETestEnv(ctx)
	require.NoError(t, err)

	capture := newWebhookCapture()
	defer capture.Close()

	resetWebhooks(t)
	insertUserWebhook(t, testuser1.ID, capture.URL(), []string{"task.reminder.fired"})

	// Dispatch a TaskReminderFiredEvent directly — simulates the reminder cron job
	err = events.Dispatch(&models.TaskReminderFiredEvent{
		Task: &models.Task{
			ID:        1,
			Title:     "Reminder task",
			ProjectID: 1,
		},
		User:    &testuser1,
		Project: &models.Project{ID: 1, Title: "Test Project"},
	})
	require.NoError(t, err)

	delivery := capture.waitForPayload(t)
	payload := parseWebhookPayload(t, delivery)

	assert.Equal(t, "task.reminder.fired", payload["event_name"])
	data, ok := payload["data"].(map[string]interface{})
	require.True(t, ok, "payload.data should be a map")
	task, ok := data["task"].(map[string]interface{})
	require.True(t, ok, "payload.data.task should be a map")
	assert.Equal(t, "Reminder task", task["title"])
}

func TestUserWebhookDoesNotFireForOtherUsers(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	_, err := setupE2ETestEnv(ctx)
	require.NoError(t, err)

	capture := newWebhookCapture()
	defer capture.Close()

	resetWebhooks(t)
	// Create a user-level webhook for user 2
	insertUserWebhook(t, 2, capture.URL(), []string{"task.overdue"})

	// Dispatch an overdue event for user 1 — user 2's webhook should NOT fire
	err = events.Dispatch(&models.TaskOverdueEvent{
		Task: &models.Task{
			ID:        1,
			Title:     "Overdue for user 1",
			ProjectID: 1,
		},
		User:    &testuser1,
		Project: &models.Project{ID: 1, Title: "Test Project"},
	})
	require.NoError(t, err)

	capture.assertNoPayload(t, 3*time.Second)
}

func TestUserWebhookAndProjectWebhookBothFire(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	_, err := setupE2ETestEnv(ctx)
	require.NoError(t, err)

	userCapture := newWebhookCapture()
	defer userCapture.Close()
	projectCapture := newWebhookCapture()
	defer projectCapture.Close()

	resetWebhooks(t)

	// User-level webhook for user 1 listening to task.overdue
	insertUserWebhook(t, testuser1.ID, userCapture.URL(), []string{"task.overdue"})

	// Project-level webhook for project 1 listening to task.overdue
	s := db.NewSession()
	_, err = s.Insert(&models.Webhook{
		TargetURL:   projectCapture.URL(),
		Events:      []string{"task.overdue"},
		ProjectID:   1,
		CreatedByID: 1,
	})
	require.NoError(t, err)
	require.NoError(t, s.Commit())
	s.Close()

	// Dispatch overdue event — both project-level and user-level webhooks should fire
	err = events.Dispatch(&models.TaskOverdueEvent{
		Task: &models.Task{
			ID:        1,
			Title:     "Both webhooks overdue",
			ProjectID: 1,
		},
		User:    &testuser1,
		Project: &models.Project{ID: 1, Title: "Test Project"},
	})
	require.NoError(t, err)

	// Both should receive the payload
	projectDelivery := projectCapture.waitForPayload(t)
	projectPayload := parseWebhookPayload(t, projectDelivery)
	assert.Equal(t, "task.overdue", projectPayload["event_name"])

	userDelivery := userCapture.waitForPayload(t)
	userPayload := parseWebhookPayload(t, userDelivery)
	assert.Equal(t, "task.overdue", userPayload["event_name"])
}

func TestUserWebhookHMACSigning(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	_, err := setupE2ETestEnv(ctx)
	require.NoError(t, err)

	capture := newWebhookCapture()
	defer capture.Close()

	resetWebhooks(t)
	secret := "test-hmac-secret-for-user-webhook"
	insertUserWebhookWithSecret(t, testuser1.ID, capture.URL(), []string{"task.overdue"}, secret)

	err = events.Dispatch(&models.TaskOverdueEvent{
		Task: &models.Task{
			ID:        1,
			Title:     "HMAC overdue",
			ProjectID: 1,
		},
		User:    &testuser1,
		Project: &models.Project{ID: 1, Title: "Test Project"},
	})
	require.NoError(t, err)

	delivery := capture.waitForPayload(t)

	// Verify the HMAC signature header is present and correct
	signature := delivery.Headers.Get("X-Vikunja-Signature")
	require.NotEmpty(t, signature, "X-Vikunja-Signature header should be set")

	mac := hmac.New(sha256.New, []byte(secret))
	_, err = mac.Write(delivery.Body)
	require.NoError(t, err)
	expectedSig := hex.EncodeToString(mac.Sum(nil))
	assert.Equal(t, expectedSig, signature, "HMAC signature should match")
}

func TestUserWebhookOnlyMatchesSubscribedEvents(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	_, err := setupE2ETestEnv(ctx)
	require.NoError(t, err)

	capture := newWebhookCapture()
	defer capture.Close()

	resetWebhooks(t)
	// Subscribe only to task.reminder.fired — task.overdue should NOT trigger it
	insertUserWebhook(t, testuser1.ID, capture.URL(), []string{"task.reminder.fired"})

	err = events.Dispatch(&models.TaskOverdueEvent{
		Task: &models.Task{
			ID:        1,
			Title:     "Wrong event",
			ProjectID: 1,
		},
		User:    &testuser1,
		Project: &models.Project{ID: 1, Title: "Test Project"},
	})
	require.NoError(t, err)

	capture.assertNoPayload(t, 3*time.Second)
}

func TestUserWebhookDoesNotFireForProjectEvents(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	e, err := setupE2ETestEnv(ctx)
	require.NoError(t, err)

	capture := newWebhookCapture()
	defer capture.Close()

	resetWebhooks(t)
	// User-level webhook subscribed to task.updated (a non-user-directed event)
	insertUserWebhook(t, testuser1.ID, capture.URL(), []string{"task.updated"})

	// Update task via the web handler — user-level webhooks should NOT fire
	// for project-scoped events like task.updated
	_, err = testUpdateWithUser(e, t, &testuser1,
		map[string]string{"projecttask": "1"},
		`{"title":"Should not trigger user webhook"}`,
	)
	require.NoError(t, err)

	capture.assertNoPayload(t, 3*time.Second)
}

func TestUserWebhookTasksOverdueBatchE2E(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	_, err := setupE2ETestEnv(ctx)
	require.NoError(t, err)

	capture := newWebhookCapture()
	defer capture.Close()

	resetWebhooks(t)
	insertUserWebhook(t, testuser1.ID, capture.URL(), []string{"tasks.overdue"})

	// Dispatch a batch TasksOverdueEvent
	err = events.Dispatch(&models.TasksOverdueEvent{
		Tasks: []*models.Task{
			{ID: 1, Title: "Overdue 1", ProjectID: 1},
			{ID: 2, Title: "Overdue 2", ProjectID: 1},
		},
		User: &user.User{ID: testuser1.ID, Username: testuser1.Username},
		Projects: map[int64]*models.Project{
			1: {ID: 1, Title: "Test Project"},
		},
	})
	require.NoError(t, err)

	delivery := capture.waitForPayload(t)
	payload := parseWebhookPayload(t, delivery)

	assert.Equal(t, "tasks.overdue", payload["event_name"])
	data, ok := payload["data"].(map[string]interface{})
	require.True(t, ok, "payload.data should be a map")
	tasks, ok := data["tasks"].([]interface{})
	require.True(t, ok, "payload.data.tasks should be an array")
	assert.Len(t, tasks, 2)
}
