import type { ClickhouseFactory } from "~/services/clickhouse/clickhouseFactory.server";
import {
  type ClickHouse,
  type PayloadInsertArray,
  type TaskRunInsertArray,
  composeTaskRunVersion,
  getPayloadField,
  getTaskRunField,
} from "@internal/clickhouse";
import { type RedisOptions } from "@internal/redis";
import {
  LogicalReplicationClient,
  type MessageDelete,
  type MessageInsert,
  type MessageUpdate,
  type PgoutputMessage,
} from "@internal/replication";
import {
  getMeter,
  recordSpanError,
  startSpan,
  trace,
  type Counter,
  type Histogram,
  type Meter,
  type Tracer,
} from "@internal/tracing";
import { Logger, type LogLevel } from "@trigger.dev/core/logger";
import { tryCatch } from "@trigger.dev/core/utils";
import { parsePacketAsJson } from "@trigger.dev/core/v3/utils/ioSerialization";
import {
  unsafeExtractIdempotencyKeyScope,
  unsafeExtractIdempotencyKeyUser,
} from "@trigger.dev/core/v3/serverOnly";
import { RunAnnotations } from "@trigger.dev/core/v3";
import { type TaskRun } from "@trigger.dev/database";
import { nanoid } from "nanoid";
import EventEmitter from "node:events";
import pLimit from "p-limit";
import { detectBadJsonStrings } from "~/utils/detectBadJsonStrings";
import { calculateErrorFingerprint } from "~/utils/errorFingerprinting";
import { baseWorkerQueue } from "~/runEngine/concerns/workerQueueSplit.server";
import {
  isClickHouseJsonParseError,
  parseRowNumberFromError,
  sanitizeRows,
} from "~/v3/eventRepository/sanitizeRowsOnParseError.server";

interface TransactionEvent<T = any> {
  tag: "insert" | "update" | "delete";
  data: T;
  raw: MessageInsert | MessageUpdate | MessageDelete;
}

interface Transaction<T = any> {
  beginStartTimestamp: number;
  commitLsn: string | null;
  commitEndLsn: string | null;
  xid: number;
  events: TransactionEvent<T>[];
  replicationLagMs: number;
}

export type RunsReplicationSource = {
  /**
   * Stable per-source id. MUST be unique across sources. It is the key off
   * which every per-source identity is derived: the LogicalReplicationClient
   * `name` (and therefore the redlock leader-lock resource key), metrics tags,
   * logs. e.g. "legacy" | "new".
   */
  id: string;
  pgConnectionUrl: string;
  slotName: string;
  publicationName: string;
  /** 0 = legacy/control-plane DB, 1 = dedicated run-ops DB. Packed into _version via composeTaskRunVersion. */
  originGeneration: number;
};

export type RunsReplicationServiceOptions = {
  clickhouseFactory: ClickhouseFactory;
  pgConnectionUrl: string;
  serviceName: string;
  slotName: string;
  publicationName: string;
  /**
   * Optional list of replication sources. When provided (and non-empty), the
   * service fans in from each named source into the single shared flush
   * scheduler. When omitted, the scalar `pgConnectionUrl`/`slotName`/
   * `publicationName` are used as a single implicit `"default"` source,
   * preserving the legacy single-source behavior exactly.
   */
  sources?: RunsReplicationSource[];
  redisOptions: RedisOptions;
  maxFlushConcurrency?: number;
  flushIntervalMs?: number;
  flushBatchSize?: number;
  leaderLockTimeoutMs?: number;
  leaderLockExtendIntervalMs?: number;
  leaderLockAcquireAdditionalTimeMs?: number;
  leaderLockRetryIntervalMs?: number;
  ackIntervalSeconds?: number;
  acknowledgeTimeoutMs?: number;
  logger?: Logger;
  logLevel?: LogLevel;
  tracer?: Tracer;
  meter?: Meter;
  waitForAsyncInsert?: boolean;
  insertStrategy?: "insert" | "insert_async";
  // Retry configuration for insert operations
  insertMaxRetries?: number;
  insertBaseDelayMs?: number;
  insertMaxDelayMs?: number;
  disablePayloadInsert?: boolean;
  disableErrorFingerprinting?: boolean;
};

type PostgresTaskRun = TaskRun & { masterQueue: string };

type CurrentTransaction =
  | (Omit<Transaction<TaskRun>, "commitEndLsn" | "replicationLagMs"> & {
      commitEndLsn?: string | null;
      replicationLagMs?: number;
    })
  | null;

type SourceRuntime = {
  source: RunsReplicationSource;
  client: LogicalReplicationClient;
  latestCommitEndLsn: string | null;
  lastAcknowledgedLsn: string | null;
  lastAcknowledgedAt: number | null;
  acknowledgeInterval: NodeJS.Timeout | null;
  currentTransaction: CurrentTransaction;
  currentParseDurationMs: number | null;
};

type TaskRunInsert = {
  _version: bigint;
  run: PostgresTaskRun;
  event: "insert" | "update" | "delete";
};

export type RunsReplicationServiceEvents = {
  message: [{ lsn: string; message: PgoutputMessage; service: RunsReplicationService }];
  batchFlushed: [
    { flushId: string; taskRunInserts: TaskRunInsertArray[]; payloadInserts: PayloadInsertArray[] },
  ];
};

export class RunsReplicationService {
  private _isSubscribed = false;

  /**
   * Per-source runtime state. Each source has its own replication client, leader
   * lock, slot, and in-flight transaction state. All fan in to the single shared
   * _concurrentFlushScheduler. Transaction/LSN state MUST be per-source because
   * logical-replication transactions interleave per stream.
   */
  private _sources: Map<string, SourceRuntime>;

  private _concurrentFlushScheduler: ConcurrentFlushScheduler<TaskRunInsert>;
  private logger: Logger;
  private _isShuttingDown = false;
  private _isShutDownComplete = false;
  private _shutdownStopInFlight = false;
  private _tracer: Tracer;
  private _meter: Meter;
  private _acknowledgeTimeoutMs: number;
  // Retry configuration
  private _insertMaxRetries: number;
  private _insertBaseDelayMs: number;
  private _insertMaxDelayMs: number;
  private _insertStrategy: "insert" | "insert_async";
  private _disablePayloadInsert: boolean;
  private _disableErrorFingerprinting: boolean;

  /**
   * Counts batches that hit a ClickHouse `Cannot parse JSON object` failure
   * that survived one sanitize-retry. These batches are dropped on the floor
   * (returning success-ish to the caller so the retry layer doesn't spin on
   * the same deterministic failure), and we track the drop count for
   * observability. Counter only — does not gate behaviour.
   */
  private _permanentlyDroppedBatches = 0;

  // Metrics
  private _replicationLagHistogram: Histogram;
  private _batchesFlushedCounter: Counter;
  private _batchSizeHistogram: Histogram;
  private _taskRunsInsertedCounter: Counter;
  private _payloadsInsertedCounter: Counter;
  private _insertRetriesCounter: Counter;
  private _eventsProcessedCounter: Counter;
  private _flushDurationHistogram: Histogram;

  public readonly events: EventEmitter<RunsReplicationServiceEvents>;

  constructor(private readonly options: RunsReplicationServiceOptions) {
    this.logger =
      options.logger ?? new Logger("RunsReplicationService", options.logLevel ?? "info");
    this.events = new EventEmitter();
    this._tracer = options.tracer ?? trace.getTracer("runs-replication-service");
    this._meter = options.meter ?? getMeter("runs-replication");

    // Initialize metrics
    this._replicationLagHistogram = this._meter.createHistogram(
      "runs_replication.replication_lag_ms",
      {
        description: "Replication lag from Postgres commit to processing",
        unit: "ms",
      }
    );

    this._batchesFlushedCounter = this._meter.createCounter("runs_replication.batches_flushed", {
      description: "Total batches flushed to ClickHouse",
    });

    this._batchSizeHistogram = this._meter.createHistogram("runs_replication.batch_size", {
      description: "Number of items per batch flush",
      unit: "items",
    });

    this._taskRunsInsertedCounter = this._meter.createCounter(
      "runs_replication.task_runs_inserted",
      {
        description: "Task run inserts to ClickHouse",
        unit: "inserts",
      }
    );

    this._payloadsInsertedCounter = this._meter.createCounter(
      "runs_replication.payloads_inserted",
      {
        description: "Payload inserts to ClickHouse",
        unit: "inserts",
      }
    );

    this._insertRetriesCounter = this._meter.createCounter("runs_replication.insert_retries", {
      description: "Insert retry attempts",
    });

    this._eventsProcessedCounter = this._meter.createCounter("runs_replication.events_processed", {
      description: "Replication events processed (inserts, updates, deletes)",
    });

    this._flushDurationHistogram = this._meter.createHistogram(
      "runs_replication.flush_duration_ms",
      {
        description: "Duration of batch flush operations",
        unit: "ms",
      }
    );

    this._acknowledgeTimeoutMs = options.acknowledgeTimeoutMs ?? 1_000;

    this._insertStrategy = options.insertStrategy ?? "insert";
    this._disablePayloadInsert = options.disablePayloadInsert ?? false;
    this._disableErrorFingerprinting = options.disableErrorFingerprinting ?? false;

    const sources: RunsReplicationSource[] =
      options.sources && options.sources.length > 0
        ? options.sources
        : [
            {
              id: "default",
              pgConnectionUrl: options.pgConnectionUrl,
              slotName: options.slotName,
              publicationName: options.publicationName,
              originGeneration: 0,
            },
          ];

    RunsReplicationService.#validateSources(sources);

    this._sources = new Map<string, SourceRuntime>();

    for (const source of sources) {
      const client = new LogicalReplicationClient({
        pgConfig: {
          connectionString: source.pgConnectionUrl,
        },
        name: `${options.serviceName}:${source.id}`,
        slotName: source.slotName,
        publicationName: source.publicationName,
        table: "TaskRun",
        redisOptions: options.redisOptions,
        autoAcknowledge: false,
        resubscribeOnFailure: true,
        publicationActions: ["insert", "update", "delete"],
        logger:
          options.logger ?? new Logger("LogicalReplicationClient", options.logLevel ?? "info"),
        leaderLockTimeoutMs: options.leaderLockTimeoutMs ?? 30_000,
        leaderLockExtendIntervalMs: options.leaderLockExtendIntervalMs ?? 10_000,
        ackIntervalSeconds: options.ackIntervalSeconds ?? 10,
        leaderLockAcquireAdditionalTimeMs: options.leaderLockAcquireAdditionalTimeMs ?? 10_000,
        leaderLockRetryIntervalMs: options.leaderLockRetryIntervalMs ?? 500,
        tracer: options.tracer,
      });

      const runtime: SourceRuntime = {
        source,
        client,
        latestCommitEndLsn: null,
        lastAcknowledgedLsn: null,
        lastAcknowledgedAt: null,
        acknowledgeInterval: null,
        currentTransaction: null,
        currentParseDurationMs: null,
      };

      this.#wireClientEvents(runtime);

      this._sources.set(source.id, runtime);
    }

    this._concurrentFlushScheduler = new ConcurrentFlushScheduler<TaskRunInsert>({
      batchSize: options.flushBatchSize ?? 50,
      flushInterval: options.flushIntervalMs ?? 100,
      maxConcurrency: options.maxFlushConcurrency ?? 100,
      callback: this.#flushBatch.bind(this),
      // Key-based deduplication to reduce duplicates sent to ClickHouse
      getKey: (item) => {
        if (!item?.run?.id) {
          this.logger.warn("Skipping replication event with null run", { event: item });
          return null;
        }
        return `${item.event}_${item.run.id}`;
      },
      // Keep the run with the higher version (latest)
      // and take the last occurrence for that version.
      // Items originating from the same DB transaction have the same version.
      shouldReplace: (existing, incoming) => incoming._version >= existing._version,
      logger: new Logger("ConcurrentFlushScheduler", options.logLevel ?? "info"),
      tracer: options.tracer,
    });

    // Initialize retry configuration
    this._insertMaxRetries = options.insertMaxRetries ?? 3;
    this._insertBaseDelayMs = options.insertBaseDelayMs ?? 100;
    this._insertMaxDelayMs = options.insertMaxDelayMs ?? 2000;
  }

  static #validateSources(sources: RunsReplicationSource[]) {
    const ids = new Set<string>();
    const slotNames = new Set<string>();
    const originGenerations = new Set<number>();

    for (const source of sources) {
      // Distinct id: a duplicate id derives a duplicate client name -> duplicate
      // redlock leader-lock key -> only one source ever streams.
      if (ids.has(source.id)) {
        throw new Error(
          `RunsReplicationService: duplicate source id "${source.id}" — source ids must be unique`
        );
      }
      ids.add(source.id);

      // Distinct slotName: two consumers on one WAL stream is a data race.
      if (slotNames.has(source.slotName)) {
        throw new Error(
          `RunsReplicationService: duplicate slotName "${source.slotName}" — slot names must be unique across sources`
        );
      }
      slotNames.add(source.slotName);

      // Distinct originGeneration: a shared generation defeats the dedup tiebreak.
      if (originGenerations.has(source.originGeneration)) {
        throw new Error(
          `RunsReplicationService: duplicate originGeneration "${source.originGeneration}" — originGeneration must be unique across sources`
        );
      }
      originGenerations.add(source.originGeneration);
    }
  }

  #wireClientEvents(runtime: SourceRuntime) {
    const { client, source } = runtime;

    client.events.on("data", async ({ lsn, log, parseDuration }) => {
      this.#handleData(runtime, lsn, log, parseDuration);
    });

    client.events.on("heartbeat", async ({ lsn, shouldRespond }) => {
      if (this._isShuttingDown) return;
      if (this._isShutDownComplete) return;

      if (shouldRespond) {
        runtime.lastAcknowledgedLsn = lsn;
        await client.acknowledge(lsn);
      }
    });

    client.events.on("error", (error) => {
      this.logger.error("Replication client error", {
        sourceId: source.id,
        error,
      });
    });

    client.events.on("start", () => {
      this.logger.info("Replication client started", { sourceId: source.id });
    });

    client.events.on("acknowledge", ({ lsn }) => {
      this.logger.debug("Acknowledged", { sourceId: source.id, lsn });
    });

    client.events.on("leaderElection", (isLeader) => {
      this.logger.info("Leader election", { sourceId: source.id, isLeader });
    });
  }

  /** Exposed for tests and metrics — total batches lost to unrecoverable parse errors. */
  get permanentlyDroppedBatches() {
    return this._permanentlyDroppedBatches;
  }

  public async shutdown() {
    if (this._isShuttingDown) return;

    this._isShuttingDown = true;

    this.logger.info("Initiating shutdown of runs replication service");

    const hasCurrentTransaction = Array.from(this._sources.values()).some(
      (runtime) => runtime.currentTransaction !== null
    );

    if (!hasCurrentTransaction) {
      this.logger.info("No transaction to commit, shutting down immediately");
      await Promise.all(
        Array.from(this._sources.values()).map((runtime) => runtime.client.shutdown())
      );
      this._isShutDownComplete = true;
      return;
    }

    this._concurrentFlushScheduler.shutdown();
  }

  async start() {
    for (const runtime of this._sources.values()) {
      this.logger.info("Starting replication client", {
        sourceId: runtime.source.id,
        lastLsn: runtime.latestCommitEndLsn,
      });

      await runtime.client.subscribe(runtime.latestCommitEndLsn ?? undefined);

      runtime.acknowledgeInterval = setInterval(
        () => this.#acknowledgeLatestTransaction(runtime),
        1000
      );
    }

    this._concurrentFlushScheduler.start();
  }

  async stop() {
    for (const runtime of this._sources.values()) {
      this.logger.info("Stopping replication client", { sourceId: runtime.source.id });

      await runtime.client.shutdown();

      if (runtime.acknowledgeInterval) {
        clearInterval(runtime.acknowledgeInterval);
      }
    }
  }

  async teardown() {
    for (const runtime of this._sources.values()) {
      this.logger.info("Teardown replication client", { sourceId: runtime.source.id });

      await runtime.client.teardown();

      if (runtime.acknowledgeInterval) {
        clearInterval(runtime.acknowledgeInterval);
      }
    }
  }

  async backfill(runs: PostgresTaskRun[], sourceId?: string) {
    const flushId = nanoid();
    // Use current timestamp as LSN (high enough to be above existing data)
    const now = Date.now();
    const syntheticLsn = `${now.toString(16).padStart(8, "0").toUpperCase()}/00000000`;

    // Backfill and live replication of the SAME source share an origin generation
    // and rely on raw-LSN ordering within that generation. Default to the single
    // source self-host uses (gen 0 => passthrough).
    const runtime = sourceId ? this._sources.get(sourceId) : this._sources.values().next().value;

    if (!runtime) {
      throw new Error(
        sourceId
          ? `RunsReplicationService.backfill: no source found with id "${sourceId}"`
          : "RunsReplicationService.backfill: no sources configured"
      );
    }

    const baseVersion = composeTaskRunVersion({
      originGeneration: runtime.source.originGeneration,
      lsnVersion: lsnToUInt64(syntheticLsn),
    });

    await this.#flushBatch(
      flushId,
      runs.map((run, index) => ({
        _version: baseVersion + BigInt(index),
        run,
        event: "insert",
      }))
    );
  }

  #handleData(
    runtime: SourceRuntime,
    lsn: string,
    message: PgoutputMessage,
    parseDuration: bigint
  ) {
    this.logger.debug("Handling data", {
      sourceId: runtime.source.id,
      lsn,
      tag: message.tag,
      parseDuration,
    });

    this.events.emit("message", { lsn, message, service: this });

    switch (message.tag) {
      case "begin": {
        if (this._isShuttingDown || this._isShutDownComplete) {
          return;
        }

        runtime.currentTransaction = {
          beginStartTimestamp: Date.now(),
          commitLsn: message.commitLsn,
          xid: message.xid,
          events: [],
        };

        runtime.currentParseDurationMs = Number(parseDuration) / 1_000_000;

        break;
      }
      case "insert": {
        if (!runtime.currentTransaction) {
          return;
        }

        if (runtime.currentParseDurationMs) {
          runtime.currentParseDurationMs =
            runtime.currentParseDurationMs + Number(parseDuration) / 1_000_000;
        }

        runtime.currentTransaction.events.push({
          tag: message.tag,
          data: message.new as TaskRun,
          raw: message,
        });
        break;
      }
      case "update": {
        if (!runtime.currentTransaction) {
          return;
        }

        if (runtime.currentParseDurationMs) {
          runtime.currentParseDurationMs =
            runtime.currentParseDurationMs + Number(parseDuration) / 1_000_000;
        }

        runtime.currentTransaction.events.push({
          tag: message.tag,
          data: message.new as TaskRun,
          raw: message,
        });
        break;
      }
      case "delete": {
        if (!runtime.currentTransaction) {
          return;
        }

        if (runtime.currentParseDurationMs) {
          runtime.currentParseDurationMs =
            runtime.currentParseDurationMs + Number(parseDuration) / 1_000_000;
        }

        runtime.currentTransaction.events.push({
          tag: message.tag,
          data: message.old as TaskRun,
          raw: message,
        });

        break;
      }
      case "commit": {
        if (!runtime.currentTransaction) {
          return;
        }

        if (runtime.currentParseDurationMs) {
          runtime.currentParseDurationMs =
            runtime.currentParseDurationMs + Number(parseDuration) / 1_000_000;
        }

        const replicationLagMs = Date.now() - Number(message.commitTime / 1000n);
        runtime.currentTransaction.commitEndLsn = message.commitEndLsn;
        runtime.currentTransaction.replicationLagMs = replicationLagMs;
        const transaction = runtime.currentTransaction as Transaction<PostgresTaskRun>;
        runtime.currentTransaction = null;

        if (transaction.commitEndLsn) {
          runtime.latestCommitEndLsn = transaction.commitEndLsn;
        }

        this.#handleTransaction(runtime, transaction);
        break;
      }
      default: {
        this.logger.debug("Unknown message tag", {
          pgMessage: message,
        });
      }
    }
  }

  #handleTransaction(runtime: SourceRuntime, transaction: Transaction<PostgresTaskRun>) {
    if (this._isShutDownComplete) return;

    if (this._isShuttingDown) {
      // A global shutdown stops every source's client; mark complete once all
      // have stopped. Guard against re-firing per incoming transaction, and
      // swallow client.stop() rejections so they don't surface as unhandled.
      if (!this._shutdownStopInFlight) {
        this._shutdownStopInFlight = true;
        Promise.all(Array.from(this._sources.values()).map((r) => r.client.shutdown()))
          .catch((error) => {
            this.logger.error("Error stopping replication clients during shutdown", { error });
          })
          .finally(() => {
            this._isShutDownComplete = true;
          });
      }
    }

    // If there are no events, do nothing
    if (transaction.events.length === 0) {
      return;
    }

    if (!transaction.commitEndLsn) {
      this.logger.error("Transaction has no commit end lsn", {
        sourceId: runtime.source.id,
        transaction,
      });

      return;
    }

    const lsnToUInt64Start = process.hrtime.bigint();

    // Compose the source's origin generation above the LSN so a higher-generation
    // source wins the ClickHouse dedup tiebreak regardless of raw LSN. Gen 0 (the
    // single-source default) is a passthrough.
    const _version = composeTaskRunVersion({
      originGeneration: runtime.source.originGeneration,
      lsnVersion: lsnToUInt64(transaction.commitEndLsn),
    });

    const lsnToUInt64DurationMs = Number(process.hrtime.bigint() - lsnToUInt64Start) / 1_000_000;

    this._concurrentFlushScheduler.addToBatch(
      transaction.events.map((event) => ({
        _version,
        run: event.data,
        event: event.tag,
      }))
    );

    // Record metrics
    this._replicationLagHistogram.record(transaction.replicationLagMs, {
      source: runtime.source.id,
      generation: runtime.source.originGeneration,
    });

    // Count events by type
    for (const event of transaction.events) {
      this._eventsProcessedCounter.add(1, { event_type: event.tag });
    }

    this.logger.debug("handle_transaction", {
      sourceId: runtime.source.id,
      transaction: {
        xid: transaction.xid,
        commitLsn: transaction.commitLsn,
        commitEndLsn: transaction.commitEndLsn,
        events: transaction.events.length,
        parseDurationMs: runtime.currentParseDurationMs,
        lsnToUInt64DurationMs,
        version: _version.toString(),
      },
    });
  }

  async #acknowledgeLatestTransaction(runtime: SourceRuntime) {
    if (!runtime.latestCommitEndLsn) {
      return;
    }

    if (runtime.lastAcknowledgedLsn === runtime.latestCommitEndLsn) {
      return;
    }

    const now = Date.now();

    if (runtime.lastAcknowledgedAt) {
      const timeSinceLastAcknowledged = now - runtime.lastAcknowledgedAt;
      // If we've already acknowledged within the last second, don't acknowledge again
      if (timeSinceLastAcknowledged < this._acknowledgeTimeoutMs) {
        return;
      }
    }

    runtime.lastAcknowledgedAt = now;
    runtime.lastAcknowledgedLsn = runtime.latestCommitEndLsn;

    this.logger.debug("acknowledge_latest_transaction", {
      sourceId: runtime.source.id,
      commitEndLsn: runtime.latestCommitEndLsn,
      lastAcknowledgedAt: runtime.lastAcknowledgedAt,
    });

    const [ackError] = await tryCatch(runtime.client.acknowledge(runtime.latestCommitEndLsn));

    if (ackError) {
      this.logger.error("Error acknowledging transaction", {
        sourceId: runtime.source.id,
        ackError,
      });
    }

    if (this._isShutDownComplete && runtime.acknowledgeInterval) {
      clearInterval(runtime.acknowledgeInterval);
    }
  }

  async #flushBatch(flushId: string, batch: Array<TaskRunInsert>) {
    if (batch.length === 0) {
      return;
    }

    this.logger.debug("Flushing batch", {
      flushId,
      batchSize: batch.length,
    });

    const flushStartTime = performance.now();

    await startSpan(this._tracer, "flushBatch", async (span) => {
      const preparedInserts = await startSpan(this._tracer, "prepare_inserts", async () => {
        return await Promise.all(batch.map(this.#prepareRunInserts.bind(this)));
      });

      const routeCache = new Map<string, ClickHouse>();
      const groups = new Map<
        ClickHouse,
        { taskRunInserts: TaskRunInsertArray[]; payloadInserts: PayloadInsertArray[] }
      >();

      for (let i = 0; i < batch.length; i++) {
        const batchedRun = batch[i]!;
        const prep = preparedInserts[i]!;
        const { run } = batchedRun;

        if (!run.organizationId || !run.environmentType) {
          continue;
        }

        let client = routeCache.get(run.organizationId);
        if (!client) {
          client = this.options.clickhouseFactory.getClickhouseForOrganizationSync(
            run.organizationId,
            "replication"
          );
          routeCache.set(run.organizationId, client);
        }

        let group = groups.get(client);
        if (!group) {
          group = { taskRunInserts: [], payloadInserts: [] };
          groups.set(client, group);
        }

        if (prep.taskRunInsert) {
          group.taskRunInserts.push(prep.taskRunInsert);
        }
        if (prep.payloadInsert) {
          group.payloadInserts.push(prep.payloadInsert);
        }
      }

      const sortTaskRunInserts = (rows: TaskRunInsertArray[]) =>
        rows.sort((a, b) => {
          const aOrgId = getTaskRunField(a, "organization_id");
          const bOrgId = getTaskRunField(b, "organization_id");
          if (aOrgId !== bOrgId) {
            return aOrgId < bOrgId ? -1 : 1;
          }
          const aProjId = getTaskRunField(a, "project_id");
          const bProjId = getTaskRunField(b, "project_id");
          if (aProjId !== bProjId) {
            return aProjId < bProjId ? -1 : 1;
          }
          const aEnvId = getTaskRunField(a, "environment_id");
          const bEnvId = getTaskRunField(b, "environment_id");
          if (aEnvId !== bEnvId) {
            return aEnvId < bEnvId ? -1 : 1;
          }
          const aCreatedAt = getTaskRunField(a, "created_at");
          const bCreatedAt = getTaskRunField(b, "created_at");
          if (aCreatedAt !== bCreatedAt) {
            return aCreatedAt - bCreatedAt;
          }
          const aRunId = getTaskRunField(a, "run_id");
          const bRunId = getTaskRunField(b, "run_id");
          if (aRunId === bRunId) return 0;
          return aRunId < bRunId ? -1 : 1;
        });

      const sortPayloadInserts = (rows: PayloadInsertArray[]) =>
        rows.sort((a, b) => {
          const aRunId = getPayloadField(a, "run_id");
          const bRunId = getPayloadField(b, "run_id");
          if (aRunId === bRunId) return 0;
          return aRunId < bRunId ? -1 : 1;
        });

      const combinedTaskRunInserts: TaskRunInsertArray[] = [];
      const combinedPayloadInserts: PayloadInsertArray[] = [];
      let taskRunError: Error | null = null;
      let payloadError: Error | null = null;

      for (const [clickhouse, group] of groups) {
        sortTaskRunInserts(group.taskRunInserts);
        sortPayloadInserts(group.payloadInserts);
        combinedTaskRunInserts.push(...group.taskRunInserts);
        combinedPayloadInserts.push(...group.payloadInserts);

        const [trErr, trOutcome] = await this.#insertWithRetry(
          (attempt) => this.#insertTaskRunInserts(clickhouse, group.taskRunInserts, attempt),
          "task run inserts",
          flushId
        );
        if (trErr && !taskRunError) {
          taskRunError = trErr;
        }

        const [plErr, plOutcome] = await this.#insertWithRetry(
          (attempt) => this.#insertPayloadInserts(clickhouse, group.payloadInserts, attempt),
          "payload inserts",
          flushId
        );
        if (plErr && !payloadError) {
          payloadError = plErr;
        }

        // Only count rows that actually landed in ClickHouse. `kind: "dropped"`
        // means the recovery wrapper bailed (sanitizer no-op or sanitize-retry
        // still failed) — those rows never made it, so they must not show up
        // as successful inserts in the per-batch counter.
        if (!trErr && trOutcome?.kind !== "dropped") {
          this._taskRunsInsertedCounter.add(group.taskRunInserts.length);
        }
        if (!plErr && plOutcome?.kind !== "dropped") {
          this._payloadsInsertedCounter.add(group.payloadInserts.length);
        }
      }

      span.setAttribute("task_run_inserts", combinedTaskRunInserts.length);
      span.setAttribute("payload_inserts", combinedPayloadInserts.length);

      this.logger.debug("Flushing inserts", {
        flushId,
        taskRunInserts: combinedTaskRunInserts.length,
        payloadInserts: combinedPayloadInserts.length,
        clickhouseGroups: groups.size,
      });

      if (taskRunError) {
        this.logger.error("Error inserting task run inserts", {
          error: taskRunError,
          flushId,
        });
        recordSpanError(span, taskRunError);
      }

      if (payloadError) {
        this.logger.error("Error inserting payload inserts", {
          error: payloadError,
          flushId,
        });
        recordSpanError(span, payloadError);
      }

      this.logger.debug("Flushed inserts", {
        flushId,
        taskRunInserts: combinedTaskRunInserts.length,
        payloadInserts: combinedPayloadInserts.length,
      });

      this.events.emit("batchFlushed", {
        flushId,
        taskRunInserts: combinedTaskRunInserts,
        payloadInserts: combinedPayloadInserts,
      });

      const flushDurationMs = performance.now() - flushStartTime;
      const hasErrors = taskRunError !== null || payloadError !== null;

      this._batchSizeHistogram.record(batch.length);
      this._flushDurationHistogram.record(flushDurationMs);
      this._batchesFlushedCounter.add(1, { success: !hasErrors });
    });
  }

  async #insertWithRetry<T>(
    insertFn: (attempt: number) => Promise<T>,
    operationName: string,
    flushId: string
  ): Promise<[Error | null, T | null]> {
    let lastError: Error | null = null;

    for (let attempt = 1; attempt <= this._insertMaxRetries; attempt++) {
      try {
        const result = await insertFn(attempt);
        return [null, result];
      } catch (error) {
        lastError = error instanceof Error ? error : new Error(String(error));

        // Check if this is a retryable error
        if (this.#isRetryableError(lastError)) {
          const delay = this.#calculateRetryDelay(attempt);

          this.logger.warn(`Retrying RunReplication insert due to error`, {
            operationName,
            flushId,
            attempt,
            maxRetries: this._insertMaxRetries,
            error: lastError.message,
            delay,
          });

          // Record retry metric
          const operation = operationName.includes("task run") ? "task_runs" : "payloads";
          this._insertRetriesCounter.add(1, { operation });

          await new Promise((resolve) => setTimeout(resolve, delay));
          continue;
        }
        break;
      }
    }

    return [lastError, null];
  }

  // Retry all errors except known permanent ones
  #isRetryableError(error: Error): boolean {
    const errorMessage = error.message.toLowerCase();

    // Permanent errors that should NOT be retried
    const permanentErrorPatterns = [
      "authentication failed",
      "permission denied",
      "invalid credentials",
      "table not found",
      "database not found",
      "column not found",
      "schema mismatch",
      "invalid query",
      "syntax error",
      "type error",
      "constraint violation",
      "duplicate key",
      "foreign key violation",
    ];

    // If it's a known permanent error, don't retry
    if (permanentErrorPatterns.some((pattern) => errorMessage.includes(pattern))) {
      return false;
    }

    // Retry everything else
    return true;
  }

  #calculateRetryDelay(attempt: number): number {
    // Exponential backoff: baseDelay, baseDelay*2, baseDelay*4, etc.
    const delay = Math.min(
      this._insertBaseDelayMs * Math.pow(2, attempt - 1),
      this._insertMaxDelayMs
    );

    // Add some jitter to prevent thundering herd
    const jitter = Math.random() * 100;
    return delay + jitter;
  }

  #getClickhouseInsertSettings() {
    if (this._insertStrategy === "insert") {
      return {};
    }

    return {
      async_insert: 1 as const,
      async_insert_max_data_size: "1000000",
      async_insert_busy_timeout_ms: 1000,
      wait_for_async_insert: this.options.waitForAsyncInsert ? (1 as const) : (0 as const),
    };
  }

  async #insertTaskRunInserts(
    clickhouse: ClickHouse,
    taskRunInserts: TaskRunInsertArray[],
    attempt: number
  ) {
    if (taskRunInserts.length === 0) {
      return;
    }
    return await startSpan(this._tracer, "insertTaskRunsInserts", async (span) => {
      const doInsert = async () => {
        const [insertError, insertResult] = await clickhouse.taskRuns.insertCompactArrays(
          taskRunInserts,
          { params: { clickhouse_settings: this.#getClickhouseInsertSettings() } }
        );
        if (insertError) {
          this.logger.error("Error inserting task run inserts attempt", {
            error: insertError,
            attempt,
          });
          recordSpanError(span, insertError);
          throw insertError;
        }
        return insertResult;
      };

      return await this.#insertWithJsonParseRecovery(
        taskRunInserts,
        doInsert,
        "task_runs_v2",
        attempt
      );
    });
  }

  async #insertPayloadInserts(
    clickhouse: ClickHouse,
    payloadInserts: PayloadInsertArray[],
    attempt: number
  ) {
    if (payloadInserts.length === 0) {
      return;
    }
    return await startSpan(this._tracer, "insertPayloadInserts", async (span) => {
      const doInsert = async () => {
        const [insertError, insertResult] = await clickhouse.taskRuns.insertPayloadsCompactArrays(
          payloadInserts,
          { params: { clickhouse_settings: this.#getClickhouseInsertSettings() } }
        );
        if (insertError) {
          this.logger.error("Error inserting payload inserts attempt", {
            error: insertError,
            attempt,
          });
          recordSpanError(span, insertError);
          throw insertError;
        }
        return insertResult;
      };

      return await this.#insertWithJsonParseRecovery(
        payloadInserts,
        doInsert,
        "raw_task_runs_payload_v1",
        attempt
      );
    });
  }

  /**
   * Wraps a ClickHouse insert with reactive UTF-16 sanitization for
   * `Cannot parse JSON object` rejections. Mirrors the pattern from
   * `ClickhouseEventRepository.#insertWithJsonParseRecovery` introduced
   * in #3659 — same root cause (lone UTF-16 surrogates in user-provided
   * JSON), same recovery shape:
   *
   *   1. Try the insert. Healthy batches pay zero scan cost.
   *   2. On parse error, walk the whole batch via `sanitizeRows` and
   *      replace any lone-surrogate string with `"[invalid-utf16]"`.
   *   3. Retry once. If the sanitizer found nothing or the retry also
   *      fails with the same error class, drop the batch loudly and
   *      return — do NOT rethrow, otherwise the surrounding
   *      `#insertWithRetry` layer would spin three more times on the
   *      same deterministic failure.
   *   4. Non-parse errors propagate unchanged so the existing
   *      transient-retry path still handles them.
   *
   * The whole-batch scan (rather than slicing on the `at row N` hint) is
   * deliberate: `at row N` semantics under `input_format_parallel_parsing`
   * aren't stable enough to safely skip rows. The cost is bounded because
   * `detectBadJsonStrings` exits in O(1) for clean strings.
   */
  async #insertWithJsonParseRecovery<T extends object>(
    rows: T[],
    doInsert: () => Promise<unknown>,
    contextLabel: string,
    attempt: number
  ): Promise<
    | { kind: "inserted"; insertResult: unknown }
    | { kind: "sanitized"; insertResult: unknown }
    | { kind: "dropped" }
  > {
    try {
      return { kind: "inserted", insertResult: await doInsert() };
    } catch (firstError) {
      if (!isClickHouseJsonParseError(firstError)) throw firstError;

      const firstMessage =
        typeof firstError === "object" && firstError !== null && "message" in firstError
          ? String((firstError as { message?: unknown }).message ?? "")
          : String(firstError);

      const rowHint = parseRowNumberFromError(firstMessage);
      const { rowsTouched, fieldsSanitized } = sanitizeRows(rows);

      if (fieldsSanitized === 0) {
        this._permanentlyDroppedBatches += 1;
        this.logger.error(
          "Dropped batch — ClickHouse JSON parse error but sanitizer found nothing to fix",
          {
            contextLabel,
            attempt,
            batchSize: rows.length,
            clickhouseRowHint: rowHint,
            permanentlyDroppedBatches: this._permanentlyDroppedBatches,
            sampleRow: JSON.stringify(rows[0] ?? null).slice(0, 1024),
            clickhouseError: firstMessage.split("\n")[0],
          }
        );
        return { kind: "dropped" };
      }

      this.logger.warn("Sanitizing batch after ClickHouse JSON parse error", {
        contextLabel,
        attempt,
        batchSize: rows.length,
        clickhouseRowHint: rowHint,
        rowsTouched,
        fieldsSanitized,
        clickhouseError: firstMessage.split("\n")[0],
      });

      try {
        return { kind: "sanitized", insertResult: await doInsert() };
      } catch (retryError) {
        if (!isClickHouseJsonParseError(retryError)) throw retryError;

        this._permanentlyDroppedBatches += 1;
        const retryMessage =
          typeof retryError === "object" && retryError !== null && "message" in retryError
            ? String((retryError as { message?: unknown }).message ?? "")
            : String(retryError);
        this.logger.error(
          "Dropped batch after sanitize-retry still hit ClickHouse JSON parse error",
          {
            contextLabel,
            attempt,
            batchSize: rows.length,
            permanentlyDroppedBatches: this._permanentlyDroppedBatches,
            sampleRow: JSON.stringify(rows[0] ?? null).slice(0, 1024),
            firstError: firstMessage.split("\n")[0],
            retryError: retryMessage.split("\n")[0],
          }
        );
        return { kind: "dropped" };
      }
    }
  }

  async #prepareRunInserts(
    batchedRun: TaskRunInsert
  ): Promise<{ taskRunInsert?: TaskRunInsertArray; payloadInsert?: PayloadInsertArray }> {
    this.logger.debug("Preparing run", {
      batchedRun,
    });

    const { run, _version, event } = batchedRun;

    if (!run.environmentType || !run.organizationId) {
      return {};
    }

    if (event === "update" || event === "delete" || this._disablePayloadInsert) {
      const taskRunInsert = await this.#prepareTaskRunInsert(
        run,
        run.organizationId,
        run.environmentType,
        event,
        _version
      );

      return { taskRunInsert };
    }

    const [taskRunInsert, payloadInsert] = await Promise.all([
      this.#prepareTaskRunInsert(run, run.organizationId, run.environmentType, event, _version),
      this.#preparePayloadInsert(run, _version),
    ]);

    return { taskRunInsert, payloadInsert };
  }

  async #prepareTaskRunInsert(
    run: PostgresTaskRun,
    organizationId: string,
    environmentType: string,
    event: "insert" | "update" | "delete",
    _version: bigint
  ): Promise<TaskRunInsertArray> {
    const output = await this.#prepareJson(run.output, run.outputType);
    const errorData = { data: run.error };

    // Calculate error fingerprint for failed runs
    const errorFingerprint =
      !this._disableErrorFingerprinting &&
      ["SYSTEM_FAILURE", "CRASHED", "INTERRUPTED", "COMPLETED_WITH_ERRORS", "TIMED_OUT"].includes(
        run.status
      )
        ? calculateErrorFingerprint(run.error)
        : "";

    const annotations = this.#parseAnnotations(run.annotations);

    // Return array matching TASK_RUN_COLUMNS order
    return [
      run.runtimeEnvironmentId, // environment_id
      organizationId, // organization_id
      run.projectId, // project_id
      run.id, // run_id
      run.updatedAt.getTime(), // updated_at
      run.createdAt.getTime(), // created_at
      run.status, // status
      environmentType, // environment_type
      run.friendlyId, // friendly_id
      run.attemptNumber ?? 1, // attempt
      run.engine, // engine
      run.taskIdentifier, // task_identifier
      run.queue, // queue
      run.scheduleId ?? "", // schedule_id
      run.batchId ?? "", // batch_id
      run.completedAt?.getTime() ?? null, // completed_at
      run.startedAt?.getTime() ?? null, // started_at
      run.executedAt?.getTime() ?? null, // executed_at
      run.delayUntil?.getTime() ?? null, // delay_until
      run.queuedAt?.getTime() ?? null, // queued_at
      run.expiredAt?.getTime() ?? null, // expired_at
      run.usageDurationMs ?? 0, // usage_duration_ms
      run.costInCents ?? 0, // cost_in_cents
      run.baseCostInCents ?? 0, // base_cost_in_cents
      output, // output
      errorData, // error
      errorFingerprint, // error_fingerprint
      run.runTags ?? [], // tags
      run.taskVersion ?? "", // task_version
      run.sdkVersion ?? "", // sdk_version
      run.cliVersion ?? "", // cli_version
      run.machinePreset ?? "", // machine_preset
      run.rootTaskRunId ?? "", // root_run_id
      run.parentTaskRunId ?? "", // parent_run_id
      run.depth ?? 0, // depth
      run.spanId, // span_id
      run.traceId, // trace_id
      run.idempotencyKey ?? "", // idempotency_key
      unsafeExtractIdempotencyKeyUser(run) ?? "", // idempotency_key_user
      unsafeExtractIdempotencyKeyScope(run) ?? "", // idempotency_key_scope
      run.ttl ?? "", // expiration_ttl
      run.isTest ?? false, // is_test
      _version.toString(), // _version
      event === "delete" ? 1 : 0, // _is_deleted
      run.concurrencyKey ?? "", // concurrency_key
      run.bulkActionGroupIds ?? [], // bulk_action_group_ids
      baseWorkerQueue(run.masterQueue ?? ""), // worker_queue (raw - operators slice by this)
      run.region ?? "", // region (geo for customers)
      run.planType ?? "", // plan_type
      run.maxDurationInSeconds ?? null, // max_duration_in_seconds
      annotations?.triggerSource ?? "", // trigger_source
      annotations?.rootTriggerSource ?? "", // root_trigger_source
      annotations?.taskKind ?? "", // task_kind
      run.isWarmStart ?? null, // is_warm_start
    ];
  }

  #parseAnnotations(annotations: unknown) {
    return RunAnnotations.safeParse(annotations).data;
  }

  async #preparePayloadInsert(run: TaskRun, _version: bigint): Promise<PayloadInsertArray> {
    const payload = await this.#prepareJson(run.payload, run.payloadType);

    // Return array matching PAYLOAD_COLUMNS order
    return [
      run.id, // run_id
      run.createdAt.getTime(), // created_at
      payload, // payload
    ];
  }

  async #prepareJson(
    data: string | undefined | null,
    dataType: string
  ): Promise<{ data: unknown }> {
    if (!data) {
      return { data: undefined };
    }

    if (dataType !== "application/json" && dataType !== "application/super+json") {
      return { data: undefined };
    }

    if (detectBadJsonStrings(data)) {
      this.logger.warn("Detected bad JSON strings", {
        data,
        dataType,
      });
      return { data: undefined };
    }

    const packet = {
      data,
      dataType,
    };

    const [parseError, parsedData] = await tryCatch(parsePacketAsJson(packet));

    if (parseError) {
      this.logger.error("Error parsing packet", {
        error: parseError,
        packet,
      });

      return { data: undefined };
    }

    return { data: parsedData };
  }
}

export type ConcurrentFlushSchedulerConfig<T> = {
  batchSize: number;
  flushInterval: number;
  maxConcurrency?: number;
  callback: (flushId: string, batch: T[]) => Promise<void>;
  /** Key-based deduplication. Return null to skip the item. */
  getKey: (item: T) => string | null;
  /** Determine if incoming item should replace existing. */
  shouldReplace: (existing: T, incoming: T) => boolean;
  tracer?: Tracer;
  logger?: Logger;
};

export class ConcurrentFlushScheduler<T> {
  private batch = new Map<string, T>();
  private readonly BATCH_SIZE: number;
  private readonly flushInterval: number;
  private readonly MAX_CONCURRENCY: number;
  private readonly concurrencyLimiter: ReturnType<typeof pLimit>;
  private flushTimer: NodeJS.Timeout | null;
  private failedBatchCount;
  private logger: Logger;
  private _tracer: Tracer;
  private _isShutDown = false;

  constructor(private readonly config: ConcurrentFlushSchedulerConfig<T>) {
    this.logger = config.logger ?? new Logger("ConcurrentFlushScheduler", "info");
    this._tracer = config.tracer ?? trace.getTracer("concurrent-flush-scheduler");

    this.BATCH_SIZE = config.batchSize;
    this.flushInterval = config.flushInterval;
    this.MAX_CONCURRENCY = config.maxConcurrency || 1;
    this.concurrencyLimiter = pLimit(this.MAX_CONCURRENCY);
    this.flushTimer = null;
    this.failedBatchCount = 0;
  }

  addToBatch(items: T[]): void {
    for (const item of items) {
      const key = this.config.getKey(item);
      if (key === null) {
        continue;
      }

      const existing = this.batch.get(key);
      if (!existing || this.config.shouldReplace(existing, item)) {
        this.batch.set(key, item);
      }
    }

    this.#flushNextBatchIfNeeded();
  }

  start(): void {
    this.logger.info("Starting ConcurrentFlushScheduler", {
      batchSize: this.BATCH_SIZE,
      flushInterval: this.flushInterval,
      maxConcurrency: this.MAX_CONCURRENCY,
    });

    this.#startFlushTimer();
  }

  shutdown(): void {
    this.logger.info("Shutting down ConcurrentFlushScheduler");

    this._isShutDown = true;

    this.#clearTimer();
    this.#flushNextBatchIfNeeded();
  }

  #getBatchSize(): number {
    return this.batch.size;
  }

  #flushNextBatchIfNeeded(): void {
    const currentSize = this.#getBatchSize();
    if (currentSize >= this.BATCH_SIZE || this._isShutDown) {
      this.logger.debug("Batch size threshold reached, initiating flush", {
        batchSize: this.BATCH_SIZE,
        currentSize,
        isShutDown: this._isShutDown,
      });

      this.#flushNextBatch().catch((error) => {
        this.logger.error("Error flushing next batch", {
          error,
        });
      });
    }
  }

  #startFlushTimer(): void {
    this.flushTimer = setInterval(() => this.#checkAndFlush().catch(() => {}), this.flushInterval);
    this.logger.debug("Started flush timer", { interval: this.flushInterval });
  }

  #clearTimer(): void {
    if (this.flushTimer) {
      clearInterval(this.flushTimer);
      this.logger.debug("Flush timer cleared");
    }
  }

  async #checkAndFlush(): Promise<void> {
    const currentSize = this.#getBatchSize();
    if (currentSize > 0) {
      this.logger.debug("Periodic flush check triggered", {
        currentBatchSize: currentSize,
      });
      await this.#flushNextBatch();
    }
  }

  async #flushNextBatch(): Promise<void> {
    if (this.batch.size === 0) return;

    const batch = Array.from(this.batch.values());
    this.batch.clear();

    const callback = this.config.callback;

    const promise = this.concurrencyLimiter(async () => {
      return await startSpan(this._tracer, "flushNextBatch", async (span) => {
        const batchId = nanoid();

        span.setAttribute("batch_id", batchId);
        span.setAttribute("batch_size", batch.length);
        span.setAttribute("concurrency_active_count", this.concurrencyLimiter.activeCount);
        span.setAttribute("concurrency_pending_count", this.concurrencyLimiter.pendingCount);
        span.setAttribute("concurrency_concurrency", this.concurrencyLimiter.concurrency);

        this.logger.info("flush_next_batch", {
          batchId,
          batchSize: batch.length,
          concurrencyActiveCount: this.concurrencyLimiter.activeCount,
          concurrencyPendingCount: this.concurrencyLimiter.pendingCount,
          concurrencyConcurrency: this.concurrencyLimiter.concurrency,
        });

        const start = performance.now();

        await callback(batchId, batch);

        const end = performance.now();

        const duration = end - start;

        return {
          batchId,
          duration,
        };
      });
    });

    const [error, result] = await tryCatch(promise);

    if (error) {
      this.logger.error("flush_batch_error", {
        error,
      });

      this.failedBatchCount++;
    } else {
      this.logger.info("flush_batch_complete", {
        totalBatches: 1,
        successfulBatches: 1,
        failedBatches: 0,
        totalFailedBatches: this.failedBatchCount,
        duration: result?.duration,
        batchId: result?.batchId,
      });
    }
  }
}

function lsnToUInt64(lsn: string): bigint {
  const [seg, off] = lsn.split("/");
  return (BigInt("0x" + seg) << 32n) | BigInt("0x" + off);
}
