package services

import (
	"context"
	"encoding/json"
	"errors"
	"os"
	"testing"
	"time"

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

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

// TestScenarioBuilderService exercises the t-paliad-340 / m/paliad#153 B0
// surface end-to-end against a live DB: create + list + deep-get + patch
// + add-proceeding + add-event + add/delete-share, plus the visibility
// negative case (a non-owner can't see the scenario unless shared).
//
// Skipped without TEST_DATABASE_URL — matches the pattern in
// project_service_test.go / event_choice_service_test.go.
func TestScenarioBuilderService(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()

	owner := uuid.New()
	other := uuid.New()
	cleanup := func() {
		// Cascade order: delete from scenarios → CASCADE clears
		// proceedings, events, shares. Then the two users.
		pool.ExecContext(ctx,
			`DELETE FROM paliad.scenarios WHERE owner_id IN ($1, $2)`, owner, other)
		pool.ExecContext(ctx,
			`DELETE FROM paliad.users WHERE id IN ($1, $2)`, owner, other)
		pool.ExecContext(ctx,
			`DELETE FROM auth.users WHERE id IN ($1, $2)`, owner, other)
	}
	cleanup()
	defer cleanup()

	for _, seed := range []struct {
		id    uuid.UUID
		email string
		name  string
	}{
		{owner, "builder-owner-test@hlc.com", "Builder Owner"},
		{other, "builder-other-test@hlc.com", "Builder Other"},
	} {
		if _, err := pool.ExecContext(ctx,
			`INSERT INTO auth.users (id, email) VALUES ($1, $2)`,
			seed.id, seed.email); err != nil {
			t.Fatalf("seed auth.users %s: %v", seed.email, err)
		}
		if _, err := pool.ExecContext(ctx,
			`INSERT INTO paliad.users (id, email, display_name, office, lang)
			 VALUES ($1, $2, $3, 'munich', 'de')`,
			seed.id, seed.email, seed.name); err != nil {
			t.Fatalf("seed paliad.users %s: %v", seed.email, err)
		}
	}

	// Pick a real proceeding_type_id so the FK insert succeeds.
	var ptID int
	if err := pool.GetContext(ctx, &ptID,
		`SELECT id FROM paliad.proceeding_types
		  WHERE code = $1 AND is_active = true
		  LIMIT 1`, CodeUPCInfringement); err != nil {
		t.Fatalf("look up upc.inf id: %v", err)
	}

	svc := NewScenarioBuilderService(pool, nil, nil, nil)

	// 1. Create a scenario for the owner. Empty name should default.
	sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
	if err != nil {
		t.Fatalf("CreateScenario: %v", err)
	}
	if sc.Name != "Unbenanntes Szenario" {
		t.Errorf("default name = %q, want %q", sc.Name, "Unbenanntes Szenario")
	}
	if sc.Status != "active" {
		t.Errorf("default status = %q, want active", sc.Status)
	}
	if sc.OwnerID == nil || *sc.OwnerID != owner {
		t.Errorf("owner_id = %v, want %v", sc.OwnerID, owner)
	}

	// 2. List — should return the one row.
	list, err := svc.ListMyScenarios(ctx, owner, "active")
	if err != nil {
		t.Fatalf("ListMyScenarios: %v", err)
	}
	if len(list) != 1 || list[0].ID != sc.ID {
		t.Errorf("ListMyScenarios returned %d rows; want 1 with id %s", len(list), sc.ID)
	}

	// 3. Other user can NOT see the scenario.
	if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); !errors.Is(err, ErrScenarioBuilderNotVisible) {
		t.Errorf("GetScenarioDeep by non-owner = %v, want ErrScenarioBuilderNotVisible", err)
	}

	// 4. Add a proceeding.
	pr, err := svc.AddProceeding(ctx, owner, sc.ID, AddProceedingInput{
		ProceedingTypeID: ptID,
		PrimaryParty:     ptrString("defendant"),
		ScenarioFlags:    json.RawMessage(`{"with_ccr": true}`),
	})
	if err != nil {
		t.Fatalf("AddProceeding: %v", err)
	}
	if pr.ProceedingTypeID != ptID {
		t.Errorf("ProceedingTypeID = %d, want %d", pr.ProceedingTypeID, ptID)
	}
	if pr.PrimaryParty == nil || *pr.PrimaryParty != "defendant" {
		t.Errorf("PrimaryParty = %v, want defendant", pr.PrimaryParty)
	}

	// 5. Add a custom-label event card.
	ev, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
		CustomLabel: ptrString("Klageerwiderung"),
		State:       ptrString("planned"),
	})
	if err != nil {
		t.Fatalf("AddEvent: %v", err)
	}
	if ev.State != "planned" {
		t.Errorf("event state = %q, want planned", ev.State)
	}

	// 5b. Add-event with NO anchor fields fails.
	if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{}); !errors.Is(err, ErrInvalidInput) {
		t.Errorf("AddEvent without anchor = %v, want ErrInvalidInput", err)
	}

	// 6. Deep get — should bundle the scenario + 1 proceeding + 1 event + 0 shares.
	deep, err := svc.GetScenarioDeep(ctx, owner, sc.ID)
	if err != nil {
		t.Fatalf("GetScenarioDeep: %v", err)
	}
	if len(deep.Proceedings) != 1 || deep.Proceedings[0].ID != pr.ID {
		t.Errorf("deep proceedings count=%d want 1; ids: %+v", len(deep.Proceedings), deep.Proceedings)
	}
	if len(deep.Events) != 1 || deep.Events[0].ID != ev.ID {
		t.Errorf("deep events count=%d want 1; ids: %+v", len(deep.Events), deep.Events)
	}
	if len(deep.Shares) != 0 {
		t.Errorf("deep shares count=%d want 0", len(deep.Shares))
	}

	// 7. Share with `other`. Recipient should now see the scenario.
	sh, err := svc.AddShare(ctx, owner, sc.ID, other)
	if err != nil {
		t.Fatalf("AddShare: %v", err)
	}
	if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); err != nil {
		t.Errorf("GetScenarioDeep by share recipient: %v", err)
	}
	// But the recipient can NOT add proceedings.
	if _, err := svc.AddProceeding(ctx, other, sc.ID, AddProceedingInput{
		ProceedingTypeID: ptID,
	}); !errors.Is(err, ErrScenarioBuilderNotVisible) {
		t.Errorf("AddProceeding by share recipient = %v, want ErrScenarioBuilderNotVisible", err)
	}

	// 7b. Self-share should be rejected.
	if _, err := svc.AddShare(ctx, owner, sc.ID, owner); !errors.Is(err, ErrInvalidInput) {
		t.Errorf("self-share = %v, want ErrInvalidInput", err)
	}

	// 8. Patch — archive then re-activate.
	patched, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
		Status: ptrString("archived"),
	})
	if err != nil {
		t.Fatalf("PatchScenario archive: %v", err)
	}
	if patched.Status != "archived" {
		t.Errorf("after archive, status = %q, want archived", patched.Status)
	}
	// PATCH to 'promoted' is rejected — that's the wizard's job.
	if _, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
		Status: ptrString("promoted"),
	}); !errors.Is(err, ErrInvalidInput) {
		t.Errorf("PATCH status=promoted = %v, want ErrInvalidInput", err)
	}
	patched, err = svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
		Status: ptrString("active"),
	})
	if err != nil {
		t.Fatalf("PatchScenario re-activate: %v", err)
	}
	if patched.Status != "active" {
		t.Errorf("after re-activate, status = %q, want active", patched.Status)
	}

	// 9. Revoke the share. Recipient loses visibility.
	if err := svc.DeleteShare(ctx, owner, sc.ID, sh.ID); err != nil {
		t.Fatalf("DeleteShare: %v", err)
	}
	if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); !errors.Is(err, ErrScenarioBuilderNotVisible) {
		t.Errorf("after revoke, recipient GetScenarioDeep = %v, want ErrScenarioBuilderNotVisible", err)
	}
}

// TestScenarioBuilderAkteDualWrite pins B4's load-bearing contract
// (m/paliad#153 / t-paliad-347 / PRD §2.3 + §10):
//
//   - PatchProceeding on a project-backed scenario (origin_project_id
//     IS NOT NULL) MUST mirror scenario_flags onto
//     paliad.projects.scenario_flags;
//   - PatchEvent flipping state→'filed' on a project-backed scenario
//     MUST upsert a paliad.deadlines row (status='completed',
//     completed_at=actual_date);
//   - PatchProceeding/PatchEvent on a non-Akte (kontextfrei) scenario
//     MUST NOT touch paliad.projects.scenario_flags or
//     paliad.deadlines.
//
// Skipped without TEST_DATABASE_URL. Mirrors the live-DB pattern used
// by TestScenarioBuilderService above.
func TestScenarioBuilderAkteDualWrite(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()

	owner := uuid.New()
	cleanup := func() {
		pool.ExecContext(ctx,
			`DELETE FROM paliad.scenarios WHERE owner_id = $1`, owner)
		pool.ExecContext(ctx,
			`DELETE FROM paliad.projects WHERE created_by = $1`, owner)
		pool.ExecContext(ctx,
			`DELETE FROM paliad.users WHERE id = $1`, owner)
		pool.ExecContext(ctx,
			`DELETE FROM auth.users WHERE id = $1`, owner)
	}
	cleanup()
	defer cleanup()

	// Seed owner.
	if _, err := pool.ExecContext(ctx,
		`INSERT INTO auth.users (id, email) VALUES ($1, $2)`,
		owner, "builder-akte-test@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, lang, global_role)
		 VALUES ($1, $2, $3, 'munich', 'de', 'global_admin')`,
		owner, "builder-akte-test@hlc.com", "Builder Akte Owner"); err != nil {
		t.Fatalf("seed paliad.users: %v", err)
	}

	// Look up a real proceeding_type_id + a sequencing_rule_id on that
	// proceeding so the deadline upsert has a real rule to point at.
	var ptID int
	if err := pool.GetContext(ctx, &ptID,
		`SELECT id FROM paliad.proceeding_types
		  WHERE code = $1 AND is_active = true LIMIT 1`,
		CodeUPCInfringement); err != nil {
		t.Fatalf("look up upc.inf id: %v", err)
	}
	var ruleID uuid.UUID
	if err := pool.GetContext(ctx, &ruleID,
		`SELECT id FROM paliad.sequencing_rules
		  WHERE proceeding_type_id = $1
		    AND is_active = true
		    AND lifecycle_state = 'published'
		  ORDER BY sequence_order NULLS LAST, id LIMIT 1`, ptID); err != nil {
		t.Fatalf("look up sequencing_rule: %v", err)
	}

	// Seed a paliad.projects (type='case') row pinned to that
	// proceeding_type. our_side='defendant' so the builder triplet's
	// primary_party derives from it.
	projectID := uuid.New()
	if _, err := pool.ExecContext(ctx,
		`INSERT INTO paliad.projects
		    (id, type, title, status, proceeding_type_id, our_side, created_by)
		 VALUES ($1, 'case', 'Builder Akte Test Project', 'active', $2, 'defendant', $3)`,
		projectID, ptID, owner); err != nil {
		t.Fatalf("seed project: %v", err)
	}
	// Place the owner on the project team so visibility checks pass.
	if _, err := pool.ExecContext(ctx,
		`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited)
		 VALUES ($1, $2, 'lead', 'lead', false)`, projectID, owner); err != nil {
		t.Fatalf("seed project_teams: %v", err)
	}

	// Wire up the service with the real project + flag deps so dual-
	// write hits live tables. NewProjectService + NewScenarioFlags
	// match the production wiring in cmd/server/main.go.
	userSvc := NewUserService(pool)
	projSvc := NewProjectService(pool, userSvc)
	flagsSvc := NewScenarioFlagsService(pool, projSvc)
	svc := NewScenarioBuilderService(pool, projSvc, flagsSvc, nil)

	// ──────────────────────────────────────────────────────────────────
	// Phase A — Akte-backed scenario writes through to project tables.
	// ──────────────────────────────────────────────────────────────────

	akte, err := svc.CreateScenarioFromProject(ctx, owner, projectID)
	if err != nil {
		t.Fatalf("CreateScenarioFromProject: %v", err)
	}
	if akte.OriginProjectID == nil || *akte.OriginProjectID != projectID {
		t.Fatalf("origin_project_id = %v, want %v", akte.OriginProjectID, projectID)
	}
	if len(akte.Proceedings) != 1 {
		t.Fatalf("seed proceedings = %d, want 1", len(akte.Proceedings))
	}
	procID := akte.Proceedings[0].ID
	if akte.Proceedings[0].PrimaryParty == nil || *akte.Proceedings[0].PrimaryParty != "defendant" {
		t.Errorf("primary_party = %v, want defendant", akte.Proceedings[0].PrimaryParty)
	}

	// Toggle with_ccr=true via PatchProceeding. Dual-write should land
	// the same key on projects.scenario_flags.
	if _, err := svc.PatchProceeding(ctx, owner, akte.ID, procID, PatchProceedingInput{
		ScenarioFlags: json.RawMessage(`{"with_ccr": true}`),
	}); err != nil {
		t.Fatalf("PatchProceeding (Akte): %v", err)
	}
	var rawProjFlags []byte
	if err := pool.GetContext(ctx, &rawProjFlags,
		`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
		t.Fatalf("read project scenario_flags: %v", err)
	}
	var projFlags map[string]any
	if err := json.Unmarshal(rawProjFlags, &projFlags); err != nil {
		t.Fatalf("decode project scenario_flags: %v", err)
	}
	if v, ok := projFlags["with_ccr"].(bool); !ok || !v {
		t.Errorf("after Akte PatchProceeding, projects.scenario_flags.with_ccr = %v, want true", projFlags["with_ccr"])
	}

	// Add an event card backed by a real sequencing rule, then PATCH
	// state='filed' with actual_date. Dual-write should insert a
	// paliad.deadlines row (status='completed', completed_at=actual_date).
	ev, err := svc.AddEvent(ctx, owner, akte.ID, procID, AddEventInput{
		SequencingRuleID: &ruleID,
		State:            ptrString("planned"),
	})
	if err != nil {
		t.Fatalf("AddEvent (Akte): %v", err)
	}
	filedDate := mustDate(t, "2026-04-15")
	if _, err := svc.PatchEvent(ctx, owner, akte.ID, ev.ID, PatchEventInput{
		State:      ptrString("filed"),
		ActualDate: &filedDate,
	}); err != nil {
		t.Fatalf("PatchEvent filed (Akte): %v", err)
	}
	var deadlineCount int
	if err := pool.GetContext(ctx, &deadlineCount,
		`SELECT COUNT(*) FROM paliad.deadlines
		  WHERE project_id = $1 AND sequencing_rule_id = $2
		    AND status = 'completed'`,
		projectID, ruleID); err != nil {
		t.Fatalf("count deadlines: %v", err)
	}
	if deadlineCount != 1 {
		t.Errorf("after Akte PatchEvent filed, deadlines rows = %d, want 1", deadlineCount)
	}

	// ──────────────────────────────────────────────────────────────────
	// Phase B — kontextfrei scenario does NOT touch project surfaces.
	// ──────────────────────────────────────────────────────────────────

	// Capture project scenario_flags + deadline count before the
	// kontextfrei mutations so we can assert no change.
	var beforeFlagsRaw []byte
	_ = pool.GetContext(ctx, &beforeFlagsRaw,
		`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID)
	var beforeDeadlines int
	_ = pool.GetContext(ctx, &beforeDeadlines,
		`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, projectID)

	kf, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
	if err != nil {
		t.Fatalf("CreateScenario (kontextfrei): %v", err)
	}
	if kf.OriginProjectID != nil {
		t.Fatalf("kontextfrei origin_project_id = %v, want nil", kf.OriginProjectID)
	}
	kfProc, err := svc.AddProceeding(ctx, owner, kf.ID, AddProceedingInput{
		ProceedingTypeID: ptID,
		PrimaryParty:     ptrString("claimant"),
	})
	if err != nil {
		t.Fatalf("AddProceeding (kontextfrei): %v", err)
	}
	// Flag toggle on a kontextfrei scenario MUST NOT mutate the
	// project's scenario_flags.
	if _, err := svc.PatchProceeding(ctx, owner, kf.ID, kfProc.ID, PatchProceedingInput{
		ScenarioFlags: json.RawMessage(`{"with_amend": true}`),
	}); err != nil {
		t.Fatalf("PatchProceeding (kontextfrei): %v", err)
	}
	var afterFlagsRaw []byte
	if err := pool.GetContext(ctx, &afterFlagsRaw,
		`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
		t.Fatalf("re-read project scenario_flags: %v", err)
	}
	if string(beforeFlagsRaw) != string(afterFlagsRaw) {
		t.Errorf("kontextfrei PatchProceeding leaked into projects.scenario_flags: before=%s after=%s",
			beforeFlagsRaw, afterFlagsRaw)
	}

	// Filed-state event on a kontextfrei scenario MUST NOT touch
	// paliad.deadlines.
	kfEv, err := svc.AddEvent(ctx, owner, kf.ID, kfProc.ID, AddEventInput{
		SequencingRuleID: &ruleID,
		State:            ptrString("planned"),
	})
	if err != nil {
		t.Fatalf("AddEvent (kontextfrei): %v", err)
	}
	kfDate := mustDate(t, "2026-04-16")
	if _, err := svc.PatchEvent(ctx, owner, kf.ID, kfEv.ID, PatchEventInput{
		State:      ptrString("filed"),
		ActualDate: &kfDate,
	}); err != nil {
		t.Fatalf("PatchEvent filed (kontextfrei): %v", err)
	}
	var afterDeadlines int
	if err := pool.GetContext(ctx, &afterDeadlines,
		`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, projectID); err != nil {
		t.Fatalf("re-count deadlines: %v", err)
	}
	if afterDeadlines != beforeDeadlines {
		t.Errorf("kontextfrei PatchEvent filed leaked into deadlines: before=%d after=%d",
			beforeDeadlines, afterDeadlines)
	}
}

// TestScenarioBuilderPromote pins B5's load-bearing contract
// (m/paliad#153 / t-paliad-350 / PRD §2.4 + §5.4 + §10): PromoteScenario
// creates a paliad.projects 'case' row transactionally, cascades parties
// + deadlines, flips the scenario to 'promoted' with a back-ref, and
// makes the original scenario read-only afterwards.
func TestScenarioBuilderPromote(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()

	owner := uuid.New()
	var createdProjectID uuid.UUID
	cleanup := func() {
		if createdProjectID != uuid.Nil {
			pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, createdProjectID)
		}
		pool.ExecContext(ctx, `DELETE FROM paliad.scenarios WHERE owner_id = $1`, owner)
		pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, owner)
		pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, owner)
	}
	cleanup()
	defer cleanup()

	if _, err := pool.ExecContext(ctx,
		`INSERT INTO auth.users (id, email) VALUES ($1, 'promote-owner-test@hlc.com')`, owner); err != nil {
		t.Fatalf("seed auth.users: %v", err)
	}
	if _, err := pool.ExecContext(ctx,
		`INSERT INTO paliad.users (id, email, display_name, office, lang)
		 VALUES ($1, 'promote-owner-test@hlc.com', 'Promote Owner', 'munich', 'de')`, owner); err != nil {
		t.Fatalf("seed paliad.users: %v", err)
	}

	var ptID int
	if err := pool.GetContext(ctx, &ptID,
		`SELECT id FROM paliad.proceeding_types WHERE code = $1 AND is_active = true LIMIT 1`,
		CodeUPCInfringement); err != nil {
		t.Fatalf("look up upc.inf id: %v", err)
	}
	// Two distinct rule ids: one filed, one planned (with an explicit
	// actual_date so the planned deadline lands even without a calc engine).
	var ruleIDs []uuid.UUID
	if err := pool.SelectContext(ctx, &ruleIDs,
		`SELECT id FROM paliad.sequencing_rules
		  WHERE proceeding_type_id = $1 AND is_active = true AND lifecycle_state = 'published'
		  ORDER BY sequence_order NULLS LAST, id LIMIT 2`, ptID); err != nil {
		t.Fatalf("look up sequencing_rules: %v", err)
	}
	if len(ruleIDs) < 2 {
		t.Skipf("need >=2 published rules for upc.inf; got %d", len(ruleIDs))
	}

	userSvc := NewUserService(pool)
	projSvc := NewProjectService(pool, userSvc)
	flagsSvc := NewScenarioFlagsService(pool, projSvc)
	// fristenrechner nil — planned events carry an explicit actual_date in
	// this test, so the cascade doesn't need computed dates.
	svc := NewScenarioBuilderService(pool, projSvc, flagsSvc, nil)

	sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{Name: "Promote-Test"})
	if err != nil {
		t.Fatalf("CreateScenario: %v", err)
	}
	pr, err := svc.AddProceeding(ctx, owner, sc.ID, AddProceedingInput{
		ProceedingTypeID: ptID,
		PrimaryParty:     ptrString("defendant"),
		ScenarioFlags:    json.RawMessage(`{"with_ccr": true}`),
	})
	if err != nil {
		t.Fatalf("AddProceeding: %v", err)
	}
	filedDate := mustDate(t, "2026-01-15")
	if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
		SequencingRuleID: &ruleIDs[0], State: ptrString("filed"), ActualDate: &filedDate,
	}); err != nil {
		t.Fatalf("AddEvent filed: %v", err)
	}
	plannedDate := mustDate(t, "2026-04-01")
	if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
		SequencingRuleID: &ruleIDs[1], State: ptrString("planned"), ActualDate: &plannedDate,
	}); err != nil {
		t.Fatalf("AddEvent planned: %v", err)
	}

	res, err := svc.PromoteScenario(ctx, owner, sc.ID, PromoteScenarioInput{
		Title:      "Becker ./. X — UPC",
		CaseNumber: ptrString("UPC_CFI_123/2026"),
		OurSide:    ptrString("defendant"),
		Parties: []PromotePartyInput{
			{Name: "Becker GmbH", Role: ptrString("defendant")},
			{Name: "X Corp", Role: ptrString("claimant")},
		},
	})
	if err != nil {
		t.Fatalf("PromoteScenario: %v", err)
	}
	createdProjectID = res.ProjectID
	if res.ProjectID == uuid.Nil {
		t.Fatal("PromoteScenario returned nil project id")
	}
	if res.PartiesCreated != 2 {
		t.Errorf("PartiesCreated = %d, want 2", res.PartiesCreated)
	}
	if res.DeadlinesCreated != 2 {
		t.Errorf("DeadlinesCreated = %d, want 2 (1 filed + 1 planned-with-date)", res.DeadlinesCreated)
	}

	// Project row exists, is a 'case', carries origin_scenario_id + flags.
	var proj struct {
		Type            string          `db:"type"`
		OriginScenario  *uuid.UUID      `db:"origin_scenario_id"`
		ProceedingType  *int            `db:"proceeding_type_id"`
		OurSide         *string         `db:"our_side"`
		ScenarioFlags   json.RawMessage `db:"scenario_flags"`
		CaseNumber      *string         `db:"case_number"`
	}
	if err := pool.GetContext(ctx, &proj,
		`SELECT type, origin_scenario_id, proceeding_type_id, our_side, scenario_flags, case_number
		   FROM paliad.projects WHERE id = $1`, res.ProjectID); err != nil {
		t.Fatalf("load promoted project: %v", err)
	}
	if proj.Type != "case" {
		t.Errorf("project type = %q, want case", proj.Type)
	}
	if proj.OriginScenario == nil || *proj.OriginScenario != sc.ID {
		t.Errorf("origin_scenario_id = %v, want %v", proj.OriginScenario, sc.ID)
	}
	if proj.ProceedingType == nil || *proj.ProceedingType != ptID {
		t.Errorf("proceeding_type_id = %v, want %d", proj.ProceedingType, ptID)
	}
	if proj.OurSide == nil || *proj.OurSide != "defendant" {
		t.Errorf("our_side = %v, want defendant", proj.OurSide)
	}

	// Scenario flipped to promoted with the back-ref.
	var after BuilderScenario
	if err := pool.GetContext(ctx, &after,
		`SELECT id, owner_id, name, status, origin_project_id, promoted_project_id,
		        stichtag, notes, project_id, description, created_by, created_at, updated_at
		   FROM paliad.scenarios WHERE id = $1`, sc.ID); err != nil {
		t.Fatalf("reload scenario: %v", err)
	}
	if after.Status != "promoted" {
		t.Errorf("scenario status = %q, want promoted", after.Status)
	}
	if after.PromotedProjectID == nil || *after.PromotedProjectID != res.ProjectID {
		t.Errorf("promoted_project_id = %v, want %v", after.PromotedProjectID, res.ProjectID)
	}

	// Deadlines + parties physically present.
	var deadlineCount, partyCount int
	pool.GetContext(ctx, &deadlineCount,
		`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, res.ProjectID)
	pool.GetContext(ctx, &partyCount,
		`SELECT COUNT(*) FROM paliad.parties WHERE project_id = $1`, res.ProjectID)
	if deadlineCount != 2 {
		t.Errorf("deadlines in DB = %d, want 2", deadlineCount)
	}
	if partyCount != 2 {
		t.Errorf("parties in DB = %d, want 2", partyCount)
	}

	// Promoted scenario is now read-only: further PATCH is rejected.
	if _, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
		Name: ptrString("rename-after-promote"),
	}); !errors.Is(err, ErrInvalidInput) {
		t.Errorf("PatchScenario after promote = %v, want ErrInvalidInput", err)
	}
	// Re-promoting is rejected.
	if _, err := svc.PromoteScenario(ctx, owner, sc.ID, PromoteScenarioInput{Title: "again"}); !errors.Is(err, ErrInvalidInput) {
		t.Errorf("re-promote = %v, want ErrInvalidInput", err)
	}
}

// mustDate parses an ISO date or fails the test. Helper for the
// dual-write test above.
func mustDate(t *testing.T, s string) time.Time {
	t.Helper()
	d, err := time.Parse("2006-01-02", s)
	if err != nil {
		t.Fatalf("parse date %q: %v", s, err)
	}
	return d
}

// (Note: ptrString lives in rule_editor_service_test.go in this package
// and is reused here. No second declaration needed.)
