import type {
  ClickHouse,
  LlmMetricsV1Input,
  MetricsV1Input,
  TaskEventDetailedSummaryV1Result,
  TaskEventDetailsV1Result,
  TaskEventSummaryV1Result,
  TaskEventV1Input,
  TaskEventV2Input,
} from "@internal/clickhouse";
import type { Attributes, Counter, Meter, Tracer } from "@internal/tracing";
import { getMeter, startSpan, trace } from "@internal/tracing";

import { createJsonErrorObject } from "@trigger.dev/core/v3/errors";
import { serializeTraceparent } from "@trigger.dev/core/v3/isomorphic";
import type {
  AttemptFailedSpanEvent,
  CancellationSpanEvent,
  ExceptionSpanEvent,
  OtherSpanEvent,
  SpanEvents,
  TaskEventStyle,
  TaskRunError,
} from "@trigger.dev/core/v3/schemas";
import {
  isAttemptFailedSpanEvent,
  isCancellationSpanEvent,
  isExceptionSpanEvent,
  PRIMARY_VARIANT,
} from "@trigger.dev/core/v3/schemas";
import { SemanticInternalAttributes } from "@trigger.dev/core/v3/semanticInternalAttributes";
import { unflattenAttributes } from "@trigger.dev/core/v3/utils/flattenAttributes";
import type { TaskEventLevel } from "@trigger.dev/database";
import { logger } from "~/services/logger.server";
import { DynamicFlushScheduler } from "../dynamicFlushScheduler.server";
import { tracePubSub } from "../services/tracePubSub.server";
import type { TaskEventStoreTable } from "../taskEventStore.server";
import {
  calculateDurationFromStart,
  calculateDurationFromStartJsDate,
  convertDateToNanoseconds,
  createExceptionPropertiesFromError,
  extractContextFromCarrier,
  generateDeterministicSpanId,
  generateSpanId,
  generateTraceId,
  getNowInNanoseconds,
  parseEventsField,
  removePrivateProperties,
} from "./common.server";
import type {
  CompleteableTaskRun,
  CreateEventInput,
  EventBuilder,
  IEventRepository,
  RunPreparedEvent,
  SpanDetail,
  SpanDetailedSummary,
  SpanOverride,
  SpanSummary,
  SpanSummaryCommon,
  StreamedTraceEvent,
  TraceAttributes,
  TraceDetailedSummary,
  TraceEventOptions,
  TraceSummary,
} from "./eventRepository.types";
import {
  isClickHouseJsonParseError,
  parseRowNumberFromError,
  sanitizeRows,
} from "./sanitizeRowsOnParseError.server";

export type ClickhouseEventRepositoryConfig = {
  clickhouse: ClickHouse;
  batchSize?: number;
  flushInterval?: number;
  insertStrategy?: "insert" | "insert_async";
  waitForAsyncInsert?: boolean;
  asyncInsertMaxDataSize?: number;
  asyncInsertBusyTimeoutMs?: number;
  tracer?: Tracer;
  maximumTraceSummaryViewCount?: number;
  maximumTraceDetailedSummaryViewCount?: number;
  maximumLiveReloadingSetting?: number;
  /**
   * Maximum age in milliseconds for start_time. If start_time is older than this threshold,
   * it will be clamped to the current time when creating events.
   * If not provided, no clamping will be done.
   */
  startTimeMaxAgeMs?: number;
  /**
   * The version of the ClickHouse task_events table to use.
   * - "v1": Uses task_events_v1 (partitioned by start_time)
   * - "v2": Uses task_events_v2 (partitioned by inserted_at to avoid "too many parts" errors)
   */
  version?: "v1" | "v2";
  /** LLM metrics flush scheduler config */
  llmMetricsBatchSize?: number;
  llmMetricsFlushInterval?: number;
  llmMetricsMaxBatchSize?: number;
  llmMetricsMaxConcurrency?: number;
  /** OTLP / task metrics_v1 flush scheduler config */
  otlpMetricsBatchSize?: number;
  otlpMetricsFlushInterval?: number;
  otlpMetricsMaxConcurrency?: number;
  /** Inject a meter for self-observability; defaults to the global provider. */
  meter?: Meter;
};

/**
 * ClickHouse-based implementation of the EventRepository.
 * This implementation stores events in ClickHouse for better analytics and performance.
 */
export class ClickhouseEventRepository implements IEventRepository {
  private _clickhouse: ClickHouse;
  private _config: ClickhouseEventRepositoryConfig;
  private readonly _flushScheduler: DynamicFlushScheduler<TaskEventV1Input | TaskEventV2Input>;
  private readonly _llmMetricsFlushScheduler: DynamicFlushScheduler<LlmMetricsV1Input>;
  private readonly _otlpMetricsFlushScheduler: DynamicFlushScheduler<MetricsV1Input>;
  private _tracer: Tracer;
  private _version: "v1" | "v2";
  /**
   * Counts batches that hit a ClickHouse JSON parse failure that survived
   * one sanitize-retry. These batches are dropped on the floor (the scheduler
   * is told the flush "succeeded" so its queue counter doesn't leak), and we
   * track the drop count for observability.
   */
  private _permanentlyDroppedBatches = 0;
  private readonly _droppedBatchesCounter: Counter;

  constructor(config: ClickhouseEventRepositoryConfig) {
    this._clickhouse = config.clickhouse;
    this._config = config;
    this._tracer = config.tracer ?? trace.getTracer("clickhouseEventRepo", "0.0.1");
    this._version = config.version ?? "v1";

    const meter = config.meter ?? getMeter("ingest-flush");
    this._droppedBatchesCounter = meter.createCounter("ingest.flush.batches_dropped", {
      description: "Batches permanently dropped after an unrecoverable ClickHouse JSON parse error",
      unit: "batches",
    });

    this._flushScheduler = new DynamicFlushScheduler({
      name: `task_events_${this._version}`,
      batchSize: config.batchSize ?? 1000,
      flushInterval: config.flushInterval ?? 1000,
      callback: this.#flushBatch.bind(this),
      minConcurrency: 1,
      maxConcurrency: 10,
      maxBatchSize: 10000,
      memoryPressureThreshold: 10000,
      loadSheddingThreshold: 10000,
      loadSheddingEnabled: false,
      isDroppableEvent: (event: TaskEventV1Input | TaskEventV2Input) => {
        // Only drop LOG events during load shedding
        return event.kind === "DEBUG_EVENT";
      },
    });

    this._llmMetricsFlushScheduler = new DynamicFlushScheduler({
      name: "llm_metrics",
      batchSize: config.llmMetricsBatchSize ?? 5000,
      flushInterval: config.llmMetricsFlushInterval ?? 2000,
      callback: this.#flushLlmMetricsBatch.bind(this),
      minConcurrency: 1,
      maxConcurrency: config.llmMetricsMaxConcurrency ?? 2,
      maxBatchSize: config.llmMetricsMaxBatchSize ?? 10000,
      memoryPressureThreshold: config.llmMetricsMaxBatchSize ?? 10000,
      loadSheddingEnabled: false,
    });

    this._otlpMetricsFlushScheduler = new DynamicFlushScheduler({
      name: "otlp_metrics",
      batchSize: config.otlpMetricsBatchSize ?? 10000,
      flushInterval: config.otlpMetricsFlushInterval ?? 1000,
      callback: this.#flushOtelMetricsBatch.bind(this),
      minConcurrency: 1,
      maxConcurrency: config.otlpMetricsMaxConcurrency ?? 3,
      loadSheddingEnabled: false,
    });
  }

  get version() {
    return this._version;
  }

  get maximumLiveReloadingSetting() {
    return this._config.maximumLiveReloadingSetting ?? 1000;
  }

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

  /**
   * Clamps a start time (in nanoseconds) to now if it's too far in the past.
   * Returns the clamped value as a bigint.
   */
  #clampStartTimeNanoseconds(startTimeNs: bigint): bigint {
    if (!this._config.startTimeMaxAgeMs) {
      return startTimeNs;
    }

    const nowNs = getNowInNanoseconds();
    const maxAgeNs = BigInt(this._config.startTimeMaxAgeMs) * 1_000_000n; // ms to ns
    const minAllowedStartTime = nowNs - maxAgeNs;

    if (startTimeNs < minAllowedStartTime) {
      return nowNs;
    }

    return startTimeNs;
  }

  /**
   * Clamps a start time string (nanoseconds as string) to now if it's too far in the past.
   * Returns the formatted string for ClickHouse.
   */
  #clampAndFormatStartTime(startTimeNsString: string): string {
    const startTimeNs = BigInt(startTimeNsString);
    const clampedNs = this.#clampStartTimeNanoseconds(startTimeNs);
    return formatClickhouseDate64NanosecondsEpochString(clampedNs.toString());
  }

  /**
   * Clamps a Date start time to now if it's too far in the past.
   */
  #clampStartTimeDate(startTime: Date): Date {
    if (!this._config.startTimeMaxAgeMs) {
      return startTime;
    }

    const now = new Date();
    const minAllowedStartTime = new Date(now.getTime() - this._config.startTimeMaxAgeMs);

    if (startTime < minAllowedStartTime) {
      return now;
    }

    return startTime;
  }

  async #flushBatch(flushId: string, events: (TaskEventV1Input | TaskEventV2Input)[]) {
    await startSpan(this._tracer, "flushBatch", async (span) => {
      span.setAttribute("flush_id", flushId);
      span.setAttribute("event_count", events.length);
      span.setAttribute("version", this._version);

      const firstEvent = events[0];

      if (firstEvent) {
        logger.debug("ClickhouseEventRepository.flushBatch first event", {
          event: firstEvent,
          version: this._version,
        });
      }

      const insertFn =
        this._version === "v2"
          ? this._clickhouse.taskEventsV2.insert
          : this._clickhouse.taskEvents.insert;

      const doInsert = async () => {
        const [insertError, insertResult] = await insertFn(events, {
          params: {
            clickhouse_settings: this.#getClickhouseInsertSettings(),
          },
        });
        if (insertError) throw insertError;
        return insertResult;
      };

      const outcome = await this.#insertWithJsonParseRecovery(
        flushId,
        events,
        doInsert,
        `task_events_${this._version}`
      );

      if (outcome.kind === "dropped") {
        // Loud log already emitted; nothing landed in ClickHouse — don't publish to Redis.
        return;
      }

      logger.debug("ClickhouseEventRepository.flushBatch Inserted batch into clickhouse", {
        events: events.length,
        insertResult: outcome.insertResult,
        sanitized: outcome.kind === "sanitized",
        version: this._version,
      });

      this.#publishToRedis(events);
    });
  }

  async #flushLlmMetricsBatch(flushId: string, rows: LlmMetricsV1Input[]) {
    const doInsert = async () => {
      const [insertError, insertResult] = await this._clickhouse.llmMetrics.insert(rows, {
        params: {
          clickhouse_settings: this.#getClickhouseInsertSettings(),
        },
      });
      if (insertError) throw insertError;
      return insertResult;
    };

    const outcome = await this.#insertWithJsonParseRecovery(
      flushId,
      rows,
      doInsert,
      "llm_metrics_v1"
    );

    if (outcome.kind === "dropped") {
      return;
    }

    logger.debug("ClickhouseEventRepository.flushLlmMetricsBatch Inserted LLM metrics batch", {
      rows: rows.length,
      sanitized: outcome.kind === "sanitized",
    });
  }

  /**
   * Wraps a ClickHouse insert callable with reactive UTF-16 sanitization.
   *
   * On a `Cannot parse JSON object` failure:
   *   1. Sanitize the batch from `max(0, parsedRowN - 1)` onwards (rows
   *      before the failing one parsed fine — known good).
   *   2. Retry the insert once with the sanitized batch.
   *   3. If the retry still fails with the same error class, log loudly,
   *      increment `permanentlyDroppedBatches`, and return without
   *      throwing — the scheduler's transient-retry path would just repeat
   *      the same deterministic failure.
   *
   * Non-parse errors propagate unchanged so the scheduler's existing
   * backoff/retry behaviour still handles transient network or CH issues.
   */
  async #insertWithJsonParseRecovery<T extends object>(
    flushId: string,
    rows: T[],
    doInsert: () => Promise<unknown>,
    contextLabel: string
  ): 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);

      // Sanitize the whole batch. ClickHouse's `at row N` index is logged
      // for observability but not used to slice — its semantics under
      // parallel parsing are not stable enough to safely skip rows.
      const rowHint = parseRowNumberFromError(firstMessage);
      const { rowsTouched, fieldsSanitized } = sanitizeRows(rows);

      // Sanitizer found nothing to fix → retrying the exact same batch is
      // guaranteed to hit the same deterministic parse failure. Skip the
      // wasted ClickHouse round-trip and drop loudly. Throwing instead would
      // hand the failure back to the scheduler's 3× transient-retry loop —
      // exactly the retry storm this wrapper is designed to avoid.
      if (fieldsSanitized === 0) {
        this._permanentlyDroppedBatches += 1;
        this._droppedBatchesCounter.add(1, { table: contextLabel });
        logger.error(
          "Dropped batch — ClickHouse JSON parse error but sanitizer found nothing to fix",
          {
            flushId,
            contextLabel,
            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" };
      }

      logger.warn("Sanitizing batch after ClickHouse JSON parse error", {
        flushId,
        contextLabel,
        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;
        this._droppedBatchesCounter.add(1, { table: contextLabel });
        const retryMessage =
          typeof retryError === "object" && retryError !== null && "message" in retryError
            ? String((retryError as { message?: unknown }).message ?? "")
            : String(retryError);
        logger.error("Dropped batch after sanitize-retry still hit ClickHouse JSON parse error", {
          flushId,
          contextLabel,
          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 #flushOtelMetricsBatch(flushId: string, rows: MetricsV1Input[]) {
    await startSpan(this._tracer, "flushOtelMetricsBatch", async (span) => {
      span.setAttribute("flush_id", flushId);
      span.setAttribute("row_count", rows.length);

      const [insertError] = await this._clickhouse.metrics.insert(rows, {
        params: {
          clickhouse_settings: this.#getClickhouseInsertSettings(),
        },
      });

      if (insertError) {
        throw insertError;
      }

      logger.debug("ClickhouseEventRepository.flushOtelMetricsBatch Inserted OTLP metrics batch", {
        rows: rows.length,
      });
    });
  }

  #createLlmMetricsInput(event: CreateEventInput): LlmMetricsV1Input {
    const llmMetrics = event._llmMetrics!;

    return {
      organization_id: event.organizationId,
      project_id: event.projectId,
      environment_id: event.environmentId,
      run_id: event.runId,
      task_identifier: event.taskSlug,
      trace_id: event.traceId,
      span_id: event.spanId,
      gen_ai_system: llmMetrics.genAiSystem,
      request_model: llmMetrics.requestModel,
      response_model: llmMetrics.responseModel,
      base_response_model: llmMetrics.baseResponseModel,
      matched_model_id: llmMetrics.matchedModelId,
      operation_id: llmMetrics.operationId,
      finish_reason: llmMetrics.finishReason,
      cost_source: llmMetrics.costSource,
      pricing_tier_id: llmMetrics.pricingTierId,
      pricing_tier_name: llmMetrics.pricingTierName,
      input_tokens: llmMetrics.inputTokens,
      output_tokens: llmMetrics.outputTokens,
      total_tokens: llmMetrics.totalTokens,
      usage_details: llmMetrics.usageDetails,
      input_cost: llmMetrics.inputCost,
      output_cost: llmMetrics.outputCost,
      total_cost: llmMetrics.totalCost,
      cost_details: llmMetrics.costDetails,
      provider_cost: llmMetrics.providerCost,
      ms_to_first_chunk: llmMetrics.msToFirstChunk,
      tokens_per_second: llmMetrics.tokensPerSecond,
      metadata: llmMetrics.metadata,
      prompt_slug: llmMetrics.promptSlug,
      prompt_version: llmMetrics.promptVersion,
      start_time: this.#clampAndFormatStartTime(event.startTime.toString()),
      duration: formatClickhouseUnsignedIntegerString(event.duration ?? 0),
    };
  }

  #getClickhouseInsertSettings() {
    if (this._config.insertStrategy === "insert") {
      return {};
    } else {
      return {
        async_insert: 1 as const,
        async_insert_max_data_size: this._config.asyncInsertMaxDataSize?.toString() ?? "10485760",
        async_insert_busy_timeout_ms: this._config.asyncInsertBusyTimeoutMs ?? 5000,
        wait_for_async_insert: this._config.waitForAsyncInsert ? (1 as const) : (0 as const),
      };
    }
  }

  async #publishToRedis(events: (TaskEventV1Input | TaskEventV2Input)[]) {
    if (events.length === 0) return;
    await tracePubSub.publish(events.map((e) => e.trace_id));
  }

  insertMany(events: CreateEventInput[]): void {
    this.addToBatch(events.flatMap((event) => this.createEventToTaskEventV1Input(event)));

    // Dual-write LLM metrics records for spans with cost enrichment
    const llmMetricsRows = events
      .filter((e) => e._llmMetrics != null)
      .map((e) => this.#createLlmMetricsInput(e));

    if (llmMetricsRows.length > 0) {
      this._llmMetricsFlushScheduler.addToBatch(llmMetricsRows);
    }
  }

  async insertManyImmediate(events: CreateEventInput[]): Promise<void> {
    this.insertMany(events);
  }

  insertManyMetrics(rows: MetricsV1Input[]): void {
    if (rows.length === 0) return;
    this._otlpMetricsFlushScheduler.addToBatch(rows);
  }

  private createEventToTaskEventV1Input(event: CreateEventInput): TaskEventV1Input[] {
    return [
      {
        environment_id: event.environmentId,
        organization_id: event.organizationId,
        project_id: event.projectId,
        task_identifier: event.taskSlug,
        run_id: event.runId,
        start_time: this.#clampAndFormatStartTime(event.startTime.toString()),
        duration: formatClickhouseUnsignedIntegerString(event.duration ?? 0),
        trace_id: event.traceId,
        span_id: event.spanId,
        parent_span_id: event.parentId ?? "",
        message: event.message,
        kind: this.createEventToTaskEventV1InputKind(event),
        status: this.createEventToTaskEventV1InputStatus(event),
        attributes: this.createEventToTaskEventV1InputAttributes(
          event.properties,
          event.resourceProperties
        ),
        metadata: this.createEventToTaskEventV1InputMetadata(event),
        expires_at: convertDateToClickhouseDateTime(
          new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year
        ),
        machine_id: event.machineId ?? "",
      },
      ...this.spanEventsToTaskEventV1Input(event),
    ];
  }

  private spanEventsToTaskEventV1Input(event: CreateEventInput): TaskEventV1Input[] {
    if (!event.events) return [];

    const spanEvents = parseEventsField(event.events);

    const records = spanEvents.map((e) => this.createTaskEventV1InputFromSpanEvent(e, event));

    if (event.isPartial) {
      return records;
    }

    // Only return events where the event start_time is greater than the span start_time
    return records.filter(
      (r) =>
        convertClickhouseDate64NanosecondsEpochStringToBigInt(r.start_time) >
        BigInt(event.startTime)
    );
  }

  private createTaskEventV1InputFromSpanEvent(
    spanEvent: SpanEvents[number],
    event: CreateEventInput
  ): TaskEventV1Input {
    if (isExceptionSpanEvent(spanEvent)) {
      return this.createTaskEventV1InputFromExceptionEvent(spanEvent, event);
    }

    if (isCancellationSpanEvent(spanEvent)) {
      return this.createTaskEventV1InputFromCancellationEvent(spanEvent, event);
    }

    if (isAttemptFailedSpanEvent(spanEvent)) {
      return this.createTaskEventV1InputFromAttemptFailedEvent(spanEvent, event);
    }

    return this.createTaskEventV1InputFromOtherEvent(spanEvent, event);
  }

  private createTaskEventV1InputFromExceptionEvent(
    spanEvent: ExceptionSpanEvent,
    event: CreateEventInput
  ): TaskEventV1Input {
    return {
      environment_id: event.environmentId,
      organization_id: event.organizationId,
      project_id: event.projectId,
      task_identifier: event.taskSlug,
      run_id: event.runId,
      start_time: this.#clampAndFormatStartTime(
        convertDateToNanoseconds(spanEvent.time).toString()
      ),
      duration: "0", // Events have no duration
      trace_id: event.traceId,
      span_id: event.spanId,
      parent_span_id: event.parentId ?? "",
      message: spanEvent.name,
      kind: "SPAN_EVENT",
      status: "ERROR",
      attributes: {
        error: {
          message: spanEvent.properties.exception.message,
          name: spanEvent.properties.exception.type,
          stackTrace: spanEvent.properties.exception.stacktrace,
        },
      },
      metadata: JSON.stringify({
        exception: spanEvent.properties.exception,
      }), // Events have no metadata
      expires_at: convertDateToClickhouseDateTime(
        new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year
      ),
    };
  }

  private createTaskEventV1InputFromCancellationEvent(
    spanEvent: CancellationSpanEvent,
    event: CreateEventInput
  ): TaskEventV1Input {
    return {
      environment_id: event.environmentId,
      organization_id: event.organizationId,
      project_id: event.projectId,
      task_identifier: event.taskSlug,
      run_id: event.runId,
      start_time: this.#clampAndFormatStartTime(
        convertDateToNanoseconds(spanEvent.time).toString()
      ),
      duration: "0", // Events have no duration
      trace_id: event.traceId,
      span_id: event.spanId,
      parent_span_id: event.parentId ?? "",
      message: spanEvent.name,
      kind: "SPAN_EVENT",
      status: "CANCELLED",
      attributes: {},
      metadata: JSON.stringify({
        reason: spanEvent.properties.reason,
      }), // Events have no metadata
      expires_at: convertDateToClickhouseDateTime(
        new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year
      ),
    };
  }

  private createTaskEventV1InputFromAttemptFailedEvent(
    spanEvent: AttemptFailedSpanEvent,
    event: CreateEventInput
  ): TaskEventV1Input {
    return {
      environment_id: event.environmentId,
      organization_id: event.organizationId,
      project_id: event.projectId,
      task_identifier: event.taskSlug,
      run_id: event.runId,
      start_time: this.#clampAndFormatStartTime(
        convertDateToNanoseconds(spanEvent.time).toString()
      ),
      duration: "0", // Events have no duration
      trace_id: event.traceId,
      span_id: event.spanId,
      parent_span_id: event.parentId ?? "",
      message: spanEvent.name,
      kind: "ANCESTOR_OVERRIDE",
      status: "OK",
      attributes: {
        error: {
          message: spanEvent.properties.exception.message,
          name: spanEvent.properties.exception.type,
          stackTrace: spanEvent.properties.exception.stacktrace,
        },
      },
      metadata: JSON.stringify(spanEvent.properties),
      expires_at: convertDateToClickhouseDateTime(
        new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year
      ),
    };
  }

  private createTaskEventV1InputFromOtherEvent(
    spanEvent: OtherSpanEvent,
    event: CreateEventInput
  ): TaskEventV1Input {
    return {
      environment_id: event.environmentId,
      organization_id: event.organizationId,
      project_id: event.projectId,
      task_identifier: event.taskSlug,
      run_id: event.runId,
      start_time: this.#clampAndFormatStartTime(
        convertDateToNanoseconds(spanEvent.time).toString()
      ),
      duration: "0", // Events have no duration
      trace_id: event.traceId,
      span_id: event.spanId,
      parent_span_id: event.parentId ?? "",
      message: spanEvent.name,
      kind: "SPAN_EVENT",
      status: "OK",
      attributes: {},
      metadata: JSON.stringify(unflattenAttributes(spanEvent.properties as Attributes)),
      expires_at: convertDateToClickhouseDateTime(
        new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year
      ),
    };
  }

  private createEventToTaskEventV1InputKind(event: CreateEventInput): string {
    if (event.kind === "UNSPECIFIED") {
      return "ANCESTOR_OVERRIDE";
    }

    if (event.level === "TRACE") {
      return "SPAN";
    }

    if (event.isDebug) {
      return "DEBUG_EVENT";
    }

    return `LOG_${(event.level ?? "LOG").toUpperCase()}`;
  }

  private createEventToTaskEventV1InputStatus(event: CreateEventInput): string {
    if (event.isPartial) {
      return "PARTIAL";
    }

    if (event.isError) {
      return "ERROR";
    }

    if (event.isCancelled) {
      return "CANCELLED";
    }

    return "OK";
  }

  private createEventToTaskEventV1InputAttributes(
    attributes: Attributes,
    resourceAttributes?: Attributes
  ): Record<string, unknown> {
    if (!attributes && !resourceAttributes) {
      return {};
    }

    return {
      ...this.createAttributesToInputAttributes(attributes),
      ...this.createAttributesToInputAttributes(resourceAttributes, "$resource"),
    };
  }

  private createAttributesToInputAttributes(
    attributes: Attributes | undefined,
    key?: string
  ): Record<string, unknown> {
    if (!attributes) {
      return {};
    }

    const publicAttributes = removePrivateProperties(attributes);

    if (!publicAttributes) {
      return {};
    }

    const unflattenedAttributes = unflattenAttributes(publicAttributes);

    if (unflattenedAttributes && typeof unflattenedAttributes === "object") {
      if (key) {
        return {
          [key]: unflattenedAttributes,
        };
      }

      return {
        ...unflattenedAttributes,
      };
    }

    return {};
  }

  private createEventToTaskEventV1InputMetadata(event: CreateEventInput): string {
    return JSON.stringify({
      style: event.style ? unflattenAttributes(event.style) : undefined,
      attemptNumber: event.attemptNumber,
      entity: this.extractEntityFromAttributes(event.properties),
    });
  }

  private extractEntityFromAttributes(
    attributes: Attributes
  ): { entityType: string; entityId?: string; entityMetadata?: string } | undefined {
    if (!attributes || typeof attributes !== "object") {
      return undefined;
    }

    const entityType = attributes[SemanticInternalAttributes.ENTITY_TYPE];
    const entityId = attributes[SemanticInternalAttributes.ENTITY_ID];
    const entityMetadata = attributes[SemanticInternalAttributes.ENTITY_METADATA];

    if (typeof entityType !== "string") {
      return undefined;
    }

    return {
      entityType,
      entityId: entityId as string | undefined,
      entityMetadata: entityMetadata as string | undefined,
    };
  }

  private addToBatch(events: TaskEventV1Input[] | TaskEventV1Input) {
    this._flushScheduler.addToBatch(Array.isArray(events) ? events : [events]);
  }

  // Event recording methods
  async recordEvent(
    message: string,
    options: TraceEventOptions & { duration?: number; parentId?: string }
  ): Promise<void> {
    const propagatedContext = extractContextFromCarrier(options.context ?? {});

    const startTime = options.startTime ?? getNowInNanoseconds();
    const duration =
      options.duration ??
      (options.endTime
        ? calculateDurationFromStart(startTime, options.endTime, 100 * 1_000_000)
        : 100);

    const traceId = propagatedContext?.traceparent?.traceId ?? generateTraceId();
    const parentId = options.parentId ?? propagatedContext?.traceparent?.spanId;
    const spanId = options.spanIdSeed
      ? generateDeterministicSpanId(traceId, options.spanIdSeed)
      : generateSpanId();

    if (!options.attributes.runId) {
      throw new Error("runId is required");
    }

    const kind = options.attributes.isDebug ? "DEBUG_EVENT" : "SPAN";

    const metadata = {
      style: {
        icon: options.attributes.isDebug ? "warn" : "play",
      },
      ...options.attributes.metadata,
    };

    const event: TaskEventV1Input = {
      environment_id: options.environment.id,
      organization_id: options.environment.organizationId,
      project_id: options.environment.projectId,
      task_identifier: options.taskSlug,
      run_id: options.attributes.runId,
      start_time: this.#clampAndFormatStartTime(startTime.toString()),
      duration: formatClickhouseUnsignedIntegerString(duration),
      trace_id: traceId,
      span_id: spanId,
      parent_span_id: parentId ?? "",
      message,
      kind,
      status: "OK",
      attributes: options.attributes.properties
        ? this.createEventToTaskEventV1InputAttributes(options.attributes.properties)
        : undefined,
      metadata: JSON.stringify(metadata),
      // TODO: make sure configurable and by org
      expires_at: convertDateToClickhouseDateTime(new Date(Date.now() + 365 * 24 * 60 * 60 * 1000)),
    };

    this._flushScheduler.addToBatch([event]);
  }

  async traceEvent<TResult>(
    message: string,
    options: TraceEventOptions & { incomplete?: boolean; isError?: boolean },
    callback: (
      e: EventBuilder,
      traceContext: Record<string, string | undefined>,
      traceparent?: { traceId: string; spanId: string }
    ) => Promise<TResult>
  ): Promise<TResult> {
    const propagatedContext = extractContextFromCarrier(options.context ?? {});

    const start = process.hrtime.bigint();
    const startTime = options.startTime ?? getNowInNanoseconds();

    const traceId = options.spanParentAsLink
      ? generateTraceId()
      : (propagatedContext?.traceparent?.traceId ?? generateTraceId());
    const parentId = options.spanParentAsLink ? undefined : propagatedContext?.traceparent?.spanId;
    const spanId = options.spanIdSeed
      ? generateDeterministicSpanId(traceId, options.spanIdSeed)
      : generateSpanId();

    const traceContext = {
      ...options.context,
      traceparent: serializeTraceparent(traceId, spanId),
    };

    let isStopped = false;
    let failedWithError: TaskRunError | undefined;

    const eventBuilder = {
      traceId,
      spanId,
      setAttribute: (key: keyof TraceAttributes, value: TraceAttributes[keyof TraceAttributes]) => {
        if (value) {
          // We need to merge the attributes with the existing attributes
          const existingValue = options.attributes[key];

          if (existingValue && typeof existingValue === "object" && typeof value === "object") {
            // @ts-ignore
            options.attributes[key] = { ...existingValue, ...value };
          } else {
            // @ts-ignore
            options.attributes[key] = value;
          }
        }
      },
      stop: () => {
        isStopped = true;
      },
      failWithError: (error: TaskRunError) => {
        failedWithError = error;
      },
    };

    const result = await callback(eventBuilder, traceContext, propagatedContext?.traceparent);

    if (isStopped) {
      return result;
    }

    const duration = process.hrtime.bigint() - start;

    if (!options.attributes.runId) {
      throw new Error("runId is required");
    }

    const metadata = {
      style: {
        icon: "task",
        variant: PRIMARY_VARIANT,
        ...options.attributes.style,
      },
      ...options.attributes.metadata,
    };

    const event: TaskEventV1Input = {
      environment_id: options.environment.id,
      organization_id: options.environment.organizationId,
      project_id: options.environment.projectId,
      task_identifier: options.taskSlug,
      run_id: options.attributes.runId,
      start_time: this.#clampAndFormatStartTime(startTime.toString()),
      duration: formatClickhouseUnsignedIntegerString(options.incomplete ? 0 : duration),
      trace_id: traceId,
      span_id: spanId,
      parent_span_id: parentId ?? "",
      message,
      kind: "SPAN",
      status: failedWithError ? "ERROR" : options.incomplete ? "PARTIAL" : "OK",
      attributes: options.attributes.properties
        ? this.createEventToTaskEventV1InputAttributes(options.attributes.properties)
        : {},
      metadata: JSON.stringify(metadata),
      // TODO: make sure configurable and by org
      expires_at: convertDateToClickhouseDateTime(new Date(Date.now() + 365 * 24 * 60 * 60 * 1000)),
    };

    const events = [event];

    if (failedWithError) {
      const error = createJsonErrorObject(failedWithError);

      events.push({
        environment_id: options.environment.id,
        organization_id: options.environment.organizationId,
        project_id: options.environment.projectId,
        task_identifier: options.taskSlug,
        run_id: options.attributes.runId,
        start_time: this.#clampAndFormatStartTime(startTime.toString()),
        duration: formatClickhouseUnsignedIntegerString(options.incomplete ? 0 : duration),
        trace_id: traceId,
        span_id: spanId,
        parent_span_id: parentId ?? "",
        message: "exception",
        kind: "SPAN_EVENT",
        status: "ERROR",
        attributes: {
          error,
        },
        metadata: JSON.stringify({
          exception: createExceptionPropertiesFromError(failedWithError),
        }),
        // TODO: make sure configurable and by org
        expires_at: convertDateToClickhouseDateTime(
          new Date(Date.now() + 365 * 24 * 60 * 60 * 1000)
        ),
      });
    }

    this._flushScheduler.addToBatch(events);

    return result;
  }

  // Run event completion methods
  async completeSuccessfulRunEvent({
    run,
    endTime,
  }: {
    run: CompleteableTaskRun;
    endTime?: Date;
  }): Promise<void> {
    if (!run.organizationId) {
      return;
    }

    const clampedCreatedAt = this.#clampStartTimeDate(run.createdAt);
    const startTime = convertDateToNanoseconds(clampedCreatedAt);
    const expiresAt = convertDateToClickhouseDateTime(
      new Date(run.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000)
    );

    const event: TaskEventV1Input = {
      environment_id: run.runtimeEnvironmentId,
      organization_id: run.organizationId,
      project_id: run.projectId,
      task_identifier: run.taskIdentifier,
      run_id: run.friendlyId,
      start_time: formatClickhouseDate64NanosecondsEpochString(startTime.toString()),
      duration: formatClickhouseUnsignedIntegerString(
        calculateDurationFromStart(startTime, endTime ?? new Date())
      ),
      trace_id: run.traceId,
      span_id: run.spanId,
      parent_span_id: run.parentSpanId ?? "",
      message: run.taskIdentifier,
      kind: "SPAN",
      status: "OK",
      attributes: {},
      metadata: "{}",
      expires_at: expiresAt,
    };

    this.addToBatch(event);
  }

  async completeCachedRunEvent({
    run,
    blockedRun,
    spanId,
    parentSpanId,
    spanCreatedAt,
    isError,
    endTime,
  }: {
    run: CompleteableTaskRun;
    blockedRun: CompleteableTaskRun;
    spanId: string;
    parentSpanId: string;
    spanCreatedAt: Date;
    isError: boolean;
    endTime?: Date;
  }): Promise<void> {
    if (!run.organizationId) {
      return;
    }

    const clampedSpanCreatedAt = this.#clampStartTimeDate(spanCreatedAt);
    const startTime = convertDateToNanoseconds(clampedSpanCreatedAt);
    const expiresAt = convertDateToClickhouseDateTime(
      new Date(run.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000)
    );

    const event: TaskEventV1Input = {
      environment_id: run.runtimeEnvironmentId,
      organization_id: run.organizationId,
      project_id: run.projectId,
      task_identifier: run.taskIdentifier,
      run_id: blockedRun.friendlyId,
      start_time: formatClickhouseDate64NanosecondsEpochString(startTime.toString()),
      duration: formatClickhouseUnsignedIntegerString(
        calculateDurationFromStart(startTime, endTime ?? new Date())
      ),
      trace_id: blockedRun.traceId,
      span_id: spanId,
      parent_span_id: parentSpanId,
      message: run.taskIdentifier,
      kind: "SPAN",
      status: isError ? "ERROR" : "OK",
      attributes: {},
      metadata: "{}",
      expires_at: expiresAt,
    };

    this.addToBatch(event);
  }

  async completeFailedRunEvent({
    run,
    endTime,
    exception,
  }: {
    run: CompleteableTaskRun;
    endTime?: Date;
    exception: { message?: string; type?: string; stacktrace?: string };
  }): Promise<void> {
    if (!run.organizationId) {
      return;
    }

    const clampedCreatedAt = this.#clampStartTimeDate(run.createdAt);
    const startTime = convertDateToNanoseconds(clampedCreatedAt);
    const expiresAt = convertDateToClickhouseDateTime(
      new Date(run.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000)
    );

    const event: TaskEventV1Input = {
      environment_id: run.runtimeEnvironmentId,
      organization_id: run.organizationId,
      project_id: run.projectId,
      task_identifier: run.taskIdentifier,
      run_id: run.friendlyId,
      start_time: formatClickhouseDate64NanosecondsEpochString(startTime.toString()),
      duration: formatClickhouseUnsignedIntegerString(
        calculateDurationFromStart(startTime, endTime ?? new Date())
      ),
      trace_id: run.traceId,
      span_id: run.spanId,
      parent_span_id: run.parentSpanId ?? "",
      message: run.taskIdentifier,
      kind: "SPAN",
      status: "ERROR",
      attributes: {
        error: {
          name: exception.type,
          message: exception.message,
          stackTrace: exception.stacktrace,
        },
      },
      metadata: "{}",
      expires_at: expiresAt,
    };

    this.addToBatch(event);
  }

  async completeExpiredRunEvent({
    run,
    endTime,
    ttl,
  }: {
    run: CompleteableTaskRun;
    endTime?: Date;
    ttl: string;
  }): Promise<void> {
    if (!run.organizationId) {
      return;
    }

    const clampedCreatedAt = this.#clampStartTimeDate(run.createdAt);
    const startTime = convertDateToNanoseconds(clampedCreatedAt);
    const expiresAt = convertDateToClickhouseDateTime(
      new Date(run.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000)
    );

    const event: TaskEventV1Input = {
      environment_id: run.runtimeEnvironmentId,
      organization_id: run.organizationId,
      project_id: run.projectId,
      task_identifier: run.taskIdentifier,
      run_id: run.friendlyId,
      start_time: formatClickhouseDate64NanosecondsEpochString(startTime.toString()),
      duration: formatClickhouseUnsignedIntegerString(
        calculateDurationFromStart(startTime, endTime ?? new Date())
      ),
      trace_id: run.traceId,
      span_id: run.spanId,
      parent_span_id: run.parentSpanId ?? "",
      message: run.taskIdentifier,
      kind: "SPAN",
      status: "ERROR",
      attributes: {
        error: {
          message: `Run expired because the TTL (${ttl}) was reached`,
        },
      },
      metadata: "{}",
      expires_at: expiresAt,
    };

    this.addToBatch(event);
  }

  async createAttemptFailedRunEvent({
    run,
    endTime,
    attemptNumber,
    exception,
  }: {
    run: CompleteableTaskRun;
    endTime?: Date;
    attemptNumber: number;
    exception: { message?: string; type?: string; stacktrace?: string };
  }): Promise<void> {
    if (!run.organizationId) {
      return;
    }

    const clampedEndTime = this.#clampStartTimeDate(endTime ?? new Date());
    const startTime = convertDateToNanoseconds(clampedEndTime);
    const expiresAt = convertDateToClickhouseDateTime(
      new Date(run.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000)
    );

    const event: TaskEventV1Input = {
      environment_id: run.runtimeEnvironmentId,
      organization_id: run.organizationId,
      project_id: run.projectId,
      task_identifier: run.taskIdentifier,
      run_id: run.friendlyId,
      start_time: formatClickhouseDate64NanosecondsEpochString(startTime.toString()),
      duration: "0",
      trace_id: run.traceId,
      span_id: run.spanId,
      parent_span_id: run.parentSpanId ?? "",
      message: "attempt_failed",
      kind: "ANCESTOR_OVERRIDE",
      status: "OK",
      attributes: {},
      metadata: JSON.stringify({
        exception,
        attemptNumber,
        runId: run.friendlyId,
      }),
      expires_at: expiresAt,
    };

    this.addToBatch(event);
  }

  async cancelRunEvent({
    reason,
    run,
    cancelledAt,
  }: {
    reason: string;
    run: CompleteableTaskRun;
    cancelledAt: Date;
  }): Promise<void> {
    if (!run.organizationId) {
      return;
    }

    const clampedCreatedAt = this.#clampStartTimeDate(run.createdAt);
    const startTime = convertDateToNanoseconds(clampedCreatedAt);
    const expiresAt = convertDateToClickhouseDateTime(
      new Date(run.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000)
    );

    const event: TaskEventV1Input = {
      environment_id: run.runtimeEnvironmentId,
      organization_id: run.organizationId,
      project_id: run.projectId,
      task_identifier: run.taskIdentifier,
      run_id: run.friendlyId,
      start_time: formatClickhouseDate64NanosecondsEpochString(startTime.toString()),
      duration: formatClickhouseUnsignedIntegerString(
        calculateDurationFromStart(startTime, cancelledAt)
      ),
      trace_id: run.traceId,
      span_id: run.spanId,
      parent_span_id: run.parentSpanId ?? "",
      message: run.taskIdentifier,
      kind: "SPAN",
      status: "CANCELLED",
      attributes: {},
      metadata: JSON.stringify({
        reason,
      }),
      expires_at: expiresAt,
    };

    this.addToBatch(event);
  }

  // Query methods
  async getTraceSummary(
    storeTable: TaskEventStoreTable,
    environmentId: string,
    traceId: string,
    startCreatedAt: Date,
    endCreatedAt?: Date,
    options?: { includeDebugLogs?: boolean }
  ): Promise<TraceSummary | undefined> {
    const limit = this._config.maximumTraceSummaryViewCount;
    const records = await this.#fetchTraceSummaryRecords({
      environmentId,
      traceId,
      startCreatedAt,
      endCreatedAt,
      options,
      limit,
    });

    if (!records) {
      return;
    }

    const summary = this.#buildTraceSummaryFromRecords(records);
    if (!summary) {
      return;
    }

    return {
      ...summary,
      isTruncated: limit !== undefined && records.length >= limit,
    };
  }

  async getTraceSubtreeSummary(
    storeTable: TaskEventStoreTable,
    environmentId: string,
    traceId: string,
    anchorSpanId: string,
    startCreatedAt: Date,
    endCreatedAt?: Date,
    options?: { includeDebugLogs?: boolean }
  ): Promise<TraceSummary | undefined> {
    const { records, isTruncated, missingAnchor } = await this.#fetchTraceSubtreeRecords({
      environmentId,
      traceId,
      anchorSpanId,
      startCreatedAt,
      endCreatedAt,
      options,
      limit: this._config.maximumTraceSummaryViewCount,
    });

    if (missingAnchor) {
      return;
    }

    const summary = this.#buildTraceSummaryFromRecords(records, {
      rootSpanId: anchorSpanId,
    });

    if (!summary) {
      return;
    }

    return {
      ...summary,
      isTruncated,
    };
  }

  async #fetchTraceSubtreeRecords({
    environmentId,
    traceId,
    anchorSpanId,
    startCreatedAt,
    endCreatedAt,
    options,
    limit: maxRows,
  }: {
    environmentId: string;
    traceId: string;
    anchorSpanId: string;
    startCreatedAt: Date;
    endCreatedAt?: Date;
    options?: { includeDebugLogs?: boolean };
    limit?: number;
  }): Promise<{
    records: TaskEventSummaryV1Result[];
    isTruncated: boolean;
    missingAnchor: boolean;
  }> {
    return this.#collectTraceSubtreeRecords({
      anchorSpanId,
      maxRows,
      // Ancestors are fetched by explicit spanIds and start before the anchor
      // run's time window, so applying startCreatedAt would wrongly exclude them
      // (and with it the cancellation/error overrides they propagate downward).
      fetchAncestor: (batch) =>
        this.#fetchTraceSummaryRecords({
          environmentId,
          traceId,
          skipTimeWindow: true,
          options,
          ...batch,
        }),
      fetchDescendant: (batch) =>
        this.#fetchTraceSummaryRecords({
          environmentId,
          traceId,
          startCreatedAt,
          endCreatedAt,
          options,
          ...batch,
        }),
    });
  }

  async #fetchTraceDetailedSubtreeRecords({
    environmentId,
    traceId,
    anchorSpanId,
    startCreatedAt,
    endCreatedAt,
    options,
    limit: maxRows,
  }: {
    environmentId: string;
    traceId: string;
    anchorSpanId: string;
    startCreatedAt: Date;
    endCreatedAt?: Date;
    options?: { includeDebugLogs?: boolean };
    limit?: number;
  }): Promise<{
    records: TaskEventDetailedSummaryV1Result[];
    isTruncated: boolean;
    missingAnchor: boolean;
  }> {
    return this.#collectTraceSubtreeRecords({
      anchorSpanId,
      maxRows,
      // Ancestors are fetched by explicit spanIds and start before the anchor
      // run's time window, so applying startCreatedAt would wrongly exclude them
      // (and with it the cancellation/error overrides they propagate downward).
      fetchAncestor: (batch) =>
        this.#fetchTraceDetailedSummaryRecords({
          environmentId,
          traceId,
          skipTimeWindow: true,
          options,
          ...batch,
        }),
      fetchDescendant: (batch) =>
        this.#fetchTraceDetailedSummaryRecords({
          environmentId,
          traceId,
          startCreatedAt,
          endCreatedAt,
          options,
          ...batch,
        }),
    });
  }

  async #collectTraceSubtreeRecords<T extends { span_id: string; parent_span_id: string }>({
    anchorSpanId,
    maxRows,
    fetchAncestor,
    fetchDescendant,
  }: {
    anchorSpanId: string;
    maxRows?: number;
    fetchAncestor: (batch: { spanIds: string[]; limit?: number }) => Promise<T[] | undefined>;
    fetchDescendant: (batch: {
      spanIds?: string[];
      parentSpanIds?: string[];
      limit?: number;
    }) => Promise<T[] | undefined>;
  }): Promise<{
    records: T[];
    isTruncated: boolean;
    missingAnchor: boolean;
  }> {
    const allRecords: T[] = [];
    const collectedSpanIds = new Set<string>();
    let isTruncated = false;

    const anchorRecords = await fetchDescendant({
      spanIds: [anchorSpanId],
      limit: maxRows,
    });

    if (!anchorRecords || anchorRecords.length === 0) {
      return { records: [], isTruncated: false, missingAnchor: true };
    }

    if (maxRows && anchorRecords.length >= maxRows) {
      isTruncated = true;
    }

    allRecords.push(...anchorRecords);
    collectedSpanIds.add(anchorSpanId);

    let parentSpanId = this.#parentSpanIdFromRecords(anchorRecords, anchorSpanId);
    while (parentSpanId) {
      if (collectedSpanIds.has(parentSpanId)) {
        break;
      }

      if (maxRows && allRecords.length >= maxRows) {
        isTruncated = true;
        break;
      }

      const parentRecords = await fetchAncestor({
        spanIds: [parentSpanId],
        limit: maxRows ? maxRows - allRecords.length : undefined,
      });

      if (!parentRecords || parentRecords.length === 0) {
        break;
      }

      allRecords.push(...parentRecords);
      collectedSpanIds.add(parentSpanId);
      parentSpanId = this.#parentSpanIdFromRecords(parentRecords, parentSpanId);
    }

    // Walk descendants level-by-level rather than fetching everything after the anchor in one
    // windowed query. parent_span_id isn't in the sort key, so each level rescans roughly the same
    // granules - but trace depth is small in practice and repeated granule reads stay cached. A
    // single broad query would pull every span after the anchor (a superset of the subtree) and
    // make the maxRows cap drop real subtree spans in favour of unrelated ones.
    let frontier = [anchorSpanId];
    while (frontier.length > 0) {
      if (maxRows && allRecords.length >= maxRows) {
        isTruncated = true;
        break;
      }

      const remaining = maxRows ? maxRows - allRecords.length : undefined;
      const childRecords = await fetchDescendant({
        parentSpanIds: frontier,
        limit: remaining,
      });

      if (!childRecords || childRecords.length === 0) {
        break;
      }

      if (remaining !== undefined && childRecords.length >= remaining) {
        isTruncated = true;
      }

      allRecords.push(...childRecords);

      const nextFrontier: string[] = [];
      for (const record of childRecords) {
        if (!collectedSpanIds.has(record.span_id)) {
          collectedSpanIds.add(record.span_id);
          nextFrontier.push(record.span_id);
        }
      }

      frontier = nextFrontier;
    }

    return {
      records: allRecords,
      isTruncated,
      missingAnchor: false,
    };
  }

  #parentSpanIdFromRecords(
    records: Array<{ span_id: string; parent_span_id: string }>,
    spanId: string
  ): string | undefined {
    const parentSpanId = records.find((record) => record.span_id === spanId)?.parent_span_id;
    return parentSpanId ? parentSpanId : undefined;
  }

  #createTraceSummaryQueryBuilder() {
    return this._version === "v2"
      ? this._clickhouse.taskEventsV2.traceSummaryQueryBuilder()
      : this._clickhouse.taskEvents.traceSummaryQueryBuilder();
  }

  async #fetchTraceSummaryRecords({
    environmentId,
    traceId,
    startCreatedAt,
    endCreatedAt,
    options,
    spanIds,
    parentSpanIds,
    limit,
    skipTimeWindow,
  }: {
    environmentId: string;
    traceId: string;
    startCreatedAt?: Date;
    endCreatedAt?: Date;
    options?: { includeDebugLogs?: boolean };
    spanIds?: string[];
    parentSpanIds?: string[];
    limit?: number;
    skipTimeWindow?: boolean;
  }): Promise<TaskEventSummaryV1Result[] | undefined> {
    const queryBuilder = this.#createTraceSummaryQueryBuilder();

    queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
    queryBuilder.where("trace_id = {traceId: String}", { traceId });

    if (!skipTimeWindow) {
      if (!startCreatedAt) {
        throw new Error("startCreatedAt is required when skipTimeWindow is false");
      }

      const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 60_000);
      const endCreatedAtWithBuffer = endCreatedAt
        ? new Date(endCreatedAt.getTime() + 60_000)
        : undefined;

      queryBuilder.where("start_time >= {startCreatedAt: String}", {
        startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(),
      });

      if (endCreatedAtWithBuffer) {
        queryBuilder.where("start_time <= {endCreatedAt: String}", {
          endCreatedAt: convertDateToNanoseconds(endCreatedAtWithBuffer).toString(),
        });
      }

      if (this._version === "v2") {
        queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
          insertedAtStart: convertDateToClickhouseDateTime(startCreatedAtWithBuffer),
        });
      }
    }

    if (options?.includeDebugLogs === false) {
      queryBuilder.where("kind != {kind: String}", { kind: "DEBUG_EVENT" });
    }

    if (spanIds && spanIds.length > 0) {
      queryBuilder.where("span_id IN {spanIds: Array(String)}", { spanIds });
    }

    if (parentSpanIds && parentSpanIds.length > 0) {
      queryBuilder.where("parent_span_id IN {parentSpanIds: Array(String)}", { parentSpanIds });
    }

    queryBuilder.orderBy("start_time ASC");

    if (limit) {
      queryBuilder.limit(limit);
    }

    const [queryError, records] = await queryBuilder.execute();

    if (queryError) {
      throw queryError;
    }

    return records;
  }

  #buildTraceSummaryFromRecords(
    records: TaskEventSummaryV1Result[],
    options?: { rootSpanId?: string }
  ): TraceSummary | undefined {
    if (records.length === 0) {
      return;
    }

    const recordsGroupedBySpanId: Record<string, TaskEventSummaryV1Result[]> = {};
    for (const record of records) {
      if (!recordsGroupedBySpanId[record.span_id]) {
        recordsGroupedBySpanId[record.span_id] = [];
      }
      recordsGroupedBySpanId[record.span_id].push(record);
    }

    const spanSummaries = new Map<string, SpanSummary>();
    let rootSpanId: string | undefined = options?.rootSpanId;

    const metadataCache = new Map<string, Record<string, unknown>>();

    for (const [spanId, spanRecords] of Object.entries(recordsGroupedBySpanId)) {
      const spanSummary = this.#mergeRecordsIntoSpanSummary(spanId, spanRecords, metadataCache);

      if (!spanSummary) {
        continue;
      }

      spanSummaries.set(spanId, spanSummary);

      if (!rootSpanId && !spanSummary.parentId) {
        rootSpanId = spanId;
      }
    }

    if (!rootSpanId) {
      return;
    }

    const spans = Array.from(spanSummaries.values());
    const rootSpan = spanSummaries.get(rootSpanId);

    if (!rootSpan) {
      return;
    }

    const overridesBySpanId: Record<string, SpanOverride> = {};

    const finalSpans = spans.map((span) => {
      return this.#applyAncestorOverrides(span, spanSummaries, overridesBySpanId);
    });

    return {
      rootSpan,
      spans: finalSpans,
      overridesBySpanId,
    };
  }

  async getSpan(
    storeTable: TaskEventStoreTable,
    environmentId: string,
    spanId: string,
    traceId: string,
    startCreatedAt: Date,
    endCreatedAt?: Date,
    options?: { includeDebugLogs?: boolean }
  ): Promise<SpanDetail | undefined> {
    const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 60_000);

    const queryBuilder =
      this._version === "v2"
        ? this._clickhouse.taskEventsV2.spanDetailsQueryBuilder()
        : this._clickhouse.taskEvents.spanDetailsQueryBuilder();

    queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
    queryBuilder.where("trace_id = {traceId: String}", { traceId });
    queryBuilder.where("span_id = {spanId: String}", { spanId });
    queryBuilder.where("start_time >= {startCreatedAt: String}", {
      startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(),
    });

    if (endCreatedAt) {
      queryBuilder.where("start_time <= {endCreatedAt: String}", {
        endCreatedAt: convertDateToNanoseconds(endCreatedAt).toString(),
      });
    }

    // For v2, add inserted_at filtering for partition pruning
    if (this._version === "v2") {
      queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
        insertedAtStart: convertDateToClickhouseDateTime(startCreatedAtWithBuffer),
      });
    }

    queryBuilder.orderBy("start_time ASC");

    const [queryError, records] = await queryBuilder.execute();

    if (queryError) {
      throw queryError;
    }

    if (!records) {
      return;
    }

    // Create temporary metadata cache for this query
    const metadataCache = new Map<string, Record<string, unknown>>();
    const span = this.#mergeRecordsIntoSpanDetail(spanId, records, metadataCache);

    return span;
  }

  #mergeRecordsIntoSpanDetail(
    spanId: string,
    records: TaskEventDetailsV1Result[],
    metadataCache: Map<string, Record<string, unknown>>
  ): SpanDetail | undefined {
    if (records.length === 0) {
      return undefined;
    }

    let span: SpanDetail | undefined;
    let earliestStartTime: Date | undefined;

    for (const record of records) {
      const recordStartTime = convertClickhouseDateTime64ToJsDate(record.start_time);

      // Track the earliest start time across all records
      if (
        record.kind !== "ANCESTOR_OVERRIDE" &&
        record.kind !== "SPAN_EVENT" &&
        (!earliestStartTime || recordStartTime < earliestStartTime)
      ) {
        earliestStartTime = recordStartTime;
      }

      if (!span) {
        span = {
          spanId: spanId,
          parentId: record.parent_span_id ? record.parent_span_id : null,
          message: record.message,
          isError: false,
          isPartial: true, // Partial by default, can only be set to false
          isCancelled: false,
          level: kindToLevel(record.kind),
          startTime: recordStartTime,
          duration: typeof record.duration === "number" ? record.duration : Number(record.duration),
          events: [],
          style: {},
          properties: undefined,
          resourceProperties: undefined,
          entity: {
            type: undefined,
            id: undefined,
            metadata: undefined,
          },
          metadata: {},
        };
      }

      if (isLogEvent(record.kind)) {
        span.isPartial = false;
        span.isCancelled = false;
        span.isError = record.status === "ERROR";
      }

      const parsedMetadata = this.#parseMetadata(record.metadata, metadataCache);

      if (record.kind === "SPAN_EVENT") {
        // We need to add an event to the span
        span.events.push({
          name: record.message,
          time: recordStartTime,
          properties: parsedMetadata ?? {},
        });
      }

      if (parsedMetadata && "style" in parsedMetadata && parsedMetadata.style) {
        span.style = parsedMetadata.style as TaskEventStyle;
      }

      if (
        parsedMetadata &&
        "entity" in parsedMetadata &&
        typeof parsedMetadata.entity === "object" &&
        parsedMetadata.entity &&
        "entityType" in parsedMetadata.entity &&
        typeof parsedMetadata.entity.entityType === "string" &&
        "entityId" in parsedMetadata.entity &&
        typeof parsedMetadata.entity.entityId === "string"
      ) {
        span.entity = {
          id: parsedMetadata.entity.entityId,
          type: parsedMetadata.entity.entityType,
          metadata:
            "entityMetadata" in parsedMetadata.entity &&
            parsedMetadata.entity.entityMetadata &&
            typeof parsedMetadata.entity.entityMetadata === "string"
              ? parsedMetadata.entity.entityMetadata
              : undefined,
        };
      }

      if (record.kind === "SPAN") {
        // Prefer SPAN record message for span title (task name); SPAN_EVENT "exception" must not override it
        span.message = record.message;
        if (record.status === "ERROR") {
          span.isError = true;
          span.isPartial = false;
          span.isCancelled = false;
        } else if (record.status === "CANCELLED") {
          span.isCancelled = true;
          span.isPartial = false;
          span.isError = false;
        } else if (record.status === "OK") {
          span.isPartial = false;
        }

        if (record.status !== "PARTIAL") {
          span.duration =
            typeof record.duration === "number" ? record.duration : Number(record.duration);
        }
      }

      // Parse attributes from the first record that has them, then re-parse for the
      // completed SPAN record. The completed record's attributes are a superset of the
      // partial's (includes enriched trigger.llm.* cost data added during ingestion).
      // This means at most 2x JSON.parse per span detail query, but only on this
      // read path (span detail view), not on ingestion.
      if (typeof record.attributes_text === "string") {
        const shouldUpdate =
          span.properties == null ||
          (typeof span.properties === "object" && Object.keys(span.properties).length === 0) ||
          (record.kind === "SPAN" && record.status !== "PARTIAL");

        if (shouldUpdate) {
          const parsedAttributes = this.#parseAttributes(record.attributes_text);
          const resourceAttributes = parsedAttributes["$resource"];

          delete parsedAttributes["$resource"];

          span.properties = parsedAttributes;
          span.resourceProperties = resourceAttributes as Record<string, unknown> | undefined;
        }
      }
    }

    // Always use the earliest start time found across all records
    if (span && earliestStartTime) {
      span.startTime = earliestStartTime;
    }

    return span;
  }

  #parseAttributes(attributes_text: string): Record<string, unknown> {
    if (!attributes_text) {
      return {};
    }

    return JSON.parse(attributes_text) as Record<string, unknown>;
  }

  #applyAncestorOverrides<TSpanSummary extends SpanSummaryCommon>(
    span: TSpanSummary,
    spansById: Map<string, TSpanSummary>,
    overridesBySpanId: Record<string, SpanOverride>
  ): TSpanSummary {
    if (span.data.level !== "TRACE") {
      return span;
    }

    if (!span.data.isPartial) {
      return span;
    }

    if (!span.parentId) {
      return span;
    }

    // Now we need to walk the ancestors of the span by span.parentId
    // The first ancestor that is a TRACE span that is "closed" we will use to override the span
    let parentSpanId: string | undefined = span.parentId;
    let overrideSpan: TSpanSummary | undefined;

    while (parentSpanId) {
      const parentSpan = spansById.get(parentSpanId);

      if (!parentSpan) {
        break;
      }

      if (parentSpan.data.level === "TRACE" && !parentSpan.data.isPartial) {
        overrideSpan = parentSpan;
        break;
      }

      parentSpanId = parentSpan.parentId;
    }

    if (overrideSpan) {
      return this.#applyAncestorToSpan(span, overrideSpan, overridesBySpanId);
    }

    return span;
  }

  #applyAncestorToSpan<TSpanSummary extends SpanSummaryCommon>(
    span: TSpanSummary,
    overrideSpan: TSpanSummary,
    overridesBySpanId: Record<string, SpanOverride>
  ): TSpanSummary {
    if (overridesBySpanId[span.id]) {
      return span;
    }

    let override: SpanOverride | undefined = undefined;

    const overrideEndTime = calculateEndTimeFromStartTime(
      overrideSpan.data.startTime,
      overrideSpan.data.duration
    );

    if (overrideSpan.data.isCancelled) {
      override = {
        isCancelled: true,
        duration: calculateDurationFromStartJsDate(span.data.startTime, overrideEndTime),
      };

      span.data.isCancelled = true;
      span.data.isPartial = false;
      span.data.isError = false;
      span.data.duration = calculateDurationFromStartJsDate(span.data.startTime, overrideEndTime);

      const cancellationEvent = overrideSpan.data.events.find(
        (event) => event.name === "cancellation"
      );

      if (cancellationEvent) {
        span.data.events.push(cancellationEvent);
        override.events = [cancellationEvent];
      }
    }

    if (overrideSpan.data.isError && span.data.attemptNumber) {
      const attemptFailedEvent = overrideSpan.data.events.find(
        (event) =>
          event.name === "attempt_failed" &&
          event.properties.attemptNumber === span.data.attemptNumber &&
          event.properties.runId === span.runId
      ) as AttemptFailedSpanEvent | undefined;

      if (attemptFailedEvent) {
        const exceptionEvent = {
          name: "exception",
          time: attemptFailedEvent.time,
          properties: {
            exception: attemptFailedEvent.properties.exception,
          },
        } satisfies ExceptionSpanEvent;

        span.data.isError = true;
        span.data.isPartial = false;
        span.data.isCancelled = false;
        span.data.duration = calculateDurationFromStartJsDate(span.data.startTime, overrideEndTime);
        span.data.events.push(exceptionEvent);
        span.data.events.push(attemptFailedEvent);

        override = {
          isError: true,
          events: [exceptionEvent],
          duration: calculateDurationFromStartJsDate(span.data.startTime, overrideEndTime),
        };
      }
    }

    if (override) {
      overridesBySpanId[span.id] = override;
    }

    return span;
  }

  #mergeRecordsIntoSpanSummary(
    spanId: string,
    records: TaskEventSummaryV1Result[],
    metadataCache: Map<string, Record<string, unknown>>
  ): SpanSummary | undefined {
    if (records.length === 0) {
      return undefined;
    }

    let span: SpanSummary | undefined;
    let earliestStartTime: Date | undefined;

    for (const record of records) {
      const recordStartTime = convertClickhouseDateTime64ToJsDate(record.start_time);

      // Track the earliest start time across all records, except for ancestor overrides and span events
      if (
        record.kind !== "ANCESTOR_OVERRIDE" &&
        record.kind !== "SPAN_EVENT" &&
        (!earliestStartTime || recordStartTime < earliestStartTime)
      ) {
        earliestStartTime = recordStartTime;
      }

      if (!span) {
        span = {
          id: spanId,
          parentId: record.parent_span_id ? record.parent_span_id : undefined,
          runId: record.run_id,
          data: {
            message: record.message,
            style: {},
            duration:
              typeof record.duration === "number" ? record.duration : Number(record.duration),
            isError: false,
            isPartial: true, // Partial by default, can only be set to false
            isCancelled: false,
            isDebug: record.kind === "DEBUG_EVENT",
            startTime: recordStartTime,
            level: kindToLevel(record.kind),
            events: [],
          },
        };
      }

      if (isLogEvent(record.kind)) {
        span.data.isPartial = false;
        span.data.isCancelled = false;
        span.data.isError = record.status === "ERROR";
      }

      const parsedMetadata = this.#parseMetadata(record.metadata, metadataCache);

      if (
        parsedMetadata &&
        "attemptNumber" in parsedMetadata &&
        typeof parsedMetadata.attemptNumber === "number"
      ) {
        span.data.attemptNumber = parsedMetadata.attemptNumber;
      }

      if (record.kind === "ANCESTOR_OVERRIDE" || record.kind === "SPAN_EVENT") {
        // We need to add an event to the span
        span.data.events.push({
          name: record.message,
          time: recordStartTime,
          properties: parsedMetadata ?? {},
        });
      }

      if (parsedMetadata && "style" in parsedMetadata && parsedMetadata.style) {
        const newStyle = parsedMetadata.style as TaskEventStyle;
        // Merge styles: prefer the most complete value for each field
        span.data.style = {
          icon: newStyle.icon ?? span.data.style.icon,
          variant: newStyle.variant ?? span.data.style.variant,
          accessory: newStyle.accessory ?? span.data.style.accessory,
        };
      }

      if (record.kind === "SPAN") {
        // Prefer SPAN record message for span title (task name); SPAN_EVENT "exception" must not override it
        span.data.message = record.message;
        if (record.status === "ERROR") {
          span.data.isError = true;
          span.data.isPartial = false;
          span.data.isCancelled = false;
        } else if (record.status === "CANCELLED") {
          span.data.isCancelled = true;
          span.data.isPartial = false;
          span.data.isError = false;
        } else if (record.status === "OK") {
          span.data.isPartial = false;
        }

        if (record.status !== "PARTIAL") {
          span.data.duration =
            typeof record.duration === "number" ? record.duration : Number(record.duration);
        }
      }
    }

    // Always use the earliest start time found across all records
    if (span && earliestStartTime) {
      span.data.startTime = earliestStartTime;
    }

    return span;
  }

  #parseMetadata(
    metadata: string,
    cache: Map<string, Record<string, unknown>>
  ): Record<string, unknown> | undefined {
    if (!metadata) {
      return undefined;
    }

    // Check cache first
    const cached = cache.get(metadata);
    if (cached) {
      return cached;
    }

    const parsed = JSON.parse(metadata);

    if (typeof parsed !== "object" || parsed === null) {
      return undefined;
    }

    const result = parsed as Record<string, unknown>;

    // Cache the result - no size limit needed since cache is per-query
    cache.set(metadata, result);

    return result;
  }

  #createTraceDetailedSummaryQueryBuilder() {
    return this._version === "v2"
      ? this._clickhouse.taskEventsV2.traceDetailedSummaryQueryBuilder()
      : this._clickhouse.taskEvents.traceDetailedSummaryQueryBuilder();
  }

  async #fetchTraceDetailedSummaryRecords({
    environmentId,
    traceId,
    startCreatedAt,
    endCreatedAt,
    options,
    spanIds,
    parentSpanIds,
    limit,
    skipTimeWindow,
  }: {
    environmentId: string;
    traceId: string;
    startCreatedAt?: Date;
    endCreatedAt?: Date;
    options?: { includeDebugLogs?: boolean };
    spanIds?: string[];
    parentSpanIds?: string[];
    limit?: number;
    skipTimeWindow?: boolean;
  }): Promise<TaskEventDetailedSummaryV1Result[] | undefined> {
    const queryBuilder = this.#createTraceDetailedSummaryQueryBuilder();

    queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
    queryBuilder.where("trace_id = {traceId: String}", { traceId });

    if (!skipTimeWindow) {
      if (!startCreatedAt) {
        throw new Error("startCreatedAt is required when skipTimeWindow is false");
      }

      const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 1000);

      queryBuilder.where("start_time >= {startCreatedAt: String}", {
        startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(),
      });

      if (endCreatedAt) {
        queryBuilder.where("start_time <= {endCreatedAt: String}", {
          endCreatedAt: convertDateToNanoseconds(endCreatedAt).toString(),
        });
      }

      if (this._version === "v2") {
        queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
          insertedAtStart: convertDateToClickhouseDateTime(startCreatedAtWithBuffer),
        });
      }
    }

    if (options?.includeDebugLogs === false) {
      queryBuilder.where("kind != {kind: String}", { kind: "DEBUG_EVENT" });
    }

    if (spanIds && spanIds.length > 0) {
      queryBuilder.where("span_id IN {spanIds: Array(String)}", { spanIds });
    }

    if (parentSpanIds && parentSpanIds.length > 0) {
      queryBuilder.where("parent_span_id IN {parentSpanIds: Array(String)}", { parentSpanIds });
    }

    queryBuilder.orderBy("start_time ASC");

    if (limit) {
      queryBuilder.limit(limit);
    }

    const [queryError, records] = await queryBuilder.execute();

    if (queryError) {
      throw queryError;
    }

    return records;
  }

  #buildTraceDetailedSummaryFromRecords(
    traceId: string,
    records: TaskEventDetailedSummaryV1Result[],
    rootSpanId?: string
  ): TraceDetailedSummary | undefined {
    if (records.length === 0) {
      return;
    }

    const recordsGroupedBySpanId: Record<string, TaskEventDetailedSummaryV1Result[]> = {};
    for (const record of records) {
      if (!recordsGroupedBySpanId[record.span_id]) {
        recordsGroupedBySpanId[record.span_id] = [];
      }
      recordsGroupedBySpanId[record.span_id].push(record);
    }

    const spanSummaries = new Map<string, SpanDetailedSummary>();
    let resolvedRootSpanId: string | undefined = rootSpanId;

    const metadataCache = new Map<string, Record<string, unknown>>();

    for (const [spanId, spanRecords] of Object.entries(recordsGroupedBySpanId)) {
      const spanSummary = this.#mergeRecordsIntoSpanDetailedSummary(
        spanId,
        spanRecords,
        metadataCache
      );

      if (!spanSummary) {
        continue;
      }

      spanSummaries.set(spanId, spanSummary);

      if (!resolvedRootSpanId && !spanSummary.parentId) {
        resolvedRootSpanId = spanId;
      }
    }

    if (!resolvedRootSpanId) {
      return;
    }

    const spans = Array.from(spanSummaries.values());

    const overridesBySpanId: Record<string, SpanOverride> = {};
    const spanDetailedSummaryMap = new Map<string, SpanDetailedSummary>();

    const finalSpans = spans.map((span) => {
      const finalSpan = this.#applyAncestorOverrides(span, spanSummaries, overridesBySpanId);
      spanDetailedSummaryMap.set(span.id, finalSpan);
      return finalSpan;
    });

    for (const finalSpan of finalSpans) {
      if (finalSpan.parentId) {
        const parent = spanDetailedSummaryMap.get(finalSpan.parentId);
        if (parent) {
          parent.children.push(finalSpan);
        }
      }
    }

    const rootSpan = spanDetailedSummaryMap.get(resolvedRootSpanId);

    if (!rootSpan) {
      return;
    }

    return {
      traceId,
      rootSpan,
    };
  }

  async getTraceDetailedSummary(
    storeTable: TaskEventStoreTable,
    environmentId: string,
    traceId: string,
    startCreatedAt: Date,
    endCreatedAt?: Date,
    options?: { includeDebugLogs?: boolean }
  ): Promise<TraceDetailedSummary | undefined> {
    const limit = this._config.maximumTraceDetailedSummaryViewCount;
    const records = await this.#fetchTraceDetailedSummaryRecords({
      environmentId,
      traceId,
      startCreatedAt,
      endCreatedAt,
      options,
      limit,
    });

    if (!records) {
      return;
    }

    const summary = this.#buildTraceDetailedSummaryFromRecords(traceId, records);
    if (!summary) {
      return;
    }

    return {
      ...summary,
      isTruncated: limit !== undefined && records.length >= limit,
    };
  }

  async getTraceDetailedSubtreeSummary(
    storeTable: TaskEventStoreTable,
    environmentId: string,
    traceId: string,
    anchorSpanId: string,
    startCreatedAt: Date,
    endCreatedAt?: Date,
    options?: { includeDebugLogs?: boolean }
  ): Promise<TraceDetailedSummary | undefined> {
    const limit = this._config.maximumTraceDetailedSummaryViewCount;

    // Try one capped full-trace query first so the common case stays at a single
    // round-trip; large traces pay an extra fetch before the subtree walk below.
    const fullRecords = await this.#fetchTraceDetailedSummaryRecords({
      environmentId,
      traceId,
      startCreatedAt,
      endCreatedAt,
      options,
      limit,
    });

    if (fullRecords && this.#canReRootDetailedRecordsAtAnchor(fullRecords, anchorSpanId)) {
      const summary = this.#buildTraceDetailedSummaryFromRecords(
        traceId,
        fullRecords,
        anchorSpanId
      );
      if (summary) {
        return {
          ...summary,
          isTruncated: limit !== undefined && fullRecords.length >= limit,
        };
      }
    }

    const { records, isTruncated, missingAnchor } = await this.#fetchTraceDetailedSubtreeRecords({
      environmentId,
      traceId,
      anchorSpanId,
      startCreatedAt,
      endCreatedAt,
      options,
      limit,
    });

    if (missingAnchor) {
      return;
    }

    const summary = this.#buildTraceDetailedSummaryFromRecords(traceId, records, anchorSpanId);
    if (!summary) {
      return;
    }

    return {
      ...summary,
      isTruncated,
    };
  }

  // Only checks the direct parent — not the full ancestor chain. Safe in practice
  // because ancestors have earlier start_time and usually land inside the cap
  // when the anchor does; otherwise we fall back to the subtree walk.
  #canReRootDetailedRecordsAtAnchor(
    records: Array<{ span_id: string; parent_span_id: string }>,
    anchorSpanId: string
  ): boolean {
    const anchorRecord = records.find((record) => record.span_id === anchorSpanId);
    if (!anchorRecord) {
      return false;
    }

    const parentSpanId = anchorRecord.parent_span_id;
    if (!parentSpanId) {
      return true;
    }

    return records.some((record) => record.span_id === parentSpanId);
  }

  async *streamTraceEvents(
    storeTable: TaskEventStoreTable,
    environmentId: string,
    traceId: string,
    startCreatedAt: Date,
    endCreatedAt?: Date,
    options?: { includeDebugLogs?: boolean }
  ): AsyncIterable<StreamedTraceEvent> {
    const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 1000);

    const queryBuilder =
      this._version === "v2"
        ? this._clickhouse.taskEventsV2.traceEventsForExportQueryBuilder()
        : this._clickhouse.taskEvents.traceEventsForExportQueryBuilder();

    queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
    queryBuilder.where("trace_id = {traceId: String}", { traceId });
    queryBuilder.where("start_time >= {startCreatedAt: String}", {
      startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(),
    });

    if (endCreatedAt) {
      queryBuilder.where("start_time <= {endCreatedAt: String}", {
        endCreatedAt: convertDateToNanoseconds(endCreatedAt).toString(),
      });
    }

    if (this._version === "v2") {
      queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
        insertedAtStart: convertDateToClickhouseDateTime(startCreatedAtWithBuffer),
      });
    }

    // Admin-only debug events stay hidden unless explicitly requested.
    if (options?.includeDebugLogs !== true) {
      queryBuilder.where("kind != {debugKind: String}", { debugKind: "DEBUG_EVENT" });
    }

    // Each span is written twice: a PARTIAL start-marker (empty attributes) and
    // the completed row. Keep only the completed row so the export has one line
    // per span (the tree path merges these; streaming can't, so we filter).
    queryBuilder.where("status != {partialStatus: String}", { partialStatus: "PARTIAL" });

    // Internal trigger.dev span events (start timeline) are uninformative noise
    // in the export; the tree path filters them too. Real span events such as
    // exceptions are kept.
    queryBuilder.where(
      "(kind != {spanEventKind: String} OR NOT startsWith(message, {internalPrefix: String}))",
      { spanEventKind: "SPAN_EVENT", internalPrefix: "trigger.dev/" }
    );

    // ANCESTOR_OVERRIDE rows duplicate a descendant's error onto an ancestor span
    // to colour the tree; they carry no event of their own. The tree path drops
    // them, so the export does too (otherwise the same error shows up twice).
    queryBuilder.where("kind != {ancestorKind: String}", { ancestorKind: "ANCESTOR_OVERRIDE" });

    queryBuilder.orderBy("start_time ASC");
    // Deliberately no LIMIT: streaming never materialises the result set, so the
    // detailed-summary memory cap doesn't apply to the export.

    for await (const row of queryBuilder.executeStream()) {
      yield {
        spanId: row.span_id,
        parentSpanId: row.parent_span_id,
        startTime: convertClickhouseDateTime64ToJsDate(row.start_time),
        durationNs: typeof row.duration === "number" ? row.duration : Number(row.duration),
        level: clickhouseKindToLevel(row.kind),
        message: row.message,
        isError: row.status === "ERROR",
        propertiesText: row.attributes_text ?? "",
      };
    }
  }

  #mergeRecordsIntoSpanDetailedSummary(
    spanId: string,
    records: TaskEventDetailedSummaryV1Result[],
    metadataCache: Map<string, Record<string, unknown>>
  ): SpanDetailedSummary | undefined {
    if (records.length === 0) {
      return undefined;
    }

    let span: SpanDetailedSummary | undefined;
    let earliestStartTime: Date | undefined;

    for (const record of records) {
      const recordStartTime = convertClickhouseDateTime64ToJsDate(record.start_time);

      // Track the earliest start time across all records
      if (
        record.kind !== "ANCESTOR_OVERRIDE" &&
        record.kind !== "SPAN_EVENT" &&
        (!earliestStartTime || recordStartTime < earliestStartTime)
      ) {
        earliestStartTime = recordStartTime;
      }

      if (!span) {
        span = {
          id: spanId,
          parentId: record.parent_span_id ? record.parent_span_id : undefined,
          runId: record.run_id,
          data: {
            message: record.message,
            taskSlug: undefined,
            duration:
              typeof record.duration === "number" ? record.duration : Number(record.duration),
            isError: false,
            isPartial: true, // Partial by default, can only be set to false
            isCancelled: false,
            startTime: recordStartTime,
            level: kindToLevel(record.kind),
            events: [],
          },
          children: [],
        };
      }

      if (isLogEvent(record.kind)) {
        span.data.isPartial = false;
        span.data.isCancelled = false;
        span.data.isError = record.status === "ERROR";
      }

      const parsedMetadata = this.#parseMetadata(record.metadata, metadataCache);

      if (
        parsedMetadata &&
        "attemptNumber" in parsedMetadata &&
        typeof parsedMetadata.attemptNumber === "number"
      ) {
        span.data.attemptNumber = parsedMetadata.attemptNumber;
      }

      if (record.kind === "ANCESTOR_OVERRIDE" || record.kind === "SPAN_EVENT") {
        // We need to add an event to the span
        span.data.events.push({
          name: record.message,
          time: recordStartTime,
          properties: parsedMetadata ?? {},
        });
      }

      if (record.kind === "SPAN") {
        // Prefer SPAN record message for span title (task name); SPAN_EVENT "exception" must not override it
        span.data.message = record.message;
        if (record.status === "ERROR") {
          span.data.isError = true;
          span.data.isPartial = false;
          span.data.isCancelled = false;
        } else if (record.status === "CANCELLED") {
          span.data.isCancelled = true;
          span.data.isPartial = false;
          span.data.isError = false;
        } else if (record.status === "OK") {
          span.data.isPartial = false;
        }

        if (record.status !== "PARTIAL") {
          span.data.duration =
            typeof record.duration === "number" ? record.duration : Number(record.duration);
        }
      }
    }

    // Always use the earliest start time found across all records
    if (span && earliestStartTime) {
      span.data.startTime = earliestStartTime;
    }

    return span;
  }

  async getRunEvents(
    storeTable: TaskEventStoreTable,
    environmentId: string,
    traceId: string,
    runId: string,
    startCreatedAt: Date,
    endCreatedAt?: Date
  ): Promise<RunPreparedEvent[]> {
    const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 1000);

    const queryBuilder =
      this._version === "v2"
        ? this._clickhouse.taskEventsV2.traceSummaryQueryBuilder()
        : this._clickhouse.taskEvents.traceSummaryQueryBuilder();

    queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
    queryBuilder.where("trace_id = {traceId: String}", { traceId });
    queryBuilder.where("run_id = {runId: String}", { runId });
    queryBuilder.where("start_time >= {startCreatedAt: String}", {
      startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(),
    });

    if (endCreatedAt) {
      queryBuilder.where("start_time <= {endCreatedAt: String}", {
        endCreatedAt: convertDateToNanoseconds(endCreatedAt).toString(),
      });
    }

    // For v2, add inserted_at filtering for partition pruning
    if (this._version === "v2") {
      queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
        insertedAtStart: convertDateToClickhouseDateTime(startCreatedAtWithBuffer),
      });
    }

    queryBuilder.where("kind != {kind: String}", { kind: "DEBUG_EVENT" });
    queryBuilder.orderBy("start_time ASC");

    if (this._config.maximumTraceSummaryViewCount) {
      queryBuilder.limit(this._config.maximumTraceSummaryViewCount);
    }

    const [queryError, records] = await queryBuilder.execute();

    if (queryError) {
      throw queryError;
    }

    if (!records) {
      return [];
    }

    // O(n) grouping instead of O(n²) array spreading
    const recordsGroupedBySpanId: Record<string, TaskEventSummaryV1Result[]> = {};
    for (const record of records) {
      if (!recordsGroupedBySpanId[record.span_id]) {
        recordsGroupedBySpanId[record.span_id] = [];
      }
      recordsGroupedBySpanId[record.span_id].push(record);
    }

    const spanSummaries = new Map<string, SpanSummary>();
    let rootSpanId: string | undefined;

    // Create temporary metadata cache for this query
    const metadataCache = new Map<string, Record<string, unknown>>();

    for (const [spanId, spanRecords] of Object.entries(recordsGroupedBySpanId)) {
      const spanSummary = this.#mergeRecordsIntoSpanSummary(spanId, spanRecords, metadataCache);

      if (!spanSummary) {
        continue;
      }

      spanSummaries.set(spanId, spanSummary);

      // Find root span for optimized override algorithm
      if (!rootSpanId && !spanSummary.parentId) {
        rootSpanId = spanId;
      }
    }

    const spans = Array.from(spanSummaries.values());

    const overridesBySpanId: Record<string, SpanOverride> = {};

    const finalSpans = spans.map((span) => {
      return this.#applyAncestorOverrides(span, spanSummaries, overridesBySpanId);
    });

    const runPreparedEvents = finalSpans.map((span) => this.#spanSummaryToRunPreparedEvent(span));

    return runPreparedEvents;
  }

  #spanSummaryToRunPreparedEvent(span: SpanSummary): RunPreparedEvent {
    return {
      spanId: span.id,
      parentId: span.parentId ?? null,
      runId: span.runId,
      message: span.data.message,
      style: span.data.style,
      events: span.data.events,
      startTime: convertDateToNanoseconds(span.data.startTime),
      duration: span.data.duration,
      isError: span.data.isError,
      isPartial: span.data.isPartial,
      isCancelled: span.data.isCancelled,
      kind: "UNSPECIFIED",
      attemptNumber: span.data.attemptNumber ?? null,
      level: span.data.level,
    };
  }
}

// Precompile regex for performance (used ~30k times per trace)
const CLICKHOUSE_DATETIME_REGEX =
  /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(?:Z|([+-])(\d{2}):?(\d{2}))?$/;

export const convertDateToClickhouseDateTime = (date: Date): string => {
  // 2024-11-06T20:37:00.123Z -> 2024-11-06 21:37:00.123
  return date.toISOString().replace("T", " ").replace("Z", "");
};

/**
 * Convert a ClickHouse DateTime64 to nanoseconds since epoch (UTC).
 * Accepts:
 *  - "2025-09-23 12:32:46.130262875"
 *  - "2025-09-23T12:32:46.13"
 *  - "2025-09-23 12:32:46Z"
 *  - "2025-09-23 12:32:46.130262875+02:00"
 */
export function convertClickhouseDateTime64ToNanosecondsEpoch(date: string): bigint {
  const s = date.trim();
  const m = CLICKHOUSE_DATETIME_REGEX.exec(s);
  if (!m) {
    throw new Error(`Invalid ClickHouse DateTime64 string: "${date}"`);
  }

  const year = Number(m[1]);
  const month = Number(m[2]); // 1-12
  const day = Number(m[3]); // 1-31
  const hour = Number(m[4]);
  const minute = Number(m[5]);
  const second = Number(m[6]);
  const fraction = m[7] ?? ""; // up to 9 digits
  const sign = m[8] as "+" | "-" | undefined;
  const offH = m[9] ? Number(m[9]) : 0;
  const offM = m[10] ? Number(m[10]) : 0;

  // Convert fractional seconds to exactly 9 digits (nanoseconds within the second).
  const nsWithinSecond = Number(fraction.padEnd(9, "0")); // 0..999_999_999

  // Split into millisecond part (for Date) and leftover nanoseconds.
  const msPart = Math.trunc(nsWithinSecond / 1_000_000); // 0..999
  const leftoverNs = nsWithinSecond - msPart * 1_000_000; // 0..999_999

  // Build milliseconds since epoch in UTC using Date.UTC (avoids local TZ/DST issues).
  let msEpoch = Date.UTC(year, month - 1, day, hour, minute, second, msPart);

  // If an explicit offset was provided, adjust to true UTC.
  if (sign) {
    const offsetMinutesSigned = (sign === "+" ? 1 : -1) * (offH * 60 + offM);
    msEpoch -= offsetMinutesSigned * 60_000;
  }

  // Combine ms to ns with leftover.
  return BigInt(msEpoch) * 1_000_000n + BigInt(leftoverNs);
}

/**
 * Convert a ClickHouse DateTime64 to a JS Date.
 * Accepts:
 *  - "2025-09-23 12:32:46.130262875"
 *  - "2025-09-23T12:32:46.13"
 *  - "2025-09-23 12:32:46Z"
 *  - "2025-09-23 12:32:46.130262875+02:00"
 *
 * Optimized with fast path for common format (avoids regex for 99% of cases).
 */
// Map a ClickHouse task-event `kind` to a human display level for the streaming
// export (e.g. LOG_INFO -> INFO, SPAN -> TRACE, SPAN_EVENT -> EVENT).
function clickhouseKindToLevel(kind: string): string {
  if (kind.startsWith("LOG_")) return kind.slice(4);
  if (kind === "SPAN") return "TRACE";
  if (kind === "SPAN_EVENT") return "EVENT";
  if (kind === "DEBUG_EVENT") return "DEBUG";
  return kind;
}

export function convertClickhouseDateTime64ToJsDate(date: string): Date {
  // Fast path for common format: "2025-09-23 12:32:46.130262875" or "2025-09-23 12:32:46"
  // This avoids the expensive regex for the common case
  if (date.length >= 19 && date[4] === "-" && date[7] === "-" && date[10] === " ") {
    const year = Number(date.substring(0, 4));
    const month = Number(date.substring(5, 7));
    const day = Number(date.substring(8, 10));
    const hour = Number(date.substring(11, 13));
    const minute = Number(date.substring(14, 16));
    const second = Number(date.substring(17, 19));

    // Parse fractional seconds if present
    let ms = 0;
    if (date.length > 20 && date[19] === ".") {
      // Take first 3 digits after decimal (milliseconds), pad if shorter
      const fracStr = date.substring(20, Math.min(23, date.length));
      ms = Number(fracStr.padEnd(3, "0"));
    }

    return new Date(Date.UTC(year, month - 1, day, hour, minute, second, ms));
  }

  // Fallback to regex for other formats (T separator, timezone offsets, etc.)
  const s = date.trim();
  const m = CLICKHOUSE_DATETIME_REGEX.exec(s);
  if (!m) {
    throw new Error(`Invalid ClickHouse DateTime64 string: "${date}"`);
  }

  const year = Number(m[1]);
  const month = Number(m[2]); // 1-12
  const day = Number(m[3]); // 1-31
  const hour = Number(m[4]);
  const minute = Number(m[5]);
  const second = Number(m[6]);
  const fraction = m[7] ?? ""; // up to 9 digits

  // Convert fractional seconds to exactly 9 digits (nanoseconds within the second).
  const nsWithinSecond = Number(fraction.padEnd(9, "0")); // 0..999_999_999

  // Split into millisecond part (for Date)
  const msPart = Math.trunc(nsWithinSecond / 1_000_000); // 0..999

  return new Date(Date.UTC(year, month - 1, day, hour, minute, second, msPart));
}

function kindToLevel(kind: string): TaskEventLevel {
  switch (kind) {
    case "DEBUG_EVENT":
    case "LOG_DEBUG": {
      return "DEBUG";
    }
    case "LOG_LOG": {
      return "LOG";
    }
    case "LOG_INFO": {
      return "INFO";
    }
    case "LOG_WARN": {
      return "WARN";
    }
    case "LOG_ERROR": {
      return "ERROR";
    }
    case "SPAN":
    case "ANCESTOR_OVERRIDE":
    case "SPAN_EVENT": {
      return "TRACE";
    }
    default: {
      return "TRACE";
    }
  }
}

function isLogEvent(kind: string): boolean {
  return kind.startsWith("LOG_") || kind === "DEBUG_EVENT";
}

function calculateEndTimeFromStartTime(startTime: Date, duration: number): Date {
  return new Date(startTime.getTime() + duration / 1_000_000);
}

// This will take a string like "1759427319944999936" and return "1759427319.944999936"
function formatClickhouseDate64NanosecondsEpochString(date: string): string {
  if (date.length !== 19) {
    return date;
  }

  return date.substring(0, 10) + "." + date.substring(10);
}

function convertClickhouseDate64NanosecondsEpochStringToBigInt(date: string): bigint {
  const parts = date.split(".");
  return BigInt(parts.join(""));
}

function formatClickhouseUnsignedIntegerString(value: number | bigint): string {
  if (value < 0) {
    return "0";
  }

  if (typeof value === "bigint") {
    return value.toString();
  }

  return Math.floor(value).toString();
}
