package automation

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

	"github.com/nexuscrm/kernel/pkg/logging"
	"github.com/nexuscrm/kernel/pkg/ports"
	sharedapi "github.com/nexuscrm/shared/pkg/api"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
	"github.com/nexuscrm/shared/pkg/queryspec"
)

// captureQuery records every QueryWithFilter call so tests can assert on the
// objectName / sortField the caller passed. Other QueryService methods are
// not used by the code under test; calling them would fail the test.
type captureQuery struct {
	calls    []captureQueryCall
	response *models.QueryResult
	err      error
}

type captureQueryCall struct {
	objectName string
	filter     string
	sortField  string
	sortDir    string
	limit      int
}

func (c *captureQuery) QueryWithFilter(ctx context.Context, objectName, filter string, _ *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
	c.calls = append(c.calls, captureQueryCall{
		objectName: objectName,
		filter:     filter,
		sortField:  sortField,
		sortDir:    sortDir,
		limit:      limit,
	})
	if c.err != nil {
		return nil, c.err
	}
	return c.response, nil
}

func (c *captureQuery) Query(ctx context.Context, req sharedapi.QueryRequest, user *models.UserSession) (*models.QueryResult, error) {
	panic("captureQuery.Query: not implemented for this test")
}
func (c *captureQuery) QueryWithSpec(context.Context, queryspec.QuerySpec, *models.UserSession) (*models.QueryResult, error) {
	panic("captureQuery.QueryWithSpec: not implemented for this test")
}
func (c *captureQuery) RunAnalytics(ctx context.Context, q models.AnalyticsQuery, user *models.UserSession) (interface{}, error) {
	panic("captureQuery.RunAnalytics: not implemented")
}
func (c *captureQuery) SearchSingleObject(ctx context.Context, objectName, term string, sf, rf []string, limit, offset int, ob, od, fe string, user *models.UserSession) ([]models.SObject, error) {
	panic("captureQuery.SearchSingleObject: not implemented")
}

func (c *captureQuery) SearchSuggestions(context.Context, string, string, []string, []string, int, *models.UserSession) ([]models.SObject, error) {
	return nil, nil
}
func (c *captureQuery) GlobalSearch(ctx context.Context, term string, user *models.UserSession) ([]models.SearchResult, error) {
	panic("captureQuery.GlobalSearch: not implemented")
}
func (c *captureQuery) Calculate(ctx context.Context, objectName string, record models.SObject, user *models.UserSession) (models.SObject, error) {
	panic("captureQuery.Calculate: not implemented")
}
func (c *captureQuery) ExecuteRawSQL(ctx context.Context, sql string, params []interface{}, user *models.UserSession) ([]models.SObject, error) {
	panic("captureQuery.ExecuteRawSQL: not implemented")
}

var _ ports.QueryService = (*captureQuery)(nil)

// capturePersistence records Update calls so tests can assert what FieldValue
// was actually written to the linked record. Other persistence methods are
// not used by executeLiveFieldUpdateMutations; calling them fails the test.
type capturePersistence struct {
	updates  []captureUpdateCall
	raw      map[string]models.SObject
	onUpdate func(tableName, id string, updates models.SObject)
	err      error
}

type captureUpdateCall struct {
	tableName string
	id        string
	updates   models.SObject
}

func (p *capturePersistence) Update(ctx context.Context, tableName, id string, updates models.SObject, user *models.UserSession) error {
	p.updates = append(p.updates, captureUpdateCall{tableName: tableName, id: id, updates: updates})
	if p.onUpdate != nil {
		p.onUpdate(tableName, id, cloneAutomationSObject(updates))
	}
	if p.raw != nil {
		key := tableName + "/" + id
		record := cloneAutomationSObject(p.raw[key])
		if record == nil {
			record = models.SObject{constants.FieldID: id}
		}
		for k, v := range updates {
			record[k] = v
		}
		p.raw[key] = record
	}
	return p.err
}

func (p *capturePersistence) PreviewBeforeUpdate(ctx context.Context, tableName, id string, updates models.SObject, internalStamps models.SObject, user *models.UserSession) (*models.PreparedRecordUpdate, error) {
	return nil, p.err
}
func (p *capturePersistence) CommitPreparedUpdate(ctx context.Context, tableName, id string, prepared *models.PreparedRecordUpdate, extraUpdates models.SObject, user *models.UserSession) error {
	p.updates = append(p.updates, captureUpdateCall{tableName: tableName, id: id, updates: extraUpdates})
	return p.err
}

func (p *capturePersistence) Insert(ctx context.Context, tableName string, record models.SObject, user *models.UserSession) (models.SObject, error) {
	panic("capturePersistence.Insert: not implemented")
}
func (p *capturePersistence) PreviewBeforeInsert(ctx context.Context, tableName string, record models.SObject, user *models.UserSession) (models.SObject, error) {
	panic("capturePersistence.PreviewBeforeInsert: not implemented")
}
func (p *capturePersistence) PrelockUpdateParents(ctx context.Context, tableName, id string, updates models.SObject, user *models.UserSession) error {
	p.updates = append(p.updates, captureUpdateCall{tableName: tableName, id: id, updates: updates})
	return p.err
}
func (p *capturePersistence) Delete(ctx context.Context, tableName, id string, user *models.UserSession) error {
	panic("capturePersistence.Delete: not implemented")
}
func (p *capturePersistence) GetRaw(ctx context.Context, objectName, id string) (models.SObject, error) {
	if p.raw != nil {
		if record, ok := p.raw[objectName+"/"+id]; ok {
			return cloneAutomationSObject(record), nil
		}
	}
	return models.SObject{constants.FieldID: id}, nil
}
func (p *capturePersistence) ReadSelectedSystemRecord(ctx context.Context, objectName, id string, fields []string) (models.SObject, error) {
	record, err := p.GetRaw(ctx, objectName, id)
	if err != nil || record == nil {
		return record, err
	}
	selected := make(models.SObject, len(fields))
	for _, field := range fields {
		if value, ok := record[field]; ok {
			selected[field] = value
		}
	}
	return selected, nil
}
func (p *capturePersistence) GetRecordType(ctx context.Context, id string) (string, error) {
	panic("capturePersistence.GetRecordType: not implemented")
}
func (p *capturePersistence) GetLock(ctx context.Context, tableName, id string) (models.SObject, error) {
	panic("capturePersistence.GetLock: not implemented")
}
func (p *capturePersistence) RunInTransaction(ctx context.Context, fn func(context.Context) error) error {
	return fn(ctx)
}
func (p *capturePersistence) IsTransactionActive(context.Context) bool { return false }
func (p *capturePersistence) RunInPessimisticTransaction(ctx context.Context, fn func(context.Context) error) error {
	return fn(ctx)
}
func (p *capturePersistence) DetachTx(ctx context.Context) context.Context { return ctx }
func (p *capturePersistence) RecomputeActiveFormulaProjection(_ context.Context, _ *models.UserSession, _, _ string, _ []string) (models.SObject, error) {
	return nil, nil
}

var _ PersistenceProvider = (*capturePersistence)(nil)

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

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

// fetchProcessActions must sort by ProcessAction.Order via the generated
// metadata constant, not by a string literal that visually collides with the
// CRM L1 SObject name "Order". Issue #230 / Refs #226: the literal "Order"
// argument was a coding red-line under L0 strong reading even though its
// runtime value is the same as the constant. This regression guard pins the
// sortField argument to the constants symbol so a future re-introduction of
// the literal is caught at PR time rather than via repository scan only.
func TestApprovalService_FetchProcessActions_SortByOrderConstantNotLiteral(t *testing.T) {
	q := &captureQuery{response: &models.QueryResult{TotalSize: 0}}
	svc := &ApprovalService{
		query:  q,
		logger: logging.Default,
	}
	user := &models.UserSession{ID: "u-1"}

	_, err := svc.fetchProcessActions(context.Background(), "pdef-1", "", "Submit", user)
	if err != nil {
		t.Fatalf("fetchProcessActions returned err = %v", err)
	}
	if len(q.calls) != 1 {
		t.Fatalf("expected exactly 1 QueryWithFilter call, got %d", len(q.calls))
	}
	got := q.calls[0]
	if got.objectName != constants.TableProcessAction {
		t.Errorf("objectName = %q; want %q (constants.TableProcessAction)", got.objectName, constants.TableProcessAction)
	}
	if got.sortField != constants.FieldProcessAction_Order {
		t.Errorf("sortField = %q; want %q (constants.FieldProcessAction_Order — not the literal SObject name)", got.sortField, constants.FieldProcessAction_Order)
	}
	if got.sortDir != "ASC" {
		t.Errorf("sortDir = %q; want %q", got.sortDir, "ASC")
	}
}

func TestApprovalService_LifecycleActionFetchesUseLifecycleHelpers(t *testing.T) {
	data, err := os.ReadFile("approval_lifecycle_actions.go")
	if err != nil {
		t.Fatalf("read approval_lifecycle_actions.go: %v", err)
	}
	source := string(data)
	if got := strings.Count(source, ".fetchProcessActions("); got != 1 {
		t.Fatalf("direct fetchProcessActions call count = %d, want 1 helper-owned required tx path", got)
	}
	if !strings.Contains(source, ".fetchRequiredProcessActions(") {
		t.Fatal("approval_lifecycle_actions.go does not call fetchRequiredProcessActions")
	}
}

// executeLiveFieldUpdateMutations must NOT strip a Salesforce-specific
// "Literal: " prefix from ProcessAction.FieldValue. L0 is supposed to be
// blind to L1-pack-specific value-type grammar — the L1 pack is responsible
// for writing plain values into FieldValue, and L0 must propagate whatever
// it finds verbatim. Pre-fix, L0 silently called strings.TrimPrefix(value,
// "Literal: ") here; that knowledge belongs in the Salesforce L1 pack's
// authoring layer, not in the platform binary.
func TestApprovalService_ExecuteLiveFieldUpdateMutations_DoesNotStripLiteralPrefix(t *testing.T) {
	persist := &capturePersistence{}
	svc := &ApprovalService{
		persistence: persist,
		logger:      logging.Default,
	}
	user := &models.UserSession{ID: "u-1"}

	// ProcessAction record carrying a Salesforce-prefixed FieldValue. After
	// the L1-cleanup, L0 must write the prefix verbatim into the linked
	// record — it is the Salesforce L1 pack's responsibility to render the
	// "Literal: " grammar on the authoring side.
	actions := []models.SObject{
		{
			"Name":         "FU-1",
			"ActionType":   "FieldUpdate",
			"FieldApiName": "Status",
			"FieldValue":   "Literal: Approved",
		},
	}
	if err := svc.executeLiveFieldUpdateMutations(context.Background(), actions, "Account", "0010001", user); err != nil {
		t.Fatalf("executeLiveFieldUpdateMutations err = %v", err)
	}
	if len(persist.updates) != 1 {
		t.Fatalf("expected 1 Update, got %d", len(persist.updates))
	}
	gotValue, _ := persist.updates[0].updates["Status"].(string)
	if gotValue != "Literal: Approved" {
		t.Errorf("Status value written = %q; want %q (raw value, prefix NOT stripped)", gotValue, "Literal: Approved")
	}
	// Belt-and-suspenders: assert the value is not the trimmed form either.
	if gotValue == "Approved" {
		t.Errorf("Status value was %q — strings.TrimPrefix(%q, \"Literal: \") was applied; that L1 grammar must not live in L0", gotValue, "Literal: Approved")
	}
}

func TestApprovalService_ExecuteLiveFieldUpdateMutations_ReResolvesCrossRecordDependenciesLive(t *testing.T) {
	persist := &capturePersistence{
		raw: map[string]models.SObject{
			"GenericObject/target-1": {constants.FieldID: "target-1", "ParentId": "parent-1", "QueueId": "queue-old"},
			"ParentObject/parent-1":  {constants.FieldID: "parent-1", "Stage": "Open"},
			"QueueObject/queue-old":  {constants.FieldID: "queue-old"},
			"QueueObject/queue-new":  {constants.FieldID: "queue-new"},
		},
	}
	persist.onUpdate = func(tableName, id string, updates models.SObject) {
		if tableName == "GenericObject" && id == "target-1" && updates["Status"] == "Submitted" {
			parent := persist.raw["ParentObject/parent-1"]
			if parent != nil && parent["Stage"] == "Closed" {
				persist.raw["GenericObject/target-1"]["QueueId"] = "queue-new"
			}
		}
	}
	svc := &ApprovalService{
		persistence: persist,
		schema: captureSchemaStub{schemas: map[string]*models.ObjectMetadata{
			"GenericObject": {
				FieldsMap: map[string]*models.FieldMetadata{
					"ParentId": {APIName: "ParentId", ReferenceTo: []string{"ParentObject"}},
					"QueueId":  {APIName: "QueueId", ReferenceTo: []string{"QueueObject"}},
				},
			},
		}},
		logger: logging.Default,
	}
	user := &models.UserSession{ID: "u-1"}

	actions := []models.SObject{
		{
			"Name":         "Close parent",
			"ActionType":   constants.ProcessActionTypeFieldUpdate,
			"FieldApiName": "ParentId.Stage",
			"FieldValue":   "Closed",
		},
		{
			"Name":         "Submit target",
			"ActionType":   constants.ProcessActionTypeFieldUpdate,
			"FieldApiName": "Status",
			"FieldValue":   "Submitted",
		},
		{
			"Name":         "Queue follow-up",
			"ActionType":   constants.ProcessActionTypeFieldUpdate,
			"FieldApiName": "QueueId.State",
			"FieldValue":   "pending",
		},
	}

	if err := svc.executeLiveFieldUpdateMutations(context.Background(), actions, "GenericObject", "target-1", user); err != nil {
		t.Fatalf("executeLiveFieldUpdateMutations err = %v", err)
	}
	if len(persist.updates) != 3 {
		t.Fatalf("expected 3 Update calls, got %d", len(persist.updates))
	}
	last := persist.updates[2]
	if last.tableName != "QueueObject" || last.id != "queue-new" {
		t.Fatalf("final FieldUpdate targeted %s/%s, want QueueObject/queue-new after live cross-record re-resolution", last.tableName, last.id)
	}
	if got := last.updates["State"]; got != "pending" {
		t.Fatalf("final QueueObject.State update = %v, want pending", got)
	}
}

// resolveEmailTemplateID must treat the input ref as opaque: a Salesforce-
// shaped folder-path like "unfiled$public/Quote_Approved" is returned
// unchanged so the downstream renderer can fail-graceful when the template
// is not found by that ID. L0 must NOT parse the path or issue a
// DeveloperName lookup against the EmailTemplate table — folder-path
// templates are an L1-pack-specific authoring convention and must not
// leak into the platform binary.
func TestApprovalService_ResolveEmailTemplateID_FolderPathTreatedAsOpaque(t *testing.T) {
	q := &captureQuery{}
	svc := &ApprovalService{
		query:  q,
		logger: logging.Default,
	}

	// Plain template id passes through unchanged.
	if got := svc.resolveEmailTemplateID("00X-001", "TestPrefix", "alert-1"); got != "00X-001" {
		t.Errorf("resolveEmailTemplateID(plain id) = %q; want %q (opaque pass-through)", got, "00X-001")
	}
	// Plain DeveloperName passes through unchanged — looking it up by name
	// is the renderer's job, not L0's path-parser job.
	if got := svc.resolveEmailTemplateID("Quote_Approved", "TestPrefix", "alert-1"); got != "Quote_Approved" {
		t.Errorf("resolveEmailTemplateID(devname) = %q; want %q (opaque pass-through)", got, "Quote_Approved")
	}
	// Folder-path-shaped ref must be returned unchanged: L0 does not
	// parse "unfiled$public/Quote_Approved" → "Quote_Approved".
	folderRef := "unfiled$public/Quote_Approved"
	if got := svc.resolveEmailTemplateID(folderRef, "TestPrefix", "alert-1"); got != folderRef {
		t.Errorf("resolveEmailTemplateID(folder-path) = %q; want %q (opaque, unparsed)", got, folderRef)
	}
	// And critically, no QueryWithFilter call was issued against the
	// EmailTemplate table — the platform must not resolve folder paths
	// via its own query layer.
	for _, call := range q.calls {
		if strings.EqualFold(call.objectName, constants.TableEmailTemplate) {
			t.Errorf("resolveEmailTemplateID issued a QueryWithFilter against %q (filter=%q) — folder-path resolution must NOT live in L0", call.objectName, call.filter)
		}
	}
}

// The _System_WorkflowAlert.Template column documents the public-core
// contract for the Template field. L0 treats the reference as opaque
// (commit 59702ed dropped Salesforce-shaped folder-path parsing such as
// "unfiled$public/Quote_Approved" — that authoring grammar is an L1-pack
// concern and LoadAndRenderTemplate only loads templates by Id).
//
// The system-table description must reflect that contract. If it still
// advertises "folder/name path" support, greenfield public-core alerts
// authored in the documented form will silently fail to render — exactly
// the Refactor Discipline anti-pattern of "land new mechanism but leave
// the old contract unchanged".
func TestSystemTables_WorkflowAlertTemplateDescription_OpaqueOnly(t *testing.T) {
	jsonPath := filepath.Join("..", "nexuscrm-kernel", "pkg", "schemacatalog", "system_tables.json")
	data, err := os.ReadFile(jsonPath)
	if err != nil {
		t.Fatalf("read %s: %v", jsonPath, err)
	}

	var tables []struct {
		TableName string `json:"tableName"`
		Columns   []struct {
			Name        string `json:"name"`
			Description string `json:"description"`
		} `json:"columns"`
	}
	if err := json.Unmarshal(data, &tables); err != nil {
		t.Fatalf("parse %s: %v", jsonPath, err)
	}

	const wantTable = "_System_WorkflowAlert"
	const wantField = "Template"
	for _, tb := range tables {
		if tb.TableName != wantTable {
			continue
		}
		for _, col := range tb.Columns {
			if col.Name != wantField {
				continue
			}
			lc := strings.ToLower(col.Description)
			// Negative assertion: description must NOT advertise template-ref
			// forms that L0 does not actually resolve.
			//
			// Codex Round-1 (PR #271) flagged the legacy phrase
			// "(ID or folder/name path)" — L0 stopped parsing folder paths
			// in commit 59702ed.
			//
			// Codex Round-2 (PR #271) flagged the follow-up phrase
			// "EmailTemplate Id or DeveloperName resolvable by
			// LoadAndRenderTemplate" — LoadAndRenderTemplate only loads
			// _System_EmailTemplate by primary-key Id (action_service.go:282
			// `GetRaw(ctx, TableEmailTemplate, templateID)`), so claiming
			// DeveloperName-resolvable behavior in the public-core contract
			// would have alerts authored in the documented form silently
			// fail to render. The contract must match the resolver: Id-only.
			//
			// Banned substrings target POSITIVE advertisement only — negation
			// phrasings such as "folder paths are not parsed" or "must be
			// resolved at apply time" remain allowed and informative.
			for _, banned := range []string{
				"folder/name",
				"or folder",
				"developername resolvable",
				"or developername",
				"name resolvable",
			} {
				if strings.Contains(lc, banned) {
					t.Errorf("_System_WorkflowAlert.Template description = %q; must not advertise %q — L0 only resolves the reference as an _System_EmailTemplate Id (LoadAndRenderTemplate), other forms must be resolved by L1 packs at apply time", col.Description, banned)
				}
			}
			// Positive assertion: the description must explicitly state
			// that the reference is loaded as an Id, so the contract is
			// not silent about the actual resolution path. "id" alone is
			// too loose (it appears in many descriptions); pin the explicit
			// "as id" / "by id" form.
			if !strings.Contains(lc, " id") {
				t.Errorf("_System_WorkflowAlert.Template description = %q; must explicitly state Id-based resolution to match LoadAndRenderTemplate's actual behavior", col.Description)
			}
			return
		}
		t.Fatalf("column %q not found in table %q", wantField, wantTable)
	}
	t.Fatalf("table %q not found in %s", wantTable, jsonPath)
}
