// Package replay implements the cmd/replay runtime: loading captured
// requests from _System_RequestCapture, correlating the audit rows they
// produced in _System_AuditLog, and diffing an "expected" audit trail
// (from the original run) against a "replay" audit trail (from a target
// backend that re-handled the same captured request).
//
// Design reference: docs/audit-log/design.md §14.5 (field-level AuditLog
// equivalence, Tolerant mode by default).
//
// This file contains only the differ — structural comparison between two
// AuditEvent slices. No DB access here; pure function, unit-tested in
// isolation via differ_test.go.
package replay

import (
	"fmt"
	"sort"
	"strings"
)

// AuditRow is the narrow projection of _System_AuditLog columns the replay
// differ compares on. Kept local to this package (rather than importing
// audit.AuditEvent) so the differ stays independent of the write-path
// struct shape — a row comes from SELECT, not from Emit().
type AuditRow struct {
	ObjectApiName string
	RecordId      string
	FieldName     string
	Action        string
	OldValue      string
	NewValue      string
	Entrypoint    string
}

// DiffMode controls comparison strictness (design §14.5).
type DiffMode string

const (
	// DiffModeStrict: byte-identical OldValue/NewValue on every row.
	DiffModeStrict DiffMode = "strict"
	// DiffModeTolerant: ignore Entrypoint inner-detail; everything else
	// must match. Default for refactor-equivalence validation.
	DiffModeTolerant DiffMode = "tolerant"
)

// Difference is a single categorized delta between Expected and Replay.
type Difference struct {
	Kind    string // "missing" | "added" | "field-changed"
	Key     string // ObjectApiName|RecordId|FieldName|Action
	Details string // human-readable explanation
}

// DiffResult is the outcome of comparing two audit trails.
type DiffResult struct {
	Match        bool
	Mode         DiffMode
	Differences  []Difference
	ExpectedSize int
	ReplaySize   int
}

// DiffAuditTrails compares two AuditRow slices and returns a DiffResult.
// Order-insensitive but multiplicity-preserving: rows are grouped by
// (ObjectApiName, RecordId, FieldName, Action), and the two sides must
// agree on BOTH the count and the per-occurrence OldValue/NewValue for a
// given key. A single request can legitimately emit multiple rows with
// the same (Object, RecordId, Field, Action) tuple (e.g. two Owner
// reassignments in a batch or two writes to the same field via different
// code paths); collapsing duplicates here would silently drop rows and
// produce false matches. Within a key, we sort the bucket by (OldValue,
// NewValue) on both sides so reorder alone does not diverge.
func DiffAuditTrails(expected, replay []AuditRow, mode DiffMode) DiffResult {
	expMap := bucketByKey(expected)
	repMap := bucketByKey(replay)

	var diffs []Difference

	// Walk expected; look for missing buckets, count mismatches, or
	// per-occurrence value changes.
	for _, k := range sortedBucketKeys(expMap) {
		expBucket := expMap[k]
		repBucket, ok := repMap[k]
		if !ok {
			diffs = append(diffs, Difference{
				Kind:    "missing",
				Key:     k,
				Details: fmt.Sprintf("expected %d row(s) not produced by replay", len(expBucket)),
			})
			continue
		}
		if len(expBucket) != len(repBucket) {
			diffs = append(diffs, Difference{
				Kind:    "count-mismatch",
				Key:     k,
				Details: fmt.Sprintf("row count differs: expected=%d replay=%d", len(expBucket), len(repBucket)),
			})
			continue
		}
		sortBucket(expBucket)
		sortBucket(repBucket)
		for i := range expBucket {
			if d := compareRow(expBucket[i], repBucket[i], mode); d != "" {
				diffs = append(diffs, Difference{
					Kind:    "field-changed",
					Key:     k,
					Details: d,
				})
			}
		}
	}

	// Walk replay for buckets not in expected.
	for _, k := range sortedBucketKeys(repMap) {
		if _, ok := expMap[k]; !ok {
			repBucket := repMap[k]
			diffs = append(diffs, Difference{
				Kind:    "added",
				Key:     k,
				Details: fmt.Sprintf("replay produced %d row(s) not in expected", len(repBucket)),
			})
		}
	}

	return DiffResult{
		Match:        len(diffs) == 0,
		Mode:         mode,
		Differences:  diffs,
		ExpectedSize: len(expected),
		ReplaySize:   len(replay),
	}
}

// bucketByKey groups rows by composite key, preserving every occurrence so
// the diff can assert count equality. A single (Object, RecordId, Field,
// Action) tuple can legitimately appear multiple times in one request.
func bucketByKey(rows []AuditRow) map[string][]AuditRow {
	m := make(map[string][]AuditRow, len(rows))
	for _, r := range rows {
		k := rowKey(r)
		m[k] = append(m[k], r)
	}
	return m
}

// sortBucket orders a bucket by (OldValue, NewValue, Entrypoint) so the
// two sides can be compared positionally without being order-sensitive at
// the request level.
func sortBucket(b []AuditRow) {
	sort.Slice(b, func(i, j int) bool {
		if b[i].OldValue != b[j].OldValue {
			return b[i].OldValue < b[j].OldValue
		}
		if b[i].NewValue != b[j].NewValue {
			return b[i].NewValue < b[j].NewValue
		}
		return b[i].Entrypoint < b[j].Entrypoint
	})
}

func sortedBucketKeys(m map[string][]AuditRow) []string {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	return keys
}

func rowKey(r AuditRow) string {
	return strings.Join([]string{r.ObjectApiName, r.RecordId, r.FieldName, r.Action}, "|")
}

// compareRow returns a non-empty description if the rows disagree on a
// value we care about given the mode. Entrypoint detail is ignored in
// Tolerant mode per §14.5.
func compareRow(exp, rep AuditRow, mode DiffMode) string {
	if exp.OldValue != rep.OldValue {
		return fmt.Sprintf("OldValue differs: expected=%q replay=%q", exp.OldValue, rep.OldValue)
	}
	if exp.NewValue != rep.NewValue {
		return fmt.Sprintf("NewValue differs: expected=%q replay=%q", exp.NewValue, rep.NewValue)
	}
	if mode == DiffModeStrict && exp.Entrypoint != rep.Entrypoint {
		return fmt.Sprintf("Entrypoint differs (strict): expected=%q replay=%q", exp.Entrypoint, rep.Entrypoint)
	}
	return ""
}
