import type { PrismaClient, Session } from "@trigger.dev/database";
import type { SessionItem } from "@trigger.dev/core/v3";
import type { RunStore } from "@internal/run-store";
import { $replica, prisma } from "~/db.server";
import { runStore as defaultRunStore } from "~/v3/runStore.server";

/**
 * Prefix that {@link SessionId.generate} attaches to every Session friendlyId.
 * Used to distinguish friendlyId lookups (`session_abc...`) from externalId
 * lookups on the public `GET /api/v1/sessions/:session` route.
 */
const SESSION_FRIENDLY_ID_PREFIX = "session_";

/**
 * Resolve a session from a URL path parameter that may contain either a
 * friendlyId (`session_abc...`) or a user-supplied externalId.
 *
 * Disambiguated by prefix: values starting with `session_` are treated as
 * friendlyIds, anything else is looked up against `externalId` scoped to
 * the caller's environment.
 */
// CONTROL-PLANE: `Session` lives on the control-plane DB; these reads are NOT
// routed to run-ops read-through — only the `TaskRun` currentRunId resolves in
// this file are run-ops read-through routed.
export async function resolveSessionByIdOrExternalId(
  prisma: Pick<PrismaClient, "session">,
  runtimeEnvironmentId: string,
  idOrExternalId: string
): Promise<Session | null> {
  if (isSessionFriendlyIdForm(idOrExternalId)) {
    return prisma.session.findFirst({
      where: { friendlyId: idOrExternalId, runtimeEnvironmentId },
    });
  }

  // `findFirst` rather than `findUnique` per the repo rule — `findUnique`'s
  // implicit DataLoader has open correctness bugs in Prisma 6.x that bite
  // hot-path lookups exactly like this one.
  return prisma.session.findFirst({
    where: { runtimeEnvironmentId, externalId: idOrExternalId },
  });
}

/**
 * Replica-first session resolution with a writer fallback on miss. For the
 * hot realtime routes (append / SSE subscribe / end-and-continue): a fresh
 * session's first append or subscribe can arrive inside the replica's apply
 * window, and a bare replica miss there surfaces as a 404 (or a subscribe
 * that never finds its session) for a session that exists on the writer.
 */
export async function resolveSessionWithWriterFallback(
  runtimeEnvironmentId: string,
  idOrExternalId: string
): Promise<Session | null> {
  const row = await resolveSessionByIdOrExternalId($replica, runtimeEnvironmentId, idOrExternalId);
  if (row) return row;
  return resolveSessionByIdOrExternalId(prisma, runtimeEnvironmentId, idOrExternalId);
}

/** True for `session_*` friendlyId form, false for everything else. */
export function isSessionFriendlyIdForm(value: string): boolean {
  return value.startsWith(SESSION_FRIENDLY_ID_PREFIX);
}

/**
 * Canonicalise the addressing key used for everything stream-level: the
 * S2 stream path and the run-engine waitpoint cache key. `chat.agent`
 * and the rest of the operational surface always pass `externalId`, but
 * a public-API caller may legitimately address by `friendlyId` — and a
 * session created without an `externalId` only has a friendlyId at all.
 *
 * Rule:
 *   - If we have a Session row, the canonical key is `externalId` if
 *     set, else `friendlyId`. This way two callers addressing the same
 *     row via different forms always converge to the same S2 stream.
 *   - If we have no row (yet — chat.agent's transport may subscribe
 *     before the agent's bind-time upsert lands), the canonical key is
 *     whatever the URL had. Operationally that's always an externalId.
 *     Friendlyid-form callers without a matching row are rejected by
 *     the route handler before this is reached.
 */
export function canonicalSessionAddressingKey(row: Session | null, paramSession: string): string {
  if (row) {
    return row.externalId ?? row.friendlyId;
  }
  return paramSession;
}

/**
 * Convert a Prisma `Session` row to the public {@link SessionItem} wire format.
 * Strips internal columns (project/environment/organization ids) and narrows
 * the `metadata` JSON to a record.
 *
 * Note: `currentRunId` is left as-is — Prisma stores the internal run id
 * (cuid), but `SessionItem.currentRunId` is the *friendly* form. Routes
 * that emit `SessionItem`s must translate: single-row endpoints via
 * {@link serializeSessionWithFriendlyRunId}, list endpoints via the
 * batched {@link serializeSessionsWithFriendlyRunIds}. Never put this
 * raw form on the wire directly.
 */
export function serializeSession(session: Session): SessionItem {
  return {
    id: session.friendlyId,
    externalId: session.externalId,
    type: session.type,
    taskIdentifier: session.taskIdentifier,
    triggerConfig: session.triggerConfig as SessionItem["triggerConfig"],
    currentRunId: session.currentRunId,
    tags: session.tags,
    metadata: (session.metadata ?? null) as SessionItem["metadata"],
    closedAt: session.closedAt,
    closedReason: session.closedReason,
    expiresAt: session.expiresAt,
    createdAt: session.createdAt,
    updatedAt: session.updatedAt,
  };
}

/**
 * Same as {@link serializeSession} but resolves `currentRunId` from the
 * internal cuid to the public `run_*` friendlyId via a TaskRun lookup.
 * Single-row endpoints (`POST/GET/PATCH/close /api/v1/sessions/:s`) use
 * this so the wire-side `currentRunId` is consistent with the rest of
 * the public API (which only accepts friendlyIds for run lookups).
 *
 * Skips the lookup when `currentRunId` is null.
 *
 * Resolves `currentRunId` -> `friendlyId` through `runStore.findRun` so a
 * run-ops id (NEW-DB) session run resolves from its owning store rather than the
 * control-plane replica. Mirrors `sessionRunManager.server.ts`.
 * Tenant-scoped because `Session.currentRunId` is a no-FK pointer.
 */
export async function serializeSessionWithFriendlyRunId(
  session: Session,
  runStore: RunStore = defaultRunStore
): Promise<SessionItem> {
  const base = serializeSession(session);
  if (!session.currentRunId) return base;

  const run = await runStore.findRun(
    {
      id: session.currentRunId,
      projectId: session.projectId,
      runtimeEnvironmentId: session.runtimeEnvironmentId,
    },
    { select: { friendlyId: true } }
  );

  return {
    ...base,
    currentRunId: run?.friendlyId ?? null,
  };
}

/**
 * Batched form of {@link serializeSessionWithFriendlyRunId} for list
 * endpoints: one `IN` lookup per page instead of N+1. `currentRunId` on
 * the wire is always the public `run_*` friendlyId — the raw
 * {@link serializeSession} form leaks the internal cuid, which customers
 * can't use with `runs.retrieve(...)`.
 */
export async function serializeSessionsWithFriendlyRunIds(
  sessions: Session[],
  scope: { projectId: string; runtimeEnvironmentId: string },
  runStore: RunStore = defaultRunStore
): Promise<SessionItem[]> {
  const runIds = [
    ...new Set(sessions.map((s) => s.currentRunId).filter((id): id is string => !!id)),
  ];

  // `runStore.findRuns` fans out across both stores under split (NEW + LEGACY
  // replica merge) and is a plain `$replica` find when split is off. Tenant-
  // scoped: `Session.currentRunId` is a no-FK pointer, so a stale id must never
  // resolve a run in another env.
  const runs =
    runIds.length > 0
      ? await runStore.findRuns({
          where: {
            id: { in: runIds },
            projectId: scope.projectId,
            runtimeEnvironmentId: scope.runtimeEnvironmentId,
          },
          select: { id: true, friendlyId: true },
        })
      : [];
  const friendlyIdByRunId = new Map(runs.map((run) => [run.id, run.friendlyId]));

  return sessions.map((session) => ({
    ...serializeSession(session),
    currentRunId: session.currentRunId
      ? (friendlyIdByRunId.get(session.currentRunId) ?? null)
      : null,
  }));
}
