package controllers

import (
	"crypto/rand"
	"crypto/tls"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"time"

	"image/jpeg"
	"image/png"

	"fotbal-club/internal/config"
	"fotbal-club/internal/models"
	"fotbal-club/internal/services"
	"fotbal-club/pkg/email"
	"fotbal-club/pkg/logger"
	"fotbal-club/pkg/utils"

	"golang.org/x/crypto/bcrypt"

	"github.com/gin-gonic/gin"
	"gopkg.in/mail.v2"
	"gorm.io/gorm"
)

// BaseController handles all the base API endpoints
type BaseController struct {
	DB *gorm.DB
}

// GetMatchesHistory returns cached past matches with overrides applied (public)
// Optional query: q= filters by home/away/venue/competition
func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
	p := filepath.Join("cache", "prefetch", "events_past.json")
	f, err := os.Open(p)
	if err != nil {
		c.JSON(http.StatusNoContent, gin.H{"message": "No cached past matches"})
		return
	}
	defer f.Close()

	var matches []map[string]interface{}
	if err := json.NewDecoder(f).Decode(&matches); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Cannot parse cached past matches"})
		return
	}

	// Apply overrides (same as in GetMatches)
	var movs []models.MatchOverride
	if err := bc.DB.Find(&movs).Error; err == nil {
		movByID := map[string]models.MatchOverride{}
		for _, m := range movs {
			movByID[m.ExternalMatchID] = m
		}

		var tlovs []models.TeamLogoOverride
		if err := bc.DB.Find(&tlovs).Error; err == nil {
			tloByTeam := map[string]models.TeamLogoOverride{}
			for _, t := range tlovs {
				tloByTeam[t.ExternalTeamID] = t
			}

			for _, m := range matches {
				var matchID string
				if v, ok := m["match_id"].(string); ok {
					matchID = v
				} else if v2, ok2 := m["id"].(string); ok2 {
					matchID = v2
				}

				if ov, ok := movByID[matchID]; ok {
					if ov.HomeNameOverride != nil {
						m["home"] = *ov.HomeNameOverride
						m["home_team"] = *ov.HomeNameOverride
					}
					if ov.AwayNameOverride != nil {
						m["away"] = *ov.AwayNameOverride
						m["away_team"] = *ov.AwayNameOverride
					}
					if ov.VenueOverride != nil {
						m["venue"] = *ov.VenueOverride
					}
					if ov.DateTimeOverride != nil {
						m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
						m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
					}
					if ov.HomeLogoURL != nil {
						m["home_logo_url"] = *ov.HomeLogoURL
					}
					if ov.AwayLogoURL != nil {
						m["away_logo_url"] = *ov.AwayLogoURL
					}
				}

				if homeTeamID, ok := m["home_team_id"].(string); ok {
					if tlo, found := tloByTeam[homeTeamID]; found && tlo.LogoURL != "" {
						m["home_logo_url"] = tlo.LogoURL
					}
				}
				if awayTeamID, ok := m["away_team_id"].(string); ok {
					if tlo, found := tloByTeam[awayTeamID]; found && tlo.LogoURL != "" {
						m["away_logo_url"] = tlo.LogoURL
					}
				}
			}
		}
	}

	// Optional search filter
	if s := strings.ToLower(strings.TrimSpace(c.Query("q"))); s != "" {
		filtered := make([]map[string]interface{}, 0, len(matches))
		for _, m := range matches {
			get := func(k string) string {
				if v, ok := m[k]; ok {
					if vs, ok2 := v.(string); ok2 {
						return vs
					}
				}
				return ""
			}
			fields := []string{get("home"), get("away"), get("venue"), get("competition"), get("competition_name"), get("league")}
			matched := false
			for _, f := range fields {
				if f == "" {
					continue
				}
				if strings.Contains(strings.ToLower(f), s) {
					matched = true
					break
				}
			}
			if matched {
				filtered = append(filtered, m)
			}
		}
		matches = filtered
	}

	c.Header("Cache-Control", "public, max-age=60")
	c.JSON(http.StatusOK, matches)
}

// writeCompetitionAliasesCache writes a JSON snapshot of competition aliases to cache/prefetch/competition_aliases.json
// This keeps the on-disk cache in sync immediately after admin changes, without waiting for the prefetcher cycle.
func (bc *BaseController) writeCompetitionAliasesCache() {
	// Load all aliases ordered by display_order (same as public API)
	var items []models.CompetitionAlias
	if err := bc.DB.Order("CASE WHEN display_order = 0 THEN 999999 ELSE display_order END ASC, code ASC").Find(&items).Error; err != nil {
		return
	}
	// Marshal pretty JSON for easier inspection
	b, err := json.MarshalIndent(items, "", "  ")
	if err != nil {
		return
	}
	// Ensure destination directory exists
	dir := filepath.Join("cache", "prefetch")
	_ = os.MkdirAll(dir, 0o755)
	// Atomic write via temporary file then rename
	tmp := filepath.Join(dir, "competition_aliases.json.tmp")
	dst := filepath.Join(dir, "competition_aliases.json")
	if err := os.WriteFile(tmp, b, 0o644); err == nil {
		_ = os.Rename(tmp, dst)
		// Sidecar header with minimal metadata, similar to prefetch pattern
		hdr := map[string]string{
			"fetched_at": time.Now().Format(time.RFC3339),
		}
		if hb, err := json.Marshal(hdr); err == nil {
			_ = os.WriteFile(dst+".hdr", hb, 0o644)
		}
	}
}

// --- Zonerama integration (public proxy + unified picks cache) ---

// GetZoneramaAlbum proxies a single Zonerama album request without caching.
// Query: link=ZONERAMA_ALBUM_URL (&photo_limit=10) (&rendered=true|false)
func (bc *BaseController) GetZoneramaAlbum(c *gin.Context) {
	link := strings.TrimSpace(c.Query("link"))
	if link == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "missing link"})
		return
	}
	photoLimit := strings.TrimSpace(c.DefaultQuery("photo_limit", "24"))
	rendered := strings.TrimSpace(c.DefaultQuery("rendered", "true"))
	// Build external URL
	api := "https://zonerama.tdvorak.dev/zonerama-album?link=" + url.QueryEscape(link)
	if photoLimit != "" {
		api += "&photo_limit=" + url.QueryEscape(photoLimit)
	}
	if rendered != "" {
		api += "&rendered=" + url.QueryEscape(rendered)
	}

	req, err := http.NewRequest("GET", api, nil)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	req.Header.Set("User-Agent", "fotbal-club/zonerama-proxy")
	client := &http.Client{Timeout: 25 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
		return
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("zonerama status %d", resp.StatusCode)})
		return
	}
	c.Header("Cache-Control", "no-store")
	c.Header("Content-Type", "application/json; charset=utf-8")
	if _, err := io.Copy(c.Writer, resp.Body); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "stream failed"})
		return
	}
}

type zoneramaPick struct {
	ID       string `json:"id"`
	AlbumID  string `json:"album_id"`
	AlbumURL string `json:"album_url"`
	PageURL  string `json:"page_url"`
	ImageURL string `json:"image_url"` // prefer cached local URL if available
	Title    string `json:"title"`
	PickedAt string `json:"picked_at"`
}

func picksPath() string {
	return filepath.Join("cache", "prefetch", "zonerama", "picks.json")
}

// GetZoneramaPicks returns the unified picks JSON (public)
func (bc *BaseController) GetZoneramaPicks(c *gin.Context) {
	p := picksPath()
	f, err := os.Open(p)
	if err != nil {
		// no content yet
		c.JSON(http.StatusOK, []zoneramaPick{})
		return
	}
	defer f.Close()
	var items []zoneramaPick
	if err := json.NewDecoder(f).Decode(&items); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid picks cache"})
		return
	}
	c.JSON(http.StatusOK, items)
}

// PutZoneramaPick saves or updates a chosen image + album link to unified cache (admin)
// Body: { id, album_id, album_url, page_url, image_url, title? }
func (bc *BaseController) PutZoneramaPick(c *gin.Context) {
	var body zoneramaPick
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	body.ID = strings.TrimSpace(body.ID)
	body.AlbumID = strings.TrimSpace(body.AlbumID)
	body.AlbumURL = strings.TrimSpace(body.AlbumURL)
	body.PageURL = strings.TrimSpace(body.PageURL)
	body.ImageURL = strings.TrimSpace(body.ImageURL)
	if body.ID == "" || body.ImageURL == "" || body.AlbumURL == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "id, image_url and album_url are required"})
		return
	}
	if body.PickedAt == "" {
		body.PickedAt = time.Now().Format(time.RFC3339)
	}
	// Load existing list (if any)
	path := picksPath()
	_ = os.MkdirAll(filepath.Dir(path), 0o755)
	var items []zoneramaPick
	if b, err := os.ReadFile(path); err == nil {
		_ = json.Unmarshal(b, &items)
	}
	// Upsert by ID (photo id); if missing, append at the front
	updated := false
	for i := range items {
		if items[i].ID == body.ID {
			items[i] = body
			updated = true
			break
		}
	}
	if !updated {
		// Prepend new picks to keep the latest at the top
		items = append([]zoneramaPick{body}, items...)
		// Trim to a reasonable size to avoid unbounded growth
		if len(items) > 500 {
			items = items[:500]
		}
	}
	// Write atomically
	tmp := path + ".tmp"
	if b, err := json.MarshalIndent(items, "", "  "); err == nil {
		if err := os.WriteFile(tmp, b, 0o644); err == nil {
			_ = os.Rename(tmp, path)
			c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(items)})
			return
		}
	}
	c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot write picks"})
}

// --- Admin: Cache RAW viewer ---

// GetAdminCacheList lists available JSON cache files from known cache roots.
// Returns: [{ label, path, size_bytes, mod_time }]
func (bc *BaseController) GetAdminCacheList(c *gin.Context) {
	type item struct {
		Label   string    `json:"label"`
		Path    string    `json:"path"`
		Size    int64     `json:"size_bytes"`
		ModTime time.Time `json:"mod_time"`
	}

	var out []item
	// Helper to scan a directory for .json files
	scan := func(root, labelPrefix string) {
		_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
			if err != nil {
				return nil
			}
			if info.IsDir() {
				return nil
			}
			if strings.HasSuffix(strings.ToLower(info.Name()), ".json") {
				rel := strings.TrimPrefix(filepath.ToSlash(p), "./")
				rel = "/" + rel
				out = append(out, item{
					Label:   labelPrefix + info.Name(),
					Path:    rel,
					Size:    info.Size(),
					ModTime: info.ModTime(),
				})
			}
			return nil
		})
	}

	scan(filepath.Join("cache", "prefetch"), "Prefetch: ")
	scan(filepath.Join("cache", "facr"), "FACR: ")

	// Stable order by label
	if len(out) > 1 {
		// simple insertion sort to avoid adding sort import
		for i := 1; i < len(out); i++ {
			j := i
			for j > 0 && strings.ToLower(out[j-1].Label) > strings.ToLower(out[j].Label) {
				out[j-1], out[j] = out[j], out[j-1]
				j--
			}
		}
	}

	c.JSON(http.StatusOK, gin.H{"files": out})
}

// GetAdminCacheFile streams a cache file as raw JSON. For FACR cache entries which
// are stored as a wrapper {"data": <json>, "stored_at": ...}, it unwraps and returns only the JSON in data.
// Query: path=/cache/... relative to server root; only allowed under /cache/prefetch and /cache/facr
func (bc *BaseController) GetAdminCacheFile(c *gin.Context) {
	raw := strings.TrimSpace(c.Query("path"))
	if raw == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "missing path"})
		return
	}
	// Security: allow only specific roots
	if !(strings.HasPrefix(raw, "/cache/prefetch/") || strings.HasPrefix(raw, "/cache/facr/")) {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
		return
	}
	// Map URL path to filesystem path
	fsPath := strings.TrimPrefix(raw, "/")
	fsPath = filepath.FromSlash(fsPath)

	// Read file
	b, err := os.ReadFile(fsPath)
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
		return
	}

	// If FACR cache: attempt to unwrap cachedItem
	if strings.HasPrefix(raw, "/cache/facr/") {
		var wrap struct {
			Data     json.RawMessage `json:"data"`
			StoredAt any             `json:"stored_at"`
		}
		if json.Unmarshal(b, &wrap) == nil && len(wrap.Data) > 0 {
			c.Header("Content-Type", "application/json; charset=utf-8")
			c.Status(http.StatusOK)
			_, _ = c.Writer.Write(wrap.Data)
			return
		}
		// If unwrap failed, fall back to raw bytes
	}

	// Default: stream as-is
	c.Header("Content-Type", "application/json; charset=utf-8")
	c.Status(http.StatusOK)
	_, _ = c.Writer.Write(b)
}

// --- Article â‡„ Match Link (FACR external match id) ---

// GetArticleMatchLink returns the linked external match id for a given article, if any (protected)
func (bc *BaseController) GetArticleMatchLink(c *gin.Context) {
	id := c.Param("id")
	var art models.Article
	if err := bc.DB.Select("id").First(&art, id).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"error": "Článek nenalezen"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
		return
	}
	var link models.ArticleMatchLink
	if err := bc.DB.Where("article_id = ?", art.ID).First(&link).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusOK, gin.H{"article_id": art.ID})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"article_id": art.ID, "external_match_id": link.ExternalMatchID, "title": link.Title})
}

// PutArticleMatchLink creates or replaces the link (protected)
func (bc *BaseController) PutArticleMatchLink(c *gin.Context) {
	id := c.Param("id")
	var art models.Article
	if err := bc.DB.Select("id").First(&art, id).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"error": "Článek nenalezen"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
		return
	}
	var body struct {
		ExternalMatchID string `json:"external_match_id"`
		Title           string `json:"title"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	ext := strings.TrimSpace(body.ExternalMatchID)
	if ext == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "external_match_id je povinné"})
		return
	}
	var link models.ArticleMatchLink
	if err := bc.DB.Where("article_id = ?", art.ID).First(&link).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			link = models.ArticleMatchLink{ArticleID: art.ID, ExternalMatchID: ext, Title: strings.TrimSpace(body.Title)}
			if err := bc.DB.Create(&link).Error; err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit odkaz"})
				return
			}
		} else {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
			return
		}
	} else {
		link.ExternalMatchID = ext
		link.Title = strings.TrimSpace(body.Title)
		if err := bc.DB.Save(&link).Error; err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit odkaz"})
			return
		}
	}
	c.JSON(http.StatusOK, gin.H{"article_id": art.ID, "external_match_id": link.ExternalMatchID, "title": link.Title})
}

// DeleteArticleMatchLink removes the link (protected)
func (bc *BaseController) DeleteArticleMatchLink(c *gin.Context) {
	id := c.Param("id")
	var art models.Article
	if err := bc.DB.Select("id").First(&art, id).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"error": "Článek nenalezen"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
		return
	}
	if err := bc.DB.Where("article_id = ?", art.ID).Delete(&models.ArticleMatchLink{}).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze odstranit odkaz"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"ok": true})
}

// deriveSeoDescription generates a short SEO description from HTML content (max ~160 chars)
func deriveSeoDescription(html string) string {
	// Remove HTML tags
	reTags := regexp.MustCompile("<[^>]+>")
	text := reTags.ReplaceAllString(html, " ")
	// Normalize whitespace
	text = strings.TrimSpace(strings.Join(strings.Fields(text), " "))
	if text == "" {
		return ""
	}
	// Limit to ~160 characters, avoid cutting words abruptly when possible
	if len([]rune(text)) > 160 {
		runes := []rune(text)
		cut := 160
		// try to cut at last space before 160
		for i := cut; i >= 120; i-- {
			if runes[i] == ' ' {
				cut = i
				break
			}
		}
		text = strings.TrimSpace(string(runes[:cut])) + "..."
	}
	return text
}

// GetArticle returns a single article by ID (public)
func (bc *BaseController) GetArticle(c *gin.Context) {
	id := c.Param("id")
	var art models.Article
	if err := bc.DB.Preload("Author").Preload("Category").First(&art, id).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}
	if art.ImageURL == "" {
		art.ImageURL = "/dist/img/logo-club-empty.svg"
	}
	if art.ReadTime == 0 {
		art.ReadTime = computeEstimatedReadMinutes(art.Content)
	}
	// Load match link if exists
	var matchLink models.ArticleMatchLink
	if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil {
		art.MatchLink = &matchLink
	}
	c.JSON(http.StatusOK, art)
}

// GetCategories returns a list of all categories (public)
func (bc *BaseController) GetCategories(c *gin.Context) {
	var items []models.Category
	if err := bc.DB.Order("name ASC").Find(&items).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}
	c.JSON(http.StatusOK, items)
}

// CreateCategory creates a new category (admin only)
func (bc *BaseController) CreateCategory(c *gin.Context) {
	var body struct {
		Name        string `json:"name" binding:"required"`
		Description string `json:"description"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
		return
	}

	name := strings.TrimSpace(body.Name)
	if name == "" {
		c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název kategorie je povinný"})
		return
	}

	// Check if category with same name already exists
	var existing models.Category
	if err := bc.DB.Where("name = ?", name).First(&existing).Error; err == nil {
		c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"})
		return
	}

	cat := models.Category{
		Name:        name,
		Description: strings.TrimSpace(body.Description),
	}

	if err := bc.DB.Create(&cat).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
		return
	}

	c.JSON(http.StatusCreated, cat)
}

// UpdateCategory updates an existing category (admin only)
func (bc *BaseController) UpdateCategory(c *gin.Context) {
	id := c.Param("id")

	var cat models.Category
	if err := bc.DB.First(&cat, id).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"chyba": "Kategorie nenalezena"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}

	var body struct {
		Name        *string `json:"name"`
		Description *string `json:"description"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
		return
	}

	if body.Name != nil {
		name := strings.TrimSpace(*body.Name)
		if name == "" {
			c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název kategorie nemůže být prázdný"})
			return
		}
		// Check if another category with same name exists
		var existing models.Category
		if err := bc.DB.Where("name = ? AND id != ?", name, id).First(&existing).Error; err == nil {
			c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"})
			return
		}
		cat.Name = name
	}

	if body.Description != nil {
		cat.Description = strings.TrimSpace(*body.Description)
	}

	if err := bc.DB.Save(&cat).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat kategorii"})
		return
	}

	c.JSON(http.StatusOK, cat)
}

// DeleteCategory deletes a category (admin only)
func (bc *BaseController) DeleteCategory(c *gin.Context) {
	id := c.Param("id")

	var cat models.Category
	if err := bc.DB.First(&cat, id).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"chyba": "Kategorie nenalezena"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}

	// Check if any articles are using this category
	var articleCount int64
	if err := bc.DB.Model(&models.Article{}).Where("category_id = ?", id).Count(&articleCount).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole článků"})
		return
	}

	if articleCount > 0 {
		c.JSON(http.StatusConflict, gin.H{
			"chyba":  "Nelze smazat kategorii, která obsahuje články",
			"detail": fmt.Sprintf("Kategorie obsahuje %d článků", articleCount),
		})
		return
	}

	if err := bc.DB.Delete(&cat).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat kategorii"})
		return
	}

	c.JSON(http.StatusOK, gin.H{"zprava": "Kategorie byla smazána"})
}

// GetArticleBySlug returns a single article by slug (public)
func (bc *BaseController) GetArticleBySlug(c *gin.Context) {
	slug := strings.TrimSpace(c.Param("slug"))
	if slug == "" {
		c.JSON(http.StatusBadRequest, gin.H{"chyba": "Chyba slug"})
		return
	}
	var art models.Article
	if err := bc.DB.Preload("Author").Preload("Category").Where("slug = ?", slug).First(&art).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}
	if art.ImageURL == "" {
		art.ImageURL = "/dist/img/logo-club-empty.svg"
	}
	if art.ReadTime == 0 {
		art.ReadTime = computeEstimatedReadMinutes(art.Content)
	}
	// Load match link if exists
	var matchLink models.ArticleMatchLink
	if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil {
		art.MatchLink = &matchLink
	}
	c.JSON(http.StatusOK, art)
}

// writeArticlesCache writes a JSON snapshot of PUBLISHED articles to cache/blogs/articles.json
// Shape: { "items": [Article], "total": N, "page": 1, "page_size": N }
func (bc *BaseController) writeArticlesCache() {
	// Load only published articles ordered by published_at desc, created_at desc
	var items []models.Article
	if err := bc.DB.Where("published = ?", true).Order("published_at DESC, created_at DESC").Find(&items).Error; err != nil {
		return
	}
	// Ensure image fallback
	for i := range items {
		if items[i].ImageURL == "" {
			items[i].ImageURL = "/dist/img/logo-club-empty.svg"
		}
	}
	payload := map[string]any{
		"items":     items,
		"total":     len(items),
		"page":      1,
		"page_size": len(items),
	}
	b, err := json.MarshalIndent(payload, "", "  ")
	if err != nil {
		return
	}
	dir := filepath.Join("cache", "blogs")
	_ = os.MkdirAll(dir, 0o755)
	tmp := filepath.Join(dir, "articles.json.tmp")
	dst := filepath.Join(dir, "articles.json")
	if err := os.WriteFile(tmp, b, 0o644); err == nil {
		_ = os.Rename(tmp, dst)
	}
}

// respondArticlesFromCache attempts to read cache/blogs/articles.json (or cache/prefetch/articles.json) and respond.
// Returns true if a response was written.
func (bc *BaseController) respondArticlesFromCache(c *gin.Context, page, size int) bool {
	// Helper to page slice safely
	pageSlice := func(arr []map[string]any, page, size int) []map[string]any {
		if size <= 0 {
			size = len(arr)
		}
		if page <= 0 {
			page = 1
		}
		start := (page - 1) * size
		if start >= len(arr) {
			return []map[string]any{}
		}
		end := start + size
		if end > len(arr) {
			end = len(arr)
		}
		return arr[start:end]
	}

	readAndRespond := func(p string) bool {
		f, err := os.Open(p)
		if err != nil {
			return false
		}
		defer f.Close()
		var raw map[string]any
		if err := json.NewDecoder(f).Decode(&raw); err != nil {
			return false
		}
		// Normalize items array
		var arr []map[string]any
		if its, ok := raw["items"].([]any); ok {
			for _, it := range its {
				if m, ok := it.(map[string]any); ok {
					arr = append(arr, m)
				}
			}
		} else if its, ok := raw["data"].([]any); ok {
			for _, it := range its {
				if m, ok := it.(map[string]any); ok {
					arr = append(arr, m)
				}
			}
		}
		if len(arr) == 0 {
			return false
		}
		// Optional filters from query
		publishedOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("published", "false"))) == "true"
		featuredOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("featured", "false"))) == "true"
		if publishedOnly || featuredOnly {
			filtered := make([]map[string]any, 0, len(arr))
			for _, m := range arr {
				if publishedOnly {
					if v, ok := m["published"].(bool); !ok || !v {
						continue
					}
				}
				if featuredOnly {
					if v, ok := m["featured"].(bool); !ok || !v {
						continue
					}
				}
				filtered = append(filtered, m)
			}
			arr = filtered
		}
		if len(arr) == 0 {
			return false
		}
		total := len(arr)
		paged := pageSlice(arr, page, size)
		c.JSON(http.StatusOK, gin.H{"items": paged, "total": total, "page": page, "page_size": size})
		return true
	}

	// Try blogs cache first
	if readAndRespond(filepath.Join("cache", "blogs", "articles.json")) {
		return true
	}
	// Fallback to prefetch cache if available
	if readAndRespond(filepath.Join("cache", "prefetch", "articles.json")) {
		return true
	}
	return false
}

// ValidateSMTP attempts to connect to an SMTP server using provided settings.
// It does NOT send an email; it only tries to establish a connection (and authenticate if username/password provided).
// POST /api/v1/setup/validate-smtp { host, port, username?, password?, from?, use_tls? }
func (bc *BaseController) ValidateSMTP(c *gin.Context) {
	type req struct {
		Host     string `json:"host"`
		Port     int    `json:"port"`
		Username string `json:"username"`
		Password string `json:"password"`
		From     string `json:"from"`
		UseTLS   *bool  `json:"use_tls"`
	}
	var body req
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": err.Error()})
		return
	}
	host := strings.TrimSpace(body.Host)
	if host == "" || body.Port <= 0 {
		c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "host and port are required"})
		return
	}

	d := mail.NewDialer(host, body.Port, strings.TrimSpace(body.Username), body.Password)
	// Determine encryption: implicit SSL for 465, STARTTLS otherwise (as supported by server)
	if body.UseTLS != nil {
		// If explicitly requested TLS and port is 465, use implicit SSL
		if *body.UseTLS && body.Port == 465 {
			d.SSL = true
		} else {
			d.SSL = false
		}
	} else {
		d.SSL = body.Port == 465
	}
	d.TLSConfig = &tls.Config{InsecureSkipVerify: false, ServerName: host}
	d.Timeout = 20 * time.Second

	// Try to open the connection (auth is attempted automatically if username/password provided)
	sc, err := d.Dial()
	if err != nil {
		errorMsg := err.Error()
		// Provide more helpful error messages for common authentication issues
		if strings.Contains(errorMsg, "535") || strings.Contains(strings.ToLower(errorMsg), "authentication failed") {
			errorMsg = "Chyba autentizace (535): Zkontrolujte prosím uživatelské jméno a heslo. " + errorMsg
		} else if strings.Contains(errorMsg, "530") {
			errorMsg = "Vyžadována autentizace (530): Server vyžaduje uživatelské jméno a heslo. " + errorMsg
		} else if strings.Contains(errorMsg, "connection refused") || strings.Contains(errorMsg, "no route to host") {
			errorMsg = "Nelze se připojit k serveru: Zkontrolujte host a port. " + errorMsg
		} else if strings.Contains(errorMsg, "certificate") || strings.Contains(errorMsg, "tls") {
			errorMsg = "Chyba TLS/SSL: Zkontrolujte nastavení šifrování a port. " + errorMsg
		}
		c.JSON(http.StatusOK, gin.H{"ok": false, "error": errorMsg})
		return
	}
	_ = sc.Close()
	c.JSON(http.StatusOK, gin.H{"ok": true, "message": "SMTP připojení a autentizace proběhly úspěšně"})
}

// GetYouTubeVideos returns cached YouTube channel JSON from prefetch cache (public)
// It reads cache/prefetch/youtube_channel.json and streams the JSON payload as-is.
func (bc *BaseController) GetYouTubeVideos(c *gin.Context) {
	p := filepath.Join("cache", "prefetch", "youtube_channel.json")
	f, err := os.Open(p)
	if err != nil {
		c.JSON(http.StatusNoContent, gin.H{"message": "No cached YouTube data"})
		return
	}
	defer f.Close()
	c.Header("Cache-Control", "public, max-age=600")
	c.Header("Content-Type", "application/json; charset=utf-8")
	if _, err := io.Copy(c.Writer, f); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze nacist cache"})
		return
	}
}

// HealthCheck returns a simple status and verifies database connectivity
func (bc *BaseController) HealthCheck(c *gin.Context) {
	// Default status
	status := gin.H{"status": "ok"}

	if bc.DB != nil {
		if sqlDB, err := bc.DB.DB(); err == nil {
			if err := sqlDB.Ping(); err != nil {
				c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unhealthy", "db": "down"})
				return
			}
		} else {
			c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unhealthy", "db": "unavailable"})
			return
		}
	}

	c.JSON(http.StatusOK, status)
}

// --- Competition Aliases ---

// Admin: list all competition aliases
func (bc *BaseController) GetCompetitionAliases(c *gin.Context) {
	var items []models.CompetitionAlias
	// Order by display_order first (0 values go last), then by code
	if err := bc.DB.Order("CASE WHEN display_order = 0 THEN 999999 ELSE display_order END ASC, code ASC").Find(&items).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
		return
	}
	c.JSON(http.StatusOK, items)
}

// Admin: create or replace alias by code (idempotent)
func (bc *BaseController) PutCompetitionAlias(c *gin.Context) {
	code := strings.TrimSpace(c.Param("code"))
	if code == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba code"})
		return
	}
	var body struct {
		Alias        string `json:"alias"`
		OriginalName string `json:"original_name"`
		DisplayOrder *int   `json:"display_order,omitempty"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if strings.TrimSpace(body.Alias) == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "alias je povinný"})
		return
	}
	var item models.CompetitionAlias
	if err := bc.DB.Where("code = ?", code).First(&item).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			item = models.CompetitionAlias{Code: code}
		} else {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
			return
		}
	}
	item.Alias = body.Alias
	item.OriginalName = body.OriginalName
	if body.DisplayOrder != nil {
		item.DisplayOrder = *body.DisplayOrder
	}
	if item.ID == 0 {
		if err := bc.DB.Create(&item).Error; err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit alias"})
			return
		}
	} else {
		if err := bc.DB.Save(&item).Error; err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit alias"})
			return
		}
	}
	// Update cache snapshot so /cache/prefetch/competition_aliases.json reflects changes immediately
	bc.writeCompetitionAliasesCache()
	c.JSON(http.StatusOK, item)
}

// Admin: delete alias by code
func (bc *BaseController) DeleteCompetitionAlias(c *gin.Context) {
	code := strings.TrimSpace(c.Param("code"))
	if code == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba code"})
		return
	}
	if err := bc.DB.Where("code = ?", code).Delete(&models.CompetitionAlias{}).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze smazat alias"})
		return
	}
	// Update cache snapshot after deletion
	bc.writeCompetitionAliasesCache()
	c.JSON(http.StatusOK, gin.H{"ok": true})
}

// Admin: bulk reorder competition aliases
func (bc *BaseController) ReorderCompetitionAliases(c *gin.Context) {
	var body struct {
		Items []struct {
			Code         string `json:"code"`
			DisplayOrder int    `json:"display_order"`
		} `json:"items"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	
	// Update each item's display_order in a transaction
	tx := bc.DB.Begin()
	for _, item := range body.Items {
		if err := tx.Model(&models.CompetitionAlias{}).
			Where("code = ?", item.Code).
			Update("display_order", item.DisplayOrder).Error; err != nil {
			tx.Rollback()
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze aktualizovat pořadí"})
			return
		}
	}
	if err := tx.Commit().Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"})
		return
	}
	
	// Update cache snapshot after reordering
	bc.writeCompetitionAliasesCache()
	c.JSON(http.StatusOK, gin.H{"ok": true, "updated": len(body.Items)})
}

// autoPopulateCompetitionAliases reads FACR cache and creates missing competition aliases
func (bc *BaseController) autoPopulateCompetitionAliases() {
	defer func() {
		if r := recover(); r != nil {
			logger.Error("Panic in autoPopulateCompetitionAliases: panic=%v", r)
		}
	}()

	// Read FACR club info cache
	cacheFile := filepath.Join("cache", "prefetch", "facr_club_info.json")
	data, err := os.ReadFile(cacheFile)
	if err != nil {
		logger.Warn("Cannot read FACR cache for alias auto-population: %v", err)
		return
	}

	var facrData struct {
		Competitions []struct {
			ID   string `json:"id"`
			Code string `json:"code"`
			Name string `json:"name"`
		} `json:"competitions"`
	}
	if err := json.Unmarshal(data, &facrData); err != nil {
		logger.Warn("Cannot parse FACR cache: %v", err)
		return
	}

	// Get existing aliases
	var existing []models.CompetitionAlias
	if err := bc.DB.Find(&existing).Error; err != nil {
		logger.Warn("Cannot fetch existing competition aliases: %v", err)
		return
	}

	existingCodes := make(map[string]bool)
	for _, alias := range existing {
		existingCodes[alias.Code] = true
	}

	// Create missing aliases
	created := 0
	for _, comp := range facrData.Competitions {
		// Use code if available, otherwise fall back to ID
		code := strings.TrimSpace(comp.Code)
		if code == "" {
			code = strings.TrimSpace(comp.ID)
		}
		name := strings.TrimSpace(comp.Name)
		if code == "" || existingCodes[code] {
			continue
		}

		newAlias := models.CompetitionAlias{
			Code:         code,
			Alias:        name, // Default alias to original name
			OriginalName: name,
		}
		if err := bc.DB.Create(&newAlias).Error; err != nil {
			logger.Warn("Failed to create competition alias: code=%s error=%v", code, err)
			continue
		}
		created++
		existingCodes[code] = true
	}

	if created > 0 {
		logger.Info("Auto-populated competition aliases: count=%d", created)
	}
}

// autoPopulateYouTubeVideos reads YouTube cache and auto-selects 5 most recent videos for homepage
func (bc *BaseController) autoPopulateYouTubeVideos(settings *models.Settings) {
	defer func() {
		if r := recover(); r != nil {
			logger.Error("Panic in autoPopulateYouTubeVideos: panic=%v", r)
		}
	}()

	// Read YouTube cache
	cacheFile := filepath.Join("cache", "prefetch", "youtube_channel.json")
	data, err := os.ReadFile(cacheFile)
	if err != nil {
		logger.Warn("Cannot read YouTube cache for auto-population: %v", err)
		return
	}

	var ytData struct {
		Videos []struct {
			VideoID       string `json:"video_id"`
			Title         string `json:"title"`
			ThumbnailURL  string `json:"thumbnail_url"`
			PublishedText string `json:"published_text"`
			PublishedDate string `json:"published_date"`
		} `json:"videos"`
	}
	if err := json.Unmarshal(data, &ytData); err != nil {
		logger.Warn("Cannot parse YouTube cache: %v", err)
		return
	}

	if len(ytData.Videos) == 0 {
		logger.Info("No YouTube videos to auto-populate")
		return
	}

	// Take first 5 videos (they're already sorted by most recent from API)
	limit := 5
	if len(ytData.Videos) < limit {
		limit = len(ytData.Videos)
	}

	type VideoItem struct {
		URL          string `json:"url"`
		Title        string `json:"title"`
		Length       string `json:"length"`
		UploadedAt   string `json:"uploaded_at"`
		ThumbnailURL string `json:"thumbnail_url"`
	}

	videoItems := make([]VideoItem, 0, limit)
	for i := 0; i < limit; i++ {
		v := ytData.Videos[i]
		videoItems = append(videoItems, VideoItem{
			URL:          "https://www.youtube.com/watch?v=" + v.VideoID,
			Title:        v.Title,
			Length:       "",
			UploadedAt:   v.PublishedDate,
			ThumbnailURL: v.ThumbnailURL,
		})
	}

	// Save to settings VideosItemsJSON
	itemsJSON, err := json.Marshal(videoItems)
	if err != nil {
		logger.Warn("Failed to marshal video items: %v", err)
		return
	}

	settings.VideosItemsJSON = string(itemsJSON)
	settings.VideosModuleEnabled = true
	settings.VideosSource = "auto" // Auto source from YouTube channel
	settings.VideosLimit = limit
	settings.VideosStyle = "slider" // Default to slider display

	if err := bc.DB.Save(settings).Error; err != nil {
		logger.Warn("Failed to save auto-populated YouTube videos: %v", err)
		return
	}

	logger.Info("Auto-populated %d YouTube videos to homepage", limit)
}

// Public: list aliases for frontend mapping
func (bc *BaseController) GetPublicCompetitionAliases(c *gin.Context) {
	var items []models.CompetitionAlias
	// Order by display_order first (0 values go last), then by code
	if err := bc.DB.Select("code", "alias", "original_name", "display_order").Order("CASE WHEN display_order = 0 THEN 999999 ELSE display_order END ASC, code ASC").Find(&items).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
		return
	}
	c.JSON(http.StatusOK, items)
}

// TrackArticleView increments the view counter for an article (public)
func (bc *BaseController) TrackArticleView(c *gin.Context) {
	id := c.Param("id")
	
	// Use atomic SQL update to increment view_count
	result := bc.DB.Model(&models.Article{}).
		Where("id = ?", id).
		UpdateColumn("view_count", gorm.Expr("view_count + ?", 1))
	
	if result.Error != nil {
		logger.Error("Failed to track article view: id=%s error=%v", id, result.Error)
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to track view"})
		return
	}
	
	if result.RowsAffected == 0 {
		c.JSON(http.StatusNotFound, gin.H{"error": "Article not found"})
		return
	}
	
	c.JSON(http.StatusOK, gin.H{"ok": true})
}

// IncrementArticleRead increments the read counter for an article (public)
func (bc *BaseController) IncrementArticleRead(c *gin.Context) {
	id := c.Param("id")
	// Use atomic SQL update
	if err := bc.DB.Model(&models.Article{}).
		Where("id = ?", id).
		UpdateColumn("read_count", gorm.Expr("read_count + ?", 1)).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"chyba": "Clanek nenalezen"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat ctenost"})
		return
	}
	var art models.Article
	if err := bc.DB.Preload("Author").First(&art, id).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}
	if art.ImageURL == "" {
		art.ImageURL = "/dist/img/logo-club-empty.svg"
	}
	c.JSON(http.StatusOK, art)
}

// computeEstimatedReadMinutes estimates reading time by stripping HTML and counting words.
func computeEstimatedReadMinutes(html string) int {
	// remove tags
	reTags := regexp.MustCompile("<[^>]+>")
	text := reTags.ReplaceAllString(html, " ")
	// collapse whitespace
	text = strings.TrimSpace(strings.Join(strings.Fields(text), " "))
	if text == "" {
		return 1
	}
	words := len(strings.Fields(text))
	// assume 200 wpm
	minutes := (words + 199) / 200
	if minutes < 1 {
		minutes = 1
	}
	if minutes > 999 {
		minutes = 999
	}
	return minutes
}

// GetAdminMatches returns cached matches merged with DB overrides (admin only)
func (bc *BaseController) GetAdminMatches(c *gin.Context) {
	// Read cached events
	p := filepath.Join("cache", "prefetch", "events_upcoming.json")
	f, err := os.Open(p)
	if err != nil {
		c.JSON(http.StatusNoContent, gin.H{"message": "No cached matches"})
		return
	}
	defer f.Close()
	var matches []map[string]interface{}
	if err := json.NewDecoder(f).Decode(&matches); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze nacist cache"})
		return
	}

	// Load overrides
	var movs []models.MatchOverride
	if err := bc.DB.Find(&movs).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (match overrides)"})
		return
	}
	movByID := map[string]models.MatchOverride{}
	for _, m := range movs {
		movByID[m.ExternalMatchID] = m
	}

	var tlovs []models.TeamLogoOverride
	if err := bc.DB.Find(&tlovs).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (team logo overrides)"})
		return
	}
	tloByTeam := map[string]models.TeamLogoOverride{}
	for _, t := range tlovs {
		tloByTeam[t.ExternalTeamID] = t
	}

	// Apply overrides in-place
	for _, m := range matches {
		// External match ID
		var matchID string
		if v, ok := m["match_id"].(string); ok {
			matchID = v
		} else if v2, ok2 := m["id"].(string); ok2 {
			matchID = v2
		}

		if ov, ok := movByID[matchID]; ok {
			if ov.HomeNameOverride != nil {
				m["home"] = *ov.HomeNameOverride
				m["home_team"] = *ov.HomeNameOverride
			}
			if ov.AwayNameOverride != nil {
				m["away"] = *ov.AwayNameOverride
				m["away_team"] = *ov.AwayNameOverride
			}
			if ov.VenueOverride != nil {
				m["venue"] = *ov.VenueOverride
			}
			if ov.DateTimeOverride != nil {
				m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
				m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
			}
			if ov.HomeLogoURL != nil {
				m["home_logo_url"] = *ov.HomeLogoURL
			}
			if ov.AwayLogoURL != nil {
				m["away_logo_url"] = *ov.AwayLogoURL
			}
		}

		// Team-logo overrides by team id
		if homeID, ok := m["home_id"].(string); ok {
			if tlo, ok := tloByTeam[homeID]; ok {
				if tlo.LogoURL != "" {
					m["home_logo_url"] = tlo.LogoURL
				}
				if tlo.TeamName != "" {
					m["home"] = tlo.TeamName
				}
			}
		}
		if awayID, ok := m["away_id"].(string); ok {
			if tlo, ok := tloByTeam[awayID]; ok {
				if tlo.LogoURL != "" {
					m["away_logo_url"] = tlo.LogoURL
				}
				if tlo.TeamName != "" {
					m["away"] = tlo.TeamName
				}
			}
		}
	}

	c.JSON(http.StatusOK, matches)
}

// --- Admin: Match & Team Logo Overrides ---

// GetMatchOverrides lists all match overrides
func (bc *BaseController) GetMatchOverrides(c *gin.Context) {
	var items []models.MatchOverride
	if err := bc.DB.Order("updated_at DESC").Find(&items).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
		return
	}
	c.JSON(http.StatusOK, items)
}

// PutMatchOverride creates or replaces an override by external_match_id
func (bc *BaseController) PutMatchOverride(c *gin.Context) {
	extID := c.Param("external_match_id")
	if extID == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Chybí external_match_id"})
		return
	}
	var body struct {
		HomeNameOverride *string    `json:"home_name_override"`
		AwayNameOverride *string    `json:"away_name_override"`
		VenueOverride    *string    `json:"venue_override"`
		DateTimeOverride *time.Time `json:"date_time_override"`
		HomeLogoURL      *string    `json:"home_logo_url"`
		AwayLogoURL      *string    `json:"away_logo_url"`
		Notes            *string    `json:"notes"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	var item models.MatchOverride
	if err := bc.DB.Where("external_match_id = ?", extID).First(&item).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			// create new
			item = models.MatchOverride{ExternalMatchID: extID}
		} else {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databaze"})
			return
		}
	}
	item.HomeNameOverride = body.HomeNameOverride
	item.AwayNameOverride = body.AwayNameOverride
	item.VenueOverride = body.VenueOverride
	item.DateTimeOverride = body.DateTimeOverride
	item.HomeLogoURL = body.HomeLogoURL
	item.AwayLogoURL = body.AwayLogoURL
	if body.Notes != nil {
		item.Notes = *body.Notes
	}
	if item.ID == 0 {
		if err := bc.DB.Create(&item).Error; err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvorit zaznam"})
			return
		}
	} else {
		if err := bc.DB.Save(&item).Error; err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze ulozit zmeny"})
			return
		}
	}
	c.JSON(http.StatusOK, item)
}

// PatchMatchOverride partially updates fields of an override by external_match_id
func (bc *BaseController) PatchMatchOverride(c *gin.Context) {
	extID := c.Param("external_match_id")
	if extID == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba external_match_id"})
		return
	}
	var item models.MatchOverride
	if err := bc.DB.Where("external_match_id = ?", extID).First(&item).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"error": "Override nenalezen"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
		return
	}
	var body map[string]interface{}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	// Prevent changing the key
	delete(body, "external_match_id")
	if err := bc.DB.Model(&item).Updates(body).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"})
		return
	}
	// Best-effort: write JSON snapshot to cache
	go bc.writeTeamLogoOverridesCache()
	c.JSON(http.StatusOK, item)
}

// GetTeamLogoOverrides lists all team logo overrides
func (bc *BaseController) GetTeamLogoOverrides(c *gin.Context) {
	var items []models.TeamLogoOverride
	if err := bc.DB.Order("updated_at DESC").Find(&items).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
		return
	}
	c.JSON(http.StatusOK, items)
}

// GetPublicTeamLogoOverrides returns a simple mapping usable by widgets without auth
// Shape: { "by_name": { "Team Name": "https://.../logo.png" } }
func (bc *BaseController) GetPublicTeamLogoOverrides(c *gin.Context) {
	var items []models.TeamLogoOverride
	if err := bc.DB.Find(&items).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
		return
	}
	m := make(map[string]string, len(items))
	for _, it := range items {
		if it.TeamName != "" && it.LogoURL != "" {
			m[it.TeamName] = it.LogoURL
		}
	}
	// Public cacheable response
	c.Header("Cache-Control", "public, max-age=120")
	c.JSON(http.StatusOK, gin.H{"by_name": m})
}

// writeTeamLogoOverridesCache writes a JSON snapshot of team-logo overrides to cache/prefetch/team_logo_overrides.json
// Shape: { "by_name": { "Team Name": "https://..." } }
func (bc *BaseController) writeTeamLogoOverridesCache() {
	var items []models.TeamLogoOverride
	if err := bc.DB.Find(&items).Error; err != nil {
		return
	}
	m := make(map[string]string, len(items))
	for _, it := range items {
		if it.TeamName != "" && it.LogoURL != "" {
			m[it.TeamName] = it.LogoURL
		}
	}
	payload := map[string]any{"by_name": m}
	b, err := json.MarshalIndent(payload, "", "  ")
	if err != nil {
		return
	}
	dir := filepath.Join("cache", "prefetch")
	_ = os.MkdirAll(dir, 0o755)
	tmp := filepath.Join(dir, "team_logo_overrides.json.tmp")
	dst := filepath.Join(dir, "team_logo_overrides.json")
	if err := os.WriteFile(tmp, b, 0o644); err == nil {
		_ = os.Rename(tmp, dst)
	}
}

// PutTeamLogoOverride creates or replaces a team logo override by external_team_id
func (bc *BaseController) PutTeamLogoOverride(c *gin.Context) {
	extID := c.Param("external_team_id")
	if extID == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba external_team_id"})
		return
	}
	var body struct {
		TeamName string `json:"team_name"`
		LogoURL  string `json:"logo_url"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	var item models.TeamLogoOverride
	if err := bc.DB.Where("external_team_id = ?", extID).First(&item).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			item = models.TeamLogoOverride{ExternalTeamID: extID}
		} else {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
			return
		}
	}
	item.TeamName = body.TeamName
	item.LogoURL = body.LogoURL
	if item.ID == 0 {
		if err := bc.DB.Create(&item).Error; err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit záznam"})
			return
		}
	} else if err := bc.DB.Save(&item).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"})
		return
	}
	// Best-effort: write JSON snapshot to cache
	go bc.writeTeamLogoOverridesCache()
	c.JSON(http.StatusOK, item)
}

// PatchTeamLogoOverride partially updates a team logo override by external_team_id
func (bc *BaseController) PatchTeamLogoOverride(c *gin.Context) {
	extID := c.Param("external_team_id")
	if extID == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba external_team_id"})
		return
	}
	var item models.TeamLogoOverride
	if err := bc.DB.Where("external_team_id = ?", extID).First(&item).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"error": "Override nenalezen"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
		return
	}
	var body map[string]interface{}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	delete(body, "external_team_id")
	if err := bc.DB.Model(&item).Updates(body).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"})
		return
	}
	c.JSON(http.StatusOK, item)
}

// ProxyImage streams a remote image to the client to avoid browser CORS restrictions for Canvas operations
// GET /api/v1/proxy/image?url=<remote_image_url>
func (bc *BaseController) ProxyImage(c *gin.Context) {
	raw := c.Query("url")
	if raw == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "missing url parameter"})
		return
	}
	u, err := url.Parse(raw)
	if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
		return
	}

	// Fetch with a short timeout
	client := &http.Client{Timeout: 10 * time.Second}
	req, err := http.NewRequest("GET", u.String(), nil)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "request init failed"})
		return
	}
	// Some CDNs require a UA
	req.Header.Set("User-Agent", "fotbal-club/1.0 (+https://localhost)")
	resp, err := client.Do(req)
	if err != nil {
		c.JSON(http.StatusBadGateway, gin.H{"error": "fetch failed"})
		return
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		c.JSON(http.StatusBadGateway, gin.H{"error": "remote returned status", "status": resp.StatusCode})
		return
	}

	ct := resp.Header.Get("Content-Type")
	// Allow only images for safety
	if ct == "" || (ct != "image/jpeg" && ct != "image/png" && ct != "image/gif" && ct != "image/webp" && ct != "image/svg+xml") {
		// try to infer from URL
		ext := filepath.Ext(u.Path)
		switch ext {
		case ".jpg", ".jpeg":
			ct = "image/jpeg"
		case ".png":
			ct = "image/png"
		case ".gif":
			ct = "image/gif"
		case ".webp":
			ct = "image/webp"
		case ".svg":
			ct = "image/svg+xml"
		default:
			c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported content type"})
			return
		}
	}

	// Stream response
	c.Header("Access-Control-Allow-Origin", "*")
	c.Header("Cache-Control", "public, max-age=86400")
	c.DataFromReader(http.StatusOK, resp.ContentLength, ct, resp.Body, nil)
}

// SetupStatus reports whether initial setup is required
// requires_setup is true if no admin user exists OR settings lack a ClubID
func (bc *BaseController) SetupStatus(c *gin.Context) {
	var adminCount int64
	if err := bc.DB.Model(&models.User{}).Where("role = ?", "admin").Count(&adminCount).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
		return
	}
	// Ensure settings table exists to avoid noisy errors on first run
	_ = bc.DB.AutoMigrate(&models.Settings{})
	var s models.Settings
	_ = bc.DB.First(&s).Error // ignore not found
	requires := adminCount == 0 || s.ClubID == ""
	c.JSON(http.StatusOK, gin.H{"requires_setup": requires})
}

// It is allowed only if no admin exists yet. SMTP is optional.
func (bc *BaseController) SetupInitialize(c *gin.Context) {
	// Ensure required tables exist even if global migrations were skipped
	_ = bc.DB.AutoMigrate(&models.User{}, &models.Settings{})

	// Check if an admin already exists
	var adminCount int64
	if err := bc.DB.Model(&models.User{}).Where("role = ?", "admin").Count(&adminCount).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
		return
	}

	if adminCount > 0 {
		// Allow idempotent updates to Settings (club basics if missing, and SMTP at any time)
		type setupBody struct {
			ClubID      string `json:"club_id"`
			ClubType    string `json:"club_type"`
			ClubName    string `json:"club_name"`
			ClubLogoURL string `json:"club_logo_url"`
			ClubURL     string `json:"club_url"`
			// Social profiles (optional)
			FacebookURL     string `json:"facebook_url"`
			InstagramURL    string `json:"instagram_url"`
			YoutubeURL      string `json:"youtube_url"`
			PrimaryColor    string `json:"primary_color"`
			SecondaryColor  string `json:"secondary_color"`
			AccentColor     string `json:"accent_color"`
			BackgroundColor string `json:"background_color"`
			TextColor       string `json:"text_color"`
			FontHeading     string `json:"font_heading"`
			FontBody        string `json:"font_body"`
			SMTP            *struct {
				Host     string `json:"host"`
				Port     int    `json:"port"`
				Username string `json:"username"`
				Password string `json:"password"`
				From     string `json:"from"`
				UseTLS   *bool  `json:"use_tls"`
			} `json:"smtp"`
		}
		var body setupBody
		_ = c.ShouldBindJSON(&body) // best-effort; all fields optional here

		var s models.Settings
		_ = bc.DB.First(&s).Error // ignore not found
		if s.ID == 0 {
			s = models.Settings{}
		}

		// Only write club basics if not set yet and payload contains values
		if s.ClubID == "" && body.ClubID != "" {
			s.ClubID = body.ClubID
			s.ClubType = body.ClubType
			s.ClubName = body.ClubName
			s.ClubLogoURL = body.ClubLogoURL
			s.ClubURL = body.ClubURL
			if body.PrimaryColor != "" {
				s.PrimaryColor = body.PrimaryColor
			}
			if body.SecondaryColor != "" {
				s.SecondaryColor = body.SecondaryColor
			}
			if body.AccentColor != "" {
				s.AccentColor = body.AccentColor
			}
			if body.BackgroundColor != "" {
				s.BackgroundColor = body.BackgroundColor
			}
			if body.TextColor != "" {
				s.TextColor = body.TextColor
			}
			if body.FontHeading != "" {
				s.FontHeading = body.FontHeading
			}
			if body.FontBody != "" {
				s.FontBody = body.FontBody
			}
		}

		// Always allow updating social profiles idempotently if provided
		if v := strings.TrimSpace(body.FacebookURL); v != "" {
			s.FacebookURL = v
		}
		if v := strings.TrimSpace(body.InstagramURL); v != "" {
			s.InstagramURL = v
		}
		if v := strings.TrimSpace(body.YoutubeURL); v != "" {
			// Normalize: allow @handle, full URLs, or www.* without scheme
			if strings.HasPrefix(strings.ToLower(v), "www.") {
				v = "https://" + v
			}
			s.YoutubeURL = v
		}

		// Always allow updating SMTP via setup when admin already exists (idempotent)
		if body.SMTP != nil {
			if host := strings.TrimSpace(body.SMTP.Host); host != "" {
				s.SMTPHost = host
			}
			if body.SMTP.Port > 0 {
				s.SMTPPort = body.SMTP.Port
			}
			if u := strings.TrimSpace(body.SMTP.Username); u != "" {
				s.SMTPUser = u
				s.SMTPAuth = true
			}
			if p := body.SMTP.Password; p != "" {
				s.SMTPPassword = p
			}
			if from := strings.TrimSpace(body.SMTP.From); from != "" {
				s.SMTPFrom = from
			}
			if s.SMTPFromName == "" {
				s.SMTPFromName = "Fotbal Club"
			}
			if body.SMTP.UseTLS != nil {
				if *body.SMTP.UseTLS {
					if body.SMTP.Port == 465 {
						s.SMTPEncryption = "ssl"
					} else {
						s.SMTPEncryption = "tls"
					}
				} else {
					s.SMTPEncryption = "none"
				}
			}
		}

		if s.ID == 0 {
			if err := bc.DB.Create(&s).Error; err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze ulozit nastaveni"})
				return
			}
		} else {
			if err := bc.DB.Save(&s).Error; err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze ulozit nastaveni"})
				return
			}
		}

		// Trigger background prefetch and YouTube cache refresh when settings are updated post-setup
		scheme := "http"
		if c.Request.TLS != nil {
			scheme = "https"
		}
		host := c.Request.Host
		if host != "" {
			baseURL := scheme + "://" + host + "/api/v1"
			go services.PrefetchOnce(baseURL)
		}
		if strings.TrimSpace(s.YoutubeURL) != "" {
			go func(u string) { _ = services.RefreshYouTubeChannelNow(u) }(s.YoutubeURL)
		}
		// If gallery_url is a Zonerama link, refresh Zonerama cache immediately
		if g := strings.TrimSpace(s.GalleryURL); g != "" && strings.Contains(strings.ToLower(g), "zonerama.com") {
			go func(link string) { _ = services.RefreshZoneramaNow(link) }(g)
		}

		c.JSON(http.StatusOK, gin.H{"message": "Inicializace již byla provedena"})
		return
	}

	// No admin exists yet: run full initial setup
	type reqBody struct {
		// Admin user
		AdminEmail    string `json:"admin_email" binding:"required,email"`
		AdminPassword string `json:"admin_password" binding:"required,min=8"`
		FirstName     string `json:"first_name"`
		LastName      string `json:"last_name"`

		// JWT
		JWTSecret string `json:"jwt_secret"`

		// Club (FACR)
		ClubID      string `json:"club_id"`
		ClubType    string `json:"club_type"`
		ClubName    string `json:"club_name"`
		ClubLogoURL string `json:"club_logo_url"`
		ClubURL     string `json:"club_url"`

		// Social (optional)
		FacebookURL  string `json:"facebook_url"`
		InstagramURL string `json:"instagram_url"`
		YoutubeURL   string `json:"youtube_url"`

		// Gallery (optional)
		GalleryURL   string `json:"gallery_url"`
		GalleryLabel string `json:"gallery_label"`

		// Location/Contact (optional)
		ContactAddress    string  `json:"contact_address"`
		ContactCity       string  `json:"contact_city"`
		ContactZip        string  `json:"contact_zip"`
		ContactCountry    string  `json:"contact_country"`
		ContactPhone      string  `json:"contact_phone"`
		ContactEmail      string  `json:"contact_email"`
		LocationLatitude  float64 `json:"location_latitude"`
		LocationLongitude float64 `json:"location_longitude"`
		MapStyle          string  `json:"map_style"`

		// Frontpage style (optional)
		FrontpageStyle string `json:"frontpage_style"`

		// Theme (optional, can set later)
		PrimaryColor    string `json:"primary_color"`
		SecondaryColor  string `json:"secondary_color"`
		AccentColor     string `json:"accent_color"`
		BackgroundColor string `json:"background_color"`
		TextColor       string `json:"text_color"`
		FontHeading     string `json:"font_heading"`
		FontBody        string `json:"font_body"`

		// SMTP optional
		SMTP *struct {
			Host     string `json:"host"`
			Port     int    `json:"port"`
			Username string `json:"username"`
			Password string `json:"password"`
			From     string `json:"from"`
			UseTLS   *bool  `json:"use_tls"`
		} `json:"smtp"`
	}
	var body reqBody
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	logger.Info("SetupInitialize payload received: admin_email=%s club_id=%s club_type=%s club_name=%s gallery_url=%s gallery_label=%s", body.AdminEmail, body.ClubID, body.ClubType, body.ClubName, body.GalleryURL, body.GalleryLabel)

	// Optionally persist JWT secret to environment if empty in config (best set via env in deployment)
	if body.JWTSecret != "" && config.AppConfig.JWTSecret == "" {
		// Not writing to disk; just warn that server restart needed to take effect
	}

	// Create admin user
	hashed, err := bcrypt.GenerateFromPassword([]byte(body.AdminPassword), bcrypt.DefaultCost)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze zpracovat heslo"})
		return
	}
	admin := models.User{
		Email:     body.AdminEmail,
		Password:  string(hashed),
		FirstName: body.FirstName,
		LastName:  body.LastName,
		Role:      "admin",
	}
	if err := bc.DB.Create(&admin).Error; err != nil {
		if errors.Is(err, gorm.ErrDuplicatedKey) {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Email již existuje"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit admina"})
		return
	}
	logger.Info("Admin user created: email=%s id=%d", admin.Email, admin.ID)

	// Upsert settings
	var s models.Settings
	if err := bc.DB.First(&s).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			s = models.Settings{}
		} else {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
			return
		}
	}
	if s.ID == 0 {
		s = models.Settings{}
	}
	if body.ClubID != "" {
		s.ClubID = body.ClubID
	}
	if body.ClubType != "" {
		s.ClubType = body.ClubType
	}
	if body.ClubName != "" {
		s.ClubName = body.ClubName
	}
	if body.ClubLogoURL != "" {
		s.ClubLogoURL = body.ClubLogoURL
	}
	if body.ClubURL != "" {
		s.ClubURL = body.ClubURL
	}
	// Social profiles
	if v := strings.TrimSpace(body.FacebookURL); v != "" {
		s.FacebookURL = v
	}
	if v := strings.TrimSpace(body.InstagramURL); v != "" {
		s.InstagramURL = v
	}
	if v := strings.TrimSpace(body.YoutubeURL); v != "" {
		if strings.HasPrefix(strings.ToLower(v), "www.") {
			v = "https://" + v
		}
		s.YoutubeURL = v
	}
	if body.PrimaryColor != "" {
		s.PrimaryColor = body.PrimaryColor
	}
	if body.SecondaryColor != "" {
		s.SecondaryColor = body.SecondaryColor
	}
	if body.AccentColor != "" {
		s.AccentColor = body.AccentColor
	}
	if body.BackgroundColor != "" {
		s.BackgroundColor = body.BackgroundColor
	}
	if body.TextColor != "" {
		s.TextColor = body.TextColor
	}
	if body.FontHeading != "" {
		s.FontHeading = body.FontHeading
	}
	if body.FontBody != "" {
		s.FontBody = body.FontBody
	}
	// Gallery
	if body.GalleryURL != "" {
		s.GalleryURL = strings.TrimSpace(body.GalleryURL)
	}
	if body.GalleryLabel != "" {
		s.GalleryLabel = strings.TrimSpace(body.GalleryLabel)
	}
	// Location/Contact
	if v := strings.TrimSpace(body.ContactAddress); v != "" {
		s.ContactAddress = v
	}
	if v := strings.TrimSpace(body.ContactCity); v != "" {
		s.ContactCity = v
	}
	if v := strings.TrimSpace(body.ContactZip); v != "" {
		s.ContactZip = v
	}
	if v := strings.TrimSpace(body.ContactCountry); v != "" {
		s.ContactCountry = v
	}
	if v := strings.TrimSpace(body.ContactPhone); v != "" {
		s.ContactPhone = v
	}
	if v := strings.TrimSpace(body.ContactEmail); v != "" {
		s.ContactEmail = v
	}
	if body.LocationLatitude != 0 {
		s.LocationLatitude = body.LocationLatitude
	}
	if body.LocationLongitude != 0 {
		s.LocationLongitude = body.LocationLongitude
	}
	if v := strings.TrimSpace(body.MapStyle); v != "" {
		s.MapStyle = v
	}
	if body.GalleryLabel != "" {
		s.GalleryLabel = strings.TrimSpace(body.GalleryLabel)
	}
	// Frontpage style
	if body.FrontpageStyle != "" {
		s.FrontpageStyle = body.FrontpageStyle
	}
	// SMTP overrides from initial setup
	if body.SMTP != nil {
		if v := strings.TrimSpace(body.SMTP.Host); v != "" {
			s.SMTPHost = v
		}
		if body.SMTP.Port > 0 {
			s.SMTPPort = body.SMTP.Port
		}
		if v := strings.TrimSpace(body.SMTP.Username); v != "" {
			s.SMTPUser = v
			s.SMTPAuth = true
		}
		if v := body.SMTP.Password; v != "" {
			s.SMTPPassword = v
		}
		if v := strings.TrimSpace(body.SMTP.From); v != "" {
			s.SMTPFrom = v
		}
		// Default FromName if empty
		if s.SMTPFromName == "" {
			s.SMTPFromName = "Fotbal Club"
		}
		if body.SMTP.UseTLS != nil {
			if *body.SMTP.UseTLS {
				if body.SMTP.Port == 465 {
					s.SMTPEncryption = "ssl"
				} else {
					s.SMTPEncryption = "tls"
				}
			} else {
				s.SMTPEncryption = "none"
			}
		}
	}

	if s.ID == 0 {
		if err := bc.DB.Create(&s).Error; err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze ulozit nastaveni"})
			return
		}
	} else {
		if err := bc.DB.Save(&s).Error; err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze ulozit nastaveni"})
			return
		}
	}
	logger.Info("Initial settings saved: club_id=%s club_name=%s gallery_url=%s gallery_label=%s", s.ClubID, s.ClubName, s.GalleryURL, s.GalleryLabel)

	// Seed default homepage page elements with all available sections
	bc.seedDefaultHomePageElements()
	logger.Info("Default homepage page elements seeded")

	// Run all setup operations asynchronously in background to provide immediate response
	scheme := "http"
	if c.Request.TLS != nil {
		scheme = "https"
	}
	host := c.Request.Host

	logger.Info("Starting initial data prefetch and setup operations in background...")

	// Run all setup operations in a single background goroutine
	go func(settingsID uint, youtubeURL, galleryURL, adminEmail, baseHost string) {
		defer func() { _ = recover() }()
		
		// 1. Trigger prefetch (matches, standings, etc.)
		if baseHost != "" {
			baseURL := scheme + "://" + baseHost + "/api/v1"
			services.PrefetchOnce(baseURL)
			logger.Info("Background prefetch completed")

			// Auto-populate competition aliases from FACR data
			bc.autoPopulateCompetitionAliases()
			logger.Info("Background competition aliases populated")
		}

		// 2. If YouTube channel is configured, refresh its cache
		if strings.TrimSpace(youtubeURL) != "" {
			if err := services.RefreshYouTubeChannelNow(youtubeURL); err != nil {
				logger.Warn("YouTube cache refresh failed during setup: %v", err)
			} else {
				logger.Info("Background YouTube cache refreshed")
				// Auto-populate 5 most recent videos into settings
				var settings models.Settings
				if err := bc.DB.First(&settings, settingsID).Error; err == nil {
					bc.autoPopulateYouTubeVideos(&settings)
				}
			}
		}

		// 3. If gallery_url is a Zonerama link, refresh Zonerama cache
		if g := strings.TrimSpace(galleryURL); g != "" && strings.Contains(strings.ToLower(g), "zonerama.com") {
			if err := services.RefreshZoneramaNow(g); err != nil {
				logger.Warn("Zonerama cache refresh failed during setup: %v", err)
			} else {
				logger.Info("Background Zonerama cache refreshed")
			}
		}

		// 4. Send welcome email
		es := email.NewEmailService(config.AppConfig, bc.DB)
		if err := es.SendAdminWelcome(adminEmail); err != nil {
			logger.Error("Failed to send admin welcome email: error=%v email=%s", err, adminEmail)
		} else {
			logger.Info("Welcome email sent to %s", adminEmail)
		}
		
		logger.Info("All background setup operations completed")
	}(s.ID, s.YoutubeURL, s.GalleryURL, admin.Email, host)

	logger.Info("SetupInitialize finished successfully - background operations running")
	c.JSON(http.StatusOK, gin.H{"message": "Setup completed successfully"})
}

// UploadImage handles image/file uploads
func (bc *BaseController) UploadImage(c *gin.Context) {
	// Auth required in normal operation. However during initial setup there is no admin user yet
	// so allow unauthenticated uploads only when no admin exists. Otherwise require authenticated user.
	if _, ok := c.Get("user"); !ok {
		// Try to parse Authorization bearer token to identify user when called on public route
		authHeader := c.GetHeader("Authorization")
		if authHeader != "" {
			parts := strings.Split(authHeader, " ")
			if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
				if claims, err := utils.ParseJWT(parts[1]); err == nil {
					var u models.User
					if err := bc.DB.First(&u, claims.UserID).Error; err == nil {
						c.Set("user", &u)
						c.Set("userRole", u.Role)
					}
				}
			}
		}
		if _, ok2 := c.Get("user"); !ok2 {
			var adminCount int64
			if err := bc.DB.Model(&models.User{}).Where("role = ?", "admin").Count(&adminCount).Error; err == nil {
				if adminCount > 0 {
					c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uzivatel neni prihlasen"})
					return
				}
			}
		}
	}

	file, header, err := c.Request.FormFile("file")
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"chyba": "Soubor nebyl nalezen v požadavku (pole 'file')"})
		return
	}
	defer file.Close()

	// Read first 512 bytes for MIME detection
	var sniff [512]byte
	n, _ := io.ReadFull(file, sniff[:])
	mimeType := http.DetectContentType(sniff[:n])

	// Validate MIME type
	allowed := false
	for _, mt := range config.AppConfig.AllowedMimeTypes {
		if mt == mimeType {
			allowed = true
			break
		}
	}
	if !allowed {
		c.JSON(http.StatusBadRequest, gin.H{"chyba": "Nepodporovany typ souboru"})
		return
	}

	// Reset reader to include sniffed bytes
	reader := io.MultiReader(bytesReader(sniff[:n]), file)

	// preserve_quality form flag: when true, store file as-is without re-encoding (max quality).
	preserveFlag := strings.ToLower(strings.TrimSpace(c.PostForm("preserve_quality")))
	preserve := preserveFlag == "1" || preserveFlag == "true" || preserveFlag == "yes"

	// Ensure upload directory exists (e.g., /uploads/2025/08)
	subdir := time.Now().Format("2006/01")
	destDir := filepath.Join(config.AppConfig.UploadDir, subdir)
	if err := os.MkdirAll(destDir, 0o755); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvorit adresar pro upload"})
		return
	}

	// Generate unique filename preserving extension
	ext := filepath.Ext(header.Filename)
	if ext == "" {
		// derive from MIME
		switch mimeType {
		case "image/jpeg":
			ext = ".jpg"
		case "image/png":
			ext = ".png"
		case "image/gif":
			ext = ".gif"
		case "image/webp":
			ext = ".webp"
		case "image/svg+xml":
			ext = ".svg"
		case "application/pdf":
			ext = ".pdf"
		case "application/msword":
			ext = ".doc"
		case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
			ext = ".docx"
		case "application/vnd.ms-excel":
			ext = ".xls"
		case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
			ext = ".xlsx"
		case "application/vnd.ms-powerpoint":
			ext = ".ppt"
		case "application/vnd.openxmlformats-officedocument.presentationml.presentation":
			ext = ".pptx"
		case "text/plain":
			ext = ".txt"
		case "application/zip", "application/x-zip-compressed":
			ext = ".zip"
		case "application/x-rar-compressed", "application/vnd.rar":
			ext = ".rar"
		default:
			ext = ""
		}
	}
	randBytes := make([]byte, 16)
	if _, err := rand.Read(randBytes); err != nil {
		randBytes = []byte(time.Now().Format("150405.000"))
	}
	fname := time.Now().Format("20060102-150405") + "-" + hex.EncodeToString(randBytes) + ext
	destPath := filepath.Join(destDir, fname)

	// Save file with optional re-encoding/compression
	out, err := os.Create(destPath)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze ulozit soubor"})
		return
	}
	defer out.Close()

	var written int64
	if preserve {
		// Store original bytes without decoding/re-encoding to preserve maximal quality
		n64, err := io.Copy(out, io.LimitReader(reader, config.AppConfig.MaxUploadSize))
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nepovedlo se ulozit soubor"})
			return
		}
		written = n64
		if written >= config.AppConfig.MaxUploadSize {
			c.JSON(http.StatusRequestEntityTooLarge, gin.H{"chyba": "Soubor je prilis velky"})
			return
		}
	} else {
		switch mimeType {
		case "image/jpeg":
			// Decode and re-encode JPEG with quality reduction to reduce size while keeping good quality
			img, err := jpeg.Decode(io.LimitReader(reader, config.AppConfig.MaxUploadSize))
			if err != nil {
				c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatny JPEG soubor"})
				return
			}
			if err := jpeg.Encode(out, img, &jpeg.Options{Quality: 82}); err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Komprese JPEG selhala"})
				return
			}
			fi, _ := out.Stat()
			written = fi.Size()
		case "image/png":
			// Decode and re-encode PNG with best compression (lossless)
			img, err := png.Decode(io.LimitReader(reader, config.AppConfig.MaxUploadSize))
			if err != nil {
				c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatný PNG soubor"})
				return
			}
			enc := png.Encoder{CompressionLevel: png.BestCompression}
			if err := enc.Encode(out, img); err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Komprese PNG selhala"})
				return
			}
			fi, _ := out.Stat()
			written = fi.Size()
		case "image/svg+xml":
			// SVG is text-based; keep as-is without modification
			n, err := io.Copy(out, io.LimitReader(reader, config.AppConfig.MaxUploadSize))
			if err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při ukládání souboru"})
				return
			}
			written = n
			if written >= config.AppConfig.MaxUploadSize {
				c.JSON(http.StatusRequestEntityTooLarge, gin.H{"chyba": "Soubor je příliš velký"})
				return
			}
		default:
			// Fallback: store as-is (for allowed types only)
			n, err := io.Copy(out, io.LimitReader(reader, config.AppConfig.MaxUploadSize))
			if err != nil {
				c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při ukládání souboru"})
				return
			}
			written = n
			if written >= config.AppConfig.MaxUploadSize {
				c.JSON(http.StatusRequestEntityTooLarge, gin.H{"chyba": "Soubor je příliš velký"})
				return
			}
		}
	}

	// Build public URL and respond
	// Files are served under /uploads in main.go, so construct the URL accordingly
	publicURL := "/uploads/" + filepath.ToSlash(filepath.Join(subdir, fname))
	// Also include a relative filesystem path for convenience
	relPath := filepath.Join("uploads", subdir, fname)

	// Track uploaded file in database
	var uploadedByID *uint
	if userID, exists := c.Get("user_id"); exists {
		if uid, ok := userID.(uint); ok {
			uploadedByID = &uid
		}
	}
	
	uploadedFile := models.UploadedFile{
		Filename:     fname,
		FilePath:     destPath,
		FileURL:      publicURL,
		FileSize:     written,
		MimeType:     mimeType,
		UploadedByID: uploadedByID,
	}
	
	// Best effort - don't fail if tracking fails
	if err := bc.DB.Create(&uploadedFile).Error; err != nil {
		logger.Warn("Failed to track uploaded file: %v", err)
	}

	c.JSON(http.StatusOK, gin.H{
		"url":      publicURL,
		"path":     relPath,
		"size":     written,
		"mime":     mimeType,
		"filename": fname,
	})
}

// helper to wrap bytes as io.Reader without extra allocs
func bytesReader(b []byte) io.Reader { return &sliceReader{b: b} }

type sliceReader struct{ b []byte }

func (r *sliceReader) Read(p []byte) (int, error) {
	if len(r.b) == 0 {
		return 0, io.EOF
	}
	n := copy(p, r.b)
	r.b = r.b[n:]
	return n, nil
}
func makeSlug(s string) string {
	s = strings.ToLower(strings.TrimSpace(s))
	if s == "" {
		return ""
	}
	
	// Map of Czech/Slovak diacritics to ASCII equivalents
	diacriticsMap := map[rune]rune{
		'á': 'a', 'č': 'c', 'ď': 'd', 'é': 'e', 'ě': 'e', 'í': 'i', 'ň': 'n',
		'ó': 'o', 'ř': 'r', 'š': 's', 'ť': 't', 'ú': 'u', 'ů': 'u', 'ý': 'y', 'ž': 'z',
	}
	
	// Replace diacritics with ASCII equivalents
	var result []rune
	for _, char := range s {
		if replacement, ok := diacriticsMap[char]; ok {
			result = append(result, replacement)
		} else {
			result = append(result, char)
		}
	}
	s = string(result)
	
	// replace non-alphanumeric with hyphens
	re := regexp.MustCompile(`[^a-z0-9]+`)
	s = re.ReplaceAllString(s, "-")
	s = strings.Trim(s, "-")
	if s == "" {
		s = "article"
	}
	if len(s) > 120 {
		s = s[:120]
	}
	return s
}

// getPrefetchBaseURL returns the base URL for internal API calls (used for prefetch trigger)
func getPrefetchBaseURL() string {
	base := strings.TrimSpace(os.Getenv("PREFETCH_TARGET"))
	if base == "" {
		port := strings.TrimSpace(os.Getenv("PORT"))
		if port == "" {
			port = "8080"
		}
		base = "http://127.0.0.1:" + port + "/api/v1"
	}
	return base
}

// CreateArticle creates a new article (protected)
func (bc *BaseController) CreateArticle(c *gin.Context) {
	// Require authenticated user
	uVal, ok := c.Get("user")
	if !ok {
		c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"})
		return
	}
	user := uVal.(*models.User)

	type reqBody struct {
		Title          string  `json:"title" binding:"required"`
		Content        string  `json:"content" binding:"required"`
		CategoryID     uint    `json:"category_id"`
		CategoryName   string  `json:"category_name"`
		ImageURL       string  `json:"image_url"`
		Published      *bool   `json:"published"`
		PublishedAt    *string `json:"published_at"`
		Featured       *bool   `json:"featured"`
		Slug           string  `json:"slug"`
		SeoTitle       string  `json:"seo_title"`
		SeoDescription string  `json:"seo_description"`
		OgImageURL     string  `json:"og_image_url"`
		// Gallery fields (optional)
		GalleryAlbumID  string   `json:"gallery_album_id"`
		GalleryAlbumURL string   `json:"gallery_album_url"`
		GalleryPhotoIDs []string `json:"gallery_photo_ids"`
		YouTubeVideoID        string `json:"youtube_video_id"`
		YouTubeVideoTitle     string `json:"youtube_video_title"`
		YouTubeVideoURL       string `json:"youtube_video_url"`
		YouTubeVideoThumbnail string `json:"youtube_video_thumbnail"`
	}
	var body reqBody
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()})
		return
	}

	slug := strings.TrimSpace(body.Slug)
	if slug == "" {
		slug = makeSlug(body.Title)
	}
	// Fallback if slug is still empty (shouldn't happen with makeSlug, but be safe)
	if slug == "" {
		slug = fmt.Sprintf("article-%d", time.Now().Unix())
	}
	// ensure unique slug; if exists, append -n
	orig := slug
	for i := 0; i < 50; i++ {
		var cnt int64
		if err := bc.DB.Model(&models.Article{}).Where("slug = ?", slug).Count(&cnt).Error; err != nil {
			logger.Error("Failed to check slug uniqueness: error=%v slug=%s", err, slug)
			c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
			return
		}
		if cnt == 0 {
			break
		}
		slug = fmt.Sprintf("%s-%d", orig, i+1)
	}
	logger.Info("Generated unique slug for article: original=%s final=%s", orig, slug)

	// Resolve category by name if provided and CategoryID not set
	if body.CategoryID == 0 && strings.TrimSpace(body.CategoryName) != "" {
		name := strings.TrimSpace(body.CategoryName)
		var cat models.Category
		if err := bc.DB.Where("name = ?", name).First(&cat).Error; err != nil {
			if err == gorm.ErrRecordNotFound {
				cat = models.Category{Name: name}
				if err := bc.DB.Create(&cat).Error; err != nil {
					c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
					return
				}
			} else {
				c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze (kategorie)"})
				return
			}
		}
		body.CategoryID = cat.ID
	}

	published := false
	if body.Published != nil {
		published = *body.Published
	}
	featured := false
	if body.Featured != nil {
		featured = *body.Featured
	}
	var pubAt time.Time
	if body.PublishedAt != nil && strings.TrimSpace(*body.PublishedAt) != "" {
		if t, err := time.Parse(time.RFC3339, *body.PublishedAt); err == nil {
			pubAt = t
		}
	}
	if published && pubAt.IsZero() {
		pubAt = time.Now()
	}

	est := computeEstimatedReadMinutes(body.Content)

	// Prepare SEO fallbacks
	seoTitle := strings.TrimSpace(body.SeoTitle)
	if seoTitle == "" {
		seoTitle = strings.TrimSpace(body.Title)
	}
	seoDesc := strings.TrimSpace(body.SeoDescription)
	if seoDesc == "" {
		seoDesc = deriveSeoDescription(body.Content)
	}

	authorID := user.ID
	var pubAtPtr *time.Time
	if !pubAt.IsZero() {
		pubAtPtr = &pubAt
	}
	var categoryIDPtr *uint
	if body.CategoryID > 0 {
		categoryIDPtr = &body.CategoryID
	}

	art := models.Article{
		Title:          strings.TrimSpace(body.Title),
		Content:        body.Content,
		AuthorID:       &authorID,
		CategoryID:     categoryIDPtr,
		Published:      published,
		PublishedAt:    pubAtPtr,
		ImageURL:       strings.TrimSpace(body.ImageURL),
		ReadTime:       est,
		Slug:           slug,
		SEOTitle:       seoTitle,
		SEODescription: seoDesc,
		Featured:       featured,
	}
	// Optional OG image
	if trimmed := strings.TrimSpace(body.OgImageURL); trimmed != "" {
		art.OGImageURL = trimmed
	}
	// Gallery fields
	if trimmed := strings.TrimSpace(body.GalleryAlbumID); trimmed != "" {
		art.GalleryAlbumID = trimmed
	}
	if trimmed := strings.TrimSpace(body.GalleryAlbumURL); trimmed != "" {
		art.GalleryAlbumURL = trimmed
	}
	if len(body.GalleryPhotoIDs) > 0 {
		art.GalleryPhotoIDs = strings.Join(body.GalleryPhotoIDs, ",")
	}
	if trimmed := strings.TrimSpace(body.YouTubeVideoID); trimmed != "" {
		art.YouTubeVideoID = trimmed
	}
	if trimmed := strings.TrimSpace(body.YouTubeVideoTitle); trimmed != "" {
		art.YouTubeVideoTitle = trimmed
	}
	if trimmed := strings.TrimSpace(body.YouTubeVideoURL); trimmed != "" {
		art.YouTubeVideoURL = trimmed
	}
	if trimmed := strings.TrimSpace(body.YouTubeVideoThumbnail); trimmed != "" {
		art.YouTubeVideoThumbnail = trimmed
	}
	if err := bc.DB.Create(&art).Error; err != nil {
		logger.Error("Failed to create article: error=%v title=%s slug=%s", err, body.Title, slug)
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit článek", "error": err.Error()})
		return
	}
	if art.ImageURL == "" {
		art.ImageURL = "/dist/img/logo-club-empty.svg"
	}

	// Track file usage
	fileTracker := services.NewFileTracker(bc.DB)
	go fileTracker.TrackArticleFiles(&art)

	// Send newsletter notification if article is published
	if art.Published {
		go bc.triggerBlogNotification(&art)
	}

	// Best-effort: refresh published articles cache
	go bc.writeArticlesCache()
	
	// Reload article with associations and match link for complete response
	bc.DB.Preload("Author").Preload("Category").First(&art, art.ID)
	var matchLink models.ArticleMatchLink
	if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil {
		art.MatchLink = &matchLink
	}
	
	c.JSON(http.StatusCreated, art)
}

// UpdateArticle updates an existing article (protected; author or admin)
func (bc *BaseController) UpdateArticle(c *gin.Context) {
	// Require authenticated user
	uVal, ok := c.Get("user")
	if !ok {
		c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"})
		return
	}
	user := uVal.(*models.User)

	type reqBody struct {
		Title          *string `json:"title"`
		Content        *string `json:"content"`
		CategoryID     *uint   `json:"category_id"`
		CategoryName   *string `json:"category_name"`
		ImageURL       *string `json:"image_url"`
		Published      *bool   `json:"published"`
		PublishedAt    *string `json:"published_at"`
		Slug           *string `json:"slug"`
		SeoTitle       *string `json:"seo_title"`
		SeoDescription *string `json:"seo_description"`
		OgImageURL     *string `json:"og_image_url"`
		Featured       *bool   `json:"featured"`
		// Gallery fields (optional)
		GalleryAlbumID  *string  `json:"gallery_album_id"`
		GalleryAlbumURL *string  `json:"gallery_album_url"`
		GalleryPhotoIDs []string `json:"gallery_photo_ids"`
		YouTubeVideoID        *string `json:"youtube_video_id"`
		YouTubeVideoTitle     *string `json:"youtube_video_title"`
		YouTubeVideoURL       *string `json:"youtube_video_url"`
		YouTubeVideoThumbnail *string `json:"youtube_video_thumbnail"`
	}
	var body reqBody
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()})
		return
	}

	id := c.Param("id")
	var art models.Article
	if err := bc.DB.First(&art, id).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}

	// Permission: admin or author
	if user.Role != "admin" && (art.AuthorID == nil || *art.AuthorID != user.ID) {
		c.JSON(http.StatusForbidden, gin.H{"chyba": "Nemáte oprávnění upravit tento článek"})
		return
	}

	// Track if article was published before update
	wasPublished := art.Published

	if body.Title != nil {
		art.Title = strings.TrimSpace(*body.Title)
	}
	if body.Content != nil {
		art.Content = *body.Content
		// Recalculate read time when content changes
		art.ReadTime = computeEstimatedReadMinutes(*body.Content)
	}
	if body.CategoryID != nil {
		art.CategoryID = body.CategoryID
	}
	if body.CategoryName != nil {
		name := strings.TrimSpace(*body.CategoryName)
		if name != "" {
			var cat models.Category
			if err := bc.DB.Where("name = ?", name).First(&cat).Error; err != nil {
				if err == gorm.ErrRecordNotFound {
					cat = models.Category{Name: name}
					if err := bc.DB.Create(&cat).Error; err != nil {
						c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
						return
					}
				} else {
					c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze (kategorie)"})
					return
				}
			}
			catID := cat.ID
			art.CategoryID = &catID
		}
	}
	if body.ImageURL != nil {
		art.ImageURL = strings.TrimSpace(*body.ImageURL)
	}
	if body.Published != nil {
		art.Published = *body.Published
	}
	if body.PublishedAt != nil && strings.TrimSpace(*body.PublishedAt) != "" {
		if t, err := time.Parse(time.RFC3339, *body.PublishedAt); err == nil {
			art.PublishedAt = &t
		}
	}
	if body.Featured != nil {
		art.Featured = *body.Featured
	}
	if body.Slug != nil {
		art.Slug = strings.TrimSpace(*body.Slug)
	}
	if body.SeoTitle != nil {
		art.SEOTitle = strings.TrimSpace(*body.SeoTitle)
	}
	if body.SeoDescription != nil {
		art.SEODescription = strings.TrimSpace(*body.SeoDescription)
	}
	if body.OgImageURL != nil {
		art.OGImageURL = strings.TrimSpace(*body.OgImageURL)
	}
	// Gallery fields
	if body.GalleryAlbumID != nil {
		art.GalleryAlbumID = strings.TrimSpace(*body.GalleryAlbumID)
	}
	if body.GalleryAlbumURL != nil {
		art.GalleryAlbumURL = strings.TrimSpace(*body.GalleryAlbumURL)
	}
	if len(body.GalleryPhotoIDs) > 0 {
		art.GalleryPhotoIDs = strings.Join(body.GalleryPhotoIDs, ",")
	}
	if body.YouTubeVideoID != nil {
		art.YouTubeVideoID = strings.TrimSpace(*body.YouTubeVideoID)
	}
	if body.YouTubeVideoTitle != nil {
		art.YouTubeVideoTitle = strings.TrimSpace(*body.YouTubeVideoTitle)
	}
	if body.YouTubeVideoURL != nil {
		art.YouTubeVideoURL = strings.TrimSpace(*body.YouTubeVideoURL)
	}
	if body.YouTubeVideoThumbnail != nil {
		art.YouTubeVideoThumbnail = strings.TrimSpace(*body.YouTubeVideoThumbnail)
	}

	// Auto-fill SEO if still empty after updates
	if strings.TrimSpace(art.SEOTitle) == "" && strings.TrimSpace(art.Title) != "" {
		art.SEOTitle = strings.TrimSpace(art.Title)
	}
	if strings.TrimSpace(art.SEODescription) == "" && strings.TrimSpace(art.Content) != "" {
		art.SEODescription = deriveSeoDescription(art.Content)
	}

	if err := bc.DB.Save(&art).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit změny"})
		return
	}
	if art.ImageURL == "" {
		art.ImageURL = "/dist/img/logo-club-empty.svg"
	}

	// Track file usage
	fileTracker := services.NewFileTracker(bc.DB)
	go fileTracker.TrackArticleFiles(&art)

	// Send newsletter notification if article was just published
	if !wasPublished && art.Published {
		go bc.triggerBlogNotification(&art)
	}

	// Best-effort: refresh published articles cache
	go bc.writeArticlesCache()
	
	// Trigger full prefetch cache update if article is published
	if art.Published {
		go func() {
			base := getPrefetchBaseURL()
			services.PrefetchOnce(base)
		}()
	}
	
	// Reload article with associations and match link for complete response
	bc.DB.Preload("Author").Preload("Category").First(&art, art.ID)
	var matchLink models.ArticleMatchLink
	if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil {
		art.MatchLink = &matchLink
	}
	
	c.JSON(http.StatusOK, art)
}

// GetArticles returns a paginated list of articles (public)
func (bc *BaseController) GetArticles(c *gin.Context) {
	// Filters
	pageStr := c.DefaultQuery("page", "1")
	sizeStr := c.DefaultQuery("page_size", "10")
	publishedOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("published", "false"))) == "true"
	featuredOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("featured", "false"))) == "true"
	categoryIDStr := strings.TrimSpace(c.Query("category_id"))
	page, _ := strconv.Atoi(pageStr)
	size, _ := strconv.Atoi(sizeStr)
	if page < 1 {
		page = 1
	}
	if size < 1 || size > 100 {
		size = 10
	}

	q := bc.DB.Model(&models.Article{}).Preload("Author").Preload("Category").Order("published_at DESC, created_at DESC")
	if publishedOnly {
		q = q.Where("published = ?", true)
	}
	if featuredOnly {
		q = q.Where("featured = ?", true)
	}
	if categoryIDStr != "" {
		if cid, err := strconv.Atoi(categoryIDStr); err == nil && cid > 0 {
			q = q.Where("category_id = ?", cid)
		}
	}
	// Optional full-text like search across title/content/slug
	if search := strings.TrimSpace(c.Query("q")); search != "" {
		like := "%" + search + "%"
		q = q.Where("title ILIKE ? OR content ILIKE ? OR slug ILIKE ?", like, like, like)
	}

	var total int64
	if err := q.Count(&total).Error; err != nil {
		// Fallback to cache on DB error
		if bc.respondArticlesFromCache(c, page, size) {
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}

	var items []models.Article
	if err := q.Offset((page - 1) * size).Limit(size).Find(&items).Error; err != nil {
		// Fallback to cache on DB error
		if bc.respondArticlesFromCache(c, page, size) {
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}
	// If requesting only published and none found, attempt cache as soft-fallback
	if (publishedOnly || featuredOnly) && len(items) == 0 {
		if bc.respondArticlesFromCache(c, page, size) {
			return
		}
	}
	for i := range items {
		if items[i].ImageURL == "" {
			items[i].ImageURL = "/dist/img/logo-club-empty.svg"
		}
	}
	// Batch load match links for all articles
	if len(items) > 0 {
		articleIDs := make([]uint, len(items))
		for i, art := range items {
			articleIDs[i] = art.ID
		}
		var matchLinks []models.ArticleMatchLink
		bc.DB.Where("article_id IN ?", articleIDs).Find(&matchLinks)
		log.Printf("[GetArticles] Loaded %d match links for %d articles", len(matchLinks), len(items))
		// Create map for quick lookup
		matchLinkMap := make(map[uint]*models.ArticleMatchLink)
		for i := range matchLinks {
			matchLinkMap[matchLinks[i].ArticleID] = &matchLinks[i]
			log.Printf("[GetArticles] Match link: article_id=%d, external_match_id=%s", matchLinks[i].ArticleID, matchLinks[i].ExternalMatchID)
		}
		// Assign match links to articles
		matchCount := 0
		for i := range items {
			if ml, ok := matchLinkMap[items[i].ID]; ok {
				items[i].MatchLink = ml
				matchCount++
			}
		}
		log.Printf("[GetArticles] Assigned %d match links to articles", matchCount)
	}
	c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size})
}

// GetFeaturedArticles returns a paginated list of featured, published articles (public)
func (bc *BaseController) GetFeaturedArticles(c *gin.Context) {
	pageStr := c.DefaultQuery("page", "1")
	sizeStr := c.DefaultQuery("page_size", "10")
	page, _ := strconv.Atoi(pageStr)
	size, _ := strconv.Atoi(sizeStr)
	if page < 1 {
		page = 1
	}
	if size < 1 || size > 100 {
		size = 10
	}

	q := bc.DB.Model(&models.Article{}).Preload("Author").Preload("Category").
		Where("published = ? AND featured = ?", true, true).
		Order("published_at DESC, created_at DESC")

	var total int64
	if err := q.Count(&total).Error; err != nil {
		if bc.respondArticlesFromCache(c, page, size) {
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}

	var items []models.Article
	if err := q.Offset((page - 1) * size).Limit(size).Find(&items).Error; err != nil {
		if bc.respondArticlesFromCache(c, page, size) {
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}
	for i := range items {
		if items[i].ImageURL == "" {
			items[i].ImageURL = "/dist/img/logo-club-empty.svg"
		}
	}
	c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size})
}

// DeleteArticle deletes an article by ID
func (bc *BaseController) DeleteArticle(c *gin.Context) {
	id := c.Param("id")
	// Auth user
	uVal, ok := c.Get("user")
	if !ok {
		c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"})
		return
	}
	user := uVal.(*models.User)

	var art models.Article
	if err := bc.DB.First(&art, id).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}

	// Permission: admin or author
	if user.Role != "admin" && (art.AuthorID == nil || *art.AuthorID != user.ID) {
		c.JSON(http.StatusForbidden, gin.H{"chyba": "Nemáte oprávnění smazat tento článek"})
		return
	}

	if err := bc.DB.Delete(&art).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Smazání článku selhalo"})
		return
	}
	// Best-effort: refresh published articles cache
	go bc.writeArticlesCache()
	c.JSON(http.StatusOK, gin.H{"success": true, "message": "Článek byl smazán"})
}

// (Removed duplicate/broken helper implementations here; helpers are defined once above.)

// Public: get one player
func (bc *BaseController) GetPlayer(c *gin.Context) {
	id := c.Param("id")
	var p models.Player
	if err := bc.DB.First(&p, id).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"chyba": "Hráč nenalezen"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}
	c.JSON(http.StatusOK, p)
}

// GetPlayers returns a paginated, filterable list of players (public)
// Query params: page, page_size, team_id, active, q (search), position, nationality
func (bc *BaseController) GetPlayers(c *gin.Context) {
	// Pagination
	pageStr := c.DefaultQuery("page", "1")
	sizeStr := c.DefaultQuery("page_size", "20")
	page, _ := strconv.Atoi(pageStr)
	size, _ := strconv.Atoi(sizeStr)
	if page < 1 {
		page = 1
	}
	if size < 1 || size > 100 {
		size = 20
	}

	q := bc.DB.Model(&models.Player{})

	// Filters
	if teamIDStr := strings.TrimSpace(c.Query("team_id")); teamIDStr != "" {
		if tid, err := strconv.Atoi(teamIDStr); err == nil && tid > 0 {
			q = q.Where("team_id = ?", tid)
		}
	}
	if activeStr := strings.ToLower(strings.TrimSpace(c.Query("active"))); activeStr != "" {
		if activeStr == "true" || activeStr == "1" {
			q = q.Where("is_active = ?", true)
		} else if activeStr == "false" || activeStr == "0" {
			q = q.Where("is_active = ?", false)
		}
	}
	if pos := strings.TrimSpace(c.Query("position")); pos != "" {
		q = q.Where("position ILIKE ?", "%"+pos+"%")
	}
	if nat := strings.TrimSpace(c.Query("nationality")); nat != "" {
		q = q.Where("nationality ILIKE ?", "%"+nat+"%")
	}
	if search := strings.TrimSpace(c.Query("q")); search != "" {
		like := "%" + search + "%"
		q = q.Where("first_name ILIKE ? OR last_name ILIKE ?", like, like)
	}

	// Count total
	var total int64
	if err := q.Count(&total).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}

	// Fetch page
	var items []models.Player
	if err := q.Order("last_name ASC, first_name ASC").Offset((page - 1) * size).Limit(size).Find(&items).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size})
}

// CreatePlayer creates a new player (protected)
func (bc *BaseController) CreatePlayer(c *gin.Context) {
	if _, ok := c.Get("user"); !ok {
		c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"})
		return
	}
	var body struct {
		FirstName    string  `json:"first_name" binding:"required"`
		LastName     string  `json:"last_name" binding:"required"`
		DateOfBirth  *string `json:"date_of_birth"`
		Position     string  `json:"position"`
		JerseyNumber *int    `json:"jersey_number"`
		TeamID       *uint   `json:"team_id"`
		Nationality  string  `json:"nationality"`
		Height       *int    `json:"height"`
		Weight       *int    `json:"weight"`
		IsActive     *bool   `json:"is_active"`
		Email        string  `json:"email"`
		Phone        string  `json:"phone"`
		ImageURL     string  `json:"image_url"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()})
		return
	}
	p := models.Player{
		FirstName:   strings.TrimSpace(body.FirstName),
		LastName:    strings.TrimSpace(body.LastName),
		Position:    strings.TrimSpace(body.Position),
		Nationality: strings.TrimSpace(body.Nationality),
		Email:       strings.TrimSpace(body.Email),
		Phone:       strings.TrimSpace(body.Phone),
		ImageURL:    strings.TrimSpace(body.ImageURL),
	}
	if body.TeamID != nil {
		// Validate team exists
		var t models.Team
		if err := bc.DB.First(&t, *body.TeamID).Error; err != nil {
			if err == gorm.ErrRecordNotFound {
				c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatný team_id"})
				return
			}
			c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
			return
		}
		p.TeamID = *body.TeamID
	}
	if body.JerseyNumber != nil {
		p.JerseyNumber = *body.JerseyNumber
	}
	if body.Height != nil {
		p.Height = *body.Height
	}
	if body.Weight != nil {
		p.Weight = *body.Weight
	}
	if body.IsActive != nil {
		p.IsActive = *body.IsActive
	} else {
		p.IsActive = true
	}
	if body.DateOfBirth != nil && strings.TrimSpace(*body.DateOfBirth) != "" {
		if t, err := time.Parse(time.RFC3339, *body.DateOfBirth); err == nil {
			p.DateOfBirth = t
		} else if t2, err2 := time.Parse("2006-01-02", *body.DateOfBirth); err2 == nil {
			p.DateOfBirth = t2
		}
	}
	if err := bc.DB.Create(&p).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit hráče"})
		return
	}
	
	// Track file usage
	fileTracker := services.NewFileTracker(bc.DB)
	go fileTracker.TrackPlayerFiles(&p)
	
	c.JSON(http.StatusCreated, p)
}

// UpdatePlayer updates an existing player (protected)
func (bc *BaseController) UpdatePlayer(c *gin.Context) {
	if _, ok := c.Get("user"); !ok {
		c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"})
		return
	}
	id := c.Param("id")
	var p models.Player
	if err := bc.DB.First(&p, id).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"chyba": "Hráč nenalezen"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}
	var body struct {
		FirstName    *string `json:"first_name"`
		LastName     *string `json:"last_name"`
		DateOfBirth  *string `json:"date_of_birth"`
		Position     *string `json:"position"`
		JerseyNumber *int    `json:"jersey_number"`
		TeamID       *uint   `json:"team_id"`
		Nationality  *string `json:"nationality"`
		Height       *int    `json:"height"`
		Weight       *int    `json:"weight"`
		IsActive     *bool   `json:"is_active"`
		Email        *string `json:"email"`
		Phone        *string `json:"phone"`
		ImageURL     *string `json:"image_url"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()})
		return
	}
	if body.FirstName != nil {
		p.FirstName = strings.TrimSpace(*body.FirstName)
	}
	if body.LastName != nil {
		p.LastName = strings.TrimSpace(*body.LastName)
	}
	if body.Position != nil {
		p.Position = strings.TrimSpace(*body.Position)
	}
	if body.TeamID != nil {
		var t models.Team
		if err := bc.DB.First(&t, *body.TeamID).Error; err != nil {
			if err == gorm.ErrRecordNotFound {
				c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatný team_id"})
				return
			}
			c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
			return
		}
		p.TeamID = *body.TeamID
	}
	if body.JerseyNumber != nil {
		p.JerseyNumber = *body.JerseyNumber
	}
	if body.Nationality != nil {
		p.Nationality = strings.TrimSpace(*body.Nationality)
	}
	if body.Height != nil {
		p.Height = *body.Height
	}
	if body.Weight != nil {
		p.Weight = *body.Weight
	}
	if body.IsActive != nil {
		p.IsActive = *body.IsActive
	}
	if body.Email != nil {
		p.Email = strings.TrimSpace(*body.Email)
	}
	if body.Phone != nil {
		p.Phone = strings.TrimSpace(*body.Phone)
	}
	if body.ImageURL != nil {
		p.ImageURL = strings.TrimSpace(*body.ImageURL)
	}
	if body.DateOfBirth != nil {
		s := strings.TrimSpace(*body.DateOfBirth)
		if s == "" { /* ignore */
		} else if t, err := time.Parse(time.RFC3339, s); err == nil {
			p.DateOfBirth = t
		} else if t2, err2 := time.Parse("2006-01-02", s); err2 == nil {
			p.DateOfBirth = t2
		}
	}
	if err := bc.DB.Save(&p).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit změny"})
		return
	}
	
	// Track file usage
	fileTracker := services.NewFileTracker(bc.DB)
	go fileTracker.TrackPlayerFiles(&p)
	
	c.JSON(http.StatusOK, p)
}

// DeletePlayer deletes a player (protected)
func (bc *BaseController) DeletePlayer(c *gin.Context) {
	if _, ok := c.Get("user"); !ok {
		c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"})
		return
	}
	id := c.Param("id")
	if err := bc.DB.Delete(&models.Player{}, id).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Smazání hráče selhalo"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"zprava": "Hráč byl smazán"})
}

// CreateTeam creates a new team (protected)
func (bc *BaseController) CreateTeam(c *gin.Context) {
	if _, ok := c.Get("user"); !ok {
		c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"})
		return
	}
	var body struct {
		Name        string `json:"name" binding:"required"`
		ShortName   string `json:"short_name"`
		Description string `json:"description"`
		LogoURL     string `json:"logo_url"`
		IsActive    *bool  `json:"is_active"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()})
		return
	}
	active := true
	if body.IsActive != nil {
		active = *body.IsActive
	}
	t := models.Team{
		Name:        strings.TrimSpace(body.Name),
		ShortName:   strings.TrimSpace(body.ShortName),
		Description: strings.TrimSpace(body.Description),
		LogoURL:     strings.TrimSpace(body.LogoURL),
		IsActive:    active,
	}
	if err := bc.DB.Create(&t).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit tým"})
		return
	}
	c.JSON(http.StatusCreated, t)
}

// UpdateTeam updates an existing team (protected)
func (bc *BaseController) UpdateTeam(c *gin.Context) {
	if _, ok := c.Get("user"); !ok {
		c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"})
		return
	}
	id := c.Param("id")
	var t models.Team
	if err := bc.DB.First(&t, id).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"chyba": "Tým nenalezen"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}
	var body struct {
		Name        *string `json:"name"`
		ShortName   *string `json:"short_name"`
		Description *string `json:"description"`
		LogoURL     *string `json:"logo_url"`
		IsActive    *bool   `json:"is_active"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()})
		return
	}
	if body.Name != nil {
		t.Name = strings.TrimSpace(*body.Name)
	}
	if body.ShortName != nil {
		t.ShortName = strings.TrimSpace(*body.ShortName)
	}
	if body.Description != nil {
		t.Description = strings.TrimSpace(*body.Description)
	}
	if body.LogoURL != nil {
		t.LogoURL = strings.TrimSpace(*body.LogoURL)
	}
	if body.IsActive != nil {
		t.IsActive = *body.IsActive
	}
	if err := bc.DB.Save(&t).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit změny"})
		return
	}
	c.JSON(http.StatusOK, t)
}

// DeleteTeam deletes a team (protected)
func (bc *BaseController) DeleteTeam(c *gin.Context) {
	if _, ok := c.Get("user"); !ok {
		c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"})
		return
	}
	id := c.Param("id")
	if err := bc.DB.Delete(&models.Team{}, id).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Smazání týmu selhalo"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"zprava": "Tým byl smazán"})
}

// Public: list teams
func (bc *BaseController) GetTeams(c *gin.Context) {
	var items []models.Team
	if err := bc.DB.Order("name ASC").Find(&items).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}
	c.JSON(http.StatusOK, items)
}

// Public: get one team
func (bc *BaseController) GetTeam(c *gin.Context) {
	id := c.Param("id")
	var t models.Team
	if err := bc.DB.First(&t, id).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"chyba": "Tým nenalezen"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}
	c.JSON(http.StatusOK, t)
}

// Public: list sponsors
func (bc *BaseController) GetSponsors(c *gin.Context) {
	var items []models.Sponsor
	// Order by tier (general first), then by display_order, then by name
	if err := bc.DB.Order("CASE WHEN tier = 'general' THEN 0 ELSE 1 END, display_order ASC, name ASC").Find(&items).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}
	c.JSON(http.StatusOK, items)
}

// CreateSponsor creates a sponsor (protected)
func (bc *BaseController) CreateSponsor(c *gin.Context) {
	if _, ok := c.Get("user"); !ok {
		c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"})
		return
	}
	var body struct {
		Name         string `json:"name" binding:"required"`
		LogoURL      string `json:"logo_url"`
		WebsiteURL   string `json:"website_url"`
		IsActive     *bool  `json:"is_active"`
		Tier         string `json:"tier"`
		DisplayOrder *int   `json:"display_order"`
		Placement    string `json:"placement"`
		Width        *int   `json:"width"`
		Height       *int   `json:"height"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()})
		return
	}
	active := true
	if body.IsActive != nil {
		active = *body.IsActive
	}
	tier := strings.TrimSpace(body.Tier)
	if tier == "" {
		tier = "standard"
	}
	displayOrder := 0
	if body.DisplayOrder != nil {
		displayOrder = *body.DisplayOrder
	}
	s := models.Sponsor{
		Name:         strings.TrimSpace(body.Name),
		LogoURL:      strings.TrimSpace(body.LogoURL),
		WebsiteURL:   strings.TrimSpace(body.WebsiteURL),
		IsActive:     active,
		Tier:         tier,
		DisplayOrder: displayOrder,
		Placement:    strings.TrimSpace(body.Placement),
	}
	if body.Width != nil {
		s.Width = *body.Width
	}
	if body.Height != nil {
		s.Height = *body.Height
	}
	// Defaults by placement if width/height not provided
	if (s.Width == 0 || s.Height == 0) && s.Placement != "" {
		switch s.Placement {
		case "homepage_top", "homepage_footer":
			if s.Width == 0 {
				s.Width = 1200
			}
			if s.Height == 0 {
				s.Height = 200
			}
		case "homepage_middle":
			if s.Width == 0 {
				s.Width = 970
			}
			if s.Height == 0 {
				s.Height = 250
			}
		case "homepage_sidebar":
			if s.Width == 0 {
				s.Width = 300
			}
			if s.Height == 0 {
				s.Height = 250
			}
		}
	}
	if err := bc.DB.Create(&s).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit sponzora"})
		return
	}
	
	// Track file usage
	fileTracker := services.NewFileTracker(bc.DB)
	go fileTracker.TrackSponsorFiles(&s)
	
	c.JSON(http.StatusCreated, s)
}

// UpdateSponsor updates a sponsor (protected)
func (bc *BaseController) UpdateSponsor(c *gin.Context) {
	if _, ok := c.Get("user"); !ok {
		c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"})
		return
	}
	id := c.Param("id")
	var s models.Sponsor
	if err := bc.DB.First(&s, id).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"chyba": "Sponzor nenalezen"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}
	var body struct {
		Name         *string `json:"name"`
		LogoURL      *string `json:"logo_url"`
		WebsiteURL   *string `json:"website_url"`
		IsActive     *bool   `json:"is_active"`
		Tier         *string `json:"tier"`
		DisplayOrder *int    `json:"display_order"`
		Placement    *string `json:"placement"`
		Width        *int    `json:"width"`
		Height       *int    `json:"height"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()})
		return
	}
	if body.Name != nil {
		s.Name = strings.TrimSpace(*body.Name)
	}
	if body.LogoURL != nil {
		s.LogoURL = strings.TrimSpace(*body.LogoURL)
	}
	if body.WebsiteURL != nil {
		s.WebsiteURL = strings.TrimSpace(*body.WebsiteURL)
	}
	if body.IsActive != nil {
		s.IsActive = *body.IsActive
	}
	if body.Tier != nil {
		tier := strings.TrimSpace(*body.Tier)
		if tier != "" {
			s.Tier = tier
		}
	}
	if body.DisplayOrder != nil {
		s.DisplayOrder = *body.DisplayOrder
	}
	if body.Placement != nil {
		s.Placement = strings.TrimSpace(*body.Placement)
	}
	if body.Width != nil {
		s.Width = *body.Width
	}
	if body.Height != nil {
		s.Height = *body.Height
	}
	// If placement changed and no explicit dimensions provided, apply defaults
	if (body.Width == nil || *body.Width == 0) && (body.Height == nil || *body.Height == 0) && s.Placement != "" {
		switch s.Placement {
		case "homepage_top", "homepage_footer":
			s.Width = 1200
			s.Height = 200
		case "homepage_middle":
			s.Width = 970
			s.Height = 250
		case "homepage_sidebar":
			s.Width = 300
			s.Height = 250
		}
	}
	if err := bc.DB.Save(&s).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit změny"})
		return
	}
	
	// Track file usage
	fileTracker := services.NewFileTracker(bc.DB)
	go fileTracker.TrackSponsorFiles(&s)
	
	// Return updated sponsor
	c.JSON(http.StatusOK, s)
}

// DeleteSponsor deletes a sponsor (protected)
func (bc *BaseController) DeleteSponsor(c *gin.Context) {
	if _, ok := c.Get("user"); !ok {
		c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"})
		return
	}
	id := c.Param("id")
	if err := bc.DB.Delete(&models.Sponsor{}, id).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Smazání sponzora selhalo"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"zprava": "Sponzor byl smazán"})
}

// --- Public: Matches and Standings (from prefetch cache) ---

// GetMatches returns cached upcoming matches with overrides applied (public)
func (bc *BaseController) GetMatches(c *gin.Context) {
	p := filepath.Join("cache", "prefetch", "events_upcoming.json")
	f, err := os.Open(p)
	if err != nil {
		c.JSON(http.StatusNoContent, gin.H{"message": "No cached matches"})
		return
	}
	defer f.Close()

	var matches []map[string]interface{}
	if err := json.NewDecoder(f).Decode(&matches); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Cannot parse cached matches"})
		return
	}

	// Load and apply overrides (same logic as admin endpoint)
	var movs []models.MatchOverride
	if err := bc.DB.Find(&movs).Error; err == nil {
		movByID := map[string]models.MatchOverride{}
		for _, m := range movs {
			movByID[m.ExternalMatchID] = m
		}

		var tlovs []models.TeamLogoOverride
		if err := bc.DB.Find(&tlovs).Error; err == nil {
			tloByTeam := map[string]models.TeamLogoOverride{}
			for _, t := range tlovs {
				tloByTeam[t.ExternalTeamID] = t
			}

			// Apply overrides
			for _, m := range matches {
				var matchID string
				if v, ok := m["match_id"].(string); ok {
					matchID = v
				} else if v2, ok2 := m["id"].(string); ok2 {
					matchID = v2
				}

				if ov, ok := movByID[matchID]; ok {
					if ov.HomeNameOverride != nil {
						m["home"] = *ov.HomeNameOverride
						m["home_team"] = *ov.HomeNameOverride
					}
					if ov.AwayNameOverride != nil {
						m["away"] = *ov.AwayNameOverride
						m["away_team"] = *ov.AwayNameOverride
					}
					if ov.VenueOverride != nil {
						m["venue"] = *ov.VenueOverride
					}
					if ov.DateTimeOverride != nil {
						m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
						m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
					}
					if ov.HomeLogoURL != nil {
						m["home_logo_url"] = *ov.HomeLogoURL
					}
					if ov.AwayLogoURL != nil {
						m["away_logo_url"] = *ov.AwayLogoURL
					}
				}

				// Apply team logo overrides
				if homeTeamID, ok := m["home_team_id"].(string); ok {
					if tlo, found := tloByTeam[homeTeamID]; found && tlo.LogoURL != "" {
						m["home_logo_url"] = tlo.LogoURL
					}
				}
				if awayTeamID, ok := m["away_team_id"].(string); ok {
					if tlo, found := tloByTeam[awayTeamID]; found && tlo.LogoURL != "" {
						m["away_logo_url"] = tlo.LogoURL
					}
				}
			}
		}
	}

	// Optional search filter: match home/away/venue/competition fields
	if s := strings.ToLower(strings.TrimSpace(c.Query("q"))); s != "" {
		filtered := make([]map[string]interface{}, 0, len(matches))
		for _, m := range matches {
			get := func(k string) string {
				if v, ok := m[k]; ok {
					if vs, ok2 := v.(string); ok2 {
						return vs
					}
				}
				return ""
			}
			fields := []string{
				get("home"), get("away"), get("venue"), get("competition"), get("competition_name"), get("league"),
			}
			matched := false
			for _, f := range fields {
				if f == "" {
					continue
				}
				if strings.Contains(strings.ToLower(f), s) {
					matched = true
					break
				}
			}
			if matched {
				filtered = append(filtered, m)
			}
		}
		matches = filtered
	}

	c.Header("Cache-Control", "public, max-age=60")
	c.JSON(http.StatusOK, matches)
}

// GetStandings returns cached FACR tables
func (bc *BaseController) GetStandings(c *gin.Context) {
	p := filepath.Join("cache", "prefetch", "facr_tables.json")
	b, err := os.ReadFile(p)
	if err != nil {
		c.JSON(http.StatusNoContent, gin.H{"message": "No cached standings"})
		return
	}
	c.Header("Cache-Control", "public, max-age=300")
	c.Data(http.StatusOK, "application/json", b)
}

// Admin: Users list
func (bc *BaseController) GetUsers(c *gin.Context) {
	var users []models.User
	if err := bc.DB.Order("created_at DESC").Find(&users).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
		return
	}
	resp := make([]gin.H, 0, len(users))
	for _, u := range users {
		name := strings.TrimSpace(strings.TrimSpace(u.FirstName) + " " + strings.TrimSpace(u.LastName))
		resp = append(resp, gin.H{
			"id":    u.ID,
			"email": u.Email,
			"name":  name,
			"role":  u.Role,
			// Model nemÃ¡ pÅ™Ã­znak aktivity; pro teÄ vÅ¾dy true
			"isActive":  true,
			"createdAt": u.CreatedAt,
		})
	}
	c.JSON(http.StatusOK, resp)
}

// Admin: Create user
func (bc *BaseController) CreateUser(c *gin.Context) {
	var body struct {
		Name     string `json:"name"`
		Email    string `json:"email" binding:"required"`
		Password string `json:"password" binding:"required,min=8"`
		Role     string `json:"role"`
		IsActive *bool  `json:"isActive"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	body.Email = strings.TrimSpace(strings.ToLower(body.Email))
	if body.Email == "" || !strings.Contains(body.Email, "@") {
		c.JSON(http.StatusBadRequest, gin.H{"error": "NeplatnÃ½ email"})
		return
	}
	parts := strings.Fields(strings.TrimSpace(body.Name))
	first, last := "", ""
	if len(parts) > 0 {
		first = parts[0]
	}
	if len(parts) > 1 {
		last = strings.Join(parts[1:], " ")
	}
	role := strings.ToLower(strings.TrimSpace(body.Role))
	if role != "admin" {
		role = "user"
	}
	var existing models.User
	if err := bc.DB.Where("LOWER(email) = LOWER(?)", body.Email).First(&existing).Error; err == nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Email jiÅ¾ existuje"})
		return
	}
	hashed, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze zpracovat heslo"})
		return
	}
	u := models.User{
		Email:     body.Email,
		Password:  string(hashed),
		FirstName: first,
		LastName:  last,
		Role:      role,
	}
	if err := bc.DB.Create(&u).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit uživatele"})
		return
	}
	name := strings.TrimSpace(strings.TrimSpace(u.FirstName) + " " + strings.TrimSpace(u.LastName))
	c.JSON(http.StatusCreated, gin.H{
		"id":        u.ID,
		"email":     u.Email,
		"name":      name,
		"role":      u.Role,
		"isActive":  true,
		"createdAt": u.CreatedAt,
	})
}

// Admin: Update user
func (bc *BaseController) UpdateUser(c *gin.Context) {
	id := c.Param("id")
	var u models.User
	if err := bc.DB.First(&u, id).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"error": "Uživatel nenalezen"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
		return
	}
	var body struct {
		Name     *string `json:"name"`
		Email    *string `json:"email"`
		Role     *string `json:"role"`
		IsActive *bool   `json:"isActive"`
		Password *string `json:"password"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if body.Name != nil {
		parts := strings.Fields(strings.TrimSpace(*body.Name))
		u.FirstName, u.LastName = "", ""
		if len(parts) > 0 {
			u.FirstName = parts[0]
		}
		if len(parts) > 1 {
			u.LastName = strings.Join(parts[1:], " ")
		}
	}
	if body.Email != nil {
		email := strings.TrimSpace(strings.ToLower(*body.Email))
		if email == "" || !strings.Contains(email, "@") {
			c.JSON(http.StatusBadRequest, gin.H{"error": "NeplatnÃ½ email"})
			return
		}
		var cnt int64
		if err := bc.DB.Model(&models.User{}).Where("LOWER(email) = LOWER(?) AND id <> ?", email, u.ID).Count(&cnt).Error; err == nil && cnt > 0 {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Email jiÅ¾ existuje"})
			return
		}
		u.Email = email
	}
	if body.Role != nil {
		r := strings.ToLower(strings.TrimSpace(*body.Role))
		if r != "admin" && r != "user" {
			c.JSON(http.StatusBadRequest, gin.H{"error": "NeplatnÃ¡ role"})
			return
		}
		u.Role = r
	}
	if body.Password != nil && *body.Password != "" {
		hashed, err := bcrypt.GenerateFromPassword([]byte(*body.Password), bcrypt.DefaultCost)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze zpracovat heslo"})
			return
		}
		u.Password = string(hashed)
	}
	if err := bc.DB.Save(&u).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"})
		return
	}
	name := strings.TrimSpace(strings.TrimSpace(u.FirstName) + " " + strings.TrimSpace(u.LastName))
	c.JSON(http.StatusOK, gin.H{
		"id":        u.ID,
		"email":     u.Email,
		"name":      name,
		"role":      u.Role,
		"isActive":  true,
		"createdAt": u.CreatedAt,
	})
}

// Admin: Delete user
func (bc *BaseController) DeleteUser(c *gin.Context) {
	id := c.Param("id")
	var u models.User
	if err := bc.DB.First(&u, id).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			c.JSON(http.StatusNotFound, gin.H{"error": "Uživatel nenalezen"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
		return
	}
	// Disallow deleting any admin user
	if u.Role == "admin" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Nelze smazat uživatele s rolí admin"})
		return
	}
	if curVal, ok := c.Get("user"); ok {
		cur := curVal.(*models.User)
		if cur.ID == u.ID {
			c.JSON(http.StatusBadRequest, gin.H{"error": "Nelze smazat sám sebe"})
			return
		}
	}
	if err := bc.DB.Delete(&u).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Smazání uživatele selhalo"})
		return
	}
}

// Admin: Settings - get settings (singleton)
func (bc *BaseController) GetSettings(c *gin.Context) {
	var s models.Settings
	if err := bc.DB.First(&s).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			// Return sensible defaults rather than 404
			s = models.Settings{
				FrontpageLayout:     "classic",
				FrontpageStyle:      "light",
				PrimaryColor:        "#1a365d",
				SecondaryColor:      "#2b6cb0",
				AccentColor:         "#e53e3e",
				BackgroundColor:     "#ffffff",
				TextColor:           "#1a202c",
				FontHeading:         "Poppins, sans-serif",
				FontBody:            "Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif",
				VideosModuleEnabled: true,
				VideosSource:        "auto",
				VideosStyle:         "slider",
				VideosLimit:         5,
				ShowAboutInNav:      true,
			}
			c.JSON(http.StatusOK, s)
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
		return
	}

	s.LoadCustomNav()
	c.JSON(http.StatusOK, s)
}

// Admin: Settings - update settings (upsert singleton)
func (bc *BaseController) UpdateSettings(c *gin.Context) {
	// Ensure latest schema for settings (adds new columns if needed)
	_ = bc.DB.AutoMigrate(&models.Settings{})
	type reqBody struct {
		FrontpageLayout *string `json:"frontpage_layout"`
		FrontpageStyle  *string `json:"frontpage_style"`
		// Sponsors module prefs
		SponsorsLayout *string `json:"sponsors_layout"`
		SponsorsTheme  *string `json:"sponsors_theme"`
		ClubID         *string `json:"club_id"`
		ClubType       *string `json:"club_type"`
		ClubName       *string `json:"club_name"`
		ClubLogoURL    *string `json:"club_logo_url"`
		ClubURL        *string `json:"club_url"`

		// Theme customization
		PrimaryColor    *string `json:"primary_color"`
		SecondaryColor  *string `json:"secondary_color"`
		AccentColor     *string `json:"accent_color"`
		BackgroundColor *string `json:"background_color"`
		TextColor       *string `json:"text_color"`
		FontHeading     *string `json:"font_heading"`
		FontBody        *string `json:"font_body"`

		// Custom overrides
		CustomCSS          *string `json:"custom_css"`
		CustomJS           *string `json:"custom_js"`
		CustomHTMLHome     *string `json:"custom_html_home"`
		CustomHTMLBlogList *string `json:"custom_html_blog_list"`
		CustomHTMLBlogPost *string `json:"custom_html_blog_post"`

		// Custom pages & navigation
		AboutHTML      *string                  `json:"about_html"`
		ShowAboutInNav *bool                    `json:"show_about_in_nav"`
		CustomNav      *[]models.CustomNavLink  `json:"custom_nav"`

		// Gallery
		GalleryURL   *string `json:"gallery_url"`
		GalleryLabel *string `json:"gallery_label"`

		// SMTP (optional, dynamic)
		SMTPHost       *string `json:"smtp_host"`
		SMTPPort       *int    `json:"smtp_port"`
		SMTPUser       *string `json:"smtp_user"`
		SMTPPassword   *string `json:"smtp_password"`
		SMTPFrom       *string `json:"smtp_from"`
		SMTPFromName   *string `json:"smtp_from_name"`
		SMTPEncryption *string `json:"smtp_encryption"` // tls|ssl|none
		SMTPAuth       *bool   `json:"smtp_auth"`
		SMTPSkipVerify *bool   `json:"smtp_skip_verify"`

		// Social profiles
		FacebookURL  *string `json:"facebook_url"`
		InstagramURL *string `json:"instagram_url"`
		YoutubeURL   *string `json:"youtube_url"`

		// Videos module
		VideosModuleEnabled *bool   `json:"videos_module_enabled"`
		VideosStyle         *string `json:"videos_style"`
		VideosSource        *string `json:"videos_source"`
		VideosLimit         *int    `json:"videos_limit"`
		// Manual videos
		Videos      *[]string `json:"videos"`
		VideosItems *[]struct {
			URL          string  `json:"url"`
			Title        *string `json:"title"`
			Length       *string `json:"length"`
			UploadedAt   *string `json:"uploaded_at"`
			ThumbnailURL *string `json:"thumbnail_url"`
		} `json:"videos_items"`

		// Merch module
		MerchModuleEnabled *bool   `json:"merch_module_enabled"`
		MerchStyle         *string `json:"merch_style"`
		MerchSource        *string `json:"merch_source"`
		MerchLimit         *int    `json:"merch_limit"`
		// Manual merch
		MerchItems *[]struct {
			Title    *string `json:"title"`
			ImageURL string  `json:"image_url"`
			URL      *string `json:"url"`
		} `json:"merch_items"`

		// Newsletter defaults
		DefaultDigestType         *string `json:"default_digest_type"`
		DefaultDigestCompetitions *string `json:"default_digest_competitions"`

		// Newsletter scheduling
		EnableWeekly                *bool   `json:"enable_weekly"`
		EnableMatchReminders        *bool   `json:"enable_match_reminders"`
		EnableResults               *bool   `json:"enable_results"`
		NewsletterWeeklyDay         *string `json:"newsletter_weekly_day"`
		NewsletterWeeklyHour        *int    `json:"newsletter_weekly_hour"`
		NewsletterReminderLeadHours *int    `json:"newsletter_reminder_lead_hours"`
		NewsletterQuietStart        *int    `json:"newsletter_quiet_start"`
		NewsletterQuietEnd          *int    `json:"newsletter_quiet_end"`

		// Contact/Location information
		ContactAddress    *string  `json:"contact_address"`
		ContactCity       *string  `json:"contact_city"`
		ContactZip        *string  `json:"contact_zip"`
		ContactCountry    *string  `json:"contact_country"`
		ContactPhone      *string  `json:"contact_phone"`
		ContactEmail      *string  `json:"contact_email"`
		LocationLatitude  *float64 `json:"location_latitude"`
		LocationLongitude *float64 `json:"location_longitude"`
		MapZoomLevel      *int     `json:"map_zoom_level"`
		MapStyle          *string  `json:"map_style"`
		ShowMapOnHomepage *bool    `json:"show_map_on_homepage"`
	}
	var body reqBody
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()})
		return
	}

	var s models.Settings
	if err := bc.DB.First(&s).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			s = models.Settings{}
		} else {
			c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
			return
		}
	}

	// Ensure navigation cache is hydrated before modifications
	s.LoadCustomNav()

	if body.FrontpageLayout != nil {
		s.FrontpageLayout = *body.FrontpageLayout
	}
	if body.FrontpageStyle != nil {
		s.FrontpageStyle = *body.FrontpageStyle
	}
	// Sponsors module
	if body.SponsorsLayout != nil {
		v := strings.TrimSpace(*body.SponsorsLayout)
		switch v {
		case "grid", "slider", "scroller", "pyramid":
			s.SponsorsLayout = v
		default:
			// keep previous or default to grid
			if s.SponsorsLayout == "" {
				s.SponsorsLayout = "grid"
			}
		}
	}
	if body.SponsorsTheme != nil {
		v := strings.TrimSpace(*body.SponsorsTheme)
		if v == "dark" || v == "light" {
			s.SponsorsTheme = v
		} else {
			if s.SponsorsTheme == "" {
				s.SponsorsTheme = "light"
			}
		}
	}
	if body.ClubID != nil {
		s.ClubID = *body.ClubID
	}
	if body.ClubType != nil {
		s.ClubType = *body.ClubType
	}
	if body.ClubName != nil {
		s.ClubName = *body.ClubName
	}
	if body.ClubLogoURL != nil {
		s.ClubLogoURL = *body.ClubLogoURL
	}
	if body.ClubURL != nil {
		s.ClubURL = *body.ClubURL
	}

	if body.PrimaryColor != nil {
		s.PrimaryColor = *body.PrimaryColor
	}
	if body.SecondaryColor != nil {
		s.SecondaryColor = *body.SecondaryColor
	}
	if body.AccentColor != nil {
		s.AccentColor = *body.AccentColor
	}
	if body.BackgroundColor != nil {
		s.BackgroundColor = *body.BackgroundColor
	}
	if body.TextColor != nil {
		s.TextColor = *body.TextColor
	}
	if body.FontHeading != nil {
		s.FontHeading = *body.FontHeading
	}
	if body.FontBody != nil {
		s.FontBody = *body.FontBody
	}

	if body.CustomCSS != nil {
		s.CustomCSS = *body.CustomCSS
	}
	// Newsletter defaults
	if body.DefaultDigestType != nil {
		s.DefaultDigestType = *body.DefaultDigestType
	}
	if body.DefaultDigestCompetitions != nil {
		s.DefaultDigestCompetitions = *body.DefaultDigestCompetitions
	}

	// Newsletter scheduling
	if body.EnableWeekly != nil {
		s.EnableWeekly = *body.EnableWeekly
	}
	if body.EnableMatchReminders != nil {
		s.EnableMatchReminders = *body.EnableMatchReminders
	}
	if body.EnableResults != nil {
		s.EnableResults = *body.EnableResults
	}
	if body.NewsletterWeeklyDay != nil {
		s.NewsletterWeeklyDay = strings.ToLower(strings.TrimSpace(*body.NewsletterWeeklyDay))
	}
	if body.NewsletterWeeklyHour != nil {
		s.NewsletterWeeklyHour = *body.NewsletterWeeklyHour
	}
	if body.NewsletterReminderLeadHours != nil {
		s.NewsletterReminderLeadHours = *body.NewsletterReminderLeadHours
	}
	if body.NewsletterQuietStart != nil {
		s.NewsletterQuietStart = *body.NewsletterQuietStart
	}
	if body.NewsletterQuietEnd != nil {
		s.NewsletterQuietEnd = *body.NewsletterQuietEnd
	}
	if body.CustomJS != nil {
		s.CustomJS = *body.CustomJS
	}
	if body.CustomHTMLHome != nil {
		s.CustomHTMLHome = *body.CustomHTMLHome
	}
	if body.CustomHTMLBlogList != nil {
		s.CustomHTMLBlogList = *body.CustomHTMLBlogList
	}
	if body.CustomHTMLBlogPost != nil {
		s.CustomHTMLBlogPost = *body.CustomHTMLBlogPost
	}

	if body.AboutHTML != nil {
		s.AboutHTML = *body.AboutHTML
	}
	if body.ShowAboutInNav != nil {
		s.ShowAboutInNav = *body.ShowAboutInNav
	}
	if body.CustomNav != nil {
		if err := s.SetCustomNav(*body.CustomNav); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná navigace"})
			return
		}
	}

	// Gallery
	if body.GalleryURL != nil {
		s.GalleryURL = *body.GalleryURL
	}
	if body.GalleryLabel != nil {
		s.GalleryLabel = strings.TrimSpace(*body.GalleryLabel)
	}

	// Social profiles
	if body.FacebookURL != nil {
		s.FacebookURL = *body.FacebookURL
	}
	if body.InstagramURL != nil {
		s.InstagramURL = *body.InstagramURL
	}
	if body.YoutubeURL != nil {
		s.YoutubeURL = *body.YoutubeURL
		// Auto-enable videos module when YouTube URL is provided and videos_source is auto
		if strings.TrimSpace(*body.YoutubeURL) != "" {
			// Only auto-enable if not explicitly disabled by user
			if body.VideosModuleEnabled == nil {
				// Check if videos_source is auto (default) or explicitly set to auto
				if s.VideosSource == "" || s.VideosSource == "auto" || (body.VideosSource != nil && *body.VideosSource == "auto") {
					s.VideosModuleEnabled = true
				}
			}
		}
	}

	// Videos module
	if body.VideosModuleEnabled != nil {
		s.VideosModuleEnabled = *body.VideosModuleEnabled
	}
	if body.VideosStyle != nil {
		s.VideosStyle = strings.TrimSpace(*body.VideosStyle)
	}
	if body.VideosSource != nil {
		s.VideosSource = strings.TrimSpace(*body.VideosSource)
	} else if s.VideosSource == "" {
		// Default to auto source
		s.VideosSource = "auto"
	}
	if body.VideosLimit != nil {
		s.VideosLimit = *body.VideosLimit
	} else if s.VideosLimit == 0 {
		// Default to 6 videos on homepage
		s.VideosLimit = 6
	}
	// Manual videos
	if body.Videos != nil {
		if b, err := json.Marshal(body.Videos); err == nil {
			s.VideosJSON = string(b)
		}
	}
	if body.VideosItems != nil {
		if b, err := json.Marshal(body.VideosItems); err == nil {
			s.VideosItemsJSON = string(b)
		}
	}

	// Merch module
	if body.MerchModuleEnabled != nil {
		s.MerchModuleEnabled = *body.MerchModuleEnabled
	}
	if body.MerchStyle != nil {
		s.MerchStyle = strings.TrimSpace(*body.MerchStyle)
	}
	if body.MerchSource != nil {
		s.MerchSource = strings.TrimSpace(*body.MerchSource)
	}
	if body.MerchLimit != nil {
		s.MerchLimit = *body.MerchLimit
	}
	if body.MerchItems != nil {
		if b, err := json.Marshal(body.MerchItems); err == nil {
			s.MerchItemsJSON = string(b)
		}
	}

	// SMTP dynamic settings (if provided)
	if body.SMTPHost != nil {
		s.SMTPHost = strings.TrimSpace(*body.SMTPHost)
	}
	if body.SMTPPort != nil {
		s.SMTPPort = *body.SMTPPort
	}
	if body.SMTPUser != nil {
		s.SMTPUser = strings.TrimSpace(*body.SMTPUser)
	}
	// Only update password if a new one is provided (not empty)
	if body.SMTPPassword != nil && *body.SMTPPassword != "" {
		s.SMTPPassword = *body.SMTPPassword
	}
	if body.SMTPFrom != nil {
		s.SMTPFrom = strings.TrimSpace(*body.SMTPFrom)
	}
	if body.SMTPFromName != nil {
		s.SMTPFromName = strings.TrimSpace(*body.SMTPFromName)
	}
	if body.SMTPEncryption != nil {
		s.SMTPEncryption = strings.ToLower(strings.TrimSpace(*body.SMTPEncryption))
	}
	if body.SMTPAuth != nil {
		s.SMTPAuth = *body.SMTPAuth
	}
	if body.SMTPSkipVerify != nil {
		s.SMTPSkipVerify = *body.SMTPSkipVerify
	}

	// Contact/Location information
	if body.ContactAddress != nil {
		s.ContactAddress = strings.TrimSpace(*body.ContactAddress)
	}
	if body.ContactCity != nil {
		s.ContactCity = strings.TrimSpace(*body.ContactCity)
	}
	if body.ContactZip != nil {
		s.ContactZip = strings.TrimSpace(*body.ContactZip)
	}
	if body.ContactCountry != nil {
		s.ContactCountry = strings.TrimSpace(*body.ContactCountry)
	}
	if body.ContactPhone != nil {
		s.ContactPhone = strings.TrimSpace(*body.ContactPhone)
	}
	if body.ContactEmail != nil {
		s.ContactEmail = strings.TrimSpace(*body.ContactEmail)
	}
	if body.LocationLatitude != nil {
		s.LocationLatitude = *body.LocationLatitude
	}
	if body.LocationLongitude != nil {
		s.LocationLongitude = *body.LocationLongitude
	}
	if body.MapZoomLevel != nil {
		s.MapZoomLevel = *body.MapZoomLevel
	}
	if body.MapStyle != nil {
		s.MapStyle = strings.TrimSpace(*body.MapStyle)
	}
	if body.ShowMapOnHomepage != nil {
		s.ShowMapOnHomepage = *body.ShowMapOnHomepage
	}

	if s.ID == 0 {
		if err := bc.DB.Create(&s).Error; err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit nastavení"})
			return
		}
	} else {
		if err := bc.DB.Save(&s).Error; err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit nastavení"})
			return
		}
	}
	logger.Info("UpdateSettings saved: club_id=%s club_name=%s gallery_url=%s gallery_label=%s", s.ClubID, s.ClubName, s.GalleryURL, s.GalleryLabel)
	// If gallery_url is a Zonerama link, refresh Zonerama cache immediately
	if g := strings.TrimSpace(s.GalleryURL); g != "" && strings.Contains(strings.ToLower(g), "zonerama.com") {
		go func(link string) { _ = services.RefreshZoneramaNow(link) }(g)
	}
	// If YouTube URL updated, trigger immediate refresh
	if body.YoutubeURL != nil {
		v := strings.TrimSpace(*body.YoutubeURL)
		if v != "" {
			go func(u string) { _ = services.RefreshYouTubeChannelNow(u) }(v)
		}
	}
	c.JSON(http.StatusOK, s)
}

// NewBaseController creates a new instance of BaseController
func (bc *BaseController) GetPublicSettings(c *gin.Context) {
	var s models.Settings
	if err := bc.DB.First(&s).Error; err != nil {
		if err == gorm.ErrRecordNotFound {
			// mirror defaults from GetSettings
			s = models.Settings{
				FrontpageLayout:     "classic",
				FrontpageStyle:      "light",
				PrimaryColor:        "#1a365d",
				SecondaryColor:      "#2b6cb0",
				AccentColor:         "#e53e3e",
				BackgroundColor:     "#ffffff",
				TextColor:           "#1a202c",
				FontHeading:         "Poppins, sans-serif",
				FontBody:            "Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif",
				VideosModuleEnabled: false,
				VideosSource:        "auto",
				VideosStyle:         "slider",
				VideosLimit:         6,
			}
		} else {
			c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
			return
		}
	}

	// Apply defaults for videos settings if not set
	if s.VideosSource == "" {
		s.VideosSource = "auto"
	}
	if s.VideosLimit == 0 {
		s.VideosLimit = 6
	}
	if s.VideosStyle == "" {
		s.VideosStyle = "slider"
	}
	// Auto-enable videos module if YouTube URL is provided and source is auto
	if s.YoutubeURL != "" && s.VideosSource == "auto" && !s.VideosModuleEnabled {
		s.VideosModuleEnabled = true
	}

	// Build a whitelist response
	// Conditional GET based on settings timestamp
	last := s.UpdatedAt
	if last.IsZero() {
		last = time.Now().Add(-1 * time.Hour)
	}
	if ims := c.GetHeader("If-Modified-Since"); ims != "" {
		if t, err := time.Parse(http.TimeFormat, ims); err == nil {
			if !last.After(t) {
				c.Status(http.StatusNotModified)
				return
			}
		}
	}
	// Decode manual videos for public payload
	var pubVids []string
	if s.VideosJSON != "" {
		_ = json.Unmarshal([]byte(s.VideosJSON), &pubVids)
	}
	var pubVidsItems any
	if s.VideosItemsJSON != "" {
		_ = json.Unmarshal([]byte(s.VideosItemsJSON), &pubVidsItems)
	}
	var pubMerchItems any
	if s.MerchItemsJSON != "" {
		_ = json.Unmarshal([]byte(s.MerchItemsJSON), &pubMerchItems)
	}
	resp := gin.H{
		// Core site identity (needed for prefetch to derive FACR endpoints)
		"club_id":       s.ClubID,
		"club_type":     s.ClubType,
		"club_name":     s.ClubName,
		"club_logo_url": s.ClubLogoURL,
		"club_url":      s.ClubURL,

		// Theme
		"primary_color":    s.PrimaryColor,
		"secondary_color":  s.SecondaryColor,
		"accent_color":     s.AccentColor,
		"background_color": s.BackgroundColor,
		"text_color":       s.TextColor,
		"font_heading":     s.FontHeading,
		"font_body":        s.FontBody,

		// Sponsors module prefs
		"sponsors_layout": s.SponsorsLayout,
		"sponsors_theme":  s.SponsorsTheme,
		// Social / media
		"facebook_url":  s.FacebookURL,
		"instagram_url": s.InstagramURL,
		"youtube_url":   s.YoutubeURL,
		"gallery_url":   s.GalleryURL,
		"gallery_label": s.GalleryLabel,
		// Videos module
		"videos_module_enabled": s.VideosModuleEnabled,
		"videos_style":          s.VideosStyle,
		"videos_source":         s.VideosSource,
		"videos_limit":          s.VideosLimit,
		// Manual videos (public so homepage can render)
		"videos":       pubVids,
		"videos_items": pubVidsItems,
		// Merch config + items
		"merch_module_enabled": s.MerchModuleEnabled,
		"merch_style":          s.MerchStyle,
		"merch_source":         s.MerchSource,
		"merch_limit":          s.MerchLimit,
		"merch_items":          pubMerchItems,
		// Custom club page & navigation
		"about_html":       s.AboutHTML,
		"show_about_in_nav": s.ShowAboutInNav,
		"custom_nav":       s.CustomNav,

		// Contact/Map information (public for displaying on contact page and homepage)
		"contact_address":      s.ContactAddress,
		"contact_city":         s.ContactCity,
		"contact_zip":          s.ContactZip,
		"contact_country":      s.ContactCountry,
		"contact_phone":        s.ContactPhone,
		"contact_email":        s.ContactEmail,
		"location_latitude":    s.LocationLatitude,
		"location_longitude":   s.LocationLongitude,
		"map_zoom_level":       s.MapZoomLevel,
		"map_style":            s.MapStyle,
		"show_map_on_homepage": s.ShowMapOnHomepage,
	}
	logger.Debug("GetPublicSettings response includes gallery: url=%s label=%s", s.GalleryURL, s.GalleryLabel)
	c.JSON(http.StatusOK, resp)
}

// Global newsletter automation instance (set from main)
var globalNewsletterAutomation *services.NewsletterAutomation

// SetNewsletterAutomation sets the global newsletter automation instance
func SetNewsletterAutomation(na *services.NewsletterAutomation) {
	globalNewsletterAutomation = na
}

// triggerBlogNotification sends newsletter notification when blog is published
func (bc *BaseController) triggerBlogNotification(article *models.Article) {
	if globalNewsletterAutomation == nil {
		logger.Warn("Newsletter automation not initialized, skipping blog notification")
		return
	}

	if err := globalNewsletterAutomation.SendBlogNotification(article); err != nil {
		logger.Error("Failed to send blog notification: %v", err)
	}
}
