package services

// DashboardLayoutSpec — JSON shape for paliad.user_dashboard_layouts.layout_json.
//
// Design: docs/design-dashboard-configurable-2026-05-20.md §5.2.
//
// Validation surface:
//   - version must be 1 (v0 / unknown versions seed the factory default at
//     read time; the validator only ever sees writes from a current client).
//   - widgets is at most 32 entries (sanity cap; catalog can grow but a
//     single user's layout shouldn't).
//   - each widget.key must be in KnownWidgetKeys on WRITE.
//   - no duplicate keys.
//   - each widget.settings (if present) is validated against its catalog
//     entry's WidgetSettingsSchema.
//
// On READ, unknown keys are dropped silently — see SanitizeForRead.

import (
	"encoding/json"
	"fmt"
	"slices"
)

// LayoutSpecVersion is the only supported version for v1.
const LayoutSpecVersion = 1

// LayoutWidgetCap is the sanity cap on widgets per layout. The v1 catalog
// has 7 entries; 32 leaves room for catalog growth without unbounded JSON
// blobs.
const LayoutWidgetCap = 32

// DashboardWidgetRef is a single widget entry in the ordered widgets[] array.
// Visible=false entries are kept in the array so the picker can show them as
// "hidden" and re-adding restores their position.
type DashboardWidgetRef struct {
	Key      WidgetKey       `json:"key"`
	Visible  bool            `json:"visible"`
	Settings json.RawMessage `json:"settings,omitempty"`
}

// DashboardLayoutSpec is the persisted layout shape.
type DashboardLayoutSpec struct {
	Version int                  `json:"v"`
	Widgets []DashboardWidgetRef `json:"widgets"`
}

// FactoryDefaultLayout returns the Slice A1 baseline layout — every
// widget in KnownWidgetKeys, visible, in canonical order, with per-widget
// default settings drawn from the catalog. A user with no row sees this
// on first load and is byte-identical to today's dashboard plus the new
// inbox-approvals widget.
func FactoryDefaultLayout() DashboardLayoutSpec {
	catalog := WidgetCatalog()
	byKey := make(map[WidgetKey]WidgetDef, len(catalog))
	for _, def := range catalog {
		byKey[def.Key] = def
	}

	widgets := make([]DashboardWidgetRef, 0, len(KnownWidgetKeys))
	for _, k := range KnownWidgetKeys {
		def, ok := byKey[k]
		if !ok {
			continue
		}
		ref := DashboardWidgetRef{Key: k, Visible: def.DefaultVisible}
		if settings := defaultSettingsJSON(def); settings != nil {
			ref.Settings = settings
		}
		widgets = append(widgets, ref)
	}

	return DashboardLayoutSpec{
		Version: LayoutSpecVersion,
		Widgets: widgets,
	}
}

// defaultSettingsJSON encodes the per-widget defaults declared on the
// catalog entry. Returns nil when the widget has no settings.
func defaultSettingsJSON(def WidgetDef) json.RawMessage {
	if def.DefaultCount == nil && def.DefaultHorizon == nil {
		return nil
	}
	out := map[string]int{}
	if def.DefaultCount != nil {
		out["count"] = *def.DefaultCount
	}
	if def.DefaultHorizon != nil {
		out["horizon_days"] = *def.DefaultHorizon
	}
	b, err := json.Marshal(out)
	if err != nil {
		return nil
	}
	return b
}

// Validate enforces the structural invariants on write. Returns
// ErrInvalidInput wrapped with a precise message on the first violation.
func (s DashboardLayoutSpec) Validate() error {
	if s.Version != LayoutSpecVersion {
		return fmt.Errorf("%w: layout version %d not supported (want %d)",
			ErrInvalidInput, s.Version, LayoutSpecVersion)
	}
	if len(s.Widgets) > LayoutWidgetCap {
		return fmt.Errorf("%w: layout has %d widgets (cap %d)",
			ErrInvalidInput, len(s.Widgets), LayoutWidgetCap)
	}

	seen := make(map[WidgetKey]bool, len(s.Widgets))
	for i, w := range s.Widgets {
		if !slices.Contains(KnownWidgetKeys, w.Key) {
			return fmt.Errorf("%w: widgets[%d].key %q is not a known widget",
				ErrInvalidInput, i, w.Key)
		}
		if seen[w.Key] {
			return fmt.Errorf("%w: widgets has duplicate key %q",
				ErrInvalidInput, w.Key)
		}
		seen[w.Key] = true

		def, ok := LookupWidgetDef(w.Key)
		if !ok {
			// Defense in depth — KnownWidgetKeys was checked above.
			return fmt.Errorf("%w: widgets[%d].key %q has no catalog entry",
				ErrInvalidInput, i, w.Key)
		}
		if err := def.Settings.Validate(w.Settings); err != nil {
			return fmt.Errorf("widgets[%d]: %w", i, err)
		}
	}
	return nil
}

// SanitizeForRead applies the forgiving read-path rules: drop entries whose
// keys are not in the catalog (catalog has shrunk) and bump the version to
// the current one if missing. Settings on surviving entries pass through
// unchanged — invalid settings on read are not worth aborting over and the
// next write will reject them anyway.
//
// Returns true if anything was changed; callers can use that to decide
// whether to PUT the cleaned spec back.
func (s *DashboardLayoutSpec) SanitizeForRead() bool {
	changed := false
	if s.Version != LayoutSpecVersion {
		s.Version = LayoutSpecVersion
		changed = true
	}
	if len(s.Widgets) == 0 {
		return changed
	}
	out := make([]DashboardWidgetRef, 0, len(s.Widgets))
	for _, w := range s.Widgets {
		if _, ok := LookupWidgetDef(w.Key); !ok {
			changed = true
			continue
		}
		out = append(out, w)
	}
	s.Widgets = out
	return changed
}

// ParseDashboardLayoutSpec decodes JSON bytes and validates. Used by the
// HTTP handler on incoming request bodies.
func ParseDashboardLayoutSpec(b []byte) (DashboardLayoutSpec, error) {
	var s DashboardLayoutSpec
	if err := json.Unmarshal(b, &s); err != nil {
		return DashboardLayoutSpec{}, fmt.Errorf("%w: layout JSON decode: %v", ErrInvalidInput, err)
	}
	if err := s.Validate(); err != nil {
		return DashboardLayoutSpec{}, err
	}
	return s, nil
}
