package permissions

import (
	"context"
	"database/sql"
	"strings"

	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
	"github.com/rs/zerolog/log"
)

type parentControlledSharingEdge struct {
	rel        *models.Relationship
	parentOnly bool
	malformed  bool
}

func (ps *PermissionService) parentControlledSharingEdgeForSchema(ctx context.Context, schema *models.ObjectMetadata, objectName string) parentControlledSharingEdge {
	if schema == nil || schema.SharingModel != constants.SharingModelControlledByParent {
		return parentControlledSharingEdge{}
	}
	if ps == nil || ps.metadata == nil {
		return parentControlledSharingEdge{malformed: true}
	}
	if strings.TrimSpace(objectName) == "" {
		objectName = schema.APIName
	}
	rel := ps.metadata.GetSharingParentRelationship(ctx, objectName)
	if rel == nil {
		return parentControlledSharingEdge{malformed: true}
	}
	field := parentControlledSharingParentField(schema, rel)
	if field == nil {
		return parentControlledSharingEdge{malformed: true}
	}
	// Fail closed when the sharing-parent edge points at a missing parent object:
	// a partial/two-step metadata apply or a deleted parent must not let stale
	// parent ObjectPermissions rows (Gate-1) or owner/share fallback (Gate-2/RLS)
	// authorize the child. This is the single malformed predicate every CbP read
	// path consumes (service.go Gate-1, record_access.go Gate-2, rls_filter.go).
	parentObjectName := strings.TrimSpace(rel.ParentObjectAPIName)
	if parentObjectName == "" || ps.metadata.GetSchema(ctx, parentObjectName) == nil {
		return parentControlledSharingEdge{malformed: true}
	}
	return parentControlledSharingEdge{
		rel:        rel,
		parentOnly: parentControlledSharingParentFieldRequiresParentEdit(field, rel),
	}
}

// checkParentControlledRecordAwareCRUD evaluates the Gate-1 capability leg for
// a parent-controlled child record. The capability source is metadata-driven:
// a child RequiredWritePermission is authoritative for writes; otherwise the
// inherited parent capability is used, with child Create/Edit/Delete normalized
// to parent Edit. Mirrors the visited-map idiom in
// checkControlledByParentAccess (record_access.go:411-418) for the same
// recursion shape.
//
// Two-tier delegation:
//
//  1. Fast path — when the parent has no record-context AccessPolicy for the
//     operation, recurse into the non-record entry point. That entry point
//     does its own state.objectOps marking at the top of
//     checkObjectPermissionWithState; do NOT pre-mark here or the recursed
//     frame self-denies on its stackKey check.
//
//  2. Record-context path — when the parent's AP genuinely needs the parent
//     record, load it via repo.GetRecordByID using the child's FK from the
//     sharing-parent relationship, then evaluate the parent's AP against
//     that loaded record. If the parent is itself parent-controlled, recurse
//     into this same helper with the parent record + the SAME visited map.
func (ps *PermissionService) checkParentControlledRecordAwareCRUD(
	ctx context.Context,
	childSchema *models.ObjectMetadata,
	childRecord models.SObject,
	parentObjectName string,
	operation string,
	user *models.UserSession,
	state *accessPolicyEvalState,
	visited map[string]bool,
) bool {
	if childSchema == nil || user == nil || ps.metadata == nil {
		return false
	}
	if state == nil {
		state = newAccessPolicyEvalState()
	}
	if visited == nil {
		visited = make(map[string]bool)
	}

	childKey := strings.ToLower(childSchema.APIName)
	if visited[childKey] {
		return false
	}
	visited[childKey] = true
	defer delete(visited, childKey)

	parentSchema := ps.metadata.GetSchema(ctx, parentObjectName)
	if parentSchema == nil {
		return false
	}
	if parentControlledRequiredWritePermissionIsCapability(childSchema, operation) {
		return ps.checkObjectPermissionWithState(ctx, childSchema.APIName, operation, user, state)
	}
	parentOperation := parentControlledParentCapabilityOperation(operation)

	// Fast path: NO ancestor in the parent chain has a record-context AccessPolicy
	// for this operation. Recurse into the non-record entry point; its
	// state.objectOps marker handles cycle detection naturally.
	//
	// We use the chain-walking helper requiresRecordAwareCRUDDeferral (not the
	// one-hop accessPolicyRequiresRecordContext) because the non-record entry
	// point at service.go:399-410 delegates parent-controlled children DOWN the
	// chain WITHOUT loading records. For a chain A→B→C where only C has a
	// record-context AP, the one-hop view on B returns false (B itself has no
	// AP), but the non-record fast path would then recurse B→C and evaluate C's
	// record-context AP with record == nil, causing a false deny on legitimate
	// edits/deletes. The op-neutral internal helper walks the FULL parent chain
	// until either a record-context AP is hit (we take the slow path below) or
	// the chain terminates (fast path is safe).
	//
	// We use the internal op-neutral helper (not the public Edit/Delete-only
	// gate in RequiresRecordAwareCRUDDeferral) because the consumer side is
	// the routing inside a record-aware entry point that has ALREADY decided
	// to operate on a record;
	// the public gate's PermCreate/PermRead carve-out (which prevents
	// producer-side deferral when no record-aware consumer gate exists
	// downstream) is not load-bearing here — the consumer gate IS this code.
	if !ps.requiresRecordAwareCRUDDeferral(ctx, parentSchema, parentOperation, make(map[string]bool)) {
		return ps.checkObjectPermissionWithState(ctx, parentObjectName, parentOperation, user, state)
	}

	// Record-context path: load the parent record so we can evaluate the
	// parent AP against it.
	rel := ps.metadata.GetSharingParentRelationship(ctx, childSchema.APIName)
	if rel == nil {
		// Defense in depth — GetParentControlledObject already returned
		// non-empty; if a concurrent metadata reload nilled it out, fail
		// closed rather than re-route through the non-record path with a
		// nil record (which would deny on recordFieldEqualsCurrentUser
		// terms anyway, but routing matters for diagnostic clarity).
		return false
	}
	parentID, _ := childRecord[rel.FieldAPIName].(string)
	if parentID == "" {
		return false // orphan child — no parent to inherit from
	}

	parentRecord, err := ps.parentRecordForAccessEvaluation(ctx, parentObjectName, parentID)
	if err != nil {
		if err == sql.ErrNoRows {
			return false // parent deleted
		}
		log.Warn().Err(err).
			Str("parent", parentObjectName).Str("parentId", parentID).
			Msg("ControlledByParent CRUD: failed to load parent record")
		return false
	}

	// If the parent is itself parent-controlled, recurse with the parent
	// record + the same visited map. The visited[childKey] / visited[parentKey]
	// markings terminate cycles A→B→A.
	if grand := ps.metadata.GetParentControlledObject(ctx, parentSchema); grand != "" {
		return ps.checkParentControlledRecordAwareCRUD(ctx, parentSchema, parentRecord, grand, operation, user, state, visited)
	}

	allowed, err := ps.evaluateObjectAccessPolicy(ctx, parentSchema, parentRecord, parentOperation, user, state)
	if err != nil {
		return false
	}
	return allowed
}

// RequiresRecordAwareCRUDDeferral returns true when the object's CRUD
// decision needs a loaded record to evaluate correctly. The single SSoT
// predicate consumed by producer-side preflights:
//
//   - records.PersistenceService.prepareOperation (Update / Delete)
//   - rest.shouldDeferBulkJobObjectPermissionCheck (Bulk Update / Upsert /
//     Delete)
//
// Covers two cases:
//
//   - schemas that themselves UseDerivedAccessPolicy and whose AP for the
//     operation requires record context (today's narrow predicate); AND
//   - parent-controlled children whose parent (or any ancestor in the
//     parent chain) needs record context for the same operation.
//
// **Edit/Delete-only gate (top of function).** Returns false for PermCreate
// and PermRead unconditionally. Rationale: Insert has NO record-aware
// consumer gate downstream — its sole object-permission check is the
// producer-side prepareOperation(PermCreate) call. If we ever returned true
// for PermCreate here, prepareOperation would silently skip
// CheckPermissionOrErrorWithUser and the consumer gate would never run,
// admitting the row with zero object-level checks. PermRead is symmetric:
// reads route through non-record paths (rls_filter, query gates) that have
// no record-aware deferral consumer. The blind spot the crossfire
// escalation called out was that the helper's parent-chain walk could see
// a parent's record-context AP for Create and return true even though the
// CHILD's gate is Create — wedging the bypass. This gate eliminates the
// blind spot at the entry point and the regression test pins it.
func (ps *PermissionService) RequiresRecordAwareCRUDDeferral(
	ctx context.Context,
	schema *models.ObjectMetadata,
	operation string,
) bool {
	if schema == nil {
		return false
	}
	// Edit/Delete-only gate. The downstream consumer gate runs only for these
	// two operations — any PermCreate or PermRead deferral here is a silent
	// bypass of the producer-side object-permission check.
	if operation != constants.PermEdit && operation != constants.PermDelete {
		return false
	}
	return ps.requiresRecordAwareCRUDDeferral(ctx, schema, operation, make(map[string]bool))
}

func (ps *PermissionService) requiresRecordAwareCRUDDeferral(
	ctx context.Context,
	schema *models.ObjectMetadata,
	operation string,
	visited map[string]bool,
) bool {
	if schema == nil {
		return false
	}
	key := strings.ToLower(schema.APIName)
	if visited[key] {
		return false
	}
	visited[key] = true

	// Self: schema's own derived AP requires record context for the op.
	if schema.UsesDerivedAccessPolicy() && ps.accessPolicyRequiresRecordContext(schema, operation) {
		return true
	}
	// Parent chain: child is parent-controlled — recurse to its parent.
	if ps.metadata == nil {
		return false
	}
	parent := ps.metadata.GetParentControlledObject(ctx, schema)
	if parent == "" {
		return false
	}
	parentSchema := ps.metadata.GetSchema(ctx, parent)
	if parentSchema == nil {
		return false
	}
	return ps.requiresRecordAwareCRUDDeferral(ctx, parentSchema, parentControlledParentCapabilityOperation(operation), visited)
}

func ParentControlledRequiredWritePermissionIsCapability(schema *models.ObjectMetadata, operation string) bool {
	if schema == nil {
		return false
	}
	switch operation {
	case constants.PermCreate, constants.PermEdit, constants.PermDelete:
	default:
		return false
	}
	required := strings.TrimSpace(schema.RequiredWritePermission)
	return required != "" && !strings.EqualFold(required, "BLOCKED")
}

func parentControlledRequiredWritePermissionIsCapability(schema *models.ObjectMetadata, operation string) bool {
	return ParentControlledRequiredWritePermissionIsCapability(schema, operation)
}

func parentControlledParentCapabilityOperation(operation string) string {
	switch operation {
	case constants.PermCreate, constants.PermEdit, constants.PermDelete:
		return constants.PermEdit
	default:
		return operation
	}
}

// ParentControlledSharingParentRequiresParentEdit reports whether a sharing
// parent FK is a composed edge whose parent-record reach must be Edit rather
// than Read. It is intentionally metadata-shaped: callers pass the effective
// relationship row and child field metadata, with no SObject-name branches.
func ParentControlledSharingParentRequiresParentEdit(schema *models.ObjectMetadata, rel *models.Relationship) bool {
	field := parentControlledSharingParentField(schema, rel)
	if field == nil {
		return false
	}
	return parentControlledSharingParentFieldRequiresParentEdit(field, rel)
}

func parentControlledSharingParentFieldRequiresParentEdit(field *models.FieldMetadata, rel *models.Relationship) bool {
	if field == nil {
		return false
	}
	return field.Required ||
		field.IsMasterDetail ||
		strings.EqualFold(string(constants.NormalizeFieldType(string(field.Type))), string(constants.FieldTypeMasterDetail)) ||
		(rel != nil && strings.EqualFold(rel.RelationshipType, string(constants.FieldTypeMasterDetail)))
}

func parentControlledSharingParentField(schema *models.ObjectMetadata, rel *models.Relationship) *models.FieldMetadata {
	if schema == nil || rel == nil || strings.TrimSpace(rel.FieldAPIName) == "" {
		return nil
	}
	for i := range schema.Fields {
		if strings.EqualFold(schema.Fields[i].APIName, rel.FieldAPIName) {
			return &schema.Fields[i]
		}
	}
	return nil
}
