// Package prwatch exposes the provider-neutral application service
// used by the GitHub and Gitea Builtin Extensions. It delegates durable watch
// rules to the existing App Service Actor store and owns only Action decoding,
// authenticated identity binding, target/resource resolution, and the bounded
// Agent-visible result projection.
package prwatch

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"strings"
	"time"

	"github.com/tidbcloud/AgentTeam/internal/service/extensionflow"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/app"
	"github.com/tidbcloud/AgentTeam/internal/store/storeerr"
)

const (
	ActionWatchPullRequest = "watch-pull-request"
	ActionListWatches      = "list-watches"
	ActionPauseWatch       = "pause-watch"
	ActionResumeWatch      = "resume-watch"
	ActionUnwatch          = "unwatch"

	createdViaAppCommand = "app_command"
)

var appAuthorizationIdentityMismatch = storeerr.Forbidden("app authorization identity mismatch")

// Invocation is the trusted in-process envelope resolved by the authoritative
// Extension runtime before dispatching to this service. Provider and Service
// Installation identities never come from Agent JSON.
type Invocation struct {
	Provider, WorkspaceID, AgentID, AgentMemberID string
	ServiceInstallationID, Action                 string
	Input                                         json.RawMessage
}

// Invoker is the narrow provider-executor dependency implemented by *Service.
type Invoker interface {
	Invoke(context.Context, Invocation) (json.RawMessage, error)
}

// Store is the existing App watch business-rule surface used by Service.
// *app.Store satisfies it.
type Store interface {
	CheckAgentAppAuthorization(
		context.Context,
		string,
		string,
		string,
		[]string,
	) (app.AgentAppAuthorizationRecord, error)
	ResolveServiceEventSubscriptionTargetForAgentTarget(
		context.Context,
		agent.AuthenticatedAgent,
		string,
	) (app.ServiceEventTarget, error)
	CreateServiceEventSubscription(
		context.Context,
		string,
		app.CreateServiceEventSubscriptionParams,
	) (app.ServiceEventSubscriptionResult, error)
	ListServiceEventSubscriptions(
		context.Context,
		string,
		app.ServiceEventActor,
		string,
		string,
		string,
		string,
		app.ListServiceEventSubscriptionsOptions,
	) ([]app.ServiceEventSubscriptionRecord, error)
	ListServiceEventSubscriptionsPage(
		context.Context,
		string,
		app.ServiceEventActor,
		string,
		string,
		string,
		string,
		app.ListServiceEventSubscriptionsOptions,
	) (app.ServiceEventSubscriptionPage, error)
	GetServiceEventSubscriptionForAppInstallation(
		context.Context,
		string,
		app.ServiceEventActor,
		string,
		string,
		string,
		[]string,
	) (app.ServiceEventSubscriptionRecord, error)
	PauseServiceEventSubscription(
		context.Context,
		string,
		app.ServiceEventActor,
		string,
		string,
	) (app.ServiceEventSubscriptionRecord, error)
	ResumeServiceEventSubscription(
		context.Context,
		string,
		app.ServiceEventActor,
		string,
	) (app.ServiceEventSubscriptionRecord, error)
	RemoveServiceEventSubscription(
		context.Context,
		string,
		app.ServiceEventActor,
		string,
	) (app.ServiceEventSubscriptionRecord, error)
}

var _ Store = (*app.Store)(nil)

// Service invokes the five pull-request watch Actions through the canonical
// App store.
type Service struct {
	store Store
}

// New constructs a provider-neutral watch Action service.
func New(store Store) *Service {
	return &Service{store: store}
}

type actionBinding struct {
	scope string
}

var actionBindings = map[string]actionBinding{
	ActionWatchPullRequest: {scope: app.ServiceEventSubscriptionScopeWrite},
	ActionListWatches:      {scope: app.ServiceEventSubscriptionScopeRead},
	ActionPauseWatch:       {scope: app.ServiceEventSubscriptionScopeWrite},
	ActionResumeWatch:      {scope: app.ServiceEventSubscriptionScopeWrite},
	ActionUnwatch:          {scope: app.ServiceEventSubscriptionScopeWrite},
}

// Invoke executes one real App-backed watch Action.
func (service *Service) Invoke(ctx context.Context, invocation Invocation) (json.RawMessage, error) {
	if service == nil || service.store == nil {
		return nil, fmt.Errorf("pull request watch service is not configured")
	}
	normalized, binding, err := normalizeInvocation(invocation)
	if err != nil {
		return nil, err
	}
	authorization, err := service.store.CheckAgentAppAuthorization(
		ctx,
		normalized.WorkspaceID,
		normalized.AgentID,
		normalized.ServiceInstallationID,
		[]string{binding.scope},
	)
	if err != nil {
		return nil, err
	}
	// The authoritative Extension preflight already resolved the selected
	// Definition and Action. Legacy mcp.tools names are not Extension authority;
	// only the App authorization's current read/write subscription scope and
	// exact host identity are rechecked here.
	if !authorizationMatchesInvocation(authorization, normalized) {
		return nil, appAuthorizationIdentityMismatch
	}
	actor := app.ServiceEventActor{
		ActorType:        "agent",
		ActorID:          normalized.AgentID,
		MemberID:         normalized.AgentMemberID,
		AuthorizerUserID: strings.TrimSpace(authorization.AuthorizedByMemberID),
	}
	if actor.AuthorizerUserID == "" {
		return nil, appAuthorizationIdentityMismatch
	}

	switch normalized.Action {
	case ActionWatchPullRequest:
		return service.watch(ctx, normalized, actor)
	case ActionListWatches:
		return service.list(ctx, normalized, actor)
	case ActionPauseWatch, ActionResumeWatch, ActionUnwatch:
		return service.mutate(ctx, normalized, actor)
	default:
		return nil, storeerr.BadRequest("pull request watch action is not supported")
	}
}

func normalizeInvocation(invocation Invocation) (Invocation, actionBinding, error) {
	invocation.Provider = strings.ToLower(strings.TrimSpace(invocation.Provider))
	invocation.WorkspaceID = strings.TrimSpace(invocation.WorkspaceID)
	invocation.AgentID = strings.TrimSpace(invocation.AgentID)
	invocation.AgentMemberID = strings.TrimSpace(invocation.AgentMemberID)
	invocation.ServiceInstallationID = strings.TrimSpace(invocation.ServiceInstallationID)
	invocation.Action = strings.TrimSpace(invocation.Action)
	switch invocation.Provider {
	case app.PullRequestSubscriptionProviderGitHub, app.PullRequestSubscriptionProviderGitea:
	default:
		return Invocation{}, actionBinding{}, storeerr.BadRequest("pull request watch provider is not supported")
	}
	if invocation.WorkspaceID == "" ||
		invocation.AgentID == "" ||
		invocation.AgentMemberID == "" ||
		invocation.ServiceInstallationID == "" {
		return Invocation{}, actionBinding{}, storeerr.BadRequest(
			"workspaceId, agentId, agentMemberId, and serviceInstallationId are required",
		)
	}
	binding, ok := actionBindings[invocation.Action]
	if !ok {
		return Invocation{}, actionBinding{}, storeerr.BadRequest(
			"pull request watch action is not supported",
		)
	}
	return invocation, binding, nil
}

func authorizationMatchesInvocation(
	authorization app.AgentAppAuthorizationRecord,
	invocation Invocation,
) bool {
	return strings.TrimSpace(authorization.WorkspaceID) == invocation.WorkspaceID &&
		strings.TrimSpace(authorization.AgentID) == invocation.AgentID &&
		strings.TrimSpace(authorization.AgentMemberID) == invocation.AgentMemberID &&
		strings.TrimSpace(authorization.AppID) == "app_"+invocation.Provider &&
		strings.TrimSpace(authorization.AppSlug) == invocation.Provider &&
		strings.TrimSpace(authorization.AppInstallationID) == invocation.ServiceInstallationID
}

type watchInput struct {
	Target          string   `json:"target"`
	PullRequest     string   `json:"pullRequest"`
	Events          []string `json:"events"`
	NotifyPrincipal *bool    `json:"notifyPrincipal"`
}

func (service *Service) watch(
	ctx context.Context,
	invocation Invocation,
	actor app.ServiceEventActor,
) (json.RawMessage, error) {
	var input watchInput
	if err := decodeInput(invocation.Input, &input); err != nil {
		return nil, err
	}
	targetLabel, err := requiredTarget(input.Target)
	if err != nil {
		return nil, err
	}
	target, err := service.resolveTarget(ctx, invocation, targetLabel)
	if err != nil {
		return nil, err
	}
	target = targetWithDisplay(target, targetLabel)
	resource, err := app.NormalizePullRequestSubscriptionRef(
		invocation.Provider,
		input.PullRequest,
	)
	if err != nil {
		return nil, err
	}
	eventFilters, err := normalizedEventFilters(input.Events, input.Events != nil)
	if err != nil {
		return nil, err
	}
	result, err := service.store.CreateServiceEventSubscription(
		ctx,
		invocation.WorkspaceID,
		app.CreateServiceEventSubscriptionParams{
			Provider:         invocation.Provider,
			InstallationID:   invocation.ServiceInstallationID,
			Actor:            actor,
			Target:           target,
			ResourceRef:      resource.ResourceRef,
			ResourceKey:      resource.ResourceKey,
			EventFilters:     eventFilters,
			ValidateResource: true,
			NotifyPrincipal:  input.NotifyPrincipal,
			CreatedVia:       createdViaAppCommand,
		},
	)
	if err != nil {
		return nil, err
	}
	watch := watchProjection(result.Subscription)
	if watch.Target == "" {
		watch.Target = targetLabel
	}
	return marshalResult(watch)
}

type listInput struct {
	Target   string `json:"target"`
	Resource string `json:"resource"`
	Status   string `json:"status"`
	Limit    int    `json:"limit"`
	Cursor   string `json:"cursor"`
}

type watchList struct {
	Watches    []watchProjectionResult `json:"watches"`
	Limit      int                     `json:"limit,omitempty"`
	Cursor     string                  `json:"cursor,omitempty"`
	HasMore    bool                    `json:"hasMore,omitempty"`
	NextCursor string                  `json:"nextCursor,omitempty"`
}

func (service *Service) list(
	ctx context.Context,
	invocation Invocation,
	actor app.ServiceEventActor,
) (json.RawMessage, error) {
	var input listInput
	if err := decodeInput(invocation.Input, &input); err != nil {
		return nil, err
	}
	var targetKind, targetRef string
	if strings.TrimSpace(input.Target) != "" {
		target, err := service.resolveTarget(ctx, invocation, input.Target)
		if err != nil {
			return nil, err
		}
		targetKind = target.TargetKind
		targetRef = target.TargetRef
	}
	var resourceKey string
	if strings.TrimSpace(input.Resource) != "" {
		resource, err := app.NormalizePullRequestSubscriptionRef(
			invocation.Provider,
			input.Resource,
		)
		if err != nil {
			return nil, err
		}
		resourceKey = resource.ResourceKey
	}
	page, err := service.store.ListServiceEventSubscriptionsPage(
		ctx,
		invocation.WorkspaceID,
		actor,
		invocation.ServiceInstallationID,
		targetKind,
		targetRef,
		strings.TrimSpace(input.Status),
		app.ListServiceEventSubscriptionsOptions{
			ResourceKey: resourceKey,
			Limit:       input.Limit,
			Cursor:      strings.TrimSpace(input.Cursor),
			Page:        true,
		},
	)
	if err != nil {
		return nil, err
	}
	watches := make([]watchProjectionResult, 0, len(page.Subscriptions))
	for _, subscription := range page.Subscriptions {
		if !subscriptionMatchesInvocation(
			subscription,
			invocation,
			targetKind,
			targetRef,
			resourceKey,
		) {
			return nil, fmt.Errorf("pull request watch store returned an out-of-scope subscription")
		}
		watches = append(watches, watchProjection(subscription))
	}
	return marshalResult(watchList{
		Watches: watches, Limit: page.Limit, Cursor: page.Cursor,
		HasMore: page.HasMore, NextCursor: page.NextCursor,
	})
}

type mutationInput struct {
	WatchID     string `json:"watchId"`
	Target      string `json:"target"`
	PullRequest string `json:"pullRequest"`
}

type mutationResult struct {
	WatchID string `json:"watchId"`
	Status  string `json:"status"`
}

func (service *Service) mutate(
	ctx context.Context,
	invocation Invocation,
	actor app.ServiceEventActor,
) (json.RawMessage, error) {
	var input mutationInput
	if err := decodeInput(invocation.Input, &input); err != nil {
		return nil, err
	}
	subscription, err := service.resolveMutationSubscription(
		ctx,
		invocation,
		actor,
		input,
	)
	if err != nil {
		return nil, err
	}
	var status string
	switch invocation.Action {
	case ActionPauseWatch:
		subscription, err = service.store.PauseServiceEventSubscription(
			ctx,
			invocation.WorkspaceID,
			actor,
			subscription.ID,
			createdViaAppCommand,
		)
		status = "paused"
	case ActionResumeWatch:
		subscription, err = service.store.ResumeServiceEventSubscription(
			ctx, invocation.WorkspaceID, actor, subscription.ID,
		)
		status = "resumed"
	case ActionUnwatch:
		subscription, err = service.store.RemoveServiceEventSubscription(
			ctx, invocation.WorkspaceID, actor, subscription.ID,
		)
		status = "deleted"
	}
	if err != nil {
		return nil, err
	}
	return marshalResult(mutationResult{WatchID: subscription.ID, Status: status})
}

func (service *Service) resolveMutationSubscription(
	ctx context.Context,
	invocation Invocation,
	actor app.ServiceEventActor,
	input mutationInput,
) (app.ServiceEventSubscriptionRecord, error) {
	if watchID := strings.TrimSpace(input.WatchID); watchID != "" {
		subscription, err := service.store.GetServiceEventSubscriptionForAppInstallation(
			ctx,
			invocation.WorkspaceID,
			actor,
			invocation.Provider,
			invocation.ServiceInstallationID,
			watchID,
			[]string{app.ServiceEventSubscriptionScopeWrite},
		)
		if err != nil {
			return app.ServiceEventSubscriptionRecord{}, err
		}
		if !subscriptionMatchesInvocation(subscription, invocation, "", "", "") {
			return app.ServiceEventSubscriptionRecord{}, storeerr.NotFound("watch_not_found")
		}
		return subscription, nil
	}
	targetLabel, err := requiredTarget(input.Target)
	if err != nil {
		return app.ServiceEventSubscriptionRecord{}, err
	}
	target, err := service.resolveTarget(ctx, invocation, targetLabel)
	if err != nil {
		return app.ServiceEventSubscriptionRecord{}, err
	}
	resource, err := app.NormalizePullRequestSubscriptionRef(
		invocation.Provider,
		input.PullRequest,
	)
	if err != nil {
		return app.ServiceEventSubscriptionRecord{}, err
	}
	subscriptions, err := service.store.ListServiceEventSubscriptions(
		ctx,
		invocation.WorkspaceID,
		actor,
		invocation.ServiceInstallationID,
		target.TargetKind,
		target.TargetRef,
		"",
		app.ListServiceEventSubscriptionsOptions{
			ResourceKey: resource.ResourceKey,
			Limit:       2,
		},
	)
	if err != nil {
		return app.ServiceEventSubscriptionRecord{}, err
	}
	for _, subscription := range subscriptions {
		if !subscriptionMatchesInvocation(
			subscription,
			invocation,
			target.TargetKind,
			target.TargetRef,
			resource.ResourceKey,
		) {
			return app.ServiceEventSubscriptionRecord{}, fmt.Errorf(
				"pull request watch store returned an out-of-scope subscription",
			)
		}
	}
	switch len(subscriptions) {
	case 0:
		return app.ServiceEventSubscriptionRecord{}, storeerr.NotFound("watch_not_found")
	case 1:
		return subscriptions[0], nil
	default:
		return app.ServiceEventSubscriptionRecord{}, storeerr.Conflict(
			"multiple watches match target and pull request; use watchId",
		)
	}
}

func subscriptionMatchesInvocation(
	subscription app.ServiceEventSubscriptionRecord,
	invocation Invocation,
	targetKind string,
	targetRef string,
	resourceKey string,
) bool {
	if strings.TrimSpace(subscription.Provider) != invocation.Provider ||
		strings.TrimSpace(subscription.AppInstallationID) != invocation.ServiceInstallationID {
		return false
	}
	if targetKind != "" &&
		(strings.TrimSpace(subscription.TargetKind) != strings.TrimSpace(targetKind) ||
			strings.TrimSpace(subscription.TargetRef) != strings.TrimSpace(targetRef)) {
		return false
	}
	return resourceKey == "" ||
		strings.TrimSpace(subscription.ResourceKey) == strings.TrimSpace(resourceKey)
}

func (service *Service) resolveTarget(
	ctx context.Context,
	invocation Invocation,
	target string,
) (app.ServiceEventTarget, error) {
	target = strings.TrimSpace(target)
	if target == "" {
		return app.ServiceEventTarget{}, storeerr.BadRequest("target is required")
	}
	return service.store.ResolveServiceEventSubscriptionTargetForAgentTarget(
		ctx,
		agent.AuthenticatedAgent{
			WorkspaceID: invocation.WorkspaceID,
			AgentID:     invocation.AgentID,
			MemberID:    invocation.AgentMemberID,
		},
		target,
	)
}

func requiredTarget(explicit string) (string, error) {
	if target := strings.TrimSpace(explicit); target != "" {
		return target, nil
	}
	return "", storeerr.BadRequest("target is required")
}

func targetWithDisplay(target app.ServiceEventTarget, display string) app.ServiceEventTarget {
	snapshot := make(map[string]any, len(target.ScopeSnapshot)+3)
	for key, value := range target.ScopeSnapshot {
		if key = strings.TrimSpace(key); key != "" {
			snapshot[key] = value
		}
	}
	snapshot["target_display"] = strings.TrimSpace(display)
	snapshot["target_confidence"] = "exact"
	snapshot["target_reason"] = "requested_target"
	target.ScopeSnapshot = snapshot
	return target
}

func normalizedEventFilters(events []string, explicit bool) (map[string]any, error) {
	if !explicit {
		return map[string]any{}, nil
	}
	normalized, err := app.NormalizePullRequestSubscriptionEvents(events)
	if err != nil {
		return nil, err
	}
	return map[string]any{"events": normalized}, nil
}

type watchProjectionResult struct {
	WatchID     string   `json:"watchId"`
	Provider    string   `json:"provider,omitempty"`
	Target      string   `json:"target,omitempty"`
	PullRequest string   `json:"pullRequest,omitempty"`
	Resource    string   `json:"resource,omitempty"`
	Status      string   `json:"status,omitempty"`
	Events      []string `json:"events,omitempty"`
	CreatedAt   string   `json:"createdAt,omitempty"`
	UpdatedAt   string   `json:"updatedAt,omitempty"`
}

func watchProjection(subscription app.ServiceEventSubscriptionRecord) watchProjectionResult {
	result := watchProjectionResult{
		WatchID:     strings.TrimSpace(subscription.ID),
		Provider:    strings.TrimSpace(subscription.Provider),
		PullRequest: strings.TrimSpace(subscription.ResourceRef),
		Resource:    strings.TrimSpace(subscription.ResourceKey),
		Status:      strings.TrimSpace(subscription.Status),
		Events:      projectionEvents(subscription.EventFilters),
	}
	var snapshot map[string]any
	if json.Unmarshal(subscription.ScopeSnapshot, &snapshot) == nil &&
		strings.EqualFold(snapshotText(snapshot, "target_confidence"), "exact") {
		result.Target = snapshotText(snapshot, "target_display")
	}
	if !subscription.CreatedAt.IsZero() {
		result.CreatedAt = subscription.CreatedAt.UTC().Format(time.RFC3339)
	}
	if !subscription.UpdatedAt.IsZero() {
		result.UpdatedAt = subscription.UpdatedAt.UTC().Format(time.RFC3339)
	}
	return result
}

func projectionEvents(payload []byte) []string {
	var filters struct {
		Events []string `json:"events"`
	}
	if json.Unmarshal(payload, &filters) != nil {
		return nil
	}
	return filters.Events
}

func snapshotText(snapshot map[string]any, key string) string {
	value, _ := snapshot[key].(string)
	return strings.TrimSpace(value)
}

func decodeInput(raw json.RawMessage, destination any) error {
	raw = bytes.TrimSpace(raw)
	if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
		raw = []byte(`{}`)
	}
	decoder := json.NewDecoder(bytes.NewReader(raw))
	decoder.DisallowUnknownFields()
	if err := decoder.Decode(destination); err != nil {
		return storeerr.BadRequest("invalid pull request watch input")
	}
	if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
		return storeerr.BadRequest("invalid pull request watch input")
	}
	return nil
}

func marshalResult(result any) (json.RawMessage, error) {
	payload, err := json.Marshal(result)
	if err != nil {
		return nil, fmt.Errorf("encode pull request watch result: %w", err)
	}
	return payload, nil
}

// ClassifyInvocationError maps provider-neutral watch failures into the closed
// Extension execution contract shared by the GitHub and Gitea Builtins.
func ClassifyInvocationError(err error) error {
	if err == nil {
		return nil
	}
	code := extensionflow.InvocationErrorCode("")
	message := ""
	switch {
	case errors.Is(err, app.ErrPullRequestProviderAuthUnavailable):
		code = extensionflow.InvocationProviderAuthUnavailable
		message = "pull request provider authorization unavailable"
	case errors.Is(err, app.ErrPullRequestProviderRateLimited):
		code = extensionflow.InvocationRateLimited
		message = "pull request provider rate limited"
	case errors.Is(err, app.ErrPullRequestProviderUpstreamFailure):
		code = extensionflow.InvocationUpstreamFailure
		message = "pull request provider request failed"
	case errors.Is(err, storeerr.ErrBadRequest),
		errors.Is(err, storeerr.ErrUnprocessable):
		code = extensionflow.InvocationValidation
		message = "invalid pull request watch request"
	case errors.Is(err, storeerr.ErrForbidden),
		errors.Is(err, storeerr.ErrUnauthorized):
		code = extensionflow.InvocationPermissionDenied
		message = "pull request watch permission denied"
	case errors.Is(err, storeerr.ErrNotFound),
		errors.Is(err, storeerr.ErrGone):
		code = extensionflow.InvocationNotFound
		message = "pull request watch not found"
	case errors.Is(err, storeerr.ErrConflict) &&
		strings.Contains(err.Error(), "rate_limited"):
		code = extensionflow.InvocationRateLimited
		message = "pull request watch rate limited"
	case errors.Is(err, storeerr.ErrConflict):
		code = extensionflow.InvocationConflict
		message = "pull request watch state conflict"
	default:
		return err
	}
	return &extensionflow.InvocationError{Code: code, Message: message}
}
