import { afterEach, describe, expect, test } from "bun:test";
import type { AxiosAdapter } from "axios";
import type {
  BatchDeleteRunsResponse,
  BatchRunLifecycleResponse,
  Run,
  RunStatus,
} from "@qltysh/fabro-api-client";

import {
  archiveRun,
  archiveRuns,
  canArchive,
  canApprove,
  canCancel,
  canRetry,
  canUnarchive,
  cancellationActionLabel,
  cancellationSuccessMessage,
  cancelRun,
  deleteRuns,
  isTerminalCancelledRun,
  isTerminalRunStatus,
  isCancellationPending,
  isCancellationPendingState,
  mapError,
  retryRun,
  unarchiveRun,
  unarchiveRuns,
} from "./run-actions";
import { generatedAxios } from "./api-client";
import { TEST_PRINCIPAL } from "./test-fixtures";

type StubResponseInit = {
  status: number;
  body?: unknown;
  statusText?: string;
};

type CapturedRequest = {
  url?: string;
  method?: string;
  data?: unknown;
};

const originalAdapter = generatedAxios.defaults.adapter;

function makeRun(status: RunStatus, archived = false): Run {
  return {
    id:               "run-1",
    goal:             "Fix the build",
    title:            "Fix the build",
    workflow:         { slug: "fix_build", name: "Fix Build", graph_name: null, node_count: 0, edge_count: 0 },
    automation:       null,
    repository:       null,
    created_by:       TEST_PRINCIPAL,
    origin:           { kind: "api" },
    labels:           {},
    lifecycle:        {
      status,
      approval: null,
      pending_control: null,
      queue_position:  null,
      error:           null,
      archived,
      archived_at:     archived ? "2026-04-20T12:05:00Z" : null,
    },
    sandbox:          null,
    models:           [],
    source_directory: null,
    timestamps:       {
      created_at:     "2026-04-20T12:00:00Z",
      started_at:     null,
      last_event_at:  null,
      completed_at:   null,
    },
    billing:          null,
    size:             "XS",
    diff:             null,
    pull_request:     null,
    current_question: null,
    superseded_by:    null,
    retried_from:     null,
    links:            { web: null },
  };
}

function stubGeneratedAxiosOnce(init: StubResponseInit): { requests: CapturedRequest[] } {
  const requests: CapturedRequest[] = [];
  generatedAxios.defaults.adapter = (async (config) => {
    requests.push({
      url: config.url,
      method: config.method,
      data: config.data,
    });
    if (init.status >= 400) {
      throw {
        isAxiosError: true,
        message: init.statusText ?? `HTTP ${init.status}`,
        response: {
          status: init.status,
          statusText: init.statusText ?? "",
          data: init.body ?? null,
          headers: {},
        },
      };
    }
    return {
      data: init.body,
      status: init.status,
      statusText: init.statusText ?? "",
      headers: {},
      config,
    };
  }) as AxiosAdapter;
  return { requests };
}

function batchResponse(
  results: BatchRunLifecycleResponse["results"],
): BatchRunLifecycleResponse {
  const succeeded = results.filter((result) => result.ok).length;
  return {
    results,
    summary: {
      requested: results.length,
      succeeded,
      failed: results.length - succeeded,
    },
  };
}

function batchDeleteResponse(
  results: BatchDeleteRunsResponse["results"],
): BatchDeleteRunsResponse {
  const succeeded = results.filter((result) => result.ok).length;
  return {
    results,
    summary: {
      requested: results.length,
      succeeded,
      failed: results.length - succeeded,
    },
  };
}

function requestJsonBody(request: CapturedRequest): unknown {
  return typeof request.data === "string" ? JSON.parse(request.data) : request.data;
}

async function expectLifecycleError(
  input: Promise<unknown>,
): Promise<{ status: number; errors: Array<{ status: string; title: string; detail: string }> }> {
  try {
    await input;
    throw new Error("expected promise to reject");
  } catch (error) {
    return error as { status: number; errors: Array<{ status: string; title: string; detail: string }> };
  }
}

describe("run lifecycle actions", () => {
  afterEach(() => {
    generatedAxios.defaults.adapter = originalAdapter;
    delete (globalThis as { window?: unknown }).window;
  });

  test("cancelRun parses a 200 response", async () => {
    stubGeneratedAxiosOnce({
      status: 200,
      body: makeRun({ kind: "failed", reason: "cancelled" }),
    });

    const result = await cancelRun("run-1");
    expect(result.lifecycle.status.kind).toBe("failed");
    if (result.lifecycle.status.kind === "failed") {
      expect(result.lifecycle.status.reason).toBe("cancelled");
    }
  });

  test("cancelRun parses a 202 response as a pending cancellation", async () => {
    const run = makeRun({ kind: "running" });
    run.lifecycle.pending_control = "cancel";
    stubGeneratedAxiosOnce({
      status: 202,
      body: run,
    });

    const result = await cancelRun("run-1");
    expect(result.lifecycle.status.kind).toBe("running");
    expect(result.lifecycle.pending_control).toBe("cancel");
    expect(cancellationSuccessMessage(result)).toBe("Cancellation requested.");
  });

  test("archiveRun parses a 200 response", async () => {
    stubGeneratedAxiosOnce({
      status: 200,
      body: makeRun({ kind: "succeeded", reason: "completed" }, true),
    });

    const result = await archiveRun("run-1");
    expect(result.lifecycle.status.kind).toBe("succeeded");
    expect(result.lifecycle.archived).toBe(true);
  });

  test("unarchiveRun parses a 200 response", async () => {
    stubGeneratedAxiosOnce({
      status: 200,
      body: makeRun({ kind: "succeeded", reason: "completed" }),
    });

    const result = await unarchiveRun("run-1");
    expect(result.lifecycle.status.kind).toBe("succeeded");
    expect(result.lifecycle.archived).toBe(false);
  });

  test("archiveRuns sends one batch request and parses results", async () => {
    const stub = stubGeneratedAxiosOnce({
      status: 200,
      body: batchResponse([
        {
          run_id: "run-1",
          ok: true,
          outcome: "archived",
          run: { ...makeRun({ kind: "succeeded", reason: "completed" }, true), id: "run-1" },
        },
        {
          run_id: "run-2",
          ok: true,
          outcome: "already_archived",
          run: { ...makeRun({ kind: "succeeded", reason: "completed" }, true), id: "run-2" },
        },
      ]),
    });

    const result = await archiveRuns(["run-1", "run-2"]);

    expect(stub.requests).toHaveLength(1);
    expect(stub.requests[0]?.method?.toUpperCase()).toBe("POST");
    expect(stub.requests[0]?.url).toBe("/api/v1/runs/archive");
    expect(requestJsonBody(stub.requests[0]!)).toEqual({ run_ids: ["run-1", "run-2"] });
    expect(result.summary).toEqual({ requested: 2, succeeded: 2, failed: 0 });
    expect(result.results.map((entry) => entry.outcome)).toEqual(["archived", "already_archived"]);
  });

  test("unarchiveRuns resolves mixed per-item results without throwing", async () => {
    stubGeneratedAxiosOnce({
      status: 200,
      body: batchResponse([
        {
          run_id: "run-1",
          ok: true,
          outcome: "unarchived",
          run: { ...makeRun({ kind: "succeeded", reason: "completed" }), id: "run-1" },
        },
        {
          run_id: "run-missing",
          ok: false,
          outcome: "not_found",
          error: { status: "404", title: "Not Found", detail: "Run not found." },
        },
      ]),
    });

    const result = await unarchiveRuns(["run-1", "run-missing"]);

    expect(result.summary).toEqual({ requested: 2, succeeded: 1, failed: 1 });
    expect(result.results[1]?.ok).toBe(false);
    expect(result.results[1]?.error?.status).toBe("404");
  });

  test("deleteRuns sends one batch request and parses results", async () => {
    const stub = stubGeneratedAxiosOnce({
      status: 200,
      body: batchDeleteResponse([
        {
          run_id: "run-1",
          ok: true,
          outcome: "deleted",
        },
        {
          run_id: "run-missing",
          ok: true,
          outcome: "already_absent",
        },
      ]),
    });

    const result = await deleteRuns(["run-1", "run-missing"]);

    expect(stub.requests).toHaveLength(1);
    expect(stub.requests[0]?.method?.toUpperCase()).toBe("POST");
    expect(stub.requests[0]?.url).toBe("/api/v1/runs/delete");
    expect(requestJsonBody(stub.requests[0]!)).toEqual({
      run_ids: ["run-1", "run-missing"],
      force: false,
    });
    expect(result.summary).toEqual({ requested: 2, succeeded: 2, failed: 0 });
    expect(result.results.map((entry) => entry.outcome)).toEqual(["deleted", "already_absent"]);
  });

  test("deleteRuns sends force when requested", async () => {
    const stub = stubGeneratedAxiosOnce({
      status: 200,
      body: batchDeleteResponse([
        {
          run_id: "run-1",
          ok: true,
          outcome: "deleted",
        },
      ]),
    });

    await deleteRuns(["run-1"], true);

    expect(stub.requests).toHaveLength(1);
    expect(requestJsonBody(stub.requests[0]!)).toEqual({
      run_ids: ["run-1"],
      force: true,
    });
  });

  test("deleteRuns preserves request-level error envelopes", async () => {
    stubGeneratedAxiosOnce({
      status: 400,
      body: {
        errors: [{ status: "400", title: "Bad Request", detail: "run_ids must contain at least one run ID." }],
      },
    });

    const error = await expectLifecycleError(deleteRuns([]));
    expect(error).toEqual({
      status: 400,
      errors: [{ status: "400", title: "Bad Request", detail: "run_ids must contain at least one run ID." }],
    });
  });

  test("batch lifecycle helpers preserve request-level error envelopes", async () => {
    stubGeneratedAxiosOnce({
      status: 400,
      body: {
        errors: [{ status: "400", title: "Bad Request", detail: "run_ids must contain at least one run ID." }],
      },
    });

    const error = await expectLifecycleError(archiveRuns([]));
    expect(error).toEqual({
      status: 400,
      errors: [{ status: "400", title: "Bad Request", detail: "run_ids must contain at least one run ID." }],
    });
  });

  test("retryRun parses a 201 response", async () => {
    stubGeneratedAxiosOnce({
      status: 201,
      body: {
        ...makeRun({ kind: "submitted" }),
        id:           "run-2",
        retried_from: "run-1",
      },
    });

    const result = await retryRun("run-1");
    expect(result.id).toBe("run-2");
    expect(result.retried_from).toBe("run-1");
    expect(result.lifecycle.status.kind).toBe("submitted");
  });

  test("404 and 409 preserve the parsed error envelope", async () => {
    stubGeneratedAxiosOnce({
      status: 404,
      body: {
        errors: [{ status: "404", title: "Not Found", detail: "Run not found." }],
      },
    });
    const notFound = await expectLifecycleError(cancelRun("missing-run"));
    expect(notFound).toEqual({
      status: 404,
      errors: [{ status: "404", title: "Not Found", detail: "Run not found." }],
    });

    stubGeneratedAxiosOnce({
      status: 409,
      body: {
        errors: [{ status: "409", title: "Conflict", detail: "Run is not terminal." }],
      },
    });
    const conflict = await expectLifecycleError(archiveRun("run-1"));
    expect(conflict).toEqual({
      status: 409,
      errors: [{ status: "409", title: "Conflict", detail: "Run is not terminal." }],
    });
  });

  test("non-JSON error bodies fall back to an empty error list", async () => {
    stubGeneratedAxiosOnce({
      status: 409,
      body: "<html>conflict</html>",
      statusText: "Conflict",
    });

    const error = await expectLifecycleError(unarchiveRun("run-1"));
    expect(error).toEqual({ status: 409, errors: [] });
  });

  test("mapError returns user-facing copy for lifecycle conflicts", () => {
    expect(mapError({ status: 409, errors: [] }, "cancel")).toBe("This run can no longer be cancelled.");
    expect(mapError({ status: 409, errors: [] }, "approve")).toBe("This run is no longer pending approval.");
    expect(mapError({ status: 409, errors: [] }, "deny")).toBe("This run is no longer pending approval.");
    expect(mapError({ status: 409, errors: [] }, "archive")).toBe("Only terminal runs can be archived.");
    expect(mapError({ status: 409, errors: [] }, "unarchive")).toBe("Active runs can't be unarchived.");
  });

  test("status predicates align with the documented run statuses", () => {
    expect(canCancel("submitted")).toBe(true);
    expect(canCancel("pending")).toBe(true);
    expect(canCancel("runnable")).toBe(true);
    expect(canCancel("starting")).toBe(true);
    expect(canCancel("running")).toBe(true);
    expect(canCancel("paused")).toBe(true);
    expect(canCancel("blocked")).toBe(true);
    expect(canCancel("archived")).toBe(false);

    expect(canArchive("succeeded")).toBe(true);
    expect(canArchive("failed")).toBe(true);
    expect(canArchive("dead")).toBe(true);
    expect(canArchive("archived")).toBe(false);
    expect(isTerminalRunStatus("succeeded")).toBe(true);
    expect(isTerminalRunStatus("running")).toBe(false);

    expect(canUnarchive("archived")).toBe(true);
    expect(canUnarchive("failed")).toBe(false);
  });

  test("approval predicate requires pending status and pending approval state", () => {
    expect(canApprove({
      ...makeRun({ kind: "pending", reason: "approval_required" }),
      lifecycle: {
        ...makeRun({ kind: "pending", reason: "approval_required" }).lifecycle,
        approval: {
          state: "pending",
          requested_at: "2026-05-23T12:00:00Z",
          decided_at: null,
          denial_reason: null,
        },
      },
    })).toBe(true);
    expect(canApprove(makeRun({ kind: "pending", reason: "approval_required" }))).toBe(false);
    expect(canApprove(makeRun({ kind: "runnable" }))).toBe(false);
  });

  test("canRetry allows terminal runs except archived runs", () => {
    expect(canRetry(makeRun({ kind: "failed", reason: "workflow_error" }))).toBe(true);
    expect(canRetry(makeRun({ kind: "dead" }))).toBe(true);
    expect(canRetry(makeRun({ kind: "failed", reason: "cancelled" }))).toBe(true);
    expect(canRetry(makeRun({ kind: "succeeded", reason: "completed" }))).toBe(true);
    expect(canRetry(makeRun({ kind: "running" }))).toBe(false);
    expect(canRetry(makeRun({ kind: "failed", reason: "workflow_error" }, true))).toBe(false);
    expect(canRetry(makeRun({ kind: "succeeded", reason: "completed" }, true))).toBe(false);
  });

  test("isTerminalCancelledRun distinguishes immediate cancel success from in-flight cancellation", () => {
    expect(
      isTerminalCancelledRun(makeRun({ kind: "failed", reason: "cancelled" })),
    ).toBe(true);
    expect(
      isTerminalCancelledRun(
        makeRun({ kind: "running" }, false),
      ),
    ).toBe(false);
  });

  test("cancellation pending combines durable state with the active mutation", () => {
    const running = makeRun({ kind: "running" });
    expect(isCancellationPending(running)).toBe(false);
    expect(isCancellationPending(running, true)).toBe(true);

    running.lifecycle.pending_control = "cancel";
    expect(isCancellationPending(running)).toBe(true);

    const cancelled = makeRun({ kind: "failed", reason: "cancelled" });
    cancelled.lifecycle.pending_control = "cancel";
    expect(isCancellationPending(cancelled, true)).toBe(false);
    expect(cancellationSuccessMessage(cancelled)).toBe("Run cancelled.");
    expect(isCancellationPendingState("running", "cancel")).toBe(true);
    expect(isCancellationPendingState("failed", "cancel", true)).toBe(false);
    expect(cancellationActionLabel(true)).toBe("Cancelling…");
    expect(cancellationActionLabel(false)).toBe("Cancel");
  });
});
