package metadata

import (
	"context"
	"encoding/json"
	"reflect"
	"strings"
	"testing"

	"github.com/nexuscrm/kernel/pkg/ports"
	"github.com/nexuscrm/shared/pkg/models"
)

// The authoring read and the presentation read own different facts and must not
// converge. GetLayoutForAuthoring returns what is stored, because its caller
// will write it back; GetLayoutByID and ResolveLayout return what may be
// rendered, and both strip autonumber fields from editable sections.
//
// All three are asserted in one body on one fixture: a test that pins only the
// authoring side would stay green if the scrub were deleted from the render
// side, and vice versa. The gap between them IS the contract.
func TestAuthoringReadKeepsAutonumberWhileRenderReadsStripIt(t *testing.T) {
	const layoutID = "curated_Quote_default"
	storedFields := []string{"Name", "QuoteNumber", "Status", "OpportunityId"}
	renderableFields := []string{"Name", "Status", "OpportunityId"}

	newRepo := func() *resolveLayoutFallbackRepo {
		return &resolveLayoutFallbackRepo{
			schema: quoteSchemaWithAutonumberField(),
			layouts: []*models.PageLayout{{
				ID:                layoutID,
				ObjectAPIName:     "Quote",
				LayoutName:        "Quote Layout",
				Source:            "curated",
				GenerationVersion: currentGenerationVersion,
				Sections: []models.PageSection{{
					ID:     "quote_information",
					Label:  "Quote Information",
					Fields: append([]string(nil), storedFields...),
				}},
			}},
		}
	}
	newService := func(repo *resolveLayoutFallbackRepo) *MetadataService {
		s := newResolveLayoutFallbackService(repo)
		s.SetAutoNumberFieldResolver(func(string) map[string]struct{} {
			return map[string]struct{}{"QuoteNumber": {}}
		})
		return s
	}

	t.Run("authoring read returns the stored field list", func(t *testing.T) {
		got, err := newService(newRepo()).GetLayoutForAuthoring(context.Background(), layoutID)
		if err != nil {
			t.Fatalf("GetLayoutForAuthoring: %v", err)
		}
		if got == nil {
			t.Fatal("GetLayoutForAuthoring returned nil")
		}
		if !reflect.DeepEqual(got.Sections[0].Fields, storedFields) {
			t.Fatalf("authoring read Fields = %v, want the stored list %v", got.Sections[0].Fields, storedFields)
		}
	})

	t.Run("presentation read by id still strips", func(t *testing.T) {
		got, err := newService(newRepo()).GetLayoutByID(context.Background(), layoutID)
		if err != nil {
			t.Fatalf("GetLayoutByID: %v", err)
		}
		if !reflect.DeepEqual(got.Sections[0].Fields, renderableFields) {
			t.Fatalf("GetLayoutByID Fields = %v, want the stripped list %v", got.Sections[0].Fields, renderableFields)
		}
	})

	t.Run("resolve for render still strips", func(t *testing.T) {
		got := newService(newRepo()).ResolveLayout(context.Background(), "Quote", nil, "", "")
		if got == nil {
			t.Fatal("ResolveLayout returned nil")
		}
		if !reflect.DeepEqual(got.Sections[0].Fields, renderableFields) {
			t.Fatalf("ResolveLayout Fields = %v, want the stripped list %v", got.Sections[0].Fields, renderableFields)
		}
	})
}

// The authoring read must not run the related-list repair that ResolveLayout
// applies. Repair rewrites labels and field lists to reconcile stale persisted
// entries — correct when serving a render, destructive when the caller is about
// to persist what it read, because the repaired shape would be written back
// over the authored one.
func TestAuthoringReadDoesNotRepairRelatedLists(t *testing.T) {
	const layoutID = "curated_Quote_default"
	stored := []models.RelatedListConfig{{
		ID:            "items",
		Label:         "",
		ObjectAPIName: "QuoteItem",
		LookupField:   "QuoteId",
		Fields:        nil,
	}}
	repo := &resolveLayoutFallbackRepo{
		schema: quoteSchemaWithAutonumberField(),
		layouts: []*models.PageLayout{{
			ID:                layoutID,
			ObjectAPIName:     "Quote",
			LayoutName:        "Quote Layout",
			Source:            "curated",
			GenerationVersion: currentGenerationVersion,
			Sections:          []models.PageSection{{ID: "s1", Fields: []string{"Name"}}},
			RelatedLists:      append([]models.RelatedListConfig(nil), stored...),
		}},
	}

	got, err := newResolveLayoutFallbackService(repo).GetLayoutForAuthoring(context.Background(), layoutID)
	if err != nil {
		t.Fatalf("GetLayoutForAuthoring: %v", err)
	}
	if !reflect.DeepEqual(got.RelatedLists, stored) {
		t.Fatalf("authoring read rewrote RelatedLists\n got: %#v\nwant: %#v", got.RelatedLists, stored)
	}
}

// authoringRoundTripRepo models the one thing that matters for a round-trip
// claim: production does not store a Go value, it stores a JSON string. The
// real repository marshals the layout into the Config column on save
// (nexuscrm-persistence/repo_layout.go:513) and unmarshals it on read
// (nexuscrm-persistence/metadata_scan_ui.go:96). A double that keeps a struct
// in a map is strictly MORE lossless than production, which would make the
// claim under test unfalsifiable — anything that fails to survive encoding
// would pass here and lose data in production.
//
// It deliberately does not reuse resolveLayoutFallbackRepo, which leaves
// SaveLayout on an embedded nil interface.
type authoringRoundTripRepo struct {
	ports.MetadataRepository

	schema *models.ObjectMetadata
	stored map[string]string // layoutID -> Config JSON, as the column holds it
}

func (r *authoringRoundTripRepo) GetLayout(ctx context.Context, layoutID string) (*models.PageLayout, error) {
	configJSON, ok := r.stored[layoutID]
	if !ok {
		return nil, nil
	}
	var layout models.PageLayout
	decoder := json.NewDecoder(strings.NewReader(configJSON))
	decoder.UseNumber()
	if err := decoder.Decode(&layout); err != nil {
		return nil, err
	}
	return &layout, nil
}

func (r *authoringRoundTripRepo) SaveLayout(ctx context.Context, layout *models.PageLayout) error {
	configJSON, err := json.Marshal(layout)
	if err != nil {
		return err
	}
	r.stored[layout.ID] = string(configJSON)
	return nil
}

// The name matters: ports.MetadataRepository declares GetSchemaByAPIName. A
// method named GetSchema would satisfy nothing, leaving the double unable to
// serve a schema and any schema-touching regression invisible.
func (r *authoringRoundTripRepo) GetSchemaByAPIName(ctx context.Context, apiName string) (*models.ObjectMetadata, error) {
	return r.schema, nil
}

func mustStoreLayout(t *testing.T, layout *models.PageLayout) map[string]string {
	t.Helper()
	configJSON, err := json.Marshal(layout)
	if err != nil {
		t.Fatalf("seed marshal: %v", err)
	}
	return map[string]string{layout.ID: string(configJSON)}
}

// The point of the authoring read is that what it returns can be handed back to
// SaveLayout without losing anything. Everything a section can carry — including
// the per-field authored behavior added for #3506 — must survive read → save →
// read unchanged.
func TestAuthoringReadSaveRoundTripIsLossless(t *testing.T) {
	const layoutID = "curated_Quote_default"
	authored := models.PageSection{
		ID:                  "quote_information",
		Label:               "Quote Information",
		Columns:             2,
		Fields:              []string{"Name", "QuoteNumber", "Status"},
		FieldLabels:         map[string]string{"QuoteNumber": "Quote #"},
		FieldBehaviors:      map[string]map[string]string{"Status": {"create": "required"}},
		VisibilityCondition: strPtr("record.Status != 'Draft'"),
	}
	recordPanels := []models.RecordPanelConfig{{
		ID:         "revenue_panel",
		RendererID: "app-pack:example/record-panel/revenue",
		Position:   "sidebar",
		Config: map[string]interface{}{
			"object": "Opportunity",
			"query": map[string]interface{}{
				"fields": []interface{}{"CloseDate", "Amount"},
				"active": true,
			},
		},
	}}
	repo := &authoringRoundTripRepo{
		schema: quoteSchemaWithAutonumberField(),
		stored: mustStoreLayout(t, &models.PageLayout{
			ID:                layoutID,
			ObjectAPIName:     "Quote",
			LayoutName:        "Quote Layout",
			Source:            "curated",
			GenerationVersion: currentGenerationVersion,
			Sections:          []models.PageSection{authored},
			RecordPanels:      recordPanels,
		}),
	}
	service := NewMetadataService(repo, nil, nil)
	service.SetAutoNumberFieldResolver(func(string) map[string]struct{} {
		return map[string]struct{}{"QuoteNumber": {}}
	})

	first, err := service.GetLayoutForAuthoring(context.Background(), layoutID)
	if err != nil {
		t.Fatalf("first authoring read: %v", err)
	}
	if err := service.SaveLayout(context.Background(), first); err != nil {
		t.Fatalf("SaveLayout of an unmodified authoring read: %v", err)
	}
	second, err := service.GetLayoutForAuthoring(context.Background(), layoutID)
	if err != nil {
		t.Fatalf("second authoring read: %v", err)
	}

	if !reflect.DeepEqual(second.Sections, first.Sections) {
		t.Fatalf("an unmodified round trip changed the sections\n before: %#v\n  after: %#v", first.Sections, second.Sections)
	}
	if !reflect.DeepEqual(second.Sections[0].Fields, authored.Fields) {
		t.Fatalf("round trip lost fields: %v, want %v", second.Sections[0].Fields, authored.Fields)
	}
	if !reflect.DeepEqual(second.Sections[0].FieldBehaviors, authored.FieldBehaviors) {
		t.Fatalf("round trip lost FieldBehaviors: %#v", second.Sections[0].FieldBehaviors)
	}
	if !reflect.DeepEqual(second.RecordPanels, recordPanels) {
		t.Fatalf("round trip lost record-panel config\n got: %#v\nwant: %#v", second.RecordPanels, recordPanels)
	}

	// The autonumber resolver above is wired deliberately: this asserts the
	// production failure the whole change exists to stop. QuoteNumber IS an
	// autonumber field here, so a read that scrubbed would hand SaveLayout a
	// shorter list and delete it from storage on the very first save.
	if !containsField(second.Sections[0].Fields, "QuoteNumber") {
		t.Fatalf("an unmodified round trip deleted the autonumber field from storage: %v", second.Sections[0].Fields)
	}

	// Negative control: the presentation read on the SAME stored row must still
	// omit it, or the assertion above would pass merely because nothing strips
	// anywhere and the two reads no longer differ.
	presented, err := service.GetLayoutByID(context.Background(), layoutID)
	if err != nil {
		t.Fatalf("GetLayoutByID: %v", err)
	}
	if containsField(presented.Sections[0].Fields, "QuoteNumber") {
		t.Fatalf("presentation read stopped stripping, so the round-trip assertion proves nothing: %v", presented.Sections[0].Fields)
	}
}

func containsField(fields []string, name string) bool {
	for _, f := range fields {
		if f == name {
			return true
		}
	}
	return false
}

func strPtr(s string) *string { return &s }
