package approvalapp

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

	"github.com/tidbcloud/AgentTeam/internal/store/app"
	"github.com/tidbcloud/AgentTeam/internal/store/approval"
	"github.com/tidbcloud/AgentTeam/internal/workspace/owner"
)

const (
	defaultProjectionReconcileBatchSize = 25
	defaultProjectionReconcileInterval  = 15 * time.Second
)

type subjectProjectionStore interface {
	ListUnprojected(context.Context, approval.UnprojectedOptions) (approval.UnprojectedPage, error)
	ListAssignments(context.Context, string, string) ([]approval.Assignment, error)
	// ExpireDueApprovals is the bounded lazy settlement sweep: it terminalizes
	// pending Approvals whose authoritative expires_at has passed so expired
	// requests become visible without a decision.
	ExpireDueApprovals(context.Context, approval.ExpireDueOptions) (int, error)
}

// ProjectionReconcileResult summarizes one bounded Approval subject projection
// pass. A replayed stable notification counts separately from a new projection.
type ProjectionReconcileResult struct {
	Scanned          int
	Projected        int
	AlreadyProjected int
	Failed           int
}

// ProjectionReconciler uses unprojected Approval instances as the durable retry
// ledger for subject presentations. It owns only projection recovery; the
// Approval instance remains the workflow source of truth.
type ProjectionReconciler struct {
	approvals subjectProjectionStore
	subjects  *SubjectRegistry
	managed   owner.ManagedWorkspaceSet
	logger    *slog.Logger
	batchSize int

	cursorMu sync.Mutex
	afterIDs map[string]string
}

func NewProjectionReconciler(
	approvals subjectProjectionStore,
	subjects *SubjectRegistry,
	managed owner.ManagedWorkspaceSet,
	logger *slog.Logger,
) *ProjectionReconciler {
	if logger == nil {
		logger = slog.Default()
	}
	return &ProjectionReconciler{
		approvals: approvals, subjects: subjects, managed: managed,
		logger: logger, batchSize: defaultProjectionReconcileBatchSize,
		afterIDs: make(map[string]string),
	}
}

// ProcessOnce scans one cursor page. Reaching the end resets the in-memory
// cursor so unprojected rows remain a retry ledger for transient projection
// failures and stable notification ids make successful rows cheap replays.
func (reconciler *ProjectionReconciler) ProcessOnce(
	ctx context.Context,
) (ProjectionReconcileResult, error) {
	if reconciler == nil || reconciler.approvals == nil || reconciler.subjects == nil {
		return ProjectionReconcileResult{}, fmt.Errorf("Approval projection reconciler is not configured")
	}
	reconciler.cursorMu.Lock()
	defer reconciler.cursorMu.Unlock()

	result := ProjectionReconcileResult{}
	errs := make([]error, 0)
	// Lazy settlement first: terminalize due pending Approvals so the projection
	// pass below surfaces them as expired (not actionable, requester-visible).
	if _, err := reconciler.approvals.ExpireDueApprovals(ctx, approval.ExpireDueOptions{
		Limit: reconciler.batchSize, Now: time.Now().UTC(),
	}); err != nil {
		errs = append(errs, fmt.Errorf("expire due Approvals: %w", err))
	}
	for _, subjectType := range reconciler.subjects.ReconcileTypes() {
		afterID := reconciler.afterIDs[subjectType]
		page, err := reconciler.approvals.ListUnprojected(ctx, approval.UnprojectedOptions{
			SubjectType: subjectType, AfterID: afterID,
			Limit: reconciler.batchSize,
		})
		if err != nil {
			return result, err
		}
		result.Scanned += len(page.Instances)
		for _, instance := range page.Instances {
			if !owner.IsManaged(reconciler.managed, instance.WorkspaceID) {
				continue
			}
			assignments, loadErr := reconciler.approvals.ListAssignments(ctx, instance.WorkspaceID, instance.ID)
			if loadErr != nil {
				result.Failed++
				errs = append(errs, fmt.Errorf("load assignments for Approval %s: %w", instance.ID, loadErr))
				continue
			}
			var projected app.AppNotificationResult
			var projectErr error
			if instance.State == approval.StatePending {
				projected, projectErr = reconciler.subjects.PresentPending(ctx, SubmitResult{Instance: instance, Assignments: assignments})
			} else {
				projected, projectErr = reconciler.subjects.PresentResolved(ctx, DecisionResult{Instance: instance}, PresentationContext{})
			}
			if projectErr != nil {
				result.Failed++
				errs = append(errs, fmt.Errorf("reconcile Approval %s projection: %w", instance.ID, projectErr))
				continue
			}
			if projected.AlreadyCreated {
				result.AlreadyProjected++
			} else {
				result.Projected++
			}
		}
		if page.HasMore {
			reconciler.afterIDs[subjectType] = strings.TrimSpace(page.NextCursor)
		} else {
			delete(reconciler.afterIDs, subjectType)
		}
	}
	return result, errors.Join(errs...)
}

// Run performs an immediate recovery pass and then continues periodically
// until the production server context is cancelled.
func (reconciler *ProjectionReconciler) Run(ctx context.Context, interval time.Duration) {
	if reconciler == nil {
		return
	}
	if interval <= 0 {
		interval = defaultProjectionReconcileInterval
	}
	runPass := func() {
		result, err := reconciler.ProcessOnce(ctx)
		if err != nil && !errors.Is(err, context.Canceled) {
			reconciler.logger.Warn(
				"Approval projection reconcile pass failed",
				slog.Int("scanned", result.Scanned),
				slog.Int("failed", result.Failed),
				slog.Any("error", err),
			)
		}
		if result.Projected > 0 {
			reconciler.logger.Info(
				"Approval projection reconcile pass completed",
				slog.Int("scanned", result.Scanned),
				slog.Int("projected", result.Projected),
				slog.Int("already_projected", result.AlreadyProjected),
			)
		}
	}

	runPass()
	ticker := time.NewTicker(interval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			runPass()
		}
	}
}
