package toolcontract

import (
	"encoding/json"
	"fmt"
	"strings"
	"testing"
	"time"

	"github.com/jmrGrav/mcp-hugo-server-go/internal/buildinfo"
	"github.com/modelcontextprotocol/go-sdk/mcp"
)

func TestNewError(t *testing.T) {
	got := NewError("ambiguous_language", "Multiple language variants exist.")
	if got.Code != "ambiguous_language" {
		t.Fatalf("NewError().Code = %q", got.Code)
	}
	if got.Message != "Multiple language variants exist." {
		t.Fatalf("NewError().Message = %q", got.Message)
	}
	if got.Retryable {
		t.Fatal("NewError().Retryable = true, want false")
	}
}

func TestSuccessInitializesSlicesAndMeta(t *testing.T) {
	origRelease := buildinfo.ReleaseVersion
	origCommit := buildinfo.Commit
	origChannel := buildinfo.BuildChannel
	buildinfo.ReleaseVersion = "v1.5.1"
	buildinfo.Commit = "50cbc9fe4217"
	buildinfo.BuildChannel = "main"
	defer func() {
		buildinfo.ReleaseVersion = origRelease
		buildinfo.Commit = origCommit
		buildinfo.BuildChannel = origChannel
	}()

	meta := NewMeta("1.4.0", time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC))
	got := Success(map[string]string{"status": "ok"}, meta)

	raw, err := json.Marshal(got)
	if err != nil {
		t.Fatalf("Marshal() error = %v", err)
	}
	var decoded map[string]any
	if err := json.Unmarshal(raw, &decoded); err != nil {
		t.Fatalf("Unmarshal() error = %v", err)
	}
	if decoded["success"] != true {
		t.Fatalf("success = %v, want true", decoded["success"])
	}
	if _, ok := decoded["errors"].([]any); !ok {
		t.Fatalf("errors = %#v, want []", decoded["errors"])
	}
	if _, ok := decoded["warnings"].([]any); !ok {
		t.Fatalf("warnings = %#v, want []", decoded["warnings"])
	}
	metaMap, ok := decoded["meta"].(map[string]any)
	if !ok {
		t.Fatalf("meta = %T, want map", decoded["meta"])
	}
	if metaMap["release_version"] != "1.4.0" {
		t.Fatalf("meta.release_version = %v, want 1.4.0", metaMap["release_version"])
	}
	if _, present := metaMap["server_version"]; present {
		t.Fatalf("meta.server_version = %v, want absent — renamed to release_version (#563)", metaMap["server_version"])
	}
	if metaMap["commit"] != "50cbc9fe4217" {
		t.Fatalf("meta.commit = %v, want 50cbc9fe4217", metaMap["commit"])
	}
	if metaMap["build_channel"] != "main" {
		t.Fatalf("meta.build_channel = %v, want main", metaMap["build_channel"])
	}
	if metaMap["schema_version"] != ToolResultVersion {
		t.Fatalf("meta.schema_version = %v, want %q", metaMap["schema_version"], ToolResultVersion)
	}
	if _, ok := decoded["version"]; ok {
		t.Fatalf("root-level version should be removed (#454), got %v", decoded["version"])
	}
}

// TestSuccessReleaseVersionAlwaysPopulatedForMainlineBuilds covers #563:
// meta.release_version (renamed from server_version) keeps the same
// always-populated semantics its predecessor had — on a mainline build it's
// "main-<sha>", not empty, unlike the older (pre-#560) release_version field
// this rename deliberately does not restore.
func TestSuccessReleaseVersionAlwaysPopulatedForMainlineBuilds(t *testing.T) {
	origVersion := buildinfo.Version
	origCommit := buildinfo.Commit
	origChannel := buildinfo.BuildChannel
	buildinfo.Version = "main-3fb254677090"
	buildinfo.Commit = "3fb25467709019d1611a252f2ccfc9376677031d"
	buildinfo.BuildChannel = ""
	defer func() {
		buildinfo.Version = origVersion
		buildinfo.Commit = origCommit
		buildinfo.BuildChannel = origChannel
	}()

	meta := NewMeta(buildinfo.Version, time.Date(2026, 7, 18, 18, 0, 0, 0, time.UTC))
	got := Success(map[string]string{"status": "ok"}, meta)

	raw, err := json.Marshal(got)
	if err != nil {
		t.Fatalf("Marshal() error = %v", err)
	}
	var decoded map[string]any
	if err := json.Unmarshal(raw, &decoded); err != nil {
		t.Fatalf("Unmarshal() error = %v", err)
	}
	metaMap, ok := decoded["meta"].(map[string]any)
	if !ok {
		t.Fatalf("meta = %T, want map", decoded["meta"])
	}
	if _, present := metaMap["server_version"]; present {
		t.Fatalf("meta.server_version = %v, want absent — renamed to release_version (#563)", metaMap["server_version"])
	}
	if got := metaMap["release_version"]; got != "main-3fb254677090" {
		t.Fatalf("meta.release_version = %v, want main-3fb254677090", got)
	}
	if got := metaMap["build_channel"]; got != "main" {
		t.Fatalf("meta.build_channel = %v, want main", got)
	}
}

func TestParseToolErrorAmbiguousLanguage(t *testing.T) {
	got := ParseToolError(fmt.Errorf("ambiguous_language: page %q has multiple language files; specify lang (available: en, fr)", "posts/hello"))
	if got.Code != "ambiguous_language" {
		t.Fatalf("Code = %q", got.Code)
	}
	if got.Field != "lang" || !got.Retryable {
		t.Fatalf("Field/Retryable = %q/%v", got.Field, got.Retryable)
	}
	if got.Resolution == nil || got.Resolution.Action != "retry_with_parameter" || got.Resolution.Parameter != "lang" {
		t.Fatalf("Resolution = %#v", got.Resolution)
	}
	if len(got.Resolution.AllowedValues) != 2 || got.Resolution.AllowedValues[0] != "en" || got.Resolution.AllowedValues[1] != "fr" {
		t.Fatalf("AllowedValues = %#v", got.Resolution.AllowedValues)
	}
}

func TestParseToolErrorPreviewUnreachableIsOperatorActionable(t *testing.T) {
	got := ParseToolError(fmt.Errorf("preview_unreachable: public preview ingress returned the wrong page"))
	if got.Code != "preview_unreachable" || got.Retryable {
		t.Fatalf("ParseToolError = %#v, want non-retryable preview_unreachable", got)
	}
	if got.Resolution == nil || got.Resolution.Action != "contact_operator" {
		t.Fatalf("resolution = %#v, want contact_operator", got.Resolution)
	}
	if !strings.Contains(got.Suggestion, "/preview/") || !strings.Contains(got.Suggestion, "index.html") {
		t.Fatalf("suggestion = %q, want concrete proxy guidance", got.Suggestion)
	}
}

func TestParseToolErrorMissingRequiredParameter(t *testing.T) {
	got := ParseToolError(fmt.Errorf("invalid_params: slug must not be empty"))
	if got.Code != "missing_required_parameter" {
		t.Fatalf("Code = %q, want missing_required_parameter", got.Code)
	}
	if got.Field != "slug" || !got.Retryable {
		t.Fatalf("Field/Retryable = %q/%v", got.Field, got.Retryable)
	}
	if got.Resolution == nil || got.Resolution.Parameter != "slug" {
		t.Fatalf("Resolution = %#v", got.Resolution)
	}
}

func TestParseToolErrorInfersFilenameField(t *testing.T) {
	got := ParseToolError(fmt.Errorf("invalid_params: filename must be a single path component matching ^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$"))
	if got.Code != "invalid_params" {
		t.Fatalf("Code = %q, want invalid_params", got.Code)
	}
	if got.Field != "filename" {
		t.Fatalf("Field = %q, want filename", got.Field)
	}
	if got.Resolution == nil || got.Resolution.Parameter != "filename" {
		t.Fatalf("Resolution = %#v, want retry_with_parameter on filename", got.Resolution)
	}
}

func TestParseToolErrorRevisionConflict(t *testing.T) {
	got := ParseToolError(fmt.Errorf("revision_conflict: page changed since it was read; read the latest revision and replan"))
	if got.Code != "revision_conflict" {
		t.Fatalf("Code = %q", got.Code)
	}
	if got.Field != "expected_revision" || !got.Retryable {
		t.Fatalf("Field/Retryable = %q/%v", got.Field, got.Retryable)
	}
	if got.Resolution == nil || got.Resolution.Action != "reread_then_retry" || got.Resolution.Parameter != "expected_revision" {
		t.Fatalf("Resolution = %#v", got.Resolution)
	}
	if got.Resolution.RecommendedTool != "get_page_for_edit" {
		t.Fatalf("RecommendedTool = %q, want get_page_for_edit", got.Resolution.RecommendedTool)
	}
}

// TestParseToolErrorRevisionConflictAssetRecommendsListPageAssets is a
// regression test for #460: delete_page_asset's own revision_conflict
// message names "asset", not a page, so get_page_for_edit (which doesn't
// return an asset's hash) would misguide the caller — list_page_assets is
// the tool that actually re-supplies expected_sha256/expected_revision.
func TestParseToolErrorRevisionConflictAssetRecommendsListPageAssets(t *testing.T) {
	got := ParseToolError(fmt.Errorf("revision_conflict: asset changed since it was read; call list_page_assets to get the current hash and retry"))
	if got.Resolution == nil || got.Resolution.RecommendedTool != "list_page_assets" {
		t.Fatalf("Resolution = %#v, want RecommendedTool=list_page_assets", got.Resolution)
	}
	if got.Field != "expected_sha256" {
		t.Fatalf("Field = %q, want expected_sha256", got.Field)
	}
	if got.Resolution.Parameter != "expected_sha256" {
		t.Fatalf("Resolution.Parameter = %q, want expected_sha256", got.Resolution.Parameter)
	}
}

// TestParseToolErrorAssetReferencedRecommendsForce is a regression test for
// #460: delete_page_asset's asset_referenced guard is retryable via the
// documented force=true override, not a caller mistake to fix by changing
// input shape.
func TestParseToolErrorAssetReferencedRecommendsForce(t *testing.T) {
	got := ParseToolError(fmt.Errorf("asset_referenced: %q is referenced in %v; pass force=true to delete anyway", "cover.png", []string{"index.md"}))
	if !got.Retryable {
		t.Fatal("Retryable = false, want true")
	}
	if got.Resolution == nil || got.Resolution.Action != "retry_with_parameter" || got.Resolution.Parameter != "force" {
		t.Fatalf("Resolution = %#v, want retry_with_parameter on force", got.Resolution)
	}
}

func TestParseToolErrorContentNotFound(t *testing.T) {
	got := ParseToolError(fmt.Errorf("content_not_found: no source or public page found for slug %q", "posts/gone"))
	if got.Code != "content_not_found" {
		t.Fatalf("Code = %q", got.Code)
	}
	if got.Resolution == nil || got.Resolution.RecommendedTool != "search_pages" {
		t.Fatalf("Resolution = %#v", got.Resolution)
	}
}

// TestParseToolErrorMissingExpectedRevision is a regression test for #461's
// concrete acceptance criterion: expected_revision's own message shape
// ("expected_revision is required for non-dry-run update_page") doesn't
// match missingRequiredField's generic "X must not be empty" phrasing, so it
// needs its own branch — verified against the exact string update_page/
// delete_page actually emit, not a synthetic one.
func TestParseToolErrorMissingExpectedRevision(t *testing.T) {
	for _, msg := range []string{
		"invalid_params: expected_revision is required for non-dry-run update_page",
		"invalid_params: expected_revision is required for non-dry-run delete_page",
	} {
		got := ParseToolError(fmt.Errorf("%s", msg))
		if got.Code != "missing_required_parameter" {
			t.Fatalf("%s: Code = %q, want missing_required_parameter", msg, got.Code)
		}
		if got.Field != "expected_revision" || !got.Retryable {
			t.Fatalf("%s: Field/Retryable = %q/%v", msg, got.Field, got.Retryable)
		}
		if got.Resolution == nil || got.Resolution.RecommendedTool != "get_page_for_edit" {
			t.Fatalf("%s: Resolution = %#v, want RecommendedTool=get_page_for_edit", msg, got.Resolution)
		}
	}
}

func TestParseToolErrorNotFoundMatchesContentNotFound(t *testing.T) {
	got := ParseToolError(fmt.Errorf("not_found: page not found for slug %q", "posts/gone"))
	if got.Code != "not_found" {
		t.Fatalf("Code = %q", got.Code)
	}
	if got.Resolution == nil || got.Resolution.Action != "search_then_retry" || got.Resolution.RecommendedTool != "search_pages" {
		t.Fatalf("Resolution = %#v", got.Resolution)
	}
}

// TestParseToolErrorContentNotPublicHasNoResolution pins the deliberate
// non-hint: content_not_public is overloaded across a draft-visibility case
// and a diagnostics-unavailable case, and only the first would benefit from
// "search again" — a single static hint would misguide the second, so #461
// leaves it with no resolution rather than guessing.
func TestParseToolErrorContentNotPublicHasNoResolution(t *testing.T) {
	got := ParseToolError(fmt.Errorf("content_not_public: reader profile cannot access source validation diagnostics"))
	if got.Resolution != nil {
		t.Fatalf("Resolution = %#v, want nil (deliberately not hinted, see #461)", got.Resolution)
	}
}

func TestParseToolErrorAlreadyExistsRecommendsUpdatePage(t *testing.T) {
	got := ParseToolError(fmt.Errorf("already_exists: page already exists at slug %q", "posts/dup"))
	if got.Resolution == nil || got.Resolution.RecommendedTool != "update_page" {
		t.Fatalf("Resolution = %#v, want RecommendedTool=update_page", got.Resolution)
	}
}

// TestParseToolErrorAlreadyExistsAssetHasNoResolution pins the deliberate
// non-hint for upload_page_asset's own already_exists message: there's no
// "update an existing asset" tool, so recommending update_page would
// misguide the caller (#461).
func TestParseToolErrorAlreadyExistsAssetHasNoResolution(t *testing.T) {
	got := ParseToolError(fmt.Errorf("already_exists: asset already exists at %q", "hero.png"))
	if got.Resolution != nil {
		t.Fatalf("Resolution = %#v, want nil (no update path for an existing asset)", got.Resolution)
	}
}

// TestParseToolErrorRateLimitExceededIncludesRetryAfterSeconds is a
// regression test for #466: rateLimitExceededErr's embedded
// retry_after_seconds must be parsed into the structured resolution, not
// just left in the free-text message.
func TestParseToolErrorRateLimitExceededIncludesRetryAfterSeconds(t *testing.T) {
	got := ParseToolError(fmt.Errorf("rate_limit_exceeded: delete_page is limited to 5 per minute (retry_after_seconds=3.2)"))
	if got.Resolution == nil {
		t.Fatal("Resolution = nil, want present")
	}
	if got.Resolution.Action != "retry_later" {
		t.Fatalf("Resolution.Action = %q, want retry_later", got.Resolution.Action)
	}
	if got.Resolution.RetryAfterSeconds == nil || *got.Resolution.RetryAfterSeconds != 3.2 {
		t.Fatalf("Resolution.RetryAfterSeconds = %#v, want 3.2", got.Resolution.RetryAfterSeconds)
	}
}

// TestParseToolErrorBuildInProgressHasNoRetryAfterSeconds pins that
// build_in_progress (which shares the "retry_later" action but has no
// numeric retry hint) doesn't spuriously get a retry_after_seconds value.
func TestParseToolErrorBuildInProgressHasNoRetryAfterSeconds(t *testing.T) {
	got := ParseToolError(fmt.Errorf("build_in_progress: content lock is held, retry in a moment"))
	if got.Resolution == nil || got.Resolution.Action != "retry_later" {
		t.Fatalf("Resolution = %#v, want retry_later action", got.Resolution)
	}
	if got.Resolution.RetryAfterSeconds != nil {
		t.Fatalf("Resolution.RetryAfterSeconds = %#v, want nil", got.Resolution.RetryAfterSeconds)
	}
}

// TestParseToolErrorBuildInProgressNeverConsumesPlan is a regression test
// for #1001: apply_content_plan and apply_bundle_plan now look their plan
// up without consuming it, so a build_in_progress lock timeout never
// consumes the plan either — the error must be plainly retryable without
// replanning, not the old "plan was already consumed, replan" resolution.
func TestParseToolErrorBuildInProgressNeverConsumesPlan(t *testing.T) {
	got := ParseToolError(fmt.Errorf("build_in_progress: content lock is held, retry in a moment"))
	if !got.Retryable {
		t.Fatalf("Retryable = %v, want true: the plan is preserved on this error", got.Retryable)
	}
	if got.Resolution == nil || got.Resolution.Action != "retry_later" {
		t.Fatalf("Resolution = %#v, want retry_later", got.Resolution)
	}
	if got.Resolution.RecommendedTool != "" {
		t.Fatalf("RecommendedTool = %q, want empty: no replan is needed", got.Resolution.RecommendedTool)
	}
}

func TestParseToolErrorRejectsNonMachinePrefix(t *testing.T) {
	got := ParseToolError(fmt.Errorf("unexpected content-type: text/html"))
	if got.Code != "tool_error" {
		t.Fatalf("Code = %q, want tool_error", got.Code)
	}
	if got.Message != "unexpected content-type: text/html" {
		t.Fatalf("Message = %q", got.Message)
	}
}

func TestErrorResultPopulatesStructuredContent(t *testing.T) {
	meta := NewMeta("1.4.0", time.Date(2026, 7, 14, 8, 0, 0, 0, time.UTC))
	res := ErrorResult(fmt.Errorf("invalid_params: slug must not be empty"), meta, nil, nil, nil)
	if res == nil {
		t.Fatal("ErrorResult() = nil")
	}
	if !res.IsError {
		t.Fatal("IsError = false, want true")
	}
	if res.StructuredContent == nil {
		t.Fatal("StructuredContent = nil, want structured error envelope")
	}
	raw, err := json.Marshal(res.StructuredContent)
	if err != nil {
		t.Fatalf("marshal StructuredContent: %v", err)
	}
	var decoded map[string]any
	if err := json.Unmarshal(raw, &decoded); err != nil {
		t.Fatalf("unmarshal StructuredContent: %v", err)
	}
	if got := decoded["success"]; got != false {
		t.Fatalf("structured success = %v, want false", got)
	}
	errors, ok := decoded["errors"].([]any)
	if !ok || len(errors) == 0 {
		t.Fatalf("structured errors = %#v, want non-empty []any", decoded["errors"])
	}
	first, ok := errors[0].(map[string]any)
	if !ok {
		t.Fatalf("structured errors[0] type = %T", errors[0])
	}
	if got := first["code"]; got != "missing_required_parameter" {
		t.Fatalf("structured errors[0].code = %v, want missing_required_parameter", got)
	}
	if _, present := decoded["is_error"]; present {
		t.Fatalf("structured payload must not contain transport-only is_error, got %v", decoded["is_error"])
	}
}

func TestErrorResultKeepsSemanticCodeAuthoritative(t *testing.T) {
	meta := NewMeta("test", time.Unix(0, 0))
	for _, tc := range []struct {
		name string
		err  error
		code string
	}{
		{name: "invalid parameters", err: fmt.Errorf("invalid_params: response_mode must be one of: standard, compact"), code: "invalid_params"},
		{name: "missing content", err: fmt.Errorf("content_not_found: page not found"), code: "content_not_found"},
		{name: "revision conflict", err: fmt.Errorf("revision_conflict: page changed since it was read"), code: "revision_conflict"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			res := ErrorResult(tc.err, meta, nil, nil, nil)
			payload, ok := res.StructuredContent.(ToolResponse[map[string]any])
			if !ok {
				t.Fatalf("StructuredContent type = %T", res.StructuredContent)
			}
			if len(payload.Errors) != 1 || payload.Errors[0].Code != tc.code {
				t.Fatalf("errors = %#v, want code %q", payload.Errors, tc.code)
			}
			raw, err := json.Marshal(res.StructuredContent)
			if err != nil {
				t.Fatal(err)
			}
			var root map[string]any
			if err := json.Unmarshal(raw, &root); err != nil {
				t.Fatal(err)
			}
			if _, exists := root["error_code"]; exists {
				t.Fatalf("non-canonical error_code appeared in server payload: %s", raw)
			}
		})
	}
}

func TestErrorResultUsesHumanReadableTextContent(t *testing.T) {
	meta := NewMeta("1.4.0", time.Date(2026, 7, 14, 8, 0, 0, 0, time.UTC))
	res := ErrorResult(fmt.Errorf("not_found: page not found"), meta, nil, nil, nil)
	if len(res.Content) != 1 {
		t.Fatalf("len(Content) = %d, want 1", len(res.Content))
	}
	text, ok := res.Content[0].(*mcp.TextContent)
	if !ok {
		t.Fatalf("Content[0] type = %T, want *mcp.TextContent", res.Content[0])
	}
	if text.Text != "not_found: page not found" {
		t.Fatalf("text = %q, want concise human-readable error", text.Text)
	}
	if json.Valid([]byte(text.Text)) {
		t.Fatalf("text = %q, want human-readable text not serialized JSON blob", text.Text)
	}
}

func TestErrorResultMirrorsCompatFieldsIntoRootAndData(t *testing.T) {
	meta := NewMeta("1.4.0", time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC))
	fields := map[string]any{"rate_limit_remaining": 59}
	res := ErrorResult(fmt.Errorf("revision_conflict: page changed since it was read; read the latest revision and replan"), meta, nil, fields, fields)
	raw, err := json.Marshal(res.StructuredContent)
	if err != nil {
		t.Fatalf("marshal StructuredContent: %v", err)
	}
	var decoded map[string]any
	if err := json.Unmarshal(raw, &decoded); err != nil {
		t.Fatalf("unmarshal StructuredContent: %v", err)
	}
	if got := decoded["rate_limit_remaining"]; got != float64(59) {
		t.Fatalf("root rate_limit_remaining = %v, want 59", got)
	}
	data, ok := decoded["data"].(map[string]any)
	if !ok {
		t.Fatalf("data type = %T, want map[string]any", decoded["data"])
	}
	if got := data["rate_limit_remaining"]; got != float64(59) {
		t.Fatalf("data.rate_limit_remaining = %v, want 59", got)
	}
}

func TestParseToolErrorPromotesStructuredBuildFailure(t *testing.T) {
	err := fmt.Errorf(`build_error: {"error":"build_error","error_class":"permission_denied","build_id":"build-123","stderr_summary":"operation not permitted","suggestion":"fix ownership","log_hint":"Search logs for build-123"}`)
	got := ParseToolError(err)
	if got.Code != "build_error" || got.ErrorClass != "permission_denied" {
		t.Fatalf("build error identity = %#v", got)
	}
	if got.BuildID != "build-123" || got.Stage != "hugo_build" {
		t.Fatalf("build error context = %#v", got)
	}
	if got.Suggestion != "fix ownership" || got.LogHint == "" {
		t.Fatalf("build error guidance = %#v", got)
	}
	if !strings.Contains(got.Message, "permission_denied") {
		t.Fatalf("build error message lost compatibility payload: %q", got.Message)
	}
}

func TestParseToolErrorHugoUpgradeResolutions(t *testing.T) {
	tests := []struct {
		err             string
		action          string
		recommendedTool string
		field           string
	}{
		{"already_exists: target Hugo version is already staged", "use_different_tool", "activate_hugo", ""},
		{"staged_artifact_missing: verification record is missing", "use_different_tool", "stage_hugo_upgrade", ""},
		{"checksum_mismatch: staged Hugo executable changed", "replan_then_retry", "stage_hugo_upgrade", ""},
		{"download_error: request failed", "retry_later", "", ""},
		{"version_policy_denied: target_version is above maximum", "retry_with_parameter", "", "target_version"},
		{"revision_conflict: managed Hugo symlink changed after activation", "contact_operator", "", ""},
		{"permission_denied: managed Hugo directory could not be created", "contact_operator", "", ""},
		{"bootstrap_unavailable: a managed Hugo version is already active", "use_different_tool", "stage_hugo_upgrade", ""},
	}
	for _, tc := range tests {
		t.Run(tc.err, func(t *testing.T) {
			got := ParseToolError(fmt.Errorf("%s", tc.err))
			if got.Resolution == nil || got.Resolution.Action != tc.action || got.Resolution.RecommendedTool != tc.recommendedTool || got.Field != tc.field {
				t.Fatalf("ParseToolError = %#v", got)
			}
		})
	}
}

// TestParseToolErrorPlanConflictResolutions is a regression test for #1001:
// apply_content_plan and apply_bundle_plan don't accept expected_revision,
// so their conflict errors must not recommend it (the generic
// revision_conflict branch's get_page_for_edit/expected_revision) — they
// must point back at the plan tool that produced the preserved plan.
func TestParseToolErrorPlanConflictResolutions(t *testing.T) {
	tests := []struct {
		err             string
		action          string
		recommendedTool string
	}{
		{"revision_conflict: page changed since the plan was created; call plan_content_change again", "replan_then_retry", "plan_content_change"},
		{"bundle_conflict: bundle changed since the plan was created; call plan_bundle_change again", "replan_then_retry", "plan_bundle_change"},
	}
	for _, tc := range tests {
		t.Run(tc.err, func(t *testing.T) {
			got := ParseToolError(fmt.Errorf("%s", tc.err))
			if got.Resolution == nil || got.Resolution.Action != tc.action || got.Resolution.RecommendedTool != tc.recommendedTool {
				t.Fatalf("ParseToolError = %#v", got.Resolution)
			}
			if got.Field == "expected_revision" {
				t.Fatalf("plan-based conflict must not recommend expected_revision, which these tools don't accept: %#v", got)
			}
			if !got.Retryable {
				t.Fatalf("plan-based conflict should be retryable: %#v", got)
			}
		})
	}
}

// TestParseToolErrorNonPlanBundleConflictUnaffected is a regression test for
// a defect caught during #1001 review: bundle_conflict has three producers
// (apply_bundle_plan, rollback_bundle, update_page's sibling-translation
// guard) and only apply_bundle_plan's is plan-based. An early version of the
// bundle_conflict case fired "replan via plan_bundle_change" unconditionally,
// which would have misguided rollback_bundle and update_page callers — they
// don't use plans, and update_page's message already names the correct tool
// (get_page_for_edit) in its own text.
func TestParseToolErrorNonPlanBundleConflictUnaffected(t *testing.T) {
	tests := []string{
		"bundle_conflict: bundle changed since it was read; read the latest bundle revision and retry",
		"bundle_conflict: a sibling translation or bundle-local asset changed since the bundle was read; re-read bundle_revision (get_page_for_edit) and replan",
	}
	for _, msg := range tests {
		t.Run(msg, func(t *testing.T) {
			got := ParseToolError(fmt.Errorf("%s", msg))
			if got.Resolution != nil && got.Resolution.RecommendedTool == "plan_bundle_change" {
				t.Fatalf("non-plan-based bundle_conflict must not recommend plan_bundle_change: %#v", got.Resolution)
			}
		})
	}
}
