package daemon

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

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/runtimecontrol"
)

// TestReviewFingerprintUnsupportedOutcomeDoesNotFallBack pins the unified
// non-fallback classification codex6 + reviewer3 require: when the desktop
// answers a review fingerprint request with the stable
// ReviewTargetUnsupportedOutcomeCode outcome (control-char paths in the
// worktree), the dispatcher MUST propagate the typed sentinel
// runtimecontrol.ErrReviewTargetUnsupported as-is — it MUST NOT invoke the
// legacy fallback, even though the fingerprint descriptor's
// SafeRetryFallback=true.
//
// Why this matters: the fallback closure in hub.reviewFingerprint returns
// ErrDaemonReviewFingerprintUnavailable, a DIFFERENT typed sentinel. If the
// fallback ran, the caller would receive the "fingerprint unavailable"
// error instead of the "target unsupported" error — the HTTP layer would
// then answer 500-ish (or whatever fingerprint-unavailable maps to) instead
// of the stable 422 the unsupported classification promises. The carve-out
// in invokeViaElasticRequestLinkOrFallback (and the matching one in the
// session-link branch of invokeRemoteEndpoint) closes that hole by treating
// ErrReviewTargetUnsupported as non-retryable regardless of
// SafeRetryFallback.
//
// Why "unsupported" is non-retryable: the desktop has signaled a product
// limitation (control-char paths in the worktree) that the legacy command
// path CANNOT fix — it would just re-surface the same condition as a 501
// (legacy ErrorCode mapper) or generic 500. Falling back would also
// overwrite the typed sentinel and inflate the metrics "safe_retry" count
// for what is actually a stable product limitation, not a transient retry.
//
// This test drives the elastic path end-to-end via the real hub method:
// real elastic link, real result delivery, real production mapper
// (reviewFingerprintFromRemoteResult → remoteEndpointResultError). It
// proves the carve-out fires on the actual wire outcome, not just on a
// synthesized error.
//
// Mutation contract: remove the errors.Is carve-out in
// invokeViaElasticRequestLinkOrFallback and this test fails — the returned
// error becomes ErrDaemonReviewFingerprintUnavailable (fallback ran)
// instead of ErrReviewTargetUnsupported (carve-out fired), AND metrics
// show safe_retry instead of fallback=none.
func TestReviewFingerprintUnsupportedOutcomeDoesNotFallBack(t *testing.T) {
	now := time.Date(2026, 7, 1, 3, 30, 0, 0, time.UTC)
	metrics := newTestDaemonTransportMetrics()
	manager := newTestElasticRequestLinkManager(t, &now, ElasticRequestLinkManagerConfig{Metrics: metrics})
	hub := &Hub{remoteEndpoints: defaultRemoteEndpointRegistry(), elasticRequestLinks: manager}
	client := newElasticReadyClient()
	descriptor, ok := defaultRemoteEndpointRegistry().Lookup(remoteEndpointReviewFingerprint, 1)
	require.True(t, ok)
	require.True(t, descriptor.SafeRetryFallback,
		"fingerprint endpoint must be SafeRetryFallback=true; otherwise the carve-out is not load-bearing")
	open, _, err := manager.CreateLinkGrant(client, descriptor)
	require.NoError(t, err)
	sender := newCaptureElasticSender()
	_, err = manager.AcceptLink(client.workspaceID, open.LinkID, open.Token, sender)
	require.NoError(t, err)

	// Goroutine fulfills the elastic assignment with the stable unsupported
	// outcome. The production mapper converts this outcome to
	// runtimecontrol.ErrReviewTargetUnsupported.
	go func() {
		message := <-sender.messages
		assignment, ok := message.(RemoteRequestAssignment[json.RawMessage])
		require.True(t, ok, "expected RemoteRequestAssignment, got %T", message)
		require.Equal(t, remoteEndpointReviewFingerprint, assignment.EndpointID)
		manager.CompleteRemoteRequest(RemoteRequestResult[json.RawMessage]{
			Type:      sessionLinkRemoteRequestResultType,
			RequestID: assignment.RequestID,
			Success:   false,
			Outcome:   runtimecontrol.ReviewTargetUnsupportedOutcomeCode,
		})
	}()

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	_, err = hub.reviewFingerprint(ctx, client, "agt_1", "/tmp/review", "main", "feature", "direct")
	require.Error(t, err)

	// The carve-out MUST surface ErrReviewTargetUnsupported as-is. If the
	// fallback fired instead, err would be ErrDaemonReviewFingerprintUnavailable
	// (the fallback's return value) — a DIFFERENT typed sentinel.
	require.True(t, errors.Is(err, runtimecontrol.ErrReviewTargetUnsupported),
		"elastic path MUST surface ErrReviewTargetUnsupported for HTTP 422 mapping; got %v", err)
	require.False(t, errors.Is(err, runtimecontrol.ErrDaemonReviewFingerprintUnavailable),
		"fallback MUST NOT run when desktop returns ErrReviewTargetUnsupported — ErrDaemonReviewFingerprintUnavailable would surface if it did; got %v", err)

	// Metrics: exactly ONE request recorded with fallback label "none". A
	// safe_retry path would have recorded TWO requests (elastic_request_link
	// + legacy_session_link), both with fallback "safe_retry" — the count
	// and label together prove the carve-out fires.
	require.True(t, metrics.hasRequest(descriptor.MetricsName, "elastic_request_link", "failure", "none"),
		"elastic path MUST record the unsupported outcome as elastic_request_link/outcome=failure/fallback=none")
	require.False(t, anyMetricWithFallback(metrics, "safe_retry"),
		"no safe_retry metric MUST be recorded for unsupported outcome (would inflate dashboards)")
	require.Equal(t, 1, countMetricsForEndpoint(metrics, descriptor.MetricsName),
		"exactly ONE metrics record MUST exist for the unsupported outcome (elastic only); a fallback path would produce TWO")
}

// anyMetricWithFallback reports whether any recorded request used the
// supplied fallback label. Used to assert that NO safe_retry metric was
// recorded for an unsupported outcome.
func anyMetricWithFallback(metrics *testDaemonTransportMetrics, fallback string) bool {
	metrics.mu.Lock()
	defer metrics.mu.Unlock()
	for _, req := range metrics.requests {
		if req.fallback == fallback {
			return true
		}
	}
	return false
}

// countMetricsForEndpoint returns the number of recorded request metrics
// for the supplied endpoint. Used to assert that exactly one record exists
// (proving the dispatcher did not double-record elastic + legacy).
func countMetricsForEndpoint(metrics *testDaemonTransportMetrics, endpoint string) int {
	metrics.mu.Lock()
	defer metrics.mu.Unlock()
	count := 0
	for _, req := range metrics.requests {
		if req.endpoint == endpoint {
			count++
		}
	}
	return count
}

// TestReviewFingerprintUnsupportedOutcomeDoesNotFallBackOnSessionLink pins
// the SESSION-link branch of the unified non-fallback classification. The
// elastic sibling test above proves the carve-out fires on the elastic path;
// reviewer3's stage-2 directive explicitly demanded "session/elastic 均
// fallback=0/metrics none" — i.e. BOTH routes MUST be exercised, because the
// carve-out lives in TWO physically distinct switch branches
// (invokeRemoteEndpoint session-link branch AND
// invokeViaElasticRequestLinkOrFallback). A single elastic test cannot prove
// the session branch's copy fires.
//
// Approach: drive the real hub.reviewFingerprint end-to-end so the production
// mapper (reviewFingerprintFromRemoteResult → remoteEndpointResultError) and
// production fallback closure are exercised. The fingerprint descriptor is
// ElasticPreferred in production, which would route via elastic and never
// touch the session carve-out. To force session-link routing without changing
// any production code, the test overrides ONLY the descriptor's LinkPolicy to
// SessionPreferred in a private registry copy. Every other field — including
// SafeRetryFallback=true, which makes the carve-out load-bearing — is the
// production value.
//
// Mutation contract: remove the errors.Is carve-out in the session-link
// branch of invokeRemoteEndpoint and this test fails — the returned error
// becomes ErrDaemonReviewFingerprintUnavailable (fallback ran) instead of
// ErrReviewTargetUnsupported (carve-out fired), AND metrics show safe_retry
// with TWO records instead of one fallback=none record.
func TestReviewFingerprintUnsupportedOutcomeDoesNotFallBackOnSessionLink(t *testing.T) {
	now := time.Date(2026, 7, 1, 3, 30, 0, 0, time.UTC)
	metrics := newTestDaemonTransportMetrics()
	manager := newTestElasticRequestLinkManager(t, &now, ElasticRequestLinkManagerConfig{Metrics: metrics})

	// Override ONLY the LinkPolicy on a private registry copy. Touching
	// SafeRetryFallback would invalidate the carve-out's load-bearing claim,
	// so we assert it stays true after the override.
	registry := mustRemoteEndpointRegistry(defaultRemoteEndpointRegistry().Descriptors())
	key := remoteEndpointKey(remoteEndpointReviewFingerprint, 1)
	fpDescriptor := registry.byKey[key]
	fpDescriptor.LinkPolicy = RemoteEndpointLinkPolicySessionPreferred
	registry.byKey[key] = fpDescriptor
	require.True(t, fpDescriptor.SafeRetryFallback,
		"fingerprint endpoint must remain SafeRetryFallback=true after policy override; otherwise the carve-out is not load-bearing")

	serverConn, desktopConn := newDaemonProtocolTestPair(t)
	client := newWorkspaceListRemoteReadyClient(serverConn)
	// Restrict capabilities to V1+endpoints so the elastic branch is never
	// reachable even if routing somehow tried it; the test's contract is
	// strictly session-link.
	client.setCapabilities([]string{daemonTransportCapabilityV1, daemonEndpointsCapabilityV1})
	hub := &Hub{remoteEndpoints: registry, elasticRequestLinks: manager}

	// Verify routing actually takes the session-link branch — if the policy
	// override is wrong, this assertion fails before the goroutine runs.
	decision := manager.DecideRoute(client, fpDescriptor, RemoteRequestRouteOptions{})
	require.Equal(t, RemoteRequestRouteSessionLink, decision.Mode,
		"test setup invariant: descriptor policy override MUST yield session-link routing")

	// Goroutine fulfills the session-link assignment with the stable
	// unsupported outcome. The production mapper converts this outcome to
	// runtimecontrol.ErrReviewTargetUnsupported.
	go func() {
		var assignment RemoteRequestAssignment[json.RawMessage]
		require.NoError(t, desktopConn.ReadJSON(&assignment))
		require.Equal(t, sessionLinkRemoteRequestAssignType, assignment.Type)
		require.Equal(t, remoteEndpointReviewFingerprint, assignment.EndpointID)
		require.NoError(t, handleSessionLinkRemoteRequestResultCommand(hub, context.Background(), client, mustMarshalRemoteWorkspaceTest(t, RemoteRequestResult[json.RawMessage]{
			Type:      sessionLinkRemoteRequestResultType,
			RequestID: assignment.RequestID,
			Success:   false,
			Outcome:   runtimecontrol.ReviewTargetUnsupportedOutcomeCode,
		})))
	}()

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	_, err := hub.reviewFingerprint(ctx, client, "agt_1", "/tmp/review", "main", "feature", "direct")
	require.Error(t, err)

	// The carve-out MUST surface ErrReviewTargetUnsupported as-is. If the
	// fallback fired instead, err would be ErrDaemonReviewFingerprintUnavailable
	// (the fallback's return value) — a DIFFERENT typed sentinel.
	require.True(t, errors.Is(err, runtimecontrol.ErrReviewTargetUnsupported),
		"session path MUST surface ErrReviewTargetUnsupported for HTTP 422 mapping; got %v", err)
	require.False(t, errors.Is(err, runtimecontrol.ErrDaemonReviewFingerprintUnavailable),
		"fallback MUST NOT run when desktop returns ErrReviewTargetUnsupported on session link — ErrDaemonReviewFingerprintUnavailable would surface if it did; got %v", err)

	// Metrics: exactly ONE request recorded with fallback label "none". A
	// safe_retry path would have recorded TWO requests (session_link +
	// legacy_session_link), both with fallback "safe_retry" — the count and
	// label together prove the carve-out fires on the session branch.
	require.True(t, metrics.hasRequest(fpDescriptor.MetricsName, "session_link", "failure", "none"),
		"session path MUST record the unsupported outcome as session_link/outcome=failure/fallback=none")
	require.False(t, anyMetricWithFallback(metrics, "safe_retry"),
		"no safe_retry metric MUST be recorded for unsupported outcome on session link (would inflate dashboards)")
	require.Equal(t, 1, countMetricsForEndpoint(metrics, fpDescriptor.MetricsName),
		"exactly ONE metrics record MUST exist for the unsupported outcome (session only); a fallback path would produce TWO")
}

// TestReviewLegacyResultHandlersReconstructTypedSentinelOnTracker is the
// wire-integration half of the legacy ErrorCode mapping contract.
// TestReviewLegacyErrorCodeMapsToTypedUnsupportedSentinel (above) pins the
// reviewLegacyError helper directly; this test drives the FULL wire path —
// a real review*ResultMessage JSON payload, decoded by
// handleSessionLinkAgentReview*ResultCommand, with the reconstructed
// sentinel delivered to the per-client tracker — and asserts the tracker
// receives runtimecontrol.ErrReviewTargetUnsupported (NOT a generic
// errors.New).
//
// reviewer3/codex6's stage-3 directive explicitly demanded this wire
// integration: "legacy ErrorCode 仅 reviewLegacyError helper 单测，没有 wire
// 集成 ... 请用真实 agentReviewSummaryResult 等 wire 消息驱动
// handleSessionLinkAgentReview*ResultCommand，断言 tracker 收到 typed sentinel".
// The helper test alone is insufficient because the handler also owns the
// Deliver call site — a regression that swaps reviewLegacyError for
// errors.New(message.Error), OR drops ErrorCode entirely, OR mis-routes the
// Deliver call, would slip past the helper test but break the wire contract.
//
// Mutation contract (per handler):
//   - Replace `result.err = reviewLegacyError(message.ErrorCode, trimmedError)`
//     with `result.err = errors.New(trimmedError)` → tracker.err is no
//     longer errors.Is-able to ErrReviewTargetUnsupported → test fails.
//   - Remove the trimmedError != "" guard (always populate result.err) →
//     not caught here, but the summary-nil/diff-nil path tests pin that.
//
// The test exercises all three legacy result handlers because each owns an
// INDEPENDENT reviewLegacyError call site — pinning one would not catch a
// regression that broke the mapping in another handler's body.
func TestReviewLegacyResultHandlersReconstructTypedSentinelOnTracker(t *testing.T) {
	t.Parallel()

	const requestID = "rq_legacy_unsupported_pin"

	// buildClient returns a minimal clientState with the review-summary /
	// review-files / review-diff tracker maps initialized. The result
	// handlers only touch these three maps plus client.mu, so the rest of
	// the clientState zero value is safe.
	buildClient := func() *clientState {
		return &clientState{
			reviewSummaryRequests: make(map[string]chan reviewSummaryResult),
			reviewFilesRequests:   make(map[string]chan reviewFilesResult),
			reviewDiffRequests:    make(map[string]chan reviewDiffResult),
		}
	}

	// Stays path-free — the typed sentinel's Error() text is the only thing
	// an operator ever sees in logs/UI, and it MUST NOT carry the offending
	// filesystem path bytes. The text used here mirrors what the desktop
	// actually emits.
	const pathFreeErrorText = "review target contains a git path with control characters (unsupported by the current diff engine)"

	t.Run("summary", func(t *testing.T) {
		t.Parallel()
		client := buildClient()
		resultCh, cleanup, err := client.reviewSummaryTracker().Register(requestID)
		require.NoError(t, err)
		defer cleanup()

		payload := mustMarshalRemoteWorkspaceTest(t, reviewSummaryResultMessage{
			Type:      sessionLinkCommandAgentReviewSummaryResult,
			AgentID:   "agt_legacy_summary",
			RequestID: requestID,
			Error:     pathFreeErrorText,
			ErrorCode: runtimecontrol.ReviewTargetUnsupportedLegacyErrorCode,
		})
		require.NoError(t, handleSessionLinkAgentReviewSummaryResultCommand(nil, context.Background(), client, payload))

		select {
		case result := <-resultCh:
			require.True(t, errors.Is(result.err, runtimecontrol.ErrReviewTargetUnsupported),
				"summary handler MUST reconstruct ErrReviewTargetUnsupported from legacy ErrorCode and deliver it to the tracker; got %v", result.err)
		case <-time.After(time.Second):
			t.Fatal("summary handler did not deliver a result to the tracker within 1s")
		}
	})

	t.Run("files", func(t *testing.T) {
		t.Parallel()
		client := buildClient()
		resultCh, cleanup, err := client.reviewFilesTracker().Register(requestID)
		require.NoError(t, err)
		defer cleanup()

		payload := mustMarshalRemoteWorkspaceTest(t, reviewFilesResultMessage{
			Type:      sessionLinkCommandAgentReviewFilesResult,
			AgentID:   "agt_legacy_files",
			RequestID: requestID,
			Error:     pathFreeErrorText,
			ErrorCode: runtimecontrol.ReviewTargetUnsupportedLegacyErrorCode,
		})
		require.NoError(t, handleSessionLinkAgentReviewFilesResultCommand(nil, context.Background(), client, payload))

		select {
		case result := <-resultCh:
			require.True(t, errors.Is(result.err, runtimecontrol.ErrReviewTargetUnsupported),
				"files handler MUST reconstruct ErrReviewTargetUnsupported from legacy ErrorCode and deliver it to the tracker; got %v", result.err)
		case <-time.After(time.Second):
			t.Fatal("files handler did not deliver a result to the tracker within 1s")
		}
	})

	t.Run("diff", func(t *testing.T) {
		t.Parallel()
		client := buildClient()
		resultCh, cleanup, err := client.reviewDiffTracker().Register(requestID)
		require.NoError(t, err)
		defer cleanup()

		payload := mustMarshalRemoteWorkspaceTest(t, reviewDiffResultMessage{
			Type:      sessionLinkCommandAgentReviewDiffResult,
			AgentID:   "agt_legacy_diff",
			RequestID: requestID,
			Error:     pathFreeErrorText,
			ErrorCode: runtimecontrol.ReviewTargetUnsupportedLegacyErrorCode,
		})
		require.NoError(t, handleSessionLinkAgentReviewDiffResultCommand(nil, context.Background(), client, payload))

		select {
		case result := <-resultCh:
			require.True(t, errors.Is(result.err, runtimecontrol.ErrReviewTargetUnsupported),
				"diff handler MUST reconstruct ErrReviewTargetUnsupported from legacy ErrorCode and deliver it to the tracker; got %v", result.err)
		case <-time.After(time.Second):
			t.Fatal("diff handler did not deliver a result to the tracker within 1s")
		}
	})

	// Unknown legacy ErrorCode MUST fall through to a generic error on the
	// wire path too — a future desktop may emit a new code that an older
	// Hub does not recognize; the Hub MUST NOT classify it as unsupported.
	// Pins the same forward-compat contract as the helper-level test, but
	// on the full wire path (handler → tracker).
	t.Run("unknown_code_falls_through_on_wire_path", func(t *testing.T) {
		t.Parallel()
		client := buildClient()
		resultCh, cleanup, err := client.reviewSummaryTracker().Register(requestID)
		require.NoError(t, err)
		defer cleanup()

		payload := mustMarshalRemoteWorkspaceTest(t, reviewSummaryResultMessage{
			Type:      sessionLinkCommandAgentReviewSummaryResult,
			AgentID:   "agt_legacy_unknown",
			RequestID: requestID,
			Error:     "some future error text",
			ErrorCode: "some_future_code",
		})
		require.NoError(t, handleSessionLinkAgentReviewSummaryResultCommand(nil, context.Background(), client, payload))

		select {
		case result := <-resultCh:
			require.Error(t, result.err)
			require.False(t, errors.Is(result.err, runtimecontrol.ErrReviewTargetUnsupported),
				"unknown legacy ErrorCode MUST NOT classify as unsupported on the wire path; got %v", result.err)
			require.Contains(t, result.err.Error(), "some future error text",
				"unknown legacy ErrorCode MUST surface the supplied error text verbatim; got %v", result.err)
		case <-time.After(time.Second):
			t.Fatal("summary handler did not deliver a result to the tracker within 1s")
		}
	})
}

// TestReviewLegacyErrorCodeMapsToTypedUnsupportedSentinel pins the OTHER wire
// half of the unified non-fallback classification: the stable Outcome string
// path (covered by the elastic/session tests above) is the NEW wire shape for
// endpoints that go through Remote Request Link; the legacy ErrorCode field
// is the OLD wire shape, used by every desktop that still emits the legacy
// agentReview*ResultMsg structs (summary / files / diff).
//
// reviewer3/codex6's directive explicitly demanded both: "wire outcome/legacy
// ErrorCode → Hub mapper → typed sentinel → HTTP 422". The channel/webagent
// HTTP-422 tests (in their packages) pin the sentinel→HTTP step; this test
// pins the legacy ErrorCode→sentinel step so the full legacy chain is
// covered by composition.
//
// Why this matters: the legacy wire has no Outcome field, so the Desktop
// emits the same stable string via ErrorCode. Hub-side reviewLegacyError is
// the ONLY place that reconstructs the typed sentinel from that code — if it
// fell through to the generic errors.New(errorText), channel/webagent would
// surface 500 instead of 422, re-storming the desktop on every poll.
//
// Mutation contract: change ReviewTargetUnsupportedLegacyErrorCode's value
// OR remove the equality check in reviewLegacyError and this test fails —
// the error becomes a generic errors.New, NOT errors.Is-able to
// ErrReviewTargetUnsupported.
func TestReviewLegacyErrorCodeMapsToTypedUnsupportedSentinel(t *testing.T) {
	// Stable code maps to the typed sentinel regardless of the surrounding
	// error text. The text is itself path-free by contract (desktop strips
	// it), but the typed sentinel is what the HTTP layer keys off.
	err := reviewLegacyError(runtimecontrol.ReviewTargetUnsupportedLegacyErrorCode,
		"some path-free generic text")
	require.True(t, errors.Is(err, runtimecontrol.ErrReviewTargetUnsupported),
		"legacy ErrorCode %q MUST map to ErrReviewTargetUnsupported; got %v",
		runtimecontrol.ReviewTargetUnsupportedLegacyErrorCode, err)

	// Whitespace around the code MUST NOT break the mapping — the desktop
	// emits the code via JSON, and any inadvertent padding must not silently
	// downgrade the response to 500.
	err = reviewLegacyError("  "+runtimecontrol.ReviewTargetUnsupportedLegacyErrorCode+"  ",
		"path-free generic text")
	require.True(t, errors.Is(err, runtimecontrol.ErrReviewTargetUnsupported),
		"padded legacy ErrorCode MUST still map to ErrReviewTargetUnsupported; got %v", err)

	// Unknown codes fall through to a generic error carrying the supplied
	// text. This is the forward-compat contract: a future desktop may emit
	// a new code that an older Hub does not recognize; the Hub MUST NOT
	// classify it as unsupported.
	err = reviewLegacyError("some_future_code", "future error text")
	require.False(t, errors.Is(err, runtimecontrol.ErrReviewTargetUnsupported),
		"unknown legacy ErrorCode MUST NOT map to ErrReviewTargetUnsupported; got %v", err)
	require.Contains(t, err.Error(), "future error text",
		"unknown legacy ErrorCode MUST surface the supplied error text verbatim; got %v", err)
}
