package collaborator

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"time"

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

// Service owns the Human collaborator-request lifecycle on top of the generic
// Approval authority. Approval create/decide/cancel and the durable request row
// commit or roll back in one transaction.
type Service struct {
	db         coredb.DB
	approvals  *approval.Store
	taskboards Taskboard
}

// New constructs the collaborator-request service.
func New(db coredb.DB, approvals *approval.Store, taskboards Taskboard) *Service {
	return &Service{db: db, approvals: approvals, taskboards: taskboards}
}

// Submit creates (or replays) a collaborator request Approval. Already-active /
// self-owner / no-op requests never create an Approval.
func (service *Service) Submit(ctx context.Context, command SubmitCommand) (SubmitResult, error) {
	if service == nil || service.db == nil || service.approvals == nil || service.taskboards == nil {
		return SubmitResult{}, fmt.Errorf("collaborator request service is not configured")
	}
	command.WorkspaceID = strings.TrimSpace(command.WorkspaceID)
	command.RequesterUserID = strings.TrimSpace(command.RequesterUserID)
	command.ScopeType = strings.TrimSpace(command.ScopeType)
	command.ScopeID = strings.TrimSpace(command.ScopeID)
	command.Role = strings.TrimSpace(command.Role)
	command.ClientRequestID = strings.TrimSpace(command.ClientRequestID)
	if command.WorkspaceID == "" || command.RequesterUserID == "" ||
		command.ScopeType == "" || command.ScopeID == "" || command.ClientRequestID == "" {
		return SubmitResult{}, storeerr.BadRequest("workspace, requester, scope, and clientRequestId are required")
	}
	if command.Now.IsZero() {
		command.Now = time.Now().UTC()
	} else {
		command.Now = command.Now.UTC()
	}

	var result SubmitResult
	err := coredb.WithTx(ctx, service.db, func(tx coredb.Tx) error {
		txs := service.taskboards.InTx(tx)
		identity, err := txs.LookupRequesterIdentity(ctx, command.WorkspaceID, command.RequesterUserID)
		if err != nil {
			return err
		}
		target, err := txs.ResolveCollaboratorRequestTarget(ctx, taskboard.CollaboratorRequestTargetInput{
			WorkspaceID:       command.WorkspaceID,
			RequesterMemberID: identity.MemberID,
			ScopeType:         command.ScopeType,
			ScopeID:           command.ScopeID,
		})
		if err != nil {
			return err
		}
		role, err := normalizeRequestRole(command.Role)
		if err != nil {
			return err
		}

		// No-op: requester is the owner or already a collaborator. No Approval is
		// created; the submit is a successful no-op so the UI stays idempotent.
		if target.NoOp {
			result = SubmitResult{Status: "noop"}
			return nil
		}

		// Deterministic request id from the client request id so an identical
		// submit replays to the SAME request row (approval SubjectRef matches the
		// frozen idempotency facts).
		requestID := deterministicRequestID(command.ClientRequestID)

		// Replay an identical submit retry (same client request id) regardless of
		// wall-clock Now. Without this pre-check, a retry a moment later shifts
		// approvalExpiresAt (Now+7d) and the frozen payload digest, and the
		// Approval store's replay guard would report a conflict instead of
		// replaying. The requester-scoped idempotency key is authoritative; the
		// returned status reflects the current request lifecycle so the UI stays
		// consistent across a retry that raced a decision.
		if existing, err := service.approvals.InTx(tx).LoadByIdempotencyKey(
			ctx, command.WorkspaceID, identity.MemberID, command.ClientRequestID,
		); err == nil {
			if existing.SubjectRef != requestID {
				return storeerr.Conflict("collaborator request idempotency key is already bound to another request")
			}
			requestRow, err := txs.LoadCollaboratorRequest(ctx, existing.SubjectRef)
			if err != nil {
				return err
			}
			result = SubmitResult{
				ApprovalID: existing.ID,
				RequestID:  requestRow.ID,
				Status:     requestRow.Status,
				Created:    false,
			}
			return nil
		} else if !isNotFound(err) {
			return err
		}

		approvalExpiresAt := command.Now.Add(DefaultApprovalValidity)
		approvalID, err := identifiers.NewEntityID("aprvl")
		if err != nil {
			return fmt.Errorf("generate collaborator request approval id: %w", err)
		}
		payload := ApprovalPayload{
			SchemaVersion:         "1",
			RequesterMemberID:     identity.MemberID,
			RequesterUserID:       identity.UserID,
			OwnerMemberID:         target.OwnerMemberID,
			ScopeType:             target.ScopeType,
			ScopeID:               target.ScopeID,
			ScopeDisplayLabel:     target.DisplayLabel,
			Role:                  role,
			CollaboratorExpiresAt: command.CollaboratorExpiresAt,
			ApprovalExpiresAt:     &approvalExpiresAt,
			Effect:                "grant_contributor",
			RequestID:             requestID,
		}
		payloadJSON, err := canonicalPayloadJSON(payload)
		if err != nil {
			return fmt.Errorf("marshal collaborator request payload: %w", err)
		}
		// Compact deterministic pending key (<=64 chars): fences one pending
		// Approval per (requester, target) so a second pending request for the
		// same target conflicts while the first is still pending.
		pendingKey := compactPendingKey(
			command.WorkspaceID,
			identity.MemberID,
			target.ScopeType,
			target.ScopeID,
			target.OwnerMemberID,
		)
		instance, _, created, err := service.approvals.InTx(tx).Create(ctx, approval.CreateParams{
			ID:                approvalID,
			WorkspaceID:       command.WorkspaceID,
			SubjectType:       SubjectType,
			SubjectRef:        requestID,
			SubjectVersion:    1,
			Payload:           payloadJSON,
			PayloadDigest:     digestOf(payloadJSON),
			IdempotencyKey:    command.ClientRequestID,
			PendingKey:        pendingKey,
			ApplicantMemberID: identity.MemberID,
			ApproverMemberIDs: []string{target.OwnerMemberID},
			CorrelationID:     command.ClientRequestID,
			ExpiresAt:         &approvalExpiresAt,
			Now:               command.Now,
		})
		if err != nil {
			return err
		}
		if err := txs.CreateCollaboratorRequest(ctx, taskboard.CreateCollaboratorRequestParams{
			ID:                    requestID,
			WorkspaceID:           command.WorkspaceID,
			ApprovalID:            instance.ID,
			RequesterMemberID:     identity.MemberID,
			OwnerMemberID:         target.OwnerMemberID,
			ScopeType:             target.ScopeType,
			ScopeID:               target.ScopeID,
			Role:                  role,
			CollaboratorExpiresAt: command.CollaboratorExpiresAt,
			ApprovalExpiresAt:     &approvalExpiresAt,
			Now:                   command.Now,
		}); err != nil {
			return err
		}
		result = SubmitResult{
			ApprovalID: instance.ID,
			RequestID:  requestID,
			Status:     "pending",
			Created:    created,
		}
		return nil
	})
	return result, err
}

// LookupReplay performs a read-only idempotency lookup. It never creates an
// Approval on a miss.
func (service *Service) LookupReplay(ctx context.Context, command SubmitCommand) (ReplayResult, bool, error) {
	if service == nil || service.approvals == nil || service.db == nil {
		return ReplayResult{}, false, fmt.Errorf("collaborator request service is not configured")
	}
	command.WorkspaceID = strings.TrimSpace(command.WorkspaceID)
	command.RequesterUserID = strings.TrimSpace(command.RequesterUserID)
	command.ClientRequestID = strings.TrimSpace(command.ClientRequestID)
	if command.WorkspaceID == "" || command.RequesterUserID == "" || command.ClientRequestID == "" {
		return ReplayResult{}, false, storeerr.BadRequest("workspace, requester, and clientRequestId are required")
	}
	// Resolve the requester member so the applicant-scoped idempotency lookup is
	// exact (never trusts a client-supplied member id).
	var memberID string
	if err := service.db.QueryRow(ctx, `
SELECT id
FROM members
WHERE workspace_id = $1 AND user_id = $2 AND type = 'human' AND status = 'active'
`, command.WorkspaceID, command.RequesterUserID).Scan(&memberID); err != nil {
		return ReplayResult{}, false, storeerr.NotFound("collaborator requester is not an active Human workspace member")
	}
	instance, err := service.approvals.LoadByIdempotencyKey(ctx, command.WorkspaceID, memberID, command.ClientRequestID)
	if err != nil {
		if isNotFound(err) {
			return ReplayResult{}, false, nil
		}
		return ReplayResult{}, false, err
	}
	return ReplayResult{
		ApprovalID: instance.ID,
		RequestID:  instance.SubjectRef,
		Status:     instance.State,
		Found:      true,
	}, true, nil
}

// Decide applies an Approval decision and, on approval, grants the collaborator
// via the canonical shared primitive. Reject and failed/stale effects never
// grant. The effect revalidates frozen facts under the row lock before granting,
// and terminalizes Approval + request atomically.
func (service *Service) Decide(ctx context.Context, params approval.DecideParams) (DecideResult, error) {
	if service == nil || service.approvals == nil || service.taskboards == nil {
		return DecideResult{}, fmt.Errorf("collaborator request service is not configured")
	}
	params.WorkspaceID = strings.TrimSpace(params.WorkspaceID)
	params.ApprovalID = strings.TrimSpace(params.ApprovalID)
	if params.WorkspaceID == "" || params.ApprovalID == "" {
		return DecideResult{}, storeerr.BadRequest("workspace and approval are required")
	}
	if params.Now.IsZero() {
		params.Now = time.Now().UTC()
	} else {
		params.Now = params.Now.UTC()
	}

	var result DecideResult
	decisionResult, err := service.approvals.DecideWithEffect(ctx, params, service.decisionEffect)
	if err != nil {
		return DecideResult{}, err
	}
	result.ApprovalID = decisionResult.Instance.ID
	result.RequestID = decisionResult.Instance.SubjectRef
	result.Status = decisionResult.Instance.State
	return result, nil
}

// Cancel cancels a pending request by its applicant (requester). The request
// row terminalizes as cancelled in the same transaction.
func (service *Service) Cancel(ctx context.Context, command CancelCommand) (DecideResult, error) {
	if service == nil || service.db == nil || service.approvals == nil || service.taskboards == nil {
		return DecideResult{}, fmt.Errorf("collaborator request service is not configured")
	}
	command.WorkspaceID = strings.TrimSpace(command.WorkspaceID)
	command.RequesterUserID = strings.TrimSpace(command.RequesterUserID)
	command.ApprovalID = strings.TrimSpace(command.ApprovalID)
	command.ClientCancellationID = strings.TrimSpace(command.ClientCancellationID)
	command.Reason = strings.TrimSpace(command.Reason)
	if command.WorkspaceID == "" || command.RequesterUserID == "" || command.ApprovalID == "" ||
		command.ClientCancellationID == "" {
		return DecideResult{}, storeerr.BadRequest("workspace, requester, approval, and client id are required")
	}
	if command.Now.IsZero() {
		command.Now = time.Now().UTC()
	} else {
		command.Now = command.Now.UTC()
	}

	var result DecideResult
	err := coredb.WithTx(ctx, service.db, func(tx coredb.Tx) error {
		identity, err := service.taskboards.InTx(tx).LookupRequesterIdentity(ctx, command.WorkspaceID, command.RequesterUserID)
		if err != nil {
			return err
		}
		// Resolve the authoritative current version server-side (a concurrent
		// decision/cancel is fenced by the ExpectedVersion guard inside
		// CancelByApplicant, which fails closed on a version change).
		expectedVersion := command.ExpectedVersion
		if expectedVersion <= 0 {
			instance, loadErr := service.approvals.InTx(tx).Load(ctx, command.WorkspaceID, command.ApprovalID)
			if loadErr != nil {
				return loadErr
			}
			expectedVersion = instance.Version
		}
		decisionResult, err := service.approvals.InTx(tx).CancelByApplicant(ctx, approval.ApplicantCancelParams{
			WorkspaceID:          command.WorkspaceID,
			ApprovalID:           command.ApprovalID,
			ActorMemberID:        identity.MemberID,
			ExpectedVersion:      expectedVersion,
			ClientCancellationID: command.ClientCancellationID,
			Reason:               command.Reason,
			Now:                  command.Now,
		})
		if err != nil {
			return err
		}
		requestID := decisionResult.Instance.SubjectRef
		if err := service.taskboards.InTx(tx).TerminalizeCollaboratorRequest(ctx, requestID, taskboard.CollaboratorRequestStatusCancelled, nil); err != nil {
			return err
		}
		result = DecideResult{
			ApprovalID: decisionResult.Instance.ID,
			RequestID:  requestID,
			Status:     taskboard.CollaboratorRequestStatusCancelled,
		}
		return nil
	})
	return result, err
}

// ListRequesterRequests returns the requester's collaborator request statuses.
func (service *Service) ListRequesterRequests(ctx context.Context, workspaceID, requesterUserID string) ([]taskboard.CollaboratorRequest, error) {
	if service == nil || service.db == nil || service.taskboards == nil {
		return nil, fmt.Errorf("collaborator request service is not configured")
	}
	workspaceID = strings.TrimSpace(workspaceID)
	requesterUserID = strings.TrimSpace(requesterUserID)
	if workspaceID == "" || requesterUserID == "" {
		return nil, storeerr.BadRequest("workspace and requester are required")
	}
	var memberID string
	if err := service.db.QueryRow(ctx, `
SELECT id
FROM members
WHERE workspace_id = $1 AND user_id = $2 AND type = 'human' AND status = 'active'
`, workspaceID, requesterUserID).Scan(&memberID); err != nil {
		return nil, storeerr.NotFound("collaborator requester is not an active Human workspace member")
	}
	return service.taskboards.ListRequesterCollaboratorRequests(ctx, workspaceID, memberID)
}

// TerminalizeExpired settles a still-pending collaborator request row to
// 'expired' once its Approval reached the authoritative expired terminal state.
// This is the durable requester-status settlement for the lazy expiry path: the
// Approval instance (state='expired', expires_at authoritative) is the source of
// truth, and the request row follows so the requester status surface never shows
// a stale pending. It is idempotent — an already-terminal request row is left
// untouched — and only transitions rows whose Approval is actually expired.
func (service *Service) TerminalizeExpired(ctx context.Context, workspaceID, requestID string) error {
	if service == nil || service.db == nil || service.approvals == nil || service.taskboards == nil {
		return fmt.Errorf("collaborator request service is not configured")
	}
	workspaceID = strings.TrimSpace(workspaceID)
	requestID = strings.TrimSpace(requestID)
	if workspaceID == "" || requestID == "" {
		return storeerr.BadRequest("workspace and request are required")
	}
	err := coredb.WithTx(ctx, service.db, func(tx coredb.Tx) error {
		txs := service.taskboards.InTx(tx)
		request, err := txs.LoadCollaboratorRequest(ctx, requestID)
		if err != nil {
			return err
		}
		if request.WorkspaceID != workspaceID {
			return storeerr.NotFound("collaborator request not found")
		}
		if request.Status != taskboard.CollaboratorRequestStatusPending {
			// Already terminal (approved/rejected/cancelled/expired); nothing to settle.
			return nil
		}
		// The Approval must actually be expired under the authoritative clock.
		instance, err := service.approvals.InTx(tx).Load(ctx, workspaceID, request.ApprovalID)
		if err != nil {
			return err
		}
		if instance.State != approval.StateExpired {
			// Not expired yet; leave the row pending.
			return nil
		}
		if err := txs.TerminalizeCollaboratorRequest(ctx, request.ID, taskboard.CollaboratorRequestStatusExpired, nil); err != nil {
			// A concurrent decision/cancel/expiry already terminalized the row:
			// idempotent settlement, nothing to do.
			if isNotFound(err) || errors.Is(err, storeerr.ErrConflict) {
				return nil
			}
			return err
		}
		return nil
	})
	return err
}

// decisionEffect is the Approval decision effect: revalidate + grant (approve)
// or terminalize (reject), all inside the Approval's own transaction.
func (service *Service) decisionEffect(
	ctx context.Context,
	tx coredb.Tx,
	instance approval.Instance,
	decision string,
) (approval.DecisionEffectResult, error) {
	var payload ApprovalPayload
	if err := json.Unmarshal(instance.Payload, &payload); err != nil {
		return approval.DecisionEffectResult{}, fmt.Errorf("decode collaborator request payload: %w", err)
	}
	if payload.RequestID == "" {
		return approval.DecisionEffectResult{}, storeerr.BadRequest("collaborator request payload is missing request id")
	}
	txs := service.taskboards.InTx(tx)
	request, err := txs.LoadCollaboratorRequest(ctx, payload.RequestID)
	if err != nil {
		return approval.DecisionEffectResult{}, err
	}
	if decision == approval.DecisionApproved {
		// Revalidate frozen facts and apply the canonical grant exactly once.
		if err := txs.ApplyApprovedCollaboratorRequest(ctx, request); err != nil {
			return approval.DecisionEffectResult{}, err
		}
		now := time.Now().UTC()
		if err := txs.TerminalizeCollaboratorRequest(ctx, request.ID, taskboard.CollaboratorRequestStatusApproved, &now); err != nil {
			return approval.DecisionEffectResult{}, err
		}
		return approval.DecisionEffectResult{}, nil
	}
	// Reject: terminalize the request, never grant.
	if err := txs.TerminalizeCollaboratorRequest(ctx, request.ID, taskboard.CollaboratorRequestStatusRejected, nil); err != nil {
		return approval.DecisionEffectResult{}, err
	}
	return approval.DecisionEffectResult{}, nil
}

func normalizeRequestRole(role string) (string, error) {
	switch strings.TrimSpace(role) {
	case "", "contributor":
		return "contributor", nil
	default:
		return "", storeerr.BadRequest("invalid collaborator role")
	}
}

func digestOf(payload []byte) string {
	sum := sha256.Sum256(payload)
	return hex.EncodeToString(sum[:])
}

func isNotFound(err error) bool {
	return errors.Is(err, storeerr.ErrNotFound)
}

// canonicalPayloadJSON marshals the frozen payload in canonical form (map key
// order) so the Approval digest matches the store's canonicalization exactly.
func canonicalPayloadJSON(payload ApprovalPayload) ([]byte, error) {
	raw, err := json.Marshal(payload)
	if err != nil {
		return nil, err
	}
	var decoded any
	if err := json.Unmarshal(raw, &decoded); err != nil {
		return nil, err
	}
	return json.Marshal(decoded)
}

// NewWithStore constructs the service from a concrete *taskboard.Store,
// adapting its InTx return type to the TaskboardTx port.
func NewWithStore(db coredb.DB, approvals *approval.Store, store *taskboard.Store) *Service {
	return New(db, approvals, taskboardStoreAdapter{store: store})
}

type taskboardStoreAdapter struct{ store *taskboard.Store }

func (adapter taskboardStoreAdapter) InTx(tx coredb.Tx) TaskboardTx {
	return adapter.store.InTx(tx)
}

func (adapter taskboardStoreAdapter) ListRequesterCollaboratorRequests(
	ctx context.Context,
	workspaceID, requesterMemberID string,
) ([]taskboard.CollaboratorRequest, error) {
	return adapter.store.ListRequesterCollaboratorRequests(ctx, workspaceID, requesterMemberID)
}

// compactPendingKey derives a short deterministic pending key from the
// requester + current target owner/scope facts (the Approval store caps
// idempotency/pending keys at 64 bytes).
func compactPendingKey(workspaceID, requesterMemberID, scopeType, scopeID, ownerMemberID string) string {
	sum := sha256.Sum256([]byte(strings.Join([]string{
		"collaborator-request", workspaceID, requesterMemberID, ownerMemberID, scopeType, scopeID,
	}, ":")))
	return "crq:" + hex.EncodeToString(sum[:8])
}

// deterministicRequestID derives a stable request id from the client request id
// (<=64 chars, matching the task_collaborator_requests.id column).
func deterministicRequestID(clientRequestID string) string {
	sum := sha256.Sum256([]byte(strings.TrimSpace(clientRequestID)))
	return "crq_" + hex.EncodeToString(sum[:12])
}
