package template import ( "context" "database/sql" "fmt" "time" "github.com/felt-app/felt/internal/blind" ) // TournamentTemplate composes building blocks into a reusable tournament configuration. type TournamentTemplate struct { ID int64 `json:"id"` Name string `json:"name"` Description string `json:"description"` ChipSetID int64 `json:"chip_set_id"` BlindStructureID int64 `json:"blind_structure_id"` PayoutStructureID int64 `json:"payout_structure_id"` BuyinConfigID int64 `json:"buyin_config_id"` PointsFormulaID *int64 `json:"points_formula_id,omitempty"` MinPlayers int `json:"min_players"` MaxPlayers *int `json:"max_players,omitempty"` EarlySignupBonusChips int64 `json:"early_signup_bonus_chips"` EarlySignupCutoff *string `json:"early_signup_cutoff,omitempty"` PunctualityBonusChips int64 `json:"punctuality_bonus_chips"` IsPKO bool `json:"is_pko"` IsBuiltin bool `json:"is_builtin"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // Summary fields populated by GetTemplate (names only, not full data) ChipSetName string `json:"chip_set_name,omitempty"` BlindStructureName string `json:"blind_structure_name,omitempty"` PayoutStructureName string `json:"payout_structure_name,omitempty"` BuyinConfigName string `json:"buyin_config_name,omitempty"` } // ExpandedTemplate returns a template with ALL building block data populated. type ExpandedTemplate struct { TournamentTemplate ChipSet *ChipSet `json:"chip_set"` BlindStructure *blind.BlindStructure `json:"blind_structure"` PayoutStructure *PayoutStructure `json:"payout_structure"` BuyinConfig *BuyinConfig `json:"buyin_config"` } // TournamentTemplateService provides CRUD operations for tournament templates. type TournamentTemplateService struct { db *sql.DB chips *ChipSetService blinds *blind.StructureService payouts *PayoutService buyins *BuyinService } // NewTournamentTemplateService creates a new TournamentTemplateService. func NewTournamentTemplateService(db *sql.DB) *TournamentTemplateService { return &TournamentTemplateService{ db: db, chips: NewChipSetService(db), blinds: blind.NewStructureService(db), payouts: NewPayoutService(db), buyins: NewBuyinService(db), } } // CreateTemplate creates a new tournament template, validating all FK references exist. func (s *TournamentTemplateService) CreateTemplate(ctx context.Context, tmpl *TournamentTemplate) (*TournamentTemplate, error) { if tmpl.Name == "" { return nil, fmt.Errorf("template name is required") } // Validate FK references exist if err := s.validateReferences(ctx, tmpl); err != nil { return nil, err } now := time.Now().Unix() isPKO := boolToInt(tmpl.IsPKO) res, err := s.db.ExecContext(ctx, `INSERT INTO tournament_templates (name, description, chip_set_id, blind_structure_id, payout_structure_id, buyin_config_id, points_formula_id, min_players, max_players, early_signup_bonus_chips, early_signup_cutoff, punctuality_bonus_chips, is_pko, is_builtin, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, tmpl.Name, tmpl.Description, tmpl.ChipSetID, tmpl.BlindStructureID, tmpl.PayoutStructureID, tmpl.BuyinConfigID, tmpl.PointsFormulaID, tmpl.MinPlayers, tmpl.MaxPlayers, tmpl.EarlySignupBonusChips, tmpl.EarlySignupCutoff, tmpl.PunctualityBonusChips, isPKO, now, now, ) if err != nil { return nil, fmt.Errorf("insert template: %w", err) } id, err := res.LastInsertId() if err != nil { return nil, fmt.Errorf("get template id: %w", err) } return s.GetTemplate(ctx, id) } // validateReferences checks that all FK references in a template point to existing entities. func (s *TournamentTemplateService) validateReferences(ctx context.Context, tmpl *TournamentTemplate) error { // Check chip set exists var exists int if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM chip_sets WHERE id = ?", tmpl.ChipSetID).Scan(&exists); err != nil || exists == 0 { return fmt.Errorf("chip set %d does not exist", tmpl.ChipSetID) } if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM blind_structures WHERE id = ?", tmpl.BlindStructureID).Scan(&exists); err != nil || exists == 0 { return fmt.Errorf("blind structure %d does not exist", tmpl.BlindStructureID) } if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM payout_structures WHERE id = ?", tmpl.PayoutStructureID).Scan(&exists); err != nil || exists == 0 { return fmt.Errorf("payout structure %d does not exist", tmpl.PayoutStructureID) } if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM buyin_configs WHERE id = ?", tmpl.BuyinConfigID).Scan(&exists); err != nil || exists == 0 { return fmt.Errorf("buyin config %d does not exist", tmpl.BuyinConfigID) } if tmpl.PointsFormulaID != nil { if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM points_formulas WHERE id = ?", *tmpl.PointsFormulaID).Scan(&exists); err != nil || exists == 0 { return fmt.Errorf("points formula %d does not exist", *tmpl.PointsFormulaID) } } return nil } // GetTemplate retrieves a template by ID with building block summary names. func (s *TournamentTemplateService) GetTemplate(ctx context.Context, id int64) (*TournamentTemplate, error) { tmpl := &TournamentTemplate{} var createdAt, updatedAt int64 var isPKO, isBuiltin int err := s.db.QueryRowContext(ctx, `SELECT t.id, t.name, t.description, t.chip_set_id, t.blind_structure_id, t.payout_structure_id, t.buyin_config_id, t.points_formula_id, t.min_players, t.max_players, t.early_signup_bonus_chips, t.early_signup_cutoff, t.punctuality_bonus_chips, t.is_pko, t.is_builtin, t.created_at, t.updated_at, cs.name, bs.name, ps.name, bc.name FROM tournament_templates t JOIN chip_sets cs ON t.chip_set_id = cs.id JOIN blind_structures bs ON t.blind_structure_id = bs.id JOIN payout_structures ps ON t.payout_structure_id = ps.id JOIN buyin_configs bc ON t.buyin_config_id = bc.id WHERE t.id = ?`, id, ).Scan( &tmpl.ID, &tmpl.Name, &tmpl.Description, &tmpl.ChipSetID, &tmpl.BlindStructureID, &tmpl.PayoutStructureID, &tmpl.BuyinConfigID, &tmpl.PointsFormulaID, &tmpl.MinPlayers, &tmpl.MaxPlayers, &tmpl.EarlySignupBonusChips, &tmpl.EarlySignupCutoff, &tmpl.PunctualityBonusChips, &isPKO, &isBuiltin, &createdAt, &updatedAt, &tmpl.ChipSetName, &tmpl.BlindStructureName, &tmpl.PayoutStructureName, &tmpl.BuyinConfigName, ) if err == sql.ErrNoRows { return nil, fmt.Errorf("tournament template not found: %d", id) } if err != nil { return nil, fmt.Errorf("get template: %w", err) } tmpl.IsPKO = isPKO != 0 tmpl.IsBuiltin = isBuiltin != 0 tmpl.CreatedAt = time.Unix(createdAt, 0) tmpl.UpdatedAt = time.Unix(updatedAt, 0) return tmpl, nil } // GetTemplateExpanded retrieves a template with ALL building block data populated. func (s *TournamentTemplateService) GetTemplateExpanded(ctx context.Context, id int64) (*ExpandedTemplate, error) { tmpl, err := s.GetTemplate(ctx, id) if err != nil { return nil, err } expanded := &ExpandedTemplate{TournamentTemplate: *tmpl} expanded.ChipSet, err = s.chips.GetChipSet(ctx, tmpl.ChipSetID) if err != nil { return nil, fmt.Errorf("expand chip set: %w", err) } expanded.BlindStructure, err = s.blinds.GetStructure(ctx, tmpl.BlindStructureID) if err != nil { return nil, fmt.Errorf("expand blind structure: %w", err) } expanded.PayoutStructure, err = s.payouts.GetPayoutStructure(ctx, tmpl.PayoutStructureID) if err != nil { return nil, fmt.Errorf("expand payout structure: %w", err) } expanded.BuyinConfig, err = s.buyins.GetBuyinConfig(ctx, tmpl.BuyinConfigID) if err != nil { return nil, fmt.Errorf("expand buyin config: %w", err) } return expanded, nil } // ListTemplates returns all tournament templates with building block summary names. func (s *TournamentTemplateService) ListTemplates(ctx context.Context) ([]TournamentTemplate, error) { rows, err := s.db.QueryContext(ctx, `SELECT t.id, t.name, t.description, t.chip_set_id, t.blind_structure_id, t.payout_structure_id, t.buyin_config_id, t.points_formula_id, t.min_players, t.max_players, t.early_signup_bonus_chips, t.early_signup_cutoff, t.punctuality_bonus_chips, t.is_pko, t.is_builtin, t.created_at, t.updated_at, cs.name, bs.name, ps.name, bc.name FROM tournament_templates t JOIN chip_sets cs ON t.chip_set_id = cs.id JOIN blind_structures bs ON t.blind_structure_id = bs.id JOIN payout_structures ps ON t.payout_structure_id = ps.id JOIN buyin_configs bc ON t.buyin_config_id = bc.id ORDER BY t.is_builtin DESC, t.name`, ) if err != nil { return nil, fmt.Errorf("list templates: %w", err) } defer rows.Close() var templates []TournamentTemplate for rows.Next() { var tmpl TournamentTemplate var createdAt, updatedAt int64 var isPKO, isBuiltin int if err := rows.Scan( &tmpl.ID, &tmpl.Name, &tmpl.Description, &tmpl.ChipSetID, &tmpl.BlindStructureID, &tmpl.PayoutStructureID, &tmpl.BuyinConfigID, &tmpl.PointsFormulaID, &tmpl.MinPlayers, &tmpl.MaxPlayers, &tmpl.EarlySignupBonusChips, &tmpl.EarlySignupCutoff, &tmpl.PunctualityBonusChips, &isPKO, &isBuiltin, &createdAt, &updatedAt, &tmpl.ChipSetName, &tmpl.BlindStructureName, &tmpl.PayoutStructureName, &tmpl.BuyinConfigName, ); err != nil { return nil, fmt.Errorf("scan template: %w", err) } tmpl.IsPKO = isPKO != 0 tmpl.IsBuiltin = isBuiltin != 0 tmpl.CreatedAt = time.Unix(createdAt, 0) tmpl.UpdatedAt = time.Unix(updatedAt, 0) templates = append(templates, tmpl) } return templates, rows.Err() } // UpdateTemplate updates a tournament template. func (s *TournamentTemplateService) UpdateTemplate(ctx context.Context, tmpl *TournamentTemplate) error { if tmpl.Name == "" { return fmt.Errorf("template name is required") } if err := s.validateReferences(ctx, tmpl); err != nil { return err } now := time.Now().Unix() isPKO := boolToInt(tmpl.IsPKO) res, err := s.db.ExecContext(ctx, `UPDATE tournament_templates SET name = ?, description = ?, chip_set_id = ?, blind_structure_id = ?, payout_structure_id = ?, buyin_config_id = ?, points_formula_id = ?, min_players = ?, max_players = ?, early_signup_bonus_chips = ?, early_signup_cutoff = ?, punctuality_bonus_chips = ?, is_pko = ?, updated_at = ? WHERE id = ?`, tmpl.Name, tmpl.Description, tmpl.ChipSetID, tmpl.BlindStructureID, tmpl.PayoutStructureID, tmpl.BuyinConfigID, tmpl.PointsFormulaID, tmpl.MinPlayers, tmpl.MaxPlayers, tmpl.EarlySignupBonusChips, tmpl.EarlySignupCutoff, tmpl.PunctualityBonusChips, isPKO, now, tmpl.ID, ) if err != nil { return fmt.Errorf("update template: %w", err) } n, _ := res.RowsAffected() if n == 0 { return fmt.Errorf("tournament template not found: %d", tmpl.ID) } return nil } // DeleteTemplate deletes a tournament template. Built-in templates cannot be deleted. func (s *TournamentTemplateService) DeleteTemplate(ctx context.Context, id int64) error { var isBuiltin int err := s.db.QueryRowContext(ctx, "SELECT is_builtin FROM tournament_templates WHERE id = ?", id).Scan(&isBuiltin) if err == sql.ErrNoRows { return fmt.Errorf("tournament template not found: %d", id) } if err != nil { return fmt.Errorf("check template: %w", err) } if isBuiltin != 0 { return fmt.Errorf("cannot delete built-in tournament template") } res, err := s.db.ExecContext(ctx, "DELETE FROM tournament_templates WHERE id = ?", id) if err != nil { return fmt.Errorf("delete template: %w", err) } n, _ := res.RowsAffected() if n == 0 { return fmt.Errorf("tournament template not found: %d", id) } return nil } // DuplicateTemplate creates an independent copy of a tournament template. func (s *TournamentTemplateService) DuplicateTemplate(ctx context.Context, id int64, newName string) (*TournamentTemplate, error) { original, err := s.GetTemplate(ctx, id) if err != nil { return nil, fmt.Errorf("get original: %w", err) } if newName == "" { newName = original.Name + " (Copy)" } copy := *original copy.ID = 0 copy.Name = newName copy.IsBuiltin = false return s.CreateTemplate(ctx, ©) } // SaveAsTemplate creates a new template from a tournament's current config. func (s *TournamentTemplateService) SaveAsTemplate(ctx context.Context, tournamentID string, name string) (*TournamentTemplate, error) { if name == "" { return nil, fmt.Errorf("template name is required") } var tmpl TournamentTemplate var isPKO int err := s.db.QueryRowContext(ctx, `SELECT chip_set_id, blind_structure_id, payout_structure_id, buyin_config_id, points_formula_id, min_players, max_players, early_signup_bonus_chips, early_signup_cutoff, punctuality_bonus_chips, is_pko FROM tournaments WHERE id = ?`, tournamentID, ).Scan( &tmpl.ChipSetID, &tmpl.BlindStructureID, &tmpl.PayoutStructureID, &tmpl.BuyinConfigID, &tmpl.PointsFormulaID, &tmpl.MinPlayers, &tmpl.MaxPlayers, &tmpl.EarlySignupBonusChips, &tmpl.EarlySignupCutoff, &tmpl.PunctualityBonusChips, &isPKO, ) if err == sql.ErrNoRows { return nil, fmt.Errorf("tournament not found: %s", tournamentID) } if err != nil { return nil, fmt.Errorf("get tournament config: %w", err) } tmpl.Name = name tmpl.IsPKO = isPKO != 0 return s.CreateTemplate(ctx, &tmpl) }