// Package automation — approval_action_log_emit.go
//
// ApprovalService's side of the Class C outcome record (NX#4150): it registers
// a completion hook on the transaction that owns the request, and owns the
// guarantee that recording never changes what it records.
//
// # Why a completion hook and not "emit after the call returns"
//
// The obvious shape — run the transaction, then write a row saying whether it
// failed — is wrong here, and PR #4481 was closed for shipping it.
//
// `backend/internal/runtimeapi/approval_server.go` wraps the whole approval
// engine in an OUTER transaction and keeps working after the engine returns:
// it validates the response, fences permissions, and writes an invocation
// record. Any of those can fail and roll everything back. Meanwhile
// `nexuscrm-records/persistence_transactions.go` documents that the inner call
// "If already in a pessimistic transaction, execute directly" — so on the
// production path ApprovalService never owns a transaction at all, and a nil
// error from it means only that the closure returned nil.
//
// Recording "committed" there would produce a durable row asserting a commit
// that later rolled back. That is worse than the silence it replaces: a
// confident wrong answer in the table built to be the source of truth.
//
// The hook removes the problem rather than defending against it. The collector
// is established by the transaction manager itself
// (transaction_manager.go: WithTransactionCompletionHooks, on both the plain
// and pessimistic entry points) and lives on the transaction context, so a
// hook registered anywhere inside — including inside a nested call that opened
// no transaction of its own — belongs to whichever transaction actually owns
// the boundary, and fires exactly once with that transaction's real terminal
// outcome.
//
// # Where the cause comes from, and when it is legitimately absent
//
// The hook reports the outcome but carries no error, so the cause is read from
// the attempt itself through an accessor the caller sets just before its
// closure returns. The hook fires after that, in the manager's completion path,
// so the value is present by then.
//
// A rolled_back row with NO cause is meaningful rather than incomplete: it says
// the work inside this scope saw no error, so the rollback was decided ABOVE
// it — the outer host failing after the engine returned cleanly. That is
// precisely the shape of the incident this issue was opened for, and being able
// to tell it apart from "the approval itself failed with X" is the point.
//
// # Why the outcome is copied, not derived
//
// The hook reports one of four terminal outcomes and the row stores it
// verbatim. There is deliberately no mapping step: the platform documents that
// "Callers may claim rollback only from TransactionCompletionRolledBack; error
// ancestry is not proof that rollback itself completed", so any code that
// inferred an outcome from an error would be reintroducing exactly the claim
// that documentation forbids.
package automation

import (
	"context"
	"fmt"
	"strings"
	"time"

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

// approvalActionOutcomeRequest is the request identity known BEFORE the
// transaction resolves. It is captured up front precisely because a rollback
// destroys everything written inside the transaction — including the rows this
// identity was read from.
type approvalActionOutcomeRequest struct {
	actionEvent         string
	workItemID          string
	processInstanceID   string
	processDefinitionID string
	targetObjectID      string
	requestedStatus     string
	actor               *models.UserSession
	startedAt           time.Time
}

// registerApprovalActionOutcome arranges for one terminal-outcome row to be
// written when the transaction owning ctx completes.
//
// It returns whether a hook was registered. false means ctx carries no
// completion collector — there is no transaction boundary here to describe —
// and callers use that to record the fact loudly rather than silently skipping
// the row.
//
// The request is taken by POINTER, and the count and cause through closures,
// for one reason: everything they describe is resolved AFTER registration and
// re-resolved on a pessimistic retry. Capturing any of it by value at
// registration time freezes what the first attempt knew onto a row describing
// the last. Recall and Reassign resolve their target record and process
// definition inside the transaction, so by-value capture left those columns
// NULL on every row including successful ones.
func (s *ApprovalService) registerApprovalActionOutcome(
	ctx context.Context,
	req *approvalActionOutcomeRequest,
	materializedActionCount func() *int,
	attemptErr func() error,
	identity func() approvalActionIdentity,
) bool {
	if s == nil || s.actionLog == nil {
		return true // recording disabled; not a missing boundary
	}

	// Detach the hook's write from the request context. The hook runs while the
	// transaction manager is finishing, and the request that triggered it may
	// already be cancelled — which is one of the outcomes being recorded.
	writeCtx := context.WithoutCancel(ctx)

	registered := ports.RegisterTransactionCompletionHook(ctx, func(outcome ports.TransactionCompletionOutcome) {
		var count *int
		if materializedActionCount != nil {
			count = materializedActionCount()
		}
		var cause error
		if attemptErr != nil {
			cause = attemptErr()
		}
		snapshot := *req
		if identity != nil {
			snapshot.applyIdentity(identity())
		}
		s.writeApprovalActionOutcome(writeCtx, snapshot, count, string(outcome), cause)
	})
	return registered
}

// writeApprovalActionOutcome assembles and writes the row, and swallows any
// failure to do so.
//
// # What this owns
//
// Recording an outcome must never change the outcome. This is the single place
// that turns a sink error into a log line and continues; the sink itself still
// returns its error so tests and operators can see breakage. A double failure —
// the action failed AND the recording failed — is logged as such, because that
// combination is when an operator has least to go on.
func (s *ApprovalService) writeApprovalActionOutcome(
	ctx context.Context,
	req approvalActionOutcomeRequest,
	materializedActionCount *int,
	outcome string,
	cause error,
) {
	if s == nil || s.actionLog == nil {
		return
	}

	row := ApprovalActionLogRow{
		ActionEvent:             req.actionEvent,
		WorkItemId:              req.workItemID,
		ProcessInstanceId:       req.processInstanceID,
		ProcessDefinitionId:     req.processDefinitionID,
		TargetObjectId:          req.targetObjectID,
		RequestedStatus:         req.requestedStatus,
		MaterializedActionCount: materializedActionCount,
		Outcome:                 outcome,
		FailureClass:            approvalFailureClassFor(outcome, cause),
		RequestId:               approvalRequestIDFromContext(ctx),
		TraceId:                 approvalTraceIDFromContext(ctx),
	}
	if cause != nil {
		row.FailureCause = cause.Error()
	}
	if req.actor != nil {
		row.ActorId = req.actor.ID
		row.CreatedById = req.actor.ID
	}
	if !req.startedAt.IsZero() {
		row.DurationMs = int(time.Since(req.startedAt).Milliseconds())
	}

	if err := s.actionLog.RecordApprovalAction(ctx, row); err != nil && s.logger != nil {
		// The log line must not claim more than the outcome proves. Only
		// committed and rolled_back are decided; commit_error and
		// rollback_error are AMBIGUOUS - a lost commit acknowledgement may well
		// have committed - and telling an operator the action "did not commit"
		// there could send them to retry an approval that already took effect.
		switch outcome {
		case ApprovalActionOutcomeCommitted:
			s.logger.Error(fmt.Sprintf(
				"approval action outcome not recorded for event=%s workItem=%s; the action itself committed",
				req.actionEvent, req.workItemID), err)
		case ApprovalActionOutcomeRolledBack:
			s.logger.Error(fmt.Sprintf(
				"approval action outcome not recorded for event=%s workItem=%s; the action ALSO rolled back and now has no durable trace",
				req.actionEvent, req.workItemID), err)
		default:
			s.logger.Error(fmt.Sprintf(
				"approval action outcome not recorded for event=%s workItem=%s; the transaction ended AMBIGUOUSLY (outcome=%s, it may or may not have committed) and now has no durable trace - do not retry on the strength of this line alone",
				req.actionEvent, req.workItemID, outcome), err)
		}
	}
}

// approvalFailureClassFor classifies a non-committed outcome.
//
// When the transaction did not commit but the work inside this scope returned
// no error, the cause was decided ABOVE this scope - the host failing after the
// engine returned, or the transaction machinery failing while finishing. The
// approval service structurally cannot see that error: the completion hook
// fires before RunInPessimisticTransaction returns to its caller.
//
// That case is recorded as unattributed rather than left blank, because a blank
// class is indistinguishable from a class nobody set, and this table exists to
// keep "known to be absent" apart from "not recorded".
func approvalFailureClassFor(outcome string, cause error) string {
	if cause != nil {
		return ClassifyApprovalActionFailure(cause)
	}
	if outcome == ApprovalActionOutcomeCommitted {
		return ""
	}
	return ApprovalActionFailureClassUnattributed
}

// noteMissingApprovalBoundary records that a request ran with no transaction
// boundary to describe.
//
// Silently skipping the row would break the contract the table exists for: an
// absent row is supposed to mean the request never reached a boundary, and an
// operator cannot tell that apart from a request whose boundary existed but
// went unhooked. Since there is no outcome to report, the honest surface is a
// loud log rather than a row asserting something unobserved.
func (s *ApprovalService) noteMissingApprovalBoundary(req approvalActionOutcomeRequest) {
	if s == nil || s.logger == nil {
		return
	}
	s.logger.Error(fmt.Sprintf(
		"approval action event=%s workItem=%s ran with no transaction completion collector; its outcome cannot be recorded",
		req.actionEvent, req.workItemID), nil)
}

// approvalActionIdentity carries the identifiers a path resolves INSIDE its
// transaction. The hook reads them when it fires rather than having each path
// publish them into the request.
//
// Publishing was tried and produced the same defect twice: the values were
// pushed in at a convenient point rather than the moment they became known, so
// anything failing in between emitted a row with NULL identity even though the
// values had already been resolved and validated. Submit never published its
// instance id at all. Reading at fire time removes the question - whatever is
// known by then is what the row says, and there is no publish step to forget.
//
// Empty fields are ignored on apply, so a path that resolves only some of them
// leaves the rest as captured before the transaction.
type approvalActionIdentity struct {
	processInstanceID   string
	processDefinitionID string
	targetObjectID      string
}

func (r *approvalActionOutcomeRequest) applyIdentity(id approvalActionIdentity) {
	if id.processInstanceID != "" {
		r.processInstanceID = id.processInstanceID
	}
	if id.processDefinitionID != "" {
		r.processDefinitionID = id.processDefinitionID
	}
	if id.targetObjectID != "" {
		r.targetObjectID = id.targetObjectID
	}
}

// approvalActionCounter accumulates resolved lifecycle actions as they are
// resolved, rather than summing them at one chosen point.
//
// Placement-based counting produced four separate defects in review: a batch
// left out of the sum, a path passing no accessor at all, a batch resolved
// after the sum was taken, and a sum placed after execution so that a failure
// mid-execution recorded "resolution never reached" for actions that had in
// fact resolved. Each fix was correct and the next ordering invented a new way
// to be wrong.
//
// Accumulating removes the question. The counter is correct at every instant
// the hook could fire, so no caller has to reason about where its assignment
// dominates which return.
//
// The nil/zero distinction is preserved: a counter that never saw a batch
// reports nil ("resolution was never reached"), while one that resolved an
// empty batch reports 0 ("resolution ran and found nothing authored").
type approvalActionCounter struct {
	resolved bool
	n        int
}

// add records a resolved batch. Call it immediately after resolution, before
// execution - the row must be able to say "resolved N, then failed".
func (c *approvalActionCounter) add(n int) {
	c.resolved = true
	c.n += n
}

// reset clears the counter for a retried attempt.
func (c *approvalActionCounter) reset() { c.resolved, c.n = false, 0 }

// value is the accessor handed to the completion hook.
func (c *approvalActionCounter) value() *int {
	if !c.resolved {
		return nil
	}
	n := c.n
	return &n
}

// approvalMaterializedActionCount returns a pointer to n. Kept as a helper so
// callsites read as intent — "resolution completed and found n" — and so the
// nil case stays visibly different from the zero case.
func approvalMaterializedActionCount(n int) *int {
	return &n
}

// approvalActionRequestVerb maps a requested terminal status onto the request
// vocabulary. An unrecognised status is passed through rather than defaulted:
// a row naming an unexpected status is evidence, a row relabelled "approve" is
// a lie.
func approvalActionRequestVerb(newStatus string) string {
	switch newStatus {
	case constants.ApprovalStatusApproved:
		return ApprovalActionRequestApprove
	case constants.ApprovalStatusRejected:
		return ApprovalActionRequestReject
	default:
		return newStatus
	}
}

// approvalRequestIDFromContext / approvalTraceIDFromContext read the carriers
// _System_AuditLog is populated from (constants.ContextKeyRequestID, written by
// middleware.RequestID; and the W3C traceparent). They are duplicated here
// rather than imported because nexuscrm-audit's copies are unexported behind a
// separate go.mod — the shared fact being reused is the context key.
//
// Both are best-effort: the columns are nullable, and a missing correlation id
// must never stop the outcome row from being written. A row with no RequestId
// still proves the request reached a boundary; no row at all does not.
func approvalRequestIDFromContext(ctx context.Context) string {
	if ctx == nil {
		return ""
	}
	if v, ok := ctx.Value(constants.ContextKeyRequestID).(string); ok {
		return v
	}
	return ""
}

// approvalTraceIDFromContext extracts the trace-id from the traceparent
// "<version>-<traceid>-<spanid>-<flags>", matching the extraction
// _System_AuditLog.TraceId already uses — including on malformed input, since
// the two must agree for the join they exist for to work.
func approvalTraceIDFromContext(ctx context.Context) string {
	if ctx == nil {
		return ""
	}
	v, ok := ctx.Value(constants.ContextKeyTraceparent).(string)
	if !ok || v == "" {
		return ""
	}
	parts := strings.Split(v, "-")
	if len(parts) < 4 {
		return ""
	}
	return parts[1]
}
