import { context, trace, TraceFlags } from "@opentelemetry/api";
import type { RunEngine } from "@internal/run-engine";
import type { PrismaClientOrTransaction } from "@trigger.dev/database";
import { RunId } from "@trigger.dev/core/v3/isomorphic";
import type {
  MollifierDrainerHandler,
  MollifierDrainerTerminalFailureHandler,
} from "@trigger.dev/redis-worker";
import { logger } from "~/services/logger.server";
import { recordRunDebugLog } from "~/v3/eventRepository/index.server";
import { PerformTaskRunAlertsService } from "~/v3/services/alerts/performTaskRunAlerts.server";
import { startSpan } from "~/v3/tracing.server";
import type { MollifierSnapshot } from "./mollifierSnapshot.server";

const tracer = trace.getTracer("mollifier-drainer");

export function isRetryablePgError(err: unknown): boolean {
  if (!(err instanceof Error)) return false;
  const msg = err.message ?? "";
  // Prisma surfaces P1001 ("Can't reach database server") via two
  // different error classes — `PrismaClientKnownRequestError` exposes
  // it as `err.code`, `PrismaClientInitializationError` exposes it as
  // `err.errorCode`. Check both so reconnection-time errors retry
  // regardless of which class fires.
  const code = (err as { code?: string }).code;
  const errorCode = (err as { errorCode?: string }).errorCode;
  if (code === "P2024") return true;
  if (code === "P1001" || errorCode === "P1001") return true;
  if (msg.includes("Can't reach database server")) return true;
  if (msg.includes("Connection lost")) return true;
  if (msg.includes("ECONNRESET")) return true;
  return false;
}

export function createDrainerHandler(deps: {
  engine: RunEngine;
  prisma: PrismaClientOrTransaction;
}): MollifierDrainerHandler<MollifierSnapshot> {
  return async (input) => {
    const dwellMs = Date.now() - input.createdAt.getTime();

    // Re-attach to the trace started by the caller's mollifier.queued span
    // (its traceId + spanId were captured into the snapshot at buffer time).
    // Without this the drainer would emit mollifier.drained in a brand-new
    // trace and the engine.trigger instrumentation would inherit an empty
    // active context — leaving the run-detail page with only the root span.
    const snapshotTraceId =
      typeof input.payload.traceId === "string" ? input.payload.traceId : undefined;
    const snapshotSpanId =
      typeof input.payload.spanId === "string" ? input.payload.spanId : undefined;

    const parentContext =
      snapshotTraceId && snapshotSpanId
        ? trace.setSpanContext(context.active(), {
            traceId: snapshotTraceId,
            spanId: snapshotSpanId,
            traceFlags: TraceFlags.SAMPLED,
            isRemote: true,
          })
        : context.active();

    // Cancel-wins-over-trigger. If a cancel API call landed on this
    // entry while it was QUEUED, the snapshot carries `cancelledAt` +
    // `cancelReason`. Skip the normal materialise path and write a
    // CANCELED PG row directly. The `runCancelled` bus emit is
    // suppressed here because a buffered-only run never had a primary
    // trace event written for it — the runCancelled handler's
    // `cancelRunEvent` lookup would fail and log noise per cancel.
    const cancelledAtStr =
      typeof input.payload.cancelledAt === "string" ? input.payload.cancelledAt : undefined;
    if (cancelledAtStr) {
      const cancelReason =
        typeof input.payload.cancelReason === "string"
          ? input.payload.cancelReason
          : "Canceled by user";
      await context.with(parentContext, async () => {
        await startSpan(tracer, "mollifier.drained.cancelled", async (span) => {
          span.setAttribute("mollifier.drained", true);
          span.setAttribute("mollifier.dwell_ms", dwellMs);
          span.setAttribute("mollifier.attempts", input.attempts);
          span.setAttribute("mollifier.run_friendly_id", input.runId);
          span.setAttribute("mollifier.cancel_bifurcation", true);
          span.setAttribute("taskRunId", input.runId);
          try {
            await deps.engine.createCancelledRun(
              {
                snapshot: input.payload as any,
                cancelledAt: new Date(cancelledAtStr),
                cancelReason,
                emitRunCancelledEvent: false,
              },
              deps.prisma
            );
          } catch (err) {
            // createCancelledRun throws a conflict when the normal trigger
            // replay path won the race and already materialised a live
            // (non-CANCELED) row for this friendlyId. Its contract leaves
            // the resolution to us: honour the cancel by actually
            // cancelling the now-live run. Letting the conflict propagate
            // would instead reach the drainer's terminal-failure path
            // (isRetryablePgError() is false for it), buffer.fail() the
            // entry, and silently lose the cancellation while the run
            // keeps executing.
            const isConflict =
              err instanceof Error && err.message.startsWith("createCancelledRun conflict");
            if (!isConflict) {
              // Mirror the SYSTEM_FAILURE fallback the non-cancelled
              // trigger path uses below. Without this branch, a
              // non-retryable createCancelledRun failure rethrows, the
              // drainer's onTerminalFailure handler skips because it
              // gates on `cause === "max-attempts-exhausted"` (and the
              // outer drainer classifies non-retryable failures with
              // `cause: "non-retryable"`), and buffer.fail() deletes
              // the entry — leaving NO PG row. The cancellation
              // disappears silently from the customer's dashboard.
              // Writing a SYSTEM_FAILURE row gives the run a terminal,
              // visible state.
              if (isRetryablePgError(err)) {
                throw err;
              }
              span.setAttribute(
                "mollifier.cancel_terminal_failure_reason",
                err instanceof Error ? err.message : String(err)
              );
              try {
                const wrote = await writeMollifierTerminalFailureRow(deps, {
                  friendlyId: input.runId,
                  snapshot: input.payload as Record<string, unknown>,
                  reason: err instanceof Error ? err.message : String(err),
                });
                if (wrote) return;
              } catch (writeErr) {
                if (isRetryablePgError(writeErr)) {
                  span.setAttribute("mollifier.cancel_terminal_write_retryable", true);
                  throw writeErr;
                }
                span.setAttribute(
                  "mollifier.cancel_terminal_write_error",
                  writeErr instanceof Error ? writeErr.message : String(writeErr)
                );
              }
              throw err;
            }
            span.setAttribute("mollifier.cancel_conflict", true);
            const friendlyId =
              typeof input.payload.friendlyId === "string" ? input.payload.friendlyId : input.runId;
            await deps.engine.cancelRun({
              runId: RunId.fromFriendlyId(friendlyId),
              completedAt: new Date(cancelledAtStr),
              reason: cancelReason,
            });
          }
        });
      });
      return;
    }

    await context.with(parentContext, async () => {
      await startSpan(tracer, "mollifier.drained", async (span) => {
        span.setAttribute("mollifier.drained", true);
        span.setAttribute("mollifier.dwell_ms", dwellMs);
        span.setAttribute("mollifier.attempts", input.attempts);
        span.setAttribute("mollifier.run_friendly_id", input.runId);
        span.setAttribute("taskRunId", input.runId);

        let triggerSucceeded = false;
        try {
          await deps.engine.trigger(input.payload as any, deps.prisma);
          triggerSucceeded = true;
        } catch (err) {
          // The retryable-PG class re-throws so the drainer's outer
          // worker loop can `buffer.requeue` (handled in
          // `MollifierDrainer.drainOne`). For non-retryable failures we
          // write a terminal SYSTEM_FAILURE row to PG via the engine's
          // existing `createFailedTaskRun` (used by batch-trigger for
          // the same purpose) so the customer sees the run in their
          // dashboard / SDK instead of silently losing it when the
          // buffer entry TTLs out. If THAT insert also fails (PG truly
          // unreachable), rethrow so the drainer's outer catch falls
          // through to its existing `buffer.fail` terminal-marker path.
          if (isRetryablePgError(err)) {
            throw err;
          }
          const reason = err instanceof Error ? err.message : String(err);
          span.setAttribute("mollifier.terminal_failure_reason", reason);
          try {
            const wrote = await writeMollifierTerminalFailureRow(deps, {
              friendlyId: input.runId,
              snapshot: input.payload as Record<string, unknown>,
              reason,
            });
            if (!wrote) {
              // Snapshot too malformed to even construct a TaskRun row.
              // Drainer's outer catch will buffer.fail this entry.
              throw err;
            }
          } catch (writeErr) {
            // The terminal SYSTEM_FAILURE write itself failed. If it
            // failed because PG is transiently unreachable, rethrow the
            // *write* error so the drainer requeues — buffer.fail()ing on
            // the original non-retryable error would lose the run with no
            // PG row ever landing. Once PG recovers the requeued entry
            // writes its failure row and the customer sees it.
            if (isRetryablePgError(writeErr)) {
              span.setAttribute("mollifier.terminal_write_retryable", true);
              throw writeErr;
            }
            // PG reachable but the write was rejected for another reason
            // (genuinely bad snapshot). Rethrow the original trigger error
            // so the drainer falls back to buffer.fail.
            span.setAttribute(
              "mollifier.terminal_write_error",
              writeErr instanceof Error ? writeErr.message : String(writeErr)
            );
            throw err;
          }
        }

        // Admin-only audit trail emitted once engine.trigger has
        // landed a PG row. `recordRunDebugLog` flips this to the
        // admin-gated debug kind (TaskEventKind.LOG in the PG store /
        // DEBUG_EVENT in the ClickHouse store) which the trace view +
        // logs download already strip for non-admins
        // (`eventRepository.server.ts:108`,
        // `resources.runs.$runParam.logs.download.ts:118`).
        //
        // Placement: emit as a zero-duration marker AT materialisation
        // time, not as a back-dated bar spanning the buffered window.
        // `engine.trigger` rewrites the run's root span at
        // materialisation (it adopts the synth root via traceId/spanId
        // carryover but updates start_time to "now"), so the trace
        // renderer treats materialisation time as t=0. A back-dated
        // event with startTime = bufferedAt would land before that t=0
        // and get clipped from the tree. Same pattern as the
        // `[engine] QUEUED` markers. The window itself is preserved
        // in metadata so admins can read it off the span detail pane.
        //
        // Best-effort: `recordRunDebugLog` has its own try/catch and
        // returns a result, so it never throws into the materialisation
        // path. Failures are logged but not surfaced because the
        // customer-visible run has already landed.
        if (triggerSucceeded) {
          const debugResult = await recordRunDebugLog(
            RunId.fromFriendlyId(input.runId),
            `Mollifier buffered ${dwellMs}ms before materialising`,
            {
              attributes: {
                runId: input.runId,
                metadata: {
                  "mollifier.bufferedAt": input.createdAt.toISOString(),
                  "mollifier.materialisedAt": new Date().toISOString(),
                  "mollifier.dwellMs": dwellMs,
                  "mollifier.attempts": input.attempts,
                },
              },
              parentId: snapshotSpanId,
            }
          );
          if (!debugResult.success && debugResult.code !== "RUN_NOT_FOUND") {
            logger.warn("mollifier drainer: failed to record admin debug log", {
              runId: input.runId,
              code: debugResult.code,
            });
          }
        }
      });
    });
  };
}

// Shared SYSTEM_FAILURE construction used by both terminal paths:
//   - non-retryable failure inside the handler (above)
//   - retryable failure after maxAttempts inside the drainer's
//     `processEntry` (via `createDrainerTerminalFailureHandler`)
//
// Suppresses `runFailed` and enqueues the alert manually — the engine's
// `runFailed` handler calls `completeFailedRunEvent`, which looks up
// the run's primary span. Buffered-only runs never had a primary trace
// event written (the mollifier gate intercepts BEFORE
// `repository.traceEvent` runs), so the lookup always fails and the
// handler logs a systematic `[runFailed] Failed to complete failed
// run event` error per terminal failure. `TriggerFailedTaskService`
// handles the identical situation the same way (see triggerFailedTask
// .server.ts:212 and 324) — pass `emitRunFailedEvent: false` to the
// engine and call `PerformTaskRunAlertsService.enqueue(...)` directly
// so customers' ERROR channels still fire. Alert enqueue is
// best-effort; an alert-side failure is logged but does not bubble up
// (the SYSTEM_FAILURE row landing is the load-bearing customer-visible
// outcome).
//
// Returns the new `TaskRun` on success or `null` when the snapshot was
// so malformed it couldn't even produce an environment — caller decides
// whether to escalate that to `buffer.fail` directly. Throws on any
// other failure so the drainer's retryable/non-retryable disposition
// logic can own the decision.
async function writeMollifierTerminalFailureRow(
  deps: { engine: RunEngine; prisma: PrismaClientOrTransaction },
  args: { friendlyId: string; snapshot: Record<string, unknown>; reason: string }
) {
  const { snapshot } = args;
  const env = snapshot.environment as
    | {
        id: string;
        type: any;
        project: { id: string };
        organization: { id: string };
      }
    | undefined;
  if (!env) return null;
  // Extract batch association from the snapshot if present. Without this
  // a SYSTEM_FAILURE row for a buffered batch child won't be linked to
  // its batch, and the batch parent's completion tracking can hang
  // indefinitely waiting on a child that landed but isn't visible to
  // the batch.
  const rawBatch = snapshot.batch;
  const batch =
    rawBatch &&
    typeof rawBatch === "object" &&
    "id" in rawBatch &&
    typeof (rawBatch as { id: unknown }).id === "string" &&
    "index" in rawBatch &&
    typeof (rawBatch as { index: unknown }).index === "number"
      ? (rawBatch as { id: string; index: number })
      : undefined;
  const failedRun = await deps.engine.createFailedTaskRun({
    friendlyId: args.friendlyId,
    environment: env,
    taskIdentifier: String(snapshot.taskIdentifier ?? ""),
    payload: typeof snapshot.payload === "string" ? snapshot.payload : undefined,
    payloadType: typeof snapshot.payloadType === "string" ? snapshot.payloadType : undefined,
    error: {
      type: "STRING_ERROR",
      raw: `Mollifier drainer terminal failure: ${args.reason}`,
    },
    parentTaskRunId:
      typeof snapshot.parentTaskRunId === "string" ? snapshot.parentTaskRunId : undefined,
    rootTaskRunId: typeof snapshot.rootTaskRunId === "string" ? snapshot.rootTaskRunId : undefined,
    depth: typeof snapshot.depth === "number" ? snapshot.depth : 0,
    resumeParentOnCompletion: snapshot.resumeParentOnCompletion === true,
    batch,
    traceId: typeof snapshot.traceId === "string" ? snapshot.traceId : undefined,
    spanId: typeof snapshot.spanId === "string" ? snapshot.spanId : undefined,
    taskEventStore:
      typeof snapshot.taskEventStore === "string" ? snapshot.taskEventStore : undefined,
    queue: typeof snapshot.queue === "string" ? snapshot.queue : undefined,
    lockedQueueId: typeof snapshot.lockedQueueId === "string" ? snapshot.lockedQueueId : undefined,
    emitRunFailedEvent: false,
  });
  // Alerts side of `runFailed` — the engine emit was suppressed above
  // so we don't create an orphan trace event; enqueue the alert
  // directly so customers' ERROR channels still see the failure.
  // Best-effort, mirroring TriggerFailedTaskService.
  try {
    await PerformTaskRunAlertsService.enqueue(failedRun.id);
  } catch (alertsError) {
    logger.warn("writeMollifierTerminalFailureRow: alert enqueue failed", {
      friendlyId: args.friendlyId,
      error: alertsError instanceof Error ? alertsError.message : String(alertsError),
    });
  }
  return failedRun;
}

// Drainer-side terminal-failure callback. Fires from
// `MollifierDrainer.processEntry` BEFORE `buffer.fail()` on any path
// where the in-handler write didn't already land — currently the
// `cause: "max-attempts-exhausted"` case for retryable PG errors. Writes
// the same SYSTEM_FAILURE row the non-retryable handler path writes
// inline (via the shared `writeMollifierTerminalFailureRow` helper) so
// the customer-visible behaviour is identical regardless of how the
// failure was classified.
//
// Re-throws retryable PG errors so the drainer requeues — buffer.fail()ing
// here would still lose the run if PG is genuinely unreachable. Throwing
// anything else falls through to buffer.fail to avoid an infinite loop on
// a genuinely bad snapshot (the drainer logs it).
export function createDrainerTerminalFailureHandler(deps: {
  engine: RunEngine;
  prisma: PrismaClientOrTransaction;
}): MollifierDrainerTerminalFailureHandler<MollifierSnapshot> {
  return async (input) => {
    // The handler's own non-retryable terminal path has already written
    // the SYSTEM_FAILURE row before it throws non-retryable. Only the
    // retryable-exhausted path reaches us with no row written yet — gate
    // on `cause` to avoid double-writing for non-retryable failures.
    if (input.cause !== "max-attempts-exhausted") return;
    await startSpan(tracer, "mollifier.drained.terminal_failure", async (span) => {
      span.setAttribute("mollifier.drained", false);
      span.setAttribute("mollifier.attempts", input.attempts);
      span.setAttribute("mollifier.run_friendly_id", input.runId);
      span.setAttribute("mollifier.terminal_failure_cause", input.cause);
      span.setAttribute("mollifier.terminal_failure_reason", input.error.message);
      span.setAttribute("taskRunId", input.runId);
      await writeMollifierTerminalFailureRow(deps, {
        friendlyId: input.runId,
        snapshot: input.payload as Record<string, unknown>,
        reason: input.error.message,
      });
    });
  };
}
