package invocationmanage

import (
	"context"
	"encoding/json"
	"errors"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/core/domain"
	"github.com/tidbcloud/AgentTeam/internal/store/workflow/invocation"
	invocationlang "github.com/tidbcloud/AgentTeam/internal/workflow/invocation"
	"github.com/tidbcloud/AgentTeam/internal/workflow/plan"
)

type fixedClock struct{ now time.Time }

func (clock fixedClock) Now() time.Time { return clock.now }

type inputAuthorityStub struct {
	agents  map[string]bool
	members map[string]bool
	scopes  map[domain.MessageScopeValue]bool
}

func (stub inputAuthorityStub) WorkflowInvocationAgentExists(_ context.Context, _ string, agentID string) (bool, error) {
	return stub.agents[agentID], nil
}

func (stub inputAuthorityStub) WorkflowInvocationMemberExists(_ context.Context, _ string, memberID string) (bool, error) {
	return stub.members[memberID], nil
}

func (stub inputAuthorityStub) AuthorizeWorkflowInvocationScope(
	_ context.Context,
	_ string,
	_ string,
	_ string,
	_ string,
	scope domain.MessageScopeValue,
) error {
	if stub.scopes[scope] {
		return nil
	}
	return errors.New("scope unavailable")
}

type repositoryStub struct {
	replay func(context.Context, invocation.ReplayManualParams) (invocationlang.AcceptResult, bool, error)
	load   func(context.Context, string, string, string) (invocation.ManualPlanVersion, error)
	accept func(context.Context, invocation.AcceptManualParams) (invocationlang.AcceptResult, error)
	get    func(context.Context, string, string) (invocationlang.Invocation, error)
	getRun func(context.Context, string, string) (invocationlang.Run, error)
	list   func(context.Context, invocation.ListParams) ([]invocation.ListItem, error)
}

func (stub repositoryStub) ReplayManual(ctx context.Context, params invocation.ReplayManualParams) (invocationlang.AcceptResult, bool, error) {
	if stub.replay == nil {
		return invocationlang.AcceptResult{}, false, nil
	}
	return stub.replay(ctx, params)
}

func (stub repositoryStub) LoadManualPlanVersion(ctx context.Context, workspaceID, planID, versionID string) (invocation.ManualPlanVersion, error) {
	if stub.load == nil {
		return invocation.ManualPlanVersion{}, errors.New("unexpected LoadManualPlanVersion call")
	}
	return stub.load(ctx, workspaceID, planID, versionID)
}

func (stub repositoryStub) AcceptManual(ctx context.Context, params invocation.AcceptManualParams) (invocationlang.AcceptResult, error) {
	if stub.accept == nil {
		return invocationlang.AcceptResult{}, errors.New("unexpected AcceptManual call")
	}
	return stub.accept(ctx, params)
}

func (stub repositoryStub) LoadInvocation(ctx context.Context, workspaceID, invocationID string) (invocationlang.Invocation, error) {
	if stub.get == nil {
		return invocationlang.Invocation{}, errors.New("unexpected LoadInvocation call")
	}
	return stub.get(ctx, workspaceID, invocationID)
}

func (stub repositoryStub) LoadRunByInvocation(ctx context.Context, workspaceID, invocationID string) (invocationlang.Run, error) {
	if stub.getRun == nil {
		return invocationlang.Run{}, errors.New("unexpected LoadRunByInvocation call")
	}
	return stub.getRun(ctx, workspaceID, invocationID)
}

func (stub repositoryStub) ListInvocations(ctx context.Context, params invocation.ListParams) ([]invocation.ListItem, error) {
	if stub.list == nil {
		return nil, errors.New("unexpected ListInvocations call")
	}
	return stub.list(ctx, params)
}

func TestInvokeManualReplayPrecedesMutablePlanRead(t *testing.T) {
	now := time.Date(2026, 7, 21, 6, 0, 0, 0, time.UTC)
	actor := invocationlang.Actor{Type: invocationlang.ActorTypeHuman, ID: "usr_1", MemberID: "mem_1"}
	existing := acceptedResult(now, actor, []byte(`{"final":"v1"}`), true)
	existing.Invocation.IdempotencyKey = "retry-1"
	service := New(repositoryStub{
		replay: func(_ context.Context, params invocation.ReplayManualParams) (invocationlang.AcceptResult, bool, error) {
			require.Equal(t, "wsp_1", params.WorkspaceID)
			require.Equal(t, "pln_1", params.PlanID)
			require.Empty(t, params.RequestedPlanVersionID)
			require.JSONEq(t, `{"a":1,"b":2}`, string(params.RequestInputJSON))
			return existing, true, nil
		},
		load: func(context.Context, string, string, string) (invocation.ManualPlanVersion, error) {
			t.Fatal("Plan/default must not be read for an exact replay")
			return invocation.ManualPlanVersion{}, nil
		},
	})

	result, err := service.InvokeManual(context.Background(), ManualInput{
		WorkspaceID: "wsp_1", PlanID: "pln_1", Input: json.RawMessage(`{"b":2,"a":1}`),
		Principal: Principal{UserID: "usr_1", MemberID: "mem_1", Role: "owner"},
		InvokedBy: actor, IdempotencyKey: "retry-1",
	})
	require.NoError(t, err)
	require.True(t, result.Replayed)
}

func TestReplayManualDoesNotRequireMutablePrincipalResolution(t *testing.T) {
	now := time.Date(2026, 7, 21, 6, 0, 0, 0, time.UTC)
	actor := invocationlang.Actor{Type: invocationlang.ActorTypeAgent, ID: "agt_1", MemberID: "mem_agent"}
	existing := acceptedResult(now, actor, []byte(`{"a":1}`), true)
	existing.Invocation.IdempotencyKey = "agent-retry"
	service := New(repositoryStub{replay: func(_ context.Context, params invocation.ReplayManualParams) (invocationlang.AcceptResult, bool, error) {
		require.Equal(t, "plv_1", params.RequestedPlanVersionID)
		return existing, true, nil
	}})
	result, found, err := service.ReplayManual(context.Background(), ReplayInput{
		WorkspaceID: "wsp_1", PlanID: "pln_1", PlanVersionID: "plv_1", Input: json.RawMessage(`{"a":1.0}`),
		InvokedBy: actor, IdempotencyKey: "agent-retry",
	})
	require.NoError(t, err)
	require.True(t, found)
	require.True(t, result.Replayed)
}

func TestInvokeManualNormalizesExactPlanInputBeforeAtomicAccept(t *testing.T) {
	now := time.Date(2026, 7, 21, 6, 0, 0, 123456000, time.UTC)
	actor := invocationlang.Actor{Type: invocationlang.ActorTypeHuman, ID: "usr_1", MemberID: "mem_1"}
	material := manualPlanVersion(t, "plv_1")
	var captured invocation.AcceptManualParams
	service := New(repositoryStub{
		load: func(_ context.Context, workspaceID, planID, versionID string) (invocation.ManualPlanVersion, error) {
			require.Equal(t, "wsp_1", workspaceID)
			require.Equal(t, "pln_1", planID)
			require.Equal(t, "plv_1", versionID)
			return material, nil
		},
		accept: func(_ context.Context, params invocation.AcceptManualParams) (invocationlang.AcceptResult, error) {
			captured = params
			return acceptedResult(now, actor, params.InputJSON, false), nil
		},
	}, WithClock(fixedClock{now: now}))

	_, err := service.InvokeManual(context.Background(), ManualInput{
		WorkspaceID: "wsp_1", PlanID: "pln_1", PlanVersionID: "plv_1",
		Input:     json.RawMessage(`{"required":"ok","override":"caller"}`),
		Principal: Principal{UserID: "usr_1", MemberID: "mem_1", Role: "owner"},
		InvokedBy: actor, IdempotencyKey: "call-1",
	})
	require.NoError(t, err)
	require.Equal(t, now, captured.Now)
	require.Equal(t, "pln_1", captured.PlanID)
	require.Equal(t, "plv_1", captured.RequestedPlanVersionID)
	require.Equal(t, material.PlanVersionID, captured.ExpectedPlanVersionID)
	require.Equal(t, material.PlanVersionDigest, captured.ExpectedVersionDigest)
	require.JSONEq(t, `{"override":"caller","plan_only":"plan","required":"ok"}`, string(captured.InputJSON))
}

func TestInvokeManualInputDiagnosticUsesNormalizedInputPath(t *testing.T) {
	material := manualPlanVersion(t, "")
	service := New(repositoryStub{load: func(context.Context, string, string, string) (invocation.ManualPlanVersion, error) {
		return material, nil
	}})
	_, err := service.InvokeManual(context.Background(), ManualInput{
		WorkspaceID: "wsp_1", PlanID: "pln_1", Input: json.RawMessage(`{"unknown":true}`),
		Principal:      Principal{UserID: "usr_1", MemberID: "mem_1", Role: "owner"},
		InvokedBy:      invocationlang.Actor{Type: invocationlang.ActorTypeHuman, ID: "usr_1", MemberID: "mem_1"},
		IdempotencyKey: "call-1",
	})
	require.Error(t, err)
	code, details, ok := ErrorDetails(err)
	require.True(t, ok)
	require.Equal(t, ErrorCodeInputInvalid, code)
	diagnostics := details.(map[string]any)["diagnostics"].([]Diagnostic)
	require.Equal(t, "$.input.unknown", diagnostics[0].Path)
}

func TestInvokeManualRejectsUnknownAgentAndUnavailableScopeBeforeAccept(t *testing.T) {
	material := manualIdentityPlanVersion(t)
	actor := invocationlang.Actor{Type: invocationlang.ActorTypeAgent, ID: "agt_caller", MemberID: "mem_agent"}
	tests := []struct {
		name     string
		input    string
		agents   map[string]bool
		scopes   map[domain.MessageScopeValue]bool
		wantCode string
		wantPath string
	}{
		{
			name:     "unknown agent in array",
			input:    `{"reviewers":["agt_known","agt_missing"],"work_scope":{"kind":"task","scopeId":"tsk_1"}}`,
			agents:   map[string]bool{"agt_known": true},
			scopes:   map[domain.MessageScopeValue]bool{{Kind: domain.MessageScopeKindTask, ScopeID: "tsk_1"}: true},
			wantCode: "input.agent_not_found",
			wantPath: "$.input.reviewers[1]",
		},
		{
			name:     "unavailable scope",
			input:    `{"reviewers":["agt_known"],"work_scope":{"kind":"task","scopeId":"tsk_missing"}}`,
			agents:   map[string]bool{"agt_known": true},
			wantCode: "input.scope_unavailable",
			wantPath: "$.input.work_scope",
		},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			service := New(repositoryStub{
				load: func(context.Context, string, string, string) (invocation.ManualPlanVersion, error) {
					return material, nil
				},
				accept: func(context.Context, invocation.AcceptManualParams) (invocationlang.AcceptResult, error) {
					t.Fatal("invalid identity input must be rejected before acceptance")
					return invocationlang.AcceptResult{}, nil
				},
			}, WithInputAuthority(inputAuthorityStub{agents: test.agents, scopes: test.scopes}))
			_, err := service.InvokeManual(context.Background(), ManualInput{
				WorkspaceID: "wsp_1", PlanID: "pln_1", Input: json.RawMessage(test.input),
				Principal: Principal{UserID: "usr_1", MemberID: "mem_1", Role: "owner"},
				InvokedBy: actor, IdempotencyKey: "call-1",
			})
			require.Error(t, err)
			code, details, ok := ErrorDetails(err)
			require.True(t, ok)
			require.Equal(t, ErrorCodeInputInvalid, code)
			diagnostics := details.(map[string]any)["diagnostics"].([]Diagnostic)
			require.Equal(t, test.wantCode, diagnostics[0].Code)
			require.Equal(t, test.wantPath, diagnostics[0].Path)
		})
	}
}

func TestInvokeManualRejectsUnavailableMemberBeforeAccept(t *testing.T) {
	material := manualMemberPlanVersion(t)
	actor := invocationlang.Actor{Type: invocationlang.ActorTypeAgent, ID: "agt_caller", MemberID: "mem_agent"}
	tests := []struct {
		name     string
		input    string
		members  map[string]bool
		wantCode string
		wantPath string
	}{
		{
			name:     "unknown member top-level",
			input:    `{"assignee":"mem_missing","members":["mem_known"]}`,
			members:  map[string]bool{"mem_known": true},
			wantCode: "input.member_not_found",
			wantPath: "$.input.assignee",
		},
		{
			name:     "unknown member in array",
			input:    `{"assignee":"mem_known","members":["mem_known","mem_missing"]}`,
			members:  map[string]bool{"mem_known": true},
			wantCode: "input.member_not_found",
			wantPath: "$.input.members[1]",
		},
		{
			name:     "unknown member in nested array",
			input:    `{"assignee":"mem_known","members":["mem_known"],"teams":[["mem_known","mem_missing"]]}`,
			members:  map[string]bool{"mem_known": true},
			wantCode: "input.member_not_found",
			wantPath: "$.input.teams[0][1]",
		},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			service := New(repositoryStub{
				load: func(context.Context, string, string, string) (invocation.ManualPlanVersion, error) {
					return material, nil
				},
				accept: func(context.Context, invocation.AcceptManualParams) (invocationlang.AcceptResult, error) {
					t.Fatal("invalid member input must be rejected before acceptance")
					return invocationlang.AcceptResult{}, nil
				},
			}, WithInputAuthority(inputAuthorityStub{members: test.members}))
			_, err := service.InvokeManual(context.Background(), ManualInput{
				WorkspaceID: "wsp_1", PlanID: "pln_1", Input: json.RawMessage(test.input),
				Principal: Principal{UserID: "usr_1", MemberID: "mem_1", Role: "owner"},
				InvokedBy: actor, IdempotencyKey: "call-1",
			})
			require.Error(t, err)
			code, details, ok := ErrorDetails(err)
			require.True(t, ok)
			require.Equal(t, ErrorCodeInputInvalid, code)
			diagnostics := details.(map[string]any)["diagnostics"].([]Diagnostic)
			require.Equal(t, test.wantCode, diagnostics[0].Code)
			require.Equal(t, test.wantPath, diagnostics[0].Path)
		})
	}
}

func TestInvokeManualAcceptsAvailableMemberInput(t *testing.T) {
	material := manualMemberPlanVersion(t)
	actor := invocationlang.Actor{Type: invocationlang.ActorTypeAgent, ID: "agt_caller", MemberID: "mem_agent"}
	now := time.Date(2026, 8, 15, 6, 0, 0, 0, time.UTC)
	var captured invocation.AcceptManualParams
	service := New(repositoryStub{
		load: func(context.Context, string, string, string) (invocation.ManualPlanVersion, error) {
			return material, nil
		},
		accept: func(_ context.Context, params invocation.AcceptManualParams) (invocationlang.AcceptResult, error) {
			captured = params
			return acceptedResult(now, actor, params.InputJSON, false), nil
		},
	}, WithClock(fixedClock{now: now}), WithInputAuthority(inputAuthorityStub{
		members: map[string]bool{"mem_known": true, "mem_1": true},
	}))
	_, err := service.InvokeManual(context.Background(), ManualInput{
		WorkspaceID: "wsp_1", PlanID: "pln_1", Input: json.RawMessage(`{"assignee":"mem_1","members":["mem_known"],"teams":[["mem_1","mem_known"]]}`),
		Principal: Principal{UserID: "usr_1", MemberID: "mem_1", Role: "owner"},
		InvokedBy: actor, IdempotencyKey: "call-1",
	})
	require.NoError(t, err)
	require.JSONEq(t, `{"assignee":"mem_1","members":["mem_known"],"teams":[["mem_1","mem_known"]]}`, string(captured.InputJSON))
}

func TestInvokeManualReplayPrecedesMemberAuthorityCheck(t *testing.T) {
	now := time.Date(2026, 8, 15, 6, 0, 0, 0, time.UTC)
	actor := invocationlang.Actor{Type: invocationlang.ActorTypeAgent, ID: "agt_caller", MemberID: "mem_agent"}
	existing := acceptedResult(now, actor, []byte(`{"assignee":"mem_gone"}`), true)
	existing.Invocation.IdempotencyKey = "agent-retry"
	service := New(repositoryStub{
		replay: func(_ context.Context, params invocation.ReplayManualParams) (invocationlang.AcceptResult, bool, error) {
			require.Equal(t, "wsp_1", params.WorkspaceID)
			require.Equal(t, "pln_1", params.PlanID)
			require.JSONEq(t, `{"assignee":"mem_gone"}`, string(params.RequestInputJSON))
			return existing, true, nil
		},
		load: func(context.Context, string, string, string) (invocation.ManualPlanVersion, error) {
			t.Fatal("Plan/default must not be read for an exact replay")
			return invocation.ManualPlanVersion{}, nil
		},
	}, WithInputAuthority(inputAuthorityStub{}))
	result, err := service.InvokeManual(context.Background(), ManualInput{
		WorkspaceID: "wsp_1", PlanID: "pln_1", Input: json.RawMessage(`{"assignee":"mem_gone"}`),
		Principal: Principal{UserID: "usr_1", MemberID: "mem_1", Role: "owner"},
		InvokedBy: actor, IdempotencyKey: "agent-retry",
	})
	require.NoError(t, err)
	require.True(t, result.Replayed)
}

func TestListUsesPlanScopedStableCursor(t *testing.T) {
	now := time.Date(2026, 7, 21, 6, 0, 0, 0, time.UTC)
	actor := invocationlang.Actor{Type: invocationlang.ActorTypeHuman, ID: "usr_1", MemberID: "mem_1"}
	first := acceptedResult(now, actor, []byte(`{}`), false).Invocation
	second := acceptedResult(now.Add(-time.Second), actor, []byte(`{}`), false).Invocation
	second.ID, second.RunID = "wiv_2", "wri_2"
	third := acceptedResult(now.Add(-2*time.Second), actor, []byte(`{}`), false).Invocation
	third.ID, third.RunID = "wiv_3", "wri_3"
	service := New(repositoryStub{list: func(_ context.Context, params invocation.ListParams) ([]invocation.ListItem, error) {
		require.Equal(t, "pln_1", params.PlanID)
		require.Equal(t, 3, params.Limit)
		return []invocation.ListItem{{Invocation: first, RunStatus: "queued"}, {Invocation: second, RunStatus: "queued"}, {Invocation: third, RunStatus: "queued"}}, nil
	}})
	page, err := service.List(context.Background(), ListInput{WorkspaceID: "wsp_1", PlanID: "pln_1", Limit: 2})
	require.NoError(t, err)
	require.Len(t, page.Items, 2)
	require.NotEmpty(t, page.NextCursor)
}

func manualPlanVersion(t *testing.T, requestedVersionID string) invocation.ManualPlanVersion {
	t.Helper()
	artifact, diagnostics, err := plan.Compile([]byte(`apiVersion: loop.plan/v1
kind: PlanVersion
input:
  override: {type: string, default: planbase}
  plan_only: {type: string, default: plan}
  required: {type: string, required: true}
graph:
  entry: {nodeId: done, port: in}
  nodes:
  - id: done
    type: end
    end: {outcome: succeeded}
  edges: []`))
	require.NoError(t, err)
	require.False(t, plan.HasErrors(diagnostics), "%#v", diagnostics)
	payload, err := artifact.DeterministicJSON()
	require.NoError(t, err)
	return invocation.ManualPlanVersion{
		WorkspaceID: "wsp_1", PlanID: "pln_1", RequestedPlanVersionID: requestedVersionID,
		PlanVersionID: "plv_1", WorkflowName: "Direct Plan",
		PlanArtifactJSON: payload, PlanCompilerVersion: artifact.CompilerVersion, PlanVersionDigest: artifact.Digest,
	}
}

func manualIdentityPlanVersion(t *testing.T) invocation.ManualPlanVersion {
	t.Helper()
	artifact, diagnostics, err := plan.Compile([]byte(`apiVersion: loop.plan/v1
kind: PlanVersion
input:
  reviewers:
    type: array
    required: true
    items: {type: agent}
  work_scope: {type: scope, required: true}
graph:
  entry: {nodeId: done, port: in}
  nodes:
  - id: done
    type: end
    end: {outcome: succeeded}
  edges: []`))
	require.NoError(t, err)
	require.False(t, plan.HasErrors(diagnostics), "%#v", diagnostics)
	payload, err := artifact.DeterministicJSON()
	require.NoError(t, err)
	return invocation.ManualPlanVersion{
		WorkspaceID: "wsp_1", PlanID: "pln_1", PlanVersionID: "plv_1",
		WorkflowName: "Identity Plan", PlanArtifactJSON: payload,
		PlanCompilerVersion: artifact.CompilerVersion, PlanVersionDigest: artifact.Digest,
	}
}

func manualMemberPlanVersion(t *testing.T) invocation.ManualPlanVersion {
	t.Helper()
	artifact, diagnostics, err := plan.Compile([]byte(`apiVersion: loop.plan/v1
kind: PlanVersion
input:
  assignee: {type: member, required: true}
  members:
    type: array
    required: true
    items: {type: member}
  teams:
    type: array
    items:
      type: array
      items: {type: member}
graph:
  entry: {nodeId: done, port: in}
  nodes:
  - id: done
    type: end
    end: {outcome: succeeded}
  edges: []`))
	require.NoError(t, err)
	require.False(t, plan.HasErrors(diagnostics), "%#v", diagnostics)
	payload, err := artifact.DeterministicJSON()
	require.NoError(t, err)
	return invocation.ManualPlanVersion{
		WorkspaceID: "wsp_1", PlanID: "pln_1", PlanVersionID: "plv_1",
		WorkflowName: "Member Plan", PlanArtifactJSON: payload,
		PlanCompilerVersion: artifact.CompilerVersion, PlanVersionDigest: artifact.Digest,
	}
}

func acceptedResult(now time.Time, actor invocationlang.Actor, input []byte, replayed bool) invocationlang.AcceptResult {
	digest := invocationlang.InputDigest(input)
	identity := invocationlang.ExecutionIdentitySnapshot{OwnerUserID: "usr_1", OwnerMemberID: "mem_1", PrincipalMemberID: "mem_1", AuthorMemberID: "mem_1"}
	invocation := invocationlang.Invocation{
		ID: "wiv_1", RunID: "wri_1", WorkspaceID: "wsp_1", PlanID: "pln_1", PlanVersionID: "plv_1",
		WorkflowNameSnapshot: "Direct Plan", SourceKind: invocationlang.SourceKindManual,
		SourceOccurrenceRef: "manual:sha256:test", SourceOccurredAt: now, InputJSON: input, InputDigest: digest,
		SourceProvenanceJSON: json.RawMessage(`{"sourceKind":"manual"}`), Identity: identity,
		InvokedBy: actor, IdempotencyKey: "call-1", RequestDigest: "digest", AcceptedAt: now,
	}
	run := invocationlang.Run{
		ID: "wri_1", WorkspaceID: "wsp_1", InvocationID: "wiv_1", PlanID: "pln_1", PlanVersionID: "plv_1",
		WorkflowNameSnapshot: "Direct Plan", Status: "queued", InputJSON: input, InputDigest: digest,
		Identity: identity, QueuedAt: now, CreatedAt: now, UpdatedAt: now,
	}
	event := invocationlang.RunEvent{
		ID: "wre_1", WorkspaceID: "wsp_1", RunID: "wri_1", Seq: 1, Type: invocationlang.RunEventAccepted,
		PayloadJSON: json.RawMessage(`{"inputDigest":"` + digest + `","invocationId":"wiv_1","planVersionId":"plv_1","status":"queued"}`), CreatedAt: now,
	}
	return invocationlang.AcceptResult{Invocation: invocation, Run: run, Event: event, Replayed: replayed}
}
