package services

// CardLayoutService is the CRUD layer for paliad.user_card_layouts —
// per-user named card layouts for the /projects Cards view.
//
// Design: docs/design-projects-page-2026-05-07.md §5b.3.
//
// Visibility: every read and write is scoped to the calling user via the
// RLS policy `user_card_layouts_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"
	"strings"
	"time"

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

// UserCardLayout is the persisted shape of a saved card layout.
type UserCardLayout struct {
	ID         uuid.UUID       `db:"id"          json:"id"`
	UserID     uuid.UUID       `db:"user_id"     json:"user_id"`
	Name       string          `db:"name"        json:"name"`
	IsDefault  bool            `db:"is_default"  json:"is_default"`
	LayoutJSON json.RawMessage `db:"layout_json" json:"layout"`
	CreatedAt  time.Time       `db:"created_at"  json:"created_at"`
	UpdatedAt  time.Time       `db:"updated_at"  json:"updated_at"`
}

// CardLayoutService manages paliad.user_card_layouts.
type CardLayoutService struct {
	db *sqlx.DB
}

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

// ErrUserCardLayoutNameTaken signals "name already exists for this user".
// HTTP layer maps to 409.
var ErrUserCardLayoutNameTaken = errors.New("card layout name taken")

// ErrUserCardLayoutNotFound signals "no row matches (id, user_id)". HTTP
// layer maps to 404.
var ErrUserCardLayoutNotFound = errors.New("card layout not found")

// ErrUserCardLayoutDefaultGate signals "cannot delete the active default
// layout — switch defaults first." HTTP layer maps to 409.
var ErrUserCardLayoutDefaultGate = errors.New("cannot delete default card layout")

// CreateInput is the payload for Create.
type CreateCardLayoutInput struct {
	Name      string
	Layout    LayoutSpec
	IsDefault bool // first layout per user is implicitly the default
}

// UpdateInput is the partial-update payload. All fields nil = no change.
type UpdateCardLayoutInput struct {
	Name      *string
	Layout    *LayoutSpec
	IsDefault *bool
}

// List returns the user's layouts in name order, default first.
func (s *CardLayoutService) List(ctx context.Context, userID uuid.UUID) ([]UserCardLayout, error) {
	out := []UserCardLayout{}
	err := s.db.SelectContext(ctx, &out, `
		SELECT id, user_id, name, is_default, layout_json, created_at, updated_at
		  FROM paliad.user_card_layouts
		 WHERE user_id = $1
		 ORDER BY is_default DESC, name ASC
	`, userID)
	if err != nil {
		return nil, fmt.Errorf("list card layouts: %w", err)
	}
	return out, nil
}

// Get returns one layout by id (gated on user_id).
func (s *CardLayoutService) Get(ctx context.Context, userID, id uuid.UUID) (*UserCardLayout, error) {
	var l UserCardLayout
	err := s.db.GetContext(ctx, &l, `
		SELECT id, user_id, name, is_default, layout_json, created_at, updated_at
		  FROM paliad.user_card_layouts
		 WHERE id = $1 AND user_id = $2
	`, id, userID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrUserCardLayoutNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("get card layout: %w", err)
	}
	return &l, nil
}

// GetDefault returns the user's default layout. Auto-seeds the seed
// "Standard" layout (DefaultLayoutSpec) on the first call so callers can
// always treat it as never-failing for read-only paths.
func (s *CardLayoutService) GetDefault(ctx context.Context, userID uuid.UUID) (*UserCardLayout, error) {
	var l UserCardLayout
	err := s.db.GetContext(ctx, &l, `
		SELECT id, user_id, name, is_default, layout_json, created_at, updated_at
		  FROM paliad.user_card_layouts
		 WHERE user_id = $1 AND is_default = true
	`, userID)
	if err == nil {
		return &l, nil
	}
	if !errors.Is(err, sql.ErrNoRows) {
		return nil, fmt.Errorf("get default card layout: %w", err)
	}
	// First-ever call for this user — seed the Standard layout.
	return s.Create(ctx, userID, CreateCardLayoutInput{
		Name:      "Standard",
		Layout:    DefaultLayoutSpec(),
		IsDefault: true,
	})
}

// Create writes a new layout. If the user has no rows yet, the new row
// becomes the default regardless of input.IsDefault. If input.IsDefault
// is true and another default exists, the previous default's flag is
// cleared in the same transaction.
func (s *CardLayoutService) Create(ctx context.Context, userID uuid.UUID, in CreateCardLayoutInput) (*UserCardLayout, error) {
	if err := validateLayoutName(in.Name); err != nil {
		return nil, err
	}
	if err := in.Layout.Validate(); err != nil {
		return nil, err
	}
	layoutBytes, err := json.Marshal(in.Layout)
	if err != nil {
		return nil, fmt.Errorf("%w: layout JSON encode: %v", ErrInvalidInput, err)
	}

	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return nil, fmt.Errorf("create card layout begin tx: %w", err)
	}
	defer tx.Rollback() //nolint:errcheck — Commit() supersedes; Rollback() on success is a no-op

	var existingCount int
	if err := tx.GetContext(ctx, &existingCount, `
		SELECT COUNT(*) FROM paliad.user_card_layouts WHERE user_id = $1
	`, userID); err != nil {
		return nil, fmt.Errorf("count existing layouts: %w", err)
	}
	wantDefault := in.IsDefault || existingCount == 0
	if wantDefault {
		if _, err := tx.ExecContext(ctx, `
			UPDATE paliad.user_card_layouts SET is_default = false, updated_at = now()
			 WHERE user_id = $1 AND is_default = true
		`, userID); err != nil {
			return nil, fmt.Errorf("clear prior default: %w", err)
		}
	}

	var l UserCardLayout
	err = tx.GetContext(ctx, &l, `
		INSERT INTO paliad.user_card_layouts (user_id, name, is_default, layout_json)
		VALUES ($1, $2, $3, $4)
		RETURNING id, user_id, name, is_default, layout_json, created_at, updated_at
	`, userID, in.Name, wantDefault, json.RawMessage(layoutBytes))
	if err != nil {
		if isPgUniqueViolation(err) {
			return nil, ErrUserCardLayoutNameTaken
		}
		return nil, fmt.Errorf("insert card layout: %w", err)
	}
	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("create card layout commit: %w", err)
	}
	return &l, nil
}

// Update writes one or more partial fields. is_default=true flips the
// user's default in a transaction.
func (s *CardLayoutService) Update(ctx context.Context, userID, id uuid.UUID, in UpdateCardLayoutInput) (*UserCardLayout, error) {
	// First ensure the row exists + is owned.
	if _, err := s.Get(ctx, userID, id); err != nil {
		return nil, err
	}

	if in.Name != nil {
		if err := validateLayoutName(*in.Name); err != nil {
			return nil, err
		}
	}
	if in.Layout != nil {
		if err := in.Layout.Validate(); err != nil {
			return nil, err
		}
	}

	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return nil, fmt.Errorf("update card layout begin tx: %w", err)
	}
	defer tx.Rollback() //nolint:errcheck

	if in.IsDefault != nil && *in.IsDefault {
		if _, err := tx.ExecContext(ctx, `
			UPDATE paliad.user_card_layouts SET is_default = false, updated_at = now()
			 WHERE user_id = $1 AND is_default = true AND id <> $2
		`, userID, id); err != nil {
			return nil, fmt.Errorf("clear prior default: %w", err)
		}
	}

	sets := []string{"updated_at = now()"}
	args := []any{userID, id}
	if in.Name != nil {
		sets = append(sets, fmt.Sprintf("name = $%d", len(args)+1))
		args = append(args, *in.Name)
	}
	if in.Layout != nil {
		layoutBytes, err := json.Marshal(*in.Layout)
		if err != nil {
			return nil, fmt.Errorf("%w: layout JSON encode: %v", ErrInvalidInput, err)
		}
		sets = append(sets, fmt.Sprintf("layout_json = $%d", len(args)+1))
		args = append(args, json.RawMessage(layoutBytes))
	}
	if in.IsDefault != nil {
		sets = append(sets, fmt.Sprintf("is_default = $%d", len(args)+1))
		args = append(args, *in.IsDefault)
	}

	q := fmt.Sprintf(`
		UPDATE paliad.user_card_layouts SET %s
		 WHERE user_id = $1 AND id = $2
		 RETURNING id, user_id, name, is_default, layout_json, created_at, updated_at
	`, strings.Join(sets, ", "))

	var l UserCardLayout
	err = tx.GetContext(ctx, &l, q, args...)
	if err != nil {
		if isPgUniqueViolation(err) {
			return nil, ErrUserCardLayoutNameTaken
		}
		return nil, fmt.Errorf("update card layout: %w", err)
	}
	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("update card layout commit: %w", err)
	}
	return &l, nil
}

// SetDefault is sugar over Update with only IsDefault set. It also flips
// the prior default in the same transaction.
func (s *CardLayoutService) SetDefault(ctx context.Context, userID, id uuid.UUID) (*UserCardLayout, error) {
	def := true
	return s.Update(ctx, userID, id, UpdateCardLayoutInput{IsDefault: &def})
}

// Delete removes a layout. Cannot delete the active default — UI gates
// this; the service returns ErrUserCardLayoutDefaultGate as a backstop.
func (s *CardLayoutService) Delete(ctx context.Context, userID, id uuid.UUID) error {
	row, err := s.Get(ctx, userID, id)
	if err != nil {
		return err
	}
	if row.IsDefault {
		return ErrUserCardLayoutDefaultGate
	}
	res, err := s.db.ExecContext(ctx, `
		DELETE FROM paliad.user_card_layouts
		 WHERE id = $1 AND user_id = $2
	`, id, userID)
	if err != nil {
		return fmt.Errorf("delete card layout: %w", err)
	}
	rows, _ := res.RowsAffected()
	if rows == 0 {
		return ErrUserCardLayoutNotFound
	}
	return nil
}

// validateLayoutName mirrors the column's NOT NULL + a sane length cap so
// the UI dropdown doesn't have 200-char names breaking the layout. Named
// distinctly from user_view_service.validateName because the user_view
// rules differ (they enforce a slug regex separately).
func validateLayoutName(name string) error {
	if strings.TrimSpace(name) == "" {
		return fmt.Errorf("%w: name is required", ErrInvalidInput)
	}
	if len([]rune(name)) > 80 {
		return fmt.Errorf("%w: name exceeds 80 characters", ErrInvalidInput)
	}
	return nil
}

// isPgUniqueViolation reports whether err is a Postgres unique-violation
// (SQLSTATE 23505). Used to map "duplicate (user_id, name)" to a clean
// 409 ErrUserCardLayoutNameTaken.
func isPgUniqueViolation(err error) bool {
	var pgErr *pq.Error
	return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
