package dashverimpl

import (
	"context"
	"errors"
	"fmt"
	"reflect"
	"strconv"
	"strings"
	"time"

	"go.opentelemetry.io/otel"
	"golang.org/x/sync/errgroup"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"

	"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
	"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
	"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
	"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1"
	"github.com/grafana/grafana/pkg/apimachinery/identity"
	"github.com/grafana/grafana/pkg/apimachinery/utils"
	"github.com/grafana/grafana/pkg/components/simplejson"
	"github.com/grafana/grafana/pkg/infra/db"
	"github.com/grafana/grafana/pkg/infra/log"
	"github.com/grafana/grafana/pkg/infra/tracing"
	"github.com/grafana/grafana/pkg/services/dashboards"
	dashboardclient "github.com/grafana/grafana/pkg/services/dashboards/service/client"
	dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
	"github.com/grafana/grafana/pkg/services/featuremgmt"
	"github.com/grafana/grafana/pkg/services/user"
	"github.com/grafana/grafana/pkg/setting"
)

var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/dashboardversion/dashverimpl")

const (
	maxVersionsToDeletePerBatch = 100
	maxVersionDeletionBatches   = 50
)

type Service struct {
	cfg       *setting.Cfg
	store     store
	dashSvc   dashboards.DashboardService
	k8sclient dashboardclient.K8sHandlerWithFallback
	features  featuremgmt.FeatureToggles
	log       log.Logger
}

func ProvideService(
	cfg *setting.Cfg,
	db db.DB,
	dashboardService dashboards.DashboardService,
	features featuremgmt.FeatureToggles,
	clientWithFallback dashboardclient.K8sHandlerWithFallback,
) dashver.Service {
	return &Service{
		cfg: cfg,
		store: &sqlStore{
			db:      db,
			dialect: db.GetDialect(),
		},
		features:  features,
		k8sclient: clientWithFallback,
		dashSvc:   dashboardService,
		log:       log.New("dashboard-version"),
	}
}

func (s *Service) Get(ctx context.Context, query *dashver.GetDashboardVersionQuery) (*dashver.DashboardVersionDTO, error) {
	if query.DashboardUID == "" {
		u, err := s.getDashUIDMaybeEmpty(ctx, query.DashboardID)
		if err != nil {
			return nil, err
		}
		query.DashboardUID = u
	}

	versionObj, err := s.getDashboardVersionThroughK8s(ctx, query.OrgID, query.DashboardUID, query.Version)
	if err != nil {
		return nil, err
	}

	return s.transformUnstructuredToLegacyDTO(ctx, versionObj)
}

func (s *Service) DeleteExpired(ctx context.Context, cmd *dashver.DeleteExpiredVersionsCommand) error {
	versionsToKeep := s.cfg.DashboardVersionsToKeep
	if versionsToKeep < 1 {
		versionsToKeep = 1
	}

	for batch := 0; batch < maxVersionDeletionBatches; batch++ {
		versionIdsToDelete, batchErr := s.store.GetBatch(ctx, cmd, maxVersionsToDeletePerBatch, versionsToKeep)
		if batchErr != nil {
			return batchErr
		}

		if len(versionIdsToDelete) < 1 {
			return nil
		}

		deleted, err := s.store.DeleteBatch(ctx, cmd, versionIdsToDelete)
		if err != nil {
			return err
		}

		cmd.DeletedRows += deleted

		if deleted < int64(maxVersionsToDeletePerBatch) {
			break
		}
	}

	return nil
}

// List all dashboard versions for the given dashboard ID.
func (s *Service) List(
	ctx context.Context, query *dashver.ListDashboardVersionsQuery,
) (*dashver.DashboardVersionResponse, error) {
	if query.DashboardUID == "" {
		u, err := s.getDashUIDMaybeEmpty(ctx, query.DashboardID)
		if err != nil {
			return nil, err
		}
		query.DashboardUID = u
	}

	if query.Limit <= 0 {
		query.Limit = 1000
	}

	list, err := s.listDashboardVersionsThroughK8s(
		ctx,
		query.OrgID,
		query.DashboardUID,
		int64(query.Limit),
		query.ContinueToken,
	)
	if err != nil {
		return nil, err
	}

	dashboards, err := s.transformUnstructuredToLegacyDTOList(ctx, list.Items)
	if err != nil {
		return nil, err
	}

	return &dashver.DashboardVersionResponse{
		ContinueToken: list.GetContinue(),
		Versions:      dashboards,
	}, nil
}

// RestoreVersion restores a dashboard version.
func (s *Service) RestoreVersion(ctx context.Context, cmd *dashver.RestoreVersionCommand) (*dashboards.Dashboard, error) {
	ctx, span := tracer.Start(ctx, "Service.RestoreVersion")
	defer span.End()

	// Get dashboard UID if not provided
	if cmd.DashboardUID == "" {
		u, err := s.getDashUIDMaybeEmpty(ctx, cmd.DashboardID)
		if err != nil {
			s.log.Debug("error getting dashboard UID", "error", err)
			return nil, tracing.Error(span, err)
		}
		cmd.DashboardUID = u
	}
	//nolint:staticcheck // not yet migrated to OpenFeature
	if s.features.IsEnabled(ctx, featuremgmt.FlagKubernetesDashboards) ||
		s.features.IsEnabled(ctx, featuremgmt.FlagDashboardNewLayouts) {
		s.log.Debug("restoring dashboard version through k8s")
		res, err := s.restoreVersionThroughK8s(ctx, cmd)
		if err != nil {
			s.log.Debug("error restoring dashboard version through k8s", "error", err)
			return nil, tracing.Error(span, err)
		}

		return res, nil
	}

	s.log.Debug("restoring dashboard version through legacy")
	res, err := s.restoreVersionLegacy(ctx, cmd)
	if err != nil {
		s.log.Debug("error restoring dashboard version through legacy", "error", err)
		return nil, tracing.Error(span, err)
	}

	return res, nil
}

func (s *Service) getDashboardVersionThroughK8s(
	ctx context.Context, orgID int64, dashboardUID string, version int64,
) (*unstructured.Unstructured, error) {
	// this is an unideal implementation - we have to list all versions and filter here,
	// since there currently is no way to query for the
	// generation id in unified storage, so we cannot query for the dashboard version directly,
	// and we cannot use search as history is not indexed.
	// use batches to make sure we don't load too much data at once.
	const batchSize = 50
	labelSelector := utils.LabelKeyGetHistory + "=true"
	fieldSelector := "metadata.name=" + dashboardUID
	var continueToken string
	for {
		out, err := s.k8sclient.List(ctx, orgID, v1.ListOptions{
			LabelSelector: labelSelector,
			FieldSelector: fieldSelector,
			Limit:         int64(batchSize),
			Continue:      continueToken,
		})
		if err != nil {
			if apierrors.IsNotFound(err) {
				return nil, dashboards.ErrDashboardNotFound
			}
			return nil, err
		}
		if out == nil {
			return nil, dashboards.ErrDashboardNotFound
		}

		for _, item := range out.Items {
			if item.GetGeneration() == version {
				return &item, nil
			}
		}

		continueToken = out.GetContinue()
		if continueToken == "" || len(out.Items) == 0 {
			break
		}
	}

	return nil, dashboards.ErrDashboardNotFound
}

func (s *Service) listDashboardVersionsThroughK8s(
	ctx context.Context, orgID int64, dashboardUID string, limit int64, continueToken string,
) (*unstructured.UnstructuredList, error) {
	labelSelector := utils.LabelKeyGetHistory + "=true"
	fieldSelector := "metadata.name=" + dashboardUID
	out, err := s.k8sclient.List(ctx, orgID, v1.ListOptions{
		LabelSelector: labelSelector,
		FieldSelector: fieldSelector,
		Limit:         limit,
		Continue:      continueToken,
	})
	if err != nil {
		if apierrors.IsNotFound(err) {
			return nil, dashboards.ErrDashboardNotFound
		}

		return nil, err
	}
	if out == nil {
		return nil, dashboards.ErrDashboardNotFound
	}

	// if k8s returns a continue token, we need to fetch the next page(s) until we either reach the limit or there are no more pages
	continueToken = out.GetContinue()
	for (len(out.Items) < int(limit)) && (continueToken != "") {
		tempOut, err := s.k8sclient.List(ctx, orgID, v1.ListOptions{
			LabelSelector: labelSelector,
			FieldSelector: fieldSelector,
			Continue:      continueToken,
			Limit:         limit - int64(len(out.Items)),
		})
		if err != nil {
			return nil, err
		}
		out.Items = append(out.Items, tempOut.Items...)
		continueToken = tempOut.GetContinue()
	}

	return out, nil
}

func (s *Service) restoreVersionThroughK8s(
	ctx context.Context, cmd *dashver.RestoreVersionCommand,
) (*dashboards.Dashboard, error) {
	ctx, span := tracer.Start(ctx, "Service.restoreVersionThroughK8s")
	defer span.End()

	// We must use separate gctx context here, because it will be canceled, once the group is done.
	// If we use the same ctx after the call to g.Wait, it will already be canceled at that point,
	// causing all subsequent context-using operations to immediately return with "context canceled" error.
	g, gctx := errgroup.WithContext(ctx)

	var current *unstructured.Unstructured
	g.Go(func() error {
		var err error
		current, err = s.k8sclient.Get(gctx, cmd.DashboardUID, cmd.Requester.GetOrgID(), v1.GetOptions{})
		if err != nil {
			s.log.Debug("error getting current dashboard", "error", err)
		}
		return err
	})

	var version *unstructured.Unstructured
	g.Go(func() error {
		var err error
		version, err = s.getDashboardVersionThroughK8s(gctx, cmd.Requester.GetOrgID(), cmd.DashboardUID, cmd.Version)
		if err != nil {
			s.log.Debug("error getting version", "error", err)
		}
		return err
	})

	if err := g.Wait(); err != nil {
		return nil, tracing.Error(span, err)
	}

	// Compare dashboard data using the new version-aware comparator
	identical, err := compareUnstructuredDashboards(version, current)
	if err != nil {
		s.log.Debug("error comparing dashboard versions", "error", err)
		return nil, tracing.Error(span, err)
	}
	if identical {
		return nil, dashboards.ErrDashboardRestoreIdenticalVersion
	}

	versionMeta, err := utils.MetaAccessor(version)
	if err != nil {
		s.log.Debug("error getting old version meta accessor", "error", err)
		return nil, tracing.Error(span, err)
	}
	spec, err := versionMeta.GetSpec()
	if err != nil {
		s.log.Debug("error getting old version spec", "error", err)
		return nil, tracing.Error(span, err)
	}

	currentMeta, err := utils.MetaAccessor(current)
	if err != nil {
		s.log.Debug("error getting current meta accessor", "error", err)
		return nil, tracing.Error(span, err)
	}
	currentMeta.SetMessage(dashboardRestoreMessage(int(versionMeta.GetGeneration())))
	if err := currentMeta.SetSpec(spec); err != nil {
		s.log.Debug("error setting current version spec", "error", err)
		return nil, tracing.Error(span, err)
	}

	updatedObj, err := s.k8sclient.Update(ctx, current, cmd.Requester.GetOrgID(), v1.UpdateOptions{})
	if err != nil {
		s.log.Debug("error updating dashboard to specified version", "error", err)
		return nil, tracing.Error(span, err)
	}

	res, err := s.dashSvc.UnstructuredToLegacyDashboard(ctx, updatedObj, cmd.Requester.GetOrgID())
	if err != nil {
		s.log.Debug("error converting dashboard to legacy dashboard", "error", err)
		return nil, tracing.Error(span, err)
	}

	return res, nil
}

func (s *Service) restoreVersionLegacy(
	ctx context.Context, cmd *dashver.RestoreVersionCommand,
) (*dashboards.Dashboard, error) {
	ctx, span := tracer.Start(ctx, "Service.restoreVersionLegacy")
	defer span.End()

	// We must use separate gctx context here, because it will be canceled, once the group is done.
	// If we use the same ctx after the call to g.Wait, it will already be canceled at that point,
	// causing all subsequent context-using operations to immediately return with "context canceled" error.
	g, gctx := errgroup.WithContext(ctx)

	var currentDash *dashboards.Dashboard
	g.Go(func() error {
		var err error
		currentDash, err = s.dashSvc.GetDashboard(gctx, &dashboards.GetDashboardQuery{
			UID:   cmd.DashboardUID,
			OrgID: cmd.Requester.GetOrgID(),
		})
		if err != nil {
			s.log.Debug("error getting dashboard", "error", err)
		}
		return err
	})

	var versionData *dashver.DashboardVersionDTO
	g.Go(func() error {
		versionObj, err := s.getDashboardVersionThroughK8s(gctx, cmd.Requester.GetOrgID(), cmd.DashboardUID, cmd.Version)
		if err != nil {
			s.log.Debug("error getting dashboard version", "error", err)
			return err
		}

		versionData, err = s.transformUnstructuredToLegacyDTO(gctx, versionObj)
		if err != nil {
			s.log.Debug("error transforming dashboard version to DTO", "error", err)
			return err
		}

		return nil
	})

	if err := g.Wait(); err != nil {
		return nil, tracing.Error(span, err)
	}

	if compareDashboardData(versionData.Data.MustMap(), currentDash.Data.MustMap(), true) {
		return nil, dashboards.ErrDashboardRestoreIdenticalVersion
	}

	userID, err := identity.UserIdentifier(cmd.Requester.GetID())
	if err != nil {
		s.log.Debug("error getting user identifier", "error", err)
		return nil, tracing.Error(span, err)
	}

	// This logic has been copied from the API handler unmodified for the most part.
	// There is some strange back-and-forth conversions between the two commands,
	// that should ideally be cleaned up.
	saveCmd := dashboards.SaveDashboardCommand{
		RestoredFrom: versionData.Version,
		OrgID:        cmd.Requester.GetOrgID(),
		UserID:       userID,
		Dashboard:    versionData.Data,
		FolderUID:    currentDash.FolderUID,
	}
	saveCmd.Dashboard.Set("version", currentDash.Version)
	saveCmd.Dashboard.Set("uid", currentDash.UID)
	dash := saveCmd.GetDashboardModel()
	dashItem := &dashboards.SaveDashboardDTO{
		User:      cmd.Requester,
		OrgID:     cmd.Requester.GetOrgID(),
		UpdatedAt: time.Now(),
		Message:   dashboardRestoreMessage(versionData.Version),
		Overwrite: false,
		Dashboard: dash,
	}

	res, err := s.dashSvc.SaveDashboard(ctx, dashItem, true)
	if err != nil {
		s.log.Debug("error saving dashboard", "error", err)
		return nil, tracing.Error(span, err)
	}

	return res, nil
}

func (s *Service) transformUnstructuredToLegacyDTO(
	ctx context.Context, item *unstructured.Unstructured,
) (*dashver.DashboardVersionDTO, error) {
	obj, err := utils.MetaAccessor(item)
	if err != nil {
		return nil, err
	}

	users, err := s.k8sclient.GetUsersFromMeta(ctx, []string{obj.GetCreatedBy(), obj.GetUpdatedBy()})
	if err != nil {
		return nil, err
	}

	return unstructuredToLegacyDashboardVersionWithUsers(item, users)
}

func (s *Service) transformUnstructuredToLegacyDTOList(
	ctx context.Context, items []unstructured.Unstructured,
) ([]*dashver.DashboardVersionDTO, error) {
	// get users ahead of time to do just one db call, rather than 2 per item in the list
	userMeta := []string{}
	for _, item := range items {
		obj, err := utils.MetaAccessor(&item)
		if err != nil {
			return nil, err
		}
		if obj.GetCreatedBy() != "" {
			userMeta = append(userMeta, obj.GetCreatedBy())
		}
		if obj.GetUpdatedBy() != "" {
			userMeta = append(userMeta, obj.GetUpdatedBy())
		}
	}

	users, err := s.k8sclient.GetUsersFromMeta(ctx, userMeta)
	if err != nil {
		return nil, err
	}

	versions := make([]*dashver.DashboardVersionDTO, len(items))
	for i, item := range items {
		version, err := unstructuredToLegacyDashboardVersionWithUsers(&item, users)
		if err != nil {
			return nil, err
		}
		versions[i] = version
	}

	return versions, nil
}

// getDashUIDMaybeEmpty is a helper function which takes a dashboardID and returns the UID.
// If the dashboard is not found, it will return an empty string.
func (s *Service) getDashUIDMaybeEmpty(ctx context.Context, id int64) (string, error) {
	q := dashboards.GetDashboardRefByIDQuery{ID: id}
	result, err := s.dashSvc.GetDashboardUIDByID(ctx, &q)
	if err != nil {
		if errors.Is(err, dashboards.ErrDashboardNotFound) {
			s.log.Debug("dashboard not found")
			return "", nil
		} else {
			s.log.Error("error getting dashboard", err)
			return "", err
		}
	}
	return result.UID, nil
}

// DashboardVersionSpec contains the necessary fields to represent a dashboard version.
type DashboardVersionSpec struct {
	UID           string
	Version       int64
	ParentVersion int64
	Spec          any
	MetaAccessor  utils.GrafanaMetaAccessor
}

// UnstructuredToDashboardVersionSpec converts a k8s unstructured object to a DashboardVersionSpec.
// It supports dashboard API versions v0alpha1 through v2beta1.
func UnstructuredToDashboardVersionSpec(obj *unstructured.Unstructured, dst *DashboardVersionSpec) error {
	if obj.GetAPIVersion() == v2alpha1.GroupVersion.String() ||
		obj.GetAPIVersion() == v2beta1.GroupVersion.String() {
		spec, ok := obj.Object["spec"]
		if !ok {
			return errors.New("error parsing dashboard from k8s response")
		}

		meta, err := utils.MetaAccessor(obj)
		if err != nil {
			return err
		}

		version := meta.GetGeneration()
		parentVersion := version - 1
		if parentVersion < 0 {
			parentVersion = 0
		}

		dst.UID = obj.GetName()
		dst.Version = version
		dst.ParentVersion = parentVersion
		dst.Spec = spec
		dst.MetaAccessor = meta

		return nil
	}

	// Otherwise we assume that we are dealing with a legacy dashboard API version (v0 / v1 / etc.)

	spec, ok := obj.Object["spec"].(map[string]any)
	if !ok {
		return errors.New("error parsing dashboard from k8s response")
	}
	meta, err := utils.MetaAccessor(obj)
	if err != nil {
		return err
	}
	uid := meta.GetName()
	spec["uid"] = uid

	dashVersion := meta.GetGeneration()
	parentVersion := dashVersion - 1
	if parentVersion < 0 {
		parentVersion = 0
	}
	if dashVersion > 0 {
		spec["version"] = dashVersion
	}

	dst.UID = uid
	dst.Version = dashVersion
	dst.ParentVersion = parentVersion
	dst.Spec = spec
	dst.MetaAccessor = meta

	return nil
}

func unstructuredToLegacyDashboardVersionWithUsers(
	item *unstructured.Unstructured, users map[string]*user.User,
) (*dashver.DashboardVersionDTO, error) {
	var vspec DashboardVersionSpec
	if err := UnstructuredToDashboardVersionSpec(item, &vspec); err != nil {
		return nil, err
	}

	obj := vspec.MetaAccessor

	createdByID := int64(0)
	if creator, ok := users[obj.GetCreatedBy()]; ok {
		createdByID = creator.ID
	}
	// if updated by is set, then this version of the dashboard was "created"
	// by that user. note: this will be empty for the first version of a dashboard in unistore,
	// but will be set in legacy
	if obj.GetUpdatedBy() != "" {
		if updater, ok := users[obj.GetUpdatedBy()]; ok {
			createdByID = updater.ID
		} else {
			createdByID = -1
		}
	}

	created := obj.GetCreationTimestamp().Time
	if updated, err := obj.GetUpdatedTimestamp(); err == nil && updated != nil {
		created = *updated
	}

	restoreVer, err := getRestoreVersion(obj.GetMessage())
	if err != nil {
		return nil, err
	}

	return &dashver.DashboardVersionDTO{
		ID:            vspec.Version,
		DashboardID:   obj.GetDeprecatedInternalID(), // nolint:staticcheck
		DashboardUID:  vspec.UID,
		Created:       created,
		CreatedBy:     createdByID,
		Message:       obj.GetMessage(),
		RestoredFrom:  restoreVer,
		Version:       int(vspec.Version),
		ParentVersion: int(vspec.ParentVersion),
		Data:          simplejson.NewFromAny(vspec.Spec),
	}, nil
}

const restoreMsg = "Restored from version "

func dashboardRestoreMessage(version int) string {
	return fmt.Sprintf("%s%d", restoreMsg, version)
}

func getRestoreVersion(msg string) (int, error) {
	parts := strings.Split(msg, restoreMsg)
	if len(parts) < 2 {
		return 0, nil
	}

	ver, err := strconv.Atoi(parts[1])
	if err != nil {
		return 0, err
	}
	return ver, nil
}

// compareUnstructuredDashboards compares two dashboards in unstructured.Unstructured format.
func compareUnstructuredDashboards(dst, src *unstructured.Unstructured) (bool, error) {
	dstVersion := dst.GetAPIVersion()
	srcVersion := src.GetAPIVersion()

	// Both should have the same API version for comparison
	if dstVersion != srcVersion {
		return false, fmt.Errorf("cannot compare dashboards with different API versions: %s vs %s", dstVersion, srcVersion)
	}

	dstSpec, ok := dst.Object["spec"].(map[string]any)
	if !ok {
		return false, fmt.Errorf("failed to parse spec for the dashboard version")
	}

	srcSpec, ok := src.Object["spec"].(map[string]any)
	if !ok {
		return false, fmt.Errorf("failed to parse spec for the current dashboard")
	}

	cleanData := dstVersion == v0alpha1.APIVersion ||
		dstVersion == v1beta1.APIVersion

	return compareDashboardData(dstSpec, srcSpec, cleanData), nil
}

func compareDashboardData(versionData, dashData map[string]any, cleanData bool) bool {
	if cleanData {
		// these can be different but the actual data is the same
		delete(versionData, "version")
		delete(dashData, "version")
		delete(versionData, "id")
		delete(dashData, "id")
		delete(versionData, "uid")
		delete(dashData, "uid")
	}

	return reflect.DeepEqual(versionData, dashData)
}
