// Licensed to 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. Apache Software Foundation (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 trace

import (
	"context"
	"fmt"
	"os"
	"path"
	"path/filepath"
	"sort"
	"strings"
	"sync"
	"time"

	"github.com/pkg/errors"
	"go.uber.org/multierr"

	"github.com/apache/skywalking-banyandb/api/common"
	"github.com/apache/skywalking-banyandb/api/data"
	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
	tracev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/trace/v1"
	"github.com/apache/skywalking-banyandb/banyand/internal/storage"
	"github.com/apache/skywalking-banyandb/banyand/metadata"
	"github.com/apache/skywalking-banyandb/banyand/observability"
	obsservice "github.com/apache/skywalking-banyandb/banyand/observability/services"
	"github.com/apache/skywalking-banyandb/banyand/protector"
	"github.com/apache/skywalking-banyandb/banyand/queue"
	"github.com/apache/skywalking-banyandb/pkg/bus"
	"github.com/apache/skywalking-banyandb/pkg/fs"
	"github.com/apache/skywalking-banyandb/pkg/logger"
	banyandbpath "github.com/apache/skywalking-banyandb/pkg/path"
	"github.com/apache/skywalking-banyandb/pkg/run"
	resourceSchema "github.com/apache/skywalking-banyandb/pkg/schema"
	"github.com/apache/skywalking-banyandb/pkg/timestamp"
)

var (
	errEmptyRootPath = errors.New("root path is empty")
	// ErrTraceNotExist denotes a trace doesn't exist in the metadata repo.
	ErrTraceNotExist = errors.New("trace doesn't exist")
)

var _ Service = (*standalone)(nil)

const defaultTracePipelineMergeGrace = 2 * time.Hour

type standalone struct {
	pm                 protector.Memory
	pipeline           queue.Server
	localPipeline      queue.Queue
	omr                observability.MetricsRegistry
	lfs                fs.FileSystem
	metadata           metadata.Repo
	l                  *logger.Logger
	diskMonitor        *storage.DiskMonitor
	finalizeCloser     *run.Closer
	schemaRepo         schemaRepo
	snapshotDir        string
	root               string
	dataPath           string
	option             option
	retentionConfig    storage.RetentionConfig
	maxFileSnapshotNum int
	minFileSnapshotAge time.Duration
}

func (s *standalone) FlagSet() *run.FlagSet {
	fs := run.NewFlagSet("trace")
	fs.StringVar(&s.root, "trace-root-path", "/tmp", "the root path for trace data")
	fs.StringVar(&s.dataPath, "trace-data-path", "", "the path for trace data (optional)")
	fs.DurationVar(&s.option.flushTimeout, "trace-flush-timeout", defaultFlushTimeout, "the timeout for trace data flush")
	fs.DurationVar(&s.option.memWaitTimeout, "trace-lifecycle-receive-mem-wait-timeout", 5*time.Minute,
		"max time the migration receiver waits for memory to recover before introducing an external segment")

	// Retention configuration flags
	fs.Float64Var(&s.retentionConfig.HighWatermark, "trace-retention-high-watermark", 95.0, "disk usage high watermark percentage that triggers forced retention cleanup")
	fs.Float64Var(&s.retentionConfig.LowWatermark, "trace-retention-low-watermark", 85.0, "disk usage low watermark percentage where forced retention cleanup stops")
	fs.DurationVar(&s.retentionConfig.CheckInterval, "trace-retention-check-interval", 5*time.Minute, "interval for checking disk usage")
	fs.DurationVar(&s.retentionConfig.Cooldown, "trace-retention-cooldown", 30*time.Second, "cooldown period between forced segment deletions")
	fs.BoolVar(&s.retentionConfig.ForceCleanupEnabled, "trace-retention-force-cleanup-enabled", false,
		"enable forced retention cleanup when disk usage exceeds high watermark")

	fs.IntVar(&s.maxFileSnapshotNum, "trace-max-file-snapshot-num", 10, "the maximum number of file snapshots")
	fs.DurationVar(&s.minFileSnapshotAge, "trace-min-file-snapshot-age", time.Hour, "minimum age for file snapshots to be eligible for deletion")
	s.option.mergePolicy = newDefaultMergePolicy()
	fs.IntVar(&s.option.mergePolicy.maxParts, "trace-max-merge-parts", s.option.mergePolicy.maxParts, "the maximum number of parts to merge at once")
	fs.VarP(&s.option.mergePolicy.maxFanOutSize, "trace-max-fan-out-size", "", "the upper bound of a single file size after merge of trace")
	bindVectorizedFlags(fs, &s.option.vectorized)
	fs.BoolVar(&s.option.nativePipelineEnabled, "trace-pipeline-native-plugin-enabled", false, "enable the native plugin pipeline for in-merge trace retention")
	fs.StringVar(&s.option.trustedPluginDir, "trace-pipeline-trusted-plugin-dir", "", "trusted directory for native trace pipeline plugins")
	fs.DurationVar(&s.option.mergeGraceDefault, "trace-pipeline-merge-grace-default", defaultTracePipelineMergeGrace,
		"default merge_grace for in-merge trace retention filter")
	fs.DurationVar(&s.option.maxTraceFragmentGap, "trace-pipeline-max-fragment-gap", 0,
		"externally enforced maximum gap between fragments of one trace; zero disables destructive merge and finalization sampling")
	fs.DurationVar(&s.option.finalizeGraceDefault, "trace-pipeline-finalize-grace-default", 5*time.Minute,
		"default finalize_grace (per-segment settling window) for finalization sampling")
	fs.DurationVar(&s.option.decideTimeout, "trace-pipeline-decide-timeout", 5*time.Second, "hard per-batch Decide timeout for trace pipeline plugins")
	fs.IntVar(&s.option.decideTimeoutCircuitBreak, "trace-pipeline-decide-timeout-circuit-break", 3,
		"consecutive-timeout threshold to disable a (group,schema) pipeline chain")
	return fs
}

func (s *standalone) Validate() error {
	if s.root == "" {
		return errEmptyRootPath
	}

	// Validate retention configuration
	if s.retentionConfig.HighWatermark < 0 || s.retentionConfig.HighWatermark > 100 {
		return errors.New("trace-retention-high-watermark must be between 0 and 100")
	}
	if s.retentionConfig.LowWatermark < 0 || s.retentionConfig.LowWatermark > 100 {
		return errors.New("trace-retention-low-watermark must be between 0 and 100")
	}
	if s.retentionConfig.LowWatermark > s.retentionConfig.HighWatermark {
		return errors.New("trace-retention-low-watermark must be less than trace-retention-high-watermark")
	}
	if s.retentionConfig.CheckInterval <= 0 && s.retentionConfig.ForceCleanupEnabled {
		return errors.New("trace-retention-check-interval must be greater than 0 when force cleanup is enabled")
	}
	if s.retentionConfig.Cooldown <= 0 {
		return errors.New("trace-retention-cooldown must be greater than 0")
	}
	if s.option.mergeGraceDefault < 0 {
		return errors.New("trace-pipeline-merge-grace-default must not be negative")
	}
	if s.option.maxTraceFragmentGap < 0 {
		return errors.New("trace-pipeline-max-fragment-gap must not be negative")
	}
	return s.option.vectorized.Validate()
}

func (s *standalone) Name() string {
	return "trace"
}

func (s *standalone) Role() databasev1.Role {
	return databasev1.Role_ROLE_DATA
}

func (s *standalone) PreRun(ctx context.Context) error {
	s.l = logger.GetLogger(s.Name())
	// Native columnar wire frame for the data↔liaison query hop follows the
	// vectorized flag (mirrors measure's wire-mode wiring).
	data.SetTraceWireModeRaw(s.option.vectorized.Enabled)
	s.l.Info().Bool("trace_wire_mode_raw", s.option.vectorized.Enabled).Msg("trace wire mode published (standalone)")
	s.l.Info().Msg("memory protector is initialized in PreRun")
	s.lfs = fs.NewLocalFileSystemWithLoggerAndLimit(s.l, s.pm.GetLimit())
	var err error
	if s.root, err = banyandbpath.Get(s.root); err != nil {
		return err
	}
	if s.dataPath, err = banyandbpath.Get(s.dataPath); err != nil {
		return err
	}
	path := path.Join(s.root, s.Name())
	s.snapshotDir = filepath.Join(path, storage.SnapshotsDir)
	obsservice.UpdatePath(path)
	val := ctx.Value(common.ContextNodeKey)
	if val == nil {
		return errors.New("node id is empty")
	}
	node := val.(common.Node)
	if s.dataPath == "" {
		s.dataPath = filepath.Join(path, storage.DataDir)
	}
	if !strings.HasPrefix(filepath.VolumeName(s.dataPath), filepath.VolumeName(path)) {
		obsservice.UpdatePath(s.dataPath)
	}
	s.schemaRepo = newSchemaRepo(s.dataPath, s, node.Labels, node.NodeID)
	if metaSvc, ok := s.metadata.(metadata.Service); ok {
		metaSvc.RegisterDataCollector(commonv1.Catalog_CATALOG_TRACE, &s.schemaRepo)
		metaSvc.RegisterLiaisonCollector(commonv1.Catalog_CATALOG_TRACE, s)
		metaSvc.RegisterGroupDropHandler(commonv1.Catalog_CATALOG_TRACE, s)
	}
	subErr := s.pipeline.Subscribe(data.TopicTraceCollectDataInfo, &collectDataInfoListener{s: s})
	if subErr != nil {
		return fmt.Errorf("failed to subscribe to TopicTraceCollectDataInfo: %w", subErr)
	}
	dropGroupErr := s.pipeline.Subscribe(data.TopicTraceDropGroup, &dropGroupDataListener{s: s})
	if dropGroupErr != nil {
		return fmt.Errorf("failed to subscribe to TopicTraceDropGroup: %w", dropGroupErr)
	}

	// Initialize snapshot directory
	s.snapshotDir = filepath.Join(path, "snapshots")

	// Initialize disk monitor for forced retention
	s.diskMonitor = storage.NewDiskMonitor(s, s.retentionConfig, s.omr)
	s.diskMonitor.Start()

	// Set up write callback handler. For now, keep the original write throttling behavior based on high watermark
	writeListener := setUpWriteCallback(s.l, &s.schemaRepo, int(s.retentionConfig.HighWatermark))
	err = s.pipeline.Subscribe(data.TopicTraceWrite, writeListener)
	if err != nil {
		return err
	}
	err = s.pipeline.Subscribe(data.TopicSnapshot, &standaloneSnapshotListener{s: s})
	if err != nil {
		return err
	}
	err = s.pipeline.Subscribe(data.TopicDeleteExpiredTraceSegments, &standaloneDeleteTraceSegmentsListener{s: s})
	if err != nil {
		return err
	}
	s.pipeline.RegisterChunkedSyncHandler(data.TopicTracePartSync, setUpChunkedSyncCallback(s.l, &s.schemaRepo))
	s.pipeline.RegisterChunkedSyncHandler(data.TopicTraceSeriesSync, setUpSeriesSyncCallback(s.l, &s.schemaRepo, s.pm, s.option.memWaitTimeout))
	err = s.pipeline.Subscribe(data.TopicTraceSidxSeriesWrite, setUpSidxSeriesIndexCallback(s.l, &s.schemaRepo))
	if err != nil {
		return err
	}

	s.localPipeline = queue.Local()
	err = s.localPipeline.Subscribe(data.TopicTraceWrite, writeListener)
	if err != nil {
		return err
	}
	// Start the background finalization-sampling scanner when the native pipeline is
	// enabled. It is a single node-wide goroutine (concurrency-1) that sweeps cooled
	// segments of finalize-enabled groups; it is inert until a group enables the
	// FINALIZE event, so starting it unconditionally-under-the-flag is safe.
	if s.option.nativePipelineEnabled {
		s.finalizeCloser = run.NewCloser(1)
		// Launch via run.Go (not a bare `go`) so a panic in the scan loop is recovered
		// and reported instead of crashing the process; the loop's deferred closer.Done
		// still fires during panic unwinding. It uses the run.Closer for cancellation,
		// hence the contextcheck suppression (mirrors the tsTable background loops).
		run.Go(context.Background(), "trace.finalize-scanner", s.l, func(_ context.Context) { //nolint:contextcheck
			s.schemaRepo.finalizeScanLoop(s.finalizeCloser, defaultFinalizeScanInterval) //nolint:contextcheck
		})
	}

	s.l.Info().
		Str("root", s.root).
		Str("dataPath", s.dataPath).
		Str("snapshotDir", s.snapshotDir).
		Msg("trace standalone service initialized")

	return nil
}

func (s *standalone) Serve() run.StopNotify {
	return s.schemaRepo.StopCh()
}

func (s *standalone) GracefulStop() {
	// Stop the finalize scanner before closing the schema repo so no round starts
	// against a segment whose tables are being torn down.
	if s.finalizeCloser != nil {
		s.finalizeCloser.CloseThenWait()
	}
	// Stop disk monitor
	if s.diskMonitor != nil {
		s.diskMonitor.Stop()
	}

	if s.schemaRepo.Repository != nil {
		s.schemaRepo.Repository.Close()
	}
	s.l.Info().Msg("trace standalone service stopped")
}

func (s *standalone) LoadGroup(name string) (resourceSchema.Group, bool) {
	return s.schemaRepo.LoadGroup(name)
}

func (s *standalone) DropGroup(_ context.Context, groupName string) error {
	return s.schemaRepo.DropGroup(groupName)
}

func (s *standalone) Trace(metadata *commonv1.Metadata) (Trace, error) {
	sm, ok := s.schemaRepo.Trace(metadata)
	if !ok {
		return nil, ErrTraceNotFound
	}
	return sm, nil
}

func (s *standalone) GetRemovalSegmentsTimeRange(group string) *timestamp.TimeRange {
	return s.schemaRepo.GetRemovalSegmentsTimeRange(group)
}

// RetentionService interface implementation.

func (s *standalone) GetDataPath() string {
	return s.dataPath
}

func (s *standalone) GetSnapshotDir() string {
	return s.snapshotDir
}

func (s *standalone) LoadAllGroups() []resourceSchema.Group {
	return s.schemaRepo.LoadAllGroups()
}

func (s *standalone) PeekOldestSegmentEndTimeInGroup(group string) (time.Time, bool) {
	g, ok := s.schemaRepo.LoadGroup(group)
	if !ok {
		return time.Time{}, false
	}

	db := g.SupplyTSDB()
	if db == nil {
		return time.Time{}, false
	}

	// Type assert to the storage interface that has PeekOldestSegmentEndTime
	if dbWithPeek, ok := db.(interface{ PeekOldestSegmentEndTime() (time.Time, bool) }); ok {
		return dbWithPeek.PeekOldestSegmentEndTime()
	}

	return time.Time{}, false
}

func (s *standalone) DeleteOldestSegmentInGroup(group string) (bool, error) {
	g, ok := s.schemaRepo.LoadGroup(group)
	if !ok {
		return false, nil
	}

	db := g.SupplyTSDB()
	if db == nil {
		return false, nil
	}

	// Type assert to the storage interface that has DeleteOldestSegment
	if dbWithDelete, ok := db.(interface{ DeleteOldestSegment() (bool, error) }); ok {
		return dbWithDelete.DeleteOldestSegment()
	}

	s.l.Debug().Str("group", group).Msg("database does not support DeleteOldestSegment")
	return false, nil
}

func (s *standalone) CleanupOldSnapshots(maxAge time.Duration) error {
	if s.snapshotDir == "" {
		return nil
	}
	// Use age-based cleanup during forced retention cleanup
	storage.DeleteOldSnapshots(s.snapshotDir, maxAge, s.lfs)
	return nil
}

func (s *standalone) GetServiceName() string {
	return s.Name()
}

func (s *standalone) takeGroupSnapshot(dstDir string, groupName string) (bool, error) {
	group, ok := s.schemaRepo.LoadGroup(groupName)
	if !ok {
		return false, errors.Errorf("group %s not found", groupName)
	}
	db := group.SupplyTSDB()
	if db == nil {
		return false, errors.Errorf("group %s has no tsdb", group.GetSchema().Metadata.Name)
	}
	tsdb := db.(storage.TSDB[*tsTable, option])
	created, err := tsdb.TakeFileSnapshot(dstDir)
	if err != nil {
		return false, errors.WithMessagef(err, "snapshot %s fail to take file snapshot for group %s", dstDir, group.GetSchema().Metadata.Name)
	}
	return created, nil
}

// collectSegDirs walks a directory tree and collects all seg-* directory paths.
// It only collects directories matching the "seg-*" pattern and ignores all files.
// Returns a map of relative paths to seg-* directories.
func collectSegDirs(rootDir string) (map[string]bool, error) {
	segDirs := make(map[string]bool)

	walkErr := filepath.Walk(rootDir, func(currentPath string, info os.FileInfo, err error) error {
		if err != nil {
			// If we can't read a directory, log but continue
			return nil
		}

		// Skip files, only process directories
		if !info.IsDir() {
			return nil
		}

		// Get the directory name
		dirName := filepath.Base(currentPath)

		// Check if this is a seg-* directory
		if strings.HasPrefix(dirName, "seg-") {
			// Get relative path from root
			relPath, relErr := filepath.Rel(rootDir, currentPath)
			if relErr != nil {
				return fmt.Errorf("failed to get relative path for %s: %w", currentPath, relErr)
			}

			// Add to our collection
			segDirs[relPath] = true

			// Don't recurse into seg-* directories
			return filepath.SkipDir
		}

		return nil
	})

	if walkErr != nil {
		return nil, fmt.Errorf("failed to walk directory %s: %w", rootDir, walkErr)
	}

	return segDirs, nil
}

// compareSegDirs compares two sets of seg-* directories and returns three slices:
// matched (in both), onlyInSnapshot (only in snapshot), onlyInData (only in data).
func compareSegDirs(snapshotDirs, dataDirs map[string]bool) (matched, onlyInSnapshot, onlyInData []string) {
	// Find directories in both sets (matched)
	for dir := range snapshotDirs {
		if dataDirs[dir] {
			matched = append(matched, dir)
		} else {
			onlyInSnapshot = append(onlyInSnapshot, dir)
		}
	}

	// Find directories only in data
	for dir := range dataDirs {
		if !snapshotDirs[dir] {
			onlyInData = append(onlyInData, dir)
		}
	}

	// Sort for consistent output
	sort.Strings(matched)
	sort.Strings(onlyInSnapshot)
	sort.Strings(onlyInData)

	return matched, onlyInSnapshot, onlyInData
}

// compareSnapshotWithData compares the snapshot directory with the data directory
// to verify that seg-* directories are consistent. Only logs differences, does not return errors.
func (d *standaloneSnapshotListener) compareSnapshotWithData(snapshotDir, dataDir, groupName string) {
	// Collect seg-* directories from snapshot
	snapshotDirs, snapshotErr := collectSegDirs(snapshotDir)
	if snapshotErr != nil {
		d.s.l.Warn().Err(snapshotErr).
			Str("group", groupName).
			Str("snapshotDir", snapshotDir).
			Msg("failed to collect seg-* directories from snapshot, skipping comparison")
		return
	}

	// Collect seg-* directories from data
	dataDirs, dataErr := collectSegDirs(dataDir)
	if dataErr != nil {
		d.s.l.Warn().Err(dataErr).
			Str("group", groupName).
			Str("dataDir", dataDir).
			Msg("failed to collect seg-* directories from data, skipping comparison")
		return
	}

	// Compare the directories
	matched, onlyInSnapshot, onlyInData := compareSegDirs(snapshotDirs, dataDirs)

	// Log consolidated comparison results at Info level
	d.s.l.Info().
		Str("group", groupName).
		Int("matched", len(matched)).
		Int("onlyInSnapshot", len(onlyInSnapshot)).
		Int("onlyInData", len(onlyInData)).
		Strs("matchedDirs", matched).
		Strs("onlyInSnapshotDirs", onlyInSnapshot).
		Strs("onlyInDataDirs", onlyInData).
		Msgf("snapshot comparison for group %s, data dir: %s, snapshot dir: %s",
			groupName, dataDir, snapshotDir)
}

type standaloneSnapshotListener struct {
	*bus.UnImplementedHealthyListener
	s           *standalone
	snapshotSeq uint64
	snapshotMux sync.Mutex
}

func (d *standaloneSnapshotListener) Rev(ctx context.Context, message bus.Message) bus.Message {
	groups := message.Data().([]*databasev1.SnapshotRequest_Group)
	var gg []resourceSchema.Group
	if len(groups) == 0 {
		gg = d.s.schemaRepo.LoadAllGroups()
	} else {
		for _, g := range groups {
			if g.Catalog != commonv1.Catalog_CATALOG_TRACE {
				continue
			}
			group, ok := d.s.schemaRepo.LoadGroup(g.Group)
			if !ok {
				continue
			}
			gg = append(gg, group)
		}
	}
	if len(gg) == 0 {
		return bus.NewMessage(bus.MessageID(time.Now().UnixNano()), nil)
	}
	d.snapshotMux.Lock()
	defer d.snapshotMux.Unlock()
	storage.DeleteStaleSnapshots(d.s.snapshotDir, d.s.maxFileSnapshotNum, d.s.minFileSnapshotAge, d.s.lfs)
	sn := d.snapshotName()
	var err error
	var snapshotCreated int
	for _, g := range gg {
		select {
		case <-ctx.Done():
			return bus.NewMessage(bus.MessageID(time.Now().UnixNano()), nil)
		default:
		}
		groupName := g.GetSchema().Metadata.Name
		snapshotPath := filepath.Join(d.s.snapshotDir, sn, groupName)
		created, errGroup := d.s.takeGroupSnapshot(snapshotPath, groupName)
		if errGroup != nil {
			d.s.l.Error().Err(errGroup).Str("group", groupName).Msg("fail to take group snapshot")
			err = multierr.Append(err, errGroup)
			continue
		}
		if !created {
			d.s.l.Info().Str("group", groupName).Msg("skip empty group snapshot")
			continue
		}
		snapshotCreated++

		// Compare snapshot with data directory to verify consistency
		dataPath := filepath.Join(d.s.dataPath, groupName)
		d.compareSnapshotWithData(snapshotPath, dataPath, groupName)
	}
	if snapshotCreated == 0 && err == nil {
		return bus.NewMessage(bus.MessageID(time.Now().UnixNano()), nil)
	}
	snp := &databasev1.Snapshot{
		Name:    sn,
		Catalog: commonv1.Catalog_CATALOG_TRACE,
	}
	if err != nil {
		snp.Error = err.Error()
	}
	return bus.NewMessage(bus.MessageID(time.Now().UnixNano()), snp)
}

func (d *standaloneSnapshotListener) snapshotName() string {
	d.snapshotSeq++
	return fmt.Sprintf("%s-%08X", time.Now().UTC().Format(storage.SnapshotTimeFormat), d.snapshotSeq)
}

type standaloneDeleteTraceSegmentsListener struct {
	*bus.UnImplementedHealthyListener
	s *standalone
}

func (s *standaloneDeleteTraceSegmentsListener) Rev(_ context.Context, message bus.Message) bus.Message {
	req := message.Data().(*tracev1.DeleteExpiredSegmentsRequest)
	if req == nil {
		return bus.NewMessage(bus.MessageID(time.Now().UnixNano()), int64(0))
	}

	db, err := s.s.schemaRepo.loadTSDB(req.Group)
	if err != nil {
		s.s.l.Error().Err(err).Str("group", req.Group).Msg("fail to load tsdb")
		return bus.NewMessage(bus.MessageID(time.Now().UnixNano()), int64(0))
	}
	s.s.l.Info().Msg("test")
	deleted := db.DeleteExpiredSegments(req.SegmentSuffixes)
	return bus.NewMessage(bus.MessageID(time.Now().UnixNano()), deleted)
}

func (s *standalone) CollectDataInfo(ctx context.Context, group string) (*databasev1.DataInfo, error) {
	return s.schemaRepo.CollectDataInfo(ctx, group)
}

func (s *standalone) CollectLiaisonInfo(_ context.Context, group string) (*databasev1.LiaisonInfo, error) {
	info := &databasev1.LiaisonInfo{}
	pendingWriteCount, writeErr := s.schemaRepo.collectPendingWriteInfo(group)
	if writeErr != nil {
		return nil, fmt.Errorf("failed to collect pending write info: %w", writeErr)
	}
	info.PendingWriteDataCount = pendingWriteCount
	return info, nil
}

type collectDataInfoListener struct {
	*bus.UnImplementedHealthyListener
	s *standalone
}

func (l *collectDataInfoListener) Rev(ctx context.Context, message bus.Message) bus.Message {
	req, ok := message.Data().(*databasev1.GroupRegistryServiceInspectRequest)
	if !ok {
		return bus.NewMessage(message.ID(), common.NewError("invalid data type for collect data info request"))
	}
	dataInfo, collectErr := l.s.schemaRepo.CollectDataInfo(ctx, req.Group)
	if collectErr != nil {
		return bus.NewMessage(message.ID(), common.NewError("failed to collect data info: %v", collectErr))
	}
	return bus.NewMessage(message.ID(), dataInfo)
}

type dropGroupDataListener struct {
	*bus.UnImplementedHealthyListener
	s *standalone
}

func (l *dropGroupDataListener) Rev(ctx context.Context, message bus.Message) bus.Message {
	req, ok := message.Data().(*databasev1.GroupRegistryServiceDeleteRequest)
	if !ok {
		return bus.NewMessage(message.ID(), common.NewError("invalid data type for drop group request"))
	}
	if dropErr := l.s.DropGroup(ctx, req.Group); dropErr != nil {
		return bus.NewMessage(message.ID(), common.NewError("failed to drop group data: %v", dropErr))
	}
	return bus.NewMessage(message.ID(), req)
}

// NewService returns a new service.
func NewService(metadata metadata.Repo, pipeline queue.Server, omr observability.MetricsRegistry, pm protector.Memory) (Service, error) {
	return &standalone{
		metadata: metadata,
		pipeline: pipeline,
		omr:      omr,
		pm:       pm,
	}, nil
}
