package data

import (
	"context"
	"fmt"

	"kra/internal/config"
	dataintegration "kra/internal/data/integration"
	"kra/internal/integration/storage"

	"gorm.io/gorm"
)

// Writing config.yaml goes through config.Document, which owns the file format
// for both directions. This file only decides which sections to write.

// cloneAdminConfig copies an admin section, normalizing nil to an empty value so
// callers can edit the result without a nil check.
func cloneAdminConfig(value *config.Admin) *config.Admin {
	if value == nil {
		return &config.Admin{}
	}
	return config.CloneAdmin(value)
}

func (d *Data) persistConfig() error {
	dataConfig, adminConfig := d.runtime.Values()
	return d.persistConfigValues(dataConfig, adminConfig)
}

func (d *Data) persistConfigValues(dataConfig *config.Data, adminConfig *config.Admin) error {
	d.configMu.Lock()
	defer d.configMu.Unlock()
	if adminConfig == nil || adminConfig.ConfigPath == "" {
		return nil
	}
	document, err := config.OpenDocument(adminConfig.ConfigPath)
	if err != nil {
		return err
	}
	if err = document.Set("data", dataConfig); err != nil {
		return err
	}
	// Storage and email live in the database; the file must not shadow them.
	fileAdmin := cloneAdminConfig(adminConfig)
	fileAdmin.Storage = nil
	fileAdmin.Email = nil
	if err = document.Set("admin", fileAdmin); err != nil {
		return err
	}
	document.DeleteIntegrationConfig()
	if adminConfig.System != nil {
		if err = document.SetServerHTTPPort(adminConfig.System.Addr); err != nil {
			return err
		}
	}
	return document.Save()
}

// persistDatabaseConfig writes only the database selected on the init page and
// the freshly generated JWT signing key. Initialization must not serialize the
// partially populated runtime config back over the template: doing so removes
// all omitted/default settings from data and admin and leaves the next startup
// with no visible configuration.
func (d *Data) persistDatabaseConfig(database *config.Database, signingKey string) error {
	d.configMu.Lock()
	defer d.configMu.Unlock()

	configPath := d.runtime.ConfigPath()
	if configPath == "" {
		return nil
	}
	document, err := config.OpenDocument(configPath)
	if err != nil {
		return err
	}
	source, err := databaseDSN(database, "")
	if err != nil {
		return err
	}
	// Update the fields owned by the init page explicitly and leave advanced
	// database, Redis, Mongo, admin and server settings untouched.
	databaseValue := map[string]any{
		"driver":   database.Driver,
		"source":   source,
		"host":     database.Host,
		"port":     database.Port,
		"user":     database.User,
		"password": database.Password,
		"name":     database.Name,
		"config":   database.Config,
		"path":     database.Path,
	}
	if err = document.Set("data", map[string]any{"database": databaseValue}); err != nil {
		return err
	}
	if signingKey != "" {
		if err = document.Set("admin", map[string]any{"jwt": map[string]any{"signing_key": signingKey}}); err != nil {
			return err
		}
	}
	document.DeleteIntegrationConfig()
	return document.Save()
}

func (d *Data) removeIntegrationConfigFromFile() error {
	d.configMu.Lock()
	defer d.configMu.Unlock()
	configPath := d.runtime.ConfigPath()
	if configPath == "" {
		return nil
	}
	document, err := config.OpenDocument(configPath)
	if err != nil {
		return err
	}
	if !document.Has("admin", "storage") && !document.Has("admin", "email") {
		return nil
	}
	document.DeleteIntegrationConfig()
	return document.Save()
}

func (d *Data) reloadConfig(ctx context.Context) error {
	// Explicit reload is the lifecycle boundary that rebuilds database, cache,
	// Mongo, storage, and integration clients after a file snapshot change.
	d.configMu.Lock()
	defer d.configMu.Unlock()
	configPath := d.runtime.ConfigPath()
	if configPath == "" {
		return fmt.Errorf("configuration path is not set")
	}
	next, err := config.Load(configPath)
	if err != nil {
		return err
	}
	next = config.MergeRuntimeConfig(d.Config(), next)
	if next.Data == nil || next.Data.Database == nil || next.Admin == nil {
		return fmt.Errorf("data.database and admin configuration are required")
	}
	next.Admin.ConfigPath = configPath
	// Every client below is opened before anything is published, so a failure at
	// any step leaves the running process untouched. undo closes whatever this
	// call opened; commit at the end hands all of it over instead.
	var undo rollback
	defer undo.run()
	databaseReady := databaseConnectionConfigured(next.Data.Database)
	var candidateDB *gorm.DB
	if databaseReady {
		candidateDB, err = openDatabase(next.Data.Database, false, "", d.logger())
		if err != nil {
			return fmt.Errorf("reload database: %w", err)
		}
	} else {
		candidateDB, err = openFallbackDatabase(d.logger())
		if err != nil {
			return fmt.Errorf("reload bootstrap database: %w", err)
		}
	}
	undo.add(func() { closeGormDB(candidateDB) })
	if databaseReady {
		if sqlDB, dbErr := candidateDB.DB(); dbErr != nil {
			return dbErr
		} else if err = sqlDB.PingContext(ctx); err != nil {
			return fmt.Errorf("reload database: %w", err)
		}
	}
	if databaseReady && (next.Admin.System == nil || !next.Admin.System.DisableAutoMigrate) {
		if err = migrateAll(candidateDB.WithContext(ctx), d.catalog); err != nil {
			return fmt.Errorf("reload database migrations: %w", err)
		}
	}
	storageConfig, err := dataintegration.ResolveStorageConfig(candidateDB.WithContext(ctx), next.Admin.Storage)
	if err != nil {
		return fmt.Errorf("reload storage configuration: %w", err)
	}
	next.Admin.Storage = storageConfig
	emailConfig, err := dataintegration.ResolveEmailConfig(candidateDB.WithContext(ctx), next.Admin.Email)
	if err != nil {
		return fmt.Errorf("reload email configuration: %w", err)
	}
	next.Admin.Email = emailConfig
	candidateStorage, err := storage.New(next.Admin)
	if err != nil {
		return fmt.Errorf("reload storage: %w", err)
	}
	useRedis := next.Admin.System != nil && next.Admin.System.UseRedis
	candidateRedis := openRedis(next.Data.Redis, useRedis, d.logger())
	undo.add(func() {
		if candidateRedis != nil {
			closeRedisClient(candidateRedis)
		}
	})
	useRedisList := useRedis && next.Admin.System.UseMultipoint
	candidateRedisList := openRedisList(next.Data.RedisList, useRedisList, d.logger())
	undo.add(func() { closeRedisList(candidateRedisList) })
	useMongo := next.Admin.System != nil && next.Admin.System.UseMongo
	// openMongo always reports a nil client alongside its error, so an unreachable
	// Mongo only warns: it must not fail an otherwise valid reload.
	candidateMongo, mongoErr := openMongo(next.Data.Mongo, useMongo)
	if mongoErr != nil {
		d.logger().Error("mongo unavailable during configuration reload", "mod", "mongo", "error", mongoErr)
	}
	undo.add(func() {
		if candidateMongo != nil {
			closeMongoClient(candidateMongo)
		}
	})
	candidateDBList, err := openDatabaseList(next.Data.DatabaseList, d.logger())
	if err != nil {
		return err
	}
	undo.add(func() { closeDatabaseList(candidateDBList) })
	integrationConfigs, err := dataintegration.ReadRuntime(candidateDB.WithContext(ctx))
	if err != nil {
		return fmt.Errorf("reload integration runtime: %w", err)
	}

	d.replacePrimaryDB(candidateDB)
	d.databaseReady.Store(databaseReady)
	for _, item := range candidateDBList {
		registerDataScopeCallbacks(item, d.enqueueDataScopeAudit)
	}
	d.replaceDatabaseList(candidateDBList)
	// openRedis and openRedisList return nil when a ping fails, so replacing
	// unconditionally would let a transient Redis outage during an unrelated
	// configuration reload retire the still-healthy clients and silently downgrade
	// the process to the in-memory cache until the next reload.
	if candidateRedis != nil || !useRedis || !redisConnectionConfigured(next.Data.Redis) {
		d.redis.replace(candidateRedis)
	} else {
		d.logger().Warn("keeping the previous redis client because the reloaded configuration failed to connect", "mod", "redis")
	}
	if candidateRedisList != nil || !useRedisList || len(next.Data.RedisList) == 0 {
		d.replaceRedisList(candidateRedisList)
	} else {
		d.logger().Warn("keeping the previous redis list because the reloaded configuration failed to connect", "mod", "redis")
	}
	// A nil client here means Mongo is switched off in the reloaded configuration,
	// which must still retire the running client; only an outright failure keeps it.
	if mongoErr == nil {
		d.mongo.replace(candidateMongo)
	}
	undo.commit()
	d.runtime.Replace(next)
	if d.integrations != nil {
		d.integrations.Replace(integrationConfigs)
	}
	if d.storage != nil {
		d.storage.Replace(candidateStorage)
	}
	return nil
}
