package automation

import (
	"context"
	"errors"
	"fmt"
	"strings"

	apperrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

// SubmitRequest represents a request to submit a record for approval
type SubmitRequest struct {
	ObjectAPIName     string `json:"object_api_name"`
	RecordID          string `json:"record_id"`
	Comments          string `json:"comments"`
	SkipEntryCriteria bool   `json:"skip_entry_criteria"`
}

// SubmitEligibility is the single source of truth for submit-time target and
// process resolution. Submit must consume this result for every downstream
// target/process sink instead of re-reading raw request identity.
type SubmitEligibility struct {
	TargetRecordID      string
	ObjectAPIName       string
	ProcessDefinition   models.SObject
	ProcessDefinitionID string
	TargetRecord        models.SObject
	runtimeRevalidate   bool
}

type submitTargetObjectCandidateResolver interface {
	ResolveObjectTypeCandidates(ctx context.Context, ids []string) (map[string][]string, error)
}

type submitErrorWithTarget struct {
	err            error
	targetObjectID string
}

func (e *submitErrorWithTarget) Error() string {
	if e == nil || e.err == nil {
		return ""
	}
	return e.err.Error()
}

func (e *submitErrorWithTarget) Unwrap() error {
	if e == nil {
		return nil
	}
	return e.err
}

func (e *submitErrorWithTarget) SubmitTargetObjectID() string {
	if e == nil {
		return ""
	}
	return e.targetObjectID
}

func wrapSubmitTargetError(err error, targetObjectID string) error {
	if err == nil || strings.TrimSpace(targetObjectID) == "" {
		return err
	}
	var carrier interface{ SubmitTargetObjectID() string }
	if errors.As(err, &carrier) && strings.TrimSpace(carrier.SubmitTargetObjectID()) != "" {
		return err
	}
	return &submitErrorWithTarget{
		err:            err,
		targetObjectID: targetObjectID,
	}
}

func (s *ApprovalService) resolveApprovalSubmitTarget(ctx context.Context, req SubmitRequest, user *models.UserSession) (string, string, error) {
	targetRecordID := strings.TrimSpace(req.RecordID)
	if targetRecordID == "" {
		return "", "", apperrors.NewValidationError("RecordId", "RecordId is required for Submit")
	}

	// Preferred path: when the caller supplies the object — record-triggered
	// flows and the record-page Submit always know it — resolve the target
	// through that ONE object's own read path (GetRaw/FindOne). This is the same
	// authoritative read the record GET uses, so a just-committed record is seen
	// exactly as GET sees it, and — crucially for NX#3478 — a transient
	// stale-InfoSchema miss on the freshly-created object's own table on
	// multi-node TiDB surfaces here as a retryable error instead of a false
	// "record not found".
	objectHint := strings.TrimSpace(req.ObjectAPIName)
	if objectHint != "" {
		record, err := s.readApprovalSystemFields(ctx, objectHint, targetRecordID, nil)
		if err != nil {
			if apperrors.IsValidation(err) {
				return "", "", err
			}
			// Missing-table / stale-InfoSchema / infra error on the target's own
			// table: fail closed with a retryable Internal, never NotFound, so an
			// afterCreate Submit does not silently vanish.
			return "", "", apperrors.NewInternalError("failed to verify submit target record", err)
		}
		if record != nil {
			return targetRecordID, objectHint, nil
		}
	}

	// An opaque ID may legitimately exist in more than one live object (for
	// example, the business record and its _System_AuditOutbox row). Never let
	// catalog order pick a single winner. Resolve every physical owner, then use
	// active approval-process ownership to disambiguate objectless submits.
	candidates, err := s.approvalSubmitTargetCandidates(ctx, targetRecordID)
	if err != nil {
		return "", "", err
	}

	if objectHint != "" {
		for _, candidate := range candidates {
			if strings.EqualFold(objectHint, strings.TrimSpace(candidate)) {
				return targetRecordID, objectHint, nil
			}
		}
		return "", "", apperrors.NewValidationError("ObjectApiName", fmt.Sprintf("ObjectApiName %q does not match any record type owning the submitted ID", objectHint))
	}

	if len(candidates) == 1 {
		objectAPIName := strings.TrimSpace(candidates[0])
		if objectAPIName == "" {
			return "", "", apperrors.NewInternalError("submit target object candidate resolved empty", nil)
		}
		return targetRecordID, objectAPIName, nil
	}

	activeCandidates, err := s.approvalSubmitActiveObjectCandidates(ctx, candidates, user)
	if err != nil {
		return "", "", err
	}
	switch len(activeCandidates) {
	case 0:
		return "", "", ErrNoActiveApprovalProcess
	case 1:
		return targetRecordID, activeCandidates[0], nil
	default:
		s.logger.Warn(fmt.Sprintf("Approval submit target %s is ambiguous across active process objects: %v", targetRecordID, activeCandidates))
		return "", "", apperrors.NewValidationError("RecordId", "record ID resolves to multiple objects with active approval processes; provide ObjectApiName")
	}
}

func (s *ApprovalService) approvalSubmitTargetCandidates(ctx context.Context, targetRecordID string) ([]string, error) {
	resolver, ok := s.persistence.(submitTargetObjectCandidateResolver)
	if !ok {
		return nil, apperrors.NewInternalError("approval submit target candidate resolver is not configured", nil)
	}
	candidatesByID, err := resolver.ResolveObjectTypeCandidates(ctx, []string{targetRecordID})
	if err != nil {
		return nil, apperrors.NewInternalError("failed to resolve submit target object candidates", err)
	}
	candidates := candidatesByID[targetRecordID]
	if len(candidates) == 0 {
		return nil, apperrors.NewNotFoundError("record", targetRecordID)
	}
	return candidates, nil
}

func (s *ApprovalService) approvalSubmitActiveObjectCandidates(ctx context.Context, candidates []string, user *models.UserSession) ([]string, error) {
	if s.query == nil {
		return nil, apperrors.NewInternalError("approval process query service is not configured", nil)
	}
	active := make([]string, 0, len(candidates))
	for _, rawCandidate := range candidates {
		candidate := strings.TrimSpace(rawCandidate)
		if candidate == "" {
			continue
		}
		filterExpr := fmt.Sprintf("TableEnumOrId == '%s' && State == 'Active'", escapeApprovalFilterValue(candidate))
		result, err := s.query.QueryWithFilter(ctx, constants.TableProcessDefinition, filterExpr, user, "", "", 1)
		if err != nil {
			return nil, apperrors.NewInternalError("failed to query active approval processes while resolving submit target", err)
		}
		if result != nil && result.TotalSize > 0 && len(result.Records) > 0 {
			active = append(active, candidate)
		}
	}
	return active, nil
}

// ResolveSubmitEligibility resolves the canonical submit target identity,
// selects the active approval process, and proves entry-criteria eligibility
// before Submit mutates approval state.
func (s *ApprovalService) ResolveSubmitEligibility(ctx context.Context, req SubmitRequest, user *models.UserSession) (*SubmitEligibility, error) {
	targetRecordID, objectName, err := s.resolveApprovalSubmitTarget(ctx, req, user)
	if err != nil {
		return nil, err
	}
	targetRecord, err := s.loadSubmitTargetRecord(ctx, objectName, targetRecordID, user)
	if err != nil {
		return nil, err
	}
	return s.resolveSubmitEligibilityAgainstRecord(ctx, req, targetRecordID, objectName, targetRecord, user)
}

// ResolveRuntimeSubmitEligibility marks the initial, user-visible resolution
// for mandatory current-row revalidation after the submit lock set is held.
// The initial pass remains useful for choosing the bounded lock/route plan,
// but Runtime never commits from this pre-lock snapshot alone.
func (s *ApprovalService) ResolveRuntimeSubmitEligibility(ctx context.Context, req SubmitRequest, user *models.UserSession) (*SubmitEligibility, error) {
	eligibility, err := s.resolveSubmitEligibilityForExecution(ctx, req, user)
	if err != nil {
		return nil, err
	}
	eligibility.runtimeRevalidate = true
	return eligibility, nil
}

// ResolveRuntimeSubmitEligibilityWithinObjectScope closes the objectless
// cross-process submit path without restoring the monolith's repository-global
// object scan. The caller supplies the immutable Catalog object closure for
// this request. Every candidate is probed, so Catalog ordering can never choose
// between colliding IDs; the normal user-visible eligibility path remains the
// final authorization and entry-criteria gate.
func (s *ApprovalService) ResolveRuntimeSubmitEligibilityWithinObjectScope(
	ctx context.Context,
	req SubmitRequest,
	objectAPINameScope []string,
	user *models.UserSession,
) (*SubmitEligibility, error) {
	targetRecordID := strings.TrimSpace(req.RecordID)
	if targetRecordID == "" {
		return nil, apperrors.NewValidationError("RecordId", "RecordId is required for Submit")
	}

	seen := make(map[string]struct{}, len(objectAPINameScope))
	canonical := make(map[string]string, len(objectAPINameScope))
	objects := make([]string, 0, len(objectAPINameScope))
	for _, rawObjectAPIName := range objectAPINameScope {
		objectAPIName := strings.TrimSpace(rawObjectAPIName)
		key := strings.ToLower(objectAPIName)
		if objectAPIName == "" || key == "" {
			return nil, apperrors.NewInternalError("runtime approval object scope contains an empty identity", nil)
		}
		if _, duplicate := seen[key]; duplicate {
			continue
		}
		seen[key] = struct{}{}
		canonical[key] = objectAPIName
		objects = append(objects, objectAPIName)
	}
	if len(objects) == 0 {
		return nil, apperrors.NewInternalError("runtime approval object scope is empty", nil)
	}

	if objectHint := strings.TrimSpace(req.ObjectAPIName); objectHint != "" {
		canonicalObjectAPIName, authorized := canonical[strings.ToLower(objectHint)]
		if !authorized {
			// The immutable scope contains every object with an active Approval
			// definition. An explicit object outside it therefore has no active
			// process; report that closed business outcome without probing the
			// record repository or disclosing whether the object/record exists.
			return nil, ErrNoActiveApprovalProcess
		}
		req.ObjectAPIName = canonicalObjectAPIName
		return s.ResolveRuntimeSubmitEligibility(ctx, req, user)
	}

	matches := make([]string, 0, 1)
	for _, objectAPIName := range objects {
		record, err := s.readApprovalSystemFields(ctx, objectAPIName, targetRecordID, nil)
		if err != nil {
			return nil, apperrors.NewInternalError("failed to resolve submit target within runtime approval object scope", err)
		}
		if record != nil {
			matches = append(matches, objectAPIName)
		}
	}

	switch len(matches) {
	case 0:
		return nil, apperrors.NewNotFoundError("record", targetRecordID)
	case 1:
		req.ObjectAPIName = matches[0]
		return s.ResolveRuntimeSubmitEligibility(ctx, req, user)
	default:
		return nil, apperrors.NewValidationError(
			"RecordId",
			"record ID resolves to multiple objects in the authorized approval scope; provide ObjectApiName",
		)
	}
}

func (s *ApprovalService) resolveSubmitEligibilityForExecution(ctx context.Context, req SubmitRequest, user *models.UserSession) (*SubmitEligibility, error) {
	if err := s.checkSubmitPendingConflict(ctx, req, user); err != nil {
		return nil, err
	}
	eligibility, err := s.ResolveSubmitEligibility(ctx, req, user)
	if err == nil {
		return eligibility, nil
	}
	if !errors.Is(err, ErrEntryCriteriaNotMet) && !errors.Is(err, ErrNoActiveApprovalProcess) {
		return nil, err
	}
	// A competing submit may commit after the first pending probe and move the
	// record out of fresh eligibility with its submit-time actions. Reconcile
	// that narrow race before returning the stale eligibility error.
	if pendingErr := s.checkSubmitPendingConflict(ctx, req, user); pendingErr != nil {
		return nil, pendingErr
	}
	return nil, err
}

func (s *ApprovalService) resolveSubmitEligibilityAgainstRecord(
	ctx context.Context,
	req SubmitRequest,
	targetRecordID string,
	objectName string,
	targetRecord models.SObject,
	user *models.UserSession,
) (*SubmitEligibility, error) {
	ctx = withApprovalCrossObjectLookupCache(ctx)
	ctx = withApprovalCriteriaRootReadCache(ctx)
	filterExpr := fmt.Sprintf("TableEnumOrId == '%s' && State == 'Active'", escapeApprovalFilterValue(objectName))
	result, err := s.query.QueryWithFilter(ctx, constants.TableProcessDefinition, filterExpr, user, constants.ColProcessDefinition_ProcessOrder, "ASC", 100)
	if err != nil {
		s.logger.Error("Failed to query ProcessDefinition for "+objectName, err)
		return nil, apperrors.NewInternalError("failed to query active approval processes", err)
	}
	if result == nil || result.TotalSize == 0 || len(result.Records) == 0 {
		return nil, ErrNoActiveApprovalProcess
	}

	var processDef models.SObject
	if req.SkipEntryCriteria {
		processDef = result.Records[0]
	} else {
		// Two evaluation-error classes with different postures (#3913):
		//
		// errCriteriaRecordUnavailable (record-side projection / cross-object
		// lookup failed) ABORTS resolution immediately — process selection is
		// first-match-wins in ProcessOrder, so once any candidate's criteria
		// cannot be evaluated, no later candidate can be proven to be the
		// first match; selecting one would route the record into a possibly
		// wrong process. Before #3913 these failures were swallowed into
		// non-matches that rejected the submit; aborting preserves that
		// submit-rejected outcome with a precise error.
		//
		// Criteria-CONTENT errors (unparseable expressions) keep the
		// long-standing posture: skipped, and a single active process is
		// still allowed — an admin-authored broken expression must not
		// hard-block the only process.
		singleActiveProcess := len(result.Records) == 1
		for _, candidate := range result.Records {
			candidateID, _ := candidate[constants.FieldID].(string)
			entryFilter, _ := candidate["EntryFilter"].(string)
			if strings.TrimSpace(entryFilter) == "" {
				processDef = candidate
				break
			}
			matched, evalErr := s.matchEntryCriteriaAgainstRecord(ctx, entryFilter, objectName, targetRecordID, targetRecord, user)
			if evalErr != nil {
				if errors.Is(evalErr, errCriteriaRecordUnavailable) {
					// Full cause goes to the server log only. The criteria
					// evaluation basis carries system-read values the
					// submitter may not be allowed to see, and for a
					// cross-object criterion the parent record ID in a hop
					// error IS the FLS-hidden FK field's value — cause text
					// must not travel to the client (leakage fence, #3945).
					s.logger.Warn(fmt.Sprintf("Entry criteria record unavailable for process %s, aborting submit resolution (fail-closed): %v", candidateID, evalErr))
					return nil, apperrors.NewInternalError("failed to evaluate approval entry criteria for submit", nil)
				}
				if singleActiveProcess {
					s.logger.Warn("Entry criteria evaluation error, allowing: " + evalErr.Error())
					processDef = candidate
					break
				}
				s.logger.Warn(fmt.Sprintf("Entry criteria evaluation error for process %s, skipping: %v", candidateID, evalErr))
				continue
			}
			if matched {
				processDef = candidate
				break
			}
		}
		if processDef == nil {
			return nil, ErrEntryCriteriaNotMet
		}
	}

	processDefID, _ := processDef[constants.FieldID].(string)
	return &SubmitEligibility{
		TargetRecordID:      targetRecordID,
		ObjectAPIName:       objectName,
		ProcessDefinition:   processDef,
		ProcessDefinitionID: processDefID,
		TargetRecord:        targetRecord,
	}, nil
}
