package ldapsync

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

	"github.com/italypaleale/francis/actor"

	"github.com/pocket-id/pocket-id/backend/internal/appconfig"
)

// The LdapSync singleton actor decides when the recurring LDAP synchronization runs.

// SyncActorType is the actor type for the LDAP sync actor
const SyncActorType = "LdapSync"

const (
	// alarmSync is the name of the repeating alarm that runs the synchronization
	alarmSync = "sync"

	// syncInterval is how often the synchronization runs, as the ISO8601 duration the alarm repetition expects
	// There's no jitter: the alarm is cluster-wide, so there are no replicas to spread apart
	syncInterval = "PT1H"

	// Delay the initial sync by 5s
	initialSyncDelay = 5 * time.Second

	// alarmTimeout bounds the alarm operations performed by the actor
	alarmTimeout = 10 * time.Second
)

// syncActor is the cluster-wide singleton that triggers the recurring LDAP synchronization
type syncActor struct {
	log       *slog.Logger
	service   *Service
	appConfig appconfig.AppConfigResolver
	// scheduleDisabled removes the alarm instead of arming it, for environments that drive syncs explicitly
	scheduleDisabled bool
	client           actor.Client[struct{}]
}

// NewSyncActor returns the factory that allocates the LDAP sync actor
func NewSyncActor(service *Service, appConfig appconfig.AppConfigResolver, scheduleDisabled bool) actor.Factory {
	return func(actorID string, actorService *actor.Service) actor.Actor {
		return &syncActor{
			log: slog.With(
				slog.String("scope", "actor"),
				slog.String("actorType", SyncActorType),
			),
			service:          service,
			appConfig:        appConfig,
			scheduleDisabled: scheduleDisabled,
			// The actor keeps no state of its own: the client is only used to manage the alarm
			client: actor.NewActorClient[struct{}](SyncActorType, actorID, actorService),
		}
	}
}

// Bootstrap implements actor.ActorBootstrapper
// The host drives it on every startup, routed to the single owning host, so it must stay idempotent
func (a *syncActor) Bootstrap(parentCtx context.Context, _ actor.Envelope) error {
	ctx, cancel := context.WithTimeout(parentCtx, alarmTimeout)
	defer cancel()

	// The schedule may have been enabled in a previous run, so make sure a leftover alarm doesn't keep firing
	if a.scheduleDisabled {
		err := a.client.DeleteAlarm(ctx, alarmSync)
		if err != nil && !errors.Is(err, actor.ErrAlarmNotFound) {
			return fmt.Errorf("error deleting the LDAP sync alarm: %w", err)
		}

		return nil
	}

	// Setting the alarm replaces whatever is registered, which both restores an alarm that was lost and picks up a change to the interval
	// It's due right away (with a small delay) so the directory is synchronized as soon as the cluster starts, matching what the pre-actor scheduled job did
	err := a.client.SetAlarm(ctx, alarmSync, actor.AlarmProperties{
		DueTime:  time.Now().Add(initialSyncDelay),
		Interval: syncInterval,
	})
	if err != nil {
		return fmt.Errorf("error setting the LDAP sync alarm: %w", err)
	}

	a.log.DebugContext(parentCtx, "Registered the recurring LDAP sync alarm", slog.String("interval", syncInterval))

	return nil
}

// Alarm implements actor.ActorAlarm
func (a *syncActor) Alarm(ctx context.Context, name string, _ actor.Envelope) error {
	if name != alarmSync {
		return fmt.Errorf("unsupported alarm '%s' for the %s actor", name, SyncActorType)
	}

	a.sync(ctx)

	// A failed sync never surfaces as an error: the framework would retry the occurrence and then delete the alarm once the attempts run out, which would stop the synchronization altogether
	// The next occurrence comes around on its own, exactly like the pre-actor scheduled job
	return nil
}

// sync runs one synchronization, unless LDAP is disabled
// It logs failures rather than returning them, since the alarm has nowhere useful to send the error
func (a *syncActor) sync(ctx context.Context) {
	dbConfig, err := a.appConfig.GetConfig(ctx)
	if err != nil {
		a.log.ErrorContext(ctx, "Failed to load the app configuration, skipping the LDAP sync", slog.Any("error", err))
		return
	}

	if !dbConfig.LdapEnabled.IsTrue() {
		a.log.DebugContext(ctx, "LDAP is disabled, skipping the sync")
		return
	}

	a.log.InfoContext(ctx, "Starting the LDAP sync")
	start := time.Now()

	err = a.service.SyncAll(ctx, dbConfig)
	if err != nil {
		a.log.ErrorContext(ctx, "LDAP sync failed, will try again on the next run", slog.Duration("duration", time.Since(start)), slog.Any("error", err))
		return
	}

	a.log.InfoContext(ctx, "LDAP sync completed", slog.Duration("duration", time.Since(start)))
}
