package main

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"regexp"
	"sort"
	"strings"
	"time"

	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

type Mode string

const (
	ModeInventory  Mode = "inventory"
	ModeDryRun     Mode = "dry-run"
	ModeApplyPack  Mode = "apply-pack"
	ModeVerifyZero Mode = "verify-zero"

	sourceStatusActive     = "active"
	sourceStatusHistorical = "historical"

	flowEmailAlertActionType = "emailAlert"
	targetSendEmailAction    = "sendEmail"

	sendEmailSenderPolicyDefault     = "default"
	sendEmailSenderPolicyCurrentUser = "currentUser"
)

type Options struct {
	Mode             Mode
	Now              func() time.Time
	OfficialPackRoot string
}

type Summary struct {
	WorkflowAlertCount           int `json:"workflowAlertCount"`
	ActiveWorkflowAlertCount     int `json:"activeWorkflowAlertCount"`
	HistoricalWorkflowAlertCount int `json:"historicalWorkflowAlertCount"`
	ProcessActionAlertCount      int `json:"processActionAlertCount"`
	ActiveProcessActionCount     int `json:"activeProcessActionCount"`
	FlowEmailAlertCount          int `json:"flowEmailAlertCount"`
	ObjectFieldCount             int `json:"objectFieldCount"`
	ReferencedTemplateCount      int `json:"referencedTemplateCount"`
	UnsupportedItemCount         int `json:"unsupportedItemCount"`
	BlockerCount                 int `json:"blockerCount"`
}

type Report struct {
	GeneratedAt         string                    `json:"generatedAt"`
	Mode                Mode                      `json:"mode"`
	Summary             Summary                   `json:"summary"`
	CutoverBlocked      bool                      `json:"cutoverBlocked"`
	WorkflowAlerts      []WorkflowAlertInventory  `json:"workflowAlerts"`
	ProcessActionAlerts []ProcessActionInventory  `json:"processActionAlerts"`
	FlowEmailAlerts     []FlowEmailAlertInventory `json:"flowEmailAlerts"`
	ObjectFields        []ObjectFieldInventory    `json:"objectFields,omitempty"`
	ReferencedTemplates []EmailTemplateInventory  `json:"referencedTemplates"`
	UnsupportedItems    []UnsupportedItem         `json:"unsupportedItems,omitempty"`
	DryRun              *DryRunPlan               `json:"dryRun,omitempty"`
	ApplyPack           *ApplyPackPlan            `json:"applyPack,omitempty"`
	VerifyZero          *VerifyZeroGate           `json:"verifyZero,omitempty"`

	templatesByID         map[string]EmailTemplateInventory
	templatesByDeveloper  map[string][]EmailTemplateInventory
	workflowsByKey        map[string]WorkflowAlertInventory
	workflowDuplicateKeys map[string][]WorkflowAlertInventory
	fieldsByObject        map[string]map[string]ObjectFieldInventory
	loadedFieldObjects    map[string]struct{}
	templateRefSet        map[string]struct{}
}

type WorkflowAlertInventory struct {
	ID                  string                   `json:"id"`
	ObjectAPIName       string                   `json:"objectApiName"`
	Name                string                   `json:"name"`
	Description         string                   `json:"description,omitempty"`
	Status              string                   `json:"status"`
	StatusReason        string                   `json:"statusReason,omitempty"`
	TemplateRef         string                   `json:"templateRef,omitempty"`
	ResolvedTemplateID  string                   `json:"resolvedTemplateId,omitempty"`
	TemplateResolution  string                   `json:"templateResolution,omitempty"`
	SenderType          string                   `json:"senderType,omitempty"`
	SenderAddress       string                   `json:"senderAddress,omitempty"`
	RecipientsJSON      string                   `json:"recipientsJson,omitempty"`
	RecipientInventory  []RecipientInventoryItem `json:"recipientInventory,omitempty"`
	CcEmails            []string                 `json:"ccEmails,omitempty"`
	CreatedDate         string                   `json:"createdDate,omitempty"`
	LastModifiedDate    string                   `json:"lastModifiedDate,omitempty"`
	HistoricalInventory *bool                    `json:"historicalInventory,omitempty"`
	RuntimeReachable    *bool                    `json:"runtimeReachable,omitempty"`
}

type RecipientInventoryItem struct {
	Type                   string   `json:"type"`
	Recipient              string   `json:"recipient,omitempty"`
	Field                  string   `json:"field,omitempty"`
	Classification         string   `json:"classification"`
	TargetHint             string   `json:"targetHint,omitempty"`
	FieldValidation        string   `json:"fieldValidation,omitempty"`
	ResolvedFieldType      string   `json:"resolvedFieldType,omitempty"`
	ResolvedReferenceTo    []string `json:"resolvedReferenceTo,omitempty"`
	ResolvedRecipientEmail string   `json:"resolvedRecipientEmail,omitempty"`
	BlockedReason          string   `json:"blockedReason,omitempty"`
}

type ProcessActionInventory struct {
	ID                                string `json:"id"`
	Name                              string `json:"name"`
	ProcessDefinitionID               string `json:"processDefinitionId"`
	ProcessDefinitionName             string `json:"processDefinitionName,omitempty"`
	ProcessDefinitionObject           string `json:"processDefinitionObject,omitempty"`
	ProcessDefinitionActive           bool   `json:"processDefinitionActive"`
	ProcessDefinitionRuntimeReachable bool   `json:"processDefinitionRuntimeReachable"`
	ProcessNodeID                     string `json:"processNodeId,omitempty"`
	ProcessNodeName                   string `json:"processNodeName,omitempty"`
	ProcessNodeDeveloperName          string `json:"processNodeDeveloperName,omitempty"`
	ActionEvent                       string `json:"actionEvent,omitempty"`
	EmailTemplateID                   string `json:"emailTemplateId,omitempty"`
	APIName                           string `json:"apiName,omitempty"`
	Order                             int    `json:"order,omitempty"`
	RunnableInstanceCount             int    `json:"runnableInstanceCount,omitempty"`
	Status                            string `json:"status"`
	StatusReason                      string `json:"statusReason,omitempty"`
	WorkflowAlertKey                  string `json:"workflowAlertKey,omitempty"`
}

type FlowEmailAlertInventory struct {
	FlowDefinitionID                 string `json:"flowDefinitionId"`
	FlowDeveloperName                string `json:"flowDeveloperName"`
	FlowVersionID                    string `json:"flowVersionId"`
	FlowVersionNumber                int    `json:"flowVersionNumber"`
	FlowStatus                       string `json:"flowStatus"`
	RuntimeState                     string `json:"runtimeState"`
	PausedInterviewCount             int    `json:"pausedInterviewCount,omitempty"`
	RecoverableRunningInterviewCount int    `json:"recoverableRunningInterviewCount,omitempty"`
	StartObject                      string `json:"startObject,omitempty"`
	ElementName                      string `json:"elementName"`
	ElementLabel                     string `json:"elementLabel,omitempty"`
	ActionName                       string `json:"actionName"`
	ObjectAPIName                    string `json:"objectApiName,omitempty"`
	AlertName                        string `json:"alertName,omitempty"`
	WorkflowAlertKey                 string `json:"workflowAlertKey,omitempty"`
	HasFaultConnector                bool   `json:"hasFaultConnector"`
	FaultConnectorTarget             string `json:"faultConnectorTarget,omitempty"`
	RunAtomically                    bool   `json:"runAtomically,omitempty"`
	ActionNameScoped                 bool   `json:"actionNameScoped"`
	RecordContextObject              string `json:"recordContextObject,omitempty"`
}

type ObjectFieldInventory struct {
	ObjectAPIName    string   `json:"objectApiName"`
	FieldAPIName     string   `json:"fieldApiName"`
	Type             string   `json:"type"`
	ReturnType       string   `json:"returnType,omitempty"`
	ReferenceTo      []string `json:"referenceTo,omitempty"`
	RelationshipName string   `json:"relationshipName,omitempty"`
	OwnerPack        string   `json:"ownerPack,omitempty"`
	IsDeleted        bool     `json:"isDeleted"`
}

type EmailTemplateInventory struct {
	ID            string                 `json:"id"`
	Name          string                 `json:"name,omitempty"`
	DeveloperName string                 `json:"developerName,omitempty"`
	OwnerPack     string                 `json:"ownerPack,omitempty"`
	FolderID      string                 `json:"folderId,omitempty"`
	IsActive      bool                   `json:"isActive"`
	IsDeleted     bool                   `json:"isDeleted"`
	Subject       string                 `json:"subject,omitempty"`
	HTMLValue     string                 `json:"htmlValue,omitempty"`
	Body          string                 `json:"body,omitempty"`
	TokenFindings []TemplateTokenFinding `json:"tokenFindings,omitempty"`
	ReferencedBy  []string               `json:"referencedBy,omitempty"`
}

type TemplateTokenFinding struct {
	Field          string `json:"field"`
	Token          string `json:"token"`
	Classification string `json:"classification"`
	Replacement    string `json:"replacement,omitempty"`
	Message        string `json:"message,omitempty"`
}

type UnsupportedItem struct {
	Severity       string `json:"severity"`
	SourceType     string `json:"sourceType"`
	SourceKey      string `json:"sourceKey"`
	Code           string `json:"code"`
	Field          string `json:"field,omitempty"`
	Value          string `json:"value,omitempty"`
	Message        string `json:"message"`
	Recommendation string `json:"recommendation,omitempty"`
}

type VerifyZeroGate struct {
	Passed                   bool                   `json:"passed"`
	OfficialPackRoot         string                 `json:"officialPackRoot,omitempty"`
	ScannedOfficialPackCount int                    `json:"scannedOfficialPackCount"`
	ActiveDependencyCount    int                    `json:"activeDependencyCount"`
	ActiveDependencies       []VerifyZeroDependency `json:"activeDependencies,omitempty"`
	HistoricalWorkflowRows   []VerifyZeroHistorical `json:"historicalWorkflowRows,omitempty"`
	ExistingBlockerCount     int                    `json:"existingBlockerCount,omitempty"`
}

type VerifyZeroDependency struct {
	Type           string   `json:"type"`
	SourceType     string   `json:"sourceType"`
	SourceKey      string   `json:"sourceKey"`
	Identifier     string   `json:"identifier,omitempty"`
	Owner          string   `json:"owner,omitempty"`
	RuntimePath    string   `json:"runtimePath"`
	Message        string   `json:"message"`
	Recommendation string   `json:"recommendation"`
	References     []string `json:"references,omitempty"`
}

type VerifyZeroHistorical struct {
	SourceKey           string `json:"sourceKey"`
	ID                  string `json:"id,omitempty"`
	HistoricalInventory bool   `json:"historicalInventory"`
	RuntimeReachable    bool   `json:"runtimeReachable"`
	Reason              string `json:"reason"`
}

type DryRunPlan struct {
	ProposedSendEmailActions []ProposedSendEmailAction `json:"proposedSendEmailActions"`
}

type ProposedSendEmailAction struct {
	SourceType    string                 `json:"sourceType"`
	SourceKey     string                 `json:"sourceKey"`
	ActionType    string                 `json:"actionType"`
	ActionName    string                 `json:"actionName"`
	TemplateID    string                 `json:"templateId,omitempty"`
	To            []string               `json:"to,omitempty"`
	Cc            []string               `json:"cc,omitempty"`
	SenderPolicy  *SendEmailSenderPolicy `json:"senderPolicy,omitempty"`
	VariablesHint []string               `json:"variablesHint,omitempty"`
	Prerequisites []string               `json:"prerequisites,omitempty"`
	BlockedBy     []string               `json:"blockedBy,omitempty"`
}

type SendEmailSenderPolicy struct {
	Type string `json:"type"`
}

type recipientEntry struct {
	Type      string `json:"type"`
	Recipient string `json:"recipient,omitempty"`
	Field     string `json:"field,omitempty"`
}

var (
	salesforceMergeTokenRE = regexp.MustCompile(`\{!\s*([^}]+?)\s*\}`)
	nativeMergeTokenRE     = regexp.MustCompile(`\{\{\s*([A-Za-z][A-Za-z0-9_]*)\b([^}]*)\}\}`)
)

func Run(ctx context.Context, db *sql.DB, opts Options) (*Report, error) {
	if db == nil {
		return nil, fmt.Errorf("database handle is required")
	}
	if opts.Mode != ModeInventory && opts.Mode != ModeDryRun && opts.Mode != ModeApplyPack && opts.Mode != ModeVerifyZero {
		return nil, fmt.Errorf("unsupported workflow alert migration mode %q", opts.Mode)
	}
	now := time.Now().UTC()
	if opts.Now != nil {
		now = opts.Now().UTC()
	}
	report := &Report{
		GeneratedAt:           now.Format(time.RFC3339),
		Mode:                  opts.Mode,
		templatesByID:         map[string]EmailTemplateInventory{},
		templatesByDeveloper:  map[string][]EmailTemplateInventory{},
		workflowsByKey:        map[string]WorkflowAlertInventory{},
		workflowDuplicateKeys: map[string][]WorkflowAlertInventory{},
		fieldsByObject:        map[string]map[string]ObjectFieldInventory{},
		loadedFieldObjects:    map[string]struct{}{},
		templateRefSet:        map[string]struct{}{},
	}

	if err := report.collectWorkflowAlerts(ctx, db); err != nil {
		return nil, err
	}
	if opts.Mode != ModeVerifyZero {
		report.addDuplicateWorkflowAlertKeyBlockers()
	}
	if err := report.collectProcessActionAlerts(ctx, db); err != nil {
		return nil, err
	}
	if err := report.collectFlowEmailAlerts(ctx, db); err != nil {
		return nil, err
	}
	if opts.Mode == ModeVerifyZero {
		report.buildVerifyZeroGate(opts.OfficialPackRoot)
		report.finalize()
		return report, nil
	}
	if err := report.collectObjectFieldMetadata(ctx, db); err != nil {
		return nil, err
	}
	if err := report.collectEmailTemplates(ctx, db); err != nil {
		return nil, err
	}
	if err := report.validateLiteralUserRecipients(ctx, db); err != nil {
		return nil, err
	}
	report.validateWorkflowAlertRecipientFields()
	report.failClosedResolverVisibilityGaps()
	report.classifyWorkflowAlerts()
	report.classifyProcessActions()
	report.classifyFlowEmailAlerts()
	report.classifyReferencedTemplates()
	if opts.Mode == ModeDryRun || opts.Mode == ModeApplyPack {
		report.buildDryRunPlan()
	}
	if opts.Mode == ModeApplyPack {
		report.buildApplyPackPlan()
	}
	report.finalize()
	return report, nil
}

func (r *Report) collectWorkflowAlerts(ctx context.Context, db *sql.DB) error {
	const query = `
SELECT Id, ObjectApiName, Name, COALESCE(Description, ''), COALESCE(Template, ''),
       COALESCE(SenderType, ''), COALESCE(SenderAddress, ''), COALESCE(Recipients, ''),
       COALESCE(CcEmails, ''), COALESCE(CreatedDate, ''), COALESCE(LastModifiedDate, '')
FROM _System_WorkflowAlert
ORDER BY ObjectApiName, Name, Id`
	rows, err := db.QueryContext(ctx, query)
	if err != nil {
		return fmt.Errorf("inventory _System_WorkflowAlert: %w", err)
	}
	defer rows.Close()

	for rows.Next() {
		var row WorkflowAlertInventory
		var ccEmails string
		if err := rows.Scan(
			&row.ID,
			&row.ObjectAPIName,
			&row.Name,
			&row.Description,
			&row.TemplateRef,
			&row.SenderType,
			&row.SenderAddress,
			&row.RecipientsJSON,
			&ccEmails,
			&row.CreatedDate,
			&row.LastModifiedDate,
		); err != nil {
			return fmt.Errorf("scan _System_WorkflowAlert: %w", err)
		}
		row.Status = sourceStatusActive
		row.StatusReason = "_System_WorkflowAlert has no IsDeleted/IsActive marker; every row is inventoried as active legacy behavior"
		row.CcEmails = splitCSV(ccEmails)
		row.RecipientInventory = classifyRecipients(row.RecipientsJSON)
		r.WorkflowAlerts = append(r.WorkflowAlerts, row)
		r.indexWorkflowAlert(row)
		if row.TemplateRef != "" {
			r.templateRefSet[row.TemplateRef] = struct{}{}
		}
	}
	if err := rows.Err(); err != nil {
		return fmt.Errorf("iterate _System_WorkflowAlert: %w", err)
	}
	return nil
}

func (r *Report) indexWorkflowAlert(row WorkflowAlertInventory) {
	key := workflowLookupKey(row.ObjectAPIName, row.Name)
	if duplicates, ok := r.workflowDuplicateKeys[key]; ok {
		r.workflowDuplicateKeys[key] = append(duplicates, row)
		delete(r.workflowsByKey, key)
		return
	}
	if existing, ok := r.workflowsByKey[key]; ok {
		r.workflowDuplicateKeys[key] = []WorkflowAlertInventory{existing, row}
		delete(r.workflowsByKey, key)
		return
	}
	r.workflowsByKey[key] = row
}

func (r *Report) addDuplicateWorkflowAlertKeyBlockers() {
	keys := make([]string, 0, len(r.workflowDuplicateKeys))
	for key := range r.workflowDuplicateKeys {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	for _, key := range keys {
		alerts := r.workflowDuplicateKeys[key]
		r.addUnsupported(UnsupportedItem{
			Severity:       "blocker",
			SourceType:     constants.TableWorkflowAlert,
			SourceKey:      duplicateWorkflowAlertSourceKey(alerts, key),
			Code:           "ambiguous_workflow_alert_key",
			Message:        fmt.Sprintf("Multiple active WorkflowAlert rows share the same case-insensitive ObjectApiName.Name key (%s).", strings.Join(workflowAlertInventoryKeys(alerts), ", ")),
			Recommendation: "Deduplicate or rename the WorkflowAlert rows before migration; inventory/dry-run will not choose one row as authoritative.",
		})
	}
}

func (r *Report) collectProcessActionAlerts(ctx context.Context, db *sql.DB) error {
	// Match approval runtime reachability: new submit paths resolve active
	// approvals by ProcessDefinition.State, older rows may only carry IsActive,
	// and pending instances can still fetch actions by ProcessDefinitionId after
	// their parent definition is no longer active.
	const query = `
SELECT a.Id, a.Name, a.ProcessDefinitionId, COALESCE(a.ProcessNodeId, ''),
       COALESCE(a.ActionEvent, ''), COALESCE(a.EmailTemplateId, ''), COALESCE(a.ApiName, ''),
       COALESCE(a.` + "`Order`" + `, 0), COALESCE(a.IsDeleted, 0),
       COALESCE(pd.TableEnumOrId, ''), COALESCE(pd.DeveloperName, ''),
       CASE WHEN COALESCE(pd.State, '') = 'Active' OR COALESCE(pd.IsActive, 0) = 1 THEN 1 ELSE 0 END AS RuntimeReachable,
       COALESCE(pn.DeveloperName, ''), COALESCE(pn.Name, ''), COUNT(DISTINCT pi.Id)
FROM ProcessAction a
LEFT JOIN ProcessDefinition pd ON pd.Id = a.ProcessDefinitionId
LEFT JOIN ProcessNode pn ON pn.Id = a.ProcessNodeId
LEFT JOIN ProcessInstance pi ON pi.ProcessDefinitionId = a.ProcessDefinitionId
  AND COALESCE(pi.Status, '') NOT IN (?, ?, ?)
WHERE a.ActionType = ?
GROUP BY a.Id, a.Name, a.ProcessDefinitionId, a.ProcessNodeId, a.ActionEvent, a.EmailTemplateId, a.ApiName,
         a.` + "`Order`" + `, a.IsDeleted, pd.TableEnumOrId, pd.DeveloperName, pd.State, pd.IsActive, pn.DeveloperName, pn.Name
ORDER BY COALESCE(a.IsDeleted, 0), pd.DeveloperName, a.ActionEvent, a.` + "`Order`" + `, a.Name, a.Id`
	rows, err := db.QueryContext(ctx, query,
		constants.ApprovalStatusApproved,
		constants.ApprovalStatusRejected,
		constants.ApprovalStatusRemoved,
		constants.ProcessActionTypeAlert,
	)
	if err != nil {
		return fmt.Errorf("inventory ProcessAction Alert rows: %w", err)
	}
	defer rows.Close()

	for rows.Next() {
		var row ProcessActionInventory
		var deleted, definitionRuntimeReachable int
		var runnableInstanceCount int
		if err := rows.Scan(
			&row.ID,
			&row.Name,
			&row.ProcessDefinitionID,
			&row.ProcessNodeID,
			&row.ActionEvent,
			&row.EmailTemplateID,
			&row.APIName,
			&row.Order,
			&deleted,
			&row.ProcessDefinitionObject,
			&row.ProcessDefinitionName,
			&definitionRuntimeReachable,
			&row.ProcessNodeDeveloperName,
			&row.ProcessNodeName,
			&runnableInstanceCount,
		); err != nil {
			return fmt.Errorf("scan ProcessAction Alert row: %w", err)
		}
		row.ProcessDefinitionRuntimeReachable = definitionRuntimeReachable != 0
		row.ProcessDefinitionActive = row.ProcessDefinitionRuntimeReachable
		row.RunnableInstanceCount = runnableInstanceCount
		if deleted != 0 {
			row.Status = sourceStatusHistorical
			row.StatusReason = "ProcessAction.IsDeleted is set"
		} else if row.ProcessDefinitionRuntimeReachable {
			row.Status = sourceStatusActive
		} else if row.RunnableInstanceCount > 0 {
			row.Status = sourceStatusActive
			row.StatusReason = fmt.Sprintf("parent ProcessDefinition is not runtime-reachable but %d nonterminal ProcessInstance row(s) can still fetch this action", row.RunnableInstanceCount)
		} else {
			row.Status = sourceStatusHistorical
			row.StatusReason = "parent ProcessDefinition is not runtime-reachable or missing"
		}
		if row.EmailTemplateID != "" {
			r.templateRefSet[row.EmailTemplateID] = struct{}{}
		}
		r.ProcessActionAlerts = append(r.ProcessActionAlerts, row)
	}
	if err := rows.Err(); err != nil {
		return fmt.Errorf("iterate ProcessAction Alert rows: %w", err)
	}
	return nil
}

func (r *Report) collectFlowEmailAlerts(ctx context.Context, db *sql.DB) error {
	const query = `
SELECT fd.Id, fd.DeveloperName, COALESCE(fd.ProcessType, ''), COALESCE(fd.ActiveVersionId, ''),
       COALESCE(f.Id, ''), COALESCE(f.ProcessType, ''), COALESCE(f.VersionNumber, 0),
       COALESCE(f.Status, ''), COALESCE(f.Metadata, ''), COALESCE(fd.TriggerObjectOrEvent, ''),
       CASE WHEN COALESCE(fd.IsDeleted, 0) = 0 AND f.Id = fd.ActiveVersionId THEN 1 ELSE 0 END,
       COUNT(DISTINCT CASE WHEN COALESCE(fi.InterviewStatus, '') = 'Paused' THEN fi.Id END),
       COUNT(DISTINCT CASE WHEN COALESCE(fi.InterviewStatus, '') = 'Running' THEN fi.Id END)
FROM Flow f
JOIN FlowDefinition fd ON fd.Id = f.DefinitionId
LEFT JOIN FlowInterview fi ON fi.FlowVersionId = f.Id
  AND (
    COALESCE(fi.InterviewStatus, '') = ?
    OR (
      COALESCE(fi.InterviewStatus, '') = ?
      AND fi.ClaimedAt IS NOT NULL
      AND fi.ClaimedAt <= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 10 MINUTE)
    )
  )
WHERE COALESCE(f.IsDeleted, 0) = 0
  AND (
    (COALESCE(fd.IsDeleted, 0) = 0 AND f.Id = fd.ActiveVersionId)
    OR fi.Id IS NOT NULL
  )
GROUP BY fd.Id, fd.DeveloperName, fd.ProcessType, fd.ActiveVersionId, f.Id, f.ProcessType,
         f.VersionNumber, f.Status, f.Metadata, fd.TriggerObjectOrEvent, fd.IsDeleted
ORDER BY fd.DeveloperName, f.VersionNumber, f.Id`
	rows, err := db.QueryContext(ctx, query, models.FlowInterviewStatusPaused, models.FlowInterviewStatusRunning)
	if err != nil {
		return fmt.Errorf("inventory Flow emailAlert actionCalls: %w", err)
	}
	defer rows.Close()

	for rows.Next() {
		var definitionID, developerName, definitionProcessType, activeVersionID, versionID, versionProcessType, status, metadataJSON, triggerObject string
		var versionNumber, activeVersion, pausedInterviewCount, recoverableRunningInterviewCount int
		if err := rows.Scan(
			&definitionID,
			&developerName,
			&definitionProcessType,
			&activeVersionID,
			&versionID,
			&versionProcessType,
			&versionNumber,
			&status,
			&metadataJSON,
			&triggerObject,
			&activeVersion,
			&pausedInterviewCount,
			&recoverableRunningInterviewCount,
		); err != nil {
			return fmt.Errorf("scan Flow emailAlert candidate row: %w", err)
		}
		if versionID == "" {
			continue
		}
		runtimeState := flowEmailAlertRuntimeState(activeVersion != 0, pausedInterviewCount, recoverableRunningInterviewCount)
		var metadata models.FlowMetadata
		if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
			r.addUnsupported(UnsupportedItem{
				Severity:       "blocker",
				SourceType:     "Flow",
				SourceKey:      developerName,
				Code:           "flow_metadata_unparseable",
				Field:          constants.FieldFlow_Metadata,
				Message:        fmt.Sprintf("%s Flow metadata could not be parsed: %v", runtimeState, err),
				Recommendation: "Repair the runtime-reachable Flow metadata before running migration apply-pack.",
			})
			continue
		}
		startObject := ""
		if metadata.Start != nil && strings.TrimSpace(metadata.Start.Object) != "" {
			startObject = strings.TrimSpace(metadata.Start.Object)
		}
		recordContextObject := startObject
		if recordContextObject == "" {
			recordContextObject = flowRecordVariableObject(metadata)
		}
		if recordContextObject == "" {
			var err error
			recordContextObject, err = flowWorkflowRecordObject(definitionProcessType, versionProcessType, metadata)
			if err != nil {
				r.addUnsupported(UnsupportedItem{
					Severity:       "blocker",
					SourceType:     "Flow",
					SourceKey:      developerName,
					Code:           "flow_trigger_projection_unparseable",
					Field:          constants.FieldFlow_Metadata,
					Message:        fmt.Sprintf("%s Workflow Flow record context could not be projected: %v", runtimeState, err),
					Recommendation: "Repair Workflow flow trigger projection metadata before migration apply-pack.",
				})
			}
		}
		for _, action := range metadata.ActionCalls {
			if !strings.EqualFold(action.ActionType, flowEmailAlertActionType) {
				continue
			}
			objectAPIName, alertName := splitEmailAlertActionName(action.ActionName, startObject)
			faultConnectorTarget := flowConnectorTarget(action.FaultConnector)
			row := FlowEmailAlertInventory{
				FlowDefinitionID:                 definitionID,
				FlowDeveloperName:                developerName,
				FlowVersionID:                    versionID,
				FlowVersionNumber:                versionNumber,
				FlowStatus:                       status,
				RuntimeState:                     runtimeState,
				PausedInterviewCount:             pausedInterviewCount,
				RecoverableRunningInterviewCount: recoverableRunningInterviewCount,
				StartObject:                      startObject,
				ElementName:                      action.Name,
				ElementLabel:                     action.Label,
				ActionName:                       action.ActionName,
				ObjectAPIName:                    objectAPIName,
				AlertName:                        alertName,
				WorkflowAlertKey:                 workflowKey(objectAPIName, alertName),
				HasFaultConnector:                faultConnectorTarget != "",
				FaultConnectorTarget:             faultConnectorTarget,
				RunAtomically:                    metadata.RunAtomically,
				ActionNameScoped:                 emailAlertActionNameScoped(action.ActionName),
				RecordContextObject:              recordContextObject,
			}
			r.FlowEmailAlerts = append(r.FlowEmailAlerts, row)
		}
	}
	if err := rows.Err(); err != nil {
		return fmt.Errorf("iterate Flow emailAlert rows: %w", err)
	}
	return nil
}

func (r *Report) collectObjectFieldMetadata(ctx context.Context, db *sql.DB) error {
	pending := r.objectRoots()
	for len(pending) > 0 {
		if err := r.collectObjectFieldMetadataForObjects(ctx, db, pending); err != nil {
			return err
		}
		pending = r.unloadedRecipientPathTargets()
	}
	return nil
}

func (r *Report) collectObjectFieldMetadataForObjects(ctx context.Context, db *sql.DB, objectAPINames []string) error {
	objectAPINames = r.unloadedObjectNames(objectAPINames)
	if len(objectAPINames) == 0 {
		return nil
	}
	placeholders := make([]string, len(objectAPINames))
	args := make([]interface{}, len(objectAPINames))
	for i, root := range objectAPINames {
		placeholders[i] = "?"
		args[i] = root
	}
	query := fmt.Sprintf(`
SELECT o.ApiName, f.ApiName, COALESCE(f.Type, ''), COALESCE(f.ReturnType, ''), COALESCE(f.ReferenceTo, '[]'),
       COALESCE(f.RelationshipName, ''), COALESCE(f.OwnerPack, ''), COALESCE(f.IsDeleted, 0)
FROM _System_Field f
JOIN _System_Object o ON o.Id = f.ObjectId
WHERE COALESCE(o.IsDeleted, 0) = 0
  AND o.ApiName IN (%s)
ORDER BY o.ApiName, f.ApiName`, strings.Join(placeholders, ", "))
	rows, err := db.QueryContext(ctx, query, args...)
	if err != nil {
		return fmt.Errorf("inventory object field metadata: %w", err)
	}
	defer rows.Close()

	for rows.Next() {
		var row ObjectFieldInventory
		var referenceTo string
		var deleted int
		if err := rows.Scan(
			&row.ObjectAPIName,
			&row.FieldAPIName,
			&row.Type,
			&row.ReturnType,
			&referenceTo,
			&row.RelationshipName,
			&row.OwnerPack,
			&deleted,
		); err != nil {
			return fmt.Errorf("scan object field metadata: %w", err)
		}
		row.ReferenceTo = parseReferenceTo(referenceTo)
		row.IsDeleted = deleted != 0
		r.ObjectFields = append(r.ObjectFields, row)
		key := strings.ToLower(row.ObjectAPIName)
		if r.fieldsByObject[key] == nil {
			r.fieldsByObject[key] = map[string]ObjectFieldInventory{}
		}
		r.fieldsByObject[key][strings.ToLower(row.FieldAPIName)] = row
		if row.RelationshipName != "" {
			r.fieldsByObject[key][strings.ToLower(row.RelationshipName)] = row
		}
	}
	if err := rows.Err(); err != nil {
		return fmt.Errorf("iterate object field metadata: %w", err)
	}
	for _, objectAPIName := range objectAPINames {
		r.loadedFieldObjects[strings.ToLower(objectAPIName)] = struct{}{}
	}
	return nil
}

func (r *Report) collectEmailTemplates(ctx context.Context, db *sql.DB) error {
	const query = `
SELECT Id, COALESCE(Name, ''), COALESCE(DeveloperName, ''), COALESCE(OwnerPack, ''),
       COALESCE(FolderId, ''), COALESCE(IsActive, 0), COALESCE(IsDeleted, 0),
       COALESCE(Subject, ''), COALESCE(HtmlValue, ''), COALESCE(Body, '')
FROM _System_EmailTemplate
ORDER BY DeveloperName, Id`
	rows, err := db.QueryContext(ctx, query)
	if err != nil {
		return fmt.Errorf("inventory _System_EmailTemplate: %w", err)
	}
	defer rows.Close()

	for rows.Next() {
		var row EmailTemplateInventory
		var active, deleted int
		if err := rows.Scan(
			&row.ID,
			&row.Name,
			&row.DeveloperName,
			&row.OwnerPack,
			&row.FolderID,
			&active,
			&deleted,
			&row.Subject,
			&row.HTMLValue,
			&row.Body,
		); err != nil {
			return fmt.Errorf("scan _System_EmailTemplate: %w", err)
		}
		row.IsActive = active != 0
		row.IsDeleted = deleted != 0
		r.templatesByID[row.ID] = row
		if row.DeveloperName != "" && row.IsActive && !row.IsDeleted {
			r.templatesByDeveloper[metadataLookupKey(row.DeveloperName)] = append(r.templatesByDeveloper[metadataLookupKey(row.DeveloperName)], row)
		}
	}
	if err := rows.Err(); err != nil {
		return fmt.Errorf("iterate _System_EmailTemplate: %w", err)
	}
	for key := range r.templatesByDeveloper {
		sort.Slice(r.templatesByDeveloper[key], func(i, j int) bool {
			return r.templatesByDeveloper[key][i].ID < r.templatesByDeveloper[key][j].ID
		})
	}
	return nil
}

func (r *Report) validateLiteralUserRecipients(ctx context.Context, db *sql.DB) error {
	ids := r.literalUserRecipientIDs()
	if len(ids) == 0 {
		return nil
	}
	placeholders := make([]string, len(ids))
	args := make([]interface{}, len(ids))
	for i, id := range ids {
		placeholders[i] = "?"
		args[i] = id
	}
	query := fmt.Sprintf("SELECT `%s`, COALESCE(`%s`, '') FROM `%s` WHERE `%s` IN (%s) ORDER BY `%s`",
		constants.FieldID,
		constants.FieldEmail,
		constants.TableUser,
		constants.FieldID,
		strings.Join(placeholders, ", "),
		constants.FieldID,
	)
	rows, err := db.QueryContext(ctx, query, args...)
	if err != nil {
		return fmt.Errorf("inventory literal user recipients: %w", err)
	}
	defer rows.Close()

	userEmails := map[string]string{}
	for rows.Next() {
		var id, email string
		if err := rows.Scan(&id, &email); err != nil {
			return fmt.Errorf("scan literal user recipient: %w", err)
		}
		userEmails[strings.TrimSpace(id)] = strings.TrimSpace(email)
	}
	if err := rows.Err(); err != nil {
		return fmt.Errorf("iterate literal user recipients: %w", err)
	}

	for i := range r.WorkflowAlerts {
		for j := range r.WorkflowAlerts[i].RecipientInventory {
			recipient := &r.WorkflowAlerts[i].RecipientInventory[j]
			if !isLiteralUserIDRecipient(*recipient) {
				continue
			}
			email, ok := userEmails[recipient.Recipient]
			if !ok {
				recipient.BlockedReason = fmt.Sprintf("user recipient %q does not resolve to a User row", recipient.Recipient)
				continue
			}
			address, ok := validateCanonicalSendEmailAddress(email)
			if !ok {
				recipient.BlockedReason = fmt.Sprintf("user recipient %q resolves to User email %q, which is not valid for canonical sendEmail.to", recipient.Recipient, email)
				continue
			}
			recipient.ResolvedRecipientEmail = address
		}
	}
	return nil
}

func (r *Report) literalUserRecipientIDs() []string {
	seen := map[string]struct{}{}
	for _, alert := range r.WorkflowAlerts {
		for _, recipient := range alert.RecipientInventory {
			if isLiteralUserIDRecipient(recipient) {
				seen[recipient.Recipient] = struct{}{}
			}
		}
	}
	ids := make([]string, 0, len(seen))
	for id := range seen {
		ids = append(ids, id)
	}
	sort.Strings(ids)
	return ids
}

func (r *Report) classifyWorkflowAlerts() {
	for i := range r.WorkflowAlerts {
		alert := &r.WorkflowAlerts[i]
		sourceKey := workflowKey(alert.ObjectAPIName, alert.Name)
		template, resolution := r.resolveTemplateRef(alert.TemplateRef)
		alert.TemplateResolution = resolution
		if template.ID != "" {
			alert.ResolvedTemplateID = template.ID
			r.templateRefSet[template.ID] = struct{}{}
		}
		if alert.TemplateRef == "" {
			r.addUnsupported(UnsupportedItem{
				Severity:       "blocker",
				SourceType:     constants.TableWorkflowAlert,
				SourceKey:      sourceKey,
				Code:           "missing_template",
				Field:          constants.FieldSysWorkflowAlert_Template,
				Message:        "WorkflowAlert has no template reference.",
				Recommendation: "Choose a native _System_EmailTemplate before generating sendEmail metadata.",
			})
		} else if template.ID == "" {
			r.addUnsupported(UnsupportedItem{
				Severity:       "blocker",
				SourceType:     constants.TableWorkflowAlert,
				SourceKey:      sourceKey,
				Code:           "unresolved_template",
				Field:          constants.FieldSysWorkflowAlert_Template,
				Value:          alert.TemplateRef,
				Message:        fmt.Sprintf("WorkflowAlert.Template does not resolve to one active _System_EmailTemplate (%s).", resolution),
				Recommendation: "Resolve template references to deterministic native EmailTemplate IDs before apply-pack.",
			})
		}
		for _, recipient := range alert.RecipientInventory {
			if recipient.BlockedReason == "" {
				continue
			}
			r.addUnsupported(UnsupportedItem{
				Severity:       "blocker",
				SourceType:     constants.TableWorkflowAlert,
				SourceKey:      sourceKey,
				Code:           "unsupported_recipient",
				Field:          constants.FieldSysWorkflowAlert_Recipients,
				Value:          recipient.Type,
				Message:        recipient.BlockedReason,
				Recommendation: "Route through a supported recipient resolver action or fix the legacy recipient row before apply-pack.",
			})
		}
		if len(alert.RecipientInventory) == 0 {
			r.addUnsupported(UnsupportedItem{
				Severity:       "blocker",
				SourceType:     constants.TableWorkflowAlert,
				SourceKey:      sourceKey,
				Code:           "missing_recipients",
				Field:          constants.FieldSysWorkflowAlert_Recipients,
				Message:        "WorkflowAlert has no recipients.",
				Recommendation: "Provide an explicit sendEmail to recipient collection before apply-pack.",
			})
		} else if workflowAlertRequiresRecipientFanout(*alert) {
			r.addUnsupported(UnsupportedItem{
				Severity:       "blocker",
				SourceType:     constants.TableWorkflowAlert,
				SourceKey:      sourceKey,
				Code:           "recipient_fanout_required",
				Field:          constants.FieldSysWorkflowAlert_Recipients,
				Message:        "WorkflowAlert has multiple recipients, but a single sendEmail action would send one shared To list instead of preserving legacy one-email-per-recipient fan-out.",
				Recommendation: "Generate an explicit per-recipient Flow loop or separate sendEmail actions before apply-pack.",
			})
		}
		for _, cc := range alert.CcEmails {
			if _, ok := validateCanonicalSendEmailAddress(cc); ok {
				continue
			}
			r.addUnsupported(UnsupportedItem{
				Severity:       "blocker",
				SourceType:     constants.TableWorkflowAlert,
				SourceKey:      sourceKey,
				Code:           "unsupported_cc_email",
				Field:          constants.FieldSysWorkflowAlert_CcEmails,
				Value:          cc,
				Message:        fmt.Sprintf("WorkflowAlert CcEmails value %q is not valid for canonical sendEmail.cc.", cc),
				Recommendation: "Fix or remove malformed CC literals before apply-pack generation.",
			})
		}
		if _, reason := workflowAlertSendEmailSenderPolicy(*alert); reason != "" {
			r.addUnsupported(UnsupportedItem{
				Severity:       "blocker",
				SourceType:     constants.TableWorkflowAlert,
				SourceKey:      sourceKey,
				Code:           "sender_policy_required",
				Field:          constants.FieldSysWorkflowAlert_SenderType,
				Value:          workflowAlertSenderPolicyValue(*alert),
				Message:        reason,
				Recommendation: "Map this alert to the sendEmail default sender policy, or defer it until a generic sender identity source of truth exists.",
			})
		}
		if _, ambiguous := r.lookupWorkflowDuplicatesByKey(sourceKey); !ambiguous {
			r.workflowsByKey[workflowLookupKey(alert.ObjectAPIName, alert.Name)] = *alert
		}
	}
}

func workflowAlertSendEmailSenderPolicy(alert WorkflowAlertInventory) (*SendEmailSenderPolicy, string) {
	rawSenderType := alert.SenderType
	senderType := strings.TrimSpace(rawSenderType)
	senderAddress := strings.TrimSpace(alert.SenderAddress)
	if senderType == "" && senderAddress == "" {
		return nil, ""
	}
	if senderAddress != "" {
		return nil, fmt.Sprintf("WorkflowAlert sender policy %q includes an explicit sender address, but no generic sender identity source of truth exists for sendEmail migration.", workflowAlertSenderPolicyValue(alert))
	}
	if rawSenderType == "DefaultWorkflowUser" {
		return &SendEmailSenderPolicy{Type: sendEmailSenderPolicyDefault}, ""
	}
	switch normalizeWorkflowAlertSenderPolicyType(senderType) {
	case sendEmailSenderPolicyCurrentUser:
		return nil, fmt.Sprintf("WorkflowAlert sender policy %q depends on per-user sender authorization, but no generic sender identity source of truth exists for sendEmail migration.", workflowAlertSenderPolicyValue(alert))
	default:
		return nil, fmt.Sprintf("WorkflowAlert sender policy %q has no safe generic sendEmail sender-policy mapping.", workflowAlertSenderPolicyValue(alert))
	}
}

func normalizeWorkflowAlertSenderPolicyType(senderType string) string {
	key := strings.ToLower(strings.TrimSpace(senderType))
	key = strings.ReplaceAll(key, "_", "")
	key = strings.ReplaceAll(key, "-", "")
	key = strings.ReplaceAll(key, " ", "")
	switch key {
	case "currentuser":
		return sendEmailSenderPolicyCurrentUser
	default:
		return strings.TrimSpace(senderType)
	}
}

func workflowAlertSenderPolicyValue(alert WorkflowAlertInventory) string {
	return strings.TrimSpace(strings.TrimSpace(alert.SenderType) + " " + strings.TrimSpace(alert.SenderAddress))
}

func (r *Report) validateWorkflowAlertRecipientFields() {
	for i := range r.WorkflowAlerts {
		alert := &r.WorkflowAlerts[i]
		for j := range alert.RecipientInventory {
			recipient := &alert.RecipientInventory[j]
			if recipient.BlockedReason != "" || strings.TrimSpace(recipient.Field) == "" {
				continue
			}
			field, status, reason := r.validateRecipientFieldPath(alert.ObjectAPIName, recipient.Field, recipient.Type)
			recipient.FieldValidation = status
			if field.FieldAPIName != "" {
				recipient.ResolvedFieldType = field.Type
				recipient.ResolvedReferenceTo = append([]string(nil), field.ReferenceTo...)
			}
			if reason != "" {
				recipient.BlockedReason = reason
			}
		}
	}
}

func (r *Report) failClosedResolverVisibilityGaps() {
	for i := range r.WorkflowAlerts {
		for j := range r.WorkflowAlerts[i].RecipientInventory {
			recipient := &r.WorkflowAlerts[i].RecipientInventory[j]
			if recipient.BlockedReason != "" {
				continue
			}
			switch {
			case recipientRequiresUserScopedResolver(*recipient):
				recipient.BlockedReason = fmt.Sprintf("%s 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", recipientDescription(*recipient))
			case recipientRequiresRelatedEmailResolver(*recipient):
				recipient.BlockedReason = fmt.Sprintf("%s traverses a related record and requires Flow-visible related-row and Email-field access that migration dry-run cannot prove", recipientDescription(*recipient))
			}
		}
	}
}

func (r *Report) validateRecipientFieldPath(objectAPIName, fieldPath, recipientType string) (ObjectFieldInventory, string, string) {
	objectAPIName = strings.TrimSpace(objectAPIName)
	fieldPath = strings.TrimSpace(fieldPath)
	if objectAPIName == "" {
		return ObjectFieldInventory{}, "missing_object_context", "recipient field path cannot be validated because the WorkflowAlert has no ObjectApiName"
	}
	if fieldPath == "" {
		return ObjectFieldInventory{}, "missing_field_path", "recipient field path is empty"
	}
	if strings.EqualFold(strings.TrimSpace(recipientType), "userlookup") && strings.Contains(fieldPath, ".") {
		return ObjectFieldInventory{}, "unsupported_field_path", fmt.Sprintf("userlookup recipient field %s uses dotted path traversal, but WorkflowAlert userlookup runtime and resolveEmailRecipients only accept direct User ID fields", fieldPath)
	}
	currentObject := objectAPIName
	segments := strings.Split(fieldPath, ".")
	var field ObjectFieldInventory
	for idx, segment := range segments {
		segment = strings.TrimSpace(segment)
		if segment == "" {
			return ObjectFieldInventory{}, "invalid_field_path", "recipient field path contains an empty segment"
		}
		resolved, ok := r.lookupObjectField(currentObject, segment)
		if !ok && idx < len(segments)-1 {
			resolved, ok = r.lookupObjectField(currentObject, segment+"Id")
		}
		if !ok {
			return ObjectFieldInventory{}, "field_not_found", fmt.Sprintf("field %q was not found on %s metadata", segment, currentObject)
		}
		if resolved.IsDeleted {
			return resolved, "field_deleted", fmt.Sprintf("field %s.%s is deleted", currentObject, resolved.FieldAPIName)
		}
		field = resolved
		if idx == len(segments)-1 {
			break
		}
		if len(resolved.ReferenceTo) != 1 || strings.TrimSpace(resolved.ReferenceTo[0]) == "" {
			return resolved, "ambiguous_reference_path", fmt.Sprintf("intermediate recipient path %s.%s must reference exactly one target object", currentObject, resolved.FieldAPIName)
		}
		currentObject = strings.TrimSpace(resolved.ReferenceTo[0])
	}

	switch strings.ToLower(strings.TrimSpace(recipientType)) {
	case "email":
		if fieldYieldsSingleStringValue(field) {
			return field, "ok", ""
		}
		return field, "wrong_field_type", fmt.Sprintf("email recipient field %s resolves to type %q, expected a scalar text-like field", fieldPath, field.Type)
	case "owner":
		if len(field.ReferenceTo) == 1 && strings.EqualFold(strings.TrimSpace(field.ReferenceTo[0]), constants.TableUser) {
			return field, "ok", ""
		}
		return field, "wrong_reference_target", fmt.Sprintf("owner recipient requires %s to reference exactly User so it can be passed to resolveEmailRecipients userIds (referenceTo=%v)", fieldPath, field.ReferenceTo)
	case "userlookup":
		if len(field.ReferenceTo) == 1 && strings.EqualFold(strings.TrimSpace(field.ReferenceTo[0]), constants.TableUser) {
			return field, "ok", ""
		}
		return field, "wrong_reference_target", fmt.Sprintf("userlookup recipient field %s must reference exactly User (referenceTo=%v)", fieldPath, field.ReferenceTo)
	default:
		return field, "not_required", ""
	}
}

func (r *Report) lookupObjectField(objectAPIName, fieldOrRelationshipName string) (ObjectFieldInventory, bool) {
	fields := r.fieldsByObject[strings.ToLower(strings.TrimSpace(objectAPIName))]
	if len(fields) == 0 {
		return ObjectFieldInventory{}, false
	}
	field, ok := fields[strings.ToLower(strings.TrimSpace(fieldOrRelationshipName))]
	return field, ok
}

func (r *Report) classifyProcessActions() {
	for i := range r.ProcessActionAlerts {
		action := &r.ProcessActionAlerts[i]
		if action.Status != sourceStatusActive {
			continue
		}
		sourceKey := action.ID
		if action.ProcessDefinitionName != "" || action.Name != "" {
			sourceKey = strings.Trim(action.ProcessDefinitionName+"/"+action.ActionEvent+"/"+action.Name, "/")
		}
		if action.APIName != "" {
			matches := r.workflowAlertsByRuntimeName(action.APIName)
			switch len(matches) {
			case 0:
				r.addUnsupported(UnsupportedItem{
					Severity:       "blocker",
					SourceType:     constants.TableProcessAction,
					SourceKey:      sourceKey,
					Code:           "missing_workflow_alert",
					Field:          constants.FieldProcessAction_APIName,
					Value:          action.APIName,
					Message:        "ProcessAction.Alert references a WorkflowAlert name that the live approval runtime cannot find.",
					Recommendation: "Fix the approval alert binding or create the target Flow notification explicitly before apply-pack.",
				})
			case 1:
				matched := matches[0]
				if action.ProcessDefinitionObject != "" && !strings.EqualFold(matched.ObjectAPIName, action.ProcessDefinitionObject) {
					r.addUnsupported(UnsupportedItem{
						Severity:       "blocker",
						SourceType:     constants.TableProcessAction,
						SourceKey:      sourceKey,
						Code:           "workflow_alert_context_mismatch",
						Field:          constants.FieldProcessAction_APIName,
						Value:          action.APIName,
						Message:        fmt.Sprintf("ProcessAction.Alert resolves WorkflowAlert %s by name-only lookup, but approval runtime renders recipients and templates against %s.", workflowKey(matched.ObjectAPIName, matched.Name), action.ProcessDefinitionObject),
						Recommendation: "Rebind the approval notification to a Flow with an explicit record context, or create an object-aligned WorkflowAlert before migration.",
					})
					continue
				}
				action.WorkflowAlertKey = workflowKey(matched.ObjectAPIName, matched.Name)
			default:
				r.addUnsupported(UnsupportedItem{
					Severity:       "blocker",
					SourceType:     constants.TableProcessAction,
					SourceKey:      sourceKey,
					Code:           "ambiguous_workflow_alert_name",
					Field:          constants.FieldProcessAction_APIName,
					Value:          action.APIName,
					Message:        fmt.Sprintf("ProcessAction.Alert ApiName matches multiple active WorkflowAlerts by the live name-only approval runtime lookup (%s).", strings.Join(workflowAlertKeys(matches), ", ")),
					Recommendation: "Rename or rebind the approval alert before migration; apply-pack must not infer object-scoped behavior that runtime does not use.",
				})
			}
			continue
		}
		if action.EmailTemplateID == "" {
			r.addUnsupported(UnsupportedItem{
				Severity:       "blocker",
				SourceType:     constants.TableProcessAction,
				SourceKey:      sourceKey,
				Code:           "missing_template",
				Field:          constants.FieldProcessAction_EmailTemplateID,
				Message:        "Legacy inline ProcessAction.Alert has no EmailTemplateId.",
				Recommendation: "Choose a native _System_EmailTemplate before generating an approval notification Flow.",
			})
			continue
		}
		template, resolution := r.resolveTemplateRef(action.EmailTemplateID)
		if template.ID != "" {
			r.templateRefSet[template.ID] = struct{}{}
			r.addUnsupported(UnsupportedItem{
				Severity:       "blocker",
				SourceType:     constants.TableProcessAction,
				SourceKey:      sourceKey,
				Code:           "approval_actor_recipient_plan_missing",
				Field:          constants.FieldProcessAction_EmailTemplateID,
				Value:          action.EmailTemplateID,
				Message:        "Inline ProcessAction.Alert sends to approval actorIDs, but dry-run has no executable actorID-to-sendEmail recipient fan-out plan.",
				Recommendation: "Wait for the approval lifecycle-to-Flow bridge to declare exact approver recipient outputs, then generate per-actor sendEmail metadata.",
			})
			continue
		}
		r.addUnsupported(UnsupportedItem{
			Severity:       "blocker",
			SourceType:     constants.TableProcessAction,
			SourceKey:      sourceKey,
			Code:           "unresolved_template",
			Field:          constants.FieldProcessAction_EmailTemplateID,
			Value:          action.EmailTemplateID,
			Message:        fmt.Sprintf("ProcessAction.EmailTemplateId does not resolve to one active _System_EmailTemplate (%s).", resolution),
			Recommendation: "Resolve approval alert template references before apply-pack.",
		})
	}
}

func (r *Report) classifyFlowEmailAlerts() {
	for _, alert := range r.FlowEmailAlerts {
		sourceKey := alert.FlowDeveloperName + "/" + alert.ElementName
		if alert.AlertName == "" || alert.ObjectAPIName == "" {
			r.addUnsupported(UnsupportedItem{
				Severity:       "blocker",
				SourceType:     constants.TableFlow,
				SourceKey:      sourceKey,
				Code:           "malformed_email_alert_action_name",
				Field:          "actionName",
				Value:          alert.ActionName,
				Message:        "Flow emailAlert actionName cannot be mapped to ObjectApiName.AlertName.",
				Recommendation: "Repair the actionName or replace the action with canonical sendEmail metadata.",
			})
			continue
		}
		if duplicates, ok := r.lookupWorkflowDuplicatesByKey(alert.WorkflowAlertKey); ok {
			r.addUnsupported(UnsupportedItem{
				Severity:       "blocker",
				SourceType:     constants.TableFlow,
				SourceKey:      sourceKey,
				Code:           "ambiguous_workflow_alert_key",
				Field:          "actionName",
				Value:          alert.ActionName,
				Message:        fmt.Sprintf("Flow emailAlert references a case-insensitive WorkflowAlert key with multiple active rows (%s).", strings.Join(workflowAlertInventoryKeys(duplicates), ", ")),
				Recommendation: "Deduplicate the WorkflowAlert rows or replace the action with canonical sendEmail metadata before apply-pack.",
			})
			continue
		}
		workflow, ok := r.lookupWorkflowByKey(alert.WorkflowAlertKey)
		if !ok {
			r.addUnsupported(UnsupportedItem{
				Severity:       "blocker",
				SourceType:     constants.TableFlow,
				SourceKey:      sourceKey,
				Code:           "missing_workflow_alert",
				Field:          "actionName",
				Value:          alert.ActionName,
				Message:        "Flow emailAlert references a WorkflowAlert that was not found.",
				Recommendation: "Create an explicit sendEmail action or fix the WorkflowAlert reference before apply-pack.",
			})
			continue
		}
		needsRecordContext := r.workflowAlertNeedsRecordContext(workflow)
		recordContextObject := strings.TrimSpace(alert.RecordContextObject)
		if needsRecordContext && recordContextObject == "" {
			r.addUnsupported(UnsupportedItem{
				Severity:       "blocker",
				SourceType:     constants.TableFlow,
				SourceKey:      sourceKey,
				Code:           "missing_flow_record_context",
				Field:          "recordContextObject",
				Value:          alert.ActionName,
				Message:        fmt.Sprintf("Flow emailAlert references %s, whose recipients or template require record data, but the Flow metadata does not declare a start object or $Record SObject input.", alert.WorkflowAlertKey),
				Recommendation: "Declare an explicit Flow record context, remove record-scoped recipients/templates, or model recipient/template variables before apply-pack.",
			})
		} else if needsRecordContext && !strings.EqualFold(recordContextObject, alert.ObjectAPIName) {
			r.addUnsupported(UnsupportedItem{
				Severity:       "blocker",
				SourceType:     constants.TableFlow,
				SourceKey:      sourceKey,
				Code:           "flow_record_context_mismatch",
				Field:          "actionName",
				Value:          alert.ActionName,
				Message:        fmt.Sprintf("Flow emailAlert references %s but the Flow runtime record context is %s.", workflowKey(alert.ObjectAPIName, alert.AlertName), recordContextObject),
				Recommendation: "Use a WorkflowAlert/sendEmail target whose object matches the Flow record context, or model an explicit alternate record source before apply-pack.",
			})
		}
		if alert.RunAtomically {
			r.addUnsupported(UnsupportedItem{
				Severity:       "blocker",
				SourceType:     constants.TableFlow,
				SourceKey:      sourceKey,
				Code:           "atomic_flow_email_io",
				Field:          "runAtomically",
				Value:          alert.ActionName,
				Message:        "Active Flow metadata has runAtomically=true, but atomic Flow execution rejects email I/O action calls.",
				Recommendation: "Move the notification out of the atomic Flow boundary or disable atomic execution before replacing emailAlert with sendEmail.",
			})
		}
		if !alert.HasFaultConnector {
			r.addUnsupported(UnsupportedItem{
				Severity:       "blocker",
				SourceType:     constants.TableFlow,
				SourceKey:      sourceKey,
				Code:           "missing_fault_connector",
				Field:          "faultConnector",
				Value:          alert.ActionName,
				Message:        "Flow emailAlert has no fault connector, but native sendEmail faults through Flow on template, recipient, or provider errors.",
				Recommendation: "Add an explicit Flow fault path or approved migration waiver before replacing emailAlert with sendEmail.",
			})
		}
	}
}

func (r *Report) classifyReferencedTemplates() {
	referenced := map[string][]string{}
	for _, alert := range r.WorkflowAlerts {
		if alert.ResolvedTemplateID != "" {
			referenced[alert.ResolvedTemplateID] = append(referenced[alert.ResolvedTemplateID], constants.TableWorkflowAlert+":"+workflowKey(alert.ObjectAPIName, alert.Name))
		}
	}
	for _, action := range r.ProcessActionAlerts {
		if action.Status != sourceStatusActive {
			continue
		}
		if action.APIName != "" {
			if workflow, ok := r.lookupWorkflowByKey(action.WorkflowAlertKey); ok && workflow.ResolvedTemplateID != "" {
				referenced[workflow.ResolvedTemplateID] = append(referenced[workflow.ResolvedTemplateID], constants.TableProcessAction+":"+action.ID)
			}
			continue
		}
		if action.EmailTemplateID == "" {
			continue
		}
		if template, _ := r.resolveTemplateRef(action.EmailTemplateID); template.ID != "" {
			referenced[template.ID] = append(referenced[template.ID], constants.TableProcessAction+":"+action.ID)
		}
	}
	for _, alert := range r.FlowEmailAlerts {
		if workflow, ok := r.lookupWorkflowByKey(alert.WorkflowAlertKey); ok && workflow.ResolvedTemplateID != "" {
			referenced[workflow.ResolvedTemplateID] = append(referenced[workflow.ResolvedTemplateID], constants.TableFlow+":"+alert.FlowDeveloperName+"/"+alert.ElementName)
		}
	}
	for id, refs := range referenced {
		template, ok := r.templatesByID[id]
		if !ok {
			continue
		}
		sort.Strings(refs)
		template.ReferencedBy = refs
		recordRoots := r.templateRecordRoots(id)
		if len(recordRoots) > 1 && templateHasRecordScopedTokens(template) {
			r.addUnsupported(UnsupportedItem{
				Severity:       "blocker",
				SourceType:     constants.TableEmailTemplate,
				SourceKey:      template.ID,
				Code:           "ambiguous_template_record_root",
				Message:        fmt.Sprintf("EmailTemplate is referenced from multiple record roots (%s) and contains record-scoped merge tokens.", strings.Join(sortedKeys(recordRoots), ", ")),
				Recommendation: "Split the template per object root or manually prove the generated native Record token contract before apply-pack.",
			})
		}
		template.TokenFindings = r.classifyTemplateTokens(template, recordRoots)
		for _, finding := range template.TokenFindings {
			if finding.Classification == "unsupported" ||
				finding.Classification == "blocked_product_decision" ||
				finding.Classification == "invalid_native_root" ||
				finding.Classification == "invalid_native_shape" ||
				finding.Classification == "invalid_legacy_syntax" {
				r.addUnsupported(UnsupportedItem{
					Severity:       "blocker",
					SourceType:     constants.TableEmailTemplate,
					SourceKey:      template.ID,
					Code:           "unsupported_template_token",
					Field:          finding.Field,
					Value:          finding.Token,
					Message:        finding.Message,
					Recommendation: "Convert the template to validated native {{Record.*}}/{{User.*}} syntax, or declare exact sendEmail variables before apply-pack.",
				})
			}
		}
		r.templatesByID[id] = template
		r.ReferencedTemplates = append(r.ReferencedTemplates, template)
	}
}

func (r *Report) buildDryRunPlan() {
	plan := &DryRunPlan{}
	for _, alert := range r.WorkflowAlerts {
		sourceKey := workflowKey(alert.ObjectAPIName, alert.Name)
		requiresFanout := workflowAlertRequiresRecipientFanout(alert)
		action := ProposedSendEmailAction{
			SourceType: constants.TableWorkflowAlert,
			SourceKey:  sourceKey,
			ActionType: targetSendEmailAction,
			ActionName: sendEmailActionName(alert.Name),
			TemplateID: alert.ResolvedTemplateID,
		}
		if senderPolicy, reason := workflowAlertSendEmailSenderPolicy(alert); reason != "" {
			action.BlockedBy = append(action.BlockedBy, reason)
		} else {
			action.SenderPolicy = senderPolicy
		}
		for _, cc := range alert.CcEmails {
			if address, ok := validateCanonicalSendEmailAddress(cc); ok {
				action.Cc = append(action.Cc, address)
			} else {
				action.BlockedBy = append(action.BlockedBy, fmt.Sprintf("cc email %q is invalid for current sendEmail", cc))
			}
		}
		for _, recipient := range alert.RecipientInventory {
			if recipient.BlockedReason != "" {
				action.BlockedBy = append(action.BlockedBy, recipient.BlockedReason)
				continue
			}
			switch recipient.Classification {
			case "literal_email":
				if !requiresFanout {
					action.To = append(action.To, recipient.Recipient)
				}
			case "needs_resolver":
				if !requiresFanout {
					action.Prerequisites = append(action.Prerequisites, recipient.TargetHint)
				}
			}
		}
		if requiresFanout {
			action.BlockedBy = append(action.BlockedBy, "WorkflowAlert has multiple recipients; dry-run cannot preserve legacy one-email-per-recipient fan-out with a single sendEmail action")
		}
		if alert.ResolvedTemplateID == "" {
			action.BlockedBy = append(action.BlockedBy, "template reference is unresolved")
		} else {
			action.BlockedBy = append(action.BlockedBy, r.emailTemplateDependencyBlockers(alert.ResolvedTemplateID)...)
		}
		if _, ambiguous := r.lookupWorkflowDuplicatesByKey(sourceKey); ambiguous {
			action.BlockedBy = append(action.BlockedBy, "WorkflowAlert key is duplicated by case-insensitive object/name lookup")
		}
		sort.Strings(action.To)
		sort.Strings(action.Cc)
		sort.Strings(action.Prerequisites)
		sort.Strings(action.BlockedBy)
		action.To = uniqueSortedStrings(action.To)
		action.Cc = uniqueSortedStrings(action.Cc)
		action.Prerequisites = uniqueSortedStrings(action.Prerequisites)
		action.BlockedBy = uniqueSortedStrings(action.BlockedBy)
		plan.ProposedSendEmailActions = append(plan.ProposedSendEmailActions, action)
	}
	for _, action := range r.ProcessActionAlerts {
		if action.Status != sourceStatusActive {
			continue
		}
		proposed := ProposedSendEmailAction{
			SourceType:    constants.TableProcessAction,
			SourceKey:     action.ID,
			ActionType:    targetSendEmailAction,
			ActionName:    sendEmailActionName(action.Name),
			VariablesHint: []string{"Approval.* context"},
		}
		if action.APIName != "" {
			if action.WorkflowAlertKey == "" {
				proposed.BlockedBy = append(proposed.BlockedBy, "WorkflowAlert ApiName is missing or ambiguous by approval runtime name-only lookup")
			} else if alert, ok := r.lookupWorkflowByKey(action.WorkflowAlertKey); ok {
				proposed.TemplateID = alert.ResolvedTemplateID
				if senderPolicy, reason := workflowAlertSendEmailSenderPolicy(alert); reason == "" {
					proposed.SenderPolicy = senderPolicy
				}
				proposed.Prerequisites = append(proposed.Prerequisites, "approval lifecycle-to-Flow notification bridge")
				proposed.BlockedBy = append(proposed.BlockedBy, r.workflowAlertDependencyBlockers(alert)...)
				if workflowAlertRequiresRecipientFanout(alert) {
					proposed.BlockedBy = append(proposed.BlockedBy, "WorkflowAlert has multiple recipients; dry-run cannot preserve legacy one-email-per-recipient fan-out with a single sendEmail action")
				}
			} else {
				proposed.BlockedBy = append(proposed.BlockedBy, "referenced WorkflowAlert is missing")
			}
		} else if template, _ := r.resolveTemplateRef(action.EmailTemplateID); template.ID != "" {
			proposed.TemplateID = template.ID
			proposed.Prerequisites = append(proposed.Prerequisites, "approval lifecycle-to-Flow notification bridge")
			proposed.BlockedBy = append(proposed.BlockedBy, "inline approval alert recipients come from approval actorIDs, but dry-run has no executable actorID-to-sendEmail recipient fan-out plan")
		} else {
			proposed.BlockedBy = append(proposed.BlockedBy, "approval EmailTemplateId is unresolved")
		}
		sort.Strings(proposed.Prerequisites)
		sort.Strings(proposed.BlockedBy)
		proposed.Prerequisites = uniqueSortedStrings(proposed.Prerequisites)
		proposed.BlockedBy = uniqueSortedStrings(proposed.BlockedBy)
		plan.ProposedSendEmailActions = append(plan.ProposedSendEmailActions, proposed)
	}
	for _, alert := range r.FlowEmailAlerts {
		proposed := ProposedSendEmailAction{
			SourceType: constants.TableFlow,
			SourceKey:  alert.FlowDeveloperName + "/" + alert.ElementName,
			ActionType: targetSendEmailAction,
			ActionName: sendEmailActionName(alert.ElementName),
		}
		if workflow, ok := r.lookupWorkflowByKey(alert.WorkflowAlertKey); ok {
			proposed.TemplateID = workflow.ResolvedTemplateID
			if senderPolicy, reason := workflowAlertSendEmailSenderPolicy(workflow); reason == "" {
				proposed.SenderPolicy = senderPolicy
			}
			proposed.Prerequisites = append(proposed.Prerequisites, "replace Flow emailAlert actionCall with canonical sendEmail plus recipient resolver output")
			proposed.BlockedBy = append(proposed.BlockedBy, r.workflowAlertDependencyBlockers(workflow)...)
			if workflowAlertRequiresRecipientFanout(workflow) {
				proposed.BlockedBy = append(proposed.BlockedBy, "WorkflowAlert has multiple recipients; dry-run cannot preserve legacy one-email-per-recipient fan-out with a single sendEmail action")
			}
			if r.workflowAlertNeedsRecordContext(workflow) {
				recordContextObject := strings.TrimSpace(alert.RecordContextObject)
				if recordContextObject == "" {
					proposed.BlockedBy = append(proposed.BlockedBy, "Flow has no declared record context for WorkflowAlert recipient/template evaluation")
				} else if strings.TrimSpace(alert.ObjectAPIName) != "" && !strings.EqualFold(recordContextObject, alert.ObjectAPIName) {
					proposed.BlockedBy = append(proposed.BlockedBy, fmt.Sprintf("Flow record context %s does not match WorkflowAlert object %s", recordContextObject, alert.ObjectAPIName))
				}
			}
		} else if _, ambiguous := r.lookupWorkflowDuplicatesByKey(alert.WorkflowAlertKey); ambiguous {
			proposed.BlockedBy = append(proposed.BlockedBy, "referenced WorkflowAlert key is duplicated by case-insensitive object/name lookup")
		} else {
			proposed.BlockedBy = append(proposed.BlockedBy, "referenced WorkflowAlert is missing")
		}
		if !alert.HasFaultConnector {
			proposed.BlockedBy = append(proposed.BlockedBy, "sendEmail would fault/abort this Flow unless a fault connector or approved waiver is added")
		}
		if alert.RunAtomically {
			proposed.BlockedBy = append(proposed.BlockedBy, "active Flow runAtomically=true cannot execute email I/O")
		}
		proposed.Prerequisites = uniqueSortedStrings(proposed.Prerequisites)
		proposed.BlockedBy = uniqueSortedStrings(proposed.BlockedBy)
		plan.ProposedSendEmailActions = append(plan.ProposedSendEmailActions, proposed)
	}
	sort.Slice(plan.ProposedSendEmailActions, func(i, j int) bool {
		if plan.ProposedSendEmailActions[i].SourceType == plan.ProposedSendEmailActions[j].SourceType {
			return plan.ProposedSendEmailActions[i].SourceKey < plan.ProposedSendEmailActions[j].SourceKey
		}
		return plan.ProposedSendEmailActions[i].SourceType < plan.ProposedSendEmailActions[j].SourceType
	})
	r.DryRun = plan
}

func (r *Report) workflowAlertDependencyBlockers(alert WorkflowAlertInventory) []string {
	sourceKey := workflowKey(alert.ObjectAPIName, alert.Name)
	seen := map[string]struct{}{}
	var blockers []string
	add := func(message string) {
		message = strings.TrimSpace(message)
		if message == "" {
			return
		}
		if _, ok := seen[message]; ok {
			return
		}
		seen[message] = struct{}{}
		blockers = append(blockers, message)
	}
	for _, item := range r.UnsupportedItems {
		if item.Severity != "blocker" {
			continue
		}
		if item.SourceType == constants.TableWorkflowAlert && item.SourceKey == sourceKey {
			add(fmt.Sprintf("referenced WorkflowAlert %s blocked by %s: %s", sourceKey, item.Code, item.Message))
		}
		if item.SourceType == constants.TableEmailTemplate && item.SourceKey == alert.ResolvedTemplateID && alert.ResolvedTemplateID != "" {
			add(fmt.Sprintf("referenced WorkflowAlert %s template %s blocked by %s: %s", sourceKey, alert.ResolvedTemplateID, item.Code, item.Message))
		}
	}
	sort.Strings(blockers)
	return blockers
}

func (r *Report) emailTemplateDependencyBlockers(templateID string) []string {
	templateID = strings.TrimSpace(templateID)
	if templateID == "" {
		return nil
	}
	var blockers []string
	for _, item := range r.UnsupportedItems {
		if item.Severity != "blocker" ||
			item.SourceType != constants.TableEmailTemplate ||
			item.SourceKey != templateID {
			continue
		}
		blockers = append(blockers, fmt.Sprintf("template %s blocked by %s: %s", templateID, item.Code, item.Message))
	}
	return uniqueSortedStrings(blockers)
}

func (r *Report) resolveTemplateRef(ref string) (EmailTemplateInventory, string) {
	ref = strings.TrimSpace(ref)
	if ref == "" {
		return EmailTemplateInventory{}, "empty"
	}
	if template, ok := r.templatesByID[ref]; ok {
		if template.IsDeleted {
			return EmailTemplateInventory{}, "matches deleted template id"
		}
		if !template.IsActive {
			return EmailTemplateInventory{}, "matches inactive template id"
		}
		return template, "id"
	}
	devName := trailingPathSegment(ref)
	matches := r.templatesByDeveloper[metadataLookupKey(devName)]
	switch len(matches) {
	case 0:
		return EmailTemplateInventory{}, "missing"
	case 1:
		return matches[0], "legacy_developer_name_or_folder_path"
	default:
		return EmailTemplateInventory{}, "ambiguous_developer_name"
	}
}

func (r *Report) addUnsupported(item UnsupportedItem) {
	r.UnsupportedItems = append(r.UnsupportedItems, item)
}

func (r *Report) buildVerifyZeroGate(officialPackRoot string) {
	gate := &VerifyZeroGate{}
	workflowReferences := map[string][]string{}
	var dependencies []VerifyZeroDependency

	for i := range r.ProcessActionAlerts {
		action := &r.ProcessActionAlerts[i]
		if action.Status != sourceStatusActive {
			continue
		}
		sourceKey := processActionSourceKey(*action)
		dependency := VerifyZeroDependency{
			Type:           "ProcessAction.Alert",
			SourceType:     constants.TableProcessAction,
			SourceKey:      sourceKey,
			Identifier:     action.ID,
			Owner:          strings.Trim(action.ProcessDefinitionName+"/"+action.ActionEvent, "/"),
			RuntimePath:    "ApprovalService ProcessAction.Alert -> WorkflowAlert compatibility dispatch",
			Message:        "Runtime-reachable approval ProcessAction.Alert remains. Approval notifications must bind to Flow before WorkflowAlert dispatch can be removed.",
			Recommendation: "Migrate this approval notification to the approval lifecycle-to-Flow bridge and canonical Flow sendEmail metadata, then rerun verify-zero.",
		}
		if action.APIName != "" {
			dependency.References = []string{constants.TableWorkflowAlert + ":" + action.APIName}
			for _, alert := range r.workflowAlertsByRuntimeName(action.APIName) {
				key := workflowLookupKey(alert.ObjectAPIName, alert.Name)
				workflowReferences[key] = append(workflowReferences[key], constants.TableProcessAction+":"+sourceKey)
			}
		} else if action.EmailTemplateID != "" {
			dependency.References = []string{constants.TableEmailTemplate + ":" + action.EmailTemplateID}
		}
		dependencies = append(dependencies, dependency)
		r.addUnsupported(UnsupportedItem{
			Severity:       "blocker",
			SourceType:     dependency.SourceType,
			SourceKey:      dependency.SourceKey,
			Code:           "active_process_action_alert_dependency",
			Field:          constants.FieldProcessAction_ActionType,
			Value:          constants.ProcessActionTypeAlert,
			Message:        dependency.Message,
			Recommendation: dependency.Recommendation,
		})
	}

	for _, alert := range r.FlowEmailAlerts {
		sourceKey := flowEmailAlertSourceKey(alert)
		dependency := VerifyZeroDependency{
			Type:           "Flow.emailAlert",
			SourceType:     constants.TableFlow,
			SourceKey:      sourceKey,
			Identifier:     alert.FlowVersionID,
			Owner:          alert.FlowDeveloperName,
			RuntimePath:    "Flow actionType=emailAlert -> ApprovalService.DispatchEmailAlertForFlow -> WorkflowAlert lookup",
			Message:        "Runtime-reachable Flow emailAlert action remains. Flow metadata must use canonical sendEmail before WorkflowAlert dispatch can be removed.",
			Recommendation: "Replace this actionCall with Flow sendEmail metadata generated or reviewed through the migration apply-pack path, then rerun verify-zero.",
		}
		if alert.WorkflowAlertKey != "" {
			dependency.References = []string{constants.TableWorkflowAlert + ":" + alert.WorkflowAlertKey}
			if duplicates, ok := r.lookupWorkflowDuplicatesByKey(alert.WorkflowAlertKey); ok {
				for _, workflow := range duplicates {
					key := workflowLookupKey(workflow.ObjectAPIName, workflow.Name)
					workflowReferences[key] = append(workflowReferences[key], constants.TableFlow+":"+sourceKey)
				}
			} else if workflow, ok := r.lookupWorkflowByKey(alert.WorkflowAlertKey); ok {
				key := workflowLookupKey(workflow.ObjectAPIName, workflow.Name)
				workflowReferences[key] = append(workflowReferences[key], constants.TableFlow+":"+sourceKey)
			}
		}
		dependencies = append(dependencies, dependency)
		r.addUnsupported(UnsupportedItem{
			Severity:       "blocker",
			SourceType:     dependency.SourceType,
			SourceKey:      dependency.SourceKey,
			Code:           "active_flow_email_alert_dependency",
			Field:          "actionType",
			Value:          flowEmailAlertActionType,
			Message:        dependency.Message,
			Recommendation: dependency.Recommendation,
		})
	}

	for i := range r.WorkflowAlerts {
		alert := &r.WorkflowAlerts[i]
		sourceKey := workflowAlertSourceKey(*alert)
		lookupKey := workflowLookupKey(alert.ObjectAPIName, alert.Name)
		references := uniqueSortedStrings(workflowReferences[lookupKey])
		if len(references) == 0 {
			alert.Status = sourceStatusHistorical
			alert.StatusReason = "verify-zero: no active ProcessAction.Alert or Flow emailAlert can read this row; retain as historical inventory only"
			alert.HistoricalInventory = boolPtr(true)
			alert.RuntimeReachable = boolPtr(false)
			gate.HistoricalWorkflowRows = append(gate.HistoricalWorkflowRows, VerifyZeroHistorical{
				SourceKey:           sourceKey,
				ID:                  alert.ID,
				HistoricalInventory: true,
				RuntimeReachable:    false,
				Reason:              alert.StatusReason,
			})
			continue
		}
		alert.Status = sourceStatusActive
		alert.StatusReason = "verify-zero: active runtime dependency can read this row: " + strings.Join(references, ", ")
		alert.HistoricalInventory = boolPtr(false)
		alert.RuntimeReachable = boolPtr(true)
		dependency := VerifyZeroDependency{
			Type:           "_System_WorkflowAlert",
			SourceType:     constants.TableWorkflowAlert,
			SourceKey:      sourceKey,
			Identifier:     alert.ID,
			Owner:          alert.ObjectAPIName,
			RuntimePath:    "WorkflowAlert row is reachable from active legacy notification runtime",
			Message:        "WorkflowAlert row is still reachable from active legacy notification runtime.",
			Recommendation: "Migrate or disable every active approval/Flow reference to this row before treating it as historical inventory.",
			References:     references,
		}
		dependencies = append(dependencies, dependency)
		r.addUnsupported(UnsupportedItem{
			Severity:       "blocker",
			SourceType:     dependency.SourceType,
			SourceKey:      dependency.SourceKey,
			Code:           "active_workflow_alert_dependency",
			Message:        dependency.Message,
			Recommendation: dependency.Recommendation,
		})
	}

	r.appendOfficialPackVerifyZeroDependencies(officialPackRoot, gate, &dependencies)

	sort.Slice(dependencies, func(i, j int) bool {
		if dependencies[i].SourceType == dependencies[j].SourceType {
			if dependencies[i].SourceKey == dependencies[j].SourceKey {
				return dependencies[i].Type < dependencies[j].Type
			}
			return dependencies[i].SourceKey < dependencies[j].SourceKey
		}
		return dependencies[i].SourceType < dependencies[j].SourceType
	})
	sort.Slice(gate.HistoricalWorkflowRows, func(i, j int) bool {
		return gate.HistoricalWorkflowRows[i].SourceKey < gate.HistoricalWorkflowRows[j].SourceKey
	})
	gate.ActiveDependencies = dependencies
	gate.ActiveDependencyCount = len(dependencies)
	gate.ExistingBlockerCount = countBlockers(r.UnsupportedItems) - len(dependencies)
	gate.Passed = gate.ActiveDependencyCount == 0 && gate.ExistingBlockerCount == 0
	r.VerifyZero = gate
}

func countBlockers(items []UnsupportedItem) int {
	count := 0
	for _, item := range items {
		if item.Severity == "blocker" {
			count++
		}
	}
	return count
}

func (r *Report) finalize() {
	sort.Slice(r.WorkflowAlerts, func(i, j int) bool {
		return workflowKey(r.WorkflowAlerts[i].ObjectAPIName, r.WorkflowAlerts[i].Name) < workflowKey(r.WorkflowAlerts[j].ObjectAPIName, r.WorkflowAlerts[j].Name)
	})
	sort.Slice(r.ProcessActionAlerts, func(i, j int) bool {
		if r.ProcessActionAlerts[i].ProcessDefinitionName == r.ProcessActionAlerts[j].ProcessDefinitionName {
			return r.ProcessActionAlerts[i].ID < r.ProcessActionAlerts[j].ID
		}
		return r.ProcessActionAlerts[i].ProcessDefinitionName < r.ProcessActionAlerts[j].ProcessDefinitionName
	})
	sort.Slice(r.FlowEmailAlerts, func(i, j int) bool {
		if r.FlowEmailAlerts[i].FlowDeveloperName == r.FlowEmailAlerts[j].FlowDeveloperName {
			return r.FlowEmailAlerts[i].ElementName < r.FlowEmailAlerts[j].ElementName
		}
		return r.FlowEmailAlerts[i].FlowDeveloperName < r.FlowEmailAlerts[j].FlowDeveloperName
	})
	sort.Slice(r.ReferencedTemplates, func(i, j int) bool {
		return r.ReferencedTemplates[i].ID < r.ReferencedTemplates[j].ID
	})
	sort.Slice(r.UnsupportedItems, func(i, j int) bool {
		if r.UnsupportedItems[i].SourceType == r.UnsupportedItems[j].SourceType {
			if r.UnsupportedItems[i].SourceKey == r.UnsupportedItems[j].SourceKey {
				return r.UnsupportedItems[i].Code < r.UnsupportedItems[j].Code
			}
			return r.UnsupportedItems[i].SourceKey < r.UnsupportedItems[j].SourceKey
		}
		return r.UnsupportedItems[i].SourceType < r.UnsupportedItems[j].SourceType
	})

	for _, alert := range r.WorkflowAlerts {
		if alert.Status == sourceStatusActive {
			r.Summary.ActiveWorkflowAlertCount++
		}
	}
	for _, action := range r.ProcessActionAlerts {
		if action.Status == sourceStatusActive {
			r.Summary.ActiveProcessActionCount++
		}
	}
	blockers := 0
	for _, item := range r.UnsupportedItems {
		if item.Severity == "blocker" {
			blockers++
		}
	}
	r.Summary.WorkflowAlertCount = len(r.WorkflowAlerts)
	for _, alert := range r.WorkflowAlerts {
		if alert.HistoricalInventory != nil && *alert.HistoricalInventory {
			r.Summary.HistoricalWorkflowAlertCount++
		}
	}
	r.Summary.ProcessActionAlertCount = len(r.ProcessActionAlerts)
	r.Summary.FlowEmailAlertCount = len(r.FlowEmailAlerts)
	r.Summary.ObjectFieldCount = len(r.ObjectFields)
	r.Summary.ReferencedTemplateCount = len(r.ReferencedTemplates)
	r.Summary.UnsupportedItemCount = len(r.UnsupportedItems)
	r.Summary.BlockerCount = blockers
	r.CutoverBlocked = blockers > 0

	r.templatesByID = nil
	r.templatesByDeveloper = nil
	r.workflowsByKey = nil
	r.fieldsByObject = nil
	r.loadedFieldObjects = nil
	r.templateRefSet = nil
}

func boolPtr(value bool) *bool {
	return &value
}

func (r *Report) classifyTemplateTokens(template EmailTemplateInventory, objectRoots map[string]struct{}) []TemplateTokenFinding {
	var findings []TemplateTokenFinding
	for _, field := range []struct {
		name string
		text string
	}{
		{name: constants.FieldSysEmailTemplate_Subject, text: template.Subject},
		{name: constants.FieldSysEmailTemplate_HTMLValue, text: template.HTMLValue},
		{name: constants.FieldSysEmailTemplate_Body, text: template.Body},
	} {
		salesforceMatches := salesforceMergeTokenRE.FindAllStringSubmatchIndex(field.text, -1)
		for _, match := range salesforceMatches {
			if len(match) < 4 {
				continue
			}
			token := strings.TrimSpace(field.text[match[2]:match[3]])
			findings = append(findings, r.classifySalesforceTemplateToken(field.name, token, objectRoots))
		}
		findings = append(findings, classifyMalformedSalesforceMergeSyntax(field.name, field.text, salesforceMatches)...)
		findings = append(findings, r.classifyNativeTemplateTokens(field.name, field.text, objectRoots)...)
	}
	sort.Slice(findings, func(i, j int) bool {
		if findings[i].Field == findings[j].Field {
			return findings[i].Token < findings[j].Token
		}
		return findings[i].Field < findings[j].Field
	})
	return findings
}

func classifyMalformedSalesforceMergeSyntax(fieldName, text string, matches [][]int) []TemplateTokenFinding {
	var findings []TemplateTokenFinding
	for offset := 0; ; {
		relative := strings.Index(text[offset:], "{!")
		if relative < 0 {
			break
		}
		start := offset + relative
		if !rangeStartCovered(start, matches) {
			findings = append(findings, TemplateTokenFinding{
				Field:          fieldName,
				Token:          legacyMergeTokenFragment(text[start:]),
				Classification: "invalid_legacy_syntax",
				Message:        "malformed Salesforce merge syntax is not supported by native sendEmail rendering",
			})
		}
		offset = start + len("{!")
	}
	return findings
}

func rangeStartCovered(start int, matches [][]int) bool {
	for _, match := range matches {
		if len(match) >= 2 && start >= match[0] && start < match[1] {
			return true
		}
	}
	return false
}

func legacyMergeTokenFragment(text string) string {
	text = strings.TrimSpace(text)
	if idx := strings.IndexAny(text, "\r\n\t "); idx > 0 {
		text = text[:idx]
	}
	if len(text) > 80 {
		text = text[:80]
	}
	return text
}

func (r *Report) classifyNativeTemplateTokens(fieldName, text string, objectRoots map[string]struct{}) []TemplateTokenFinding {
	var findings []TemplateTokenFinding
	remaining := text
	for {
		open := strings.Index(remaining, "{{")
		closeBeforeOpen := strings.Index(remaining, "}}")
		if open < 0 {
			if closeBeforeOpen >= 0 {
				findings = append(findings, invalidNativeTemplateTokenFinding(fieldName, "}}", "unmatched closing native template token"))
			}
			return findings
		}
		if closeBeforeOpen >= 0 && closeBeforeOpen < open {
			findings = append(findings, invalidNativeTemplateTokenFinding(fieldName, "}}", "unmatched closing native template token"))
			remaining = remaining[closeBeforeOpen+len("}}"):]
			continue
		}
		afterOpen := remaining[open+len("{{"):]
		close := strings.Index(afterOpen, "}}")
		if close < 0 {
			findings = append(findings, invalidNativeTemplateTokenFinding(fieldName, remaining[open:], "unmatched opening native template token"))
			return findings
		}
		rawInner := afterOpen[:close]
		rawToken := "{{" + rawInner + "}}"
		if finding, ok := r.classifyNativeTemplateToken(fieldName, rawToken, rawInner, objectRoots); ok {
			findings = append(findings, finding)
		}
		remaining = afterOpen[close+len("}}"):]
	}
}

func (r *Report) classifyNativeTemplateToken(fieldName, rawToken, rawInner string, objectRoots map[string]struct{}) (TemplateTokenFinding, bool) {
	token := strings.TrimSpace(rawToken)
	inner := strings.TrimSpace(rawInner)
	if inner == "" {
		return invalidNativeTemplateTokenFinding(fieldName, token, "native template token is empty"), true
	}
	if strings.ContainsAny(inner, "{}!") {
		return invalidNativeTemplateTokenFinding(fieldName, token, fmt.Sprintf("native template token %q is malformed", token)), true
	}
	parts := strings.Split(inner, ".")
	if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
		return invalidNativeTemplateTokenFinding(fieldName, token, fmt.Sprintf("native template token %q must use exactly one approved root and field name", token)), true
	}
	root := strings.TrimSpace(parts[0])
	fieldPath := strings.TrimSpace(parts[1])
	if root != parts[0] || fieldPath != parts[1] {
		return invalidNativeTemplateTokenFinding(fieldName, token, fmt.Sprintf("native template token %q has leading or trailing whitespace in a segment", token)), true
	}
	switch root {
	case "Record":
		if ok, missingRoot := r.recordFieldExistsOnAllRoots(objectRoots, fieldPath); !ok {
			if missingRoot == "" {
				return TemplateTokenFinding{
					Field:          fieldName,
					Token:          token,
					Classification: "unsupported",
					Message:        fmt.Sprintf("native Record token %q cannot be validated because no record root was available", token),
				}, true
			}
			return TemplateTokenFinding{
				Field:          fieldName,
				Token:          token,
				Classification: "unsupported",
				Message:        fmt.Sprintf("native Record token %q references field %q that was not found on %s metadata", token, fieldPath, missingRoot),
			}, true
		}
		return TemplateTokenFinding{}, false
	case "User":
		if !validUserTemplateField(fieldPath) {
			return TemplateTokenFinding{
				Field:          fieldName,
				Token:          token,
				Classification: "unsupported",
				Message:        fmt.Sprintf("native User token %q references field %q outside the current User template projection", token, fieldPath),
			}, true
		}
		return TemplateTokenFinding{}, false
	case "Variables":
		return TemplateTokenFinding{
			Field:          fieldName,
			Token:          token,
			Classification: "unsupported",
			Message:        fmt.Sprintf("native Variables token %q has no source-specific sendEmail variables contract in this migration report", token),
		}, true
	default:
		return TemplateTokenFinding{
			Field:          fieldName,
			Token:          token,
			Classification: "invalid_native_root",
			Message:        fmt.Sprintf("native template token root %q is not supported; allowed roots are Record, User, and Variables", root),
		}, true
	}
}

func invalidNativeTemplateTokenFinding(fieldName, token, message string) TemplateTokenFinding {
	return TemplateTokenFinding{
		Field:          fieldName,
		Token:          strings.TrimSpace(token),
		Classification: "invalid_native_shape",
		Message:        message,
	}
}

func (r *Report) classifySalesforceTemplateToken(fieldName, token string, objectRoots map[string]struct{}) TemplateTokenFinding {
	root, path := tokenRootAndPath(token)
	switch {
	case root == "Recipient" || token == "ApproverName":
		return TemplateTokenFinding{
			Field:          fieldName,
			Token:          "{!" + token + "}",
			Classification: "unsupported",
			Message:        fmt.Sprintf("Salesforce merge token %q depends on legacy WorkflowAlert recipient/approval runtime state that sendEmail does not expose", token),
		}
	case root == "ApprovalRequest" && path == "Internal_URL":
		return TemplateTokenFinding{
			Field:          fieldName,
			Token:          "{!" + token + "}",
			Classification: "blocked_product_decision",
			Message:        "ApprovalRequest.Internal_URL requires an approved NexusCRM Approval.RequestUrl variable contract before automatic translation.",
		}
	case root == "User" && path != "":
		if strings.Contains(path, ".") {
			return TemplateTokenFinding{
				Field:          fieldName,
				Token:          "{!" + token + "}",
				Classification: "unsupported",
				Message:        fmt.Sprintf("Salesforce User merge token %q cannot translate to native sendEmail because native tokens support exactly one field segment", token),
			}
		}
		if !validUserTemplateField(path) {
			return TemplateTokenFinding{
				Field:          fieldName,
				Token:          "{!" + token + "}",
				Classification: "unsupported",
				Message:        fmt.Sprintf("Salesforce User merge token %q references field %q outside the current User template projection", token, path),
			}
		}
		return TemplateTokenFinding{
			Field:          fieldName,
			Token:          "{!" + token + "}",
			Classification: "candidate",
			Replacement:    "{{User." + path + "}}",
			Message:        "candidate native User token translation",
		}
	case objectRoots != nil:
		if matchedRoot, ok := objectRootForToken(objectRoots, root); ok && path != "" {
			if strings.Contains(path, ".") {
				return TemplateTokenFinding{
					Field:          fieldName,
					Token:          "{!" + token + "}",
					Classification: "unsupported",
					Message:        fmt.Sprintf("Salesforce record merge token %q cannot translate to native sendEmail because native Record tokens support exactly one field segment", token),
				}
			}
			field, ok := r.lookupObjectFieldByAPINameFold(matchedRoot, path)
			if !ok || field.IsDeleted {
				return TemplateTokenFinding{
					Field:          fieldName,
					Token:          "{!" + token + "}",
					Classification: "unsupported",
					Message:        fmt.Sprintf("Salesforce record merge token %q references field %q that was not found on %s metadata", token, path, root),
				}
			}
			return TemplateTokenFinding{
				Field:          fieldName,
				Token:          "{!" + token + "}",
				Classification: "candidate",
				Replacement:    "{{Record." + field.FieldAPIName + "}}",
				Message:        "candidate native Record token translation",
			}
		}
	}
	return TemplateTokenFinding{
		Field:          fieldName,
		Token:          "{!" + token + "}",
		Classification: "unsupported",
		Message:        fmt.Sprintf("Salesforce merge token root %q has no exact approved native translation", root),
	}
}

func (r *Report) templateRecordRoots(templateID string) map[string]struct{} {
	roots := map[string]struct{}{}
	for _, alert := range r.WorkflowAlerts {
		if alert.ResolvedTemplateID == templateID && alert.ObjectAPIName != "" {
			addObjectRoot(roots, alert.ObjectAPIName)
		}
	}
	for _, action := range r.ProcessActionAlerts {
		if action.Status != sourceStatusActive || action.ProcessDefinitionObject == "" {
			continue
		}
		if action.APIName != "" {
			if workflow, ok := r.lookupWorkflowByKey(action.WorkflowAlertKey); ok && workflow.ResolvedTemplateID == templateID {
				addObjectRoot(roots, action.ProcessDefinitionObject)
			}
			continue
		}
		if template, _ := r.resolveTemplateRef(action.EmailTemplateID); template.ID == templateID {
			addObjectRoot(roots, action.ProcessDefinitionObject)
		}
	}
	for _, alert := range r.FlowEmailAlerts {
		workflow, ok := r.lookupWorkflowByKey(alert.WorkflowAlertKey)
		if !ok || workflow.ResolvedTemplateID != templateID {
			continue
		}
		if !r.flowEmailAlertRecordContextValid(alert, workflow) {
			continue
		}
		if alert.RecordContextObject != "" {
			addObjectRoot(roots, alert.RecordContextObject)
		} else if alert.ObjectAPIName != "" {
			addObjectRoot(roots, alert.ObjectAPIName)
		}
	}
	return roots
}

func templateHasRecordScopedTokens(template EmailTemplateInventory) bool {
	text := strings.Join([]string{template.Subject, template.HTMLValue, template.Body}, "\n")
	for _, match := range nativeMergeTokenRE.FindAllStringSubmatch(text, -1) {
		if strings.TrimSpace(match[1]) == "Record" {
			return true
		}
	}
	for _, match := range salesforceMergeTokenRE.FindAllStringSubmatch(text, -1) {
		root, _ := tokenRootAndPath(strings.TrimSpace(match[1]))
		if root != "" && root != "User" && root != "Recipient" && root != "ApproverName" && root != "ApprovalRequest" {
			return true
		}
	}
	return false
}

func (r *Report) workflowAlertNeedsRecordContext(alert WorkflowAlertInventory) bool {
	for _, recipient := range alert.RecipientInventory {
		if strings.TrimSpace(recipient.Field) != "" {
			return true
		}
	}
	if alert.ResolvedTemplateID == "" {
		return false
	}
	template, ok := r.templatesByID[alert.ResolvedTemplateID]
	return ok && templateHasRecordScopedTokens(template)
}

func (r *Report) flowEmailAlertRecordContextValid(alert FlowEmailAlertInventory, workflow WorkflowAlertInventory) bool {
	if !r.workflowAlertNeedsRecordContext(workflow) {
		return true
	}
	recordContextObject := strings.TrimSpace(alert.RecordContextObject)
	return recordContextObject != "" && strings.EqualFold(recordContextObject, alert.ObjectAPIName)
}

func (r *Report) workflowAlertsByRuntimeName(name string) []WorkflowAlertInventory {
	name = strings.TrimSpace(name)
	if name == "" {
		return nil
	}
	var matches []WorkflowAlertInventory
	for _, alert := range r.WorkflowAlerts {
		if alert.Status == sourceStatusActive && strings.EqualFold(alert.Name, name) {
			matches = append(matches, alert)
		}
	}
	sort.Slice(matches, func(i, j int) bool {
		return workflowKey(matches[i].ObjectAPIName, matches[i].Name) < workflowKey(matches[j].ObjectAPIName, matches[j].Name)
	})
	return matches
}

func workflowAlertKeys(alerts []WorkflowAlertInventory) []string {
	keys := make([]string, 0, len(alerts))
	for _, alert := range alerts {
		keys = append(keys, workflowKey(alert.ObjectAPIName, alert.Name))
	}
	sort.Strings(keys)
	return keys
}

func workflowAlertInventoryKeys(alerts []WorkflowAlertInventory) []string {
	keys := make([]string, 0, len(alerts))
	for _, alert := range alerts {
		key := workflowKey(alert.ObjectAPIName, alert.Name)
		if alert.ID != "" {
			key += "#" + alert.ID
		}
		keys = append(keys, key)
	}
	sort.Strings(keys)
	return keys
}

func duplicateWorkflowAlertSourceKey(alerts []WorkflowAlertInventory, fallback string) string {
	keys := workflowAlertKeys(alerts)
	if len(keys) > 0 && keys[0] != "." {
		return keys[0]
	}
	return fallback
}

func (r *Report) lookupWorkflowByKey(key string) (WorkflowAlertInventory, bool) {
	workflow, ok := r.workflowsByKey[metadataLookupKey(key)]
	return workflow, ok
}

func (r *Report) lookupWorkflowDuplicatesByKey(key string) ([]WorkflowAlertInventory, bool) {
	duplicates := r.workflowDuplicateKeys[metadataLookupKey(key)]
	return duplicates, len(duplicates) > 0
}

func (r *Report) recordFieldExistsOnAllRoots(objectRoots map[string]struct{}, fieldAPIName string) (bool, string) {
	if len(objectRoots) == 0 {
		return false, ""
	}
	for objectAPIName := range objectRoots {
		field, ok := r.lookupObjectFieldByAPIName(objectAPIName, fieldAPIName)
		if !ok || field.IsDeleted {
			return false, objectAPIName
		}
	}
	return true, ""
}

func (r *Report) lookupObjectFieldByAPIName(objectAPIName, fieldAPIName string) (ObjectFieldInventory, bool) {
	objectAPIName = strings.TrimSpace(objectAPIName)
	fieldAPIName = strings.TrimSpace(fieldAPIName)
	if objectAPIName == "" || fieldAPIName == "" {
		return ObjectFieldInventory{}, false
	}
	for _, field := range r.ObjectFields {
		if strings.EqualFold(field.ObjectAPIName, objectAPIName) && field.FieldAPIName == fieldAPIName {
			return field, true
		}
	}
	return ObjectFieldInventory{}, false
}

func (r *Report) lookupObjectFieldByAPINameFold(objectAPIName, fieldAPIName string) (ObjectFieldInventory, bool) {
	objectAPIName = strings.TrimSpace(objectAPIName)
	fieldAPIName = strings.TrimSpace(fieldAPIName)
	if objectAPIName == "" || fieldAPIName == "" {
		return ObjectFieldInventory{}, false
	}
	for _, field := range r.ObjectFields {
		if strings.EqualFold(field.ObjectAPIName, objectAPIName) && strings.EqualFold(field.FieldAPIName, fieldAPIName) {
			return field, true
		}
	}
	return ObjectFieldInventory{}, false
}

func validUserTemplateField(fieldAPIName string) bool {
	switch strings.TrimSpace(fieldAPIName) {
	case "Id", "ID", "Name", "Email", "ProfileId", "ProfileID", "RoleId", "RoleID", "RoleName":
		return true
	default:
		return false
	}
}

func objectRootForToken(objectRoots map[string]struct{}, root string) (string, bool) {
	for objectAPIName := range objectRoots {
		if strings.EqualFold(objectAPIName, root) {
			return objectAPIName, true
		}
	}
	return "", false
}

func addObjectRoot(roots map[string]struct{}, objectAPIName string) {
	objectAPIName = strings.TrimSpace(objectAPIName)
	if objectAPIName == "" {
		return
	}
	for existing := range roots {
		if strings.EqualFold(existing, objectAPIName) {
			return
		}
	}
	roots[objectAPIName] = struct{}{}
}

func (r *Report) objectRoots() []string {
	seen := map[string]struct{}{}
	for _, alert := range r.WorkflowAlerts {
		if alert.ObjectAPIName != "" {
			addObjectRoot(seen, alert.ObjectAPIName)
		}
	}
	for _, action := range r.ProcessActionAlerts {
		if action.ProcessDefinitionObject != "" {
			addObjectRoot(seen, action.ProcessDefinitionObject)
		}
	}
	for _, alert := range r.FlowEmailAlerts {
		if alert.StartObject != "" {
			addObjectRoot(seen, alert.StartObject)
		}
		if alert.RecordContextObject != "" {
			addObjectRoot(seen, alert.RecordContextObject)
		}
		if alert.ObjectAPIName != "" {
			addObjectRoot(seen, alert.ObjectAPIName)
		}
	}
	roots := make([]string, 0, len(seen))
	for root := range seen {
		roots = append(roots, root)
	}
	sort.Strings(roots)
	return roots
}

func (r *Report) unloadedObjectNames(objectAPINames []string) []string {
	seen := map[string]string{}
	for _, objectAPIName := range objectAPINames {
		objectAPIName = strings.TrimSpace(objectAPIName)
		if objectAPIName == "" {
			continue
		}
		key := strings.ToLower(objectAPIName)
		if _, loaded := r.loadedFieldObjects[key]; loaded {
			continue
		}
		if _, exists := seen[key]; !exists {
			seen[key] = objectAPIName
		}
	}
	out := make([]string, 0, len(seen))
	for _, objectAPIName := range seen {
		out = append(out, objectAPIName)
	}
	sort.Strings(out)
	return out
}

func (r *Report) unloadedRecipientPathTargets() []string {
	targets := map[string]string{}
	for _, alert := range r.WorkflowAlerts {
		for _, recipient := range alert.RecipientInventory {
			for _, target := range r.recipientPathTargets(alert.ObjectAPIName, recipient.Field) {
				key := strings.ToLower(target)
				if _, loaded := r.loadedFieldObjects[key]; loaded {
					continue
				}
				targets[key] = target
			}
		}
	}
	out := make([]string, 0, len(targets))
	for _, target := range targets {
		out = append(out, target)
	}
	sort.Strings(out)
	return out
}

func (r *Report) recipientPathTargets(objectAPIName, fieldPath string) []string {
	objectAPIName = strings.TrimSpace(objectAPIName)
	fieldPath = strings.TrimSpace(fieldPath)
	if objectAPIName == "" || fieldPath == "" || !strings.Contains(fieldPath, ".") {
		return nil
	}
	var targets []string
	currentObject := objectAPIName
	segments := strings.Split(fieldPath, ".")
	for _, segment := range segments[:len(segments)-1] {
		segment = strings.TrimSpace(segment)
		if segment == "" {
			return targets
		}
		field, ok := r.lookupObjectField(currentObject, segment)
		if !ok {
			field, ok = r.lookupObjectField(currentObject, segment+"Id")
		}
		if !ok || field.IsDeleted || len(field.ReferenceTo) != 1 || strings.TrimSpace(field.ReferenceTo[0]) == "" {
			return targets
		}
		nextObject := strings.TrimSpace(field.ReferenceTo[0])
		targets = append(targets, nextObject)
		currentObject = nextObject
	}
	return targets
}

func splitEmailAlertActionName(actionName, startObject string) (string, string) {
	actionName = strings.TrimSpace(actionName)
	if actionName == "" {
		return "", ""
	}
	if idx := strings.Index(actionName, "."); idx > 0 && idx < len(actionName)-1 {
		return strings.TrimSpace(actionName[:idx]), strings.TrimSpace(actionName[idx+1:])
	}
	if strings.Contains(actionName, ".") {
		return "", actionName
	}
	return strings.TrimSpace(startObject), actionName
}

func emailAlertActionNameScoped(actionName string) bool {
	actionName = strings.TrimSpace(actionName)
	idx := strings.Index(actionName, ".")
	return idx > 0 && idx < len(actionName)-1
}

func flowRecordVariableObject(metadata models.FlowMetadata) string {
	for _, variable := range metadata.Variables {
		if !strings.EqualFold(strings.TrimSpace(variable.Name), "$Record") {
			continue
		}
		if !strings.EqualFold(strings.TrimSpace(variable.DataType), "SObject") {
			continue
		}
		return strings.TrimSpace(variable.ObjectType)
	}
	return ""
}

func flowWorkflowRecordObject(definitionProcessType, versionProcessType string, metadata models.FlowMetadata) (string, error) {
	projection, err := resolveFlowTriggerProjection(
		&models.FlowDefinition{ProcessType: definitionProcessType},
		&models.FlowVersion{ProcessType: versionProcessType, Metadata: metadata},
	)
	if err != nil {
		return "", err
	}
	if projection.Kind != triggerProjectionKindRecord {
		return "", nil
	}
	return strings.TrimSpace(projection.ObjectOrEvent), nil
}

func flowConnectorTarget(connector *models.FlowConnector) string {
	if connector == nil {
		return ""
	}
	return strings.TrimSpace(connector.TargetReference)
}

func uniqueSortedStrings(values []string) []string {
	if len(values) == 0 {
		return nil
	}
	seen := map[string]struct{}{}
	out := make([]string, 0, len(values))
	for _, value := range values {
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		out = append(out, value)
	}
	sort.Strings(out)
	return out
}

func workflowAlertSourceKey(alert WorkflowAlertInventory) string {
	sourceKey := workflowKey(alert.ObjectAPIName, alert.Name)
	if sourceKey == "" {
		sourceKey = alert.ID
	}
	return sourceKey
}

func processActionSourceKey(action ProcessActionInventory) string {
	sourceKey := action.ID
	if action.ProcessDefinitionName != "" || action.ActionEvent != "" || action.Name != "" {
		sourceKey = strings.Trim(action.ProcessDefinitionName+"/"+action.ActionEvent+"/"+action.Name, "/")
	}
	return sourceKey
}

func flowEmailAlertSourceKey(alert FlowEmailAlertInventory) string {
	if !strings.Contains(alert.RuntimeState, "active_version") &&
		(alert.PausedInterviewCount > 0 || alert.RecoverableRunningInterviewCount > 0) {
		return strings.Trim(fmt.Sprintf("%s/v%d/%s", alert.FlowDeveloperName, alert.FlowVersionNumber, alert.ElementName), "/")
	}
	return strings.Trim(alert.FlowDeveloperName+"/"+alert.ElementName, "/")
}

func flowEmailAlertRuntimeState(activeVersion bool, pausedInterviewCount, recoverableRunningInterviewCount int) string {
	var states []string
	if activeVersion {
		states = append(states, "active_version")
	}
	if pausedInterviewCount > 0 {
		states = append(states, "paused_interview")
	}
	if recoverableRunningInterviewCount > 0 {
		states = append(states, "recoverable_running_interview")
	}
	if len(states) == 0 {
		return "runtime_reachable"
	}
	return strings.Join(states, "+")
}

func workflowKey(objectAPIName, name string) string {
	objectAPIName = strings.TrimSpace(objectAPIName)
	name = strings.TrimSpace(name)
	if objectAPIName == "" {
		return name
	}
	if name == "" {
		return objectAPIName
	}
	return objectAPIName + "." + name
}

func workflowLookupKey(objectAPIName, name string) string {
	return metadataLookupKey(workflowKey(objectAPIName, name))
}

func metadataLookupKey(value string) string {
	return strings.ToLower(strings.TrimSpace(value))
}

func splitCSV(value string) []string {
	parts := strings.Split(value, ",")
	out := make([]string, 0, len(parts))
	for _, part := range parts {
		part = strings.TrimSpace(part)
		if part != "" {
			out = append(out, part)
		}
	}
	sort.Strings(out)
	return out
}

func trailingPathSegment(value string) string {
	value = strings.TrimSpace(value)
	if idx := strings.LastIndex(value, "/"); idx >= 0 && idx < len(value)-1 {
		return value[idx+1:]
	}
	return value
}

func tokenRootAndPath(token string) (string, string) {
	parts := strings.SplitN(strings.TrimSpace(token), ".", 2)
	root := strings.TrimSpace(parts[0])
	if len(parts) == 1 {
		return root, ""
	}
	return root, strings.TrimSpace(parts[1])
}

func parseReferenceTo(raw string) []string {
	raw = strings.TrimSpace(raw)
	if raw == "" || raw == "null" {
		return nil
	}
	var refs []string
	if err := json.Unmarshal([]byte(raw), &refs); err == nil {
		return cleanStringSlice(refs)
	}
	var single string
	if err := json.Unmarshal([]byte(raw), &single); err == nil {
		return cleanStringSlice([]string{single})
	}
	if strings.Contains(raw, ",") {
		return cleanStringSlice(strings.Split(raw, ","))
	}
	return cleanStringSlice([]string{raw})
}

func cleanStringSlice(values []string) []string {
	out := make([]string, 0, len(values))
	for _, value := range values {
		value = strings.TrimSpace(value)
		if value != "" {
			out = append(out, value)
		}
	}
	sort.Strings(out)
	return out
}

func sortedKeys(values map[string]struct{}) []string {
	keys := make([]string, 0, len(values))
	for key := range values {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	return keys
}

func sendEmailActionName(name string) string {
	name = strings.TrimSpace(name)
	if name == "" {
		return "Send_Email"
	}
	name = strings.Map(func(r rune) rune {
		switch {
		case r >= 'a' && r <= 'z':
			return r
		case r >= 'A' && r <= 'Z':
			return r
		case r >= '0' && r <= '9':
			return r
		default:
			return '_'
		}
	}, name)
	name = strings.Trim(name, "_")
	if name == "" {
		return "Send_Email"
	}
	return "Send_Email_" + name
}
