package rest

import (
	"bytes"
	"context"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/nexuscrm/backend/internal/bootstrap"
	"github.com/nexuscrm/backend/internal/features/automation"
	"github.com/nexuscrm/backend/pkg/formula"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

func newDispatcherTestContext(body string) (*gin.Context, *httptest.ResponseRecorder) {
	gin.SetMode(gin.TestMode)
	w := httptest.NewRecorder()
	c, _ := gin.CreateTestContext(w)
	c.Request = httptest.NewRequest(http.MethodPost, "/services/data/v63.0/actions/standard/doesnotmatter",
		bytes.NewReader([]byte(body)))
	c.Request.Header.Set("Content-Type", "application/json")
	withTestUser(c)
	return c, w
}

// stubPermissions is a minimal dispatcherPermissions stub used by unit
// tests. By default it grants every permission; tests that need to
// exercise input-guard denial set `deny` to block specific apiNames.
type stubPermissions struct {
	deny       map[string]bool
	denyObject map[string]bool
	denyField  map[string]bool
}

func (s stubPermissions) HasPermission(_ context.Context, _ *models.UserSession, apiName string) bool {
	return !s.deny[apiName]
}

func (s stubPermissions) CheckObjectPermissionWithUser(_ context.Context, objectAPIName string, operation string, _ *models.UserSession) bool {
	return !s.denyObject[objectAPIName+"."+operation]
}

func (s stubPermissions) CheckFieldVisibilityWithUser(_ context.Context, objectAPIName string, fieldAPIName string, _ *models.UserSession) bool {
	return !s.denyField[objectAPIName+"."+fieldAPIName]
}

// stubAllowAll keeps the old name for the default allow-all variant.
type stubAllowAll = stubPermissions

type stubActionSchemaMetadata struct {
	schemas map[string]*models.ObjectMetadata
}

func (s stubActionSchemaMetadata) GetSchema(_ context.Context, apiName string) *models.ObjectMetadata {
	return s.schemas[apiName]
}

func newActionGuardRows() *sqlmock.Rows {
	return sqlmock.NewRows([]string{
		"Condition",
		"RequiredPermission",
		"RequiredObject",
		"RequiredOperation",
		"ErrorMessage",
		"GatedInput",
		"SortOrder",
	})
}

func newInternalAccessScopeRows() *sqlmock.Rows {
	return sqlmock.NewRows([]string{"StandardActionId", "ObjectApiName", "Operation", "Reason"})
}

func expectInternalAccessScopes(mock sqlmock.Sqlmock, actionID string, rows *sqlmock.Rows) {
	expectInternalAccessScopeOwnerLookup(mock, actionID, "convertLead", "Std_ConvertLead", false)
	expectActiveStandardFlowPackOwned(mock, "Std_ConvertLead", bootstrap.FlowSourceStandard, false)
	mock.ExpectQuery(`SELECT StandardActionId, ObjectApiName, Operation, Reason\s+FROM _System_StandardActionInternalAccessScope\s+WHERE StandardActionId = \? AND IsActive = 1 AND IsDeleted = 0\s+AND \(`).
		WillReturnRows(rows)
}

func expectNoInternalAccessScopes(mock sqlmock.Sqlmock, actionID, actionName, flowName string) {
	expectInternalAccessScopeOwnerLookup(mock, actionID, actionName, flowName, false)
}

func expectInternalAccessScopeOwnerLookup(mock sqlmock.Sqlmock, actionID, actionName, flowName string, isCustomized bool) {
	mock.ExpectQuery(`SELECT Name, FlowDeveloperName, IsCustomized FROM .*_System_StandardAction.* WHERE Id = \? AND IsDeleted = 0`).
		WithArgs(actionID).
		WillReturnRows(sqlmock.NewRows([]string{"Name", "FlowDeveloperName", "IsCustomized"}).AddRow(actionName, flowName, isCustomized))
}

func expectActiveStandardFlowPackOwned(mock sqlmock.Sqlmock, flowName, source string, isCustomized bool) {
	mock.ExpectQuery(`SELECT fd\.IsCustomized, f\.Source\s+FROM FlowDefinition fd\s+LEFT JOIN Flow f ON f\.Id = fd\.ActiveVersionId AND f\.IsDeleted = 0\s+WHERE fd\.DeveloperName = \? AND fd\.IsDeleted = 0`).
		WithArgs(flowName).
		WillReturnRows(sqlmock.NewRows([]string{"IsCustomized", "Source"}).AddRow(isCustomized, source))
}

// TestDispatcher_MalformedRequiredPermissionsJSON_Returns500 pins the
// unmarshal-error contract: a registry row with corrupt RequiredPermissions
// JSON must surface as HTTP 500 with SF-shaped INTERNAL_ERROR. Previously
// the dispatcher silently ignored the parse error (`_ = json.Unmarshal`),
// which — combined with the W3 fail-closed check — would mask the real
// root cause (registry-row corruption) as a permission denial (403). The
// fix returns a proper error from lookupAction; Dispatch responds 500 with
// a message that includes the action name + "RequiredPermissions" + "not
// valid JSON" so ops can grep logs to the exact bad row.
func TestDispatcher_MalformedRequiredPermissionsJSON_Returns500(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	// Corrupt JSON in the RequiredPermissions column — production could
	// see this from a manual SQL edit or a schema-migration bug.
	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `this is not json`))

	d := &StandardActionDispatcher{db: db, permissions: stubAllowAll{}, formulas: newTestFormulaEngine(t)}

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	require.Equal(t, http.StatusInternalServerError, w.Code,
		"registry-row corruption must surface as 500, not 403")
	body := w.Body.String()
	assert.Contains(t, body, "INTERNAL_ERROR",
		"error code must be SF-shaped INTERNAL_ERROR, not UNKNOWN_EXCEPTION or INVALID_REQUEST")
	assert.Contains(t, body, "convertLead", "message must name the misconfigured action")
	assert.Contains(t, body, "RequiredPermissions", "message must point at the bad column")
	assert.Contains(t, body, "not valid JSON", "message must identify the failure mode so ops can grep")
	require.NoError(t, mock.ExpectationsWereMet())
}

// TestDispatcher_List_ReturnsActivePermittedActions pins Part E's contract.
// GET /actions/standard returns the subset of active _System_StandardAction
// rows the caller is permitted to invoke — no more, no less. Frontend
// button visibility reads from this list, so admin deactivation (IsActive=0)
// must translate directly to "row disappears from the frontend menu".
func TestDispatcher_List_ReturnsActivePermittedActions(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	// Registry has 3 rows — one caller can run, one they can't, one with
	// empty perms (misconfigured). List must return only the one row.
	mock.ExpectQuery(`SELECT Id, Name, Label, Description, Category, FlowDeveloperName,\s+IsAtomic, RequiredPermissions, TargetObjects FROM _System_StandardAction`).
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "Name", "Label", "Description", "Category", "FlowDeveloperName", "IsAtomic", "RequiredPermissions", "TargetObjects",
		}).
			AddRow("sa_convertlead", "convertLead", "Convert Lead", "Converts a lead", "LeadActions", "Std_ConvertLead", true, `["ConvertLeads"]`, `["Lead"]`).
			AddRow("sa_restrictedaction", "restrictedAction", "Restricted", "", "AdminActions", "Std_Restricted", false, `["ModifyAllData"]`, `[]`).
			AddRow("sa_misconfiguredaction", "misconfiguredAction", "Misconfigured", "", "", "Std_Misc", false, `[]`, `[]`))
	// List resolves guards before schema so whole-action guard failures can
	// hide actions without extra metadata queries. Only
	// convertLead passes the permission filter. The lookups use entry.ID (the
	// registry Id column) — NOT a derivation from Name.
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows())
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "RequiredPicklistTag", "IsRequired", "SortOrder"}).
			AddRow("input", "leadId", "id", nil, nil, 1, 0).
			AddRow("input", "convertedStatus", "picklist", nil, "converted", 1, 1).
			AddRow("input", "accountId", "reference", "Account", nil, 0, 2).
			AddRow("output", "accountId", "reference", "Account", nil, 0, 0))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}).
			AddRow(actionInputDefaultTarget, "convertedStatus", "'MetadataDefault'", nil, 0).
			AddRow("Opportunity", "CloseDate", "TODAY() + 30", nil, 0))

	d := &StandardActionDispatcher{db: db, permissions: stubPermissions{deny: map[string]bool{"ModifyAllData": true}}}

	c, w := newDispatcherTestContext(`{}`)
	c.Request.Method = http.MethodGet
	d.List(c)

	require.Equal(t, http.StatusOK, w.Code)
	body := w.Body.String()
	assert.Contains(t, body, `"name":"convertLead"`)
	assert.Contains(t, body, `"targetObjects":["Lead"]`,
		"the Lead→convertLead binding must be metadata; frontend reads this field to decide button placement")
	assert.Contains(t, body, `"inputs":[`,
		"action schema must be exposed so the modal renders from metadata")
	assert.Contains(t, body, `"name":"accountId","type":"reference","referenceTo":"Account"`)
	assert.Contains(t, body, `"name":"convertedStatus","type":"picklist"`,
		"convertedStatus must be discoverable so UI can render it when metadata exposes it")
	assert.Contains(t, body, `"name":"convertedStatus","type":"picklist","required":true`,
		"convertedStatus is required unless org-owned input-default metadata fills it before schema validation")
	assert.Contains(t, body, `"requiredPicklistTag":"converted"`,
		"convertedStatus must advertise the target picklist tag required by dispatch")
	assert.Contains(t, body, `"defaulted":true`,
		"$Input-scoped ActionFieldDefault metadata must be discoverable so frontend validation does not shadow server defaults")
	assert.Contains(t, body, `"defaultValue":"MetadataDefault"`,
		"unconditional $Input defaults should be surfaced without hardcoded UI fallbacks")
	assert.Contains(t, body, `"endpoint":"/services/data/v63.0/actions/standard/convertLead"`)
	assert.NotContains(t, body, "restrictedAction",
		"caller lacks ModifyAllData → restrictedAction must be filtered out")
	assert.NotContains(t, body, "misconfiguredAction",
		"empty RequiredPermissions → row is hidden (same fail-closed posture as Dispatch)")
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_List_DoesNotAdvertiseConditionalInputDefaultsAsGuaranteed(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, Label, Description, Category, FlowDeveloperName,\s+IsAtomic, RequiredPermissions, TargetObjects FROM _System_StandardAction`).
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "Name", "Label", "Description", "Category", "FlowDeveloperName", "IsAtomic", "RequiredPermissions", "TargetObjects",
		}).
			AddRow("sa_convertlead", "convertLead", "Convert Lead", "", "LeadActions", "Std_ConvertLead", true, `["ConvertLeads"]`, `["Lead"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows())
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}).
			AddRow("input", "convertedStatus", "string", nil, 1, 0))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}).
			AddRow(actionInputDefaultTarget, "convertedStatus", "'MetadataDefault'", "Input.useDefault == true", 0))

	d := &StandardActionDispatcher{db: db, permissions: stubAllowAll{}}

	c, w := newDispatcherTestContext(`{}`)
	c.Request.Method = http.MethodGet
	d.List(c)

	require.Equal(t, http.StatusOK, w.Code)
	body := w.Body.String()
	assert.Contains(t, body, `"name":"convertedStatus","type":"string","required":true`)
	assert.NotContains(t, body, `"defaulted":true`,
		"conditional defaults are not guaranteed; UI must still render required input")
	assert.NotContains(t, body, `"defaultValue":"MetadataDefault"`,
		"conditional default values must not be surfaced as unconditional UI defaults")
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_List_ProjectsActionSchemaBindingMetadata(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, Label, Description, Category, FlowDeveloperName,\s+IsAtomic, RequiredPermissions, TargetObjects FROM _System_StandardAction`).
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "Name", "Label", "Description", "Category", "FlowDeveloperName", "IsAtomic", "RequiredPermissions", "TargetObjects",
		}).
			AddRow("sa_convertlead", "convertLead", "Convert Lead", "", "LeadActions", "Std_ConvertLead", true, `["ConvertLeads"]`, `["Lead"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows())
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{
			"Direction",
			"APIName",
			"DataType",
			"ReferenceTo",
			"Label",
			"UISection",
			"UIMode",
			"TargetObject",
			"TargetFieldAPIName",
			"MappingSourceObject",
			"MappingActionType",
			"IsRequired",
			"SortOrder",
		}).
			AddRow("input", "contactLastName", "string", nil, nil, "Contact", "create", "Contact", "LastName", "Lead", "convert", 0, 4).
			AddRow("output", "opportunityCampaignId", "reference", "Campaign", "Campaign attribution", "Opportunity", "preview", "Opportunity", "CampaignId", nil, nil, 0, 5))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT SourceField\s+FROM _System_ObjectFieldMapping`).
		WithArgs("Lead", "Contact", "convert", "LastName").
		WillReturnRows(sqlmock.NewRows([]string{"SourceField"}).AddRow("LastName"))

	d := &StandardActionDispatcher{
		db:          db,
		permissions: stubAllowAll{},
		metadata: stubActionSchemaMetadata{schemas: map[string]*models.ObjectMetadata{
			"Contact": {
				APIName: "Contact",
				Fields: []models.FieldMetadata{{
					APIName:  "LastName",
					Label:    "Last Name",
					Type:     constants.FieldTypeString,
					Required: true,
				}},
			},
			"Opportunity": {
				APIName: "Opportunity",
				Fields: []models.FieldMetadata{{
					APIName: "CampaignId",
					Label:   "Primary Campaign Source",
					Type:    constants.FieldTypeReference,
				}},
			},
		}},
	}

	c, w := newDispatcherTestContext(`{}`)
	c.Request.Method = http.MethodGet
	d.List(c)

	require.Equal(t, http.StatusOK, w.Code)
	body := w.Body.String()
	assert.Contains(t, body, `"name":"contactLastName"`)
	assert.Contains(t, body, `"label":"Last Name"`)
	assert.NotContains(t, body, `"required":true`)
	assert.Contains(t, body, `"uiSection":"Contact"`)
	assert.Contains(t, body, `"uiMode":"create"`)
	assert.Contains(t, body, `"targetObject":"Contact"`)
	assert.Contains(t, body, `"targetFieldAPIName":"LastName"`)
	assert.Contains(t, body, `"mappingSourceObject":"Lead"`)
	assert.Contains(t, body, `"mappingActionType":"convert"`)
	assert.Contains(t, body, `"prefillSourceFieldAPIName":"LastName"`)
	assert.Contains(t, body, `"targetRequired":true`)
	assert.Contains(t, body, `"name":"opportunityCampaignId"`)
	assert.Contains(t, body, `"label":"Campaign attribution"`)
	assert.Contains(t, body, `"referenceTo":"Campaign"`)
	assert.Contains(t, body, `"uiMode":"preview"`)
	assert.Contains(t, body, `"targetObject":"Opportunity"`)
	assert.Contains(t, body, `"targetFieldAPIName":"CampaignId"`)
	assert.NotContains(t, body, `"label":"Primary Campaign Source"`)
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_List_HidesActionWhenWholeActionGuardDenied(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, Label, Description, Category, FlowDeveloperName,\s+IsAtomic, RequiredPermissions, TargetObjects FROM _System_StandardAction`).
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "Name", "Label", "Description", "Category", "FlowDeveloperName", "IsAtomic", "RequiredPermissions", "TargetObjects",
		}).
			AddRow("sa_convertlead", "convertLead", "Convert Lead", "", "LeadActions", "Std_ConvertLead", true, `["ConvertLeads"]`, `["Lead"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows().
			AddRow("true", nil, "Lead", "Edit", "Lead.Edit required", "", 0))

	d := &StandardActionDispatcher{db: db, permissions: stubPermissions{
		denyObject: map[string]bool{"Lead.Edit": true},
	}}

	c, w := newDispatcherTestContext(`{}`)
	c.Request.Method = http.MethodGet
	d.List(c)

	require.Equal(t, http.StatusOK, w.Code)
	assert.Equal(t, `{"actions":[]}`, w.Body.String(),
		"whole-action guard denial must hide the action before ObjectView can render an invokable button")
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_List_DoesNotHideActionForInputDependentGuard(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, Label, Description, Category, FlowDeveloperName,\s+IsAtomic, RequiredPermissions, TargetObjects FROM _System_StandardAction`).
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "Name", "Label", "Description", "Category", "FlowDeveloperName", "IsAtomic", "RequiredPermissions", "TargetObjects",
		}).
			AddRow("sa_convertlead", "convertLead", "Convert Lead", "", "LeadActions", "Std_ConvertLead", true, `["ConvertLeads"]`, `["Lead"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows().
			AddRow("AND(Input.doNotCreateOpportunity != true, ISBLANK(Input.opportunityId))",
				nil, "Opportunity", "Create", "Opportunity.Create required", "", 0))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))

	d := &StandardActionDispatcher{db: db, permissions: stubPermissions{
		denyObject: map[string]bool{"Opportunity.Create": true},
	}}

	c, w := newDispatcherTestContext(`{}`)
	c.Request.Method = http.MethodGet
	d.List(c)

	require.Equal(t, http.StatusOK, w.Code)
	body := w.Body.String()
	assert.Contains(t, body, `"name":"convertLead"`,
		"List has no request inputs, so input-dependent guard rows must remain dispatch-time checks")
	assert.Contains(t, body, `"requiredObject":"Opportunity"`)
	assert.Contains(t, body, `"callerHasPermission":false`,
		"the denied guard is still exposed for clients that can map it to input UI")
	require.NoError(t, mock.ExpectationsWereMet())
}

// TestDispatcher_List_EmptyRegistryReturnsEmptyList pins the shape for a
// fresh-install org. Response must be `{"actions":[]}`, NOT a 404 or a
// null payload — the frontend treats absence of a row as "button hidden",
// so the response must always unmarshal cleanly into []StandardActionListEntry.
func TestDispatcher_List_EmptyRegistryReturnsEmptyList(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, Label, Description, Category, FlowDeveloperName,\s+IsAtomic, RequiredPermissions, TargetObjects FROM _System_StandardAction`).
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "Name", "Label", "Description", "Category", "FlowDeveloperName", "IsAtomic", "RequiredPermissions", "TargetObjects",
		}))

	d := &StandardActionDispatcher{db: db, permissions: stubAllowAll{}}

	c, w := newDispatcherTestContext(`{}`)
	c.Request.Method = http.MethodGet
	d.List(c)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.Equal(t, `{"actions":[]}`, w.Body.String())
	require.NoError(t, mock.ExpectationsWereMet())
}

// TestDispatcher_List_ResolvesInputGuardsForCaller pins the Phase-1
// Finding-4 fix: GET /actions/standard must return per-input guard
// verdicts already evaluated server-side. Frontend consumers (e.g. the
// ConvertLeadModal's doNotCreateOpportunity checkbox) read
// `callerHasPermission` without needing to know whether a guard's
// permission is a system-permission or a custom-permission — the server
// is the single source of truth for authorization.
func TestDispatcher_List_ResolvesInputGuardsForCaller(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, Label, Description, Category, FlowDeveloperName,\s+IsAtomic, RequiredPermissions, TargetObjects FROM _System_StandardAction`).
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "Name", "Label", "Description", "Category", "FlowDeveloperName", "IsAtomic", "RequiredPermissions", "TargetObjects",
		}).
			AddRow("sa_convertlead", "convertLead", "Convert Lead", "", "LeadActions", "Std_ConvertLead", true, `["ConvertLeads"]`, `["Lead"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows().
			AddRow("Input.doNotCreateOpportunity == true", "SkipOpportunityOnConvert", nil, nil, "denied", "doNotCreateOpportunity", 0).
			AddRow("ISBLANK(Input.accountId)", nil, "Account", "Create", "Account.Create required", "accountId", 10))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))

	// Caller lacks SkipOpportunityOnConvert — per-input verdict must come
	// back as callerHasPermission=false, but the action itself is still
	// listed (its RequiredPermission ConvertLeads is allowed).
	d := &StandardActionDispatcher{db: db, permissions: stubPermissions{
		deny:       map[string]bool{"SkipOpportunityOnConvert": true},
		denyObject: map[string]bool{"Account.Create": true},
	}}

	c, w := newDispatcherTestContext(`{}`)
	c.Request.Method = http.MethodGet
	d.List(c)

	require.Equal(t, http.StatusOK, w.Code)
	body := w.Body.String()
	assert.Contains(t, body, `"name":"convertLead"`, "action itself is listed")
	assert.Contains(t, body, `"gatedInput":"doNotCreateOpportunity"`,
		"guard must name the input it gates so frontend can locate the UI element to hide")
	assert.Contains(t, body, `"callerHasPermission":false`,
		"server-evaluated verdict must travel to frontend so UI hides the input — no client-side permission lookup required")
	assert.Contains(t, body, `"requiredObject":"Account"`)
	assert.Contains(t, body, `"requiredOperation":"Create"`)
	require.NoError(t, mock.ExpectationsWereMet())
}

// TestDispatcher_List_InputGuardsUseRegistryId pins the fix for the
// SSoT violation where annotateInputGuards previously re-derived the
// action Id from Name (`bootstrap.StandardActionID(entry.Name)`) instead
// of reading the scanned Id column. An action row inserted via admin UI,
// a data migration, or hand-typed SQL can have a non-`sa_<lowercase-name>`
// Id — `lookupAction` at dispatch time keys by Name so dispatch still
// works, but guards would silently disappear from the discovery response.
//
// Fix: listActiveActions SELECTs Id and stores it (StandardActionListEntry.ID,
// json:"-"); annotateInputGuards uses entry.ID. This test drives a row
// with a deliberately non-conventional Id ("manual-uuid-123") and asserts
// its guards reach the JSON payload.
func TestDispatcher_List_InputGuardsUseRegistryId(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	// Row Id is "manual-uuid-123" — the naming-convention derivation
	// (bootstrap.StandardActionID) would produce "sa_customaction", which
	// does NOT match this row's Id. Only a correct SSoT lookup finds
	// the associated guard.
	mock.ExpectQuery(`SELECT Id, Name, Label, Description, Category, FlowDeveloperName,\s+IsAtomic, RequiredPermissions, TargetObjects FROM _System_StandardAction`).
		WillReturnRows(sqlmock.NewRows([]string{
			"Id", "Name", "Label", "Description", "Category", "FlowDeveloperName", "IsAtomic", "RequiredPermissions", "TargetObjects",
		}).
			AddRow("manual-uuid-123", "customAction", "Custom Action", "", "CustomActions", "Std_Custom", false, `["ApiEnabled"]`, `["CustomObject"]`))

	// Guard SELECT must use the row's actual Id, not the derived one.
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("manual-uuid-123").
		WillReturnRows(newActionGuardRows().
			AddRow("Input.something == true", "CustomPerm", nil, nil, "denied", "something", 0))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("manual-uuid-123").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("manual-uuid-123").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))

	d := &StandardActionDispatcher{db: db, permissions: stubAllowAll{}}

	c, w := newDispatcherTestContext(`{}`)
	c.Request.Method = http.MethodGet
	d.List(c)

	require.Equal(t, http.StatusOK, w.Code)
	body := w.Body.String()
	// Id is never shipped to frontend (json:"-"); assertion is negative.
	assert.NotContains(t, body, "manual-uuid-123",
		"Id is internal SSoT, must not leak to frontend")
	// But the guard DID come through — proving the SQL lookup used the
	// scanned Id, not a re-derivation from Name.
	assert.Contains(t, body, `"gatedInput":"something"`,
		"guard for non-conventional-Id row must appear in discovery response")
	assert.Contains(t, body, `"requiredPermission":"CustomPerm"`)
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_UnknownActionReturnsNotFound(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("mysteryAction").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}))

	// No legacy handler wired (nil) so fallthrough returns 404.
	d := NewStandardActionDispatcher(db, nil, nil, nil)

	c, w := newDispatcherTestContext(`{"inputs":[]}`)
	c.Params = gin.Params{{Key: "name", Value: "mysteryAction"}}
	d.Dispatch(c)

	assert.Equal(t, http.StatusNotFound, w.Code)
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_ConvertLeadWithoutRegistryRowReturns404(t *testing.T) {
	// Part D regression: once convertLead migrated to the metadata
	// registry path (Phase 1), the legacy fallthrough for that name was
	// deleted. A deployment that deactivates the _System_StandardAction
	// row (e.g. an admin toggling IsActive=0 to disable the action for
	// their org) must now get 404 — NOT silently fall back to a stale
	// Go handler with a "Prospecting" literal baked in.
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}))

	legacy := newTestHandler()
	d := NewStandardActionDispatcher(db, nil, nil, legacy)

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"001"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	assert.Equal(t, http.StatusNotFound, w.Code,
		"convertLead must 404 when no registry row exists — no legacy fallback")
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_LegacyFallthroughRecognizesChangeOwnerAndCreateRecord(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	for _, name := range []string{"changeOwner", "createRecord"} {
		mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
			WithArgs(name).
			WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}))
	}

	legacy := newTestHandler()
	d := NewStandardActionDispatcher(db, nil, nil, legacy)

	for _, name := range []string{"changeOwner", "createRecord"} {
		c, w := newDispatcherTestContext(`{"inputs":[]}`)
		c.Params = gin.Params{{Key: "name", Value: name}}
		d.Dispatch(c)
		assert.NotEqual(t, http.StatusNotFound, w.Code, "legacy action %s must not 404 during migration", name)
	}

	require.NoError(t, mock.ExpectationsWereMet())
}

// W3 regression: an action row without RequiredPermissions is a silent
// privilege footgun — admins who forget to wire permissions on a new
// action would otherwise ship a publicly-invokable endpoint. The
// dispatcher must fail-closed with 403 and a diagnostic message
// regardless of input validity — the authorization gate runs first.
func TestDispatcher_RegistryHitWithEmptyRequiredPermissions_FailsClosed(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("ghostAction").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_ghost", "ghostAction", "Std_Ghost", "[]"))

	d := NewStandardActionDispatcher(db, nil, nil, nil)

	c, w := newDispatcherTestContext(`{"inputs":[{}]}`)
	c.Params = gin.Params{{Key: "name", Value: "ghostAction"}}
	d.Dispatch(c)

	assert.Equal(t, http.StatusForbidden, w.Code)
	assert.Contains(t, w.Body.String(), "misconfigured")
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_RegistryHitWithPermissions_DeniesWhenServiceMissing(t *testing.T) {
	// If a RequiredPermissions list is present but no PermissionService
	// is wired, the dispatcher denies rather than silently admitting.
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("registeredAction").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_registered", "registeredAction", "Std_Registered", `["ConvertLeads"]`))

	d := NewStandardActionDispatcher(db, nil, nil, nil)

	c, w := newDispatcherTestContext(`{"inputs":[{"foo":"bar"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "registeredAction"}}
	d.Dispatch(c)

	assert.Equal(t, http.StatusForbidden, w.Code)
	assert.Contains(t, w.Body.String(), "ConvertLeads")
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_RegistryHitWithNoExecutorReturnsServerError(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("registeredAction").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_registered", "registeredAction", "Std_Registered", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_registered").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_registered").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_registered").
		WillReturnRows(newActionGuardRows())

	d := NewStandardActionDispatcher(db, nil, stubAllowAll{}, nil)

	c, w := newDispatcherTestContext(`{"inputs":[{"foo":"bar"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "registeredAction"}}
	d.Dispatch(c)

	// An unwired executor surfaces per-input as an IsSuccess=false entry;
	// the Salesforce actions contract keeps the outer status at 200.
	assert.Equal(t, http.StatusOK, w.Code)
	assert.Contains(t, w.Body.String(), "Flow executor unavailable")
	assert.Contains(t, w.Body.String(), `"isSuccess":false`)
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_InputGuard_DeniesWhenConditionTruthyAndPermMissing(t *testing.T) {
	// This is the load-bearing test for the data-driven permission story:
	// with this passing, "SkipOpportunityOnConvert" can move from a Go
	// enum check into an _System_ActionInputGuard row with no dispatcher
	// code changes.
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows().
			AddRow("Input.doNotCreateOpportunity == true", "SkipOpportunityOnConvert",
				nil, nil, "you cannot skip opportunity creation", nil, 0))

	// The action declares ApiEnabled (allow), and the guard row requires
	// SkipOpportunityOnConvert (deny) — verifies the input-guard fires
	// independently of the action-level RequiredPermissions.
	d := NewStandardActionDispatcher(db, nil,
		stubPermissions{deny: map[string]bool{"SkipOpportunityOnConvert": true}}, nil)

	c, w := newDispatcherTestContext(`{"inputs":[{"doNotCreateOpportunity": true}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	// The guard-denied result rides inside the per-input array with
	// IsSuccess=false + the custom ErrorMessage — the HTTP response is
	// still 200 per the Salesforce actions contract.
	assert.Equal(t, http.StatusOK, w.Code)
	assert.Contains(t, w.Body.String(), "skip opportunity")
	assert.Contains(t, w.Body.String(), `"isSuccess":false`)
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_InputGuard_PassesThroughWhenConditionFalse(t *testing.T) {
	// doNotCreateOpportunity is absent/false — the guard's Condition
	// doesn't fire, so the dispatcher proceeds to the executor path even
	// though the user has no SkipOpportunityOnConvert permission.
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows().
			AddRow("Input.doNotCreateOpportunity == true", "SkipOpportunityOnConvert", nil, nil, "", nil, 0))

	d := NewStandardActionDispatcher(db, nil, stubAllowAll{}, nil)

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	// Guard doesn't fire → dispatcher proceeds; nil executor surfaces as
	// a per-input IsSuccess=false entry with 200 (not a 500).
	assert.Equal(t, http.StatusOK, w.Code)
	assert.Contains(t, w.Body.String(), "Flow executor unavailable")
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_InputGuard_DeniesWhenObjectCrudMissing(t *testing.T) {
	// Object CRUD guards are metadata rows, not fake custom permissions.
	// The dispatcher must evaluate them through the generic object-permission
	// helper so the same ObjectPermissions metadata powers all CRUD checks.
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows().
			AddRow("ISBLANK(Input.accountId)", nil, "Account", "Create",
				"Account.Create is required", nil, 0))

	d := NewStandardActionDispatcher(db, nil,
		stubPermissions{denyObject: map[string]bool{"Account.Create": true}}, nil)

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.Contains(t, w.Body.String(), "Account.Create")
	assert.Contains(t, w.Body.String(), `"isSuccess":false`)
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_InputGuard_CreatePathDoesNotRequireReuseRead(t *testing.T) {
	// Lead Convert object CRUD guards follow the request-specific Flow branch:
	// omitting accountId creates an Account, so Account.Read must not block
	// the create-new path before the Flow can run.
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ConvertLeads"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows().
			AddRow("ISBLANK(Input.accountId)", nil, "Account", "Create", "Account.Create is required", "", 0).
			AddRow("NOT(ISBLANK(Input.accountId))", nil, "Account", "Read", "Account.Read is required", "", 10))

	d := NewStandardActionDispatcher(db, nil,
		stubPermissions{denyObject: map[string]bool{"Account.Read": true}}, nil)

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.Contains(t, w.Body.String(), "Flow executor unavailable")
	assert.NotContains(t, w.Body.String(), "Account.Read is required",
		"create-new Account path must not preflight Account.Read")
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_InputGuard_ReusePathDoesNotRequireCreate(t *testing.T) {
	// Supplying accountId reuses an Account, so Account.Create must not block
	// callers that have read permission for the reused record path.
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ConvertLeads"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows().
			AddRow("ISBLANK(Input.accountId)", nil, "Account", "Create", "Account.Create is required", "", 0).
			AddRow("NOT(ISBLANK(Input.accountId))", nil, "Account", "Read", "Account.Read is required", "", 10))

	d := NewStandardActionDispatcher(db, nil,
		stubPermissions{denyObject: map[string]bool{"Account.Create": true}}, nil)

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001","accountId":"acct-001"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.Contains(t, w.Body.String(), "Flow executor unavailable")
	assert.NotContains(t, w.Body.String(), "Account.Create is required",
		"reuse Account path must not preflight Account.Create")
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_InputGuard_ObjectCrudConditionFalseSkipsDeny(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows().
			AddRow("ISBLANK(Input.accountId)", nil, "Account", "Create",
				"Account.Create is required", nil, 0))

	d := NewStandardActionDispatcher(db, nil,
		stubPermissions{denyObject: map[string]bool{"Account.Create": true}}, nil)

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001","accountId":"acct-001"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.Contains(t, w.Body.String(), "Flow executor unavailable")
	assert.NotContains(t, w.Body.String(), "Account.Create is required")
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_InputGuard_InvalidConditionSurfacesPerInputError(t *testing.T) {
	// An unparseable Condition shouldn't crash the dispatcher or silently
	// admit the request — it reports per-input INVALID_REQUEST so the
	// admin sees their mistake.
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows().
			AddRow("this is not ( a valid formula", "SkipOpportunityOnConvert", nil, nil, "", nil, 0))

	d := NewStandardActionDispatcher(db, nil, stubAllowAll{}, nil)

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.Contains(t, w.Body.String(), "is invalid")
	assert.Contains(t, w.Body.String(), `"isSuccess":false`)
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_InputSchema_RejectsMissingRequiredField(t *testing.T) {
	// With this passing, the required-field story for convertLead lives
	// in _System_ActionSchema rows rather than in hand-written Go checks.
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}).
			AddRow("input", "leadId", "id", nil, 1, 0).
			AddRow("input", "convertedStatus", "string", nil, 1, 1).
			AddRow("input", "accountId", "reference", "Account", 0, 2))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows())

	d := NewStandardActionDispatcher(db, nil, stubAllowAll{}, nil)

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001","accountId":"acct-001"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.Contains(t, w.Body.String(), `"REQUIRED_FIELD_MISSING"`)
	assert.Contains(t, w.Body.String(), `convertedStatus`)
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_InputSchema_RejectsUnknownField(t *testing.T) {
	// A typo like `opportunitiName` must surface at dispatch time rather
	// than silently landing as an unused variable in the flow.
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}).
			AddRow("input", "leadId", "id", nil, 1, 0).
			AddRow("input", "convertedStatus", "string", nil, 1, 1).
			AddRow("input", "accountId", "reference", "Account", 0, 2))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows())

	d := NewStandardActionDispatcher(db, nil, stubAllowAll{}, nil)

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001","convertedStatus":"Converted","accountId":"acct-001","opportunitiName":"typo"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.Contains(t, w.Body.String(), `"INVALID_FIELD"`)
	assert.Contains(t, w.Body.String(), `opportunitiName`)
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_InputSchema_RejectsPicklistValueMissingRequiredTag(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{
			"Direction", "APIName", "DataType", "ReferenceTo", "TargetObject", "TargetFieldAPIName", "RequiredPicklistTag", "IsRequired", "SortOrder",
		}).
			AddRow("input", "leadId", "id", nil, "Lead", "Id", nil, 1, 0).
			AddRow("input", "convertedStatus", "picklist", nil, "Lead", "Status", "converted", 1, 1))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows())

	d := NewStandardActionDispatcher(db, nil, stubAllowAll{}, nil, leadStatusMetadataForActionSchemaTest())

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001","convertedStatus":"Open - Not Contacted"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.Contains(t, w.Body.String(), `"INVALID_FIELD"`)
	assert.Contains(t, w.Body.String(), `convertedStatus`)
	assert.Contains(t, w.Body.String(), `converted`)
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_InputSchema_AcceptsPicklistValueWithRequiredTag(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{
			"Direction", "APIName", "DataType", "ReferenceTo", "TargetObject", "TargetFieldAPIName", "RequiredPicklistTag", "IsRequired", "SortOrder",
		}).
			AddRow("input", "leadId", "id", nil, "Lead", "Id", nil, 1, 0).
			AddRow("input", "convertedStatus", "picklist", nil, "Lead", "Status", "converted", 1, 1))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows())

	d := NewStandardActionDispatcher(db, nil, stubAllowAll{}, nil, leadStatusMetadataForActionSchemaTest())

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001","convertedStatus":"Converted"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.Contains(t, w.Body.String(), "Flow executor unavailable")
	require.NoError(t, mock.ExpectationsWereMet())
}

func leadStatusMetadataForActionSchemaTest() stubActionSchemaMetadata {
	return stubActionSchemaMetadata{schemas: map[string]*models.ObjectMetadata{
		"Lead": {
			APIName: "Lead",
			Fields: []models.FieldMetadata{{
				APIName: "Status",
				Type:    constants.FieldTypePicklist,
				Options: models.PicklistOptions{
					{Value: "Open - Not Contacted", Label: "Open - Not Contacted"},
					{Value: "Converted", Label: "Converted", Tags: []string{"converted"}},
				},
			}},
		},
	}}
}

func TestDispatcher_InputSchema_EmptySchemaSkipsValidation(t *testing.T) {
	// Actions that declare no schema rows opt out of validation. This
	// preserves backwards compat for admins who register an action with
	// just a row + flow, no schema descriptors.
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("registeredAction").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_registered", "registeredAction", "Std_Registered", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_registered").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_registered").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_registered").
		WillReturnRows(newActionGuardRows())

	d := NewStandardActionDispatcher(db, nil, stubAllowAll{}, nil)

	c, w := newDispatcherTestContext(`{"inputs":[{"anything":"goes"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "registeredAction"}}
	d.Dispatch(c)

	assert.Equal(t, http.StatusOK, w.Code)
	// No schema rejection, no guard rejection — the nil executor takes over.
	assert.Contains(t, w.Body.String(), "Flow executor unavailable")
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_InputSchema_PassesWhenAllRequiredPresent(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}).
			AddRow("input", "leadId", "id", nil, 1, 0).
			AddRow("input", "convertedStatus", "string", nil, 1, 1).
			AddRow("input", "accountId", "reference", "Account", 0, 2))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows())

	d := NewStandardActionDispatcher(db, nil, stubAllowAll{}, nil)

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001","convertedStatus":"Converted","accountId":"acct-001"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	assert.Equal(t, http.StatusOK, w.Code)
	// Validation passed; executor nil fires instead.
	assert.Contains(t, w.Body.String(), "Flow executor unavailable")
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_InputDefaults_FillMissingInputBeforeSchemaValidation(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("registeredAction").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_registered", "registeredAction", "Std_Registered", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_registered").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}).
			AddRow("input", "leadId", "id", nil, 1, 0).
			AddRow("input", "convertedStatus", "string", nil, 1, 1).
			AddRow("input", "accountId", "reference", "Account", 0, 2))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_registered").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}).
			AddRow(actionInputDefaultTarget, "convertedStatus", "'MetadataDefault'", nil, 0))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_registered").
		WillReturnRows(newActionGuardRows())
	expectNoInternalAccessScopes(mock, "sa_registered", "registeredAction", "Std_Registered")

	spy := &inputCaptureExecutor{}
	d := &StandardActionDispatcher{
		db:           db,
		executorHook: spy.Execute,
		formulas:     newTestFormulaEngine(t),
		permissions:  stubAllowAll{},
	}

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001","accountId":"acct-001"},{"leadId":"lead-002","accountId":"acct-002","convertedStatus":"CallerValue"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "registeredAction"}}
	d.Dispatch(c)

	require.Equal(t, http.StatusOK, w.Code)
	require.Len(t, spy.seen, 2)
	assert.Equal(t, "MetadataDefault", spy.seen[0]["convertedStatus"],
		"$Input-scoped ActionFieldDefault must satisfy required schema before validation")
	assert.Equal(t, "CallerValue", spy.seen[1]["convertedStatus"],
		"input defaults must not overwrite a caller-provided value")

	_, hasRows := automation.ActionFieldDefaultsFromCtx(spy.seenCtx[0])
	assert.False(t, hasRows, "$Input-scoped defaults must not leak into recordCreate defaults")
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_InputDefaults_FillRequiredInputBeforeExecute(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}).
			AddRow("input", "leadId", "id", nil, 1, 0).
			AddRow("input", "convertedStatus", "string", nil, 1, 1).
			AddRow("input", "accountId", "reference", "Account", 0, 2))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}).
			AddRow(actionInputDefaultTarget, "convertedStatus", "'MetadataConverted'", nil, 0).
			AddRow("Opportunity", "CloseDate", "TODAY() + 30", nil, 0))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows())
	expectInternalAccessScopes(mock, "sa_convertlead", newInternalAccessScopeRows())

	spy := &inputCaptureExecutor{}
	d := &StandardActionDispatcher{
		db:           db,
		executorHook: spy.Execute,
		formulas:     newTestFormulaEngine(t),
		permissions:  stubAllowAll{},
	}

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001","accountId":"acct-001"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	require.Equal(t, http.StatusOK, w.Code)
	require.Len(t, spy.seen, 1)
	assert.Equal(t, "MetadataConverted", spy.seen[0]["convertedStatus"],
		"org-owned $Input-scoped metadata can satisfy required convertedStatus before the flow executes")

	rows, ok := automation.ActionFieldDefaultsFromCtx(spy.seenCtx[0])
	require.True(t, ok, "object-scoped defaults must remain available to recordCreate")
	require.Len(t, rows, 1)
	assert.Equal(t, "Opportunity", rows[0].TargetObject)
	assert.Equal(t, "CloseDate", rows[0].FieldAPIName)
	require.NoError(t, mock.ExpectationsWereMet())
}

// TestDispatcher_FieldDefaults_StampsRowsOnCtx pins the new contract:
// the dispatcher pre-loads _System_ActionFieldDefault rows and stamps them
// on ctx via automation.WithActionFieldDefaults. The inputs map is passed
// verbatim to the executor — no `_defaults.*` key is injected (that
// convention was removed; the executor applies defaults at recordCreate
// time via the ctx-stamped rows).
func TestDispatcher_FieldDefaults_StampsRowsOnCtx(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}).
			AddRow("Opportunity", "CloseDate", `TODAY() + 30`, nil, 0))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows())
	expectInternalAccessScopes(mock, "sa_convertlead", newInternalAccessScopeRows())

	spy := &inputCaptureExecutor{}
	d := &StandardActionDispatcher{
		db:           db,
		executorHook: spy.Execute,
		formulas:     newTestFormulaEngine(t),
		permissions:  stubAllowAll{},
	}

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	require.Equal(t, http.StatusOK, w.Code)
	require.Len(t, spy.seen, 1)

	// Inputs must be passed verbatim — no `_defaults.*` injection.
	_, hasDefaultsKey := spy.seen[0]["_defaults"]
	assert.False(t, hasDefaultsKey, "dispatcher must not inject _defaults; that convention was removed")

	// Ctx must carry the pre-loaded ActionFieldDefault rows verbatim.
	// The executor applies them at recordCreate time (stage 3).
	rows, ok := automation.ActionFieldDefaultsFromCtx(spy.seenCtx[0])
	require.True(t, ok, "dispatcher must stamp ActionFieldDefault rows on ctx")
	require.Len(t, rows, 1)
	assert.Equal(t, "Opportunity", rows[0].TargetObject)
	assert.Equal(t, "CloseDate", rows[0].FieldAPIName)
	assert.Equal(t, "TODAY() + 30", rows[0].ValueFormula)

	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_InternalAccessScopes_StampsRowsOnExecutionCtx(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows())
	expectInternalAccessScopes(mock, "sa_convertlead", newInternalAccessScopeRows().
		AddRow("sa_convertlead", "Task", constants.PermEdit, "Lead convert child relink"))

	spy := &inputCaptureExecutor{}
	d := &StandardActionDispatcher{
		db:           db,
		executorHook: spy.Execute,
		formulas:     newTestFormulaEngine(t),
		permissions:  stubAllowAll{},
	}

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	require.Equal(t, http.StatusOK, w.Code)
	require.Len(t, spy.seenCtx, 1)
	assert.True(t, constants.IsScopedInternalAccess(spy.seenCtx[0], "Task", constants.PermEdit))
	assert.False(t, constants.IsScopedInternalAccess(spy.seenCtx[0], "Task", constants.PermCreate))
	assert.False(t, constants.IsScopedInternalAccess(spy.seenCtx[0], "Account", constants.PermCreate))

	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_InternalAccessScopes_NotStampedForCustomActiveFlow(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows())
	expectInternalAccessScopeOwnerLookup(mock, "sa_convertlead", "convertLead", "Std_ConvertLead", false)
	expectActiveStandardFlowPackOwned(mock, "Std_ConvertLead", bootstrap.FlowSourceCustom, false)

	spy := &inputCaptureExecutor{}
	d := &StandardActionDispatcher{
		db:           db,
		executorHook: spy.Execute,
		formulas:     newTestFormulaEngine(t),
		permissions:  stubAllowAll{},
	}

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	require.Equal(t, http.StatusOK, w.Code)
	require.Len(t, spy.seenCtx, 1)
	assert.False(t, constants.IsScopedInternalAccess(spy.seenCtx[0], "Task", constants.PermEdit),
		"L1 internal scopes must not be stamped onto an admin-authored active flow graph")
	require.NoError(t, mock.ExpectationsWereMet())
}

// TestDispatcher_FieldDefaults_EmptyRowsDoesNotStampCtxKey pins that when
// no _System_ActionFieldDefault rows exist for the action, the dispatcher
// does not stamp an empty list on ctx (the executor's stage-3 check then
// treats the absence as a no-op, matching the "zero ceremony for fresh
// actions" contract).
func TestDispatcher_FieldDefaults_EmptyRowsDoesNotStampCtxKey(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows())
	expectInternalAccessScopes(mock, "sa_convertlead", newInternalAccessScopeRows())

	spy := &inputCaptureExecutor{}
	d := &StandardActionDispatcher{
		db:           db,
		executorHook: spy.Execute,
		formulas:     newTestFormulaEngine(t),
		permissions:  stubAllowAll{},
	}

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	require.Equal(t, http.StatusOK, w.Code)
	require.Len(t, spy.seen, 1)
	// No rows means no stamp on the defaults key — the resolver then
	// short-circuits in ActionFieldDefaultsFromCtx.
	_, hasRows := automation.ActionFieldDefaultsFromCtx(spy.seenCtx[0])
	assert.False(t, hasRows, "dispatcher must not stamp an empty rows slice on ctx")
	require.NoError(t, mock.ExpectationsWereMet())
}

// inputCaptureExecutor is a test seam that records every input map the
// dispatcher passes to the flow engine. StandardActionDispatcher uses it
// via the executorHook field so tests can observe without wiring a full
// FlowExecutor.
type inputCaptureExecutor struct {
	seen    []map[string]interface{}
	seenCtx []context.Context
}

func (s *inputCaptureExecutor) Execute(ctx context.Context, flowName string, inputs map[string]interface{}, user *models.UserSession) (*models.FlowExecutionResult, error) {
	s.seen = append(s.seen, inputs)
	s.seenCtx = append(s.seenCtx, ctx)
	return &models.FlowExecutionResult{ActionName: flowName, IsSuccess: true}, nil
}

func newTestFormulaEngine(t *testing.T) *formula.Engine {
	t.Helper()
	return formula.NewEngine()
}

func TestDispatcher_InputSchema_EmptyStringCountsAsMissing(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ApiEnabled"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"Direction", "APIName", "DataType", "ReferenceTo", "IsRequired", "SortOrder"}).
			AddRow("input", "leadId", "id", nil, 1, 0))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows())

	d := NewStandardActionDispatcher(db, nil, stubAllowAll{}, nil)

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":""}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.Dispatch(c)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.Contains(t, w.Body.String(), `"REQUIRED_FIELD_MISSING"`)
	assert.Contains(t, w.Body.String(), `leadId`)
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_PreviewRecordCreateFields_UsesActionMetadataAndPreviewHook(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ConvertLeads"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{
			"Direction", "APIName", "DataType", "ReferenceTo", "UISection", "UIMode", "TargetObject", "TargetFieldAPIName", "IsRequired", "SortOrder",
		}).
			AddRow("input", "leadId", "id", nil, nil, nil, nil, nil, 1, 0).
			AddRow("input", "convertedStatus", "picklist", nil, nil, nil, nil, nil, 0, 1).
			AddRow("output", "opportunityCampaignId", "reference", "Campaign", "Opportunity", "preview", "Opportunity", "CampaignId", 0, 2))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionFieldDefault`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{"TargetObject", "FieldAPIName", "ValueFormula", "Condition", "Priority"}))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionInputGuard`).
		WithArgs("sa_convertlead").
		WillReturnRows(newActionGuardRows())
	expectInternalAccessScopes(mock, "sa_convertlead", newInternalAccessScopeRows())

	var seenFlow string
	var seenTargets []automation.FlowRecordCreateFieldPreviewTarget
	d := NewStandardActionDispatcher(db, nil, stubAllowAll{}, nil)
	d.recordCreatePreviewHook = func(ctx context.Context, flowName string, inputs map[string]interface{}, targets []automation.FlowRecordCreateFieldPreviewTarget, user *models.UserSession) (*automation.FlowRecordCreateFieldsPreviewResult, error) {
		seenFlow = flowName
		seenTargets = targets
		require.True(t, constants.StandardActionExecutionOwnsRootFlow(ctx, "Std_ConvertLead"),
			"preview must run under the same protected standard action context as dispatch")
		return &automation.FlowRecordCreateFieldsPreviewResult{
			RecordCreates: []automation.FlowRecordCreatePreview{{
				ElementName:   "CreateOpportunity",
				ObjectAPIName: "Opportunity",
				Fields:        map[string]interface{}{"CampaignId": "camp-001"},
			}},
		}, nil
	}

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001","convertedStatus":"Converted"}],"recordCreateFields":[{"objectApiName":"Opportunity","fieldApiName":"CampaignId"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.PreviewRecordCreateFields(c)

	require.Equal(t, http.StatusOK, w.Code)
	assert.Equal(t, "Std_ConvertLead", seenFlow)
	assert.Equal(t, []automation.FlowRecordCreateFieldPreviewTarget{{
		ObjectAPIName: "Opportunity",
		FieldAPIName:  "CampaignId",
	}}, seenTargets)
	assert.Contains(t, w.Body.String(), `"recordCreates"`)
	assert.Contains(t, w.Body.String(), `"CampaignId":"camp-001"`)
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_PreviewRecordCreateFields_RejectsUnreadableTargetField(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ConvertLeads"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{
			"Direction", "APIName", "DataType", "ReferenceTo", "UISection", "UIMode", "TargetObject", "TargetFieldAPIName", "IsRequired", "SortOrder",
		}).
			AddRow("output", "opportunityCampaignId", "reference", "Campaign", "Opportunity", "preview", "Opportunity", "CampaignId", 0, 0))

	d := NewStandardActionDispatcher(db, nil, stubPermissions{
		denyField: map[string]bool{"Opportunity.CampaignId": true},
	}, nil)
	d.recordCreatePreviewHook = func(context.Context, string, map[string]interface{}, []automation.FlowRecordCreateFieldPreviewTarget, *models.UserSession) (*automation.FlowRecordCreateFieldsPreviewResult, error) {
		t.Fatal("preview hook must not be invoked when the requested field is not readable")
		return nil, nil
	}

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001"}],"recordCreateFields":[{"objectApiName":"Opportunity","fieldApiName":"CampaignId"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.PreviewRecordCreateFields(c)

	require.Equal(t, http.StatusForbidden, w.Code)
	assert.Contains(t, w.Body.String(), "INSUFFICIENT_PERMISSIONS")
	assert.Contains(t, w.Body.String(), "Opportunity.CampaignId")
	require.NoError(t, mock.ExpectationsWereMet())
}

func TestDispatcher_PreviewRecordCreateFields_RejectsTargetNotDeclaredByOutputMetadata(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	mock.ExpectQuery(`SELECT Id, Name, FlowDeveloperName, RequiredPermissions FROM _System_StandardAction`).
		WithArgs("convertLead").
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Name", "FlowDeveloperName", "RequiredPermissions"}).
			AddRow("sa_convertlead", "convertLead", "Std_ConvertLead", `["ConvertLeads"]`))
	mock.ExpectQuery(`SELECT .+ FROM _System_ActionSchema`).
		WithArgs("sa_convertlead").
		WillReturnRows(sqlmock.NewRows([]string{
			"Direction", "APIName", "DataType", "ReferenceTo", "UISection", "UIMode", "TargetObject", "TargetFieldAPIName", "IsRequired", "SortOrder",
		}).
			AddRow("output", "opportunityCampaignId", "reference", "Campaign", "Opportunity", "preview", "Opportunity", "CampaignId", 0, 0))

	d := NewStandardActionDispatcher(db, nil, stubAllowAll{}, nil)
	d.recordCreatePreviewHook = func(context.Context, string, map[string]interface{}, []automation.FlowRecordCreateFieldPreviewTarget, *models.UserSession) (*automation.FlowRecordCreateFieldsPreviewResult, error) {
		t.Fatal("preview hook must not be invoked for targets not declared by output metadata")
		return nil, nil
	}

	c, w := newDispatcherTestContext(`{"inputs":[{"leadId":"lead-001"}],"recordCreateFields":[{"objectApiName":"Opportunity","fieldApiName":"Name"}]}`)
	c.Params = gin.Params{{Key: "name", Value: "convertLead"}}
	d.PreviewRecordCreateFields(c)

	require.Equal(t, http.StatusBadRequest, w.Code)
	assert.Contains(t, w.Body.String(), "INVALID_REQUEST")
	assert.Contains(t, w.Body.String(), "Opportunity.Name")
	assert.Contains(t, w.Body.String(), "preview output")
	require.NoError(t, mock.ExpectationsWereMet())
}
