import { randomUUID } from "node:crypto";
import type {
  IdempotencyClaimResult,
  IdempotencyLookupInput,
  MollifierBuffer,
} from "@trigger.dev/redis-worker";
import { logger } from "~/services/logger.server";
import { getMollifierBuffer } from "./mollifierBuffer.server";

// Tunables. The TTL on the claim key is bounded by typical trigger-pipeline
// dwell; long enough that a slow PG insert doesn't expire mid-flight,
// short enough that a crashed claimant unblocks waiters quickly.
export const DEFAULT_CLAIM_TTL_SECONDS = 30;
// safetyNetMs caps how long a waiter blocks before returning timed_out.
// Matches the mutateWithFallback safety net so SDK retry policies don't
// have to special-case this path.
export const DEFAULT_CLAIM_WAIT_MS = 5_000;
export const DEFAULT_CLAIM_POLL_MS = 25;

export type ClaimOrAwaitOutcome =
  // We own the claim. `token` MUST be passed to publishClaim/releaseClaim
  // so the buffer can compare-and-act against our ownership marker — a
  // late release from a previous claimant whose TTL expired cannot
  // erase our slot.
  | { kind: "claimed"; token: string }
  | { kind: "resolved"; runId: string } // someone else's runId; caller returns isCached:true
  | { kind: "timed_out" };

export type ClaimOrAwaitInput = IdempotencyLookupInput & {
  ttlSeconds?: number;
  safetyNetMs?: number;
  pollStepMs?: number;
  abortSignal?: AbortSignal;
  // Test injection.
  buffer?: MollifierBuffer | null;
  now?: () => number;
  sleep?: (ms: number) => Promise<void>;
  // Test override for the ownership-token generator. Defaults to
  // `crypto.randomUUID()`. Tests pass a deterministic value so they
  // can assert publish/release pass-through.
  generateToken?: () => string;
};

// Pre-gate Redis claim. All same-key triggers serialise through here
// before the trigger pipeline runs. Returning `resolved` short-circuits
// the trigger entirely — the caller responds with the cached runId.
// Returning `claimed` means we own the claim and MUST publish the
// winning runId on success (`publishClaim`) or release the claim on
// failure (`releaseClaim`).
//
// Failure modes:
// - Redis down at claim time: returns `claimed` (fail open, no
//   coordination). Customer is no worse than today's race; the
//   PG unique constraint is the eventual arbiter.
// - Claimant crashes mid-pipeline: claim TTL expires, waiters
//   eventually time out, SDK retries.
// - PG/buffer publish failure: waiters time out and SDK retries; next
//   attempt sees the eventual PG/buffer state via existing
//   IdempotencyKeyConcern PG-first lookup.
export async function claimOrAwait(input: ClaimOrAwaitInput): Promise<ClaimOrAwaitOutcome> {
  const buffer = input.buffer === undefined ? getMollifierBuffer() : input.buffer;
  if (!buffer) {
    // Mollifier disabled / buffer construction failed. Fall open —
    // caller proceeds with the trigger pipeline (PG unique constraint
    // backstop). The token is never read in this case (publish/release
    // are buffer-null no-ops downstream), so we skip the default
    // `randomUUID()` to keep the mollifier-OFF hot path allocation-free
    // for idempotency-keyed triggers — `triggerTask` is the
    // highest-throughput code path in the system. A test-injected
    // generator is still honoured for deterministic assertions.
    return { kind: "claimed", token: input.generateToken ? input.generateToken() : "" };
  }
  const generateToken = input.generateToken ?? randomUUID;
  // Generate the ownership token up front so the retry loop reuses it
  // — we're the same logical claimant across attempts; only the slot
  // owner changes between releases.
  const token = generateToken();
  const ttlSeconds = input.ttlSeconds ?? DEFAULT_CLAIM_TTL_SECONDS;
  const safetyNetMs = input.safetyNetMs ?? DEFAULT_CLAIM_WAIT_MS;
  const pollStepMs = input.pollStepMs ?? DEFAULT_CLAIM_POLL_MS;
  const now = input.now ?? Date.now;
  const sleep = input.sleep ?? defaultSleep;

  const lookupInput: IdempotencyLookupInput = {
    envId: input.envId,
    taskIdentifier: input.taskIdentifier,
    idempotencyKey: input.idempotencyKey,
  };

  // Initial claim attempt. Most production-path calls resolve here on
  // the first call (either we win, or the key is already resolved from
  // a prior burst).
  let result: IdempotencyClaimResult;
  try {
    result = await buffer.claimIdempotency({ ...lookupInput, token, ttlSeconds });
  } catch (err) {
    logger.warn("idempotency claim failed (fail-open)", {
      envId: input.envId,
      taskIdentifier: input.taskIdentifier,
      err: err instanceof Error ? err.message : String(err),
    });
    return { kind: "claimed", token };
  }

  if (result.kind === "claimed") return { kind: "claimed", token };
  if (result.kind === "resolved") return result;

  // result.kind === "pending" — wait/poll loop. May see the value flip
  // to "resolved" (winner published), the key vanish (winner released
  // on error → retry claim), or stay "pending" until the safety net.
  const deadline = now() + safetyNetMs;
  while (now() < deadline) {
    if (input.abortSignal?.aborted) return { kind: "timed_out" };
    await sleep(pollStepMs);

    let current: IdempotencyClaimResult | null;
    try {
      current = await buffer.readClaim(lookupInput);
    } catch (err) {
      // Transient read failure — keep polling until deadline.
      logger.warn("idempotency claim read failed mid-poll", {
        err: err instanceof Error ? err.message : String(err),
      });
      continue;
    }

    if (current === null) {
      // Claimant released on error. Re-attempt the claim — one of the
      // waiters will win, the rest see "pending" again. Reuse our token:
      // we're still the same logical claimant, just contending for a
      // freshly empty slot.
      try {
        const retry = await buffer.claimIdempotency({ ...lookupInput, token, ttlSeconds });
        if (retry.kind === "claimed") return { kind: "claimed", token };
        if (retry.kind === "resolved") return retry;
        // "pending" again → keep polling.
      } catch (err) {
        logger.warn("idempotency claim retry failed", {
          err: err instanceof Error ? err.message : String(err),
        });
        return { kind: "claimed", token };
      }
      continue;
    }
    if (current.kind === "resolved") return current;
    // current.kind === "pending" → keep polling.
  }
  return { kind: "timed_out" };
}

// Publish the winning runId so waiters resolve. Best-effort: failure
// here means waiters will time out and the SDK will retry, which will
// then find the row via the existing IdempotencyKeyConcern PG-first
// check.
export async function publishClaim(input: {
  envId: string;
  taskIdentifier: string;
  idempotencyKey: string;
  // Ownership token from the `claimed` outcome. Buffer compare-and-sets
  // on this so a publish from a stale claimant (TTL expired, another
  // claimant moved in) is a no-op rather than overwriting their claim.
  token: string;
  runId: string;
  ttlSeconds?: number;
  buffer?: MollifierBuffer | null;
}): Promise<void> {
  const buffer = input.buffer === undefined ? getMollifierBuffer() : input.buffer;
  if (!buffer) return;
  const ttlSeconds = input.ttlSeconds ?? DEFAULT_CLAIM_TTL_SECONDS;
  try {
    await buffer.publishClaim({
      envId: input.envId,
      taskIdentifier: input.taskIdentifier,
      idempotencyKey: input.idempotencyKey,
      token: input.token,
      runId: input.runId,
      ttlSeconds,
    });
  } catch (err) {
    logger.warn("idempotency claim publish failed", {
      envId: input.envId,
      taskIdentifier: input.taskIdentifier,
      err: err instanceof Error ? err.message : String(err),
    });
  }
}

// Release on pipeline failure. Best-effort. If the DEL fails, the claim
// TTL is the safety net — waiters time out, SDK retries.
export async function releaseClaim(input: {
  envId: string;
  taskIdentifier: string;
  idempotencyKey: string;
  // Ownership token from the `claimed` outcome. Buffer compare-and-
  // deletes on this so a release from a stale claimant whose TTL
  // expired can't wipe a new owner's claim.
  token: string;
  buffer?: MollifierBuffer | null;
}): Promise<void> {
  const buffer = input.buffer === undefined ? getMollifierBuffer() : input.buffer;
  if (!buffer) return;
  try {
    await buffer.releaseClaim({
      envId: input.envId,
      taskIdentifier: input.taskIdentifier,
      idempotencyKey: input.idempotencyKey,
      token: input.token,
    });
  } catch (err) {
    logger.warn("idempotency claim release failed", {
      err: err instanceof Error ? err.message : String(err),
    });
  }
}

function defaultSleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
