package approval

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

	"github.com/tidbcloud/AgentTeam/internal/core/identifiers"
	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/storeerr"
)

// ExpireDueOptions bounds the lazy settlement sweep for expired pending
// Approvals. The authoritative expires_at column decides the deadline; the sweep
// only surfaces what the clock already decided.
type ExpireDueOptions struct {
	// WorkspaceID optionally narrows the sweep to one workspace. Empty means all
	// workspaces.
	WorkspaceID string
	// Limit bounds the number of instances transitioned per call (bounded lazy
	// settlement). Defaults to 50 when zero.
	Limit int
	// Now is the authoritative decision clock.
	Now time.Time
}

// ExpireDueApprovals transitions pending Approvals whose authoritative
// expires_at has passed into the expired terminal state (bounded lazy
// settlement). Each transition runs in its own transaction so a single stuck
// row cannot block the sweep. It returns the number expired.
//
// The reconciler/worker path calls this periodically so an expired pending
// request becomes visible (projected_version lags, projection resolves the card
// to an expired result) without a Human decision. A concurrent late approve is
// fenced by decideInTx/cancelInTx, which re-check the authoritative timestamp
// under the row lock before running any effect — expiry always wins.
func (store *Store) ExpireDueApprovals(ctx context.Context, options ExpireDueOptions) (int, error) {
	if err := store.Validate(); err != nil {
		return 0, err
	}
	limit := options.Limit
	if limit <= 0 {
		limit = 50
	}
	now := options.Now
	if now.IsZero() {
		now = time.Now().UTC()
	} else {
		now = now.UTC()
	}

	query := `
SELECT workspace_id, id
FROM approval_instances
WHERE state = 'pending'
  AND expires_at IS NOT NULL
  AND expires_at <= $1
`
	args := []any{now}
	if strings.TrimSpace(options.WorkspaceID) != "" {
		query += " AND workspace_id = $2\n"
		args = append(args, strings.TrimSpace(options.WorkspaceID))
	}
	query += " ORDER BY expires_at ASC, id ASC\n"
	query += fmt.Sprintf(" LIMIT $%d", len(args)+1)
	args = append(args, limit)

	rows, err := store.db.Query(ctx, query, args...)
	if err != nil {
		return 0, fmt.Errorf("list due approval instances: %w", err)
	}
	type due struct {
		workspaceID string
		approvalID  string
	}
	var candidates []due
	for rows.Next() {
		var value due
		if err := rows.Scan(&value.workspaceID, &value.approvalID); err != nil {
			rows.Close()
			return 0, fmt.Errorf("scan due approval instance: %w", err)
		}
		candidates = append(candidates, value)
	}
	rows.Close()

	expired := 0
	for _, candidate := range candidates {
		ok, err := store.expireOne(ctx, candidate.workspaceID, candidate.approvalID, now)
		if err != nil {
			// A row may have just been decided or cancelled concurrently; the
			// fenced UPDATE fails and we skip it.
			if isTerminalRace(err) {
				continue
			}
			return expired, err
		}
		if ok {
			expired++
		}
	}
	return expired, nil
}

// expireOne transitions a single pending Approval to the expired terminal state
// atomically. It returns true when it performed the transition.
func (store *Store) expireOne(ctx context.Context, workspaceID, approvalID string, now time.Time) (bool, error) {
	done := false
	err := coredb.WithTx(ctx, store.db, func(tx coredb.Tx) error {
		instance, err := loadInstance(ctx, tx, workspaceID, approvalID, true)
		if err != nil {
			return err
		}
		if instance.State != StatePending {
			return nil
		}
		if instance.ExpiresAt == nil || instance.ExpiresAt.After(now) {
			return nil
		}
		if err := expireInstanceInTx(ctx, tx, instance, now); err != nil {
			return err
		}
		done = true
		return nil
	})
	return done, err
}

// expireInstanceInTx transitions a locked pending Approval to the expired
// terminal state. The caller must hold the instance row lock (FOR UPDATE) and
// have verified instance.State == pending and instance.ExpiresAt <= now.
func expireInstanceInTx(ctx context.Context, tx coredb.Tx, instance Instance, now time.Time) error {
	updated, err := tx.Exec(ctx, `
UPDATE approval_instances
SET state='expired', decision='expired', decision_reason=$4,
    pending_key=NULL, version=version+1, updated_at=$5, decided_at=$5
WHERE workspace_id=$1 AND id=$2 AND version=$3 AND state='pending'
`, instance.WorkspaceID, instance.ID, instance.Version, "approval expired before a decision", now)
	if err != nil {
		return fmt.Errorf("expire approval instance: %w", err)
	}
	if updated.RowsAffected() != 1 {
		return storeerr.Conflict("approval version changed during expiry")
	}
	if _, err := tx.Exec(ctx, `
UPDATE approval_assignments
SET status='expired', version=version+1, updated_at=$3, closed_at=$3
WHERE workspace_id=$1 AND approval_instance_id=$2 AND status='pending'
`, instance.WorkspaceID, instance.ID, now); err != nil {
		return fmt.Errorf("expire approval assignments: %w", err)
	}
	// The receipt must reference a real assignment row (FK) and a real actor
	// member. Use the first assignment's approver (the execution owner) as the
	// actor, since the authoritative clock — not a Human — expired the request.
	receiptAssignmentID, approverMemberID, err := firstAssignment(ctx, tx, instance.WorkspaceID, instance.ID)
	if err != nil {
		return err
	}
	receiptID, err := identifiers.NewEntityID("apdec")
	if err != nil {
		return fmt.Errorf("generate approval expiry receipt id: %w", err)
	}
	if _, err := tx.Exec(ctx, `
INSERT INTO approval_decision_receipts (
    id, workspace_id, approval_instance_id, assignment_id, actor_member_id,
    client_decision_id, request_digest, decision, reason, result_version, created_at
) VALUES ($1,$2,$3,$4,$5,$6,'','expired',$7,$8,$9)
`, receiptID, instance.WorkspaceID, instance.ID, receiptAssignmentID, approverMemberID, "approval-expiry:"+instance.ID, "approval expired before a decision", instance.Version+1, now); err != nil {
		return fmt.Errorf("insert approval expiry receipt: %w", err)
	}
	return nil
}

// firstAssignment returns the ID and approver member of the first assignment on
// an Approval instance (used to satisfy the receipt FK when no Human decided).
func firstAssignment(ctx context.Context, tx coredb.Tx, workspaceID, approvalID string) (string, string, error) {
	rows, err := tx.Query(ctx, `
SELECT id, approver_member_id
FROM approval_assignments
WHERE workspace_id=$1 AND approval_instance_id=$2
ORDER BY approver_member_id ASC, id ASC
LIMIT 1
`, workspaceID, approvalID)
	if err != nil {
		return "", "", fmt.Errorf("list approval assignment for expiry: %w", err)
	}
	defer rows.Close()
	if rows.Next() {
		var id string
		var approver string
		if err := rows.Scan(&id, &approver); err != nil {
			return "", "", fmt.Errorf("scan approval assignment for expiry: %w", err)
		}
		return id, approver, nil
	}
	return "", "", storeerr.Conflict("approval has no assignment to close")
}

// ensureNotExpired re-checks the authoritative deadline under the row lock and
// terminalizes the instance when the deadline passed. It returns true when the
// instance was just expired (so the caller can fail the decision without
// running its effect).
func ensureNotExpired(ctx context.Context, tx coredb.Tx, instance Instance, now time.Time) (bool, error) {
	if instance.State != StatePending || instance.ExpiresAt == nil || instance.ExpiresAt.After(now) {
		return false, nil
	}
	if err := expireInstanceInTx(ctx, tx, instance, now); err != nil {
		return false, err
	}
	return true, nil
}

func isTerminalRace(err error) bool {
	return errors.Is(err, storeerr.ErrConflict) || errors.Is(err, storeerr.ErrNotFound)
}
