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

import (
	"bytes"
	"encoding/json"
	"math/rand"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// canonJ1Iterations is the fuzz count for the J1 property tests
// (PRD §10.1 case J1). 1000 covers the round-trip + determinism +
// preservation assertions without making the unit suite slow.
const canonJ1Iterations = 1000

// TestCanonicalize_J1_RoundTripIdempotence covers PRD §10.1 case J1
// main assertion: `parse(canon(input)) == parse(input)` for any
// valid token_refs-shaped input.
//
// Stronger form actually required by §4.1 docblock:
//
//	CanonicalizeTokenRefs(decode(CanonicalizeTokenRefs(x))) ==
//	    CanonicalizeTokenRefs(x)
//
// i.e. canonicalizing, decoding, and re-canonicalizing must produce
// byte-identical output.
//
// Wave 1: CanonicalizeTokenRefs returns errNotImplemented, so every
// iteration of this property fails RED.
func TestCanonicalize_J1_RoundTripIdempotence(t *testing.T) {
	t.Parallel()

	rng := rand.New(rand.NewSource(1)) // fixed seed for reproducibility

	for i := 0; i < canonJ1Iterations; i++ {
		input := generateTokenRefsFixture(rng)

		first, err := CanonicalizeTokenRefs(input)
		require.NoErrorf(t, err, "iteration %d: first canonicalize failed", i)

		var decoded any
		require.NoErrorf(t, json.Unmarshal(first, &decoded),
			"iteration %d: canonical output must be valid JSON", i)

		second, err := CanonicalizeTokenRefs(decoded)
		require.NoErrorf(t, err, "iteration %d: second canonicalize failed", i)

		require.Truef(t, bytes.Equal(first, second),
			"iteration %d: round-trip produced different canonical bytes:\n first=%s\nsecond=%s",
			i, first, second)
	}
}

// TestCanonicalize_J1_Determinism covers the F12 fixpack sub-assertion
// of PRD §10.1 case J1: `canon(x) == canon(x)` across 1000 independent
// invocations against the *same* input. This catches Go map-iteration
// randomization leaking into the output, which plain round-trip tests
// miss (they canonicalize once and compare the decode path).
//
// The input is deliberately a `map[string]any` so that naive
// `json.Marshal` output would be non-deterministic; the canonicalizer
// must serialize maps with sorted keys.
func TestCanonicalize_J1_Determinism(t *testing.T) {
	t.Parallel()

	// Construct an input with broad, map-iteration-sensitive shape:
	// a top-level array of maps with overlapping key sets, nested
	// object values, and varied scalar types.
	input := []any{
		map[string]any{
			"tokenLiteral": "{Account.BillingCountryCode}",
			"targetObject": "Account",
			"targetField":  "BillingCountryCode",
			"readStrategy": "caller_txn",
			"directField":  "Account_Country_Code__c",
			"fkChains": []any{
				[]any{
					map[string]any{
						"fk":           "AccountId",
						"targetField":  "BillingCountryCode",
						"targetObject": "Account",
					},
				},
				[]any{
					map[string]any{
						"fk":           "OpportunityId",
						"targetField":  "AccountId",
						"targetObject": "Opportunity",
					},
					map[string]any{
						"fk":           "AccountId",
						"targetField":  "BillingCountryCode",
						"targetObject": "Account",
					},
				},
			},
		},
		map[string]any{
			"tokenLiteral": "{RecordType.RecordTypeCode}",
			"targetObject": "RecordType",
			"targetField":  "RecordTypeCode",
			"readStrategy": "caller_txn",
			"fkChains": []any{
				[]any{
					map[string]any{
						"fk":           "RecordTypeId",
						"targetField":  "RecordTypeCode",
						"targetObject": "RecordType",
					},
				},
			},
		},
	}

	first, err := CanonicalizeTokenRefs(input)
	require.NoError(t, err, "baseline canonicalize failed")

	for i := 0; i < canonJ1Iterations; i++ {
		got, err := CanonicalizeTokenRefs(input)
		require.NoErrorf(t, err, "iteration %d failed", i)
		require.Truef(t, bytes.Equal(first, got),
			"iteration %d: canon(x) != canon(x) (map iteration leaked into output?)\nfirst=%s\n  got=%s",
			i, first, got)
	}
}

// TestCanonicalize_PreservesArrayOrder — arrays must preserve source
// order; only map keys are sorted (PRD §4.1 canonicalization rules).
// Swapping two entries in an input slice must produce distinct
// canonical output.
func TestCanonicalize_PreservesArrayOrder(t *testing.T) {
	t.Parallel()

	orderA := []any{
		map[string]any{"tokenLiteral": "{A.X}"},
		map[string]any{"tokenLiteral": "{B.Y}"},
	}
	orderB := []any{
		map[string]any{"tokenLiteral": "{B.Y}"},
		map[string]any{"tokenLiteral": "{A.X}"},
	}

	canonA, errA := CanonicalizeTokenRefs(orderA)
	require.NoError(t, errA)
	canonB, errB := CanonicalizeTokenRefs(orderB)
	require.NoError(t, errB)

	assert.False(t, bytes.Equal(canonA, canonB),
		"array order must be preserved: reordered input must produce distinct canonical bytes")
}

// TestCanonicalize_SortsMapKeys — inputs that differ only in the
// author's key-ordering must produce byte-identical canonical output.
func TestCanonicalize_SortsMapKeys(t *testing.T) {
	t.Parallel()

	// Two maps with the same keys+values written in opposite order.
	a := map[string]any{
		"tokenLiteral": "{Account.BillingCountryCode}",
		"targetObject": "Account",
		"targetField":  "BillingCountryCode",
		"readStrategy": "caller_txn",
	}
	b := map[string]any{
		"readStrategy": "caller_txn",
		"targetField":  "BillingCountryCode",
		"targetObject": "Account",
		"tokenLiteral": "{Account.BillingCountryCode}",
	}

	canonA, err := CanonicalizeTokenRefs(a)
	require.NoError(t, err)
	canonB, err := CanonicalizeTokenRefs(b)
	require.NoError(t, err)

	assert.True(t, bytes.Equal(canonA, canonB),
		"map-key ordering must not influence canonical output:\n a=%s\n b=%s",
		canonA, canonB)
}

// TestCanonicalize_NoExtraneousWhitespace — the canonical form must use
// no whitespace (compact JSON), per PRD §4.1 rule (c).
func TestCanonicalize_NoExtraneousWhitespace(t *testing.T) {
	t.Parallel()

	input := []any{
		map[string]any{
			"tokenLiteral": "{Account.BillingCountryCode}",
			"targetObject": "Account",
		},
	}
	got, err := CanonicalizeTokenRefs(input)
	require.NoError(t, err)

	for _, b := range got {
		assert.NotContains(t, " \t\n\r", string(b),
			"canonical output must not contain whitespace bytes, got %q", got)
	}
}

// --- fixture generator ---

// generateTokenRefsFixture produces a synthetic token_refs-shaped
// value suitable for fuzzing the canonicalizer. The generator covers
// the documented shape in PRD §4.1 plus edge-case scalar types so the
// property test exercises the full canonicalization surface.
func generateTokenRefsFixture(rng *rand.Rand) any {
	n := rng.Intn(3) + 1 // 1..3 entries
	arr := make([]any, 0, n)
	for i := 0; i < n; i++ {
		entry := map[string]any{
			"tokenLiteral": randomTokenLiteral(rng),
			"targetObject": randomIdent(rng, 1+rng.Intn(8)),
			"targetField":  randomIdent(rng, 1+rng.Intn(12)),
			"readStrategy": pickOne(rng, []string{"caller_txn", "fresh_read"}),
			"fkChains":     randomFkChains(rng),
		}
		// Optional fast-path.
		if rng.Intn(2) == 0 {
			entry["directField"] = randomIdent(rng, 1+rng.Intn(20))
		}
		arr = append(arr, entry)
	}
	return arr
}

func randomFkChains(rng *rand.Rand) []any {
	nChains := rng.Intn(2) + 1
	chains := make([]any, 0, nChains)
	for i := 0; i < nChains; i++ {
		// Depth 1 or 2 only (PRD §1.2 non-goal cap).
		depth := rng.Intn(2) + 1
		chain := make([]any, 0, depth)
		for j := 0; j < depth; j++ {
			chain = append(chain, map[string]any{
				"fk":           randomIdent(rng, 1+rng.Intn(10)) + "Id",
				"targetField":  randomIdent(rng, 1+rng.Intn(10)),
				"targetObject": randomIdent(rng, 1+rng.Intn(10)),
			})
		}
		chains = append(chains, chain)
	}
	return chains
}

func randomTokenLiteral(rng *rand.Rand) string {
	return "{" + randomIdent(rng, 1+rng.Intn(8)) + "." + randomIdent(rng, 1+rng.Intn(12)) + "}"
}

const identAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_"

func randomIdent(rng *rand.Rand, n int) string {
	if n <= 0 {
		n = 1
	}
	b := make([]byte, n)
	for i := 0; i < n; i++ {
		b[i] = identAlphabet[rng.Intn(len(identAlphabet))]
	}
	return string(b)
}

func pickOne(rng *rand.Rand, opts []string) string {
	return opts[rng.Intn(len(opts))]
}
