package localdb

import (
	"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
}

// 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_remote_migrations_applied",
		"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)
	}

	if err := ensureLocalProjectsTable(db); err != nil {
		return nil, fmt.Errorf("ensure local_projects table: %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 := db.AutoMigrate(
		&ConnectionSettings{},
		&LocalConfiguration{},
		&LocalConfigurationVersion{},
		&LocalPricelist{},
		&LocalPricelistItem{},
		&LocalComponent{},
		&AppSetting{},
		&LocalRemoteMigrationApplied{},
		&LocalSyncGuardState{},
		&PendingChange{},
	); err != nil {
		return nil, fmt.Errorf("migrating sqlite database: %w", err)
	}
	if err := runLocalMigrations(db); err != nil {
		return nil, fmt.Errorf("running local sqlite migrations: %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
}

// HasSettings returns true if connection settings exist
func (l *LocalDB) HasSettings() bool {
	var count int64
	l.db.Model(&ConnectionSettings{}).Count(&count)
	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, err := Decrypt(settings.PasswordEncrypted)
	if err != nil {
		return nil, fmt.Errorf("decrypting password: %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
}

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) 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("created_at DESC").
		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 {
	return l.db.Save(config).Error
}

// 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
	l.db.Model(&LocalConfiguration{}).Count(&count)
	return count
}

// CountProjects returns the number of local projects
func (l *LocalDB) CountProjects() int64 {
	var count int64
	l.db.Model(&LocalProject{}).Count(&count)
	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
}

// 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
}

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

// GetLatestLocalPricelist returns the most recently synced pricelist
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
	var pricelist LocalPricelist
	if err := l.db.Where("source = ?", "estimate").Order("created_at 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).Order("created_at 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
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
	if len(items) == 0 {
		return nil
	}

	// Batch insert
	batchSize := 500
	for i := 0; i < len(items); i += batchSize {
		end := i + batchSize
		if end > len(items) {
			end = len(items)
		}
		if err := l.db.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
}

// 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
}

// GetRemoteMigrationApplied returns a locally applied remote migration by ID.
func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) {
	var migration LocalRemoteMigrationApplied
	if err := l.db.Where("id = ?", id).First(&migration).Error; err != nil {
		return nil, err
	}
	return &migration, nil
}

// UpsertRemoteMigrationApplied writes applied migration metadata.
func (l *LocalDB) UpsertRemoteMigrationApplied(id, checksum, appVersion string, appliedAt time.Time) error {
	record := &LocalRemoteMigrationApplied{
		ID:         id,
		Checksum:   checksum,
		AppVersion: appVersion,
		AppliedAt:  appliedAt,
	}
	return l.db.Clauses(clause.OnConflict{
		Columns: []clause.Column{{Name: "id"}},
		DoUpdates: clause.Assignments(map[string]interface{}{
			"checksum":    checksum,
			"app_version": appVersion,
			"applied_at":  appliedAt,
		}),
	}).Create(record).Error
}

// GetLatestAppliedRemoteMigrationID returns last applied remote migration id.
func (l *LocalDB) GetLatestAppliedRemoteMigrationID() (string, error) {
	var record LocalRemoteMigrationApplied
	if err := l.db.Order("applied_at DESC").First(&record).Error; err != nil {
		return "", err
	}
	return record.ID, 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
}
