package localdb

import (
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"net"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"git.mchus.pro/mchus/quoteforge/internal/appmeta"
	"git.mchus.pro/mchus/quoteforge/internal/appstate"
	"github.com/glebarez/sqlite"
	mysqlDriver "github.com/go-sql-driver/mysql"
	uuidpkg "github.com/google/uuid"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
	"gorm.io/gorm/logger"
)

// ConnectionSettings stores MariaDB connection credentials
type ConnectionSettings struct {
	ID                uint      `gorm:"primaryKey"`
	Host              string    `gorm:"not null"`
	Port              int       `gorm:"not null;default:3306"`
	Database          string    `gorm:"not null"`
	User              string    `gorm:"not null"`
	PasswordEncrypted string    `gorm:"not null"` // AES encrypted
	UpdatedAt         time.Time `gorm:"autoUpdateTime"`
}

func (ConnectionSettings) TableName() string {
	return "connection_settings"
}

// LocalDB manages the local SQLite database for settings
type LocalDB struct {
	db   *gorm.DB
	path string
}

var localReadOnlyCacheTables = []string{
	"local_pricelist_items",
	"local_pricelists",
	"local_components",
	"local_partnumber_book_items",
	"local_partnumber_books",
}

// ResetData clears local data tables while keeping connection settings.
// It does not drop schema or connection_settings.
func ResetData(dbPath string) error {
	if strings.TrimSpace(dbPath) == "" {
		return nil
	}
	if _, err := os.Stat(dbPath); err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil
		}
		return fmt.Errorf("stat local db: %w", err)
	}

	db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
		Logger: logger.Default.LogMode(logger.Silent),
	})
	if err != nil {
		return fmt.Errorf("opening sqlite database: %w", err)
	}

	// Order does not matter because we use DELETEs without FK constraints in SQLite.
	tables := []string{
		"local_projects",
		"local_configurations",
		"local_configuration_versions",
		"local_pricelists",
		"local_pricelist_items",
		"local_components",
		"local_sync_guard_state",
		"pending_changes",
		"app_settings",
	}
	for _, table := range tables {
		if err := db.Exec("DELETE FROM " + table).Error; err != nil {
			return fmt.Errorf("clear %s: %w", table, err)
		}
	}

	slog.Info("local database data reset", "path", dbPath)
	return nil
}

// New creates a new LocalDB instance
func New(dbPath string) (*LocalDB, error) {
	// Ensure directory exists
	dir := filepath.Dir(dbPath)
	if err := os.MkdirAll(dir, 0755); err != nil {
		return nil, fmt.Errorf("creating data directory: %w", err)
	}

	if cfgPath, err := appstate.ResolveConfigPathNearDB("", dbPath); err == nil {
		if _, err := appstate.EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
			return nil, fmt.Errorf("backup local data: %w", err)
		}
	} else {
		return nil, fmt.Errorf("resolve config path: %w", err)
	}

	db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
		Logger: logger.Default.LogMode(logger.Silent),
	})
	if err != nil {
		return nil, fmt.Errorf("opening sqlite database: %w", err)
	}

	// Enable WAL mode so background sync writes never block UI reads.
	if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
		slog.Warn("failed to enable WAL mode", "error", err)
	}
	if err := db.Exec("PRAGMA synchronous=NORMAL").Error; err != nil {
		slog.Warn("failed to set synchronous=NORMAL", "error", err)
	}

	if err := ensureLocalProjectsTable(db); err != nil {
		return nil, fmt.Errorf("ensure local_projects table: %w", err)
	}
	if err := prepareLocalPartnumberBookCatalog(db); err != nil {
		return nil, fmt.Errorf("prepare local partnumber book catalog: %w", err)
	}
	if err := cleanupStaleReadOnlyCacheTempTables(db); err != nil {
		return nil, fmt.Errorf("cleanup stale read-only cache temp tables: %w", err)
	}

	// Preflight: ensure local_projects has non-null UUIDs before AutoMigrate rebuilds tables.
	if db.Migrator().HasTable(&LocalProject{}) {
		if !db.Migrator().HasColumn(&LocalProject{}, "uuid") {
			if err := db.Exec(`ALTER TABLE local_projects ADD COLUMN uuid TEXT`).Error; err != nil {
				return nil, fmt.Errorf("adding local_projects.uuid: %w", err)
			}
		}
		var ids []uint
		if err := db.Raw(`SELECT id FROM local_projects WHERE uuid IS NULL OR uuid = ''`).Scan(&ids).Error; err != nil {
			return nil, fmt.Errorf("finding local_projects without uuid: %w", err)
		}
		for _, id := range ids {
			if err := db.Exec(`UPDATE local_projects SET uuid = ? WHERE id = ?`, uuidpkg.New().String(), id).Error; err != nil {
				return nil, fmt.Errorf("backfilling local_projects.uuid: %w", err)
			}
		}
	}

	// Auto-migrate all local tables
	if err := autoMigrateLocalSchema(db); err != nil {
		if recovered, recoveryErr := recoverFromReadOnlyCacheInitFailure(db, err); recoveryErr != nil {
			return nil, fmt.Errorf("migrating sqlite database: %w (recovery failed: %v)", err, recoveryErr)
		} else if !recovered {
			return nil, fmt.Errorf("migrating sqlite database: %w", err)
		}
		if err := autoMigrateLocalSchema(db); err != nil {
			return nil, fmt.Errorf("migrating sqlite database after cache recovery: %w", err)
		}
	}
	if err := ensureLocalPartnumberBookItemTable(db); err != nil {
		return nil, fmt.Errorf("ensure local partnumber book item table: %w", err)
	}
	if err := runLocalMigrations(db); err != nil {
		if recovered, recoveryErr := recoverFromReadOnlyCacheInitFailure(db, err); recoveryErr != nil {
			return nil, fmt.Errorf("running local sqlite migrations: %w (recovery failed: %v)", err, recoveryErr)
		} else if !recovered {
			return nil, fmt.Errorf("running local sqlite migrations: %w", err)
		}
		if err := runLocalMigrations(db); err != nil {
			return nil, fmt.Errorf("running local sqlite migrations after cache recovery: %w", err)
		}
	}

	slog.Info("local SQLite database initialized", "path", dbPath)

	return &LocalDB{
		db:   db,
		path: dbPath,
	}, nil
}

func ensureLocalProjectsTable(db *gorm.DB) error {
	if db.Migrator().HasTable(&LocalProject{}) {
		return nil
	}

	if err := db.Exec(`
CREATE TABLE local_projects (
	id INTEGER PRIMARY KEY AUTOINCREMENT,
	uuid TEXT NOT NULL UNIQUE,
	server_id INTEGER NULL,
	owner_username TEXT NOT NULL,
	code TEXT NOT NULL,
	variant TEXT NOT NULL DEFAULT '',
	name TEXT NULL,
	tracker_url TEXT NULL,
	is_active INTEGER NOT NULL DEFAULT 1,
	is_system INTEGER NOT NULL DEFAULT 0,
	created_at DATETIME,
	updated_at DATETIME,
	synced_at DATETIME NULL,
	sync_status TEXT DEFAULT 'local'
)`).Error; err != nil {
		return err
	}

	_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
	_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
	_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
	_ = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
	return nil
}

func autoMigrateLocalSchema(db *gorm.DB) error {
	return db.AutoMigrate(
		&ConnectionSettings{},
		&LocalConfiguration{},
		&LocalConfigurationVersion{},
		&LocalPricelist{},
		&LocalPricelistItem{},
		&LocalComponent{},
		&AppSetting{},
		&LocalSyncGuardState{},
		&PendingChange{},
		&LocalPartnumberBook{},
		&SyncLogEntry{},
		&LocalQtSetting{},
	)
}

func sanitizeLocalPartnumberBookCatalog(db *gorm.DB) error {
	if !db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
		return nil
	}

	// Old local databases may contain partially migrated catalog rows with NULL/empty
	// partnumber values. SQLite table rebuild during AutoMigrate fails on such rows once
	// the schema enforces NOT NULL, so remove them before AutoMigrate touches the table.
	if err := db.Exec(`
		DELETE FROM local_partnumber_book_items
		WHERE partnumber IS NULL OR TRIM(partnumber) = ''
	`).Error; err != nil {
		return err
	}
	return nil
}

func prepareLocalPartnumberBookCatalog(db *gorm.DB) error {
	if err := cleanupStaleLocalPartnumberBookCatalogTempTable(db); err != nil {
		if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("cleanup stale temp table: %w", err)); recoveryErr != nil {
			return recoveryErr
		}
		return nil
	}
	if err := sanitizeLocalPartnumberBookCatalog(db); err != nil {
		if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("sanitize catalog: %w", err)); recoveryErr != nil {
			return recoveryErr
		}
		return nil
	}
	if err := migrateLegacyPartnumberBookCatalogBeforeAutoMigrate(db); err != nil {
		if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("migrate legacy catalog: %w", err)); recoveryErr != nil {
			return recoveryErr
		}
		return nil
	}
	if err := ensureLocalPartnumberBookItemTable(db); err != nil {
		if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("ensure canonical catalog table: %w", err)); recoveryErr != nil {
			return recoveryErr
		}
		return nil
	}
	if err := validateLocalPartnumberBookCatalog(db); err != nil {
		if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("validate canonical catalog: %w", err)); recoveryErr != nil {
			return recoveryErr
		}
	}
	return nil
}

func cleanupStaleReadOnlyCacheTempTables(db *gorm.DB) error {
	for _, tableName := range localReadOnlyCacheTables {
		tempName := tableName + "__temp"
		if !db.Migrator().HasTable(tempName) {
			continue
		}
		if db.Migrator().HasTable(tableName) {
			if err := db.Exec(`DROP TABLE ` + tempName).Error; err != nil {
				return err
			}
			continue
		}
		if err := quarantineSQLiteTable(db, tempName, localReadOnlyCacheQuarantineTableName(tableName, "temp")); err != nil {
			return err
		}
	}
	return nil
}

func cleanupStaleLocalPartnumberBookCatalogTempTable(db *gorm.DB) error {
	if !db.Migrator().HasTable("local_partnumber_book_items__temp") {
		return nil
	}
	if db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
		return db.Exec(`DROP TABLE local_partnumber_book_items__temp`).Error
	}
	return quarantineSQLiteTable(db, "local_partnumber_book_items__temp", localPartnumberBookCatalogQuarantineTableName("temp"))
}

func migrateLegacyPartnumberBookCatalogBeforeAutoMigrate(db *gorm.DB) error {
	if !db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
		return nil
	}

	// Legacy databases may still have the pre-catalog shape (`book_id`/`lot_name`) or the
	// intermediate canonical shape with obsolete columns like `is_primary_pn`. Let the
	// explicit rebuild logic normalize this table before GORM AutoMigrate attempts a
	// table-copy migration on its own.
	return migrateLocalPartnumberBookCatalog(db)
}

func ensureLocalPartnumberBookItemTable(db *gorm.DB) error {
	if db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
		return nil
	}
	if err := db.Exec(`
		CREATE TABLE local_partnumber_book_items (
			id INTEGER PRIMARY KEY AUTOINCREMENT,
			partnumber TEXT NOT NULL UNIQUE,
			lots_json TEXT NOT NULL,
			description TEXT
		)
	`).Error; err != nil {
		return err
	}
	return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_partnumber_book_items_partnumber ON local_partnumber_book_items(partnumber)`).Error
}

func validateLocalPartnumberBookCatalog(db *gorm.DB) error {
	if !db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
		return nil
	}

	type rawCatalogRow struct {
		Partnumber  string `gorm:"column:partnumber"`
		LotsJSON    string `gorm:"column:lots_json"`
		Description string `gorm:"column:description"`
	}

	var rows []rawCatalogRow
	if err := db.Raw(`
		SELECT partnumber, lots_json, COALESCE(description, '') AS description
		FROM local_partnumber_book_items
		ORDER BY id ASC
	`).Scan(&rows).Error; err != nil {
		return fmt.Errorf("load canonical catalog rows: %w", err)
	}

	seen := make(map[string]struct{}, len(rows))
	for _, row := range rows {
		partnumber := strings.TrimSpace(row.Partnumber)
		if partnumber == "" {
			return errors.New("catalog contains empty partnumber")
		}
		if _, exists := seen[partnumber]; exists {
			return fmt.Errorf("catalog contains duplicate partnumber %q", partnumber)
		}
		seen[partnumber] = struct{}{}
		if strings.TrimSpace(row.LotsJSON) == "" {
			return fmt.Errorf("catalog row %q has empty lots_json", partnumber)
		}
		var lots LocalPartnumberBookLots
		if err := json.Unmarshal([]byte(row.LotsJSON), &lots); err != nil {
			return fmt.Errorf("catalog row %q has invalid lots_json: %w", partnumber, err)
		}
	}

	return nil
}

func recoverLocalPartnumberBookCatalog(db *gorm.DB, cause error) error {
	slog.Warn("recovering broken local partnumber book catalog", "error", cause.Error())

	if err := ensureLocalPartnumberBooksCatalogColumn(db); err != nil {
		return fmt.Errorf("ensure local_partnumber_books.partnumbers_json during recovery: %w", err)
	}

	if db.Migrator().HasTable("local_partnumber_book_items__temp") {
		if err := quarantineSQLiteTable(db, "local_partnumber_book_items__temp", localPartnumberBookCatalogQuarantineTableName("temp")); err != nil {
			return fmt.Errorf("quarantine local_partnumber_book_items__temp: %w", err)
		}
	}
	if db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
		if err := quarantineSQLiteTable(db, "local_partnumber_book_items", localPartnumberBookCatalogQuarantineTableName("broken")); err != nil {
			return fmt.Errorf("quarantine local_partnumber_book_items: %w", err)
		}
	}
	if err := ensureLocalPartnumberBookItemTable(db); err != nil {
		return fmt.Errorf("recreate local_partnumber_book_items after recovery: %w", err)
	}

	slog.Warn("local partnumber book catalog reset to empty cache; next sync will rebuild it")
	return nil
}

func recoverFromReadOnlyCacheInitFailure(db *gorm.DB, cause error) (bool, error) {
	lowerCause := strings.ToLower(cause.Error())
	recoveredAny := false

	for _, tableName := range affectedReadOnlyCacheTables(lowerCause) {
		if err := resetReadOnlyCacheTable(db, tableName); err != nil {
			return recoveredAny, err
		}
		recoveredAny = true
	}

	if strings.Contains(lowerCause, "local_partnumber_book_items") || strings.Contains(lowerCause, "local_partnumber_books") {
		if err := recoverLocalPartnumberBookCatalog(db, cause); err != nil {
			return recoveredAny, err
		}
		recoveredAny = true
	}

	if recoveredAny {
		slog.Warn("recovered read-only local cache tables after startup failure", "error", cause.Error())
	}
	return recoveredAny, nil
}

func affectedReadOnlyCacheTables(lowerCause string) []string {
	affected := make([]string, 0, len(localReadOnlyCacheTables))
	for _, tableName := range localReadOnlyCacheTables {
		if tableName == "local_partnumber_book_items" || tableName == "local_partnumber_books" {
			continue
		}
		if strings.Contains(lowerCause, tableName) {
			affected = append(affected, tableName)
		}
	}
	return affected
}

func resetReadOnlyCacheTable(db *gorm.DB, tableName string) error {
	slog.Warn("resetting read-only local cache table", "table", tableName)
	tempName := tableName + "__temp"
	if db.Migrator().HasTable(tempName) {
		if err := quarantineSQLiteTable(db, tempName, localReadOnlyCacheQuarantineTableName(tableName, "temp")); err != nil {
			return fmt.Errorf("quarantine temp table %s: %w", tempName, err)
		}
	}
	if db.Migrator().HasTable(tableName) {
		if err := quarantineSQLiteTable(db, tableName, localReadOnlyCacheQuarantineTableName(tableName, "broken")); err != nil {
			return fmt.Errorf("quarantine table %s: %w", tableName, err)
		}
	}
	return nil
}

func ensureLocalPartnumberBooksCatalogColumn(db *gorm.DB) error {
	if !db.Migrator().HasTable(&LocalPartnumberBook{}) {
		return nil
	}
	if db.Migrator().HasColumn(&LocalPartnumberBook{}, "partnumbers_json") {
		return nil
	}
	return db.Exec(`ALTER TABLE local_partnumber_books ADD COLUMN partnumbers_json TEXT NOT NULL DEFAULT '[]'`).Error
}

func quarantineSQLiteTable(db *gorm.DB, tableName string, quarantineName string) error {
	if !db.Migrator().HasTable(tableName) {
		return nil
	}
	if tableName == quarantineName {
		return nil
	}
	if db.Migrator().HasTable(quarantineName) {
		if err := db.Exec(`DROP TABLE ` + quarantineName).Error; err != nil {
			return err
		}
	}
	return db.Exec(`ALTER TABLE ` + tableName + ` RENAME TO ` + quarantineName).Error
}

func localPartnumberBookCatalogQuarantineTableName(kind string) string {
	return "local_partnumber_book_items_" + kind + "_" + time.Now().UTC().Format("20060102150405")
}

func localReadOnlyCacheQuarantineTableName(tableName string, kind string) string {
	return tableName + "_" + kind + "_" + time.Now().UTC().Format("20060102150405")
}

// HasSettings returns true if connection settings exist
func (l *LocalDB) HasSettings() bool {
	var count int64
	if err := l.db.Model(&ConnectionSettings{}).Count(&count).Error; err != nil {
		slog.Error("localdb: HasSettings count failed", "err", err)
		return false
	}
	return count > 0
}

// GetSettings retrieves the connection settings with decrypted password
func (l *LocalDB) GetSettings() (*ConnectionSettings, error) {
	var settings ConnectionSettings
	if err := l.db.First(&settings).Error; err != nil {
		return nil, fmt.Errorf("getting settings: %w", err)
	}

	// Decrypt password
	password, migrated, err := DecryptWithMetadata(settings.PasswordEncrypted)
	if err != nil {
		return nil, fmt.Errorf("decrypting password: %w", err)
	}

	if migrated {
		encrypted, encryptErr := Encrypt(password)
		if encryptErr != nil {
			return nil, fmt.Errorf("re-encrypting legacy password: %w", encryptErr)
		}
		if err := l.db.Model(&ConnectionSettings{}).
			Where("id = ?", settings.ID).
			Update("password_encrypted", encrypted).Error; err != nil {
			return nil, fmt.Errorf("upgrading legacy password encryption: %w", err)
		}
	}

	settings.PasswordEncrypted = password // Return decrypted password in this field

	return &settings, nil
}

// SaveSettings saves connection settings with encrypted password
func (l *LocalDB) SaveSettings(host string, port int, database, user, password string) error {
	// Encrypt password
	encrypted, err := Encrypt(password)
	if err != nil {
		return fmt.Errorf("encrypting password: %w", err)
	}

	settings := ConnectionSettings{
		ID:                1, // Always use ID=1 for single settings row
		Host:              host,
		Port:              port,
		Database:          database,
		User:              user,
		PasswordEncrypted: encrypted,
	}

	// Upsert: create or update
	result := l.db.Save(&settings)
	if result.Error != nil {
		return fmt.Errorf("saving settings: %w", result.Error)
	}

	slog.Info("connection settings saved", "host", host, "port", port, "database", database, "user", user)
	return nil
}

// DeleteSettings removes all connection settings
func (l *LocalDB) DeleteSettings() error {
	return l.db.Where("1=1").Delete(&ConnectionSettings{}).Error
}

// GetDSN returns the MariaDB DSN string
func (l *LocalDB) GetDSN() (string, error) {
	settings, err := l.GetSettings()
	if err != nil {
		return "", err
	}

	cfg := mysqlDriver.NewConfig()
	cfg.User = settings.User
	cfg.Passwd = settings.PasswordEncrypted // Contains decrypted password after GetSettings
	cfg.Net = "tcp"
	cfg.Addr = net.JoinHostPort(settings.Host, strconv.Itoa(settings.Port))
	cfg.DBName = settings.Database
	cfg.ParseTime = true
	cfg.Loc = time.Local
	// Add aggressive timeouts for offline-first architecture.
	cfg.Timeout = 3 * time.Second
	cfg.ReadTimeout = 3 * time.Second
	cfg.WriteTimeout = 3 * time.Second
	cfg.Params = map[string]string{
		"charset": "utf8mb4",
	}

	return cfg.FormatDSN(), nil
}

// DB returns the underlying gorm.DB for advanced operations
func (l *LocalDB) DB() *gorm.DB {
	return l.db
}

// Close closes the database connection
func (l *LocalDB) Close() error {
	sqlDB, err := l.db.DB()
	if err != nil {
		return err
	}
	return sqlDB.Close()
}

// GetDBUser returns the database username from settings
func (l *LocalDB) GetDBUser() string {
	settings, err := l.GetSettings()
	if err != nil {
		return ""
	}
	return settings.User
}

// Configuration methods

// Project methods

func (l *LocalDB) SaveProject(project *LocalProject) error {
	return l.db.Save(project).Error
}

// SaveProjectPreservingUpdatedAt stores a project without replacing UpdatedAt
// with the current local sync time.
func (l *LocalDB) SaveProjectPreservingUpdatedAt(project *LocalProject) error {
	if project == nil {
		return fmt.Errorf("project is nil")
	}

	if project.ID == 0 && strings.TrimSpace(project.UUID) != "" {
		var existing LocalProject
		err := l.db.Where("uuid = ?", project.UUID).First(&existing).Error
		if err == nil {
			project.ID = existing.ID
		} else if !errors.Is(err, gorm.ErrRecordNotFound) {
			return err
		}
	}

	if project.ID == 0 {
		return l.db.Create(project).Error
	}

	return l.db.Model(&LocalProject{}).
		Where("id = ?", project.ID).
		UpdateColumns(map[string]interface{}{
			"uuid":           project.UUID,
			"server_id":      project.ServerID,
			"owner_username": project.OwnerUsername,
			"code":           project.Code,
			"variant":        project.Variant,
			"name":           project.Name,
			"tracker_url":    project.TrackerURL,
			"is_active":      project.IsActive,
			"is_system":      project.IsSystem,
			"created_at":     project.CreatedAt,
			"updated_at":     project.UpdatedAt,
			"synced_at":      project.SyncedAt,
			"sync_status":    project.SyncStatus,
		}).Error
}

func (l *LocalDB) GetProjects(ownerUsername string, includeArchived bool) ([]LocalProject, error) {
	var projects []LocalProject
	query := l.db.Model(&LocalProject{}).Where("owner_username = ?", ownerUsername)
	if !includeArchived {
		query = query.Where("is_active = ?", true)
	}
	err := query.Order("created_at DESC").Find(&projects).Error
	return projects, err
}

func (l *LocalDB) GetAllProjects(includeArchived bool) ([]LocalProject, error) {
	var projects []LocalProject
	query := l.db.Model(&LocalProject{})
	if !includeArchived {
		query = query.Where("is_active = ?", true)
	}
	err := query.Order("created_at DESC").Find(&projects).Error
	return projects, err
}

func (l *LocalDB) GetProjectByUUID(uuid string) (*LocalProject, error) {
	var project LocalProject
	if err := l.db.Where("uuid = ?", uuid).First(&project).Error; err != nil {
		return nil, err
	}
	return &project, nil
}

func (l *LocalDB) GetProjectByCode(code string) (*LocalProject, error) {
	var project LocalProject
	if err := l.db.Where("LOWER(code) = LOWER(?) AND variant = ''", code).First(&project).Error; err != nil {
		return nil, err
	}
	return &project, nil
}

func (l *LocalDB) GetProjectByCodeAndVariant(code, variant string) (*LocalProject, error) {
	var project LocalProject
	if err := l.db.Where("LOWER(code) = LOWER(?) AND LOWER(variant) = LOWER(?)", code, variant).First(&project).Error; err != nil {
		return nil, err
	}
	return &project, nil
}

func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, error) {
	var project LocalProject
	if err := l.db.Where("owner_username = ? AND name = ?", ownerUsername, name).First(&project).Error; err != nil {
		return nil, err
	}
	return &project, nil
}

func (l *LocalDB) GetProjectConfigurations(projectUUID string) ([]LocalConfiguration, error) {
	var configs []LocalConfiguration
	err := l.db.Where("project_uuid = ? AND is_active = ?", projectUUID, true).
		Order(configurationLineOrderClause()).
		Find(&configs).Error
	return configs, err
}

func (l *LocalDB) EnsureDefaultProject(ownerUsername string) (*LocalProject, error) {
	project := &LocalProject{}
	err := l.db.
		Where("LOWER(TRIM(COALESCE(name, ''))) = LOWER(?) AND is_system = ?", "Без проекта", true).
		Order("CASE WHEN TRIM(COALESCE(owner_username, '')) = '' THEN 0 ELSE 1 END, created_at ASC, id ASC").
		First(project).Error
	if err == nil {
		return project, nil
	}
	if !errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, err
	}

	now := time.Now()
	project = &LocalProject{
		UUID:          uuidpkg.NewString(),
		OwnerUsername: "",
		Code:          "Без проекта",
		Name:          ptrString("Без проекта"),
		IsActive:      true,
		IsSystem:      true,
		CreatedAt:     now,
		UpdatedAt:     now,
		SyncStatus:    "pending",
	}
	if err := l.SaveProject(project); err != nil {
		return nil, err
	}
	return project, nil
}

// ConsolidateSystemProjects merges all "Без проекта" projects into one shared canonical project.
// Configurations are reassigned to canonical UUID, duplicate projects are deleted.
func (l *LocalDB) ConsolidateSystemProjects() (int64, error) {
	var removed int64
	err := l.db.Transaction(func(tx *gorm.DB) error {
		var canonical LocalProject
		err := tx.
			Where("LOWER(TRIM(COALESCE(name, ''))) = LOWER(?) AND is_system = ?", "Без проекта", true).
			Order("CASE WHEN TRIM(COALESCE(owner_username, '')) = '' THEN 0 ELSE 1 END, created_at ASC, id ASC").
			First(&canonical).Error
		if errors.Is(err, gorm.ErrRecordNotFound) {
			now := time.Now()
			canonical = LocalProject{
				UUID:          uuidpkg.NewString(),
				OwnerUsername: "",
				Code:          "Без проекта",
				Name:          ptrString("Без проекта"),
				IsActive:      true,
				IsSystem:      true,
				CreatedAt:     now,
				UpdatedAt:     now,
				SyncStatus:    "pending",
			}
			if err := tx.Create(&canonical).Error; err != nil {
				return err
			}
		} else if err != nil {
			return err
		}

		if err := tx.Model(&LocalProject{}).
			Where("uuid = ?", canonical.UUID).
			Updates(map[string]any{
				"name":      "Без проекта",
				"is_system": true,
				"is_active": true,
			}).Error; err != nil {
			return err
		}

		var duplicates []LocalProject
		if err := tx.Where("LOWER(TRIM(COALESCE(name, ''))) = LOWER(?) AND uuid <> ?", "Без проекта", canonical.UUID).
			Find(&duplicates).Error; err != nil {
			return err
		}

		for i := range duplicates {
			p := duplicates[i]
			if err := tx.Model(&LocalConfiguration{}).
				Where("project_uuid = ?", p.UUID).
				Update("project_uuid", canonical.UUID).Error; err != nil {
				return err
			}

			// Remove stale pending project events for deleted UUIDs.
			if err := tx.Where("entity_type = ? AND entity_uuid = ?", "project", p.UUID).
				Delete(&PendingChange{}).Error; err != nil {
				return err
			}

			res := tx.Where("uuid = ?", p.UUID).Delete(&LocalProject{})
			if res.Error != nil {
				return res.Error
			}
			removed += res.RowsAffected
		}

		// Backfill orphaned local configurations to canonical project.
		if err := tx.Model(&LocalConfiguration{}).
			Where("project_uuid IS NULL OR TRIM(COALESCE(project_uuid, '')) = ''").
			Update("project_uuid", canonical.UUID).Error; err != nil {
			return err
		}

		return nil
	})
	return removed, err
}

// PurgeEmptyNamelessProjects removes service-trash projects that have no linked configurations:
// 1) projects with empty names;
// 2) duplicate "Без проекта" rows without configurations (case-insensitive, trimmed).
func (l *LocalDB) PurgeEmptyNamelessProjects() (int64, error) {
	tx := l.db.Exec(`
DELETE FROM local_projects
WHERE (
      TRIM(COALESCE(name, '')) = ''
      OR LOWER(TRIM(COALESCE(name, ''))) = LOWER('Без проекта')
)
  AND uuid NOT IN (
      SELECT DISTINCT project_uuid
      FROM local_configurations
      WHERE project_uuid IS NOT NULL AND project_uuid <> ''
  )`)
	return tx.RowsAffected, tx.Error
}

func ptrString(value string) *string {
	return &value
}

// BackfillConfigurationProjects ensures every configuration has project_uuid set.
// If missing, it assigns system project "Без проекта" for configuration owner.
func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
	configs, err := l.GetConfigurations()
	if err != nil {
		return err
	}

	for i := range configs {
		cfg := configs[i]
		if cfg.ProjectUUID != nil && *cfg.ProjectUUID != "" {
			continue
		}
		owner := strings.TrimSpace(cfg.OriginalUsername)
		if owner == "" {
			owner = strings.TrimSpace(defaultOwner)
		}
		if owner == "" {
			continue
		}

		project, err := l.EnsureDefaultProject(owner)
		if err != nil {
			return err
		}

		cfg.ProjectUUID = &project.UUID
		if saveErr := l.SaveConfiguration(&cfg); saveErr != nil {
			return saveErr
		}
	}
	return nil
}

// SaveConfiguration saves a configuration to local SQLite
func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error {
	if config != nil && config.IsActive && config.Line <= 0 {
		line, err := l.NextConfigurationLine(config.ProjectUUID, config.UUID)
		if err != nil {
			return err
		}
		config.Line = line
	}
	return l.db.Save(config).Error
}

func (l *LocalDB) NextConfigurationLine(projectUUID *string, excludeUUID string) (int, error) {
	return NextConfigurationLineTx(l.db, projectUUID, excludeUUID)
}

func NextConfigurationLineTx(tx *gorm.DB, projectUUID *string, excludeUUID string) (int, error) {
	query := tx.Model(&LocalConfiguration{}).
		Where("is_active = ?", true)

	trimmedExclude := strings.TrimSpace(excludeUUID)
	if trimmedExclude != "" {
		query = query.Where("uuid <> ?", trimmedExclude)
	}

	if projectUUID != nil && strings.TrimSpace(*projectUUID) != "" {
		query = query.Where("project_uuid = ?", strings.TrimSpace(*projectUUID))
	} else {
		query = query.Where("project_uuid IS NULL OR TRIM(project_uuid) = ''")
	}

	var maxLine int
	if err := query.Select("COALESCE(MAX(line_no), 0)").Scan(&maxLine).Error; err != nil {
		return 0, fmt.Errorf("read max line_no: %w", err)
	}
	if maxLine < 0 {
		maxLine = 0
	}

	next := ((maxLine / 10) + 1) * 10
	if next < 10 {
		next = 10
	}
	return next, nil
}

func configurationLineOrderClause() string {
	return "CASE WHEN COALESCE(local_configurations.line_no, 0) <= 0 THEN 2147483647 ELSE local_configurations.line_no END ASC, local_configurations.created_at DESC, local_configurations.id DESC"
}

// GetConfigurations returns all local configurations
func (l *LocalDB) GetConfigurations() ([]LocalConfiguration, error) {
	var configs []LocalConfiguration
	err := l.db.Order("created_at DESC").Find(&configs).Error
	return configs, err
}

// GetConfigurationByUUID returns a configuration by UUID
func (l *LocalDB) GetConfigurationByUUID(uuid string) (*LocalConfiguration, error) {
	var config LocalConfiguration
	err := l.db.Where("uuid = ?", uuid).First(&config).Error
	return &config, err
}

// ListConfigurationsWithFilters returns configurations with DB-level filtering and pagination.
func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, offset, limit int) ([]LocalConfiguration, int64, error) {
	query := l.db.Model(&LocalConfiguration{})
	switch status {
	case "active":
		query = query.Where("local_configurations.is_active = ?", true)
	case "archived":
		query = query.Where("local_configurations.is_active = ?", false)
	case "all", "":
		// no-op
	default:
		query = query.Where("local_configurations.is_active = ?", true)
	}

	search = strings.TrimSpace(search)
	if search != "" {
		needle := "%" + strings.ToLower(search) + "%"
		hasProjectsTable := l.db.Migrator().HasTable(&LocalProject{})
		hasServerModel := l.db.Migrator().HasColumn(&LocalConfiguration{}, "server_model")

		conditions := []string{"LOWER(local_configurations.name) LIKE ?"}
		args := []interface{}{needle}

		if hasProjectsTable {
			query = query.Joins("LEFT JOIN local_projects lp ON lp.uuid = local_configurations.project_uuid")
			conditions = append(conditions, "LOWER(COALESCE(lp.name, '')) LIKE ?")
			args = append(args, needle)
		}

		if hasServerModel {
			conditions = append(conditions, "LOWER(COALESCE(local_configurations.server_model, '')) LIKE ?")
			args = append(args, needle)
		}

		query = query.Where(strings.Join(conditions, " OR "), args...)
	}

	var total int64
	if err := query.Count(&total).Error; err != nil {
		return nil, 0, err
	}

	var configs []LocalConfiguration
	if err := query.Order("local_configurations.created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil {
		return nil, 0, err
	}
	return configs, total, nil
}

// DeleteConfiguration deletes a configuration by UUID
func (l *LocalDB) DeleteConfiguration(uuid string) error {
	return l.DeactivateConfiguration(uuid)
}

// DeactivateConfiguration marks configuration as inactive and appends one snapshot version.
func (l *LocalDB) DeactivateConfiguration(uuid string) error {
	return l.db.Transaction(func(tx *gorm.DB) error {
		var cfg LocalConfiguration
		if err := tx.Where("uuid = ?", uuid).First(&cfg).Error; err != nil {
			return err
		}
		if !cfg.IsActive {
			return nil
		}

		cfg.IsActive = false
		cfg.UpdatedAt = time.Now()
		cfg.SyncStatus = "pending"
		if err := tx.Save(&cfg).Error; err != nil {
			return fmt.Errorf("save inactive configuration: %w", err)
		}

		var maxVersion int
		if err := tx.Model(&LocalConfigurationVersion{}).
			Where("configuration_uuid = ?", cfg.UUID).
			Select("COALESCE(MAX(version_no), 0)").
			Scan(&maxVersion).Error; err != nil {
			return fmt.Errorf("read max version for deactivate: %w", err)
		}

		snapshot, err := BuildConfigurationSnapshot(&cfg)
		if err != nil {
			return fmt.Errorf("build deactivate snapshot: %w", err)
		}
		note := "deactivate via local delete"
		version := &LocalConfigurationVersion{
			ID:                uuidpkg.NewString(),
			ConfigurationUUID: cfg.UUID,
			VersionNo:         maxVersion + 1,
			Data:              snapshot,
			ChangeNote:        &note,
			AppVersion:        appmeta.Version(),
			CreatedAt:         time.Now(),
		}
		if err := tx.Create(version).Error; err != nil {
			return fmt.Errorf("insert deactivate version: %w", err)
		}
		if err := tx.Model(&LocalConfiguration{}).
			Where("uuid = ?", cfg.UUID).
			Update("current_version_id", version.ID).Error; err != nil {
			return fmt.Errorf("set current version after deactivate: %w", err)
		}

		return nil
	})
}

// CountConfigurations returns the number of local configurations
func (l *LocalDB) CountConfigurations() int64 {
	var count int64
	if err := l.db.Model(&LocalConfiguration{}).Count(&count).Error; err != nil {
		slog.Error("localdb: CountConfigurations failed", "err", err)
	}
	return count
}

// CountProjects returns the number of local projects
func (l *LocalDB) CountProjects() int64 {
	var count int64
	if err := l.db.Model(&LocalProject{}).Count(&count).Error; err != nil {
		slog.Error("localdb: CountProjects failed", "err", err)
	}
	return count
}

// Pricelist methods

// GetLastSyncTime returns the last sync timestamp
func (l *LocalDB) GetLastSyncTime() *time.Time {
	var setting struct {
		Value string
	}
	if err := l.db.Table("app_settings").
		Where("key = ?", "last_pricelist_sync").
		First(&setting).Error; err != nil {
		return nil
	}

	t, err := time.Parse(time.RFC3339, setting.Value)
	if err != nil {
		return nil
	}
	return &t
}

func (l *LocalDB) getAppSettingValue(key string) (string, bool) {
	var setting struct {
		Value string
	}
	if err := l.db.Table("app_settings").
		Where("key = ?", key).
		First(&setting).Error; err != nil {
		return "", false
	}
	return setting.Value, true
}

func (l *LocalDB) upsertAppSetting(tx *gorm.DB, key, value string, updatedAt time.Time) error {
	return tx.Exec(`
		INSERT INTO app_settings (key, value, updated_at)
		VALUES (?, ?, ?)
		ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
	`, key, value, updatedAt.Format(time.RFC3339)).Error
}

// SetLastSyncTime sets the last sync timestamp
func (l *LocalDB) SetLastSyncTime(t time.Time) error {
	// Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions
	return l.db.Exec(`
		INSERT INTO app_settings (key, value, updated_at)
		VALUES (?, ?, ?)
		ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
	`, "last_pricelist_sync", t.Format(time.RFC3339), time.Now().Format(time.RFC3339)).Error
}

func (l *LocalDB) GetLastPricelistSyncAttemptAt() *time.Time {
	value, ok := l.getAppSettingValue("last_pricelist_sync_attempt_at")
	if !ok {
		return nil
	}
	t, err := time.Parse(time.RFC3339, value)
	if err != nil {
		return nil
	}
	return &t
}

func (l *LocalDB) GetLastPricelistSyncStatus() string {
	value, ok := l.getAppSettingValue("last_pricelist_sync_status")
	if !ok {
		return ""
	}
	return strings.TrimSpace(value)
}

func (l *LocalDB) GetLastPricelistSyncError() string {
	value, ok := l.getAppSettingValue("last_pricelist_sync_error")
	if !ok {
		return ""
	}
	return strings.TrimSpace(value)
}

func (l *LocalDB) SetPricelistSyncResult(status, errorText string, attemptedAt time.Time) error {
	status = strings.TrimSpace(status)
	errorText = strings.TrimSpace(errorText)
	if status == "" {
		status = "unknown"
	}

	return l.db.Transaction(func(tx *gorm.DB) error {
		if err := l.upsertAppSetting(tx, "last_pricelist_sync_status", status, attemptedAt); err != nil {
			return err
		}
		if err := l.upsertAppSetting(tx, "last_pricelist_sync_error", errorText, attemptedAt); err != nil {
			return err
		}
		if err := l.upsertAppSetting(tx, "last_pricelist_sync_attempt_at", attemptedAt.Format(time.RFC3339), attemptedAt); err != nil {
			return err
		}
		return nil
	})
}

const syncLogMaxPerType = 100

// AppendSyncLog writes a sync result and prunes old entries beyond the per-type cap.
func (l *LocalDB) AppendSyncLog(syncType, status, errorText string, syncedCount int, startedAt time.Time, durationMs int64) {
	entry := SyncLogEntry{
		SyncType:    syncType,
		Status:      status,
		ErrorText:   errorText,
		SyncedCount: syncedCount,
		StartedAt:   startedAt,
		DurationMs:  durationMs,
	}
	if err := l.db.Create(&entry).Error; err != nil {
		return
	}
	// Prune: keep only the most recent N entries for this sync_type
	l.db.Exec(`
		DELETE FROM sync_log
		WHERE sync_type = ? AND id NOT IN (
			SELECT id FROM sync_log WHERE sync_type = ? ORDER BY started_at DESC LIMIT ?
		)
	`, syncType, syncType, syncLogMaxPerType)
}

// GetSyncLog returns the most recent sync log entries, newest first.
func (l *LocalDB) GetSyncLog(limit int) ([]SyncLogEntry, error) {
	var entries []SyncLogEntry
	err := l.db.Order("started_at DESC").Limit(limit).Find(&entries).Error
	return entries, err
}

func (l *LocalDB) GetLastComponentSyncAttemptAt() *time.Time {
	value, ok := l.getAppSettingValue("last_component_sync_attempt_at")
	if !ok {
		return nil
	}
	t, err := time.Parse(time.RFC3339, value)
	if err != nil {
		return nil
	}
	return &t
}

func (l *LocalDB) GetLastComponentSyncStatus() string {
	value, ok := l.getAppSettingValue("last_component_sync_status")
	if !ok {
		return ""
	}
	return strings.TrimSpace(value)
}

func (l *LocalDB) GetLastComponentSyncError() string {
	value, ok := l.getAppSettingValue("last_component_sync_error")
	if !ok {
		return ""
	}
	return strings.TrimSpace(value)
}

func (l *LocalDB) SetComponentSyncResult(status, errorText string, attemptedAt time.Time) error {
	status = strings.TrimSpace(status)
	errorText = strings.TrimSpace(errorText)
	if status == "" {
		status = "unknown"
	}
	return l.db.Transaction(func(tx *gorm.DB) error {
		if err := l.upsertAppSetting(tx, "last_component_sync_status", status, attemptedAt); err != nil {
			return err
		}
		if err := l.upsertAppSetting(tx, "last_component_sync_error", errorText, attemptedAt); err != nil {
			return err
		}
		if err := l.upsertAppSetting(tx, "last_component_sync_attempt_at", attemptedAt.Format(time.RFC3339), attemptedAt); err != nil {
			return err
		}
		return nil
	})
}

// CountLocalPricelists returns the number of local pricelists
func (l *LocalDB) CountLocalPricelists() int64 {
	var count int64
	l.db.Model(&LocalPricelist{}).Count(&count)
	return count
}

// CountAllPricelistItems returns total rows across all local_pricelist_items.
func (l *LocalDB) CountAllPricelistItems() int64 {
	var count int64
	l.db.Model(&LocalPricelistItem{}).Count(&count)
	return count
}

// CountComponents returns the number of rows in local_components.
func (l *LocalDB) CountComponents() int64 {
	var count int64
	l.db.Model(&LocalComponent{}).Count(&count)
	return count
}

// DBFileSizeBytes returns the size of the SQLite database file in bytes.
func (l *LocalDB) DBFileSizeBytes() int64 {
	info, err := os.Stat(l.path)
	if err != nil {
		return 0
	}
	return info.Size()
}

// GetLatestLocalPricelist returns the most recently synced pricelist
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
	var pricelist LocalPricelist
	if err := l.db.
		Where("source = ?", "estimate").
		Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
		Order("created_at DESC, id DESC").
		First(&pricelist).Error; err != nil {
		return nil, err
	}
	return &pricelist, nil
}

// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
	var pricelist LocalPricelist
	if err := l.db.
		Where("source = ?", source).
		Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
		Order("created_at DESC, id DESC").
		First(&pricelist).Error; err != nil {
		return nil, err
	}
	return &pricelist, nil
}

// GetLocalPricelistByServerID returns a local pricelist by its server ID
func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, error) {
	var pricelist LocalPricelist
	if err := l.db.Where("server_id = ?", serverID).First(&pricelist).Error; err != nil {
		return nil, err
	}
	return &pricelist, nil
}

// GetLocalPricelistByVersion returns a local pricelist by version string.
func (l *LocalDB) GetLocalPricelistByVersion(version string) (*LocalPricelist, error) {
	var pricelist LocalPricelist
	if err := l.db.Where("version = ? AND source = ?", version, "estimate").First(&pricelist).Error; err != nil {
		return nil, err
	}
	return &pricelist, nil
}

// GetLocalPricelistBySourceAndVersion returns a local pricelist by source and version string.
func (l *LocalDB) GetLocalPricelistBySourceAndVersion(source, version string) (*LocalPricelist, error) {
	var pricelist LocalPricelist
	if err := l.db.Where("source = ? AND version = ?", source, version).First(&pricelist).Error; err != nil {
		return nil, err
	}
	return &pricelist, nil
}

// GetLocalPricelistByID returns a local pricelist by its local ID
func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
	var pricelist LocalPricelist
	if err := l.db.First(&pricelist, id).Error; err != nil {
		return nil, err
	}
	return &pricelist, nil
}

// SaveLocalPricelist saves a pricelist to local SQLite
func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error {
	return l.db.Clauses(clause.OnConflict{
		Columns: []clause.Column{{Name: "server_id"}},
		DoUpdates: clause.Assignments(map[string]interface{}{
			"source":     pricelist.Source,
			"version":    pricelist.Version,
			"name":       pricelist.Name,
			"created_at": pricelist.CreatedAt,
			"synced_at":  pricelist.SyncedAt,
			"is_used":    pricelist.IsUsed,
		}),
	}).Create(pricelist).Error
}

// GetLocalPricelists returns all local pricelists
func (l *LocalDB) GetLocalPricelists() ([]LocalPricelist, error) {
	var pricelists []LocalPricelist
	if err := l.db.Order("created_at DESC").Find(&pricelists).Error; err != nil {
		return nil, err
	}
	return pricelists, nil
}

// CountLocalPricelistItems returns the number of items for a pricelist
func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
	var count int64
	l.db.Model(&LocalPricelistItem{}).Where("pricelist_id = ?", pricelistID).Count(&count)
	return count
}

// CountLocalPricelistItemsWithEmptyCategory returns the number of items for a pricelist with missing lot_category.
func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (int64, error) {
	var count int64
	if err := l.db.Model(&LocalPricelistItem{}).
		Where("pricelist_id = ? AND (lot_category IS NULL OR TRIM(lot_category) = '')", pricelistID).
		Count(&count).Error; err != nil {
		return 0, err
	}
	return count, nil
}

// SaveLocalPricelistItems saves pricelist items to local SQLite.
// Duplicate (pricelist_id, lot_name) rows are silently ignored.
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
	if len(items) == 0 {
		return nil
	}

	batchSize := 500
	for i := 0; i < len(items); i += batchSize {
		end := i + batchSize
		if end > len(items) {
			end = len(items)
		}
		if err := l.db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(items[i:end], batchSize).Error; err != nil {
			return err
		}
	}
	return nil
}

// ReplaceLocalPricelistItems atomically replaces all items for a pricelist.
func (l *LocalDB) ReplaceLocalPricelistItems(pricelistID uint, items []LocalPricelistItem) error {
	return l.db.Transaction(func(tx *gorm.DB) error {
		if err := tx.Where("pricelist_id = ?", pricelistID).Delete(&LocalPricelistItem{}).Error; err != nil {
			return err
		}
		if len(items) == 0 {
			return nil
		}

		batchSize := 500
		for i := 0; i < len(items); i += batchSize {
			end := i + batchSize
			if end > len(items) {
				end = len(items)
			}
			if err := tx.CreateInBatches(items[i:end], batchSize).Error; err != nil {
				return err
			}
		}
		return nil
	})
}

// GetLocalPricelistItems returns items for a local pricelist
func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) {
	var items []LocalPricelistItem
	if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil {
		return nil, err
	}
	return items, nil
}

// GetLocalPriceForLot returns the price for a lot from a local pricelist
func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64, error) {
	var item LocalPricelistItem
	if err := l.db.Where("pricelist_id = ? AND lot_name = ?", pricelistID, lotName).
		First(&item).Error; err != nil {
		return 0, err
	}
	return item.Price, nil
}

// GetLocalPricesForLots returns prices for multiple lots from a local pricelist in a single query.
// Uses the composite index (pricelist_id, lot_name). Missing lots are omitted from the result.
func (l *LocalDB) GetLocalPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
	result := make(map[string]float64, len(lotNames))
	if len(lotNames) == 0 {
		return result, nil
	}
	type row struct {
		LotName string  `gorm:"column:lot_name"`
		Price   float64 `gorm:"column:price"`
	}
	var rows []row
	if err := l.db.Model(&LocalPricelistItem{}).
		Select("lot_name, price").
		Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
		Find(&rows).Error; err != nil {
		return nil, err
	}
	for _, r := range rows {
		if r.Price > 0 {
			result[r.LotName] = r.Price
		}
	}
	return result, nil
}

// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
// Missing lots are not included in the map; caller is responsible for strict validation.
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
	result := make(map[string]string, len(lotNames))
	if serverPricelistID == 0 || len(lotNames) == 0 {
		return result, nil
	}

	localPL, err := l.GetLocalPricelistByServerID(serverPricelistID)
	if err != nil {
		return nil, err
	}

	type row struct {
		LotName     string `gorm:"column:lot_name"`
		LotCategory string `gorm:"column:lot_category"`
	}
	var rows []row
	if err := l.db.Model(&LocalPricelistItem{}).
		Select("lot_name, lot_category").
		Where("pricelist_id = ? AND lot_name IN ?", localPL.ID, lotNames).
		Find(&rows).Error; err != nil {
		return nil, err
	}
	for _, r := range rows {
		result[r.LotName] = r.LotCategory
	}
	return result, nil
}

// MarkPricelistAsUsed marks a pricelist as used by a configuration
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
	return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).
		Update("is_used", isUsed).Error
}

// RecalculateAllLocalPricelistUsage refreshes local_pricelists.is_used based on active configurations.
func (l *LocalDB) RecalculateAllLocalPricelistUsage() error {
	return l.db.Transaction(func(tx *gorm.DB) error {
		if err := tx.Model(&LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
			return err
		}

		return tx.Exec(`
			UPDATE local_pricelists
			SET is_used = 1
			WHERE server_id IN (
				SELECT DISTINCT pricelist_id
				FROM local_configurations
				WHERE pricelist_id IS NOT NULL AND is_active = 1
			)
		`).Error
	})
}

// DeleteLocalPricelist deletes a pricelist and its items
func (l *LocalDB) DeleteLocalPricelist(id uint) error {
	// Delete items first
	if err := l.db.Where("pricelist_id = ?", id).Delete(&LocalPricelistItem{}).Error; err != nil {
		return err
	}
	// Delete pricelist
	return l.db.Delete(&LocalPricelist{}, id).Error
}

// DeleteUnusedLocalPricelistsMissingOnServer removes local pricelists that are absent on server
// and not referenced by active local configurations.
func (l *LocalDB) DeleteUnusedLocalPricelistsMissingOnServer(serverPricelistIDs []uint) (int, error) {
	returned := 0
	err := l.db.Transaction(func(tx *gorm.DB) error {
		var candidates []LocalPricelist
		query := tx.Model(&LocalPricelist{})
		if len(serverPricelistIDs) > 0 {
			query = query.Where("server_id NOT IN ?", serverPricelistIDs)
		}
		if err := query.Find(&candidates).Error; err != nil {
			return err
		}

		for i := range candidates {
			pl := candidates[i]
			var refs int64
			if err := tx.Model(&LocalConfiguration{}).
				Where("pricelist_id = ? AND is_active = 1", pl.ServerID).
				Count(&refs).Error; err != nil {
				return err
			}
			if refs > 0 {
				continue
			}
			if err := tx.Where("pricelist_id = ?", pl.ID).Delete(&LocalPricelistItem{}).Error; err != nil {
				return err
			}
			if err := tx.Delete(&LocalPricelist{}, pl.ID).Error; err != nil {
				return err
			}
			returned++
		}
		return nil
	})
	if err != nil {
		return 0, err
	}
	return returned, nil
}

// PendingChange methods

// AddPendingChange adds a change to the sync queue
func (l *LocalDB) AddPendingChange(entityType, entityUUID, operation, payload string) error {
	change := PendingChange{
		EntityType: entityType,
		EntityUUID: entityUUID,
		Operation:  operation,
		Payload:    payload,
		CreatedAt:  time.Now(),
		Attempts:   0,
	}
	return l.db.Create(&change).Error
}

// GetPendingChanges returns all pending changes ordered by creation time
func (l *LocalDB) GetPendingChanges() ([]PendingChange, error) {
	var changes []PendingChange
	err := l.db.Order("created_at ASC").Find(&changes).Error
	return changes, err
}

// GetPendingChangesByEntity returns pending changes for a specific entity
func (l *LocalDB) GetPendingChangesByEntity(entityType, entityUUID string) ([]PendingChange, error) {
	var changes []PendingChange
	err := l.db.Where("entity_type = ? AND entity_uuid = ?", entityType, entityUUID).
		Order("created_at ASC").Find(&changes).Error
	return changes, err
}

// DeletePendingChange removes a change from the sync queue after successful sync
func (l *LocalDB) DeletePendingChange(id int64) error {
	return l.db.Delete(&PendingChange{}, id).Error
}

// IncrementPendingChangeAttempts updates the attempt counter and last error
func (l *LocalDB) IncrementPendingChangeAttempts(id int64, errorMsg string) error {
	return l.db.Model(&PendingChange{}).Where("id = ?", id).Updates(map[string]interface{}{
		"attempts":   gorm.Expr("attempts + 1"),
		"last_error": errorMsg,
	}).Error
}

// CountPendingChanges returns the total number of pending changes
func (l *LocalDB) CountPendingChanges() int64 {
	var count int64
	l.db.Model(&PendingChange{}).Count(&count)
	return count
}

// CountPendingChangesByType returns the number of pending changes by entity type
func (l *LocalDB) CountPendingChangesByType(entityType string) int64 {
	var count int64
	l.db.Model(&PendingChange{}).Where("entity_type = ?", entityType).Count(&count)
	return count
}

// CountErroredChanges returns the number of pending changes with errors
func (l *LocalDB) CountErroredChanges() int64 {
	var count int64
	l.db.Model(&PendingChange{}).Where("last_error != ?", "").Count(&count)
	return count
}

// MarkChangesSynced marks multiple pending changes as synced by deleting them
func (l *LocalDB) MarkChangesSynced(ids []int64) error {
	if len(ids) == 0 {
		return nil
	}
	return l.db.Where("id IN ?", ids).Delete(&PendingChange{}).Error
}

// PurgeOrphanConfigurationPendingChanges removes configuration pending changes
// whose entity_uuid no longer exists in local_configurations.
func (l *LocalDB) PurgeOrphanConfigurationPendingChanges() (int64, error) {
	tx := l.db.Where(
		"entity_type = ? AND entity_uuid NOT IN (SELECT uuid FROM local_configurations)",
		"configuration",
	).Delete(&PendingChange{})
	return tx.RowsAffected, tx.Error
}

// GetPendingCount returns the total number of pending changes (alias for CountPendingChanges)
func (l *LocalDB) GetPendingCount() int64 {
	return l.CountPendingChanges()
}

// RepairPendingChanges attempts to fix errored pending changes by validating and correcting data.
// Returns the number of changes repaired and a list of errors that couldn't be fixed.
func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
	var erroredChanges []PendingChange
	if err := l.db.Where("last_error != ?", "").Find(&erroredChanges).Error; err != nil {
		return 0, nil, fmt.Errorf("fetching errored changes: %w", err)
	}

	if len(erroredChanges) == 0 {
		return 0, nil, nil
	}

	repaired := 0
	var remainingErrors []string

	for _, change := range erroredChanges {
		var repairErr error
		switch change.EntityType {
		case "project":
			repairErr = l.repairProjectChange(&change)
		case "configuration":
			repairErr = l.repairConfigurationChange(&change)
		default:
			repairErr = fmt.Errorf("unknown entity type: %s", change.EntityType)
		}

		if repairErr != nil {
			remainingErrors = append(remainingErrors, fmt.Sprintf("%s %s %s: %v",
				change.Operation, change.EntityType, change.EntityUUID[:8], repairErr))
			continue
		}

		// Clear error and reset attempts
		if err := l.db.Model(&PendingChange{}).Where("id = ?", change.ID).Updates(map[string]interface{}{
			"last_error": "",
			"attempts":   0,
		}).Error; err != nil {
			remainingErrors = append(remainingErrors, fmt.Sprintf("clearing error for %s: %v", change.EntityUUID[:8], err))
			continue
		}

		repaired++
	}

	return repaired, remainingErrors, nil
}

// repairProjectChange validates and fixes project data.
// Note: This only validates local data. Server-side conflicts (like duplicate code+variant)
// are handled by sync service layer with deduplication logic.
func (l *LocalDB) repairProjectChange(change *PendingChange) error {
	project, err := l.GetProjectByUUID(change.EntityUUID)
	if err != nil {
		return fmt.Errorf("project not found locally: %w", err)
	}

	modified := false

	// Fix Code: must be non-empty
	if strings.TrimSpace(project.Code) == "" {
		if project.Name != nil && strings.TrimSpace(*project.Name) != "" {
			project.Code = strings.TrimSpace(*project.Name)
		} else {
			project.Code = project.UUID[:8]
		}
		modified = true
	}

	// Fix Name: use Code if empty
	if project.Name == nil || strings.TrimSpace(*project.Name) == "" {
		name := project.Code
		project.Name = &name
		modified = true
	}

	// Fix OwnerUsername: must be non-empty
	if strings.TrimSpace(project.OwnerUsername) == "" {
		project.OwnerUsername = l.GetDBUser()
		if project.OwnerUsername == "" {
			return fmt.Errorf("cannot determine owner username")
		}
		modified = true
	}

	// Check for local duplicates with same (code, variant)
	var duplicate LocalProject
	err = l.db.Where("code = ? AND variant = ? AND uuid != ?", project.Code, project.Variant, project.UUID).
		First(&duplicate).Error
	if err == nil {
		// Found local duplicate - deduplicate by appending UUID suffix to variant
		if project.Variant == "" {
			project.Variant = project.UUID[:8]
		} else {
			project.Variant = project.Variant + "-" + project.UUID[:8]
		}
		modified = true
	}

	if modified {
		if err := l.SaveProject(project); err != nil {
			return fmt.Errorf("saving repaired project: %w", err)
		}
	}

	return nil
}

// repairConfigurationChange validates and fixes configuration data
func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
	config, err := l.GetConfigurationByUUID(change.EntityUUID)
	if err != nil {
		return fmt.Errorf("configuration not found locally: %w", err)
	}

	modified := false

	// Check if referenced project exists
	if config.ProjectUUID != nil && *config.ProjectUUID != "" {
		_, err := l.GetProjectByUUID(*config.ProjectUUID)
		if err != nil {
			// Project doesn't exist locally - use default system project
			systemProject, sysErr := l.EnsureDefaultProject(config.OriginalUsername)
			if sysErr != nil {
				return fmt.Errorf("getting system project: %w", sysErr)
			}
			config.ProjectUUID = &systemProject.UUID
			modified = true
		}
	}

	if modified {
		if err := l.SaveConfiguration(config); err != nil {
			return fmt.Errorf("saving repaired configuration: %w", err)
		}
	}

	return nil
}

// GetSyncGuardState returns the latest readiness guard state.
func (l *LocalDB) GetSyncGuardState() (*LocalSyncGuardState, error) {
	var state LocalSyncGuardState
	if err := l.db.Order("id DESC").First(&state).Error; err != nil {
		return nil, err
	}
	return &state, nil
}

// SetSyncGuardState upserts readiness guard state (single-row logical table).
func (l *LocalDB) SetSyncGuardState(status, reasonCode, reasonText string, requiredMinAppVersion *string, checkedAt *time.Time) error {
	state := &LocalSyncGuardState{
		ID:                    1,
		Status:                status,
		ReasonCode:            reasonCode,
		ReasonText:            reasonText,
		RequiredMinAppVersion: requiredMinAppVersion,
		LastCheckedAt:         checkedAt,
	}
	return l.db.Clauses(clause.OnConflict{
		Columns: []clause.Column{{Name: "id"}},
		DoUpdates: clause.Assignments(map[string]interface{}{
			"status":                   status,
			"reason_code":              reasonCode,
			"reason_text":              reasonText,
			"required_min_app_version": requiredMinAppVersion,
			"last_checked_at":          checkedAt,
			"updated_at":               time.Now(),
		}),
	}).Create(state).Error
}

// GetLocalPartnumberBookByServerID returns a local partnumber book by its server-side ID.
func (l *LocalDB) GetLocalPartnumberBookByServerID(serverID uint) (*LocalPartnumberBook, error) {
	var book LocalPartnumberBook
	if err := l.db.Where("server_id = ?", serverID).First(&book).Error; err != nil {
		return nil, err
	}
	return &book, nil
}

// GetLocalPricelistItemsPage returns a paginated, searchable list of items for a pricelist.
func (l *LocalDB) GetLocalPricelistItemsPage(pricelistID uint, search string, page, perPage int) ([]LocalPricelistItem, int64, error) {
	dbq := l.db.Model(&LocalPricelistItem{}).Where("pricelist_id = ?", pricelistID)
	if search != "" {
		dbq = dbq.Where("lot_name LIKE ?", "%"+search+"%")
	}
	var total int64
	if err := dbq.Count(&total).Error; err != nil {
		return nil, 0, fmt.Errorf("count pricelist items: %w", err)
	}
	offset := (page - 1) * perPage
	var items []LocalPricelistItem
	if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil {
		return nil, 0, fmt.Errorf("fetch pricelist items: %w", err)
	}
	return items, total, nil
}

// GetLocalComponentDescriptionsByLotNames returns a map of lot_name → lot_description for the given lots.
func (l *LocalDB) GetLocalComponentDescriptionsByLotNames(lotNames []string) (map[string]string, error) {
	if len(lotNames) == 0 {
		return map[string]string{}, nil
	}
	type row struct {
		LotName        string
		LotDescription string
	}
	var rows []row
	if err := l.db.Table("local_components").
		Select("lot_name, lot_description").
		Where("lot_name IN ?", lotNames).
		Scan(&rows).Error; err != nil {
		return nil, fmt.Errorf("fetch component descriptions: %w", err)
	}
	m := make(map[string]string, len(rows))
	for _, r := range rows {
		m[r.LotName] = r.LotDescription
	}
	return m, nil
}

// GetSchemaMigrations returns all applied local schema migrations ordered by applied_at.
func (l *LocalDB) GetSchemaMigrations() ([]LocalSchemaMigration, error) {
	var migrations []LocalSchemaMigration
	if err := l.db.Order("applied_at ASC").Find(&migrations).Error; err != nil {
		return nil, fmt.Errorf("fetch schema migrations: %w", err)
	}
	return migrations, nil
}
