// Payload tests cover successful embedded run replies, final-answer selection,
// message-tool source replies, media directives, and tool-error warning policy.
import type { AssistantMessage } from "openclaw/plugin-sdk/llm";
import { describe, expect, it } from "vitest";
import { getReplyPayloadMetadata } from "../../../auto-reply/reply-payload.js";
import type { InteractiveReply, MessagePresentation } from "../../../interactive/payload.js";
import {
  buildPayloads,
  expectSinglePayloadText,
  expectSingleToolErrorPayload,
} from "./payloads.test-helpers.js";

describe("buildEmbeddedRunPayloads tool-error warnings", () => {
  function expectNoPayloads(params: Parameters<typeof buildPayloads>[0]) {
    // Many suppression cases should produce no channel reply at all; keep the
    // assertion explicit so accidental fallback text is obvious.
    const payloads = buildPayloads(params);
    expect(payloads).toHaveLength(0);
  }

  it("does not fall back to commentary-only assistant text when streamed text was suppressed", () => {
    const payloads = buildPayloads({
      lastAssistant: {
        role: "assistant",
        stopReason: "toolUse",
        content: [
          {
            type: "text",
            text: "Need update cron messages to use finalBrief/briefPath.",
            textSignature: JSON.stringify({
              v: 1,
              id: "item_commentary",
              phase: "commentary",
            }),
          },
        ],
      } as AssistantMessage,
    });

    expect(payloads).toStrictEqual([]);
  });

  it("falls back to final-answer assistant text when streamed text is unavailable", () => {
    const payloads = buildPayloads({
      lastAssistant: {
        role: "assistant",
        stopReason: "stop",
        content: [
          {
            type: "text",
            text: "Need inspect.",
            textSignature: JSON.stringify({
              v: 1,
              id: "item_commentary",
              phase: "commentary",
            }),
          },
          {
            type: "text",
            text: "Done.",
            textSignature: JSON.stringify({
              v: 1,
              id: "item_final",
              phase: "final_answer",
            }),
          },
        ],
      } as AssistantMessage,
    });

    expectSinglePayloadText(payloads, "Done.");
  });

  it("marks runtime-persisted final replies as transcript owned", () => {
    const payloads = buildPayloads({
      assistantTexts: ["Already persisted."],
      assistantTranscriptOwned: true,
    });

    expect(payloads).toHaveLength(1);
    expect(getReplyPayloadMetadata(payloads[0] as object)).toMatchObject({
      assistantTranscriptOwned: true,
    });
  });

  it("does not revive signed unphased text when explicit final-answer text is empty", () => {
    expectNoPayloads({
      lastAssistant: {
        role: "assistant",
        stopReason: "stop",
        content: [
          {
            type: "text",
            text: "MEDIA:/tmp/old.png",
            textSignature: JSON.stringify({ v: 1, id: "item_old" }),
          },
          {
            type: "text",
            text: "   ",
            textSignature: JSON.stringify({
              v: 1,
              id: "item_final",
              phase: "final_answer",
            }),
          },
        ],
      } as AssistantMessage,
    });
  });

  it("does not revive signed unphased text when explicit output_text final-answer text is empty", () => {
    expectNoPayloads({
      lastAssistant: {
        role: "assistant",
        stopReason: "stop",
        content: [
          {
            type: "text",
            text: "MEDIA:/tmp/old.png",
            textSignature: JSON.stringify({ v: 1, id: "item_old" }),
          },
          {
            type: "output_text",
            text: "   ",
            textSignature: JSON.stringify({
              v: 1,
              id: "item_final",
              phase: "final_answer",
            }),
          },
        ],
      } as AssistantMessage,
    });
  });

  it("keeps literal mid-answer reasoning-looking tags in final-answer text", () => {
    const text = "Before <think>literal tag text after";
    const payloads = buildPayloads({
      lastAssistant: {
        role: "assistant",
        stopReason: "stop",
        content: [
          {
            type: "text",
            text,
            textSignature: JSON.stringify({
              v: 1,
              id: "item_final",
              phase: "final_answer",
            }),
          },
        ],
      } as AssistantMessage,
    });

    expectSinglePayloadText(payloads, text);
  });

  it("keeps strict reasoning-tag stripping for legacy string fallback text", () => {
    const payloads = buildPayloads({
      lastAssistant: {
        role: "assistant",
        stopReason: "stop",
        content: "Visible prefix <think>private reasoning tail",
      } as unknown as AssistantMessage,
    });

    expectSinglePayloadText(payloads, "Visible prefix");
  });

  it("falls back to final-answer assistant text when streamed text only contains blanks", () => {
    const payloads = buildPayloads({
      assistantTexts: ["   "],
      lastAssistant: {
        role: "assistant",
        stopReason: "stop",
        content: [
          {
            type: "text",
            text: "Fixed.",
            textSignature: JSON.stringify({
              v: 1,
              id: "item_final",
              phase: "final_answer",
            }),
          },
        ],
      } as AssistantMessage,
    });

    expectSinglePayloadText(payloads, "Fixed.");
  });

  it("uses the final assistant answer when streamed text was an incomplete preview", () => {
    const payloads = buildPayloads({
      assistantTexts: ["Long answer, part one"],
      lastAssistant: {
        role: "assistant",
        stopReason: "stop",
        content: [
          {
            type: "text",
            text: "Long answer, part one\nLong answer, part two\nLong answer, part three",
            textSignature: JSON.stringify({
              v: 1,
              id: "item_final",
              phase: "final_answer",
            }),
          },
        ],
      } as AssistantMessage,
    });

    expectSinglePayloadText(
      payloads,
      "Long answer, part one\nLong answer, part two\nLong answer, part three",
    );
  });

  it("uses the final assistant answer when one streamed text contains progress and final text", () => {
    const payloads = buildPayloads({
      assistantTexts: ["Need inspect.\n\nDone."],
      lastAssistant: {
        role: "assistant",
        stopReason: "stop",
        content: [
          {
            type: "text",
            text: "Need inspect.",
            textSignature: JSON.stringify({
              v: 1,
              id: "item_commentary",
              phase: "commentary",
            }),
          },
          {
            type: "text",
            text: "Done.",
            textSignature: JSON.stringify({
              v: 1,
              id: "item_final",
              phase: "final_answer",
            }),
          },
        ],
      } as AssistantMessage,
    });

    expectSinglePayloadText(payloads, "Done.");
  });

  it("keeps a current one-chunk reply when only a stale transcript assistant is available", () => {
    const payloads = buildPayloads({
      assistantTexts: ["Current room event reply."],
      currentAssistant: null,
      lastAssistant: {
        role: "assistant",
        stopReason: "stop",
        content: [
          {
            type: "text",
            text: "Previous transcript reply.",
            textSignature: JSON.stringify({
              v: 1,
              id: "item_previous",
              phase: "final_answer",
            }),
          },
        ],
      } as AssistantMessage,
    });

    expectSinglePayloadText(payloads, "Current room event reply.");
  });

  it("delivers only the final assistant answer when accumulated text includes pre-tool progress", () => {
    const payloads = buildPayloads({
      assistantTexts: ["I'll inspect that first.", "Done."],
      lastAssistant: {
        role: "assistant",
        stopReason: "stop",
        content: [
          {
            type: "text",
            text: "Done.",
            textSignature: JSON.stringify({
              v: 1,
              id: "item_final",
              phase: "final_answer",
            }),
          },
        ],
      } as AssistantMessage,
    });

    expectSinglePayloadText(payloads, "Done.");
  });

  it("does not replay raw-looking accumulated tool output when final answer text is available", () => {
    const payloads = buildPayloads({
      assistantTexts: [
        "/root/openclaw/packages/gateway-protocol/src/schema/protocol-schemas.ts:181:  PluginControlUiDescriptorSchema,",
        "The schema export is fixed.",
      ],
      lastAssistant: {
        role: "assistant",
        stopReason: "stop",
        content: [
          {
            type: "text",
            text: "The schema export is fixed.",
            textSignature: JSON.stringify({
              v: 1,
              id: "item_final",
              phase: "final_answer",
            }),
          },
        ],
      } as AssistantMessage,
    });

    expectSinglePayloadText(payloads, "The schema export is fixed.");
  });

  it("turns internal message-tool source replies into suppression-safe final payloads", () => {
    // message_tool_only source replies are already delivered internally but
    // still need mirror metadata so transcript/persistence can record them.
    const payloads = buildPayloads({
      assistantTexts: ["ordinary final should stay private"],
      didSendViaMessagingTool: true,
      messagingToolSourceReplyPayloads: [
        {
          text: "sent through message tool",
          mediaUrls: ["/tmp/reply.png"],
        },
      ],
      sourceReplyDeliveryMode: "message_tool_only",
      sessionKey: "agent:main",
      agentId: "main",
      runId: "run-1",
    });

    expect(payloads).toHaveLength(1);
    expect(payloads[0]).toMatchObject({
      text: "sent through message tool",
      mediaUrl: "/tmp/reply.png",
      mediaUrls: ["/tmp/reply.png"],
    });
    expect(getReplyPayloadMetadata(payloads[0] as object)).toMatchObject({
      deliverDespiteSourceReplySuppression: true,
      sourceReplyTranscriptMirror: {
        sessionKey: "agent:main",
        agentId: "main",
        text: "sent through message tool",
        mediaUrls: ["/tmp/reply.png"],
        idempotencyKey: "run-1:internal-source-reply:0",
      },
    });
  });

  it("suppresses terminal assistant text after direct message-tool source replies", () => {
    const payloads = buildPayloads({
      assistantTexts: ["ordinary final should stay private"],
      didSendViaMessagingTool: true,
      didDeliverSourceReplyViaMessageTool: true,
      sourceReplyDeliveryMode: "message_tool_only",
      sessionKey: "agent:main",
      agentId: "main",
      runId: "run-1",
    });

    expect(payloads).toEqual([]);
  });

  it("preserves rich-only internal message-tool source replies", () => {
    const presentation = {
      blocks: [
        {
          type: "buttons",
          buttons: [{ label: "Approve", value: "approve" }],
        },
      ],
    } satisfies MessagePresentation;
    const interactive = {
      blocks: [
        {
          type: "buttons",
          buttons: [{ label: "Open", value: "open" }],
        },
      ],
    } satisfies InteractiveReply;

    const payloads = buildPayloads({
      assistantTexts: ["ordinary final should stay private"],
      didSendViaMessagingTool: true,
      messagingToolSourceReplyPayloads: [
        {
          presentation,
        },
        {
          interactive,
        },
      ],
      sourceReplyDeliveryMode: "message_tool_only",
      sessionKey: "agent:main",
      agentId: "main",
      runId: "run-1",
    });

    expect(payloads).toHaveLength(2);
    expect(payloads[0]).toMatchObject({ presentation });
    expect(payloads[0]?.text).toBeUndefined();
    expect(payloads[1]).toMatchObject({ interactive });
    expect(payloads[1]?.text).toBeUndefined();
    expect(getReplyPayloadMetadata(payloads[0] as object)).toMatchObject({
      deliverDespiteSourceReplySuppression: true,
      sourceReplyTranscriptMirror: {
        sessionKey: "agent:main",
        agentId: "main",
        idempotencyKey: "run-1:internal-source-reply:0",
      },
    });
    expect(getReplyPayloadMetadata(payloads[1] as object)).toMatchObject({
      deliverDespiteSourceReplySuppression: true,
      sourceReplyTranscriptMirror: {
        sessionKey: "agent:main",
        agentId: "main",
        idempotencyKey: "run-1:internal-source-reply:1",
      },
    });
  });

  it("ignores accumulated internal/status text after the final answer", () => {
    const payloads = buildPayloads({
      assistantTexts: [
        "Done.",
        "Background task done: Context engine turn maintenance. Rewrote 0 transcript entries and freed 0 bytes.",
      ],
      lastAssistant: {
        role: "assistant",
        stopReason: "stop",
        content: [
          {
            type: "text",
            text: "Done.",
            textSignature: JSON.stringify({
              v: 1,
              id: "item_final",
              phase: "final_answer",
            }),
          },
        ],
      } as AssistantMessage,
    });

    expectSinglePayloadText(payloads, "Done.");
  });

  it("surfaces concise exec tool errors when verbose mode is off", () => {
    const payloads = buildPayloads({
      lastToolError: { toolName: "exec", error: "command failed" },
      verboseLevel: "off",
    });

    expectSingleToolErrorPayload(payloads, {
      title: "Exec",
      absentDetail: "command failed",
    });
  });

  it("marks middleware tool-error warnings after assistant output as non-terminal", () => {
    // Middleware failures after useful assistant output warn the user without
    // replacing the successful answer as the terminal payload.
    const payloads = buildPayloads({
      assistantTexts: ["Queued 3 topics."],
      lastToolError: {
        toolName: "exec",
        error: "Tool output unavailable due to post-processing error",
        middlewareError: true,
      },
      verboseLevel: "off",
    });

    expect(payloads).toHaveLength(2);
    expect(payloads[0]?.text).toBe("Queued 3 topics.");
    expect(payloads[1]).toMatchObject({
      isError: true,
    });
    expect(payloads[1]?.text).toContain("Exec failed");
    expect(getReplyPayloadMetadata(payloads[1] as object)).toMatchObject({
      nonTerminalToolErrorWarning: true,
    });
  });

  it("surfaces concise bash tool errors when verbose mode is off", () => {
    const payloads = buildPayloads({
      lastToolError: { toolName: "bash", error: "command failed" },
      verboseLevel: "off",
    });

    expectSingleToolErrorPayload(payloads, {
      title: "Bash",
      absentDetail: "command failed",
    });
  });

  it("surfaces declined Codex native command errors for aborted empty turns", () => {
    const payloads = buildPayloads({
      assistantTexts: [],
      lastToolError: {
        toolName: "bash",
        error: "codex native tool blocked",
        mutatingAction: true,
      },
      runAborted: true,
    });

    expectSingleToolErrorPayload(payloads, {
      title: "Bash",
      absentDetail: "codex native tool blocked",
    });
  });

  it("surfaces exec tool errors for cron sessions even when verbose mode is off", () => {
    const payloads = buildPayloads({
      lastToolError: {
        toolName: "exec",
        timedOut: true,
        error:
          "Command timed out after 1800 seconds. If this command is expected to take longer, re-run with a higher timeout (e.g., exec timeout=300).",
      },
      sessionKey: "agent:main:cron:job-1",
      verboseLevel: "off",
    });

    expectSingleToolErrorPayload(payloads, {
      title: "Exec",
      detail:
        "Command timed out after 1800 seconds. If this command is expected to take longer, re-run with a higher timeout (e.g., exec timeout=300).",
    });
  });

  it("surfaces timed-out exec tool errors for cron-triggered custom session keys", () => {
    const payloads = buildPayloads({
      lastToolError: {
        toolName: "exec",
        timedOut: true,
        error: "Command timed out after 1800 seconds.",
      },
      sessionKey: "agent:main:project-alpha",
      isCronTrigger: true,
      verboseLevel: "off",
    });

    expectSingleToolErrorPayload(payloads, {
      title: "Exec",
      detail: "Command timed out after 1800 seconds.",
    });
  });

  it("surfaces heartbeat exec tool output details when the task run fails", () => {
    const payloads = buildPayloads({
      lastToolError: {
        toolName: "exec",
        meta: "show last 20 lines of ~/.openclaw/workspace/memory/2026-06-04.md",
        error:
          "tail: cannot open '/home/user/.openclaw/workspace/memory/2026-06-04.md' for reading: No such file or directory",
      },
      isHeartbeatTrigger: true,
      verboseLevel: "off",
    });

    expectSingleToolErrorPayload(payloads, {
      title: "show last 20 lines",
      detail: "No such file or directory",
    });
  });

  it("surfaces non-timeout exec tool errors for cron sessions without raw details", () => {
    const payloads = buildPayloads({
      lastToolError: { toolName: "exec", error: "Command not found" },
      sessionKey: "agent:main:cron:job-1",
      verboseLevel: "off",
    });

    expectSingleToolErrorPayload(payloads, {
      title: "Exec",
      absentDetail: "Command not found",
    });
  });

  it("keeps exec tool errors compact when verbose mode is on", () => {
    const payloads = buildPayloads({
      lastToolError: { toolName: "exec", error: "command failed" },
      verboseLevel: "on",
    });

    expectSingleToolErrorPayload(payloads, {
      title: "Exec",
      absentDetail: "command failed",
    });
  });

  it("shows exec tool error details when verbose mode is full", () => {
    const payloads = buildPayloads({
      lastToolError: { toolName: "exec", error: "command failed" },
      verboseLevel: "full",
    });

    expectSingleToolErrorPayload(payloads, {
      title: "Exec",
      detail: "command failed",
    });
  });

  it("keeps stale full-verbose tool errors compact when live verbose is off", () => {
    const payloads = buildPayloads({
      lastToolError: { toolName: "write", error: "permission denied" },
      suppressToolErrorWarnings: () => false,
      verboseLevel: "full",
    });

    expectSingleToolErrorPayload(payloads, {
      title: "Write",
      absentDetail: "permission denied",
    });
  });

  it("preserves full-verbose tool error details with static suppression disabled", () => {
    const payloads = buildPayloads({
      lastToolError: { toolName: "write", error: "permission denied" },
      suppressToolErrorWarnings: false,
      verboseLevel: "full",
    });

    expectSingleToolErrorPayload(payloads, {
      title: "Write",
      detail: "permission denied",
    });
  });

  it("keeps non-exec mutating tool failures visible", () => {
    const payloads = buildPayloads({
      lastToolError: { toolName: "write", error: "permission denied" },
      verboseLevel: "off",
    });

    expectSingleToolErrorPayload(payloads, {
      title: "Write",
      absentDetail: "permission denied",
    });
  });

  it.each([
    {
      name: "keeps mutating tool failures compact when verbose is on",
      verboseLevel: "on" as const,
      detail: undefined,
      absentDetail: "permission denied",
    },
    {
      name: "includes details for mutating tool failures when verbose is full",
      verboseLevel: "full" as const,
      detail: "permission denied",
      absentDetail: undefined,
    },
  ])("$name", ({ verboseLevel, detail, absentDetail }) => {
    const payloads = buildPayloads({
      lastToolError: { toolName: "write", error: "permission denied" },
      verboseLevel,
    });

    expectSingleToolErrorPayload(payloads, {
      title: "Write",
      detail,
      absentDetail,
    });
  });

  it.each([
    {
      name: "default relay failure",
      lastToolError: { toolName: "sessions_send", error: "delivery timeout" },
    },
    {
      name: "mutating relay failure",
      lastToolError: {
        toolName: "sessions_send",
        error: "delivery timeout",
        mutatingAction: true,
      },
    },
  ])("suppresses sessions_send errors for $name", ({ lastToolError }) => {
    expectNoPayloads({
      lastToolError,
      verboseLevel: "on",
    });
  });

  it("suppresses assistant text when a deterministic exec approval prompt was already delivered", () => {
    expectNoPayloads({
      assistantTexts: ["Approval is needed. Please run /approve abc allow-once"],
      didSendDeterministicApprovalPrompt: true,
    });
  });

  it("suppresses JSON NO_REPLY assistant payloads", () => {
    expectNoPayloads({
      assistantTexts: ['{"action":"NO_REPLY"}'],
    });
  });

  it("strips NO_REPLY text but keeps voice media directives", () => {
    const payloads = buildPayloads({
      assistantTexts: ["NO_REPLY\nMEDIA:/tmp/openclaw/tts-a/voice-a.opus\n[[audio_as_voice]]"],
    });

    expect(payloads).toHaveLength(1);
    expect(payloads[0]?.mediaUrl).toBe("/tmp/openclaw/tts-a/voice-a.opus");
    expect(payloads[0]?.mediaUrls).toEqual(["/tmp/openclaw/tts-a/voice-a.opus"]);
    expect(payloads[0]?.audioAsVoice).toBe(true);
    expect(payloads[0]?.text).toBeUndefined();
  });

  it("preserves media directives when stored assistant text was reduced to visible text only", () => {
    const payloads = buildPayloads({
      assistantTexts: ["Attached image"],
      lastAssistant: {
        role: "assistant",
        stopReason: "stop",
        content: [
          {
            type: "text",
            text: "MEDIA:/tmp/reply-image.png\nAttached image",
            textSignature: JSON.stringify({
              v: 1,
              id: "item_final",
              phase: "final_answer",
            }),
          },
        ],
      } as AssistantMessage,
    });

    expect(payloads).toHaveLength(1);
    expect(payloads[0]?.text).toBe("Attached image");
    expect(payloads[0]?.mediaUrl).toBe("/tmp/reply-image.png");
    expect(payloads[0]?.mediaUrls).toEqual(["/tmp/reply-image.png"]);
  });

  it("keeps media directives when collapsing accumulated pre-tool text to the final answer", () => {
    const payloads = buildPayloads({
      assistantTexts: ["Preparing the image...", "Attached image"],
      lastAssistant: {
        role: "assistant",
        stopReason: "stop",
        content: [
          {
            type: "text",
            text: "MEDIA:/tmp/reply-image.png\nAttached image",
            textSignature: JSON.stringify({
              v: 1,
              id: "item_final",
              phase: "final_answer",
            }),
          },
        ],
      } as AssistantMessage,
    });

    expect(payloads).toHaveLength(1);
    expect(payloads[0]?.text).toBe("Attached image");
    expect(payloads[0]?.mediaUrl).toBe("/tmp/reply-image.png");
    expect(payloads[0]?.mediaUrls).toEqual(["/tmp/reply-image.png"]);
  });

  it("uses raw final assistant text when visible-text extraction removed a media-only directive line", () => {
    // Media directives are not visible text, but they still carry channel media
    // attachments and must survive final-answer extraction.
    const payloads = buildPayloads({
      lastAssistant: {
        role: "assistant",
        stopReason: "stop",
        content: [
          {
            type: "text",
            text: "MEDIA:/tmp/reply-image.png\nAttached image",
            textSignature: JSON.stringify({
              v: 1,
              id: "item_final",
              phase: "final_answer",
            }),
          },
        ],
      } as AssistantMessage,
    });

    expect(payloads).toHaveLength(1);
    expect(payloads[0]?.text).toBe("Attached image");
    expect(payloads[0]?.mediaUrl).toBe("/tmp/reply-image.png");
    expect(payloads[0]?.mediaUrls).toEqual(["/tmp/reply-image.png"]);
  });

  it("suppresses native reasoning payloads when thinking is disabled", () => {
    const payloads = buildPayloads({
      reasoningLevel: "on",
      thinkingLevel: "off",
      lastAssistant: {
        role: "assistant",
        stopReason: "stop",
        content: [
          {
            type: "thinking",
            thinking: "",
            thinkingSignature: JSON.stringify({ type: "reasoning", id: "rs_live", summary: [] }),
          },
          { type: "text", text: "THINKING-OFF-OK" },
        ],
      } as AssistantMessage,
    });

    expectSinglePayloadText(payloads, "THINKING-OFF-OK");
  });
});
