package agentapproval

import (
	"context"
	"encoding/json"
	"os"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/coredb/testkit"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/agentops"
	"github.com/tidbcloud/AgentTeam/internal/store/approval"
	"github.com/tidbcloud/AgentTeam/internal/store/storeerr"
)

var agentManagementTiDB testkit.TiDBCoreDBFixture

func TestMain(m *testing.M) {
	os.Exit(testkit.RunTestsWithTiDBCoreDBFixture(m, "agentmanagement", &agentManagementTiDB))
}

type agentFixture struct {
	WorkspaceID     string
	OwnerUserID     string
	OwnerMemberID   string
	ApplicantAgent  string
	ApplicantMember string
	MachineID       string
	Principal       agent.AuthenticatedAgent
}

func seedAgentFixture(t *testing.T, ctx context.Context, db coredb.DB, suffix string, now time.Time) agentFixture {
	t.Helper()
	fixture := agentFixture{
		WorkspaceID:     "wsp_agm_" + suffix,
		OwnerUserID:     "usr_agm_owner_" + suffix,
		OwnerMemberID:   "mem_agm_owner_" + suffix,
		ApplicantAgent:  "agt_agm_app_" + suffix,
		ApplicantMember: "mem_agm_app_" + suffix,
		MachineID:       "mac_agm_" + suffix,
	}
	orgID := "org_agm_" + suffix
	mustExecAgent(t, ctx, db, `INSERT INTO users (id, email, name, display_name, password_hash, created_at, updated_at) VALUES ($1,$2,$3,$3,'hash',$4,$4)`, fixture.OwnerUserID, fixture.OwnerUserID+"@example.com", fixture.OwnerUserID, now)
	mustExecAgent(t, ctx, db, `INSERT INTO organizations (id, slug, name, created_by_user_id, created_at, updated_at) VALUES ($1,$2,$2,$3,$4,$4)`, orgID, "agm-"+suffix, fixture.OwnerUserID, now)
	mustExecAgent(t, ctx, db, `INSERT INTO workspaces (id, organization_id, slug, name, created_at, updated_at) VALUES ($1,$2,$3,$3,$4,$4)`, fixture.WorkspaceID, orgID, "agm-"+suffix, now)
	mustExecAgent(t, ctx, db, `INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at) VALUES ($1,$2,'human',$1,$4,'owner','active',$5,$5),($3,$2,'agent',$3,NULL,NULL,'active',$5,$5)`, fixture.OwnerMemberID, fixture.WorkspaceID, fixture.ApplicantMember, fixture.OwnerUserID, now)
	mustExecAgent(t, ctx, db, `INSERT INTO machines (id, workspace_id, created_by_user_id, name, api_key, created_at, updated_at) VALUES ($1,$2,$3,$1,$4,$5,$5)`, fixture.MachineID, fixture.WorkspaceID, fixture.OwnerUserID, "key-"+fixture.MachineID, now)
	mustExecAgent(t, ctx, db, `INSERT INTO agents (id, workspace_id, machine_id, member_id, name, avatar_url, runtime, model, description, auth_token_hash, activity_detail, created_at, updated_at) VALUES ($1,$2,$3,$4,$1,'','codex','gpt-5','',NULL,'',$5,$5)`, fixture.ApplicantAgent, fixture.WorkspaceID, fixture.MachineID, fixture.ApplicantMember, now)
	fixture.Principal = agent.AuthenticatedAgent{AgentID: fixture.ApplicantAgent, WorkspaceID: fixture.WorkspaceID, MemberID: fixture.ApplicantMember}
	return fixture
}

func mustExecAgent(t testing.TB, ctx context.Context, db coredb.QueryExecer, query string, args ...any) {
	t.Helper()
	_, err := db.Exec(ctx, query, args...)
	require.NoError(t, err)
}

func TestAgentManagementSubmitReplayConflictApproveReject(t *testing.T) {
	ctx := context.Background()
	now := time.Now().UTC()
	db := agentManagementTiDB.SerializedRollbackDB(t, ctx)
	fixture := seedAgentFixture(t, ctx, db, "flow", now)
	service := New(db, approval.New(db), agentops.New(db, agentops.Options{}))

	createInput := func() SubmitInput {
		return SubmitInput{ClientRequestID: "req_create_1", Now: now, Payload: Payload{Action: ActionCreate, Create: &CreateProposal{Name: "new-agent", MachineID: fixture.MachineID, Runtime: "codex"}}}
	}

	// Submit creates one approval whose sole approver is the owner Human.
	first, err := service.Submit(ctx, fixture.Principal, createInput())
	require.NoError(t, err)
	require.True(t, first.Created)
	require.Equal(t, SubjectType, first.Approval.SubjectType)
	require.Len(t, first.Assignments, 1)
	require.Equal(t, fixture.OwnerMemberID, first.Assignments[0].ApproverMemberID)
	var frozenCreate Payload
	require.NoError(t, json.Unmarshal(first.Approval.Payload, &frozenCreate))
	require.Equal(t, []AgentIdentitySnapshot{{DisplayName: "new-agent"}}, frozenCreate.Presentation.Agents)

	// Same applicant+requestId+payload replays (not re-created).
	replay, err := service.Submit(ctx, fixture.Principal, createInput())
	require.NoError(t, err)
	require.False(t, replay.Created)
	require.Equal(t, first.Approval.ID, replay.Approval.ID)
	var replayedCreate Payload
	require.NoError(t, json.Unmarshal(replay.Approval.Payload, &replayedCreate))
	require.Equal(t, frozenCreate.Presentation, replayedCreate.Presentation)

	// Same requestId but a different payload conflicts.
	different := createInput()
	different.Payload.Create.Name = "renamed"
	_, err = service.Submit(ctx, fixture.Principal, different)
	require.ErrorIs(t, err, storeerr.ErrConflict)

	// Approve enqueues exactly one queued operation with one item.
	decided, err := service.Decide(ctx, approval.DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: first.Approval.ID,
		AssignmentID: first.Assignments[0].ID, ActorMemberID: fixture.OwnerMemberID,
		ExpectedVersion: first.Approval.Version, ClientDecisionID: "dec_create_approve",
		Decision: approval.DecisionApproved, Now: now,
	})
	require.NoError(t, err)
	require.NotZero(t, decided.OperationID)
	require.Equal(t, approval.StateApproved, decided.Instance.State)
	requireOperation(t, ctx, db, first.Approval.ID, ActionCreate, 1)

	// Approve replay (same clientDecisionId) does not enqueue a second operation.
	again, err := service.Decide(ctx, approval.DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: first.Approval.ID,
		AssignmentID: first.Assignments[0].ID, ActorMemberID: fixture.OwnerMemberID,
		ExpectedVersion: first.Approval.Version, ClientDecisionID: "dec_create_approve",
		Decision: approval.DecisionApproved, Now: now,
	})
	require.NoError(t, err)
	require.True(t, again.Replay)
	require.NotZero(t, again.OperationID)
	require.Equal(t, decided.OperationID, again.OperationID)
	requireOperation(t, ctx, db, first.Approval.ID, ActionCreate, 1)

	// Reject enqueues no operation.
	rejected, err := service.Submit(ctx, fixture.Principal, SubmitInput{ClientRequestID: "req_create_reject", Now: now, Payload: Payload{Action: ActionCreate, Create: &CreateProposal{Name: "rejected-agent", MachineID: fixture.MachineID, Runtime: "codex"}}})
	require.NoError(t, err)
	_, err = service.Decide(ctx, approval.DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: rejected.Approval.ID,
		AssignmentID: rejected.Assignments[0].ID, ActorMemberID: fixture.OwnerMemberID,
		ExpectedVersion: rejected.Approval.Version, ClientDecisionID: "dec_create_reject",
		Decision: approval.DecisionRejected, Reason: "no", Now: now,
	})
	require.NoError(t, err)
	requireNoOperation(t, ctx, db, rejected.Approval.ID)

	// Delete items preserve caller input order (no sort/dedupe).
	ordered, err := service.Submit(ctx, fixture.Principal, SubmitInput{ClientRequestID: "req_delete_ordered", Now: now, Payload: Payload{Action: ActionDelete, Delete: &DeleteProposal{AgentIDs: []string{"agt_z", "agt_a", "agt_m"}}}})
	require.NoError(t, err)
	decidedOrdered, err := service.Decide(ctx, approval.DecideParams{
		WorkspaceID: fixture.WorkspaceID, ApprovalID: ordered.Approval.ID,
		AssignmentID: ordered.Assignments[0].ID, ActorMemberID: fixture.OwnerMemberID,
		ExpectedVersion: ordered.Approval.Version, ClientDecisionID: "dec_delete_ordered",
		Decision: approval.DecisionApproved, Now: now,
	})
	require.NoError(t, err)
	requireOrderedDeleteItems(t, ctx, db, decidedOrdered.OperationID, []string{"agt_z", "agt_a", "agt_m"})

	// Frozen payloads (approval payload + operation payload + each item input) carry no raw credentials.
	requireNoCredentials(t, first.Approval.Payload)
	requireOperationHasNoCredentials(t, ctx, db, first.Approval.ID)
}

func requireOperation(t *testing.T, ctx context.Context, db coredb.QueryExecer, approvalID, wantAction string, wantItems int) {
	t.Helper()
	var opID int64
	var action, status string
	require.NoError(t, db.QueryRow(ctx, `SELECT id, action, status FROM agent_operations WHERE approval_id=$1`, approvalID).Scan(&opID, &action, &status))
	require.Equal(t, wantAction, action)
	require.Equal(t, "queued", status)
	var itemCount int
	require.NoError(t, db.QueryRow(ctx, `SELECT COUNT(*) FROM agent_operation_items WHERE operation_id=$1`, opID).Scan(&itemCount))
	require.Equal(t, wantItems, itemCount)
}

func requireNoOperation(t *testing.T, ctx context.Context, db coredb.QueryExecer, approvalID string) {
	t.Helper()
	var count int
	require.NoError(t, db.QueryRow(ctx, `SELECT COUNT(*) FROM agent_operations WHERE approval_id=$1`, approvalID).Scan(&count))
	require.Zero(t, count)
}

func requireOrderedDeleteItems(t *testing.T, ctx context.Context, db coredb.QueryExecer, opID int64, want []string) {
	t.Helper()
	rows, err := db.Query(ctx, `SELECT input FROM agent_operation_items WHERE operation_id=$1 ORDER BY item_index`, opID)
	require.NoError(t, err)
	defer rows.Close()
	got := make([]string, 0, len(want))
	for rows.Next() {
		var raw json.RawMessage
		require.NoError(t, rows.Scan(&raw))
		var item map[string]string
		require.NoError(t, json.Unmarshal(raw, &item))
		got = append(got, item["agentId"])
	}
	require.NoError(t, rows.Err())
	require.Equal(t, want, got)
}

func requireNoCredentials(t *testing.T, payload []byte) {
	t.Helper()
	lower := strings.ToLower(string(payload))
	for _, needle := range []string{"apikey", "session", "authtoken", "credential"} {
		require.NotContains(t, lower, needle)
	}
}

func TestDeterministicPayloadScalarWhitespaceEquivalence(t *testing.T) {
	p1 := Payload{SchemaVersion: PayloadSchemaVersion, ClientRequestID: "r", Action: ActionCreate, Create: &CreateProposal{Name: "name", MachineID: "mac", Runtime: "rt", Description: " desc ", Model: " model "}}
	p2 := Payload{SchemaVersion: PayloadSchemaVersion, ClientRequestID: "r", Action: ActionCreate, Create: &CreateProposal{Name: "  name  ", MachineID: " mac ", Runtime: "rt ", Description: "desc", Model: "model"}}
	_, _, digest1, err := canonicalPayload(p1)
	require.NoError(t, err)
	_, _, digest2, err := canonicalPayload(p2)
	require.NoError(t, err)
	require.Equal(t, digest1, digest2, "scalar whitespace must produce identical digest for replay")
}

func TestSubmitReplayEquivalenceOnScalarWhitespace(t *testing.T) {
	ctx := context.Background()
	now := time.Now().UTC()
	db := agentManagementTiDB.SerializedRollbackDB(t, ctx)
	fixture := seedAgentFixture(t, ctx, db, "ws", now)
	service := New(db, approval.New(db), agentops.New(db, agentops.Options{}))
	first, err := service.Submit(ctx, fixture.Principal, SubmitInput{
		ClientRequestID: "req_ws",
		Payload:         Payload{Action: ActionCreate, Create: &CreateProposal{Name: "  name  ", MachineID: fixture.MachineID, Runtime: "  codex  ", Description: " desc "}},
	})
	require.NoError(t, err)
	require.True(t, first.Created)
	second, err := service.Submit(ctx, fixture.Principal, SubmitInput{
		ClientRequestID: "req_ws",
		Payload:         Payload{Action: ActionCreate, Create: &CreateProposal{Name: "name", MachineID: fixture.MachineID, Runtime: "codex", Description: "desc"}},
	})
	require.NoError(t, err)
	require.False(t, second.Created)
	require.Equal(t, first.Approval.ID, second.Approval.ID)
	var count int
	require.NoError(t, db.QueryRow(ctx, `SELECT COUNT(*) FROM approval_instances WHERE workspace_id=$1 AND subject_type=$2`, fixture.WorkspaceID, SubjectType).Scan(&count))
	require.Equal(t, 1, count)
}

func requireOperationHasNoCredentials(t *testing.T, ctx context.Context, db coredb.QueryExecer, approvalID string) {
	t.Helper()
	var opPayload json.RawMessage
	require.NoError(t, db.QueryRow(ctx, `SELECT payload FROM agent_operations WHERE approval_id=$1`, approvalID).Scan(&opPayload))
	requireNoCredentials(t, opPayload)
	rows, err := db.Query(ctx, `SELECT input FROM agent_operation_items WHERE operation_id=(SELECT id FROM agent_operations WHERE approval_id=$1)`, approvalID)
	require.NoError(t, err)
	defer rows.Close()
	for rows.Next() {
		var input json.RawMessage
		require.NoError(t, rows.Scan(&input))
		requireNoCredentials(t, input)
	}
	require.NoError(t, rows.Err())
}

func TestAgentManagementSubmitRejectsMismatchedAgentMember(t *testing.T) {
	ctx := context.Background()
	now := time.Now().UTC()
	db := agentManagementTiDB.SerializedRollbackDB(t, ctx)
	fixture := seedAgentFixture(t, ctx, db, "mismatch", now)
	service := New(db, approval.New(db), agentops.New(db, agentops.Options{}))
	otherMember := "mem_agm_other_mismatch"
	mustExecAgent(t, ctx, db, `INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at) VALUES ($1,$2,'agent',$1,NULL,NULL,'active',$3,$3)`, otherMember, fixture.WorkspaceID, now)
	mustExecAgent(t, ctx, db, `INSERT INTO agents (id, workspace_id, machine_id, member_id, name, avatar_url, runtime, model, description, auth_token_hash, activity_detail, created_at, updated_at) VALUES ($1,$2,$3,$4,$1,'','codex','gpt-5','',NULL,'',$5,$5)`, "agt_agm_other_mismatch", fixture.WorkspaceID, fixture.MachineID, otherMember, now)
	mismatched := fixture.Principal
	mismatched.MemberID = otherMember
	_, err := service.Submit(ctx, mismatched, SubmitInput{ClientRequestID: "req_mismatch", Now: now, Payload: Payload{Action: ActionCreate, Create: &CreateProposal{Name: "x", MachineID: fixture.MachineID, Runtime: "codex"}}})
	require.ErrorIs(t, err, storeerr.ErrForbidden)
	var approvals int
	require.NoError(t, db.QueryRow(ctx, `SELECT COUNT(*) FROM approval_instances WHERE workspace_id=$1 AND subject_type=$2`, fixture.WorkspaceID, SubjectType).Scan(&approvals))
	require.Zero(t, approvals)
}

func TestAgentManagementSubmitFreezesTargetIdentityAndReplaysAfterRename(t *testing.T) {
	ctx := context.Background()
	now := time.Now().UTC()
	db := agentManagementTiDB.SerializedRollbackDB(t, ctx)
	fixture := seedAgentFixture(t, ctx, db, "presentation", now)
	service := New(db, approval.New(db), agentops.New(db, agentops.Options{}))
	description := "updated"
	input := SubmitInput{
		ClientRequestID: "req_presentation",
		Now:             now,
		Payload: Payload{
			Action: ActionUpdate,
			Update: &UpdateProposal{
				AgentID: fixture.ApplicantAgent, Description: &description,
			},
		},
	}

	first, err := service.Submit(ctx, fixture.Principal, input)
	require.NoError(t, err)
	var frozen Payload
	require.NoError(t, json.Unmarshal(first.Approval.Payload, &frozen))
	require.Equal(t, []AgentIdentitySnapshot{{
		ID: fixture.ApplicantAgent, DisplayName: fixture.ApplicantAgent,
	}}, frozen.Presentation.Agents)

	mustExecAgent(t, ctx, db, `UPDATE members SET display_name='Renamed' WHERE workspace_id=$1 AND id=$2`, fixture.WorkspaceID, fixture.ApplicantMember)
	replayed, err := service.Submit(ctx, fixture.Principal, input)
	require.NoError(t, err)
	require.False(t, replayed.Created)
	require.Equal(t, first.Approval.ID, replayed.Approval.ID)
}

func TestValidatePayloadEnforcesFrozenContract(t *testing.T) {
	str := func(value string) *string { return &value }
	tests := []struct {
		name    string
		payload Payload
		wantErr bool
	}{
		{"valid create", Payload{Action: ActionCreate, Create: &CreateProposal{Name: "n", MachineID: "m", Runtime: "r"}}, false},
		{"valid update one field", Payload{Action: ActionUpdate, Update: &UpdateProposal{AgentID: "a", Description: str("d")}}, false},
		{"valid delete", Payload{Action: ActionDelete, Delete: &DeleteProposal{AgentIDs: []string{"a", "b"}}}, false},
		{"valid duplicate", Payload{Action: ActionDuplicate, Duplicate: &DuplicateProposal{SourceAgentID: "s", Count: 2, Mode: "config_only"}}, false},
		{"action proposal mismatch", Payload{Action: ActionCreate, Delete: &DeleteProposal{AgentIDs: []string{"a"}}}, true},
		{"zero proposals", Payload{Action: ActionCreate}, true},
		{"two proposals", Payload{Action: ActionCreate, Create: &CreateProposal{Name: "n", MachineID: "m", Runtime: "r"}, Delete: &DeleteProposal{AgentIDs: []string{"a"}}}, true},
		{"unknown action", Payload{Action: "bogus", Create: &CreateProposal{Name: "n", MachineID: "m", Runtime: "r"}}, true},
		{"create missing name", Payload{Action: ActionCreate, Create: &CreateProposal{MachineID: "m", Runtime: "r"}}, true},
		{"create missing machineId", Payload{Action: ActionCreate, Create: &CreateProposal{Name: "n", Runtime: "r"}}, true},
		{"create missing runtime", Payload{Action: ActionCreate, Create: &CreateProposal{Name: "n", MachineID: "m"}}, true},
		{"update missing agentId", Payload{Action: ActionUpdate, Update: &UpdateProposal{Description: str("d")}}, true},
		{"update no change field", Payload{Action: ActionUpdate, Update: &UpdateProposal{AgentID: "a"}}, true},
		{"delete empty", Payload{Action: ActionDelete, Delete: &DeleteProposal{}}, true},
		{"delete too many", Payload{Action: ActionDelete, Delete: &DeleteProposal{AgentIDs: []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"}}}, true},
		{"delete blank id", Payload{Action: ActionDelete, Delete: &DeleteProposal{AgentIDs: []string{"   "}}}, true},
		{"duplicate zero count", Payload{Action: ActionDuplicate, Duplicate: &DuplicateProposal{SourceAgentID: "s", Count: 0}}, true},
		{"duplicate too many", Payload{Action: ActionDuplicate, Duplicate: &DuplicateProposal{SourceAgentID: "s", Count: 11}}, true},
		{"duplicate empty source", Payload{Action: ActionDuplicate, Duplicate: &DuplicateProposal{Count: 1}}, true},
		{"duplicate invalid mode", Payload{Action: ActionDuplicate, Duplicate: &DuplicateProposal{SourceAgentID: "s", Count: 1, Mode: "bogus"}}, true},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := ValidatePayload(tt.payload)
			if tt.wantErr {
				require.Error(t, err)
			} else {
				require.NoError(t, err)
			}
		})
	}
}
