package services

import (
	"context"
	"os"
	"testing"
	"time"

	"github.com/google/uuid"
	"github.com/jmoiron/sqlx"
	_ "github.com/lib/pq"

	"mgit.msbls.de/m/paliad/internal/db"
)

// TestProjectFilter_IncludesDescendants pins the t-paliad-124 contract: when
// the caller filters a list/summary by a Project UUID, the result includes
// rows attached to that Project AND every descendant Project (Litigation,
// Patent, Case below it). The descendant set is derived from
// paliad.projects.path, which the schema's path trigger keeps in sync from
// parent_id.
//
// t-paliad-139 extends the contract to the per-project narrow read paths
// that the /projects/{id} detail surfaces use:
//   - DeadlineService.ListForProject
//   - AppointmentService.ListForProject
//   - ProjectService.ListEvents (audit / Verlauf)
// All three default to subtree aggregation (directOnly=false). When
// directOnly=true, only rows with project_id == filter are returned.
//
// The check is exercised against:
//   - DeadlineService.ListVisibleForUser (union, t-124)
//   - DeadlineService.SummaryCounts (union, t-124)
//   - AppointmentService.ListVisibleForUser (union, t-124)
//   - EventService.ListVisibleForUser (union, t-124)
//   - DeadlineService.ListForProject (per-project narrow, t-139)
//   - AppointmentService.ListForProject (per-project narrow, t-139)
//   - ProjectService.ListEvents (per-project narrow, t-139)
//
// Skipped when TEST_DATABASE_URL is unset.
func TestProjectFilter_IncludesDescendants(t *testing.T) {
	url := os.Getenv("TEST_DATABASE_URL")
	if url == "" {
		t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
	}
	if err := db.ApplyMigrations(url); err != nil {
		t.Fatalf("apply migrations: %v", err)
	}
	pool, err := sqlx.Connect("postgres", url)
	if err != nil {
		t.Fatalf("connect: %v", err)
	}
	defer pool.Close()

	ctx := context.Background()

	adminID := uuid.New()
	clientID := uuid.New()     // root: Client
	litigationID := uuid.New() // child of Client: Litigation
	caseID := uuid.New()       // child of Litigation: Case

	// Six items: deadline + appointment at each of the three levels.
	dClient := uuid.New()
	dLitigation := uuid.New()
	dCase := uuid.New()
	aClient := uuid.New()
	aLitigation := uuid.New()
	aCase := uuid.New()

	cleanup := func() {
		pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE id IN ($1, $2, $3)`, aClient, aLitigation, aCase)
		pool.ExecContext(ctx, `DELETE FROM paliad.deadlines    WHERE id IN ($1, $2, $3)`, dClient, dLitigation, dCase)
		// project_events / project_teams cleanup: hit each project_id.
		for _, pid := range []uuid.UUID{caseID, litigationID, clientID} {
			pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id = $1`, pid)
			pool.ExecContext(ctx, `DELETE FROM paliad.project_teams  WHERE project_id = $1`, pid)
		}
		// Delete bottom-up so the parent_id FK doesn't bite.
		pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, caseID)
		pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, litigationID)
		pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, clientID)
		pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, adminID)
		pool.ExecContext(ctx, `DELETE FROM auth.users  WHERE id = $1`, adminID)
	}
	cleanup()
	defer cleanup()

	if _, err := pool.ExecContext(ctx,
		`INSERT INTO auth.users (id, email) VALUES ($1, $2)`,
		adminID, "filter-desc@hlc.com"); err != nil {
		t.Fatalf("seed auth.users: %v", err)
	}
	if _, err := pool.ExecContext(ctx,
		`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
		 VALUES ($1, $2, 'Filter Desc', 'munich', 'global_admin', 'de')`,
		adminID, "filter-desc@hlc.com"); err != nil {
		t.Fatalf("seed paliad.users: %v", err)
	}

	// Build the Client → Litigation → Case tree. The BEFORE-INSERT path
	// trigger fills the materialised path from parent_id; we still have to
	// supply a non-null placeholder because path is NOT NULL — the trigger
	// then overwrites it. Passing the row's own id as the placeholder
	// matches the existing test convention (see deadline_service_test).
	for _, p := range []struct {
		id        uuid.UUID
		typ       string
		parent    *uuid.UUID
		title     string
		reference string
	}{
		{clientID, "client", nil, "Acme Corp", "2026/9001"},
		{litigationID, "litigation", &clientID, "Acme v. Foo", "2026/9002"},
		{caseID, "case", &litigationID, "EP1234567 B1", "2026/9003"},
	} {
		// path is overwritten by the BEFORE-INSERT trigger; pass the row's id
		// stringified just to satisfy the NOT NULL column. parent_id is nullable
		// uuid; passing a typed *uuid.UUID lets lib/pq encode NULL correctly.
		var parent any
		if p.parent != nil {
			parent = *p.parent
		}
		if _, err := pool.ExecContext(ctx,
			`INSERT INTO paliad.projects (id, type, parent_id, path, title, reference, status, created_by)
			 VALUES ($1, $2, $3, $4, $5, $6, 'active', $7)`,
			p.id, p.typ, parent, p.id.String(), p.title, p.reference, adminID); err != nil {
			t.Fatalf("seed paliad.projects %s: %v", p.id, err)
		}
	}

	// Sanity-check the path trigger built the tree correctly.
	var paths struct {
		Client     string `db:"client_path"`
		Litigation string `db:"lit_path"`
		Case       string `db:"case_path"`
	}
	if err := pool.GetContext(ctx, &paths, `
		SELECT
		  (SELECT path FROM paliad.projects WHERE id = $1) AS client_path,
		  (SELECT path FROM paliad.projects WHERE id = $2) AS lit_path,
		  (SELECT path FROM paliad.projects WHERE id = $3) AS case_path`,
		clientID, litigationID, caseID); err != nil {
		t.Fatalf("read paths: %v", err)
	}
	if paths.Client != clientID.String() {
		t.Fatalf("client.path = %q, want %q", paths.Client, clientID.String())
	}
	wantLit := clientID.String() + "." + litigationID.String()
	if paths.Litigation != wantLit {
		t.Fatalf("litigation.path = %q, want %q", paths.Litigation, wantLit)
	}
	wantCase := wantLit + "." + caseID.String()
	if paths.Case != wantCase {
		t.Fatalf("case.path = %q, want %q", paths.Case, wantCase)
	}

	// Seed one deadline + one appointment at each level. Use a date well in
	// the future so all of them land in a single SummaryCounts bucket and
	// the math is unambiguous regardless of when the test runs.
	now := time.Now().UTC()
	due := now.AddDate(0, 6, 0).Truncate(24 * time.Hour)
	startAt := now.AddDate(0, 6, 0)
	for _, d := range []struct {
		id  uuid.UUID
		pid uuid.UUID
	}{
		{dClient, clientID},
		{dLitigation, litigationID},
		{dCase, caseID},
	} {
		if _, err := pool.ExecContext(ctx,
			`INSERT INTO paliad.deadlines
			   (id, project_id, title, due_date, source, status, created_by)
			 VALUES ($1, $2, 'D', $3::date, 'manual', 'pending', $4)`,
			d.id, d.pid, due.Format("2006-01-02"), adminID); err != nil {
			t.Fatalf("seed deadline %s: %v", d.id, err)
		}
	}
	for _, a := range []struct {
		id  uuid.UUID
		pid uuid.UUID
	}{
		{aClient, clientID},
		{aLitigation, litigationID},
		{aCase, caseID},
	} {
		if _, err := pool.ExecContext(ctx,
			`INSERT INTO paliad.appointments
			   (id, project_id, title, start_at, appointment_type, created_by)
			 VALUES ($1, $2, 'A', $3, 'meeting', $4)`,
			a.id, a.pid, startAt, adminID); err != nil {
			t.Fatalf("seed appointment %s: %v", a.id, err)
		}
	}

	users := NewUserService(pool)
	projects := NewProjectService(pool, users)
	eventTypes := NewEventTypeService(pool, users)
	deadlines := NewDeadlineService(pool, projects, eventTypes)
	appointments := NewAppointmentService(pool, projects)
	events := NewEventService(pool, deadlines, appointments)

	// countSeed counts how many of our six seed UUIDs appear in `ids`.
	// Filtering to seed UUIDs avoids interference from any other rows the
	// shared dev DB might carry.
	seedDeadlines := map[uuid.UUID]bool{dClient: true, dLitigation: true, dCase: true}
	seedAppointments := map[uuid.UUID]bool{aClient: true, aLitigation: true, aCase: true}

	type expect struct {
		name           string
		filter         uuid.UUID
		wantDeadlines  int
		wantAppointmts int
	}
	cases := []expect{
		{"filter Client → all 3 levels", clientID, 3, 3},
		{"filter Litigation → Litigation + Case", litigationID, 2, 2},
		{"filter Case → just Case", caseID, 1, 1},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			pid := c.filter

			// DeadlineService.ListVisibleForUser
			drows, err := deadlines.ListVisibleForUser(ctx, adminID, ListFilter{ProjectID: &pid})
			if err != nil {
				t.Fatalf("deadlines.ListVisibleForUser: %v", err)
			}
			gotD := 0
			for _, r := range drows {
				if seedDeadlines[r.ID] {
					gotD++
				}
			}
			if gotD != c.wantDeadlines {
				t.Errorf("deadlines.ListVisibleForUser: got %d seed rows, want %d", gotD, c.wantDeadlines)
			}

			// DeadlineService.SummaryCounts
			summary, err := deadlines.SummaryCounts(ctx, adminID, &pid)
			if err != nil {
				t.Fatalf("deadlines.SummaryCounts: %v", err)
			}
			// All seed deadlines are pending and far-future, so they all land
			// in Total but in none of the time buckets we constrain here. We
			// can't assert Total exactly (other rows may exist in the dev DB
			// for the same project tree), so assert "at least N".
			if summary.Total < c.wantDeadlines {
				t.Errorf("deadlines.SummaryCounts.Total = %d, want >= %d", summary.Total, c.wantDeadlines)
			}

			// AppointmentService.ListVisibleForUser
			arows, err := appointments.ListVisibleForUser(ctx, adminID, AppointmentListFilter{ProjectID: &pid})
			if err != nil {
				t.Fatalf("appointments.ListVisibleForUser: %v", err)
			}
			gotA := 0
			for _, r := range arows {
				if seedAppointments[r.ID] {
					gotA++
				}
			}
			if gotA != c.wantAppointmts {
				t.Errorf("appointments.ListVisibleForUser: got %d seed rows, want %d", gotA, c.wantAppointmts)
			}

			// EventService.ListVisibleForUser (union shape — same filter UUID).
			erows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{
				Type:      EventTypeAll,
				ProjectID: &pid,
			})
			if err != nil {
				t.Fatalf("events.ListVisibleForUser: %v", err)
			}
			gotED, gotEA := 0, 0
			for _, r := range erows {
				if seedDeadlines[r.ID] {
					gotED++
				}
				if seedAppointments[r.ID] {
					gotEA++
				}
			}
			if gotED != c.wantDeadlines {
				t.Errorf("events.ListVisibleForUser deadlines: got %d, want %d", gotED, c.wantDeadlines)
			}
			if gotEA != c.wantAppointmts {
				t.Errorf("events.ListVisibleForUser appointments: got %d, want %d", gotEA, c.wantAppointmts)
			}

			// t-paliad-152: DirectOnly=true on the union endpoint must
			// collapse both rails to "this project only" — backs the
			// /projects/{id} Fristen + Termine "direkt" toggle.
			erowsDirect, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{
				Type:       EventTypeAll,
				ProjectID:  &pid,
				DirectOnly: true,
			})
			if err != nil {
				t.Fatalf("events.ListVisibleForUser DirectOnly: %v", err)
			}
			gotEDDirect, gotEADirect := 0, 0
			for _, r := range erowsDirect {
				if seedDeadlines[r.ID] {
					gotEDDirect++
				}
				if seedAppointments[r.ID] {
					gotEADirect++
				}
			}
			if gotEDDirect != 1 {
				t.Errorf("events.ListVisibleForUser DirectOnly deadlines: got %d, want 1", gotEDDirect)
			}
			if gotEADirect != 1 {
				t.Errorf("events.ListVisibleForUser DirectOnly appointments: got %d, want 1", gotEADirect)
			}

			// DeadlineService.ListVisibleForUser — DirectOnly via ListFilter
			// directly, mirroring the EventService path.
			drowsDirect, err := deadlines.ListVisibleForUser(ctx, adminID, ListFilter{
				ProjectID:  &pid,
				DirectOnly: true,
			})
			if err != nil {
				t.Fatalf("deadlines.ListVisibleForUser DirectOnly: %v", err)
			}
			gotDDirect := 0
			for _, r := range drowsDirect {
				if seedDeadlines[r.ID] {
					gotDDirect++
				}
			}
			if gotDDirect != 1 {
				t.Errorf("deadlines.ListVisibleForUser DirectOnly: got %d, want 1", gotDDirect)
			}

			// AppointmentService.ListVisibleForUser — DirectOnly.
			arowsDirect, err := appointments.ListVisibleForUser(ctx, adminID, AppointmentListFilter{
				ProjectID:  &pid,
				DirectOnly: true,
			})
			if err != nil {
				t.Fatalf("appointments.ListVisibleForUser DirectOnly: %v", err)
			}
			gotADirect := 0
			for _, r := range arowsDirect {
				if seedAppointments[r.ID] {
					gotADirect++
				}
			}
			if gotADirect != 1 {
				t.Errorf("appointments.ListVisibleForUser DirectOnly: got %d, want 1", gotADirect)
			}

			// t-paliad-139: per-project narrow paths must match the union path
			// when directOnly=false (subtree default), and must collapse to
			// just the direct row when directOnly=true.

			// DeadlineService.ListForProject — subtree (default).
			dlfp, err := deadlines.ListForProject(ctx, adminID, pid, false)
			if err != nil {
				t.Fatalf("deadlines.ListForProject subtree: %v", err)
			}
			gotDFP := 0
			for _, r := range dlfp {
				if seedDeadlines[r.ID] {
					gotDFP++
				}
			}
			if gotDFP != c.wantDeadlines {
				t.Errorf("deadlines.ListForProject subtree: got %d, want %d", gotDFP, c.wantDeadlines)
			}

			// DeadlineService.ListForProject — directOnly=true.
			dlfpDirect, err := deadlines.ListForProject(ctx, adminID, pid, true)
			if err != nil {
				t.Fatalf("deadlines.ListForProject direct: %v", err)
			}
			gotDFPDirect := 0
			for _, r := range dlfpDirect {
				if seedDeadlines[r.ID] {
					gotDFPDirect++
				}
			}
			// directOnly: only the deadline whose project_id == filter is
			// returned (the seeded direct-row at this level).
			if gotDFPDirect != 1 {
				t.Errorf("deadlines.ListForProject directOnly: got %d, want 1", gotDFPDirect)
			}

			// AppointmentService.ListForProject — subtree (default).
			alfp, err := appointments.ListForProject(ctx, adminID, pid, false)
			if err != nil {
				t.Fatalf("appointments.ListForProject subtree: %v", err)
			}
			gotAFP := 0
			for _, r := range alfp {
				if seedAppointments[r.ID] {
					gotAFP++
				}
			}
			if gotAFP != c.wantAppointmts {
				t.Errorf("appointments.ListForProject subtree: got %d, want %d", gotAFP, c.wantAppointmts)
			}

			// AppointmentService.ListForProject — directOnly=true.
			alfpDirect, err := appointments.ListForProject(ctx, adminID, pid, true)
			if err != nil {
				t.Fatalf("appointments.ListForProject direct: %v", err)
			}
			gotAFPDirect := 0
			for _, r := range alfpDirect {
				if seedAppointments[r.ID] {
					gotAFPDirect++
				}
			}
			if gotAFPDirect != 1 {
				t.Errorf("appointments.ListForProject directOnly: got %d, want 1", gotAFPDirect)
			}
		})
	}
}
