import { describe, expect, it, vi } from "vitest";
import { trace } from "@opentelemetry/api";
import { RunId } from "@trigger.dev/core/v3/isomorphic";

vi.mock("~/db.server", () => ({
  prisma: {},
  $replica: {},
}));

// `writeMollifierTerminalFailureRow` enqueues a PerformTaskRunAlertsService
// after writing the SYSTEM_FAILURE row (mirrors TriggerFailedTaskService).
// In production that enqueues into the alerts redis-worker; the test
// environment has no redis-worker, so the real call hangs the tick out
// to its 5s vitest timeout. Stub `enqueue` to a resolved no-op so the
// handler's best-effort try/catch sees a clean success path.
vi.mock("~/v3/services/alerts/performTaskRunAlerts.server", () => ({
  PerformTaskRunAlertsService: {
    enqueue: vi.fn(async () => undefined),
  },
}));

// The drainer calls `recordRunDebugLog` after a successful engine.trigger
// to emit an admin-only LOG-kind event encoding the buffered window.
// The real implementation imports the configured event repository (prisma
// + clickhouse + env), which has heavy side-effects on first import.
// Stub it to a vi.fn so the unit tests can assert call shape without
// dragging the whole eventRepository graph into webapp test setup.
// `vi.hoisted` is required because `vi.mock` factories are hoisted above
// regular `const`s — referencing a top-level variable from inside the
// factory otherwise fires `Cannot access 'X' before initialization`.
const { recordRunDebugLogMock } = vi.hoisted(() => ({
  recordRunDebugLogMock: vi.fn(async () => ({ success: true as const })),
}));
vi.mock("~/v3/eventRepository/index.server", () => ({
  recordRunDebugLog: recordRunDebugLogMock,
}));

import {
  createDrainerHandler,
  isRetryablePgError,
} from "~/v3/mollifier/mollifierDrainerHandler.server";

describe("isRetryablePgError", () => {
  it("returns true for P2024 (connection pool timeout)", () => {
    const err = Object.assign(new Error("Timed out fetching a new connection"), {
      code: "P2024",
    });
    expect(isRetryablePgError(err)).toBe(true);
  });

  it("returns true for generic connection-lost messages", () => {
    expect(isRetryablePgError(new Error("Connection lost"))).toBe(true);
    expect(isRetryablePgError(new Error("Can't reach database server"))).toBe(true);
  });

  it("returns false for validation errors", () => {
    expect(isRetryablePgError(new Error("Invalid payload"))).toBe(false);
  });

  it("returns false for non-Error inputs", () => {
    expect(isRetryablePgError("string error")).toBe(false);
    expect(isRetryablePgError({ message: "object" })).toBe(false);
  });
});

describe("createDrainerHandler", () => {
  it("invokes engine.trigger with the deserialised snapshot", async () => {
    const trigger = vi.fn(async () => ({ friendlyId: "run_x" }));
    const handler = createDrainerHandler({
      engine: { trigger } as any,
      prisma: {} as any,
    });

    await handler({
      runId: "run_x",
      envId: "env_a",
      orgId: "org_1",
      payload: { taskIdentifier: "t", payload: "{}" },
      attempts: 0,
      createdAt: new Date(),
    } as any);

    expect(trigger).toHaveBeenCalledOnce();
    const callArg = trigger.mock.calls[0][0] as { taskIdentifier: string };
    expect(callArg.taskIdentifier).toBe("t");
  });

  it("re-attaches the snapshot's traceId so engine.trigger inherits the original trace", async () => {
    // Captures the active traceId at the moment engine.trigger is invoked.
    // Without context propagation it would be a fresh traceId, leaving the
    // run-detail page with only the root span.
    let observedTraceId: string | undefined;
    const trigger = vi.fn(async () => {
      observedTraceId = trace.getActiveSpan()?.spanContext().traceId;
      return { friendlyId: "run_x" };
    });

    const handler = createDrainerHandler({
      engine: { trigger } as any,
      prisma: {} as any,
    });

    const snapshotTraceId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    const snapshotSpanId = "bbbbbbbbbbbbbbbb";

    await handler({
      runId: "run_x",
      envId: "env_a",
      orgId: "org_1",
      payload: {
        taskIdentifier: "t",
        traceId: snapshotTraceId,
        spanId: snapshotSpanId,
      },
      attempts: 0,
      createdAt: new Date(),
    } as any);

    expect(observedTraceId).toBe(snapshotTraceId);
  });

  it("rethrows retryable PG errors so MollifierDrainer requeues the entry", async () => {
    const err = new Error("Can't reach database server");
    const trigger = vi.fn(async () => {
      throw err;
    });
    const createFailedTaskRun = vi.fn();
    const handler = createDrainerHandler({
      engine: { trigger, createFailedTaskRun } as any,
      prisma: {} as any,
    });

    await expect(
      handler({
        runId: "run_x",
        envId: "env_a",
        orgId: "org_1",
        payload: { taskIdentifier: "t" },
        attempts: 0,
        createdAt: new Date(),
      } as any)
    ).rejects.toThrow("Can't reach database server");
    // Retryable: we do NOT write a SYSTEM_FAILURE row, the entry should
    // be requeued for another shot.
    expect(createFailedTaskRun).not.toHaveBeenCalled();
  });

  const envFixture = {
    id: "env_a",
    type: "DEVELOPMENT",
    project: { id: "proj_1" },
    organization: { id: "org_1" },
  };

  it("writes a SYSTEM_FAILURE PG row when engine.trigger fails non-retryably", async () => {
    const trigger = vi.fn(async () => {
      throw new Error("validation failed: payload too large");
    });
    const createFailedTaskRun = vi.fn(async () => ({
      id: "internal",
      friendlyId: "run_x",
    }));
    const handler = createDrainerHandler({
      engine: { trigger, createFailedTaskRun } as any,
      prisma: {} as any,
    });

    await expect(
      handler({
        runId: "run_x",
        envId: "env_a",
        orgId: "org_1",
        payload: { taskIdentifier: "t", environment: envFixture },
        attempts: 0,
        createdAt: new Date(),
      } as any)
    ).resolves.toBeUndefined();

    expect(trigger).toHaveBeenCalledOnce();
    expect(createFailedTaskRun).toHaveBeenCalledOnce();
    const arg = createFailedTaskRun.mock.calls[0][0] as { error: { raw: string } };
    expect(arg.error.raw).toContain("validation failed");
  });

  it("propagates the batch association into createFailedTaskRun (so batch parents don't hang on missing children)", async () => {
    // Devin's ANALYSIS report on PR #3754: the terminal-failure path
    // extracts most snapshot fields (parentTaskRunId, rootTaskRunId,
    // depth, etc.) but dropped `batch`. If the original trigger was
    // part of a batch, the SYSTEM_FAILURE row isn't associated with
    // the batch, so the batch parent's completion-tracking can hang
    // indefinitely waiting on a child that landed but isn't linked.
    const trigger = vi.fn(async () => {
      throw new Error("validation failed: payload too large");
    });
    const createFailedTaskRun = vi.fn(async () => ({
      id: "internal",
      friendlyId: "run_x",
    }));
    const handler = createDrainerHandler({
      engine: { trigger, createFailedTaskRun } as any,
      prisma: {} as any,
    });

    await expect(
      handler({
        runId: "run_batched",
        envId: "env_a",
        orgId: "org_1",
        payload: {
          taskIdentifier: "t",
          environment: envFixture,
          batch: { id: "batch_xyz", index: 7 },
        },
        attempts: 0,
        createdAt: new Date(),
      } as any)
    ).resolves.toBeUndefined();

    expect(createFailedTaskRun).toHaveBeenCalledOnce();
    const arg = createFailedTaskRun.mock.calls[0][0] as {
      batch?: { id: string; index: number };
    };
    expect(arg.batch).toEqual({ id: "batch_xyz", index: 7 });
  });

  it("rethrows the original error when createFailedTaskRun also fails (PG genuinely unreachable)", async () => {
    const triggerErr = new Error("engine rejected the snapshot");
    const trigger = vi.fn(async () => {
      throw triggerErr;
    });
    const createFailedTaskRun = vi.fn(async () => {
      throw new Error("connection refused");
    });
    const handler = createDrainerHandler({
      engine: { trigger, createFailedTaskRun } as any,
      prisma: {} as any,
    });

    await expect(
      handler({
        runId: "run_x",
        envId: "env_a",
        orgId: "org_1",
        payload: { taskIdentifier: "t", environment: envFixture },
        attempts: 0,
        createdAt: new Date(),
      } as any)
    ).rejects.toThrow("engine rejected the snapshot");
    // Drainer's outer drainOne loop now decides retry vs buffer.fail.
    expect(createFailedTaskRun).toHaveBeenCalledOnce();
  });

  it("calls createCancelledRun with emitRunCancelledEvent: false (suppresses orphan trace-event log noise)", async () => {
    // Buffered-only runs never had a primary trace event written for
    // them — the mollifier gate skipped `repository.traceEvent` since
    // the run hadn't materialised in PG yet. The `runCancelled` handler
    // would log `[runCancelled] Failed to cancel run event` for every
    // cancelled buffered run if we let the emit fire. Suppress it.
    const friendlyId = RunId.generate().friendlyId;
    const createCancelledRun = vi.fn(async () => ({
      id: "internal",
      friendlyId,
      status: "CANCELED",
    }));
    const handler = createDrainerHandler({
      engine: { createCancelledRun } as any,
      prisma: {} as any,
    });

    await handler({
      runId: friendlyId,
      envId: "env_a",
      orgId: "org_1",
      payload: {
        friendlyId,
        taskIdentifier: "t",
        environment: envFixture,
        cancelledAt: new Date().toISOString(),
        cancelReason: "Canceled by user",
      },
      attempts: 0,
      createdAt: new Date(),
    } as any);

    expect(createCancelledRun).toHaveBeenCalledOnce();
    const arg = createCancelledRun.mock.calls[0][0] as {
      emitRunCancelledEvent?: boolean;
    };
    expect(arg.emitRunCancelledEvent).toBe(false);
  });

  it("honours the cancel when a buffered cancel races a materialised non-CANCELED row", async () => {
    // Cancel-wins-over-trigger. If the normal trigger
    // replay path materialised a live PENDING row before the cancel
    // bifurcation drained, engine.createCancelledRun throws a conflict —
    // its documented contract is that "the caller must decide between
    // engine.cancelRun() and skipping". The drainer handler must honour
    // the cancel intent by actually cancelling the now-live run; otherwise
    // the conflict propagates, isRetryablePgError() returns false, and the
    // drainer buffer.fail()s the entry — silently losing the cancellation
    // while the run keeps executing.
    const friendlyId = RunId.generate().friendlyId;
    const createCancelledRun = vi.fn(async () => {
      throw new Error(`createCancelledRun conflict: existing run ${friendlyId} has status PENDING`);
    });
    const cancelRun = vi.fn(async () => ({ alreadyFinished: false }));
    const handler = createDrainerHandler({
      engine: { createCancelledRun, cancelRun } as any,
      prisma: {} as any,
    });

    await expect(
      handler({
        runId: friendlyId,
        envId: "env_a",
        orgId: "org_1",
        payload: {
          friendlyId,
          taskIdentifier: "t",
          environment: envFixture,
          cancelledAt: new Date().toISOString(),
          cancelReason: "Canceled by user",
        },
        attempts: 0,
        createdAt: new Date(),
      } as any)
    ).resolves.toBeUndefined();

    // The live run is actually cancelled, by its internal id.
    expect(cancelRun).toHaveBeenCalledOnce();
    expect(cancelRun.mock.calls[0][0].runId).toBe(RunId.fromFriendlyId(friendlyId));
  });

  it("requeues on a transient PG outage during the SYSTEM_FAILURE fallback write", async () => {
    // engine.trigger failed non-retryably, so we try to write a terminal
    // SYSTEM_FAILURE row. If THAT write fails because PG is transiently
    // unreachable, rethrowing the *original* non-retryable error makes the
    // drainer buffer.fail() the entry — losing the run with no PG row ever
    // landing. Rethrow the retryable write error instead so the drainer
    // requeues; once PG recovers the failure row lands and the customer
    // sees it.
    const trigger = vi.fn(async () => {
      throw new Error("validation failed: payload too large");
    });
    const createFailedTaskRun = vi.fn(async () => {
      throw new Error("Can't reach database server");
    });
    const handler = createDrainerHandler({
      engine: { trigger, createFailedTaskRun } as any,
      prisma: {} as any,
    });

    await expect(
      handler({
        runId: "run_x",
        envId: "env_a",
        orgId: "org_1",
        payload: { taskIdentifier: "t", environment: envFixture },
        attempts: 0,
        createdAt: new Date(),
      } as any)
    ).rejects.toThrow("Can't reach database server");
  });

  it("writes a SYSTEM_FAILURE row when createCancelledRun fails non-retryably (cancel bifurcation)", async () => {
    // Without this guard a non-conflict, non-retryable failure from
    // createCancelledRun rethrows out of the handler. The drainer's
    // onTerminalFailure gates on cause==="max-attempts-exhausted" and
    // skips "non-retryable", so buffer.fail() deletes the entry with
    // no PG row written — the cancellation disappears silently.
    // Mirror the non-cancel path's SYSTEM_FAILURE fallback so the
    // customer always sees a terminal row.
    const friendlyId = RunId.generate().friendlyId;
    const cancelErr = new Error("validation failed: bad cancel snapshot");
    const createCancelledRun = vi.fn(async () => {
      throw cancelErr;
    });
    const createFailedTaskRun = vi.fn(async () => ({ id: "internal_x" }));
    const handler = createDrainerHandler({
      engine: { createCancelledRun, createFailedTaskRun } as any,
      prisma: {} as any,
    });

    await handler({
      runId: friendlyId,
      envId: "env_a",
      orgId: "org_1",
      payload: {
        friendlyId,
        taskIdentifier: "t",
        environment: envFixture,
        cancelledAt: new Date().toISOString(),
        cancelReason: "Canceled by user",
      },
      attempts: 0,
      createdAt: new Date(),
    } as any);

    // SYSTEM_FAILURE row was written via the shared helper. Handler
    // returns cleanly so the drainer ACKs the entry instead of
    // buffer.fail()ing it.
    expect(createFailedTaskRun).toHaveBeenCalledOnce();
    expect(createFailedTaskRun.mock.calls[0][0].friendlyId).toBe(friendlyId);
    expect(createFailedTaskRun.mock.calls[0][0].error.raw).toContain(
      "validation failed: bad cancel snapshot"
    );
  });

  it("requeues when createCancelledRun fails with a retryable PG error (cancel bifurcation)", async () => {
    // Retryable PG failures must rethrow so the drainer requeues the
    // entry — writing a SYSTEM_FAILURE row when PG is transiently
    // unreachable would still fail. The drainer's existing retry loop
    // handles the requeue.
    const friendlyId = RunId.generate().friendlyId;
    const cancelErr = new Error("Can't reach database server");
    const createCancelledRun = vi.fn(async () => {
      throw cancelErr;
    });
    const createFailedTaskRun = vi.fn();
    const handler = createDrainerHandler({
      engine: { createCancelledRun, createFailedTaskRun } as any,
      prisma: {} as any,
    });

    await expect(
      handler({
        runId: friendlyId,
        envId: "env_a",
        orgId: "org_1",
        payload: {
          friendlyId,
          taskIdentifier: "t",
          environment: envFixture,
          cancelledAt: new Date().toISOString(),
          cancelReason: "Canceled by user",
        },
        attempts: 0,
        createdAt: new Date(),
      } as any)
    ).rejects.toThrow("Can't reach database server");
    expect(createFailedTaskRun).not.toHaveBeenCalled();
  });

  it("rethrows the original error when the snapshot lacks an environment block", async () => {
    const triggerErr = new Error("engine rejected the snapshot");
    const trigger = vi.fn(async () => {
      throw triggerErr;
    });
    const createFailedTaskRun = vi.fn();
    const handler = createDrainerHandler({
      engine: { trigger, createFailedTaskRun } as any,
      prisma: {} as any,
    });

    await expect(
      handler({
        runId: "run_x",
        envId: "env_a",
        orgId: "org_1",
        payload: { taskIdentifier: "t" /* no environment */ },
        attempts: 0,
        createdAt: new Date(),
      } as any)
    ).rejects.toThrow("engine rejected the snapshot");
    expect(createFailedTaskRun).not.toHaveBeenCalled();
  });

  it("emits an admin-only LOG-kind event with the buffered window after engine.trigger succeeds", async () => {
    // The drainer's audit trail rides the existing TaskEventKind.LOG
    // filter pattern (`eventRepository.server.ts:108` + `logs.download.ts:118`)
    // — admins see the buffered window in the trace; non-admins don't.
    recordRunDebugLogMock.mockClear();
    const trigger = vi.fn(async () => ({ friendlyId: "run_z" }));
    const handler = createDrainerHandler({
      engine: { trigger } as any,
      prisma: {} as any,
    });

    const bufferedAt = new Date(Date.now() - 4_000);
    await handler({
      runId: "run_z",
      envId: "env_a",
      orgId: "org_1",
      payload: { taskIdentifier: "t", spanId: "snapspan", traceId: "snaptrace" },
      attempts: 2,
      createdAt: bufferedAt,
    } as any);

    expect(recordRunDebugLogMock).toHaveBeenCalledOnce();
    const [callRunId, message, options] = recordRunDebugLogMock.mock.calls[0] as [
      string,
      string,
      any,
    ];
    // Internal cuid derived from the friendlyId, mirroring what
    // `findRunForEventCreation` queries on.
    expect(callRunId).toBe("z");
    expect(message).toMatch(/Mollifier buffered \d+ms before materialising/);
    // Emitted as a marker at materialisation time (no `startTime` /
    // `duration` overrides) — engine.trigger has just rewritten the
    // root span's start_time to "now", so back-dating the event would
    // clip it off-screen in the trace renderer. The historical window
    // is preserved in metadata so admins can still read it.
    expect(options.startTime).toBeUndefined();
    expect(options.duration).toBeUndefined();
    expect(options.parentId).toBe("snapspan");
    expect(options.attributes.metadata["mollifier.bufferedAt"]).toBe(bufferedAt.toISOString());
    expect(options.attributes.metadata["mollifier.attempts"]).toBe(2);
    expect(options.attributes.metadata["mollifier.dwellMs"]).toBeGreaterThan(0);
  });

  it("does NOT emit the admin LOG event when engine.trigger fails non-retryably", async () => {
    // The audit trail is for runs that actually materialised. On a
    // terminal SYSTEM_FAILURE path the customer-visible outcome is the
    // failure row; emitting a "buffered for Xms" event next to it would
    // imply the buffered window completed normally.
    recordRunDebugLogMock.mockClear();
    const trigger = vi.fn(async () => {
      throw new Error("engine rejected the snapshot");
    });
    const createFailedTaskRun = vi.fn(async () => ({ id: "internal" }));
    const handler = createDrainerHandler({
      engine: { trigger, createFailedTaskRun } as any,
      prisma: {} as any,
    });

    await handler({
      runId: "run_z",
      envId: "env_a",
      orgId: "org_1",
      payload: { taskIdentifier: "t", environment: envFixture },
      attempts: 0,
      createdAt: new Date(),
    } as any);

    expect(recordRunDebugLogMock).not.toHaveBeenCalled();
  });

  it("does NOT emit the admin LOG event on the cancel-bifurcation path", async () => {
    // Cancel-bifurcation writes a CANCELED row directly without calling
    // engine.trigger. There's no buffered-then-materialised window to
    // describe — the run never ran.
    recordRunDebugLogMock.mockClear();
    const friendlyId = RunId.generate().friendlyId;
    const createCancelledRun = vi.fn(async () => ({
      id: "internal",
      friendlyId,
      status: "CANCELED",
    }));
    const handler = createDrainerHandler({
      engine: { createCancelledRun } as any,
      prisma: {} as any,
    });

    await handler({
      runId: friendlyId,
      envId: "env_a",
      orgId: "org_1",
      payload: {
        friendlyId,
        taskIdentifier: "t",
        environment: envFixture,
        cancelledAt: new Date().toISOString(),
        cancelReason: "Canceled by user",
      },
      attempts: 0,
      createdAt: new Date(),
    } as any);

    expect(recordRunDebugLogMock).not.toHaveBeenCalled();
  });
});
