import { describe, test, expect } from "bun:test";
import {
  AppError,
  JiraApiError,
  AgentError,
  GitHubApiError,
  RepoOperationError,
  SafetyViolationError,
  BuildFailureError,
  JobTimeoutError,
  RepoSelectionError,
} from "@/core/errors.js";

describe("AppError base class", () => {
  test("sets code, message, and name", () => {
    const err = new AppError("TEST_CODE", "something broke");
    expect(err.code).toBe("TEST_CODE");
    expect(err.message).toBe("something broke");
    expect(err.name).toBe("AppError");
  });

  test("isRetryable defaults to false", () => {
    const err = new AppError("X", "msg");
    expect(err.isRetryable).toBe(false);
  });

  test("isRetryable can be set to true", () => {
    const err = new AppError("X", "msg", { isRetryable: true });
    expect(err.isRetryable).toBe(true);
  });

  test("cause is forwarded to Error", () => {
    const cause = new Error("root");
    const err = new AppError("X", "msg", { cause });
    expect(err.cause).toBe(cause);
  });

  test("instanceof Error is true", () => {
    const err = new AppError("X", "msg");
    expect(err).toBeInstanceOf(Error);
    expect(err).toBeInstanceOf(AppError);
  });

  test("toJSON returns expected shape without cause", () => {
    const err = new AppError("MY_CODE", "hello");
    expect(err.toJSON()).toEqual({
      name: "AppError",
      code: "MY_CODE",
      message: "hello",
      isRetryable: false,
      cause: undefined,
    });
  });

  test("toJSON serializes Error cause to its message string", () => {
    const cause = new Error("inner problem");
    const err = new AppError("MY_CODE", "outer", { cause });
    expect(err.toJSON().cause).toBe("inner problem");
  });

  test("toJSON passes through non-Error cause as-is", () => {
    const err = new AppError("MY_CODE", "outer", { cause: "raw string cause" as unknown });
    expect(err.toJSON().cause).toBe("raw string cause");
  });
});

describe("error subclasses — table-driven", () => {
  const cases: Array<{
    label: string;
    instance: AppError;
    expectedName: string;
    expectedCode: string;
    expectedRetryable: boolean;
    expectedMessage: string;
  }> = [
    {
      label: "JiraApiError",
      instance: new JiraApiError("jira failed"),
      expectedName: "JiraApiError",
      expectedCode: "JIRA_API_ERROR",
      expectedRetryable: true,
      expectedMessage: "jira failed",
    },
    {
      label: "AgentError (default)",
      instance: new AgentError("cursor broke"),
      expectedName: "AgentError",
      expectedCode: "AGENT_ERROR",
      expectedRetryable: true,
      expectedMessage: "cursor broke",
    },
    {
      label: "GitHubApiError",
      instance: new GitHubApiError("gh 500"),
      expectedName: "GitHubApiError",
      expectedCode: "GITHUB_API_ERROR",
      expectedRetryable: true,
      expectedMessage: "gh 500",
    },
    {
      label: "RepoOperationError",
      instance: new RepoOperationError("clone failed"),
      expectedName: "RepoOperationError",
      expectedCode: "REPO_OPERATION_ERROR",
      expectedRetryable: false,
      expectedMessage: "clone failed",
    },
    {
      label: "SafetyViolationError",
      instance: new SafetyViolationError("too many files"),
      expectedName: "SafetyViolationError",
      expectedCode: "SAFETY_VIOLATION",
      expectedRetryable: false,
      expectedMessage: "too many files",
    },
    {
      label: "BuildFailureError",
      instance: new BuildFailureError("build broke", "stderr output here"),
      expectedName: "BuildFailureError",
      expectedCode: "BUILD_FAILURE",
      expectedRetryable: true,
      expectedMessage: "build broke",
    },
    {
      label: "JobTimeoutError",
      instance: new JobTimeoutError("50 min exceeded"),
      expectedName: "JobTimeoutError",
      expectedCode: "JOB_TIMEOUT",
      expectedRetryable: false,
      expectedMessage: "50 min exceeded",
    },
    {
      label: "RepoSelectionError",
      instance: new RepoSelectionError("no match"),
      expectedName: "RepoSelectionError",
      expectedCode: "REPO_SELECTION_ERROR",
      expectedRetryable: false,
      expectedMessage: "no match",
    },
  ];

  test.each(cases)(
    "$label — name, code, message, isRetryable, and instanceof chain",
    ({ instance, expectedName, expectedCode, expectedRetryable, expectedMessage }) => {
      expect(instance.name).toBe(expectedName);
      expect(instance.code).toBe(expectedCode);
      expect(instance.message).toBe(expectedMessage);
      expect(instance.isRetryable).toBe(expectedRetryable);
      expect(instance).toBeInstanceOf(AppError);
      expect(instance).toBeInstanceOf(Error);
    },
  );
});

describe("AgentError — caller can override isRetryable", () => {
  test("isRetryable overridden to false", () => {
    const err = new AgentError("x", { isRetryable: false });
    expect(err.isRetryable).toBe(false);
  });
});

describe("JiraApiError — isRetryable is always true regardless of caller", () => {
  test("cannot be overridden because spread order forces true last", () => {
    // JiraApiError does { ...options, isRetryable: true } so even if
    // a cause is passed, isRetryable is always true.
    const err = new JiraApiError("x", { cause: new Error("inner") });
    expect(err.isRetryable).toBe(true);
  });
});

describe("BuildFailureError — extra field", () => {
  test("output field is accessible on the instance", () => {
    const err = new BuildFailureError("compile error", "Error on line 42\nfailed");
    expect(err.output).toBe("Error on line 42\nfailed");
  });
});

describe("subclass preserves extra fields when thrown and caught", () => {
  test("BuildFailureError retains output after throw/catch", () => {
    try {
      throw new BuildFailureError("boom", "build log contents");
    } catch (e) {
      expect(e).toBeInstanceOf(BuildFailureError);
      expect(e).toBeInstanceOf(AppError);
      expect(e).toBeInstanceOf(Error);
      const bf = e as BuildFailureError;
      expect(bf.output).toBe("build log contents");
      expect(bf.code).toBe("BUILD_FAILURE");
      expect(bf.name).toBe("BuildFailureError");
    }
  });
});

import { JobAbortedError } from "@/core/errors.js";

test("JobAbortedError is non-retryable with the JOB_ABORTED code", () => {
  const err = new JobAbortedError("aborted for shutdown");
  expect(err.code).toBe("JOB_ABORTED");
  expect(err.isRetryable).toBe(false);
  expect(err.name).toBe("JobAbortedError");
});
