import { API_VERSIONS } from "~/api/versions";
import { logger } from "../logger.server";
import {
  type RealtimeEnvironment,
  type RealtimeRequestOptions,
  type RealtimeRunsParams,
} from "../realtimeClient.server";
import { RESERVED_COLUMNS } from "./electricStreamProtocol.server";
import {
  type RealtimeListEnvironment,
  type RealtimeStreamClient,
} from "./nativeRealtimeClient.server";
import { type RunListFilter } from "./runReader.server";
import {
  type RealtimeShadowComparator,
  type ShadowCompareOutcome,
  type ShadowFeed,
} from "./shadowCompare.server";

export type ShadowRealtimeClientOptions = {
  /** The path actually served to the client (Electric). */
  electric: RealtimeStreamClient;
  comparator: RealtimeShadowComparator;
  /** createdAt window (ms) used to resolve tag-list membership for the compare. */
  maximumCreatedAtFilterAgeMs: number;
  /** Cap for the membership resolve. */
  maxListResults: number;
  /** Metrics sink for compare outcomes. */
  onOutcome?: (outcome: ShadowCompareOutcome) => void;
};

/** Transparent wrapper that serves the Electric response unchanged and, in the background (fire-and-forget), diffs what the native backend would emit. */
export class ShadowRealtimeClient implements RealtimeStreamClient {
  constructor(private readonly options: ShadowRealtimeClientOptions) {}

  async streamRun(
    url: URL | string,
    environment: RealtimeEnvironment,
    runId: string,
    apiVersion: API_VERSIONS,
    requestOptions?: RealtimeRequestOptions,
    clientVersion?: string,
    signal?: AbortSignal
  ): Promise<Response> {
    const response = await this.options.electric.streamRun(
      url,
      environment,
      runId,
      apiVersion,
      requestOptions,
      clientVersion,
      signal
    );
    this.#shadow("run", response, url, environment, requestOptions);
    return response;
  }

  async streamRuns(
    url: URL | string,
    environment: RealtimeListEnvironment,
    params: RealtimeRunsParams,
    apiVersion: API_VERSIONS,
    requestOptions?: RealtimeRequestOptions,
    clientVersion?: string,
    signal?: AbortSignal
  ): Promise<Response> {
    const response = await this.options.electric.streamRuns(
      url,
      environment,
      params,
      apiVersion,
      requestOptions,
      clientVersion,
      signal
    );
    this.#shadow("runs", response, url, environment, requestOptions, { tags: params.tags ?? [] });
    return response;
  }

  async streamBatch(
    url: URL | string,
    environment: RealtimeListEnvironment,
    batchId: string,
    apiVersion: API_VERSIONS,
    requestOptions?: RealtimeRequestOptions,
    clientVersion?: string,
    signal?: AbortSignal
  ): Promise<Response> {
    const response = await this.options.electric.streamBatch(
      url,
      environment,
      batchId,
      apiVersion,
      requestOptions,
      clientVersion,
      signal
    );
    this.#shadow("batch", response, url, environment, requestOptions, { batchId });
    return response;
  }

  /** Fire-and-forget; never blocks the served response, never throws into the request. */
  #shadow(
    feed: ShadowFeed,
    electricResponse: Response,
    url: URL | string,
    environment: RealtimeEnvironment & { projectId?: string },
    requestOptions?: RealtimeRequestOptions,
    membership?: { tags?: string[]; batchId?: string }
  ): void {
    // Clone synchronously before the client consumes the body.
    let bodyClone: Response;
    try {
      if (electricResponse.status !== 200) {
        return;
      }
      bodyClone = electricResponse.clone();
    } catch {
      return;
    }

    void this.#runShadow(feed, bodyClone, url, environment, requestOptions, membership).catch(
      (error) => logger.debug("[shadowRealtime] compare failed", { feed, error })
    );
  }

  async #runShadow(
    feed: ShadowFeed,
    bodyClone: Response,
    url: URL | string,
    environment: RealtimeEnvironment & { projectId?: string },
    requestOptions: RealtimeRequestOptions | undefined,
    membership: { tags?: string[]; batchId?: string } | undefined
  ): Promise<void> {
    const $url = new URL(url.toString());
    const offset = $url.searchParams.get("offset") ?? "-1";
    const handle = $url.searchParams.get("handle") ?? $url.searchParams.get("shape_id");
    const isInitialSnapshot = offset === "-1" || !handle;
    const skipColumns = resolveSkipColumns($url, requestOptions);
    const electricBody = await bodyClone.text();

    let membershipFilter: RunListFilter | undefined;
    if (isInitialSnapshot && membership && environment.projectId) {
      membershipFilter = {
        organizationId: environment.organizationId,
        projectId: environment.projectId,
        environmentId: environment.id,
        tags: membership.tags,
        batchId: membership.batchId,
        createdAtAfter: membership.batchId
          ? undefined
          : new Date(Date.now() - this.options.maximumCreatedAtFilterAgeMs),
        limit: this.options.maxListResults,
      };
    }

    const outcome = await this.options.comparator.compare({
      feed,
      electricBody,
      environment: { id: environment.id },
      skipColumns,
      isInitialSnapshot,
      membershipFilter,
    });

    this.options.onOutcome?.(outcome);

    if (outcome.serializationDiverged > 0 || outcome.membershipMatch === false) {
      logger.warn("[shadowRealtime] divergence detected", {
        feed,
        serializationDiverged: outcome.serializationDiverged,
        serializationMatched: outcome.serializationMatched,
        serializationSkew: outcome.serializationSkew,
        membershipMatch: outcome.membershipMatch,
        missingInNative: outcome.missingInNative?.slice(0, 20),
        extraInNative: outcome.extraInNative?.slice(0, 20),
        // Log only which run/column diverged, never the raw cell values — they can
        // include run payload/output/metadata and must not leak into logs.
        diffs: outcome.diffs.map(({ runId, column }) => ({ runId, column })),
      });
    }
  }
}

function resolveSkipColumns(url: URL, requestOptions?: RealtimeRequestOptions): string[] {
  const raw = requestOptions?.skipColumns ?? url.searchParams.get("skipColumns")?.split(",") ?? [];
  return raw.map((c) => c.trim()).filter((c) => c !== "" && !RESERVED_COLUMNS.includes(c));
}
