package localdb

import (
	"errors"
	"fmt"
	"log/slog"
	"sort"
	"strings"
	"time"

	"github.com/google/uuid"
	"gorm.io/gorm"
)

type LocalSchemaMigration struct {
	ID        string    `gorm:"primaryKey;size:128"`
	Name      string    `gorm:"not null;size:255"`
	AppliedAt time.Time `gorm:"not null"`
}

func (LocalSchemaMigration) TableName() string {
	return "local_schema_migrations"
}

type localMigration struct {
	id   string
	name string
	run  func(tx *gorm.DB) error
}

var localMigrations = []localMigration{
	{
		id:   "2026_02_04_versioning_backfill",
		name: "Ensure configuration versioning data and current pointers",
		run:  backfillConfigurationVersions,
	},
	{
		id:   "2026_02_04_is_active_backfill",
		name: "Ensure is_active defaults to true for existing configurations",
		run:  backfillConfigurationIsActive,
	},
	{
		id:   "2026_02_06_projects_backfill",
		name: "Create default projects and attach existing configurations",
		run:  backfillProjectsForConfigurations,
	},
	{
		id:   "2026_02_06_pricelist_backfill",
		name: "Attach existing configurations to latest local pricelist and recalc usage",
		run:  backfillConfigurationPricelists,
	},
	{
		id:   "2026_02_06_pricelist_index_fix",
		name: "Use unique server_id for local pricelists and allow duplicate versions",
		run:  fixLocalPricelistIndexes,
	},
	{
		id:   "2026_02_06_pricelist_source",
		name: "Backfill source for local pricelists and create source indexes",
		run:  backfillLocalPricelistSource,
	},
	{
		id:   "2026_02_09_drop_component_unused_fields",
		name: "Remove current_price and synced_at from local_components (unused fields)",
		run:  dropComponentUnusedFields,
	},
	{
		id:   "2026_02_09_add_warehouse_competitor_pricelists",
		name: "Add warehouse_pricelist_id and competitor_pricelist_id to local_configurations",
		run:  addWarehouseCompetitorPriceLists,
	},
	{
		id:   "2026_02_11_local_pricelist_item_category",
		name: "Add lot_category to local_pricelist_items and create indexes",
		run:  addLocalPricelistItemCategoryAndIndexes,
	},
	{
		id:   "2026_02_11_local_config_article",
		name: "Add article to local_configurations",
		run:  addLocalConfigurationArticle,
	},
	{
		id:   "2026_02_11_local_config_server_model",
		name: "Add server_model to local_configurations",
		run:  addLocalConfigurationServerModel,
	},
	{
		id:   "2026_02_11_local_config_support_code",
		name: "Add support_code to local_configurations",
		run:  addLocalConfigurationSupportCode,
	},
	{
		id:   "2026_02_13_local_project_code",
		name: "Add project code to local_projects and backfill",
		run:  addLocalProjectCode,
	},
	{
		id:   "2026_02_13_local_project_variant",
		name: "Add project variant to local_projects and backfill",
		run:  addLocalProjectVariant,
	},
	{
		id:   "2026_02_13_local_project_name_nullable",
		name: "Allow NULL project names in local_projects",
		run:  allowLocalProjectNameNull,
	},
	{
		id:   "2026_02_19_configuration_versions_dedup_spec_price",
		name: "Deduplicate configuration revisions by spec+price",
		run:  deduplicateConfigurationVersionsBySpecAndPrice,
	},
	{
		id:   "2026_02_19_local_config_line_no",
		name: "Add line_no to local_configurations and backfill ordering",
		run:  addLocalConfigurationLineNo,
	},
	{
		id:   "2026_03_07_local_partnumber_book_catalog",
		name: "Convert local partnumber book cache to book membership + deduplicated PN catalog",
		run:  migrateLocalPartnumberBookCatalog,
	},
	{
		id:   "2026_03_13_pricelist_items_dedup_unique",
		name: "Deduplicate local_pricelist_items and add unique index on (pricelist_id, lot_name)",
		run:  deduplicatePricelistItemsAndAddUniqueIndex,
	},
}

type localPartnumberCatalogRow struct {
	Partnumber  string
	LotsJSON    LocalPartnumberBookLots
	Description string
	CreatedAt   time.Time
	ServerID    int
}

func runLocalMigrations(db *gorm.DB) error {
	if err := db.AutoMigrate(&LocalSchemaMigration{}); err != nil {
		return fmt.Errorf("migrate local schema migrations table: %w", err)
	}

	for _, migration := range localMigrations {
		var count int64
		if err := db.Model(&LocalSchemaMigration{}).Where("id = ?", migration.id).Count(&count).Error; err != nil {
			return fmt.Errorf("check local migration %s: %w", migration.id, err)
		}
		if count > 0 {
			continue
		}

		if err := db.Transaction(func(tx *gorm.DB) error {
			if err := migration.run(tx); err != nil {
				return fmt.Errorf("run migration %s: %w", migration.id, err)
			}

			record := &LocalSchemaMigration{
				ID:        migration.id,
				Name:      migration.name,
				AppliedAt: time.Now(),
			}
			if err := tx.Create(record).Error; err != nil {
				return fmt.Errorf("insert migration %s record: %w", migration.id, err)
			}
			return nil
		}); err != nil {
			return err
		}

		slog.Info("local migration applied", "id", migration.id, "name", migration.name)
	}

	return nil
}

func backfillConfigurationVersions(tx *gorm.DB) error {
	var configs []LocalConfiguration
	if err := tx.Find(&configs).Error; err != nil {
		return fmt.Errorf("load local configurations for backfill: %w", err)
	}

	for i := range configs {
		cfg := configs[i]
		var versionCount int64
		if err := tx.Model(&LocalConfigurationVersion{}).
			Where("configuration_uuid = ?", cfg.UUID).
			Count(&versionCount).Error; err != nil {
			return fmt.Errorf("count versions for %s: %w", cfg.UUID, err)
		}

		if versionCount == 0 {
			snapshot, err := BuildConfigurationSnapshot(&cfg)
			if err != nil {
				return fmt.Errorf("build initial snapshot for %s: %w", cfg.UUID, err)
			}
			note := "Initial snapshot backfill (v1)"
			version := LocalConfigurationVersion{
				ID:                uuid.NewString(),
				ConfigurationUUID: cfg.UUID,
				VersionNo:         1,
				Data:              snapshot,
				ChangeNote:        &note,
				AppVersion:        "backfill",
				CreatedAt:         chooseNonZeroTime(cfg.CreatedAt, time.Now()),
			}
			if err := tx.Create(&version).Error; err != nil {
				return fmt.Errorf("create v1 backfill for %s: %w", cfg.UUID, err)
			}
		}

		if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
			var latest LocalConfigurationVersion
			if err := tx.Where("configuration_uuid = ?", cfg.UUID).
				Order("version_no DESC").
				First(&latest).Error; err != nil {
				return fmt.Errorf("load latest version for %s: %w", cfg.UUID, err)
			}
			if err := tx.Model(&LocalConfiguration{}).
				Where("uuid = ?", cfg.UUID).
				Update("current_version_id", latest.ID).Error; err != nil {
				return fmt.Errorf("set current version for %s: %w", cfg.UUID, err)
			}
		}
	}

	return nil
}

func backfillConfigurationIsActive(tx *gorm.DB) error {
	return tx.Exec("UPDATE local_configurations SET is_active = 1 WHERE is_active IS NULL").Error
}

func backfillProjectsForConfigurations(tx *gorm.DB) error {
	var owners []string
	if err := tx.Model(&LocalConfiguration{}).
		Distinct("original_username").
		Pluck("original_username", &owners).Error; err != nil {
		return fmt.Errorf("load owners for projects backfill: %w", err)
	}

	for _, owner := range owners {
		project, err := ensureDefaultProjectTx(tx, owner)
		if err != nil {
			return err
		}

		if err := tx.Model(&LocalConfiguration{}).
			Where("original_username = ? AND (project_uuid IS NULL OR project_uuid = '')", owner).
			Update("project_uuid", project.UUID).Error; err != nil {
			return fmt.Errorf("assign default project for owner %s: %w", owner, err)
		}
	}

	return nil
}

func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, error) {
	var project LocalProject
	err := tx.Where("owner_username = ? AND is_system = ? AND name = ?", ownerUsername, true, "Без проекта").
		First(&project).Error
	if err == nil {
		return &project, nil
	}
	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, fmt.Errorf("load system project for %s: %w", ownerUsername, err)
	}

	now := time.Now()
	project = LocalProject{
		UUID:          uuid.NewString(),
		OwnerUsername: ownerUsername,
		Code:          "Без проекта",
		Name:          ptrString("Без проекта"),
		IsActive:      true,
		IsSystem:      true,
		CreatedAt:     now,
		UpdatedAt:     now,
		SyncStatus:    "pending",
	}
	if err := tx.Create(&project).Error; err != nil {
		return nil, fmt.Errorf("create system project for %s: %w", ownerUsername, err)
	}

	return &project, nil
}

func addLocalProjectCode(tx *gorm.DB) error {
	if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN code TEXT`).Error; err != nil {
		if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
			!strings.Contains(strings.ToLower(err.Error()), "exists") {
			return err
		}
	}

	// Drop unique index if it already exists to allow de-duplication updates.
	if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
		return err
	}

	// Copy code from current project name.
	if err := tx.Exec(`
UPDATE local_projects
SET code = TRIM(COALESCE(name, ''))`).Error; err != nil {
		return err
	}

	// Ensure any remaining blanks have a unique fallback.
	if err := tx.Exec(`
UPDATE local_projects
SET code = 'P-' || uuid
WHERE code IS NULL OR TRIM(code) = ''`).Error; err != nil {
		return err
	}

	// De-duplicate codes: OPS-1948-2, OPS-1948-3...
	if err := tx.Exec(`
WITH ranked AS (
	SELECT id, code,
	       ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
	FROM local_projects
)
UPDATE local_projects
SET code = code || '-' || (SELECT rn FROM ranked WHERE ranked.id = local_projects.id)
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)`).Error; err != nil {
		return err
	}

	// Create unique index for project codes (ignore if exists).
	if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code ON local_projects(code)`).Error; err != nil {
		return err
	}

	return nil
}

func addLocalProjectVariant(tx *gorm.DB) error {
	if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN variant TEXT NOT NULL DEFAULT ''`).Error; err != nil {
		if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
			!strings.Contains(strings.ToLower(err.Error()), "exists") {
			return err
		}
	}

	// Drop legacy code index if present.
	if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
		return err
	}

	// Reset code from name and clear variant.
	if err := tx.Exec(`
UPDATE local_projects
SET code = TRIM(COALESCE(name, '')),
    variant = ''`).Error; err != nil {
		return err
	}

	// De-duplicate by assigning variant numbers: 2,3...
	if err := tx.Exec(`
WITH ranked AS (
	SELECT id, code,
	       ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
	FROM local_projects
)
UPDATE local_projects
SET variant = CASE
	WHEN (SELECT rn FROM ranked WHERE ranked.id = local_projects.id) = 1 THEN ''
	ELSE '-' || CAST((SELECT rn FROM ranked WHERE ranked.id = local_projects.id) AS TEXT)
END`).Error; err != nil {
		return err
	}

	if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error; err != nil {
		return err
	}

	return nil
}

func allowLocalProjectNameNull(tx *gorm.DB) error {
	if err := tx.Exec(`ALTER TABLE local_projects RENAME TO local_projects_old`).Error; err != nil {
		return err
	}

	if err := tx.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
	}

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

	if err := tx.Exec(`
INSERT INTO local_projects (id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status)
SELECT id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status
FROM local_projects_old`).Error; err != nil {
		return err
	}

	_ = tx.Exec(`DROP TABLE local_projects_old`).Error
	return nil
}

func backfillConfigurationPricelists(tx *gorm.DB) error {
	var latest LocalPricelist
	if err := tx.Where("source = ?", "estimate").Order("created_at DESC").First(&latest).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil
		}
		return fmt.Errorf("load latest local pricelist: %w", err)
	}

	if err := tx.Model(&LocalConfiguration{}).
		Where("pricelist_id IS NULL").
		Update("pricelist_id", latest.ServerID).Error; err != nil {
		return fmt.Errorf("backfill configuration pricelist_id: %w", err)
	}

	if err := tx.Model(&LocalPricelist{}).Where("1 = 1").Update("is_used", false).Error; err != nil {
		return fmt.Errorf("reset local pricelist usage flags: %w", err)
	}

	if err := 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; err != nil {
		return fmt.Errorf("recalculate local pricelist usage flags: %w", err)
	}

	return nil
}

func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
	if candidate.IsZero() {
		return fallback
	}
	return candidate
}

func deduplicateConfigurationVersionsBySpecAndPrice(tx *gorm.DB) error {
	var configs []LocalConfiguration
	if err := tx.Select("uuid", "current_version_id").Find(&configs).Error; err != nil {
		return fmt.Errorf("load configurations for revision deduplication: %w", err)
	}

	var removedTotal int
	for i := range configs {
		cfg := configs[i]

		var versions []LocalConfigurationVersion
		if err := tx.Where("configuration_uuid = ?", cfg.UUID).
			Order("version_no ASC, created_at ASC").
			Find(&versions).Error; err != nil {
			return fmt.Errorf("load versions for %s: %w", cfg.UUID, err)
		}
		if len(versions) < 2 {
			continue
		}

		deleteIDs := make([]string, 0)
		deleteSet := make(map[string]struct{})
		kept := make([]LocalConfigurationVersion, 0, len(versions))
		var prevKey string
		hasPrev := false

		for _, version := range versions {
			snapshotCfg, err := DecodeConfigurationSnapshot(version.Data)
			if err != nil {
				// Keep malformed snapshots untouched and reset chain to avoid accidental removals.
				kept = append(kept, version)
				hasPrev = false
				continue
			}

			key, err := BuildConfigurationSpecPriceFingerprint(snapshotCfg)
			if err != nil {
				kept = append(kept, version)
				hasPrev = false
				continue
			}

			if !hasPrev || key != prevKey {
				kept = append(kept, version)
				prevKey = key
				hasPrev = true
				continue
			}

			deleteIDs = append(deleteIDs, version.ID)
			deleteSet[version.ID] = struct{}{}
		}

		if len(deleteIDs) == 0 {
			continue
		}

		if err := tx.Where("id IN ?", deleteIDs).Delete(&LocalConfigurationVersion{}).Error; err != nil {
			return fmt.Errorf("delete duplicate versions for %s: %w", cfg.UUID, err)
		}
		removedTotal += len(deleteIDs)

		latestKeptID := kept[len(kept)-1].ID
		if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
			if err := tx.Model(&LocalConfiguration{}).
				Where("uuid = ?", cfg.UUID).
				Update("current_version_id", latestKeptID).Error; err != nil {
				return fmt.Errorf("set missing current_version_id for %s: %w", cfg.UUID, err)
			}
			continue
		}

		if _, deleted := deleteSet[*cfg.CurrentVersionID]; deleted {
			if err := tx.Model(&LocalConfiguration{}).
				Where("uuid = ?", cfg.UUID).
				Update("current_version_id", latestKeptID).Error; err != nil {
				return fmt.Errorf("repair current_version_id for %s: %w", cfg.UUID, err)
			}
		}
	}

	if removedTotal > 0 {
		slog.Info("deduplicated configuration revisions", "removed_versions", removedTotal)
	}
	return nil
}

func fixLocalPricelistIndexes(tx *gorm.DB) error {
	type indexRow struct {
		Name   string `gorm:"column:name"`
		Unique int    `gorm:"column:unique"`
	}
	var indexes []indexRow
	if err := tx.Raw("PRAGMA index_list('local_pricelists')").Scan(&indexes).Error; err != nil {
		return fmt.Errorf("list local_pricelists indexes: %w", err)
	}

	for _, idx := range indexes {
		if idx.Unique == 0 {
			continue
		}

		type indexInfoRow struct {
			Name string `gorm:"column:name"`
		}
		var info []indexInfoRow
		if err := tx.Raw(fmt.Sprintf("PRAGMA index_info('%s')", strings.ReplaceAll(idx.Name, "'", "''"))).Scan(&info).Error; err != nil {
			return fmt.Errorf("load index info for %s: %w", idx.Name, err)
		}
		if len(info) != 1 || info[0].Name != "version" {
			continue
		}

		quoted := strings.ReplaceAll(idx.Name, `"`, `""`)
		if err := tx.Exec(fmt.Sprintf(`DROP INDEX IF EXISTS "%s"`, quoted)).Error; err != nil {
			return fmt.Errorf("drop unique version index %s: %w", idx.Name, err)
		}
	}

	if err := tx.Exec(`
		CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_server_id
		ON local_pricelists(server_id)
	`).Error; err != nil {
		return fmt.Errorf("ensure unique index local_pricelists(server_id): %w", err)
	}

	if err := tx.Exec(`
		CREATE INDEX IF NOT EXISTS idx_local_pricelists_version
		ON local_pricelists(version)
	`).Error; err != nil {
		return fmt.Errorf("ensure index local_pricelists(version): %w", err)
	}

	return nil
}

func backfillLocalPricelistSource(tx *gorm.DB) error {
	if err := tx.Exec(`
		UPDATE local_pricelists
		SET source = 'estimate'
		WHERE source IS NULL OR source = ''
	`).Error; err != nil {
		return fmt.Errorf("backfill local_pricelists.source: %w", err)
	}

	if err := tx.Exec(`
		CREATE INDEX IF NOT EXISTS idx_local_pricelists_source_created_at
		ON local_pricelists(source, created_at DESC)
	`).Error; err != nil {
		return fmt.Errorf("ensure idx_local_pricelists_source_created_at: %w", err)
	}

	return nil
}

func dropComponentUnusedFields(tx *gorm.DB) error {
	// Check if columns exist
	type columnInfo struct {
		Name string `gorm:"column:name"`
	}

	var columns []columnInfo
	if err := tx.Raw(`
		SELECT name FROM pragma_table_info('local_components')
		WHERE name IN ('current_price', 'synced_at')
	`).Scan(&columns).Error; err != nil {
		return fmt.Errorf("check columns existence: %w", err)
	}

	if len(columns) == 0 {
		slog.Info("unused fields already removed from local_components")
		return nil
	}

	// SQLite: recreate table without current_price and synced_at
	if err := tx.Exec(`
		CREATE TABLE local_components_new (
			lot_name TEXT PRIMARY KEY,
			lot_description TEXT,
			category TEXT,
			model TEXT
		)
	`).Error; err != nil {
		return fmt.Errorf("create new local_components table: %w", err)
	}

	if err := tx.Exec(`
		INSERT INTO local_components_new (lot_name, lot_description, category, model)
		SELECT lot_name, lot_description, category, model
		FROM local_components
	`).Error; err != nil {
		return fmt.Errorf("copy data to new table: %w", err)
	}

	if err := tx.Exec(`DROP TABLE local_components`).Error; err != nil {
		return fmt.Errorf("drop old table: %w", err)
	}

	if err := tx.Exec(`ALTER TABLE local_components_new RENAME TO local_components`).Error; err != nil {
		return fmt.Errorf("rename new table: %w", err)
	}

	slog.Info("dropped current_price and synced_at columns from local_components")
	return nil
}

func addWarehouseCompetitorPriceLists(tx *gorm.DB) error {
	// Check if columns exist
	type columnInfo struct {
		Name string `gorm:"column:name"`
	}

	var columns []columnInfo
	if err := tx.Raw(`
		SELECT name FROM pragma_table_info('local_configurations')
		WHERE name IN ('warehouse_pricelist_id', 'competitor_pricelist_id')
	`).Scan(&columns).Error; err != nil {
		return fmt.Errorf("check columns existence: %w", err)
	}

	if len(columns) == 2 {
		slog.Info("warehouse and competitor pricelist columns already exist")
		return nil
	}

	// Add columns if they don't exist
	if err := tx.Exec(`
		ALTER TABLE local_configurations
		ADD COLUMN warehouse_pricelist_id INTEGER
	`).Error; err != nil {
		// Column might already exist, ignore
		if !strings.Contains(err.Error(), "duplicate column") {
			return fmt.Errorf("add warehouse_pricelist_id column: %w", err)
		}
	}

	if err := tx.Exec(`
		ALTER TABLE local_configurations
		ADD COLUMN competitor_pricelist_id INTEGER
	`).Error; err != nil {
		// Column might already exist, ignore
		if !strings.Contains(err.Error(), "duplicate column") {
			return fmt.Errorf("add competitor_pricelist_id column: %w", err)
		}
	}

	// Create indexes
	if err := tx.Exec(`
		CREATE INDEX IF NOT EXISTS idx_local_configurations_warehouse_pricelist
		ON local_configurations(warehouse_pricelist_id)
	`).Error; err != nil {
		return fmt.Errorf("create warehouse pricelist index: %w", err)
	}

	if err := tx.Exec(`
		CREATE INDEX IF NOT EXISTS idx_local_configurations_competitor_pricelist
		ON local_configurations(competitor_pricelist_id)
	`).Error; err != nil {
		return fmt.Errorf("create competitor pricelist index: %w", err)
	}

	slog.Info("added warehouse and competitor pricelist fields to local_configurations")
	return nil
}

func addLocalPricelistItemCategoryAndIndexes(tx *gorm.DB) error {
	type columnInfo struct {
		Name string `gorm:"column:name"`
	}

	var columns []columnInfo
	if err := tx.Raw(`
		SELECT name FROM pragma_table_info('local_pricelist_items')
		WHERE name IN ('lot_category')
	`).Scan(&columns).Error; err != nil {
		return fmt.Errorf("check local_pricelist_items(lot_category) existence: %w", err)
	}

	if len(columns) == 0 {
		if err := tx.Exec(`
			ALTER TABLE local_pricelist_items
			ADD COLUMN lot_category TEXT
		`).Error; err != nil {
			return fmt.Errorf("add local_pricelist_items.lot_category: %w", err)
		}
		slog.Info("added lot_category to local_pricelist_items")
	}

	if err := tx.Exec(`
		CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot
		ON local_pricelist_items(pricelist_id, lot_name)
	`).Error; err != nil {
		return fmt.Errorf("ensure idx_local_pricelist_items_pricelist_lot: %w", err)
	}

	if err := tx.Exec(`
		CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_lot_category
		ON local_pricelist_items(lot_category)
	`).Error; err != nil {
		return fmt.Errorf("ensure idx_local_pricelist_items_lot_category: %w", err)
	}

	return nil
}

func addLocalConfigurationArticle(tx *gorm.DB) error {
	type columnInfo struct {
		Name string `gorm:"column:name"`
	}
	var columns []columnInfo
	if err := tx.Raw(`
		SELECT name FROM pragma_table_info('local_configurations')
		WHERE name IN ('article')
	`).Scan(&columns).Error; err != nil {
		return fmt.Errorf("check local_configurations(article) existence: %w", err)
	}
	if len(columns) == 0 {
		if err := tx.Exec(`
			ALTER TABLE local_configurations
			ADD COLUMN article TEXT
		`).Error; err != nil {
			return fmt.Errorf("add local_configurations.article: %w", err)
		}
		slog.Info("added article to local_configurations")
	}
	return nil
}

func addLocalConfigurationServerModel(tx *gorm.DB) error {
	type columnInfo struct {
		Name string `gorm:"column:name"`
	}
	var columns []columnInfo
	if err := tx.Raw(`
		SELECT name FROM pragma_table_info('local_configurations')
		WHERE name IN ('server_model')
	`).Scan(&columns).Error; err != nil {
		return fmt.Errorf("check local_configurations(server_model) existence: %w", err)
	}
	if len(columns) == 0 {
		if err := tx.Exec(`
			ALTER TABLE local_configurations
			ADD COLUMN server_model TEXT
		`).Error; err != nil {
			return fmt.Errorf("add local_configurations.server_model: %w", err)
		}
		slog.Info("added server_model to local_configurations")
	}
	return nil
}

func addLocalConfigurationSupportCode(tx *gorm.DB) error {
	type columnInfo struct {
		Name string `gorm:"column:name"`
	}
	var columns []columnInfo
	if err := tx.Raw(`
		SELECT name FROM pragma_table_info('local_configurations')
		WHERE name IN ('support_code')
	`).Scan(&columns).Error; err != nil {
		return fmt.Errorf("check local_configurations(support_code) existence: %w", err)
	}
	if len(columns) == 0 {
		if err := tx.Exec(`
			ALTER TABLE local_configurations
			ADD COLUMN support_code TEXT
		`).Error; err != nil {
			return fmt.Errorf("add local_configurations.support_code: %w", err)
		}
		slog.Info("added support_code to local_configurations")
	}
	return nil
}

func addLocalConfigurationLineNo(tx *gorm.DB) error {
	type columnInfo struct {
		Name string `gorm:"column:name"`
	}
	var columns []columnInfo
	if err := tx.Raw(`
		SELECT name FROM pragma_table_info('local_configurations')
		WHERE name IN ('line_no')
	`).Scan(&columns).Error; err != nil {
		return fmt.Errorf("check local_configurations(line_no) existence: %w", err)
	}
	if len(columns) == 0 {
		if err := tx.Exec(`
			ALTER TABLE local_configurations
			ADD COLUMN line_no INTEGER
		`).Error; err != nil {
			return fmt.Errorf("add local_configurations.line_no: %w", err)
		}
		slog.Info("added line_no to local_configurations")
	}

	if err := tx.Exec(`
WITH ranked AS (
	SELECT
		id,
		ROW_NUMBER() OVER (
			PARTITION BY COALESCE(NULLIF(TRIM(project_uuid), ''), '__NO_PROJECT__')
			ORDER BY created_at ASC, id ASC
		) AS rn
	FROM local_configurations
	WHERE line_no IS NULL OR line_no <= 0
)
UPDATE local_configurations
SET line_no = (
	SELECT rn * 10
	FROM ranked
	WHERE ranked.id = local_configurations.id
)
WHERE id IN (SELECT id FROM ranked)
`).Error; err != nil {
		return fmt.Errorf("backfill local_configurations.line_no: %w", err)
	}

	if err := tx.Exec(`
		CREATE INDEX IF NOT EXISTS idx_local_configurations_project_line_no
		ON local_configurations(project_uuid, line_no)
	`).Error; err != nil {
		return fmt.Errorf("ensure idx_local_configurations_project_line_no: %w", err)
	}

	return nil
}

func migrateLocalPartnumberBookCatalog(tx *gorm.DB) error {
	type columnInfo struct {
		Name string `gorm:"column:name"`
	}

	hasBooksTable := tx.Migrator().HasTable(&LocalPartnumberBook{})
	hasItemsTable := tx.Migrator().HasTable(&LocalPartnumberBookItem{})
	if !hasItemsTable {
		return nil
	}

	if hasBooksTable {
		var bookCols []columnInfo
		if err := tx.Raw(`SELECT name FROM pragma_table_info('local_partnumber_books')`).Scan(&bookCols).Error; err != nil {
			return fmt.Errorf("load local_partnumber_books columns: %w", err)
		}
		hasPartnumbersJSON := false
		for _, c := range bookCols {
			if c.Name == "partnumbers_json" {
				hasPartnumbersJSON = true
				break
			}
		}
		if !hasPartnumbersJSON {
			if err := tx.Exec(`ALTER TABLE local_partnumber_books ADD COLUMN partnumbers_json TEXT NOT NULL DEFAULT '[]'`).Error; err != nil {
				return fmt.Errorf("add local_partnumber_books.partnumbers_json: %w", err)
			}
		}
	}

	var itemCols []columnInfo
	if err := tx.Raw(`SELECT name FROM pragma_table_info('local_partnumber_book_items')`).Scan(&itemCols).Error; err != nil {
		return fmt.Errorf("load local_partnumber_book_items columns: %w", err)
	}
	hasBookID := false
	hasLotName := false
	hasLotsJSON := false
	for _, c := range itemCols {
		if c.Name == "book_id" {
			hasBookID = true
		}
		if c.Name == "lot_name" {
			hasLotName = true
		}
		if c.Name == "lots_json" {
			hasLotsJSON = true
		}
	}
	if !hasBookID && !hasLotName && !hasLotsJSON {
		return nil
	}

	type legacyRow struct {
		BookID      uint
		Partnumber  string
		LotName     string
		Description string
		CreatedAt   time.Time
		ServerID    int
	}
	bookPNs := make(map[uint]map[string]struct{})
	catalog := make(map[string]*localPartnumberCatalogRow)

	if hasBookID || hasLotName {
		var rows []legacyRow
		if err := tx.Raw(`
			SELECT
				i.book_id,
				i.partnumber,
				i.lot_name,
				COALESCE(i.description, '') AS description,
				b.created_at,
				b.server_id
			FROM local_partnumber_book_items i
			INNER JOIN local_partnumber_books b ON b.id = i.book_id
			ORDER BY b.created_at DESC, b.id DESC, i.partnumber ASC, i.id ASC
		`).Scan(&rows).Error; err != nil {
			return fmt.Errorf("load legacy local partnumber book items: %w", err)
		}

		for _, row := range rows {
			if _, ok := bookPNs[row.BookID]; !ok {
				bookPNs[row.BookID] = make(map[string]struct{})
			}
			bookPNs[row.BookID][row.Partnumber] = struct{}{}

			entry, ok := catalog[row.Partnumber]
			if !ok {
				entry = &localPartnumberCatalogRow{
					Partnumber:  row.Partnumber,
					Description: row.Description,
					CreatedAt:   row.CreatedAt,
					ServerID:    row.ServerID,
				}
				catalog[row.Partnumber] = entry
			}
			if row.CreatedAt.After(entry.CreatedAt) || (row.CreatedAt.Equal(entry.CreatedAt) && row.ServerID >= entry.ServerID) {
				entry.Description = row.Description
				entry.CreatedAt = row.CreatedAt
				entry.ServerID = row.ServerID
			}
			found := false
			for i := range entry.LotsJSON {
				if entry.LotsJSON[i].LotName == row.LotName {
					entry.LotsJSON[i].Qty += 1
					found = true
					break
				}
			}
			if !found && row.LotName != "" {
				entry.LotsJSON = append(entry.LotsJSON, LocalPartnumberBookLot{LotName: row.LotName, Qty: 1})
			}
		}

		var books []LocalPartnumberBook
		if err := tx.Find(&books).Error; err != nil {
			return fmt.Errorf("load local partnumber books: %w", err)
		}
		for _, book := range books {
			pnSet := bookPNs[book.ID]
			partnumbers := make([]string, 0, len(pnSet))
			for pn := range pnSet {
				partnumbers = append(partnumbers, pn)
			}
			sort.Strings(partnumbers)
			if err := tx.Model(&LocalPartnumberBook{}).
				Where("id = ?", book.ID).
				Update("partnumbers_json", LocalStringList(partnumbers)).Error; err != nil {
				return fmt.Errorf("update partnumbers_json for local book %d: %w", book.ID, err)
			}
		}
	} else {
		var items []LocalPartnumberBookItem
		if err := tx.Order("id DESC").Find(&items).Error; err != nil {
			return fmt.Errorf("load canonical local partnumber book items: %w", err)
		}
		for _, item := range items {
			entry, ok := catalog[item.Partnumber]
			if !ok {
				copiedLots := append(LocalPartnumberBookLots(nil), item.LotsJSON...)
				catalog[item.Partnumber] = &localPartnumberCatalogRow{
					Partnumber:  item.Partnumber,
					LotsJSON:    copiedLots,
					Description: item.Description,
				}
				continue
			}
			if entry.Description == "" && item.Description != "" {
				entry.Description = item.Description
			}
			for _, lot := range item.LotsJSON {
				merged := false
				for i := range entry.LotsJSON {
					if entry.LotsJSON[i].LotName == lot.LotName {
						if lot.Qty > entry.LotsJSON[i].Qty {
							entry.LotsJSON[i].Qty = lot.Qty
						}
						merged = true
						break
					}
				}
				if !merged {
					entry.LotsJSON = append(entry.LotsJSON, lot)
				}
			}
		}
	}

	return rebuildLocalPartnumberBookCatalog(tx, catalog)
}

func rebuildLocalPartnumberBookCatalog(tx *gorm.DB, catalog map[string]*localPartnumberCatalogRow) error {
	if err := tx.Exec(`
		CREATE TABLE local_partnumber_book_items_new (
			id INTEGER PRIMARY KEY AUTOINCREMENT,
			partnumber TEXT NOT NULL UNIQUE,
			lots_json TEXT NOT NULL,
			description TEXT
		)
	`).Error; err != nil {
		return fmt.Errorf("create new local_partnumber_book_items table: %w", err)
	}

	orderedPartnumbers := make([]string, 0, len(catalog))
	for pn := range catalog {
		orderedPartnumbers = append(orderedPartnumbers, pn)
	}
	sort.Strings(orderedPartnumbers)
	for _, pn := range orderedPartnumbers {
		row := catalog[pn]
		sort.Slice(row.LotsJSON, func(i, j int) bool {
			return row.LotsJSON[i].LotName < row.LotsJSON[j].LotName
		})
		if err := tx.Table("local_partnumber_book_items_new").Create(&LocalPartnumberBookItem{
			Partnumber:  row.Partnumber,
			LotsJSON:    row.LotsJSON,
			Description: row.Description,
		}).Error; err != nil {
			return fmt.Errorf("insert new local_partnumber_book_items row for %s: %w", pn, err)
		}
	}

	if err := tx.Exec(`DROP TABLE local_partnumber_book_items`).Error; err != nil {
		return fmt.Errorf("drop legacy local_partnumber_book_items: %w", err)
	}
	if err := tx.Exec(`ALTER TABLE local_partnumber_book_items_new RENAME TO local_partnumber_book_items`).Error; err != nil {
		return fmt.Errorf("rename new local_partnumber_book_items table: %w", err)
	}
	if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_partnumber_book_items_partnumber ON local_partnumber_book_items(partnumber)`).Error; err != nil {
		return fmt.Errorf("create local_partnumber_book_items partnumber index: %w", err)
	}
	return nil
}

func deduplicatePricelistItemsAndAddUniqueIndex(tx *gorm.DB) error {
	// Remove duplicate (pricelist_id, lot_name) rows keeping only the row with the lowest id.
	if err := tx.Exec(`
		DELETE FROM local_pricelist_items
		WHERE id NOT IN (
			SELECT MIN(id) FROM local_pricelist_items
			GROUP BY pricelist_id, lot_name
		)
	`).Error; err != nil {
		return fmt.Errorf("deduplicate local_pricelist_items: %w", err)
	}

	// Add unique index to prevent future duplicates.
	if err := tx.Exec(`
		CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot_unique
		ON local_pricelist_items(pricelist_id, lot_name)
	`).Error; err != nil {
		return fmt.Errorf("create unique index on local_pricelist_items: %w", err)
	}
	slog.Info("deduplicated local_pricelist_items and added unique index")
	return nil
}
