package appconfig

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"time"

	"github.com/italypaleale/francis/actor"

	"github.com/pocket-id/pocket-id/backend/internal/common"
	"github.com/pocket-id/pocket-id/backend/internal/dto"
)

// The AppConfig singleton actor maintains the dynamic configuration for the Pocket ID cluster
// Instances of Pocket ID should bootstrap the AppConfig's actor upon startup to ensure the config is loaded (and migrated if needed)
// After startup, Peek can be used for read-only operations such as retrieving the config or listing it

// AppConfigActorType is the actor type for the AppConfig actor
const AppConfigActorType = "AppConfig"

// appConfigActor is a singleton actor that manages the dynamic app configuration
type appConfigActor struct {
	log    *slog.Logger
	client actor.Client[*AppConfigModel]
}

// appConfigActorBootstrap is the type for the payload of the init method
type appConfigActorBootstrap struct {
	LegacyConfig map[string]string
}

// NewAppConfigActor allocates a new AppConfig actor
// It satisfies actor.Factory
func NewAppConfigActor(actorID string, service *actor.Service) actor.Actor {
	log := slog.
		With(
			slog.String("scope", "actor"),
			slog.String("actorType", AppConfigActorType),
			slog.String("actorID", actorID),
		)

	log.Info("AppConfig actor created")

	return &appConfigActor{
		log:    log,
		client: actor.NewActorClient[*AppConfigModel](AppConfigActorType, actorID, service),
	}
}

// Bootstrap implements actor.ActorBootstrapper for the singleton actor
func (a *appConfigActor) Bootstrap(parentCtx context.Context, data actor.Envelope) error {
	// Load the actor state
	ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
	defer cancel()
	state, err := a.client.GetState(ctx)
	if err != nil {
		return fmt.Errorf("error retrieving actor state: %w", err)
	}

	// Upgrade existing state with defaults added by newer Pocket ID versions
	if state != nil {
		state = state.Clone()
		if !state.applyDefaults() {
			return nil
		}
	}

	// Check if a new state should be initialized from legacy configuration
	if state == nil && data != nil {
		payload := appConfigActorBootstrap{}
		err = data.Decode(&payload)
		if err != nil {
			return fmt.Errorf("request body is not valid for method 'init': %w", err)
		}

		if len(payload.LegacyConfig) > 0 {
			state, err = fromLegacyConfig(payload.LegacyConfig)
			if err != nil {
				return fmt.Errorf("request body is not valid for method 'init': LegacyConfig property could not be parsed: %w", err)
			}
		}
	}

	// Initialize a new state with defaults when no legacy configuration exists
	if state == nil {
		state = getDefaultConfig()
	}

	// Persist new and upgraded state eagerly so every later activation sees the complete model
	ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
	defer cancel()
	err = a.client.SetState(ctx, state, nil)
	if err != nil {
		return fmt.Errorf("error saving actor state: %w", err)
	}

	return nil
}

func (a *appConfigActor) Peek(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
	// Only supported method is "get"
	if method != "get" {
		return nil, common.ErrUnsupportedActorMethod{Method: method}
	}

	// Load the actor state
	ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
	defer cancel()
	state, err := a.client.GetState(ctx)
	if err != nil {
		return nil, fmt.Errorf("error retrieving actor state: %w", err)
	}

	// Return the state
	return state, nil
}

func (a *appConfigActor) Invoke(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
	// Check the method first
	switch method {
	case "get", "update", "replace":
		// All good
		// Note: we support "get" also via Invoke and not just Peek
	default:
		return nil, common.ErrUnsupportedActorMethod{Method: method}
	}

	// Load the actor state
	ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
	defer cancel()
	state, err := a.client.GetState(ctx)
	if err != nil {
		return nil, fmt.Errorf("error retrieving actor state: %w", err)
	}

	switch method {
	case "get":
		// If the method is "get", just return the actor state, we're done
		// This switch case is a no-op

	case "replace":
		// Replace the entire config
		// The input data must be a dto.AppConfigUpdateDto
		if data == nil {
			return nil, errors.New("request body is empty for method 'replace'")
		}
		payload := dto.AppConfigUpdateDto{}
		err = data.Decode(&payload)
		if err != nil {
			return nil, fmt.Errorf("request body is not valid for method 'replace': %w", err)
		}

		// Update the in-memory data
		// Work on a clone to avoid touching the cached object in case of errors
		newState := state.Clone()
		newState.Replace(payload)

		// Save the updated state, which also updates the cached object
		ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
		defer cancel()
		err = a.client.SetState(ctx, newState, nil)
		if err != nil {
			return nil, fmt.Errorf("error saving actor state: %w", err)
		}

		return newState, nil

	case "update":
		// Update the config
		// The input data must be a map[string]string
		if data == nil {
			return nil, errors.New("request body is empty for method 'update'")
		}
		payload := map[string]string{}
		err = data.Decode(&payload)
		if err != nil {
			return nil, fmt.Errorf("request body is not valid for method 'update': %w", err)
		}

		// Update the in-memory data
		// Work on a clone to avoid touching the cached object in case of errors
		newState := state.Clone()
		err = newState.Update(payload)
		if err != nil {
			return nil, fmt.Errorf("request body is not valid for method 'update': %w", err)
		}

		// Save the updated state, which also updates the cached object
		ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
		defer cancel()
		err = a.client.SetState(ctx, newState, nil)
		if err != nil {
			return nil, fmt.Errorf("error saving actor state: %w", err)
		}

		return newState, nil
	}

	// Return the state
	return state, nil
}
