package records

import (
	"context"
	"fmt"
	"strings"

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

// activeFormulaProjector projects active-mode formula fields for a record
// immediately before an outbox event is enqueued. It reads the formula
// view row (via ActiveFormulaProjectionPort) and returns a snapshot
// containing only the active-formula fields. The snapshot is carried
// through the outbox InternalMetadata so the flow-trigger listener can
// overlay it onto $Record without re-querying the DB.
//
// The projector is wired into PersistenceService as an optional dependency
// (SetActiveFormulaProjector). When nil the insert path skips projection
// and the listener falls back to raw payload.Record — correct for schemas
// with no active formula fields.
//
// This vertical slice covers RecordCreated only. Update / BulkInsert /
// FlowExecutor chained-reread are tracked as follow-up slices.
type activeFormulaProjector struct {
	port ActiveFormulaProjectionPort
}

// newActiveFormulaProjector constructs a projector backed by the given port.
func newActiveFormulaProjector(port ActiveFormulaProjectionPort) *activeFormulaProjector {
	return &activeFormulaProjector{port: port}
}

// project fetches the formula-view row for recordID and returns a snapshot
// map containing only the active-formula fields declared in schema.
//
// user is forwarded to the port's QueryActiveProjectionRow for object/FLS
// checks. Write-side callers elevate ctx to SystemContext before calling
// (making FLS checks a no-op on those paths).
//
// Returns (nil, nil) when the record has no active-formula fields in
// schema — this is the legitimate "nothing to project" case.
//
// Returns (nil, error) on:
//   - query failure
//   - row absent from the view (non-nil only when the view exists and the
//     field is expected)
//   - any active field missing from the view row (view/metadata drift,
//     fail-closed so callers abort the write transaction)
//
// Uses formulastorage.IsActiveComputedField as the single source of truth for
// "is this an active computed field". Do NOT introduce a local predicate
// here — that was Codex #1390 R4 HIGH (SSoT divergence on the
// FormulaMode trim + Formula whitespace-only edge cases).
func (p *activeFormulaProjector) project(
	ctx context.Context,
	user *models.UserSession,
	schema *models.ObjectMetadata,
	recordID string,
) (models.SObject, error) {
	return p.projectWithDeletedVisibility(ctx, user, schema, recordID, false, nil)
}

// projectFields is project restricted to the given active-field API names
// (nil = all). Field-scoped strict callers (approval criteria evaluation,
// #3945) use it so that both the view SELECT and the fail-closed
// missing-field validation cover exactly the fields the caller is authorized
// to read: an unrelated active field's view drift must not fail a scoped
// read that never asked about it.
func (p *activeFormulaProjector) projectFields(
	ctx context.Context,
	user *models.UserSession,
	schema *models.ObjectMetadata,
	recordID string,
	fields []string,
) (models.SObject, error) {
	return p.projectWithDeletedVisibility(ctx, user, schema, recordID, false, fields)
}

func (p *activeFormulaProjector) projectIncludingDeleted(
	ctx context.Context,
	user *models.UserSession,
	schema *models.ObjectMetadata,
	recordID string,
) (models.SObject, error) {
	return p.projectWithDeletedVisibility(ctx, user, schema, recordID, true, nil)
}

// activeFieldsForProjection collects the schema's active-formula field API
// names, restricted to `restrictTo` when non-nil (case-insensitive match;
// names that are not active fields of this schema are ignored). Uses
// formulastorage.IsActiveComputedField as the single source of truth for
// "is this an active computed field".
func activeFieldsForProjection(schema *models.ObjectMetadata, restrictTo []string) []string {
	var allowed map[string]struct{}
	if restrictTo != nil {
		allowed = make(map[string]struct{}, len(restrictTo))
		for _, name := range restrictTo {
			allowed[strings.ToLower(strings.TrimSpace(name))] = struct{}{}
		}
	}
	activeFields := make([]string, 0, 4)
	for i := range schema.Fields {
		f := &schema.Fields[i]
		if !formulastorage.IsActiveComputedField(f) {
			continue
		}
		if allowed != nil {
			if _, ok := allowed[strings.ToLower(f.APIName)]; !ok {
				continue
			}
		}
		activeFields = append(activeFields, f.APIName)
	}
	return activeFields
}

func (p *activeFormulaProjector) projectWithDeletedVisibility(
	ctx context.Context,
	user *models.UserSession,
	schema *models.ObjectMetadata,
	recordID string,
	includeDeleted bool,
	restrictTo []string,
) (models.SObject, error) {
	if schema == nil {
		return nil, nil
	}

	activeFields := activeFieldsForProjection(schema, restrictTo)
	if len(activeFields) == 0 {
		// Schema has no (matching) active formula fields — nothing to project.
		return nil, nil
	}

	var row models.SObject
	var err error
	row, err = p.port.QueryActiveProjectionRow(ctx, user, schema.APIName, recordID, includeDeleted, activeFields)
	if err != nil {
		return nil, fmt.Errorf("active-formula projection query for %s/%s: %w", schema.APIName, recordID, err)
	}
	if row == nil {
		// Fail-closed: we already established that schema has active fields
		// (activeFields > 0). The caller is on an after-save / mid-tx path
		// that has already INSERTed the row — view eventual-consistency or
		// view-rebuild drift is the only way the row would be absent here.
		// Returning (nil, nil) would let the listener silently fall back to
		// the raw payload (which omits the active fields) — the original
		// #1362 bug class. Callers must abort.
		return nil, fmt.Errorf(
			"active-formula projection: row %s/%s not found in view (eventual consistency or view rebuild in flight)",
			schema.APIName, recordID,
		)
	}

	// Extract active fields. Fail-closed on missing key: the view is
	// authoritative for all active fields once the row exists. Absence
	// means view/metadata drift — surface it so the user retries rather
	// than silently publishing a partial snapshot.
	//
	// The snapshot ALSO carries the record's Id under the standard "Id"
	// key (in addition to the active fields). This is the per-event
	// scope marker that the flow trigger listener checks before
	// overlaying: even if a nested write inherits this ctx (slice-1
	// scope is INSERT only, so Update/BulkInsert/Before* publish sites
	// don't clear), the listener compares snap["Id"] against the inner
	// payload.Record["Id"] and skips the overlay on mismatch — the
	// outer record's active fields cannot bleed onto a nested event's
	// $Record. (Codex #1400 R1 HIGH on per-event ctx isolation.)
	projected := make(models.SObject, len(activeFields)+1)
	projected[constants.FieldID] = recordID
	for _, name := range activeFields {
		v, ok := row[name]
		if !ok {
			return nil, fmt.Errorf(
				"active-formula projection: field %s.%s absent from view row (view/metadata drift)",
				schema.APIName, name,
			)
		}
		projected[name] = v
	}
	return projected, nil
}

// projectBestEffort is the read-side variant of project. Unlike project, it
// does NOT fail-closed on missing active-field keys in the view row —
// instead it silently skips them. Used by FlowExecutor's post-RecordUpdate
// reprojection path (RecomputeActiveFormulaProjection) where the user-context
// QueryService read may legitimately omit active fields the caller has no
// FLS read access to. Treating that omission as fail-closed (like the
// write-side path does) would convert FLS-hidden fields into a post-commit
// error and surface a partial-commit regression for normal user-invoked
// flows.
//
// Write-side callers (Insert/Update/BulkInsert outbox enqueue) elevate ctx
// to SystemContext, so they continue to use the strict project() variant —
// under SystemContext, "missing key" really does mean view drift.
//
// Read-side caller (FlowExecutor.executeRecordUpdate via
// RecomputeActiveFormulaProjection) uses this best-effort variant:
// anything the user can see gets overlaid onto $Record; FLS-hidden active
// fields stay at whatever value the trigger-time snapshot set (or absent
// if the user couldn't see them then either — same degraded behavior as
// today's SELECT path under user FLS).
//
// Returns:
//   - (snap, nil)  — projection succeeded; snap may carry only the fields
//     the user can read, plus snap["Id"] = recordID
//   - (nil, nil)   — schema has no active fields, recordID empty, or the
//     view row is not visible under caller's RLS
//   - (nil, error) — transient query failure (different from FLS-hidden)
func (p *activeFormulaProjector) projectBestEffort(
	ctx context.Context,
	user *models.UserSession,
	schema *models.ObjectMetadata,
	recordID string,
	restrictTo []string,
) (models.SObject, error) {
	if schema == nil {
		return nil, nil
	}

	activeFields := activeFieldsForProjection(schema, restrictTo)
	if len(activeFields) == 0 {
		return nil, nil
	}

	row, err := p.port.QueryActiveProjectionRow(ctx, user, schema.APIName, recordID, false, activeFields)
	if err != nil {
		return nil, fmt.Errorf("active-formula projection (best-effort) query for %s/%s: %w", schema.APIName, recordID, err)
	}
	if row == nil {
		// Under user-context read, an absent row most likely means RLS hid
		// the row (e.g. update changed ownership). Silently fall back to
		// whatever $Record snapshot the caller already has. Same degraded
		// behavior as a normal SELECT returning 0 rows.
		return nil, nil
	}

	projected := make(models.SObject, len(activeFields)+1)
	projected[constants.FieldID] = recordID
	for _, name := range activeFields {
		if v, ok := row[name]; ok {
			projected[name] = v
		}
		// Missing key under user-context = FLS-hidden field. Skip silently.
	}
	return projected, nil
}
