// Package autonumber implements metadata-driven identifier assignment for NexusCRM.
package autonumber

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

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

// CrossRecordResolver is the minimal abstraction the engine uses to
// walk FK chains and read target fields for `{Object.Field}` tokens.
//
// Implementations own the DB handle(s) — caller txn vs fresh-read —
// and the SF metadata. The engine passes a strategy string derived
// from CrossRecordRef.ReadStrategy; concrete implementations may switch
// to the appropriate handle.
//
// Accept interfaces, return structs — keep this tiny so tests can use
// a map-backed fake.
type CrossRecordResolver interface {
	// LoadTarget returns the row addressed by rootRecord's FK chain.
	// On failure, the returned error wraps one of two sentinels so
	// resolveSingleRef can classify multi-chain outcomes correctly:
	//   - ErrTokenResolve — user-data condition (null FK, missing row).
	//   - ErrChainResolverFailure — infra condition (DB error, wiring bug).
	//
	// `readStrategy` is "caller_txn" (default) or "fresh_read".
	// `rootRecord` is the record being INSERTed — FK reads start
	// from its columns.
	LoadTarget(
		ctx context.Context,
		readStrategy string,
		rootRecord models.SObject,
		chain []CrossRecordStep,
	) (models.SObject, error)
}

// resolveCrossRecords walks each CrossRecordRef against rootRecord and
// returns a map keyed on the token literal (e.g. `{Account.Country}`)
// with the terminal-field value. Called by engine.AssignOne.
//
// Resolution policy (PRD §3.5 + user Q1 fail-closed):
//  1. If `DirectField` is set on the ref AND rootRecord has that
//     column non-empty, use it directly (fast path; mirrors
//     compound_autonumber_listener.go:86).
//  2. Otherwise try each `FkChains` entry in declared order; the
//     first walk that yields a non-empty terminal value wins.
//  3. If NO chain yields a value, return
//     `fmt.Errorf("%w: token %s: no chain resolved", ErrTokenResolve, …)`.
//
// `resolver` may be nil — in that case a request containing any ref
// returns ErrTokenResolve. This matches engine behavior when the
// caller explicitly declines cross-record support (e.g. initial
// bootstrap tests with only counter tokens).
func resolveCrossRecords(
	ctx context.Context,
	resolver CrossRecordResolver,
	rootRecord models.SObject,
	refs []CrossRecordRef,
) (map[string]any, error) {
	if len(refs) == 0 {
		return nil, nil
	}
	out := make(map[string]any, len(refs))
	for _, ref := range refs {
		value, err := resolveSingleRef(ctx, resolver, rootRecord, ref)
		if err != nil {
			return nil, err
		}
		out[ref.TokenLiteral] = value
	}
	return out, nil
}

// resolveSingleRef implements the 3-step resolution policy for one
// token ref. Documented inline in the policy list above.
func resolveSingleRef(
	ctx context.Context,
	resolver CrossRecordResolver,
	rootRecord models.SObject,
	ref CrossRecordRef,
) (any, error) {
	// 1. Direct-field fast path.
	if ref.DirectField != "" {
		if rootRecord != nil {
			if v, ok := rootRecord[ref.DirectField]; ok && !isNilOrEmpty(v) {
				return v, nil
			}
		}
	}

	// 2-3. Chain walks.
	if resolver == nil {
		// Wiring error — programmer / engine init bug, not user data.
		return nil, fmt.Errorf("%w: token %s: no resolver wired", ErrChainResolverFailure, ref.TokenLiteral)
	}
	// Track three outcomes across the chain loop:
	//   - anyLoadSucceeded: at least one chain reached the parent row (terminal
	//     value was null/empty, otherwise we would have early-returned).
	//   - firstInfraErr: first chain that returned ErrChainResolverFailure
	//     (DB / wiring failure). If set, it dominates — the user cannot fix
	//     a DB error by editing the record, so we surface 500 even when
	//     another chain loaded a parent with a null field.
	//
	// Final classification:
	//   - firstInfraErr != nil  → 500 (infra dominates; cannot tell whether
	//                                   the user could have populated a fallback)
	//   - anyLoadSucceeded      → 400 structured (definitively user-recoverable)
	//   - else                  → 500 (no chain reached a parent and no infra err
	//                                   — defensive fail-closed, same as before)
	var (
		anyLoadSucceeded bool
		firstInfraErr    error
	)
	for _, chain := range ref.FkChains {
		if len(chain) == 0 {
			continue
		}
		strategy := ref.ReadStrategy
		if strategy == "" {
			strategy = "caller_txn"
		}
		terminal, err := resolver.LoadTarget(ctx, strategy, rootRecord, chain)
		if err != nil {
			// Distinguish infra failure (DB error / wiring) from user-data
			// failure (null FK / missing row). Only the former dominates the
			// final classification; user-data errors on one chain still allow
			// a sibling chain to provide a value or to trigger the 400 path
			// if its parent row exists with a null field.
			if errors.Is(err, ErrChainResolverFailure) && firstInfraErr == nil {
				firstInfraErr = err
			}
			continue
		}
		anyLoadSucceeded = true
		tfield := chain[len(chain)-1].TargetField
		if tfield == "" {
			continue
		}
		v, ok := lookupFieldCaseInsensitive(terminal, tfield)
		if !ok || isNilOrEmpty(v) {
			continue
		}
		return v, nil
	}

	if firstInfraErr != nil {
		// Infrastructure failure on at least one chain. Even if another chain
		// loaded a parent row with a null field, the infra error means we
		// cannot rule out that the user's data on the failing chain WOULD
		// have yielded a value — so we must surface 500, not a 400 that
		// blames the user.
		return nil, fmt.Errorf("%w: token %s: chain resolver failure: %v",
			ErrChainResolverFailure, ref.TokenLiteral, firstInfraErr)
	}
	if !anyLoadSucceeded {
		// No chain reached a parent row, and no infra error. Either every
		// chain had a null FK / missing row, or the metadata had no usable
		// chains. Fail closed as plain ErrTokenResolve → 500 InternalError;
		// the caller cannot tell whether the user can fix this without more
		// signal, so we do not promise 400.
		return nil, fmt.Errorf("%w: token %s: no chain resolved a non-empty value (null/missing parent)",
			ErrTokenResolve, ref.TokenLiteral)
	}
	// At least one chain loaded the parent row, and no infra error occurred.
	// The target field is null/empty — definitively user-recoverable.
	return nil, NewTokenResolveError(ref.TokenLiteral)
}

// lookupFieldCaseInsensitive reads `want` from `m`, tolerating
// PascalCase ↔ snake_case drift between the format token (e.g.
// `{RecordType.RecordTypeCode}`) and the physical DB column
// (`record_type_code`). Order: exact match → snake_case(want) → case-
// insensitive scan.
func lookupFieldCaseInsensitive(m models.SObject, want string) (any, bool) {
	if v, ok := m[want]; ok {
		return v, true
	}
	if snake := pascalToSnake(want); snake != want {
		if v, ok := m[snake]; ok {
			return v, true
		}
	}
	for k, v := range m {
		if strings.EqualFold(k, want) {
			return v, true
		}
	}
	return nil, false
}

// pascalToSnake lowers a PascalCase-style name into snake_case
// ("RecordTypeCode" → "record_type_code"). Only ASCII letters are
// transformed; digits and other chars pass through unchanged.
func pascalToSnake(s string) string {
	if s == "" {
		return s
	}
	var b strings.Builder
	b.Grow(len(s) + 4)
	for i, r := range s {
		if r >= 'A' && r <= 'Z' {
			if i > 0 {
				b.WriteByte('_')
			}
			b.WriteRune(r + ('a' - 'A'))
			continue
		}
		b.WriteRune(r)
	}
	return b.String()
}

// isNilOrEmpty reports whether v is nil or an empty string / byte slice.
// Matches the listener's fail-closed semantics: `null FK` AND `empty
// string target` are both treated as missing.
func isNilOrEmpty(v any) bool {
	if v == nil {
		return true
	}
	switch t := v.(type) {
	case string:
		return t == ""
	case []byte:
		return len(t) == 0
	}
	return false
}

// resolveCrossRecordsBatch resolves token refs for N records in one
// pass. Implementations MAY optimize by batching multiple rows to the
// same target object into a single IN-query; the default implementation
// loops over resolveCrossRecords for simplicity.
//
// Returns a slice where index i corresponds to records[i]'s resolved
// crossValues map. On any record's failure, returns (nil, err) —
// fail-closed matches the caller txn's abort semantics.
func resolveCrossRecordsBatch(
	ctx context.Context,
	resolver CrossRecordResolver,
	records []models.SObject,
	refs []CrossRecordRef,
) ([]map[string]any, error) {
	if len(refs) == 0 {
		return make([]map[string]any, len(records)), nil
	}
	out := make([]map[string]any, len(records))
	for i, r := range records {
		resolved, err := resolveCrossRecords(ctx, resolver, r, refs)
		if err != nil {
			return nil, fmt.Errorf("record[%d]: %w", i, err)
		}
		out[i] = resolved
	}
	return out, nil
}
