package main

import (
	"context"
	"encoding/json"
	"os"
	"path/filepath"
	"reflect"
	"sort"
	"strings"
	"testing"
	"time"

	sqlmock "github.com/DATA-DOG/go-sqlmock"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

func TestDryRunInventoriesLegacySurfacesAndReportsBlockers(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()
	expectWorkflowAlertMigrationQueries(mock)

	report, err := Run(context.Background(), db, Options{
		Mode: ModeDryRun,
		Now:  func() time.Time { return time.Date(2026, 6, 4, 16, 0, 0, 0, time.UTC) },
	})
	if err != nil {
		t.Fatalf("Run dry-run: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}

	if report.GeneratedAt != "2026-06-04T16:00:00Z" {
		t.Fatalf("GeneratedAt = %q", report.GeneratedAt)
	}
	if got := report.Summary.WorkflowAlertCount; got != 17 {
		t.Fatalf("WorkflowAlertCount = %d, want 17", got)
	}
	if got := report.Summary.ActiveWorkflowAlertCount; got != 17 {
		t.Fatalf("ActiveWorkflowAlertCount = %d, want 17", got)
	}
	if got := report.Summary.ProcessActionAlertCount; got != 5 {
		t.Fatalf("ProcessActionAlertCount = %d, want 5", got)
	}
	if got := report.Summary.ActiveProcessActionCount; got != 4 {
		t.Fatalf("ActiveProcessActionCount = %d, want 4", got)
	}
	if got := report.Summary.FlowEmailAlertCount; got != 9 {
		t.Fatalf("FlowEmailAlertCount = %d, want 9", got)
	}
	if got := report.Summary.ObjectFieldCount; got != 7 {
		t.Fatalf("ObjectFieldCount = %d, want 7", got)
	}
	if got := report.Summary.ReferencedTemplateCount; got != 6 {
		t.Fatalf("ReferencedTemplateCount = %d, want 6", got)
	}
	if !report.CutoverBlocked {
		t.Fatal("CutoverBlocked = false, want true")
	}

	assertUnsupportedCode(t, report, "unsupported_recipient")
	assertUnsupportedCode(t, report, "unsupported_cc_email")
	assertUnsupportedCode(t, report, "recipient_fanout_required")
	assertUnsupportedCode(t, report, "approval_actor_recipient_plan_missing")
	assertUnsupportedCode(t, report, "ambiguous_workflow_alert_name")
	assertUnsupportedCode(t, report, "ambiguous_workflow_alert_key")
	assertUnsupportedCode(t, report, "malformed_email_alert_action_name")
	assertUnsupportedCode(t, report, "sender_policy_required")
	assertUnsupportedCode(t, report, "unsupported_template_token")
	assertUnsupportedItem(t, report, constants.TableProcessAction, "Opportunity_Cross_Approval/Submit/Cross_Object_Alert", "workflow_alert_context_mismatch")
	assertUnsupportedItem(t, report, constants.TableProcessAction, "Opportunity_Approval/Approve/Inline_Alert", "approval_actor_recipient_plan_missing")
	assertUnsupportedItem(t, report, constants.TableWorkflowAlert, "Account.Multi_Recipient", "recipient_fanout_required")
	assertUnsupportedItem(t, report, constants.TableWorkflowAlert, "Opportunity.Approval_Notice", "sender_policy_required")
	assertUnsupportedItem(t, report, constants.TableWorkflowAlert, "Account.Current_User_Sender", "sender_policy_required")
	assertNoUnsupportedItem(t, report, constants.TableWorkflowAlert, "Account.Default_Workflow_User_Sender", "sender_policy_required")
	assertUnsupportedItem(t, report, constants.TableWorkflowAlert, "Account.Dirty_Default_Workflow_User_Sender", "sender_policy_required")
	assertUnsupportedItem(t, report, constants.TableWorkflowAlert, "Account.Trailing_Default_Workflow_User_Sender", "sender_policy_required")
	assertNoUnsupportedItem(t, report, constants.TableWorkflowAlert, "Account.Duplicate_Literal", "recipient_fanout_required")
	assertUnsupportedItem(t, report, constants.TableWorkflowAlert, "Case.Case_Notice", "ambiguous_workflow_alert_key")
	assertUnsupportedItem(t, report, constants.TableFlow, "Account_Flow/Case_Duplicate_Email", "ambiguous_workflow_alert_key")
	assertUnsupportedItem(t, report, constants.TableFlow, "Account_Flow/Legacy_Email", "missing_fault_connector")
	assertUnsupportedItem(t, report, constants.TableFlow, "Account_Flow_No_Start/Blank_Fault_Target", "missing_fault_connector")
	assertUnsupportedItem(t, report, constants.TableFlow, "Account_Flow/Cross_Object_Email", "flow_record_context_mismatch")
	assertUnsupportedItem(t, report, constants.TableFlow, "Account_Flow_No_Start/Qualified_Email_No_Start", "missing_flow_record_context")
	assertUnsupportedItem(t, report, constants.TableFlow, "Atomic_Account_Flow/Atomic_Email", "atomic_flow_email_io")
	assertNoUnsupportedItem(t, report, constants.TableFlow, "Account_Flow_No_Start/Literal_Email_No_Start", "missing_flow_record_context")
	assertNoUnsupportedItem(t, report, constants.TableFlow, "Account_Flow_No_Start/Literal_Email_No_Start", "missing_fault_connector")
	assertNoUnsupportedItem(t, report, constants.TableFlow, "Legacy_Workflow_Flow/Workflow_Email", "missing_flow_record_context")
	assertNoUnsupportedItem(t, report, constants.TableFlow, "Legacy_Workflow_Flow/Workflow_Email", "flow_record_context_mismatch")
	assertUnsupportedTemplateToken(t, report, "tmpl-welcome", "{!Account.Owner.Email}")
	assertUnsupportedTemplateToken(t, report, "tmpl-welcome", "{!Account.Owner}")
	assertUnsupportedTemplateToken(t, report, "tmpl-welcome", "{{Record.Owner.Name}}")
	assertUnsupportedTemplateToken(t, report, "tmpl-welcome", "{{Record.Owner}}")
	assertUnsupportedTemplateToken(t, report, "tmpl-welcome", "{{Record. Name}}")
	assertUnsupportedTemplateToken(t, report, "tmpl-welcome", "{{Record.name}}")
	assertUnsupportedTemplateToken(t, report, "tmpl-welcome", "{{Variables.Foo}}")
	assertUnsupportedTemplateToken(t, report, "tmpl-welcome", "{!Account.Name")
	assertTemplateTokenCandidate(t, report, "tmpl-welcome", "{!Account.name}", "{{Record.Name}}")
	assertNoUnsupportedItem(t, report, constants.TableEmailTemplate, "tmpl-legacy", "ambiguous_template_record_root")
	assertUnsupportedItem(t, report, constants.TableEmailTemplate, "tmpl-multi-root-record", "ambiguous_template_record_root")
	assertRecipientLiteral(t, report, "wa-welcome", "user-inline@example.com")
	assertRecipientResolvedEmail(t, report, "wa-welcome", "005-user-1", "user-one@example.com")
	assertRecipientBlocked(t, report, "wa-welcome", "005-user-1")
	assertRecipientBlocked(t, report, "wa-welcome", "005-missing-user")
	assertRecipientBlocked(t, report, "wa-welcome", "005-no-email")
	assertRecipientBlocked(t, report, "wa-welcome", "Ops <ops@example.com>")
	assertRecipientBlocked(t, report, "wa-welcome", "Bad <bad@example.com>")
	assertRecipientBlocked(t, report, "wa-welcome", "Sales Team")
	assertRecipientBlocked(t, report, "wa-welcome", "Support Queue")
	assertRecipientFieldBlocked(t, report, "wa-welcome", "OwnerId")
	assertRecipientFieldOK(t, report, "wa-text-email", "Text_Email__c", string(constants.FieldTypeTextarea))
	assertRecipientFieldOK(t, report, "wa-formula-email", "Formula_Email__c", string(constants.FieldTypeFormula))
	assertRecipientFieldBlocked(t, report, "wa-approval", "Owner.Email")
	assertRecipientFieldBlocked(t, report, "wa-approval", "OwnerId")
	assertRecipientFieldBlocked(t, report, "wa-approval", "Owner.ManagerId")
	assertProcessActionWorkflowAlertUnresolved(t, report, "pa-duplicate-alert")
	assertWorkflowTemplateResolved(t, report, "wa-approval", "tmpl-legacy")
	assertNoUnsupportedItem(t, report, constants.TableFlow, "Account_Flow/Legacy_Email", "missing_workflow_alert")

	for _, alert := range report.WorkflowAlerts {
		if alert.ID != "wa-approval" {
			continue
		}
		if len(alert.RecipientInventory) == 0 || alert.RecipientInventory[0].FieldValidation != "ok" {
			t.Fatalf("wa-approval recipient validation = %#v, want first item ok", alert.RecipientInventory)
		}
		if got := alert.RecipientInventory[0].ResolvedFieldType; got != string(constants.FieldTypeEmail) {
			t.Fatalf("wa-approval recipient field type = %q, want email", got)
		}
	}
	assertTemplateTokenCandidate(t, report, "tmpl-inline-approval", "{!Opportunity.Name}", "{{Record.Name}}")
	assertTemplateTokenCandidate(t, report, "tmpl-account-record", "{!Account.Name}", "{{Record.Name}}")

	var historical ProcessActionInventory
	for _, action := range report.ProcessActionAlerts {
		if action.ID == "pa-deleted" {
			historical = action
			break
		}
	}
	if historical.Status != sourceStatusHistorical {
		t.Fatalf("deleted ProcessAction status = %q, want historical", historical.Status)
	}

	if report.DryRun == nil {
		t.Fatal("DryRun = nil, want plan")
	}
	assertProposedAction(t, report, constants.TableWorkflowAlert, "Account.Welcome_Email", "tmpl-welcome")
	assertProposedActionBlocked(t, report, constants.TableWorkflowAlert, "Account.Welcome_Email", "WorkflowAlert has multiple recipients; dry-run cannot preserve legacy one-email-per-recipient fan-out with a single sendEmail action")
	assertProposedActionExcludesTo(t, report, constants.TableWorkflowAlert, "Account.Welcome_Email", "user-inline@example.com")
	assertProposedAction(t, report, constants.TableWorkflowAlert, "Account.Multi_Recipient", "tmpl-duplicate")
	assertProposedActionBlocked(t, report, constants.TableWorkflowAlert, "Account.Multi_Recipient", "WorkflowAlert has multiple recipients; dry-run cannot preserve legacy one-email-per-recipient fan-out with a single sendEmail action")
	assertProposedActionExcludesTo(t, report, constants.TableWorkflowAlert, "Account.Multi_Recipient", "first@example.com")
	assertProposedActionExcludesTo(t, report, constants.TableWorkflowAlert, "Account.Multi_Recipient", "second@example.com")
	assertProposedAction(t, report, constants.TableWorkflowAlert, "Account.Duplicate_Literal", "tmpl-duplicate")
	assertProposedActionHasTo(t, report, constants.TableWorkflowAlert, "Account.Duplicate_Literal", "duplicate@example.com")
	assertProposedActionToCount(t, report, constants.TableWorkflowAlert, "Account.Duplicate_Literal", 1)
	assertProposedActionNotBlocked(t, report, constants.TableWorkflowAlert, "Account.Duplicate_Literal", "WorkflowAlert has multiple recipients; dry-run cannot preserve legacy one-email-per-recipient fan-out with a single sendEmail action")
	assertProposedAction(t, report, constants.TableWorkflowAlert, "Account.Text_Email", "tmpl-duplicate")
	assertProposedActionIncludesPrerequisite(t, report, constants.TableWorkflowAlert, "Account.Text_Email", "resolveEmailRecipients emails:Record.Text_Email__c")
	assertProposedAction(t, report, constants.TableWorkflowAlert, "Account.Formula_Email", "tmpl-duplicate")
	assertProposedActionIncludesPrerequisite(t, report, constants.TableWorkflowAlert, "Account.Formula_Email", "resolveEmailRecipients emails:Record.Formula_Email__c")
	assertProposedAction(t, report, constants.TableWorkflowAlert, "Account.Record_Template", "tmpl-account-record")
	assertProposedActionHasTo(t, report, constants.TableWorkflowAlert, "Account.Record_Template", "record@example.com")
	assertProposedAction(t, report, constants.TableWorkflowAlert, "Account.Current_User_Sender", "tmpl-duplicate")
	assertProposedActionHasTo(t, report, constants.TableWorkflowAlert, "Account.Current_User_Sender", "current-user-sender@example.com")
	assertProposedActionBlockedContains(t, report, constants.TableWorkflowAlert, "Account.Current_User_Sender", "depends on per-user sender authorization")
	assertProposedAction(t, report, constants.TableWorkflowAlert, "Account.Default_Workflow_User_Sender", "tmpl-duplicate")
	assertProposedActionHasTo(t, report, constants.TableWorkflowAlert, "Account.Default_Workflow_User_Sender", "default-workflow-user-sender@example.com")
	assertProposedActionSenderPolicy(t, report, constants.TableWorkflowAlert, "Account.Default_Workflow_User_Sender", sendEmailSenderPolicyDefault)
	assertProposedActionNotBlocked(t, report, constants.TableWorkflowAlert, "Account.Default_Workflow_User_Sender", "has no safe generic sendEmail sender-policy mapping")
	assertProposedAction(t, report, constants.TableWorkflowAlert, "Account.Dirty_Default_Workflow_User_Sender", "tmpl-duplicate")
	assertProposedActionHasTo(t, report, constants.TableWorkflowAlert, "Account.Dirty_Default_Workflow_User_Sender", "dirty-default-workflow-user-sender@example.com")
	assertProposedActionBlockedContains(t, report, constants.TableWorkflowAlert, "Account.Dirty_Default_Workflow_User_Sender", "has no safe generic sendEmail sender-policy mapping")
	assertProposedAction(t, report, constants.TableWorkflowAlert, "Account.Trailing_Default_Workflow_User_Sender", "tmpl-duplicate")
	assertProposedActionHasTo(t, report, constants.TableWorkflowAlert, "Account.Trailing_Default_Workflow_User_Sender", "trailing-default-workflow-user-sender@example.com")
	assertProposedActionBlockedContains(t, report, constants.TableWorkflowAlert, "Account.Trailing_Default_Workflow_User_Sender", "has no safe generic sendEmail sender-policy mapping")
	assertProposedActionBlockedContains(t, report, constants.TableWorkflowAlert, "Account.Multi_Root_Record", "template tmpl-multi-root-record blocked by ambiguous_template_record_root")
	assertProposedActionExcludesPrerequisite(t, report, constants.TableWorkflowAlert, "Account.Welcome_Email", "resolveEmailRecipients userIds:005-user-1")
	assertProposedActionExcludesPrerequisite(t, report, constants.TableWorkflowAlert, "Account.Welcome_Email", "resolveEmailRecipients userIds:005-missing-user")
	assertProposedActionExcludesPrerequisite(t, report, constants.TableWorkflowAlert, "Account.Welcome_Email", "resolveEmailRecipients userIds:005-no-email")
	assertProposedActionBlocked(t, report, constants.TableWorkflowAlert, "Account.Welcome_Email", "user recipient \"005-user-1\" requires resolveEmailRecipients userIds, but migration dry-run cannot prove the generated Flow runner can read the target User row and Email field through user-scoped queries")
	assertProposedActionExcludesPrerequisite(t, report, constants.TableWorkflowAlert, "Account.Welcome_Email", "resolveEmailRecipients role:Sales Team")
	assertProposedActionExcludesPrerequisite(t, report, constants.TableWorkflowAlert, "Account.Welcome_Email", "resolveEmailRecipients group:Support Queue")
	assertProposedActionExcludesPrerequisite(t, report, constants.TableWorkflowAlert, "Account.Welcome_Email", "resolveEmailRecipients owner")
	assertProposedActionExcludesPrerequisite(t, report, constants.TableWorkflowAlert, "Account.Welcome_Email", "resolveEmailRecipients userlookup:OwnerId")
	assertProposedActionExcludesPrerequisite(t, report, constants.TableWorkflowAlert, "Opportunity.Approval_Notice", "resolveEmailRecipients emails:Record.Owner.Email")
	assertProposedActionExcludesPrerequisite(t, report, constants.TableWorkflowAlert, "Opportunity.Approval_Notice", "resolveEmailRecipients userlookup:Owner.ManagerId")
	assertProposedActionExcludesPrerequisite(t, report, constants.TableWorkflowAlert, "Opportunity.Approval_Notice", "resolveEmailRecipients userIds:Record.OwnerId")
	assertProposedActionBlocked(t, report, constants.TableWorkflowAlert, "Opportunity.Approval_Notice", "email recipient field \"Owner.Email\" traverses a related record and requires Flow-visible related-row and Email-field access that migration dry-run cannot prove")
	assertProposedActionBlocked(t, report, constants.TableWorkflowAlert, "Opportunity.Approval_Notice", "owner recipient field \"OwnerId\" requires resolveEmailRecipients userIds, but migration dry-run cannot prove the generated Flow runner can read the target User row and Email field through user-scoped queries")
	assertProposedAction(t, report, constants.TableProcessAction, "pa-submit-alert", "tmpl-welcome")
	assertProposedActionBlocked(t, report, constants.TableProcessAction, "pa-submit-alert", "WorkflowAlert has multiple recipients; dry-run cannot preserve legacy one-email-per-recipient fan-out with a single sendEmail action")
	assertProposedActionBlockedContains(t, report, constants.TableProcessAction, "pa-submit-alert", "referenced WorkflowAlert Account.Welcome_Email blocked by unsupported_cc_email")
	assertProposedActionBlockedContains(t, report, constants.TableProcessAction, "pa-submit-alert", "referenced WorkflowAlert Account.Welcome_Email template tmpl-welcome blocked by unsupported_template_token")
	assertProposedActionBlocked(t, report, constants.TableProcessAction, "pa-inline-alert", "inline approval alert recipients come from approval actorIDs, but dry-run has no executable actorID-to-sendEmail recipient fan-out plan")
	assertProposedAction(t, report, constants.TableFlow, "Account_Flow/Legacy_Email", "tmpl-welcome")
	assertProposedAction(t, report, constants.TableFlow, "Account_Flow_No_Start/Literal_Email_No_Start", "tmpl-duplicate")
	assertProposedAction(t, report, constants.TableFlow, "Legacy_Workflow_Flow/Workflow_Email", "tmpl-legacy")
	assertProposedActionBlocked(t, report, constants.TableFlow, "Account_Flow/Legacy_Email", "WorkflowAlert has multiple recipients; dry-run cannot preserve legacy one-email-per-recipient fan-out with a single sendEmail action")
	assertProposedActionBlockedContains(t, report, constants.TableFlow, "Account_Flow/Legacy_Email", "referenced WorkflowAlert Account.Welcome_Email blocked by unsupported_cc_email")
	assertProposedActionBlockedContains(t, report, constants.TableFlow, "Account_Flow/Legacy_Email", "referenced WorkflowAlert Account.Welcome_Email template tmpl-welcome blocked by unsupported_template_token")
	assertProposedActionBlocked(t, report, constants.TableFlow, "Account_Flow/Legacy_Email", "sendEmail would fault/abort this Flow unless a fault connector or approved waiver is added")
	assertProposedActionBlocked(t, report, constants.TableFlow, "Account_Flow_No_Start/Blank_Fault_Target", "sendEmail would fault/abort this Flow unless a fault connector or approved waiver is added")
	assertProposedActionBlocked(t, report, constants.TableFlow, "Account_Flow/Cross_Object_Email", "Flow record context Account does not match WorkflowAlert object Opportunity")
	assertProposedActionBlocked(t, report, constants.TableFlow, "Account_Flow_No_Start/Qualified_Email_No_Start", "Flow has no declared record context for WorkflowAlert recipient/template evaluation")
	assertProposedActionBlocked(t, report, constants.TableFlow, "Atomic_Account_Flow/Atomic_Email", "active Flow runAtomically=true cannot execute email I/O")
	assertProposedActionBlocked(t, report, constants.TableFlow, "Account_Flow/Case_Duplicate_Email", "referenced WorkflowAlert key is duplicated by case-insensitive object/name lookup")

	outputDir := t.TempDir()
	if err := WriteReports(report, outputDir); err != nil {
		t.Fatalf("WriteReports: %v", err)
	}
	jsonBytes, err := os.ReadFile(filepath.Join(outputDir, "migration_report.json"))
	if err != nil {
		t.Fatalf("read migration_report.json: %v", err)
	}
	jsonText := string(jsonBytes)
	for _, want := range []string{`"mode": "dry-run"`, `"cutoverBlocked": true`, `"actionType": "sendEmail"`} {
		if !strings.Contains(jsonText, want) {
			t.Fatalf("migration_report.json missing %q:\n%s", want, jsonText)
		}
	}
	markdownBytes, err := os.ReadFile(filepath.Join(outputDir, "migration_report.md"))
	if err != nil {
		t.Fatalf("read migration_report.md: %v", err)
	}
	if markdown := string(markdownBytes); !strings.Contains(markdown, "WorkflowAlert Migration dry-run Report") || !strings.Contains(markdown, "Blockers") {
		t.Fatalf("migration_report.md missing expected sections:\n%s", markdown)
	}
}

func TestInventoryModeOmitsDryRunPlan(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()
	expectWorkflowAlertMigrationQueries(mock)

	report, err := Run(context.Background(), db, Options{Mode: ModeInventory})
	if err != nil {
		t.Fatalf("Run inventory: %v", err)
	}
	if report.DryRun != nil {
		t.Fatalf("inventory DryRun = %#v, want nil", report.DryRun)
	}
	outputDir := t.TempDir()
	if err := WriteReports(report, outputDir); err != nil {
		t.Fatalf("WriteReports inventory: %v", err)
	}
	jsonBytes, err := os.ReadFile(filepath.Join(outputDir, "migration_report.json"))
	if err != nil {
		t.Fatalf("read inventory migration_report.json: %v", err)
	}
	jsonText := string(jsonBytes)
	for _, wantAbsent := range []string{`"historicalInventory"`, `"runtimeReachable"`} {
		if strings.Contains(jsonText, wantAbsent) {
			t.Fatalf("inventory migration_report.json unexpectedly emitted verify-zero-only field %q:\n%s", wantAbsent, jsonText)
		}
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestVerifyZeroFailsForActiveWorkflowAlertDependencies(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()
	expectVerifyZeroActiveDependencyQueries(mock)

	report, err := Run(context.Background(), db, Options{
		Mode:             ModeVerifyZero,
		Now:              func() time.Time { return time.Date(2026, 6, 5, 4, 30, 0, 0, time.UTC) },
		OfficialPackRoot: writeVerifyZeroCleanOfficialPackRoot(t),
	})
	if err != nil {
		t.Fatalf("Run verify-zero: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}

	if report.VerifyZero == nil {
		t.Fatal("VerifyZero = nil, want gate report")
	}
	if report.VerifyZero.Passed {
		t.Fatal("VerifyZero.Passed = true, want false for active dependencies")
	}
	if got := report.VerifyZero.ActiveDependencyCount; got != 3 {
		t.Fatalf("ActiveDependencyCount = %d, want 3: %#v", got, report.VerifyZero.ActiveDependencies)
	}
	if !report.CutoverBlocked {
		t.Fatal("CutoverBlocked = false, want true")
	}
	assertVerifyZeroDependency(t, report, "_System_WorkflowAlert", constants.TableWorkflowAlert, "Account.Welcome_Email")
	assertVerifyZeroDependency(t, report, "ProcessAction.Alert", constants.TableProcessAction, "Account_Approval/Submit/Submit_Alert")
	assertVerifyZeroDependency(t, report, "Flow.emailAlert", constants.TableFlow, "Account_Flow/Legacy_Email")
	assertUnsupportedItem(t, report, constants.TableWorkflowAlert, "Account.Welcome_Email", "active_workflow_alert_dependency")
	assertUnsupportedItem(t, report, constants.TableProcessAction, "Account_Approval/Submit/Submit_Alert", "active_process_action_alert_dependency")
	assertUnsupportedItem(t, report, constants.TableFlow, "Account_Flow/Legacy_Email", "active_flow_email_alert_dependency")

	outputDir := t.TempDir()
	if err := WriteReports(report, outputDir); err != nil {
		t.Fatalf("WriteReports verify-zero: %v", err)
	}
	jsonBytes, err := os.ReadFile(filepath.Join(outputDir, "migration_report.json"))
	if err != nil {
		t.Fatalf("read verify-zero migration_report.json: %v", err)
	}
	jsonText := string(jsonBytes)
	for _, want := range []string{`"mode": "verify-zero"`, `"passed": false`, `"activeDependencyCount": 3`, `"active_flow_email_alert_dependency"`} {
		if !strings.Contains(jsonText, want) {
			t.Fatalf("verify-zero migration_report.json missing %q:\n%s", want, jsonText)
		}
	}
	markdownBytes, err := os.ReadFile(filepath.Join(outputDir, "migration_report.md"))
	if err != nil {
		t.Fatalf("read verify-zero migration_report.md: %v", err)
	}
	markdown := string(markdownBytes)
	for _, want := range []string{"Verify-Zero Gate", "Status: `FAIL`", "Official packs scanned: `1`", "Active Dependencies", "Flow.emailAlert"} {
		if !strings.Contains(markdown, want) {
			t.Fatalf("verify-zero migration_report.md missing %q:\n%s", want, markdown)
		}
	}
}

func TestVerifyZeroFailsForPendingApprovalOnInactiveDefinition(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery("FROM _System_WorkflowAlert").
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "ObjectApiName", "Name", "Description", "Template", "SenderType", "SenderAddress", "Recipients", "CcEmails", "CreatedDate", "LastModifiedDate",
		}).
			AddRow("wa-pending", "Account", "Pending_Email", "pending approval", "tmpl-pending", "", "", `[{"type":"email","recipient":"ops@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z"))
	mock.ExpectQuery("FROM ProcessAction a").
		WithArgs(constants.ApprovalStatusApproved, constants.ApprovalStatusRejected, constants.ApprovalStatusRemoved, constants.ProcessActionTypeAlert).
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "Name", "ProcessDefinitionId", "ProcessNodeId", "ActionEvent", "EmailTemplateId", "ApiName", "Order", "IsDeleted", "TableEnumOrId", "DeveloperName", "RuntimeReachable", "NodeDeveloperName", "NodeName", "RunnableInstanceCount",
		}).
			AddRow("pa-pending-alert", "Final_Alert", "pd-inactive", "pn-final", "Approve", "", "Pending_Email", 1, 0, "Account", "Inactive_Approval", 0, "FinalStep", "Final Step", 1))
	mock.ExpectQuery("JOIN FlowDefinition fd").
		WithArgs(models.FlowInterviewStatusPaused, models.FlowInterviewStatusRunning).
		WillReturnRows(sqlmock.NewRows([]string{
			"DefinitionId", "DeveloperName", "DefinitionProcessType", "ActiveVersionId", "VersionId", "VersionProcessType", "VersionNumber", "Status", "Metadata", "TriggerObjectOrEvent", "ActiveVersion", "PausedInterviewCount", "RecoverableRunningInterviewCount",
		}))

	report, err := Run(context.Background(), db, Options{
		Mode:             ModeVerifyZero,
		OfficialPackRoot: writeVerifyZeroCleanOfficialPackRoot(t),
	})
	if err != nil {
		t.Fatalf("Run verify-zero: %v", err)
	}
	if report.VerifyZero == nil || report.VerifyZero.Passed {
		t.Fatalf("VerifyZero = %#v, want failed gate", report.VerifyZero)
	}
	if got := report.VerifyZero.ActiveDependencyCount; got != 2 {
		t.Fatalf("ActiveDependencyCount = %d, want ProcessAction + WorkflowAlert blockers: %#v", got, report.VerifyZero.ActiveDependencies)
	}
	assertVerifyZeroDependency(t, report, "ProcessAction.Alert", constants.TableProcessAction, "Inactive_Approval/Approve/Final_Alert")
	assertVerifyZeroDependency(t, report, "_System_WorkflowAlert", constants.TableWorkflowAlert, "Account.Pending_Email")
	if got := report.ProcessActionAlerts[0].Status; got != sourceStatusActive {
		t.Fatalf("ProcessAction status = %q, want active because pending instance can still run", got)
	}
	if !strings.Contains(report.ProcessActionAlerts[0].StatusReason, "nonterminal ProcessInstance") {
		t.Fatalf("ProcessAction statusReason = %q, want pending-instance evidence", report.ProcessActionAlerts[0].StatusReason)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestVerifyZeroFailsForPausedInterviewOnHistoricalFlowVersion(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery("FROM _System_WorkflowAlert").
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "ObjectApiName", "Name", "Description", "Template", "SenderType", "SenderAddress", "Recipients", "CcEmails", "CreatedDate", "LastModifiedDate",
		}).
			AddRow("wa-paused", "Account", "Paused_Email", "paused flow", "tmpl-paused", "", "", `[{"type":"email","recipient":"ops@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z"))
	mock.ExpectQuery("FROM ProcessAction a").
		WithArgs(constants.ApprovalStatusApproved, constants.ApprovalStatusRejected, constants.ApprovalStatusRemoved, constants.ProcessActionTypeAlert).
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "Name", "ProcessDefinitionId", "ProcessNodeId", "ActionEvent", "EmailTemplateId", "ApiName", "Order", "IsDeleted", "TableEnumOrId", "DeveloperName", "RuntimeReachable", "NodeDeveloperName", "NodeName", "RunnableInstanceCount",
		}))
	metadata := `{
	  "start": {"object": "Account"},
	  "actionCalls": [
	    {"name": "Legacy_Email", "label": "Legacy Email", "actionName": "Account.Paused_Email", "actionType": "emailAlert"}
	  ]
	}`
	mock.ExpectQuery("JOIN FlowDefinition fd").
		WithArgs(models.FlowInterviewStatusPaused, models.FlowInterviewStatusRunning).
		WillReturnRows(sqlmock.NewRows([]string{
			"DefinitionId", "DeveloperName", "DefinitionProcessType", "ActiveVersionId", "VersionId", "VersionProcessType", "VersionNumber", "Status", "Metadata", "TriggerObjectOrEvent", "ActiveVersion", "PausedInterviewCount", "RecoverableRunningInterviewCount",
		}).
			AddRow("fd-account", "Account_Flow", "AutoLaunchedFlow", "flow-current", "flow-old", "AutoLaunchedFlow", 7, "Obsolete", metadata, "Account", 0, 2, 0))

	report, err := Run(context.Background(), db, Options{
		Mode:             ModeVerifyZero,
		OfficialPackRoot: writeVerifyZeroCleanOfficialPackRoot(t),
	})
	if err != nil {
		t.Fatalf("Run verify-zero: %v", err)
	}
	if report.VerifyZero == nil || report.VerifyZero.Passed {
		t.Fatalf("VerifyZero = %#v, want failed gate", report.VerifyZero)
	}
	if got := report.VerifyZero.ActiveDependencyCount; got != 2 {
		t.Fatalf("ActiveDependencyCount = %d, want Flow + WorkflowAlert blockers: %#v", got, report.VerifyZero.ActiveDependencies)
	}
	assertVerifyZeroDependency(t, report, "Flow.emailAlert", constants.TableFlow, "Account_Flow/v7/Legacy_Email")
	assertVerifyZeroDependency(t, report, "_System_WorkflowAlert", constants.TableWorkflowAlert, "Account.Paused_Email")
	if got := report.FlowEmailAlerts[0].RuntimeState; got != "paused_interview" {
		t.Fatalf("Flow runtimeState = %q, want paused_interview", got)
	}
	if got := report.FlowEmailAlerts[0].PausedInterviewCount; got != 2 {
		t.Fatalf("PausedInterviewCount = %d, want 2", got)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestVerifyZeroFailsForRecoverableRunningInterviewOnHistoricalFlowVersion(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	mock.ExpectQuery("FROM _System_WorkflowAlert").
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "ObjectApiName", "Name", "Description", "Template", "SenderType", "SenderAddress", "Recipients", "CcEmails", "CreatedDate", "LastModifiedDate",
		}).
			AddRow("wa-recoverable", "Account", "Recoverable_Email", "recoverable running flow", "tmpl-recoverable", "", "", `[{"type":"email","recipient":"ops@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z"))
	mock.ExpectQuery("FROM ProcessAction a").
		WithArgs(constants.ApprovalStatusApproved, constants.ApprovalStatusRejected, constants.ApprovalStatusRemoved, constants.ProcessActionTypeAlert).
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "Name", "ProcessDefinitionId", "ProcessNodeId", "ActionEvent", "EmailTemplateId", "ApiName", "Order", "IsDeleted", "TableEnumOrId", "DeveloperName", "RuntimeReachable", "NodeDeveloperName", "NodeName", "RunnableInstanceCount",
		}))
	metadata := `{
	  "start": {"object": "Account"},
	  "actionCalls": [
	    {"name": "Legacy_Email", "label": "Legacy Email", "actionName": "Account.Recoverable_Email", "actionType": "emailAlert"}
	  ]
	}`
	mock.ExpectQuery("JOIN FlowDefinition fd").
		WithArgs(models.FlowInterviewStatusPaused, models.FlowInterviewStatusRunning).
		WillReturnRows(sqlmock.NewRows([]string{
			"DefinitionId", "DeveloperName", "DefinitionProcessType", "ActiveVersionId", "VersionId", "VersionProcessType", "VersionNumber", "Status", "Metadata", "TriggerObjectOrEvent", "ActiveVersion", "PausedInterviewCount", "RecoverableRunningInterviewCount",
		}).
			AddRow("fd-account", "Account_Flow", "AutoLaunchedFlow", "flow-current", "flow-old", "AutoLaunchedFlow", 7, "Obsolete", metadata, "Account", 0, 0, 1))

	report, err := Run(context.Background(), db, Options{
		Mode:             ModeVerifyZero,
		OfficialPackRoot: writeVerifyZeroCleanOfficialPackRoot(t),
	})
	if err != nil {
		t.Fatalf("Run verify-zero: %v", err)
	}
	if report.VerifyZero == nil || report.VerifyZero.Passed {
		t.Fatalf("VerifyZero = %#v, want failed gate", report.VerifyZero)
	}
	if got := report.VerifyZero.ActiveDependencyCount; got != 2 {
		t.Fatalf("ActiveDependencyCount = %d, want Flow + WorkflowAlert blockers: %#v", got, report.VerifyZero.ActiveDependencies)
	}
	assertVerifyZeroDependency(t, report, "Flow.emailAlert", constants.TableFlow, "Account_Flow/v7/Legacy_Email")
	assertVerifyZeroDependency(t, report, "_System_WorkflowAlert", constants.TableWorkflowAlert, "Account.Recoverable_Email")
	if got := report.FlowEmailAlerts[0].RuntimeState; got != "recoverable_running_interview" {
		t.Fatalf("Flow runtimeState = %q, want recoverable_running_interview", got)
	}
	if got := report.FlowEmailAlerts[0].RecoverableRunningInterviewCount; got != 1 {
		t.Fatalf("RecoverableRunningInterviewCount = %d, want 1", got)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestVerifyZeroFailsWithoutOfficialPackRoot(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()
	expectVerifyZeroHistoricalOnlyQueries(mock)

	report, err := Run(context.Background(), db, Options{Mode: ModeVerifyZero})
	if err != nil {
		t.Fatalf("Run verify-zero without official pack root: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}

	if report.VerifyZero == nil {
		t.Fatal("VerifyZero = nil, want gate report")
	}
	if report.VerifyZero.Passed {
		t.Fatal("VerifyZero.Passed = true, want false without official pack evidence")
	}
	if got := report.VerifyZero.ActiveDependencyCount; got != 0 {
		t.Fatalf("ActiveDependencyCount = %d, want 0 database/source dependencies with only scan blocker", got)
	}
	if got := report.VerifyZero.ExistingBlockerCount; got != 1 {
		t.Fatalf("ExistingBlockerCount = %d, want missing-root blocker", got)
	}
	assertUnsupportedItem(t, report, sourceTypeOfficialPack, officialAppPacksRootEnvVar, "official_pack_root_missing")
}

func TestVerifyZeroFailsForSourcePackLegacyDependencies(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()
	expectVerifyZeroHistoricalOnlyQueries(mock)

	report, err := Run(context.Background(), db, Options{
		Mode:             ModeVerifyZero,
		OfficialPackRoot: writeVerifyZeroLegacyOfficialPackRoot(t),
	})
	if err != nil {
		t.Fatalf("Run verify-zero with legacy official pack source: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}

	if report.VerifyZero == nil {
		t.Fatal("VerifyZero = nil, want gate report")
	}
	if report.VerifyZero.Passed {
		t.Fatal("VerifyZero.Passed = true, want false for source-pack legacy dependencies")
	}
	if got := report.VerifyZero.ScannedOfficialPackCount; got != 1 {
		t.Fatalf("ScannedOfficialPackCount = %d, want 1", got)
	}
	if got := report.VerifyZero.ActiveDependencyCount; got != 3 {
		t.Fatalf("ActiveDependencyCount = %d, want 3 source-pack dependencies: %#v", got, report.VerifyZero.ActiveDependencies)
	}
	assertVerifyZeroDependency(t, report, "SourcePack.workflow_alert", sourceTypeOfficialPack, "legacy-pack/workflow_alert/Account.Welcome_Email")
	assertVerifyZeroDependency(t, report, "SourcePack.Flow.emailAlert", sourceTypeOfficialPack, "legacy-pack/standard_flow/Legacy_Flow/Legacy_Email")
	assertVerifyZeroDependency(t, report, "SourcePack.ApprovalProcess.Alert", sourceTypeOfficialPack, "legacy-pack/approval_process/Account/Legacy_Approval/Submit/Submit_Email")
	assertUnsupportedItem(t, report, sourceTypeOfficialPack, "legacy-pack/workflow_alert/Account.Welcome_Email", "source_pack_workflow_alert_dependency")
	assertUnsupportedItem(t, report, sourceTypeOfficialPack, "legacy-pack/standard_flow/Legacy_Flow/Legacy_Email", "source_pack_flow_email_alert_dependency")
	assertUnsupportedItem(t, report, sourceTypeOfficialPack, "legacy-pack/approval_process/Account/Legacy_Approval/Submit/Submit_Email", "source_pack_approval_alert_dependency")
}

func TestVerifyZeroPassesWithHistoricalWorkflowAlertRowsOnly(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()
	expectVerifyZeroHistoricalOnlyQueries(mock)

	report, err := Run(context.Background(), db, Options{
		Mode:             ModeVerifyZero,
		OfficialPackRoot: writeVerifyZeroCleanOfficialPackRoot(t),
	})
	if err != nil {
		t.Fatalf("Run verify-zero historical-only: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}

	if report.VerifyZero == nil {
		t.Fatal("VerifyZero = nil, want gate report")
	}
	if !report.VerifyZero.Passed {
		t.Fatalf("VerifyZero.Passed = false, want true: %#v", report.VerifyZero)
	}
	if report.CutoverBlocked {
		t.Fatalf("CutoverBlocked = true, want false: %#v", report.UnsupportedItems)
	}
	if got := report.Summary.ActiveWorkflowAlertCount; got != 0 {
		t.Fatalf("ActiveWorkflowAlertCount = %d, want historical-only rows not to fail the gate", got)
	}
	if got := report.Summary.HistoricalWorkflowAlertCount; got != 2 {
		t.Fatalf("HistoricalWorkflowAlertCount = %d, want 2 inventory rows", got)
	}
	if got := len(report.VerifyZero.HistoricalWorkflowRows); got != 2 {
		t.Fatalf("HistoricalWorkflowRows = %d, want duplicate historical rows to pass: %#v", got, report.VerifyZero.HistoricalWorkflowRows)
	}
	for _, row := range report.VerifyZero.HistoricalWorkflowRows {
		if !row.HistoricalInventory {
			t.Fatalf("historical row %s HistoricalInventory = false, want true", row.SourceKey)
		}
		if row.RuntimeReachable {
			t.Fatalf("historical row %s RuntimeReachable = true, want false", row.SourceKey)
		}
	}
	for _, alert := range report.WorkflowAlerts {
		if alert.ID != "wa-old" {
			continue
		}
		if alert.Status != sourceStatusHistorical {
			t.Fatalf("historical WorkflowAlert status = %q, want historical", alert.Status)
		}
		if !strings.Contains(alert.StatusReason, "no active ProcessAction.Alert or Flow emailAlert can read this row") {
			t.Fatalf("historical WorkflowAlert reason = %q", alert.StatusReason)
		}
		if alert.HistoricalInventory == nil || !*alert.HistoricalInventory {
			t.Fatalf("historical WorkflowAlert HistoricalInventory = false, want true")
		}
		if alert.RuntimeReachable == nil || *alert.RuntimeReachable {
			t.Fatalf("historical WorkflowAlert RuntimeReachable = true, want false")
		}
		outputDir := t.TempDir()
		if err := WriteReports(report, outputDir); err != nil {
			t.Fatalf("WriteReports historical verify-zero: %v", err)
		}
		jsonBytes, err := os.ReadFile(filepath.Join(outputDir, "migration_report.json"))
		if err != nil {
			t.Fatalf("read historical verify-zero migration_report.json: %v", err)
		}
		jsonText := string(jsonBytes)
		for _, want := range []string{`"historicalWorkflowAlertCount": 2`, `"historicalInventory": true`, `"runtimeReachable": false`} {
			if !strings.Contains(jsonText, want) {
				t.Fatalf("historical verify-zero migration_report.json missing %q:\n%s", want, jsonText)
			}
		}
		markdownBytes, err := os.ReadFile(filepath.Join(outputDir, "migration_report.md"))
		if err != nil {
			t.Fatalf("read historical verify-zero migration_report.md: %v", err)
		}
		markdown := string(markdownBytes)
		for _, want := range []string{"Status: `PASS`", "Historical WorkflowAlert inventory rows: `2`", "Historical WorkflowAlert Rows"} {
			if !strings.Contains(markdown, want) {
				t.Fatalf("historical verify-zero migration_report.md missing %q:\n%s", want, markdown)
			}
		}
		return
	}
	t.Fatalf("WorkflowAlerts missing wa-old: %#v", report.WorkflowAlerts)
}

func TestApplyPackGeneratesDeterministicSourceBackedPack(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()
	expectWorkflowAlertMigrationQueries(mock)

	report, err := Run(context.Background(), db, Options{
		Mode: ModeApplyPack,
		Now:  func() time.Time { return time.Date(2026, 6, 4, 17, 0, 0, 0, time.UTC) },
	})
	if err != nil {
		t.Fatalf("Run apply-pack: %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
	if report.DryRun == nil {
		t.Fatal("DryRun = nil, want apply-pack to consume dry-run plan")
	}
	if report.ApplyPack == nil {
		t.Fatal("ApplyPack = nil, want generated apply-pack plan")
	}
	if len(report.ApplyPack.GeneratedEmailTemplates) == 0 {
		t.Fatalf("GeneratedEmailTemplates = 0, want safe source-backed templates: %#v", report.ApplyPack)
	}
	if len(report.ApplyPack.GeneratedFlows) == 0 {
		t.Fatalf("GeneratedFlows = 0, want safe sendEmail flows: %#v", report.ApplyPack)
	}
	assertGeneratedFlowForSource(t, report, constants.TableWorkflowAlert, "Account.Record_Template")
	assertGeneratedFlowSenderPolicy(t, report, constants.TableWorkflowAlert, "Account.Default_Workflow_User_Sender", sendEmailSenderPolicyDefault)
	assertSkippedApplyPackAction(t, report, constants.TableWorkflowAlert, "Account.Welcome_Email")
	assertSkippedApplyPackActionContains(t, report, constants.TableWorkflowAlert, "Account.Current_User_Sender", "depends on per-user sender authorization")
	assertSkippedApplyPackActionContains(t, report, constants.TableWorkflowAlert, "Account.Text_Email", "resolver-only WorkflowAlert recipients require an explicit no-recipient Flow branch")
	assertSkippedApplyPackActionContains(t, report, constants.TableWorkflowAlert, "Account.Formula_Email", "resolver-only WorkflowAlert recipients require an explicit no-recipient Flow branch")
	assertSkippedApplyPackActionContains(t, report, constants.TableWorkflowAlert, "Account.Multi_Root_Record", "ambiguous_template_record_root")

	outputA := t.TempDir()
	stalePath := filepath.Join(outputA, applyPackPath, "flows", "stale.flow.json")
	if err := os.MkdirAll(filepath.Dir(stalePath), 0o755); err != nil {
		t.Fatalf("create stale generated pack directory: %v", err)
	}
	if err := os.WriteFile(stalePath, []byte("{}\n"), 0o644); err != nil {
		t.Fatalf("write stale generated flow: %v", err)
	}
	if err := WriteReportsWithOptions(report, outputA, WriteReportsOptions{AllowBlockedApplyPack: true}); err != nil {
		t.Fatalf("WriteReports apply-pack A: %v", err)
	}
	outputB := t.TempDir()
	if err := WriteReportsWithOptions(report, outputB, WriteReportsOptions{AllowBlockedApplyPack: true}); err != nil {
		t.Fatalf("WriteReports apply-pack B: %v", err)
	}
	filesA := readGeneratedPackFiles(t, filepath.Join(outputA, applyPackPath))
	filesB := readGeneratedPackFiles(t, filepath.Join(outputB, applyPackPath))
	if !reflect.DeepEqual(filesA, filesB) {
		t.Fatalf("generated pack output is not deterministic\nA=%#v\nB=%#v", filesA, filesB)
	}
	if _, ok := filesA["flows/stale.flow.json"]; ok {
		t.Fatal("generated pack retained stale flow output")
	}

	packRoot := filepath.Join(outputA, applyPackPath)
	assertGeneratedPackLooksInstallable(t, packRoot)

	var sawSendEmail bool
	var sawNativeTemplate bool
	for path, text := range filesA {
		if strings.HasSuffix(path, ".flow.json") {
			if strings.Contains(text, `"actionType": "emailAlert"`) ||
				strings.Contains(text, "_System_WorkflowAlert") ||
				strings.Contains(text, "workflow_alert") {
				t.Fatalf("generated flow %s contains legacy WorkflowAlert/emailAlert runtime reference:\n%s", path, text)
			}
			if strings.Contains(text, `"actionType": "sendEmail"`) {
				sawSendEmail = true
			}
			if strings.Contains(text, `"actionType": "resolveEmailRecipients"`) {
				t.Fatalf("generated flow %s contains resolver-only recipient action without an explicit no-recipient branch:\n%s", path, text)
			}
		}
		if strings.Contains(path, "email_templates/") {
			if strings.Contains(text, "{!") {
				t.Fatalf("generated template %s still contains Salesforce merge syntax:\n%s", path, text)
			}
			if strings.Contains(text, "{{Record.Name}}") {
				sawNativeTemplate = true
			}
		}
	}
	if !sawSendEmail {
		t.Fatal("generated flows did not contain canonical sendEmail action")
	}
	if !sawNativeTemplate {
		t.Fatal("generated templates did not contain converted native Record token")
	}
	assertGeneratedRecordContextForRecordReferences(t, report.ApplyPack, filesA)
	assertGeneratedManifestDependencies(t, filesA["pack.json"], "salesforce-crm-standard")

	applyReportBytes, err := os.ReadFile(filepath.Join(outputA, "apply_pack_report.json"))
	if err != nil {
		t.Fatalf("read apply_pack_report.json: %v", err)
	}
	var applyReport ApplyPackPlan
	if err := json.Unmarshal(applyReportBytes, &applyReport); err != nil {
		t.Fatalf("parse apply_pack_report.json: %v", err)
	}
	if applyReport.PackPath != applyPackPath {
		t.Fatalf("apply_pack_report PackPath = %q, want %q", applyReport.PackPath, applyPackPath)
	}
}

func TestApplyPackSkipsRecordContextFlowWithoutWorkflowAlertObject(t *testing.T) {
	report := &Report{
		GeneratedAt: "2026-06-04T17:10:00Z",
		Mode:        ModeApplyPack,
		ReferencedTemplates: []EmailTemplateInventory{{
			ID:            "tmpl-record",
			Name:          "Record Template",
			DeveloperName: "Record_Template",
			Subject:       "Record {!Account.Name}",
			Body:          "Record body",
			TokenFindings: []TemplateTokenFinding{{
				Field:          constants.FieldSysEmailTemplate_Subject,
				Token:          "{!Account.Name}",
				Classification: "candidate",
				Replacement:    "{{Record.Name}}",
			}},
		}},
		DryRun: &DryRunPlan{ProposedSendEmailActions: []ProposedSendEmailAction{{
			SourceType: constants.TableWorkflowAlert,
			SourceKey:  "MissingObjectPrefix",
			ActionType: targetSendEmailAction,
			ActionName: "Send_Email",
			TemplateID: "tmpl-record",
			To:         []string{"record@example.com"},
		}}},
	}
	report.buildApplyPackPlan()
	if report.ApplyPack == nil {
		t.Fatal("ApplyPack = nil, want generated plan")
	}
	if got := len(report.ApplyPack.GeneratedFlows); got != 0 {
		t.Fatalf("GeneratedFlows = %d, want malformed record-context source to be skipped: %#v", got, report.ApplyPack.GeneratedFlows)
	}
	assertSkippedApplyPackActionContains(t, report, constants.TableWorkflowAlert, "MissingObjectPrefix", "record context object is required for generated $Record references")
}

func TestApplyPackSkipsRecordContextFlowWithoutFieldOwnerPack(t *testing.T) {
	report := &Report{
		GeneratedAt: "2026-06-04T17:12:00Z",
		Mode:        ModeApplyPack,
		ObjectFields: []ObjectFieldInventory{{
			ObjectAPIName: "Account",
			FieldAPIName:  "Name",
			Type:          string(constants.FieldTypeString),
		}},
		ReferencedTemplates: []EmailTemplateInventory{{
			ID:            "tmpl-record",
			Name:          "Record Template",
			DeveloperName: "Record_Template",
			Subject:       "Record {!Account.Name}",
			Body:          "Record body",
			TokenFindings: []TemplateTokenFinding{{
				Field:          constants.FieldSysEmailTemplate_Subject,
				Token:          "{!Account.Name}",
				Classification: "candidate",
				Replacement:    "{{Record.Name}}",
			}},
		}},
		DryRun: &DryRunPlan{ProposedSendEmailActions: []ProposedSendEmailAction{{
			SourceType: constants.TableWorkflowAlert,
			SourceKey:  "Account.Record_Template",
			ActionType: targetSendEmailAction,
			ActionName: "Send_Email",
			TemplateID: "tmpl-record",
			To:         []string{"record@example.com"},
		}}},
	}
	report.buildApplyPackPlan()
	if report.ApplyPack == nil {
		t.Fatal("ApplyPack = nil, want generated plan")
	}
	if got := len(report.ApplyPack.GeneratedFlows); got != 0 {
		t.Fatalf("GeneratedFlows = %d, want missing field OwnerPack to be skipped: %#v", got, report.ApplyPack.GeneratedFlows)
	}
	assertSkippedApplyPackActionContains(t, report, constants.TableWorkflowAlert, "Account.Record_Template", "has no OwnerPack")
}

func TestApplyPackCapsGeneratedDeveloperNamesForMaxLengthMetadata(t *testing.T) {
	maxTemplateDeveloper := "T" + strings.Repeat("a", 254)
	maxWorkflowAlertName := "A" + strings.Repeat("b", 254)
	report := &Report{
		GeneratedAt: "2026-06-04T17:15:00Z",
		Mode:        ModeApplyPack,
		ReferencedTemplates: []EmailTemplateInventory{{
			ID:            "tmpl-max-length",
			Name:          maxTemplateDeveloper,
			DeveloperName: maxTemplateDeveloper,
			Subject:       "Max length",
			Body:          "Max length body",
		}},
		DryRun: &DryRunPlan{ProposedSendEmailActions: []ProposedSendEmailAction{{
			SourceType: constants.TableWorkflowAlert,
			SourceKey:  "Account." + maxWorkflowAlertName,
			ActionType: targetSendEmailAction,
			ActionName: "Send_Email_" + maxWorkflowAlertName,
			TemplateID: "tmpl-max-length",
			To:         []string{"max-length@example.com"},
		}}},
	}
	report.buildApplyPackPlan()
	if report.ApplyPack == nil {
		t.Fatal("ApplyPack = nil, want generated plan")
	}
	if got := len(report.ApplyPack.GeneratedEmailTemplates); got != 1 {
		t.Fatalf("GeneratedEmailTemplates = %d, want 1: %#v", got, report.ApplyPack)
	}
	if got := len(report.ApplyPack.GeneratedFlows); got != 1 {
		t.Fatalf("GeneratedFlows = %d, want 1: %#v", got, report.ApplyPack)
	}

	template := report.ApplyPack.GeneratedEmailTemplates[0]
	flow := report.ApplyPack.GeneratedFlows[0]
	if len(template.DeveloperName) > developerNameMaxLength {
		t.Fatalf("template DeveloperName length = %d, want <= %d: %q", len(template.DeveloperName), developerNameMaxLength, template.DeveloperName)
	}
	if len(flow.FlowDeveloperName) > developerNameMaxLength {
		t.Fatalf("flow DeveloperName length = %d, want <= %d: %q", len(flow.FlowDeveloperName), developerNameMaxLength, flow.FlowDeveloperName)
	}
	if got, want := filepath.Base(filepath.FromSlash(template.FilePath)), template.DeveloperName+".json"; got != want {
		t.Fatalf("template FilePath base = %q, want %q", got, want)
	}
	if got, want := filepath.Base(filepath.FromSlash(flow.FilePath)), flow.FlowDeveloperName+".flow.json"; got != want {
		t.Fatalf("flow FilePath base = %q, want %q", got, want)
	}

	outputA := t.TempDir()
	if err := WriteReports(report, outputA); err != nil {
		t.Fatalf("WriteReports max length A: %v", err)
	}
	outputB := t.TempDir()
	if err := WriteReports(report, outputB); err != nil {
		t.Fatalf("WriteReports max length B: %v", err)
	}
	filesA := readGeneratedPackFiles(t, filepath.Join(outputA, applyPackPath))
	filesB := readGeneratedPackFiles(t, filepath.Join(outputB, applyPackPath))
	if !reflect.DeepEqual(filesA, filesB) {
		t.Fatalf("generated max-length pack output is not deterministic\nA=%#v\nB=%#v", filesA, filesB)
	}
	for path := range filesA {
		base := filepath.Base(filepath.FromSlash(path))
		if len(base) > developerNameMaxLength+len(".flow.json") {
			t.Fatalf("generated filename %q length = %d, want capped source-backed path component", base, len(base))
		}
	}
	assertGeneratedPackLooksInstallable(t, filepath.Join(outputA, applyPackPath))
}

func TestApplyPackDoesNotWriteArtifactsForBlockedReportWithoutOverride(t *testing.T) {
	outputDir := t.TempDir()
	staleGenerated := filepath.Join(outputDir, applyPackPath, "flows", "stale.flow.json")
	if err := os.MkdirAll(filepath.Dir(staleGenerated), 0o755); err != nil {
		t.Fatalf("create stale generated pack directory: %v", err)
	}
	if err := os.WriteFile(staleGenerated, []byte("{}\n"), 0o644); err != nil {
		t.Fatalf("write stale generated flow: %v", err)
	}
	if err := os.WriteFile(filepath.Join(outputDir, "apply_pack_report.json"), []byte("{}\n"), 0o644); err != nil {
		t.Fatalf("write stale apply_pack_report.json: %v", err)
	}

	report := &Report{
		GeneratedAt:    "2026-06-04T17:30:00Z",
		Mode:           ModeApplyPack,
		CutoverBlocked: true,
		Summary:        Summary{BlockerCount: 1},
		ApplyPack: &ApplyPackPlan{
			PackName: applyPackName,
			PackPath: applyPackPath,
			GeneratedFlows: []GeneratedFlowArtifact{{
				FlowDeveloperName: "Would_Write",
				FilePath:          "flows/would_write.flow.json",
				payload:           []byte("{}\n"),
			}},
		},
	}
	err := WriteReports(report, outputDir)
	if err == nil || !strings.Contains(err.Error(), "apply-pack artifact generation blocked") {
		t.Fatalf("WriteReports blocked apply-pack error = %v, want blocked artifact generation error", err)
	}
	if _, err := os.Stat(filepath.Join(outputDir, "migration_report.json")); err != nil {
		t.Fatalf("migration_report.json was not written for blocked apply-pack: %v", err)
	}
	if _, err := os.Stat(filepath.Join(outputDir, "migration_report.md")); err != nil {
		t.Fatalf("migration_report.md was not written for blocked apply-pack: %v", err)
	}
	if _, err := os.Stat(filepath.Join(outputDir, applyPackPath)); !os.IsNotExist(err) {
		t.Fatalf("generated pack directory exists after blocked apply-pack: %v", err)
	}
	if _, err := os.Stat(filepath.Join(outputDir, "apply_pack_report.json")); !os.IsNotExist(err) {
		t.Fatalf("apply_pack_report.json exists after blocked apply-pack: %v", err)
	}
}

func TestWriteReportsClearsStaleApplyPackArtifactsForNonApplyPackRun(t *testing.T) {
	outputDir := t.TempDir()
	staleGenerated := filepath.Join(outputDir, applyPackPath, "flows", "stale.flow.json")
	if err := os.MkdirAll(filepath.Dir(staleGenerated), 0o755); err != nil {
		t.Fatalf("create stale generated pack directory: %v", err)
	}
	if err := os.WriteFile(staleGenerated, []byte("{}\n"), 0o644); err != nil {
		t.Fatalf("write stale generated flow: %v", err)
	}
	if err := os.WriteFile(filepath.Join(outputDir, "apply_pack_report.json"), []byte("{}\n"), 0o644); err != nil {
		t.Fatalf("write stale apply_pack_report.json: %v", err)
	}

	report := &Report{
		GeneratedAt: "2026-06-04T17:45:00Z",
		Mode:        ModeDryRun,
	}
	if err := WriteReports(report, outputDir); err != nil {
		t.Fatalf("WriteReports dry-run: %v", err)
	}
	if _, err := os.Stat(filepath.Join(outputDir, "migration_report.json")); err != nil {
		t.Fatalf("migration_report.json was not written for dry-run: %v", err)
	}
	if _, err := os.Stat(filepath.Join(outputDir, "migration_report.md")); err != nil {
		t.Fatalf("migration_report.md was not written for dry-run: %v", err)
	}
	if _, err := os.Stat(filepath.Join(outputDir, applyPackPath)); !os.IsNotExist(err) {
		t.Fatalf("generated pack directory exists after non-apply-pack write: %v", err)
	}
	if _, err := os.Stat(filepath.Join(outputDir, "apply_pack_report.json")); !os.IsNotExist(err) {
		t.Fatalf("apply_pack_report.json exists after non-apply-pack write: %v", err)
	}
}

func TestWriteReportsPreservesApplyPackArtifactsForVerifyZeroRun(t *testing.T) {
	outputDir := t.TempDir()
	generatedFlow := filepath.Join(outputDir, applyPackPath, "flows", "migrated.flow.json")
	if err := os.MkdirAll(filepath.Dir(generatedFlow), 0o755); err != nil {
		t.Fatalf("create generated pack directory: %v", err)
	}
	if err := os.WriteFile(generatedFlow, []byte("{}\n"), 0o644); err != nil {
		t.Fatalf("write generated flow: %v", err)
	}
	applyReportPath := filepath.Join(outputDir, "apply_pack_report.json")
	if err := os.WriteFile(applyReportPath, []byte(`{"packPath":"generated-pack"}`+"\n"), 0o644); err != nil {
		t.Fatalf("write apply_pack_report.json: %v", err)
	}

	report := &Report{
		GeneratedAt: "2026-06-05T05:45:00Z",
		Mode:        ModeVerifyZero,
		VerifyZero:  &VerifyZeroGate{Passed: true, ScannedOfficialPackCount: 1},
	}
	if err := WriteReports(report, outputDir); err != nil {
		t.Fatalf("WriteReports verify-zero: %v", err)
	}
	if _, err := os.Stat(filepath.Join(outputDir, "migration_report.json")); err != nil {
		t.Fatalf("migration_report.json was not written for verify-zero: %v", err)
	}
	if _, err := os.Stat(filepath.Join(outputDir, "migration_report.md")); err != nil {
		t.Fatalf("migration_report.md was not written for verify-zero: %v", err)
	}
	if _, err := os.Stat(generatedFlow); err != nil {
		t.Fatalf("generated pack flow was not preserved after verify-zero: %v", err)
	}
	if _, err := os.Stat(applyReportPath); err != nil {
		t.Fatalf("apply_pack_report.json was not preserved after verify-zero: %v", err)
	}
}

func expectWorkflowAlertMigrationQueries(mock sqlmock.Sqlmock) {
	mock.ExpectQuery("FROM _System_WorkflowAlert").
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "ObjectApiName", "Name", "Description", "Template", "SenderType", "SenderAddress", "Recipients", "CcEmails", "CreatedDate", "LastModifiedDate",
		}).
			AddRow("wa-welcome", "Account", "Welcome_Email", "welcome", "tmpl-welcome", "", "", `[{"type":"email","recipient":"ops@example.com"},{"type":"user","recipient":"user-inline@example.com"},{"type":"user","recipient":"Bad <bad@example.com>"},{"type":"user","recipient":"005-user-1"},{"type":"user","recipient":"005-missing-user"},{"type":"user","recipient":"005-no-email"},{"type":"owner"},{"type":"email","recipient":"Ops <ops@example.com>"},{"type":"role","recipient":"Sales Team"},{"type":"group","recipient":"Support Queue"},{"type":"userlookup","field":"OwnerId"}]`, "cc@example.com, Bad <bad@example.com>", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z").
			AddRow("wa-fanout", "Account", "Multi_Recipient", "multi recipient", "tmpl-duplicate", "", "", `[{"type":"email","recipient":"first@example.com"},{"type":"email","recipient":"second@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z").
			AddRow("wa-duplicate-literal", "Account", "Duplicate_Literal", "duplicate literal recipient", "tmpl-duplicate", "", "", `[{"type":"email","recipient":"duplicate@example.com"},{"type":"email","recipient":"duplicate@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z").
			AddRow("wa-text-email", "Account", "Text_Email", "textarea email recipient", "tmpl-duplicate", "", "", `[{"type":"email","field":"Text_Email__c"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z").
			AddRow("wa-formula-email", "Account", "Formula_Email", "formula email recipient", "tmpl-duplicate", "", "", `[{"type":"email","field":"Formula_Email__c"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z").
			AddRow("wa-current-user-sender", "Account", "Current_User_Sender", "current user sender", "tmpl-duplicate", "CurrentUser", "", `[{"type":"email","recipient":"current-user-sender@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z").
			AddRow("wa-default-workflow-user-sender", "Account", "Default_Workflow_User_Sender", "default workflow user sender", "tmpl-duplicate", "DefaultWorkflowUser", "", `[{"type":"email","recipient":"default-workflow-user-sender@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z").
			AddRow("wa-dirty-default-workflow-user-sender", "Account", "Dirty_Default_Workflow_User_Sender", "dirty default workflow user sender", "tmpl-duplicate", "Default-Workflow-User", "", `[{"type":"email","recipient":"dirty-default-workflow-user-sender@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z").
			AddRow("wa-trailing-default-workflow-user-sender", "Account", "Trailing_Default_Workflow_User_Sender", "trailing default workflow user sender", "tmpl-duplicate", "DefaultWorkflowUser ", "", `[{"type":"email","recipient":"trailing-default-workflow-user-sender@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z").
			AddRow("wa-record-template", "Account", "Record_Template", "record template", "tmpl-account-record", "", "", `[{"type":"email","recipient":"record@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z").
			AddRow("wa-multi-root-account", "Account", "Multi_Root_Record", "multi-root account", "tmpl-multi-root-record", "", "", `[{"type":"email","recipient":"multi-account@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z").
			AddRow("wa-multi-root-case", "Case", "Multi_Root_Record", "multi-root case", "tmpl-multi-root-record", "", "", `[{"type":"email","recipient":"multi-case@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z").
			AddRow("wa-approval", "Opportunity", "Approval_Notice", "approval", "unfiled$public/legacyapproval", "OrgWideEmailAddress", "alerts@example.com", `[{"type":"email","field":"Owner.Email"},{"type":"owner"},{"type":"userlookup","field":"Owner.ManagerId"},{"type":"mystery","recipient":"Legacy"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z").
			AddRow("wa-dup-account", "Account", "Duplicate_Notice", "duplicate account", "tmpl-duplicate", "", "", `[{"type":"email","recipient":"account@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z").
			AddRow("wa-dup-case", "Case", "Duplicate_Notice", "duplicate case", "tmpl-duplicate", "", "", `[{"type":"email","recipient":"case@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z").
			AddRow("wa-case-notice", "Case", "Case_Notice", "case notice", "tmpl-duplicate", "", "", `[{"type":"email","recipient":"case-notice@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z").
			AddRow("wa-case-notice-lower", "case", "case_notice", "case notice lower", "tmpl-duplicate", "", "", `[{"type":"email","recipient":"case-notice-lower@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z"))

	mock.ExpectQuery("FROM ProcessAction a").
		WithArgs(constants.ApprovalStatusApproved, constants.ApprovalStatusRejected, constants.ApprovalStatusRemoved, constants.ProcessActionTypeAlert).
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "Name", "ProcessDefinitionId", "ProcessNodeId", "ActionEvent", "EmailTemplateId", "ApiName", "Order", "IsDeleted", "TableEnumOrId", "DeveloperName", "RuntimeReachable", "NodeDeveloperName", "NodeName", "RunnableInstanceCount",
		}).
			AddRow("pa-submit-alert", "Submit_Alert", "pd-account", "pn-submit", "Submit", "", "Welcome_Email", 1, 0, "Account", "Account_Approval", 1, "SubmitStep", "Submit Step", 0).
			AddRow("pa-cross-object-alert", "Cross_Object_Alert", "pd-opp-cross", "pn-cross", "Submit", "", "Welcome_Email", 2, 0, "Opportunity", "Opportunity_Cross_Approval", 1, "CrossStep", "Cross Step", 0).
			AddRow("pa-duplicate-alert", "Duplicate_Alert", "pd-duplicate", "pn-duplicate", "Submit", "", "Duplicate_Notice", 2, 0, "Account", "Duplicate_Approval", 1, "DuplicateStep", "Duplicate Step", 0).
			AddRow("pa-inline-alert", "Inline_Alert", "pd-opp", "pn-approval", "Approve", "tmpl-inline-approval", "", 2, 0, "Opportunity", "Opportunity_Approval", 1, "ApprovalStep", "Approval Step", 0).
			AddRow("pa-deleted", "Old_Alert", "pd-old", "", "Submit", "tmpl-old", "", 1, 1, "Case", "Old_Approval", 1, "", "", 0))

	metadata := `{
	  "start": {"object": "Account"},
	  "actionCalls": [
	    {"name": "Legacy_Email", "label": "Legacy Email", "actionName": "account.Welcome_Email", "actionType": "emailAlert"},
	    {"name": "Case_Duplicate_Email", "label": "Case Duplicate Email", "actionName": "case.Case_Notice", "actionType": "emailAlert"},
	    {"name": "Cross_Object_Email", "label": "Cross Object Email", "actionName": "Opportunity.Approval_Notice", "actionType": "emailAlert"},
	    {"name": "Native_Email", "actionName": "Send_Email", "actionType": "sendEmail"}
	  ]
	}`
	metadataNoStart := `{
	  "actionCalls": [
	    {"name": "Legacy_Email_No_Start", "label": "Legacy Email No Start", "actionName": "Welcome_Email", "actionType": "emailAlert"},
	    {"name": "Qualified_Email_No_Start", "label": "Qualified Email No Start", "actionName": "Account.Welcome_Email", "actionType": "emailAlert"},
	    {"name": "Literal_Email_No_Start", "label": "Literal Email No Start", "actionName": "Account.Duplicate_Notice", "actionType": "emailAlert", "faultConnector": {"targetReference": "Done"}},
	    {"name": "Blank_Fault_Target", "label": "Blank Fault Target", "actionName": "Account.Duplicate_Notice", "actionType": "emailAlert", "faultConnector": {}}
	  ]
	}`
	metadataAtomic := `{
	  "runAtomically": true,
	  "start": {"object": "Account"},
	  "actionCalls": [
	    {"name": "Atomic_Email", "label": "Atomic Email", "actionName": "Account.Welcome_Email", "actionType": "emailAlert"}
	  ]
	}`
	metadataWorkflow := `{
	  "variables": [
	    {"name": "myVariable_current", "dataType": "SObject", "objectType": "Opportunity"}
	  ],
	  "actionCalls": [
	    {"name": "Workflow_Email", "label": "Workflow Email", "actionName": "Opportunity.Approval_Notice", "actionType": "emailAlert", "faultConnector": {"targetReference": "Done"}}
	  ]
	}`
	mock.ExpectQuery("JOIN FlowDefinition fd").
		WithArgs(models.FlowInterviewStatusPaused, models.FlowInterviewStatusRunning).
		WillReturnRows(sqlmock.NewRows([]string{
			"DefinitionId", "DeveloperName", "DefinitionProcessType", "ActiveVersionId", "VersionId", "VersionProcessType", "VersionNumber", "Status", "Metadata", "TriggerObjectOrEvent", "ActiveVersion", "PausedInterviewCount", "RecoverableRunningInterviewCount",
		}).
			AddRow("fd-account", "Account_Flow", "AutoLaunchedFlow", "flow-v1", "flow-v1", "AutoLaunchedFlow", 1, "Active", metadata, "Account", 1, 0, 0).
			AddRow("fd-no-start", "Account_Flow_No_Start", "AutoLaunchedFlow", "flow-v2", "flow-v2", "AutoLaunchedFlow", 2, "Active", metadataNoStart, "Account", 1, 0, 0).
			AddRow("fd-atomic", "Atomic_Account_Flow", "AutoLaunchedFlow", "flow-v3", "flow-v3", "AutoLaunchedFlow", 3, "Active", metadataAtomic, "Account", 1, 0, 0).
			AddRow("fd-workflow", "Legacy_Workflow_Flow", "Workflow", "flow-v4", "flow-v4", "Workflow", 4, "Active", metadataWorkflow, "", 1, 0, 0))

	mock.ExpectQuery("FROM _System_Field f").
		WithArgs("Account", "Case", "Opportunity").
		WillReturnRows(sqlmock.NewRows([]string{
			"ObjectApiName", "FieldApiName", "Type", "ReturnType", "ReferenceTo", "RelationshipName", "OwnerPack", "IsDeleted",
		}).
			AddRow("Account", "Name", string(constants.FieldTypeString), "", "[]", "", "salesforce-crm-standard", 0).
			AddRow("Account", "OwnerId", string(constants.FieldTypeReference), "", `["User","Group"]`, "Owner", "salesforce-crm-standard", 0).
			AddRow("Account", "Text_Email__c", string(constants.FieldTypeTextarea), "", "[]", "", "salesforce-crm-standard", 0).
			AddRow("Account", "Formula_Email__c", string(constants.FieldTypeFormula), string(constants.FieldTypeString), "[]", "", "salesforce-crm-standard", 0).
			AddRow("Opportunity", "Name", string(constants.FieldTypeString), "", "[]", "", "salesforce-crm-standard", 0).
			AddRow("Opportunity", "OwnerId", string(constants.FieldTypeReference), "", `["User"]`, "Owner", "salesforce-crm-standard", 0))

	mock.ExpectQuery("FROM _System_Field f").
		WithArgs(constants.TableUser).
		WillReturnRows(sqlmock.NewRows([]string{
			"ObjectApiName", "FieldApiName", "Type", "ReturnType", "ReferenceTo", "RelationshipName", "OwnerPack", "IsDeleted",
		}).
			AddRow(constants.TableUser, constants.FieldEmail, string(constants.FieldTypeEmail), "", "[]", "", "platform-core", 0))

	mock.ExpectQuery("FROM _System_EmailTemplate").
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "Name", "DeveloperName", "OwnerPack", "FolderId", "IsActive", "IsDeleted", "Subject", "HtmlValue", "Body",
		}).
			AddRow("tmpl-welcome", "Welcome", "WelcomeTemplate", "crm", "folder-1", 1, 0, "Hello {!Account.Name} {!Account.name} {!Account.Owner.Email} {!Account.Owner}", "<p>{{Record.Owner.Name}} {{Record.Owner}} {{Record. Name}} {{Record.name}} {{Variables.Foo}}</p>", "Hi {{User.Email}} Broken {!Account.Name").
			AddRow("tmpl-legacy", "Legacy Approval", "LegacyApproval", "crm", "folder-1", 1, 0, "Approve {{Record.Name}} {!Recipient.Name}", "<a>{!ApprovalRequest.Internal_URL}</a>", "").
			AddRow("tmpl-duplicate", "Duplicate", "DuplicateTemplate", "crm", "folder-1", 1, 0, "Duplicate", "", "Duplicate body").
			AddRow("tmpl-account-record", "Account Record", "AccountRecordTemplate", "crm", "folder-1", 1, 0, "Hello {!Account.Name}", "", "Account body {{User.Email}}").
			AddRow("tmpl-multi-root-record", "Multi Root Record", "MultiRootRecordTemplate", "crm", "folder-1", 1, 0, "Hello {!Account.Name}", "", "Multi root body").
			AddRow("tmpl-inline-approval", "Inline Approval", "InlineApproval", "crm", "folder-1", 1, 0, "Approve {!Opportunity.Name}", "", "").
			AddRow("tmpl-old", "Old", "OldApproval", "crm", "folder-1", 1, 0, "Old", "", ""))

	mock.ExpectQuery("FROM `User`").
		WithArgs("005-missing-user", "005-no-email", "005-user-1").
		WillReturnRows(sqlmock.NewRows([]string{constants.FieldID, constants.FieldEmail}).
			AddRow("005-no-email", "").
			AddRow("005-user-1", "user-one@example.com"))
}

func expectVerifyZeroActiveDependencyQueries(mock sqlmock.Sqlmock) {
	mock.ExpectQuery("FROM _System_WorkflowAlert").
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "ObjectApiName", "Name", "Description", "Template", "SenderType", "SenderAddress", "Recipients", "CcEmails", "CreatedDate", "LastModifiedDate",
		}).
			AddRow("wa-welcome", "Account", "Welcome_Email", "welcome", "tmpl-welcome", "", "", `[{"type":"email","recipient":"ops@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z"))

	mock.ExpectQuery("FROM ProcessAction a").
		WithArgs(constants.ApprovalStatusApproved, constants.ApprovalStatusRejected, constants.ApprovalStatusRemoved, constants.ProcessActionTypeAlert).
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "Name", "ProcessDefinitionId", "ProcessNodeId", "ActionEvent", "EmailTemplateId", "ApiName", "Order", "IsDeleted", "TableEnumOrId", "DeveloperName", "RuntimeReachable", "NodeDeveloperName", "NodeName", "RunnableInstanceCount",
		}).
			AddRow("pa-submit-alert", "Submit_Alert", "pd-account", "pn-submit", "Submit", "", "Welcome_Email", 1, 0, "Account", "Account_Approval", 1, "SubmitStep", "Submit Step", 0))

	metadata := `{
	  "start": {"object": "Account"},
	  "actionCalls": [
	    {"name": "Legacy_Email", "label": "Legacy Email", "actionName": "Account.Welcome_Email", "actionType": "emailAlert"}
	  ]
	}`
	mock.ExpectQuery("JOIN FlowDefinition fd").
		WithArgs(models.FlowInterviewStatusPaused, models.FlowInterviewStatusRunning).
		WillReturnRows(sqlmock.NewRows([]string{
			"DefinitionId", "DeveloperName", "DefinitionProcessType", "ActiveVersionId", "VersionId", "VersionProcessType", "VersionNumber", "Status", "Metadata", "TriggerObjectOrEvent", "ActiveVersion", "PausedInterviewCount", "RecoverableRunningInterviewCount",
		}).
			AddRow("fd-account", "Account_Flow", "AutoLaunchedFlow", "flow-v1", "flow-v1", "AutoLaunchedFlow", 1, "Active", metadata, "Account", 1, 0, 0))
}

func expectVerifyZeroHistoricalOnlyQueries(mock sqlmock.Sqlmock) {
	mock.ExpectQuery("FROM _System_WorkflowAlert").
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "ObjectApiName", "Name", "Description", "Template", "SenderType", "SenderAddress", "Recipients", "CcEmails", "CreatedDate", "LastModifiedDate",
		}).
			AddRow("wa-old", "Account", "Old_Email", "old", "tmpl-old", "", "", `[{"type":"email","recipient":"old@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z").
			AddRow("wa-old-duplicate", "account", "old_email", "old duplicate", "tmpl-old", "", "", `[{"type":"email","recipient":"old2@example.com"}]`, "", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z"))

	mock.ExpectQuery("FROM ProcessAction a").
		WithArgs(constants.ApprovalStatusApproved, constants.ApprovalStatusRejected, constants.ApprovalStatusRemoved, constants.ProcessActionTypeAlert).
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "Name", "ProcessDefinitionId", "ProcessNodeId", "ActionEvent", "EmailTemplateId", "ApiName", "Order", "IsDeleted", "TableEnumOrId", "DeveloperName", "RuntimeReachable", "NodeDeveloperName", "NodeName", "RunnableInstanceCount",
		}))

	metadata := `{
	  "start": {"object": "Account"},
	  "actionCalls": [
	    {"name": "Native_Email", "label": "Native Email", "actionName": "Send_Email", "actionType": "sendEmail"}
	  ]
	}`
	mock.ExpectQuery("JOIN FlowDefinition fd").
		WithArgs(models.FlowInterviewStatusPaused, models.FlowInterviewStatusRunning).
		WillReturnRows(sqlmock.NewRows([]string{
			"DefinitionId", "DeveloperName", "DefinitionProcessType", "ActiveVersionId", "VersionId", "VersionProcessType", "VersionNumber", "Status", "Metadata", "TriggerObjectOrEvent", "ActiveVersion", "PausedInterviewCount", "RecoverableRunningInterviewCount",
		}).
			AddRow("fd-account", "Account_Flow", "AutoLaunchedFlow", "flow-v1", "flow-v1", "AutoLaunchedFlow", 1, "Active", metadata, "Account", 1, 0, 0))
}

func writeVerifyZeroCleanOfficialPackRoot(t *testing.T) string {
	t.Helper()
	root := t.TempDir()
	writeVerifyZeroFile(t, root, "clean-pack/pack.json", `{
  "manifestFormatVersion": "1",
  "name": "clean-pack",
  "category": "app-pack",
  "version": "0.1.0",
  "platformCompatibility": {"min": "0.1.0", "maxExclusive": "0.2.0"},
  "dependencies": [],
  "components": [
    {
      "kind": "standard_flow",
      "businessKey": "Clean_Flow",
      "manageability": "defaults_only",
      "path": "flows/Clean_Flow.flow.json"
    }
  ]
}`)
	writeVerifyZeroFile(t, root, "clean-pack/flows/Clean_Flow.flow.json", `{
  "status": "Active",
  "actionCalls": [
    {"name": "Send_Email", "label": "Send Email", "actionName": "sendEmail", "actionType": "sendEmail"}
  ]
}`)
	return root
}

func writeVerifyZeroLegacyOfficialPackRoot(t *testing.T) string {
	t.Helper()
	root := t.TempDir()
	writeVerifyZeroFile(t, root, "legacy-pack/pack.json", `{
  "manifestFormatVersion": "1",
  "name": "legacy-pack",
  "category": "app-pack",
  "version": "0.1.0",
  "platformCompatibility": {"min": "0.1.0", "maxExclusive": "0.2.0"},
  "dependencies": [],
  "components": [
    {
      "kind": "workflow_alert",
      "businessKey": "Account.Welcome_Email",
      "manageability": "locked",
      "path": "workflowAlerts/Welcome_Email.workflowAlert.json"
    },
    {
      "kind": "standard_flow",
      "businessKey": "Legacy_Flow",
      "manageability": "defaults_only",
      "path": "flows/Legacy_Flow.flow.json"
    },
    {
      "kind": "approval_process",
      "businessKey": "Account/Legacy_Approval",
      "manageability": "locked",
      "path": "approvalProcesses/Legacy_Approval.approvalProcess-meta.xml"
    }
  ]
}`)
	writeVerifyZeroFile(t, root, "legacy-pack/flows/Legacy_Flow.flow.json", `{
  "status": "Active",
  "actionCalls": [
    {"name": "Legacy_Email", "label": "Legacy Email", "actionName": "Account.Welcome_Email", "actionType": "emailAlert"}
  ]
}`)
	writeVerifyZeroFile(t, root, "legacy-pack/approvalProcesses/Legacy_Approval.approvalProcess-meta.xml", `<ApprovalProcess>
  <active>true</active>
  <label>Legacy Approval</label>
  <initialSubmissionActions>
    <action>
      <name>Submit_Email</name>
      <type>alert</type>
      <apiName>Account.Welcome_Email</apiName>
    </action>
  </initialSubmissionActions>
</ApprovalProcess>`)
	return root
}

func writeVerifyZeroFile(t *testing.T, root, relPath, contents string) {
	t.Helper()
	path := filepath.Join(root, filepath.FromSlash(relPath))
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatalf("mkdir %s: %v", relPath, err)
	}
	if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
		t.Fatalf("write %s: %v", relPath, err)
	}
}

func assertVerifyZeroDependency(t *testing.T, report *Report, dependencyType, sourceType, sourceKey string) {
	t.Helper()
	if report.VerifyZero == nil {
		t.Fatal("VerifyZero = nil")
	}
	for _, dependency := range report.VerifyZero.ActiveDependencies {
		if dependency.Type == dependencyType && dependency.SourceType == sourceType && dependency.SourceKey == sourceKey {
			return
		}
	}
	t.Fatalf("verify-zero dependency missing %s %s/%s: %#v", dependencyType, sourceType, sourceKey, report.VerifyZero.ActiveDependencies)
}

func assertUnsupportedCode(t *testing.T, report *Report, code string) {
	t.Helper()
	for _, item := range report.UnsupportedItems {
		if item.Code == code {
			return
		}
	}
	t.Fatalf("UnsupportedItems missing code %q: %#v", code, report.UnsupportedItems)
}

func assertUnsupportedItem(t *testing.T, report *Report, sourceType, sourceKey, code string) {
	t.Helper()
	for _, item := range report.UnsupportedItems {
		if item.SourceType == sourceType && item.SourceKey == sourceKey && item.Code == code {
			return
		}
	}
	t.Fatalf("UnsupportedItems missing %s/%s %s: %#v", sourceType, sourceKey, code, report.UnsupportedItems)
}

func assertNoUnsupportedItem(t *testing.T, report *Report, sourceType, sourceKey, code string) {
	t.Helper()
	for _, item := range report.UnsupportedItems {
		if item.SourceType == sourceType && item.SourceKey == sourceKey && item.Code == code {
			t.Fatalf("UnsupportedItems contained %s/%s %s: %#v", sourceType, sourceKey, code, item)
		}
	}
}

func assertWorkflowTemplateResolved(t *testing.T, report *Report, alertID, templateID string) {
	t.Helper()
	for _, alert := range report.WorkflowAlerts {
		if alert.ID != alertID {
			continue
		}
		if alert.ResolvedTemplateID == templateID {
			return
		}
		t.Fatalf("WorkflowAlert %s ResolvedTemplateID = %q, want %q", alertID, alert.ResolvedTemplateID, templateID)
	}
	t.Fatalf("WorkflowAlerts missing alert %s: %#v", alertID, report.WorkflowAlerts)
}

func assertTemplateTokenCandidate(t *testing.T, report *Report, templateID, token, replacement string) {
	t.Helper()
	for _, template := range report.ReferencedTemplates {
		if template.ID != templateID {
			continue
		}
		for _, finding := range template.TokenFindings {
			if finding.Token == token && finding.Classification == "candidate" && finding.Replacement == replacement {
				return
			}
		}
		t.Fatalf("template %s token findings = %#v, want candidate %s -> %s", templateID, template.TokenFindings, token, replacement)
	}
	t.Fatalf("ReferencedTemplates missing template %s: %#v", templateID, report.ReferencedTemplates)
}

func assertUnsupportedTemplateToken(t *testing.T, report *Report, templateID, token string) {
	t.Helper()
	for _, template := range report.ReferencedTemplates {
		if template.ID != templateID {
			continue
		}
		for _, finding := range template.TokenFindings {
			if finding.Token == token && (finding.Classification == "unsupported" || finding.Classification == "invalid_native_shape" || finding.Classification == "invalid_legacy_syntax") {
				return
			}
		}
		t.Fatalf("template %s token findings = %#v, want unsupported token %s", templateID, template.TokenFindings, token)
	}
	t.Fatalf("ReferencedTemplates missing template %s: %#v", templateID, report.ReferencedTemplates)
}

func assertRecipientLiteral(t *testing.T, report *Report, alertID, recipient string) {
	t.Helper()
	for _, alert := range report.WorkflowAlerts {
		if alert.ID != alertID {
			continue
		}
		for _, item := range alert.RecipientInventory {
			if item.Recipient == recipient && item.Classification == "literal_email" && item.BlockedReason == "" {
				return
			}
		}
		t.Fatalf("alert %s recipient inventory = %#v, want literal recipient %s", alertID, alert.RecipientInventory, recipient)
	}
	t.Fatalf("WorkflowAlerts missing alert %s: %#v", alertID, report.WorkflowAlerts)
}

func assertRecipientBlocked(t *testing.T, report *Report, alertID, recipient string) {
	t.Helper()
	for _, alert := range report.WorkflowAlerts {
		if alert.ID != alertID {
			continue
		}
		for _, item := range alert.RecipientInventory {
			if item.Recipient == recipient && item.BlockedReason != "" {
				return
			}
		}
		t.Fatalf("alert %s recipient inventory = %#v, want blocked recipient %s", alertID, alert.RecipientInventory, recipient)
	}
	t.Fatalf("WorkflowAlerts missing alert %s: %#v", alertID, report.WorkflowAlerts)
}

func assertRecipientResolvedEmail(t *testing.T, report *Report, alertID, recipient, email string) {
	t.Helper()
	for _, alert := range report.WorkflowAlerts {
		if alert.ID != alertID {
			continue
		}
		for _, item := range alert.RecipientInventory {
			if item.Recipient == recipient && item.ResolvedRecipientEmail == email {
				return
			}
		}
		t.Fatalf("alert %s recipient inventory = %#v, want recipient %s resolved to %s", alertID, alert.RecipientInventory, recipient, email)
	}
	t.Fatalf("WorkflowAlerts missing alert %s: %#v", alertID, report.WorkflowAlerts)
}

func assertRecipientFieldBlocked(t *testing.T, report *Report, alertID, field string) {
	t.Helper()
	for _, alert := range report.WorkflowAlerts {
		if alert.ID != alertID {
			continue
		}
		for _, item := range alert.RecipientInventory {
			if item.Field == field && item.BlockedReason != "" {
				return
			}
		}
		t.Fatalf("alert %s recipient inventory = %#v, want blocked field %s", alertID, alert.RecipientInventory, field)
	}
	t.Fatalf("WorkflowAlerts missing alert %s: %#v", alertID, report.WorkflowAlerts)
}

func assertRecipientFieldOK(t *testing.T, report *Report, alertID, field, fieldType string) {
	t.Helper()
	for _, alert := range report.WorkflowAlerts {
		if alert.ID != alertID {
			continue
		}
		for _, item := range alert.RecipientInventory {
			if item.Field == field && item.BlockedReason == "" && item.FieldValidation == "ok" && item.ResolvedFieldType == fieldType {
				return
			}
		}
		t.Fatalf("alert %s recipient inventory = %#v, want ok field %s type %s", alertID, alert.RecipientInventory, field, fieldType)
	}
	t.Fatalf("WorkflowAlerts missing alert %s: %#v", alertID, report.WorkflowAlerts)
}

func assertProcessActionWorkflowAlertUnresolved(t *testing.T, report *Report, actionID string) {
	t.Helper()
	for _, action := range report.ProcessActionAlerts {
		if action.ID != actionID {
			continue
		}
		if action.WorkflowAlertKey == "" {
			return
		}
		t.Fatalf("ProcessAction %s WorkflowAlertKey = %q, want empty for ambiguous runtime lookup", actionID, action.WorkflowAlertKey)
	}
	t.Fatalf("ProcessActionAlerts missing action %s: %#v", actionID, report.ProcessActionAlerts)
}

func assertProposedAction(t *testing.T, report *Report, sourceType, sourceKey, templateID string) {
	t.Helper()
	for _, action := range report.DryRun.ProposedSendEmailActions {
		if action.SourceType == sourceType && action.SourceKey == sourceKey && action.TemplateID == templateID {
			if action.ActionType != targetSendEmailAction {
				t.Fatalf("proposed action type = %q, want %q", action.ActionType, targetSendEmailAction)
			}
			return
		}
	}
	t.Fatalf("missing proposed action %s/%s template %s: %#v", sourceType, sourceKey, templateID, report.DryRun.ProposedSendEmailActions)
}

func assertProposedActionHasTo(t *testing.T, report *Report, sourceType, sourceKey, to string) {
	t.Helper()
	for _, action := range report.DryRun.ProposedSendEmailActions {
		if action.SourceType != sourceType || action.SourceKey != sourceKey {
			continue
		}
		for _, got := range action.To {
			if got == to {
				return
			}
		}
		t.Fatalf("proposed action %s/%s To = %#v, want %q", sourceType, sourceKey, action.To, to)
	}
	t.Fatalf("missing proposed action %s/%s: %#v", sourceType, sourceKey, report.DryRun.ProposedSendEmailActions)
}

func assertProposedActionExcludesTo(t *testing.T, report *Report, sourceType, sourceKey, to string) {
	t.Helper()
	for _, action := range report.DryRun.ProposedSendEmailActions {
		if action.SourceType != sourceType || action.SourceKey != sourceKey {
			continue
		}
		for _, got := range action.To {
			if got == to {
				t.Fatalf("proposed action %s/%s has To %q despite blocked fan-out shape: %#v", sourceType, sourceKey, to, action)
			}
		}
		return
	}
	t.Fatalf("missing proposed action %s/%s: %#v", sourceType, sourceKey, report.DryRun.ProposedSendEmailActions)
}

func assertProposedActionToCount(t *testing.T, report *Report, sourceType, sourceKey string, count int) {
	t.Helper()
	for _, action := range report.DryRun.ProposedSendEmailActions {
		if action.SourceType != sourceType || action.SourceKey != sourceKey {
			continue
		}
		if len(action.To) != count {
			t.Fatalf("proposed action %s/%s To = %#v, want %d recipient(s)", sourceType, sourceKey, action.To, count)
		}
		return
	}
	t.Fatalf("missing proposed action %s/%s: %#v", sourceType, sourceKey, report.DryRun.ProposedSendEmailActions)
}

func assertProposedActionIncludesPrerequisite(t *testing.T, report *Report, sourceType, sourceKey, prerequisite string) {
	t.Helper()
	for _, action := range report.DryRun.ProposedSendEmailActions {
		if action.SourceType != sourceType || action.SourceKey != sourceKey {
			continue
		}
		for _, got := range action.Prerequisites {
			if got == prerequisite {
				return
			}
		}
		t.Fatalf("proposed action %s/%s prerequisites = %#v, want %q", sourceType, sourceKey, action.Prerequisites, prerequisite)
	}
	t.Fatalf("missing proposed action %s/%s: %#v", sourceType, sourceKey, report.DryRun.ProposedSendEmailActions)
}

func assertProposedActionExcludesPrerequisite(t *testing.T, report *Report, sourceType, sourceKey, prerequisite string) {
	t.Helper()
	for _, action := range report.DryRun.ProposedSendEmailActions {
		if action.SourceType != sourceType || action.SourceKey != sourceKey {
			continue
		}
		for _, got := range action.Prerequisites {
			if got == prerequisite {
				t.Fatalf("proposed action %s/%s has unsupported prerequisite %q: %#v", sourceType, sourceKey, prerequisite, action)
			}
		}
		return
	}
	t.Fatalf("missing proposed action %s/%s: %#v", sourceType, sourceKey, report.DryRun.ProposedSendEmailActions)
}

func assertProposedActionBlocked(t *testing.T, report *Report, sourceType, sourceKey, blockedBy string) {
	t.Helper()
	for _, action := range report.DryRun.ProposedSendEmailActions {
		if action.SourceType != sourceType || action.SourceKey != sourceKey {
			continue
		}
		for _, got := range action.BlockedBy {
			if got == blockedBy {
				return
			}
		}
		t.Fatalf("proposed action %s/%s blockedBy = %#v, want %q", sourceType, sourceKey, action.BlockedBy, blockedBy)
	}
	t.Fatalf("missing proposed action %s/%s: %#v", sourceType, sourceKey, report.DryRun.ProposedSendEmailActions)
}

func assertProposedActionNotBlocked(t *testing.T, report *Report, sourceType, sourceKey, blockedBy string) {
	t.Helper()
	for _, action := range report.DryRun.ProposedSendEmailActions {
		if action.SourceType != sourceType || action.SourceKey != sourceKey {
			continue
		}
		for _, got := range action.BlockedBy {
			if got == blockedBy {
				t.Fatalf("proposed action %s/%s unexpectedly blocked by %q: %#v", sourceType, sourceKey, blockedBy, action.BlockedBy)
			}
		}
		return
	}
	t.Fatalf("missing proposed action %s/%s: %#v", sourceType, sourceKey, report.DryRun.ProposedSendEmailActions)
}

func assertProposedActionSenderPolicy(t *testing.T, report *Report, sourceType, sourceKey, senderType string) {
	t.Helper()
	for _, action := range report.DryRun.ProposedSendEmailActions {
		if action.SourceType != sourceType || action.SourceKey != sourceKey {
			continue
		}
		if action.SenderPolicy == nil {
			t.Fatalf("proposed action %s/%s SenderPolicy = nil, want %q", sourceType, sourceKey, senderType)
		}
		if action.SenderPolicy.Type != senderType {
			t.Fatalf("proposed action %s/%s SenderPolicy.Type = %q, want %q", sourceType, sourceKey, action.SenderPolicy.Type, senderType)
		}
		return
	}
	t.Fatalf("missing proposed action %s/%s: %#v", sourceType, sourceKey, report.DryRun.ProposedSendEmailActions)
}

func assertProposedActionBlockedContains(t *testing.T, report *Report, sourceType, sourceKey, contains string) {
	t.Helper()
	for _, action := range report.DryRun.ProposedSendEmailActions {
		if action.SourceType != sourceType || action.SourceKey != sourceKey {
			continue
		}
		for _, got := range action.BlockedBy {
			if strings.Contains(got, contains) {
				return
			}
		}
		t.Fatalf("proposed action %s/%s blockedBy = %#v, want item containing %q", sourceType, sourceKey, action.BlockedBy, contains)
	}
	t.Fatalf("missing proposed action %s/%s: %#v", sourceType, sourceKey, report.DryRun.ProposedSendEmailActions)
}

func assertGeneratedFlowSenderPolicy(t *testing.T, report *Report, sourceType, sourceKey, senderType string) {
	t.Helper()
	for _, flow := range report.ApplyPack.GeneratedFlows {
		if flow.SourceType != sourceType || flow.SourceKey != sourceKey {
			continue
		}
		if flow.SenderPolicy == nil {
			t.Fatalf("generated flow %s/%s SenderPolicy = nil, want %q", sourceType, sourceKey, senderType)
		}
		if flow.SenderPolicy.Type != senderType {
			t.Fatalf("generated flow %s/%s SenderPolicy.Type = %q, want %q", sourceType, sourceKey, flow.SenderPolicy.Type, senderType)
		}

		var payload generatedFlowPayload
		if err := json.Unmarshal(flow.payload, &payload); err != nil {
			t.Fatalf("parse generated flow %s payload: %v\n%s", flow.FilePath, err, string(flow.payload))
		}
		for _, action := range payload.ActionCalls {
			if action.ActionType != targetSendEmailAction {
				continue
			}
			for _, param := range action.InputParameters {
				if param.Name != "from" {
					continue
				}
				value, ok := param.Value.(map[string]interface{})
				if !ok {
					t.Fatalf("generated flow %s from parameter = %#v, want object", flow.FilePath, param.Value)
				}
				if got, _ := value["type"].(string); got != senderType {
					t.Fatalf("generated flow %s from.type = %q, want %q", flow.FilePath, got, senderType)
				}
				return
			}
		}
		t.Fatalf("generated flow %s missing sendEmail from parameter in payload:\n%s", flow.FilePath, string(flow.payload))
	}
	t.Fatalf("generated flows missing %s/%s: %#v", sourceType, sourceKey, report.ApplyPack.GeneratedFlows)
}

func assertGeneratedFlowForSource(t *testing.T, report *Report, sourceType, sourceKey string) {
	t.Helper()
	var generatedSources []string
	var targetSkips []string
	for _, flow := range report.ApplyPack.GeneratedFlows {
		generatedSources = append(generatedSources, flow.SourceType+"/"+flow.SourceKey)
		if flow.SourceType == sourceType && flow.SourceKey == sourceKey {
			if flow.TemplateID == "" || flow.TemplateDeveloper == "" {
				t.Fatalf("generated flow %s/%s missing generated template linkage: %#v", sourceType, sourceKey, flow)
			}
			return
		}
	}
	for _, skipped := range report.ApplyPack.SkippedActions {
		if skipped.SourceType == sourceType && skipped.SourceKey == sourceKey {
			targetSkips = skipped.Reasons
			break
		}
	}
	t.Fatalf("generated flows missing %s/%s: generated=%v targetSkippedReasons=%v", sourceType, sourceKey, generatedSources, targetSkips)
}

func assertGeneratedRecordContextForRecordReferences(t *testing.T, plan *ApplyPackPlan, files map[string]string) {
	t.Helper()
	if plan == nil {
		t.Fatal("ApplyPack = nil, want generated apply-pack plan")
	}
	templateTextByDeveloper := map[string]string{}
	for _, template := range plan.GeneratedEmailTemplates {
		text, ok := files[template.FilePath]
		if !ok {
			t.Fatalf("generated template %s missing file %s", template.DeveloperName, template.FilePath)
		}
		templateTextByDeveloper[template.DeveloperName] = text
	}

	for _, flow := range plan.GeneratedFlows {
		flowText, ok := files[flow.FilePath]
		if !ok {
			t.Fatalf("generated flow %s missing file %s", flow.FlowDeveloperName, flow.FilePath)
		}
		templateText := templateTextByDeveloper[flow.TemplateDeveloper]
		if !strings.Contains(flowText, "$Record.") && !strings.Contains(templateText, "{{Record.") {
			continue
		}

		wantObject := applyPackWorkflowAlertObject(ProposedSendEmailAction{
			SourceType: flow.SourceType,
			SourceKey:  flow.SourceKey,
		})
		if wantObject == "" {
			t.Fatalf("generated flow %s has record references but no derivable WorkflowAlert object: %#v", flow.FlowDeveloperName, flow)
		}
		if !flow.RequiresRecordContext {
			t.Fatalf("generated flow %s RequiresRecordContext = false, want true for record references", flow.FlowDeveloperName)
		}
		if flow.RecordContextObject != wantObject {
			t.Fatalf("generated flow %s RecordContextObject = %q, want %q", flow.FlowDeveloperName, flow.RecordContextObject, wantObject)
		}
		if len(flow.RecordFieldDependencies) == 0 {
			t.Fatalf("generated flow %s has record references but no record field dependencies", flow.FlowDeveloperName)
		}
		if !stringSliceContains(flow.RequiredPackDependencies, "salesforce-crm-standard") {
			t.Fatalf("generated flow %s RequiredPackDependencies = %#v, want salesforce-crm-standard", flow.FlowDeveloperName, flow.RequiredPackDependencies)
		}

		var payload generatedFlowPayload
		if err := json.Unmarshal([]byte(flowText), &payload); err != nil {
			t.Fatalf("parse generated flow %s: %v\n%s", flow.FilePath, err, flowText)
		}
		recordVariableCount := 0
		for _, variable := range payload.Variables {
			if variable.Name != "$Record" {
				continue
			}
			recordVariableCount++
			if variable.DataType != "SObject" || !variable.IsInput || variable.ObjectType != wantObject {
				t.Fatalf("generated flow %s $Record variable = %#v, want SObject input for %s", flow.FlowDeveloperName, variable, wantObject)
			}
		}
		if recordVariableCount != 1 {
			t.Fatalf("generated flow %s $Record variable count = %d, want 1 in variables %#v", flow.FlowDeveloperName, recordVariableCount, payload.Variables)
		}
	}
}

func assertGeneratedManifestDependencies(t *testing.T, manifestText string, wantPack string) {
	t.Helper()
	var manifest generatedPackManifest
	if err := json.Unmarshal([]byte(manifestText), &manifest); err != nil {
		t.Fatalf("parse generated manifest: %v\n%s", err, manifestText)
	}
	for _, dependency := range manifest.Dependencies {
		if dependency.Name == wantPack {
			return
		}
	}
	t.Fatalf("generated manifest dependencies = %#v, want pack dependency %q", manifest.Dependencies, wantPack)
}

func assertSkippedApplyPackAction(t *testing.T, report *Report, sourceType, sourceKey string) {
	t.Helper()
	for _, skipped := range report.ApplyPack.SkippedActions {
		if skipped.SourceType == sourceType && skipped.SourceKey == sourceKey && len(skipped.Reasons) > 0 {
			return
		}
	}
	t.Fatalf("skipped apply-pack actions missing %s/%s: %#v", sourceType, sourceKey, report.ApplyPack.SkippedActions)
}

func assertSkippedApplyPackActionContains(t *testing.T, report *Report, sourceType, sourceKey, contains string) {
	t.Helper()
	for _, skipped := range report.ApplyPack.SkippedActions {
		if skipped.SourceType != sourceType || skipped.SourceKey != sourceKey {
			continue
		}
		for _, reason := range skipped.Reasons {
			if strings.Contains(reason, contains) {
				return
			}
		}
		t.Fatalf("skipped apply-pack action %s/%s reasons = %#v, want item containing %q", sourceType, sourceKey, skipped.Reasons, contains)
	}
	t.Fatalf("skipped apply-pack actions missing %s/%s: %#v", sourceType, sourceKey, report.ApplyPack.SkippedActions)
}

func readGeneratedPackFiles(t *testing.T, root string) map[string]string {
	t.Helper()
	out := map[string]string{}
	var paths []string
	if err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if entry.IsDir() {
			return nil
		}
		paths = append(paths, path)
		return nil
	}); err != nil {
		t.Fatalf("walk generated pack %s: %v", root, err)
	}
	sort.Strings(paths)
	for _, path := range paths {
		raw, err := os.ReadFile(path)
		if err != nil {
			t.Fatalf("read generated pack file %s: %v", path, err)
		}
		rel, err := filepath.Rel(root, path)
		if err != nil {
			t.Fatalf("relative path for %s: %v", path, err)
		}
		out[filepath.ToSlash(rel)] = string(raw)
	}
	return out
}

func stringSliceContains(values []string, want string) bool {
	for _, value := range values {
		if value == want {
			return true
		}
	}
	return false
}
