package agent_test

import (
	"context"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/api/agent"
	"github.com/tidbcloud/AgentTeam/internal/api/daemon"
	"github.com/tidbcloud/AgentTeam/internal/api/testkit"
	"github.com/tidbcloud/AgentTeam/internal/archive"
	"github.com/tidbcloud/AgentTeam/internal/core/secrets"
	"github.com/tidbcloud/AgentTeam/internal/store/taskboard"
)

// TestRealServerDesktopTaskRemoteE2E drives the real agent-runtime HTTP surface
// (canonical workspace-scoped URLs served by internal/api/agent.RegisterRoutes
// over a real TiDB-backed store) with real workspace / Agent / Machine / Member
// / Task / board / Channel rows, using the Desktop taskRemote transport shape
// (POST /tasks/get with channel+number). It proves:
//
//   - forward: a board Channel member with NO direct Task membership can get the
//     task (the server derives the board Channel from tasks.board_id ->
//     task_boards and authorizes the active Agent+Machine+Member).
//   - reverse: revoking the board Channel membership while retaining a direct
//     Task membership fails closed (the retained Task membership grants nothing).
//   - missing and existing-unauthorized raw task scopes return the identical
//     non-enumerating transport (same status and body), so a caller cannot learn
//     Task existence.
func TestRealServerDesktopTaskRemoteE2E(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	_, pool := startAgentAPITestPool(t, ctx)

	archiveStorage := archiveForTest(t)
	dataStore := testkit.NewWithOptions(pool, archiveStorage, 30, testkit.StoreOptions{
		AppCredentialStorageKey: []byte("0123456789abcdef0123456789abcdef"),
	})
	taskStore := taskboard.New(pool)

	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	service := agent.New(
		logger,
		dataStore.AgentStorePortsDeps(),
		dataStore.Attachment(),
		taskStore,
		daemon.New(logger, daemon.NewStore(newAgentRuntime(pool, dataStore).Agents(), newAgentRuntime(pool, dataStore).Recovery(), newAgentRuntime(pool, dataStore).PrepareAgentStart), "http://example.com", nil),
		nil,
	)

	gin.SetMode(gin.TestMode)
	router := gin.New()
	agent.RegisterRoutes(router, service)

	now := time.Date(2026, 8, 16, 12, 0, 0, 0, time.UTC)
	workspaceID := "wsp_desktop_task_e2e"
	userID := "usr_desktop_task_owner"
	humanMemberID := "mem_desktop_task_owner"
	agentMemberID := "mem_desktop_task_agent"
	channelID := "chn_desktop_task_all"
	taskScopeID := "tsk_desktop_task_e2e"
	taskID := "tsk_desktop_task_e2e"
	boardID := "brd_desktop_task_e2e"
	machineID := "mac_desktop_task_owner"
	agentID := "agt_desktop_task_owner"
	agentToken := "desktop-task-agent-token"

	mustExec(t, pool, `
INSERT INTO users (id, email, name, display_name, avatar_url, is_admin, password_hash, email_verified_at, created_at, updated_at)
VALUES ($1, 'desktop-task-owner@example.com', 'desktop-task-owner', 'Desktop Task Owner', '', TRUE, 'hash', $2, $2, $2)
`, userID, now)
	organizationID := seedAgentTestWorkspaceOrganization(t, pool, workspaceID, "desktop-task-e2e", userID, now)
	mustExec(t, pool, `
INSERT INTO workspaces (id, organization_id, slug, name, latest_message_seq, created_at, updated_at)
VALUES ($1, $2, 'desktop-task-e2e', 'desktop-task-e2e', 0, $3, $3)
`, workspaceID, organizationID, now)
	mustExec(t, pool, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, created_at, updated_at)
VALUES ($1, $2, 'human', 'Owner', $3, 'owner', $4, $4)
`, humanMemberID, workspaceID, userID, now)
	mustExec(t, pool, `
INSERT INTO members (id, workspace_id, type, display_name, created_at, updated_at)
VALUES ($1, $2, 'agent', 'Desktop Task App', $3, $3)
`, agentMemberID, workspaceID, now)
	mustExec(t, pool, `
INSERT INTO machines (id, workspace_id, created_by_user_id, name, api_key, created_at, updated_at)
VALUES ($1, $2, $3, 'Desktop machine', 'machine-key', $4, $4)
`, machineID, workspaceID, userID, now)
	mustExec(t, pool, `
INSERT INTO agents (id, workspace_id, machine_id, member_id, name, runtime, model, description, auth_token_hash, activity_detail, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, 'desktop-task-agent', 'codex', 'gpt-5', '', $5, '', 'active', $6, $6)
`, agentID, workspaceID, machineID, agentMemberID, secrets.HashToken(agentToken), now)
	seedAgentTestChannelScope(t, pool, workspaceID, channelID, "all", humanMemberID, now)
	// The agent holds board Channel membership but is NOT yet a Task participant.
	seedAgentTestPublicScopeMembership(t, pool, channelID, agentMemberID, now)
	seedAgentTestPublicScopeMembership(t, pool, channelID, humanMemberID, now)

	// Seed the task + board with real rows (active Task scope, board derived from
	// tasks.board_id -> task_boards, denormalized channel_id matching).
	mustExec(t, pool, `
INSERT INTO task_boards (id, channel_id, workspace_id, next_task_number, created_at, updated_at)
VALUES ($1, $2, $3, 2, $4, $4)
`, boardID, channelID, workspaceID, now)
	mustExec(t, pool, `
INSERT INTO scopes (id, workspace_id, type, identity_key, latest_seq, latest_global_seq, latest_message_at, archived_until_seq, status, created_by_member_id, created_at, updated_at)
VALUES ($1, $2, 'task', $3, 0, 0, NULL, 0, 'active', $4, $5, $5)
`, taskScopeID, workspaceID, "scope/task/"+taskScopeID, humanMemberID, now)
	mustExec(t, pool, `
INSERT INTO tasks (
  id, task_scope_id, board_id, channel_id, task_number, title, status,
  created_by_member_id, created_by_ref, execution_owner_member_id, suggested_assignee_ref,
  source_thread_parent_channel_id, source_thread_parent_message_id, source_thread_ref,
  description, reviewer_ref, blocked_reason, parent_task_ref, kind,
  created_at, updated_at
) VALUES (
  $1, $2, $3, $4, 1, 'Desktop E2E task', 'todo',
  $6, $6, $6, '', '', '', '', '', '', '', '', 'task',
  $5, $5
)
`, taskID, taskScopeID, boardID, channelID, now, humanMemberID)

	var seededTaskID string
	require.NoError(t, pool.QueryRow(ctx, `
SELECT t.id
FROM tasks t
JOIN task_boards b ON b.id = t.board_id
JOIN scopes s ON s.id = t.task_scope_id
WHERE b.workspace_id = $1 AND t.task_scope_id = $2 AND s.type = 'task' AND s.status = 'active'
`, workspaceID, taskScopeID).Scan(&seededTaskID))
	require.Equal(t, taskID, seededTaskID)
	var lockableTaskID string
	require.NoError(t, pool.QueryRow(ctx, `
SELECT ct.id
FROM tasks ct
JOIN task_boards tb ON tb.id = ct.board_id AND tb.workspace_id = $1
JOIN scopes task_scope ON task_scope.id = ct.task_scope_id
  AND task_scope.workspace_id = $1 AND task_scope.type = 'task'
  AND task_scope.status = 'active' AND task_scope.archived_at IS NULL
JOIN scopes task_channel ON task_channel.id = tb.channel_id
  AND task_channel.workspace_id = $1 AND task_channel.type = 'channel'
  AND task_channel.status = 'active' AND task_channel.archived_at IS NULL
WHERE ct.channel_id = tb.channel_id AND ct.id = $2
`, workspaceID, taskID).Scan(&lockableTaskID))
	require.Equal(t, taskID, lockableTaskID)

	// Real HTTP client driving the canonical workspace-scoped agent runtime URL.
	baseURL := "/api/v1/workspaces/" + workspaceID + "/agents/" + agentID + "/runtime"
	requestAgent := func(path string, body map[string]any) (int, string) {
		req := httptest.NewRequest(http.MethodPost, baseURL+path, jsonBody(t, body))
		req.Header.Set("Content-Type", "application/json")
		req.Header.Set("Authorization", "Bearer "+agentToken)
		req.Header.Set("X-Agent-Id", agentID)
		resp := httptest.NewRecorder()
		router.ServeHTTP(resp, req)
		return resp.Code, resp.Body.String()
	}
	get := func(body map[string]any) (int, string) {
		return requestAgent("/tasks/get", body)
	}

	// Forward: the Desktop binder's task get (channel + number) resolves through
	// the real server's canonical Task->board Channel authority. The agent is a
	// board Channel member with NO direct Task membership, so the get succeeds.
	status, body := get(map[string]any{"channel": "#all", "number": 1})
	require.Equal(t, http.StatusOK, status, body)
	require.Contains(t, body, "Desktop E2E task")

	// The public Agent create route must support arbitrary-depth parentTaskScope
	// nesting. Create a child through the canonical endpoint, then create its
	// child through the same endpoint; neither operation uses fixture SQL.
	create := func(parentScope string, title string) (string, int64) {
		createStatus, createBody := requestAgent("/tasks", map[string]any{
			"parentTaskScope": map[string]any{"kind": "task", "scopeId": parentScope},
			"tasks":           []map[string]any{{"title": title}},
		})
		require.Equal(t, http.StatusOK, createStatus, createBody)
		var response struct {
			Tasks []struct {
				TaskScopeID string `json:"taskScopeId"`
				TaskNumber  int64  `json:"taskNumber"`
			} `json:"tasks"`
		}
		require.NoError(t, json.Unmarshal([]byte(createBody), &response))
		require.Len(t, response.Tasks, 1, createBody)
		return response.Tasks[0].TaskScopeID, response.Tasks[0].TaskNumber
	}
	childScopeID, childNumber := create(taskScopeID, "Desktop E2E child")
	grandchildScopeID, grandchildNumber := create(childScopeID, "Desktop E2E correction")
	require.NotEmpty(t, grandchildScopeID)
	status, body = get(map[string]any{"channel": "#all", "number": childNumber})
	require.Equal(t, http.StatusOK, status, body)
	require.Contains(t, body, "Desktop E2E child")
	status, body = get(map[string]any{"channel": "#all", "number": grandchildNumber})
	require.Equal(t, http.StatusOK, status, body)
	require.Contains(t, body, "Desktop E2E correction")

	// Reverse: retain the direct Task membership, then revoke the board Channel
	// membership. The retained Task membership must grant nothing.
	seedAgentTestPrivateScopeMembership(t, pool, taskScopeID, agentMemberID, humanMemberID, now)
	mustExec(t, pool, `
UPDATE scope_memberships
SET removed_at = $3, updated_at = $3
WHERE scope_id = $1 AND member_id = $2 AND removed_at IS NULL
`, channelID, agentMemberID, now)
	status, body = get(map[string]any{"channel": "#all", "number": 1})
	require.Equal(t, http.StatusForbidden, status, "retained Task membership after Channel revoke must not grant access")
	require.NotContains(t, body, "Desktop E2E task")

	// Missing and existing-unauthorized raw task scopes (via the raw #tsk
	// channel target, matching the Desktop binder's raw-task-scope path) return
	// the identical non-enumerating transport: same status and same body.
	missingStatus, missingBody := get(map[string]any{"channel": "#tsk_desktop_missing", "number": 1})
	unauthorizedStatus, unauthorizedBody := get(map[string]any{"channel": "#tsk_desktop_unauthorized", "number": 1})
	require.Equal(t, missingStatus, unauthorizedStatus, "missing and unauthorized raw task scopes must be identical status")
	require.Equal(t, missingBody, unauthorizedBody, "missing and unauthorized raw task scopes must be identical body")
	require.Equal(t, http.StatusForbidden, missingStatus)
}

func archiveForTest(t *testing.T) archive.Storage {
	t.Helper()
	storage, err := archive.NewFilesystem(t.TempDir())
	require.NoError(t, err)
	return storage
}

func jsonBody(t *testing.T, value any) io.Reader {
	t.Helper()
	encoded, err := json.Marshal(value)
	require.NoError(t, err)
	return strings.NewReader(string(encoded))
}
