package events

import (
	"context"
	"encoding/json"
	"fmt"

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

// Internal metadata keys for active-formula projection snapshots.
// These keys are stored in OutboxEvent.InternalMetadata and are stripped from
// the public payload before dispatch (see StripInternalActiveProjections).
const (
	internalMetadataKeyActiveProjectionRecord     = "active_projection_record"
	internalMetadataKeyActiveProjectionOldRecord  = "active_projection_old_record"
	internalMetadataKeyActiveProjectionAttempted  = "active_projection_attempted"
)

// MarkActiveProjectionAttempted stamps the context so the outbox enqueue
// path records that a projection attempt was made for this write, even when
// the schema has no active fields (snapshot keys absent but attempted=true).
// The listener uses this to distinguish:
//
//   - attempted=true + snap=nil  → legitimate "no active fields" schema
//   - attempted=false (key absent) → wiring not in effect or pre-PR event
//     → use raw payload (backward-compat)
func MarkActiveProjectionAttempted(ctx context.Context) context.Context {
	return context.WithValue(ctx, constants.ContextKey(internalMetadataKeyActiveProjectionAttempted), "true")
}

// ActiveProjectionAttempted reports whether the write path marked a
// projection attempt on ctx.
func ActiveProjectionAttempted(ctx context.Context) bool {
	v, _ := ctx.Value(constants.ContextKey(internalMetadataKeyActiveProjectionAttempted)).(string)
	return v == "true"
}

// WithActiveProjections stamps active-formula snapshots for newRec and
// oldRec onto ctx.  Either argument may be nil (e.g. INSERT has no
// oldRec).  Returns an error if JSON encoding fails — the caller MUST
// propagate the error to abort the enclosing write transaction.
func WithActiveProjections(ctx context.Context, newRec, oldRec models.SObject) (context.Context, error) {
	if newRec != nil {
		b, err := json.Marshal(newRec)
		if err != nil {
			return ctx, fmt.Errorf("encode active-projection record: %w", err)
		}
		ctx = context.WithValue(ctx, constants.ContextKey(internalMetadataKeyActiveProjectionRecord), string(b))
	}
	if oldRec != nil {
		b, err := json.Marshal(oldRec)
		if err != nil {
			return ctx, fmt.Errorf("encode active-projection old record: %w", err)
		}
		ctx = context.WithValue(ctx, constants.ContextKey(internalMetadataKeyActiveProjectionOldRecord), string(b))
	}
	return ctx, nil
}

// ActiveProjectionRecordFromCtx decodes the new-record active-formula
// snapshot from ctx.
//
// Returns:
//   - (snap, nil)   — snapshot present and decoded successfully
//   - (nil,  nil)   — no snapshot stamped (backward-compat)
//   - (nil,  error) — snapshot present but decode failed; caller MUST fail-closed
func ActiveProjectionRecordFromCtx(ctx context.Context) (models.SObject, error) {
	return activeProjectionFromCtx(ctx, internalMetadataKeyActiveProjectionRecord)
}

// ActiveProjectionOldRecordFromCtx decodes the old-record active-formula
// snapshot from ctx.  Same return semantics as ActiveProjectionRecordFromCtx.
func ActiveProjectionOldRecordFromCtx(ctx context.Context) (models.SObject, error) {
	return activeProjectionFromCtx(ctx, internalMetadataKeyActiveProjectionOldRecord)
}

func activeProjectionFromCtx(ctx context.Context, key string) (models.SObject, error) {
	raw, _ := ctx.Value(constants.ContextKey(key)).(string)
	if raw == "" {
		return nil, nil
	}
	var obj models.SObject
	if err := json.Unmarshal([]byte(raw), &obj); err != nil {
		return nil, fmt.Errorf("decode active-projection %s: %w", key, err)
	}
	return obj, nil
}

// LiftActiveProjectionsToMetadata reads active-projection context values and
// stores them in internalMetadata so they survive the outbox serialization.
// Called by EnqueueEvent after the standard chain-lineage lift.
func LiftActiveProjectionsToMetadata(ctx context.Context, internalMetadata map[string]string) {
	if v, _ := ctx.Value(constants.ContextKey(internalMetadataKeyActiveProjectionRecord)).(string); v != "" {
		internalMetadata[internalMetadataKeyActiveProjectionRecord] = v
	}
	if v, _ := ctx.Value(constants.ContextKey(internalMetadataKeyActiveProjectionOldRecord)).(string); v != "" {
		internalMetadata[internalMetadataKeyActiveProjectionOldRecord] = v
	}
	if v, _ := ctx.Value(constants.ContextKey(internalMetadataKeyActiveProjectionAttempted)).(string); v != "" {
		internalMetadata[internalMetadataKeyActiveProjectionAttempted] = v
	}
}

// ClearActiveProjections returns ctx with the three active-projection
// metadata keys (record snapshot, old-record snapshot, attempted marker)
// removed. Use this before dispatching nested writes that should NOT
// inherit the outer record's projection — without clearing, a rollup-
// driven UpdateDerived reuses the caller's ctx and its own EnqueueEvent
// lifts the outer record's snapshot into the nested event's
// InternalMetadata, causing FlowTriggerListener to overlay the wrong
// record's $Record / $Record__Old. Codex Pass A HIGH on
// persistence_service.go:1177 found this leak path.
//
// Implementation: context values are immutable per key, but storing an
// empty string sentinel for each key effectively clears them (the read
// helpers return nil/false when raw == "").
func ClearActiveProjections(ctx context.Context) context.Context {
	ctx = context.WithValue(ctx, constants.ContextKey(internalMetadataKeyActiveProjectionRecord), "")
	ctx = context.WithValue(ctx, constants.ContextKey(internalMetadataKeyActiveProjectionOldRecord), "")
	ctx = context.WithValue(ctx, constants.ContextKey(internalMetadataKeyActiveProjectionAttempted), "")
	return ctx
}

// StripInternalActiveProjections removes the three active-projection internal
// metadata keys from a metadata map before the payload is published.
// Called in processEventAtomic after restoreInternalMetadata so subscribers
// never see these keys in payload.Metadata.
func StripInternalActiveProjections(metadata map[string]string) map[string]string {
	if len(metadata) == 0 {
		return metadata
	}
	scrubbed := make(map[string]string, len(metadata))
	for k, v := range metadata {
		switch k {
		case internalMetadataKeyActiveProjectionRecord,
			internalMetadataKeyActiveProjectionOldRecord,
			internalMetadataKeyActiveProjectionAttempted:
			continue
		default:
			scrubbed[k] = v
		}
	}
	if len(scrubbed) == 0 {
		return nil
	}
	return scrubbed
}
