package analytics

import (
	"context"
	"errors"
	"strings"
	"testing"

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

// --- mock CRTRepository ---------------------------------------------------

type mockCRTRepo struct {
	row      *models.SystemReportType
	err      error
	seenName string
}

func (m *mockCRTRepo) LoadByFullName(ctx context.Context, fullName string, user *models.UserSession) (*models.SystemReportType, error) {
	m.seenName = fullName
	if m.err != nil {
		return nil, m.err
	}
	if m.row == nil {
		return nil, pkgerrors.NewNotFoundError("CRT", fullName)
	}
	return m.row, nil
}

func (m *mockCRTRepo) LoadByID(ctx context.Context, id string, user *models.UserSession) (*models.SystemReportType, error) {
	return nil, pkgerrors.NewNotFoundError("CRT", id)
}

// buildStdResolver constructs a StandardObjectResolver that recognises the
// given API names under both the canonical and "<PascalCase>List" alias.
func buildStdResolver(names ...string) *StandardObjectResolver {
	r := &StandardObjectResolver{
		canonical: map[string]string{},
		aliases:   map[string]string{},
	}
	for _, n := range names {
		registerStandardObject(r, n)
	}
	return r
}

func runResolve(t *testing.T, r *crtResolver, src string) (*JoinTemplate, []CRTWarning, error) {
	t.Helper()
	return r.Resolve(context.Background(), src, &models.UserSession{ID: "u1"})
}

// --- R1: unknown SourceObject ---------------------------------------------

func TestCRTResolver_R1_UnknownSourceObject(t *testing.T) {
	md := newFakeMetadata()
	r := newCRTResolverForTest(&mockCRTRepo{}, md, buildStdResolver(), nil)
	tmpl, warns, err := runResolve(t, r, "Gibberish")
	assert.Nil(t, tmpl)
	assert.Nil(t, warns)
	assert.NoError(t, err)
}

// --- R2: standard object hit ----------------------------------------------

func TestCRTResolver_R2_StandardObjectShortCircuit(t *testing.T) {
	md := newFakeMetadata()
	md.addObject("", "Opportunity", "Id")
	r := newCRTResolverForTest(&mockCRTRepo{}, md, buildStdResolver("Opportunity"), nil)
	// OpportunityList is the auto-registered PascalCaseList alias.
	tmpl, warns, err := runResolve(t, r, "OpportunityList")
	assert.Nil(t, tmpl)
	assert.Nil(t, warns)
	assert.NoError(t, err)
}

// --- R3: existing hardcoded join template ---------------------------------

func TestCRTResolver_R3_HardcodedJoinTemplateShortCircuit(t *testing.T) {
	// Choose a SourceObject we can confirm LookupJoinTemplate returns non-nil
	// for. We probe the real map so the test remains correct if the legacy
	// fast-path list changes.
	var known string
	for _, name := range []string{"CampaignWithCampaignMembers", "OpportunityProductReport"} {
		if LookupJoinTemplate(name) != nil {
			known = name
			break
		}
	}
	if known == "" {
		t.Skip("no hardcoded JoinTemplate keys available to exercise R3")
	}
	md := newFakeMetadata()
	r := newCRTResolverForTest(&mockCRTRepo{}, md, buildStdResolver(), nil)
	tmpl, warns, err := runResolve(t, r, known)
	assert.Nil(t, tmpl)
	assert.Nil(t, warns)
	assert.NoError(t, err)
}

// --- R4: _System_ReportType hit by exact FullName -------------------------

func TestCRTResolver_R4_CRTLoaderHit(t *testing.T) {
	md := newFakeMetadata()
	md.addObject("", "Campaign", "Id", "Name")
	md.addObject("Campaign", "CampaignMember", "Id", "CampaignId->Campaign:CampaignMembers")

	repo := &mockCRTRepo{row: &models.SystemReportType{
		FullName:   "My_Report",
		BaseObject: "Campaign",
		Join:       `{"outerJoin":"false","relationship":"CampaignMembers"}`,
		Sections:   `[{"columns":[{"field":"Name","table":"Campaign"}]}]`,
		Deployed:   true,
	}}
	r := newCRTResolverForTest(repo, md, buildStdResolver(), nil)
	tmpl, warns, err := runResolve(t, r, "My_Report")
	require.NoError(t, err)
	require.NotNil(t, tmpl)
	assert.Equal(t, "campaign", tmpl.PrimaryTable)
	assert.NotNil(t, warns, "warnings slice must be non-nil when tmpl is non-nil")
}

// --- R5: _System_ReportType hit after trimming __c ------------------------

func TestCRTResolver_R5_CRTLoaderHitWithTrimmedSuffix(t *testing.T) {
	// We use the real loader trim behavior indirectly — the mock returns the
	// row regardless of seen name, but we verify the resolver passed through
	// the original SourceObject including the "__c" suffix.
	md := newFakeMetadata()
	md.addObject("", "Campaign", "Id", "Name")

	repo := &mockCRTRepo{row: &models.SystemReportType{
		FullName:   "Trimmed",
		BaseObject: "Campaign",
	}}
	r := newCRTResolverForTest(repo, md, buildStdResolver(), nil)
	tmpl, _, err := runResolve(t, r, "Some_CRT__c")
	require.NoError(t, err)
	require.NotNil(t, tmpl)
	assert.Equal(t, "Some_CRT__c", repo.seenName,
		"resolver must hand the raw SourceObject to the loader; trim logic lives in the loader")
}

// --- R6: not found + shapeUnknown -----------------------------------------

func TestCRTResolver_R6_NotFoundUnknownShape(t *testing.T) {
	md := newFakeMetadata()
	r := newCRTResolverForTest(&mockCRTRepo{}, md, buildStdResolver(), nil)
	tmpl, warns, err := runResolve(t, r, "SomethingRandom_WithNoShape")
	assert.Nil(t, tmpl)
	assert.Nil(t, warns)
	assert.NoError(t, err)
}

// --- R7: shapeCustomEntity + base exists ----------------------------------

func TestCRTResolver_R7_SyntheticCustomEntityBaseOnly(t *testing.T) {
	md := newFakeMetadata()
	md.addObject("", "PoC__c", "Id", "Name")
	r := newCRTResolverForTest(&mockCRTRepo{}, md, buildStdResolver(), nil)
	tmpl, warns, err := runResolve(t, r, "CustomEntity$PoC__c")
	require.NoError(t, err)
	require.NotNil(t, tmpl)
	assert.Equal(t, "poc__c", tmpl.PrimaryTable)
	assert.Empty(t, tmpl.Joins)
	assert.Empty(t, warns)
}

// --- R8: shapeCustomEntity + base missing — C4 ----------------------------

func TestCRTResolver_R8_SyntheticBaseMissing(t *testing.T) {
	md := newFakeMetadata()
	r := newCRTResolverForTest(&mockCRTRepo{}, md, buildStdResolver(), nil)
	tmpl, warns, err := runResolve(t, r, "CustomEntity$Gone__c")
	assert.Nil(t, tmpl)
	assert.Nil(t, warns)
	assert.NoError(t, err)
}

// --- R9: shapeCustomEntityScoped full join — C1 ---------------------------

func TestCRTResolver_R9_SyntheticScopedTwoTable(t *testing.T) {
	md := newFakeMetadata()
	md.addObject("", "PoC__c", "Id", "Name", "Opportunity__c->Opportunity:Opp_rel")
	md.addObject("", "Opportunity", "Id", "Name")
	r := newCRTResolverForTest(&mockCRTRepo{}, md, buildStdResolver(), nil)
	tmpl, warns, err := runResolve(t, r, "CustomEntity$PoC__c@Opportunity.Opportunity__c")
	require.NoError(t, err)
	require.NotNil(t, tmpl)
	assert.Empty(t, warns)
	require.Len(t, tmpl.Joins, 1)
	j := tmpl.Joins[0]
	assert.Equal(t, "opportunity", j.Table)
	assert.Equal(t, "LEFT JOIN", j.JoinType)
	assert.Equal(t, "p.Opportunity__c", j.OnLeft)
	assert.Equal(t, j.Alias+".Id", j.OnRight)
}

// --- R10: shapeCustomEntityScoped + anchor obj missing — C3 ---------------

func TestCRTResolver_R10_ScopedAnchorObjectMissing(t *testing.T) {
	md := newFakeMetadata()
	md.addObject("", "PoC__c", "Id", "Name", "Opportunity__c->Opportunity:Opp_rel")
	// Opportunity deliberately absent.
	r := newCRTResolverForTest(&mockCRTRepo{}, md, buildStdResolver(), nil)
	tmpl, warns, err := runResolve(t, r, "CustomEntity$PoC__c@Opportunity.Opportunity__c")
	require.NoError(t, err)
	require.NotNil(t, tmpl)
	assert.Empty(t, tmpl.Joins)
	require.Len(t, warns, 1)
	assert.Equal(t, WarnCRTAnchorObjectMissing, warns[0].Code)
}

// --- R11: shapeCustomEntityScoped + FK column missing — C2 ---------------

func TestCRTResolver_R11_ScopedAnchorFKMissing(t *testing.T) {
	md := newFakeMetadata()
	// PoC__c has no Opportunity__c field.
	md.addObject("", "PoC__c", "Id", "Name")
	md.addObject("", "Opportunity", "Id", "Name")
	r := newCRTResolverForTest(&mockCRTRepo{}, md, buildStdResolver(), nil)
	tmpl, warns, err := runResolve(t, r, "CustomEntity$PoC__c@Opportunity.Opportunity__c")
	require.NoError(t, err)
	require.NotNil(t, tmpl)
	assert.Empty(t, tmpl.Joins)
	require.Len(t, warns, 1)
	assert.Equal(t, WarnCRTAnchorFKMissing, warns[0].Code)
}

// --- R12: shapeNestedCustomEntity + base exists — C5 ----------------------

func TestCRTResolver_R12_NestedBaseOnly(t *testing.T) {
	md := newFakeMetadata()
	md.addObject("", "A__c", "Id")
	r := newCRTResolverForTest(&mockCRTRepo{}, md, buildStdResolver(), nil)
	tmpl, warns, err := runResolve(t, r, "CustomEntityCustomEntity$A__c$B__c")
	require.NoError(t, err)
	require.NotNil(t, tmpl)
	assert.Empty(t, tmpl.Joins)
	require.Len(t, warns, 1)
	assert.Equal(t, WarnCRTNestedUnsupported, warns[0].Code)
}

// --- R13: shapeNestedCustomEntity + base missing — C6 --------------------

func TestCRTResolver_R13_NestedBaseMissing(t *testing.T) {
	md := newFakeMetadata()
	r := newCRTResolverForTest(&mockCRTRepo{}, md, buildStdResolver(), nil)
	tmpl, warns, err := runResolve(t, r, "CustomEntityCustomEntity$Missing__c$Also__c")
	assert.Nil(t, tmpl)
	assert.Nil(t, warns)
	assert.NoError(t, err)
}

// --- R14: loader DB error (non-not-found) ---------------------------------

func TestCRTResolver_R14_LoaderError(t *testing.T) {
	md := newFakeMetadata()
	boom := errors.New("tidb is drunk")
	r := newCRTResolverForTest(&mockCRTRepo{err: boom}, md, buildStdResolver(), nil)
	tmpl, warns, err := runResolve(t, r, "Some_Report_Type_Name")
	assert.Nil(t, tmpl)
	assert.Nil(t, warns)
	require.Error(t, err)
	assert.True(t, errors.Is(err, boom),
		"loader error must be wrapped, not swallowed")
	assert.Contains(t, err.Error(), "crt resolver")
}

// --- Bonus: empty SourceObject short-circuits ------------------------------

func TestCRTResolver_EmptySourceObject(t *testing.T) {
	md := newFakeMetadata()
	r := newCRTResolverForTest(&mockCRTRepo{}, md, buildStdResolver(), nil)
	tmpl, warns, err := runResolve(t, r, "   ")
	assert.Nil(t, tmpl)
	assert.Nil(t, warns)
	assert.NoError(t, err)
}

// Sanity: ensure Resolve is panic-safe per §4.10 C8.
func TestCRTResolver_PanicRecoveredAsNoCRT(t *testing.T) {
	md := &panicMetadata{}
	r := newCRTResolverForTest(&mockCRTRepo{}, md, buildStdResolver(), nil)
	tmpl, warns, err := runResolve(t, r, "CustomEntity$X__c")
	assert.Nil(t, tmpl)
	assert.Nil(t, warns)
	assert.NoError(t, err)
}

type panicMetadata struct{}

func (panicMetadata) GetSchema(_ context.Context, apiName string) *models.ObjectMetadata {
	panic("schema boom for " + strings.ToLower(apiName))
}
