package metadata

import (
	"context"
	"errors"
	"testing"

	"github.com/nexuscrm/shared/pkg/models"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// fixtureQuoteRule mirrors the seed shape from
// controlplane/packs.IdentifierRuleSeeds. Keeping the JSON-tagged structs
// in lock-step is the whole point of the cache; build a representative
// instance here so the tests catch tag drift the moment it happens.
func fixtureQuoteRule() *models.IdentifierRule {
	return &models.IdentifierRule{
		ID:            "idr_Quote_BusinessId",
		ObjectAPIName: "Quote",
		RuleName:      "Quote Business ID",
		Template:      "{Account.BillingCountryCode}-QT-{YYMMDD}{SEQ:2}",
		TargetFields: map[string]string{
			"prefix": "AutoNumberText__c",
			"seq":    "AutoNumber__c",
			"name":   "Name",
			"code":   "Quote_No__c",
		},
		FieldLookups: map[string]models.IdentifierFieldLookup{
			"Account.BillingCountryCode": {
				DirectField: "Account_Country_Code__c",
				Chains: [][]models.IdentifierLookupStep{
					{
						{FKField: "OpportunityId", TargetTable: "Opportunity", TargetField: "AccountId"},
						{FKField: "AccountId", TargetTable: "Account", TargetField: "BillingCountryCode"},
					},
					{
						{FKField: "AccountId", TargetTable: "Account", TargetField: "BillingCountryCode"},
					},
				},
			},
		},
	}
}

func TestMetadataService_GetIdentifierRule(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name        string
		setup       func(repo *mockMetadataRepo)
		object      string
		wantRuleID  string // "" means expect (nil, nil)
		wantErr     bool
		wantQueries int // expected number of GetAllIdentifierRules round-trips after the call
	}{
		{
			name: "cache miss then hit returns the active rule",
			setup: func(repo *mockMetadataRepo) {
				repo.identifierRules = []*models.IdentifierRule{fixtureQuoteRule()}
			},
			object:      "Quote",
			wantRuleID:  "idr_Quote_BusinessId",
			wantQueries: 1,
		},
		{
			name: "case-insensitive lookup matches lowercased seed",
			setup: func(repo *mockMetadataRepo) {
				repo.identifierRules = []*models.IdentifierRule{fixtureQuoteRule()}
			},
			object:      "quote",
			wantRuleID:  "idr_Quote_BusinessId",
			wantQueries: 1,
		},
		{
			name: "object with no rule returns nil without error",
			setup: func(repo *mockMetadataRepo) {
				repo.identifierRules = []*models.IdentifierRule{fixtureQuoteRule()}
			},
			object:      "Account",
			wantRuleID:  "",
			wantQueries: 1,
		},
		{
			name: "inactive rule is filtered out at the repo layer",
			setup: func(repo *mockMetadataRepo) {
				repo.identifierRules = []*models.IdentifierRule{fixtureQuoteRule()}
				repo.inactiveIdentifierRules["idr_Quote_BusinessId"] = true
			},
			object:      "Quote",
			wantRuleID:  "",
			wantQueries: 1,
		},
		{
			name:        "empty repo returns nil for any object",
			setup:       func(repo *mockMetadataRepo) {},
			object:      "Quote",
			wantRuleID:  "",
			wantQueries: 1,
		},
	}

	for _, tc := range tests {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			ms, repo, _, _ := newTestService()
			tc.setup(repo)

			got, err := ms.GetIdentifierRule(context.Background(), tc.object)
			if tc.wantErr {
				require.Error(t, err)
				return
			}
			require.NoError(t, err)
			if tc.wantRuleID == "" {
				assert.Nil(t, got, "expected no rule but got %+v", got)
			} else {
				require.NotNil(t, got)
				assert.Equal(t, tc.wantRuleID, got.ID)
			}
			assert.Equal(t, tc.wantQueries, repo.identifierRuleQueryCount,
				"unexpected number of repo calls; cache should hide repeated reads")
		})
	}
}

func TestMetadataService_GetIdentifierRule_CacheServesRepeatedCalls(t *testing.T) {
	t.Parallel()
	ms, repo, _, _ := newTestService()
	repo.identifierRules = []*models.IdentifierRule{fixtureQuoteRule()}

	const calls = 5
	for i := 0; i < calls; i++ {
		got, err := ms.GetIdentifierRule(context.Background(), "Quote")
		require.NoError(t, err)
		require.NotNil(t, got)
		assert.Equal(t, "idr_Quote_BusinessId", got.ID)
	}

	assert.Equal(t, 1, repo.identifierRuleQueryCount,
		"GetAllIdentifierRules must be called exactly once even across repeated reads")
}

func TestMetadataService_GetIdentifierRule_InvalidationReloadsFromRepo(t *testing.T) {
	t.Parallel()
	ms, repo, _, _ := newTestService()
	repo.identifierRules = []*models.IdentifierRule{fixtureQuoteRule()}

	// Prime the cache.
	_, err := ms.GetIdentifierRule(context.Background(), "Quote")
	require.NoError(t, err)
	require.Equal(t, 1, repo.identifierRuleQueryCount)

	// Mutate the underlying data and invalidate. Next read must observe the
	// new state, not the stale cache.
	updated := fixtureQuoteRule()
	updated.Template = "UPDATED-{SEQ:2}"
	repo.identifierRules = []*models.IdentifierRule{updated}
	ms.InvalidateCache()

	got, err := ms.GetIdentifierRule(context.Background(), "Quote")
	require.NoError(t, err)
	require.NotNil(t, got)
	assert.Equal(t, "UPDATED-{SEQ:2}", got.Template, "post-invalidate read must reflect new data")
	assert.Equal(t, 2, repo.identifierRuleQueryCount,
		"GetAllIdentifierRules must be called again after InvalidateCache")
}

func TestMetadataService_GetIdentifierRule_PreservesJSONFields(t *testing.T) {
	t.Parallel()
	ms, repo, _, _ := newTestService()
	repo.identifierRules = []*models.IdentifierRule{fixtureQuoteRule()}

	got, err := ms.GetIdentifierRule(context.Background(), "Quote")
	require.NoError(t, err)
	require.NotNil(t, got)

	// TargetFields preserved through the cache (JSON-decoded map).
	assert.Equal(t, "AutoNumberText__c", got.TargetFields["prefix"])
	assert.Equal(t, "AutoNumber__c", got.TargetFields["seq"])
	assert.Equal(t, "Name", got.TargetFields["name"])
	assert.Equal(t, "Quote_No__c", got.TargetFields["code"])

	// FieldLookups preserved (DirectField + Chains).
	lookup, ok := got.FieldLookups["Account.BillingCountryCode"]
	require.True(t, ok, "expected lookup for Account.BillingCountryCode")
	assert.Equal(t, "Account_Country_Code__c", lookup.DirectField)
	require.Len(t, lookup.Chains, 2, "both FK chains must survive the cache round-trip")
	assert.Equal(t, "OpportunityId", lookup.Chains[0][0].FKField)
	assert.Equal(t, "Opportunity", lookup.Chains[0][0].TargetTable)
	assert.Equal(t, "AccountId", lookup.Chains[0][0].TargetField)
	assert.Equal(t, "AccountId", lookup.Chains[1][0].FKField)
	assert.Equal(t, "Account", lookup.Chains[1][0].TargetTable)
	assert.Equal(t, "BillingCountryCode", lookup.Chains[1][0].TargetField)
}

func TestMetadataService_GetIdentifierRule_RepoErrorYieldsEmptyCache(t *testing.T) {
	t.Parallel()
	ms, repo, _, _ := newTestService()
	repo.getAllIdentifierRulesErr = errors.New("simulated db outage")

	// buildAndSwapCache logs but does not fail when GetAllIdentifierRules
	// errors (matches the AutoNumber/ValidationRules pattern). The cache is
	// still published, just without identifier rules — callers see (nil, nil).
	got, err := ms.GetIdentifierRule(context.Background(), "Quote")
	require.NoError(t, err)
	assert.Nil(t, got, "rule lookup must degrade gracefully when repo errors during cache warm")
}

func TestMetadataService_GetIdentifierRule_MultipleObjects(t *testing.T) {
	t.Parallel()
	ms, repo, _, _ := newTestService()
	repo.identifierRules = []*models.IdentifierRule{
		fixtureQuoteRule(),
		{
			ID:            "idr_Order_BusinessId",
			ObjectAPIName: "Order",
			RuleName:      "Order Business ID",
			Template:      "{Account.BillingCountryCode}-ORD-{YYMMDD}{SEQ:2}",
			TargetFields:  map[string]string{"code": "Order_Number__c"},
		},
	}

	quote, err := ms.GetIdentifierRule(context.Background(), "Quote")
	require.NoError(t, err)
	require.NotNil(t, quote)
	assert.Equal(t, "idr_Quote_BusinessId", quote.ID)

	order, err := ms.GetIdentifierRule(context.Background(), "Order")
	require.NoError(t, err)
	require.NotNil(t, order)
	assert.Equal(t, "idr_Order_BusinessId", order.ID)

	// Two reads, one repo round-trip — both objects share the same cache snapshot.
	assert.Equal(t, 1, repo.identifierRuleQueryCount)
}
