/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package controllers

import (
	"context"
	"fmt"
	"k8s.io/apimachinery/pkg/fields"
	"reflect"
	"sigs.k8s.io/controller-runtime/pkg/builder"
	"sigs.k8s.io/controller-runtime/pkg/handler"
	"sigs.k8s.io/controller-runtime/pkg/predicate"
	"time"

	"github.com/apache/solr-operator/controllers/util"
	"github.com/go-logr/logr"
	"k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/types"
	"k8s.io/client-go/rest"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/log"
	"sigs.k8s.io/controller-runtime/pkg/reconcile"

	solrv1beta1 "github.com/apache/solr-operator/api/v1beta1"
)

// SolrBackupReconciler reconciles a SolrBackup object
type SolrBackupReconciler struct {
	client.Client
	Scheme *runtime.Scheme
	Config *rest.Config
}

//+kubebuilder:rbac:groups="",resources=pods/exec,verbs=create
//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list
//+kubebuilder:rbac:groups=solr.apache.org,resources=solrclouds,verbs=get;list;watch
//+kubebuilder:rbac:groups=solr.apache.org,resources=solrclouds/status,verbs=get
//+kubebuilder:rbac:groups=solr.apache.org,resources=solrbackups,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=solr.apache.org,resources=solrbackups/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=solr.apache.org,resources=solrbackups/finalizers,verbs=update

// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
//
// For more details, check Reconcile and its Result here:
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.8.3/pkg/reconcile
func (r *SolrBackupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	logger := log.FromContext(ctx)

	// Fetch the SolrBackup instance
	backup := &solrv1beta1.SolrBackup{}
	err := r.Get(ctx, req.NamespacedName, backup)
	if err != nil {
		if errors.IsNotFound(err) {
			// Object not found, return.  Created objects are automatically garbage collected.
			// For additional cleanup logic use finalizers.
			return reconcile.Result{}, nil
		}
		// Error reading the object - requeue the req.
		return reconcile.Result{}, err
	}

	changed := backup.WithDefaults()
	if changed {
		logger.Info("Setting default settings for solr-backup")
		if err = r.Update(ctx, backup); err != nil {
			return reconcile.Result{}, err
		}
		return reconcile.Result{Requeue: true}, nil
	}

	unmodifiedBackupResource := backup.DeepCopy()

	requeueOrNot := reconcile.Result{}

	// Backup work needs to be done by default if the current backup is not finished
	doBackupWork := !backup.Status.IndividualSolrBackupStatus.Finished

	// Check if we should start the next backup
	// Do not check if already doing a backup
	if !doBackupWork && backup.Status.NextScheduledTime != nil {
		if !backup.Spec.Recurrence.IsEnabled() {
			backup.Status.NextScheduledTime = nil
			doBackupWork = false
		} else if backup.Status.NextScheduledTime.UTC().Before(time.Now().UTC()) {
			// We have hit the next scheduled restart time.
			doBackupWork = true
			backup.Status.NextScheduledTime = nil

			// Add the current backup to the front of the history.
			// If there is no max
			backup.Status.History = append([]solrv1beta1.IndividualSolrBackupStatus{backup.Status.IndividualSolrBackupStatus}, backup.Status.History...)

			// Remove history if we have too much saved
			if len(backup.Status.History) > backup.Spec.Recurrence.MaxSaved {
				backup.Status.History = backup.Status.History[:backup.Spec.Recurrence.MaxSaved]
			}

			// Reset Current, which is fine since it is now in the history.
			backup.Status.IndividualSolrBackupStatus = solrv1beta1.IndividualSolrBackupStatus{}
		} else {
			// If we have not hit the next scheduled restart, wait to requeue until that is true.
			updateRequeueAfter(&requeueOrNot, backup.Status.NextScheduledTime.UTC().Sub(time.Now().UTC()))
			doBackupWork = false
		}
	}

	// Do backup work if a backup is in-progress or needs to be started
	if doBackupWork {
		solrCloud, _, err1 := r.reconcileSolrCloudBackup(ctx, backup, &backup.Status.IndividualSolrBackupStatus, logger)
		if err1 != nil {
			// TODO Should we be failing the backup for some sub-set of errors here?
			logger.Error(err1, "Error while taking SolrCloud backup")

			// Requeue after 10 seconds for errors.
			updateRequeueAfter(&requeueOrNot, time.Second*10)
		} else if backup.Status.IndividualSolrBackupStatus.Finished {
			// Set finish time
			now := metav1.Now()
			backup.Status.IndividualSolrBackupStatus.FinishTime = &now
		} else if solrCloud != nil {
			// When working with the collection backups, auto-requeue after 5 seconds
			// to check on the status of the async solr backup calls
			updateRequeueAfter(&requeueOrNot, time.Second*5)
		}
	}

	// Schedule the next backupTime, if it doesn't have a next scheduled time, it has recurrence and the current backup is finished
	if backup.Status.IndividualSolrBackupStatus.Finished && backup.Spec.Recurrence.IsEnabled() {
		if nextBackupTime, err1 := util.ScheduleNextBackup(backup.Spec.Recurrence.Schedule, backup.Status.IndividualSolrBackupStatus.StartTime.Time); err1 != nil {
			logger.Error(err1, "Could not update backup scheduling due to bad cron schedule", "cron", backup.Spec.Recurrence.Schedule)
		} else {
			convTime := metav1.NewTime(nextBackupTime)
			if backup.Status.NextScheduledTime == nil || convTime != *backup.Status.NextScheduledTime {
				// Only log out the message if there is a change in NextScheduled
				logger.Info("(Re)scheduling Next Backup", "time", nextBackupTime)
				backup.Status.NextScheduledTime = &convTime
				updateRequeueAfter(&requeueOrNot, backup.Status.NextScheduledTime.Sub(time.Now()))
			}
		}
	}

	if !reflect.DeepEqual(unmodifiedBackupResource.Status, backup.Status) {
		logger.Info("Updating status for solr-backup", "newStatus", backup.Status, "oldStatus", unmodifiedBackupResource.Status)
		err = r.Status().Patch(ctx, backup, client.MergeFrom(unmodifiedBackupResource))
	}

	return requeueOrNot, err
}

func (r *SolrBackupReconciler) reconcileSolrCloudBackup(ctx context.Context, backup *solrv1beta1.SolrBackup, currentBackupStatus *solrv1beta1.IndividualSolrBackupStatus, logger logr.Logger) (solrCloud *solrv1beta1.SolrCloud, actionTaken bool, err error) {
	// Get the solrCloud that this backup is for.
	solrCloud = &solrv1beta1.SolrCloud{}

	err = r.Get(ctx, types.NamespacedName{Namespace: backup.Namespace, Name: backup.Spec.SolrCloud}, solrCloud)
	if err != nil && errors.IsNotFound(err) {
		logger.Error(err, "Could not find cloud to backup", "solrCloud", backup.Spec.SolrCloud)
		return nil, actionTaken, err
	} else if err != nil {
		return nil, actionTaken, err
	}

	// Add any additional values needed to Authn to Solr to the Context used when invoking the API
	if solrCloud.Spec.SolrSecurity != nil {
		ctx, err = util.AddAuthToContext(ctx, &r.Client, solrCloud)
		if err != nil {
			return nil, actionTaken, err
		}
	}

	// First check if the collection backups have been completed
	collectionBackupsFinished := util.UpdateStatusOfCollectionBackups(currentBackupStatus)

	// If the collectionBackups are complete, then nothing else has to be done here
	if collectionBackupsFinished {
		return solrCloud, actionTaken, nil
	}

	actionTaken = true
	backupRepository := util.GetBackupRepositoryByName(solrCloud.Spec.BackupRepositories, backup.Spec.RepositoryName)
	if backupRepository == nil {
		err = fmt.Errorf("Unable to find backup repository to use for backup [%s] (which specified the repository"+
			" [%s]).  solrcloud must define a repository matching that name (or have only 1 repository defined).",
			backup.Name, backup.Spec.RepositoryName)
		return solrCloud, actionTaken, err
	}

	// This should only occur before the backup processes have been started
	if currentBackupStatus.StartTime.IsZero() {
		// Prep the backup directory in the persistentVolume
		err = util.EnsureDirectoryForBackup(solrCloud, backupRepository, backup, r.Config)
		if err != nil {
			return solrCloud, actionTaken, err
		}

		// Make sure that all solr living Solr pods have the backupRepo configured
		if !solrCloud.Status.BackupRepositoriesAvailable[backupRepository.Name] {
			logger.Info("Cloud not ready for backup", "solrCloud", solrCloud.Name, "repository", backupRepository.Name)
			return solrCloud, actionTaken, errors.NewServiceUnavailable(fmt.Sprintf("Cloud is not ready for backups in the %s repository", backupRepository.Name))
		}

		// Only set the solr version at the start of the backup. This shouldn't change throughout the backup.
		currentBackupStatus.SolrVersion = solrCloud.Status.Version
		currentBackupStatus.StartTime = metav1.Now()
	}

	collectionsToBackup := backup.Spec.Collections

	// If there are no collections specified, we need to list through collections available in Solr
	if len(collectionsToBackup) == 0 {
		collectionsToBackup, err = util.ListAllSolrCollections(ctx, solrCloud, logger)
		if err != nil {
			logger.Error(err, "Error listing collections", "solrCloud", solrCloud.Name)
		}
	}

	// Go through each collection specified and reconcile the backup.
	for _, collection := range collectionsToBackup {
		// This will in-place update the CollectionBackupStatus in the backup object
		if _, err = reconcileSolrCollectionBackup(ctx, backup, currentBackupStatus, solrCloud, backupRepository, collection, logger); err != nil {
			break
		}
	}

	// First check if the collection backups have been completed
	util.UpdateStatusOfCollectionBackups(currentBackupStatus)

	return solrCloud, actionTaken, err
}

func reconcileSolrCollectionBackup(ctx context.Context, backup *solrv1beta1.SolrBackup, currentBackupStatus *solrv1beta1.IndividualSolrBackupStatus, solrCloud *solrv1beta1.SolrCloud, backupRepository *solrv1beta1.SolrBackupRepository, collection string, logger logr.Logger) (finished bool, err error) {
	now := metav1.Now()
	collectionBackupStatus := solrv1beta1.CollectionBackupStatus{}
	collectionBackupStatus.Collection = collection
	backupIndex := -1
	// Get the backup status for this collection, if one exists
	for i, status := range currentBackupStatus.CollectionBackupStatuses {
		if status.Collection == collection {
			collectionBackupStatus = status
			backupIndex = i
		}
	}

	// If the collection backup hasn't started, start it
	if collectionBackupStatus.Finished {
		return true, nil
	} else if !collectionBackupStatus.InProgress {
		// Start the backup by calling solr
		var started bool
		started, err = util.StartBackupForCollection(ctx, solrCloud, backupRepository, backup, collection, logger)
		if err != nil {
			return true, err
		}
		collectionBackupStatus.InProgress = started
		if started && collectionBackupStatus.StartTime == nil {
			collectionBackupStatus.StartTime = &now
		}
		collectionBackupStatus.BackupName = util.FullCollectionBackupName(collection, backup.Name)
	} else if collectionBackupStatus.InProgress {
		var successful bool
		var asyncStatus string
		// Check the state of the backup, when it is in progress, and update the state accordingly
		finished, successful, asyncStatus, err = util.CheckBackupForCollection(ctx, solrCloud, collection, backup.Name, logger)
		if err != nil {
			return false, err
		}
		collectionBackupStatus.Finished = finished
		if finished {
			collectionBackupStatus.InProgress = false
			if collectionBackupStatus.Successful == nil {
				collectionBackupStatus.Successful = &successful
			}
			collectionBackupStatus.AsyncBackupStatus = ""
			if collectionBackupStatus.FinishTime == nil {
				collectionBackupStatus.FinishTime = &now
			}

			err = util.DeleteAsyncInfoForBackup(ctx, solrCloud, collection, backup.Name, logger)
		} else {
			collectionBackupStatus.AsyncBackupStatus = asyncStatus
		}
	}

	if backupIndex < 0 {
		currentBackupStatus.CollectionBackupStatuses = append(currentBackupStatus.CollectionBackupStatuses, collectionBackupStatus)
	} else {
		currentBackupStatus.CollectionBackupStatuses[backupIndex] = collectionBackupStatus
	}

	return collectionBackupStatus.Finished, err
}

// SetupWithManager sets up the controller with the Manager.
func (r *SolrBackupReconciler) SetupWithManager(mgr ctrl.Manager) (err error) {
	r.Config = mgr.GetConfig()

	ctrlBuilder := ctrl.NewControllerManagedBy(mgr).
		For(&solrv1beta1.SolrBackup{})

	ctrlBuilder, err = r.indexAndWatchForSolrClouds(mgr, ctrlBuilder)
	if err != nil {
		return err
	}

	return ctrlBuilder.Complete(r)
}

func (r *SolrBackupReconciler) indexAndWatchForSolrClouds(mgr ctrl.Manager, ctrlBuilder *builder.Builder) (*builder.Builder, error) {
	solrCloudField := ".spec.solrCloud"

	if err := mgr.GetFieldIndexer().IndexField(context.Background(), &solrv1beta1.SolrBackup{}, solrCloudField, func(rawObj client.Object) []string {
		// grab the SolrBackup object, extract the used SolrCloud...
		return []string{rawObj.(*solrv1beta1.SolrBackup).Spec.SolrCloud}
	}); err != nil {
		return ctrlBuilder, err
	}

	return ctrlBuilder.Watches(
		&solrv1beta1.SolrCloud{},
		handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request {
			solrCloud := obj.(*solrv1beta1.SolrCloud)
			foundBackups := &solrv1beta1.SolrBackupList{}
			listOps := &client.ListOptions{
				FieldSelector: fields.OneTermEqualSelector(solrCloudField, obj.GetName()),
				Namespace:     obj.GetNamespace(),
			}
			err := r.List(ctx, foundBackups, listOps)
			if err != nil {
				// if no exporters found, just no-op this
				return []reconcile.Request{}
			}

			requests := make([]reconcile.Request, 0)
			for _, item := range foundBackups.Items {
				// Only queue the request if the Cloud is ready.
				cloudIsReady := solrCloud.Status.BackupRestoreReady
				if item.Spec.RepositoryName != "" {
					cloudIsReady = solrCloud.Status.BackupRepositoriesAvailable[item.Spec.RepositoryName]
				}
				if cloudIsReady {
					requests = append(requests, reconcile.Request{
						NamespacedName: types.NamespacedName{
							Name:      item.GetName(),
							Namespace: item.GetNamespace(),
						},
					})
				}
			}
			return requests
		}),
		builder.WithPredicates(predicate.GenerationChangedPredicate{})), nil
}
