package api

import (
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"unicode/utf8"

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

	"github.com/tidbcloud/AgentTeam/internal/store/message"
	"github.com/tidbcloud/AgentTeam/internal/store/taskboard"
	"github.com/tidbcloud/AgentTeam/internal/workflow/publicerr"
	"github.com/tidbcloud/AgentTeam/internal/workspace/owner"
)

func TestStoreErrorWritesContentModerationCode(t *testing.T) {
	gin.SetMode(gin.TestMode)
	recorder := httptest.NewRecorder()
	ctx, _ := gin.CreateTestContext(recorder)

	StoreError(ctx, message.ContentModerationBlocked("blocked"))

	require.Equal(t, http.StatusBadRequest, recorder.Code)
	require.JSONEq(t, `{"error":"content contains disallowed information","code":"content_moderation_blocked"}`, recorder.Body.String())
}

func TestStoreErrorWritesTaskForbidden(t *testing.T) {
	gin.SetMode(gin.TestMode)
	recorder := httptest.NewRecorder()
	ctx, _ := gin.CreateTestContext(recorder)

	StoreError(ctx, taskboard.ErrForbidden)

	require.Equal(t, http.StatusForbidden, recorder.Code)
	require.JSONEq(t, `{"error":"forbidden"}`, recorder.Body.String())
}

func TestStoreErrorWritesStructuredTaskStatusTransitionConflict(t *testing.T) {
	gin.SetMode(gin.TestMode)
	recorder := httptest.NewRecorder()
	ctx, _ := gin.CreateTestContext(recorder)

	StoreError(ctx, taskboard.InvalidTaskStatusTransitionError{
		Message:             "invalid task status transition",
		CurrentStatus:       taskboard.StatusTodo,
		DesiredStatus:       taskboard.StatusDone,
		AllowedNextStatuses: []taskboard.Status{taskboard.StatusInProgress, taskboard.StatusInReview, taskboard.StatusDone},
		Hint:                "Task status changes are open by default; configure status policy gates separately when needed.",
	})

	require.Equal(t, http.StatusConflict, recorder.Code)
	require.JSONEq(t, `{
		"error": "invalid task status transition",
		"code": "invalid_task_status_transition",
		"details": {
			"currentStatus": "todo",
			"desiredStatus": "done",
			"allowedNextStatuses": ["in_progress", "in_review", "done"],
			"hint": "Task status changes are open by default; configure status policy gates separately when needed."
		}
	}`, recorder.Body.String())
}

func TestRequireWorkspaceOwnerReturnsSharedHTTPError(t *testing.T) {
	t.Parallel()

	err := RequireWorkspaceOwner(owner.NewStaticSet([]string{"wsp_owned"}), "wsp_other")

	require.ErrorIs(t, err, owner.ErrOwnerMismatch)
	require.Equal(t, http.StatusMisdirectedRequest, StatusForError(err))

	var httpErr HTTPError
	require.True(t, errors.As(err, &httpErr))
	require.Equal(t, http.StatusMisdirectedRequest, httpErr.Status)
	require.Equal(t, WorkspaceOwnerMismatchMessage, httpErr.Message)
	require.Equal(t, WorkspaceOwnerMismatchCode, httpErr.Code)
}

func TestErrorWorkspaceOwnerMismatchWritesSharedPayload(t *testing.T) {
	gin.SetMode(gin.TestMode)
	recorder := httptest.NewRecorder()
	ctx, _ := gin.CreateTestContext(recorder)

	ErrorWorkspaceOwnerMismatch(ctx)

	require.Equal(t, http.StatusMisdirectedRequest, recorder.Code)
	require.JSONEq(t, `{"error":"Workspace owner mismatch","code":"workspace_owner_mismatch"}`, recorder.Body.String())
}

func TestEnsureWorkspaceOwnerWritesSharedPayload(t *testing.T) {
	gin.SetMode(gin.TestMode)
	recorder := httptest.NewRecorder()
	ctx, _ := gin.CreateTestContext(recorder)

	ok := EnsureWorkspaceOwner(ctx, owner.NewStaticSet([]string{"wsp_owned"}), "wsp_other")

	require.False(t, ok)
	require.Equal(t, http.StatusMisdirectedRequest, recorder.Code)
	require.JSONEq(t, `{"error":"Workspace owner mismatch","code":"workspace_owner_mismatch"}`, recorder.Body.String())
}

func TestErrorWithContractWholeEnvelopeStaysBelowApiclientCeiling(t *testing.T) {
	gin.SetMode(gin.TestMode)
	// The desktop apiclient reads up to 64 KiB; the writer must keep the complete
	// serialized envelope below that ceiling even with a large diagnostics array
	// and a large details envelope.
	diagnostics := make([]publicerr.Diagnostic, 0, publicerr.MaxServerDiagnostics)
	for i := 0; i < publicerr.MaxServerDiagnostics; i++ {
		diagnostics = append(diagnostics, publicerr.Diagnostic{
			Severity: publicerr.SeverityError, Code: "plan.test", Path: "$.nodes.n",
			Field: "n", Message: strings.Repeat("m", 1000),
		})
	}
	recorder := httptest.NewRecorder()
	ctx, _ := gin.CreateTestContext(recorder)
	ErrorWithContract(ctx, http.StatusUnprocessableEntity, strings.Repeat("p", 500), "plan_compile_failed", map[string]any{
		"resource": strings.Repeat("r", 4000),
	}, ErrorContract{
		Stage: "plan", Path: "$.nodes.entry", Hint: strings.Repeat("h", 300),
		Diagnostics: diagnostics,
	})
	require.Equal(t, http.StatusUnprocessableEntity, recorder.Code)
	require.LessOrEqual(t, len(recorder.Body.Bytes()), 64*1024, "serialized envelope must stay below the apiclient ceiling")
	var body map[string]any
	require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
	// The envelope is still a coherent contract: code survives, diagnostics are
	// present (bounded), and any whole-envelope truncation is explicit.
	require.Equal(t, "plan_compile_failed", body["code"])
	require.Contains(t, body, "diagnostics")
	require.NotNil(t, body["truncation"])
}

func TestErrorWithContractSmallEnvelopeHasNoTruncation(t *testing.T) {
	gin.SetMode(gin.TestMode)
	recorder := httptest.NewRecorder()
	ctx, _ := gin.CreateTestContext(recorder)
	ErrorWithContract(ctx, http.StatusNotFound, "not found", "run_not_found", nil, ErrorContract{
		Stage: "run", Path: "$.run",
	})
	require.Equal(t, http.StatusNotFound, recorder.Code)
	require.JSONEq(t, `{"error":"not found","code":"run_not_found","stage":"run","path":"$.run"}`, recorder.Body.String())
}

func TestErrorWithContractUTF8Safe(t *testing.T) {
	gin.SetMode(gin.TestMode)
	diagnostics := []publicerr.Diagnostic{{
		Severity: publicerr.SeverityError, Code: "plan.test", Path: "$.n",
		Message: "节点不可达\xff\xfe",
	}}
	recorder := httptest.NewRecorder()
	ctx, _ := gin.CreateTestContext(recorder)
	ErrorWithContract(ctx, http.StatusUnprocessableEntity, "编译失败", "plan_compile_failed", nil, ErrorContract{
		Stage: "plan", Diagnostics: diagnostics,
	})
	require.Equal(t, http.StatusUnprocessableEntity, recorder.Code)
	require.True(t, utf8.Valid(recorder.Body.Bytes()), "serialized envelope must remain valid UTF-8")
	var body map[string]any
	require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
	require.NotNil(t, body["diagnostics"])
}

// TestErrorWithContractSerializedBytesMatchesGinWriterBody proves the writer is
// a true serialization fixed point: the truncation report's SerializedBytes
// equals the exact byte length of the JSON body the Gin writer actually emitted
// (including the truncation metadata and any encoder escaping), and that body
// stays below the desktop apiclient's 64 KiB read ceiling.
func TestErrorWithContractSerializedBytesMatchesGinWriterBody(t *testing.T) {
	gin.SetMode(gin.TestMode)
	diagnostics := make([]publicerr.Diagnostic, 0, publicerr.MaxServerDiagnostics)
	for i := 0; i < publicerr.MaxServerDiagnostics; i++ {
		diagnostics = append(diagnostics, publicerr.Diagnostic{
			Severity: publicerr.SeverityError, Code: "plan.test", Path: "$.nodes.n",
			Field: "n", Message: strings.Repeat("m", 2000),
		})
	}
	recorder := httptest.NewRecorder()
	ctx, _ := gin.CreateTestContext(recorder)
	ErrorWithContract(ctx, http.StatusUnprocessableEntity, strings.Repeat("p", 500), "plan_compile_failed", map[string]any{
		"resource": strings.Repeat("r", 4000),
	}, ErrorContract{
		Stage: "plan", Path: "$.nodes.entry", Hint: strings.Repeat("h", 300),
		Diagnostics: diagnostics,
	})
	require.Equal(t, http.StatusUnprocessableEntity, recorder.Code)
	body := recorder.Body.Bytes()
	require.LessOrEqual(t, len(body), 64*1024, "serialized envelope must stay below the apiclient ceiling")
	var parsed struct {
		Truncation *struct {
			SerializedBytes int `json:"serializedBytes"`
		} `json:"truncation"`
	}
	require.NoError(t, json.Unmarshal(body, &parsed))
	require.NotNil(t, parsed.Truncation, "a writer-budgeted envelope must report truncation")
	require.Equal(t, len(body), parsed.Truncation.SerializedBytes,
		"SerializedBytes must equal the exact final Gin-written body length")
}
