import { BestAgentError } from "@best-agent/foundation";
import { describe, expect, it } from "vitest";
import {
  classifyRuntimeFailure,
  isBlockedRuntimeFailure,
  isRepairableToolFailure,
} from "./failure-classification.js";

describe("failure classification", () => {
  it("classifies retryable transport failures", () => {
    const failure = classifyRuntimeFailure(new Error("provider request timed out before response"));
    expect(failure).toMatchObject({
      failureKind: "dispatch-failed",
      recoverability: "retryable",
      scope: "provider",
    });
  });

  it("classifies gateway time-out failures as retryable transport errors", () => {
    const failure = classifyRuntimeFailure(
      new BestAgentError("PROVIDER_REQUEST_FAILED", "Gateway Time-out"),
    );
    expect(failure).toMatchObject({
      errorCode: "PROVIDER_REQUEST_FAILED",
      failureKind: "dispatch-failed",
      recoverability: "retryable",
      scope: "provider",
    });
  });

  it("classifies bare fetch failures as retryable transport errors", () => {
    const failure = classifyRuntimeFailure(
      new BestAgentError("PROVIDER_REQUEST_FAILED", "fetch failed"),
    );
    expect(failure).toMatchObject({
      errorCode: "PROVIDER_REQUEST_FAILED",
      failureKind: "dispatch-failed",
      recoverability: "retryable",
      scope: "provider",
    });
  });

  it("classifies repairable grounding failures", () => {
    expect(
      classifyRuntimeFailure(new BestAgentError("TOOL_INPUT_NOT_FOUND", "missing path")),
    ).toMatchObject({
      recoverability: "repairable",
    });
    expect(
      classifyRuntimeFailure(new BestAgentError("TOOL_INPUT_INVALID", "bad shape")).recoverability,
    ).toBe("repairable");
    expect(
      classifyRuntimeFailure(new BestAgentError("WORKSPACE_STALE_VIEW", "stale view"))
        .recoverability,
    ).toBe("repairable");
    expect(
      classifyRuntimeFailure(new BestAgentError("EXEC_INVALID", "bad exec")).recoverability,
    ).toBe("repairable");
    expect(isRepairableToolFailure(new BestAgentError("TOOL_NOT_ALLOWED", "tool denied"))).toBe(
      true,
    );
  });

  it("classifies provider no-action failures as repairable provider failures", () => {
    expect(
      classifyRuntimeFailure(
        new BestAgentError("PROVIDER_NO_ACTION", "Provider returned neither text nor a tool call."),
      ),
    ).toMatchObject({
      errorCode: "PROVIDER_NO_ACTION",
      failureKind: "validation-failed",
      recoverability: "repairable",
      scope: "provider",
    });
    expect(
      classifyRuntimeFailure(
        new BestAgentError(
          "PROVIDER_REQUEST_FAILED",
          "No output generated. Check the stream for errors.",
        ),
      ),
    ).toMatchObject({
      errorCode: "PROVIDER_NO_ACTION",
      failureKind: "validation-failed",
      recoverability: "repairable",
      scope: "provider",
    });
  });

  it("classifies provider context overflow as blocked turn dispatch failure", () => {
    expect(
      classifyRuntimeFailure(
        new BestAgentError(
          "PROVIDER_REQUEST_FAILED",
          "context_length_exceeded: maximum context length is 8192 tokens",
        ),
      ),
    ).toMatchObject({
      errorCode: "PROVIDER_CONTEXT_OVERFLOW",
      failureKind: "dispatch-failed",
      recoverability: "blocked",
      scope: "turn",
    });
    expect(
      classifyRuntimeFailure(new Error("context_length_exceeded: maximum context length reached")),
    ).toMatchObject({
      errorCode: "PROVIDER_CONTEXT_OVERFLOW",
      failureKind: "dispatch-failed",
      recoverability: "blocked",
      scope: "turn",
    });
  });

  it("classifies request and provider turn budget exhaustion as blocked turn failures", () => {
    expect(
      classifyRuntimeFailure(
        new BestAgentError(
          "REQUEST_BUDGET_EXCEEDED",
          "The next model request exceeds the available input budget.",
        ),
      ),
    ).toMatchObject({
      errorCode: "REQUEST_BUDGET_EXCEEDED",
      failureKind: "dispatch-failed",
      recoverability: "blocked",
      scope: "turn",
    });
    expect(
      classifyRuntimeFailure(
        new BestAgentError("PROVIDER_TURN_BUDGET_EXCEEDED", "Provider turn budget exhausted."),
      ),
    ).toMatchObject({
      errorCode: "PROVIDER_TURN_BUDGET_EXCEEDED",
      failureKind: "dispatch-failed",
      recoverability: "blocked",
      scope: "turn",
    });
  });

  it("classifies duck-typed runtime errors with best-agent codes", () => {
    const error = new Error("bad exec") as Error & { code?: string };
    error.code = "EXEC_INVALID";
    expect(classifyRuntimeFailure(error)).toMatchObject({
      errorCode: "EXEC_INVALID",
      failureKind: "validation-failed",
      recoverability: "repairable",
      scope: "tool",
    });
    expect(isRepairableToolFailure(error)).toBe(true);
  });

  it("classifies plain shell-shape failures as repairable tool validation errors", () => {
    expect(
      classifyRuntimeFailure(
        new Error(
          "The shell tool does not run shell syntax in argv mode. Use command for shell syntax or direct argv tokens for exact execution.",
        ),
      ),
    ).toMatchObject({
      errorCode: "EXEC_INVALID",
      failureKind: "validation-failed",
      recoverability: "repairable",
      scope: "tool",
    });
  });

  it("classifies plain missing-command failures as repairable tool execution errors", () => {
    expect(
      classifyRuntimeFailure(
        new Error("Command xxd is not available in this environment. Install xxd first."),
      ),
    ).toMatchObject({
      errorCode: "EXEC_FAILED",
      failureKind: "execution-failed",
      recoverability: "repairable",
      scope: "tool",
    });

    expect(classifyRuntimeFailure(new Error("uvx: command not found"))).toMatchObject({
      errorCode: "EXEC_FAILED",
      failureKind: "execution-failed",
      recoverability: "repairable",
      scope: "tool",
    });

    expect(
      classifyRuntimeFailure(new Error("/tests/test.sh: line 19: uvx: command not found")),
    ).toMatchObject({
      errorCode: "EXEC_FAILED",
      failureKind: "execution-failed",
      recoverability: "repairable",
      scope: "tool",
    });

    expect(classifyRuntimeFailure(new Error("env: uvx: No such file or directory"))).toMatchObject({
      errorCode: "EXEC_FAILED",
      failureKind: "execution-failed",
      recoverability: "repairable",
      scope: "tool",
    });
  });

  it("classifies blocked failures", () => {
    expect(isBlockedRuntimeFailure(new BestAgentError("CAPABILITY_DENIED", "denied"))).toBe(true);
    expect(isBlockedRuntimeFailure(new BestAgentError("ACTION_EXPIRED", "expired"))).toBe(true);
    expect(isBlockedRuntimeFailure(new BestAgentError("PROVIDER_MISSING", "missing key"))).toBe(
      true,
    );
    expect(
      classifyRuntimeFailure(new BestAgentError("CAPABILITY_DENIED", "denied")).failureKind,
    ).toBe("denied");
    expect(
      classifyRuntimeFailure(new BestAgentError("TOOL_CAPABILITY_DENIED", "denied")),
    ).toMatchObject({
      failureKind: "denied",
      recoverability: "repairable",
      scope: "tool",
    });
    expect(
      classifyRuntimeFailure(
        new BestAgentError("PROVIDER_REQUEST_FAILED", "insufficient_quota: hard quota exhausted"),
      ),
    ).toMatchObject({
      failureKind: "denied",
      recoverability: "blocked",
    });
  });

  it("classifies interrupted failures", () => {
    const abortError = new Error("The operation was aborted.");
    abortError.name = "AbortError";
    expect(classifyRuntimeFailure(abortError).recoverability).toBe("interrupted");
    expect(
      classifyRuntimeFailure(new Error("runtime shutdown while waiting for response"))
        .recoverability,
    ).toBe("interrupted");
  });

  it("classifies transient external tool failures as retryable", () => {
    expect(
      classifyRuntimeFailure(new BestAgentError("FETCH_FAILED", "network call failed")),
    ).toMatchObject({
      recoverability: "retryable",
      scope: "tool",
    });
    expect(
      classifyRuntimeFailure(new BestAgentError("MCP_CALL_FAILED", "server disconnected")),
    ).toMatchObject({
      recoverability: "retryable",
      scope: "tool",
    });
  });

  it("does not tell the model to wait for heartbeat-stalled tools", () => {
    expect(
      classifyRuntimeFailure(
        new BestAgentError("TOOL_HEARTBEAT_STALLED", "Tool exceeded heartbeat timeout."),
      ),
    ).toMatchObject({
      failureKind: "heartbeat-timeout",
      recoverability: "repairable",
      scope: "tool",
    });
  });

  it("keeps generic tool runtime failures terminal", () => {
    expect(
      classifyRuntimeFailure(new BestAgentError("TOOL_RUNTIME", "tool crashed")),
    ).toMatchObject({
      recoverability: "terminal",
    });
  });

  it("classifies shell execution failures as repairable", () => {
    expect(
      classifyRuntimeFailure(new BestAgentError("EXEC_FAILED", "command exited 1")).recoverability,
    ).toBe("repairable");
    expect(
      classifyRuntimeFailure(new BestAgentError("EXEC_FAILED", "spawn xxd ENOENT")).recoverability,
    ).toBe("repairable");
    expect(
      classifyRuntimeFailure(
        new BestAgentError("EXEC_COMMAND_NOT_FOUND", "Command xxd is missing"),
      ),
    ).toMatchObject({
      errorCode: "EXEC_COMMAND_NOT_FOUND",
      failureKind: "execution-failed",
      recoverability: "repairable",
      scope: "tool",
    });
  });

  it("classifies leaked terminal control payloads as repairable turn failures", () => {
    expect(
      classifyRuntimeFailure(
        new BestAgentError(
          "TERMINAL_OUTPUT_INVALID",
          "Terminal assistant output contained leaked control payload text instead of a user-visible answer.",
        ),
      ),
    ).toMatchObject({
      errorCode: "TERMINAL_OUTPUT_INVALID",
      failureKind: "execution-failed",
      recoverability: "repairable",
      scope: "turn",
    });
  });

  it("falls back to unknown when the runtime cannot classify a failure", () => {
    expect(
      classifyRuntimeFailure(new BestAgentError("THREAD_NOT_FOUND", "missing thread")),
    ).toMatchObject({
      failureKind: "execution-failed",
      errorCode: "THREAD_NOT_FOUND",
      recoverability: "unknown",
      scope: "turn",
    });
  });
});
