// Package transport — idempotency.go implements the X-Orca-Idempotency-Key
// header for cross-node dispatch (REQ-037). The dedupe store is a
// in-memory map with a TTL window; persistent dedupe across daemon
// restarts is out of scope for v0.2 (the bin-packing scheduler is
// single-daemon for now; the dedupe window just covers in-flight retries).
package transport

import (
	"context"
	"errors"
	"sync"
	"time"
)

const (
	// IdempotencyHeader is the canonical header name. Casing-insensitive
	// per HTTP spec, but we keep the canonical form for log clarity.
	IdempotencyHeader = "X-Orca-Idempotency-Key"
	// DedupeWindow is how long an idempotency key is honored after
	// first use. Tuned for the in-flight retry window: a transient
	// dispatch error followed by an exponential-backoff retry (max 5
	// attempts with cap 5s) completes well within 60s. The dedupe
	// window is 5 minutes to cover cases where a peer processes a
	// request but the response is lost on the wire.
	DedupeWindow = 5 * time.Minute
)

// dedupeEntry is a single (key -> response) record with expiry.
type dedupeEntry struct {
	key       string
	jobID     string
	expiresAt time.Time
}

// IdempotencyStore is a thread-safe in-memory dedupe map. Keys are
// scoped per-process; a restart drops the map. For P02 this is
// sufficient because the dispatcher is single-instance.
type IdempotencyStore struct {
	mu      sync.Mutex
	entries map[string]dedupeEntry
}

// NewIdempotencyStore returns an empty store.
func NewIdempotencyStore() *IdempotencyStore {
	return &IdempotencyStore{entries: make(map[string]dedupeEntry)}
}

// Get returns the recorded jobID for key, or "" if no entry is present
// (or the entry is expired). The second return is true if a live
// (non-expired) entry was found.
func (s *IdempotencyStore) Get(key string) (string, bool) {
	if key == "" {
		return "", false
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	e, ok := s.entries[key]
	if !ok {
		return "", false
	}
	if time.Now().After(e.expiresAt) {
		delete(s.entries, key)
		return "", false
	}
	return e.jobID, true
}

// Put records (key -> jobID) with a default expiry of DedupeWindow.
// Overwrites any prior entry (rare in practice since we check Get first).
func (s *IdempotencyStore) Put(key, jobID string) {
	if key == "" || jobID == "" {
		return
	}
	s.mu.Lock()
	s.entries[key] = dedupeEntry{
		key:       key,
		jobID:     jobID,
		expiresAt: time.Now().Add(DedupeWindow),
	}
	s.mu.Unlock()
}

// Sweep removes all expired entries. Called periodically by the dispatch
// service; safe to call concurrently.
func (s *IdempotencyStore) Sweep() {
	now := time.Now()
	s.mu.Lock()
	for k, e := range s.entries {
		if now.After(e.expiresAt) {
			delete(s.entries, k)
		}
	}
	s.mu.Unlock()
}

// ErrIdempotencyKeyRequired is returned by retry helpers when a
// non-idempotent call (e.g., POST) is retried without an idempotency
// key. Matches REQ-037's "absent header + transient error → no retry".
var ErrIdempotencyKeyRequired = errors.New("retry requires X-Orca-Idempotency-Key header")

// HeaderFromContext extracts the X-Orca-Idempotency-Key from a
// request-scoped context, if any. The dispatcher stores the key on
// the context via WithIdempotencyKey so downstream layers can read it
// without parsing headers.
type idempotencyKey struct{}

// WithIdempotencyKey attaches an idempotency key to ctx.
func WithIdempotencyKey(ctx context.Context, key string) context.Context {
	if key == "" {
		return ctx
	}
	return context.WithValue(ctx, idempotencyKey{}, key)
}

// IdempotencyKeyFromContext returns the key attached to ctx, or "".
func IdempotencyKeyFromContext(ctx context.Context) string {
	if v := ctx.Value(idempotencyKey{}); v != nil {
		if s, ok := v.(string); ok {
			return s
		}
	}
	return ""
}
