//go:build integration

package sync

import (
	"context"
	"io"
	"log/slog"
	"testing"

	"go.mongodb.org/mongo-driver/v2/bson"

	"bountyboard/internal/domain"
	"bountyboard/internal/files"
	"bountyboard/internal/metrics"
	"bountyboard/internal/store"
	"bountyboard/internal/testutil"
	"bountyboard/internal/ulid"
)

func newSyncStack(t *testing.T) (*Manager, *store.Store, *domain.Customer, *domain.User) {
	t.Helper()
	db := testutil.MongoDB(t)
	if err := store.EnsureIndexes(t.Context(), db); err != nil {
		t.Fatal(err)
	}
	st := &store.Store{Client: db.Client(), DB: db}

	consultant := &domain.User{
		ID: ulid.New(), Email: "cons@example.com", Name: "Cons",
		Roles: domain.Roles{Consultant: true},
	}
	if err := st.CreateUser(t.Context(), consultant); err != nil {
		t.Fatal(err)
	}
	customer := &domain.Customer{
		Name: "Demo Co",
		Ticketing: domain.Ticketing{
			Type: domain.TicketingDemo, ProjectKey: "DEMO", PollIntervalSec: 60,
		},
		ConsultantIDs: []string{consultant.ID},
		DefaultBudget: 1500,
	}
	if err := st.CreateCustomer(t.Context(), customer); err != nil {
		t.Fatal(err)
	}

	log := slog.New(slog.NewTextHandler(io.Discard, nil))
	m := NewManager(st, files.NewStore(db, 25), log, metrics.NewRegistry(),
		func(*domain.Customer) (Credentials, error) { return Credentials{}, nil }, nil)
	return m, st, customer, consultant
}

func countTasks(t *testing.T, st *store.Store, filter bson.M) int64 {
	t.Helper()
	n, err := st.DB.Collection("tasks").CountDocuments(t.Context(), filter)
	if err != nil {
		t.Fatal(err)
	}
	return n
}

func TestDemoSyncImportsAndIsIdempotent(t *testing.T) {
	m, st, customer, consultant := newSyncStack(t)
	ctx := context.Background()

	if err := m.SyncCustomer(ctx, customer); err != nil {
		t.Fatalf("first sync: %v", err)
	}
	if n := countTasks(t, st, bson.M{"customerId": customer.ID}); n != 5 {
		t.Fatalf("after first sync: %d tasks, want 5", n)
	}

	// idempotent: same tickets re-fetched (overlap window) don't duplicate
	for i := 0; i < 3; i++ {
		if err := m.SyncCustomer(ctx, customer); err != nil {
			t.Fatalf("re-sync %d: %v", i, err)
		}
	}
	if n := countTasks(t, st, bson.M{"customerId": customer.ID}); n != 5 {
		t.Fatalf("after re-syncs: %d tasks, want 5", n)
	}

	// imported tasks carry the right shape
	tasks, err := st.ListTasks(ctx, store.TaskFilter{CustomerID: customer.ID})
	if err != nil {
		t.Fatal(err)
	}
	for _, task := range tasks {
		if task.Status != domain.StatusImported || task.Origin != domain.OriginImported {
			t.Errorf("task %s: status=%s origin=%s", task.ID, task.Status, task.Origin)
		}
		if task.ConsultantID != consultant.ID || task.RootID != task.ID {
			t.Errorf("task %s: consultant=%s root=%s", task.ID, task.ConsultantID, task.RootID)
		}
		if task.Budget != 1500 {
			t.Errorf("task %s: budget %v, want customer default 1500", task.ID, task.Budget)
		}
		if task.External == nil || task.External.System != "demo" {
			t.Errorf("task %s: external %+v", task.ID, task.External)
		}
	}

	// consultant got import notifications (5, once each)
	notifs, err := st.ListNotifications(ctx, consultant.ID, false, "", 100)
	if err != nil {
		t.Fatal(err)
	}
	imported := 0
	for _, n := range notifs {
		if n.Kind == "ticket_imported" {
			imported++
		}
	}
	if imported != 5 {
		t.Fatalf("import notifications = %d, want 5", imported)
	}
}

func TestSyncPreservesWorkAfterAtomization(t *testing.T) {
	m, st, customer, _ := newSyncStack(t)
	ctx := context.Background()
	if err := m.SyncCustomer(ctx, customer); err != nil {
		t.Fatal(err)
	}
	tasks, _ := st.ListTasks(ctx, store.TaskFilter{CustomerID: customer.ID})
	target := tasks[0]

	// simulate atomization started + consultant edits
	if _, err := st.DB.Collection("tasks").UpdateOne(ctx, bson.M{"_id": target.ID},
		bson.M{"$set": bson.M{
			"status": domain.StatusAtomized, "title": "Consultant Edited Title",
			"external.contentHash": "different-upstream-hash",
		}}); err != nil {
		t.Fatal(err)
	}

	if err := m.SyncCustomer(ctx, customer); err != nil {
		t.Fatal(err)
	}
	fresh, err := st.TaskByID(ctx, target.ID)
	if err != nil {
		t.Fatal(err)
	}
	if fresh.Title != "Consultant Edited Title" {
		t.Fatalf("title clobbered after atomization: %q", fresh.Title)
	}
	var sawUpstreamChange bool
	for _, e := range fresh.Timeline {
		if e.Event == "upstream_changed" {
			sawUpstreamChange = true
		}
	}
	if !sawUpstreamChange {
		t.Fatal("timeline missing upstream_changed entry")
	}
}

func TestOrphanFlagging(t *testing.T) {
	m, st, customer, consultant := newSyncStack(t)
	ctx := context.Background()
	if err := m.SyncCustomer(ctx, customer); err != nil {
		t.Fatal(err)
	}

	// flip the demo project key to the orphan variant: DEMO-5 disappears
	if _, err := st.DB.Collection("customers").UpdateOne(ctx, bson.M{"_id": customer.ID},
		bson.M{"$set": bson.M{"ticketing.projectKey": "DEMO-ORPHAN"}}); err != nil {
		t.Fatal(err)
	}
	fresh, err := st.CustomerByID(ctx, customer.ID)
	if err != nil {
		t.Fatal(err)
	}
	if err := m.SyncCustomer(ctx, fresh); err != nil {
		t.Fatal(err)
	}

	orphaned := countTasks(t, st, bson.M{"customerId": customer.ID, "external.orphaned": true})
	if orphaned != 1 {
		t.Fatalf("orphaned = %d, want 1", orphaned)
	}
	// the task is NOT deleted (§5.3)
	if n := countTasks(t, st, bson.M{"customerId": customer.ID}); n != 5 {
		t.Fatalf("tasks after orphan = %d, want 5 (never deleted)", n)
	}

	// consultant notified once, and re-sync doesn't duplicate the flagging
	if err := m.SyncCustomer(ctx, fresh); err != nil {
		t.Fatal(err)
	}
	notifs, _ := st.ListNotifications(ctx, consultant.ID, false, "", 100)
	orphanNotifs := 0
	for _, n := range notifs {
		if n.Kind == "ticket_orphaned" {
			orphanNotifs++
		}
	}
	if orphanNotifs != 1 {
		t.Fatalf("orphan notifications = %d, want exactly 1", orphanNotifs)
	}
}

// TestTicketingIdentityBSONRoundTrip pins a live-found bug: the driver
// decodes nested extra documents as bson.D (not map[string]any), which used
// to make identity lookups silently return "".
func TestTicketingIdentityBSONRoundTrip(t *testing.T) {
	_, st, _, consultant := newSyncStack(t)
	ctx := context.Background()

	if _, err := st.DB.Collection("users").UpdateOne(ctx,
		bson.M{"_id": consultant.ID},
		bson.M{"$set": bson.M{"extra": bson.M{"ticketingIdentities": bson.M{
			"jira": "cons@corp.example", "wekan": "cons-wekan",
		}}}}); err != nil {
		t.Fatal(err)
	}
	fresh, err := st.UserByID(ctx, consultant.ID)
	if err != nil {
		t.Fatal(err)
	}
	if got := ticketingIdentity(fresh, domain.TicketingJira); got != "cons@corp.example" {
		t.Fatalf("jira identity after BSON round trip = %q", got)
	}
	if got := ticketingIdentity(fresh, domain.TicketingWekan); got != "cons-wekan" {
		t.Fatalf("wekan identity after BSON round trip = %q", got)
	}
	if got := ticketingIdentity(fresh, domain.TicketingAzure); got != "" {
		t.Fatalf("unset identity = %q, want empty", got)
	}
}

func TestSyncSkipsConsultantWithoutIdentity(t *testing.T) {
	_, st, customer, _ := newSyncStack(t)
	ctx := context.Background()

	// switch to jira: consultant has no jira identity in extra
	if _, err := st.DB.Collection("customers").UpdateOne(ctx, bson.M{"_id": customer.ID},
		bson.M{"$set": bson.M{"ticketing.type": "jira", "ticketing.baseUrl": "https://nope.invalid"}}); err != nil {
		t.Fatal(err)
	}
	fresh, _ := st.CustomerByID(ctx, customer.ID)

	log := slog.New(slog.NewTextHandler(io.Discard, nil))
	m := NewManager(st, files.NewStore(st.DB, 25), log, metrics.NewRegistry(),
		func(*domain.Customer) (Credentials, error) {
			return Credentials{"email": "c@x.y", "apiToken": "token"}, nil
		}, nil)

	// must not error: the consultant is skipped, nothing is ever fetched
	if err := m.SyncCustomer(ctx, fresh); err != nil {
		t.Fatalf("sync with missing identity should be a no-op, got %v", err)
	}
	if n := countTasks(t, st, bson.M{"customerId": customer.ID}); n != 0 {
		t.Fatalf("tasks = %d, want 0", n)
	}
}
