package services

// UserViewService is the CRUD layer for paliad.user_views — saved Custom
// View definitions per user.
//
// Design: docs/design-data-display-model-2026-05-06.md §5.
//
// Visibility: every read and write is scoped to the calling user via the
// RLS policy `user_views_owner_all` on auth.uid() = user_id. The service
// also AND-joins user_id in the SQL for defense-in-depth (RLS can be
// disabled in tests, the code-level check still holds).

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

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

// UserView is the persisted shape of a saved Custom View.
type UserView struct {
	ID          uuid.UUID       `db:"id"            json:"id"`
	UserID      uuid.UUID       `db:"user_id"       json:"user_id"`
	Slug        string          `db:"slug"          json:"slug"`
	Name        string          `db:"name"          json:"name"`
	Icon        *string         `db:"icon"          json:"icon,omitempty"`
	FilterSpec  json.RawMessage `db:"filter_spec"   json:"filter_spec"`
	RenderSpec  json.RawMessage `db:"render_spec"   json:"render_spec"`
	SortOrder   int             `db:"sort_order"    json:"sort_order"`
	ShowCount   bool            `db:"show_count"    json:"show_count"`
	LastUsedAt  *time.Time      `db:"last_used_at"  json:"last_used_at,omitempty"`
	CreatedAt   time.Time       `db:"created_at"    json:"created_at"`
	UpdatedAt   time.Time       `db:"updated_at"    json:"updated_at"`
}

// UserViewService manages paliad.user_views.
type UserViewService struct {
	db *sqlx.DB
}

// NewUserViewService wires the service to the pool.
func NewUserViewService(db *sqlx.DB) *UserViewService {
	return &UserViewService{db: db}
}

// CreateUserViewInput is the payload for Create.
type CreateUserViewInput struct {
	Slug       string
	Name       string
	Icon       *string
	FilterSpec FilterSpec
	RenderSpec RenderSpec
	ShowCount  bool
	// SortOrder is server-assigned (MAX+1) on create — callers cannot set it.
}

// UpdateUserViewInput is the partial-update payload. All fields are
// optional; nil means "no change".
type UpdateUserViewInput struct {
	Slug       *string
	Name       *string
	Icon       *string // pointer-to-pointer-of-string would be clearer for "clear vs unchanged"; we treat *string{""} as "clear"
	FilterSpec *FilterSpec
	RenderSpec *RenderSpec
	SortOrder  *int
	ShowCount  *bool
}

// slugRE caps slugs to URL-safe lowercase. Same shape as the migration
// comment promises (^[a-z0-9][a-z0-9-]{0,62}$).
var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)

// ErrUserViewSlugTaken signals "slug already exists for this user". The
// HTTP layer maps this to 409.
var ErrUserViewSlugTaken = errors.New("user_view slug already exists for this user")

// ListForUser returns the caller's saved views, ordered by sort_order ASC
// then name. Result is the same shape /api/user-views returns to the
// frontend on app load (sidebar hydration).
func (s *UserViewService) ListForUser(ctx context.Context, userID uuid.UUID) ([]UserView, error) {
	var rows []UserView
	err := s.db.SelectContext(ctx, &rows, `
		SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
		       sort_order, show_count, last_used_at, created_at, updated_at
		  FROM paliad.user_views
		 WHERE user_id = $1
		 ORDER BY sort_order ASC, name ASC`, userID)
	if err != nil {
		return nil, fmt.Errorf("list user_views: %w", err)
	}
	return rows, nil
}

// GetBySlug fetches one view by slug. Returns (nil, nil) when the slug
// is unknown for this user.
func (s *UserViewService) GetBySlug(ctx context.Context, userID uuid.UUID, slug string) (*UserView, error) {
	var v UserView
	err := s.db.GetContext(ctx, &v, `
		SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
		       sort_order, show_count, last_used_at, created_at, updated_at
		  FROM paliad.user_views
		 WHERE user_id = $1 AND slug = $2`, userID, slug)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil
	}
	if err != nil {
		return nil, fmt.Errorf("get user_view: %w", err)
	}
	return &v, nil
}

// GetByID fetches one view by id. Same nil-on-miss semantic.
func (s *UserViewService) GetByID(ctx context.Context, userID, id uuid.UUID) (*UserView, error) {
	var v UserView
	err := s.db.GetContext(ctx, &v, `
		SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
		       sort_order, show_count, last_used_at, created_at, updated_at
		  FROM paliad.user_views
		 WHERE user_id = $1 AND id = $2`, userID, id)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil
	}
	if err != nil {
		return nil, fmt.Errorf("get user_view: %w", err)
	}
	return &v, nil
}

// Create persists a new view for the caller. Server-assigns sort_order
// to MAX(existing)+1 inside the same tx so two parallel creates don't
// collide.
func (s *UserViewService) Create(ctx context.Context, userID uuid.UUID, input CreateUserViewInput) (*UserView, error) {
	if err := validateCreateInput(input); err != nil {
		return nil, err
	}
	filterJSON, err := MarshalFilterSpec(input.FilterSpec)
	if err != nil {
		return nil, err
	}
	renderJSON, err := MarshalRenderSpec(input.RenderSpec)
	if err != nil {
		return nil, err
	}

	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return nil, fmt.Errorf("begin tx: %w", err)
	}
	defer func() { _ = tx.Rollback() }()

	var nextSortOrder int
	if err := tx.GetContext(ctx, &nextSortOrder, `
		SELECT COALESCE(MAX(sort_order), -1) + 1
		  FROM paliad.user_views
		 WHERE user_id = $1`, userID); err != nil {
		return nil, fmt.Errorf("compute next sort_order: %w", err)
	}

	var v UserView
	err = tx.GetContext(ctx, &v, `
		INSERT INTO paliad.user_views
		    (user_id, slug, name, icon, filter_spec, render_spec, sort_order, show_count)
		VALUES
		    ($1, $2, $3, $4, $5, $6, $7, $8)
		RETURNING id, user_id, slug, name, icon, filter_spec, render_spec,
		          sort_order, show_count, last_used_at, created_at, updated_at`,
		userID, input.Slug, input.Name, input.Icon,
		filterJSON, renderJSON, nextSortOrder, input.ShowCount)
	if err != nil {
		if isUniqueViolation(err) {
			return nil, fmt.Errorf("%w: %s", ErrUserViewSlugTaken, input.Slug)
		}
		return nil, fmt.Errorf("insert user_view: %w", err)
	}

	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit user_view create: %w", err)
	}
	return &v, nil
}

// Update applies a partial update to an existing view. Returns
// (nil, nil) if the row doesn't exist for this user.
func (s *UserViewService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateUserViewInput) (*UserView, error) {
	current, err := s.GetByID(ctx, userID, id)
	if err != nil {
		return nil, err
	}
	if current == nil {
		return nil, nil
	}

	// Coalesce input over current.
	slug := current.Slug
	if input.Slug != nil {
		slug = *input.Slug
	}
	if err := validateSlug(slug); err != nil {
		return nil, err
	}

	name := current.Name
	if input.Name != nil {
		name = *input.Name
	}
	if err := validateName(name); err != nil {
		return nil, err
	}

	icon := current.Icon
	if input.Icon != nil {
		s := *input.Icon
		if s == "" {
			icon = nil
		} else {
			icon = &s
		}
	}

	filterJSON := []byte(current.FilterSpec)
	if input.FilterSpec != nil {
		b, err := MarshalFilterSpec(*input.FilterSpec)
		if err != nil {
			return nil, err
		}
		filterJSON = b
	}

	renderJSON := []byte(current.RenderSpec)
	if input.RenderSpec != nil {
		b, err := MarshalRenderSpec(*input.RenderSpec)
		if err != nil {
			return nil, err
		}
		renderJSON = b
	}

	sortOrder := current.SortOrder
	if input.SortOrder != nil {
		sortOrder = *input.SortOrder
	}

	showCount := current.ShowCount
	if input.ShowCount != nil {
		showCount = *input.ShowCount
	}

	var v UserView
	err = s.db.GetContext(ctx, &v, `
		UPDATE paliad.user_views
		   SET slug = $3, name = $4, icon = $5,
		       filter_spec = $6, render_spec = $7,
		       sort_order = $8, show_count = $9,
		       updated_at = now()
		 WHERE user_id = $1 AND id = $2
		 RETURNING id, user_id, slug, name, icon, filter_spec, render_spec,
		           sort_order, show_count, last_used_at, created_at, updated_at`,
		userID, id, slug, name, icon, filterJSON, renderJSON, sortOrder, showCount)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil
	}
	if err != nil {
		if isUniqueViolation(err) {
			return nil, fmt.Errorf("%w: %s", ErrUserViewSlugTaken, slug)
		}
		return nil, fmt.Errorf("update user_view: %w", err)
	}
	return &v, nil
}

// Delete removes a saved view. Single Yes/No modal on the frontend
// (Q25 lock-in); no audit emit (these are personal working state).
// Returns (false, nil) when the row didn't exist.
func (s *UserViewService) Delete(ctx context.Context, userID, id uuid.UUID) (bool, error) {
	res, err := s.db.ExecContext(ctx, `
		DELETE FROM paliad.user_views
		 WHERE user_id = $1 AND id = $2`, userID, id)
	if err != nil {
		return false, fmt.Errorf("delete user_view: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return false, fmt.Errorf("delete user_view rows affected: %w", err)
	}
	return n > 0, nil
}

// Touch updates last_used_at to now. Fire-and-forget from the page
// handler — no error surface to the user.
func (s *UserViewService) Touch(ctx context.Context, userID, id uuid.UUID) error {
	_, err := s.db.ExecContext(ctx, `
		UPDATE paliad.user_views
		   SET last_used_at = now()
		 WHERE user_id = $1 AND id = $2`, userID, id)
	if err != nil {
		return fmt.Errorf("touch user_view: %w", err)
	}
	return nil
}

// MostRecent returns the caller's most-recently-used view, or nil if
// the user has none / has never opened one. Used for the /views landing
// (Q10 most-recently-used default).
func (s *UserViewService) MostRecent(ctx context.Context, userID uuid.UUID) (*UserView, error) {
	var v UserView
	err := s.db.GetContext(ctx, &v, `
		SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
		       sort_order, show_count, last_used_at, created_at, updated_at
		  FROM paliad.user_views
		 WHERE user_id = $1
		   AND last_used_at IS NOT NULL
		 ORDER BY last_used_at DESC
		 LIMIT 1`, userID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil
	}
	if err != nil {
		return nil, fmt.Errorf("most-recent user_view: %w", err)
	}
	return &v, nil
}

// ============================================================================
// Validators (slug + name + create input)
// ============================================================================

func validateSlug(slug string) error {
	if slug == "" {
		return fmt.Errorf("%w: slug is required", ErrInvalidInput)
	}
	if !slugRE.MatchString(slug) {
		return fmt.Errorf("%w: slug must match ^[a-z0-9][a-z0-9-]{0,62}$ (got %q)", ErrInvalidInput, slug)
	}
	if IsReservedUserViewSlug(slug) {
		return fmt.Errorf("%w: slug %q is reserved", ErrInvalidInput, slug)
	}
	return nil
}

func validateName(name string) error {
	if name == "" {
		return fmt.Errorf("%w: name is required", ErrInvalidInput)
	}
	// 1-character names are fine (some users may want 1-letter shortcuts).
	// 200 is the codebase-wide cap (matches Notes / Checklists).
	if len(name) > 200 {
		return fmt.Errorf("%w: name exceeds 200 characters", ErrInvalidInput)
	}
	return nil
}

func validateCreateInput(input CreateUserViewInput) error {
	if err := validateSlug(input.Slug); err != nil {
		return err
	}
	if err := validateName(input.Name); err != nil {
		return err
	}
	if input.Icon != nil && len(*input.Icon) > 64 {
		return fmt.Errorf("%w: icon key exceeds 64 characters", ErrInvalidInput)
	}
	if err := input.FilterSpec.Validate(); err != nil {
		return err
	}
	if err := input.RenderSpec.Validate(); err != nil {
		return err
	}
	return nil
}

// isUniqueViolation is shared with event_type_service.go (defined there).
