package services

// Pure-function tests for DashboardLayoutSpec + WidgetCatalog.
// No DB; safe to run in any environment.

import (
	"encoding/json"
	"errors"
	"strings"
	"testing"
)

func TestFactoryDefaultLayout_AllKnownWidgetsPresent(t *testing.T) {
	def := FactoryDefaultLayout()
	if def.Version != LayoutSpecVersion {
		t.Errorf("FactoryDefaultLayout version=%d; want %d", def.Version, LayoutSpecVersion)
	}
	if len(def.Widgets) != len(KnownWidgetKeys) {
		t.Fatalf("FactoryDefaultLayout has %d widgets; want %d", len(def.Widgets), len(KnownWidgetKeys))
	}
	for i, k := range KnownWidgetKeys {
		if def.Widgets[i].Key != k {
			t.Errorf("widgets[%d].Key = %q; want %q", i, def.Widgets[i].Key, k)
		}
		// Slice C: some catalog entries default-hidden (pinned-projects,
		// quick-actions) — opt-in via the picker. Factory visibility
		// must match the catalog declaration so a user can re-enable a
		// widget without going through the picker every time.
		catalogDef, ok := LookupWidgetDef(k)
		if !ok {
			t.Errorf("widgets[%d] %q has no catalog def", i, k)
			continue
		}
		if def.Widgets[i].Visible != catalogDef.DefaultVisible {
			t.Errorf("widgets[%d] %q: factory Visible=%v; catalog DefaultVisible=%v",
				i, k, def.Widgets[i].Visible, catalogDef.DefaultVisible)
		}
	}
}

func TestFactoryDefaultLayout_SettingsDefaultsPresent(t *testing.T) {
	def := FactoryDefaultLayout()
	for _, w := range def.Widgets {
		catalogDef, ok := LookupWidgetDef(w.Key)
		if !ok {
			t.Errorf("factory widget %q is not in catalog", w.Key)
			continue
		}
		hasDefaults := catalogDef.DefaultCount != nil || catalogDef.DefaultHorizon != nil
		if hasDefaults && len(w.Settings) == 0 {
			t.Errorf("widget %q has catalog defaults but factory layout has empty settings", w.Key)
		}
		if !hasDefaults && len(w.Settings) > 0 {
			t.Errorf("widget %q has no catalog defaults but factory layout has settings %s", w.Key, string(w.Settings))
		}
	}
}

func TestFactoryDefaultLayout_PassesValidation(t *testing.T) {
	def := FactoryDefaultLayout()
	if err := def.Validate(); err != nil {
		t.Fatalf("factory default failed Validate(): %v", err)
	}
}

func TestDashboardLayoutSpec_Validate_WrongVersion(t *testing.T) {
	s := DashboardLayoutSpec{Version: 99, Widgets: []DashboardWidgetRef{{Key: WidgetDeadlineSummary, Visible: true}}}
	err := s.Validate()
	if !errors.Is(err, ErrInvalidInput) {
		t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
	}
	if !strings.Contains(err.Error(), "version") {
		t.Errorf("error %q should mention 'version'", err.Error())
	}
}

func TestDashboardLayoutSpec_Validate_TooManyWidgets(t *testing.T) {
	widgets := make([]DashboardWidgetRef, LayoutWidgetCap+1)
	for i := range widgets {
		widgets[i] = DashboardWidgetRef{Key: WidgetDeadlineSummary, Visible: true}
	}
	s := DashboardLayoutSpec{Version: 1, Widgets: widgets}
	err := s.Validate()
	if !errors.Is(err, ErrInvalidInput) {
		t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
	}
}

func TestDashboardLayoutSpec_Validate_UnknownKey(t *testing.T) {
	s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
		{Key: "not-a-real-widget", Visible: true},
	}}
	err := s.Validate()
	if !errors.Is(err, ErrInvalidInput) {
		t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
	}
}

func TestDashboardLayoutSpec_Validate_DuplicateKey(t *testing.T) {
	s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
		{Key: WidgetDeadlineSummary, Visible: true},
		{Key: WidgetDeadlineSummary, Visible: false},
	}}
	err := s.Validate()
	if !errors.Is(err, ErrInvalidInput) {
		t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
	}
	if !strings.Contains(err.Error(), "duplicate") {
		t.Errorf("error %q should mention 'duplicate'", err.Error())
	}
}

func TestDashboardLayoutSpec_Validate_BadSettings(t *testing.T) {
	// count over CountMax=50 for upcoming-deadlines must be rejected.
	// (Values inside [1,CountMax] are accepted free-form per the gear
	// pane's numeric input; values inside CountOptions are the curated
	// presets. 100 is outside both.)
	s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
		{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 100}`)},
	}}
	err := s.Validate()
	if !errors.Is(err, ErrInvalidInput) {
		t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
	}
}

func TestDashboardLayoutSpec_Validate_AcceptsCustomCountWithinMax(t *testing.T) {
	// Custom count not in CountOptions but inside CountMax — accepted.
	// upcoming-deadlines: CountOptions {1,3,5,10,20}, CountMax 50.
	s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
		{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 7}`)},
	}}
	if err := s.Validate(); err != nil {
		t.Fatalf("Validate rejected custom count=7 within CountMax=50: %v", err)
	}
}

func TestDashboardLayoutSpec_Validate_AcceptsValidView(t *testing.T) {
	s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
		{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"view":"calendar"}`)},
	}}
	if err := s.Validate(); err != nil {
		t.Fatalf("Validate rejected legal view=calendar: %v", err)
	}
}

func TestDashboardLayoutSpec_Validate_RejectsUnknownView(t *testing.T) {
	s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
		{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"view":"sankey"}`)},
	}}
	err := s.Validate()
	if !errors.Is(err, ErrInvalidInput) {
		t.Fatalf("Validate accepted unknown view; want ErrInvalidInput, got %v", err)
	}
}

func TestDashboardLayoutSpec_Validate_RejectsViewOnNoViewWidget(t *testing.T) {
	// deadline-summary has no Views — view knob must be rejected.
	s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
		{Key: WidgetDeadlineSummary, Visible: true, Settings: json.RawMessage(`{"view":"list"}`)},
	}}
	err := s.Validate()
	if !errors.Is(err, ErrInvalidInput) {
		t.Fatalf("Validate accepted view on no-view widget; want ErrInvalidInput, got %v", err)
	}
}

func TestDashboardLayoutSpec_Validate_GridPosition(t *testing.T) {
	// X+W overflowing GridColumns must be rejected.
	s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
		{Key: WidgetUpcomingDeadlines, Visible: true, X: 8, W: 6},
	}}
	err := s.Validate()
	if !errors.Is(err, ErrInvalidInput) {
		t.Fatalf("Validate accepted x+w overflow; want ErrInvalidInput, got %v", err)
	}
}

func TestDashboardLayoutSpec_Validate_GridSizeOutsideClamps(t *testing.T) {
	// upcoming-deadlines has MinW=4. W=2 must be rejected.
	s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
		{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 2, H: 1},
	}}
	err := s.Validate()
	if !errors.Is(err, ErrInvalidInput) {
		t.Fatalf("Validate accepted W=2 below MinW=4; want ErrInvalidInput, got %v", err)
	}
}

func TestFactoryDefaultLayout_AssignsPositions(t *testing.T) {
	def := FactoryDefaultLayout()
	// At least one visible widget must have a non-zero position OR
	// W set (W=0 means "auto = full width" but factory should assign
	// concrete sizes from the catalog).
	anySized := false
	for _, w := range def.Widgets {
		if w.W > 0 {
			anySized = true
			break
		}
	}
	if !anySized {
		t.Fatal("FactoryDefaultLayout did not assign any W; every visible widget should carry a default size")
	}
}

// TestFactoryDefaultLayout_NoOverlap verifies the greedy packer in
// FactoryDefaultLayout produces a non-overlapping arrangement. CSS Grid
// would render overlapping items stacked on top of each other — a
// regression here would mean every new paliad user sees a broken
// dashboard until they manually adjust positions.
func TestFactoryDefaultLayout_NoOverlap(t *testing.T) {
	def := FactoryDefaultLayout()
	type rect struct{ x, y, w, h int }
	visible := []rect{}
	for _, w := range def.Widgets {
		if !w.Visible {
			continue
		}
		if w.W <= 0 || w.H <= 0 {
			t.Errorf("factory visible widget %q has non-positive size (w=%d, h=%d)", w.Key, w.W, w.H)
			continue
		}
		visible = append(visible, rect{w.X, w.Y, w.W, w.H})
	}
	for i, a := range visible {
		if a.x+a.w > DashboardGridColumns {
			t.Errorf("widget %d overflows grid: x=%d w=%d", i, a.x, a.w)
		}
		for j, b := range visible {
			if i == j {
				continue
			}
			if a.x < b.x+b.w && b.x < a.x+a.w && a.y < b.y+b.h && b.y < a.y+a.h {
				t.Errorf("widgets %d and %d overlap: %v vs %v", i, j, a, b)
			}
		}
	}
}

func TestDashboardLayoutSpec_Validate_AcceptsValidSettings(t *testing.T) {
	s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
		{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 5, "horizon_days": 14}`)},
		{Key: WidgetInlineAgenda, Visible: true, Settings: json.RawMessage(`{"horizon_days": 60}`)},
		{Key: WidgetRecentActivity, Visible: false},
	}}
	if err := s.Validate(); err != nil {
		t.Fatalf("Validate returned %v; want nil", err)
	}
}

func TestDashboardLayoutSpec_Validate_SettingsOnNoSettingsWidget(t *testing.T) {
	// deadline-summary has no Settings schema.
	s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
		{Key: WidgetDeadlineSummary, Visible: true, Settings: json.RawMessage(`{"count": 5}`)},
	}}
	err := s.Validate()
	if !errors.Is(err, ErrInvalidInput) {
		t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
	}
}

func TestDashboardLayoutSpec_SanitizeForRead_DropsUnknownKeys(t *testing.T) {
	s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
		{Key: WidgetDeadlineSummary, Visible: true},
		{Key: "deprecated-widget", Visible: true},
		{Key: WidgetInlineAgenda, Visible: true},
	}}
	changed := s.SanitizeForRead()
	if !changed {
		t.Errorf("SanitizeForRead returned false; expected true (one entry dropped)")
	}
	if len(s.Widgets) != 2 {
		t.Errorf("after sanitize: %d widgets; want 2", len(s.Widgets))
	}
	if s.Widgets[0].Key != WidgetDeadlineSummary || s.Widgets[1].Key != WidgetInlineAgenda {
		t.Errorf("after sanitize: keys = %v %v; want %v %v",
			s.Widgets[0].Key, s.Widgets[1].Key, WidgetDeadlineSummary, WidgetInlineAgenda)
	}
}

// TestDashboardLayoutSpec_SanitizeForRead_ClampsOutOfRange covers the
// m/paliad#73 recovery path: a stale row in user_dashboard_layouts
// carrying a W below MinW (or above MaxW) must be normalised on load so
// the user doesn't get stranded with super-slim columns. Pre-fix the
// sanitizer only dropped unknown keys; sizes passed through verbatim.
func TestDashboardLayoutSpec_SanitizeForRead_ClampsOutOfRange(t *testing.T) {
	// upcoming-deadlines: MinW=4, MaxW=12, MinH=1, MaxH=4 (per catalog).
	def, ok := LookupWidgetDef(WidgetUpcomingDeadlines)
	if !ok {
		t.Fatal("LookupWidgetDef(WidgetUpcomingDeadlines) = !ok")
	}
	cases := []struct {
		name   string
		in     DashboardWidgetRef
		wantW  int
		wantH  int
		wantX  int
		wantY  int
		wantOK bool // expected SanitizeForRead-returns-true
	}{
		{
			name:   "W below MinW snaps to MinW",
			in:     DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 1, H: 1},
			wantW:  def.MinW,
			wantH:  1,
			wantOK: true,
		},
		{
			name:   "W above MaxW snaps to MaxW",
			in:     DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 99, H: 1},
			wantW:  DashboardGridColumns,
			wantH:  1,
			wantOK: true,
		},
		{
			name:   "W above grid width snaps to grid width",
			in:     DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 50, H: 1},
			wantW:  DashboardGridColumns,
			wantH:  1,
			wantOK: true,
		},
		{
			name:   "H above MaxGridRowSpan snaps to MaxGridRowSpan",
			in:     DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 6, H: 99},
			wantW:  6,
			wantH:  def.MaxH, // upcoming-deadlines MaxH=4 < MaxGridRowSpan=5
			wantOK: true,
		},
		{
			name:   "X+W overflowing grid snaps X down",
			in:     DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 10, Y: 0, W: 6, H: 1},
			wantW:  6,
			wantH:  1,
			wantX:  6, // 12 - 6 = 6
			wantOK: true,
		},
		{
			name:   "W=0 stays 0 (auto / default sentinel)",
			in:     DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 0, H: 0},
			wantW:  0,
			wantH:  0,
			wantOK: false,
		},
		{
			name:   "negative X snaps to 0",
			in:     DashboardWidgetRef{Key: WidgetUpcomingDeadlines, Visible: true, X: -3, Y: 0, W: 6, H: 1},
			wantW:  6,
			wantH:  1,
			wantX:  0,
			wantOK: true,
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			s := DashboardLayoutSpec{Version: LayoutSpecVersion, Widgets: []DashboardWidgetRef{tc.in}}
			changed := s.SanitizeForRead()
			if changed != tc.wantOK {
				t.Errorf("SanitizeForRead returned %v; want %v", changed, tc.wantOK)
			}
			if len(s.Widgets) != 1 {
				t.Fatalf("expected 1 widget after sanitize, got %d", len(s.Widgets))
			}
			got := s.Widgets[0]
			if got.W != tc.wantW {
				t.Errorf("W = %d; want %d", got.W, tc.wantW)
			}
			if got.H != tc.wantH {
				t.Errorf("H = %d; want %d", got.H, tc.wantH)
			}
			if got.X != tc.wantX {
				t.Errorf("X = %d; want %d", got.X, tc.wantX)
			}
			if got.Y != tc.wantY {
				t.Errorf("Y = %d; want %d", got.Y, tc.wantY)
			}
		})
	}
}

// TestDashboardLayoutSpec_SanitizeForRead_ClampedSpecPassesValidate is
// the round-trip guarantee — after the sanitiser heals a stale row, the
// result must be acceptable to Validate so the next PUT doesn't reject
// the user's layout. Without this guarantee, sanitizing on read could
// produce a layout the validator won't accept on the autosave path.
func TestDashboardLayoutSpec_SanitizeForRead_ClampedSpecPassesValidate(t *testing.T) {
	s := DashboardLayoutSpec{
		Version: LayoutSpecVersion,
		Widgets: []DashboardWidgetRef{
			{Key: WidgetUpcomingDeadlines, Visible: true, X: 0, Y: 0, W: 1, H: 1},
			{Key: WidgetUpcomingDeadlines, Visible: true, X: 50, Y: 0, W: 99, H: 99}, // duplicate key — Validate will reject; this case checks size clamp at least
		},
	}
	// Trim to one widget for the validate assertion (duplicates are a
	// separate concern).
	s.Widgets = s.Widgets[:1]
	s.SanitizeForRead()
	if err := s.Validate(); err != nil {
		t.Errorf("Validate after SanitizeForRead returned %v; want nil", err)
	}
}

func TestDashboardLayoutSpec_SanitizeForRead_NoopOnClean(t *testing.T) {
	s := FactoryDefaultLayout()
	if s.SanitizeForRead() {
		t.Errorf("SanitizeForRead on factory default returned true; want false (already clean)")
	}
}

func TestDashboardLayoutSpec_SanitizeForRead_BumpsVersion(t *testing.T) {
	s := DashboardLayoutSpec{Version: 0, Widgets: []DashboardWidgetRef{{Key: WidgetDeadlineSummary, Visible: true}}}
	if !s.SanitizeForRead() {
		t.Errorf("SanitizeForRead returned false; expected version bump")
	}
	if s.Version != LayoutSpecVersion {
		t.Errorf("after sanitize: Version=%d; want %d", s.Version, LayoutSpecVersion)
	}
}

func TestParseDashboardLayoutSpec_RoundTrip(t *testing.T) {
	def := FactoryDefaultLayout()
	bytes, err := json.Marshal(def)
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	parsed, err := ParseDashboardLayoutSpec(bytes)
	if err != nil {
		t.Fatalf("parse: %v", err)
	}
	if parsed.Version != def.Version {
		t.Errorf("version mismatch: %d vs %d", parsed.Version, def.Version)
	}
	if len(parsed.Widgets) != len(def.Widgets) {
		t.Errorf("widget count mismatch: %d vs %d", len(parsed.Widgets), len(def.Widgets))
	}
}

func TestParseDashboardLayoutSpec_InvalidJSON(t *testing.T) {
	_, err := ParseDashboardLayoutSpec([]byte(`{not-json}`))
	if !errors.Is(err, ErrInvalidInput) {
		t.Fatalf("ParseDashboardLayoutSpec returned %v; want ErrInvalidInput", err)
	}
}

func TestWidgetCatalog_AllKnownKeysHaveDef(t *testing.T) {
	for _, k := range KnownWidgetKeys {
		def, ok := LookupWidgetDef(k)
		if !ok {
			t.Errorf("KnownWidgetKeys entry %q has no WidgetDef", k)
			continue
		}
		if def.TitleDE == "" || def.TitleEN == "" {
			t.Errorf("widget %q missing title (de=%q en=%q)", k, def.TitleDE, def.TitleEN)
		}
		if def.DescriptionDE == "" || def.DescriptionEN == "" {
			t.Errorf("widget %q missing description", k)
		}
	}
}

func TestWidgetCatalog_SliceC_HasPinnedAndQuickActions(t *testing.T) {
	// Slice C activated pinned-projects + quick-actions in the catalog
	// AND in KnownWidgetKeys. Lock both via this test so a future
	// change can't accidentally remove them without thinking about the
	// firm-default migration story.
	if _, ok := LookupWidgetDef(WidgetPinnedProjects); !ok {
		t.Errorf("WidgetCatalog missing pinned-projects entry")
	}
	if _, ok := LookupWidgetDef(WidgetQuickActions); !ok {
		t.Errorf("WidgetCatalog missing quick-actions entry")
	}
	var hasPinned, hasQuick bool
	for _, k := range KnownWidgetKeys {
		if k == WidgetPinnedProjects {
			hasPinned = true
		}
		if k == WidgetQuickActions {
			hasQuick = true
		}
	}
	if !hasPinned {
		t.Errorf("KnownWidgetKeys missing pinned-projects")
	}
	if !hasQuick {
		t.Errorf("KnownWidgetKeys missing quick-actions")
	}
}

func TestWidgetCatalog_NoOrphanDefs(t *testing.T) {
	known := make(map[WidgetKey]bool, len(KnownWidgetKeys))
	for _, k := range KnownWidgetKeys {
		known[k] = true
	}
	for _, def := range WidgetCatalog() {
		if !known[def.Key] {
			// Orphans are allowed (forward-compat: pinned-projects const
			// exists in widget_catalog.go before its widget module ships).
			// But verify the catalog entry is internally coherent.
			if def.TitleDE == "" || def.TitleEN == "" {
				t.Errorf("orphan catalog entry %q must still have titles", def.Key)
			}
		}
	}
}

func TestWidgetSettingsSchema_NilRejectsNonEmpty(t *testing.T) {
	var sch *WidgetSettingsSchema
	if err := sch.Validate(json.RawMessage(`{"count": 5}`)); !errors.Is(err, ErrInvalidInput) {
		t.Fatalf("nil schema accepted settings; got %v", err)
	}
	if err := sch.Validate(nil); err != nil {
		t.Errorf("nil schema rejected empty settings: %v", err)
	}
	if err := sch.Validate(json.RawMessage(`null`)); err != nil {
		t.Errorf("nil schema rejected 'null' settings: %v", err)
	}
}
