package data

import (
	"context"
	"fmt"
	"log/slog"
	"sync"
	"sync/atomic"
	"time"

	"github.com/google/wire"
	"github.com/redis/go-redis/v9"
	"go.mongodb.org/mongo-driver/mongo"
	"gorm.io/gorm"
	"kra/internal/config"
	dataintegration "kra/internal/data/integration"
	datapayment "kra/internal/data/payment"
	datasystem "kra/internal/data/system"
	datatask "kra/internal/data/task"
	"kra/internal/integration/runtimeconfig"
	"kra/internal/integration/storage"
	"kra/pkg/database/migration"
	"kra/pkg/module"
)

var ProviderSet = wire.NewSet(
	NewData,
	NewIntegrationRuntime,
	wire.Bind(new(datasystem.Provider), new(*Data)),
	wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
	wire.Bind(new(dataintegration.Provider), new(*Data)),
	wire.Bind(new(datapayment.Provider), new(*Data)),
	datasystem.ProviderSet,
	dataintegration.ProviderSet,
	datatask.ProviderSet,
	datapayment.ProviderSet,
)

func NewIntegrationRuntime(data *Data) *runtimeconfig.Store {
	if data == nil {
		return runtimeconfig.NewStore()
	}
	return data.IntegrationRuntime()
}

type Data struct {
	initMu        sync.Mutex
	configMu      sync.Mutex
	databaseReady atomic.Bool
	gormDB        *reloadable[*gorm.DB]
	redis         *reloadable[redis.UniversalClient]
	redisListMu   sync.RWMutex
	redisList     map[string]redis.UniversalClient
	mongo         *reloadable[*mongo.Client]
	runtime       *config.Store
	integrations  *runtimeconfig.Store
	storage       *storage.Reloadable
	dbListMu      sync.RWMutex
	dbList        map[string]*gorm.DB
	// Handles replaced by a hot reload of the named lists. They follow the same
	// grace period as the primary pool so in-flight queries are not cut off.
	retiredDBList    retiredSet[*gorm.DB]
	retiredRedisList retiredSet[redis.UniversalClient]
	appLogger        *slog.Logger
	auditLog         *dataScopeAuditWriter
	catalog          module.Catalog
}

// DB exposes the active primary database to narrowly scoped data submodules.
func (d *Data) DB() *gorm.DB {
	if d == nil || d.gormDB == nil {
		return nil
	}
	return d.gormDB.load()
}

// DatabaseReady reports whether the configured primary database has been
// initialized. It is intentionally small so system repositories do not depend
// on the full Data implementation.
func (d *Data) DatabaseReady() bool {
	return d != nil && d.databaseReady.Load()
}

// Runtime exposes the immutable runtime configuration snapshot to data
// submodules that need system settings while keeping Data itself private.
func (d *Data) Runtime() *config.Store {
	if d == nil {
		return nil
	}
	return d.runtime
}

// IntegrationRuntime exposes database-backed integration configuration to
// long-lived adapters without making config.yaml part of their lifecycle.
func (d *Data) IntegrationRuntime() *runtimeconfig.Store {
	if d == nil {
		return nil
	}
	return d.integrations
}

// Database resolves the primary or a named database for repositories such as
// the system export module.
func (d *Data) Database(name string) (*gorm.DB, error) {
	if name == "" {
		return d.gormDB.load(), nil
	}
	d.dbListMu.RLock()
	db := d.dbList[name]
	d.dbListMu.RUnlock()
	if db == nil {
		return nil, fmt.Errorf("database %q not found", name)
	}
	return db, nil
}

func (d *Data) RedisClient() redis.UniversalClient {
	if d == nil || d.redis == nil {
		return nil
	}
	return d.redis.load()
}

func (d *Data) logger() *slog.Logger {
	if d != nil && d.appLogger != nil {
		return d.appLogger
	}
	return slog.Default()
}

func openDatabaseList(configs []*config.Database, appLogger *slog.Logger) (map[string]*gorm.DB, error) {
	items := make(map[string]*gorm.DB)
	for _, config := range configs {
		if config == nil || config.Disable || config.AliasName == "" {
			continue
		}
		db, err := openDatabase(config, false, "", appLogger)
		if err != nil {
			for _, opened := range items {
				if sqlDB, dbErr := opened.DB(); dbErr == nil {
					_ = sqlDB.Close()
				}
			}
			return nil, fmt.Errorf("open database %q: %w", config.AliasName, err)
		}
		items[config.AliasName] = db
	}
	return items, nil
}

func closeDatabaseList(items map[string]*gorm.DB) {
	for _, db := range items {
		if db != nil {
			closeGormDB(db)
		}
	}
}

// replaceDatabaseList swaps the named database handles. Superseded handles are
// retired instead of closed on the spot: a request that already resolved a
// handle would otherwise fail with "sql: database is closed" mid-flight.
func (d *Data) replaceDatabaseList(items map[string]*gorm.DB) {
	d.dbListMu.Lock()
	old := d.dbList
	d.dbList = items
	d.dbListMu.Unlock()
	for name, db := range old {
		if db == nil || db == items[name] {
			continue
		}
		d.retiredDBList.retire(db, closeGormDB)
	}
}

func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *storage.Reloadable, catalog module.Catalog) (*Data, func(), error) {
	if appLogger == nil {
		appLogger = slog.Default()
	}
	c := runtime.Data()
	if c == nil {
		c = &config.Data{}
	}
	if c.Database == nil {
		// An empty database block starts the service on the bootstrap database
		// so /init/checkdb
		// and /init/initdb remain available.
		c.Database = &config.Database{}
	}
	d := &Data{runtime: runtime, integrations: runtimeconfig.NewStore(), appLogger: appLogger, storage: storageManager, catalog: catalog}
	var cleanupOnce sync.Once
	cleanup := func() {
		cleanupOnce.Do(func() {
			if d.auditLog != nil {
				d.auditLog.Close()
			}
			if d.gormDB != nil {
				d.gormDB.close()
			}
			closeDatabaseList(d.dbList)
			for _, db := range d.retiredDBList.drain() {
				closeGormDB(db)
			}
			if d.redis != nil {
				d.redis.close()
			}
			closeRedisList(d.redisList)
			for _, client := range d.retiredRedisList.drain() {
				closeRedisClient(client)
			}
			if d.mongo != nil {
				d.mongo.close()
			}
		})
	}
	var undo rollback
	undo.add(cleanup)
	defer undo.run()
	usingFallback := !databaseConnectionConfigured(c.Database)
	var db *gorm.DB
	var err error
	if !usingFallback {
		db, err = openDatabase(c.Database, false, "", appLogger)
	}
	if usingFallback || err != nil {
		// The initialization endpoint must remain available when the configured
		// target database has not been created yet.
		if err != nil {
			appLogger.Warn("configured database unavailable before initialization", "mod", "system", "error", err)
		}
		db, err = openFallbackDatabase(appLogger)
		if err != nil {
			return nil, nil, fmt.Errorf("open bootstrap database: %w", err)
		}
		usingFallback = true
	}
	d.databaseReady.Store(!usingFallback)
	d.gormDB = newReloadableDB(db)
	d.auditLog = newDataScopeAuditWriter(d, appLogger)
	registerDataScopeCallbacks(db, d.enqueueDataScopeAudit)
	d.dbList, err = openDatabaseList(c.DatabaseList, appLogger)
	if err != nil {
		return nil, nil, err
	}
	for _, item := range d.dbList {
		registerDataScopeCallbacks(item, d.enqueueDataScopeAudit)
	}
	admin := runtime.Admin()
	if admin == nil {
		admin = &config.Admin{}
	}
	disableAutoMigrate := admin.System != nil && admin.System.DisableAutoMigrate
	if !usingFallback && !disableAutoMigrate {
		if err = migrateAll(db, catalog); err != nil {
			return nil, nil, fmt.Errorf("migrate tables: %w", err)
		}
	}
	if !usingFallback {
		storageConfig, storageErr := dataintegration.ResolveStorageConfig(db, admin.Storage)
		if storageErr != nil {
			return nil, nil, fmt.Errorf("load storage integration configuration: %w", storageErr)
		}
		emailConfig, emailErr := dataintegration.ResolveEmailConfig(db, admin.Email)
		if emailErr != nil {
			return nil, nil, fmt.Errorf("load email integration configuration: %w", emailErr)
		}
		admin.Storage = storageConfig
		admin.Email = emailConfig
		runtime.Replace(&config.Config{Server: runtime.Server(), Data: c, Admin: admin})
		if err = d.loadIntegrationRuntime(db); err != nil {
			return nil, nil, fmt.Errorf("load integration runtime: %w", err)
		}
		activeStorage, storageErr := storage.New(admin)
		if storageErr != nil {
			return nil, nil, fmt.Errorf("initialize storage: %w", storageErr)
		}
		if storageManager != nil {
			storageManager.Replace(activeStorage)
		}
		if db.Migrator().HasTable("sys_integration_configs") {
			if removeErr := d.removeIntegrationConfigFromFile(); removeErr != nil {
				appLogger.Warn("remove legacy integration configuration from file", "mod", "integration", "error", removeErr)
			}
		}
	}
	if storageManager == nil {
		return nil, nil, fmt.Errorf("storage manager is nil")
	}
	useRedis := admin != nil && admin.System != nil && admin.System.UseRedis
	d.redis = newReloadable(openRedis(c.Redis, useRedis, appLogger), closeRedisClient)
	useRedisList := useRedis && admin.System.UseMultipoint
	d.redisList = openRedisList(c.RedisList, useRedisList, appLogger)
	useMongo := admin != nil && admin.System != nil && admin.System.UseMongo
	mongoClient, err := openMongo(c.Mongo, useMongo)
	if err != nil {
		appLogger.Error("mongo unavailable", "mod", "mongo", "error", err)
		mongoClient = nil
	}
	d.mongo = newReloadable(mongoClient, closeMongoClient)
	undo.commit()
	return d, cleanup, nil
}

// redisConnectionConfigured reports whether a Redis block names an endpoint.
// The reload path shares it with openRedis so "no endpoint configured" is never
// confused with "configured endpoint failed to answer".
func redisConnectionConfigured(config *config.Redis) bool {
	return config != nil && (config.Addr != "" || len(config.ClusterAddrs) > 0)
}

func openRedis(config *config.Redis, enabled bool, appLogger *slog.Logger) redis.UniversalClient {
	if !enabled || !redisConnectionConfigured(config) {
		return nil
	}
	if appLogger == nil {
		appLogger = slog.Default()
	}
	var candidate redis.UniversalClient
	if config.UseCluster {
		addresses := config.ClusterAddrs
		if len(addresses) == 0 && config.Addr != "" {
			addresses = []string{config.Addr}
		}
		candidate = redis.NewClusterClient(&redis.ClusterOptions{Addrs: addresses, Password: config.Password})
	} else {
		options := &redis.Options{Addr: config.Addr, Network: config.Network, Password: config.Password, DB: int(config.DB)}
		if config.ReadTimeout > 0 {
			options.ReadTimeout = config.ReadTimeout
		}
		if config.WriteTimeout > 0 {
			options.WriteTimeout = config.WriteTimeout
		}
		candidate = redis.NewClient(options)
	}
	pingCtx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
	defer cancel()
	if err := candidate.Ping(pingCtx).Err(); err != nil {
		appLogger.Warn("redis unavailable, using in-memory cache", "mod", "redis", "error", err)
		_ = candidate.Close()
		return nil
	}
	return candidate
}

func openRedisList(configs []*config.Redis, enabled bool, appLogger *slog.Logger) map[string]redis.UniversalClient {
	if !enabled || len(configs) == 0 {
		return nil
	}
	clients := make(map[string]redis.UniversalClient)
	for _, item := range configs {
		if item == nil || item.Name == "" {
			continue
		}
		if client := openRedis(item, true, appLogger); client != nil {
			clients[item.Name] = client
		}
	}
	if len(clients) == 0 {
		return nil
	}
	return clients
}

func closeRedisList(clients map[string]redis.UniversalClient) {
	for _, client := range clients {
		if client != nil {
			closeRedisClient(client)
		}
	}
}

// replaceRedisList mirrors replaceDatabaseList: superseded clients stay open for
// the retire grace period so in-flight commands are not aborted.
func (d *Data) replaceRedisList(clients map[string]redis.UniversalClient) {
	if d == nil {
		return
	}
	d.redisListMu.Lock()
	old := d.redisList
	d.redisList = clients
	d.redisListMu.Unlock()
	for name, client := range old {
		if client == nil || client == clients[name] {
			continue
		}
		d.retiredRedisList.retire(client, closeRedisClient)
	}
}

// replacePrimaryDB swaps in a hot-reloaded pool, attaching the row-level data
// scope callbacks before the handle becomes readable.
func (d *Data) replacePrimaryDB(db *gorm.DB) {
	registerDataScopeCallbacks(db, d.enqueueDataScopeAudit)
	d.gormDB.replace(db)
}

func (d *Data) activateDatabase(db *gorm.DB, config *config.Database) {
	d.replacePrimaryDB(db)
	d.runtime.UpdateDatabase(config)
	d.databaseReady.Store(true)
}

// migrateAll is the single data-layer migration entry point. Every module
// must register its migrations through the application catalog; there is no
// hidden system/payment fallback that could silently omit a new module.
func migrateAll(db *gorm.DB, catalog module.Catalog) error {
	return migration.Run(db, catalog.MigrationSteps())
}

// loadIntegrationRuntime republishes the database-backed integration
// configuration into the live store the long-lived adapters read.
func (d *Data) loadIntegrationRuntime(db *gorm.DB) error {
	configs, err := dataintegration.ReadRuntime(db)
	if err != nil {
		return err
	}
	if d.integrations != nil {
		d.integrations.Replace(configs)
	}
	return nil
}
