package services

// DashboardLayoutService is the CRUD layer for paliad.user_dashboard_layouts —
// per-user configurable dashboard layout for /dashboard.
//
// Design: docs/design-dashboard-configurable-2026-05-20.md §5.4.
//
// Visibility: every read and write is scoped to the calling user via the
// RLS policy `user_dashboard_layouts_owner_all` on auth.uid() = user_id.
// The service also AND-joins user_id in SQL for defense-in-depth.

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"

	"github.com/google/uuid"
	"github.com/jmoiron/sqlx"
)

// DashboardLayoutService manages paliad.user_dashboard_layouts.
type DashboardLayoutService struct {
	db          *sqlx.DB
	firmDefault *FirmDashboardDefaultService
}

// NewDashboardLayoutService wires the service.
func NewDashboardLayoutService(db *sqlx.DB) *DashboardLayoutService {
	return &DashboardLayoutService{db: db}
}

// SetFirmDefaultService wires the firm-wide default source (Slice C).
// When set and non-empty, GetOrSeed/ResetToDefault prefer it over the
// code-resident FactoryDefaultLayout. nil-safe — when unwired or when
// the table is empty, behavior falls back to the code-resident default.
func (s *DashboardLayoutService) SetFirmDefaultService(f *FirmDashboardDefaultService) {
	s.firmDefault = f
}

// defaultLayout returns the firm-wide default if one is set, else the
// code-resident FactoryDefaultLayout. Used by the seed and reset paths.
// On any error reading the firm row, falls back to the factory default
// so a transient DB blip can't strand a user without a dashboard.
func (s *DashboardLayoutService) defaultLayout(ctx context.Context) DashboardLayoutSpec {
	if s.firmDefault == nil {
		return FactoryDefaultLayout()
	}
	spec, ok, err := s.firmDefault.Get(ctx)
	if err != nil || !ok {
		return FactoryDefaultLayout()
	}
	return spec
}

// GetOrSeed returns the caller's saved layout. On first call for a user
// (no row), it inserts and returns the factory default. The seed is
// idempotent — concurrent first-loads converge to the same row via the
// ON CONFLICT DO NOTHING clause.
//
// The returned spec has SanitizeForRead applied; if any entries were
// dropped (catalog shrank) the cleaned spec is also persisted back so the
// next write doesn't trip on stale entries.
func (s *DashboardLayoutService) GetOrSeed(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
	spec, found, err := s.fetch(ctx, userID)
	if err != nil {
		return DashboardLayoutSpec{}, err
	}
	if !found {
		return s.seedFactoryDefault(ctx, userID)
	}
	if spec.SanitizeForRead() {
		// Best-effort cleanup; on failure we still return the in-memory
		// sanitized spec — the user sees a clean dashboard either way.
		_ = s.upsert(ctx, userID, spec)
	}
	return spec, nil
}

// Update validates the spec and UPSERTs it. Returns the persisted spec
// (round-tripped through the DB to confirm storage).
func (s *DashboardLayoutService) Update(ctx context.Context, userID uuid.UUID, spec DashboardLayoutSpec) (DashboardLayoutSpec, error) {
	if err := spec.Validate(); err != nil {
		return DashboardLayoutSpec{}, err
	}
	if err := s.upsert(ctx, userID, spec); err != nil {
		return DashboardLayoutSpec{}, err
	}
	out, found, err := s.fetch(ctx, userID)
	if err != nil {
		return DashboardLayoutSpec{}, err
	}
	if !found {
		return DashboardLayoutSpec{}, fmt.Errorf("dashboard layout vanished after upsert for user %s", userID)
	}
	return out, nil
}

// ResetToDefault overwrites the user's layout with the firm-wide default
// when one is set, otherwise with the code-resident factory default
// (Slice C). The single user-facing "Auf Standard zurücksetzen" link
// always lands the user on whatever the firm considers default at the
// time of the click — admins can update it later and users get the new
// default on their next reset.
func (s *DashboardLayoutService) ResetToDefault(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
	def := s.defaultLayout(ctx)
	if err := s.upsert(ctx, userID, def); err != nil {
		return DashboardLayoutSpec{}, err
	}
	return def, nil
}

// fetch returns (spec, found, err). found=false means the user has no row
// yet — the seed path takes over.
func (s *DashboardLayoutService) fetch(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, bool, error) {
	var raw json.RawMessage
	err := s.db.GetContext(ctx, &raw, `
		SELECT layout_json
		  FROM paliad.user_dashboard_layouts
		 WHERE user_id = $1
	`, userID)
	if errors.Is(err, sql.ErrNoRows) {
		return DashboardLayoutSpec{}, false, nil
	}
	if err != nil {
		return DashboardLayoutSpec{}, false, fmt.Errorf("fetch dashboard layout: %w", err)
	}
	var spec DashboardLayoutSpec
	if err := json.Unmarshal(raw, &spec); err != nil {
		// Stored row is unparseable — treat as a missing row, the seed
		// path will overwrite it. Log via the returned error wrapper.
		return DashboardLayoutSpec{}, false, fmt.Errorf("dashboard layout JSON decode for user %s: %w", userID, err)
	}
	return spec, true, nil
}

// seedFactoryDefault inserts the firm-wide default (if set) or the
// code-resident factory layout for a brand-new user. ON CONFLICT DO
// NOTHING handles the race where two concurrent first loads both miss
// the SELECT and both try to insert. Function name kept as "Factory"
// even though it may now seed the firm default — renaming the call site
// would churn one file for no callsite benefit.
func (s *DashboardLayoutService) seedFactoryDefault(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) {
	def := s.defaultLayout(ctx)
	bytes, err := json.Marshal(def)
	if err != nil {
		return DashboardLayoutSpec{}, fmt.Errorf("seed dashboard layout marshal: %w", err)
	}
	if _, err := s.db.ExecContext(ctx, `
		INSERT INTO paliad.user_dashboard_layouts (user_id, layout_json)
		VALUES ($1, $2)
		ON CONFLICT (user_id) DO NOTHING
	`, userID, json.RawMessage(bytes)); err != nil {
		return DashboardLayoutSpec{}, fmt.Errorf("seed dashboard layout insert: %w", err)
	}
	// Re-fetch in case ON CONFLICT DO NOTHING let another writer's row win;
	// either way the user now has a row.
	out, found, err := s.fetch(ctx, userID)
	if err != nil {
		return DashboardLayoutSpec{}, err
	}
	if !found {
		// Extremely unlikely — would mean the row vanished between
		// INSERT and SELECT. Return the factory default in-memory.
		return def, nil
	}
	return out, nil
}

// upsert overwrites the layout. updated_at gets bumped on conflict so
// callers can observe write recency.
func (s *DashboardLayoutService) upsert(ctx context.Context, userID uuid.UUID, spec DashboardLayoutSpec) error {
	bytes, err := json.Marshal(spec)
	if err != nil {
		return fmt.Errorf("dashboard layout marshal: %w", err)
	}
	_, err = s.db.ExecContext(ctx, `
		INSERT INTO paliad.user_dashboard_layouts (user_id, layout_json)
		VALUES ($1, $2)
		ON CONFLICT (user_id) DO UPDATE
		   SET layout_json = EXCLUDED.layout_json,
		       updated_at  = now()
	`, userID, json.RawMessage(bytes))
	if err != nil {
		return fmt.Errorf("dashboard layout upsert: %w", err)
	}
	return nil
}
