package binding

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

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

	"github.com/tidbcloud/AgentTeam/internal/api"
	"github.com/tidbcloud/AgentTeam/internal/store/identity"
	"github.com/tidbcloud/AgentTeam/internal/store/storeerr"
	"github.com/tidbcloud/AgentTeam/internal/store/workspace"
	"github.com/tidbcloud/AgentTeam/internal/workflow/bindingmanage"
	"github.com/tidbcloud/AgentTeam/internal/workflow/publicerr"
)

// manyShortBindingDiagnostics builds count short validation diagnostics so a
// test can push the classifier count budget deterministically.
func manyShortBindingDiagnostics(count int) []bindingmanage.Diagnostic {
	diagnostics := make([]bindingmanage.Diagnostic, 0, count)
	for i := 0; i < count; i++ {
		diagnostics = append(diagnostics, bindingmanage.Diagnostic{
			Severity: "error", Code: "binding.validation_failed",
			Path: "input", Message: "invalid binding input",
		})
	}
	return diagnostics
}

// bindingValidationError builds the resolved manage RequestError that carries
// typed diagnostics in its details envelope, exactly as the manage adapter
// produces for a validation failure.
func bindingValidationError(diagnostics []bindingmanage.Diagnostic) error {
	return bindingmanage.RequestError{
		Kind: storeerr.ErrUnprocessable, Code: bindingmanage.ErrorCodeValidationFailed,
		Message: "binding validation failed",
		Details: map[string]any{"diagnostics": diagnostics},
	}
}

// bindingAPIErrorDetailsProbe is a stand-in for an optional APIErrorDetails
// adapter: it carries a stable code, a synthetic kind, arbitrary error text,
// and structured extra details, letting tests prove the classifier hardens the
// code-only path.
type bindingAPIErrorDetailsProbe struct {
	code  string
	kind  error
	text  string
	extra map[string]any
}

func (probe *bindingAPIErrorDetailsProbe) Error() string { return probe.text }
func (probe *bindingAPIErrorDetailsProbe) Unwrap() error { return probe.kind }
func (probe *bindingAPIErrorDetailsProbe) BindingAPIError() (string, any) {
	return probe.code, probe.extra
}

// TestClassifyBindingEmitsOneDiagnosticsList proves the Human Binding
// classifier emits exactly one resolved diagnostics list: the bounded
// top-level projection with its truncation metadata, and never a second
// unbounded details.diagnostics copy after classification.
func TestClassifyBindingEmitsOneDiagnosticsList(t *testing.T) {
	for _, count := range []int{1, publicerr.MaxServerDiagnostics, publicerr.MaxServerDiagnostics + 5} {
		t.Run("count", func(t *testing.T) {
			response, ok := classifyError(bindingValidationError(manyShortBindingDiagnostics(count)))
			require.True(t, ok)
			require.Equal(t, http.StatusUnprocessableEntity, response.Status)
			require.Equal(t, string(bindingmanage.ErrorCodeValidationFailed), response.Code)
			require.Equal(t, "binding", response.Stage)
			require.Equal(t, "input", response.Path, "first diagnostic path must survive as the resolved path")

			diagnostics, ok := response.Diagnostics.([]publicerr.Diagnostic)
			require.True(t, ok)
			want := count
			if want > publicerr.MaxServerDiagnostics {
				want = publicerr.MaxServerDiagnostics
			}
			require.Len(t, diagnostics, want)

			if count <= publicerr.MaxServerDiagnostics {
				require.Nil(t, response.Truncation, "within-budget diagnostics must not carry truncation metadata")
			} else {
				truncation, ok := response.Truncation.(*publicerr.Truncation)
				require.True(t, ok)
				require.True(t, truncation.DiagnosticsTruncated)
				require.Equal(t, count-publicerr.MaxServerDiagnostics, truncation.OmittedCount)
			}

			// The details envelope must never carry a duplicate diagnostics key.
			require.Nil(t, response.Details, "details envelope must not restore details.diagnostics")
		})
	}
}

// TestClassifyBindingBoundsOversizedDiagnosticFields proves oversized
// diagnostic fields are deterministically rune-capped at classification time
// and the cut fields are reported in the truncation metadata.
func TestClassifyBindingBoundsOversizedDiagnosticFields(t *testing.T) {
	huge := strings.Repeat("x", 4096)
	response, ok := classifyError(bindingValidationError([]bindingmanage.Diagnostic{{
		Severity: "error", Code: "binding.validation_failed", Path: "input",
		Message: huge,
	}}))
	require.True(t, ok)
	require.Equal(t, http.StatusUnprocessableEntity, response.Status)

	diagnostics, ok := response.Diagnostics.([]publicerr.Diagnostic)
	require.True(t, ok)
	require.Len(t, diagnostics, 1)
	require.LessOrEqual(t, len([]rune(diagnostics[0].Message)), 1024)

	truncation, ok := response.Truncation.(*publicerr.Truncation)
	require.True(t, ok, "oversized fields must carry truncation metadata")
	require.True(t, truncation.DiagnosticsTruncated)
	require.Contains(t, truncation.TruncatedFields, "message")
	require.Nil(t, response.Details)
}

// TestClassifyBindingSerializedBudgetCapsDiagnostics proves the classifier
// caps the serialized diagnostics array below the deterministic size budget,
// dropping from the tail and reporting the omitted count.
func TestClassifyBindingSerializedBudgetCapsDiagnostics(t *testing.T) {
	heavy := make([]bindingmanage.Diagnostic, 0, publicerr.MaxServerDiagnostics)
	for i := 0; i < publicerr.MaxServerDiagnostics; i++ {
		heavy = append(heavy, bindingmanage.Diagnostic{
			Severity: "error", Code: "binding.validation_failed", Path: "input",
			Message: strings.Repeat("m", 900),
		})
	}
	response, ok := classifyError(bindingValidationError(heavy))
	require.True(t, ok)
	require.Equal(t, http.StatusUnprocessableEntity, response.Status)

	diagnostics, ok := response.Diagnostics.([]publicerr.Diagnostic)
	require.True(t, ok)
	require.NotEmpty(t, diagnostics, "a reduced but non-empty list must survive")
	require.Less(t, len(diagnostics), publicerr.MaxServerDiagnostics, "serialized budget must drop from the tail")

	truncation, ok := response.Truncation.(*publicerr.Truncation)
	require.True(t, ok)
	require.True(t, truncation.DiagnosticsTruncated)
	require.Greater(t, truncation.OmittedCount, 0)
	require.Greater(t, truncation.SerializedBytes, 0)
	require.Nil(t, response.Details)
}

// TestClassifyBindingAPIErrorDetailsHardened proves the optional APIErrorDetails
// path never trusts arbitrary err.Error as Problem and never restores an
// unbounded details.diagnostics: the problem is the fixed bounded default and
// typed diagnostics project only to the bounded top-level list.
func TestClassifyBindingAPIErrorDetailsHardened(t *testing.T) {
	err := &bindingAPIErrorDetailsProbe{
		code:  "binding_revision_conflict",
		kind:  storeerr.ErrConflict,
		text:  "raw repository: SELECT secret FROM bindings WHERE id = 'x'",
		extra: map[string]any{"expectedRevision": 1, "currentRevision": 2},
	}
	response, ok := classifyError(err)
	require.True(t, ok)
	require.Equal(t, http.StatusPreconditionFailed, response.Status)
	require.Equal(t, "binding_revision_conflict", response.Code)
	require.NotContains(t, response.Message, "SELECT", "arbitrary err.Error must never be projected as the Problem")
	require.NotContains(t, response.Message, "secret")
	require.Contains(t, response.Message, "Workflow request failed")
	require.Equal(t, map[string]any{"expectedRevision": 1, "currentRevision": 2}, response.Details)
}

// TestClassifyBindingAPIErrorDetailsProjectsBoundedDiagnostics proves the
// APIErrorDetails path runs typed diagnostics through the same classifier
// projection as the manage path: bounded top-level list, truncation metadata,
// and no details.diagnostics restoration.
func TestClassifyBindingAPIErrorDetailsProjectsBoundedDiagnostics(t *testing.T) {
	err := &bindingAPIErrorDetailsProbe{
		code:  "binding_validation_failed",
		kind:  storeerr.ErrUnprocessable,
		text:  "arbitrary adapter text",
		extra: map[string]any{"diagnostics": manyShortBindingDiagnostics(publicerr.MaxServerDiagnostics + 3)},
	}
	response, ok := classifyError(err)
	require.True(t, ok)
	require.Equal(t, http.StatusUnprocessableEntity, response.Status)
	require.Equal(t, "binding_validation_failed", response.Code)

	diagnostics, ok := response.Diagnostics.([]publicerr.Diagnostic)
	require.True(t, ok)
	require.Len(t, diagnostics, publicerr.MaxServerDiagnostics)
	truncation, ok := response.Truncation.(*publicerr.Truncation)
	require.True(t, ok)
	require.True(t, truncation.DiagnosticsTruncated)
	require.Equal(t, 3, truncation.OmittedCount)
	require.Nil(t, response.Details, "APIErrorDetails path must never restore details.diagnostics")
}

// TestClassifyBindingOneListJSONDeterminism proves the classifier output
// renders through the real handler+writer into deterministic one-list JSON:
// exactly one top-level diagnostics array and no nested details.diagnostics.
func TestClassifyBindingOneListJSONDeterminism(t *testing.T) {
	gin.SetMode(gin.TestMode)
	handlers := newRouteHandlers(&fakeBindingService{err: bindingValidationError(manyShortBindingDiagnostics(publicerr.MaxServerDiagnostics + 2))})
	router := gin.New()
	router.GET("/workflow/bindings/:bindingId",
		func(ctx *gin.Context) {
			api.SetAuthenticatedUser(ctx, identity.AuthenticatedUser{User: identity.User{ID: "usr_1"}})
			api.SetCurrentWorkspace(ctx, workspace.Summary{WorkspaceID: "wsp_1", MemberID: "mem_1", MemberUserID: "usr_1", WorkspaceRole: "owner"})
		},
		handleWorkspace(handlers.detail.Get),
	)
	request := httptest.NewRequest(http.MethodGet, "/workflow/bindings/bnd_1", http.NoBody)
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)

	serialized := recorder.Body.String()
	require.Equal(t, 1, strings.Count(serialized, `"diagnostics"`), "exactly one diagnostics list must be serialized")
	require.NotContains(t, serialized, `"details"`)
	var body map[string]any
	require.NoError(t, json.Unmarshal([]byte(serialized), &body))
	require.NotNil(t, body["truncation"], "a count-capped diagnostics list must carry truncation metadata")
	require.LessOrEqual(t, len(serialized), 64*1024, "serialized envelope must stay below the apiclient ceiling")
}
