// Package audit — fls_plan.go implements the §4.1 CH-6 FLS redaction
// invariant for <Object>History queries.
//
// The threat model: an attacker with access to History rows but without FLS
// Read on field F must not be able to probe F's Old/NewValue via a SOQL
// predicate (LIKE, equality, comparison). If the predicate were passed to
// the DB it would leak the value one bit at a time via binary search on
// response size / timing, even though the value never appears in the
// response body.
//
// The redaction has two modes (design §4.1):
//
//  1. Projection on a denied field  -> silently dropped. A suppression
//     predicate `FieldName != F` is added so NO history rows for F
//     surface. The caller never learns F exists in this object's history.
//
//  2. Predicate on a denied field's OldValue / NewValue -> REJECTED at the
//     planner stage with *FieldDeniedByFLSError (HTTP 400,
//     code FIELD_DENIED_BY_FLS). Never 500, never echoing the filter value
//     (echoing would itself be a leak channel).
//
// Metadata predicates (FieldName, ChangedAt, ChangedById) are always allowed
// and do not count as value probes.
//
// C11 will produce HistoryQueryPlan from parsed SOQL. This file is
// parse-free: it operates on the post-parse plan struct only.
package audit

import "fmt"

// Column identifies which History column a FieldRef or Predicate targets.
type Column string

const (
	// ColumnOldValue / ColumnNewValue are the two FLS-gated value columns.
	ColumnOldValue Column = "OldValue"
	ColumnNewValue Column = "NewValue"
	// ColumnFieldName / ColumnChangedAt / ColumnChangedById are metadata
	// columns: not FLS-gated in their own right.
	ColumnFieldName   Column = "FieldName"
	ColumnChangedAt   Column = "ChangedAt"
	ColumnChangedById Column = "ChangedById"
)

// Op is a placeholder for the predicate operator. The redactor only needs
// to know whether a predicate is on a value column — the op itself is not
// inspected for the FLS decision. C11 populates this for downstream use.
type Op string

const (
	OpEqual       Op = "="
	OpNotEqual    Op = "!="
	OpLike        Op = "LIKE"
	OpGreaterThan Op = ">"
	OpLessThan    Op = "<"
)

// PredicateKind classifies what a predicate is probing. The FLS decision
// depends on Kind, not Op.
type PredicateKind int

const (
	// PredicateValue: predicate on a field-value column (Old/NewValue).
	// FLS-denied -> reject.
	PredicateValue PredicateKind = iota
	// PredicateMetadata: predicate on a metadata column (FieldName,
	// ChangedAt, ChangedById). Always allowed.
	PredicateMetadata
	// PredicateSuppressField: planner-injected "FieldName != F" to hide the
	// existence of a denied field. Produced by RedactFieldHistoryPlan.
	PredicateSuppressField
)

// FieldRef identifies a (field, column) pair the caller wants projected.
type FieldRef struct {
	FieldName string // API name of the SObject field; empty for metadata-only
	Column    Column
}

// Predicate is a WHERE-clause fragment the caller has built up.
//
// RawValueRedacted is populated by C11 for downstream use (e.g. parameter
// binding). The redactor does NOT include it in error messages — that
// would itself be an FLS side channel.
type Predicate struct {
	FieldName        string // target field API name (empty for metadata)
	Column           Column
	Op               Op
	Kind             PredicateKind
	RawValueRedacted string // filter literal; NEVER echoed in errors
}

// HistoryQueryPlan is the minimal post-parse shape the redactor operates
// on. C11's SOQL translator produces this; the redactor returns a
// sanitized copy or an error.
type HistoryQueryPlan struct {
	ObjectApiName string
	Projections   []FieldRef
	Predicates    []Predicate
	// DeniedFields is populated by RedactFieldHistoryPlan: the set of
	// fields the caller referenced but lacks FLS Read on. Informational
	// only — callers should not expose this list to end users.
	DeniedFields []string
}

// UserPermissions is the minimal FLS interface the redactor needs.
// Implementations may wrap the project's permission repo / effective-perm
// cache; the redactor is permission-source-agnostic.
type UserPermissions interface {
	HasFieldRead(objectApiName, fieldApiName string) bool
}

// CheckFLSReadAccess returns true iff the caller has FLS Read on
// (objectApiName, fieldApiName). A nil UserPermissions denies everything
// — fail closed.
func CheckFLSReadAccess(perms UserPermissions, objectApiName, fieldApiName string) bool {
	if perms == nil {
		return false
	}
	return perms.HasFieldRead(objectApiName, fieldApiName)
}

// FieldDeniedByFLSError is the typed error returned when the planner
// detects a predicate on a field the caller cannot read. It carries the
// field name (safe to surface — FieldName is FLS-visible per SF semantics)
// and a stable code/HTTP status, but NEVER the user-supplied filter value.
type FieldDeniedByFLSError struct {
	FieldApiName string
}

// Error returns a message containing only the field name. Never the value
// the user tried to filter on (that would be a leak channel).
func (e *FieldDeniedByFLSError) Error() string {
	return fmt.Sprintf("field %s is denied by FLS", e.FieldApiName)
}

// Code returns the stable machine-readable error code clients should switch on.
func (e *FieldDeniedByFLSError) Code() string { return "FIELD_DENIED_BY_FLS" }

// HTTPStatus returns the suggested HTTP status. Explicitly 400: this is a
// client input problem (the client asked for a field it cannot read), not
// a server fault.
func (e *FieldDeniedByFLSError) HTTPStatus() int { return 400 }

// RedactFieldHistoryPlan enforces the §4.1 CH-6 invariant.
//
// Returns a NEW plan; the input is not mutated.
//
// Ordering:
//  1. Validate ObjectApiName is set.
//  2. Scan predicates first — a predicate on a denied field's value column
//     is fatal, so fail fast before any projection mutation. This also
//     means callers get a deterministic error even if the plan has both a
//     droppable projection and a rejectable predicate.
//  3. Walk projections, dropping any whose field lacks FLS read and
//     recording the field in DeniedFields.
//  4. For each denied field, inject a PredicateSuppressField so the DB
//     returns no rows referencing it.
func RedactFieldHistoryPlan(plan HistoryQueryPlan, perms UserPermissions) (HistoryQueryPlan, error) {
	if plan.ObjectApiName == "" {
		return HistoryQueryPlan{}, fmt.Errorf("history query plan: ObjectApiName is required")
	}

	// Step 1: reject predicates on denied value columns.
	for _, p := range plan.Predicates {
		if p.Kind != PredicateValue {
			continue
		}
		if !isValueColumn(p.Column) {
			continue
		}
		if p.FieldName == "" {
			continue
		}
		if CheckFLSReadAccess(perms, plan.ObjectApiName, p.FieldName) {
			continue
		}
		return HistoryQueryPlan{}, &FieldDeniedByFLSError{FieldApiName: p.FieldName}
	}

	// Step 2: build a new plan, dropping projections on denied fields.
	out := HistoryQueryPlan{
		ObjectApiName: plan.ObjectApiName,
		Projections:   make([]FieldRef, 0, len(plan.Projections)),
		Predicates:    make([]Predicate, 0, len(plan.Predicates)+4),
	}
	deniedSet := make(map[string]struct{})
	for _, proj := range plan.Projections {
		if proj.FieldName == "" {
			// Metadata projection — always allowed.
			out.Projections = append(out.Projections, proj)
			continue
		}
		if CheckFLSReadAccess(perms, plan.ObjectApiName, proj.FieldName) {
			out.Projections = append(out.Projections, proj)
			continue
		}
		// Silently drop and remember for suppression.
		deniedSet[proj.FieldName] = struct{}{}
	}

	// Step 3: carry original predicates forward (all already validated).
	out.Predicates = append(out.Predicates, plan.Predicates...)

	// Step 4: emit suppression predicates + DeniedFields (sorted for
	// deterministic output — we iterate plan.Projections in order and only
	// insert each field once).
	seen := make(map[string]struct{}, len(deniedSet))
	for _, proj := range plan.Projections {
		if _, denied := deniedSet[proj.FieldName]; !denied {
			continue
		}
		if _, already := seen[proj.FieldName]; already {
			continue
		}
		seen[proj.FieldName] = struct{}{}
		out.DeniedFields = append(out.DeniedFields, proj.FieldName)
		out.Predicates = append(out.Predicates, Predicate{
			FieldName: proj.FieldName,
			Column:    ColumnFieldName,
			Op:        OpNotEqual,
			Kind:      PredicateSuppressField,
		})
	}

	return out, nil
}

func isValueColumn(c Column) bool {
	return c == ColumnOldValue || c == ColumnNewValue
}
