package main

import (
	"bytes"
	"context"
	"errors"
	"flag"
	"fmt"
	"io"
	"io/fs"
	"log/slog"
	"math"
	"net"
	"net/http"
	"os"
	"os/exec"
	"os/signal"
	"path/filepath"
	"runtime"
	"sort"
	"strconv"
	"strings"
	syncpkg "sync"
	"syscall"
	"time"

	qfassets "git.mchus.pro/mchus/quoteforge"
	"git.mchus.pro/mchus/quoteforge/internal/appmeta"
	"git.mchus.pro/mchus/quoteforge/internal/appstate"
	"git.mchus.pro/mchus/quoteforge/internal/config"
	"git.mchus.pro/mchus/quoteforge/internal/db"
	"git.mchus.pro/mchus/quoteforge/internal/handlers"
	"git.mchus.pro/mchus/quoteforge/internal/localdb"
	"git.mchus.pro/mchus/quoteforge/internal/middleware"
	"git.mchus.pro/mchus/quoteforge/internal/models"
	"git.mchus.pro/mchus/quoteforge/internal/services"
	"git.mchus.pro/mchus/quoteforge/internal/services/sync"
	"github.com/gin-gonic/gin"
	"gopkg.in/yaml.v3"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

// Version is set via ldflags during build
var Version = "dev"
var errVendorImportTooLarge = errors.New("vendor workspace file exceeds 1 GiB limit")

const backgroundSyncInterval = 5 * time.Minute
const onDemandPullCooldown = 30 * time.Second
const startupConsoleWarning = "Не закрывайте консоль иначе приложение не будет работать"

var vendorImportMaxBytes int64 = 1 << 30

const vendorImportMultipartOverheadBytes int64 = 8 << 20

func main() {
	showStartupConsoleWarning()

	configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
	localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
	resetLocalDB := flag.Bool("reset-localdb", false, "reset local SQLite data on startup (keeps connection settings)")
	migrate := flag.Bool("migrate", false, "run database migrations")
	version := flag.Bool("version", false, "show version information")
	flag.Parse()

	// Show version if requested
	if *version {
		fmt.Printf("qfs version %s\n", Version)
		os.Exit(0)
	}

	exePath, _ := os.Executable()
	slog.Info("starting qfs", "version", Version, "executable", exePath)
	appmeta.SetVersion(Version)

	resolvedLocalDBPath, err := appstate.ResolveDBPath(*localDBPath)
	if err != nil {
		slog.Error("failed to resolve local database path", "error", err)
		os.Exit(1)
	}

	resolvedConfigPath, err := appstate.ResolveConfigPathNearDB(*configPath, resolvedLocalDBPath)
	if err != nil {
		slog.Error("failed to resolve config path", "error", err)
		os.Exit(1)
	}

	// Migrate legacy project-local config path to the user state directory when using defaults.
	if *configPath == "" && os.Getenv("QFS_CONFIG_PATH") == "" {
		migratedFrom, migrateErr := appstate.MigrateLegacyFile(resolvedConfigPath, []string{"config.yaml"})
		if migrateErr != nil {
			slog.Warn("failed to migrate legacy config file", "error", migrateErr)
		} else if migratedFrom != "" {
			slog.Info("migrated legacy config file", "from", migratedFrom, "to", resolvedConfigPath)
		}
	}

	// Migrate legacy project-local DB path to the user state directory when using defaults.
	if *localDBPath == "" && os.Getenv("QFS_DB_PATH") == "" {
		legacyPaths := []string{
			filepath.Join("data", "settings.db"),
			filepath.Join("data", "qfs.db"),
		}
		migratedFrom, migrateErr := appstate.MigrateLegacyDB(resolvedLocalDBPath, legacyPaths)
		if migrateErr != nil {
			slog.Warn("failed to migrate legacy local database", "error", migrateErr)
		} else if migratedFrom != "" {
			slog.Info("migrated legacy local database", "from", migratedFrom, "to", resolvedLocalDBPath)
		}
	}

	if shouldResetLocalDB(*resetLocalDB) {
		if err := localdb.ResetData(resolvedLocalDBPath); err != nil {
			slog.Error("failed to reset local database", "error", err)
			os.Exit(1)
		}
	}

	// Initialize local SQLite database (always used)
	local, err := localdb.New(resolvedLocalDBPath)
	if err != nil {
		slog.Error("failed to initialize local database", "error", err)
		os.Exit(1)
	}

	// Check if running in setup mode (no connection settings)
	if !local.HasSettings() {
		slog.Info("no database settings found, starting setup mode")
		runSetupMode(local)
		return
	}

	// Load config for server settings (optional)
	if err := ensureDefaultConfigFile(resolvedConfigPath); err != nil {
		slog.Error("failed to ensure default config file", "path", resolvedConfigPath, "error", err)
		os.Exit(1)
	}
	cfg, err := config.Load(resolvedConfigPath)
	if err != nil {
		if errors.Is(err, fs.ErrNotExist) {
			// Use defaults if config file doesn't exist
			slog.Info("config file not found, using defaults", "path", resolvedConfigPath)
			cfg = &config.Config{}
		} else {
			slog.Error("failed to load config", "path", resolvedConfigPath, "error", err)
			os.Exit(1)
		}
	}
	setConfigDefaults(cfg)
	normalizedHost, changed, err := normalizeLoopbackServerHost(cfg.Server.Host)
	if err != nil {
		slog.Error("invalid server host", "host", cfg.Server.Host, "error", err)
		os.Exit(1)
	}
	if changed {
		slog.Warn("corrected server host to loopback", "from", cfg.Server.Host, "to", normalizedHost)
	}
	cfg.Server.Host = normalizedHost
	if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil {
		slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err)
		os.Exit(1)
	}
	slog.Info("resolved runtime files", "config_path", resolvedConfigPath, "localdb_path", resolvedLocalDBPath)

	setupLogger(cfg.Logging)

	// Create connection manager. Runtime stays local-first; MariaDB is used on demand by sync/setup only.
	connMgr := db.NewConnectionManager(local)

	dbUser := local.GetDBUser()

	slog.Info("starting QuoteForge server",
		"version", Version,
		"host", cfg.Server.Host,
		"port", cfg.Server.Port,
		"db_user", dbUser,
		"online", false,
	)

	if *migrate {
		mariaDB, err := connMgr.GetDB()
		if err != nil {
			slog.Error("cannot run migrations: database not available", "error", err)
			os.Exit(1)
		}
		if mariaDB == nil {
			slog.Error("cannot run migrations: database not available")
			os.Exit(1)
		}
		slog.Info("running database migrations...")
		if err := models.Migrate(mariaDB); err != nil {
			slog.Error("migration failed", "error", err)
			os.Exit(1)
		}
		if err := models.SeedCategories(mariaDB); err != nil {
			slog.Error("seeding categories failed", "error", err)
			os.Exit(1)
		}
		slog.Info("migrations completed")
	}

	gin.SetMode(cfg.Server.Mode)
	restartSig := make(chan struct{}, 1)

	router, syncService, err := setupRouter(cfg, local, connMgr, dbUser, restartSig)
	if err != nil {
		slog.Error("failed to setup router", "error", err)
		os.Exit(1)
	}

	if readiness, readinessErr := syncService.GetReadiness(); readinessErr != nil {
		slog.Warn("sync readiness check failed on startup", "error", readinessErr)
	} else if readiness != nil && readiness.Blocked {
		slog.Warn("sync readiness blocked on startup",
			"reason_code", readiness.ReasonCode,
			"reason_text", readiness.ReasonText,
		)
	}

	// Start background sync worker (will auto-skip when offline)
	workerCtx, workerCancel := context.WithCancel(context.Background())
	defer workerCancel()

	syncWorker := sync.NewWorker(syncService, connMgr, backgroundSyncInterval)
	go syncWorker.Start(workerCtx)

	backupCtx, backupCancel := context.WithCancel(context.Background())
	defer backupCancel()
	go startBackupScheduler(backupCtx, cfg, resolvedLocalDBPath, resolvedConfigPath)

	srv := &http.Server{
		Addr:         cfg.Address(),
		Handler:      router,
		ReadTimeout:  cfg.Server.ReadTimeout,
		WriteTimeout: cfg.Server.WriteTimeout,
	}

	go func() {
		slog.Info("server listening", "address", cfg.Address())
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			slog.Error("server error", "error", err)
			os.Exit(1)
		}
	}()

	// Automatically open browser after server starts (with a small delay)
	go func() {
		time.Sleep(1 * time.Second)
		// Always use localhost for browser, even if server binds to 0.0.0.0
		browserURL := fmt.Sprintf("http://127.0.0.1:%d", cfg.Server.Port)
		slog.Info("Opening browser to", "url", browserURL)
		err := openBrowser(browserURL)
		if err != nil {
			slog.Warn("Failed to open browser", "error", err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

	shouldRestart := false
	select {
	case <-quit:
		slog.Info("shutting down server...")
	case <-restartSig:
		shouldRestart = true
		slog.Info("restarting application after connection settings update...")
	}

	// Stop background sync worker first
	syncWorker.Stop()
	workerCancel()
	backupCancel()

	// Then shutdown HTTP server
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		slog.Error("server forced to shutdown", "error", err)
	}

	slog.Info("server stopped")

	if shouldRestart {
		restartProcess()
	}
}

func showStartupConsoleWarning() {
	slog.Warn(startupConsoleWarning)
	// Keep the warning always visible in the console window title when supported.
	fmt.Printf("\033]0;%s\007", startupConsoleWarning)
}

func shouldResetLocalDB(flagValue bool) bool {
	if flagValue {
		return true
	}
	value := strings.TrimSpace(os.Getenv("QFS_RESET_LOCAL_DB"))
	if value == "" {
		return false
	}
	switch strings.ToLower(value) {
	case "1", "true", "yes", "y":
		return true
	default:
		return false
	}
}

func derefString(value *string) string {
	if value == nil {
		return ""
	}
	return *value
}

func setConfigDefaults(cfg *config.Config) {
	if cfg.Server.Host == "" {
		cfg.Server.Host = "127.0.0.1"
	}
	if cfg.Server.Port == 0 {
		cfg.Server.Port = 8080
	}
	if cfg.Server.Mode == "" {
		cfg.Server.Mode = "release"
	}
	if cfg.Server.ReadTimeout == 0 {
		cfg.Server.ReadTimeout = 30 * time.Second
	}
	if cfg.Server.WriteTimeout == 0 {
		// Sync operations (pricelist download over slow VPN) can take several minutes.
		// Loopback-only binding means there is no risk of holding connections from external clients.
		cfg.Server.WriteTimeout = 10 * time.Minute
	}
	if cfg.Backup.Time == "" {
		cfg.Backup.Time = "00:00"
	}
}

func normalizeLoopbackServerHost(host string) (string, bool, error) {
	trimmed := strings.TrimSpace(host)
	if trimmed == "" {
		return "", false, fmt.Errorf("server.host must not be empty")
	}
	const loopbackHost = "127.0.0.1"
	if trimmed == loopbackHost {
		return loopbackHost, false, nil
	}
	if strings.EqualFold(trimmed, "localhost") {
		return loopbackHost, true, nil
	}

	ip := net.ParseIP(strings.Trim(trimmed, "[]"))
	if ip != nil {
		if ip.IsLoopback() || ip.IsUnspecified() {
			return loopbackHost, trimmed != loopbackHost, nil
		}
		return loopbackHost, true, nil
	}

	return loopbackHost, true, nil
}

func vendorImportBodyLimit() int64 {
	return vendorImportMaxBytes + vendorImportMultipartOverheadBytes
}

func isVendorImportTooLarge(fileSize int64, err error) bool {
	if fileSize > vendorImportMaxBytes {
		return true
	}
	var maxBytesErr *http.MaxBytesError
	return errors.As(err, &maxBytesErr)
}

func ensureDefaultConfigFile(configPath string) error {
	if strings.TrimSpace(configPath) == "" {
		return fmt.Errorf("config path is empty")
	}
	if _, err := os.Stat(configPath); err == nil {
		return nil
	} else if !errors.Is(err, os.ErrNotExist) {
		return err
	}

	if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
		return err
	}

	const defaultConfigYAML = `server:
  host: "127.0.0.1"
  port: 8080
  mode: "release"
  read_timeout: 30s
  write_timeout: 10m

backup:
  time: "00:00"

logging:
  level: "info"
  format: "json"
  output: "stdout"
`
	if err := os.WriteFile(configPath, []byte(defaultConfigYAML), 0644); err != nil {
		return err
	}
	slog.Info("created default config file", "path", configPath)
	return nil
}

type runtimeServerConfig struct {
	Host         string        `yaml:"host"`
	Port         int           `yaml:"port"`
	Mode         string        `yaml:"mode"`
	ReadTimeout  time.Duration `yaml:"read_timeout"`
	WriteTimeout time.Duration `yaml:"write_timeout"`
}

type runtimeLoggingConfig struct {
	Level  string `yaml:"level"`
	Format string `yaml:"format"`
	Output string `yaml:"output"`
}

type runtimeBackupConfig struct {
	Time string `yaml:"time"`
}

type runtimeConfigFile struct {
	Server  runtimeServerConfig  `yaml:"server"`
	Logging runtimeLoggingConfig `yaml:"logging"`
	Backup  runtimeBackupConfig  `yaml:"backup"`
}

// migrateConfigFileToRuntimeShape rewrites config.yaml in a minimal runtime format.
// Deprecated sections from legacy configs are intentionally dropped.
func migrateConfigFileToRuntimeShape(configPath string, cfg *config.Config) error {
	if cfg == nil {
		return fmt.Errorf("config is nil")
	}

	runtimeCfg := runtimeConfigFile{
		Server: runtimeServerConfig{
			Host:         cfg.Server.Host,
			Port:         cfg.Server.Port,
			Mode:         cfg.Server.Mode,
			ReadTimeout:  cfg.Server.ReadTimeout,
			WriteTimeout: cfg.Server.WriteTimeout,
		},
		Logging: runtimeLoggingConfig{
			Level:  cfg.Logging.Level,
			Format: cfg.Logging.Format,
			Output: cfg.Logging.Output,
		},
		Backup: runtimeBackupConfig{
			Time: cfg.Backup.Time,
		},
	}

	rendered, err := yaml.Marshal(&runtimeCfg)
	if err != nil {
		return fmt.Errorf("marshal runtime config: %w", err)
	}

	current, err := os.ReadFile(configPath)
	if err == nil && bytes.Equal(bytes.TrimSpace(current), bytes.TrimSpace(rendered)) {
		return nil
	}
	if err := os.WriteFile(configPath, rendered, 0644); err != nil {
		return fmt.Errorf("write runtime config: %w", err)
	}
	slog.Info("migrated config.yaml to runtime format", "path", configPath)
	return nil
}

func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
	if cfg == nil {
		return
	}

	hour, minute, err := parseBackupTime(cfg.Backup.Time)
	if err != nil {
		slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
		hour = 0
		minute = 0
	}

	if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
		slog.Error("local backup failed", "error", backupErr)
	} else if len(created) > 0 {
		for _, path := range created {
			slog.Info("local backup completed", "archive", path)
		}
	}

	for {
		next := nextBackupTime(time.Now(), hour, minute)
		timer := time.NewTimer(time.Until(next))

		select {
		case <-ctx.Done():
			timer.Stop()
			return
		case <-timer.C:
			start := time.Now()
			created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
			duration := time.Since(start)
			if backupErr != nil {
				slog.Error("local backup failed", "error", backupErr, "duration", duration)
			} else {
				for _, path := range created {
					slog.Info("local backup completed", "archive", path, "duration", duration)
				}
			}
		}
	}
}

func parseBackupTime(value string) (int, int, error) {
	if strings.TrimSpace(value) == "" {
		return 0, 0, fmt.Errorf("empty backup time")
	}
	parsed, err := time.Parse("15:04", value)
	if err != nil {
		return 0, 0, err
	}
	return parsed.Hour(), parsed.Minute(), nil
}

func nextBackupTime(now time.Time, hour, minute int) time.Time {
	location := now.Location()
	target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, location)
	if !now.Before(target) {
		target = target.Add(24 * time.Hour)
	}
	return target
}

// runSetupMode starts a minimal server that only serves the setup page
func runSetupMode(local *localdb.LocalDB) {
	restartSig := make(chan struct{}, 1)

	// In setup mode, we don't have a connection manager yet (will restart after setup)
	templatesPath := filepath.Join("web", "templates")
	setupHandler, err := handlers.NewSetupHandler(local, nil, templatesPath, restartSig)
	if err != nil {
		slog.Error("failed to create setup handler", "error", err)
		os.Exit(1)
	}

	gin.SetMode(gin.ReleaseMode)
	router := gin.New()
	router.Use(gin.Recovery())

	if staticFS, err := qfassets.StaticFS(); err == nil {
		router.StaticFS("/static", http.FS(staticFS))
	} else {
		slog.Error("failed to load embedded static assets", "error", err)
		os.Exit(1)
	}

	// Setup routes only
	router.GET("/", func(c *gin.Context) {
		c.Redirect(http.StatusFound, "/setup")
	})
	router.GET("/setup", setupHandler.ShowSetup)
	router.POST("/setup", setupHandler.SaveConnection)
	router.POST("/setup/test", setupHandler.TestConnection)
	router.GET("/setup/status", setupHandler.GetStatus)

	// Health check
	router.GET("/health", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"status": "setup_required",
			"time":   time.Now().UTC().Format(time.RFC3339),
		})
	})

	addr := "127.0.0.1:8080"
	slog.Info("starting setup mode server", "address", addr, "version", Version)

	srv := &http.Server{
		Addr:    addr,
		Handler: router,
	}

	go func() {
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			slog.Error("server error", "error", err)
			os.Exit(1)
		}
	}()

	// Open browser to setup page
	go func() {
		time.Sleep(1 * time.Second)
		browserURL := "http://127.0.0.1:8080/setup"
		slog.Info("Opening browser to setup page", "url", browserURL)
		err := openBrowser(browserURL)
		if err != nil {
			slog.Warn("Failed to open browser", "error", err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

	select {
	case <-quit:
		slog.Info("setup mode server stopped")
	case <-restartSig:
		slog.Info("restarting application with saved settings...")

		// Graceful shutdown
		ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
		defer cancel()
		srv.Shutdown(ctx)

		// Restart process with same arguments
		restartProcess()
	}
}

func setupLogger(cfg config.LoggingConfig) {
	var level slog.Level
	switch cfg.Level {
	case "debug":
		level = slog.LevelDebug
	case "warn":
		level = slog.LevelWarn
	case "error":
		level = slog.LevelError
	default:
		level = slog.LevelInfo
	}

	opts := &slog.HandlerOptions{Level: level}

	var handler slog.Handler
	if cfg.Format == "json" {
		handler = slog.NewJSONHandler(os.Stdout, opts)
	} else {
		handler = slog.NewTextHandler(os.Stdout, opts)
	}

	slog.SetDefault(slog.New(handler))
}

func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
	gormLogger := logger.Default.LogMode(logger.Silent)

	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
		Logger: gormLogger,
	})
	if err != nil {
		return nil, err
	}

	sqlDB, err := db.DB()
	if err != nil {
		return nil, err
	}

	sqlDB.SetMaxOpenConns(25)
	sqlDB.SetMaxIdleConns(5)
	sqlDB.SetConnMaxLifetime(5 * time.Minute)

	return db, nil
}

func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, dbUsername string, restartSig chan struct{}) (*gin.Engine, *sync.Service, error) {
	var syncService *sync.Service
	var projectService *services.ProjectService

	syncService = sync.NewService(connMgr, local)
	componentService := services.NewComponentService(nil, nil)
	quoteService := services.NewQuoteService(nil, nil, local, nil)
	exportService := services.NewExportService(cfg.Export, local)

	// isOnline function for local-first architecture
	isOnline := func() bool {
		return connMgr.IsOnline()
	}

	// Local-first configuration service (replaces old ConfigurationService)
	projectService = services.NewProjectService(local)
	configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline)

	// Data hygiene: remove empty nameless projects and ensure every configuration is attached to a project.
	if removed, err := local.ConsolidateSystemProjects(); err == nil && removed > 0 {
		slog.Info("consolidated duplicate local system projects", "removed", removed)
	}
	if removed, err := local.PurgeEmptyNamelessProjects(); err == nil && removed > 0 {
		slog.Info("purged empty nameless local projects", "removed", removed)
	}
	if err := local.BackfillConfigurationProjects(dbUsername); err != nil {
		slog.Warn("failed to backfill local configuration projects", "error", err)
	}
	type pullState struct {
		mu          syncpkg.Mutex
		running     bool
		lastStarted time.Time
	}
	triggerPull := func(label string, state *pullState, pullFn func() error) {
		state.mu.Lock()
		if state.running {
			state.mu.Unlock()
			return
		}
		if !state.lastStarted.IsZero() && time.Since(state.lastStarted) < onDemandPullCooldown {
			state.mu.Unlock()
			return
		}
		state.running = true
		state.lastStarted = time.Now()
		state.mu.Unlock()

		go func() {
			defer func() {
				state.mu.Lock()
				state.running = false
				state.mu.Unlock()
			}()
			if err := pullFn(); err != nil {
				slog.Warn("on-demand pull failed", "scope", label, "error", err)
			}
		}()
	}

	var projectsPullState pullState
	var configsPullState pullState

	syncProjectsFromServer := func() error {
		if !connMgr.IsOnline() {
			return nil
		}
		if readiness, err := syncService.EnsureReadinessForSync(); err != nil {
			slog.Warn("skipping project pull: sync readiness blocked",
				"error", err,
				"reason_code", readiness.ReasonCode,
				"reason_text", readiness.ReasonText,
			)
			return nil
		}
		if _, err := syncService.ImportProjectsToLocal(); err != nil && !errors.Is(err, sync.ErrOffline) {
			return err
		}
		return nil
	}

	syncConfigurationsFromServer := func() error {
		if !connMgr.IsOnline() {
			return nil
		}
		if readiness, err := syncService.EnsureReadinessForSync(); err != nil {
			slog.Warn("skipping configuration pull: sync readiness blocked",
				"error", err,
				"reason_code", readiness.ReasonCode,
				"reason_text", readiness.ReasonText,
			)
			return nil
		}
		_, err := configService.ImportFromServer()
		if err != nil && !errors.Is(err, sync.ErrOffline) {
			return err
		}
		return nil
	}

	// Use filepath.Join for cross-platform path compatibility
	templatesPath := filepath.Join("web", "templates")

	// Handlers
	componentHandler := handlers.NewComponentHandler(componentService, local)
	quoteHandler := handlers.NewQuoteHandler(quoteService)
	exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
	pricelistHandler := handlers.NewPricelistHandler(local)
	vendorSpecHandler := handlers.NewVendorSpecHandler(local, syncService)
	partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local)
	respondError := handlers.RespondError
	syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
	if err != nil {
		return nil, nil, fmt.Errorf("creating sync handler: %w", err)
	}

	supportBundleHandler := handlers.NewSupportBundleHandler(local, connMgr, syncService, cfg.Logging.FilePath)

	// Setup handler (for reconfiguration)
	setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, restartSig)
	if err != nil {
		return nil, nil, fmt.Errorf("creating setup handler: %w", err)
	}

	// Web handler (templates)
	webHandler, err := handlers.NewWebHandler(templatesPath, local)
	if err != nil {
		return nil, nil, err
	}

	// Router
	router := gin.New()
	router.MaxMultipartMemory = vendorImportBodyLimit()
	router.Use(gin.Recovery())
	router.Use(requestLogger())
	router.Use(middleware.CORS())
	router.Use(middleware.OfflineDetector(connMgr, local))

	// Static files (use filepath.Join for Windows compatibility)
	if staticFS, err := qfassets.StaticFS(); err == nil {
		router.StaticFS("/static", http.FS(staticFS))
	} else {
		return nil, nil, fmt.Errorf("load embedded static assets: %w", err)
	}

	// Health check
	router.GET("/health", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"status": "ok",
			"time":   time.Now().UTC().Format(time.RFC3339),
		})
	})

	// Restart endpoint is intentionally debug-only.
	if cfg.Server.Mode == "debug" {
		router.POST("/api/restart", func(c *gin.Context) {
			slog.Info("Restart requested via API")
			go func() {
				time.Sleep(100 * time.Millisecond)
				restartProcess()
			}()
			c.JSON(http.StatusOK, gin.H{"message": "restarting..."})
		})
	}

	// DB status endpoint
	router.GET("/api/db-status", func(c *gin.Context) {
		var lotCount, lotLogCount, metadataCount int64
		var dbOK bool
		var dbError string
		includeCounts := c.Query("include_counts") == "true"

		// Fast status path: do not execute heavy COUNT queries unless requested.
		status := connMgr.GetStatus()
		dbOK = status.IsConnected
		if !status.IsConnected {
			dbError = "Database not connected (offline mode)"
			if status.LastError != "" {
				dbError = status.LastError
			}
		}

		// Runtime diagnostics stay local-only. Server table counts are intentionally unavailable here.
		if !includeCounts || !status.IsConnected {
			lotCount = 0
			lotLogCount = 0
			metadataCount = 0
		}

		c.JSON(http.StatusOK, gin.H{
			"connected":      dbOK,
			"error":          dbError,
			"lot_count":      lotCount,
			"lot_log_count":  lotLogCount,
			"metadata_count": metadataCount,
			"db_user":        local.GetDBUser(),
		})
	})

	// Current user info (local DB username)
	router.GET("/api/current-user", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"username": local.GetDBUser(),
		})
	})

	// Setup routes (for reconfiguration)
	router.GET("/setup", setupHandler.ShowSetup)
	router.POST("/setup", setupHandler.SaveConnection)
	router.POST("/setup/test", setupHandler.TestConnection)
	router.GET("/setup/status", setupHandler.GetStatus)

	// Web pages
	router.GET("/", webHandler.Index)
	router.GET("/configs", webHandler.Configs)
	router.GET("/configurator", webHandler.Configurator)
	router.GET("/projects", webHandler.Projects)
	router.GET("/projects/:uuid", webHandler.ProjectDetail)
	router.GET("/configs/:uuid/revisions", webHandler.ConfigRevisions)
	router.GET("/pricelists", webHandler.Pricelists)
	router.GET("/pricelists/:id", webHandler.PricelistDetail)
	router.GET("/partnumber-books", webHandler.PartnumberBooks)

	// Short project URLs: /:code → main variant, /:code/:variant → named variant
	router.GET("/:code", func(c *gin.Context) {
		code := c.Param("code")
		project, err := projectService.GetByCode(code)
		if err != nil {
			c.Redirect(http.StatusFound, "/projects")
			return
		}
		c.Redirect(http.StatusFound, "/projects/"+project.UUID)
	})
	router.GET("/:code/:variant", func(c *gin.Context) {
		code := c.Param("code")
		variant := c.Param("variant")
		project, err := projectService.GetByCodeAndVariant(code, variant)
		if err != nil {
			c.Redirect(http.StatusFound, "/projects")
			return
		}
		c.Redirect(http.StatusFound, "/projects/"+project.UUID)
	})

	// htmx partials
	partials := router.Group("/partials")
	{
		partials.GET("/components", webHandler.ComponentsPartial)
		partials.GET("/sync-status", syncHandler.SyncStatusPartial)
	}

	// API routes
	api := router.Group("/api")
	{
		api.GET("/ping", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{"message": "pong"})
		})

		api.GET("/support-bundle", supportBundleHandler.DownloadBundle)

		// Components (public read)
		components := api.Group("/components")
		{
			components.GET("", componentHandler.List)
			components.GET("/:lot_name", componentHandler.Get)
		}

		// Categories (public)
		api.GET("/categories", componentHandler.GetCategories)
		api.GET("/configurator-settings", componentHandler.GetConfiguratorSettings)

		// Quote (public)
		quote := api.Group("/quote")
		{
			quote.POST("/validate", quoteHandler.Validate)
			quote.POST("/calculate", quoteHandler.Calculate)
			quote.POST("/price-levels", quoteHandler.PriceLevels)
		}

		// Export (public)
		export := api.Group("/export")
		{
			export.POST("/csv", exportHandler.ExportCSV)
		}

		// Pricelists (public - RBAC disabled in Phase 1-3)
		pricelists := api.Group("/pricelists")
		{
			pricelists.GET("", pricelistHandler.List)
			pricelists.GET("/latest", pricelistHandler.GetLatest)
			pricelists.GET("/:id", pricelistHandler.Get)
			pricelists.GET("/:id/items", pricelistHandler.GetItems)
			pricelists.GET("/:id/lots", pricelistHandler.GetLotNames)
		}

		// Partnumber books (read-only)
		pnBooks := api.Group("/partnumber-books")
		{
			pnBooks.GET("", partnumberBooksHandler.List)
			pnBooks.GET("/:id", partnumberBooksHandler.GetItems)
		}

		// Stateless BOM text parsing shared by paste and file-import paths.
		api.POST("/vendor-spec/parse-text", vendorSpecHandler.ParseText)

		// Configurations (public - RBAC disabled)
		configs := api.Group("/configs")
		{
			configs.GET("", func(c *gin.Context) {
				triggerPull("configs", &configsPullState, syncConfigurationsFromServer)

				page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
				perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
				status := c.DefaultQuery("status", "active")
				search := c.Query("search")
				if status != "active" && status != "archived" && status != "all" {
					c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
					return
				}

				cfgs, total, err := configService.ListAllWithStatus(page, perPage, status, search)
				if err != nil {
					respondError(c, http.StatusInternalServerError, "internal server error", err)
					return
				}

				c.JSON(http.StatusOK, gin.H{
					"configurations": cfgs,
					"total":          total,
					"page":           page,
					"per_page":       perPage,
					"status":         status,
					"search":         search,
				})
			})

			configs.POST("/import", func(c *gin.Context) {
				result, err := configService.ImportFromServer()
				if err != nil {
					if errors.Is(err, sync.ErrOffline) {
						c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database is offline"})
						return
					}
					respondError(c, http.StatusInternalServerError, "internal server error", err)
					return
				}
				c.JSON(http.StatusOK, result)
			})

			configs.POST("", func(c *gin.Context) {
				var req services.CreateConfigRequest
				if err := c.ShouldBindJSON(&req); err != nil {
					respondError(c, http.StatusBadRequest, "invalid request", err)
					return
				}

				config, err := configService.Create(dbUsername, &req)
				if err != nil {
					respondError(c, http.StatusInternalServerError, "internal server error", err)
					return
				}

				c.JSON(http.StatusCreated, config)
			})

			configs.POST("/preview-article", func(c *gin.Context) {
				var req services.ArticlePreviewRequest
				if err := c.ShouldBindJSON(&req); err != nil {
					respondError(c, http.StatusBadRequest, "invalid request", err)
					return
				}
				result, err := configService.BuildArticlePreview(&req)
				if err != nil {
					respondError(c, http.StatusBadRequest, "invalid request", err)
					return
				}
				c.JSON(http.StatusOK, gin.H{
					"article":  result.Article,
					"warnings": result.Warnings,
				})
			})

			configs.GET("/:uuid", func(c *gin.Context) {
				uuid := c.Param("uuid")
				config, err := configService.GetByUUIDNoAuth(uuid)
				if err != nil {
					c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
					return
				}
				c.JSON(http.StatusOK, config)
			})

			configs.PUT("/:uuid", func(c *gin.Context) {
				uuid := c.Param("uuid")
				var req services.CreateConfigRequest
				if err := c.ShouldBindJSON(&req); err != nil {
					respondError(c, http.StatusBadRequest, "invalid request", err)
					return
				}

				config, err := configService.UpdateNoAuth(uuid, &req)
				if err != nil {
					switch {
					case errors.Is(err, services.ErrConfigNotFound):
						respondError(c, http.StatusNotFound, "resource not found", err)
					case errors.Is(err, services.ErrProjectNotFound):
						respondError(c, http.StatusNotFound, "resource not found", err)
					case errors.Is(err, services.ErrProjectForbidden):
						respondError(c, http.StatusForbidden, "access denied", err)
					default:
						respondError(c, http.StatusInternalServerError, "internal server error", err)
					}
					return
				}

				c.JSON(http.StatusOK, config)
			})

			configs.DELETE("/:uuid", func(c *gin.Context) {
				uuid := c.Param("uuid")
				if err := configService.DeleteNoAuth(uuid); err != nil {
					respondError(c, http.StatusInternalServerError, "internal server error", err)
					return
				}
				c.JSON(http.StatusOK, gin.H{"message": "archived"})
			})

			configs.POST("/:uuid/reactivate", func(c *gin.Context) {
				uuid := c.Param("uuid")
				config, err := configService.ReactivateNoAuth(uuid)
				if err != nil {
					respondError(c, http.StatusInternalServerError, "internal server error", err)
					return
				}
				c.JSON(http.StatusOK, gin.H{
					"message": "reactivated",
					"config":  config,
				})
			})

			configs.PATCH("/:uuid/rename", func(c *gin.Context) {
				uuid := c.Param("uuid")
				var req struct {
					Name string `json:"name"`
				}
				if err := c.ShouldBindJSON(&req); err != nil {
					respondError(c, http.StatusBadRequest, "invalid request", err)
					return
				}

				config, err := configService.RenameNoAuth(uuid, req.Name)
				if err != nil {
					respondError(c, http.StatusInternalServerError, "internal server error", err)
					return
				}

				c.JSON(http.StatusOK, config)
			})

			configs.POST("/:uuid/clone", func(c *gin.Context) {
				uuid := c.Param("uuid")
				var req struct {
					Name        string `json:"name"`
					FromVersion int    `json:"from_version"`
				}
				if err := c.ShouldBindJSON(&req); err != nil {
					respondError(c, http.StatusBadRequest, "invalid request", err)
					return
				}

				config, err := configService.CloneNoAuthToProjectFromVersion(uuid, req.Name, dbUsername, nil, req.FromVersion)
				if err != nil {
					if errors.Is(err, services.ErrConfigVersionNotFound) {
						c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
						return
					}
					respondError(c, http.StatusInternalServerError, "internal server error", err)
					return
				}

				c.JSON(http.StatusCreated, config)
			})

			configs.POST("/:uuid/refresh-prices", func(c *gin.Context) {
				uuid := c.Param("uuid")
				var req struct {
					PricelistID *uint `json:"pricelist_id"`
				}
				// Ignore bind error — pricelist_id is optional
				_ = c.ShouldBindJSON(&req)
				config, err := configService.RefreshPricesNoAuth(uuid, req.PricelistID)
				if err != nil {
					respondError(c, http.StatusInternalServerError, "internal server error", err)
					return
				}
				c.JSON(http.StatusOK, config)
			})

			configs.POST("/:uuid/snapshot", func(c *gin.Context) {
				uuid := c.Param("uuid")
				if err := configService.SnapshotCurrentState(uuid); err != nil {
					respondError(c, http.StatusInternalServerError, "internal server error", err)
					return
				}
				c.JSON(http.StatusOK, gin.H{"ok": true})
			})

			configs.PATCH("/:uuid/project", func(c *gin.Context) {
				uuid := c.Param("uuid")
				var req struct {
					ProjectUUID string `json:"project_uuid"`
				}
				if err := c.ShouldBindJSON(&req); err != nil {
					respondError(c, http.StatusBadRequest, "invalid request", err)
					return
				}
				updated, err := configService.SetProjectNoAuth(uuid, req.ProjectUUID)
				if err != nil {
					switch {
					case errors.Is(err, services.ErrConfigNotFound):
						respondError(c, http.StatusNotFound, "resource not found", err)
					case errors.Is(err, services.ErrProjectNotFound):
						respondError(c, http.StatusNotFound, "resource not found", err)
					case errors.Is(err, services.ErrProjectForbidden):
						respondError(c, http.StatusForbidden, "access denied", err)
					default:
						respondError(c, http.StatusInternalServerError, "internal server error", err)
					}
					return
				}
				c.JSON(http.StatusOK, updated)
			})

			configs.GET("/:uuid/versions", func(c *gin.Context) {
				uuid := c.Param("uuid")

				limit, err := strconv.Atoi(c.DefaultQuery("limit", "20"))
				if err != nil || limit <= 0 {
					c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"})
					return
				}
				offset, err := strconv.Atoi(c.DefaultQuery("offset", "0"))
				if err != nil || offset < 0 {
					c.JSON(http.StatusBadRequest, gin.H{"error": "invalid offset"})
					return
				}

				versions, err := configService.ListVersions(uuid, limit, offset)
				if err != nil {
					switch {
					case errors.Is(err, services.ErrConfigNotFound):
						c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
					case errors.Is(err, services.ErrInvalidVersionNumber):
						c.JSON(http.StatusBadRequest, gin.H{"error": "invalid paging params"})
					default:
						respondError(c, http.StatusInternalServerError, "internal server error", err)
					}
					return
				}

				c.JSON(http.StatusOK, gin.H{
					"versions": versions,
					"limit":    limit,
					"offset":   offset,
				})
			})

			configs.GET("/:uuid/versions/:version", func(c *gin.Context) {
				uuid := c.Param("uuid")
				versionNo, err := strconv.Atoi(c.Param("version"))
				if err != nil || versionNo <= 0 {
					c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version number"})
					return
				}

				version, err := configService.GetVersion(uuid, versionNo)
				if err != nil {
					switch {
					case errors.Is(err, services.ErrInvalidVersionNumber):
						c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version number"})
					case errors.Is(err, services.ErrConfigVersionNotFound):
						c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
					default:
						respondError(c, http.StatusInternalServerError, "internal server error", err)
					}
					return
				}

				c.JSON(http.StatusOK, version)
			})

			configs.POST("/:uuid/rollback", func(c *gin.Context) {
				uuid := c.Param("uuid")
				var req struct {
					TargetVersion int    `json:"target_version"`
					Note          string `json:"note"`
				}
				if err := c.ShouldBindJSON(&req); err != nil {
					respondError(c, http.StatusBadRequest, "invalid request", err)
					return
				}
				if req.TargetVersion <= 0 {
					c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_version"})
					return
				}

				config, err := configService.RollbackToVersionWithNote(uuid, req.TargetVersion, dbUsername, req.Note)
				if err != nil {
					switch {
					case errors.Is(err, services.ErrInvalidVersionNumber):
						c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_version"})
					case errors.Is(err, services.ErrConfigNotFound), errors.Is(err, services.ErrConfigVersionNotFound):
						c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
					case errors.Is(err, services.ErrVersionConflict):
						c.JSON(http.StatusConflict, gin.H{"error": "version conflict"})
					default:
						respondError(c, http.StatusInternalServerError, "internal server error", err)
					}
					return
				}

				currentVersion, err := configService.GetCurrentVersion(uuid)
				if err != nil {
					c.JSON(http.StatusOK, gin.H{
						"message": "rollback applied",
						"config":  config,
					})
					return
				}

				c.JSON(http.StatusOK, gin.H{
					"message":         "rollback applied",
					"config":          config,
					"current_version": currentVersion,
				})
			})

			configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
			configs.POST("/:uuid/export/pricing", exportHandler.ExportConfigPricingCSV)

			// Vendor spec (BOM) endpoints
			configs.GET("/:uuid/vendor-spec", vendorSpecHandler.GetVendorSpec)
			configs.PUT("/:uuid/vendor-spec", vendorSpecHandler.PutVendorSpec)
			configs.POST("/:uuid/vendor-spec/resolve", vendorSpecHandler.ResolveVendorSpec)
			configs.POST("/:uuid/vendor-spec/apply", vendorSpecHandler.ApplyVendorSpec)

			configs.PATCH("/:uuid/server-count", func(c *gin.Context) {
				uuid := c.Param("uuid")
				var req struct {
					ServerCount int `json:"server_count" binding:"required,min=1"`
				}
				if err := c.ShouldBindJSON(&req); err != nil {
					respondError(c, http.StatusBadRequest, "invalid request", err)
					return
				}
				config, err := configService.UpdateServerCount(uuid, req.ServerCount)
				if err != nil {
					respondError(c, http.StatusInternalServerError, "internal server error", err)
					return
				}
				c.JSON(http.StatusOK, config)
			})
		}

		projects := api.Group("/projects")
		{
			projects.GET("", func(c *gin.Context) {
				triggerPull("projects", &projectsPullState, syncProjectsFromServer)
				triggerPull("configs", &configsPullState, syncConfigurationsFromServer)

				status := c.DefaultQuery("status", "active")
				search := strings.ToLower(strings.TrimSpace(c.Query("search")))
				author := strings.ToLower(strings.TrimSpace(c.Query("author")))
				page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
				// Return all projects by default (set high limit for configs to reference)
				perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "1000"))
				sortField := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sort", "created_at")))
				sortDir := strings.ToLower(strings.TrimSpace(c.DefaultQuery("dir", "desc")))
				if status != "active" && status != "archived" && status != "all" {
					c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
					return
				}
				if page < 1 {
					page = 1
				}
				if perPage < 1 {
					perPage = 10
				}
				if perPage > 100 {
					perPage = 100
				}
				if sortField != "name" && sortField != "created_at" {
					c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sort field"})
					return
				}
				if sortDir != "asc" && sortDir != "desc" {
					c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sort direction"})
					return
				}

				allProjects, err := projectService.ListByUser(dbUsername, true)
				if err != nil {
					respondError(c, http.StatusInternalServerError, "internal server error", err)
					return
				}

				filtered := make([]models.Project, 0, len(allProjects))
				for i := range allProjects {
					p := allProjects[i]
					if status == "active" && !p.IsActive {
						continue
					}
					if status == "archived" && p.IsActive {
						continue
					}
					if search != "" &&
						!strings.Contains(strings.ToLower(derefString(p.Name)), search) &&
						!strings.Contains(strings.ToLower(p.Code), search) &&
						!strings.Contains(strings.ToLower(p.Variant), search) {
						continue
					}
					if author != "" && !strings.Contains(strings.ToLower(strings.TrimSpace(p.OwnerUsername)), author) {
						continue
					}
					filtered = append(filtered, p)
				}

				sort.Slice(filtered, func(i, j int) bool {
					left := filtered[i]
					right := filtered[j]
					if sortField == "name" {
						leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name)))
						rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name)))
						if leftName == rightName {
							if sortDir == "asc" {
								return left.CreatedAt.Before(right.CreatedAt)
							}
							return left.CreatedAt.After(right.CreatedAt)
						}
						if sortDir == "asc" {
							return leftName < rightName
						}
						return leftName > rightName
					}
					if left.CreatedAt.Equal(right.CreatedAt) {
						leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name)))
						rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name)))
						if sortDir == "asc" {
							return leftName < rightName
						}
						return leftName > rightName
					}
					if sortDir == "asc" {
						return left.CreatedAt.Before(right.CreatedAt)
					}
					return left.CreatedAt.After(right.CreatedAt)
				})

				total := len(filtered)
				totalPages := 0
				if total > 0 {
					totalPages = int(math.Ceil(float64(total) / float64(perPage)))
				}
				if totalPages > 0 && page > totalPages {
					page = totalPages
				}

				start := (page - 1) * perPage
				if start < 0 {
					start = 0
				}
				end := start + perPage
				if end > total {
					end = total
				}

				paged := []models.Project{}
				if start < total {
					paged = filtered[start:end]
				}

				// Build per-project active config stats in one pass (avoid N+1 scans).
				projectConfigCount := map[string]int{}
				projectConfigTotal := map[string]float64{}
				if localConfigs, cfgErr := local.GetConfigurations(); cfgErr == nil {
					for i := range localConfigs {
						cfg := localConfigs[i]
						if !cfg.IsActive || cfg.ProjectUUID == nil || *cfg.ProjectUUID == "" {
							continue
						}
						projectUUID := *cfg.ProjectUUID
						projectConfigCount[projectUUID]++
						if cfg.TotalPrice != nil {
							projectConfigTotal[projectUUID] += *cfg.TotalPrice
						}
					}
				}

				projectRows := make([]gin.H, 0, len(paged))
				for i := range paged {
					p := paged[i]
					projectRows = append(projectRows, gin.H{
						"id":             p.ID,
						"uuid":           p.UUID,
						"owner_username": p.OwnerUsername,
						"code":           p.Code,
						"variant":        p.Variant,
						"name":           p.Name,
						"tracker_url":    p.TrackerURL,
						"is_active":      p.IsActive,
						"is_system":      p.IsSystem,
						"created_at":     p.CreatedAt,
						"updated_at":     p.UpdatedAt,
						"config_count":   projectConfigCount[p.UUID],
						"total":          projectConfigTotal[p.UUID],
					})
				}

				c.JSON(http.StatusOK, gin.H{
					"projects":    projectRows,
					"status":      status,
					"search":      search,
					"author":      author,
					"sort":        sortField,
					"dir":         sortDir,
					"page":        page,
					"per_page":    perPage,
					"total":       total,
					"total_pages": totalPages,
				})
			})

			// GET /api/projects/all - Returns all projects without pagination for UI dropdowns
			projects.GET("/all", func(c *gin.Context) {
				allProjects, err := projectService.ListByUser(dbUsername, true)
				if err != nil {
					respondError(c, http.StatusInternalServerError, "internal server error", err)
					return
				}

				// Return simplified list of all projects (UUID + Name only)
				type ProjectSimple struct {
					UUID     string `json:"uuid"`
					Code     string `json:"code"`
					Variant  string `json:"variant"`
					Name     string `json:"name"`
					IsActive bool   `json:"is_active"`
				}

				simplified := make([]ProjectSimple, 0, len(allProjects))
				for _, p := range allProjects {
					simplified = append(simplified, ProjectSimple{
						UUID:     p.UUID,
						Code:     p.Code,
						Variant:  p.Variant,
						Name:     derefString(p.Name),
						IsActive: p.IsActive,
					})
				}

				c.JSON(http.StatusOK, simplified)
			})

			projects.POST("", func(c *gin.Context) {
				var req services.CreateProjectRequest
				if err := c.ShouldBindJSON(&req); err != nil {
					respondError(c, http.StatusBadRequest, "invalid request", err)
					return
				}
				if strings.TrimSpace(req.Code) == "" {
					c.JSON(http.StatusBadRequest, gin.H{"error": "project code is required"})
					return
				}
				project, err := projectService.Create(dbUsername, &req)
				if err != nil {
					switch {
					case errors.Is(err, services.ErrReservedMainVariant),
						errors.Is(err, services.ErrProjectCodeInvalidChars),
						errors.Is(err, services.ErrProjectVariantInvalidChars):
						respondError(c, http.StatusBadRequest, "invalid request", err)
					case errors.Is(err, services.ErrProjectCodeExists):
						respondError(c, http.StatusConflict, "conflict detected", err)
					default:
						respondError(c, http.StatusInternalServerError, "internal server error", err)
					}
					return
				}
				c.JSON(http.StatusCreated, project)
			})

			projects.GET("/:uuid", func(c *gin.Context) {
				project, err := projectService.GetByUUID(c.Param("uuid"), dbUsername)
				if err != nil {
					switch {
					case errors.Is(err, services.ErrProjectNotFound):
						respondError(c, http.StatusNotFound, "resource not found", err)
					case errors.Is(err, services.ErrProjectForbidden):
						respondError(c, http.StatusForbidden, "access denied", err)
					default:
						respondError(c, http.StatusInternalServerError, "internal server error", err)
					}
					return
				}
				c.JSON(http.StatusOK, project)
			})

			projects.PUT("/:uuid", func(c *gin.Context) {
				var req services.UpdateProjectRequest
				if err := c.ShouldBindJSON(&req); err != nil {
					respondError(c, http.StatusBadRequest, "invalid request", err)
					return
				}
				project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
				if err != nil {
					switch {
					case errors.Is(err, services.ErrReservedMainVariant),
						errors.Is(err, services.ErrCannotRenameMainVariant),
						errors.Is(err, services.ErrProjectCodeInvalidChars),
						errors.Is(err, services.ErrProjectVariantInvalidChars):
						respondError(c, http.StatusBadRequest, "invalid request", err)
					case errors.Is(err, services.ErrProjectCodeExists):
						respondError(c, http.StatusConflict, "conflict detected", err)
					case errors.Is(err, services.ErrProjectNotFound):
						respondError(c, http.StatusNotFound, "resource not found", err)
					case errors.Is(err, services.ErrProjectForbidden):
						respondError(c, http.StatusForbidden, "access denied", err)
					default:
						respondError(c, http.StatusInternalServerError, "internal server error", err)
					}
					return
				}
				c.JSON(http.StatusOK, project)
			})

			projects.POST("/:uuid/archive", func(c *gin.Context) {
				if err := projectService.Archive(c.Param("uuid"), dbUsername); err != nil {
					switch {
					case errors.Is(err, services.ErrProjectNotFound):
						respondError(c, http.StatusNotFound, "resource not found", err)
					case errors.Is(err, services.ErrProjectForbidden):
						respondError(c, http.StatusForbidden, "access denied", err)
					default:
						respondError(c, http.StatusInternalServerError, "internal server error", err)
					}
					return
				}
				c.JSON(http.StatusOK, gin.H{"message": "project archived"})
			})

			projects.POST("/:uuid/reactivate", func(c *gin.Context) {
				if err := projectService.Reactivate(c.Param("uuid"), dbUsername); err != nil {
					switch {
					case errors.Is(err, services.ErrProjectNotFound):
						respondError(c, http.StatusNotFound, "resource not found", err)
					case errors.Is(err, services.ErrProjectForbidden):
						respondError(c, http.StatusForbidden, "access denied", err)
					default:
						respondError(c, http.StatusInternalServerError, "internal server error", err)
					}
					return
				}
				c.JSON(http.StatusOK, gin.H{"message": "project reactivated"})
			})

			projects.DELETE("/:uuid", func(c *gin.Context) {
				if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil {
					switch {
					case errors.Is(err, services.ErrCannotDeleteMainVariant):
						respondError(c, http.StatusBadRequest, "invalid request", err)
					case errors.Is(err, services.ErrProjectNotFound):
						respondError(c, http.StatusNotFound, "resource not found", err)
					case errors.Is(err, services.ErrProjectForbidden):
						respondError(c, http.StatusForbidden, "access denied", err)
					default:
						respondError(c, http.StatusInternalServerError, "internal server error", err)
					}
					return
				}
				c.JSON(http.StatusOK, gin.H{"message": "variant deleted"})
			})

			projects.GET("/:uuid/configs", func(c *gin.Context) {
				triggerPull("configs", &configsPullState, syncConfigurationsFromServer)

				status := c.DefaultQuery("status", "active")
				if status != "active" && status != "archived" && status != "all" {
					c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
					return
				}

				result, err := projectService.ListConfigurations(c.Param("uuid"), dbUsername, status)
				if err != nil {
					switch {
					case errors.Is(err, services.ErrProjectNotFound):
						respondError(c, http.StatusNotFound, "resource not found", err)
					case errors.Is(err, services.ErrProjectForbidden):
						respondError(c, http.StatusForbidden, "access denied", err)
					default:
						respondError(c, http.StatusInternalServerError, "internal server error", err)
					}
					return
				}
				c.Header("X-Config-Status", status)
				c.JSON(http.StatusOK, result)
			})

			projects.PATCH("/:uuid/configs/reorder", func(c *gin.Context) {
				var req struct {
					OrderedUUIDs []string `json:"ordered_uuids"`
				}
				if err := c.ShouldBindJSON(&req); err != nil {
					respondError(c, http.StatusBadRequest, "invalid request", err)
					return
				}
				if len(req.OrderedUUIDs) == 0 {
					c.JSON(http.StatusBadRequest, gin.H{"error": "ordered_uuids is required"})
					return
				}

				configs, err := configService.ReorderProjectConfigurationsNoAuth(c.Param("uuid"), req.OrderedUUIDs)
				if err != nil {
					switch {
					case errors.Is(err, services.ErrProjectNotFound):
						respondError(c, http.StatusNotFound, "resource not found", err)
					default:
						respondError(c, http.StatusBadRequest, "invalid request", err)
					}
					return
				}

				total := 0.0
				for i := range configs {
					if configs[i].TotalPrice != nil {
						total += *configs[i].TotalPrice
					}
				}
				c.JSON(http.StatusOK, gin.H{
					"project_uuid":   c.Param("uuid"),
					"configurations": configs,
					"total":          total,
				})
			})

			projects.POST("/:uuid/configs", func(c *gin.Context) {
				var req services.CreateConfigRequest
				if err := c.ShouldBindJSON(&req); err != nil {
					respondError(c, http.StatusBadRequest, "invalid request", err)
					return
				}
				projectUUID := c.Param("uuid")
				req.ProjectUUID = &projectUUID

				config, err := configService.Create(dbUsername, &req)
				if err != nil {
					respondError(c, http.StatusInternalServerError, "internal server error", err)
					return
				}
				c.JSON(http.StatusCreated, config)
			})

			projects.POST("/:uuid/vendor-import", func(c *gin.Context) {
				c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, vendorImportBodyLimit())
				fileHeader, err := c.FormFile("file")
				if err != nil {
					if isVendorImportTooLarge(0, err) {
						respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
						return
					}
					respondError(c, http.StatusBadRequest, "file is required", err)
					return
				}
				if isVendorImportTooLarge(fileHeader.Size, nil) {
					respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
					return
				}

				file, err := fileHeader.Open()
				if err != nil {
					respondError(c, http.StatusBadRequest, "failed to open uploaded file", err)
					return
				}
				defer file.Close()

				data, err := io.ReadAll(io.LimitReader(file, vendorImportMaxBytes+1))
				if err != nil {
					respondError(c, http.StatusBadRequest, "failed to read uploaded file", err)
					return
				}
				if int64(len(data)) > vendorImportMaxBytes {
					respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
					return
				}
				if !services.IsCFXMLWorkspace(data) && !services.IsQuoteForgeCSV(data) && !services.IsInspurBOM(data) && !services.IsTextBOM(data) {
					c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"})
					return
				}

				result, err := configService.ImportVendorWorkspaceToProject(c.Param("uuid"), fileHeader.Filename, data, dbUsername)
				if err != nil {
					switch {
					case errors.Is(err, services.ErrProjectNotFound):
						respondError(c, http.StatusNotFound, "resource not found", err)
					default:
						respondError(c, http.StatusBadRequest, "invalid request", err)
					}
					return
				}

				c.JSON(http.StatusCreated, result)
			})

			projects.GET("/:uuid/export", exportHandler.ExportProjectCSV)
			projects.POST("/:uuid/export", exportHandler.ExportProjectPricingCSV)

			projects.POST("/:uuid/configs/:config_uuid/clone", func(c *gin.Context) {
				var req struct {
					Name string `json:"name"`
				}
				if err := c.ShouldBindJSON(&req); err != nil {
					respondError(c, http.StatusBadRequest, "invalid request", err)
					return
				}

				projectUUID := c.Param("uuid")
				config, err := configService.CloneNoAuthToProject(c.Param("config_uuid"), req.Name, dbUsername, &projectUUID)
				if err != nil {
					respondError(c, http.StatusInternalServerError, "internal server error", err)
					return
				}
				c.JSON(http.StatusCreated, config)
			})
		}

		// Sync API (for offline mode)
		syncAPI := api.Group("/sync")
		{
			syncAPI.GET("/status", syncHandler.GetStatus)
			syncAPI.GET("/readiness", syncHandler.GetReadiness)
			syncAPI.GET("/info", syncHandler.GetInfo)
			syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
			syncAPI.POST("/components", syncHandler.SyncComponents)
			syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
			syncAPI.POST("/partnumber-books", syncHandler.SyncPartnumberBooks)
			syncAPI.POST("/partnumber-seen", syncHandler.ReportPartnumberSeen)
			syncAPI.POST("/all", syncHandler.SyncAll)
			syncAPI.POST("/push", syncHandler.PushPendingChanges)
			syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
			syncAPI.GET("/pending", syncHandler.GetPendingChanges)
			syncAPI.POST("/repair", syncHandler.RepairPendingChanges)
		}
	}

	return router, syncService, nil
}

// restartProcess restarts the current process with the same arguments
func restartProcess() {
	executable, err := os.Executable()
	if err != nil {
		slog.Error("failed to get executable path", "error", err)
		os.Exit(1)
	}

	args := os.Args
	env := os.Environ()

	slog.Info("executing restart", "executable", executable, "args", args)

	err = syscall.Exec(executable, args, env)
	if err != nil {
		slog.Error("failed to restart process", "error", err)
		os.Exit(1)
	}
}

func openBrowser(url string) error {
	var cmd string
	var args []string

	switch runtime.GOOS {
	case "windows":
		cmd = "cmd"
		args = []string{"/c", "start", url}
	case "darwin":
		cmd = "open"
		args = []string{url}
	default: // "linux", "freebsd", "openbsd", "netbsd"
		cmd = "xdg-open"
		args = []string{url}
	}

	return exec.Command(cmd, args...).Start()
}

func requestLogger() gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()
		path := c.Request.URL.Path
		query := c.Request.URL.RawQuery

		c.Next()

		latency := time.Since(start)
		status := c.Writer.Status()

		if status >= http.StatusBadRequest {
			errText := strings.TrimSpace(c.Errors.String())

			slog.Error("request failed",
				"method", c.Request.Method,
				"path", path,
				"query", query,
				"status", status,
				"latency", latency,
				"ip", c.ClientIP(),
				"errors", errText,
			)
			return
		}

		slog.Info("request",
			"method", c.Request.Method,
			"path", path,
			"query", query,
			"status", status,
			"latency", latency,
			"ip", c.ClientIP(),
		)
	}
}
