package mapping

import (
	"context"
	"errors"
	"reflect"
	"strings"
	"testing"
	"time"

	semantic "github.com/pingcap-inc/nexuscrm-contract/pkg/runtime/analyticsauthoringv1"
	commonsemantic "github.com/pingcap-inc/nexuscrm-contract/pkg/runtime/commonv1"
	"github.com/pingcap-inc/nexuscrm-contract/wire-go/envelope"
	runtimev1 "github.com/pingcap-inc/nexuscrm-contract/wire-go/gen/runtime/v1"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/proto"
)

func TestAnalyticsAuthoringMappingRoundTrip(t *testing.T) {
	request := analyticsDashboardRequest()
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	wireRequest, err := ToWireMutateAnalyticsDefinitionRequest(ctx, request)
	if err != nil {
		t.Fatal(err)
	}
	decoded, err := FromWireMutateAnalyticsDefinitionRequest(ctx, wireRequest)
	if err != nil || !reflect.DeepEqual(decoded, request) {
		t.Fatalf("decoded=%#v err=%v", decoded, err)
	}
	response := semantic.Response{CommandID: request.CommandID, ResourceID: "dashboard-1", Receipts: semantic.Receipts{ProtocolVersion: "1.0", CatalogRevision: 1, SchemaGeneration: 1, EngineGeneration: 1, SecurityEpoch: 1, CapabilityVersion: semantic.CapabilityName}}
	wireResponse, err := ToWireMutateAnalyticsDefinitionResponse(request, response)
	if err != nil {
		t.Fatal(err)
	}
	decodedResponse, err := FromWireMutateAnalyticsDefinitionResponse(request, wireResponse)
	if err != nil || decodedResponse.ResourceID != response.ResourceID || decodedResponse.CommandID != response.CommandID {
		t.Fatalf("decoded=%#v err=%v", decodedResponse, err)
	}
}

func TestAnalyticsReportMappingPreservesTypedDefinitionAndPresence(t *testing.T) {
	name, stable, folder, source := "Pipeline", "Pipeline", "folder-1", "Opportunity"
	columns := []string{}
	filters := []semantic.ReportFilter{{Column: "Stage", Operator: "equals", Value: "Open"}}
	request := semantic.Request{CommandID: "report-create-1", Operation: semantic.CreateReport, Report: &semantic.ReportDefinition{
		Name: &name, StableName: &stable, FolderID: &folder, SourceObject: &source, Columns: &columns, Filters: &filters,
		DateFilter: &semantic.ReportDateFilter{Column: "CloseDate", DurationValue: "CUSTOM", StartDate: "2026-01-01", EndDate: "2026-01-31"},
	}}
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	wireRequest, err := ToWireMutateAnalyticsDefinitionRequest(ctx, request)
	if err != nil {
		t.Fatal(err)
	}
	encoded, err := proto.Marshal(wireRequest)
	if err != nil {
		t.Fatal(err)
	}
	decodedWire := new(runtimev1.MutateAnalyticsDefinitionRequest)
	if err := proto.Unmarshal(encoded, decodedWire); err != nil {
		t.Fatal(err)
	}
	decoded, err := FromWireMutateAnalyticsDefinitionRequest(ctx, decodedWire)
	if err != nil || !reflect.DeepEqual(decoded, request) {
		t.Fatalf("decoded=%#v err=%v", decoded, err)
	}
}

func TestAnalyticsReportDateFilterSetAndClearRoundTrip(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	tests := []struct {
		name       string
		definition *semantic.ReportDefinition
		wireAction any
	}{
		{
			name: "set",
			definition: &semantic.ReportDefinition{DateFilter: &semantic.ReportDateFilter{
				Column: "CloseDate", DurationValue: "CUSTOM", StartDate: "2026-01-01", EndDate: "2026-01-31",
			}},
			wireAction: (*runtimev1.AnalyticsReportDefinition_DateFilter)(nil),
		},
		{
			name:       "clear",
			definition: &semantic.ReportDefinition{ClearDateFilter: true},
			wireAction: (*runtimev1.AnalyticsReportDefinition_ClearDateFilter)(nil),
		},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			request := semantic.Request{CommandID: "report-date-" + test.name, Operation: semantic.UpdateReport, ResourceID: "report-1", Report: test.definition}
			wireRequest, err := ToWireMutateAnalyticsDefinitionRequest(ctx, request)
			if err != nil {
				t.Fatal(err)
			}
			if reflect.TypeOf(wireRequest.GetReport().GetDateFilterChange()) != reflect.TypeOf(test.wireAction) {
				t.Fatalf("wire action = %T, want %T", wireRequest.GetReport().GetDateFilterChange(), test.wireAction)
			}
			encoded, err := proto.Marshal(wireRequest)
			if err != nil {
				t.Fatal(err)
			}
			decodedWire := new(runtimev1.MutateAnalyticsDefinitionRequest)
			if err := proto.Unmarshal(encoded, decodedWire); err != nil {
				t.Fatal(err)
			}
			decoded, err := FromWireMutateAnalyticsDefinitionRequest(ctx, decodedWire)
			if err != nil || !reflect.DeepEqual(decoded, request) {
				t.Fatalf("decoded=%#v err=%v", decoded, err)
			}
		})
	}
}

func TestAnalyticsFolderMappingPreservesOptionalEmptyParent(t *testing.T) {
	empty := ""
	request := semantic.Request{CommandID: "folder-update-1", Operation: semantic.UpdateFolder, ResourceID: "folder-1", Folder: &semantic.FolderDefinition{ParentFolderID: &empty}}
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	wireRequest, err := ToWireMutateAnalyticsDefinitionRequest(ctx, request)
	if err != nil {
		t.Fatal(err)
	}
	encoded, err := proto.Marshal(wireRequest)
	if err != nil {
		t.Fatal(err)
	}
	decodedWire := new(runtimev1.MutateAnalyticsDefinitionRequest)
	if err := proto.Unmarshal(encoded, decodedWire); err != nil {
		t.Fatal(err)
	}
	decoded, err := FromWireMutateAnalyticsDefinitionRequest(ctx, decodedWire)
	if err != nil || !reflect.DeepEqual(decoded, request) {
		t.Fatalf("decoded=%#v err=%v", decoded, err)
	}
}

func TestAnalyticsAuthoringOperationMappingIsComplete(t *testing.T) {
	for _, operation := range []semantic.Operation{
		semantic.CreateDashboard,
		semantic.UpdateDashboard,
		semantic.CreateReport,
		semantic.UpdateReport,
		semantic.DeleteReport,
		semantic.CreateFolder,
		semantic.UpdateFolder,
		semantic.DeleteFolder,
	} {
		wireOperation, ok := toWireAnalyticsAuthoringOperation(operation)
		if !ok {
			t.Fatalf("semantic operation %q has no wire value", operation)
		}
		decoded, ok := fromWireAnalyticsAuthoringOperation(wireOperation)
		if !ok || decoded != operation {
			t.Fatalf("operation %q decoded as %q, ok=%t", operation, decoded, ok)
		}
	}
	if _, ok := toWireAnalyticsAuthoringOperation(semantic.Operation("UNKNOWN")); ok {
		t.Fatal("unknown semantic operation mapped to wire")
	}
	if _, ok := fromWireAnalyticsAuthoringOperation(runtimev1.AnalyticsAuthoringOperation_ANALYTICS_AUTHORING_OPERATION_UNSPECIFIED); ok {
		t.Fatal("unspecified wire operation mapped to semantic")
	}
}

// The semantic root proves that present nil and present empty collections have
// one command identity. This independently released module proves the other
// half of the invariant: protobuf preserves the canonical present-empty patch.
func TestAnalyticsAuthoringWirePreservesEveryPresentEmptyCollection(t *testing.T) {
	components := []semantic.DashboardComponent{}
	columns := []string{}
	filters := []semantic.ReportFilter{}
	grouping := []string{}
	tests := []struct {
		path    string
		request semantic.Request
		present func(semantic.Request) bool
	}{
		{
			path: "DashboardDefinition.Components",
			request: semantic.Request{
				CommandID: "dashboard-update-1", Operation: semantic.UpdateDashboard, ResourceID: "dashboard-1",
				Dashboard: &semantic.DashboardDefinition{Components: &components},
			},
			present: func(request semantic.Request) bool {
				return request.Dashboard != nil && request.Dashboard.Components != nil
			},
		},
		{
			path: "ReportDefinition.Columns",
			request: semantic.Request{
				CommandID: "report-update-columns-1", Operation: semantic.UpdateReport, ResourceID: "report-1",
				Report: &semantic.ReportDefinition{Columns: &columns},
			},
			present: func(request semantic.Request) bool { return request.Report != nil && request.Report.Columns != nil },
		},
		{
			path: "ReportDefinition.Filters",
			request: semantic.Request{
				CommandID: "report-update-filters-1", Operation: semantic.UpdateReport, ResourceID: "report-1",
				Report: &semantic.ReportDefinition{Filters: &filters},
			},
			present: func(request semantic.Request) bool { return request.Report != nil && request.Report.Filters != nil },
		},
		{
			path: "ReportDefinition.Grouping",
			request: semantic.Request{
				CommandID: "report-update-grouping-1", Operation: semantic.UpdateReport, ResourceID: "report-1",
				Report: &semantic.ReportDefinition{Grouping: &grouping},
			},
			present: func(request semantic.Request) bool { return request.Report != nil && request.Report.Grouping != nil },
		},
	}
	covered := make(map[string]struct{}, len(tests))
	for _, test := range tests {
		if _, duplicate := covered[test.path]; duplicate {
			t.Fatalf("duplicate optional collection case %q", test.path)
		}
		covered[test.path] = struct{}{}
	}
	for _, definition := range []reflect.Type{
		reflect.TypeOf(semantic.DashboardDefinition{}),
		reflect.TypeOf(semantic.ReportDefinition{}),
		reflect.TypeOf(semantic.FolderDefinition{}),
	} {
		for index := 0; index < definition.NumField(); index++ {
			field := definition.Field(index)
			if field.Type.Kind() != reflect.Ptr || field.Type.Elem().Kind() != reflect.Slice {
				continue
			}
			path := definition.Name() + "." + field.Name
			if _, ok := covered[path]; !ok {
				t.Fatalf("optional collection %s has no binary wire presence case", path)
			}
			delete(covered, path)
		}
	}
	for path := range covered {
		t.Fatalf("binary wire presence case %s does not name an optional collection", path)
	}

	for _, test := range tests {
		t.Run(test.path, func(t *testing.T) {
			wantDigest, err := semantic.CommandDigest(test.request)
			if err != nil {
				t.Fatal(err)
			}
			ctx, cancel := context.WithTimeout(context.Background(), time.Second)
			defer cancel()
			wireRequest, err := ToWireMutateAnalyticsDefinitionRequest(ctx, test.request)
			if err != nil {
				t.Fatal(err)
			}
			encoded, err := proto.Marshal(wireRequest)
			if err != nil {
				t.Fatal(err)
			}
			decodedWire := new(runtimev1.MutateAnalyticsDefinitionRequest)
			if err := proto.Unmarshal(encoded, decodedWire); err != nil {
				t.Fatal(err)
			}
			decoded, err := FromWireMutateAnalyticsDefinitionRequest(ctx, decodedWire)
			if err != nil {
				t.Fatal(err)
			}
			if !test.present(decoded) {
				t.Fatal("present empty collection became an absent patch")
			}
			gotDigest, err := semantic.CommandDigest(decoded)
			if err != nil {
				t.Fatal(err)
			}
			if gotDigest != wantDigest {
				t.Fatalf("wire round trip changed command digest: %q != %q", gotDigest, wantDigest)
			}
		})
	}
}

func TestAnalyticsAuthoringInboundPreflightRejectsCountsNilComponentsAndRawBytes(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()

	oversizedCount := &runtimev1.MutateAnalyticsDefinitionRequest{
		Operation: runtimev1.AnalyticsAuthoringOperation_ANALYTICS_AUTHORING_OPERATION_CREATE_DASHBOARD,
		CommandId: "dashboard-create-1",
		Dashboard: &runtimev1.AnalyticsDashboardDefinition{ComponentSet: &runtimev1.AnalyticsDashboardComponentSet{
			// Nil members prove that the count limit wins before component conversion.
			Components: make([]*runtimev1.AnalyticsDashboardComponent, semantic.MaxDashboardComponents+1),
		}},
	}
	_, err := FromWireMutateAnalyticsDefinitionRequest(ctx, oversizedCount)
	var failure *commonsemantic.Failure
	if !errors.As(err, &failure) || failure.Category != commonsemantic.FailureResourceExhausted ||
		failure.LimitKind != commonsemantic.LimitOperationCount || failure.Maximum != semantic.MaxDashboardComponents {
		t.Fatalf("oversized component count = %#v, want operation-count limit %d", err, semantic.MaxDashboardComponents)
	}

	reportCountCases := []struct {
		path   string
		report *runtimev1.AnalyticsReportDefinition
	}{
		{
			path: "ReportDefinition.Columns",
			report: &runtimev1.AnalyticsReportDefinition{ColumnSet: &runtimev1.AnalyticsReportColumnSet{
				Columns: make([]string, semantic.MaxReportDefinitionEntries+1),
			}},
		},
		{
			path: "ReportDefinition.Filters",
			report: &runtimev1.AnalyticsReportDefinition{FilterSet: &runtimev1.AnalyticsReportFilterSet{
				// Nil members prove that the aggregate count wins before value inspection.
				Filters: make([]*runtimev1.AnalyticsReportFilter, semantic.MaxReportDefinitionEntries+1),
			}},
		},
		{
			path: "ReportDefinition.Grouping",
			report: &runtimev1.AnalyticsReportDefinition{GroupingSet: &runtimev1.AnalyticsReportGroupingSet{
				Grouping: make([]string, semantic.MaxReportDefinitionEntries+1),
			}},
		},
		{
			path: "aggregate",
			report: &runtimev1.AnalyticsReportDefinition{
				ColumnSet:   &runtimev1.AnalyticsReportColumnSet{Columns: make([]string, semantic.MaxReportDefinitionEntries/2)},
				FilterSet:   &runtimev1.AnalyticsReportFilterSet{Filters: make([]*runtimev1.AnalyticsReportFilter, semantic.MaxReportDefinitionEntries/2)},
				GroupingSet: &runtimev1.AnalyticsReportGroupingSet{Grouping: []string{"OwnerId"}},
			},
		},
	}
	requireEveryWireReportCollectionBounded(t, reportCountCases[:len(reportCountCases)-1])
	for _, test := range reportCountCases {
		t.Run("report "+test.path, func(t *testing.T) {
			_, countErr := FromWireMutateAnalyticsDefinitionRequest(ctx, &runtimev1.MutateAnalyticsDefinitionRequest{
				Operation: runtimev1.AnalyticsAuthoringOperation_ANALYTICS_AUTHORING_OPERATION_UPDATE_REPORT_DEFINITION,
				CommandId: "report-update-1", ResourceId: "report-1", Report: test.report,
			})
			if !errors.As(countErr, &failure) || failure.Category != commonsemantic.FailureResourceExhausted ||
				failure.LimitKind != commonsemantic.LimitOperationCount || failure.Maximum != semantic.MaxReportDefinitionEntries {
				t.Fatalf("oversized report entry count = %#v, want operation-count limit %d", countErr, semantic.MaxReportDefinitionEntries)
			}
		})
	}

	nilComponent := proto.Clone(oversizedCount).(*runtimev1.MutateAnalyticsDefinitionRequest)
	nilComponent.Dashboard.ComponentSet.Components = nilComponent.Dashboard.ComponentSet.Components[:1]
	if got, nilErr := FromWireMutateAnalyticsDefinitionRequest(ctx, nilComponent); nilErr == nil || got.Dashboard != nil {
		t.Fatalf("nil component request = %#v, %v; want rejection before semantic dashboard", got, nilErr)
	}

	description := strings.Repeat("d", semantic.MaxDefinitionBytes+1)
	oversizedRaw := &runtimev1.MutateAnalyticsDefinitionRequest{
		Operation: runtimev1.AnalyticsAuthoringOperation_ANALYTICS_AUTHORING_OPERATION_CREATE_DASHBOARD,
		CommandId: "dashboard-create-1", Dashboard: &runtimev1.AnalyticsDashboardDefinition{Description: &description},
	}
	_, err = FromWireMutateAnalyticsDefinitionRequest(ctx, oversizedRaw)
	if !errors.As(err, &failure) || failure.Category != commonsemantic.FailureResourceExhausted ||
		failure.LimitKind != commonsemantic.LimitSemanticRequest || failure.Maximum != semantic.MaxDefinitionBytes {
		t.Fatalf("oversized definition bytes = %#v, want definition limit %d", err, semantic.MaxDefinitionBytes)
	}
}

func requireEveryWireReportCollectionBounded(t *testing.T, tests []struct {
	path   string
	report *runtimev1.AnalyticsReportDefinition
}) {
	t.Helper()
	covered := make(map[string]struct{}, len(tests))
	for _, test := range tests {
		covered[test.path] = struct{}{}
	}
	typeOfReport := reflect.TypeOf(semantic.ReportDefinition{})
	for index := 0; index < typeOfReport.NumField(); index++ {
		field := typeOfReport.Field(index)
		if field.Type.Kind() != reflect.Ptr || field.Type.Elem().Kind() != reflect.Slice {
			continue
		}
		path := typeOfReport.Name() + "." + field.Name
		if _, ok := covered[path]; !ok {
			t.Fatalf("report collection %s has no direct-wire entry-boundary case", path)
		}
		delete(covered, path)
	}
	for path := range covered {
		t.Fatalf("direct-wire entry-boundary case %s does not name a report collection", path)
	}
}

func TestNearMaximumAnalyticsAuthoringRequestFitsUnaryEnvelope(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	revision := ^uint64(0)
	request := semantic.Request{
		CommandID: strings.Repeat("c", semantic.MaxCommandIdentifierBytes), Operation: semantic.UpdateDashboard,
		ResourceID:         strings.Repeat("r", semantic.MaxResourceIdentifierBytes),
		Dashboard:          &semantic.DashboardDefinition{Description: stringPointer(strings.Repeat("x", semantic.MaxDefinitionBytes-512))},
		MinCatalogRevision: &revision,
	}
	wireRequest, err := ToWireMutateAnalyticsDefinitionRequest(ctx, request)
	if err != nil {
		t.Fatal(err)
	}
	if got := proto.Size(wireRequest); got > envelope.MaxRequestWireBytes {
		t.Fatalf("near-maximum semantic request wire size=%d cap=%d", got, envelope.MaxRequestWireBytes)
	}
}

func TestAnalyticsAuthoringCommandIDFailureMapsToInvalidArgument(t *testing.T) {
	err := ToPlatformStatusError(&commonsemantic.Failure{
		Category:         commonsemantic.FailureInvalidRequest,
		InvalidComponent: commonsemantic.InvalidComponentCommandID,
	}, semantic.CapabilityName)
	if got := status.Code(err); got != codes.InvalidArgument {
		t.Fatalf("command ID failure code = %v, want %v", got, codes.InvalidArgument)
	}
}

func TestAnalyticsAuthoringReportEntryLimitMapsToResourceExhausted(t *testing.T) {
	statusErr := ToPlatformStatusError(&commonsemantic.Failure{
		Category: commonsemantic.FailureResourceExhausted, LimitKind: commonsemantic.LimitOperationCount,
		Maximum: semantic.MaxReportDefinitionEntries,
	}, semantic.CapabilityName)
	if got := status.Code(statusErr); got != codes.ResourceExhausted {
		t.Fatalf("report entry limit code = %v, want %v", got, codes.ResourceExhausted)
	}
	var failure *commonsemantic.Failure
	if err := FromPlatformStatusError(statusErr, semantic.CapabilityName); !errors.As(err, &failure) ||
		failure.Category != commonsemantic.FailureResourceExhausted || failure.LimitKind != commonsemantic.LimitOperationCount ||
		failure.Maximum != semantic.MaxReportDefinitionEntries {
		t.Fatalf("round-trip report entry limit = %#v", err)
	}
}

func analyticsDashboardRequest() semantic.Request {
	name, stable, folder, layout := "Pipeline", "Pipeline", "folder-1", "GRID"
	components := []semantic.DashboardComponent{{ReportID: "report-1", Kind: "CHART", Title: "Open", Configuration: `{"color":"blue"}`}}
	return semantic.Request{CommandID: "dashboard-create-1", Operation: semantic.CreateDashboard, Dashboard: &semantic.DashboardDefinition{
		Name: &name, StableName: &stable, FolderID: &folder, LayoutKind: &layout, Components: &components,
	}}
}

func stringPointer(value string) *string { return &value }
