import {
  type Prisma,
  type PrismaClient,
  type PrismaClientOrTransaction,
} from "@trigger.dev/database";
import type { RunStore } from "@internal/run-store";
import { BoundedTtlCache } from "./boundedTtlCache";
import { RESERVED_COLUMNS, type RealtimeRunRow } from "./electricStreamProtocol.server";

/**
 * RunReader — the pluggable read half of the native-backend realtime feed: ClickHouse is filter-only
 * (resolves ids), Postgres always hydrates row columns. Owns the `RunHydrator` (by-id) and the
 * `RunListResolver` interface (the tag/list filter -> id-set seam, implemented over ClickHouse).
 */

/** The TaskRun columns the realtime feed projects (mirrors DEFAULT_ELECTRIC_COLUMNS). */
export const RUN_HYDRATOR_SELECT = {
  id: true,
  taskIdentifier: true,
  createdAt: true,
  updatedAt: true,
  startedAt: true,
  delayUntil: true,
  queuedAt: true,
  expiredAt: true,
  completedAt: true,
  friendlyId: true,
  number: true,
  isTest: true,
  status: true,
  usageDurationMs: true,
  costInCents: true,
  baseCostInCents: true,
  ttl: true,
  payload: true,
  payloadType: true,
  metadata: true,
  metadataType: true,
  output: true,
  outputType: true,
  runTags: true,
  error: true,
  realtimeStreams: true,
} satisfies Prisma.TaskRunSelect;

/** Columns hydrated regardless of `skipColumns`: `id` keys the row, `updatedAt` drives the offset and working-set diff. */
const ALWAYS_HYDRATED_COLUMNS = new Set<string>(["id", "updatedAt", ...RESERVED_COLUMNS]);

/** Project `RUN_HYDRATOR_SELECT` down to the columns the client didn't skip (plus
 * the always-needed ones). An empty skip set returns the full select unchanged. */
export function buildHydratorSelect(skipColumns: string[] = []): Prisma.TaskRunSelect {
  if (skipColumns.length === 0) {
    return RUN_HYDRATOR_SELECT;
  }
  const skip = new Set(skipColumns);
  const select: Record<string, boolean> = {};
  for (const column of Object.keys(RUN_HYDRATOR_SELECT)) {
    if (ALWAYS_HYDRATED_COLUMNS.has(column) || !skip.has(column)) {
      select[column] = true;
    }
  }
  return select as Prisma.TaskRunSelect;
}

export type RunListFilter = {
  organizationId: string;
  projectId: string;
  environmentId: string;
  /** Contains-ANY tag match (OR). Omit/empty for non-tag feeds. */
  tags?: string[];
  /** Restrict to a single batch (internal batch id) — the batch feed. */
  batchId?: string;
  /** Lower bound on createdAt (the tag-list feed pins this; batch omits it). */
  createdAtAfter?: Date;
  /** Hard cap on the result set so a broad filter can't unbound the snapshot. */
  limit: number;
};

/** Resolves a tag/list filter into the matching run id-set, filter-only (rows hydrated from Postgres by id afterward). ClickHouse impl in `clickHouseRunListResolver.server.ts`. */
export interface RunListResolver {
  resolveMatchingRunIds(filter: RunListFilter): Promise<string[]>;
}

export type RunHydratorOptions = {
  /** A read-replica Prisma client (`$replica`). Always Postgres. */
  replica: Pick<PrismaClient, "taskRun">;
  /** RunStore the reads are routed through; `replica` is passed as the read client. */
  runStore: RunStore;
  /** Read-through cache TTL (ms) collapsing duplicate refetches for the same run. Set 0 to disable. Defaults to 250ms. */
  cacheTtlMs?: number;
  /** Hard cap on cache entries before expired entries are swept. */
  maxCacheEntries?: number;
};

const DEFAULT_CACHE_TTL_MS = 250;
const DEFAULT_MAX_CACHE_ENTRIES = 5_000;

/** Hydrates runs by id from the read replica, projected to the realtime columns; concurrent same-run refetches are single-flighted + short-TTL cached. */
export class RunHydrator {
  readonly #inflight = new Map<string, Promise<RealtimeRunRow | null>>();
  readonly #cache: BoundedTtlCache<RealtimeRunRow | null>;
  readonly #cacheTtlMs: number;

  constructor(private readonly options: RunHydratorOptions) {
    this.#cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
    this.#cache = new BoundedTtlCache(
      this.#cacheTtlMs,
      options.maxCacheEntries ?? DEFAULT_MAX_CACHE_ENTRIES
    );
  }

  async getRunById(environmentId: string, runId: string): Promise<RealtimeRunRow | null> {
    const key = `${environmentId}:${runId}`;

    if (this.#cacheTtlMs > 0) {
      // A cached null is a valid "run not found" hit; only undefined is a miss.
      const cached = this.#cache.get(key);
      if (cached !== undefined) {
        return cached;
      }
    }

    const existing = this.#inflight.get(key);
    if (existing) {
      return existing;
    }

    const promise = this.#fetch(environmentId, runId).finally(() => this.#inflight.delete(key));
    this.#inflight.set(key, promise);

    const row = await promise;

    if (this.#cacheTtlMs > 0) {
      this.#cache.set(key, row);
    }

    return row;
  }

  /** Hydrate many runs by id in one query (order not guaranteed); `skipColumns` projects the SELECT so dropped columns aren't shipped. */
  async hydrateByIds(
    environmentId: string,
    ids: string[],
    skipColumns: string[] = []
  ): Promise<RealtimeRunRow[]> {
    if (ids.length === 0) {
      return [];
    }
    const rows = await this.options.runStore.findRuns(
      {
        where: {
          runtimeEnvironmentId: environmentId,
          id: { in: ids },
        },
        select: buildHydratorSelect(skipColumns),
      },
      this.options.replica as PrismaClientOrTransaction
    );
    return rows as unknown as RealtimeRunRow[];
  }

  async #fetch(environmentId: string, runId: string): Promise<RealtimeRunRow | null> {
    const run = await this.options.runStore.findRun(
      {
        id: runId,
        runtimeEnvironmentId: environmentId,
      },
      { select: RUN_HYDRATOR_SELECT },
      this.options.replica as PrismaClientOrTransaction
    );

    return (run ?? null) as RealtimeRunRow | null;
  }
}
